"""RingSpec v3 — the LLM-facing declarative ring language.

A spec describes a ring as {shank, head, accents[]} plus global fields; this
module validates it (errors written for LLM self-correction: field, given
value, allowed range, one concrete fix) and expands it into the internal
composition dict `templates.build_ring` compiles. Legacy v2 specs
(`archetype` enum) expand to compositions that route through exactly the same
part calls as before — presets are sugar, not a separate path.

Pure module: no build123d imports; unit-testable.
"""

from __future__ import annotations

from difflib import get_close_matches

VALID_CUTS = {"round", "oval", "cushion", "elongated_cushion", "princess",
              "emerald", "radiant", "asscher", "pear", "marquise"}
SHANK_TYPES = ("band", "helix", "split", "path")
HEAD_TYPES = ("prong", "bezel", "halo", "none")
ACCENT_TYPES = ("pave", "side_stones")
DETAIL_TYPES = ("wire", "bead")
ARCH_WORDS = ("bezel", "halo", "three", "pav", "solitaire")

# numeric bounds: dotted path -> (lo, hi, int_only, example_fix, why)
NUM = {
    "carat": (0.2, 6.0, False, '"carat": 1.5', ""),
    "size": (3.0, 13.0, False, '"size": 6.5', "US scale"),
    "shank.band.width_mm": (1.5, 4.0, False, '"width_mm": 1.8', ""),
    "shank.band.thickness_mm": (1.0, 2.5, False, '"thickness_mm": 1.2',
                                "1.0 is the casting floor"),
    "shank.helix.strands": (2, 3, True, '"strands": 2', ""),
    "shank.helix.twists": (1, 4, True, '"twists": 2',
                           "a non-integer twist cannot close the loop"),
    "shank.helix.amplitude_mm": (0.3, 1.6, False, '"amplitude_mm": 0.8', ""),
    "shank.helix.profile_mm": (1.0, 2.5, False, '"profile_mm": 1.3',
                               "1.0 is the casting floor"),
    "shank.split.fork_deg": (30, 80, False, '"fork_deg": 55',
                             "angle from top center where the band forks"),
    "shank.split.gap_mm": (0.8, 4.0, False, '"gap_mm": 1.6', ""),
    "shank.split.branch_mm": (1.0, 2.0, False, '"branch_mm": 1.2',
                              "1.0 is the casting floor"),
    "shank.split.width_mm": (1.5, 4.0, False, '"width_mm": 1.8', ""),
    "shank.split.thickness_mm": (1.0, 2.5, False, '"thickness_mm": 1.2', ""),
    "shank.path.profile_mm": (1.0, 2.5, False, '"profile_mm": 1.3', ""),
    "head.bezel.wall_mm": (0.2, 0.6, False, '"wall_mm": 0.3', ""),
    "head.halo.offset_mm": (0.3, 0.9, False, '"offset_mm": 0.44', ""),
    "head.halo.melee_mm": (0.4, 0.8, False, '"melee_mm": 0.55', ""),
    "accents.pave.melee_mm": (0.4, 0.8, False, '"melee_mm": 0.5', ""),
    "accents.pave.rows": (1, 3, True, '"rows": 2', "parallel rows across the band"),
    "accents.side_stones.ratio": (0.3, 0.8, False, '"ratio": 0.55',
                                  "side stone diameter / center width"),
}


def _err(path, val, allowed, fix):
    return f"spec.{path} = {val!r} — {allowed}. Fix: {fix}"


def _num(errs, path, obj, key, default=None):
    """Bounds-check obj[key] against NUM; returns the value (or default)."""
    lo, hi, int_only, fix, why = NUM[path]
    if key not in obj:
        return default
    v = obj[key]
    try:
        v = float(v)
    except (TypeError, ValueError):
        errs.append(_err(path, obj[key], f"must be a number {lo}..{hi}", fix))
        return default
    kind = "an INTEGER" if int_only else "a number"
    note = f" ({why})" if why else ""
    if int_only and v != int(v):
        errs.append(_err(path, obj[key], f"must be {kind} {lo}..{hi}{note}", fix))
        return default
    if not (lo <= v <= hi):
        errs.append(_err(path, obj[key], f"must be {kind} {lo}..{hi}{note}", fix))
        return default
    return int(v) if int_only else v


def _enum(errs, path, val, valid, fix):
    if val in valid:
        return True
    close = get_close_matches(str(val), list(valid), n=1)
    hint = f' Did you mean "{close[0]}"?' if close else ""
    errs.append(_err(path, val, f"unknown (valid: {', '.join(valid)}).{hint}", fix))
    return False


def is_v3(spec: dict) -> bool:
    return "shank" in spec


def is_production(spec: dict) -> bool:
    return bool(spec.get("production")) or "stone_mm" in spec


def validate(spec: dict) -> list[str]:
    errs = []
    if "name" not in spec:
        errs.append('spec.name missing. Fix: "name": "my-ring"')
    if "size" not in spec:
        errs.append('spec.size missing (US ring size). Fix: "size": 6.5')
    else:
        _num(errs, "size", spec, "size")
    v3 = is_v3(spec)
    if v3 and "archetype" in spec:
        errs.append('spec has BOTH "shank" (v3) and "archetype" (preset) — '
                    'use one. Fix: delete "archetype"')
    if not v3:
        a = str(spec.get("archetype", "")).lower()
        if not a:
            errs.append('spec needs "archetype" (preset) or "shank" (v3). '
                        'Fix: "archetype": "solitaire"')
        elif not any(w in a for w in ARCH_WORDS):
            errs.append(_err("archetype", spec["archetype"],
                             "unknown (valid: solitaire, bezel, halo, "
                             "three_stone, pavé)", '"archetype": "halo"'))

    head = spec.get("head", {}) if v3 else {"type": "prong"}
    head_type = head.get("type", "prong") if isinstance(head, dict) else "?"
    stoneless = v3 and head_type == "none"
    if not stoneless:
        if spec.get("cut") is None:
            errs.append('spec.cut missing. Fix: "cut": "round"')
        elif not _enum(errs, "cut", spec["cut"], sorted(VALID_CUTS),
                       '"cut": "round"'):
            pass
        if spec.get("carat") is None:
            errs.append('spec.carat missing. Fix: "carat": 1.5')
        else:
            _num(errs, "carat", spec, "carat")

    sc = spec.get("stone_color")
    if sc is not None:
        from ringcli.parts import STONE_COLORS
        if sc not in STONE_COLORS:
            errs.append(_err("stone_color", sc,
                             f"unknown (valid: {', '.join(sorted(STONE_COLORS))})",
                             '"stone_color": "emerald"'))
    if v3:
        errs += _validate_shank(spec)
        errs += _validate_head(spec)
        errs += _validate_accents(spec)
    errs += _validate_details(spec)
    errs += _validate_production(spec)
    return errs


def _validate_details(spec):
    """`details`: freeform placeable primitives (data, not code). Frame:
    Y = finger axis, +Z = head. Casting floors are hard bounds."""
    errs = []
    details = spec.get("details", [])
    if not isinstance(details, list):
        return ['spec.details must be a list. Fix: "details": []']
    if len(details) > 24:
        errs.append(f"spec.details has {len(details)} entries — max 24. "
                    "Fix: consolidate")
    for i, d in enumerate(details):
        if not isinstance(d, dict) or d.get("type") not in DETAIL_TYPES:
            errs.append(f'spec.details[{i}].type = {d.get("type") if isinstance(d, dict) else d!r} '
                        f'— unknown (valid: wire, bead). '
                        f'Fix: {{"type": "wire", "points": [[...]], "profile_mm": 1.0}}')
            continue
        if d["type"] == "wire":
            pts = d.get("points")
            if not isinstance(pts, list) or not (2 <= len(pts) <= 16) or not all(
                    isinstance(q, (list, tuple)) and len(q) == 3 for q in pts):
                errs.append(f"spec.details[{i}].points — must be 2-16 [x,y,z] "
                            f'mm points (open path). Fix: "points": '
                            f"[[0,1.5,9.6],[2,1.2,9.4],[4,0.8,8.8]]")
            prof = float(d.get("profile_mm", 1.0))
            if not (0.8 <= prof <= 2.5):
                errs.append(f"spec.details[{i}].profile_mm = {prof} — must be "
                            f"0.8..2.5 (0.8 is the casting floor for details). "
                            f'Fix: "profile_mm": 1.0')
        if d["type"] == "bead":
            at = d.get("at")
            if not (isinstance(at, (list, tuple)) and len(at) == 3):
                errs.append(f'spec.details[{i}].at — must be [x,y,z] mm. '
                            f'Fix: "at": [0, 1.2, 10.1]')
            dm = float(d.get("d_mm", 0.8))
            if not (0.5 <= dm <= 3.0):
                errs.append(f"spec.details[{i}].d_mm = {dm} — must be 0.5..3.0 "
                            f"(0.5 is the bead casting floor). "
                            f'Fix: "d_mm": 0.8')
    return errs


def _validate_shank(spec):
    errs = []
    sh = spec["shank"]
    if not isinstance(sh, dict) or "type" not in sh:
        return ['spec.shank must be an object with a "type". '
                'Fix: "shank": {"type": "band"}']
    t = sh["type"]
    if not _enum(errs, "shank.type", t, SHANK_TYPES,
                 '{"type": "helix", "strands": 2}'):
        return errs
    for key in sh:
        if key == "type" or f"shank.{t}.{key}" in NUM or (t, key) in (
                ("band", "cathedral"), ("path", "points")):
            continue
        errs.append(_err(f"shank.{key}", sh[key],
                         f'not a field of shank type "{t}"',
                         "remove it or check the field tables"))
    for path in [p for p in NUM if p.startswith(f"shank.{t}.")]:
        _num(errs, path, sh, path.rsplit(".", 1)[1])
    if t == "path":
        pts = sh.get("points")
        if not isinstance(pts, list) or not (8 <= len(pts) <= 32) or not all(
                isinstance(p, (list, tuple)) and len(p) == 3 for p in pts):
            errs.append(_err("shank.path.points", "…" if pts else pts,
                             "must be 8-32 [x,y,z] mm points (Y = finger "
                             "axis, +Z = head)",
                             '"points": [[9,0,0],[8,1,4],…] (a closed loop)'))
    return errs


def _validate_head(spec):
    errs = []
    hd = spec.get("head")
    if hd is None:
        return []                       # defaults to prong
    if not isinstance(hd, dict) or "type" not in hd:
        return ['spec.head must be an object with a "type". '
                'Fix: "head": {"type": "prong"}']
    t = hd["type"]
    if not _enum(errs, "head.type", t, HEAD_TYPES, '{"type": "prong"}'):
        return errs
    if t in ("prong", "halo"):
        p = hd.get("prongs", "auto")
        if p != "auto":
            if p not in (4, 6):
                errs.append(_err("head.prongs", p,
                                 'must be "auto", 4, or 6', '"prongs": "auto"'))
            elif spec.get("cut") not in ("round", "oval"):
                errs.append(_err("head.prongs", p,
                                 f'explicit counts only for round/oval; '
                                 f'"{spec.get("cut")}" claws are cut-derived',
                                 '"prongs": "auto"'))
    for path in [p for p in NUM if p.startswith(f"head.{t}.")]:
        _num(errs, path, hd, path.rsplit(".", 1)[1])
    return errs


def _validate_accents(spec):
    errs = []
    accents = spec.get("accents", [])
    if not isinstance(accents, list):
        return ['spec.accents must be a list. Fix: "accents": []']
    seen = set()
    shank_t = spec.get("shank", {}).get("type", "band")
    head_t = spec.get("head", {}).get("type", "prong")
    for i, a in enumerate(accents):
        if not isinstance(a, dict) or "type" not in a:
            errs.append(f'spec.accents[{i}] must be an object with a "type". '
                        f'Fix: {{"type": "pave"}}')
            continue
        t = a["type"]
        if not _enum(errs, f"accents[{i}].type", t, ACCENT_TYPES,
                     '{"type": "pave"}'):
            continue
        if t in seen:
            errs.append(f"spec.accents has two '{t}' entries — one per type. "
                        f"Fix: merge them")
        seen.add(t)
        if t == "pave" and shank_t not in ("band", "split"):
            errs.append(_err(f"accents[{i}]", "pave",
                             f'pave requires a band or split shank (melee '
                             f'need a cylindrical surface, shank is '
                             f'"{shank_t}")',
                             'remove the pave accent or use "shank": '
                             '{"type": "band"}'))
        if t == "side_stones" and head_t not in ("prong", "halo"):
            errs.append(_err(f"accents[{i}]", "side_stones",
                             f'side_stones need a prong or halo head '
                             f'(head is "{head_t}")',
                             '"head": {"type": "prong"}'))
        for path in [p for p in NUM if p.startswith(f"accents.{t}.")]:
            _num(errs, path, a, path.rsplit(".", 1)[1])
        if t == "pave" and "arc_deg" in a:
            arc = a["arc_deg"]
            ok = (isinstance(arc, list) and len(arc) == 2
                  and (arc[0] == "auto" or isinstance(arc[0], (int, float)))
                  and isinstance(arc[1], (int, float)))
            if ok and arc[0] != "auto":
                ok = 0 <= arc[0] < arc[1] <= 180
            elif ok:
                ok = 20 <= arc[1] <= 180
            if not ok:
                errs.append(_err(f"accents[{i}].arc_deg", arc,
                                 'must be ["auto", end] or [start, end], '
                                 "start 0..180 < end <= 180 (degrees from "
                                 "top center down the band)",
                                 '"arc_deg": ["auto", 79]'))
    return errs


def _validate_production(spec):
    errs = []
    if not is_production(spec):
        return errs
    if is_v3(spec):
        sh = spec.get("shank", {}).get("type", "band")
        if sh == "path":
            errs.append('spec.shank.type = "path" with production — path '
                        "shanks are concept-only in v1 (ID gates assume a "
                        'conventional shank). Fix: use "band", "helix" or '
                        '"split"')
        if spec.get("head", {}).get("type") == "none":
            errs.append('spec.head.type = "none" with production — a '
                        'production build must set a stone. Fix: '
                        '"head": {"type": "prong"}')
    sm = spec.get("stone_mm")
    if sm is not None:
        for k in ("width", "length"):
            v = float(sm.get(k, 0))
            if not (1.0 <= v <= 20.0):
                errs.append(_err(f"stone_mm.{k}", sm.get(k),
                                 "must be 1..20 mm (measured from the cert)",
                                 f'"{k}": 6.5'))
        if sm.get("depth") is not None:
            w = float(sm.get("width", 1))
            if not (0.15 * w <= float(sm["depth"]) <= 1.0 * w):
                errs.append(_err("stone_mm.depth", sm["depth"],
                                 f"implausible for width {w} (0.15-1.0x width)",
                                 '"depth": 3.9'))
    casting = spec.get("casting", {})
    if casting:
        shrink = float(casting.get("shrink_scale", 1.0))
        if not (0.95 <= shrink <= 1.06):
            errs.append(_err("casting.shrink_scale", shrink,
                             "must be 0.95..1.06 (calibrate with "
                             "`ringcli calibrate`)", '"shrink_scale": 1.0'))
        from ringcli.alloys import DENSITY
        if casting.get("alloy") and casting["alloy"] not in DENSITY:
            errs.append(_err("casting.alloy", casting["alloy"],
                             f"unknown (valid: {', '.join(sorted(DENSITY))})",
                             '"alloy": "18k_white"'))
    return errs


# ---- expansion --------------------------------------------------------------
def _arch_of(spec: dict) -> str:
    a = str(spec.get("archetype", "solitaire")).lower()
    if "bezel" in a:
        return "bezel"
    if "halo" in a:
        return "halo"
    if "three" in a:
        return "three_stone"
    if "pav" in a:
        return "pave"
    return "solitaire"


def expand(spec: dict) -> dict:
    """Spec -> internal composition. Legacy (archetype) compositions carry NO
    numeric overrides — builders keep reading the eval-tuned STYLE table, so
    the preset path executes exactly the calls it always has."""
    if not is_v3(spec):
        arch = _arch_of(spec)
        comp = {"arch": arch, "legacy": True,
                "shank": {"type": "band", "cathedral": True},
                "head": {"type": {"bezel": "bezel", "halo": "halo"}.get(
                    arch, "prong")},
                "accents": []}
        if arch == "halo":
            comp["accents"].append({"type": "pave", "start_min": 20})
        if arch == "pave":
            comp["accents"].append({"type": "pave", "start_min": 16})
        if arch == "three_stone":
            comp["accents"].append({"type": "side_stones"})
        comp["details"] = list(spec.get("details", []))
        return comp

    shank = dict(spec["shank"])
    head = dict(spec.get("head") or {"type": "prong"})
    accents = [dict(a) for a in spec.get("accents", [])]
    stoneless = (spec.get("head") or {}).get("type") == "none"
    for a in accents:
        if a["type"] == "pave":
            a.setdefault("start_min", 0 if stoneless else 16)
    # arch label: drives STYLE span_by_arch sizing + work-order wording
    arch = {"bezel": "bezel", "halo": "halo", "none": "band"}.get(
        head["type"], "solitaire")
    if any(a["type"] == "side_stones" for a in accents):
        arch = "three_stone"
    elif any(a["type"] == "pave" for a in accents) and arch == "solitaire":
        arch = "pave"
    return {"arch": arch, "legacy": False, "shank": shank, "head": head,
            "accents": accents, "details": list(spec.get("details", []))}
