#!/usr/bin/env python3
"""
Validate that an okstra schedule Markdown file conforms to the Section Contract
defined in skills/okstra-schedule-gen/SKILL.md.

Usage:
    python3 validators/validate-schedule.py <path-to-schedule.md>
    python3 validators/validate-schedule.py <path-to-schedule.md> \
        --selection-json <selection.json>

Exits 0 if compliant, 1 with a list of violations, or 2 for invalid arguments.
Intended to be called by the okstra-schedule-gen skill (self-validation step)
and by humans / hooks before committing a schedule.
"""

from __future__ import annotations

import re
import sys
from pathlib import Path

# 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.schedule_semantics import (  # noqa: E402
    ScheduleSemanticError,
    parse_effort_ranges,
    validate_schedule_semantics,
)

REQUIRED_SECTIONS_IN_ORDER: list[str] = [
    "## At a Glance",
    "## Executive Summary",
    "## Task Details",
]

# Both are omitted when they carry nothing: a single-task schedule has no
# cross-task edge to draw, and a section whose whole body says "look at the
# other table" is noise the reader has to step over.
OPTIONAL_GRAPHIC_SECTIONS_IN_ORDER: list[str] = [
    "## Task Dependency Graph",
    "## Gantt Chart",
]

REQUIRED_EXEC_SUMMARY_SUBSECTION = "### Effort Sizing Criteria"
# `P0` and `High` are controlled vocabulary the tables draw from, and a reader who
# never saw the source report cannot rank work by a code the document never
# defines — an opaque code with a friendlier shape.
REQUIRED_SCALE_SUBSECTION = "### Priority & Risk Scale"
# A reader who opens only the summary must learn why the work happens and what
# is true when it ends. Without these the section states scope and arithmetic
# and never says what the work is for.

# Section headings and field labels stay English literals in every language, but
# the words around them belong to the reader. A schedule declares its language in
# frontmatter (`lang: ko`) and is checked against that language's contract —
# otherwise a faithful translation fails for being a translation.
SCHEDULE_LABELS = {
    "ko": {
        "title_suffix": "\u2014 \uc791\uc5c5 \uc77c\uc815",
        "meta_pattern": r"^>\s*\uc791\uc131\uc77c\s*[0-9]{4}-[0-9]{2}-[0-9]{2}",
        "meta_hint": "> \uc791\uc131\uc77c <YYYY-MM-DD> \u00b7 \ub300\uc0c1 \uc800\uc7a5\uc18c <repo>",
        "summary_labels": ("**\ubaa9\uc801**", "**\ub2ec\ub77c\uc9c0\ub294 \uac83**"),
        "scale_headers": ("| Priority | \uae30\uc900 |", "| Risk | \uae30\uc900 |"),
        "glance_work_column": "\uc791\uc5c5",
    },
    "fr": {
        "title_suffix": "\u2014 Planning de travail",
        "meta_pattern": r"^>\s*R\u00e9dig\u00e9 le\s*[0-9]{4}-[0-9]{2}-[0-9]{2}",
        "meta_hint": "> R\u00e9dig\u00e9 le <YYYY-MM-DD> \u00b7 D\u00e9p\u00f4t concern\u00e9 <repo>",
        "summary_labels": ("**Objectif**", "**Ce qui aura chang\u00e9 \u00e0 la fin**"),
        "scale_headers": ("| Priority | Crit\u00e8re |", "| Risk | Crit\u00e8re |"),
        "glance_work_column": "Travail",
    },
}


def _schedule_language(text):
    """The `lang:` the document declares, and any complaint about it."""
    match = re.search(r'^lang:\s*"?([a-z]{2})"?\s*$', text, re.MULTILINE)
    if match is None:
        return "", [
            "missing `lang: <code>` in frontmatter — the validator checks the "
            "document against that language's labels (known: "
            + ", ".join(sorted(SCHEDULE_LABELS)) + ")"
        ]
    lang = match.group(1)
    if lang not in SCHEDULE_LABELS:
        return "", [
            "unknown `lang: " + lang + "` — known languages are "
            + ", ".join(sorted(SCHEDULE_LABELS))
        ]
    return lang, []

REQUIRED_TASK_FIELDS = [
    "**Category**",
    "**Priority**",
    "**Effort**",
    "**Status**",
    "**Risk**",
    "**Scope**",
    "**Repo**",
]

FORBIDDEN_HEADINGS_RE = re.compile(
    r"^##\s+("
    # Korean translations of mandatory/optional headings
    r"전체\s*개요|요약(\s*\(Executive\s*Summary\))?|단위\s*간\s*의존성\s*그래프|"
    r"단위\s*간\s*의존성\s*&\s*공유\s*사항|위험\s*완화\s*전략|즉시\s*권장\s*액션|"
    r"Gantt\s*다이어그램.*|누적\s*일정표.*|Cumulative\s*Timeline.*|Effort-to-Day\s*매핑.*|"
    # French translations of mandatory/optional headings (require French-distinctive
    # tokens — never match the canonical English forms like `Phase 3: Architecture`)
    r"Vue\s*d['’]?Ensemble|Résumé\s*Exécutif|"
    r"Graphe\s*de\s*Dépendances.*|Diagramme\s*de\s*Gantt.*|"
    r"Calendrier\s*Cumulé.*|Référentiel\s*Effort-to-Day.*|"
    r"Dépendances\s*Inter-Tâches.*|Stratégie\s*d['’]?Atténuation.*|"
    r"Stratégie\s*de\s*Mitigation.*|Actions\s*Immédiates\s*Recommandées|"
    r"Actions\s*Recommandées\s*Immédiates|Phase\s*\d+\s*:\s*Correctifs\s*Critiques.*|"
    r"Phase\s*\d+\s*:\s*Améliorations|Phase\s*\d+\s*:\s*Architecture\s*\(Volumes.*|"
    r"Phase\s*\d+\s*:\s*Extension.*"
    r")\s*$",
    re.MULTILINE,
)

FORBIDDEN_FRENCH_BODY_RE = re.compile(
    # Require accented forms only — plain ASCII variants would clash with
    # legitimate English (`Detail`, `Repo`, `Category`).
    r"(Charge\s+estimée|Catégorie\s*\||Priorité\s*\||Risque\s*\||Dépôt\s*\||Détail\s*\|)"
)

# Allowed enum values for At a Glance table cells. Cell text is normalized
# (strip + collapse spaces) before matching.
ALLOWED_EFFORT = {"S", "M", "L", "XL", "XXL"}
ALLOWED_PRIORITY = {"P0", "P1", "P2", "P3"}
ALLOWED_RISK = {"Very Low", "Low", "Medium", "Med-High", "High"}

# Per-task block sub-section labels in REQUIRED order. Each label is matched
# at the start of a line as `**Label**:` or as a `####` heading for the last.
# A stage block states what the stage does and when it is finished. `Value` was
# the plan arguing its own slicing at a reviewer; `Acceptance` restated
# `Exit contract`. Both are refused by label so a regenerated schedule cannot
# quietly bring them back.
STAGE_BLOCK_RE = re.compile(r"^####\s+Stage\s+(\d+)\s+—", re.MULTILINE)
STAGE_REQUIRED_LABELS = ("**Steps**:", "**Exit criteria**:")
STAGE_FORBIDDEN_LABELS = (
    ("**Value**:", "the plan's justification for its own slicing"),
    ("**Acceptance**:", "a restatement of Exit criteria"),
    ("**Exit contract**:", "renamed to `**Exit criteria**:`"),
)

PER_TASK_SUBSECTIONS_IN_ORDER = [
    "**Problem**:",
    "**Solution**:",
    "**Work Breakdown**:",
    "**Verification Commands**:",
    "**Rollback**:",
]

# Forbidden graph-DSL fences inside `## Gantt Chart` (the only ASCII section).
FORBIDDEN_GANTT_DSL_FENCES = (
    "```mermaid",
    "```gantt",
    "```plantuml",
    "```graphviz",
    "```dot",
)

# Calendar-date / weekday tokens that must NOT appear in the Gantt axis line.
CALENDAR_DATE_RE = re.compile(
    r"\b(20\d{2}-\d{2}-\d{2}"
    r"|\d{4}/\d{2}/\d{2}"
    r"|Mon|Tue|Wed|Thu|Fri|Sat|Sun"
    r"|월요일|화요일|수요일|목요일|금요일|토요일|일요일"
    r"|오늘\s*\+\s*\d+)\b"
)

DAYS_TOTAL_RE = re.compile(
    r"\*\*\d+\s*tasks\s*total\s*/\s*estimated\s*effort:\s*"
    r"\d+(?:\.\d+)?\s*~\s*\d+(?:\.\d+)?\s*days?\s*\(Effort\s*sum\)\*\*"
)

# Opaque cross-document identifiers from internal report formats.
# These mean nothing to a client reading the schedule alone, so they MUST
# either be inlined in plain prose (Form A) or resolved in `## Glossary`
# (Form B).
#
# Patterns that are TASK-ID-shaped (`FC-5`, `UC-3`, etc.) are detected
# generically as `<2+ uppercase letters>-<digits>` and then filtered against
# the TASK-ID whitelist (extracted from At a Glance) so legitimate task
# references (`DEV-1234`, `MOBILE-42`) pass through.
OPAQUE_DASHED_CODE_RE = re.compile(r"\b([A-Z]{2,5})-(\d+)\b")

# Decision-item letter codes (`A1`, `B2`, `C3`, `D4`). These represent
# approval/decision items in source reports and are forbidden in the schedule
# at all — the glossary cannot whitewash them.
DECISION_ITEM_RE = re.compile(r"\b([A-D])(\d{1,2})\b")
DECISION_ITEM_ALLOWLIST = set()  # nothing legitimate matches this shape

# Milestone codes (`M1`, `M2`, …). Allowed in body only if resolved in `## Glossary`.
MILESTONE_RE = re.compile(r"\bM(\d+)\b")


def _outside_fenced_lines(text: str) -> list[str]:
    visible: list[str] = []
    fence_marker = ""
    for line in text.splitlines():
        stripped = line.lstrip()
        marker = stripped[:3] if stripped.startswith(("```", "~~~")) else ""
        if fence_marker:
            visible.append("")
            if marker == fence_marker:
                fence_marker = ""
        elif marker:
            fence_marker = marker
            visible.append("")
        else:
            visible.append(line)
    return visible



def _strip_frontmatter(text: str) -> str:
    """Body only. Frontmatter is tooling metadata the reader never renders."""
    if not text.startswith("---\n"):
        return text
    end = text.find("\n---\n", 4)
    return text[end + 5:] if end != -1 else text

def _validate_format(path: Path) -> list[str]:
    if not path.exists():
        return [f"file not found: {path}"]

    text = path.read_text(encoding="utf-8")
    lines = text.splitlines()
    visible_lines = _outside_fenced_lines(text)
    violations: list[str] = []
    lang, lang_violations = _schedule_language(text)
    violations.extend(lang_violations)
    labels = SCHEDULE_LABELS.get(lang, SCHEDULE_LABELS["ko"])

    # 1. Title must end with the schedule's own suffix
    title_line = next((ln for ln in lines if ln.startswith("# ")), "")
    if not title_line:
        violations.append("missing top-level title (# …)")
    elif not title_line.rstrip().endswith(labels["title_suffix"]):
        violations.append(
            f'title must end with "{labels["title_suffix"]}"; got: {title_line!r}'
        )

    # 2. One metadata line: when it was written and which repository it targets.
    #    The schedule is shared with people who do not run okstra, so it names
    #    neither the tool nor its task-group / task-key vocabulary.
    if not re.search(labels["meta_pattern"], text, re.MULTILINE):
        violations.append(f"missing `{labels['meta_hint']}` metadata line")
    body = re.sub(r"<!--.*?-->", "", _strip_frontmatter(text), flags=re.DOTALL)
    # A path under the run's own working tree — `.okstra/…`, or the `qa/…`
    # scratch it holds — is a place the reader cannot open and does not own.
    # Name the artefact instead: "계약 캡처 도구", not its path.
    tooling_path = re.search(r"`[^`\n]*(?:\.okstra/|(?<![\w/])qa/)[^`\n]*`", body)
    if tooling_path:
        violations.append(
            f"schedule cites a run-internal path {tooling_path.group(0)} — that "
            "tree belongs to the tool, not the reader; name the artefact instead"
        )
    prose = re.sub(r"`[^`\n]*`", "", body)
    leaked = re.search(
        r"\b(okstra|task-group|Task Group|taskType|task-key|taskKey)\b", prose
    )
    if leaked:
        violations.append(
            "schedule prose names okstra or its vocabulary "
            f"({leaked.group(1)!r}) — the reader does not run it"
        )

    # 3. Required sections present and in order. The optional `## Gantt Chart`
    #    sections, if present, MUST sit between Executive Summary and
    #    Task Details.
    section_positions: dict[str, int] = {}
    optional_positions: dict[str, int] = {}
    for idx, line in enumerate(visible_lines):
        stripped = line.rstrip()
        if stripped in REQUIRED_SECTIONS_IN_ORDER and stripped not in section_positions:
            section_positions[stripped] = idx
        elif (
            stripped in OPTIONAL_GRAPHIC_SECTIONS_IN_ORDER
            and stripped not in optional_positions
        ):
            optional_positions[stripped] = idx

    missing = [s for s in REQUIRED_SECTIONS_IN_ORDER if s not in section_positions]
    for s in missing:
        violations.append(f"missing required section: {s!r}")

    for authority in ("## At a Glance", REQUIRED_EXEC_SUMMARY_SUBSECTION):
        count = sum(line.rstrip() == authority for line in visible_lines)
        if count > 1:
            violations.append(
                f"semantic authority {authority!r} must appear exactly once; "
                f"found {count}"
            )

    if not missing:
        ordered_actual = sorted(section_positions, key=lambda s: section_positions[s])
        if ordered_actual != REQUIRED_SECTIONS_IN_ORDER:
            violations.append(
                "required sections are out of order. expected:\n  "
                + "\n  ".join(REQUIRED_SECTIONS_IN_ORDER)
                + "\nactual:\n  "
                + "\n  ".join(ordered_actual)
            )

        # Optional sections sit between Executive Summary and Task Details
        summary_idx = section_positions["## Executive Summary"]
        details_idx = section_positions["## Task Details"]
        for opt_name, opt_idx in optional_positions.items():
            if not (summary_idx < opt_idx < details_idx):
                violations.append(
                    f"optional section {opt_name!r} is misplaced — must appear between "
                    "'## Executive Summary' and '## Task Details'"
                )
    # 3b. Priority / Risk scale definitions
    if sum(
        line.rstrip() == REQUIRED_SCALE_SUBSECTION for line in visible_lines
    ) != 1:
        violations.append(
            f"missing subsection {REQUIRED_SCALE_SUBSECTION!r} — `P0` and "
            "`High` are undefined codes without it"
        )
    else:
        for header in labels["scale_headers"]:
            if header not in text:
                violations.append(
                    f"{REQUIRED_SCALE_SUBSECTION} requires the header literal "
                    f"{header!r}"
                )

    # 3c. The summary states purpose and end state, not just scope.
    if "## Executive Summary" in section_positions:
        start = section_positions["## Executive Summary"]
        rest = "\n".join(lines[start + 1:])
        next_h = re.search(r"^##\s", rest, re.MULTILINE)
        summary = rest[: next_h.start()] if next_h else rest
        for label in labels["summary_labels"]:
            if label not in summary:
                violations.append(
                    f"`## Executive Summary` is missing {label!r} — the reader "
                    "must learn why the work happens and what is true when it ends"
                )

    # 4. Executive Summary subsection
    effort_heading_count = sum(
        line.rstrip() == REQUIRED_EXEC_SUMMARY_SUBSECTION
        for line in visible_lines
    )
    if effort_heading_count == 0:
        violations.append(
            f"missing subsection {REQUIRED_EXEC_SUMMARY_SUBSECTION!r} inside Executive Summary"
        )
    elif effort_heading_count == 1:
        try:
            parse_effort_ranges(text)
        except ScheduleSemanticError as exc:
            violations.append(str(exc))

    # 5. Forbidden translated/extra headings
    for match in FORBIDDEN_HEADINGS_RE.finditer(text):
        violations.append(
            f"forbidden heading (translated or extra): {match.group(0).strip()!r} "
            "— see SKILL Section Contract"
        )

    # 6. Forbidden French body labels (per-task field labels in French, etc.)
    if FORBIDDEN_FRENCH_BODY_RE.search(text):
        violations.append(
            "found French structural label (e.g. 'Charge estimée', 'Catégorie |', "
            "'Détail |'); per-task tables MUST use English field labels "
            "(**Category**, **Priority**, **Risk**, **Repo**, …)"
        )

    # 7. Per-task field labels — every per-task heading (### N-i.) MUST be followed
    #    (within the next 60 lines) by all required field labels.
    task_heading_re = re.compile(r"^###\s+\d+\.\s+", re.MULTILINE)
    for m in task_heading_re.finditer(text):
        start = m.start()
        # find the next ### or ## heading
        next_h = re.search(r"^(##\s|###\s)", text[start + len(m.group(0)):], re.MULTILINE)
        block = text[start: start + len(m.group(0)) + (next_h.start() if next_h else len(text))]
        for label in REQUIRED_TASK_FIELDS:
            if label not in block:
                heading_line = text[start: text.find("\n", start)]
                violations.append(
                    f"per-task block {heading_line.strip()!r} missing required field {label!r}"
                )

    # 8. Item table header literal
    if "| Item | Detail |" not in text and task_heading_re.search(text):
        violations.append(
            "per-task tables must use header `| Item | Detail |` (literal English)"
        )

    # 9. Forbid client-leaking artifacts: Consolidated User Decision Checklist /
    #    per-task 사용자 확인 필요 항목 — schedule is for client delivery, blocking
    #    / approval items must NOT surface.
    if "## Consolidated User Decision Checklist" in text:
        violations.append(
            "`## Consolidated User Decision Checklist` is forbidden — schedule "
            "is a client-facing work plan; blocking/approval items belong in "
            "internal task reports, not the schedule"
        )
    if re.search(r"^####\s+사용자\s*확인\s*필요\s*항목\s*$", text, re.MULTILINE):
        violations.append(
            "per-task `#### 사용자 확인 필요 항목` is forbidden in schedule — "
            "client-facing document; surface confirmation items in the source "
            "report instead"
        )

    # 9b. Stage blocks: Steps + Exit criteria, and neither reviewer-facing label.
    stage_headings = list(STAGE_BLOCK_RE.finditer(text))
    for index, heading in enumerate(stage_headings):
        end = (
            stage_headings[index + 1].start()
            if index + 1 < len(stage_headings)
            else len(text)
        )
        block = text[heading.start():end]
        stage_no = heading.group(1)
        for label in STAGE_REQUIRED_LABELS:
            if label not in block:
                violations.append(
                    f"Stage {stage_no} block is missing {label!r}"
                )
        for label, why in STAGE_FORBIDDEN_LABELS:
            if label in block:
                violations.append(
                    f"Stage {stage_no} block carries forbidden {label!r} — "
                    f"{why}; a stage block is Steps plus Exit criteria"
                )

    # 10. Days total format inside At a Glance: `**N tasks total / estimated effort: X.X ~ Y.Y days (Effort sum)**`
    if "## At a Glance" in section_positions:
        start = section_positions["## At a Glance"]
        rest = "\n".join(lines[start + 1:])
        next_h = re.search(r"^##\s", rest, re.MULTILINE)
        body = rest[: next_h.start()] if next_h else rest
        if not DAYS_TOTAL_RE.search(body):
            violations.append(
                "At a Glance must contain a totals line matching "
                "`**N tasks total / estimated effort: X.X ~ Y.Y days (Effort sum)**`"
            )

        # 11. At a Glance row enum check (Effort / Priority / Risk / Phase)
        for row in re.finditer(
            r"^\|\s*\d+\s*\|.*$", body, re.MULTILINE
        ):
            cells = split_pipe_row(row.group(0))
            # header: # | 작업 | Category | Priority | Effort | Days | Risk
            if len(cells) < 7:
                continue
            priority = re.sub(r"\*+", "", cells[3]).strip()
            effort = re.sub(r"\*+", "", cells[4]).strip()
            # `Effort` cell may contain extra hint like `L (5-15 files)`; pick the leading token
            effort_token = effort.split()[0] if effort else ""
            risk = re.sub(r"\*+", "", cells[6]).strip()
            task_id = cells[1]  # the work's name; ids are not printed here
            if priority and priority not in ALLOWED_PRIORITY:
                violations.append(
                    f"At a Glance row for {task_id!r}: Priority {priority!r} "
                    f"not in allowed set {sorted(ALLOWED_PRIORITY)}"
                )
            if effort_token and effort_token not in ALLOWED_EFFORT:
                violations.append(
                    f"At a Glance row for {task_id!r}: Effort {effort_token!r} "
                    f"not in allowed set {sorted(ALLOWED_EFFORT)}"
                )
            if risk and risk not in ALLOWED_RISK:
                violations.append(
                    f"At a Glance row for {task_id!r}: Risk {risk!r} "
                    f"not in allowed set {sorted(ALLOWED_RISK)}"
                )

    # 12. Gantt Chart content checks — forbid DSL fences, force relative-day axis
    if "## Gantt Chart" in optional_positions:
        start = optional_positions["## Gantt Chart"]
        rest = "\n".join(lines[start + 1:])
        next_h = re.search(r"^##\s", rest, re.MULTILINE)
        body = rest[: next_h.start()] if next_h else rest

        for forbidden in FORBIDDEN_GANTT_DSL_FENCES:
            if forbidden in body:
                violations.append(
                    f"`## Gantt Chart` contains forbidden DSL fence {forbidden!r} — "
                    "only plain ``` (no language tag) ASCII blocks are allowed"
                )

        # Detect any ``` fence with a language hint inside the Gantt section.
        for m in re.finditer(r"^```([^\s`]+)\s*$", body, re.MULTILINE):
            lang = m.group(1)
            if lang.lower() not in {""}:
                violations.append(
                    f"`## Gantt Chart` fence has language tag ```{lang}``` — "
                    "fence MUST be plain ``` with no language hint"
                )

        # Calendar-date / weekday axis check on the body.
        cal_match = CALENDAR_DATE_RE.search(body)
        if cal_match:
            violations.append(
                f"`## Gantt Chart` axis contains calendar/weekday token "
                f"{cal_match.group(0)!r} — use relative day-counts only "
                "(`Day 1`, `J1`, …)"
            )

    # 12b. Task Dependency Graph content check (the section is optional).
    if "## Task Dependency Graph" in optional_positions:
        start = optional_positions["## Task Dependency Graph"]
        rest = "\n".join(lines[start + 1:])
        next_h = re.search(r"^##\s", rest, re.MULTILINE)
        body = (rest[: next_h.start()] if next_h else rest).strip()

        no_data_marker = "_none_"
        fence_match = re.search(r"^```([^\n]*)\n(.*?)^```\s*$", body, re.MULTILINE | re.DOTALL)

        if not body:
            violations.append(
                "`## Task Dependency Graph` is empty — must contain either "
                f"`{no_data_marker}` or an adjacency-list fenced block"
            )
        elif fence_match:
            lang = fence_match.group(1).strip()
            inner = fence_match.group(2)
            if lang:
                violations.append(
                    f"`## Task Dependency Graph` fence has language tag ```{lang}``` — "
                    "fence MUST be plain ``` with no language hint"
                )
            for forbidden in FORBIDDEN_GANTT_DSL_FENCES:
                if forbidden in body:
                    violations.append(
                        f"`## Task Dependency Graph` contains forbidden DSL fence "
                        f"{forbidden!r} — only ASCII adjacency lines are allowed"
                    )
            # A node is a TASK-ID. Stage order is deliberately NOT drawn here —
            # the Work Breakdown's `Depends On` column already carries it, and
            # a second arrow-list rendering makes the reader cross-reference one
            # table against another to learn nothing new. A stage node would
            # also reintroduce the `S<n>` abbreviation the schedule spells out
            # everywhere else.
            node = r"[A-Za-z][A-Za-z0-9_-]*"
            adjacency_re = re.compile(
                rf"^{node}\s*->\s*{node}(?:\s*,\s*{node})*\s*$"
            )
            for line_no, raw in enumerate(inner.splitlines(), start=1):
                ln = raw.rstrip()
                if not ln.strip():
                    continue
                if ln.lstrip().startswith("#"):
                    continue
                if "→" in ln:
                    violations.append(
                        f"`## Task Dependency Graph` line {line_no} uses Unicode '→' "
                        "— use ASCII '->' only"
                    )
                    continue
                if not adjacency_re.match(ln.strip()):
                    violations.append(
                        f"`## Task Dependency Graph` line {line_no} is not a valid "
                        "adjacency line: expected `<TASK-ID> -> <TASK-ID>[, <TASK-ID>]*` "
                        "— this graph carries cross-task edges only, and stage "
                        "order belongs to the Work Breakdown's `Depends On` "
                        f"column; got {ln.strip()!r}"
                    )
        elif no_data_marker not in body:
            violations.append(
                "`## Task Dependency Graph` must be either the literal "
                f"`{no_data_marker}` or a plain ``` fenced adjacency-list "
                "block — free-form prose is not allowed"
            )

    # 13. Per-task block sub-section order check
    task_heading_re = re.compile(r"^###\s+\d+\.\s+", re.MULTILINE)
    headings = list(task_heading_re.finditer(text))
    for idx, m in enumerate(headings):
        block_start = m.start()
        block_end = headings[idx + 1].start() if idx + 1 < len(headings) else len(text)
        # also stop at the next `## ` heading. Skip past the current line first
        # so the heading itself ('### N-i. …') doesn't match `^##\s` from offset 1.
        body_start = text.find("\n", block_start) + 1
        next_phase_re = re.compile(r"^##\s", re.MULTILINE)
        np = next_phase_re.search(text, body_start, block_end)
        if np:
            block_end = np.start()
        block = text[block_start:block_end]
        heading_line = text[block_start : text.find("\n", block_start)].strip()

        # Skip blocks marked as NEEDS-OKSTRA-RUN, PARSE-ERROR, or NEEDS-PLANNING
        # — these are intentionally partial.
        if (
            "[NEEDS-OKSTRA-RUN]" in block
            or "[PARSE-ERROR" in block
            or "[NEEDS-PLANNING]" in block
        ):
            continue

        last_pos = -1
        for label in PER_TASK_SUBSECTIONS_IN_ORDER:
            pos = block.find(label)
            if pos == -1:
                violations.append(
                    f"per-task block {heading_line!r} missing required "
                    f"sub-section {label!r}"
                )
                last_pos = -1  # avoid cascading order errors after a missing one
                break
            if pos < last_pos:
                violations.append(
                    f"per-task block {heading_line!r}: sub-section {label!r} "
                    "appears out of required order "
                    "(Problem → Solution → Work Breakdown → Verification Commands "
                    "→ Rollback)"
                )
                break
            last_pos = pos

    # A schedule is a work plan, not a todo list: no checkbox anywhere.
    if re.search(r"^\s*[-*]\s+\[[ xX]\]\s", _strip_frontmatter(text), re.MULTILINE):
        violations.append(
            "schedule uses checkbox items (`- [ ] …`) — it is a work plan, "
            "not an internal todo list"
        )

    # 14. Self-contained identifiers — opaque cross-doc codes (FC-N, UC-N,
    # M-N, A1, B2, …) must be either inlined as prose (Form A) or resolved in
    # `## Glossary` (Form B). Decision-item letters (A1-D99) are forbidden
    # outright.
    code_violations = _check_self_contained_identifiers(text, lines, section_positions)
    violations.extend(code_violations)

    return violations


def validate(
    path: Path, selection_path: Path | None = None,
) -> list[str]:
    violations = _validate_format(path)
    if violations or selection_path is None:
        return violations
    text = path.read_text(encoding="utf-8")
    return violations + validate_schedule_semantics(text, selection_path)


def _strip_code_fences(text: str) -> str:
    """Remove ``` fenced blocks so opaque-id regex doesn't false-positive
    on shell commands, file paths, etc."""
    return re.sub(r"```.*?```", "", text, flags=re.DOTALL)


def _extract_task_id_whitelist(text: str, section_positions: dict[str, int],
                                lines: list[str]) -> set[str]:
    """TASK-IDs the schedule legitimately prints.

    At a Glance names tasks by their work, not their id, so the ids that do
    reach the page come from the two places that need to tell tasks apart when
    more than one is scheduled: the Gantt row labels and the dependency graph.
    """
    whitelist: set[str] = set()
    for match in re.finditer(
        r"^\s*([A-Z][A-Z0-9_-]*)\s+Stage\s+\d+", text, re.MULTILINE
    ):
        whitelist.add(match.group(1))
    if "## Task Dependency Graph" in section_positions:
        start = section_positions["## Task Dependency Graph"]
        rest = "\n".join(lines[start + 1:])
        next_h = re.search(r"^##\s", rest, re.MULTILINE)
        body = rest[: next_h.start()] if next_h else rest
        for token in re.findall(r"\b[A-Z][A-Z0-9_-]*\b", body):
            whitelist.add(token)
    return whitelist


def _extract_glossary(text: str) -> tuple[bool, set[str], int]:
    """Return (present, code_set, header_line_index).

    A `## Glossary` section is recognized by the literal heading. Codes are
    extracted from the first column of `| Code | Description |` rows.
    """
    m = re.search(r"^##\s+Glossary\s*$", text, re.MULTILINE)
    if not m:
        return False, set(), -1
    after = text[m.end():]
    next_h = re.search(r"^##\s", after, re.MULTILINE)
    body = after[: next_h.start()] if next_h else after
    codes: set[str] = set()
    for row in re.finditer(r"^\|\s*([^|\s][^|]*?)\s*\|.*\|.*$", body, re.MULTILINE):
        cell = row.group(1).strip()
        # skip header / separator rows
        if cell.lower() in {"code", "---", ":---", "---:"} or set(cell) <= {"-", ":"}:
            continue
        if cell:
            codes.add(cell)
    return True, codes, m.start()


def _check_self_contained_identifiers(text: str, lines: list[str],
                                       section_positions: dict[str, int]) -> list[str]:
    """Enforce schedule self-containment — see SKILL §"Audience"."""
    violations: list[str] = []

    glossary_present, glossary_codes, glossary_pos = _extract_glossary(text)

    # Body excludes Glossary section and code fences.
    if glossary_present:
        body_text = text[:glossary_pos]
    else:
        body_text = text
    body_text = _strip_code_fences(body_text)

    task_ids = _extract_task_id_whitelist(text, section_positions, lines)

    # Decision-item letters: forbidden everywhere (not just unresolved).
    seen_decision: set[str] = set()
    for m in DECISION_ITEM_RE.finditer(body_text):
        code = m.group(0)
        if code in seen_decision:
            continue
        seen_decision.add(code)
        violations.append(
            f"forbidden decision-item code {code!r} in schedule body — "
            "decision/approval items must not appear in a client-facing "
            "schedule (see SKILL §'Audience'); inline the underlying work "
            "as prose or drop the reference"
        )

    # Dashed codes: <2+ uppercase>-<digits>. Filter out TASK-IDs.
    seen_dashed: set[str] = set()
    for m in OPAQUE_DASHED_CODE_RE.finditer(body_text):
        code = m.group(0)
        if code in task_ids:
            continue
        if code in seen_dashed:
            continue
        seen_dashed.add(code)
        if glossary_present and code in glossary_codes:
            continue
        if glossary_present:
            violations.append(
                f"opaque code {code!r} appears in body but is missing from "
                "`## Glossary` — every opaque identifier must be resolved "
                "in the glossary (Form B) or inlined as prose (Form A)"
            )
        else:
            violations.append(
                f"opaque cross-document code {code!r} found in schedule body "
                "— schedule must be self-contained. Either inline the item "
                "content as prose (Form A) or add a `## Glossary` section at "
                "document bottom resolving every code (Form B). See SKILL "
                "§'Audience'"
            )

    # Milestone-style codes (M1, M2, …).
    seen_milestone: set[str] = set()
    for m in MILESTONE_RE.finditer(body_text):
        code = m.group(0)
        if code in seen_milestone:
            continue
        seen_milestone.add(code)
        if glossary_present and code in glossary_codes:
            continue
        if glossary_present:
            violations.append(
                f"milestone code {code!r} appears in body but is missing "
                "from `## Glossary` — resolve it or inline as prose"
            )
        else:
            violations.append(
                f"opaque milestone code {code!r} found in schedule body — "
                "either inline the milestone description as prose (Form A) "
                "or add a `## Glossary` section (Form B)"
            )

    # Glossary placement: nothing may follow it.
    if glossary_present:
        glossary_line_idx = next(
            (i for i, ln in enumerate(lines) if ln.rstrip() == "## Glossary"), -1
        )
        if glossary_line_idx >= 0 and any(
            ln.startswith("## ") and ln.rstrip() != "## Glossary"
            for ln in _outside_fenced_lines(text)[glossary_line_idx + 1:]
        ):
            violations.append(
                "`## Glossary` must be the last `##` section"
            )

    # Glossary header literal check.
    if glossary_present:
        gloss_section = text[glossary_pos:]
        next_h = re.search(r"^##\s", gloss_section[3:], re.MULTILINE)
        gloss_body = gloss_section[: next_h.start() + 3] if next_h else gloss_section
        if "| Code | Description |" not in gloss_body:
            violations.append(
                "`## Glossary` table must use header `| Code | Description |` "
                "(literal English)"
            )

    return violations


def main(argv: list[str]) -> int:
    usage = (
        f"usage: {argv[0]} <path-to-schedule.md> "
        "[--selection-json <selection.json>]"
    )
    args = argv[1:]
    if len(args) == 1 and not args[0].startswith("-"):
        path = Path(args[0])
        selection_path = None
    elif (
        len(args) == 3
        and args[1] == "--selection-json"
        and not args[0].startswith("-")
        and not args[2].startswith("-")
    ):
        path = Path(args[0])
        selection_path = Path(args[2])
    else:
        print(usage, file=sys.stderr)
        return 2
    violations = validate(path, selection_path)
    if not violations:
        print(f"OK: {path} conforms to okstra-schedule-gen Section Contract")
        return 0
    print(f"FAIL: {path}", file=sys.stderr)
    for v in violations:
        print(f"  - {v}", file=sys.stderr)
    print(
        "\nFix per skills/okstra-schedule-gen/SKILL.md "
        "(Section Contract).",
        file=sys.stderr,
    )
    return 1


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