#!/usr/bin/env python3
"""Build the skills navigation graph for discovery-only browsing."""
from __future__ import annotations

from collections import defaultdict
from pathlib import Path
import json
import re
import subprocess
from typing import Any

import yaml

ROOT = Path(__file__).resolve().parent.parent
DIST = ROOT / "dist"
CATALOG_PATH = DIST / "catalog.json"
MANIFEST_PATH = DIST / "install-manifest.json"
SOURCES_PATH = ROOT / "operations" / "navigation-sources.json"
CORE_MANIFEST_PATH = ROOT / "core" / "manifest.json"
SOURCE_REPO = "architectonic/skills"
FM_RE = re.compile(r"^---\r?\n(.*?)\r?\n---\r?\n", re.S)


def load_json(path: Path) -> dict[str, Any]:
    payload = json.loads(path.read_text(encoding="utf-8"))
    if not isinstance(payload, dict):
        raise ValueError(f"{path.relative_to(ROOT)} must contain a JSON object")
    return payload


def git_head() -> str:
    result = subprocess.run(
        ["git", "rev-parse", "HEAD"],
        cwd=ROOT,
        check=True,
        capture_output=True,
        text=True,
    )
    return result.stdout.strip()


def parse_frontmatter(path: Path) -> dict[str, Any]:
    raw = path.read_text(encoding="utf-8", errors="replace")
    match = FM_RE.match(raw)
    if not match:
        return {}
    try:
        parsed = yaml.safe_load(match.group(1))
    except yaml.YAMLError:
        parsed = {}
    return parsed if isinstance(parsed, dict) else {}


def title_from_path(path: Path) -> str:
    frontmatter = parse_frontmatter(path)
    title = str(frontmatter.get("title") or "").strip()
    if title:
        return title
    stem = path.stem
    return " ".join(part.capitalize() for part in stem.replace("-", " ").split())


def dist_browse_default(entry: dict[str, Any]) -> bool:
    lifecycle = str(entry.get("lifecycle_status") or "unreviewed")
    recommendation = str(entry.get("install_recommendation") or "inspect")
    if lifecycle in {"superseded", "blocked"}:
        return False
    if recommendation == "do-not-install":
        return False
    if entry.get("catalog_decision"):
        return True
    if lifecycle == "reviewed":
        return True
    if recommendation == "conditional":
        return True
    return False


def dist_leaf_key(entry: dict[str, Any]) -> str:
    directory = str(entry.get("directory") or "").replace("\\", "/").rstrip("/")
    if directory:
        return directory.split("/")[-1]
    path = str(entry.get("path") or "").replace("\\", "/")
    if path:
        parts = path.split("/")
        if len(parts) >= 2:
            return parts[-2]
    slug = str(entry.get("slug") or "unknown")
    return re.sub(r"[^a-zA-Z0-9._-]+", "-", slug).strip("-") or "unknown"


class GraphBuilder:
    def __init__(self) -> None:
        self.nodes: list[dict[str, Any]] = []
        self.edges: list[dict[str, Any]] = []
        self.index: dict[str, dict[str, Any]] = {}
        self.slug_to_leaf: dict[str, str] = {}
        self.path_to_leaf: dict[str, str] = {}
        self.catalog_path_to_leaf: dict[str, str] = {}

    def register_leaf_lookup(
        self,
        node_id: str,
        *,
        slug: str = "",
        source_path: str = "",
        catalog_path: str = "",
    ) -> None:
        if slug and slug not in self.slug_to_leaf:
            self.slug_to_leaf[slug] = node_id
        if source_path:
            self.path_to_leaf[source_path.replace("\\", "/")] = node_id
        if catalog_path:
            self.catalog_path_to_leaf[catalog_path.replace("\\", "/")] = node_id

    def add_node(self, node: dict[str, Any]) -> None:
        if node["id"] in self.index:
            raise ValueError(f"duplicate navigation node id: {node['id']}")
        self.nodes.append(node)
        self.index[node["id"]] = node
        if node["kind"] == "leaf":
            ref = node.get("ref", {})
            self.register_leaf_lookup(
                node["id"],
                slug=str(ref.get("slug") or "").strip(),
                source_path=str(ref.get("source_path") or ""),
            )

    def add_edge(self, from_id: str, to_id: str, kind: str = "superseded_by") -> None:
        self.edges.append({"from": from_id, "to": to_id, "kind": kind})

    def leaf(
        self,
        node_id: str,
        title: str,
        source_path: str,
        *,
        slug: str = "",
        browse_default: bool = True,
        description: str = "",
        trust: dict[str, Any] | None = None,
    ) -> str:
        ref: dict[str, str] = {
            "source_repo": SOURCE_REPO,
            "source_path": source_path.replace("\\", "/"),
        }
        if slug:
            ref["slug"] = slug
        node: dict[str, Any] = {
            "id": node_id,
            "kind": "leaf",
            "title": title,
            "browse_default": browse_default,
            "ref": ref,
        }
        if description:
            node["description"] = description
        if trust:
            node["trust"] = trust
        self.add_node(node)
        return node_id

    def container(
        self,
        node_id: str,
        kind: str,
        title: str,
        children: list[str],
        *,
        description: str = "",
    ) -> str:
        node: dict[str, Any] = {
            "id": node_id,
            "kind": kind,
            "title": title,
            "children": children,
        }
        if description:
            node["description"] = description
        self.add_node(node)
        return node_id


def validate_core_groups(sources: dict[str, Any], manifest: dict[str, Any]) -> None:
    manifest_skills = list(manifest.get("skills") or [])
    grouped: list[str] = []
    for group in (sources.get("core_groups") or {}).values():
        grouped.extend(group.get("skills") or [])
    if sorted(grouped) != sorted(manifest_skills):
        missing = sorted(set(manifest_skills) - set(grouped))
        extra = sorted(set(grouped) - set(manifest_skills))
        raise ValueError(
            "navigation core_groups must cover core/manifest.json exactly; "
            f"missing={missing!r} extra={extra!r}"
        )


def build_core_section(builder: GraphBuilder, sources: dict[str, Any]) -> str:
    group_ids: list[str] = []
    for group_key, group in (sources.get("core_groups") or {}).items():
        leaf_ids: list[str] = []
        for skill_id in group.get("skills") or []:
            source_path = f"core/skills/{skill_id}.md"
            path = ROOT / source_path
            if not path.exists():
                raise ValueError(f"missing core skill file: {source_path}")
            leaf_ids.append(
                builder.leaf(
                    f"leaf:core:{skill_id}",
                    title_from_path(path),
                    source_path,
                    slug=skill_id,
                    browse_default=True,
                )
            )
        group_ids.append(
            builder.container(
                f"group:core:{group_key}",
                "group",
                str(group.get("title") or group_key),
                leaf_ids,
            )
        )
    return builder.container(
        "section:reviewed-core",
        "section",
        "Reviewed core",
        group_ids,
        description="First-party reviewed procedures enumerated in core/manifest.json.",
    )


def build_pack_section(builder: GraphBuilder, sources: dict[str, Any]) -> str:
    pack_ids: list[str] = []
    for pack_key, pack in (sources.get("packs") or {}).items():
        if "catalog" in pack:
            pack_ids.append(build_marketing_pack(builder, pack_key, pack))
        else:
            pack_ids.append(build_flat_pack(builder, pack_key, pack))
    return builder.container(
        "section:reviewed-packs",
        "section",
        "Reviewed packs",
        pack_ids,
        description="Bounded reviewed skill packs with explicit provenance.",
    )


def build_marketing_pack(
    builder: GraphBuilder, pack_key: str, pack: dict[str, Any]
) -> str:
    catalog_path = ROOT / str(pack["catalog"])
    catalog = load_json(catalog_path)
    by_category: dict[str, list[dict[str, Any]]] = defaultdict(list)
    for skill in catalog.get("skills") or []:
        by_category[str(skill.get("category") or "uncategorized")].append(skill)

    category_ids: list[str] = []
    for category in sorted(by_category):
        leaf_ids: list[str] = []
        router_path = f"skills/marketing/marketing-{category}.md"
        for skill in by_category[category]:
            skill_id = str(skill.get("id") or "")
            leaf_ids.append(
                builder.leaf(
                    f"leaf:pack:{pack_key}:{skill_id}",
                    str(skill.get("title") or skill_id),
                    router_path,
                    slug=skill_id,
                    browse_default=True,
                )
            )
        category_ids.append(
            builder.container(
                f"category:{pack_key}:{category}",
                "category",
                category.replace("-", " ").title(),
                leaf_ids,
            )
        )

    orchestrator_id = builder.leaf(
        f"leaf:pack:{pack_key}:orchestrator",
        "Marketing Orchestrator",
        "skills/marketing/marketing-orchestrator.md",
        slug="marketing-orchestrator",
        browse_default=True,
    )
    return builder.container(
        f"pack:{pack_key}",
        "pack",
        str(pack.get("title") or catalog.get("title") or pack_key),
        [orchestrator_id, *category_ids],
    )


def build_flat_pack(builder: GraphBuilder, pack_key: str, pack: dict[str, Any]) -> str:
    child_ids: list[str] = []
    orchestrator_path = str(pack.get("orchestrator") or "").replace("\\", "/")
    if orchestrator_path:
        path = ROOT / orchestrator_path
        if not path.exists():
            raise ValueError(f"missing pack orchestrator file: {orchestrator_path}")
        slug = path.stem
        child_ids.append(
            builder.leaf(
                f"leaf:pack:{pack_key}:orchestrator",
                title_from_path(path),
                orchestrator_path,
                slug=slug,
                browse_default=True,
            )
        )
    for source_path in pack.get("skills") or []:
        normalized = str(source_path).replace("\\", "/")
        if orchestrator_path and normalized == orchestrator_path:
            continue
        path = ROOT / normalized
        if not path.exists():
            raise ValueError(f"missing pack skill file: {normalized}")
        slug = path.stem
        child_ids.append(
            builder.leaf(
                f"leaf:pack:{pack_key}:{slug}",
                title_from_path(path),
                normalized,
                slug=slug,
                browse_default=True,
            )
        )
    return builder.container(
        f"pack:{pack_key}",
        "pack",
        str(pack.get("title") or pack_key),
        child_ids,
    )


def pack_skill_paths(sources: dict[str, Any]) -> set[str]:
    claimed: set[str] = set()
    for pack in (sources.get("packs") or {}).values():
        for source_path in pack.get("skills") or []:
            claimed.add(str(source_path).replace("\\", "/"))
        orchestrator = str(pack.get("orchestrator") or "").replace("\\", "/")
        if orchestrator:
            claimed.add(orchestrator)
    return claimed


def find_unclaimed_working_skills(sources: dict[str, Any], manifest: dict[str, Any]) -> list[str]:
    claimed = pack_skill_paths(sources)
    core_skills = {f"skills/{skill_id}.md" for skill_id in (manifest.get("skills") or [])}
    skills_dir = ROOT / "skills"
    unclaimed: list[str] = []
    for path in sorted(skills_dir.glob("*.md")):
        if path.name == "index.md":
            continue
        rel = path.relative_to(ROOT).as_posix()
        if rel in claimed or rel in core_skills:
            continue
        unclaimed.append(rel)
    return unclaimed


def dist_trust_metadata(entry: dict[str, Any]) -> dict[str, Any]:
    return {
        "lifecycle_status": str(entry.get("lifecycle_status") or "unreviewed"),
        "install_recommendation": str(entry.get("install_recommendation") or "inspect"),
        "risk_level": str(entry.get("risk_level") or "unspecified"),
        "classification_status": str(entry.get("classification_status") or "unclassified"),
        "catalog_decision": bool(entry.get("catalog_decision")),
    }


def build_distribution_section(
    builder: GraphBuilder, catalog: dict[str, Any]
) -> str:
    entries = catalog.get("skills") or []
    by_domain_kind: dict[str, dict[str, list[dict[str, Any]]]] = defaultdict(
        lambda: defaultdict(list)
    )
    for entry in entries:
        domain = str(entry.get("domain") or "uncategorized")
        kind = str(entry.get("artifact_kind") or "playbook")
        by_domain_kind[domain][kind].append(entry)

    domain_ids: list[str] = []
    for domain in sorted(by_domain_kind):
        group_ids: list[str] = []
        for kind in sorted(by_domain_kind[domain]):
            leaf_ids: list[str] = []
            for entry in sorted(
                by_domain_kind[domain][kind], key=lambda row: dist_leaf_key(row)
            ):
                slug = str(entry.get("slug") or "")
                leaf_key = dist_leaf_key(entry)
                source_path = str(
                    entry.get("path") or f"dist/skills/{leaf_key}/SKILL.md"
                ).replace("\\", "/")
                node_id = builder.leaf(
                    f"leaf:dist:{leaf_key}",
                    str(entry.get("title") or slug or leaf_key),
                    source_path,
                    slug=slug or leaf_key,
                    browse_default=dist_browse_default(entry),
                    description=str(entry.get("description") or ""),
                    trust=dist_trust_metadata(entry),
                )
                builder.register_leaf_lookup(
                    node_id,
                    slug=slug,
                    source_path=source_path,
                    catalog_path=source_path,
                )
                leaf_ids.append(node_id)
            group_ids.append(
                builder.container(
                    f"group:dist:{domain}:{kind}",
                    "group",
                    kind.replace("-", " ").title(),
                    leaf_ids,
                )
            )
        domain_ids.append(
            builder.container(
                f"category:dist:{domain}",
                "category",
                domain.replace("-", " ").title(),
                group_ids,
            )
        )
    return builder.container(
        "section:distribution",
        "section",
        "Distribution catalog",
        domain_ids,
        description="Generated external registry organized by domain and artifact kind.",
    )


def resolve_supersession_target(builder: GraphBuilder, target: str) -> str | None:
    normalized = target.replace("\\", "/").strip()
    if not normalized:
        return None
    if normalized in builder.path_to_leaf:
        return builder.path_to_leaf[normalized]
    basename = Path(normalized).stem
    if basename in builder.slug_to_leaf:
        return builder.slug_to_leaf[basename]
    if normalized in builder.slug_to_leaf:
        return builder.slug_to_leaf[normalized]
    return None


def add_supersession_edges(builder: GraphBuilder, catalog: dict[str, Any]) -> None:
    for entry in catalog.get("skills") or []:
        target = str(entry.get("superseded_by") or "").strip()
        if not target:
            continue
        catalog_path = str(entry.get("path") or "").replace("\\", "/")
        from_id = builder.catalog_path_to_leaf.get(catalog_path) or builder.slug_to_leaf.get(
            str(entry.get("slug") or "")
        )
        to_id = resolve_supersession_target(builder, target)
        if from_id and to_id:
            builder.add_edge(from_id, to_id, "superseded_by")


def build_markdown(graph: dict[str, Any]) -> str:
    summary = graph["summary"]
    index = {node["id"]: node for node in graph["nodes"]}
    lines = [
        "# Skills Navigation Graph",
        "",
        f"- Source repo: `{graph['source_repo']}`",
        f"- Source ref: `{graph['source_ref']}`",
        f"- Nodes: **{summary['node_count']}**",
        f"- Leaves: **{summary['leaf_count']}**",
        f"- Browse default visible: **{summary['browse_default_visible']}**",
        f"- Browse default hidden: **{summary['browse_default_hidden']}**",
        f"- Supersession edges: **{summary['edge_count']}**",
        "",
        "Discovery-only navigation surface. `browse_default=true` means show in default browse; "
        "it does not grant runtime authority.",
        "",
    ]

    def walk(node_id: str, depth: int = 0) -> None:
        node = index[node_id]
        indent = "  " * depth
        if node["kind"] == "leaf":
            visible = "visible" if node.get("browse_default") else "hidden"
            slug = node.get("ref", {}).get("slug") or ""
            slug_suffix = f" (`{slug}`)" if slug else ""
            lines.append(f"{indent}- {node['title']}{slug_suffix} — {visible}")
            return
        lines.append(f"{indent}- **{node['title']}** ({node['kind']})")
        for child_id in node.get("children") or []:
            walk(child_id, depth + 1)

    walk("root")
    if graph["edges"]:
        lines.extend(["", "## Supersession edges", ""])
        for edge in graph["edges"]:
            from_title = index[edge["from"]]["title"]
            to_title = index[edge["to"]]["title"]
            lines.append(f"- `{from_title}` → `{to_title}`")
    return "\n".join(lines) + "\n"


def update_install_manifest() -> None:
    manifest = load_json(MANIFEST_PATH)
    discovery = list(manifest.get("discovery_files") or [])
    if "dist/navigation.json" not in discovery:
        discovery.append("dist/navigation.json")
    manifest["discovery_files"] = discovery
    entrypoints = dict(manifest.get("recommended_entrypoints") or {})
    entrypoints["navigation"] = "dist/navigation.json"
    manifest["recommended_entrypoints"] = entrypoints
    MANIFEST_PATH.write_text(
        json.dumps(manifest, indent=2, ensure_ascii=False) + "\n",
        encoding="utf-8",
    )


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

    catalog = load_json(CATALOG_PATH)
    sources = load_json(SOURCES_PATH)
    manifest = load_json(CORE_MANIFEST_PATH)
    validate_core_groups(sources, manifest)

    builder = GraphBuilder()
    unclaimed = find_unclaimed_working_skills(sources, manifest)
    if unclaimed:
        raise ValueError(
            "skills/*.md files are not assigned to any pack in navigation-sources.json: "
            f"{unclaimed}"
        )

    children = [
        build_core_section(builder, sources),
        build_pack_section(builder, sources),
        build_distribution_section(builder, catalog),
    ]
    builder.container("root", "root", "Architectonic Skills", children)

    add_supersession_edges(builder, catalog)

    leaves = [node for node in builder.nodes if node["kind"] == "leaf"]
    visible = sum(1 for node in leaves if node.get("browse_default"))
    hidden = len(leaves) - visible
    graph = {
        "schema_version": "0.1",
        "source_repo": SOURCE_REPO,
        "source_ref": git_head(),
        "summary": {
            "node_count": len(builder.nodes),
            "leaf_count": len(leaves),
            "browse_default_visible": visible,
            "browse_default_hidden": hidden,
            "edge_count": len(builder.edges),
        },
        "nodes": builder.nodes,
        "edges": builder.edges,
    }

    (DIST / "navigation.json").write_text(
        json.dumps(graph, indent=2, ensure_ascii=False) + "\n",
        encoding="utf-8",
    )
    (DIST / "navigation.md").write_text(build_markdown(graph), encoding="utf-8")
    update_install_manifest()

    print(
        "Navigation graph built: "
        f"{graph['summary']['node_count']} nodes, "
        f"{graph['summary']['leaf_count']} leaves, "
        f"{visible} visible / {hidden} hidden, "
        f"{graph['summary']['edge_count']} edges"
    )


if __name__ == "__main__":
    main()
