"""Agent health-check & simulation API.

Two modes:
  - health: fast, no LLM — validates ALL agents (skills, tools, contract, personality)
  - full: dispatches a health-check prompt to every agent via LLM

Reports are agent-centric: each agent gets its own result regardless of workflow membership.
"""

from __future__ import annotations

import asyncio
import json
import logging
import time
from datetime import UTC, datetime
from pathlib import Path
from typing import Any

from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel

log = logging.getLogger(__name__)

# Maximum log files to keep (rolling)
_MAX_LOG_FILES = 50

router = APIRouter(prefix="/orchestrator/simulate", tags=["simulate"])


class SimulateRequest(BaseModel):
    mode: str = "health"  # "health" or "full"


# ── Agent-centric health check (fast, no LLM) ──


def _check_single_agent(orchestrator: Any, agent_key: str) -> dict:
    """Check if an agent is properly configured and ready."""
    result: dict[str, Any] = {
        "agent_key": agent_key,
        "status": "pass",
        "checks": [],
        "issues": [],
    }

    agent = orchestrator.agents.get(agent_key)
    if not agent:
        result["status"] = "fail"
        result["issues"].append(f"Agent '{agent_key}' not found in registry")
        result["checks"].append({"check": "agent_exists", "passed": False})
        return result
    result["checks"].append({"check": "agent_exists", "passed": True})

    # Populate metadata for UI display
    result["agent_name"] = agent.name or agent_key
    result["agent_type"] = getattr(agent, "type", "specialist") or "specialist"

    # Active status
    if agent.status != "active":
        result["status"] = "warn"
        result["issues"].append(f"Agent status is '{agent.status}' (not active)")
    result["checks"].append({"check": "agent_active", "passed": agent.status == "active"})

    # Skills — check all skills the agent declares
    agent_skills = list(agent.skills or [])
    skill_loader = getattr(orchestrator, "_skill_loader", None)
    for skill in agent_skills:
        loaded = True
        if skill_loader:
            loaded = skill_loader.get_skill(skill) is not None
        result["checks"].append({"check": f"skill:{skill}", "passed": loaded})
        if not loaded:
            if result["status"] == "pass":
                result["status"] = "warn"
            result["issues"].append(f"Skill not loaded: {skill}")

    # Tools accessible
    try:
        allowed = set(orchestrator.get_allowed_tools(agent_key))
        result["checks"].append({"check": "tools_accessible", "passed": True})
        result["tool_count"] = len(allowed)
    except Exception:
        result["checks"].append({"check": "tools_accessible", "passed": False})
        if result["status"] == "pass":
            result["status"] = "warn"
        result["issues"].append("Could not resolve tool access")

    # Personality defined
    has_personality = bool(agent.personality and agent.personality.strip())
    result["checks"].append({"check": "has_personality", "passed": has_personality})
    if not has_personality:
        result["issues"].append("No personality defined")

    # Final: any issues means at least warn
    if result["issues"] and result["status"] == "pass":
        result["status"] = "warn"

    return result


def _run_health_check(orchestrator: Any, contract: Any | None = None) -> dict:
    """Run health checks on ALL agents in the system."""
    results: dict[str, Any] = {
        "mode": "health",
        "timestamp": datetime.now(UTC).isoformat(),
        "overall_status": "pass",
        "agents": {},
        "summary": {"total_agents": 0, "passed": 0, "warnings": 0, "failures": 0},
    }

    for agent_key in sorted(orchestrator.agents.keys()):
        agent_result = _check_single_agent(orchestrator, agent_key)
        results["agents"][agent_key] = agent_result
        results["summary"]["total_agents"] += 1

        if agent_result["status"] == "pass":
            results["summary"]["passed"] += 1
        elif agent_result["status"] == "warn":
            results["summary"]["warnings"] += 1
        elif agent_result["status"] == "fail":
            results["summary"]["failures"] += 1

        # Propagate worst status
        if agent_result["status"] == "fail":
            results["overall_status"] = "fail"
        elif agent_result["status"] == "warn" and results["overall_status"] == "pass":
            results["overall_status"] = "warn"

    # Contract checks
    if contract:
        for action in ["delegate_task", "run_workflow"]:
            try:
                decision = contract.check(action)
                passed = decision != "blocked"
                if not passed:
                    results["overall_status"] = "fail"
                    results.setdefault("contract_issues", []).append(
                        f"Contract blocks: {action}",
                    )
            except Exception:
                pass

    return results


# ── Full simulation (with LLM) — agent-centric ──


async def _run_full_simulation(request: Request) -> dict:
    """Dispatch a health-check prompt to every agent."""
    orchestrator = request.app.state.orchestrator

    # Detect local mode — Ollama serializes requests so we need longer timeouts
    settings = getattr(request.app.state, "settings", None)
    is_local = getattr(getattr(settings, "llm", None), "mode", "cloud") == "local" if settings else False
    per_agent_timeout = 120.0 if is_local else 30.0

    results: dict[str, Any] = {
        "mode": "full",
        "timestamp": datetime.now(UTC).isoformat(),
        "overall_status": "pass",
        "agents": {},
        "summary": {"total_agents": 0, "passed": 0, "warnings": 0, "failures": 0},
        "total_duration_ms": 0,
    }

    for agent_key in sorted(orchestrator.agents.keys()):
        agent = orchestrator.agents[agent_key]
        agent_result: dict[str, Any] = {
            "agent_key": agent_key,
            "agent_name": agent.name or agent_key,
            "agent_type": getattr(agent, "type", "specialist") or "specialist",
            "status": "pass",
            "duration_ms": 0,
            "response_preview": "",
            "issues": [],
        }

        step_start = time.monotonic()
        try:
            result = await asyncio.wait_for(
                orchestrator.dispatch(
                    agent_name=agent_key,
                    task=(
                        "SIMULATION HEALTH CHECK — respond with a single sentence "
                        "confirming your role and that you are READY."
                    ),
                    context="This is a system health check. Respond briefly.",
                ),
                timeout=per_agent_timeout,
            )
            agent_result["duration_ms"] = int((time.monotonic() - step_start) * 1000)
            agent_result["response_preview"] = (result.content or "")[:200]

            if not result.content or len(result.content.strip()) < 5:
                agent_result["status"] = "warn"
                agent_result["issues"].append("Empty/minimal response")
        except TimeoutError:
            agent_result["status"] = "fail"
            agent_result["issues"].append(f"Timed out ({int(per_agent_timeout)}s)")
            agent_result["duration_ms"] = int(per_agent_timeout * 1000)
        except Exception as exc:
            agent_result["status"] = "fail"
            agent_result["issues"].append(str(exc)[:200])
            agent_result["duration_ms"] = int((time.monotonic() - step_start) * 1000)

        results["agents"][agent_key] = agent_result
        results["total_duration_ms"] += agent_result["duration_ms"]
        results["summary"]["total_agents"] += 1

        if agent_result["status"] == "pass":
            results["summary"]["passed"] += 1
        elif agent_result["status"] == "warn":
            results["summary"]["warnings"] += 1
        elif agent_result["status"] == "fail":
            results["summary"]["failures"] += 1

        if agent_result["status"] == "fail":
            results["overall_status"] = "fail"
        elif agent_result["status"] == "warn" and results["overall_status"] == "pass":
            results["overall_status"] = "warn"

    return results


# ── Log persistence ──


def _get_logs_dir(request: Request) -> Path | None:
    """Get the simulation logs directory in system/logs/simulation/."""
    vault = getattr(request.app.state, "vault", None)
    if not vault:
        return None
    if hasattr(vault, "paths") and vault.paths:
        logs_dir = vault.paths.resolve("system/logs/simulation")
    else:
        vault_dir = getattr(vault, "_vault_dir", None)
        if not vault_dir:
            return None
        logs_dir = Path(vault_dir) / "system" / "logs" / "simulation"
    logs_dir.mkdir(parents=True, exist_ok=True)
    return logs_dir


def _build_md_report(results: dict) -> str:
    """Build a human-readable markdown report from simulation results."""
    mode = results.get("mode", "unknown")
    ts = results.get("timestamp", "")
    overall = results.get("overall_status", "unknown")
    summary = results.get("summary")

    lines = [
        "---",
        f"title: {'Health Check' if mode == 'health' else 'Full Simulation'} Report",
        f"date: {ts[:10] if ts else ''}",
        f"mode: {mode}",
        f"status: {overall}",
        "author: agent",
        "agent: healer",
        "---",
        "",
        f"# {'Health Check' if mode == 'health' else 'Full Simulation'} Report",
        "",
        f"**Status:** {overall.upper()}  ",
        f"**Time:** {ts}  ",
    ]

    if summary:
        lines.append(
            f"**Agents:** {summary.get('passed', 0)}/{summary.get('total_agents', 0)} passed, "
            f"{summary.get('warnings', 0)} warnings, {summary.get('failures', 0)} failures  ",
        )
    lines.append("")

    # Group by type: supervisors first, then specialists
    agents_data = results.get("agents", {})
    supervisors = {k: v for k, v in agents_data.items() if v.get("agent_type") == "supervisor"}
    specialists = {k: v for k, v in agents_data.items() if v.get("agent_type") != "supervisor"}

    for group_name, group in [("Supervisors", supervisors), ("Specialists", specialists)]:
        if not group:
            continue
        lines.append(f"## {group_name}")
        lines.append("")
        for agent_key, a in group.items():
            name = a.get("agent_name", agent_key)
            status = a.get("status", "unknown")
            icon = "✅" if status == "pass" else "⚠️" if status == "warn" else "❌"
            dur = a.get("duration_ms")
            dur_part = f" ({dur}ms)" if dur else ""

            lines.append(f"- {icon} **{name}** ({agent_key}){dur_part}")

            for iss in a.get("issues", []):
                lines.append(f"  - {iss}")

            checks = a.get("checks", [])
            if checks:
                failed_checks = [c for c in checks if not c.get("passed")]
                for c in failed_checks:
                    lines.append(f"  - ❌ `{c['check']}`")

        lines.append("")

    return "\n".join(lines)


def _persist_log(request: Request, results: dict) -> str | None:
    """Write simulation results as JSON + MD report. Returns JSON filename."""
    logs_dir = _get_logs_dir(request)
    if not logs_dir:
        return None

    try:
        mode = results.get("mode", "unknown")
        ts = datetime.now(UTC).strftime("%Y-%m-%d_%H%M%S")
        base_name = f"{ts}_{mode}"

        # Save JSON (machine-readable)
        json_path = logs_dir / f"{base_name}.json"
        with open(json_path, "w", encoding="utf-8") as f:
            json.dump(results, f, indent=2, default=str)

        # Save MD (human-readable, Obsidian-compatible)
        md_path = logs_dir / f"{base_name}.md"
        with open(md_path, "w", encoding="utf-8") as f:
            f.write(_build_md_report(results))

        # Rolling cleanup — keep only the most recent logs
        all_logs = sorted(logs_dir.glob("*.json"), key=lambda p: p.stat().st_mtime)
        if len(all_logs) > _MAX_LOG_FILES:
            for old in all_logs[: len(all_logs) - _MAX_LOG_FILES]:
                old.unlink(missing_ok=True)
                md_pair = old.with_suffix(".md")
                if md_pair.exists():
                    md_pair.unlink(missing_ok=True)

        log.info("Simulation log saved: %s (.json + .md)", base_name)
        return f"{base_name}.json"
    except Exception as exc:
        log.warning("Failed to save simulation log: %s", exc)
        return None


def _get_plan_path(request: Request) -> Path | None:
    """Get the action plan file path."""
    logs_dir = _get_logs_dir(request)
    return logs_dir / "action-plan.json" if logs_dir else None


def _generate_action_plan(sim_results: dict) -> list[dict]:
    """Analyze simulation results and generate structured action plan items.

    Each item has: what (problem), why (cause/impact), who (agent + resolver),
    how (concrete steps), and whether the system can auto-fix it.
    """
    items: list[dict] = []
    seen: set[tuple[str, str]] = set()
    ts = datetime.now(UTC).isoformat()
    agent_file = "system/agents/{key}.md"

    for agent_key, agent_data in sim_results.get("agents", {}).items():
        if agent_data.get("status") == "pass":
            continue

        name = agent_data.get("agent_name", agent_key)
        atype = agent_data.get("agent_type", "specialist")
        issues = agent_data.get("issues", [])
        response = agent_data.get("response_preview", "")
        path = agent_file.format(key=agent_key)
        base = {"agent_key": agent_key, "agent_name": name, "agent_type": atype, "created_at": ts}

        for issue in issues:
            # ── Skill not loaded (auto-fixable) ──
            if issue.startswith("Skill not loaded:"):
                skill = issue.replace("Skill not loaded: ", "").strip()
                key = (agent_key, f"skill:{skill}")
                if key in seen:
                    continue
                seen.add(key)
                items.append({
                    **base,
                    "id": f"skill-{agent_key}-{skill}",
                    "what": f"Skill '{skill}' is declared in {name}'s definition but not found in the skill registry",
                    "why": (
                        f"{name} lists '{skill}' as a required skill, but the skill file doesn't exist or failed to load. "
                        f"Without it, {name} won't have the instructions it needs for tasks that depend on this skill."
                    ),
                    "who": "System auto-fix",
                    "how": [
                        "Check if skills/*/{skill}/SKILL.md exists in the vault",
                        f"If the skill exists, it will be assigned to {name} automatically",
                        f"If not, a draft skill will be created in skills/pending/{skill}/ for you to review",
                    ],
                    "fix_type": "assign_skill",
                    "fix_detail": {"skill": skill},
                    "severity": "warn",
                    "status": "pending",
                })

            # ── Agent inactive (auto-fixable) ──
            elif "status is" in issue and "not active" in issue:
                key = (agent_key, "inactive")
                if key in seen:
                    continue
                seen.add(key)
                items.append({
                    **base,
                    "id": f"inactive-{agent_key}",
                    "what": f"{name} is disabled (status is not 'active')",
                    "why": (
                        f"An inactive agent is skipped during dispatches and workflows. "
                        f"Any task that requires {name} will fail or fall back to a less suitable agent."
                    ),
                    "who": "System auto-fix",
                    "how": [
                        f"Set status: active in {path}",
                    ],
                    "fix_type": "activate_agent",
                    "fix_detail": {},
                    "severity": "warn",
                    "status": "pending",
                })

            # ── No personality (recommendation) ──
            elif "No personality defined" in issue:
                key = (agent_key, "personality")
                if key in seen:
                    continue
                seen.add(key)
                items.append({
                    **base,
                    "id": f"personality-{agent_key}",
                    "what": f"{name} has no personality section in its definition",
                    "why": (
                        "Without a personality, the agent produces generic, undifferentiated responses. "
                        "Personality shapes tone, judgment, and domain expertise — it's what makes "
                        "each specialist unique."
                    ),
                    "who": "User",
                    "how": [
                        f"Open {path} in your editor",
                        "Add a personality section describing the agent's voice, expertise, and approach",
                        "Example: 'Meticulous and detail-oriented. Thinks in systems and patterns.'",
                    ],
                    "fix_type": "recommendation",
                    "fix_detail": {"file": path},
                    "severity": "warn",
                    "status": "info",
                })

            # ── Tool access broken (recommendation) ──
            elif "Could not resolve tool access" in issue:
                key = (agent_key, "tools")
                if key in seen:
                    continue
                seen.add(key)
                items.append({
                    **base,
                    "id": f"tools-{agent_key}",
                    "what": f"{name}'s tool permissions could not be resolved",
                    "why": (
                        "The agent's access tier or skill-granted tools produced an error. "
                        "This agent cannot execute any tools until this is fixed."
                    ),
                    "who": "User",
                    "how": [
                        f"Open {path} and verify the 'access' field is one of: read-only, read-write-drafts, read-write",
                        "Check that all listed skills exist and are active",
                        "Run health check again after fixing",
                    ],
                    "fix_type": "recommendation",
                    "fix_detail": {"file": path},
                    "severity": "fail",
                    "status": "info",
                })

        # ── Full sim: empty response (recommendation, skip timeouts) ──
        for issue in issues:
            if "Timed out" in issue:
                continue

            if "Empty/minimal response" in issue:
                key = (agent_key, "empty_response")
                if key in seen:
                    continue
                seen.add(key)
                items.append({
                    **base,
                    "id": f"empty-{agent_key}",
                    "what": f"{name} returned an empty or minimal response during simulation",
                    "why": (
                        "The agent was dispatched but produced little or no output. "
                        "Common causes: the system prompt is too complex for the current model, "
                        "the personality section is empty, or the agent definition is malformed."
                    ),
                    "who": "User",
                    "how": [
                        f"Open {path} and check that personality and role are filled in",
                        "If using a local model (Ollama), try a larger model or simplify the agent's skills list",
                        "Run a full simulation again to verify the fix",
                    ],
                    "fix_type": "recommendation",
                    "fix_detail": {"file": path, "response_preview": response},
                    "severity": "warn",
                    "status": "info",
                })
                continue

            # ── Generic LLM error (recommendation) ──
            if issue.startswith("Skill not loaded:") or "status is" in issue:
                continue
            if "personality" in issue.lower() or "tool" in issue.lower():
                continue
            key = (agent_key, f"error:{issue[:40]}")
            if key in seen:
                continue
            seen.add(key)
            items.append({
                **base,
                "id": f"error-{agent_key}-{len(items)}",
                "what": f"{name} encountered an error: {issue[:120]}",
                "why": (
                    "The LLM call failed for this agent. This could be a transient API error, "
                    "a model compatibility issue, or a problem with the agent's prompt."
                ),
                "who": "User",
                "how": [
                    "Check the server logs for detailed error information",
                    "Verify LLM API key and connectivity in Settings",
                    f"Review {path} for any configuration issues",
                ],
                "fix_type": "recommendation",
                "fix_detail": {"error": issue},
                "severity": "fail",
                "status": "info",
            })

    return items


def _save_action_plan(request: Request, sim_results: dict) -> int:
    """Generate and persist action plan from simulation results. Returns item count."""
    plan_path = _get_plan_path(request)
    if not plan_path:
        return 0

    new_items = _generate_action_plan(sim_results)
    if not new_items:
        return 0

    # Merge with existing pending items (don't duplicate by id)
    existing: list[dict] = []
    if plan_path.exists():
        try:
            existing = json.loads(plan_path.read_text(encoding="utf-8"))
        except Exception:
            existing = []

    # Dedup: skip items that already exist with any active status
    _active = {"pending", "info", "applied", "needs_retry"}
    existing_ids = {item.get("id") for item in existing if item.get("status") in _active}
    for item in new_items:
        if item["id"] not in existing_ids:
            existing.append(item)

    # Prune old resolved items (keep only last 20 completed)
    _done = {"verified", "rejected"}
    active_items = [i for i in existing if i.get("status") not in _done]
    done_items = [i for i in existing if i.get("status") in _done]
    existing = active_items + done_items[-20:]

    plan_path.write_text(json.dumps(existing, indent=2, default=str), encoding="utf-8")
    added = sum(1 for i in new_items if i["id"] not in existing_ids)
    log.info("Action plan: %d new item(s), %d total active", added, len(active_items))

    return len(new_items)


# ── API Endpoints ──


@router.post("")
async def simulate_workflows(request: Request, body: SimulateRequest) -> dict:
    """Run agent health check / simulation.

    Modes:
      - health: fast validation (no LLM calls) — checks all agents
      - full: dispatches a health-check prompt to every agent (uses LLM tokens)

    Results are agent-centric and persisted to system/logs/simulation/.
    """
    orchestrator = request.app.state.orchestrator
    contract = getattr(request.app.state, "contract", None)

    if body.mode == "health":
        results = _run_health_check(orchestrator, contract)
    elif body.mode == "full":
        results = await _run_full_simulation(request)
    else:
        raise HTTPException(status_code=400, detail=f"Unknown mode: {body.mode}. Use 'health' or 'full'.")

    # Persist log
    log_file = _persist_log(request, results)
    if log_file:
        results["log_file"] = log_file

    # Auto-generate action plan when issues found
    if results.get("overall_status") in ("warn", "fail"):
        plan_count = _save_action_plan(request, results)
        if plan_count:
            results["action_plan_items"] = plan_count

    return results


@router.get("/status")
async def simulation_status(request: Request) -> dict:
    """Get agent readiness overview (quick health summary)."""
    orchestrator = request.app.state.orchestrator

    agents = orchestrator.agents
    total_agents = len(agents)
    active_agents = sum(1 for a in agents.values() if a.status == "active")

    return {
        "total_agents": total_agents,
        "active_agents": active_agents,
        "ready": active_agents > 0,
    }


@router.get("/logs")
async def list_simulation_logs(
    request: Request,
    limit: int = 20,
    include_archived: bool = False,
) -> list[dict]:
    """List recent simulation log files with full detail (newest first).

    By default, archived reports are excluded.  Pass ``include_archived=true``
    to include them (the ``archived`` flag is returned so the UI can dim them).
    """
    logs_dir = _get_logs_dir(request)
    if not logs_dir or not logs_dir.exists():
        return []

    all_logs = sorted(logs_dir.glob("*.json"), key=lambda p: p.stat().st_mtime, reverse=True)
    entries = []
    for lf in all_logs:
        if len(entries) >= limit:
            break
        try:
            with open(lf, encoding="utf-8") as f:
                data = json.load(f)
            archived = data.get("archived", False)
            if archived and not include_archived:
                continue
            entries.append({
                "filename": lf.name,
                "mode": data.get("mode"),
                "timestamp": data.get("timestamp") or data.get("started_at"),
                "overall_status": data.get("overall_status"),
                "summary": data.get("summary"),
                "agents": data.get("agents", {}),
                # Backward compat: old logs might still have "workflows"
                "workflows": data.get("workflows"),
                "archived": archived,
            })
        except Exception:
            entries.append({"filename": lf.name, "error": "Failed to parse"})

    return entries


@router.get("/logs/{filename}")
async def get_simulation_log(request: Request, filename: str) -> dict:
    """Get a specific simulation log file (full detail)."""
    logs_dir = _get_logs_dir(request)
    if not logs_dir:
        raise HTTPException(status_code=404, detail="Logs directory not found")

    safe_name = Path(filename).name
    if not safe_name.endswith(".json"):
        raise HTTPException(status_code=400, detail="Invalid log filename")

    filepath = logs_dir / safe_name
    if not filepath.is_file():
        raise HTTPException(status_code=404, detail=f"Log not found: {safe_name}")

    try:
        with open(filepath, encoding="utf-8") as f:
            return json.load(f)
    except Exception as exc:
        raise HTTPException(status_code=500, detail=f"Failed to read log: {exc}") from exc


@router.patch("/logs/{filename}/archive")
async def archive_simulation_log(request: Request, filename: str) -> dict:
    """Archive a simulation log (hides from default listing)."""
    return _set_archived(request, filename, True)


@router.patch("/logs/{filename}/unarchive")
async def unarchive_simulation_log(request: Request, filename: str) -> dict:
    """Unarchive a simulation log (shows in default listing again)."""
    return _set_archived(request, filename, False)


def _set_archived(request: Request, filename: str, archived: bool) -> dict:
    logs_dir = _get_logs_dir(request)
    if not logs_dir:
        raise HTTPException(status_code=404, detail="Logs directory not found")

    safe_name = Path(filename).name
    if not safe_name.endswith(".json"):
        raise HTTPException(status_code=400, detail="Invalid log filename")

    filepath = logs_dir / safe_name
    if not filepath.is_file():
        raise HTTPException(status_code=404, detail=f"Log not found: {safe_name}")

    try:
        data = json.loads(filepath.read_text(encoding="utf-8"))
        data["archived"] = archived
        filepath.write_text(json.dumps(data, indent=2, default=str), encoding="utf-8")
        return {"status": "archived" if archived else "unarchived", "filename": safe_name}
    except Exception as exc:
        raise HTTPException(status_code=500, detail=f"Failed to update log: {exc}") from exc


@router.delete("/logs/{filename}")
async def delete_simulation_log(request: Request, filename: str) -> dict:
    """Delete a specific simulation log file."""
    logs_dir = _get_logs_dir(request)
    if not logs_dir:
        raise HTTPException(status_code=404, detail="Logs directory not found")

    safe_name = Path(filename).name
    filepath = logs_dir / safe_name
    if not filepath.is_file():
        raise HTTPException(status_code=404, detail=f"Log not found: {safe_name}")

    filepath.unlink()
    md_pair = filepath.with_suffix(".md")
    if md_pair.is_file():
        md_pair.unlink()
    return {"status": "deleted", "filename": safe_name}


# ── Action Plan (self-improving loop) ──


@router.get("/plan")
async def get_action_plan(request: Request) -> list[dict]:
    """Get the current action plan items."""
    path = _get_plan_path(request)
    if not path or not path.exists():
        return []
    try:
        return json.loads(path.read_text(encoding="utf-8"))
    except Exception:
        return []


class PlanItemAction(BaseModel):
    item_id: str


@router.post("/plan/approve")
async def approve_plan_item(request: Request, body: PlanItemAction) -> dict:
    """Approve and apply a plan item."""
    path = _get_plan_path(request)
    if not path or not path.exists():
        raise HTTPException(status_code=404, detail="No action plan found")

    plan = json.loads(path.read_text(encoding="utf-8"))
    item = next((i for i in plan if i["id"] == body.item_id and i["status"] == "pending"), None)
    if not item:
        raise HTTPException(status_code=404, detail="Plan item not found or already processed")

    # Apply the fix
    applied = await _apply_plan_item(request, item)
    item["status"] = "applied" if applied else "approved"
    item["applied_at"] = datetime.now(UTC).isoformat()
    path.write_text(json.dumps(plan, indent=2, default=str), encoding="utf-8")

    return {"status": item["status"], "item_id": body.item_id, "applied": applied}


@router.post("/plan/reject")
async def reject_plan_item(request: Request, body: PlanItemAction) -> dict:
    """Reject a plan item."""
    path = _get_plan_path(request)
    if not path or not path.exists():
        raise HTTPException(status_code=404, detail="No action plan found")

    plan = json.loads(path.read_text(encoding="utf-8"))
    item = next((i for i in plan if i["id"] == body.item_id and i["status"] == "pending"), None)
    if not item:
        raise HTTPException(status_code=404, detail="Plan item not found or already processed")

    item["status"] = "rejected"
    path.write_text(json.dumps(plan, indent=2, default=str), encoding="utf-8")

    return {"status": "rejected", "item_id": body.item_id}


class HealRequest(BaseModel):
    """Body for the one-click heal endpoint. Both fields are optional —
    omit them to heal every auto-fixable item across the whole plan."""
    agent_key: str | None = None
    item_ids: list[str] | None = None


@router.post("/plan/heal")
async def heal_plan(request: Request, body: HealRequest | None = None) -> dict:
    """One-click heal: apply every auto-fixable pending item, then re-run
    the health check to verify, all in a single round-trip.

    The caller decides the scope:
      - no body          → heal everything auto-fixable
      - {agent_key: …}   → heal one agent's auto-fixable issues
      - {item_ids: […]}  → heal a specific subset

    Returns aggregate counts so the UI can show one toast instead of
    surfacing per-item state changes one at a time. Reduces the historic
    Apply → wait → Verify dance to a single click.
    """
    body = body or HealRequest()
    path = _get_plan_path(request)
    if not path or not path.exists():
        raise HTTPException(status_code=404, detail="No action plan found")

    plan = json.loads(path.read_text(encoding="utf-8"))

    def _is_target(it: dict) -> bool:
        if it.get("status") not in ("pending", "needs_retry"):
            return False
        if it.get("fix_type") not in ("assign_skill", "activate_agent"):
            return False
        if body.item_ids and it.get("id") not in body.item_ids:
            return False
        if body.agent_key and it.get("agent_key") != body.agent_key:
            return False
        return True

    targets = [it for it in plan if _is_target(it)]
    if not targets:
        return {
            "applied": 0,
            "verified": 0,
            "still_failing": 0,
            "failed": 0,
            "items": [],
            "message": "Nothing to heal",
        }

    applied_ids: list[str] = []
    failed_ids: list[str] = []
    for item in targets:
        try:
            ok = await _apply_plan_item(request, item)
        except Exception:
            log.exception("heal: failed to apply item %s", item.get("id"))
            ok = False
        if ok:
            item["status"] = "applied"
            item["applied_at"] = datetime.now(UTC).isoformat()
            applied_ids.append(item["id"])
        else:
            failed_ids.append(item.get("id", ""))

    path.write_text(json.dumps(plan, indent=2, default=str), encoding="utf-8")

    # Re-verify by re-running the health check. We deliberately use the
    # fast (no-LLM) variant — heal is meant to feel synchronous.
    orchestrator = request.app.state.orchestrator
    contract = getattr(request.app.state, "contract", None)
    results = _run_health_check(orchestrator, contract)
    failing_agents = {
        k for k, v in results.get("agents", {}).items()
        if v.get("status") != "pass"
    }

    plan = json.loads(path.read_text(encoding="utf-8"))
    verified = 0
    still_failing = 0
    for item in plan:
        if item.get("id") not in applied_ids:
            continue
        if item.get("agent_key") in failing_agents:
            item["status"] = "needs_retry"
            still_failing += 1
        else:
            item["status"] = "verified"
            item["verified_at"] = datetime.now(UTC).isoformat()
            verified += 1

    path.write_text(json.dumps(plan, indent=2, default=str), encoding="utf-8")
    _persist_log(request, results)

    return {
        "applied": len(applied_ids),
        "verified": verified,
        "still_failing": still_failing,
        "failed": len(failed_ids),
        "health": results.get("overall_status"),
        "items": [
            *({"id": i, "outcome": "applied"} for i in applied_ids),
            *({"id": i, "outcome": "failed"} for i in failed_ids),
        ],
    }


@router.post("/plan/verify")
async def verify_plan(request: Request) -> dict:
    """Re-run health check and mark applied items as verified or failed."""
    orchestrator = request.app.state.orchestrator
    contract = getattr(request.app.state, "contract", None)
    results = _run_health_check(orchestrator, contract)

    path = _get_plan_path(request)
    if not path or not path.exists():
        return {"verified": 0, "still_failing": 0, "health": results.get("overall_status")}

    plan = json.loads(path.read_text(encoding="utf-8"))
    # Collect currently-failing agents
    failing_agents = {
        k for k, v in results.get("agents", {}).items()
        if v.get("status") != "pass"
    }

    verified = 0
    still_failing = 0
    for item in plan:
        if item["status"] != "applied":
            continue
        if item["agent_key"] in failing_agents:
            item["status"] = "needs_retry"
            still_failing += 1
        else:
            item["status"] = "verified"
            item["verified_at"] = datetime.now(UTC).isoformat()
            verified += 1

    path.write_text(json.dumps(plan, indent=2, default=str), encoding="utf-8")

    # Persist the verification health check log too
    _persist_log(request, results)

    return {"verified": verified, "still_failing": still_failing, "health": results.get("overall_status")}


@router.delete("/plan")
async def clear_action_plan(request: Request) -> dict:
    """Clear all completed/rejected items from the action plan."""
    path = _get_plan_path(request)
    if not path or not path.exists():
        return {"cleared": 0}

    plan = json.loads(path.read_text(encoding="utf-8"))
    remaining = [i for i in plan if i["status"] in ("pending", "applied", "needs_retry")]
    cleared = len(plan) - len(remaining)
    path.write_text(json.dumps(remaining, indent=2, default=str), encoding="utf-8")

    return {"cleared": cleared, "remaining": len(remaining)}


async def _apply_plan_item(request: Request, item: dict) -> bool:
    """Execute a plan item fix. Returns True if auto-applied, False if manual review needed."""
    fix_type = item.get("fix_type")
    agent_key = item.get("agent_key")
    orchestrator = request.app.state.orchestrator
    writer = getattr(request.app.state, "vault_writer", None)
    vault = getattr(request.app.state, "vault", None)
    vault_dir = getattr(vault, "_vault_dir", None) if vault else None

    if fix_type == "assign_skill":
        # Auto-apply: add skill to agent definition
        skill = item.get("fix_detail", {}).get("skill", "")
        agent_def = orchestrator.agents.get(agent_key)
        if not agent_def or not skill:
            return False
        if skill in (agent_def.skills or []):
            return True  # already assigned

        agent_def.skills.append(skill)
        if vault_dir and writer:
            agent_path = f"system/agents/{agent_key}.md"
            full_path = Path(vault_dir) / agent_path
            if full_path.exists():
                content = full_path.read_text(encoding="utf-8")
                if f"  - {skill}" not in content:
                    content = content.replace(
                        f"access: {agent_def.access}",
                        f"  - {skill}\naccess: {agent_def.access}",
                    )
                    writer.write(agent_path, content)
        orchestrator.load_agents()
        return True

    elif fix_type == "activate_agent":
        # Auto-apply: set agent status to active
        agent_def = orchestrator.agents.get(agent_key)
        if not agent_def:
            return False
        if vault_dir and writer:
            agent_path = f"system/agents/{agent_key}.md"
            full_path = Path(vault_dir) / agent_path
            if full_path.exists():
                content = full_path.read_text(encoding="utf-8")
                content = content.replace(f"status: {agent_def.status}", "status: active")
                writer.write(agent_path, content)
        orchestrator.load_agents()
        return True

    # These fix types need manual review — mark as approved but not auto-applied
    # review_personality, review_model_tier, review_tools, investigate
    return False


# ── Heal proposals (user approval gate — backward compat) ──


def _get_proposals_path(request: Request) -> Path | None:
    logs_dir = _get_logs_dir(request)
    if not logs_dir:
        return None
    return logs_dir / "heal-proposals.json"


@router.get("/proposals")
async def list_proposals(request: Request) -> list[dict]:
    """List pending heal proposals for user approval."""
    path = _get_proposals_path(request)
    if not path or not path.exists():
        return []
    try:
        data = json.loads(path.read_text(encoding="utf-8"))
        return [p for p in data if p.get("status") == "pending"]
    except Exception:
        return []


class ApproveRequest(BaseModel):
    agent_key: str
    skill: str


@router.post("/proposals/approve")
async def approve_proposal(request: Request, body: ApproveRequest) -> dict:
    """Approve a heal proposal — adds the skill to the agent."""
    path = _get_proposals_path(request)
    if not path or not path.exists():
        raise HTTPException(status_code=404, detail="No proposals found")

    proposals = json.loads(path.read_text(encoding="utf-8"))

    found = None
    for p in proposals:
        if p["agent_key"] == body.agent_key and p["skill"] == body.skill and p.get("status") == "pending":
            found = p
            break
    if not found:
        raise HTTPException(status_code=404, detail="Proposal not found or already processed")

    orchestrator = request.app.state.orchestrator
    writer = request.app.state.vault_writer
    agent_def = orchestrator.agents.get(body.agent_key)
    if not agent_def:
        raise HTTPException(status_code=404, detail=f"Agent not found: {body.agent_key}")

    if body.skill not in (agent_def.skills or []):
        agent_def.skills.append(body.skill)

        agent_path = f"system/agents/{body.agent_key}.md"
        vault = request.app.state.vault
        vault_dir = getattr(vault, "_vault_dir", None)
        if vault_dir:
            full_path = Path(vault_dir) / agent_path
            if full_path.exists():
                content = full_path.read_text(encoding="utf-8")
                if f"  - {body.skill}" not in content:
                    content = content.replace(
                        f"access: {agent_def.access}",
                        f"  - {body.skill}\naccess: {agent_def.access}",
                    )
                    writer.write(agent_path, content)

        orchestrator.load_agents()

    found["status"] = "approved"
    path.write_text(json.dumps(proposals, indent=2), encoding="utf-8")

    return {"status": "approved", "agent": body.agent_key, "skill": body.skill}


@router.post("/proposals/reject")
async def reject_proposal(request: Request, body: ApproveRequest) -> dict:
    """Reject a heal proposal."""
    path = _get_proposals_path(request)
    if not path or not path.exists():
        raise HTTPException(status_code=404, detail="No proposals found")

    proposals = json.loads(path.read_text(encoding="utf-8"))
    found = None
    for p in proposals:
        if p["agent_key"] == body.agent_key and p["skill"] == body.skill and p.get("status") == "pending":
            found = p
            break
    if not found:
        raise HTTPException(status_code=404, detail="Proposal not found or already processed")

    found["status"] = "rejected"
    path.write_text(json.dumps(proposals, indent=2), encoding="utf-8")

    return {"status": "rejected", "agent": body.agent_key, "skill": body.skill}


def _resolve_vault_path(vault, logical_path: str) -> Path:
    """Resolve a logical vault path to a physical path."""
    return vault.paths.resolve(logical_path)


class SkillApproveRequest(BaseModel):
    skill: str


@router.post("/proposals/approve-skill")
async def approve_skill_draft(request: Request, body: SkillApproveRequest) -> dict:
    """Approve a drafted skill — moves from pending/ to private/, sets status active, reloads skills."""
    vault = request.app.state.vault

    pending_dir = _resolve_vault_path(vault, f"system/skills/pending/{body.skill}")
    if not pending_dir.exists():
        raise HTTPException(status_code=404, detail=f"No draft found for skill: {body.skill}")

    target_dir = _resolve_vault_path(vault, f"system/skills/private/{body.skill}")
    target_dir.mkdir(parents=True, exist_ok=True)

    import shutil
    for f in pending_dir.iterdir():
        content = f.read_text(encoding="utf-8")
        content = content.replace("status: draft", "status: active")
        (target_dir / f.name).write_text(content, encoding="utf-8")

    shutil.rmtree(pending_dir)

    path = _get_proposals_path(request)
    if path and path.exists():
        proposals = json.loads(path.read_text(encoding="utf-8"))
        for p in proposals:
            if p.get("skill") == body.skill and p.get("type") == "create_skill" and p.get("status") == "pending":
                p["status"] = "approved"
        path.write_text(json.dumps(proposals, indent=2), encoding="utf-8")

    skill_loader = getattr(request.app.state, "skills", None)
    if skill_loader:
        skill_loader.load_all()

    return {"status": "approved", "skill": body.skill, "location": f"system/skills/private/{body.skill}"}


@router.post("/proposals/reject-skill")
async def reject_skill_draft(request: Request, body: SkillApproveRequest) -> dict:
    """Reject a drafted skill — removes the pending draft."""
    vault = request.app.state.vault

    pending_dir = _resolve_vault_path(vault, f"system/skills/pending/{body.skill}")
    if pending_dir.exists():
        import shutil
        shutil.rmtree(pending_dir)

    path = _get_proposals_path(request)
    if path and path.exists():
        proposals = json.loads(path.read_text(encoding="utf-8"))
        for p in proposals:
            if p.get("skill") == body.skill and p.get("type") == "create_skill" and p.get("status") == "pending":
                p["status"] = "rejected"
        path.write_text(json.dumps(proposals, indent=2), encoding="utf-8")

    return {"status": "rejected", "skill": body.skill}
