#!/usr/bin/env python3
"""Validate brief markdown files produced by the okstra-brief-gen skill.

Checks performed per brief file:

1. YAML frontmatter exists on line 1 with required keys.
2. brief-id matches the filename stem.
3. depth equals the number of `sub/` segments in the path (relative to the
   `briefs/` root), and the task-group directory segment equals the
   slugified frontmatter `task-group` (lowercase, non-alphanumeric → `-`).
4. Every Open Questions row starts with one of the five signal prefixes
   (general | terminology | intent-check | conversion-block | adr-candidate).
   `adr-candidate:` targets okstra-internal
   `<PROJECT_ROOT>/.okstra/decisions/`, not external `docs/adr/`.
5. Every Augmentation entry (inline `> augmented: <label>` blockquotes and
   `Augmentation` section bullets) carries one of the four labels
   (evidence-link | format-conversion | terminology-mapping | intent-inference).
   Both documented forms are accepted: `label: ...` and `label — ...`.
6. Every `intent-inference` augmentation has a corresponding
   `intent-check:` row in Open Questions (auto-mirroring rule).
7. Every `terminology-mapping` augmentation (excluding Step 4.5 outcome
   markers `applied glossary:` / `skipped glossary:`) has a corresponding
   `terminology:` row in Open Questions.
8. `parent-id` chain: at depth 0 the value MUST be the literal `self`;
   at depth ≥ 1 it MUST NOT be `self` and MUST differ from the brief's
   own `brief-id`.
9. `reporter-confirmations` consistency: when `complete`, every
   `intent-check:` and `conversion-block:` row in Open Questions MUST
   carry a `[CONFIRMED YYYY-MM-DD → RC-N]` marker; when `partial`, at
   least one such row MUST carry the marker (use `skipped` when nothing
   was answered).
10. `scope` is one of {reporter-input, codebase} (absent ⇒ reporter-input).
11. codebase-scan variant (`scope: codebase`): the Scan Scope section is
    non-empty and Priority Lenses lists 1–4 values from the lens whitelist
    (`scripts/okstra_ctl/improvement_lenses.py` SSOT).
12. When present, `Related Task Graph` is a markdown table with the canonical
    columns and relation/direction values from the okstra-brief-gen contract.
13. The requirement/objective section `## Desired Outcome` (required in every
    brief variant) exists and its body is not blank. `_(none)_` stays valid.
14. The end-state sections `## Expected Behavior` (EB-NNN) / `## Preserved
    Behavior` (PB-NNN) / `## Expected Outcome` (EO-NNN): each item carries a
    3-digit id and an `— verify: <how>` observation method, EB/EO are rejected
    in the codebase-scan variant, and `## External Gates` — the destination for
    an item with no observation method — is required in every variant.

Exit code 0 on PASS, 1 on FAIL.
"""

from __future__ import annotations

import argparse
import re
import sys
from pathlib import Path
from typing import Iterable

# scripts/ (repo) or python/ (installed under ~/.okstra/lib) is not a package;
# insert whichever exists so okstra_ctl is importable for the lens whitelist.
_VALIDATORS_DIR = Path(__file__).resolve().parent
for _ssot_dir in (_VALIDATORS_DIR.parent / "scripts", _VALIDATORS_DIR.parent / "python"):
    if _ssot_dir.is_dir() and str(_ssot_dir) not in sys.path:
        sys.path.insert(0, str(_ssot_dir))

from okstra_ctl.improvement_lenses import (
    LENSES,
    MAX_PRIORITY_LENSES,
    MIN_PRIORITY_LENSES,
)
from okstra_ctl.ids import slugify_task_segment

REQUIRED_FRONTMATTER_KEYS = {
    "type",
    "brief-id",
    "parent-id",
    "ticket-id",
    "source-type",
    "task-group",
    "depth",
    "created",
    "generator",
    "reporter-confirmations",
}

OPEN_QUESTIONS_PREFIXES = {
    "general:",
    "terminology:",
    "intent-check:",
    "conversion-block:",
    "adr-candidate:",
}

AUGMENTATION_LABELS = {
    "evidence-link",
    "format-conversion",
    "terminology-mapping",
    "intent-inference",
}

REPORTER_CONFIRMATION_VALUES = {"complete", "partial", "pending", "skipped"}

SCOPE_VALUES = {"reporter-input", "codebase"}

TASK_GRAPH_HEADER = ["From", "Relation", "To", "Direction", "Source", "Impact"]

DIRECTED_TASK_RELATIONS = {
    "parent-of",
    "child-of",
    "depends-on",
    "blocks",
    "blocked-by",
    "follow-up-of",
    "split-from",
}

UNDIRECTED_TASK_RELATIONS = {"duplicates", "related-to"}

TASK_RELATIONS = DIRECTED_TASK_RELATIONS | UNDIRECTED_TASK_RELATIONS

TASK_RELATION_DIRECTIONS = {"directed", "undirected"}

_HTML_COMMENT_RE = re.compile(r"<!--.*?-->", re.DOTALL)


def strip_html_comments(text: str) -> str:
    """Remove `<!-- ... -->` blocks before parsing.

    The brief template carries author-guidance comments whose example rows
    (`intent-check: <…>`, `conversion-block: <…>`, `terminology-mapping: <…>`)
    are NOT real entries. Without this, every `- ` collector below
    (`open_questions_rows`, `augmentation_entries`, `meaningful_bullets`) would
    count them as data and raise false `reporter-confirmations` / `terminology`
    failures. Comments are never brief content, so stripping them once here is
    the single root fix. Frontmatter uses `#` comments only, so this is a no-op
    for the YAML block.
    """
    return _HTML_COMMENT_RE.sub("", text)


def strip_yaml_comment(line: str) -> str:
    """Drop a YAML inline comment, keeping `#` that is part of a value.

    Per YAML, `#` starts a comment only at line start or after whitespace;
    a `#` glued to a non-space char (e.g. `ticket-id: PROJ-42#thread`) is data.
    """
    for i, ch in enumerate(line):
        if ch == "#" and (i == 0 or line[i - 1].isspace()):
            return line[:i]
    return line


def parse_frontmatter(text: str) -> tuple[dict[str, str], int]:
    """Return (frontmatter dict, line after closing `---`)."""
    lines = text.splitlines()
    if not lines or lines[0].strip() != "---":
        raise ValueError("missing opening frontmatter delimiter on line 1")
    out: dict[str, str] = {}
    for idx in range(1, len(lines)):
        line = lines[idx]
        if line.strip() == "---":
            return out, idx + 1
        bare = strip_yaml_comment(line).strip()
        if not bare:
            continue
        if ":" not in bare:
            raise ValueError(f"frontmatter line without colon: {line!r}")
        key, _, value = bare.partition(":")
        out[key.strip()] = value.strip()
    raise ValueError("missing closing frontmatter delimiter")


def section_body(text: str, heading: str) -> str:
    """Return the body lines between `## <heading>` and the next `## ` heading."""
    pattern = re.compile(
        r"^##\s+" + re.escape(heading) + r"\s*$(.*?)(?=^##\s|\Z)",
        re.MULTILINE | re.DOTALL,
    )
    match = pattern.search(text)
    if not match:
        return ""
    return match.group(1)


def is_placeholder(line: str) -> bool:
    bare = line.strip().lstrip("-").strip()
    return bare in {"_(none)_", "_(none — pending or skipped)_", ""}


def is_template_example(line: str) -> bool:
    """Lines that are template scaffolding (placeholder/example), not real entries."""
    bare = line.strip().lstrip("-").strip()
    return bare.startswith("<") and bare.endswith(">")


def open_questions_rows(text: str) -> list[str]:
    body = section_body(text, "Open Questions")
    rows: list[str] = []
    for line in body.splitlines():
        stripped = line.strip()
        if not stripped.startswith("- "):
            continue
        content = stripped[2:].strip()
        if is_placeholder(content) or is_template_example(content):
            continue
        # strip backticks if the row body is wrapped in `…`
        content = content.strip("`")
        rows.append(content)
    return rows


def augmentation_entries(text: str) -> list[str]:
    """Bullets under the `## Augmentation` section (entries that look like real data)."""
    body = section_body(text, "Augmentation")
    entries: list[str] = []
    for line in body.splitlines():
        stripped = line.strip()
        if not stripped.startswith("- "):
            continue
        content = stripped[2:].strip()
        if is_placeholder(content) or is_template_example(content):
            continue
        # strip backticks
        content = content.strip("`")
        entries.append(content)
    return entries


def inline_augmented_blockquotes(text: str) -> list[str]:
    """Lines starting with `> augmented:`."""
    out: list[str] = []
    for line in text.splitlines():
        stripped = line.strip()
        if stripped.startswith("> augmented:"):
            payload = stripped[len("> augmented:"):].strip()
            if payload.startswith("<") and payload.endswith(">"):
                # template scaffold, e.g. `> augmented: <label> — <interpretation>`
                continue
            out.append(payload)
    return out


def parse_augmentation_label(entry: str) -> tuple[str | None, str]:
    """Return (label, payload) for documented augmentation forms."""
    stripped = entry.strip()
    for label in AUGMENTATION_LABELS:
        if stripped == label:
            return label, ""
        for sep in (":", " — ", " - "):
            prefix = f"{label}{sep}"
            if stripped.startswith(prefix):
                return label, stripped[len(prefix):].strip()
    return None, stripped


def meaningful_bullets(text: str, heading: str) -> list[str]:
    """Real `- ` bullets under a heading, excluding placeholders/template scaffold."""
    body = section_body(text, heading)
    out: list[str] = []
    for line in body.splitlines():
        stripped = line.strip()
        if not stripped.startswith("- "):
            continue
        content = stripped[2:].strip()
        if is_placeholder(content) or is_template_example(content):
            continue
        out.append(content.strip("`"))
    return out


def priority_lens_values(text: str) -> list[str]:
    """Lens tokens from the `## Priority Lenses` bullets (`- <lens>: <rationale>`)."""
    return [
        bullet.split(":", 1)[0].strip()
        for bullet in meaningful_bullets(text, "Priority Lenses")
    ]


def check_codebase_scope(text: str, errors: list[str]) -> None:
    """codebase-scan variant: Scan Scope non-empty, Priority Lenses ⊆ whitelist."""
    if not meaningful_bullets(text, "Scan Scope"):
        errors.append("scope is 'codebase' but the Scan Scope section has no entries")
    lenses = priority_lens_values(text)
    if not (MIN_PRIORITY_LENSES <= len(lenses) <= MAX_PRIORITY_LENSES):
        errors.append(
            f"Priority Lenses must list {MIN_PRIORITY_LENSES}–{MAX_PRIORITY_LENSES} "
            f"lens(es), found {len(lenses)}"
        )
    unknown = [lens for lens in lenses if lens not in LENSES]
    if unknown:
        errors.append(
            f"Priority Lenses contains values outside the lens whitelist: {unknown} "
            f"(allowed: {sorted(LENSES)})"
        )


def parse_markdown_table_row(line: str) -> list[str]:
    """Return markdown table cells without the outer pipes."""
    stripped = line.strip()
    if not stripped.startswith("|") or not stripped.endswith("|"):
        return []
    return [cell.strip() for cell in stripped.strip("|").split("|")]


def is_markdown_table_separator(cells: list[str]) -> bool:
    return bool(cells) and all(re.fullmatch(r":?-{3,}:?", cell) for cell in cells)


def check_related_task_graph(text: str, errors: list[str]) -> None:
    body = section_body(text, "Related Task Graph")
    if not body.strip():
        return

    lines = [line.strip() for line in body.splitlines() if line.strip()]
    if all(is_placeholder(line) or is_template_example(line) for line in lines):
        return

    table_lines = [line for line in lines if line.startswith("|")]
    if not table_lines:
        errors.append(
            "Related Task Graph must be a markdown table or the literal _(none)_"
        )
        return

    if len(table_lines) < 2:
        errors.append("Related Task Graph table must include a header and separator")
        return

    header = parse_markdown_table_row(table_lines[0])
    if header != TASK_GRAPH_HEADER:
        errors.append(
            "Related Task Graph header must be "
            f"{TASK_GRAPH_HEADER}, got {header}"
        )
        return

    separator = parse_markdown_table_row(table_lines[1])
    if len(separator) != len(TASK_GRAPH_HEADER) or not is_markdown_table_separator(
        separator
    ):
        errors.append("Related Task Graph separator row is malformed")
        return

    for row_number, line in enumerate(table_lines[2:], start=1):
        cells = parse_markdown_table_row(line)
        if len(cells) != len(TASK_GRAPH_HEADER):
            errors.append(
                f"Related Task Graph row {row_number} has {len(cells)} cells; "
                f"expected {len(TASK_GRAPH_HEADER)}"
            )
            continue

        row = dict(zip(TASK_GRAPH_HEADER, cells))
        relation = row["Relation"]
        direction = row["Direction"]
        source = row["Source"]
        impact = row["Impact"]
        from_ref = row["From"]
        to_ref = row["To"]

        for key in TASK_GRAPH_HEADER:
            if not row[key]:
                errors.append(f"Related Task Graph row {row_number} has empty {key}")

        if from_ref and to_ref and from_ref == to_ref:
            errors.append(
                f"Related Task Graph row {row_number} links {from_ref!r} to itself"
            )

        if relation not in TASK_RELATIONS:
            errors.append(
                f"Related Task Graph row {row_number} has unknown relation "
                f"{relation!r}; allowed: {sorted(TASK_RELATIONS)}"
            )

        if direction not in TASK_RELATION_DIRECTIONS:
            errors.append(
                f"Related Task Graph row {row_number} has unknown Direction "
                f"{direction!r}; allowed: {sorted(TASK_RELATION_DIRECTIONS)}"
            )

        if relation in DIRECTED_TASK_RELATIONS and direction != "directed":
            errors.append(
                f"Related Task Graph row {row_number} relation {relation!r} "
                "must use Direction=directed"
            )
        elif relation in UNDIRECTED_TASK_RELATIONS and direction != "undirected":
            errors.append(
                f"Related Task Graph row {row_number} relation {relation!r} "
                "must use Direction=undirected"
            )

        if source in {"-", "_(none)_", "none"}:
            errors.append(
                f"Related Task Graph row {row_number} must cite a source for the edge"
            )
        if impact in {"-", "_(none)_", "none"}:
            errors.append(
                f"Related Task Graph row {row_number} must state the edge impact"
            )


REQUIREMENT_SECTION = "Desired Outcome"


def has_section_heading(text: str, heading: str) -> bool:
    """True when a `## <heading>` line exists (distinct from an empty body)."""
    pattern = re.compile(r"^##\s+" + re.escape(heading) + r"\s*$", re.MULTILINE)
    return pattern.search(text) is not None


def check_requirement_section(text: str, errors: list[str]) -> None:
    """The requirement/objective section must exist and carry a non-blank body.

    `## Desired Outcome` is the "shape of success" required by every brief
    variant (okstra-brief-gen SKILL.md Step 5 §"Required sections by variant").
    Without this check a brief that drops the heading — or wipes its body —
    still passes, letting implementation-planning report 100% Requirement
    Coverage against an empty objective. `_(none)_` stays valid: it is the
    contract's explicit placeholder for a deliberately-empty gap (SKILL.md
    Step 4 stop conditions), not a missing requirement.
    """
    if not has_section_heading(text, REQUIREMENT_SECTION):
        errors.append(
            f"required section '## {REQUIREMENT_SECTION}' is missing "
            "(every brief variant must state the shape of success)"
        )
        return
    if not section_body(text, REQUIREMENT_SECTION).strip():
        errors.append(
            f"required section '## {REQUIREMENT_SECTION}' has an empty body "
            "(use _(none)_ only for a deliberately-empty gap)"
        )


# The brief pins the end state once; every later phase maps to it by id rather
# than restating a goal of its own. Splitting behavior from outcome is what lets
# a refactor say "nothing observable changes" without leaving the upper bound
# blank — `Preserved Behavior` carries the bound that `Expected Behavior` can't.
_END_STATE_SECTIONS = (
    ("Expected Behavior", "EB"),
    ("Preserved Behavior", "PB"),
    ("Expected Outcome", "EO"),
)
# The destination for a must-pass point nobody can hand an observation method.
# It is enforced here rather than in ALWAYS_REQUIRED_SECTIONS so the failure can
# still say WHY the section exists: a generic "required section missing" gives
# the reporter no way to tell a gate apart from an end state.
_GATE_SECTION = "External Gates"
_END_STATE_ID_RE = re.compile(r"^(?P<id>(?:EB|PB|EO)-\d{3})\b")
# Mirrors the `[—-]` tolerance in scripts/okstra_ctl/scope_provenance.py so a
# reporter who cannot type an em dash is not silently rejected.
_VERIFY_RE = re.compile(r"[—-]{1,2}\s*verify:\s*(?P<how>\S.*)$")
_SCAN_ONLY_PREFIXES = frozenset({"EB", "EO"})


def _has_bullet(body: str) -> bool:
    return any(line.strip().startswith("- ") for line in body.splitlines())


def _declares_nothing(body: str) -> bool:
    """True when the body is the contract's explicit empty marker and nothing else.

    `_(none)_` is written bare in every other brief section — `is_placeholder`
    accepts both the bare and the `- ` form, and the template tells the reporter
    to "use _(none)_ if none". Demanding a dash here alone would reject the house
    form, and the message that fires would tell the author to write the very
    thing they wrote.
    """
    lines = [line.strip() for line in body.splitlines() if line.strip()]
    return bool(lines) and all(is_placeholder(line) for line in lines)


def _check_gate_section(text: str, errors: list[str]) -> None:
    if not has_section_heading(text, _GATE_SECTION):
        errors.append(
            f"required section '## {_GATE_SECTION}' is missing — it is where a "
            "must-pass point that a person or live infrastructure owns goes. "
            "Without it, an item okstra cannot satisfy has nowhere to land but "
            "the end-state sections, and the plan is then forced to build a "
            "stage for work no phase can do"
        )
        return
    if not section_body(text, _GATE_SECTION).strip():
        errors.append(
            f"required section '## {_GATE_SECTION}' has an empty body "
            "(use _(none)_ only for a deliberately-empty section)"
        )


def _check_end_state_items(
    text: str, heading: str, prefix: str, seen: set[str], errors: list[str]
) -> None:
    for bullet in meaningful_bullets(text, heading):
        match = _END_STATE_ID_RE.match(bullet)
        if match is None or not match.group("id").startswith(f"{prefix}-"):
            errors.append(
                f"'## {heading}' item {bullet!r} must start with a `{prefix}-NNN` id "
                "(3 digits) — later phases map to the brief by id, not by heading"
            )
            continue
        item_id = match.group("id")
        if item_id in seen:
            errors.append(f"duplicate end-state id {item_id!r}")
        seen.add(item_id)
        if not _VERIFY_RE.search(bullet):
            errors.append(
                f"end-state item {item_id!r} has no observation method — append "
                "`— verify: <command or observation point>`. An item nobody can "
                "observe is not an end state. It has two destinations, both of "
                f"which keep it in the brief: '## {_GATE_SECTION}' when a person "
                "or live infrastructure owns it, or '## Open Questions' as a "
                "`general:` row when the observation method is what is still "
                "unknown. Deleting it is not one of them."
            )


def _check_one_end_state_section(
    text: str, heading: str, prefix: str, scope: str, seen: set[str], errors: list[str]
) -> None:
    """One end-state section: whether it may exist here, its body shape, its items.

    A codebase-scan brief is authored before anything has been found, so it can
    only pin what must NOT change. Declaring an expected behavior there would be
    an invented requirement, which is why EB/EO are rejected rather than treated
    as optional — and why `_(none)_` is rejected for the one section that
    survives there: it is the whole upper bound of that variant.
    """
    if scope == "codebase" and prefix in _SCAN_ONLY_PREFIXES:
        if has_section_heading(text, heading):
            errors.append(
                f"section '## {heading}' is not allowed in a codebase-scan brief "
                "(the scan has not run yet, so its outcome cannot be declared) — "
                "use '## Preserved Behavior' for what must not change"
            )
        return
    if not has_section_heading(text, heading):
        errors.append(
            f"required section '## {heading}' is missing (the brief pins the "
            "end state that every later phase maps to)"
        )
        return
    body = section_body(text, heading)
    if not body.strip():
        errors.append(
            f"required section '## {heading}' has an empty body "
            "(use _(none)_ only for a deliberately-empty section)"
        )
        return
    if _declares_nothing(body):
        # In a scan this is the only section left, so an empty marker here is
        # not "deliberately empty" — it is a brief with no upper bound at all,
        # which is what improvement-discovery reads as its bound.
        if scope == "codebase":
            errors.append(
                f"'## {heading}' is _(none)_ in a codebase-scan brief. It is the "
                "only end-state section this variant carries, and "
                "improvement-discovery treats it as the bound on what a "
                "candidate may change — an empty one bounds nothing. State at "
                "least one behaviour no candidate from this scan may break"
            )
        return
    # Only `- ` lines are read as items, so prose here is silently dropped
    # rather than rejected — the reporter believes they pinned an end state
    # and every downstream phase sees a section that declared nothing.
    if not _has_bullet(body):
        errors.append(
            f"'## {heading}' has a body but no `- ` item — prose in this "
            "section is dropped, not read. Every end state is a "
            f"`- {prefix}-NNN <condition> — verify: <how>` bullet; write "
            "_(none)_ on its own line if the section is deliberately empty"
        )
        return
    _check_end_state_items(text, heading, prefix, seen, errors)


def _check_end_state_declared(text: str, errors: list[str]) -> None:
    """A reporter-input brief declares at least one observable end state."""
    if meaningful_bullets(text, "Expected Behavior") or meaningful_bullets(
        text, "Expected Outcome"
    ):
        return
    errors.append(
        "'## Expected Behavior' and '## Expected Outcome' are both empty — a brief "
        "must declare at least one observable end state. A pure bugfix may leave "
        "Expected Outcome as _(none)_ and a pure refactor may leave Expected "
        "Behavior as _(none)_, but not both. If every must-pass point you have is "
        f"owned by a person ('## {_GATE_SECTION}') or still lacks an observation "
        "method ('## Open Questions'), this brief is not ready to start a run — "
        "answer the open question first rather than emptying the end state"
    )


def check_end_state_sections(text: str, scope: str, errors: list[str]) -> None:
    """The brief's end state: expected behavior, preserved behavior, expected outcome.

    `## External Gates` is checked here too, in every variant: it is where an
    item with no observation method goes, so the admissible set and its overflow
    have to be required by the same check or the overflow silently disappears.
    """
    _check_gate_section(text, errors)
    seen: set[str] = set()
    for heading, prefix in _END_STATE_SECTIONS:
        _check_one_end_state_section(text, heading, prefix, scope, seen, errors)
    if scope != "codebase":
        _check_end_state_declared(text, errors)


# The variant-independent rows of the skill's "Required sections by variant"
# table (okstra-brief-gen SKILL.md). Desired Outcome and the end-state
# sections / External Gates have dedicated checks with richer messages;
# Scan Scope / Priority Lenses are codebase-only (check_codebase_scope);
# Source Material / Problem / Symptom are omitted by the codebase variant, so
# they are enforced only off it.
ALWAYS_REQUIRED_SECTIONS = (
    "Context",
    "Constraints",
    "Related Artifacts",
    "Related Task Graph",
    "Open Questions",
)
NON_CODEBASE_REQUIRED_SECTIONS = ("Source Material", "Problem / Symptom")


def check_variant_required_sections(
    text: str, scope: str, errors: list[str]
) -> None:
    """Presence + non-blank body for the table's remaining Required rows.

    Downstream phases read these sections by heading; a silently-absent
    heading reads as "the reporter had nothing to say" when the brief was
    simply authored off-template. `_(none)_` stays the explicit empty marker.
    """
    required = ALWAYS_REQUIRED_SECTIONS
    if scope != "codebase":
        required = required + NON_CODEBASE_REQUIRED_SECTIONS
    for heading in required:
        if not has_section_heading(text, heading):
            errors.append(
                f"required section '## {heading}' is missing "
                "(okstra-brief-gen SKILL.md 'Required sections by variant')"
            )
            continue
        if not section_body(text, heading).strip():
            errors.append(
                f"required section '## {heading}' has an empty body "
                "(use _(none)_ for a deliberately-empty section)"
            )


def check_reporter_confirmations(
    rc_status: str | None, reporter_rows: list[str], errors: list[str]
) -> None:
    """`complete` ⇒ every reporter row confirmed; `partial` ⇒ at least one."""
    confirmed = [row for row in reporter_rows if "[CONFIRMED" in row]
    if rc_status == "complete":
        unconfirmed = [row for row in reporter_rows if "[CONFIRMED" not in row]
        if unconfirmed:
            errors.append(
                f"reporter-confirmations is 'complete' but {len(unconfirmed)} "
                f"intent-check:/conversion-block: row(s) lack a [CONFIRMED …] "
                f"marker (e.g. {unconfirmed[0]!r})"
            )
    elif rc_status == "partial" and reporter_rows and not confirmed:
        errors.append(
            "reporter-confirmations is 'partial' but no intent-check:/"
            "conversion-block: row carries a [CONFIRMED …] marker "
            "(use 'skipped' when nothing was answered)"
        )


def validate_brief(path: Path, briefs_root: Path) -> list[str]:
    text = strip_html_comments(path.read_text(encoding="utf-8"))
    errors: list[str] = []

    # 1. frontmatter
    try:
        fm, _ = parse_frontmatter(text)
    except ValueError as exc:
        return [f"frontmatter: {exc}"]

    missing = REQUIRED_FRONTMATTER_KEYS - fm.keys()
    if missing:
        errors.append(f"frontmatter missing keys: {sorted(missing)}")

    if fm.get("type") != "brief":
        errors.append(f"frontmatter type must be 'brief', got {fm.get('type')!r}")

    if fm.get("generator") != "okstra-brief-gen":
        errors.append(
            f"frontmatter generator must be 'okstra-brief-gen', got {fm.get('generator')!r}"
        )

    if fm.get("reporter-confirmations") not in REPORTER_CONFIRMATION_VALUES:
        errors.append(
            "frontmatter reporter-confirmations must be one of "
            f"{sorted(REPORTER_CONFIRMATION_VALUES)}, got "
            f"{fm.get('reporter-confirmations')!r}"
        )

    scope = fm.get("scope", "reporter-input")
    if scope not in SCOPE_VALUES:
        errors.append(
            f"frontmatter scope must be one of {sorted(SCOPE_VALUES)} "
            f"(or absent ⇒ reporter-input), got {scope!r}"
        )
    if scope == "codebase":
        check_codebase_scope(text, errors)

    check_related_task_graph(text, errors)

    check_requirement_section(text, errors)
    check_end_state_sections(text, scope, errors)
    check_variant_required_sections(text, scope, errors)

    # 2. brief-id matches filename stem
    stem = path.stem
    if fm.get("brief-id") and fm["brief-id"] != stem:
        errors.append(
            f"brief-id {fm['brief-id']!r} does not match filename stem {stem!r}"
        )

    # 3. depth equals path's `sub/` nesting depth
    try:
        rel = path.relative_to(briefs_root)
        under_root = True
    except ValueError:
        rel = path
        under_root = False
    # path components after the task-group dir: any number of `sub` segments + filename
    parts = list(rel.parts)
    if len(parts) >= 2:
        nested = [p for p in parts[1:-1] if p == "sub"]
        expected_depth = len(nested)
        try:
            actual_depth = int(fm.get("depth", "0"))
        except ValueError:
            actual_depth = -1
        if actual_depth != expected_depth:
            errors.append(
                f"depth mismatch: path has {expected_depth} `sub/` segments, "
                f"frontmatter says depth={fm.get('depth')!r}"
            )

    # 3b. task-group directory segment must equal the slugified frontmatter value.
    # Catches case/format drift (e.g. dir `uploadFont` vs slug `uploadfont`) that
    # case-insensitive filesystems hide but case-sensitive CI breaks on.
    if under_root and len(parts) >= 2:
        expected_segment = slugify_task_segment(fm.get("task-group", ""))
        if expected_segment and parts[0] != expected_segment:
            errors.append(
                f"task-group directory segment {parts[0]!r} does not match the "
                f"slugified frontmatter task-group {expected_segment!r} "
                f"(frontmatter task-group={fm.get('task-group')!r}); rename the "
                f"directory or fix the frontmatter so both use the slug"
            )

    # 4. Open Questions prefixes
    oq_rows = open_questions_rows(text)
    intent_check_rows: list[str] = []
    terminology_rows: list[str] = []
    conversion_block_rows: list[str] = []
    for row in oq_rows:
        if not any(row.startswith(prefix) for prefix in OPEN_QUESTIONS_PREFIXES):
            errors.append(f"Open Questions row lacks a known prefix: {row!r}")
        if row.startswith("intent-check:"):
            intent_check_rows.append(row)
        elif row.startswith("terminology:"):
            terminology_rows.append(row)
        elif row.startswith("conversion-block:"):
            conversion_block_rows.append(row)

    # 5. Augmentation labels
    augmentation_lines: list[str] = []
    augmentation_lines.extend(augmentation_entries(text))
    augmentation_lines.extend(inline_augmented_blockquotes(text))
    intent_inference_count = 0
    terminology_mapping_count = 0
    for entry in augmentation_lines:
        label, payload = parse_augmentation_label(entry)
        if label not in AUGMENTATION_LABELS:
            errors.append(
                f"Augmentation entry lacks a known label: {entry!r} "
                f"(label parsed as {label!r})"
            )
            continue
        if label == "intent-inference":
            intent_inference_count += 1
        elif label == "terminology-mapping":
            # Step 4.5 outcome markers do not need a paired Open Questions row.
            if payload.startswith("applied glossary:") or payload.startswith(
                "skipped glossary:"
            ):
                continue
            terminology_mapping_count += 1

    # 6. auto-mirroring rule (intent-inference ↔ intent-check:)
    if intent_inference_count > len(intent_check_rows):
        errors.append(
            f"intent-inference augmentations present ({intent_inference_count}) "
            f"but only {len(intent_check_rows)} intent-check: row(s) in Open Questions"
        )

    # 7. dual-record rule (terminology-mapping ↔ terminology:)
    if terminology_mapping_count > 0 and not terminology_rows:
        errors.append(
            f"terminology-mapping augmentations present ({terminology_mapping_count}) "
            f"but no terminology: row(s) in Open Questions"
        )

    # 8. parent-id chain
    parent_id = fm.get("parent-id", "")
    brief_id = fm.get("brief-id", "")
    try:
        depth_value = int(fm.get("depth", "0"))
    except ValueError:
        depth_value = -1
    if depth_value == 0:
        if parent_id != "self":
            errors.append(
                f"parent-id for the root (depth 0) brief must be 'self', "
                f"got {parent_id!r}"
            )
    elif depth_value > 0:
        if parent_id == "self":
            errors.append(
                f"parent-id for a descendant (depth {depth_value}) brief must not be 'self'"
            )
        elif parent_id == brief_id:
            errors.append(
                f"parent-id for a descendant brief must differ from its own brief-id "
                f"({brief_id!r})"
            )

    # 9. reporter-confirmations consistency (complete / partial)
    check_reporter_confirmations(
        fm.get("reporter-confirmations"),
        intent_check_rows + conversion_block_rows,
        errors,
    )

    return errors


def find_briefs(root: Path) -> Iterable[Path]:
    if root.is_file():
        if root.suffix == ".md":
            yield root
        return
    yield from root.rglob("*.md")


def main(argv: list[str] | None = None) -> int:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument(
        "briefs_dir",
        type=Path,
        help="Brief markdown file or directory containing brief files.",
    )
    parser.add_argument(
        "--briefs-root",
        type=Path,
        default=None,
        help=(
            "Root used for depth computation (defaults to briefs_dir). "
            "Usually `<PROJECT_ROOT>/.okstra/briefs`."
        ),
    )
    args = parser.parse_args(argv)

    briefs_dir: Path = args.briefs_dir
    if not briefs_dir.exists():
        print(f"[FAIL] briefs directory not found: {briefs_dir}", file=sys.stderr)
        return 1

    briefs_root: Path = args.briefs_root or briefs_dir

    total = 0
    failed_files: list[tuple[Path, list[str]]] = []
    for brief in find_briefs(briefs_dir):
        total += 1
        errors = validate_brief(brief, briefs_root)
        if errors:
            failed_files.append((brief, errors))

    if total == 0:
        print(f"[PASS] no briefs found under {briefs_dir} (nothing to validate)")
        return 0

    if not failed_files:
        print(f"[PASS] {total} brief(s) validated under {briefs_dir}")
        return 0

    for path, errors in failed_files:
        print(f"[FAIL] {path}")
        for err in errors:
            print(f"  - {err}")
    print(
        f"[FAIL] {len(failed_files)}/{total} brief(s) failed validation",
        file=sys.stderr,
    )
    return 1


if __name__ == "__main__":
    raise SystemExit(main())
