"""Single source of truth for scope-provenance grammar.

Every requirement a phase emits must declare where it came from. Shared by
validators/validate-run.py and validators/validate_fanout.py so the grammar
cannot drift between the planning report and the fan-out packets.
"""
from __future__ import annotations

import re
from dataclasses import dataclass
from pathlib import Path
from typing import Literal

# Items okstra's own phase contracts mandate — they have no brief line to cite.
# Keep this narrow: a wide allowlist turns into a bypass for invented scope.
CONTRACT_RULES = frozenset(
    {
        "decision-record-step",  # implementation-planning.md: decisionDrafts materialization
        "glossary-step",         # implementation-planning.md: glossary proposals
    }
)

# Mirrors the report schema's requirement id pattern
# (schemas/final-report-v2.0.schema.json: `^R-\d{3,}$`).
_REQ_ID_RE = re.compile(r"^R-\d{3,}$")
_BRIEF_RE = re.compile(r"^brief:\s*(?P<heading>.+?)\s*$")
_DERIVED_RE = re.compile(r"^derived:\s*(?P<parent>\S+)\s*[—-]\s*(?P<reason>.+?)\s*$")
_CONTRACT_RE = re.compile(r"^contract:\s*(?P<rule>.+?)\s*$")
_BRIEF_HEADING_RE = re.compile(r"^(#{1,6}\s+.+?)\s*$")
_FENCE_RE = re.compile(r"^\s*(`{3,}|~{3,})")

_END_STATE_ID_RE = re.compile(r"^(?:EB|PB|EO)-\d{3}$")
_END_STATE_HEADINGS = frozenset(
    {"Expected Behavior", "Preserved Behavior", "Expected Outcome"}
)
_END_STATE_BULLET_RE = re.compile(r"^-\s+(?P<id>(?:EB|PB|EO)-\d{3})\b")
# Only `## ` closes a section, mirroring validate-brief.py's section_body
# lookahead. `_BRIEF_HEADING_RE` above matches `#` through `######` and is for
# heading citations, which are checked against a different reader.
_SECTION_HEADING_RE = re.compile(r"^##\s+(?P<heading>.+?)\s*$")
_HTML_COMMENT_RE = re.compile(r"<!--.*?-->", re.DOTALL)


def normalize_heading(text: str) -> str:
    """Heading text stripped of its `#` markers and surrounding whitespace.

    Applied to both sides of the citation comparison so `brief:Acceptance
    Criteria` and `brief:## Acceptance Criteria` both name the same heading.
    The comparison stays exact after this — a heading the brief lacks must
    still fail.

    Heading citations are the legacy form, reachable only for briefs authored
    before the end-state sections existed (`brief_end_state_ids` returns an
    empty set for those). A brief that pins ids takes `brief:EB-001` instead,
    and `brief_citation_problem` rejects a heading there.
    """
    return " ".join(text.lstrip("#").split())


def brief_headings(brief_path: Path) -> set[str]:
    """Markdown headings present in the brief, normalized for comparison.

    An unreadable brief yields an empty set so callers degrade to skipping
    heading verification instead of failing every brief-sourced citation.

    Fenced regions are skipped: a shell comment inside a ```bash block starts
    with `#` too, and admitting it would let a requirement cite a "heading" the
    reporter never wrote.
    """
    try:
        text = Path(brief_path).read_text(encoding="utf-8")
    except OSError:
        return set()

    headings: set[str] = set()
    fence: str | None = None
    for line in text.splitlines():
        if m := _FENCE_RE.match(line):
            marker = m.group(1)
            if fence is None:
                fence = marker[0]
            elif marker[0] == fence:
                fence = None
            continue
        if fence is not None:
            continue
        if m := _BRIEF_HEADING_RE.match(line):
            headings.add(normalize_heading(m.group(1)))
    return headings


def brief_end_state_id_sequence(brief_path: Path) -> tuple[str, ...]:
    """End-state ids the brief declares, preserving section and item order.

    The reading rules mirror validators/validate-brief.py exactly, because that
    file checks the very same lines for their format and the two readers must
    not disagree about what the brief declared. Concretely: only `## ` closes a
    section (its `section_body`), fenced regions are NOT skipped (its
    `meaningful_bullets` knows nothing about fences), and HTML comments are
    removed (it strips them file-wide before any check).

    This is the one function here that does not follow brief_headings' fence
    discipline, and the asymmetry is deliberate. A heading citation and an
    end-state item are validated by different code; each reader has to match its
    own checker. Diverging the other way — skipping a fenced item the checker
    accepted — is the worse failure: the brief passes, the id is absent from the
    declared set, and no phase is ever asked to account for it.

    An empty sequence means the brief predates the end-state sections, which is
    what the downstream conditional gate keys on: a legacy brief keeps the
    legacy path instead of being wedged by a contract it was never written
    against.
    """
    try:
        text = _HTML_COMMENT_RE.sub("", Path(brief_path).read_text(encoding="utf-8"))
    except OSError:
        return ()

    ids: list[str] = []
    section = ""
    for line in text.splitlines():
        if m := _SECTION_HEADING_RE.match(line):
            heading = normalize_heading(m.group("heading"))
            section = heading if heading in _END_STATE_HEADINGS else ""
            continue
        if not section:
            continue
        if m := _END_STATE_BULLET_RE.match(line.strip()):
            ids.append(m.group("id"))
    return tuple(ids)


def brief_end_state_ids(brief_path: Path) -> set[str]:
    """End-state ids the brief declares, across all three sections."""
    return set(brief_end_state_id_sequence(brief_path))


@dataclass(frozen=True)
class SourceRef:
    kind: Literal["brief", "derived", "contract", "invalid"]
    value: str = ""
    reason: str = ""

    @property
    def is_end_state_id(self) -> bool:
        """True when a `brief:` citation names an end-state id rather than a heading.

        Heading citations survive only for briefs authored before the end-state
        sections existed; the id form is what makes a citation checkable against
        a specific reporter line instead of a heading every brief carries.
        """
        return self.kind == "brief" and bool(_END_STATE_ID_RE.match(self.value))


def parse_source(raw: str) -> SourceRef:
    text = (raw or "").strip()
    if m := _DERIVED_RE.match(text):
        parent = m.group("parent")
        if not _REQ_ID_RE.match(parent):
            return SourceRef("invalid")
        return SourceRef("derived", parent, m.group("reason"))
    if m := _BRIEF_RE.match(text):
        return SourceRef("brief", m.group("heading"))
    if m := _CONTRACT_RE.match(text):
        rule = m.group("rule")
        if rule not in CONTRACT_RULES:
            return SourceRef("invalid")
        return SourceRef("contract", rule)
    return SourceRef("invalid")


def brief_citation_problem(
    ref: SourceRef, headings: set[str], end_state: set[str]
) -> str | None:
    """Why a `brief:` citation is inadmissible, or None when it is fine.

    Shared by validators/validate-run.py (requirementCoverage) and
    validators/validate_fanout.py (fan-out packets): both decide admissibility
    the same way and differ only in how they word the failure, so the decision
    lives here and the wording stays with the caller.

    A brief that pins ids refuses heading citations outright: every brief
    carries the same generic headings, so a heading cannot say WHICH reporter
    line the item came from — that is the loophole the id form closes.
    """
    if ref.kind != "brief":
        return None
    if end_state:
        if not ref.is_end_state_id:
            return (
                f"cites `brief:{ref.value}`, but this brief pins end-state ids. "
                "Cite the end-state id the item came from (`brief:EB-001`)"
            )
        if ref.value not in end_state:
            return f"cites `brief:{ref.value}` which the brief does not declare"
        return None
    if headings and normalize_heading(ref.value) not in headings:
        return f"cites brief heading `{ref.value}` which does not exist in the brief"
    return None


def resolve_chain(rows: dict[str, SourceRef]) -> dict[str, str]:
    """Walk each row's derivation chain to its terminator.

    Returns id -> "ok" or a one-line reason the chain is not admissible.
    """
    result: dict[str, str] = {}
    for rid in rows:
        seen: list[str] = []
        cursor = rid
        while True:
            if cursor in seen:
                result[rid] = f"derivation cycle: {' -> '.join(seen + [cursor])}"
                break
            seen.append(cursor)
            ref = rows.get(cursor)
            if ref is None:
                result[rid] = f"derives from `{cursor}` which is not a row in this table"
                break
            if ref.kind in ("brief", "contract"):
                result[rid] = "ok"
                break
            if ref.kind == "derived":
                cursor = ref.value
                continue
            result[rid] = (
                f"unrecognized source on `{cursor}` — must be one of "
                "`brief:EB-001` (or the legacy `brief:<heading>`), "
                "`derived:R-NNN — <reason>`, `contract:<rule>`"
            )
            break
    return result
