"""
Verdant Ride gear-tag generator.

Builds 2-colour, double-sided eFoil gear tags from a CSV of wings.
Geometry is pure shapely (2D) extruded with trimesh -- no CAD app required.
"""
import math, os, pickle
import numpy as np
import trimesh
from shapely.geometry import Polygon, MultiPolygon, box, Point
from shapely.ops import unary_union
from shapely import affinity

HERE = os.path.dirname(os.path.abspath(__file__))
ASSETS = os.path.join(HERE, 'assets')

# ---------------------------------------------------------------- parameters
class P:
    size          = 30.0   # tag width/height (mm)
    corner_r      = 3.0    # outline corner radius
    half_t        = 1.6    # thickness of ONE half
    base_t        = 1.0    # bottom colour (shows through the recesses)
    solo_t        = 3.2    # thickness of the single-part double-sided variant

    hole_d        = 3.2    # keychain hole diameter
    hole_inset    = 3.4    # hole centre inset from each edge

    key_d         = 2.6    # glue-key socket diameter (back face)
    key_depth     = 0.55   # glue-key socket depth
    # Mirror-symmetric about x=0 so a socket meets a socket when the back
    # half is flipped, and clear of the keychain hole in BOTH bottom corners.
    key_positions = [(-10.5, 10.5), (10.5, 10.5), (-10.5, 1.0),
                     (10.5, 1.0), (0.0, -9.5)]

    margin        = 2.0    # keep artwork this far from the outline
    name_cap      = 3.3    # cap height of the wing-name text
    name_y        = 11.35   # centre of the name row
    name_max_w    = 25.8
    name_min_cap  = 2.55   # below this the name wraps to two lines
    name2_cap     = 2.95
    name2_y       = (12.35, 8.85)
    logo_box2     = (20.2, 15.8)
    logo_centre2  = (-2.5, -2.9)
    name_track    = 0.05   # letterspacing as a fraction of cap height
    num_cap       = 4.2    # cap height of the big rotated size number
    num_x         = 11.6   # centre of the number column
    num_bottom    = -8.7   # clears the keychain hole in BOTH bottom corners  # lowest extent of the number
    num_max_len   = 15.5   # max length of the number column
    num_track     = 0.05
    foot_cap      = 2.0   # cap height of the verdantride.com footer
    foot_y        = -13.3
    foot_x        = -0.1
    foot_max_w    = 18.0

    logo_box      = (20.2, 19.2)   # max width/height for the silhouette
    logo_centre   = (-2.5, -1.2)

FONT_DISPLAY = os.path.join(ASSETS, 'ArchivoCond-XBold.ttf')
FONT_FOOT    = os.path.join(ASSETS, 'ArchivoCond-XBold.ttf')


# ---------------------------------------------------------------- primitives
def rrect(w, h, r, cx=0.0, cy=0.0, seg=14):
    """Rounded rectangle centred on (cx, cy)."""
    return box(cx - w / 2, cy - h / 2, cx + w / 2, cy + h / 2) \
        .buffer(-r, join_style=1, quad_segs=seg) \
        .buffer(r, join_style=1, quad_segs=seg)


def _clean(g):
    g = g.buffer(0)
    if g.is_empty:
        return g
    return g


_FONT_CACHE = {}


def _font(path):
    if path not in _FONT_CACHE:
        from fontTools.ttLib import TTFont
        f = TTFont(path)
        _FONT_CACHE[path] = (f, f['glyf'] if 'glyf' in f else None,
                             f.getGlyphSet(), f['hmtx'], f['cmap'].getBestCmap(),
                             f['head'].unitsPerEm)
    return _FONT_CACHE[path]


def _glyph_poly(gs, name):
    """Outline of one glyph as a shapely geometry, in font units."""
    from fontTools.pens.recordingPen import RecordingPen
    from fontTools.pens.pointPen import SegmentToPointPen
    pen = RecordingPen()
    gs[name].draw(pen)
    contours, cur = [], []
    last = (0.0, 0.0)
    for op, args in pen.value:
        if op == 'moveTo':
            if len(cur) > 2:
                contours.append(cur)
            cur = [args[0]]
            last = args[0]
        elif op == 'lineTo':
            cur.append(args[0]); last = args[0]
        elif op == 'qCurveTo':
            pts = list(args)
            on = pts[-1]
            ctrls = pts[:-1]
            if on is None:
                on = ((ctrls[0][0] + ctrls[-1][0]) / 2.0,
                      (ctrls[0][1] + ctrls[-1][1]) / 2.0)
            prev = last
            if len(ctrls) == 1:
                cur.extend(_quad(prev, ctrls[0], on))
            else:
                for i in range(len(ctrls) - 1):
                    mid = ((ctrls[i][0] + ctrls[i + 1][0]) / 2.0,
                           (ctrls[i][1] + ctrls[i + 1][1]) / 2.0)
                    cur.extend(_quad(prev, ctrls[i], mid)); prev = mid
                cur.extend(_quad(prev, ctrls[-1], on))
            last = on
        elif op == 'curveTo':
            pts = list(args)
            cur.extend(_cubic(last, pts[0], pts[1], pts[2])); last = pts[2]
        elif op == 'closePath':
            if len(cur) > 2:
                contours.append(cur)
            cur = []
    if len(cur) > 2:
        contours.append(cur)

    polys = []
    for c in contours:
        pg = Polygon(c)
        if not pg.is_valid:
            pg = pg.buffer(0)
        if not pg.is_empty and pg.area > 0:
            polys.append(pg)
    if not polys:
        return Polygon()
    out = polys[0]
    for pg in polys[1:]:
        out = out.symmetric_difference(pg)
    return _clean(out)


def _quad(p0, p1, p2, n=6):
    return [((1 - t) ** 2 * p0[0] + 2 * (1 - t) * t * p1[0] + t * t * p2[0],
             (1 - t) ** 2 * p0[1] + 2 * (1 - t) * t * p1[1] + t * t * p2[1])
            for t in [i / n for i in range(1, n + 1)]]


def _cubic(p0, p1, p2, p3, n=9):
    out = []
    for i in range(1, n + 1):
        t = i / n; u = 1 - t
        out.append((u**3*p0[0] + 3*u*u*t*p1[0] + 3*u*t*t*p2[0] + t**3*p3[0],
                    u**3*p0[1] + 3*u*u*t*p1[1] + 3*u*t*t*p2[1] + t**3*p3[1]))
    return out


def text_poly(s, font_path, cap_h, tracking=0.0, weight=None):
    """String -> shapely geometry with cap height cap_h, centred on the origin.
    `tracking` is extra letterspacing as a fraction of the cap height."""
    f, _glyf, gs, hmtx, cmap, upem = _font(font_path)

    ref = cmap.get(ord('H'))
    ref_h = _glyph_poly(gs, ref).bounds[3] if ref else upem * 0.7
    scale = cap_h / ref_h

    parts, x = [], 0.0
    track_units = tracking * ref_h
    for ch in s:
        gn = cmap.get(ord(ch))
        if gn is None:
            x += upem * 0.3 + track_units
            continue
        adv = hmtx[gn][0]
        g = _glyph_poly(gs, gn)
        if not g.is_empty:
            parts.append(affinity.translate(g, xoff=x))
        x += adv + track_units

    if not parts:
        return Polygon()
    g = _clean(unary_union(parts))
    g = affinity.scale(g, scale, scale, origin=(0, 0))
    b = g.bounds
    return affinity.translate(g, -(b[0] + b[2]) / 2, -(b[1] + b[3]) / 2)


def fit_width(g, max_w):
    """Uniformly scale a geometry down if it is wider than max_w."""
    b = g.bounds
    w = b[2] - b[0]
    if w <= max_w or w == 0:
        return g
    s = max_w / w
    return affinity.scale(g, s, s, origin='center')


# ---------------------------------------------------------------- the logo
def load_logo():
    with open(os.path.join(ASSETS, 'foiler_logo.pkl'), 'rb') as f:
        return pickle.load(f)


def placed_logo(two_line=False):
    g = load_logo()
    bw, bh = P.logo_box2 if two_line else P.logo_box
    b = g.bounds
    s = min(bw / (b[2] - b[0]), bh / (b[3] - b[1]))
    g = affinity.scale(g, s, s, origin='center')
    cx, cy = P.logo_centre2 if two_line else P.logo_centre
    b = g.bounds
    return affinity.translate(g, cx - (b[0] + b[2]) / 2, cy - (b[1] + b[3]) / 2)


# ---------------------------------------------------------------- artwork
def build_artwork(name, number, footer=''):
    """Return the union of everything that gets recessed into the top colour."""
    parts = []

    two_line = False
    probe = text_poly(name.upper(), FONT_DISPLAY, P.name_cap, P.name_track)
    pw = probe.bounds[2] - probe.bounds[0]
    if P.name_cap * min(1.0, P.name_max_w / pw) < P.name_min_cap:
        words = name.upper().split()
        if len(words) > 1:
            two_line = True

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

    if number:
        num = text_poly(str(number), FONT_DISPLAY, P.num_cap, P.num_track)
        num = affinity.rotate(num, -90, origin='center')
        b = num.bounds
        h = b[3] - b[1]
        if h > P.num_max_len:
            sc = P.num_max_len / h
            num = affinity.scale(num, sc, sc, origin='center')
            b = num.bounds
        num = affinity.translate(num,
                                 P.num_x - (b[0] + b[2]) / 2,
                                 P.num_bottom - b[1])
        parts.append(num)

    parts.append(placed_logo(two_line))

    if footer:
        ft = text_poly(footer, FONT_FOOT, P.foot_cap, 0.16)
        ft = fit_width(ft, P.foot_max_w)
        b = ft.bounds
        ft = affinity.translate(ft, P.foot_x - (b[0] + b[2]) / 2,
                                P.foot_y - (b[1] + b[3]) / 2)
        parts.append(ft)

    return _clean(unary_union(parts)).simplify(0.014)


def outline(hole_corner):
    """Tag outline and keychain hole.  hole_corner is 'left' or 'right'."""
    out = rrect(P.size, P.size, P.corner_r)
    hx = -(P.size / 2 - P.hole_inset) if hole_corner == 'left' else (P.size / 2 - P.hole_inset)
    hy = -(P.size / 2 - P.hole_inset)
    hole = Point(hx, hy).buffer(P.hole_d / 2, quad_segs=18)
    return out, hole, (hx, hy)


# ---------------------------------------------------------------- extrusion
def _safe_extrude(p, h):
    """Extrude one polygon, repairing near-degenerate rings if needed.

    Mirroring / boolean ops occasionally leave a ring with a duplicated or
    self-touching vertex, which triangulates into a non-manifold prism.  A
    micro open-close pass removes it without visibly moving the outline."""
    for prep in (lambda q: q,
                 lambda q: q.buffer(0.002, join_style=2).buffer(-0.002, join_style=2),
                 lambda q: q.buffer(-0.002, join_style=2).buffer(0.002, join_style=2),
                 lambda q: q.simplify(0.004)):
        try:
            q = prep(p)
        except Exception:
            continue
        if q.is_empty or not isinstance(q, Polygon) or q.area < 1e-6:
            continue
        try:
            m = trimesh.creation.extrude_polygon(q, h, engine='earcut')
        except Exception:
            continue
        if m.is_watertight and m.volume > 0:
            return m
    return None


def extrude(geom, z0, z1):
    if geom.is_empty:
        return None
    polys = geom.geoms if hasattr(geom, 'geoms') else [geom]
    meshes = []
    for p in polys:
        if not isinstance(p, Polygon) or p.area < 1e-6:
            continue
        m = _safe_extrude(p, z1 - z0)
        if m is None:
            continue
        m.apply_translation([0, 0, z0])
        meshes.append(m)
    if not meshes:
        return None
    return trimesh.util.concatenate(meshes)
