"""Shank builders — the composable base of RingSpec v3.

Every builder returns {"parts": [...], "inner_r", "outer_r_eff", "drop_r",
"wants_shoulders"}: outer_r_eff is where the head sits, drop_r the lowest
radial extent (sprue attachment), wants_shoulders whether cathedral shoulders
should bridge shank->head (band only; split branches ARE the shoulders;
helix/path strands rise to the head themselves via the basket fuse).

Helix/path recipe (proven): sample the path, Spline it, sweep a circle profile
in two overlapping halves, fuse — one valid solid per strand.
"""

from __future__ import annotations

import math

from build123d import Axis, Circle, Plane, Spline, loft, sweep

from ringcli.parts import (MIN_WALL, band, inner_radius, metal_color, seg)


def shank_meta(spec: dict, comp: dict, st: dict) -> dict:
    """Pure sizing: no solids built. Used by head_dims/supports/validate."""
    inner_r = inner_radius(float(spec["size"]))
    sh = comp["shank"]
    t = sh["type"]
    if t in ("band", "split"):
        thick = sh.get("thickness_mm", max(st["band_thickness"], MIN_WALL))
        outer = inner_r + max(thick, MIN_WALL)
        return {"inner_r": inner_r, "outer_r_eff": outer, "drop_r": outer}
    if t == "helix":
        amp = sh.get("amplitude_mm", 0.8)
        prof = sh.get("profile_mm", 1.3)
        # matches shank_helix: head sits on the converged crest (floor=0.15),
        # sprue reaches the full-amplitude bottom
        return {"inner_r": inner_r,
                "outer_r_eff": inner_r + 1.15 * amp + prof,
                "drop_r": inner_r + 2 * amp + prof}
    # path: head height from points near the top, sprue depth from global max
    prof = sh.get("profile_mm", 1.3)
    rmax = max(math.hypot(p[0], p[2]) for p in sh["points"])
    top = [p for p in sh["points"]
           if abs(math.atan2(p[2], p[0]) - math.pi / 2) < math.radians(30)]
    nearest = min(top, key=lambda p: abs(math.atan2(p[2], p[0]) - math.pi / 2),
                  default=None)
    top_r = math.hypot(nearest[0], nearest[2]) if nearest else rmax
    return {"inner_r": inner_r, "outer_r_eff": top_r + prof / 2,
            "drop_r": rmax + prof / 2}


def _tube_arc(pts, prof_r, sections=40):
    """Open tube: LOFT tangent-oriented circles along the polyline. OCC's
    pipe-shell sweep proved profile-radius-fragile (hollow shells / invalid
    solids / hangs at some radii); lofting sections is fast and robust."""
    secs = []
    step = max(1, len(pts) // sections)
    # even stride ONLY — appending the exact last point can create two nearly
    # coincident sections whose tangent flip breaks the loft's validity; the
    # caller provides overlap between arcs instead
    for i in range(0, len(pts), step):
        p = pts[i]
        q = pts[i + 1] if i + 1 < len(pts) else pts[i - 1]
        t = (q[0] - p[0], q[1] - p[1], q[2] - p[2]) if i + 1 < len(pts) else \
            (p[0] - q[0], p[1] - q[1], p[2] - q[2])
        secs.append(Plane(origin=p, z_dir=t) * Circle(prof_r))
    return loft(secs, ruled=False)


def _tube_ok(s) -> bool:
    try:
        iv = s.is_valid
        return (len(s.solids()) == 1 and (iv() if callable(iv) else iv)
                and s.volume > 1e-3)
    except Exception:
        return False


def _segment_tube(pts, prof_r):
    """Boolean-proof fallback: the loop as short cylinders + sphere joints.
    Slightly faceted, always fuses."""
    from ringcli.parts import fuse_all
    from build123d import Pos, Sphere
    parts = []
    step = 2
    idxs = list(range(0, len(pts), step))
    for j, i in enumerate(idxs):
        p = pts[i]
        q = pts[idxs[(j + 1) % len(idxs)]]
        parts.append(seg(p, q, prof_r, prof_r))
        # 3% proud: equal radii make sphere-cylinder contact perfectly
        # TANGENT and OCC shreds tangent fuses into islands
        parts.append(Pos(*p) * Sphere(prof_r * 1.03))
    return fuse_all(parts)


def _sweep_loop(pts, prof_r):
    """Closed-loop tube, three strategies in order of surface quality:
    1) OCC pipe-sweep halves (smooth; radius-fragile — hollow shells/invalid
       solids at some profile radii), 2) lofted-section halves (smooth; the
       half-fuse can go null when overlap surfaces coincide at tight
       curvature), 3) segmented cylinders+spheres (always fuses). The result
    must be ONE valid solid or we raise — never ship broken geometry."""
    if math.dist(pts[0], pts[-1]) < 1e-9:
        pts = pts[:-1]
    n = len(pts)
    h1 = pts[: n // 2 + 2]
    h2 = pts[n // 2:] + pts[:2]
    try:
        out = None
        for hp in (h1, h2):
            path = Spline(*hp)
            prof = Plane(origin=hp[0], z_dir=path % 0) * Circle(prof_r)
            s = sweep(prof, path)
            if not s.solids() or s.volume < 1e-6:
                raise RuntimeError("swept hollow")
            out = s if out is None else out + s
        if _tube_ok(out):
            return out
    except Exception:
        pass
    try:
        out = _tube_arc(pts[: n // 2 + 4], prof_r) + \
              _tube_arc(pts[n // 2:] + pts[:4], prof_r)
        if _tube_ok(out):
            return out
    except Exception:
        pass
    out = _segment_tube(pts, prof_r)
    if _tube_ok(out):
        return out
    raise RuntimeError("loop tube failed to form one valid solid — "
                       "adjust profile_mm or amplitude")


def shank_band(spec, comp, st, metal):
    sh = comp["shank"]
    thick = sh.get("thickness_mm", st["band_thickness"])
    width = sh.get("width_mm", st["band_width"])
    b, inner_r, outer_r = band(spec["size"], thick, width, metal)
    return {"parts": [b], "inner_r": inner_r, "outer_r_eff": outer_r,
            "drop_r": outer_r, "wants_shoulders": sh.get("cathedral", True)}


def shank_helix(spec, comp, st, metal, flat_top_deg=50.0, floor=0.15):
    """Torus-knot strands. mid_r = inner_r + amp + prof/2 keeps the finger
    clear BY CONSTRUCTION. The wave smoothsteps down to `floor`x amplitude
    within flat_top_deg of the head: strands converge (and merge — centers
    end up closer than the profile diameter) under the stone so the basket
    root lands on metal and the seat cutter can't sever a lone crest."""
    sh = comp["shank"]
    strands = int(sh.get("strands", 2))
    twists = int(sh.get("twists", 2))
    amp = float(sh.get("amplitude_mm", 0.8))
    prof = float(sh.get("profile_mm", 1.3))
    prof_r = max(prof, MIN_WALL) / 2
    inner_r = inner_radius(float(spec["size"]))
    mid_r = inner_r + amp + prof_r
    flat = math.radians(flat_top_deg)

    def strand_pts(phase):
        pts = []
        n = 160
        for i in range(n):
            t = math.tau * i / n
            # angular distance from the head (top, t = pi/2)
            d = abs((t - math.pi / 2 + math.pi) % math.tau - math.pi)
            u = min(1.0, d / flat)
            a_eff = amp * (floor + (1 - floor) * (3 * u * u - 2 * u ** 3))
            r = mid_r + a_eff * math.cos(twists * t + phase)
            y = a_eff * math.sin(twists * t + phase)
            pts.append((r * math.cos(t), y, r * math.sin(t)))
        return pts

    parts = []
    bottoms = []
    for k in range(strands):
        phase = math.tau * k / strands
        s = _sweep_loop(strand_pts(phase), prof_r)
        s.color = metal_color(metal)
        parts.append(s)
        t_bot = 3 * math.pi / 2
        r = mid_r + amp * math.cos(twists * t_bot + phase)
        y = amp * math.sin(twists * t_bot + phase)
        bottoms.append((r * math.cos(t_bot), y, r * math.sin(t_bot)))
    if 2 * amp >= prof - 0.1 and strands > 1:
        # strands don't self-fuse mid-loop: bridge them at bottom dead center
        # so the metal stays ONE castable body (they merge at the head)
        for i in range(len(bottoms) - 1):
            lug = seg(bottoms[i], bottoms[i + 1], max(0.5, prof_r * 0.8),
                      max(0.5, prof_r * 0.8))
            lug.color = metal_color(metal)
            parts.append(lug)
    return {"parts": parts, "inner_r": inner_r,
            "outer_r_eff": mid_r + floor * amp + prof_r,
            "drop_r": mid_r + amp + prof_r, "wants_shoulders": False}


def shank_split(spec, comp, st, metal, girdle_z=None, rim_r=None):
    """Solid bottom arc + four rod branches (two per side) that ARE the
    shoulders, landing under the head basket."""
    from build123d import Pos, RectangleRounded, revolve
    sh = comp["shank"]
    thick = max(sh.get("thickness_mm", st["band_thickness"]), MIN_WALL)
    width = max(sh.get("width_mm", st["band_width"]), 1.5)
    fork = float(sh.get("fork_deg", 55))
    gap = float(sh.get("gap_mm", 1.6))
    branch = max(float(sh.get("branch_mm", 1.2)), MIN_WALL)
    inner_r = inner_radius(float(spec["size"]))
    outer_r = inner_r + thick
    mid = inner_r + thick / 2

    if rim_r is not None and gap / 2 + branch / 2 > rim_r * 0.9:
        raise ValueError(
            f"spec.shank.split.gap_mm = {gap} — branches would land wide of "
            f"the head basket (limit here ~{2 * (rim_r * 0.9 - branch / 2):.1f}). "
            f'Fix: "gap_mm": {max(0.8, 2 * (rim_r * 0.85 - branch / 2)):.1f}')

    prof = Plane.XZ * Pos(mid, 0) * RectangleRounded(thick, width,
                                                     min(thick, width) * 0.32)
    arc = revolve(prof, Axis.Z, 360 - 2 * fork)
    arc = arc.rotate(Axis.Z, 90 + fork).rotate(Axis.X, 90)
    arc.color = metal_color(metal)
    parts = [arc]

    gz = girdle_z if girdle_z is not None else outer_r + 0.45
    rr = rim_r if rim_r is not None else 2.0
    # land branch tips ON the basket cone surface (base r=1.05 at
    # outer_r-0.25 -> rim_r at gz-0.12): a fixed landing radius missed the
    # cone entirely for big heads and the whole head fell off the fused body
    gbot = outer_r - 0.25
    z_land = max(gz - 0.60, gbot + 0.15)
    r_cone = 1.05 + (rr - 1.05) * (z_land - gbot) / max(gz - 0.12 - gbot, 1e-6)
    r_land = max(r_cone * 0.92, 0.85)
    fr = math.radians(fork)
    yspread = min(gap / 2 + branch / 2, r_land * 0.6)
    for sx in (1, -1):
        # root must embed INSIDE the bottom arc: the arc edge is exactly at
        # +-fork from top, so the anchor angle must EXCEED fork (0.96x left
        # the root floating 2 deg into the void)
        end = (sx * mid * math.sin(fr * 1.08), 0, mid * math.cos(fr * 1.08))
        for sy in (1, -1):
            norm = math.hypot(1.0, yspread / r_land)
            tip = (sx * r_land / norm, sy * yspread / norm, z_land)
            rod = seg(end, tip, thick * 0.45, branch / 2)
            rod.color = metal_color(metal)
            parts.append(rod)
    return {"parts": parts, "inner_r": inner_r, "outer_r_eff": outer_r,
            "drop_r": outer_r, "wants_shoulders": False}


def shank_path(spec, comp, st, metal):
    """Escape hatch: model-supplied closed loop, CLI-validated + swept."""
    sh = comp["shank"]
    prof = max(float(sh.get("profile_mm", 1.3)), MIN_WALL)
    prof_r = prof / 2
    inner_r = inner_radius(float(spec["size"]))
    pts = [tuple(map(float, p)) for p in sh["points"]]

    # winds exactly once around the finger axis (Y)
    total = 0.0
    for i in range(len(pts)):
        x0, _, z0 = pts[i]
        x1, _, z1 = pts[(i + 1) % len(pts)]
        a0, a1 = math.atan2(z0, x0), math.atan2(z1, x1)
        d = (a1 - a0 + math.pi) % math.tau - math.pi
        total += d
    if abs(abs(total) - math.tau) > math.radians(20):
        raise ValueError(
            f"spec.shank.path.points — the loop winds {math.degrees(total):.0f} "
            "deg around the finger axis, must be ~360 (a single closed ring, "
            "no open C-shapes or figure-8s). Fix: order the points once "
            "around the finger")
    for i, (x, y, z) in enumerate(pts):
        r = math.hypot(x, z)
        if r < inner_r + prof_r:
            raise ValueError(
                f"spec.shank.path.points[{i}] = [{x}, {y}, {z}] — only "
                f"{r:.1f}mm from the finger axis; must be >= "
                f"{inner_r + prof_r:.1f} (finger radius {inner_r:.1f} + "
                f"profile/2). Fix: move it outward")
        if r > inner_r + 6.0 or abs(y) > 6.0:
            raise ValueError(
                f"spec.shank.path.points[{i}] = [{x}, {y}, {z}] — outside "
                f"the ring envelope (radial <= {inner_r + 6:.1f}, |y| <= 6). "
                "Fix: pull it in")
    # densify: coarse control points make the pipe builder emit a hollow
    # shell (0 solids) — fit the spline they define, then sweep a fine
    # resampling of it
    master = Spline(*pts, periodic=True)     # C1 at the closure — an open
                                             # spline kinks at the seam and
                                             # the seam-half pipe won't solidify
    dense = []
    for i in range(144):
        p = master @ (i / 144)
        dense.append((p.X, p.Y, p.Z))
        r = math.hypot(p.X, p.Z)
        if r < inner_r + prof_r - 0.05:
            raise ValueError(
                f"spec.shank.path — the curve between your points dips to "
                f"{r:.1f}mm from the finger axis (needs "
                f">= {inner_r + prof_r:.1f}). Fix: move nearby points outward")
    s = _sweep_loop(dense, prof_r)
    if not s.solids() or s.volume < 1.0:
        raise RuntimeError(
            "spec.shank.path — the swept loop did not produce a solid "
            "(likely a kink or self-intersection). Fix: smooth the points, "
            "increase spacing, or reduce profile_mm")
    s.color = metal_color(metal)
    # the head sits on +Z: outer_r_eff must be the shank's height THERE, not
    # the global max radius, or the basket floats and gets dropped
    top = [p for p in dense
           if abs(math.atan2(p[2], p[0]) - math.pi / 2) < math.radians(18)
           and abs(p[1]) < 3.0]
    if not top:
        raise ValueError(
            "spec.shank.path — no part of the loop passes near the top "
            "(+Z) where the head sits. Fix: route the loop over the top")
    # basket root must EMBED in the strand at top dead center — use the
    # radius exactly there, not the window max (which floats the head)
    nearest = min(top, key=lambda p: abs(math.atan2(p[2], p[0]) - math.pi / 2))
    top_r = math.hypot(nearest[0], nearest[2])
    rmax = max(math.hypot(p[0], p[2]) for p in dense)
    return {"parts": [s], "inner_r": inner_r, "outer_r_eff": top_r + prof_r,
            "drop_r": rmax + prof_r, "wants_shoulders": False}
