"""The engine: items running pinned workflow specs, event-sourced.

Every mutation appends ONE item_event and recomputes the cached
status/current_step in the same transaction. item.status and current_step_id
are caches — replaying item_events against the pinned spec reproduces them
(tested). Nothing here ever mutates or deletes an event.

Per-target semantics: a step with `targets` renders its primary action once
per resolved target. Completions are counted SINCE THE LAST ENTRY into the
step — re-entering via a jump-back is a fresh ask (simple, replay-pure, and
the client re-confirming a couple of clicks beats a stuck "all checked but
the team says someone's missing" state).
"""

import re

from sqlalchemy import select

from app.models import Item, ItemEvent, Punchlist, PunchlistSeen, utcnow
from app.schemas import DONE, WorkflowSpec, validate_spec
from app.services import workflows as wf
from app.services.rendering import render_text

OTHER_ACTION = "other"
RESOLVE_ACTION = "resolve_flag"


class ItemError(Exception):
    def __init__(self, message, code="BAD_INPUT"):
        super().__init__(message)
        self.code = code


# ------------------------------------------------------------------ spec access

def spec_of(session, item: Item) -> WorkflowSpec:
    if item.spec_inline is not None:
        return validate_spec(item.spec_inline)
    tpl = wf.get_template(session, item.template_id)
    if tpl is None:
        raise ItemError("This item's workflow template is missing.", "NO_SUCH_TEMPLATE")
    return validate_spec(tpl.spec)


def step_def(spec: WorkflowSpec, step_id: str):
    return next((s for s in spec.steps if s.id == step_id), None)


# ------------------------------------------------------------------ instantiate

def instantiate(session, actor: str, punchlist_id: str, *, template_key: str | None = None,
                spec_inline: dict | None = None, variables: dict | None = None,
                set_run_id: str | None = None, position: int | None = None,
                section: str | None = None) -> Item:
    """Create one item from a published library template OR an inline one-off
    spec (exactly one — the DB CHECK enforces the same)."""
    if bool(template_key) == bool(spec_inline):
        raise ItemError("Provide exactly one of template_key or spec (inline).", "BAD_INPUT")

    variables = dict(variables or {})
    if template_key:
        tpl = wf.latest_published(session, template_key)
        if tpl is None:
            raise ItemError(f"No published workflow '{template_key}'.", "NO_SUCH_TEMPLATE")
        spec = validate_spec(tpl.spec)
        template_id, inline = tpl.id, None
    else:
        spec = validate_spec(spec_inline)   # SpecError propagates with the full list
        template_id, inline = None, spec_inline

    missing = [name for name, v in spec.variables.items()
               if v.required and not variables.get(name)]
    if missing:
        raise ItemError(f"Missing required variables: {', '.join(sorted(missing))}.",
                        "MISSING_VARIABLES")

    if position is None:
        siblings = session.execute(
            select(Item.position).where(Item.punchlist_id == punchlist_id)
        ).scalars().all()
        position = (max(siblings) + 1) if siblings else 0

    item = Item(punchlist_id=punchlist_id, template_id=template_id, spec_inline=inline,
                set_run_id=set_run_id, section=(section or None),
                title=spec.title, variables=variables,
                current_step_id=spec.start, status="", position=position)
    session.add(item)
    session.flush()
    _append(session, item, actor_username=actor, actor_kind="system", action_key="instantiated",
            step_id=spec.start, from_step="", to_step=spec.start)
    _recompute(session, item, spec)
    return item


def adopt_latest_template(session, item: Item, actor: str) -> dict:
    """Re-point a live item at the newest published version of its workflow.

    The pin exists so that editing a template cannot mutate live work (plan
    D10) — so moving an item forward is DELIBERATE and guarded, never implicit:
    the new version must still contain every step this item has actually
    touched, or its event history would replay against steps that no longer
    exist. Additive changes (better instructions, new tutorials) always pass;
    a spec that renamed or dropped a visited step is refused.
    """
    if item.spec_inline is not None:
        raise ItemError("This item carries its own one-off spec — there is no "
                        "library version to adopt.", "BAD_INPUT")
    cur = wf.get_template(session, item.template_id)
    if cur is None:
        raise ItemError("This item's workflow template is missing.", "NO_SUCH_TEMPLATE")
    latest = wf.latest_published(session, cur.key)
    if latest is None or latest.id == cur.id:
        return {"changed": False, "key": cur.key, "version": cur.version}

    spec = validate_spec(latest.spec)
    ids = {st.id for st in spec.steps}
    events = events_of(session, item)
    visited = {e.step_id for e in events if e.step_id}
    visited |= {e.to_step for e in events if e.to_step and e.to_step != DONE}
    if item.current_step_id:
        visited.add(item.current_step_id)
    missing = sorted(visited - ids)
    if missing:
        raise ItemError(
            f"v{latest.version} of '{cur.key}' no longer has step(s) this item has "
            f"already used ({', '.join(missing)}), so its history could not be "
            f"replayed. Leaving it on v{cur.version}.", "INCOMPATIBLE_VERSION")
    needed = [n for n, v in spec.variables.items()
              if v.required and not item.variables.get(n)]
    if needed:
        raise ItemError(
            f"v{latest.version} of '{cur.key}' requires variable(s) this item does "
            f"not carry ({', '.join(sorted(needed))}).", "MISSING_VARIABLES")

    item.template_id = latest.id
    item.title = spec.title
    _recompute(session, item, spec)
    return {"changed": True, "key": cur.key,
            "from_version": cur.version, "version": latest.version}


# ------------------------------------------------------------------ events

def items_of(session, punchlist_id: str) -> list[Item]:
    return list(session.execute(
        select(Item).where(Item.punchlist_id == punchlist_id).order_by(Item.position)
    ).scalars())


def events_of(session, item: Item) -> list[ItemEvent]:
    return list(session.execute(
        select(ItemEvent).where(ItemEvent.item_id == item.id).order_by(ItemEvent.seq)
    ).scalars())


def _append(session, item: Item, **kw) -> ItemEvent:
    last = session.execute(
        select(ItemEvent.seq).where(ItemEvent.item_id == item.id)
        .order_by(ItemEvent.seq.desc())
    ).scalars().first()
    ev = ItemEvent(item_id=item.id, seq=(last or 0) + 1, **kw)
    session.add(ev)
    session.flush()
    return ev


def field_values(events: list[ItemEvent]) -> dict:
    """{step_id: {field_key: value}} — later submissions win."""
    out: dict = {}
    for e in events:
        if e.field_values:
            out.setdefault(e.step_id, {}).update(e.field_values)
    return out


def _entry_seq(events: list[ItemEvent], step_id: str) -> int:
    """Seq of the most recent event that ENTERED this step (to_step == it)."""
    entry = 0
    for e in events:
        if e.to_step == step_id:
            entry = e.seq
    return entry


def targets_state(spec: WorkflowSpec, item: Item, events: list[ItemEvent]) -> list[dict]:
    """[{value, done}] for the current step, counting acts since last entry."""
    step = step_def(spec, item.current_step_id) if item.current_step_id else None
    if step is None or not step.targets:
        return []
    resolved = list(item.variables.get(step.targets) or [])
    entry = _entry_seq(events, step.id)
    done = {e.target for e in events
            if e.seq > entry and e.step_id == step.id
            and e.action_key == "primary" and e.target}
    return [{"value": t, "done": t in done} for t in resolved]


# ------------------------------------------------------------------ acting

def _validate_fields(step, submitted: dict) -> dict:
    clean = {}
    for f in step.fields:
        val = submitted.get(f.key)
        if f.required and (val is None or val == "" or val == []):
            raise ItemError(f"'{f.label}' is required.", "FIELD_REQUIRED")
        if val is None:
            continue
        if f.pattern and isinstance(val, str) and not re.fullmatch(f.pattern, val):
            # Never show a client the raw regex — the hint is a human example.
            how = f" — it usually looks like {f.hint}" if f.hint else " — double-check and try again"
            raise ItemError(f"'{f.label}' doesn't look right{how}.", "FIELD_INVALID")
        if f.type == "confirm" and not val:
            raise ItemError(f"'{f.label}' must be confirmed.", "FIELD_REQUIRED")
        if f.type == "select" and f.options and val not in f.options:
            raise ItemError(f"'{f.label}' must be one of: {', '.join(f.options)}.",
                            "FIELD_INVALID")
        clean[f.key] = val
    return clean


def act(session, item: Item, *, actor: str, actor_kind: str, action: str,
        target: str | None = None, fields: dict | None = None,
        message: str | None = None, to: str | None = None) -> Item:
    """The one write path for item state (wrapper): runs the workflow action,
    bridges any message into the item's comment thread (ONE conversation
    stream — flags, replies and freeform comments all land together), and
    emits turn notifications when the ball changes courts."""
    from app.services import interaction

    old_status = item.status
    _act_core(session, item, actor=actor, actor_kind=actor_kind, action=action,
              target=target, fields=fields, message=message, to=to)
    if message and (message or "").strip():
        interaction.add_comment(session, item, author=actor,
                                author_kind=actor_kind, body=message.strip())
    interaction.notify_turn(session, item, actor=actor, old_status=old_status)
    return item


def _act_core(session, item: Item, *, actor: str, actor_kind: str, action: str,
              target: str | None = None, fields: dict | None = None,
              message: str | None = None, to: str | None = None) -> Item:
    """Workflow legality only. `action` is:
      "primary" | an alternative key | "other" | "resolve_flag" | "set_step".
    Authorization happened in the router."""
    spec = spec_of(session, item)

    if action == RESOLVE_ACTION:
        if actor_kind != "team":
            raise ItemError("Only the team resolves flags.", "FORBIDDEN")
        if not item.flag:
            raise ItemError("There is no open flag on this item.", "NO_FLAG")
        if not (message or "").strip():
            raise ItemError("A reply message is required to resolve a flag.", "MESSAGE_REQUIRED")
        _append(session, item, actor_username=actor, actor_kind=actor_kind, action_key=RESOLVE_ACTION,
                step_id=item.current_step_id or "", from_step=item.current_step_id or "",
                to_step=None, message=message.strip())
        item.flag = None
        _recompute(session, item, spec)
        return item

    if action == "set_step":
        # Manager override for cases builders didn't foresee. Audited like all acts.
        if actor_kind != "team":
            raise ItemError("Only the team may move an item's step.", "FORBIDDEN")
        if to != DONE and step_def(spec, to) is None:
            raise ItemError(f"No step '{to}' in this workflow.", "NO_SUCH_STEP")
        _move(session, item, spec, actor, actor_kind, "set_step", to, message=message)
        return item

    if item.current_step_id is None:
        raise ItemError("This item is already done.", "ITEM_DONE")
    step = step_def(spec, item.current_step_id)

    # Who may act on this step: the step's owner (clients act on client steps;
    # team on team steps). Team members may also act on a client step (acting
    # on the client's behalf) — recorded as themselves.
    if step.owner == "team" and actor_kind != "team":
        raise ItemError("This step is with the team.", "NOT_YOUR_STEP")

    # A status list: the client moves it along, and reaching the LAST state is
    # what finishes the step. Intermediate states are recorded and stay put —
    # "where is this up to" is itself worth knowing.
    if action == "state":
        if not step.states:
            raise ItemError("This step has no status list.", "NO_SUCH_ACTION")
        want = (fields or {}).get("state")
        if want not in step.states:
            raise ItemError(f"'{want}' is not one of this step's states.", "NO_SUCH_ACTION")
        idx = step.states.index(want)
        _append(session, item, actor_username=actor, actor_kind=actor_kind, action_key="state",
                step_id=step.id, from_step=step.id, to_step=None,
                field_values={"state": want})
        item.flag = None
        if idx == len(step.states) - 1:
            _move(session, item, spec, actor, actor_kind, "advance", step.primary.to)
        else:
            _recompute(session, item, spec)
        return item

    choice = next((c for c in step.choices if c.key == action), None)
    if choice is not None:
        # The chosen LABEL is stored as a synthetic field so a later step can
        # say {{fields.<step>.choice}} through the existing token machinery —
        # no second interpolation path to keep in sync.
        _append(session, item, actor_username=actor, actor_kind=actor_kind, action_key=action,
                step_id=step.id, from_step=step.id, to_step=None,
                field_values={"choice": choice.label})
        item.flag = None
        _move(session, item, spec, actor, actor_kind, "advance", choice.to)
        return item

    if action == "primary":
        if step.primary is None:
            raise ItemError("This step is answered by choosing an option.", "NO_SUCH_ACTION")
        clean = _validate_fields(step, fields or {})
        if step.targets:
            resolved = list(item.variables.get(step.targets) or [])
            if target not in resolved:
                raise ItemError("Unknown target for this step.", "NO_SUCH_TARGET")
            events = events_of(session, item)
            entry = _entry_seq(events, step.id)
            already = {e.target for e in events
                       if e.seq > entry and e.step_id == step.id
                       and e.action_key == "primary" and e.target}
            if target in already:
                raise ItemError("Already marked for that one.", "TARGET_DONE")
            _append(session, item, actor_username=actor, actor_kind=actor_kind, action_key="primary",
                    step_id=step.id, from_step=step.id, to_step=None, target=target,
                    field_values=clean or None)
            item.flag = None
            remaining = [t for t in resolved if t != target and t not in already]
            if not remaining:
                _move(session, item, spec, actor, "system", "targets_complete", step.primary.to)
            else:
                _recompute(session, item, spec)
            return item
        _append(session, item, actor_username=actor, actor_kind=actor_kind, action_key="primary",
                step_id=step.id, from_step=step.id, to_step=None, field_values=clean or None)
        item.flag = None
        _move(session, item, spec, actor, actor_kind, "advance", step.primary.to)
        return item

    if action == OTHER_ACTION:
        if not (message or "").strip():
            raise ItemError("Tell us a little about your situation.", "MESSAGE_REQUIRED")
        _append(session, item, actor_username=actor, actor_kind=actor_kind, action_key=OTHER_ACTION,
                step_id=step.id, from_step=step.id, to_step=None, message=message.strip())
        item.flag = "other"
        _recompute(session, item, spec)
        return item

    alt = next((a for a in step.alternatives if a.key == action), None)
    if alt is None:
        raise ItemError(f"No action '{action}' on this step.", "NO_SUCH_ACTION")
    if alt.kind == "jump":
        _append(session, item, actor_username=actor, actor_kind=actor_kind, action_key=action,
                step_id=step.id, from_step=step.id, to_step=None, message=(message or None))
        item.flag = None
        _move(session, item, spec, actor, actor_kind, action, alt.to)
        return item
    # flag
    if alt.tone == "attention" and not (message or "").strip():
        raise ItemError("Tell us what's going wrong so we can help.", "MESSAGE_REQUIRED")
    _append(session, item, actor_username=actor, actor_kind=actor_kind, action_key=action,
            step_id=step.id, from_step=step.id, to_step=None, message=(message or None))
    item.flag = action           # the alternative KEY; tone resolves from the spec
    _recompute(session, item, spec)
    return item


def _move(session, item: Item, spec: WorkflowSpec, actor, actor_kind, action_key, to,
          message=None):
    frm = item.current_step_id or ""
    _append(session, item, actor_username=actor, actor_kind=actor_kind, action_key=action_key,
            step_id=frm, from_step=frm, to_step=to, message=message)
    item.current_step_id = None if to == DONE else to
    item.flag = None
    _recompute(session, item, spec)


# ------------------------------------------------------------------ status

def _flag_tone(spec: WorkflowSpec, item: Item) -> str | None:
    if not item.flag:
        return None
    if item.flag == "other":
        return "attention"
    for s in spec.steps:
        for a in s.alternatives:
            if a.key == item.flag and a.kind == "flag":
                return a.tone
    return "attention"   # unknown flag: fail toward visibility


def _recompute(session, item: Item, spec: WorkflowSpec) -> None:
    if item.current_step_id is None:
        item.status = "done"
    elif _flag_tone(spec, item) == "attention":
        item.status = "needs_attention"
    else:
        step = step_def(spec, item.current_step_id)
        item.status = "waiting_on_client" if step.owner == "client" else "waiting_on_team"
    item.updated_at = utcnow()
    session.flush()


def replay_status(spec: WorkflowSpec, events: list[ItemEvent]) -> tuple[str | None, str]:
    """Pure replay: (current_step_id, status) from events alone. The test
    oracle that proves item.status/current_step are honest caches."""
    current: str | None = None
    flag: str | None = None
    for e in events:
        if e.to_step is not None:
            current = None if e.to_step == DONE else e.to_step
            flag = None
        elif e.action_key == RESOLVE_ACTION:
            flag = None
        elif e.action_key == OTHER_ACTION:
            flag = "other"
        elif e.action_key == "primary":
            flag = None
        else:
            sd = next((s for s in spec.steps if s.id == e.step_id), None)
            if sd and any(a.key == e.action_key and a.kind == "flag" for a in sd.alternatives):
                flag = e.action_key
    if current is None:
        return None, "done"
    tone = None
    if flag == "other":
        tone = "attention"
    elif flag:
        for s in spec.steps:
            for a in s.alternatives:
                if a.key == flag and a.kind == "flag":
                    tone = a.tone
    if tone == "attention":
        return current, "needs_attention"
    sd = next(s for s in spec.steps if s.id == current)
    return current, ("waiting_on_client" if sd.owner == "client" else "waiting_on_team")


# ------------------------------------------------------------------ rendering

def _tutorial_payload(tut, variables, fv):
    """ONE shape for the client: None, or a guide object. A legacy string
    tutorial (a URL, or an asset:// placeholder) becomes a guide with no steps
    carrying `legacy`, so the modal branches once instead of type-sniffing."""
    if not tut:
        return None
    if isinstance(tut, str):
        return {"title": "", "intro": "", "steps": [], "legacy": tut}
    return {
        "title": render_text(tut.title, variables, fv),
        "intro": render_text(tut.intro, variables, fv),
        "steps": [{"text": render_text(st.text, variables, fv),
                   "note": render_text(st.note, variables, fv),
                   "image": st.image} for st in tut.steps],
        "legacy": "",
    }


def render_item(session, item: Item, *, for_team: bool, last_seen=None) -> dict:
    """The item as a view renders it — client anatomy (plan §2) or team row."""
    spec = spec_of(session, item)
    events = events_of(session, item)
    fv = field_values(events)
    out = {
        "id": item.id,
        "position": item.position,
        "section": item.section,
        "title": item.title,
        # The LABEL: the goal-phrased spec title with variables rendered. It is
        # the one line the checklist shows, on every step, in every state.
        "label": render_text(spec.title, item.variables, fv),
        "status": item.status,
        "flag": item.flag,
        "updated_at": item.updated_at.isoformat() if item.updated_at else None,
    }
    # client_state: the §2 perception mapping
    if item.status == "done":
        done_at = next((e.created_at for e in reversed(events) if e.to_step == DONE), None)
        out["client_state"] = "done"
        out["done_since_seen"] = bool(done_at and last_seen and done_at > last_seen) or \
                                 bool(done_at and last_seen is None)
    elif item.status == "waiting_on_team":
        out["client_state"] = "waiting_on_us"
    elif item.flag:
        out["client_state"] = "flagged"
    else:
        out["client_state"] = "todo"

    step = step_def(spec, item.current_step_id) if item.current_step_id else None
    if step is not None:
        out["step"] = {
            "id": step.id,
            "owner": step.owner,
            "instruction": render_text(step.instruction, item.variables, fv),
            "detail": render_text(step.detail, item.variables, fv),
            "tutorial": _tutorial_payload(step.tutorial, item.variables, fv),
            "fields": [f.model_dump() for f in step.fields],
            "primary_label": step.primary.label if step.primary else "",
            "control": step.control,
            "link": step.link.model_dump() if step.link else None,
            "states": list(step.states),
            # which state it is on: the last one recorded, else the first
            "state_index": (
                step.states.index(fv.get(step.id, {}).get("state"))
                if step.states and fv.get(step.id, {}).get("state") in step.states
                else 0
            ),
            "choices": [{"key": c.key, "label": c.label} for c in step.choices],
            "targets": targets_state(spec, item, events),
            "alternatives": [{"key": a.key, "label": a.label, "kind": a.kind}
                             for a in step.alternatives],
        }
    else:
        out["step"] = None

    # The client's own answer stays visible after the step has moved on — a
    # form that saves as you go shows you what you saved. Carries the whole
    # option set so the control can render settled without the step it came
    # from still being current.
    answered = [e for e in events if e.field_values and "choice" in e.field_values]
    if answered:
        last = answered[-1]
        src = step_def(spec, last.step_id)
        out["choice_made"] = {
            "step": last.step_id,
            "key": last.action_key,
            "label": last.field_values["choice"],
            "options": [{"key": c.key, "label": c.label} for c in src.choices] if src else [],
        }
    else:
        out["choice_made"] = None

    # The conversation: the item's comment thread (flag messages, team replies
    # and freeform comments are ONE stream — see interaction.add_comment).
    from app.services import interaction

    comments = interaction.comments_of(session, item)
    out["comment_count"] = len(comments)
    shown = comments if item.status != "done" else comments[-3:]
    out["messages"] = [
        {"by": c.author, "kind": c.author_kind, "text": c.body,
         "at": c.created_at.isoformat()}
        for c in shown
    ]

    if for_team:
        out["events_count"] = len(events)
    return out
