#!/usr/bin/env python3
"""Validate navigation graph integrity against schema and graph invariants."""
from __future__ import annotations

from pathlib import Path
from typing import Any
import json

ROOT = Path(__file__).resolve().parent.parent
GRAPH_PATH = ROOT / "dist" / "navigation.json"
SCHEMA_PATH = ROOT / "schema" / "navigation.schema.json"

ALLOWED_NODE_KINDS = {"root", "section", "group", "pack", "category", "leaf"}
ALLOWED_EDGE_KINDS = {"superseded_by"}


def load_json(path: Path) -> Any:
    return json.loads(path.read_text(encoding="utf-8"))


def require(condition: bool, message: str) -> None:
    if not condition:
        raise ValueError(message)


def validate_schema_shape(graph: dict[str, Any]) -> None:
    require(graph.get("schema_version") == "0.1", "schema_version must be 0.1")
    for field in [
        "source_repo",
        "source_ref",
        "summary",
        "nodes",
        "edges",
    ]:
        require(field in graph, f"missing required field: {field}")

    summary = graph["summary"]
    for field in [
        "node_count",
        "leaf_count",
        "browse_default_visible",
        "browse_default_hidden",
        "edge_count",
    ]:
        require(field in summary, f"summary missing {field}")

    nodes = graph["nodes"]
    edges = graph["edges"]
    require(isinstance(nodes, list) and nodes, "nodes must be a non-empty list")
    require(isinstance(edges, list), "edges must be a list")
    require(
        summary["node_count"] == len(nodes),
        "summary.node_count does not match nodes length",
    )
    require(
        summary["edge_count"] == len(edges),
        "summary.edge_count does not match edges length",
    )


def validate_nodes(nodes: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
    index: dict[str, dict[str, Any]] = {}
    leaf_count = 0
    visible = 0
    hidden = 0

    for node in nodes:
        node_id = str(node.get("id") or "")
        kind = str(node.get("kind") or "")
        require(node_id, "node missing id")
        require(node_id not in index, f"duplicate node id: {node_id}")
        require(kind in ALLOWED_NODE_KINDS, f"{node_id} has invalid kind {kind!r}")
        require(str(node.get("title") or "").strip(), f"{node_id} missing title")

        if kind == "leaf":
            leaf_count += 1
            require("ref" in node, f"{node_id} leaf missing ref")
            require("browse_default" in node, f"{node_id} leaf missing browse_default")
            ref = node["ref"]
            require(
                str(ref.get("source_repo") or "").strip(),
                f"{node_id} ref missing source_repo",
            )
            require(
                str(ref.get("source_path") or "").strip(),
                f"{node_id} ref missing source_path",
            )
            path = ROOT / str(ref["source_path"])
            require(path.exists(), f"{node_id} ref path does not exist: {ref['source_path']}")
            if node.get("browse_default"):
                visible += 1
            else:
                hidden += 1
            require(
                "children" not in node,
                f"{node_id} leaf must not declare children",
            )
        else:
            children = node.get("children")
            require(
                isinstance(children, list) and children,
                f"{node_id} container must declare non-empty children",
            )
            require("ref" not in node, f"{node_id} container must not declare ref")

        index[node_id] = node

    return {
        "index": index,
        "leaf_count": leaf_count,
        "visible": visible,
        "hidden": hidden,
    }


def validate_children(index: dict[str, dict[str, Any]]) -> None:
    for node_id, node in index.items():
        if node["kind"] == "leaf":
            continue
        for child_id in node.get("children") or []:
            require(
                child_id in index,
                f"{node_id} references missing child {child_id!r}",
            )
            child = index[child_id]
            parent_kind = node["kind"]
            child_kind = child["kind"]
            if parent_kind == "root":
                require(child_kind == "section", f"{node_id} child must be section")
            elif parent_kind == "section":
                require(
                    child_kind in {"group", "pack", "category", "leaf"},
                    f"{node_id} child must be group, pack, category, or leaf",
                )
            elif parent_kind == "group":
                require(child_kind == "leaf", f"{node_id} child must be leaf")
            elif parent_kind == "pack":
                require(
                    child_kind in {"category", "leaf"},
                    f"{node_id} child must be category or leaf",
                )
            elif parent_kind == "category":
                require(
                    child_kind in {"leaf", "group"},
                    f"{node_id} child must be leaf or group",
                )

    roots = [node_id for node_id, node in index.items() if node["kind"] == "root"]
    require(len(roots) == 1, "graph must contain exactly one root node")

    visited: set[str] = set()
    stack = [roots[0]]
    while stack:
        current = stack.pop()
        if current in visited:
            continue
        visited.add(current)
        node = index[current]
        if node["kind"] != "leaf":
            stack.extend(node.get("children") or [])

    unreachable = set(index) - visited
    require(not unreachable, f"unreachable nodes: {sorted(unreachable)}")


def validate_edges(
    edges: list[dict[str, Any]], index: dict[str, dict[str, Any]]
) -> None:
    adjacency: dict[str, list[str]] = {}

    for edge in edges:
        kind = str(edge.get("kind") or "")
        from_id = str(edge.get("from") or "")
        to_id = str(edge.get("to") or "")
        require(kind in ALLOWED_EDGE_KINDS, f"invalid edge kind: {kind!r}")
        require(from_id in index, f"edge from missing node: {from_id!r}")
        require(to_id in index, f"edge to missing node: {to_id!r}")
        require(
            index[from_id]["kind"] == "leaf",
            f"supersession edge must start at leaf: {from_id}",
        )
        require(
            index[to_id]["kind"] == "leaf",
            f"supersession edge must end at leaf: {to_id}",
        )
        adjacency.setdefault(from_id, []).append(to_id)

    visiting: set[str] = set()
    visited: set[str] = set()

    def dfs(node_id: str) -> None:
        if node_id in visiting:
            raise ValueError(f"supersession cycle detected at {node_id}")
        if node_id in visited:
            return
        visiting.add(node_id)
        for target in adjacency.get(node_id, []):
            dfs(target)
        visiting.remove(node_id)
        visited.add(node_id)

    for node_id in adjacency:
        dfs(node_id)


def validate_summary_counts(
    graph: dict[str, Any], leaf_count: int, visible: int, hidden: int
) -> None:
    summary = graph["summary"]
    require(summary["leaf_count"] == leaf_count, "summary.leaf_count mismatch")
    require(
        summary["browse_default_visible"] == visible,
        "summary.browse_default_visible mismatch",
    )
    require(
        summary["browse_default_hidden"] == hidden,
        "summary.browse_default_hidden mismatch",
    )
    require(
        visible + hidden == leaf_count,
        "visible + hidden must equal leaf count",
    )


def main() -> int:
    if not GRAPH_PATH.exists():
        raise SystemExit(
            f"{GRAPH_PATH.relative_to(ROOT)} is required; run build:navigation first"
        )

    graph = load_json(GRAPH_PATH)
    validate_schema_shape(graph)
    stats = validate_nodes(graph["nodes"])
    validate_children(stats["index"])
    validate_edges(graph["edges"], stats["index"])
    validate_summary_counts(
        graph,
        stats["leaf_count"],
        stats["visible"],
        stats["hidden"],
    )

    require(SCHEMA_PATH.exists(), "schema/navigation.schema.json must exist")
    print(
        "Navigation graph validation passed: "
        f"{stats['leaf_count']} leaves, "
        f"{stats['visible']} visible / {stats['hidden']} hidden, "
        f"{len(graph['edges'])} edges"
    )
    return 0


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