"""
Verdant Ride gear tag — bottom-shell / top-insert construction.

The shell is a tray whose floor carries the artwork as a FLUSH INLAY (cut clean
through the floor and filled with the second colour) rather than a recess.  That
is what makes this work: both colours are laid down in the same layers, so the
counters of "0", "8", "R" are held in place laterally instead of being detached
islands on the build plate.  Neither part needs supports, and neither needs the
artwork-face-up orientation that ruled out snap pins in the glued two-half design.

  shell   35 x 35 x 2.4, wall 1.2, floor 0.6, cavity 32.6 x 32.6 x 1.8 deep
  insert  32.2 x 32.2 x 1.8  (0.20 mm clearance per side), bottoms out on the floor
"""
import os, sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import trimesh
from shapely.geometry import Point
from shapely import affinity
import tagkit as tk
import build as B


class S:
    size       = 35.0    # outer footprint
    corner_r   = 3.5
    wall       = 1.2     # 2 clean perimeters at 0.4 mm nozzle
    floor      = 0.6     # 3 layers at 0.2 mm
    height     = 2.4     # total shell height = assembled thickness
    clear      = 0.20    # insert-to-cavity clearance, per side
    lead_in    = 0.4     # chamfer on the insert's bottom edge, to start it in

    ins_base   = 1.2     # insert backing, in the artwork colour
    ins_face   = 0.6     # insert top plate; recess depth == this

    hole_d     = 3.0
    hole_off   = 13.1    # keyring hole centre, from tag centre on both axes

    # --- artwork, laid out natively in the 35 mm frame (centre 0, edge +/-17.5).
    # Everything must stay inside the INSERT (+/-16.1), not the tag, because the
    # top face is the insert.  art_lim is that minus a 1 mm border.
    art_lim    = 15.1
    name_cap   = 4.1
    name_y     = 12.95
    name_max_w = 30.2
    name_min_cap = 3.3   # below this the name wraps to two lines
    name2_cap  = 3.8
    # 4.0 mm between line centres left ~0.2 mm of air between two rows of caps --
    # they read as one solid block.  5.1 mm gives a gap of about a third of the
    # cap height, which is what the single-line version looks like next to the logo.
    name2_y    = (13.0, 7.9)
    logo_faces_left = True   # direction the rider faces on the FRONT
    # The foiler is drawn as a fine silhouette: its mast and front foil come out
    # ~0.7 mm wide at tag size, which a 0.4 mm nozzle prints as one wobbly line.
    # Dilating with MITRE joins keeps the edges straight and just makes the
    # silhouette bolder; round joins turn it into a string of beads.
    logo_bold  = 0.35   # mm added to every edge, then rescaled back into the box
    logo_box   = (24.4, 24.5)
    logo_c     = (-2.9, -2.35)
    logo_box2  = (24.4, 19.0)    # when the name takes two lines
    logo_c2    = (-2.9, -4.9)
    num_cap    = 4.8
    num_x      = 12.7
    num_bottom = -10.6           # clears the keyring hole in BOTH bottom corners
    num_max_len= 20.0
    track      = 0.05

    @classmethod
    def cavity_w(cls):  return cls.size - 2 * cls.wall           # 32.6
    @classmethod
    def cavity_r(cls):  return cls.corner_r - cls.wall           # 2.3
    @classmethod
    def cavity_d(cls):  return cls.height - cls.floor            # 1.8
    @classmethod
    def insert_w(cls):  return cls.cavity_w() - 2 * cls.clear    # 32.2
    @classmethod
    def insert_r(cls):  return max(0.4, cls.cavity_r() - cls.clear)


def artwork(name, number):
    """Artwork for the FRONT face, laid out natively in the 35 mm frame.

    Only the logo is mirrored to face left, matching the mockup -- mirroring the
    whole composite would flip the text with it.  The back face is the mirror of
    all of this, so its text reads correctly and its rider faces the other way.
    """
    from shapely.ops import unary_union
    parts = []

    # --- wing name across the top -------------------------------------------
    probe = tk.text_poly(name.upper(), tk.FONT_DISPLAY, S.name_cap, S.track)
    pw = probe.bounds[2] - probe.bounds[0]
    two_line = (S.name_cap * min(1.0, S.name_max_w / pw) < S.name_min_cap
                and len(name.split()) > 1)

    if not two_line:
        nm = tk.fit_width(probe, S.name_max_w)
        b = nm.bounds
        parts.append(affinity.translate(nm, -(b[0] + b[2]) / 2,
                                        S.name_y - (b[1] + b[3]) / 2))
    else:
        words = name.upper().split()
        best, score = None, None
        for k in range(1, len(words)):
            g1 = tk.text_poly(' '.join(words[:k]), tk.FONT_DISPLAY, S.name2_cap, S.track)
            g2 = tk.text_poly(' '.join(words[k:]), tk.FONT_DISPLAY, S.name2_cap, S.track)
            sc = (max(g1.bounds[2] - g1.bounds[0], g2.bounds[2] - g2.bounds[0]), -k)
            if score is None or sc < score:
                score, best = sc, (g1, g2)
        for g, y in zip(best, S.name2_y):
            g = tk.fit_width(g, S.name_max_w)
            b = g.bounds
            parts.append(affinity.translate(g, -(b[0] + b[2]) / 2, y - (b[1] + b[3]) / 2))

    # --- size number, rotated up the right edge ------------------------------
    if number:
        num = tk.text_poly(str(number), tk.FONT_DISPLAY, S.num_cap, S.track)
        num = affinity.rotate(num, -90, origin='center')
        b = num.bounds
        if b[3] - b[1] > S.num_max_len:
            k = S.num_max_len / (b[3] - b[1])
            num = affinity.scale(num, k, k, origin='center'); b = num.bounds
        parts.append(affinity.translate(num, S.num_x - (b[0] + b[2]) / 2,
                                        S.num_bottom - b[1]))

    # --- foiler ---------------------------------------------------------------
    bw, bh = S.logo_box2 if two_line else S.logo_box
    cx, cy = S.logo_c2 if two_line else S.logo_c
    lg = tk.load_logo()
    if S.logo_faces_left:
        lg = affinity.scale(lg, -1, 1, origin='center')   # rider faces left
    b = lg.bounds
    k = min(bw / (b[2] - b[0]), bh / (b[3] - b[1]))
    lg = affinity.scale(lg, k, k, origin='center')
    if S.logo_bold > 0:
        lg = lg.buffer(S.logo_bold, join_style=2, mitre_limit=2.5).simplify(0.01)
        b = lg.bounds                      # put it back inside its box
        k2 = min(bw / (b[2] - b[0]), bh / (b[3] - b[1]))
        lg = affinity.scale(lg, k2, k2, origin='center')
    b = lg.bounds
    parts.append(affinity.translate(lg, cx - (b[0] + b[2]) / 2, cy - (b[1] + b[3]) / 2))

    return unary_union(parts).buffer(0).simplify(0.014)


def hole():
    return Point(-S.hole_off, -S.hole_off).buffer(S.hole_d / 2, quad_segs=24)


def _solid(geom2d, z0, z1, label):
    """Extrude, refusing to silently return nothing.

    tk.extrude drops any polygon it cannot make watertight, which is fine for
    simple shapes but hides failures on plates with a dozen holes in them.
    """
    m = tk.extrude(geom2d, z0, z1)
    if m is None or not m.is_watertight:
        raise RuntimeError(f'{label}: extrusion failed or is not watertight')
    return m


def _plate_minus(plate2d, cuts2d, z0, z1, label):
    """A holed plate, built by boolean subtraction.

    Triangulating a polygon with many interior rings is fragile -- earcut
    happily emits non-manifold output when rings nearly touch.  Extruding the
    solid plate and subtracting the cutters is robust.
    """
    body = _solid(plate2d, z0, z1, label + ' body')
    cutters = []
    for c in (cuts2d.geoms if hasattr(cuts2d, 'geoms') else [cuts2d]):
        if c.is_empty or c.area < 1e-9:
            continue
        m = tk.extrude(c, z0 - 0.05, z1 + 0.05)
        if m is not None:
            cutters.append(m)
    if not cutters:
        return body
    out = B._diff(body, B._union(cutters))
    if not out.is_watertight:
        raise RuntimeError(f'{label}: subtraction left a non-manifold mesh')
    return out


def _bevel_bottom(mesh, foot2d, z0, cham, steps=3):
    """Knock a chamfer off the bottom outer edge with thin ring cutters."""
    outer = foot2d.buffer(1.0, join_style=1, quad_segs=8)
    cutters = []
    for i in range(1, steps + 1):
        inset = cham * (steps - i + 1) / steps
        ring = outer.difference(foot2d.buffer(-inset, join_style=1, quad_segs=16))
        zb = z0 + cham * (i - 1) / steps
        zt = z0 + cham * i / steps
        m = tk.extrude(ring, zb - (0.001 if i == 1 else 0.0), zt)
        if m is not None:
            cutters.append(m)
    return B._diff(mesh, B._union(cutters)) if cutters else mesh


def build_shell(name, number):
    """Returns (face_colour_mesh, artwork_colour_mesh) for the tray."""
    out    = tk.rrect(S.size, S.size, S.corner_r)
    cav    = tk.rrect(S.cavity_w(), S.cavity_w(), S.cavity_r())
    h      = hole()
    # the back face is the mirror of the front, so its text reads correctly too
    art    = affinity.scale(artwork(name, number), -1, 1, origin=(0, 0)).intersection(out)

    floor_cuts = art.union(h)
    face = B._union([
        _plate_minus(out, floor_cuts, 0.0, S.floor, 'shell floor'),        # inlaid floor
        _solid(out.difference(cav), S.floor, S.height, 'shell walls'),     # walls
    ])
    if not face.is_watertight:
        raise RuntimeError('shell face is not watertight')
    ink = _solid(art, 0.0, S.floor, 'shell inlay')
    return face, ink


def build_insert(name, number):
    """Returns (face_colour_mesh, artwork_colour_mesh) for the drop-in insert."""
    foot = tk.rrect(S.insert_w(), S.insert_w(), S.insert_r())
    h    = hole()
    art  = artwork(name, number).intersection(foot)

    base = _plate_minus(foot, h, 0.0, S.ins_base, 'insert base')
    base = _bevel_bottom(base, foot, 0.0, S.lead_in)
    face = _plate_minus(foot, art.union(h), S.ins_base,
                        S.ins_base + S.ins_face, 'insert face')
    return face, base


def checks(name, number):
    """The numbers that decide whether it assembles."""
    out  = tk.rrect(S.size, S.size, S.corner_r)
    foot = tk.rrect(S.insert_w(), S.insert_w(), S.insert_r())
    art  = artwork(name, number)
    art_m = affinity.scale(art, -1, 1, origin=(0, 0))
    h    = hole()
    hm   = Point(S.hole_off, -S.hole_off).buffer(S.hole_d / 2, quad_segs=24)
    return {
        'clearance per side':      S.clear,
        'wall':                    S.wall,
        'assembled thickness':     S.height,
        'artwork -> hole (near)':  round(min(art.distance(h), art.distance(hm),
                                             art_m.distance(h), art_m.distance(hm)), 3),
        'artwork -> insert edge':  round(art.distance(foot.exterior), 3),
        'artwork -> tag edge':     round(art_m.distance(out.exterior), 3),
        'hole -> insert edge':     round(h.distance(foot.exterior), 3),
        'artwork inside insert':   art.within(foot) and art_m.within(foot),
        'insert fills cavity':     abs((S.ins_base + S.ins_face) - S.cavity_d()) < 1e-9,
    }


# ---------------------------------------------------------------------------
#  Variant B: two glued halves (v1 construction, rebuilt at 35 mm)
# ---------------------------------------------------------------------------
class G:
    """Glue-pocket half. Prints artwork-face-UP, so the artwork is a recess and
    the flat back can only carry blind pockets -- nothing may protrude from a
    face that sits on the build plate."""
    half_t   = 1.6
    base_t   = 1.0          # artwork colour; recess depth = half_t - base_t
    key_d    = 2.6          # glue pocket
    key_depth = 0.55
    # mirror-symmetric about x=0 so a pocket meets a pocket when the back half
    # is flipped, and clear of the keyring hole in BOTH bottom corners
    key_pos  = [(-12.0, 12.0), (12.0, 12.0), (-12.0, 1.0), (12.0, 1.0), (0.0, -11.0)]


def build_glue_half(name, number, hole_corner='left'):
    """Returns (face_colour_mesh, artwork_colour_mesh) for one glued half."""
    out = tk.rrect(S.size, S.size, S.corner_r)
    art = artwork(name, number).intersection(out)
    hx = -S.hole_off if hole_corner == 'left' else S.hole_off
    h = Point(hx, -S.hole_off).buffer(S.hole_d / 2, quad_segs=24)

    base = _plate_minus(out, h, 0.0, G.base_t, 'glue base')
    pockets = B._union([_cyl(x, y, G.key_d, -0.1, G.key_depth) for x, y in G.key_pos])
    base = B._diff(base, pockets)
    face = _plate_minus(out, art.union(h), G.base_t, G.half_t, 'glue face')
    if not (base.is_watertight and face.is_watertight):
        raise RuntimeError('glue half not watertight')
    return face, base


def _cyl(x, y, d, z0, z1, seg=20):
    m = trimesh.creation.cylinder(radius=d / 2.0, height=z1 - z0, sections=seg)
    m.apply_translation([x, y, (z0 + z1) / 2.0])
    return m


# ---------------------------------------------------------------------------
#  Variant A: one piece, artwork on both faces, nothing to assemble
# ---------------------------------------------------------------------------
class O:
    thick = 3.2
    face  = 0.6      # show-face depth at top and bottom


def build_one_piece(name, number):
    """Front is a recess showing the core colour; back is a FLUSH inlay, because
    a recess on the underside would leave letter counters as detached islands on
    the build plate."""
    out = tk.rrect(S.size, S.size, S.corner_r)
    art = artwork(name, number).intersection(out)
    art_back = affinity.scale(art, -1, 1, origin=(0, 0)).intersection(out)
    hl = Point(-S.hole_off, -S.hole_off).buffer(S.hole_d / 2, quad_segs=24)
    hr = Point(S.hole_off, -S.hole_off).buffer(S.hole_d / 2, quad_segs=24)
    body = out.difference(hl).difference(hr)      # symmetric, so it reads the
    t, f = O.thick, O.face                        # same corner from either side

    ink = B._union([
        _plate_minus(body.intersection(art_back), None, 0.0, f, 'one-piece back inlay')
        if False else _solid(body.intersection(art_back), 0.0, f, 'one-piece back inlay'),
        _solid(body, f, t - f, 'one-piece core'),
    ])
    field = B._union([
        _plate_minus(body, art_back, 0.0, f, 'one-piece back field'),
        _plate_minus(body, art, t - f, t, 'one-piece front plate'),
    ])
    if not (ink.is_watertight and field.is_watertight):
        raise RuntimeError('one-piece not watertight')
    return field, ink
