#!/usr/bin/env python3
"""Deterministic cross-surface CHML audit for the workflow engine."""

from __future__ import annotations

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


ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "scripts"))

import prd_config
import feature_skill_audit
import prd_tools
import prd_workflows


REQUIRED_WORKFLOWS = set(prd_config.WORKFLOW_IDS)
REQUIRED_MCP = {
    "prd_workflow_list", "prd_workflow_actions", "prd_workflow_audit",
    "prd_workflow_plan", "prd_workflow_run", "prd_workflow_status",
    "prd_workflow_resume", "prd_workflow_cancel", "prd_workflow_retry",
}


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


def _sha(path: Path) -> str:
    # Skill mirrors are semantically identical across hosts even when Git has
    # materialized platform-specific line endings.
    text = path.read_text(encoding="utf-8-sig").replace("\r\n", "\n").replace("\r", "\n")
    return hashlib.sha256(text.encode("utf-8")).hexdigest()


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

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

    catalog_report = prd_workflows.audit_catalog(root)
    for row in catalog_report.get("findings", []):
        add(row.get("severity", "high"), "catalog", row.get("message", "catalog defect"))
    if catalog_report.get("status") == "ok":
        catalog = prd_workflows.load_catalog(root)
        actual = {row["id"] for row in catalog["workflows"]}
        if actual != REQUIRED_WORKFLOWS:
            add("high", "workflow_coverage", f"catalog ids differ: missing={sorted(REQUIRED_WORKFLOWS-actual)} extra={sorted(actual-REQUIRED_WORKFLOWS)}")
        if not any(step["action"] == "judgment.request" for row in catalog["workflows"] for step in row["steps"]):
            add("high", "judgment_boundary", "catalog has no explicit judgment step")
        if not any(step["action"] == "state.mutate" for row in catalog["workflows"] for step in row["steps"]):
            add("medium", "mutation_coverage", "catalog has no canonical mutation workflow")

    inventory = prd_workflows.action_inventory()["actions"]
    unsafe_components = {"shell", "eval", "exec"}
    unsafe = [
        row["name"]
        for row in inventory
        if unsafe_components.intersection(row["name"].replace("-", ".").split("."))
    ]
    if unsafe:
        add("critical", "action_security", f"unsafe action names: {unsafe}")
    judgment = next((row for row in inventory if row["name"] == "judgment.request"), None)
    if not judgment or judgment["mutation"] != "none":
        add("critical", "judgment_security", "judgment.request must have no mutation authority")

    config_report = prd_config.audit_config(root)
    if config_report.get("status") != "ok":
        add("high", "config", f"config audit is {config_report.get('status')}")

    skill_report = feature_skill_audit.audit_feature_skills(root)
    for row in skill_report.get("findings", []):
        add(
            row.get("severity", "high"),
            "feature_skill",
            f"{row.get('feature')}: {row.get('path')}: {row.get('message')}",
        )
    config = _json(root / ".prd_plugin" / "config.json")
    if set(config.get("workflows", {}).get("enabled_ids", [])) != REQUIRED_WORKFLOWS:
        add("medium", "config_coverage", "workflows.enabled_ids differs from shipped catalog")
    if "WFR" not in config.get("ids", {}).get("required_prefixes", []):
        add("high", "wfr_identity", "config ids.required_prefixes lacks WFR")
    registry = _json(root / ".prd_plugin" / "ids" / "registry.json")
    if not isinstance(registry.get("next", {}).get("WFR"), int):
        add("high", "wfr_identity", "registry next.WFR is missing or invalid")
    try:
        prd_workflows.load_runs(root)
    except prd_workflows.WorkflowError as exc:
        add("high", "run_state", str(exc))

    parity_sets = [
        [root / "templates/workflows.json", root / ".prd_plugin/workflows.json",
         root / "templates/repo-skeleton/.prd_plugin/workflows.json"],
        [root / ".prd_plugin/hooks/prd_hook_dispatch.py",
         root / "templates/repo-skeleton/.prd_plugin/hooks/prd_hook_dispatch.py"],
        [root / "templates/script-install-scope.json",
         root / "templates/repo-skeleton/.prd_plugin/templates/script-install-scope.json"],
        [root / "templates/skill-install-scope.json",
         root / "templates/repo-skeleton/.prd_plugin/templates/skill-install-scope.json"],
    ]
    # The instruction files themselves (REQ-163). Byte parity is wrong for them -
    # they are deliberately worded differently per host - so this checks that the
    # same RULES reach all four and that none has been demoted below the fold.
    try:
        import instruction_parity
        findings.extend(instruction_parity.audit(root))
    except Exception as exc:  # never let a checker bug wall off the audit
        add("high", "instruction_parity", f"check unavailable: {type(exc).__name__}: {exc}")

    for paths in parity_sets:
        missing = [str(path.relative_to(root)) for path in paths if not path.is_file()]
        if missing:
            add("high", "delivery_parity", f"missing delivery files: {missing}")
        elif len({_sha(path) for path in paths}) != 1:
            add("high", "delivery_parity", f"mirrors differ: {[str(path.relative_to(root)) for path in paths]}")

    script_manifest = _json(root / "templates/script-install-scope.json").get("scripts", {})
    if script_manifest.get("prd_workflows.py", {}).get("install_scope") != "downstream_runtime":
        add("high", "installer", "prd_workflows.py is not downstream_runtime")
    skill_manifest = _json(root / "templates/skill-install-scope.json").get("skills", {})
    if not skill_manifest.get("project-deterministic-workflows", {}).get("installed_by_default"):
        add("high", "installer", "deterministic workflow skill is not installed by default")
    downstream_skills = {name for name, meta in skill_manifest.items()
                         if meta.get("install_scope") == "downstream_runtime" and meta.get("installed_by_default")}
    available_downstream_skills = {name for name, meta in skill_manifest.items()
                                   if meta.get("install_scope") in {"downstream_runtime", "downstream_optional"}}
    adapted_skeleton = {"project-git-workflow", "project-prd-plugin-setup", "project-verification-before-completion"}
    mirrors = (
        (root / ".opencode/skill", set(), set(skill_manifest)),
        (root / "templates/repo-skeleton/.agents/skills", adapted_skeleton, downstream_skills),
        (root / "templates/repo-skeleton/.opencode/skill", adapted_skeleton, downstream_skills),
        (root / "templates/repo-skeleton/.claude/skills", adapted_skeleton, downstream_skills),
    )
    for mirror, adapted, expected in mirrors:
        actual = {path.name for path in mirror.iterdir() if path.is_dir() and (path / "SKILL.md").is_file()}
        if actual != expected:
            add("high", "skill_parity", f"{mirror.relative_to(root)} differs: missing={sorted(expected-actual)} extra={sorted(actual-expected)}")
        for name in (actual & expected) - adapted:
            if _sha(mirror / name / "SKILL.md") != _sha(root / "skills" / name / "SKILL.md"):
                add("high", "skill_parity", f"{mirror.relative_to(root)}/{name}/SKILL.md differs from canonical")

    required_files = [
        "scripts/feature_skill_audit.py", "templates/feature-skill-map.json",
        "commands/prd-workflow.md", "skills/project-deterministic-workflows/SKILL.md",
        ".agents/skills/project-deterministic-workflows/SKILL.md",
        ".opencode/skill/project-deterministic-workflows/SKILL.md",
        "templates/repo-skeleton/.agents/skills/project-deterministic-workflows/SKILL.md",
        ".prd_plugin/method/deterministic-workflows.md",
        "templates/repo-skeleton/.prd_plugin/method/deterministic-workflows.md",
        "wiki/workflows/deterministic-workflow-engine.md",
        "tests/test_prd_workflows.py",
    ]
    for relative in required_files:
        if not (root / relative).is_file():
            add("medium", "delivery_surface", f"missing {relative}")

    node = subprocess.run(
        ["node", "-e", "const m=require('./mcp/server.cjs');process.stdout.write(JSON.stringify(m.listTools().map(x=>x.name)))"],
        cwd=root, text=True, capture_output=True, check=False,
    )
    if node.returncode:
        add("high", "mcp", node.stderr.strip() or "cannot list MCP tools")
    else:
        names = set(json.loads(node.stdout))
        if not REQUIRED_MCP.issubset(names):
            add("high", "mcp", f"missing workflow MCP tools: {sorted(REQUIRED_MCP-names)}")
    manual_names = {row["name"] for row in prd_tools.build_manual()["tools"]}
    if "workflow" not in manual_names:
        add("medium", "utcp", "read-only workflow UTCP tool is missing")

    counts = {level: sum(row["severity"] == level for row in findings)
              for level in ("critical", "high", "medium", "low")}
    return {"status": "ok" if not findings else "findings", "repo_root": str(root),
            "summary": counts, "findings": findings}


def main(argv: list[str] | None = None) -> int:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--repo-root", default=".")
    parser.add_argument("--format", choices=("json", "markdown"), default="json")
    args = parser.parse_args(argv)
    result = audit(args.repo_root)
    if args.format == "json":
        print(json.dumps(result, indent=2))
    else:
        print("# Workflow CHML Audit\n")
        print(f"Status: `{result['status']}`\n")
        print(" ".join(f"{key.upper()}: {value}" for key, value in result["summary"].items()))
        for row in result["findings"]:
            print(f"- `{row['severity']}` [{row['check']}]: {row['message']}")
    return 0 if result["status"] == "ok" else 1


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