#!/usr/bin/env python3
"""Validate the shipped PRD-to-Substrate capability and intent catalog."""

from __future__ import annotations

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

import prd_config


SCHEMA_VERSION = "1.0"
POLICIES = {"integrated", "opt_in", "discover_only", "local_only"}
FALLBACKS = {"local", "skip", "fail"}
OPERATIONS = {"observe", "recall", "act", "orchestrate"}
SCOPES = {"always", "relevant_only", "explicit_only"}
HUB_RUNTIME_SCRIPTS = {
    "prd_substrate.py", "prd_substrate_catalog.py", "prd_substrate_links.py",
    "prd_runtime_worker.py", "prd_services.py", "prd_test_scope.py",
}
HUB_MCP_TOOLS = {
    "prd_service_audit", "prd_service_list", "prd_service_get",
    "prd_service_upsert", "prd_service_remove", "prd_substrate_catalog",
    "prd_substrate_links", "prd_substrate_call", "prd_substrate_runtime",
    "prd_reporting_validate",
}
HUB_WORKFLOW_ACTIONS = {
    "services.audit", "substrate.preflight", "substrate.enrich",
    "substrate.notices", "substrate.telemetry", "substrate.goals",
    "reporting.delegate", "verification.execute",
}
HUB_AUTOMATION_KEYS = {
    "discovery_on_session_start", "knowledge_recall", "memory_recall",
    "context_enrichment", "notices_on_session_start", "goals_sync",
    "telemetry_on_maintenance", "reporting_dispatch", "judgment_dispatch",
    "verification_execution",
}


class CatalogError(ValueError):
    """The shipped capability catalog is missing or malformed."""


def load_catalog(repo_root: str | Path = ".") -> dict[str, Any]:
    root = Path(repo_root).resolve()
    candidates = [
        root / ".prd_plugin" / "templates" / "substrate-capabilities.json",
        root / "templates" / "substrate-capabilities.json",
    ]
    path = next((candidate for candidate in candidates if candidate.is_file()), candidates[0])
    try:
        data = json.loads(path.read_text(encoding="utf-8-sig"))
    except (OSError, json.JSONDecodeError) as exc:
        raise CatalogError(f"cannot load {path}: {exc}") from exc
    if not isinstance(data, dict) or data.get("schema_version") != SCHEMA_VERSION:
        raise CatalogError("substrate capability catalog must be a schema 1.0 object")
    if not isinstance(data.get("capabilities"), list):
        raise CatalogError("substrate capability catalog must contain capabilities[]")
    return data


def runtime_tools(data: dict[str, Any]) -> set[str]:
    return {
        tool
        for row in data.get("capabilities", [])
        if isinstance(row, dict)
        for tool in row.get("tools", [])
        if isinstance(tool, str) and tool
    }


def intent(data: dict[str, Any], intent_id: str) -> dict[str, Any]:
    matches = [
        {**item, "capability": row.get("id")}
        for row in data.get("capabilities", [])
        if isinstance(row, dict)
        for item in row.get("intents", [])
        if isinstance(item, dict) and item.get("id") == intent_id
    ]
    if len(matches) != 1:
        raise CatalogError(f"intent {intent_id!r} must resolve exactly once")
    return matches[0]


def audit_catalog(repo_root: str | Path = ".") -> dict[str, Any]:
    findings: list[dict[str, str]] = []

    def add(severity: str, code: str, message: str) -> None:
        findings.append({"severity": severity, "code": code, "message": message})

    root = Path(repo_root).resolve()
    try:
        data = load_catalog(repo_root)
    except CatalogError as exc:
        add("critical", "catalog_load", str(exc))
        data = {"capabilities": []}
    rows = data.get("capabilities", [])
    ids = [row.get("id") for row in rows if isinstance(row, dict)]
    for duplicate in sorted({value for value in ids if ids.count(value) > 1}):
        add("critical", "duplicate_capability", f"duplicate capability: {duplicate}")
    for missing in sorted(set(prd_config.SUBSTRATE_CAPABILITIES) - set(ids)):
        add("high", "missing_capability", f"missing configured capability: {missing}")
    for unknown in sorted(set(ids) - set(prd_config.SUBSTRATE_CAPABILITIES)):
        add("medium", "unknown_capability", f"catalog capability is not configurable: {unknown}")

    intent_ids: list[str] = []
    tool_owners: dict[str, list[str]] = {}
    for row in rows:
        if not isinstance(row, dict):
            add("critical", "invalid_capability", "capability rows must be objects")
            continue
        cap = str(row.get("id", "<missing>"))
        if row.get("policy") not in POLICIES:
            add("high", "invalid_policy", f"{cap} has invalid policy")
        if row.get("fallback") not in FALLBACKS:
            add("high", "invalid_fallback", f"{cap} has invalid fallback")
        tools = row.get("tools", [])
        if not isinstance(tools, list) or any(not isinstance(tool, str) or not tool for tool in tools):
            add("high", "invalid_tools", f"{cap} tools must be non-empty strings")
            tools = []
        for duplicate in sorted({value for value in tools if tools.count(value) > 1}):
            add("medium", "duplicate_tool", f"{cap} contains duplicate tool: {duplicate}")
        for tool in tools:
            tool_owners.setdefault(tool, []).append(cap)
        dependencies = row.get("uses_capabilities", [])
        if not isinstance(dependencies, list):
            add("high", "invalid_capability_dependency", f"{cap} uses_capabilities must be an array")
        else:
            for dependency in dependencies:
                if dependency not in ids:
                    add("high", "unknown_capability_dependency", f"{cap} depends on unknown capability: {dependency}")
        intents = row.get("intents", [])
        if not isinstance(intents, list) or not intents:
            add("high", "missing_intent", f"{cap} must declare at least one intent")
            continue
        for item in intents:
            if not isinstance(item, dict) or not isinstance(item.get("id"), str) or not item["id"]:
                add("high", "invalid_intent", f"{cap} contains an invalid intent")
                continue
            intent_ids.append(item["id"])
            if item.get("operation") not in OPERATIONS:
                add("medium", "invalid_operation", f"{item['id']} has invalid operation")
            if item.get("workflow_scope") not in SCOPES:
                add("medium", "invalid_scope", f"{item['id']} has invalid workflow_scope")
            if not isinstance(item.get("workflows"), list):
                add("low", "invalid_workflows", f"{item['id']} workflows must be an array")
            else:
                for workflow in item["workflows"]:
                    if workflow not in prd_config.WORKFLOW_IDS:
                        add("high", "unknown_workflow", f"{item['id']} maps unknown workflow: {workflow}")
    for duplicate in sorted({value for value in intent_ids if intent_ids.count(value) > 1}):
        add("high", "duplicate_intent", f"duplicate intent id: {duplicate}")
    for tool, owners in sorted(tool_owners.items()):
        if len(owners) > 1:
            add("medium", "ambiguous_tool_owner", f"{tool} is mapped by multiple capabilities: {', '.join(owners)}")
    expected_count = data.get("runtime_contract", {}).get("tool_count")
    if expected_count != len(runtime_tools(data)):
        add(
            "high",
            "runtime_tool_count",
            f"catalog maps {len(runtime_tools(data))} unique tools but runtime_contract.tool_count is {expected_count}",
        )
    _audit_hub_surfaces(root, add)
    summary = {level: sum(1 for row in findings if row["severity"] == level) for level in ("critical", "high", "medium", "low")}
    return {
        "status": "ok" if not findings else "error",
        "schema_version": SCHEMA_VERSION,
        "capability_count": len(rows),
        "runtime_tool_count": len(runtime_tools(data)),
        "intent_count": len(intent_ids),
        "summary": summary,
        "findings": findings,
    }


def _audit_hub_surfaces(root: Path, add: Any) -> None:
    """Check release surfaces only in the PRD Plugin hub, not downstream repos."""
    package_path = root / "package.json"
    if not package_path.is_file():
        return
    try:
        package = json.loads(package_path.read_text(encoding="utf-8-sig"))
    except (OSError, json.JSONDecodeError):
        return
    if package.get("name") != "prd-plugin":
        return

    config_paths = (
        root / ".prd_plugin" / "config.json",
        root / "templates" / "config.json",
        root / "templates" / "repo-skeleton" / ".prd_plugin" / "config.json",
        root / "templates" / "repo-skeleton" / ".prd_plugin" / "templates" / "config.json",
    )
    for path in config_paths:
        try:
            substrate = json.loads(path.read_text(encoding="utf-8-sig"))["integrations"]["substrate"]
        except (OSError, json.JSONDecodeError, KeyError, TypeError) as exc:
            add("high", "hub_config_surface", f"{path.relative_to(root)} cannot provide Substrate config: {exc}")
            continue
        if substrate.get("contract_version") != 2:
            add("high", "hub_contract_version", f"{path.relative_to(root)} must use Substrate contract 2")
        missing_automation = HUB_AUTOMATION_KEYS - set(substrate.get("automation", {}))
        if missing_automation:
            add("high", "hub_automation_surface", f"{path.relative_to(root)} misses automation toggles: {sorted(missing_automation)}")

    scope_path = root / "templates" / "script-install-scope.json"
    try:
        scope = json.loads(scope_path.read_text(encoding="utf-8-sig"))
        scripts = set(scope.get("scripts", {}))
    except (OSError, json.JSONDecodeError, AttributeError) as exc:
        add("high", "hub_script_scope", f"cannot load downstream script scope: {exc}")
        scripts = set()
    missing_scripts = HUB_RUNTIME_SCRIPTS - scripts
    if missing_scripts:
        add("high", "hub_script_scope", f"downstream script scope misses: {sorted(missing_scripts)}")

    workflow_path = root / "templates" / "workflows.json"
    try:
        workflows = json.loads(workflow_path.read_text(encoding="utf-8-sig"))["workflows"]
        actions = {step.get("action") for row in workflows for step in row.get("steps", [])}
    except (OSError, json.JSONDecodeError, KeyError, TypeError) as exc:
        add("high", "hub_workflow_surface", f"cannot load workflow catalog: {exc}")
        actions = set()
    missing_actions = HUB_WORKFLOW_ACTIONS - actions
    if missing_actions:
        add("high", "hub_workflow_surface", f"workflow catalog misses: {sorted(missing_actions)}")

    mcp_path = root / "mcp" / "server.cjs"
    try:
        mcp_text = mcp_path.read_text(encoding="utf-8-sig")
    except OSError as exc:
        add("high", "hub_mcp_surface", f"cannot read MCP server: {exc}")
        mcp_text = ""
    missing_mcp = {name for name in HUB_MCP_TOOLS if f'name: "{name}"' not in mcp_text}
    if missing_mcp:
        add("high", "hub_mcp_surface", f"MCP server misses: {sorted(missing_mcp)}")

    service_paths = (
        root / ".prd_plugin" / "services.json",
        root / "templates" / "services.json",
        root / "templates" / "repo-skeleton" / ".prd_plugin" / "services.json",
    )
    service_values: list[dict[str, Any]] = []
    for path in service_paths:
        try:
            service_values.append(json.loads(path.read_text(encoding="utf-8-sig")))
        except (OSError, json.JSONDecodeError) as exc:
            add("high", "hub_service_surface", f"cannot load {path.relative_to(root)}: {exc}")
    # Parity is modulo REPOSITORY IDENTITY (REQ-127): templates ship the
    # placeholder id because they cannot know the repo name, while an installed
    # repo (including this hub) resolves it. Identity is per-repo by design;
    # everything else must still match.
    def _without_identity(value):
        return {key: item for key, item in value.items() if key != "repository"}

    def _identity_shape(value):
        repository = value.get("repository")
        if not isinstance(repository, dict):
            return repository
        return {key: item for key, item in repository.items() if key != "id"}

    if len(service_values) == len(service_paths) and (
        any(_without_identity(value) != _without_identity(service_values[0])
            for value in service_values[1:])
        or any(_identity_shape(value) != _identity_shape(service_values[0])
               for value in service_values[1:])
    ):
        add("high", "hub_service_parity", "hub and downstream service manifest defaults differ")
    if service_values:
        consumer = next((row for row in service_values[0].get("consumes", []) if row.get("id") == "ai-collab-substrate"), None)
        if not consumer or consumer.get("contract_versions") != ["2"]:
            add("high", "hub_service_contract", "AI-Collab service declaration must advertise contract 2")

    feature_path = root / "templates" / "feature-skill-map.json"
    try:
        feature_ids = {row.get("id") for row in json.loads(feature_path.read_text(encoding="utf-8-sig"))["features"]}
    except (OSError, json.JSONDecodeError, KeyError, TypeError) as exc:
        add("high", "hub_skill_surface", f"cannot load feature-to-skill map: {exc}")
        feature_ids = set()
    required_features = {"repository_service_manifest", "substrate_runtime_v2", "deterministic_verification_execution"}
    missing_features = required_features - feature_ids
    if missing_features:
        add("high", "hub_skill_surface", f"feature-to-skill map misses: {sorted(missing_features)}")


def main(argv: list[str] | None = None) -> int:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--repo-root", default=".")
    parser.add_argument("command", choices=("audit", "list", "intent"))
    parser.add_argument("value", nargs="?")
    args = parser.parse_args(argv)
    try:
        data = load_catalog(args.repo_root)
        if args.command == "audit":
            result = audit_catalog(args.repo_root)
        elif args.command == "list":
            result = data
        else:
            if not args.value:
                raise CatalogError("intent command requires an intent id")
            result = intent(data, args.value)
    except CatalogError as exc:
        print(json.dumps({"status": "error", "error": str(exc)}, indent=2))
        return 2
    print(json.dumps(result, indent=2))
    return 0 if result.get("status", "ok") == "ok" else 1


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