#!/usr/bin/env python3
"""Validate repo-scoped Codex agent orchestration surfaces."""

from __future__ import annotations

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

try:
    import tomllib
except ModuleNotFoundError as exc:  # pragma: no cover
    raise SystemExit(f"tomllib is required: {exc}") from exc


DEFAULT_ROOT = Path(__file__).resolve().parents[4]
CONFIG_RELATIVE_PATH = Path(".codex/config.toml")
AGENTS_RELATIVE_PATH = Path(".codex/agents")
SKILLS_RELATIVE_PATH = Path(".codex/skills")
WORKFLOWS_RELATIVE_PATH = Path(".codex/workflows")
DELEGATION_ROUTER_RELATIVE_PATH = Path(
    ".codex/skills/agent-orchestration-governance/scripts/delegation_router.py"
)

EXPECTED_AGENTS = {
    "analytics_observability_architect",
    "governor",
    "reviewer",
    "repo_operator",
    "rca_investigator",
    "data_model_architect",
    "frontend_architect",
    "backend_architect",
    "mobile_native_architect",
    "product_docs_architect",
    "security_consent_auditor",
    "voice_systems_architect",
}
REQUIRED_KEYS = {"name", "description", "developer_instructions", "sandbox_mode"}
READ_ONLY_BASELINE = EXPECTED_AGENTS
ALLOWED_REASONING_EFFORTS = {"high", "xhigh"}
NICKNAME_RE = re.compile(r"^[A-Za-z0-9 _-]+$")
SKILL_BLOCK_HEADER = "Use these repo-local skills when they fit the lane:"
GOVERNOR_AUTHORITY_RULE = "only you may produce final merge, deploy, or plan recommendations"
NON_GOVERNOR_AUTHORITY_RULE = "You are advisory-only. Do not self-authorize merge, deploy, release, or governance decisions."
TRUTH_FIRST_HEADER = "Truth-first protocol:"
TRUTH_FIRST_TOKENS = [
    "already_exists",
    "partially_exists",
    "missing",
    "future_state_only",
    "wrong_direction",
    "needs_verification",
    "claim_inspected",
    "classification",
    "evidence_checked",
    "current_repo_truth",
    "real_gap",
    "suggested_boundary",
    "risk_if_prompt_is_accepted_blindly",
    "scope_covered",
    "inspected_surfaces",
    "assumptions",
    "validations_run",
    "unresolved_risks",
]


def load_toml(path: Path) -> dict:
    try:
        return tomllib.loads(path.read_text(encoding="utf-8"))
    except Exception as exc:  # pragma: no cover - surfaced directly to CLI
        raise ValueError(f"{path}: failed to parse TOML: {exc}") from exc


def load_skill_ids(root: Path) -> set[str]:
    skill_ids: set[str] = set()
    for manifest_path in (root / SKILLS_RELATIVE_PATH).glob("*/skill.json"):
        try:
            manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
        except Exception as exc:  # pragma: no cover - surfaced directly to CLI
            raise ValueError(f"{manifest_path}: failed to parse JSON: {exc}") from exc
        skill_id = manifest.get("id")
        if isinstance(skill_id, str) and skill_id:
            skill_ids.add(skill_id)
    return skill_ids


def validate_config(root: Path, errors: list[str]) -> None:
    config_path = root / CONFIG_RELATIVE_PATH
    if not config_path.exists():
        errors.append(f"missing config file: {config_path}")
        return
    config = load_toml(config_path)
    agents = config.get("agents")
    if not isinstance(agents, dict):
        errors.append(f"{config_path}: missing [agents] table")
        return
    if agents.get("max_threads") != 6:
        errors.append(f"{config_path}: agents.max_threads must equal 6")
    if agents.get("max_depth") != 1:
        errors.append(f"{config_path}: agents.max_depth must equal 1")


def validate_delegation_router(root: Path, errors: list[str]) -> None:
    router_path = root / DELEGATION_ROUTER_RELATIVE_PATH
    if not router_path.exists():
        errors.append(f"missing delegation router: {router_path}")
        return
    router_text = router_path.read_text(encoding="utf-8")
    required_markers = [
        "auto_spawn_authorized",
        "parent_authority",
        "default_reasoning_effort",
        "repo_global_auto_spawn",
        "repo_intent_lane_match",
        "phase",
    ]
    for marker in required_markers:
        if marker not in router_text:
            errors.append(f"{router_path}: delegation router missing marker '{marker}'")


def parse_skill_block(path: Path, instructions: str, errors: list[str]) -> list[str]:
    match = re.search(
        rf"{re.escape(SKILL_BLOCK_HEADER)}\n(?P<block>(?:- [^\n]+\n)+)",
        instructions,
    )
    if not match:
        errors.append(f"{path}: developer_instructions must include the standardized skill block header")
        return []

    block = match.group("block").strip().splitlines()
    skills = []
    for line in block:
        if not line.startswith("- "):
            errors.append(f"{path}: malformed skill line in developer_instructions: {line}")
            continue
        skills.append(line[2:].strip())
    return skills


def validate_agent_file(path: Path, skill_ids: set[str], seen_names: set[str], errors: list[str]) -> None:
    agent = load_toml(path)
    missing = sorted(REQUIRED_KEYS - agent.keys())
    if missing:
        errors.append(f"{path}: missing required keys: {', '.join(missing)}")
        return

    name = agent["name"]
    if not isinstance(name, str) or not name:
        errors.append(f"{path}: name must be a non-empty string")
        return
    description = agent["description"]
    if not isinstance(description, str) or not description.strip():
        errors.append(f"{path}: description must be a non-empty string")
    instructions = agent["developer_instructions"]
    if not isinstance(instructions, str) or not instructions.strip():
        errors.append(f"{path}: developer_instructions must be a non-empty string")
        return
    sandbox_mode = agent["sandbox_mode"]
    if not isinstance(sandbox_mode, str) or not sandbox_mode.strip():
        errors.append(f"{path}: sandbox_mode must be a non-empty string")
        return
    reasoning_effort = agent.get("default_reasoning_effort")
    if name in EXPECTED_AGENTS:
        if reasoning_effort not in ALLOWED_REASONING_EFFORTS:
            errors.append(
                f"{path}: baseline agents must set default_reasoning_effort to high or xhigh"
            )

    if path.stem != name:
        errors.append(f"{path}: filename stem must match name '{name}'")

    if name in seen_names:
        errors.append(f"{path}: duplicate agent name '{name}'")
    seen_names.add(name)

    if sandbox_mode != "read-only" and name in READ_ONLY_BASELINE:
        errors.append(f"{path}: wave-1 baseline agent '{name}' must use sandbox_mode = \"read-only\"")

    referenced_skills = parse_skill_block(path, instructions, errors)
    for skill_id in referenced_skills:
        if skill_id not in skill_ids:
            errors.append(f"{path}: referenced repo-local skill does not exist: {skill_id}")

    if TRUTH_FIRST_HEADER not in instructions:
        errors.append(f"{path}: missing truth-first protocol block")
    for token in TRUTH_FIRST_TOKENS:
        if token not in instructions:
            errors.append(f"{path}: truth-first protocol missing token '{token}'")

    instructions_lower = instructions.lower()
    if name == "governor":
        if GOVERNOR_AUTHORITY_RULE not in instructions_lower:
            errors.append(f"{path}: governor must explicitly contain the final-authority rule")
    else:
        if NON_GOVERNOR_AUTHORITY_RULE not in instructions:
            errors.append(f"{path}: non-governor baseline agents must explicitly contain the advisory-only rule")

    nicknames = agent.get("nickname_candidates")
    if nicknames is not None:
        if not isinstance(nicknames, list) or not nicknames:
            errors.append(f"{path}: nickname_candidates must be a non-empty list when present")
        else:
            if len(set(nicknames)) != len(nicknames):
                errors.append(f"{path}: nickname_candidates must be unique")
            for nickname in nicknames:
                if not isinstance(nickname, str) or not NICKNAME_RE.fullmatch(nickname):
                    errors.append(f"{path}: invalid nickname '{nickname}'")


def validate_agents(root: Path, errors: list[str]) -> None:
    agents_dir = root / AGENTS_RELATIVE_PATH
    if not agents_dir.exists():
        errors.append(f"missing agents directory: {agents_dir}")
        return
    files = sorted(agents_dir.glob("*.toml"))
    if not files:
        errors.append(f"{agents_dir}: no custom agent files found")
        return

    skill_ids = load_skill_ids(root)
    seen_names: set[str] = set()
    for path in files:
        validate_agent_file(path, skill_ids, seen_names, errors)

    missing_expected = sorted(EXPECTED_AGENTS - seen_names)
    if missing_expected:
        errors.append(
            f"{agents_dir}: missing required baseline agents: {', '.join(missing_expected)}"
        )
    extra_agents = sorted(seen_names - EXPECTED_AGENTS)
    if extra_agents:
        errors.append(
            f"{agents_dir}: unexpected repo-scoped agents outside the curated wave-1 baseline: {', '.join(extra_agents)}"
        )


def validate_delegation_policies(root: Path, errors: list[str]) -> None:
    workflow_dir = root / WORKFLOWS_RELATIVE_PATH
    if not workflow_dir.exists():
        errors.append(f"missing workflows directory: {workflow_dir}")
        return
    for path in sorted(workflow_dir.glob("*/workflow.json")):
        try:
            workflow = json.loads(path.read_text(encoding="utf-8"))
        except Exception as exc:  # pragma: no cover - surfaced directly to CLI
            errors.append(f"{path}: failed to parse JSON: {exc}")
            continue
        policy = workflow.get("delegation_policy")
        if not isinstance(policy, dict):
            continue
        if policy.get("auto_spawn_read_only_evidence_lanes") is not True:
            errors.append(f"{path}: delegation_policy must explicitly enable read-only evidence lanes")
        if policy.get("router") != str(DELEGATION_ROUTER_RELATIVE_PATH):
            errors.append(f"{path}: delegation_policy.router must point to {DELEGATION_ROUTER_RELATIVE_PATH}")
        phase_checkpoints = policy.get("phase_checkpoints")
        if phase_checkpoints != ["start", "mid"]:
            errors.append(f"{path}: delegation_policy.phase_checkpoints must equal ['start', 'mid']")
        lanes = policy.get("lanes")
        if not isinstance(lanes, dict) or not lanes:
            errors.append(f"{path}: delegation_policy.lanes must be a non-empty object")
            continue
        if "writer_lane_exception" in lanes:
            errors.append(f"{path}: writer_lane_exception must not be nested inside delegation_policy.lanes")
        for lane_name, lane in lanes.items():
            if not isinstance(lane, dict):
                errors.append(f"{path}: delegation lane '{lane_name}' must be an object")
                continue
            agent = lane.get("agent")
            effort = lane.get("reasoning_effort")
            if agent not in EXPECTED_AGENTS:
                errors.append(f"{path}: delegation lane '{lane_name}' references unknown agent '{agent}'")
            if effort not in ALLOWED_REASONING_EFFORTS:
                errors.append(f"{path}: delegation lane '{lane_name}' must use high or xhigh reasoning")
        writer_exception = policy.get("writer_lane_exception")
        if writer_exception is not None:
            if workflow.get("id") != "pr-governance-review":
                errors.append(f"{path}: writer_lane_exception is only allowed for pr-governance-review")
            if not isinstance(writer_exception, dict):
                errors.append(f"{path}: writer_lane_exception must be an object")
                continue
            if "agent" in writer_exception:
                errors.append(f"{path}: writer_lane_exception must not reference a repo-scoped agent")
            if writer_exception.get("scope") != "pr-governance-review":
                errors.append(f"{path}: writer_lane_exception.scope must equal pr-governance-review")
            if writer_exception.get("executor") != "workflow-local-pr-train-worker":
                errors.append(f"{path}: writer_lane_exception.executor must be workflow-local-pr-train-worker")
            allowed_actions = set(writer_exception.get("allowed_actions") or [])
            required_actions = {
                "edit_standardized_maintainer_comment",
                "post_standardized_maintainer_comment",
                "request_changes",
                "close_superseded_pr",
                "acknowledge_harvest",
                "enqueue_exact_head_queue_candidate",
            }
            if not required_actions <= allowed_actions:
                missing = ", ".join(sorted(required_actions - allowed_actions))
                errors.append(f"{path}: writer_lane_exception.allowed_actions missing {missing}")
            required_gates = set(writer_exception.get("required_gates") or [])
            for gate in {
                "operator_approved_train_set",
                "exact_head_sha",
                "green_ci_status_gate",
                "clean_mergeability",
                "no_hard_collision_edge",
                "comment_and_report_contract",
            }:
                if gate not in required_gates:
                    errors.append(f"{path}: writer_lane_exception.required_gates missing {gate}")
            forbidden_actions = set(writer_exception.get("forbidden_actions") or [])
            for action in {"branch_switching", "commits", "pushes", "code_patches", "secrets", "deploys", "direct_merge_to_main"}:
                if action not in forbidden_actions:
                    errors.append(f"{path}: writer_lane_exception.forbidden_actions missing {action}")


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description="Validate repo-scoped Codex agent orchestration surfaces.")
    parser.add_argument("--root", type=Path, default=DEFAULT_ROOT, help="repo root to validate")
    return parser.parse_args()


def main() -> int:
    args = parse_args()
    root = args.root.resolve()
    errors: list[str] = []
    validate_config(root, errors)
    validate_delegation_router(root, errors)
    validate_agents(root, errors)
    validate_delegation_policies(root, errors)

    if errors:
        for error in errors:
            print(f"ERROR: {error}", file=sys.stderr)
        return 1

    config_path = root / CONFIG_RELATIVE_PATH
    agent_files = sorted(p.name for p in (root / AGENTS_RELATIVE_PATH).glob("*.toml"))
    print("Agent orchestration surfaces valid.")
    print(f"Config: {config_path.relative_to(root)}")
    print(f"Agents: {', '.join(agent_files)}")
    return 0


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