#!/usr/bin/env python3
"""Parse ErsatzTV decision records from docs/decisions.md and docs/decisions/*.md.

A decision record is a Markdown H2 section. A *migrated* record carries a visible metadata
block as its first non-blank content:

    ## 2026-07-17 — Title … (#406)
    `key: ci.runner-placement` · `status: active` · `since: 2026-07-17` · `supersedes: none` · `superseded-by: none`
    **Rule:** one-line current rule.
    **Signals:** concept · paths: a/b.yml · issues: #406
    **Mechanics:** docs/ci-cd.md → CI lanes
    <rationale prose …>

An H2 with no metadata line is treated as status `legacy-unmigrated` (migration target).

Two OPTIONAL fields (ersatztv#603, adopted from OKF v0.2's lifecycle/provenance families) may also
appear — `stale-after: YYYY-MM-DD` on the metadata line, and a `**Sources:**` line in the metadata
block. Absence is never an error; see decisions_validate for how they are checked.
"""

from __future__ import annotations

import re
from dataclasses import dataclass
from pathlib import Path

REPO_ROOT = Path(__file__).resolve().parent.parent
DECISIONS_MD = REPO_ROOT / "docs" / "decisions.md"
TOPIC_DIR = REPO_ROOT / "docs" / "decisions"
ARCHIVE_DIR = TOPIC_DIR / "archive"
RECORDS_DIR = TOPIC_DIR / "records"  # split layout (#610): one YAML-frontmatter record per file

# H2 headings that are document structure, not decision records. "Records formerly in this file"
# is the generated where-did-it-go index the #610 split appends to each stripped file.
SKIP_HEADINGS = {"Index", "Active catalog", "Contents", "Records formerly in this file"}

STATUSES = {"active", "superseded", "retired", "legacy-unmigrated"}
KEY_RE = re.compile(r"^[a-z0-9]+(\.[a-z0-9-]+)+$")
_HEADING_RE = re.compile(r"^##\s+(.*\S)\s*$")
_META_FIELD_RE = re.compile(r"`([a-z-]+):\s*([^`]*)`")


@dataclass
class Record:
    heading: str
    source: Path
    lineno: int
    key: str | None = None
    status: str = "legacy-unmigrated"
    since: str | None = None
    supersedes: str | None = None
    superseded_by: str | None = None
    rule: str | None = None
    signals: str | None = None
    mechanics: str | None = None
    # Optional (ersatztv#603). `stale_after` is an absolute date, no TTL — the record is due for
    # re-confirmation once today >= it. `sources` cites the evidence the record rests on.
    stale_after: str | None = None
    sources: str | None = None
    body: str = ""


def _parse_meta_line(line: str) -> dict[str, str]:
    return {m.group(1): m.group(2).strip() for m in _META_FIELD_RE.finditer(line)}


def parse_text(text: str, source: Path) -> list[Record]:
    if has_frontmatter(text):
        return _parse_frontmatter(text, source)
    lines = text.splitlines()
    records: list[Record] = []
    i = 0
    while i < len(lines):
        m = _HEADING_RE.match(lines[i])
        if not m:
            i += 1
            continue
        rec = Record(heading=m.group(1), source=source, lineno=i + 1)
        j = i + 1
        body_lines: list[str] = []
        while j < len(lines) and not _HEADING_RE.match(lines[j]):
            body_lines.append(lines[j])
            j += 1
        for k, bl in enumerate(body_lines):
            if not bl.strip():
                continue
            meta = _parse_meta_line(bl)
            if "key" in meta and "status" in meta:
                rec.key = meta.get("key") or None
                rec.status = meta.get("status") or "legacy-unmigrated"
                rec.since = meta.get("since") or None
                rec.supersedes = meta.get("supersedes") or None
                rec.superseded_by = meta.get("superseded-by") or None
                # NOT `or None`: a present-but-empty `stale-after:` must stay "" so the validator can
                # tell it from an absent field and reject it. Collapsing the two would let a blank or
                # truncated value through as "absent" — a field that silently never fires, which is
                # exactly what the blocking format check exists to prevent.
                rec.stale_after = meta.get("stale-after")
                # The metadata block is contiguous: scan only until the first blank line, so a
                # bolded **Rule:** appearing later inside rationale prose can't overwrite the real one.
                for bl2 in body_lines[k + 1 :]:
                    if not bl2.strip():
                        break
                    if bl2.startswith("**Rule:**"):
                        rec.rule = bl2[len("**Rule:**") :].strip()
                    elif bl2.startswith("**Signals:**"):
                        rec.signals = bl2[len("**Signals:**") :].strip()
                    elif bl2.startswith("**Mechanics:**"):
                        rec.mechanics = bl2[len("**Mechanics:**") :].strip()
                    elif bl2.startswith("**Sources:**"):
                        rec.sources = bl2[len("**Sources:**") :].strip()
            break
        rec.body = "\n".join(body_lines).strip()
        records.append(rec)
        i = j
    return records


# --- YAML-frontmatter form (ersatztv#610): one record per file ------------------------------
#
# The corpus is migrating from "many `##` records per file, metadata on a backtick line" to "one
# record per file, metadata in YAML frontmatter". BOTH forms parse here, dispatching on a leading
# `---`. That is what lets the migration validate itself: the body-diff guard parses the OLD form
# at the merge-base and the NEW form at head, both yielding Records keyed on `key`, so no one-time
# bypass is needed for the commit that moves 166 records.
#
# Frontmatter keys are the SAME vocabulary as the backtick line (`superseded-by`, `stale-after`),
# so the on-disk names don't shift under anyone mid-migration.

_FM_DELIM = "---"
_FM_TO_FIELD = {
    "key": "key",
    "status": "status",
    "since": "since",
    "supersedes": "supersedes",
    "superseded-by": "superseded_by",
    "stale-after": "stale_after",
    "rule": "rule",
    "signals": "signals",
    "mechanics": "mechanics",
    "sources": "sources",
}


def has_frontmatter(text: str) -> bool:
    """True if `text` opens with a YAML frontmatter fence. Must be the very first line."""
    lines = text.splitlines()
    return bool(lines) and lines[0].rstrip() == _FM_DELIM


def _unquote(raw: str) -> str:
    """Undo the quoting `yaml.safe_dump` applies. Single-line scalars only.

    The frontmatter this reads is MACHINE-GENERATED by `migrate_decisions_split.render_record`
    with `default_flow_style=False` and `width=10**9`, so every value is a single-line scalar,
    either plain or quoted. That narrowness is what makes a dependency-free reader safe.
    """
    raw = raw.strip()
    if len(raw) >= 2 and raw[0] == raw[-1] == "'":
        return raw[1:-1].replace("''", "'")
    if len(raw) >= 2 and raw[0] == raw[-1] == '"':
        body = raw[1:-1]
        out, i = [], 0
        while i < len(body):
            if body[i] == "\\" and i + 1 < len(body):
                nxt = body[i + 1]
                out.append({"n": "\n", "t": "\t", '"': '"', "\\": "\\"}.get(nxt, nxt))
                i += 2
            else:
                out.append(body[i])
                i += 1
        return "".join(out)
    return raw


def _read_frontmatter(block: str) -> dict[str, str] | None:
    """Parse the frontmatter block WITHOUT PyYAML. None if it doesn't look like our format.

    Deliberately dependency-free: this read path runs in CI's `decisions lifecycle` job, in the
    Husky pre-commit hook, and on every contributor's machine. Requiring PyYAML there made the
    validator crash with ModuleNotFoundError on a runner that installs nothing — the split's own
    records became unparseable. Writing still uses PyYAML (`migrate_decisions_split`), because that
    is a one-shot run by a human who can install it.

    `test_frontmatter_reader_matches_pyyaml` asserts this agrees with PyYAML on every real record.
    """
    meta: dict[str, str] = {}
    for line in block.splitlines():
        if not line.strip() or line.lstrip().startswith("#"):
            continue
        if line[:1].isspace():
            return None  # nested/continued structure — not the flat form we emit
        k, sep, v = line.partition(":")
        if not sep:
            return None
        meta[k.strip()] = _unquote(v)
    return meta


def _parse_frontmatter(text: str, source: Path) -> list[Record]:
    lines = text.splitlines()
    end = None
    for i, ln in enumerate(lines[1:], start=1):
        if ln.rstrip() == _FM_DELIM:
            end = i
            break
    if end is None:
        return []  # unterminated frontmatter — malformed; validator reports the missing record
    meta = _read_frontmatter("\n".join(lines[1:end]))
    if not meta:
        return []

    rec = Record(heading=str(meta.get("title") or "").strip(), source=source, lineno=1)
    for fm_key, field in _FM_TO_FIELD.items():
        if fm_key not in meta:
            continue
        # "" is preserved rather than collapsed to None — the empty-vs-absent distinction
        # `stale-after` depends on (#603).
        setattr(rec, field, meta[fm_key].strip())
    if not rec.status:
        rec.status = "legacy-unmigrated"
    rec.body = "\n".join(lines[end + 1 :]).strip()
    return [rec]


def parse_file(path: Path) -> list[Record]:
    return parse_text(path.read_text(encoding="utf-8"), path)


def metadata_line_count(rec: Record) -> int:
    """Count how many lines in `rec.body` look like a metadata line (a line starting with
    `` `key: `` after stripping). A well-formed record has exactly 1; more indicates a
    duplicate/stacked metadata block left behind by a botched migration."""
    count = 0
    for line in rec.body.splitlines():
        if line.strip().startswith("`key:"):
            count += 1
    return count


_NON_DECISION_FILES = {"README.md", "migration-map.md", "retrieval-eval.md"}


def active_files() -> list[Path]:
    """Every file holding ACTIVE records, in both the legacy and split layouts.

    Legacy: `docs/decisions.md` + the flat topic files. Split (#610): `docs/decisions/records/**`.
    Both are listed so the two layouts can coexist during the migration and so a ref on either side
    of it parses correctly.
    """
    files = [DECISIONS_MD]
    files += sorted(p for p in TOPIC_DIR.glob("*.md") if p.name not in _NON_DECISION_FILES)
    files += sorted(RECORDS_DIR.rglob("*.md"))
    return files


def all_active_records() -> list[Record]:
    recs: list[Record] = []
    for f in active_files():
        if f.exists():
            recs += parse_file(f)
    return recs
