#!/usr/bin/env -S uv run --no-project --python >=3.12 python
"""scripts/docs-index — regenerate the gitignored doc indexes.

Four standalone generated files, one script, run by the SessionStart
hook (each worktree regenerates its own copies; nothing is committed —
the files are gitignored, so they never churn or conflict):

  docs/backlog/INDEX.md    one line per work item: slug — status — summary
  docs/runbooks/INDEX.md   one line per runbook: slug — summary
  docs/codebase-map.md     package map: import path — docstring first line
  docs/backlog/threads/PRIORITIES.md  activity + bounded Resume fields

The summary for a markdown file is its first non-heading, non-front-matter
prose line; `status:` is read from front-matter (backlog only, defaults to
`idea`). The backlog index is grouped by `pillar:` (docs/roadmap.md), one
H2 per pillar in the roadmap's order, unpillared items last so the lint
line and the index agree on what is missing. The package map walks src/*/**/__init__.py and takes each module
docstring's first line (PEP 257) — a package without a docstring is listed
as such, which is the nudge to write one.

`scripts/docs-index --lint` writes nothing; it checks the docstring layer
(exit 1 on any finding, one `docs-index: ...` line each on stderr):
  - ceiling: a package docstring over DOCSTRING_WORD_CEILING words — split it
    by subpackage/module; the narrowest package owns each fact;
  - restatement: a package docstring that copies a run of >= RESTATE_MIN_WORDS
    consecutive words from a doc under docs/ or a skill (the match unit is an
    8-word window; a copied sentence is a run of windows). One owner per fact:
    the docstring owns subsystem truth and docs/skills point at it, or the
    reverse for agent-facing contract — never both. docs/backlog/ and
    generated files are exempt.

Stdlib-only on purpose: hooks and inline commands run it with a bare
`python3`, no uv/container. Idempotent; exits non-zero only on I/O
errors, invalid thread navigation or (with --lint) a docstring finding.
"""

from __future__ import annotations

import ast
import re
import sys
from pathlib import Path

ROOT = Path(__file__).resolve().parent.parent

HEADER = (
    "<!-- GENERATED by scripts/docs-index — do not edit; gitignored, "
    "regenerated at session start. -->"
)


def front_matter(text: str) -> tuple[dict[str, str], str]:
    """Parse a simple `key: value` front-matter block; return (fm, body)."""
    if not text.startswith("---\n"):
        return {}, text
    end = text.find("\n---", 4)
    if end < 0:
        return {}, text
    fm: dict[str, str] = {}
    for line in text[4:end].splitlines():
        if ":" in line:
            k, _, v = line.partition(":")
            fm[k.strip()] = v.strip()
    return fm, text[end + 4 :]


def summary_line(body: str) -> str:
    """First non-heading, non-blockquote prose line, tidied."""
    for line in body.splitlines():
        s = line.strip()
        if s and not s.startswith(("#", ">", "---", "<!--", "|", "```", "-")):
            return re.sub(r"\s+", " ", s)
    return ""


# Backlog priority buckets → sort rank (high first). Unset/unknown = normal.
_PRIO_RANK = {"high": 0, "normal": 1, "low": 2}

# Pillar order for the backlog index — docs/roadmap.md's order, then the two
# non-pillar buckets, then whatever has no pillar yet.
_PILLAR_ORDER = [
    "memory-graph",
    "3d-design",
    "local-compute",
    "personal",
    "quests",
    "platform",
]
_NO_PILLAR = "(no pillar — add `pillar:` per docs/roadmap.md)"


def md_index(directory: Path, with_status: bool) -> list[str]:
    # (pillar, rank, filename, row): the backlog groups by pillar, then sorts
    # high-priority first, then filename for a stable order within a bucket.
    # Runbooks (with_status=False) have no pillar and all get rank 0, so they
    # stay in pure filename order as before.
    entries: list[tuple[str, int, str, str]] = []
    for f in sorted(directory.glob("*.md")):
        if f.name in ("README.md", "INDEX.md", "TEMPLATE.md"):
            continue
        fm, body = front_matter(f.read_text(encoding="utf-8"))
        summary = fm.get("summary") or summary_line(body)
        if with_status:
            status = fm.get("status", "idea")
            prio = (fm.get("prio") or "normal").lower()
            marker = f" ·{prio}·" if prio in ("high", "low") else ""
            row = f"- [`{f.stem}`](./{f.name}) **{status}**{marker} — {summary}"
            pillar = fm.get("pillar") or _NO_PILLAR
            entries.append((pillar, _PRIO_RANK.get(prio, 1), f.name, row))
        else:
            entries.append(("", 0, f.name, f"- [`{f.stem}`](./{f.name}) — {summary}"))
    if not with_status:
        entries.sort(key=lambda e: (e[1], e[2]))
        return [row for _, _, _, row in entries]
    known = {p: i for i, p in enumerate(_PILLAR_ORDER)}

    def pillar_rank(p: str) -> tuple[int, str]:
        return (known.get(p, len(known) + (1 if p == _NO_PILLAR else 0)), p)

    entries.sort(key=lambda e: (pillar_rank(e[0]), e[1], e[2]))
    rows: list[str] = []
    current = None
    for pillar, _, _, row in entries:
        if pillar != current:
            n = sum(1 for e in entries if e[0] == pillar)
            rows.extend(["", f"## {pillar} ({n})", ""])
            current = pillar
        rows.append(row)
    return rows[1:] if rows and rows[0] == "" else rows


def docstring_first_line(init: Path) -> str | None:
    """First line of the module docstring, or None if absent."""
    text = init.read_text(encoding="utf-8", errors="replace")
    m = re.match(r'\s*(?:#[^\n]*\n\s*)*("""|\'\'\')(.*?)\1', text, re.DOTALL)
    if not m:
        return None
    first = m.group(2).strip().splitlines()
    return re.sub(r"\s+", " ", first[0]).strip() if first else None


def package_map() -> list[str]:
    rows = []
    for init in (ROOT / "src").glob("**/__init__.py"):
        rel = init.relative_to(ROOT / "src").parent
        if any(p.startswith((".", "_")) or p == "__pycache__" for p in rel.parts):
            continue
        if "data" in rel.parts[:-1]:  # resource dirs under a data/ package
            continue
        mod = ".".join(rel.parts)
        line = docstring_first_line(init)
        rows.append(f"- `{mod}` — {line or '*(no package docstring yet)*'}")
    return sorted(rows)


DOCSTRING_WORD_CEILING = 1500
RESTATE_WINDOW = 8  # words per match window
RESTATE_MIN_WORDS = 16  # shortest copied run reported (a full sentence)
_LINT_SKIP_DOCS = ("docs/backlog/", "docs/codebase-map.md", "docs/reference/schema.md")


def _words(text: str) -> list[str]:
    return re.findall(r"[a-z0-9_]+", text.lower())


def package_docstrings() -> dict[str, str]:
    """Package `__init__.py` docstring text (same package set as the map)."""
    out: dict[str, str] = {}
    for init in sorted((ROOT / "src").glob("**/__init__.py")):
        rel = init.relative_to(ROOT / "src").parent
        if any(p.startswith((".", "_")) or p == "__pycache__" for p in rel.parts):
            continue
        if "data" in rel.parts[:-1]:
            continue
        try:
            tree = ast.parse(init.read_text(encoding="utf-8"))
        except SyntaxError:
            continue
        doc = ast.get_docstring(tree)
        if doc:
            out[init.relative_to(ROOT).as_posix()] = doc
    return out


def lint_docstrings() -> list[str]:
    """Findings for the docstring layer: size ceiling + doc/skill restatement."""
    docs = package_docstrings()
    findings = [
        f"{path}: package docstring is {len(doc.split())} words "
        f"(ceiling {DOCSTRING_WORD_CEILING}) — split it by subpackage/module"
        for path, doc in docs.items()
        if len(doc.split()) > DOCSTRING_WORD_CEILING
    ]
    win = RESTATE_WINDOW
    index: dict[tuple[str, ...], set[str]] = {}
    sources = [
        *sorted((ROOT / "docs").glob("**/*.md")),
        *sorted((ROOT / "src/precis/data/skills").glob("**/*.md")),
    ]
    for f in sources:
        rel = f.relative_to(ROOT).as_posix()
        if rel.startswith(_LINT_SKIP_DOCS):
            continue
        text = f.read_text(encoding="utf-8")
        if HEADER[:24] in text[:200]:  # generated file
            continue
        ws = _words(text)
        for i in range(len(ws) - win + 1):
            index.setdefault(tuple(ws[i : i + win]), set()).add(rel)
    need = RESTATE_MIN_WORDS - win + 1  # consecutive matching windows
    for path, doc in docs.items():
        ws = _words(doc)
        runs: dict[str, list[int]] = {}  # file -> [current run, best run]
        last: dict[str, int] = {}
        for i in range(len(ws) - win + 1):
            for rel in index.get(tuple(ws[i : i + win]), ()):
                cur = runs.setdefault(rel, [0, 0])
                cur[0] = cur[0] + 1 if last.get(rel) == i - 1 else 1
                cur[1] = max(cur[1], cur[0])
                last[rel] = i
        for rel, (_, best) in sorted(runs.items()):
            if best >= need:
                findings.append(
                    f"{path} <-> {rel}: {best + win - 1} consecutive words "
                    "restated — one owner: keep it in one place, point from the other"
                )
    return findings


def write_generated(target: Path, title: str, rows: list[str]) -> bool:
    new = "\n".join([f"# {title}", "", HEADER, "", *rows, ""])
    if target.exists() and target.read_text(encoding="utf-8") == new:
        return False
    target.write_text(new, encoding="utf-8")
    return True


RESUME_FIELDS = (
    "Pillar",
    "Next",
    "Blocked by",
    "Unblocks",
    "Acceptance",
    "Worktree",
    "Builds",
    "Detail",
)


def thread_priorities(root: Path) -> list[str]:
    """Render declared activity; reject ambiguous or unreachable entry points.

    Resume fields stay with ranked work so owners update one handoff. The
    fleet roster owns activity; generated views never infer live processes.
    """
    directory = root / "docs/backlog/threads"
    active: set[str] = set()
    for line in (
        (root / ".claude/fleet/threads.tsv").read_text(encoding="utf-8").splitlines()
    ):
        if not line.strip() or line.startswith("#"):
            continue
        slug = line.split("\t")[0]
        if slug in active or not (directory / f"{slug}.md").is_file():
            raise ValueError(f"fleet target missing or duplicated: {slug}")
        active.add(slug)
    entries = []
    for path in sorted(directory.glob("*.md")):
        if path.name in ("README.md", "INDEX.md", "PRIORITIES.md"):
            continue
        text = path.read_text(encoding="utf-8")
        blocks = re.findall(r"^## Resume\n(.*?)(?=^## |\Z)", text, re.M | re.S)
        if len(blocks) != 1:
            raise ValueError(f"{path.name}: expected one Resume section")
        block = blocks[0]
        fields = re.findall(r"^- \*\*([^:]+):\*\* (.+)$", block, re.M)
        values = dict(fields)
        if len(fields) != len(RESUME_FIELDS) or set(values) != set(RESUME_FIELDS):
            raise ValueError(f"{path.name}: expected Resume fields {RESUME_FIELDS}")
        if len(block.split()) > 350:
            raise ValueError(f"{path.name}: Resume exceeds 350 words")
        if values["Pillar"] not in _PILLAR_ORDER:
            raise ValueError(f"{path.name}: unknown pillar")
        for target in re.findall(r"\]\(([^)]+)\)", block):
            if "://" in target:
                continue
            filename, _, anchor = target.partition("#")
            linked = path.parent / filename if filename else path
            if not linked.is_file():
                raise ValueError(f"{path.name}: missing Resume link {target}")
            if anchor:
                headings = re.findall(
                    r"^#+ (.+)$", linked.read_text(encoding="utf-8"), re.M
                )
                anchors = {
                    re.sub(r"[^\w -]", "", h.lower()).replace(" ", "-")
                    for h in headings
                }
                if anchor not in anchors:
                    raise ValueError(f"{path.name}: missing Resume anchor {target}")
        state = "active" if path.stem in active else "dormant"
        cells = [f"[{path.stem}]({path.name}#resume)", values["Pillar"], state]
        # The table lives beside the thread, so relative file links keep
        # their base but fragment-only links must name the source thread.
        cells.extend(
            re.sub(r"\]\((#[^)]+)\)", rf"]({path.name}\1)", values[key])
            for key in ("Next", "Blocked by", "Unblocks", "Builds")
        )
        entries.append(
            (
                state != "active",
                _PILLAR_ORDER.index(values["Pillar"]),
                path.stem,
                "| " + " | ".join(c.replace("|", "\\|") for c in cells) + " |",
            )
        )
    return [
        "Activity is declared by `.claude/fleet/threads.tsv`, not observed live. "
        "Check `scripts/inflight` before resuming. Owners maintain Resume fields; "
        "this table does not re-rank threads.",
        "",
        "[Allocation priorities](../../roadmap.md#allocation-priorities) · "
        "[Programme seams](INDEX.md) · [Thread contract](README.md)",
        "",
        "| Thread | Pillar | Activity | Next action | Blocked by | Unblocks | Builds |",
        "|---|---|---|---|---|---|---|",
        *(entry[3] for entry in sorted(entries)),
    ]


def main() -> int:
    if "--lint" in sys.argv[1:]:
        problems = lint_docstrings()
        for line in problems:
            print(f"docs-index: {line}", file=sys.stderr)
        return 1 if problems else 0
    changed = []
    try:
        priorities = thread_priorities(ROOT)
    except ValueError as error:
        print(f"docs-index: {error}", file=sys.stderr)
        return 1
    if write_generated(
        ROOT / "docs/backlog/threads/PRIORITIES.md", "Thread priorities", priorities
    ):
        changed.append("docs/backlog/threads/PRIORITIES.md")
    backlog = ROOT / "docs" / "backlog"
    if backlog.is_dir() and write_generated(
        backlog / "INDEX.md", "Backlog index", md_index(backlog, with_status=True)
    ):
        changed.append("docs/backlog/INDEX.md")
    runbooks = ROOT / "docs" / "runbooks"
    if runbooks.is_dir() and write_generated(
        runbooks / "INDEX.md", "Runbook index", md_index(runbooks, with_status=False)
    ):
        changed.append("docs/runbooks/INDEX.md")
    if write_generated(ROOT / "docs" / "codebase-map.md", "Package map", package_map()):
        changed.append("docs/codebase-map.md")
    for c in changed:
        print(f"→ {c}")
    return 0


if __name__ == "__main__":
    sys.exit(main())
