#!/usr/bin/env python3
"""Canonical schema, validator, and generator for Ralph `prd.json` backlogs.

`prd.json` is the single authoritative source for a Ralph-managed backlog.
This module defines the field shape every story must have, validates
structural and cross-story invariants (unique IDs/titles, resolvable
`dependsOn`, acyclic dependency graph, `blocks` derived correctly), and
renders the same fields into the generated Markdown/Gitea-issue view used
by `.scratch/<feature>/issues/*.md`.

Generation is one-directional: prd.json -> Markdown / Gitea payload. There
is no Markdown -> prd.json parser, so a generated artifact can never feed
`passes` (or any other field) back into the authoritative source. Every
generated Markdown document must start with the authority disclaimer
(`authority_disclaimer()`, sourced from prd.json's own `metadataSchema` when
present) so a stray edit of the generated file cannot be mistaken for a
source-of-truth change. The shared "quality gates" boilerplate is likewise
sourced from prd.json's `qualityGates`, not duplicated as a Python literal.

Example:
    python scripts/ralph_prd_schema.py validate .scratch/distributed-gguf-runtime/prd.json
    python scripts/ralph_prd_schema.py render .scratch/distributed-gguf-runtime/prd.json DGR-018
"""

from __future__ import annotations

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

REPO_ROOT = Path(__file__).resolve().parents[1]

AUTHORITY_DISCLAIMER = (
    "<!-- GENERATED FROM prd.json — DO NOT EDIT AS AN INDEPENDENT SOURCE. "
    "prd.json IS AUTHORITATIVE. -->"
)

EXECUTION_MODES = {"AFK", "HITL"}
EVIDENCE_CLASSES = {"model-free", "fixture", "real-model", "real-hardware", "release"}
HARDWARE_FLAGS = {"none", "optional", "required"}
UPSTREAM_FLAGS = {"yes", "no", "conditional"}
TRIAGE_VALUES = {"ready-for-agent", "ready-for-human"}

# Canonical field registry: name -> (required, expected python type(s)).
# This is the single definition of "what a story is" referenced by both
# validation and documentation; keep it in sync with any schema change.
STORY_FIELDS: dict[str, tuple[bool, tuple[type, ...]]] = {
    "id": (True, (str,)),
    "title": (True, (str,)),
    "priority": (True, (int,)),
    "milestone": (True, (str,)),
    "executionMode": (True, (str,)),
    "labels": (True, (list,)),
    "evidenceClass": (True, (str,)),
    "evidencePath": (True, (str,)),
    "hardware": (True, (str,)),
    "model": (True, (str,)),
    "upstream": (True, (str,)),
    "dependsOn": (True, (list,)),
    "triage": (True, (str,)),
    "description": (True, (str,)),
    "acceptanceCriteria": (True, (list,)),
    "passes": (True, (bool,)),
    "notes": (True, (str,)),
    "blocks": (True, (list,)),
    "completionNotes": (False, (str,)),
}

_ID_RE = re.compile(r"^[A-Z][A-Z0-9]*-\d+$")


class PrdValidationError(ValueError):
    """Raised with all accumulated validation errors, one per line."""

    def __init__(self, errors: list[str]):
        super().__init__("\n".join(errors))
        self.errors = errors


def load_prd(path: Path) -> dict[str, Any]:
    try:
        text = path.read_text(encoding="utf-8")
    except FileNotFoundError as exc:
        raise PrdValidationError([f"PRD not found: {path}"]) from exc
    try:
        data = json.loads(text)
    except json.JSONDecodeError as exc:
        raise PrdValidationError([f"Invalid JSON in {path}: {exc}"]) from exc
    if not isinstance(data, dict):
        raise PrdValidationError([f"{path}: top-level PRD document must be an object"])
    return data


def stories_of(data: dict[str, Any]) -> list[dict[str, Any]]:
    stories = data.get("userStories")
    if not isinstance(stories, list):
        return []
    return [s for s in stories if isinstance(s, dict)]


def derive_type(labels: list[str]) -> str | None:
    """Derive the story's `type` from its `type:` label, if present.

    Release-gate stories (labelled `gate:hitl`) intentionally carry no
    `type:` label; they derive the synthetic type `release-gate` instead.
    """
    types = [label.split(":", 1)[1] for label in labels if label.startswith("type:")]
    if types:
        return types[0]
    if any(label.startswith("gate:") for label in labels):
        return "release-gate"
    return None


def _label_prefix_counts(labels: list[str], prefix: str) -> int:
    return sum(1 for label in labels if label.startswith(prefix))


def validate_schema(data: dict[str, Any]) -> list[str]:
    """Structural validation: required top-level keys and per-story field shape."""
    errors: list[str] = []
    for key in ("name", "description", "branchName", "userStories"):
        if key not in data:
            errors.append(f"prd: missing top-level field '{key}'")
    stories = data.get("userStories")
    if not isinstance(stories, list):
        errors.append("prd: 'userStories' must be a list")
        return errors

    for index, story in enumerate(stories):
        label = f"userStories[{index}]"
        if not isinstance(story, dict):
            errors.append(f"{label}: story must be an object")
            continue
        sid = story.get("id", label)

        for field, (required, types) in STORY_FIELDS.items():
            if field not in story:
                if required:
                    errors.append(f"{sid}: missing required field '{field}'")
                continue
            value = story[field]
            if types == (bool,) and isinstance(value, bool):
                continue
            if not isinstance(value, types):
                errors.append(
                    f"{sid}: field '{field}' must be {types}, got {type(value).__name__}"
                )

        if isinstance(story.get("id"), str) and not _ID_RE.match(story["id"]):
            errors.append(f"{sid}: id '{story['id']}' does not match PREFIX-NUMBER convention")
        if isinstance(story.get("executionMode"), str) and story["executionMode"] not in EXECUTION_MODES:
            errors.append(f"{sid}: executionMode '{story['executionMode']}' not in {sorted(EXECUTION_MODES)}")
        if isinstance(story.get("evidenceClass"), str) and story["evidenceClass"] not in EVIDENCE_CLASSES:
            errors.append(f"{sid}: evidenceClass '{story['evidenceClass']}' not in {sorted(EVIDENCE_CLASSES)}")
        if isinstance(story.get("hardware"), str) and story["hardware"] not in HARDWARE_FLAGS:
            errors.append(f"{sid}: hardware '{story['hardware']}' not in {sorted(HARDWARE_FLAGS)}")
        if isinstance(story.get("upstream"), str) and story["upstream"] not in UPSTREAM_FLAGS:
            errors.append(f"{sid}: upstream '{story['upstream']}' not in {sorted(UPSTREAM_FLAGS)}")
        if isinstance(story.get("triage"), str) and story["triage"] not in TRIAGE_VALUES:
            errors.append(f"{sid}: triage '{story['triage']}' not in {sorted(TRIAGE_VALUES)}")

        labels = story.get("labels")
        if isinstance(labels, list):
            if not all(isinstance(label, str) for label in labels):
                errors.append(f"{sid}: labels must all be strings")
            elif _label_prefix_counts(labels, "type:") > 1:
                errors.append(f"{sid}: at most one 'type:' label allowed")
            elif _label_prefix_counts(labels, "priority:") > 1:
                errors.append(f"{sid}: at most one 'priority:' label allowed")

        depends_on = story.get("dependsOn")
        if isinstance(depends_on, list) and not all(isinstance(dep, str) for dep in depends_on):
            errors.append(f"{sid}: dependsOn entries must all be strings")

        acceptance = story.get("acceptanceCriteria")
        if isinstance(acceptance, list):
            if not acceptance:
                errors.append(f"{sid}: acceptanceCriteria must be non-empty")
            elif not all(isinstance(item, str) and item.strip() for item in acceptance):
                errors.append(f"{sid}: acceptanceCriteria entries must be non-empty strings")

    return errors


def _find_cycle(story_ids: set[str], depends_on: dict[str, list[str]]) -> list[str] | None:
    WHITE, GRAY, BLACK = 0, 1, 2
    color = {sid: WHITE for sid in story_ids}
    path: list[str] = []

    def dfs(node: str) -> list[str] | None:
        color[node] = GRAY
        path.append(node)
        for dep in depends_on.get(node, []):
            if dep not in color:
                continue
            if color[dep] == GRAY:
                cycle_start = path.index(dep)
                return path[cycle_start:] + [dep]
            if color[dep] == WHITE:
                found = dfs(dep)
                if found:
                    return found
        path.pop()
        color[node] = BLACK
        return None

    for sid in sorted(story_ids):
        if color[sid] == WHITE:
            cycle = dfs(sid)
            if cycle:
                return cycle
    return None


def validate_semantics(data: dict[str, Any]) -> list[str]:
    """Cross-story validation: uniqueness, dependency graph, derived `blocks`."""
    errors: list[str] = []
    stories = stories_of(data)

    ids = [s.get("id") for s in stories if isinstance(s.get("id"), str)]
    seen_ids: set[str] = set()
    for sid in ids:
        if sid in seen_ids:
            errors.append(f"duplicate story id: '{sid}'")
        seen_ids.add(sid)

    titles = [s.get("title") for s in stories if isinstance(s.get("title"), str)]
    seen_titles: set[str] = set()
    for title in titles:
        if title in seen_titles:
            errors.append(f"duplicate story title: '{title}'")
        seen_titles.add(title)

    by_id = {s["id"]: s for s in stories if isinstance(s.get("id"), str)}
    depends_on: dict[str, list[str]] = {}
    for story in stories:
        sid = story.get("id")
        if not isinstance(sid, str):
            continue
        deps = story.get("dependsOn") if isinstance(story.get("dependsOn"), list) else []
        deps = [d for d in deps if isinstance(d, str)]
        depends_on[sid] = deps
        if sid in deps:
            errors.append(f"{sid}: cannot depend on itself")
        for dep in deps:
            if dep not in by_id:
                errors.append(f"{sid}: depends on unknown story '{dep}'")

    cycle = _find_cycle(set(by_id), depends_on)
    if cycle:
        errors.append("dependency cycle detected: " + " -> ".join(cycle))

    derived_blocks: dict[str, list[str]] = {sid: [] for sid in by_id}
    for sid, deps in depends_on.items():
        for dep in deps:
            if dep in derived_blocks:
                derived_blocks[dep].append(sid)
    for sid in derived_blocks:
        derived_blocks[sid] = sorted(derived_blocks[sid])

    for story in stories:
        sid = story.get("id")
        if not isinstance(sid, str):
            continue
        given = story.get("blocks") if isinstance(story.get("blocks"), list) else []
        given_sorted = sorted(d for d in given if isinstance(d, str))
        expected = derived_blocks.get(sid, [])
        if given_sorted != expected:
            errors.append(
                f"{sid}: 'blocks' does not match derived dependents "
                f"(got {given_sorted}, expected {expected})"
            )

        evidence_path = story.get("evidencePath")
        if isinstance(evidence_path, str):
            expected_path = f".scratch/distributed-gguf-runtime/evidence/{sid}/README.md"
            if evidence_path != expected_path:
                errors.append(
                    f"{sid}: evidencePath '{evidence_path}' does not match convention "
                    f"'{expected_path}'"
                )

    return errors


def validate_fresh_backlog(data: dict[str, Any]) -> list[str]:
    """Extra invariant for a freshly generated (not-yet-started) backlog.

    A fresh backlog must not claim any completion credit: every story
    starts `passes: false`. This is distinct from validating an in-flight
    backlog (use validate_schema + validate_semantics for that), because a
    real backlog legitimately accumulates `passes: true` stories over time.
    """
    errors = validate_backlog(data)
    for story in stories_of(data):
        if story.get("passes") is not False:
            errors.append(
                f"{story.get('id')}: fresh backlog requires passes=false, "
                f"got {story.get('passes')!r}"
            )
    return errors


def validate_backlog(data: dict[str, Any]) -> list[str]:
    """Full validation for an in-progress backlog: structure, semantics, and
    (when present) prd.json's self-documented metadataSchema/qualityGates."""
    return (
        validate_schema(data)
        + validate_semantics(data)
        + validate_metadata_schema_consistency(data)
    )


def validate_metadata_schema_consistency(data: dict[str, Any]) -> list[str]:
    """If prd.json documents its own `metadataSchema`/`qualityGates`, verify
    that self-description hasn't drifted from what this validator actually
    enforces. Absent entirely, this is a no-op (a minimal fixture PRD is not
    required to carry self-documentation to be structurally valid).
    """
    errors: list[str] = []
    ms = data.get("metadataSchema")
    if ms is None:
        return errors

    enum_checks = {
        "triageValues": TRIAGE_VALUES,
        "executionModeValues": EXECUTION_MODES,
        "evidenceClassValues": EVIDENCE_CLASSES,
        "hardwareValues": HARDWARE_FLAGS,
        "upstreamValues": UPSTREAM_FLAGS,
    }
    for key, expected in enum_checks.items():
        declared = ms.get(key)
        if declared is None:
            errors.append(f"metadataSchema: missing '{key}'")
            continue
        if set(declared) != expected:
            errors.append(
                f"metadataSchema.{key} {sorted(declared)} does not match "
                f"enforced values {sorted(expected)}"
            )

    required_in_code = {field for field, (required, _) in STORY_FIELDS.items() if required}
    declared_required = set(ms.get("requiredStoryFields", []))
    if declared_required != required_in_code:
        errors.append(
            f"metadataSchema.requiredStoryFields {sorted(declared_required)} does not "
            f"match code-enforced required fields {sorted(required_in_code)}"
        )

    optional_in_code = {field for field, (required, _) in STORY_FIELDS.items() if not required}
    declared_optional = set(ms.get("optionalStoryFields", []))
    if declared_optional != optional_in_code:
        errors.append(
            f"metadataSchema.optionalStoryFields {sorted(declared_optional)} does not "
            f"match code-enforced optional fields {sorted(optional_in_code)}"
        )

    if not ms.get("generatedArtifactDisclaimer"):
        errors.append("metadataSchema: missing 'generatedArtifactDisclaimer'")

    if "qualityGates" not in data:
        errors.append("prd: metadataSchema is documented but top-level 'qualityGates' is missing")
    elif not quality_gate_bullets(data):
        errors.append("qualityGates: no bullets declared under universal/native/realModelHardware/scope")

    return errors


def quality_gate_bullets(data: dict[str, Any]) -> list[str]:
    """Flatten prd.json's `qualityGates` into the bullet order used by the
    generated "Shared quality gates" Markdown section."""
    gates = data.get("qualityGates", {})
    bullets: list[str] = []
    for key in ("universal", "native", "realModelHardware", "scope"):
        bullets.extend(gates.get(key, []))
    return bullets


def authority_disclaimer(data: dict[str, Any]) -> str:
    """The exact disclaimer generated Markdown/Gitea bodies must start with.

    Sourced from prd.json's `metadataSchema.generatedArtifactDisclaimer` when
    present, falling back to the module default for minimal fixtures.
    """
    return data.get("metadataSchema", {}).get("generatedArtifactDisclaimer", AUTHORITY_DISCLAIMER)


def check_generated_markdown_authority(text: str, disclaimer: str = AUTHORITY_DISCLAIMER) -> list[str]:
    """Reject generated Markdown that fails to defer authority to prd.json."""
    errors: list[str] = []
    first_line = text.splitlines()[0] if text else ""
    if first_line != disclaimer:
        errors.append(
            "generated Markdown must start with the authority disclaimer: "
            f"expected {disclaimer!r}, got {first_line!r}"
        )
    lowered = text.lower()
    for claim in ("this file is authoritative", "this document is authoritative", "markdown is authoritative"):
        if claim in lowered:
            errors.append(f"generated Markdown claims authority over prd.json: found '{claim}'")
    return errors


def slugify(title: str) -> str:
    slug = re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-")
    return re.sub(r"-{2,}", "-", slug)


def issue_number(story_id: str) -> str:
    match = re.search(r"(\d+)$", story_id)
    if not match:
        raise ValueError(f"story id '{story_id}' has no trailing number")
    return match.group(1)


def issue_filename(story: dict[str, Any]) -> str:
    return f"{issue_number(story['id'])}-{slugify(story['title'])}.md"


# Fallback bullets used only when a minimal fixture PRD has no qualityGates
# block of its own; the real backlog always sources these from prd.json via
# quality_gate_bullets() so this list is a default, not a duplicate of truth.
_DEFAULT_QUALITY_GATE_BULLETS = [
    "Targeted deterministic tests pass; Python changes also pass `python -m compileall packages tests`.",
    "`git diff --check` passes.",
    "Default tests are model-download-free, API-credit-free, and GPU-free.",
    "Evidence README records exact changed files, commands/results, limitations, and dependency handoff; no fabricated evidence or inherited completion credit.",
]


def render_issue_markdown(story: dict[str, Any], data: dict[str, Any] | None = None) -> str:
    """Render the canonical generated Markdown view of a story.

    `data` supplies the shared quality-gates bullets and authority disclaimer
    from prd.json's own `qualityGates`/`metadataSchema`; omit it only for
    fixtures that don't model those fields. This is byte-for-byte identical
    to the convention already used by `.scratch/distributed-gguf-runtime/
    issues/*.md`; see the round-trip tests in tests/test_ralph_prd_schema.py.
    """
    data = data or {}
    sid = story["id"]
    passes = story["passes"]
    deps = story.get("dependsOn", [])
    blocks = sorted(story.get("blocks", []))

    deps_text = ", ".join(f"`{d}`" for d in deps) if deps else "None"
    blocks_text = ", ".join(f"`{b}`" for b in blocks) if blocks else "None"
    labels_text = ", ".join(f"`{label}`" for label in story.get("labels", []))

    if passes:
        status_line = "completed; `passes: true`"
    else:
        status_line = f"specification only; `{story['triage']}`; `passes: false`"

    checkbox = "x" if passes else " "
    acceptance_lines = "\n".join(
        f"- [{checkbox}] {criterion}" for criterion in story["acceptanceCriteria"]
    )

    if passes:
        evidence_handoff = (
            f"Verified evidence: `{story['evidencePath']}`. Legacy evidence remains "
            "provenance only and grants no implementation completion credit."
        )
    else:
        evidence_handoff = (
            f"Write and verify `{story['evidencePath']}`. Until every criterion and "
            f"applicable gate has real evidence, this story remains `passes: false`. "
            "Legacy evidence is provenance only, not completion credit."
        )

    description = story["description"]

    gate_bullets = quality_gate_bullets(data) if "qualityGates" in data else _DEFAULT_QUALITY_GATE_BULLETS
    gates_section = "## Shared quality gates\n\n" + "\n".join(f"- {bullet}" for bullet in gate_bullets)

    lines = [
        authority_disclaimer(data),
        f"# {sid}: {story['title']}",
        "",
        f"- **Status / triage:** {status_line}",
        f"- **Execution mode:** `{story['executionMode']}`",
        f"- **Milestone:** `{story['milestone']}`",
        f"- **Dependencies:** {deps_text}",
        f"- **Blocks (derived):** {blocks_text}",
        f"- **Labels:** {labels_text}",
        f"- **Evidence class:** `{story['evidenceClass']}`",
        f"- **Hardware:** `{story['hardware']}`",
        f"- **Model:** `{story['model']}`",
        f"- **Upstream:** `{story['upstream']}`",
        "",
        "## Objective / description",
        "",
        description,
        "",
        "## Acceptance criteria",
        "",
        acceptance_lines,
        "",
        gates_section,
        "",
        "## Evidence handoff",
        "",
        evidence_handoff,
        "",
    ]
    return "\n".join(lines[:-1]) + "\n"


def to_gitea_issue_payload(story: dict[str, Any], data: dict[str, Any] | None = None) -> dict[str, Any]:
    """Structured payload matching the Gitea create-issue API shape.

    `body` is exactly `render_issue_markdown`'s output, so the payload
    carries no information that isn't losslessly derivable from prd.json.
    """
    return {
        "title": f"{story['id']}: {story['title']}",
        "body": render_issue_markdown(story, data),
        "labels": list(story.get("labels", [])),
        "milestone": story["milestone"],
    }


def _cmd_validate(args: argparse.Namespace) -> int:
    data = load_prd(args.prd)
    errors = validate_fresh_backlog(data) if args.fresh else validate_backlog(data)
    if errors:
        for error in errors:
            print(f"ERROR: {error}", file=sys.stderr)
        print(f"{len(errors)} validation error(s).", file=sys.stderr)
        return 1
    print(f"OK: {len(stories_of(data))} stories validated.")
    return 0


def _cmd_render(args: argparse.Namespace) -> int:
    data = load_prd(args.prd)
    by_id = {s["id"]: s for s in stories_of(data)}
    story = by_id.get(args.story_id)
    if story is None:
        print(f"Unknown story id: {args.story_id}", file=sys.stderr)
        return 1
    print(render_issue_markdown(story, data), end="")
    return 0


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(description=__doc__)
    subparsers = parser.add_subparsers(dest="command", required=True)

    validate = subparsers.add_parser("validate", help="Validate a prd.json against the canonical schema")
    validate.add_argument("prd", type=Path)
    validate.add_argument("--fresh", action="store_true", help="Also require passes=false on every story")
    validate.set_defaults(func=_cmd_validate)

    render = subparsers.add_parser("render", help="Render the generated Markdown view of one story")
    render.add_argument("prd", type=Path)
    render.add_argument("story_id")
    render.set_defaults(func=_cmd_render)

    return parser


def main(argv: list[str]) -> int:
    parser = build_parser()
    args = parser.parse_args(argv[1:])
    return args.func(args)


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