"""WorkflowSpec — THE canonical definition of a punchlist workflow.

This module is the one place the spec format lives (plan §6). Builders are
AI-first (Claude today, a small local LLM later), so validation errors are the
UX: every error names the step, says exactly what is wrong, and suggests
near-matches for probable typos. A spec that passes `validate_spec` is safe for
the engine to run; the engine never re-checks structure.

Shape (annotated example in plan §6):

  key, title       # title IS the item's LABEL: phrase it as the GOAL, it may
                   # use {{variables}}, and it stays on the item through every
                   # step. Steps change the ACTION (the button), never the
                   # item's identity — "Grant us GA4 admin access" stays put
                   # while the button evolves from "Granted" to "Added at the
                   # property level".
  variables: {name: {type, label, required}}
  start: step id
  steps: [{id, owner: client|team,
           instruction?,                         # the step's explainer — lives
                                                 # behind the expand chevron,
                                                 # NEVER on the default row
                                                 # ("headline" accepted as a
                                                 # legacy alias)
           detail?,
           tutorial?: str | {title?, intro?, steps: [{text, note?, image?}]},
                                                 # a STEP-BY-STEP guide, shown
                                                 # one step at a time behind
                                                 # "Show me how". A bare string
                                                 # is the legacy form (a URL or
                                                 # an asset:// placeholder).
           targets?: <email_list variable name>,
           fields?: [{key, type, label, required?, pattern?, hint?, options?}],
           # EXACTLY ONE of primary / choices:
           primary: {label, to},                 # one button; label carries step
                                                 # context; to: step id or "$done"
           control: "button"|"input"|"upload"|"link",
                                                 # HOW the primary renders. The
                                                 # row checkbox marks the item
                                                 # done, so a control exists
                                                 # only to collect or to send
                                                 # somewhere. Default "button";
                                                 # omit it entirely for a step
                                                 # that is purely a claim.
           link: {label, url},                   # control "link" only
           states: [str],                        # a status list — the client
                                                 # moves it along and reaching
                                                 # the LAST one advances the
                                                 # step. XOR with choices.
           choices: [{key, label, to}],          # 2-4 mutually exclusive answers
                                                 # rendered inline as a toggle, so
                                                 # the client answers without ever
                                                 # opening the item. The chosen
                                                 # label is readable downstream as
                                                 # {{fields.<step_id>.choice}}.
           alternatives?: [{key, label, kind: jump|flag, to?, tone?}]}]

  * kind "jump": moves to `to` — conditional flows are humans picking buttons.
  * kind "flag": stays on the step; tone "attention" (team queue) or "ack"
    (recorded, no alarm). Free-text "Other…" is implicit on every client step.
"""

import difflib
import re

from pydantic import AliasChoices, BaseModel, Field, field_validator

DONE = "$done"

VAR_TYPES = {"text", "email_list", "url", "number", "select"}
FIELD_TYPES = {"text", "longtext", "url", "date", "number", "select", "email_list", "confirm"}
OWNERS = {"client", "team"}
ALT_KINDS = {"jump", "flag"}
FLAG_TONES = {"attention", "ack"}

_TOKEN = re.compile(r"\{\{\s*([a-zA-Z0-9_.]+)\s*\}\}")
# action keys the engine owns; a choice may not shadow them
RESERVED_ACTIONS = {"primary", "other", "set_step", "resolve_flag", "targets_complete",
                    "advance", "instantiated"}
_ID = re.compile(r"^[a-z][a-z0-9_]{0,79}$")


class SpecError(Exception):
    """Validation failed. `.errors` is a list of pointed, human/LLM-readable
    messages — the whole list, never just the first."""

    def __init__(self, errors):
        self.errors = list(errors)
        super().__init__("; ".join(self.errors))


class VariableDef(BaseModel):
    type: str
    label: str = ""
    required: bool = True

    @field_validator("type")
    @classmethod
    def _type(cls, v):
        if v not in VAR_TYPES:
            raise ValueError(f"unknown variable type '{v}' (one of: {sorted(VAR_TYPES)})")
        return v


class FieldDef(BaseModel):
    key: str
    type: str
    label: str
    required: bool = True
    pattern: str | None = None
    hint: str | None = None      # human example shown when the pattern fails
    options: list[str] | None = None

    @field_validator("type")
    @classmethod
    def _type(cls, v):
        if v not in FIELD_TYPES:
            raise ValueError(f"unknown field type '{v}' (one of: {sorted(FIELD_TYPES)})")
        return v


class TutorialStep(BaseModel):
    """One numbered step of a guide. `text` carries the click-path (markdown
    links/bold allowed); `note` is the gotcha shown underneath in a quieter
    voice; `image` is an optional screenshot reference."""

    text: str
    note: str = ""
    image: str | None = None


class TutorialGuide(BaseModel):
    title: str = ""
    intro: str = ""
    steps: list[TutorialStep]


class PrimaryAction(BaseModel):
    label: str
    to: str


CONTROLS = {"button", "input", "upload", "link"}


class LinkTarget(BaseModel):
    label: str
    url: str


class Choice(BaseModel):
    """One answer in an inline toggle. `to` works exactly like primary.to, so
    a choice can branch — "our own host" jumps to the step that asks which."""

    key: str
    label: str
    to: str


class Alternative(BaseModel):
    key: str
    label: str
    kind: str
    to: str | None = None       # jump only
    tone: str | None = None     # flag only: attention|ack

    @field_validator("kind")
    @classmethod
    def _kind(cls, v):
        if v not in ALT_KINDS:
            raise ValueError(f"unknown alternative kind '{v}' (one of: {sorted(ALT_KINDS)})")
        return v


class StepDef(BaseModel):
    model_config = {"populate_by_name": True}

    id: str
    owner: str
    # The step explainer (optional): shown behind the expand, never on the row.
    # "headline" is accepted as a legacy alias — published specs are immutable,
    # so the old field name must stay readable forever.
    instruction: str = Field(default="", validation_alias=AliasChoices("instruction", "headline"))
    detail: str = ""
    tutorial: str | TutorialGuide = ""
    targets: str | None = None
    fields: list[FieldDef] = Field(default_factory=list)
    primary: PrimaryAction | None = None
    control: str = "button"
    link: LinkTarget | None = None
    states: list[str] = Field(default_factory=list)
    choices: list[Choice] = Field(default_factory=list)
    alternatives: list[Alternative] = Field(default_factory=list)

    @field_validator("control")
    @classmethod
    def _control(cls, v):
        if v not in CONTROLS:
            raise ValueError(f"unknown control '{v}' (one of: {sorted(CONTROLS)})")
        return v

    @field_validator("owner")
    @classmethod
    def _owner(cls, v):
        if v not in OWNERS:
            raise ValueError(f"unknown owner '{v}' (one of: {sorted(OWNERS)})")
        return v


class WorkflowSpec(BaseModel):
    key: str
    title: str
    variables: dict[str, VariableDef] = Field(default_factory=dict)
    start: str
    steps: list[StepDef]


def _suggest(name, pool):
    hits = difflib.get_close_matches(name, list(pool), n=1, cutoff=0.6)
    return f" — did you mean '{hits[0]}'?" if hits else ""


def validate_spec(raw: dict) -> WorkflowSpec:
    """Full structural validation. Returns the parsed spec or raises SpecError
    with EVERY problem found (an AI builder fixes them all in one pass)."""
    try:
        spec = WorkflowSpec.model_validate(raw)
    except Exception as exc:  # pydantic error — reshape into pointed lines
        errors = []
        for e in getattr(exc, "errors", lambda: [])():
            loc = ".".join(str(p) for p in e["loc"])
            errors.append(f"{loc}: {e['msg']}")
        raise SpecError(errors or [str(exc)])

    errors: list[str] = []
    ids = [s.id for s in spec.steps]
    dupes = {i for i in ids if ids.count(i) > 1}
    for d in sorted(dupes):
        errors.append(f"step id '{d}' is defined more than once")
    step_ids = set(ids)

    for s in spec.steps:
        if not _ID.match(s.id):
            errors.append(f"step id '{s.id}' must be snake_case (^[a-z][a-z0-9_]*$)")

    if spec.start not in step_ids:
        errors.append(f"start step '{spec.start}' does not exist{_suggest(spec.start, step_ids)}")

    # the title is the item's LABEL: {{tokens}} must be declared variables
    # (never fields.* — the label renders before any field is captured)
    for tok in _TOKEN.findall(spec.title or ""):
        if tok not in spec.variables:
            errors.append(f"title: token '{{{{{tok}}}}}' is not a declared "
                          f"variable{_suggest(tok, spec.variables)} "
                          f"(the label cannot reference fields)")

    # transitions + alternative shape
    for s in spec.steps:
        if bool(s.primary) == bool(s.choices):
            errors.append(f"step '{s.id}': give it EITHER a 'primary' button OR "
                          f"'choices' (an inline toggle), not both and not neither")
            continue
        if s.states:
            if s.choices:
                errors.append(f"step '{s.id}': states and choices are two different "
                              f"controls — a step has one or the other")
            if len(s.states) < 2:
                errors.append(f"step '{s.id}': a status list needs at least two states")
            if len(s.states) != len(set(s.states)):
                errors.append(f"step '{s.id}': duplicate state names")
            if s.control != "button":
                errors.append(f"step '{s.id}': a status list IS the control, so "
                              f"'control' must be left at its default")
            if s.fields or s.targets:
                errors.append(f"step '{s.id}': a status list can't also collect "
                              f"fields or fan out over targets")

        if s.control in ("input", "upload"):
            if len(s.fields) != 1:
                errors.append(f"step '{s.id}': control '{s.control}' answers with exactly "
                              f"ONE field (it renders in the row, not in a panel); "
                              f"this step has {len(s.fields)}")
            if s.choices or s.targets:
                errors.append(f"step '{s.id}': control '{s.control}' can't share a step "
                              f"with choices or targets")
        if s.control == "link":
            if s.link is None:
                errors.append(f"step '{s.id}': control 'link' needs a "
                              f"link: {{label, url}} to send them to")
            elif not s.link.url.startswith(("http://", "https://")):
                errors.append(f"step '{s.id}': link url must be http(s)")
            if s.fields:
                errors.append(f"step '{s.id}': control 'link' sends them somewhere and "
                              f"the checkbox records it — it collects nothing")
        if s.link is not None and s.control != "link":
            errors.append(f"step '{s.id}': a link belongs to control 'link'")

        if s.choices:
            if len(s.choices) < 2:
                errors.append(f"step '{s.id}': choices needs at least two answers "
                              f"(one answer is a primary button)")
            if len(s.choices) > 4:
                errors.append(f"step '{s.id}': {len(s.choices)} choices is too many to "
                              f"sit on one line — use a primary button with a select field")
            if s.fields:
                errors.append(f"step '{s.id}': a choices step answers in ONE tap, so it "
                              f"cannot also collect fields — branch to a step that does")
            if s.targets:
                errors.append(f"step '{s.id}': choices and targets can't share a step")
            seen_ck = set()
            for c in s.choices:
                if c.key in seen_ck:
                    errors.append(f"step '{s.id}': duplicate choice key '{c.key}'")
                seen_ck.add(c.key)
                if c.key in RESERVED_ACTIONS:
                    errors.append(f"step '{s.id}': choice key '{c.key}' is reserved")
            for a in s.alternatives:
                if a.key in seen_ck:
                    errors.append(f"step '{s.id}': '{a.key}' is both a choice and an "
                                  f"alternative — the engine could not tell them apart")
        dests = ([("primary", s.primary.to)] if s.primary else
                 [(f"choice '{c.key}'", c.to) for c in s.choices])
        seen_alt_keys = set()
        for a in s.alternatives:
            if a.key in seen_alt_keys:
                errors.append(f"step '{s.id}': duplicate alternative key '{a.key}'")
            seen_alt_keys.add(a.key)
            if a.kind == "jump":
                if not a.to:
                    errors.append(f"step '{s.id}': alternative '{a.key}' is a jump but has no 'to'")
                else:
                    dests.append((f"alternative '{a.key}'", a.to))
                if a.tone:
                    errors.append(f"step '{s.id}': alternative '{a.key}' is a jump; 'tone' belongs to flags")
            else:  # flag
                if a.to:
                    errors.append(f"step '{s.id}': alternative '{a.key}' is a flag; it stays on the step — remove 'to'")
                if a.tone not in FLAG_TONES:
                    errors.append(f"step '{s.id}': flag alternative '{a.key}' needs tone "
                                  f"'attention' or 'ack' (got {a.tone!r})")
        for what, to in dests:
            if to != DONE and to not in step_ids:
                errors.append(f"step '{s.id}': {what} transitions to unknown step "
                              f"'{to}'{_suggest(to, step_ids)}")

        # targets must name a declared email_list variable
        if s.targets is not None:
            var = spec.variables.get(s.targets)
            if var is None:
                errors.append(f"step '{s.id}': targets '{s.targets}' is not a declared "
                              f"variable{_suggest(s.targets, spec.variables)}")
            elif var.type != "email_list":
                errors.append(f"step '{s.id}': targets variable '{s.targets}' must be type "
                              f"'email_list' (it is '{var.type}')")

        # field keys unique per step
        fkeys = [f.key for f in s.fields]
        for d in sorted({k for k in fkeys if fkeys.count(k) > 1}):
            errors.append(f"step '{s.id}': duplicate field key '{d}'")

        # {{tokens}} in instruction/detail/tutorial must resolve
        texts = [s.instruction, s.detail]
        if isinstance(s.tutorial, TutorialGuide):
            if not s.tutorial.steps:
                errors.append(f"step '{s.id}': tutorial has no steps — give it at "
                              f"least one, or drop the tutorial")
            for n, ts in enumerate(s.tutorial.steps, 1):
                if not ts.text.strip():
                    errors.append(f"step '{s.id}': tutorial step {n} has empty text")
            texts += [s.tutorial.title, s.tutorial.intro]
            texts += [t for ts in s.tutorial.steps for t in (ts.text, ts.note)]
        for text in texts:
            for tok in _TOKEN.findall(text or ""):
                if tok in spec.variables:
                    continue
                parts = tok.split(".")
                if len(parts) == 3 and parts[0] == "fields":
                    _, ref_step, ref_field = parts
                    ref = next((x for x in spec.steps if x.id == ref_step), None)
                    if ref is None:
                        errors.append(f"step '{s.id}': token '{{{{{tok}}}}}' references unknown "
                                      f"step '{ref_step}'{_suggest(ref_step, step_ids)}")
                    elif ref.choices and ref_field == "choice":
                        pass       # synthetic: the chosen label, written at act time
                    elif ref.states and ref_field == "state":
                        pass       # synthetic: the state it reached
                    elif ref_field not in {f.key for f in ref.fields}:
                        errors.append(f"step '{s.id}': token '{{{{{tok}}}}}' references unknown field "
                                      f"'{ref_field}' on step '{ref_step}'"
                                      f"{_suggest(ref_field, {f.key for f in ref.fields})}")
                else:
                    errors.append(f"step '{s.id}': token '{{{{{tok}}}}}' is not a declared "
                                  f"variable{_suggest(tok, spec.variables)}")

    if errors:
        raise SpecError(errors)

    # graph checks (only meaningful once ids/transitions are sound)
    fwd: dict[str, set[str]] = {}
    for s in spec.steps:
        outs = ([s.primary.to] if s.primary else [c.to for c in s.choices])
        outs += [a.to for a in s.alternatives if a.kind == "jump" and a.to]
        fwd[s.id] = set(outs)

    reachable = set()
    stack = [spec.start]
    while stack:
        cur = stack.pop()
        if cur == DONE or cur in reachable:
            continue
        reachable.add(cur)
        stack.extend(fwd.get(cur, ()))
    for s in spec.steps:
        if s.id not in reachable:
            errors.append(f"step '{s.id}' is unreachable from start '{spec.start}'")

    # every reachable step must have a path to $done
    can_finish: set[str] = set()
    changed = True
    while changed:
        changed = False
        for sid, outs in fwd.items():
            if sid in can_finish:
                continue
            if DONE in outs or outs & can_finish:
                can_finish.add(sid)
                changed = True
    for sid in sorted(reachable):
        if sid not in can_finish:
            errors.append(f"step '{sid}' has no path to $done")

    if errors:
        raise SpecError(errors)
    return spec
