"""Verify the bootstrap.md §5g github-config.json template parses
through the live ``GithubConfig`` schema (#189).

The 2.0.0 regression that motivated this check was a one-character
field-name drift: ``bootstrap.md`` §5g emitted ``"org"`` while the
MCP server's pydantic model declared ``owner``. The MCP server's
own ``scaffold_project`` writes ``"owner"`` correctly — the bug
only fired when an operator hand-followed the prose template. This
module extracts that template, fills its placeholder strings with
schema-valid stand-ins, and constructs a ``GithubConfig`` from the
result; any drift between prose and schema raises immediately.

Designed as a pure-stdlib + pydantic leaf so it slots into:
* the ``validate_release`` pre-flight checklist (#189 AC4)
* a CLI entrypoint (``python -m
  active_claude_github_mcp.bootstrap_template_parity``) for direct
  pre-commit / dev-loop invocation
without dragging in github / mcp / anthropic deps.

The bootstrap.md file lives outside the mcp-server bazel workspace
root, so unit tests pin the parser logic against synthetic fixtures
in ``tmp_path``; the live-file check is the job of ``validate_release``
(which already accepts a ``project_dir`` outside the bazel sandbox).
"""
from __future__ import annotations

import argparse
import json
import re
import sys
from pathlib import Path
from typing import Sequence

from .models import GithubConfig


BOOTSTRAP_REL = Path(".claude/skills/active-claude-github/bootstrap.md")
SECTION_HEADING = "#### 5g — Commit github-config.json"

# §5g shows ``"account_type": "org | personal"`` as an operator-pick
# placeholder. The Literal in models.py rejects that string literally;
# substitute one of the two valid values before validating.
_ACCOUNT_TYPE_PLACEHOLDER_RE = re.compile(
    r'"account_type"\s*:\s*"org\s*\|\s*personal"'
)

# §5g uses ``"[node-id]"`` (9 chars) as a placeholder, but the
# ``ids_look_like_node_ids`` validator on GithubConfig rejects values
# under 10 chars as suspiciously short. Substitute every occurrence
# with a 20-char stand-in so the parity check measures schema parity,
# not placeholder length.
_NODE_ID_PLACEHOLDER = "[node-id]"
_NODE_ID_STANDIN = "NODE_ID_PLACEHOLDER_X"  # 21 chars, comfortably above the threshold


class TemplateParityError(Exception):
    """Raised when the §5g template no longer parses as a GithubConfig."""


def find_bootstrap_md(project_dir: Path) -> Path | None:
    """Locate the skill's bootstrap.md under ``project_dir`` or None."""
    candidate = project_dir / BOOTSTRAP_REL
    return candidate if candidate.exists() else None


def extract_section_json(text: str, heading: str = SECTION_HEADING) -> str:
    """Return the first fenced ``json`` block following ``heading``.

    Raises ``TemplateParityError`` if the heading is missing or no
    ``json`` fence follows it before the next heading.
    """
    idx = text.find(heading)
    if idx == -1:
        raise TemplateParityError(
            f"heading not found in bootstrap.md: {heading!r}"
        )
    after = text[idx + len(heading):]
    fence_open = after.find("```json\n")
    if fence_open == -1:
        raise TemplateParityError(
            f"no ```json fence following heading {heading!r}"
        )
    body_start = fence_open + len("```json\n")
    fence_close = after.find("\n```", body_start)
    if fence_close == -1:
        raise TemplateParityError(
            f"unterminated ```json fence following heading {heading!r}"
        )
    return after[body_start:fence_close]


def normalize_placeholders(raw_json: str) -> str:
    """Rewrite operator-pick placeholders that don't satisfy the schema.

    Two substitutions:
    * ``"account_type": "org | personal"`` → ``"account_type": "org"``
      (the prose shows both legal values; the Literal on the field
      rejects the disjunction string literally).
    * ``"[node-id]"`` → a 21-char stand-in (the
      ``ids_look_like_node_ids`` validator rejects values under 10
      chars; the prose placeholder is exactly 9).
    """
    out = _ACCOUNT_TYPE_PLACEHOLDER_RE.sub('"account_type": "org"', raw_json)
    out = out.replace(
        f'"{_NODE_ID_PLACEHOLDER}"', f'"{_NODE_ID_STANDIN}"'
    )
    return out


def validate_template(project_dir: Path) -> GithubConfig:
    """Load §5g from ``project_dir`` and validate it as ``GithubConfig``.

    Returns the constructed model on success; raises
    ``TemplateParityError`` on any failure (missing file, missing
    section, JSON-parse error, schema-validation error).
    """
    path = find_bootstrap_md(project_dir)
    if path is None:
        raise TemplateParityError(
            f"bootstrap.md not found at {project_dir / BOOTSTRAP_REL}"
        )
    text = path.read_text(encoding="utf-8")
    raw = extract_section_json(text)
    normalized = normalize_placeholders(raw)
    try:
        payload = json.loads(normalized)
    except json.JSONDecodeError as exc:
        raise TemplateParityError(
            f"§5g json fence is not valid JSON: {exc}"
        ) from exc
    try:
        return GithubConfig.model_validate(payload)
    except Exception as exc:
        raise TemplateParityError(
            f"§5g template fails GithubConfig schema: {exc}"
        ) from exc


def main(argv: Sequence[str] | None = None) -> int:
    parser = argparse.ArgumentParser(
        prog="active_claude_github_mcp.bootstrap_template_parity",
        description=(
            "Validate that bootstrap.md §5g still parses as GithubConfig "
            "(#189). Exits 0 on success, 1 on parity failure."
        ),
    )
    parser.add_argument(
        "--project-dir",
        type=Path,
        default=Path("."),
        help="Path to the project root (default: %(default)s)",
    )
    args = parser.parse_args(argv)

    try:
        validate_template(args.project_dir)
    except TemplateParityError as exc:
        print(f"::error::{exc}", file=sys.stderr)
        return 1

    print(
        f"OK: {args.project_dir / BOOTSTRAP_REL} §5g template "
        "parses as GithubConfig"
    )
    return 0


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