from __future__ import annotations

from copy import deepcopy
from difflib import unified_diff
from html import escape
import json
from pathlib import Path
import shutil
from typing import Any

from .render import render_page
from .source import SourcePackage
from .util import (
    load_json,
    project_root,
    sha256_file,
    sha256_json,
    utc_now,
    write_json,
    write_text,
)
from .validation import validate_page


CHANGE_CATEGORIES = (
    "textual_transcription",
    "document_structure",
    "date_normalization",
    "continuation",
    "intervention_or_uncertainty",
    "editorial_notes",
    "other_metadata",
)


def _page_id(page: int) -> str:
    return f"page-{page:03d}"


def _paths(root: Path, page: int) -> dict[str, Path]:
    page_id = _page_id(page)
    return {
        "diagnostic": root / "evaluation" / "goldens" / f"{page_id}.json",
        "approved": root / "edition" / "owner-approved" / f"{page_id}.json",
        "state": root / "evaluation" / "owner-review" / "states" / f"{page_id}.json",
        "submission": (
            root / "evaluation" / "owner-review" / "submissions" / f"{page_id}.json"
        ),
    }


def _selected_cases(root: Path) -> list[dict[str, Any]]:
    return load_json(root / "evaluation" / "selection.json")["cases"]


def _candidate_records(root: Path) -> tuple[dict[str, Any], dict[int, dict[str, Any]]]:
    index = load_json(root / "evaluation" / "candidate-index.json")
    return index, {record["page"]: record for record in index["candidates"]}


def _require_selected(root: Path, page: int) -> None:
    selected = {case["page"] for case in _selected_cases(root)}
    if page not in selected:
        raise ValueError(f"Page {page} is not in the 14-page evaluation selection")


def _load_state(root: Path, page: int) -> dict[str, Any]:
    state_path = _paths(root, page)["state"]
    if state_path.is_file():
        return load_json(state_path)
    return {
        "page_id": _page_id(page),
        "current_status": "unapproved",
        "events": [],
    }


def _write_state(root: Path, page: int, state: dict[str, Any]) -> None:
    write_json(_paths(root, page)["state"], state)


def _structure_signature(page: dict[str, Any]) -> list[Any]:
    signature: list[Any] = []
    for block in page["blocks"]:
        parts = []
        if block["type"] in {"diary_entry", "letter"}:
            parts = [part["type"] for part in block["content"]]
        signature.append((block["type"], tuple(parts)))
    return signature


def _collect_keyed(node: Any, predicate: Any, path: str = "$") -> list[Any]:
    values: list[Any] = []
    if isinstance(node, dict):
        for key, value in node.items():
            child = f"{path}.{key}"
            if predicate(key, value):
                values.append((child, value))
            values.extend(_collect_keyed(value, predicate, child))
    elif isinstance(node, list):
        for index, value in enumerate(node):
            values.extend(_collect_keyed(value, predicate, f"{path}[{index}]"))
    return values


def _inline_interventions(page: dict[str, Any]) -> list[Any]:
    found: list[Any] = []

    def walk(node: Any, path: str) -> None:
        if isinstance(node, dict):
            if node.get("type") in {
                "unclear",
                "illegible",
                "addition",
                "deletion",
                "correction",
            }:
                found.append((path, node))
            for key, value in node.items():
                walk(value, f"{path}.{key}")
        elif isinstance(node, list):
            for index, value in enumerate(node):
                walk(value, f"{path}[{index}]")

    walk(page, "$")
    return found


def classify_changes(before: dict[str, Any], after: dict[str, Any]) -> list[str]:
    categories: list[str] = []
    if (
        render_page(before, diplomatic=False) != render_page(after, diplomatic=False)
        or render_page(before, diplomatic=True) != render_page(after, diplomatic=True)
    ):
        categories.append("textual_transcription")
    if _structure_signature(before) != _structure_signature(after):
        categories.append("document_structure")
    date_predicate = lambda key, _value: "date" in key
    if _collect_keyed(before, date_predicate) != _collect_keyed(after, date_predicate):
        categories.append("date_normalization")
    continuation_predicate = lambda key, _value: key in {
        "boundary",
        "continuation",
        "starts_mid_block",
        "ends_mid_block",
        "from_previous",
        "to_next",
        "previous",
        "next",
    }
    if _collect_keyed(before, continuation_predicate) != _collect_keyed(
        after, continuation_predicate
    ):
        categories.append("continuation")
    if _inline_interventions(before) != _inline_interventions(after):
        categories.append("intervention_or_uncertainty")
    if before.get("editorial_notes") != after.get("editorial_notes"):
        categories.append("editorial_notes")
    if not categories:
        before_core = deepcopy(before)
        after_core = deepcopy(after)
        for value in (before_core, after_core):
            value.pop("schema_version", None)
            value.pop("review", None)
            value.pop("provenance", None)
        if before_core != after_core:
            categories.append("other_metadata")
    return [category for category in CHANGE_CATEGORIES if category in categories]


def _json_diff(
    before: dict[str, Any],
    after: dict[str, Any],
    fromfile: str = "model-candidate",
    tofile: str = "agent-diagnostic",
) -> str:
    left = json.dumps(before, ensure_ascii=False, indent=2).splitlines()
    right = json.dumps(after, ensure_ascii=False, indent=2).splitlines()
    return "\n".join(
        unified_diff(left, right, fromfile=fromfile, tofile=tofile)
    )


def _text_diff(
    before: dict[str, Any],
    after: dict[str, Any],
    fromfile: str = "candidate-reading",
    tofile: str = "diagnostic-reading",
) -> str:
    left = render_page(before, diplomatic=False).splitlines()
    right = render_page(after, diplomatic=False).splitlines()
    return "\n".join(
        unified_diff(left, right, fromfile=fromfile, tofile=tofile)
    )


def summarize_uncertainties(page: dict[str, Any]) -> list[str]:
    found: list[str] = []

    def walk(node: Any, path: str) -> None:
        if isinstance(node, dict):
            kind = node.get("type")
            if kind in {"unclear", "illegible"}:
                reading = node.get("reading")
                alternatives = node.get("alternatives") or []
                detail = reading or "/".join(alternatives) or "unreadable"
                found.append(
                    f"{path}: {kind} ({node.get('confidence', 'unreadable')}): {detail}"
                )
            for key, value in node.items():
                walk(value, f"{path}.{key}")
        elif isinstance(node, list):
            for index, value in enumerate(node):
                walk(value, f"{path}[{index}]")

    walk(page.get("blocks", []), "$.blocks")
    if "uncertain" in page.get("boundary", {}).values():
        found.append(f"$.boundary: {page['boundary']}")
    for block in page.get("blocks", []):
        continuation = block.get("continuation", {})
        if "uncertain" in (continuation.get("from_previous"), continuation.get("to_next")):
            found.append(f"$.blocks.{block['id']}.continuation: {continuation}")
    return found


def _model_run_summary(root: Path, page: int, record: dict[str, Any], index: dict[str, Any]) -> dict[str, Any]:
    page_id = _page_id(page)
    run_root = root / "artifacts" / "runs" / record["run_id"]
    page_root = run_root / "pages" / page_id
    status_path = page_root / "status.json"
    status = load_json(status_path)["status"] if status_path.is_file() else "unknown"
    repair_used = (page_root / "attempt-02").is_dir()
    initially_failed = page in index.get("initial_batch_failures", {}).get("pages", [])
    if initially_failed and status == "valid_candidate":
        status = "valid_candidate_after_targeted_rerun"
    return {
        "status": status,
        "run_id": record["run_id"],
        "repair_used": repair_used,
        "initial_batch_failure_preserved": initially_failed,
    }


def _owner_status(state: dict[str, Any]) -> tuple[str, str | None]:
    if state["current_status"] == "owner_approved":
        return "owner_approved", state["events"][-1]["action"]
    if state["current_status"] == "needs_work":
        return "needs_work", "needs_work"
    return "unapproved", None


def review_index(root: Path | None = None) -> dict[str, Any]:
    root = (root or project_root()).resolve()
    index, candidate_records = _candidate_records(root)
    pages: list[dict[str, Any]] = []
    for case in _selected_cases(root):
        page = case["page"]
        record = candidate_records[page]
        candidate = load_json(root / record["path"])
        diagnostic = load_json(_paths(root, page)["diagnostic"])
        categories = classify_changes(candidate, diagnostic)
        json_diff = _json_diff(candidate, diagnostic)
        state = _load_state(root, page)
        owner_status, owner_action = _owner_status(state)
        approved_path = _paths(root, page)["approved"]
        approved = (
            load_json(approved_path)
            if owner_status == "owner_approved" and approved_path.is_file()
            else None
        )
        current_edition = approved or diagnostic
        owner_categories = classify_changes(diagnostic, approved) if approved else []
        uncertainties = summarize_uncertainties(current_edition)
        pages.append(
            {
                "page": page,
                "page_id": _page_id(page),
                "challenge": case["reason"],
                "model_run": _model_run_summary(root, page, record, index),
                "agent_review_changes": {
                    "categories": categories,
                    "changed_diff_lines": sum(
                        1
                        for line in json_diff.splitlines()
                        if line.startswith(("+", "-"))
                        and not line.startswith(("+++", "---"))
                    ),
                    "reading_text_changed": (
                        render_page(candidate) != render_page(diagnostic)
                    ),
                },
                "uncertainties": {
                    "count": len(uncertainties),
                    "items": uncertainties,
                },
                "owner_review_status": owner_status,
                "owner_action": owner_action,
                "owner_review_changes": {
                    "categories": owner_categories,
                    "reading_text_changed": (
                        approved is not None
                        and render_page(diagnostic) != render_page(approved)
                    ),
                },
            }
        )
    return {
        "title": "Crowley diary Phase 1.5 owner-review checklist",
        "diagnostic_fixtures_are_ground_truth": False,
        "owner_approval_is_explicit_only": True,
        "page_count": len(pages),
        "pages": pages,
    }


def _render_list(items: list[str], empty: str = "None recorded") -> str:
    if not items:
        return f"<p>{escape(empty)}</p>"
    return "<ul>" + "".join(f"<li>{escape(item)}</li>" for item in items) + "</ul>"


def _page_html(
    *,
    root: Path,
    source: SourcePackage,
    output: Path,
    record: dict[str, Any],
    checklist: dict[str, Any],
    previous_page: int | None,
    next_page: int | None,
) -> str:
    page = checklist["page"]
    page_id = checklist["page_id"]
    candidate = load_json(root / record["path"])
    diagnostic = load_json(_paths(root, page)["diagnostic"])
    approved_path = _paths(root, page)["approved"]
    approved = (
        load_json(approved_path)
        if checklist["owner_review_status"] == "owner_approved"
        and approved_path.is_file()
        else None
    )
    current_edition = approved or diagnostic
    ocr = source.ocr_text(page)
    reading = render_page(current_edition, diplomatic=False)
    diplomatic = render_page(current_edition, diplomatic=True)
    json_difference = _json_diff(candidate, diagnostic)
    text_difference = _text_diff(candidate, diagnostic)
    owner_json_difference = (
        _json_diff(
            diagnostic,
            approved,
            fromfile="agent-diagnostic",
            tofile="owner-approved-edition",
        )
        if approved
        else ""
    )
    owner_text_difference = (
        _text_diff(
            diagnostic,
            approved,
            fromfile="diagnostic-reading",
            tofile="owner-approved-reading",
        )
        if approved
        else ""
    )
    notes = [note["text"] for note in current_edition["editorial_notes"]]
    notes.extend(current_edition["review"]["notes"])
    navigation = []
    if previous_page is not None:
        navigation.append(f'<a href="page-{previous_page:03d}.html">← page {previous_page}</a>')
    navigation.append('<a href="../index.html">checklist</a>')
    if next_page is not None:
        navigation.append(f'<a href="page-{next_page:03d}.html">page {next_page} →</a>')
    if approved:
        action_panel = (
            "<p>Explicit owner approval is recorded. The historical model candidate "
            "and agent diagnostic remain available below.</p>"
        )
    else:
        owner_command = (
            "uv run diary owner-review approve-as-is "
            f"--page {page} --owner &quot;OWNER NAME&quot; --source-root {escape(str(source.root))}"
        )
        corrected_command = (
            "uv run diary owner-review approve-corrected "
            f"--page {page} --owner &quot;OWNER NAME&quot; --corrected-json /path/to/{page_id}.json "
            f"--source-root {escape(str(source.root))}"
        )
        needs_work_command = (
            "uv run diary owner-review needs-work "
            f"--page {page} --owner &quot;OWNER NAME&quot; --reason &quot;REASON&quot;"
        )
        action_panel = (
            "<p>Viewing or rebuilding this bundle never approves a page. Approval "
            "requires one explicit command:</p>"
            f"<pre>{owner_command}</pre><pre>{corrected_command}</pre>"
            f"<pre>{needs_work_command}</pre>"
        )
    owner_difference_panel = (
        "<h2>Diagnostic-to-owner reading diff</h2>"
        f"<pre>{escape(owner_text_difference or 'No reading-text difference.')}</pre>"
        "<h2>Diagnostic-to-owner canonical JSON diff</h2>"
        f"<pre>{escape(owner_json_difference)}</pre>"
        if approved
        else ""
    )
    approved_details = (
        "<details><summary>Owner-approved edition JSON</summary>"
        f'<p><a href="../data/approved/{page_id}.json">open JSON</a></p>'
        f"<pre>{escape(json.dumps(approved, ensure_ascii=False, indent=2))}</pre></details>"
        if approved
        else ""
    )
    return f"""<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width">
<title>{page_id} owner review</title><link rel="stylesheet" href="../assets/review.css"></head>
<body><header><nav>{' · '.join(navigation)}</nav><h1>{page_id}</h1>
<p><strong>Challenge:</strong> {escape(checklist['challenge'])}</p>
<p class="warning">Agent-reviewed material is diagnostic only. The page image is authoritative.</p></header>
<main class="page-grid"><section class="image-pane"><h2>Authoritative page image</h2>
<a href="../images/{page_id}.png"><img src="../images/{page_id}.png" alt="Scanned source {page_id}"></a></section>
<section class="evidence-pane"><h2>Owner status: {escape(checklist['owner_review_status'])}</h2>
{action_panel}
<h2>Uncertainties</h2>{_render_list(checklist['uncertainties']['items'])}
<h2>Editorial and review notes</h2>{_render_list(notes)}
<h2>Current edition reading text</h2><pre>{escape(reading)}</pre>
<h2>Current edition diplomatic rendering</h2><pre>{escape(diplomatic)}</pre>
<h2>Raw OCR</h2><p><a href="../data/ocr/{page_id}.txt">open text file</a></p><pre>{escape(ocr)}</pre>
<h2>Candidate-to-diagnostic reading diff</h2><pre>{escape(text_difference or 'No reading-text difference.')}</pre>
<h2>Candidate-to-diagnostic canonical JSON diff</h2><p>Categories: {escape(', '.join(checklist['agent_review_changes']['categories']) or 'review metadata only')}</p>
<pre>{escape(json_difference or 'No canonical JSON difference.')}</pre>
{owner_difference_panel}
<details><summary>Raw model candidate JSON</summary><p><a href="../data/candidates/{page_id}.json">open JSON</a></p><pre>{escape(json.dumps(candidate, ensure_ascii=False, indent=2))}</pre></details>
<details><summary>Agent-reviewed diagnostic JSON</summary><p><a href="../data/diagnostics/{page_id}.json">open or download JSON</a></p><pre>{escape(json.dumps(diagnostic, ensure_ascii=False, indent=2))}</pre></details>
{approved_details}
</section></main></body></html>"""


def _index_html(index: dict[str, Any]) -> str:
    rows = []
    for page in index["pages"]:
        changes = ", ".join(page["agent_review_changes"]["categories"]) or "review metadata only"
        run = page["model_run"]
        rows.append(
            "<tr>"
            f'<td><a href="pages/{page["page_id"]}.html">{page["page"]}</a></td>'
            f"<td>{escape(page['challenge'])}</td>"
            f"<td>{escape(run['status'])}<br><small>{escape(run['run_id'])}</small></td>"
            f"<td>{'yes' if run['repair_used'] else 'no'}</td>"
            f"<td>{escape(changes)} ({page['agent_review_changes']['changed_diff_lines']} diff lines)</td>"
            f"<td>{page['uncertainties']['count']}</td>"
            f"<td>{escape(page['owner_review_status'])}</td>"
            "</tr>"
        )
    return f"""<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width">
<title>Owner-review checklist</title><link rel="stylesheet" href="assets/review.css"></head><body>
<header><h1>Phase 1.5 owner-review checklist</h1>
<p class="warning">The page image is authoritative. Agent-reviewed diagnostics are comparison aids, never independent ground truth.</p>
<p>Opening this bundle does not change review state. Only explicit <code>owner-review approve-*</code> commands can create owner-approved edition files.</p></header>
<main><table><thead><tr><th>Page</th><th>Selection challenge</th><th>Model run</th><th>Repair used</th><th>Agent-review changes</th><th>Uncertainties</th><th>Owner status</th></tr></thead>
<tbody>{''.join(rows)}</tbody></table></main></body></html>"""


REVIEW_CSS = """body{font:16px/1.45 system-ui,sans-serif;margin:0;color:#25231f;background:#f7f3ea}header,main{padding:1rem 2rem}header{background:#26362f;color:#fff}.warning{background:#fff0bd;color:#4b3300;padding:.7rem;border-left:5px solid #c78a00}a{color:#17609a}header a{color:#bfe1ff}table{border-collapse:collapse;width:100%;background:#fff}th,td{border:1px solid #bbb;padding:.55rem;text-align:left;vertical-align:top}th{background:#e5e0d5}.page-grid{display:grid;grid-template-columns:minmax(340px,45vw) 1fr;gap:1.5rem}.image-pane{align-self:start;position:sticky;top:0}.image-pane img{width:100%;height:auto;background:#fff}.evidence-pane{min-width:0}pre{white-space:pre-wrap;overflow-wrap:anywhere;background:#fff;border:1px solid #ccc;padding:.8rem}details{margin:1rem 0}small{color:#555}@media(max-width:900px){.page-grid{grid-template-columns:1fr}.image-pane{position:static}}"""


def build_review_bundle(
    *, source: SourcePackage, output: Path, root: Path | None = None
) -> dict[str, Any]:
    root = (root or project_root()).resolve()
    output = output.resolve()
    index, records = _candidate_records(root)
    checklist = review_index(root)
    pages = checklist["pages"]
    write_text(output / "assets" / "review.css", REVIEW_CSS)
    for directory in (
        output / "images",
        output / "data" / "candidates",
        output / "data" / "diagnostics",
        output / "data" / "approved",
        output / "pages",
    ):
        directory.mkdir(parents=True, exist_ok=True)
    for position, item in enumerate(pages):
        page = item["page"]
        page_id = item["page_id"]
        record = records[page]
        shutil.copyfile(source.image_path(page), output / "images" / f"{page_id}.png")
        write_text(output / "data" / "ocr" / f"{page_id}.txt", source.ocr_text(page))
        shutil.copyfile(root / record["path"], output / "data" / "candidates" / f"{page_id}.json")
        shutil.copyfile(
            _paths(root, page)["diagnostic"],
            output / "data" / "diagnostics" / f"{page_id}.json",
        )
        approved_path = _paths(root, page)["approved"]
        if approved_path.is_file():
            shutil.copyfile(
                approved_path,
                output / "data" / "approved" / f"{page_id}.json",
            )
        previous_page = pages[position - 1]["page"] if position else None
        next_page = pages[position + 1]["page"] if position + 1 < len(pages) else None
        write_text(
            output / "pages" / f"{page_id}.html",
            _page_html(
                root=root,
                source=source,
                output=output,
                record=record,
                checklist=item,
                previous_page=previous_page,
                next_page=next_page,
            ),
        )
    write_json(output / "index.json", checklist)
    write_text(output / "index.html", _index_html(checklist))
    return {
        "output": str(output),
        "index": str(output / "index.html"),
        "page_count": len(pages),
        "owner_approvals_created": 0,
    }


def _prepare_approval(
    *,
    source: SourcePackage,
    root: Path,
    page: int,
    owner: str,
    corrected_json: Path | None,
    note: str | None,
) -> tuple[dict[str, Any], dict[str, Any]]:
    _require_selected(root, page)
    paths = _paths(root, page)
    if paths["approved"].exists():
        raise ValueError(f"Owner-approved edition already exists: {paths['approved']}")
    diagnostic = load_json(paths["diagnostic"])
    diagnostic_report = validate_page(diagnostic, source)
    if not diagnostic_report.valid:
        raise ValueError("Diagnostic failed validation:\n" + "\n".join(diagnostic_report.errors))
    if (
        diagnostic["review"]["status"] != "agent_reviewed"
        or diagnostic["review"]["authority"] != "diagnostic_only"
    ):
        raise ValueError("Approval source must be an agent-reviewed diagnostic-only fixture")

    action = "approve_as_is" if corrected_json is None else "approve_corrected"
    timestamp = utc_now()
    diagnostic_hash = sha256_file(paths["diagnostic"])
    if corrected_json is None:
        approved = deepcopy(diagnostic)
        input_hash = diagnostic_hash
        artifact = str(paths["diagnostic"].relative_to(root))
        categories: list[str] = []
        textual_change = False
        submission_hash = None
    else:
        submitted = load_json(corrected_json)
        if submitted.get("page_id") != diagnostic["page_id"]:
            raise ValueError("Corrected JSON page_id does not match the selected page")
        if submitted.get("source") != diagnostic["source"]:
            raise ValueError("Corrected JSON must preserve trusted source provenance exactly")
        if submitted.get("segment") != diagnostic["segment"]:
            raise ValueError("Corrected JSON must preserve the deterministic segment assignment")
        submitted_report = validate_page(submitted, source)
        if not submitted_report.valid:
            raise ValueError("Corrected JSON failed validation:\n" + "\n".join(submitted_report.errors))
        approved = deepcopy(submitted)
        approved["provenance"] = deepcopy(diagnostic["provenance"])
        approved["source"] = deepcopy(diagnostic["source"])
        approved["segment"] = deepcopy(diagnostic["segment"])
        submission_hash = sha256_file(corrected_json)
        input_hash = submission_hash
        artifact = str(paths["submission"].relative_to(root))
        categories = classify_changes(diagnostic, submitted)
        textual_change = (
            render_page(diagnostic, diplomatic=False)
            != render_page(submitted, diplomatic=False)
            or render_page(diagnostic, diplomatic=True)
            != render_page(submitted, diplomatic=True)
        )

    inherited_notes = (
        diagnostic["review"]["notes"]
        if action == "approve_as_is"
        else [
            "The owner-approved correction supersedes, but does not overwrite, "
            "the preserved agent-reviewed diagnostic."
        ]
    )
    approved["review"] = {
        "status": "owner_approved",
        "authority": "owner_approved_edition",
        "reviewer": owner,
        "reviewed_at": timestamp,
        "notes": [
            *inherited_notes,
            f"Explicit owner action: {action}.",
            f"Approval lineage begins with diagnostic SHA-256 {diagnostic_hash}.",
            *([note] if note else []),
        ],
    }
    approved["provenance"]["steps"].append(
        {
            "kind": "owner_review",
            "actor": owner,
            "artifact": artifact,
            "created_at": timestamp,
            "action": action,
            "input_sha256": input_hash,
            "change_categories": categories,
        }
    )
    report = validate_page(approved, source)
    if not report.valid:
        raise ValueError("Owner-approved page failed validation:\n" + "\n".join(report.errors))
    event = {
        "action": action,
        "owner": owner,
        "decided_at": timestamp,
        "note": note,
        "diagnostic_path": str(paths["diagnostic"].relative_to(root)),
        "diagnostic_sha256": diagnostic_hash,
        "submission_path": (
            str(paths["submission"].relative_to(root)) if corrected_json else None
        ),
        "submission_sha256": submission_hash,
        "approved_path": str(paths["approved"].relative_to(root)),
        "change_categories": categories,
        "owner_textual_change": textual_change,
    }
    return approved, event


def approve_page(
    *,
    source: SourcePackage,
    page: int,
    owner: str,
    corrected_json: Path | None = None,
    note: str | None = None,
    root: Path | None = None,
) -> dict[str, Any]:
    root = (root or project_root()).resolve()
    approved, event = _prepare_approval(
        source=source,
        root=root,
        page=page,
        owner=owner,
        corrected_json=corrected_json,
        note=note,
    )
    paths = _paths(root, page)
    if corrected_json is not None:
        paths["submission"].parent.mkdir(parents=True, exist_ok=True)
        shutil.copyfile(corrected_json, paths["submission"])
    write_json(paths["approved"], approved)
    event["approved_sha256"] = sha256_file(paths["approved"])
    state = _load_state(root, page)
    state["current_status"] = "owner_approved"
    state["events"].append(event)
    _write_state(root, page, state)
    return {
        "page_id": _page_id(page),
        "status": "owner_approved",
        "authority": "owner_approved_edition",
        "action": event["action"],
        "approved_path": str(paths["approved"]),
        "state_path": str(paths["state"]),
    }


def mark_needs_work(
    *, page: int, owner: str, reason: str, root: Path | None = None
) -> dict[str, Any]:
    root = (root or project_root()).resolve()
    _require_selected(root, page)
    paths = _paths(root, page)
    if paths["approved"].exists():
        raise ValueError("An owner-approved edition already exists; it was not changed")
    state = _load_state(root, page)
    state["current_status"] = "needs_work"
    state["events"].append(
        {
            "action": "needs_work",
            "owner": owner,
            "decided_at": utc_now(),
            "reason": reason,
        }
    )
    _write_state(root, page, state)
    return {
        "page_id": _page_id(page),
        "status": "needs_work",
        "owner_approved_edition_created": False,
        "state_path": str(paths["state"]),
    }


def summarize_owner_review(root: Path | None = None) -> dict[str, Any]:
    root = (root or project_root()).resolve()
    selected = _selected_cases(root)
    approved = 0
    approved_as_is = 0
    corrected = 0
    deferred = 0
    unapproved = 0
    textual_pages: list[int] = []
    category_pages: dict[str, list[int]] = {category: [] for category in CHANGE_CATEGORIES}
    pages: list[dict[str, Any]] = []
    for case in selected:
        page = case["page"]
        state = _load_state(root, page)
        current = state["current_status"]
        event = state["events"][-1] if state["events"] else None
        if current == "owner_approved":
            approved += 1
            if event["action"] == "approve_as_is":
                approved_as_is += 1
            else:
                corrected += 1
            if event.get("owner_textual_change"):
                textual_pages.append(page)
            for category in event.get("change_categories", []):
                category_pages[category].append(page)
        elif current == "needs_work":
            deferred += 1
        else:
            unapproved += 1
        pages.append(
            {
                "page": page,
                "status": current,
                "action": event.get("action") if event else None,
            }
        )
    category_pages = {key: value for key, value in category_pages.items() if value}
    return {
        "scope": "14-page Phase 1.5 evaluation set",
        "diagnostics_are_ground_truth": False,
        "accuracy_calculated_against_diagnostics": False,
        "selected": len(selected),
        "number_approved": approved,
        "number_approved_as_is": approved_as_is,
        "number_corrected_by_owner": corrected,
        "number_rejected_or_deferred": deferred,
        "number_unapproved": unapproved,
        "pages_requiring_owner_textual_changes": textual_pages,
        "change_categories": category_pages,
        "pages": pages,
    }
