#!/usr/bin/env python3
"""K5 — offline tabletop-pack gate.

Validates every `scenario-*.md` under this directory without contacting anything:

  * the file is readable text
  * required sections are present (heading match, order-independent):
      - Objective
      - Roles
      - Injects
      - Discussion Questions  (and references all five K1 phases:
        Identification, Containment, Eradication, Recovery, Post-Incident)
      - Evaluation Rubric
      - After-Action
  * it has at least 4 timed injects (rows of the form `| N | T+ ... min |`)
  * it cross-links a K1 incident runbook (`../incident/<name>.md`) AND that
    target file actually exists on disk (no dangling cross-links)

Also checks the reusable template (`tabletop-template.md`) carries the same
section skeleton, so authors copy a valid shape.

Exit 0 = all scenarios valid; exit 1 = at least one violation (prints each).

Pure stdlib; the K5 analogue of J5's validate_dashboards.py and F8's
gitops/validate.sh — the always-on offline gate that keeps a tabletop scenario
runnable and correctly cross-linked before anyone tries to facilitate it.
"""
from __future__ import annotations

import pathlib
import re
import sys

# Required section headings -> regex matched against any markdown heading line.
REQUIRED_SECTIONS: dict[str, re.Pattern[str]] = {
    "Objective": re.compile(r"^#{1,4}\s+Objective\b", re.IGNORECASE | re.MULTILINE),
    "Roles": re.compile(r"^#{1,4}\s+Roles\b", re.IGNORECASE | re.MULTILINE),
    "Injects": re.compile(r"^#{1,4}\s+Injects\b", re.IGNORECASE | re.MULTILINE),
    "Discussion Questions": re.compile(
        r"^#{1,4}\s+Discussion Questions\b", re.IGNORECASE | re.MULTILINE
    ),
    "Evaluation Rubric": re.compile(
        r"^#{1,4}\s+Evaluation Rubric\b", re.IGNORECASE | re.MULTILINE
    ),
    "After-Action": re.compile(
        r"^#{1,4}\s+After-?Action\b", re.IGNORECASE | re.MULTILINE
    ),
}

# The five K1 IR phases that the discussion questions must walk through.
K1_PHASES = ("Identification", "Containment", "Eradication", "Recovery", "Post-Incident")

# A cross-link to a K1 incident runbook: ../incident/<slug>.md
INCIDENT_LINK = re.compile(r"\.\./incident/([A-Za-z0-9._-]+\.md)")

# A timed inject row in the injects table: starts with `| <n> | ... min`.
INJECT_ROW = re.compile(r"^\|\s*\d+\s*\|.*\bmin\b", re.MULTILINE)

MIN_INJECTS = 4


def _check_sections(name: str, text: str) -> list[str]:
    out: list[str] = []
    for label, pat in REQUIRED_SECTIONS.items():
        if not pat.search(text):
            out.append(f"{name}: missing required section '{label}'")
    return out


def validate_scenario(path: pathlib.Path, incident_dir: pathlib.Path) -> list[str]:
    name = path.name
    try:
        text = path.read_text(encoding="utf-8")
    except (OSError, UnicodeDecodeError) as exc:
        return [f"{name}: unreadable — {exc}"]

    out = _check_sections(name, text)

    # Discussion questions must reference all five K1 phases.
    missing_phases = [p for p in K1_PHASES if not re.search(re.escape(p), text)]
    if missing_phases:
        out.append(
            f"{name}: Discussion Questions do not reference K1 phase(s): "
            f"{', '.join(missing_phases)}"
        )

    # At least MIN_INJECTS timed inject rows.
    n_injects = len(INJECT_ROW.findall(text))
    if n_injects < MIN_INJECTS:
        out.append(
            f"{name}: only {n_injects} timed inject row(s) found "
            f"(need >= {MIN_INJECTS})"
        )

    # Must cross-link an existing K1 incident runbook.
    links = INCIDENT_LINK.findall(text)
    if not links:
        out.append(f"{name}: no cross-link to a K1 incident runbook (../incident/*.md)")
    else:
        for target in sorted(set(links)):
            if not (incident_dir / target).is_file():
                out.append(
                    f"{name}: dangling cross-link — ../incident/{target} does not exist"
                )
    return out


def main() -> int:
    here = pathlib.Path(__file__).resolve().parent
    incident_dir = (here.parent / "incident").resolve()

    if not incident_dir.is_dir():
        print(f"K1 incident runbook dir not found: {incident_dir}", file=sys.stderr)
        return 1

    scenarios = sorted(here.glob("scenario-*.md"))
    if not scenarios:
        print(f"no scenario-*.md files found under {here}", file=sys.stderr)
        return 1

    violations: list[str] = []

    # The template must carry the same section skeleton (authors copy a valid shape).
    template = here / "tabletop-template.md"
    if not template.is_file():
        violations.append("tabletop-template.md: missing (the reusable scenario template)")
    else:
        violations.extend(_check_sections(template.name, template.read_text(encoding="utf-8")))

    for path in scenarios:
        violations.extend(validate_scenario(path, incident_dir))

    if violations:
        print("Tabletop validation FAILED:")
        for v in violations:
            print(f"  - {v}")
        return 1

    print(
        f"All {len(scenarios)} tabletop scenario(s) valid "
        f"(+ template): {', '.join(p.name for p in scenarios)}"
    )
    return 0


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