"""The lenses — pure functions from a project's ``generated/`` to view-models.

Each lens answers one question by composing what the **mgf-fedres** spine reports
(``read_file`` → ``ReadResult``; ``shallow_check`` → ``SyncReport``) into a plain
dict the templates render. Nothing here is a second source of truth: reported
facts pass through untouched; anything the web computes (ages, the "you are here"
marker, the global aggregate) is tagged ``derived`` in the template. Absence is a
first-class state — a project that reports no ``roadmap.json`` renders "Nothing
reported yet"; a corrupt/unknown-schema one renders its ``SyncState`` and the
reason — never a blank, never a guess (spec §1.6, §5.7).
"""

from __future__ import annotations

import json
from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path
from typing import Any

from mgf.fedres import (
    DiskProvider,
    GenerateError,
    ReadResult,
    SyncReport,
    SyncState,
    current_session,
    deep_check,
    generate,
    read_file,
    shallow_check,
    validate_payload,
)

from .history import generated_snapshots

# Worst-actionable-first ordering for the global aggregate (spec §1.5). The global
# pill takes the worst state present; IN_SYNC only wins if every project is IN_SYNC.
_SEVERITY: tuple[str, ...] = (
    "ERROR",
    "TAMPERED",
    "UNREADABLE",
    "STALE",
    "UNKNOWN",
    "SYNCING",
    "CHECKING",
    "IN_SYNC",
)

# The per-project lens set (spec §5.4a order). M2 shipped roadmap/inprogress/
# decisions/architecture; M5 adds stalled, nextbest, changed, coupling. The
# overview adds "sync"; feedback + review-pack are sibling project surfaces, not
# generated/ lenses, so they live outside this registry.
LENS_ORDER: tuple[str, ...] = (
    "roadmap",
    "inprogress",
    "stalled",
    "nextbest",
    "decisions",
    "changed",
    "coupling",
    "architecture",
)
LENS_LABELS: dict[str, str] = {
    "roadmap": "Roadmap",
    "inprogress": "In progress",
    "stalled": "Stalled",
    "nextbest": "Next best",
    "decisions": "Decisions",
    "changed": "Changed",
    "coupling": "Coupling",
    "architecture": "Architecture",
}
DEFAULT_LENS = "roadmap"

# Next-best weights (spec §5.4b) — stated once, here, so the ranking is auditable.
_IMPACT_W: dict[str, int] = {"low": 1, "medium": 2, "high": 3}
_EFFORT_W: dict[str, int] = {"XS": 1, "S": 1, "M": 2, "L": 3, "XL": 4}
# An in_progress task older than this (with no movement) is "stalled" (spec §5.4a.3).
_STALE_IN_PROGRESS_DAYS = 21


# --------------------------------------------------------------------------- #
# project meta + sync                                                         #
# --------------------------------------------------------------------------- #
@dataclass(frozen=True)
class ProjectMeta:
    """Header + rail + matrix facts for one project (all reported, plus its sync)."""

    slug: str
    name: str
    kind: str
    wired: bool
    sync_state: str
    sync_detail: str
    sync_reason: str | None
    source_commit: str | None
    head_commit: str | None
    generated_at: str | None

    @property
    def source_short(self) -> str:
        return self.source_commit[:8] if self.source_commit else "—"

    @property
    def head_short(self) -> str:
        return self.head_commit[:8] if self.head_commit else "—"

    @property
    def behind(self) -> bool:
        """HEAD has moved past the commit the dashboard was generated from."""
        return bool(
            self.source_commit and self.head_commit and self.source_commit != self.head_commit
        )


def _read(provider: DiskProvider, name: str) -> ReadResult:
    """Read a generated file through the spine's honest-absence reader."""
    return read_file(provider.generated_dir / name)


def assemble_meta(
    slug: str, provider: DiskProvider, report: SyncReport | None = None
) -> ProjectMeta:
    """Compute a project's header/rail facts: its sync verdict + manifest display.

    ``report`` lets a caller supply an already-computed verdict — the authoritative
    ``deep_check`` behind a *Check sync* action (M3). Omitted, it runs the instant
    ``shallow_check`` (the dashboard-load tier, M2).
    """
    if report is None:
        report = shallow_check(provider)
    name, kind = slug, "project"
    source_commit = generated_at = None
    manifest = _read(provider, "manifest.json")
    if manifest.ok and isinstance(manifest.payload, dict):
        proj = manifest.payload.get("project") or {}
        name = str(proj.get("name") or slug)
        kind = str(proj.get("kind") or "project")
        source_commit = manifest.payload.get("source_commit")
        generated_at = manifest.payload.get("generated_at")
    return ProjectMeta(
        slug=slug,
        name=name,
        kind=kind,
        # "wired" = the manifest is present AND readable, so lenses can be trusted.
        # UNKNOWN (no tree) and UNREADABLE (broken manifest) both render project-level
        # honest-absence instead of per-lens views.
        wired=report.state not in (SyncState.UNKNOWN, SyncState.UNREADABLE),
        sync_state=report.state.value,
        sync_detail=report.detail,
        sync_reason=report.reason,
        source_commit=source_commit,
        head_commit=provider.head_commit(),
        generated_at=generated_at,
    )


def unknown_meta(slug: str) -> ProjectMeta:
    """A requested project that isn't in the federation — honest UNKNOWN (spec §5.7)."""
    return ProjectMeta(
        slug=slug,
        name=slug,
        kind="not wired",
        wired=False,
        sync_state=SyncState.UNKNOWN.value,
        sync_detail="no fed-resources/ found — this project isn't wired into the federation",
        sync_reason=None,
        source_commit=None,
        head_commit=None,
        generated_at=None,
    )


def federation_state(metas: list[ProjectMeta]) -> dict[str, Any]:
    """Aggregate the per-project states into the global pill (spec §1.5)."""
    counts: dict[str, int] = {}
    for m in metas:
        counts[m.sync_state] = counts.get(m.sync_state, 0) + 1
    worst = "IN_SYNC"
    for m in metas:
        if _SEVERITY.index(m.sync_state) < _SEVERITY.index(worst):
            worst = m.sync_state
    all_synced = bool(metas) and all(m.sync_state == "IN_SYNC" for m in metas)
    label = "All in sync" if all_synced else ("Drift" if metas else "No projects")
    parts = [
        f"{counts[s]} {'in sync' if s == 'IN_SYNC' else s.lower()}"
        for s in _SEVERITY
        if counts.get(s)
    ]
    return {
        "state": worst,
        "label": label,
        "counts": "" if all_synced else " · ".join(parts),
        "all_synced": all_synced,
    }


# --------------------------------------------------------------------------- #
# honest-absence                                                              #
# --------------------------------------------------------------------------- #
def _absence(lens: str, read: ReadResult) -> dict[str, Any]:
    """Render a failed read as its explicit state (never a blank) — spec §1.6."""
    state = read.state.value if read.state else SyncState.UNKNOWN.value
    if state == SyncState.UNKNOWN.value:
        return {
            "lens": lens,
            "ok": False,
            "state": state,
            "title": "Nothing reported yet",
            "reason": "This project doesn't emit this resource.",
            "action": None,
            "errors": list(read.errors),
        }
    return {
        "lens": lens,
        "ok": False,
        "state": state,
        "title": "This resource can't be read",
        "reason": read.reason or "unreadable",
        "action": "Regenerate on schema 0.1, or upgrade the reader.",
        "errors": list(read.errors),
    }


def _age_days(started_at: str | None) -> int | None:
    """Derived: whole days since ``started_at`` (UTC), or None if not reported/parseable."""
    if not started_at:
        return None
    try:
        started = datetime.fromisoformat(started_at.replace("Z", "+00:00"))
    except ValueError:
        return None
    if started.tzinfo is None:
        started = started.replace(tzinfo=UTC)
    return (datetime.now(UTC) - started).days


def _age(started_at: str | None) -> str | None:
    """Derived: ``now - started_at`` as a short human string, or None if not reported."""
    days = _age_days(started_at)
    if days is None:
        return None
    if days <= 0:
        return "today"
    if days < 14:
        return f"{days}d"
    if days < 60:
        return f"{days // 7}w"
    return f"{days // 30}mo"


# --------------------------------------------------------------------------- #
# lenses                                                                       #
# --------------------------------------------------------------------------- #
def roadmap_lens(provider: DiskProvider) -> dict[str, Any]:
    """Q1 — the whole arc: the chain (start→done→now→left) paired with the table."""
    read = _read(provider, "roadmap.json")
    if not read.ok or not isinstance(read.payload, dict):
        return _absence("roadmap", read)
    payload = read.payload
    stages = list(payload.get("stages") or [])
    here = None
    for i, stage in enumerate(stages):
        if stage.get("state") == "in_progress":
            here = i
    return {
        "lens": "roadmap",
        "ok": True,
        "final_stage": payload.get("final_stage") or {},
        "stages": stages,
        "here": here,
    }


def inprogress_lens(provider: DiskProvider) -> dict[str, Any]:
    """Q2 — what's happening now: every in_progress task + its (derived) age."""
    read = _read(provider, "status.json")
    if not read.ok or not isinstance(read.payload, dict):
        return _absence("inprogress", read)
    tasks = list(read.payload.get("tasks") or [])
    items = [
        {"task": t, "age": _age(t.get("started_at"))}
        for t in tasks
        if t.get("state") == "in_progress"
    ]
    return {
        "lens": "inprogress",
        "ok": True,
        "items": items,
        # Live discovery marker (derived): is a traced op streaming to live/CURRENT right
        # now? The Follow-live view (M6) reads the session; this only flags its presence.
        "live_now": current_session(provider) is not None,
        "empty_title": "Nothing in progress",
        "empty_reason": "No tasks are currently in flight.",
    }


def decisions_lens(provider: DiskProvider) -> dict[str, Any]:
    """Q5 — your worklist: founder-owned tasks that aren't done (spec §5.4a.5)."""
    read = _read(provider, "status.json")
    if not read.ok or not isinstance(read.payload, dict):
        return _absence("decisions", read)
    tasks = list(read.payload.get("tasks") or [])
    items = [
        t
        for t in tasks
        if t.get("owner") == "founder" and t.get("state") not in ("done", "skipped")
    ]
    return {
        "lens": "decisions",
        "ok": True,
        "items": items,
        "empty_title": "Nothing waiting on you",
        "empty_reason": "No founder-owned items are open in this project.",
    }


def architecture_lens(provider: DiskProvider) -> dict[str, Any]:
    """The Architecture lens: embed the project's own diagram, or render its graph."""
    read = _read(provider, "architecture.json")
    if not read.ok or not isinstance(read.payload, dict):
        return _absence("architecture", read)
    payload = read.payload
    note = payload.get("note")
    if payload.get("mode") == "embed_svg":
        return {
            "lens": "architecture",
            "ok": True,
            "mode": "embed_svg",
            "embed": payload.get("embed") or {},
            "note": note,
        }
    graph = payload.get("graph") or {}
    layers = list(graph.get("layers") or [])
    nodes = list(graph.get("nodes") or [])
    edges = list(graph.get("edges") or [])
    # Group nodes by layer and attach each node's outgoing deps, so the graph reads
    # as a layered list (these projects emit `graph`, not a pre-rendered diagram).
    deps_by_node: dict[str, list[str]] = {}
    for edge in edges:
        src = str(edge.get("from"))
        deps_by_node.setdefault(src, []).append(str(edge.get("to")))
    grouped = [
        {"layer": layer, "nodes": [n for n in nodes if n.get("layer") == layer]} for layer in layers
    ]
    # Nodes whose layer isn't listed still render (honest — never dropped silently).
    listed = {n["id"] for group in grouped for n in group["nodes"]}
    orphans = [n for n in nodes if n.get("id") not in listed]
    if orphans:
        grouped.append({"layer": "(unlayered)", "nodes": orphans})
    return {
        "lens": "architecture",
        "ok": True,
        "mode": "graph",
        "grouped": grouped,
        "deps_by_node": deps_by_node,
        "counts": {"nodes": len(nodes), "edges": len(edges), "layers": len(layers)},
        "note": note,
    }


def stalled_lens(provider: DiskProvider) -> dict[str, Any]:
    """Q3 — started, not finished, and why: blocked/postponed + stale in_progress.

    Each item carries its ``reason`` and (derived) age. A stalled item **missing a
    reason is itself flagged** (spec §5.4a.3) — in valid data that surfaces as a
    stale ``in_progress`` task nobody explained (``reason`` is schema-required only
    for blocked/postponed, so those always carry one).
    """
    read = _read(provider, "status.json")
    if not read.ok or not isinstance(read.payload, dict):
        return _absence("stalled", read)
    tasks = list(read.payload.get("tasks") or [])
    items: list[dict[str, Any]] = []
    flagged = 0
    for t in tasks:
        state = t.get("state")
        age_days = _age_days(t.get("started_at"))
        if state in ("blocked", "postponed"):
            why = state
        elif state == "in_progress" and age_days is not None and age_days > _STALE_IN_PROGRESS_DAYS:
            why = "stale_in_progress"
        else:
            continue
        missing_reason = not str(t.get("reason") or "").strip()
        if missing_reason:
            flagged += 1
        items.append(
            {
                "task": t,
                "age": _age(t.get("started_at")),
                "why": why,
                "missing_reason": missing_reason,
            }
        )
    return {
        "lens": "stalled",
        "ok": True,
        "items": items,
        "flagged": flagged,
        "threshold_days": _STALE_IN_PROGRESS_DAYS,
        "empty_title": "Nothing stalled",
        "empty_reason": (
            "No blocked or postponed work, and nothing in progress past the "
            f"{_STALE_IN_PROGRESS_DAYS}-day staleness threshold."
        ),
    }


def _impact_w(impact: Any) -> int:
    """impact -> weight {low:1, medium:2, high:3}; an unreported impact weighs 1."""
    return _IMPACT_W.get(impact, 1)


def _effort_w(effort: Any) -> int:
    """effort -> weight; T-shirt sizes per spec §5.4b, integer points bucketed onto 1..4."""
    if isinstance(effort, str):
        return _EFFORT_W.get(effort, 2)
    if isinstance(effort, int):  # points (schema: integer >= 0), bucketed like the sizes
        if effort <= 1:
            return 1
        if effort <= 3:
            return 2
        if effort <= 5:
            return 3
        return 4
    return 2  # unreported effort -> neutral middle (M-equivalent)


def _priority_rank(priority: Any) -> int | None:
    """Normalize priority to a sort key (lower = more urgent); None when not set."""
    if isinstance(priority, int):  # 1 (most urgent) .. 5
        return priority
    return {"high": 1, "medium": 2, "low": 3}.get(priority)


def _reverse_deps(tasks: list[dict[str, Any]]) -> dict[Any, set[Any]]:
    """Map each task id -> the set of task ids that directly depend on it."""
    rev: dict[Any, set[Any]] = {}
    for t in tasks:
        for dep in t.get("depends_on") or []:
            rev.setdefault(dep, set()).add(t.get("id"))
    return rev


def _leverage(task_id: Any, rev: dict[Any, set[Any]]) -> int:
    """Count tasks that **transitively** depend on ``task_id`` (DFS over the reverse graph)."""
    seen: set[Any] = set()
    stack = list(rev.get(task_id, ()))
    while stack:
        node = stack.pop()
        if node in seen:
            continue
        seen.add(node)
        stack.extend(rev.get(node, ()))
    return len(seen)


def _why_string(impact: Any, leverage: int, *, ready: bool, effort: Any) -> str:
    """The audit string shown on a next-best card (``impact high · unblocks 3 · ready · effort S``)."""
    return " · ".join(
        [
            f"impact {impact or '—'}",
            f"unblocks {leverage}",
            "ready" if ready else "blocked by deps",
            f"effort {effort or '—'}",
        ]
    )


def nextbest_lens(provider: DiskProvider) -> dict[str, Any]:
    """Q4 — most valuable next, with why: rank ready ``next``/``todo`` by the §5.4b score.

    ``score = (impact_w * 2) + leverage - (effort_w - 1)`` over ready candidates
    (every ``depends_on`` is ``done``); ties break by lower effort then higher impact.
    A reported ``priority`` floats an item to the top of its readiness class. Deps-blocked
    candidates are never dropped — they're listed separately as "highest-value, but
    blocked," with what each is waiting on. The whole ordering is ``derived``.
    """
    read = _read(provider, "status.json")
    if not read.ok or not isinstance(read.payload, dict):
        return _absence("nextbest", read)
    tasks = list(read.payload.get("tasks") or [])
    done = {t.get("id") for t in tasks if t.get("state") == "done"}
    rev = _reverse_deps(tasks)

    ready: list[dict[str, Any]] = []
    blocked: list[dict[str, Any]] = []
    for t in (t for t in tasks if t.get("state") in ("next", "todo")):
        unmet = [d for d in (t.get("depends_on") or []) if d not in done]
        impact, effort = t.get("impact"), t.get("effort")
        iw, ew = _impact_w(impact), _effort_w(effort)
        lev = _leverage(t.get("id"), rev)
        prank = _priority_rank(t.get("priority"))
        row: dict[str, Any] = {
            "task": t,
            "impact_w": iw,
            "effort_w": ew,
            "leverage": lev,
            "priority": t.get("priority"),
            "priority_rank": prank,
        }
        if unmet:
            row["blocked_on"] = unmet
            row["why"] = _why_string(impact, lev, ready=False, effort=effort)
            blocked.append(row)
        else:
            # score is computed ONLY for ready tasks (spec §5.4b).
            row["score"] = (iw * 2) + lev - (ew - 1)
            row["why"] = _why_string(impact, lev, ready=True, effort=effort)
            ready.append(row)

    def _prio_key(r: dict[str, Any]) -> tuple[int, int]:
        # priority floats to the top of its readiness class; un-prioritized sort after.
        return (0, r["priority_rank"]) if r["priority_rank"] is not None else (1, 0)

    ready.sort(key=lambda r: (*_prio_key(r), -r["score"], r["effort_w"], -r["impact_w"]))
    blocked.sort(key=lambda r: (*_prio_key(r), -r["impact_w"], -r["leverage"], r["effort_w"]))
    return {
        "lens": "nextbest",
        "ok": True,
        "ready": ready,
        "blocked": blocked,
        "weights": {"impact": _IMPACT_W, "effort": _EFFORT_W},
        "empty_title": "No actionable candidates",
        "empty_reason": "No next/todo tasks to rank.",
    }


def changed_lens(provider: DiskProvider) -> dict[str, Any]:
    """Q5 — catch me up: the delta between the last two committed ``generated/`` snapshots.

    Diffs task state transitions + new/closed tasks (``status.json``), stage progress/
    state moves (``roadmap.json``), and new/closed agent suggestions. Everything here
    is web-computed over committed history, so it is tagged ``derived``. With fewer than
    two snapshots it says so honestly (spec §4.10) — never a faked or blank delta.
    """
    snaps = generated_snapshots(provider, limit=2)
    if len(snaps) < 2:
        return {
            "lens": "changed",
            "ok": True,
            "single": True,
            "snapshots": len(snaps),
            "commit": snaps[0].commit[:8] if snaps else None,
            "title": "Not enough history to show a delta",
            "reason": (
                "No committed generated/ snapshot yet — the Changed lens compares the two "
                "most recent."
                if not snaps
                else "Only one committed generated/ snapshot — the delta appears once the next "
                "change is committed."
            ),
        }
    new, old = snaps[0], snaps[1]

    def _by_id(payload: Any, key: str) -> dict[Any, dict[str, Any]]:
        items = (payload or {}).get(key) or [] if isinstance(payload, dict) else []
        return {it.get("id"): it for it in items}

    new_tasks = _by_id(new.files.get("status.json"), "tasks")
    old_tasks = _by_id(old.files.get("status.json"), "tasks")
    transitions = [
        {
            "id": tid,
            "title": new_tasks[tid].get("title"),
            "from": old_tasks[tid].get("state"),
            "to": new_tasks[tid].get("state"),
        }
        for tid in new_tasks
        if tid in old_tasks and old_tasks[tid].get("state") != new_tasks[tid].get("state")
    ]
    new_added = [
        {"id": tid, "title": new_tasks[tid].get("title"), "state": new_tasks[tid].get("state")}
        for tid in new_tasks
        if tid not in old_tasks
    ]
    closed = [
        {"id": tid, "title": old_tasks[tid].get("title"), "state": old_tasks[tid].get("state")}
        for tid in old_tasks
        if tid not in new_tasks
    ]

    new_stages = _by_id(new.files.get("roadmap.json"), "stages")
    old_stages = _by_id(old.files.get("roadmap.json"), "stages")
    stage_moves = [
        {
            "id": sid,
            "label": new_stages[sid].get("label"),
            "from_state": old_stages[sid].get("state"),
            "to_state": new_stages[sid].get("state"),
            "from_progress": old_stages[sid].get("progress"),
            "to_progress": new_stages[sid].get("progress"),
        }
        for sid in new_stages
        if sid in old_stages
        and (
            old_stages[sid].get("state") != new_stages[sid].get("state")
            or old_stages[sid].get("progress") != new_stages[sid].get("progress")
        )
    ]

    new_sugg = _by_id(new.files.get("suggestions.json"), "items")
    old_sugg = _by_id(old.files.get("suggestions.json"), "items")
    new_suggestions = [
        {"id": sid, "title": new_sugg[sid].get("title")} for sid in new_sugg if sid not in old_sugg
    ]
    closed_suggestions = [
        {"id": sid, "title": old_sugg[sid].get("title")} for sid in old_sugg if sid not in new_sugg
    ]
    has_changes = bool(
        transitions or new_added or closed or stage_moves or new_suggestions or closed_suggestions
    )
    return {
        "lens": "changed",
        "ok": True,
        "single": False,
        "new_commit": new.commit[:8],
        "old_commit": old.commit[:8],
        "transitions": transitions,
        "new_tasks": new_added,
        "closed_tasks": closed,
        "stage_moves": stage_moves,
        "new_suggestions": new_suggestions,
        "closed_suggestions": closed_suggestions,
        "has_changes": has_changes,
        "empty_title": "No changes between the last two snapshots",
        "empty_reason": "The two most recent committed generated/ snapshots match in state.",
    }


def coupling_lens(provider: DiskProvider) -> dict[str, Any]:
    """Q5 — cross-project: what this project consumes from siblings, from ``federation.json``.

    Reported (declared fact, not inference): each ``consumes`` entry with its
    ``on_upstream_change`` policy, plus declared ``roadmap_external_deps``. Where the
    policy is ``trigger_check`` the web flags that an upstream advance should trigger a
    downstream check here (that one implication is ``derived``). Absent file → honest
    "Nothing reported yet" (most projects declare no coupling).
    """
    read = _read(provider, "federation.json")
    if not read.ok or not isinstance(read.payload, dict):
        return _absence("coupling", read)
    payload = read.payload
    consumes = [
        {
            "from": c.get("from"),
            "what": c.get("what"),
            "why": c.get("why"),
            "pinned_at": c.get("pinned_at"),
            "policy": c.get("on_upstream_change"),
            "triggers_check": c.get("on_upstream_change") == "trigger_check",
        }
        for c in (payload.get("consumes") or [])
    ]
    return {
        "lens": "coupling",
        "ok": True,
        "consumes": consumes,
        "external_deps": list(payload.get("roadmap_external_deps") or []),
        "triggers": sum(1 for c in consumes if c["triggers_check"]),
        "empty_title": "No declared coupling",
        "empty_reason": "This project declares no upstream dependencies in federation.json.",
    }


# Lens id → builder. Routes pick the builder; templates render the result.
LENS_BUILDERS = {
    "roadmap": roadmap_lens,
    "inprogress": inprogress_lens,
    "stalled": stalled_lens,
    "nextbest": nextbest_lens,
    "decisions": decisions_lens,
    "changed": changed_lens,
    "coupling": coupling_lens,
    "architecture": architecture_lens,
}


def build_lens(lens: str, provider: DiskProvider) -> dict[str, Any]:
    """Build one lens' view-model, falling back to the default for an unknown id."""
    builder = LENS_BUILDERS.get(lens, LENS_BUILDERS[DEFAULT_LENS])
    return builder(provider)


def sync_matrix(metas: list[ProjectMeta]) -> list[ProjectMeta]:
    """The federation overview is the per-project metas, shown as the sync matrix."""
    return metas


# --------------------------------------------------------------------------- #
# M3 — sync actions: the deep (authoritative) check + the one sanctioned write #
#                                                                              #
# Every spine call lives here (the web's single seam onto ``mgf.fedres``):     #
# ``deep_check`` is provably non-mutating (it regenerates into memory with     #
# ``write=False``); ``generate(write=True)`` is the ONLY write, and only of    #
# the working tree's ``generated/`` — never git, never an auto-commit.         #
# --------------------------------------------------------------------------- #
def check_result(report: SyncReport) -> dict[str, Any]:
    """A one-line result for a *deep* check (authoritative, non-mutating) — spec §5.5."""
    state = report.state.value
    if state == SyncState.IN_SYNC.value:
        return {"ok": True, "text": "deep check — regenerate is byte-identical: in sync"}
    if state == SyncState.STALE.value:
        n = len(report.changed_files)
        return {"ok": False, "text": f"deep check — regenerate would change {n} file(s)"}
    if state == SyncState.ERROR.value:
        return {"ok": False, "text": f"deep check failed — {report.error or report.detail}"}
    # TAMPERED / UNREADABLE / UNKNOWN: surface the spine's own honest detail.
    return {"ok": False, "text": f"deep check — {report.detail}"}


def force_result(changed: int) -> dict[str, Any]:
    """The result line for a force-sync (regenerate-only; never auto-commit) — spec §2.3/§5.5."""
    if changed:
        return {
            "ok": True,
            "text": f"regenerated {changed} file(s) — uncommitted; review + commit project-side",
        }
    return {
        "ok": True,
        "text": "regenerated — 0 file(s) changed (already current); commit project-side if pending",
    }


def deep_meta(slug: str, provider: DiskProvider) -> ProjectMeta:
    """A project's meta carrying its *authoritative* (deep-check) verdict — for Check all."""
    return assemble_meta(slug, provider, report=deep_check(provider))


def run_check(provider: DiskProvider) -> tuple[SyncReport, dict[str, Any]]:
    """Check sync — the deep, authoritative, **non-mutating** verdict (spec §1.4, §5.5).

    ``deep_check`` regenerates into memory (``write=False``) and diffs against the
    committed ``generated/``; it writes nothing. The route asserts this in a test.
    """
    report = deep_check(provider)
    return report, check_result(report)


def run_force_sync(provider: DiskProvider, generated_at: str) -> tuple[SyncReport, dict[str, Any]]:
    """Force-sync — regenerate ``generated/`` in the working tree (the ONE sanctioned write).

    Counts the would-change set via a non-mutating ``deep_check`` first (drives the
    result line), regenerates with ``write=True``, then settles to the authoritative
    state. Governed-repo default: regenerate only — never git, never auto-commit
    (spec §2.3/§5.5). An unwired/unreadable project can't be regenerated, so it is
    surfaced honestly (its state + reason) rather than faked into IN_SYNC.
    """
    pre = deep_check(provider)
    if pre.state in (SyncState.UNKNOWN, SyncState.UNREADABLE):
        return pre, check_result(pre)
    try:
        generate(provider, generated_at=generated_at, write=True)
    except GenerateError as exc:
        report = SyncReport(SyncState.ERROR, "force-sync failed", error=str(exc))
        return report, {"ok": False, "text": f"force-sync failed — {exc}"}
    settled = deep_check(provider)
    return settled, force_result(len(pre.changed_files))


# --------------------------------------------------------------------------- #
# M4 — feedback: the closed loop (the ONE sanctioned write beyond display)      #
#                                                                              #
# Compose -> produce a schema-valid ``feedback.json`` -> DELIVER it into the    #
# target's ``feedback/rounds/v<N>/`` inbox (local mode: the sibling working     #
# tree, UNCOMMITTED). The generator never writes ``feedback/``; the web never   #
# touches ``generated/`` here, never git, never auto-commits. Round ``open`` /  #
# ``answered`` is web-derived (answered = every feedback item has a matching     #
# answer). Non-operative: feedback is actionable text, not a change to the       #
# project (spec §4.7/4.8, §5.6, D1).                                            #
# --------------------------------------------------------------------------- #
def _rounds_dir(provider: DiskProvider) -> Path:
    return provider.feedback_dir / "rounds"


def _round_numbers(provider: DiskProvider) -> list[int]:
    """Existing ``v<N>`` round numbers under ``feedback/rounds/`` (honest-absent → [])."""
    rounds_dir = _rounds_dir(provider)
    if not rounds_dir.is_dir():
        return []
    nums: list[int] = []
    for child in rounds_dir.iterdir():
        if child.is_dir() and child.name.startswith("v"):
            try:
                nums.append(int(child.name[1:]))
            except ValueError:
                continue
    return sorted(nums)


def next_round_number(provider: DiskProvider) -> int:
    """The round number a freshly-composed feedback delivery takes (max existing + 1)."""
    nums = _round_numbers(provider)
    return (nums[-1] + 1) if nums else 1


def feedback_thread(provider: DiskProvider) -> dict[str, Any]:
    """The round thread: each round's feedback items paired with the project's answers.

    Reads ``feedback.json`` (+ optional ``response.json``) per round through the spine's
    honest-absence reader. A round is **answered** (derived) when every feedback item
    has a matching ``answer.feedback_id``; otherwise **open**. Newest round first. The
    agent's own ``agent_raised`` channel rides along on the response (spec §4.8).
    """
    rounds: list[dict[str, Any]] = []
    for n in _round_numbers(provider):
        rdir = _rounds_dir(provider) / f"v{n}"
        fb = read_file(rdir / "feedback.json")
        if not fb.ok or not isinstance(fb.payload, dict):
            # A present-but-unreadable round is surfaced, never silently dropped.
            rounds.append({"round": n, "unreadable": True, "reason": fb.reason})
            continue
        resp = read_file(rdir / "response.json")
        response = resp.payload if resp.ok and isinstance(resp.payload, dict) else None
        items = list(fb.payload.get("items") or [])
        answers = (
            {a.get("feedback_id"): a for a in (response.get("answers") or [])} if response else {}
        )
        paired = [{"item": it, "answer": answers.get(it.get("id"))} for it in items]
        answered = bool(items) and all(it.get("id") in answers for it in items)
        rounds.append(
            {
                "round": n,
                "unreadable": False,
                "feedback": fb.payload,
                "items": paired,
                "response": response,
                "agent_raised": list(response.get("agent_raised") or []) if response else [],
                "answered": answered,
            }
        )
    rounds.sort(key=lambda r: r["round"], reverse=True)
    return {"rounds": rounds, "has_rounds": bool(rounds)}


def open_round_count(provider: DiskProvider) -> int:
    """Derived: rounds with feedback still awaiting a complete response (for the nav badge)."""
    return sum(
        1
        for r in feedback_thread(provider)["rounds"]
        if not r.get("unreadable") and not r["answered"]
    )


def build_feedback_payload(
    slug: str,
    round_n: int,
    *,
    authored_by: str,
    authored_at: str,
    kind: str,
    priority: str,
    title: str,
    body: str,
    view: str | None,
    ref: str | None,
) -> dict[str, Any]:
    """Assemble a schema-§4.7 ``feedback.json`` for one composed item (round-keyed)."""
    item: dict[str, Any] = {
        "id": "FB-1",
        "kind": kind,
        "priority": priority,
        "title": title,
        "body": body,
    }
    if view and ref:
        item["applies_to"] = {"view": view, "ref": ref}
    return {
        "schema_version": "0.1",
        "kind": "feedback",
        "round": round_n,
        "project": slug,
        "authored_by": authored_by,
        "authored_at": authored_at,
        "items": [item],
    }


def deliver_feedback(provider: DiskProvider, payload: dict[str, Any]) -> Path:
    """Deliver ``feedback.json`` into the target's ``feedback/rounds/v<N>/`` inbox.

    Local mode: writes the sibling working tree, UNCOMMITTED. Never ``generated/``,
    never git, never an auto-commit (spec §3, §5.6). Canonical JSON so the artifact is
    stable. The caller validates the payload first (fail-closed) — this only writes.
    """
    dest = _rounds_dir(provider) / f"v{payload['round']}" / "feedback.json"
    dest.parent.mkdir(parents=True, exist_ok=True)
    dest.write_text(json.dumps(payload, sort_keys=True, indent=2) + "\n", encoding="utf-8")
    return dest


def compose_and_deliver(
    provider: DiskProvider,
    slug: str,
    *,
    authored_by: str,
    authored_at: str,
    kind: str,
    priority: str,
    title: str,
    body: str,
    view: str | None,
    ref: str | None,
) -> tuple[Path | None, dict[str, Any]]:
    """Compose + validate + deliver one feedback item; fail-closed on a schema error.

    Returns ``(delivered_path | None, result)``. A schema-invalid payload is **not
    written** (the result carries ``ok: False`` + the reason); on success the result
    names the round the artifact landed in.
    """
    if not title.strip() or not body.strip():
        return None, {"ok": False, "text": "not delivered — a title and a body are required"}
    round_n = next_round_number(provider)
    payload = build_feedback_payload(
        slug,
        round_n,
        authored_by=authored_by,
        authored_at=authored_at,
        kind=kind,
        priority=priority,
        title=title,
        body=body,
        view=view,
        ref=ref,
    )
    errors = validate_payload(payload)
    if errors:
        return None, {
            "ok": False,
            "text": f"not delivered — feedback.json failed schema validation: {'; '.join(errors)}",
        }
    deliver_feedback(provider, payload)
    return Path(_rounds_dir(provider) / f"v{round_n}" / "feedback.json"), {
        "ok": True,
        "round": round_n,
        "text": f"delivered to round v{round_n} — act on it in the project (uncommitted)",
    }
