#!/usr/bin/env python3
"""S1–S13 checks for the Stage Map structure of an approved
implementation-planning final-report.md. Run from prepare_task_bundle
of `implementation` task or standalone."""

from __future__ import annotations

import argparse
import re
import sys
from collections import Counter
from dataclasses import dataclass
from pathlib import Path
from typing import List, Tuple

# scripts/ (repo) and python/ (installed under ~/.okstra/lib) are not packages;
# insert whichever exists so okstra_ctl is importable directly.
_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.md_table import split_pipe_row  # noqa: E402
from okstra_ctl.stage_map import (  # noqa: E402
    STAGE_MAP_HEADING,
    StageMapError,
    StageMapStage,
    parse_stage_map_text,
    schema_v2_report,
)

HARD_STEP_CAP = 8
REQUIRED_SUBSECTIONS = (
    "Carry-In",
    "Stepwise Execution Order",
    "Stage Exit Contract",
    "Stage Validation",
)

EXIT_CONTRACT_HEADING = re.compile(r"^###\s+Stage Exit Contract\b", re.M)
# best-effort path token: only slash-containing paths count as files, so
# endpoints (`/bar`), env vars (`BAZ_MODE`), and extensionless tokens are skipped.
PATH_TOKEN = re.compile(r"(?:[\w.@-]+/)+[\w.@-]+")


# S12 — a step command that reads an okstra artifact back out of a git object
# (`git cat-file -e <rev>:.okstra/...`, `git show <rev>:.okstra/...`). `.okstra/**`
# is never committed: `_implementation-executor.md` forbids `git add -f` and makes
# a staged ignored path abort the commit, and `_implementation-verifier.md` reports
# a committed `.okstra` path as a branch defect. A verification step built on such
# a read can never pass, whatever the stage does.
GIT_OBJECT_OKSTRA_READ = re.compile(
    r"\bgit\b[^&|;]*?\b(?:cat-file|show|ls-tree|archive|grep)\b[^&|;]*?"
    r"(?<![\w./@'\"-])[\w./@{}~^-]+:\.okstra/"
)
# S13 — a clean-worktree assertion built on a bare `git status`. okstra provisions
# `.okstra`, the configured sync entries, and (for implementation) a nested stage
# worktree into every task worktree, so a bare status is never empty there.
BARE_GIT_STATUS = re.compile(r"\bgit\b[^&|;]*?\bstatus\b[^&|;]*?--(?:porcelain|short)\b")
CLEAN_GATE_COMMAND = "okstra worktree-status --check-clean"


@dataclass
class ValidationError:
    code: str   # S1..S13
    stage: int  # 0 = global
    message: str


def _check_stage_map_present(text: str) -> List[ValidationError]:
    if not STAGE_MAP_HEADING.search(text):
        return [ValidationError("S1", 0,
            "section '## 5.5 Stage Map' is missing")]
    return []


def _parse_depends_on_cell(raw: str) -> List[int] | None:
    """Stage numbers from schema-v2 `stageMap[].dependsOn`."""
    value = raw.strip()
    if value in ("(none)", ""):
        return []
    try:
        return [int(x.strip()) for x in value.split(",") if x.strip()]
    except ValueError:
        return None


def _stage_numbers_monotonic(
    stages: List[StageMapStage],
) -> List[ValidationError]:
    return [
        ValidationError("S2", r.stage_number,
            f"stage numbers must be 1..N monotonic, got {r.stage_number} at row {i}")
        for i, r in enumerate(stages, start=1)
        if r.stage_number != i
    ]

def _slice_stage_section(text: str, stage_number: int) -> str:
    """Return the body of `## 5.5.<n> Stage <n>:` up to the next stage heading."""
    start_m = re.search(
        rf"^##\s+5\.5\.{stage_number}\s+Stage\s+{stage_number}\s*:", text, re.M
    )
    if not start_m:
        return ""
    start = start_m.end()
    nxt = re.search(
        rf"^##\s+5\.5\.{stage_number + 1}\s+Stage\s+", text[start:], re.M
    )
    return text[start: start + nxt.start()] if nxt else text[start:]


STEP_COMMAND_CELL = 3


def _effective_step_rows(section: str) -> List[List[str]]:
    """Effective (non header/divider/comment) rows of the `### Stepwise
    Execution Order` table, each as a list of stripped cells. Columns are
    `step | action | files | command | outcome | expected`, so action is
    index 1, command index 3, outcome index 4."""
    m = re.search(r"^###\s+Stepwise Execution Order\b", section, re.M)
    if not m:
        return []
    body = section[m.end():]
    nxt = re.search(r"^###\s+\w", body, re.M)
    if nxt:
        body = body[: nxt.start()]
    rows: List[List[str]] = []
    for line in body.splitlines():
        s = line.strip()
        if not s or s.startswith("<!--"):
            continue
        if not s.startswith("|"):
            continue
        cells = split_pipe_row(s)
        first_cell = cells[0]
        if first_cell.lower() == "step":
            continue
        if set(first_cell) <= set("-: "):
            continue
        rows.append(cells)
    return rows


def _count_effective_steps(section: str) -> int:
    return len(_effective_step_rows(section))


def _check_each_stage_section(text: str, stages: List[StageMapStage]) -> List[ValidationError]:
    errs: List[ValidationError] = []
    for s in stages:
        if not re.search(
            rf"^##\s+5\.5\.{s.stage_number}\s+Stage\s+{s.stage_number}\s*:", text, re.M
        ):
            errs.append(ValidationError("S3", s.stage_number,
                f"stage section '## 5.5.{s.stage_number} Stage {s.stage_number}:' missing"))
            continue
        section = _slice_stage_section(text, s.stage_number)

        for sub in REQUIRED_SUBSECTIONS:
            if not re.search(rf"^###\s+{re.escape(sub)}\b", section, re.M):
                errs.append(ValidationError("S4", s.stage_number,
                    f"required subsection '### {sub}' missing"))

        # S5: effective step count
        steps = _count_effective_steps(section)
        if steps > HARD_STEP_CAP:
            errs.append(ValidationError("S5", s.stage_number,
                f"effective step count {steps} exceeds {HARD_STEP_CAP}"))

        # S7: step-count cell vs. real count
        if s.step_count >= 0 and s.step_count != steps:
            errs.append(ValidationError("S7", s.stage_number,
                f"Stage Map step-count={s.step_count} but real count={steps}"))
    return errs


# Each label line may be plain (`Slice value: …`) or rendered as a bold-label
# bullet (`- **Slice value:** …`). The optional `(?:[-*]\s+)?` bullet marker and
# the two optional `\*\*` groups accept both forms; the bold wraps the colon, so
# the closing `**` sits AFTER the colon.
_LABEL_PREFIX = r"^\s*(?:[-*]\s+)?(?:\*\*)?"
SLICE_VALUE = re.compile(_LABEL_PREFIX + r"Slice value\s*:\s*(?:\*\*)?\s*(.+?)\s*$", re.M)
ACCEPTANCE = re.compile(_LABEL_PREFIX + r"Acceptance\s*:\s*(?:\*\*)?\s*(.+?)\s*$", re.M)
TDD_EXEMPTION = re.compile(_LABEL_PREFIX + r"TDD exemption\s*:\s*(?:\*\*)?\s*(.+?)\s*$", re.M)
# Profile implementation-planning.md:81 limits the exemption to these three
# categories; any other reason (e.g. "refactor") must not waive RED/GREEN.
TDD_EXEMPTION_ALLOWED = ("doc-only", "config-only", "pure-rename")
TEST_CASE_CATEGORIES = ("success", "boundary", "failure")
TEST_CASE = {
    cat: re.compile(
        _LABEL_PREFIX + rf"Test case\s*\({cat}\)\s*:\s*(?:\*\*)?\s*\S", re.M
    )
    for cat in TEST_CASE_CATEGORIES
}
CONFORMANCE_TESTS = re.compile(_LABEL_PREFIX + r"Conformance tests\s*:\s*(?:\*\*)?\s*\S", re.M)
CONFORMANCE_EXEMPTION = re.compile(_LABEL_PREFIX + r"Conformance exemption\s*:\s*(?:\*\*)?\s*\S", re.M)


def _exemption_reason_allowed(section: str) -> bool:
    """True when a `TDD exemption:` line is present AND its reason names an
    allowed category (doc-only / config-only / pure-rename, case-insensitive).
    A present-but-unlisted reason returns False so S10e can reject it."""
    m = TDD_EXEMPTION.search(section)
    if not m:
        return False
    reason = m.group(1).lower()
    return any(cat in reason for cat in TDD_EXEMPTION_ALLOWED)


def _check_slice_tdd(text: str, stages: List[StageMapStage]) -> List[ValidationError]:
    """S10: each stage declares a vertical slice and follows RED→GREEN ordering.

    S10a — `Slice value:` line with a non-empty value.
    S10b — `Acceptance:` line with a non-empty value.
    S10c — first effective Stepwise step's action starts with `RED:` (its
           `expected` cell reading FAIL) AND some action starts with `GREEN:`
           (its `expected` cell reading PASS), OR a valid `TDD exemption:` line.
    S10d — stage declares all three `Test case (success|boundary|failure):`
           lines with non-empty values, OR a valid `TDD exemption:` line.
           Forces the plan to address the happy path, edge/boundary inputs, and
           failure/negative inputs instead of a single acceptance assertion.
    S10e — a present `TDD exemption:` line's reason must name an allowed
           category (doc-only / config-only / pure-rename); an arbitrary reason
           must not waive RED/GREEN.
    """
    errs: List[ValidationError] = []
    for s in stages:
        section = _slice_stage_section(text, s.stage_number)
        if not section:
            continue   # S3 already reported the missing section

        if not SLICE_VALUE.search(section):
            errs.append(ValidationError("S10", s.stage_number,
                "S10a: 'Slice value:' line missing or empty"))
        if not ACCEPTANCE.search(section):
            errs.append(ValidationError("S10", s.stage_number,
                "S10b: 'Acceptance:' line missing or empty"))

        if TDD_EXEMPTION.search(section):
            if not _exemption_reason_allowed(section):
                errs.append(ValidationError("S10", s.stage_number,
                    "S10e: 'TDD exemption:' reason must be one of "
                    "doc-only / config-only / pure-rename — an arbitrary reason "
                    "cannot waive RED/GREEN"))
            continue

        missing_cases = [
            cat for cat in TEST_CASE_CATEGORIES if not TEST_CASE[cat].search(section)
        ]
        if missing_cases:
            errs.append(ValidationError("S10", s.stage_number,
                "S10d: missing 'Test case (" + "|".join(missing_cases) + "):' "
                "line(s) — declare a non-empty success, boundary, and failure "
                "case each, or add a 'TDD exemption:' line"))

        errs.extend(_check_red_green_steps(section, s.stage_number))
    return errs


def _check_red_green_steps(section: str, stage_number: int) -> List[ValidationError]:
    """S10c: RED-first + later GREEN, with each step's `outcome` cell matching
    its prefix (RED → FAIL, GREEN → PASS).

    `outcome` is its own column, holding one of two words; the sentence about
    what that looks like lives in `expected` beside it. Written as one cell the
    author had to bury a literal token inside prose, and the rule fired on
    fourteen rows of one measured plan whose prose was correct throughout.
    """
    rows = _effective_step_rows(section)
    steps = [(r[1], r[4]) for r in rows if len(r) > 4]
    actions = [a for a, _ in steps]
    first_is_red = bool(actions) and actions[0].startswith("RED:")
    has_green = any(a.startswith("GREEN:") for a in actions)
    errs: List[ValidationError] = []
    if not (first_is_red and has_green):
        errs.append(ValidationError("S10", stage_number,
            "S10c: first step action must start with 'RED:' and some "
            "step action with 'GREEN:', or add a 'TDD exemption:' line"))
        return errs
    for action, outcome in steps:
        cell = outcome.strip().upper()
        if action.startswith("RED:") and cell != "FAIL":
            errs.append(ValidationError("S10", stage_number,
                f"S10c: 'RED:' step's outcome cell must be FAIL, got '{outcome}'"))
        elif action.startswith("GREEN:") and cell != "PASS":
            errs.append(ValidationError("S10", stage_number,
                f"S10c: 'GREEN:' step's outcome cell must be PASS, got '{outcome}'"))
    return errs


def _check_step_command(command: str, stage_number: int) -> List[ValidationError]:
    """S12 / S13 over one step's `command` cell.

    The command cell is what actually closes a step, so it has to be runnable
    inside the worktree layout okstra provisions. Both rules reject a command
    that can never pass there, regardless of what the stage implements.
    """
    errs: List[ValidationError] = []
    if GIT_OBJECT_OKSTRA_READ.search(command):
        errs.append(ValidationError("S12", stage_number,
            "S12: step command reads an `.okstra/` path out of a git object — "
            "`.okstra/**` is gitignored and never committed, so the read can "
            "never resolve. Pass the artifact forward through the stage carry "
            "sidecar / verifier result, or read it from the working tree"))
    if BARE_GIT_STATUS.search(command):
        errs.append(ValidationError("S13", stage_number,
            "S13: step command asserts a clean worktree with a bare `git status` — "
            "okstra provisions `.okstra`, the synced entries, and any nested stage "
            f"worktree there, so it is never empty. Use `{CLEAN_GATE_COMMAND}`"))
    return errs


def _check_markdown_step_commands(
    text: str, stages: List[StageMapStage]
) -> List[ValidationError]:
    """S12 / S13 over the rendered `### Stepwise Execution Order` rows."""
    errs: List[ValidationError] = []
    for s in stages:
        for row in _effective_step_rows(_slice_stage_section(text, s.stage_number)):
            if len(row) > STEP_COMMAND_CELL:
                errs.extend(_check_step_command(row[STEP_COMMAND_CELL], s.stage_number))
    return errs


def _check_conformance_declaration(
    text: str, stages: List[StageMapStage]
) -> List[ValidationError]:
    """S11: 각 stage 는 conformance 검증을 선언하거나 명시적으로 면제한다.

    S11 — `Conformance tests:` 라인(Tier3 검증 스크립트 선언) 또는
          `Conformance exemption:` 라인(테스트 불필요 사유) 중 하나 필수.
    diff 가 db/io/http surface 를 건드렸는데 아무 선언이 없는 silent-pass(DEV-9184)
    를 planning boundary 에서 차단한다.
    """
    errs: List[ValidationError] = []
    for s in stages:
        section = _slice_stage_section(text, s.stage_number)
        if not (CONFORMANCE_TESTS.search(section) or CONFORMANCE_EXEMPTION.search(section)):
            errs.append(ValidationError(
                "S11", s.stage_number,
                "S11: stage must declare 'Conformance tests:' (Tier3 검증 스크립트) "
                "or 'Conformance exemption:' (사유) — stage conformance QA design §12.2",
            ))
    return errs


def _check_depends_on(stages: List[StageMapStage]) -> List[ValidationError]:
    errs: List[ValidationError] = []
    valid = {s.stage_number for s in stages}
    for s in stages:
        for d in s.depends_on:
            if d == s.stage_number:
                errs.append(ValidationError("S8", s.stage_number, "self depends-on"))
            elif d not in valid:
                errs.append(ValidationError("S6", s.stage_number,
                    f"depends-on {d} does not exist"))

    # DAG cycle detection via Kahn's algorithm
    # Build a graph only from valid edges (S6 errors already reported above)
    indeg = {s.stage_number: 0 for s in stages}
    graph: dict[int, list[int]] = {s.stage_number: [] for s in stages}
    for s in stages:
        for d in s.depends_on:
            if d in graph and d != s.stage_number:
                graph[d].append(s.stage_number)
                indeg[s.stage_number] += 1

    queue = [n for n, k in indeg.items() if k == 0]
    visited = 0
    while queue:
        n = queue.pop()
        visited += 1
        for m in graph[n]:
            indeg[m] -= 1
            if indeg[m] == 0:
                queue.append(m)
    if visited != len(stages):
        errs.append(ValidationError("S8", 0, "depends-on graph has a cycle"))
    return errs


def _extract_exit_contract_files(section: str) -> set:
    m = EXIT_CONTRACT_HEADING.search(section)
    if not m:
        return set()
    body = section[m.end():]
    nxt = re.search(r"^###\s+\w", body, re.M)
    if nxt:
        body = body[: nxt.start()]
    return set(PATH_TOKEN.findall(body))


def _report_shared_parallel_files(files: dict) -> List[ValidationError]:
    """S9 over an already-extracted {stage_number: {path}} map."""
    errs: List[ValidationError] = []
    nums = sorted(files)
    for i in range(len(nums)):
        for j in range(i + 1, len(nums)):
            a, b = nums[i], nums[j]
            shared = files[a] & files[b]
            if shared:
                errs.append(ValidationError("S9", 0,
                    f"parallel stages {a} and {b} share predicted file(s): "
                    f"{', '.join(sorted(shared))}"))
    return errs


def _check_parallel_safety(
    text: str, stages: List[StageMapStage],
) -> List[ValidationError]:
    """S9: two `depends-on (none)` stages must not predict the same file —
    otherwise two parallel implementation runs would edit it concurrently."""
    return _report_shared_parallel_files({
        s.stage_number: _extract_exit_contract_files(
            _slice_stage_section(text, s.stage_number)
        )
        for s in stages
        if not s.depends_on
    })


def collect_validation_errors(text: str) -> List[ValidationError]:
    """All S1–S11 checks against the report text; empty list means valid.

    S1 (missing `## 5.5 Stage Map` heading) makes the rest unparseable, so it
    short-circuits. Shared by `main()` (CLI / implementation entry) and the
    implementation-planning boundary check in validate-run.py — one validator,
    one reference point, enforced at both produce-time and consume-time."""
    present = _check_stage_map_present(text)
    if present:
        return present

    errors: List[ValidationError] = []
    try:
        stages = parse_stage_map_text(text)
    except StageMapError as exc:
        return [ValidationError("S2", 0, exc.reason)]
    if stages:
        errors.extend(_check_each_stage_section(text, stages))
        errors.extend(_check_slice_tdd(text, stages))
        errors.extend(_check_markdown_step_commands(text, stages))
        errors.extend(_check_conformance_declaration(text, stages))
        errors.extend(_check_depends_on(stages))
        errors.extend(_check_parallel_safety(text, stages))
    return errors


def _data_stage_metas(
    stage_map: List[dict],
) -> Tuple[List[StageMapStage], List[ValidationError]]:
    rows = []
    for row in stage_map:
        if not isinstance(row, dict) or not isinstance(row.get("stage"), int):
            continue
        depends = _parse_depends_on_cell(str(row.get("dependsOn") or ""))
        if depends is None:
            continue
        rows.append(StageMapStage(
            row["stage"],
            str(row.get("title") or ""),
            depends,
            row.get("stepCount") if isinstance(row.get("stepCount"), int) else -1,
            str(row.get("exitContractSummary") or ""),
        ))
    return rows, _stage_numbers_monotonic(rows)


def _check_data_slice_tdd(stage: dict) -> List[ValidationError]:
    """S10c / S10e over one schema-v2 `stages[]` entry.

    The schema already requires `sliceValue`, `acceptance`, and — through its
    `if not tddExemption then testCase*` conditional — the three test cases, so
    S10a/S10b/S10d are covered declaratively. Two rules a JSON Schema cannot
    state are left: the RED→GREEN ordering across `stepwiseExecution` rows, and
    that a `tddExemption` naming no allowed category cannot waive them. The
    schema types `tddExemption` as a plain string, so `""` currently satisfies
    the conditional and drops all three test cases with no reason given.
    """
    number = stage.get("stage") if isinstance(stage.get("stage"), int) else 0
    if "tddExemption" in stage:
        reason = str(stage.get("tddExemption") or "").lower()
        if not any(cat in reason for cat in TDD_EXEMPTION_ALLOWED):
            return [ValidationError("S10", number,
                "S10e: 'tddExemption' reason must be one of "
                + " / ".join(TDD_EXEMPTION_ALLOWED)
                + " — an empty or arbitrary reason cannot waive RED/GREEN "
                "and the three test cases")]
        return []

    steps = [s for s in (stage.get("stepwiseExecution") or []) if isinstance(s, dict)]
    actions = [str(s.get("action") or "") for s in steps]
    if not (actions and actions[0].startswith("RED:")
            and any(a.startswith("GREEN:") for a in actions)):
        return [ValidationError("S10", number,
            "S10c: first stepwiseExecution action must start with 'RED:' and "
            "some action with 'GREEN:', or declare a 'tddExemption'")]
    # Whether each row's own `outcome` agrees with its RED/GREEN prefix is a
    # schema conditional now (`StageStepRow.allOf`), not a string search here.
    return []


def _check_data_step_counts(
    stage_map: List[StageMapStage], stages: List[dict]
) -> List[ValidationError]:
    """The Stage Map row is the plan's own index of its stage body. A row
    claiming a step count the body does not have makes the map unusable for
    sizing a stage, which is the only reason the cell exists."""
    by_number = {
        s.get("stage"): s for s in stages
        if isinstance(s, dict) and isinstance(s.get("stage"), int)
    }
    errs: List[ValidationError] = []
    for meta in stage_map:
        stage = by_number.get(meta.stage_number)
        if stage is None:
            errs.append(ValidationError("S3", meta.stage_number,
                "stageMap row has no matching stages[] entry"))
            continue
        actual = len([
            s for s in (stage.get("stepwiseExecution") or []) if isinstance(s, dict)
        ])
        if meta.step_count != actual:
            errs.append(ValidationError("S4", meta.stage_number,
                f"stageMap stepCount {meta.step_count} != "
                f"{actual} stepwiseExecution rows"))
        if actual > HARD_STEP_CAP:
            errs.append(ValidationError("S5", meta.stage_number,
                f"effective step count {actual} exceeds {HARD_STEP_CAP}"))
    for number in sorted(n for n in by_number if n not in {m.stage_number for m in stage_map}):
        errs.append(ValidationError("S3", number,
            "stages[] entry has no matching stageMap row"))
    return errs


def _check_data_stage_identities(
    raw_stage_map: List[dict], stages: List[dict]
) -> List[ValidationError]:
    """Reject ambiguous schema-v2 identities before building stage indexes."""
    errs: List[ValidationError] = []
    stage_map_numbers = [
        row["stage"]
        for row in raw_stage_map
        if isinstance(row, dict) and isinstance(row.get("stage"), int)
    ]
    stage_numbers = [
        row["stage"]
        for row in stages
        if isinstance(row, dict) and isinstance(row.get("stage"), int)
    ]
    duplicate_stage_map = {
        number for number, count in Counter(stage_map_numbers).items() if count > 1
    }
    duplicate_stages = {
        number for number, count in Counter(stage_numbers).items() if count > 1
    }
    errs.extend(
        ValidationError("S3", number, f"stageMap duplicate stage {number}")
        for number in sorted(duplicate_stage_map)
    )
    errs.extend(
        ValidationError("S3", number, f"stages[] duplicate stage {number}")
        for number in sorted(duplicate_stages)
    )

    stage_map_set = set(stage_map_numbers)
    stage_set = set(stage_numbers)
    errs.extend(
        ValidationError(
            "S3",
            number,
            f"stageMap is missing stages[] stage {number}",
        )
        for number in sorted(stage_set - stage_map_set)
    )
    errs.extend(
        ValidationError(
            "S3",
            number,
            f"stages[] is missing stageMap stage {number}",
        )
        for number in sorted(stage_map_set - stage_set)
    )

    stage_map_by_number = {
        row["stage"]: row
        for row in raw_stage_map
        if isinstance(row, dict)
        and isinstance(row.get("stage"), int)
        and row["stage"] not in duplicate_stage_map
    }
    stages_by_number = {
        row["stage"]: row
        for row in stages
        if isinstance(row, dict)
        and isinstance(row.get("stage"), int)
        and row["stage"] not in duplicate_stages
    }
    errs.extend(
        ValidationError(
            "S3",
            number,
            f"stageMap and stages[] stage {number} title must match",
        )
        for number in sorted(stage_map_by_number.keys() & stages_by_number.keys())
        if stage_map_by_number[number].get("title")
        != stages_by_number[number].get("title")
    )

    for stage in stages:
        number = stage.get("stage")
        step_numbers = [
            step["step"]
            for step in stage.get("stepwiseExecution") or []
            if isinstance(step, dict) and isinstance(step.get("step"), int)
        ]
        errs.extend(
            ValidationError(
                "S4",
                number if isinstance(number, int) else 0,
                f"stages[] stage {number} step {step} is duplicate",
            )
            for step, count in sorted(Counter(step_numbers).items())
            if count > 1
        )
    return errs


def collect_data_validation_errors(planning: dict) -> List[ValidationError]:
    """The S-checks that schema v2 cannot express, over `implementationPlanning`.

    `collect_validation_errors` scans rendered v1 Markdown, and
    `validate_phase_boundary` returns before calling it for a v2 report — so on
    the current schema nothing enforced the depends-on DAG, parallel-stage file
    safety, RED→GREEN ordering, or the TDD-exemption vocabulary. The schema
    covers presence and cardinality; this covers the relationships between
    fields, which is what a JSON Schema has no way to say.
    """
    if (
        planning.get("planningContract") == "selected-direction"
        and planning.get("outcome") == "direction-invalidated"
    ):
        return []

    raw_stage_map = planning.get("stageMap") or []
    stage_map, errors = _data_stage_metas(raw_stage_map)
    stages = [s for s in (planning.get("stages") or []) if isinstance(s, dict)]
    if not stage_map and not stages:
        return errors

    identity_errors = _check_data_stage_identities(raw_stage_map, stages)
    errors.extend(identity_errors)
    if identity_errors:
        return errors
    errors.extend(_check_data_step_counts(stage_map, stages))
    errors.extend(_check_depends_on(stage_map))
    errors.extend(_report_shared_parallel_files({
        meta.stage_number: set(PATH_TOKEN.findall(
            str((next(
                (s for s in stages if s.get("stage") == meta.stage_number), {}
            )).get("exitContract") or "")
        ))
        for meta in stage_map
        if not meta.depends_on
    }))
    for stage in stages:
        errors.extend(_check_data_slice_tdd(stage))
        number = stage.get("stage") if isinstance(stage.get("stage"), int) else 0
        for step in stage.get("stepwiseExecution") or []:
            if isinstance(step, dict):
                errors.extend(_check_step_command(str(step.get("command") or ""), number))
    return errors


def collect_plan_errors(plan_path: Path) -> List[ValidationError]:
    """The S-checks for one approved plan, whichever schema wrote it.

    A schema-v2 report keeps its stage map in the `.data.json` sidecar and
    renders no `## 5.5 Stage Map` section, so scanning its markdown reports the
    section as missing and blocks every run that approved such a plan.
    """
    planning = schema_v2_report(plan_path).get("implementationPlanning")
    if isinstance(planning, dict) and planning:
        return collect_data_validation_errors(planning)
    return collect_validation_errors(plan_path.read_text(encoding="utf-8"))


def main(argv: List[str]) -> int:
    p = argparse.ArgumentParser()
    p.add_argument("--plan", required=True)
    args = p.parse_args(argv)

    try:
        errors = collect_plan_errors(Path(args.plan))
    except StageMapError as exc:
        print(f"S0 stage=0: {exc.reason}", file=sys.stderr)
        return 1
    if errors:
        for e in errors:
            print(f"{e.code} stage={e.stage}: {e.message}", file=sys.stderr)
        return 1
    return 0


if __name__ == "__main__":
    sys.exit(main(sys.argv[1:]))
