"""
cvc.gateway — The CVC Gateway (Management Dashboard Server).

A FastAPI application that runs on ``http://127.0.0.1:17888`` and provides:

1. A web-based dashboard UI (Open WebUI-style) for managing all CVC features.
2. A management REST API for service lifecycle control.
3. An agent chat endpoint (streaming) — full CVC Agent in the browser.
4. Operations API: commit, branch, merge, restore, recall, diff, timeline.
5. Memory & context browsing.
6. Model catalog and provider switching.
7. Hive Mind / SDK multi-agent management.
8. Real-time event streaming via WebSocket.

The gateway manages the Proxy (port 19333) as a child service and
exposes analytics pulled from the shared ``.cvc/`` database.
"""

from __future__ import annotations

import asyncio
import json
import logging
import os
import time
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Any

from fastapi import FastAPI, Request, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles
from starlette.responses import FileResponse, StreamingResponse
from starlette.websockets import WebSocket, WebSocketDisconnect

logger = logging.getLogger("cvc.gateway")

# ---------------------------------------------------------------------------
# Module-level singletons (initialised in lifespan)
# ---------------------------------------------------------------------------

_manager = None        # GatewayManager
_workspace_mgr = None  # WorkspaceManager — multi-workspace isolation
_event_bus = None      # EventBus
_health_task = None    # Background health poller
_project_root = None   # Discovered project root
_agent_llm = None      # AgentLLM (direct LLM client for chat)
_agent_tools = []      # Tool definitions for the dashboard agent
_tool_executor = None  # ToolExecutor for executing agent tool calls
_system_prompt = ""    # Agent system prompt
_chat_messages = []    # Persistent conversation history (system + user + assistant + tool)

# ── Tool permission system ───────────────────────────────────────────
_autopilot: bool = False  # When True, all tools execute without confirmation
_tool_confirm_event: asyncio.Event | None = None  # Set when user confirms/denies
_tool_confirm_result: str = ""  # Decision string from user

# ── Abort / Stop system ──────────────────────────────────────────────
_chat_abort: bool = False  # When True, the agentic loop stops ASAP

# Session-level tool trust ("Always allow X this session")
_session_trusted_tools: set[str] = set()
# Session-level "Trust ALL commands" flag
_session_trust_all: bool = False

# Tools that are always safe to auto-approve (read-only, no side effects)
_SAFE_TOOLS: set[str] = {
    "read_file", "grep", "glob", "list_dir",
    "cvc_status", "cvc_log", "cvc_search", "cvc_smart_search", "cvc_diff",
    "cvc_get_context", "cvc_switch_workspace",
}

# Valid permission decisions (matches CLI-style options)
# allow_once  — execute this one time only
# allow_always — always allow this tool for the rest of the session
# trust_all  — trust ALL tools this session (like _session_trust_all)
# deny       — deny and inject error message
# deny_suggest — deny and ask agent to suggest an alternative
_VALID_DECISIONS = {"allow_once", "allow_always", "trust_all", "deny", "deny_suggest"}

# Agentic loop limits
_MAX_AGENT_ITERATIONS = 50
_MAX_TOOL_OUTPUT_LLM = 15000   # chars fed back to LLM
_AGENT_TIMEOUT = 300.0         # seconds total per chat turn (5 min)

_GATEWAY_PORT: int = int(os.environ.get("CVC_GATEWAY_PORT", "17888"))
_PROXY_PORT: int = int(os.environ.get("CVC_PROXY_PORT", "19333"))
_HOST: str = os.environ.get("CVC_GATEWAY_HOST", "127.0.0.1")


class _StaticAsset304Filter(logging.Filter):
    """Hide noisy uvicorn access logs for cached static assets (304 responses)."""

    def filter(self, record: logging.LogRecord) -> bool:
        args = getattr(record, "args", ())
        if not isinstance(args, tuple) or len(args) < 5:
            return True

        method = str(args[1])
        path = str(args[2])
        status = str(args[4])
        if method == "GET" and path.startswith("/static/") and status == "304":
            return False
        return True


class _WebSocketNoiseFilter(logging.Filter):
    """Suppress low-value websocket lifecycle chatter from uvicorn logs."""

    def filter(self, record: logging.LogRecord) -> bool:
        message = record.getMessage().strip().lower()
        return message not in {"connection open", "connection closed"}


def _configure_gateway_logging() -> None:
    """Filter routine cache/websocket noise while keeping real logs visible."""
    access_logger = logging.getLogger("uvicorn.access")
    if not any(isinstance(f, _StaticAsset304Filter) for f in access_logger.filters):
        access_logger.addFilter(_StaticAsset304Filter())

    error_logger = logging.getLogger("uvicorn.error")
    if not any(isinstance(f, _WebSocketNoiseFilter) for f in error_logger.filters):
        error_logger.addFilter(_WebSocketNoiseFilter())


def _set_tool_confirm(evt: asyncio.Event | None, result: str) -> None:
    """Set the module-level tool confirmation state."""
    global _tool_confirm_event, _tool_confirm_result
    _tool_confirm_event = evt
    _tool_confirm_result = result


def _get_tool_confirm_result() -> str:
    """Read the current tool confirmation result."""
    return _tool_confirm_result


def _get_event_bus():
    global _event_bus
    if _event_bus is None:
        from cvc.sdk.events import EventBus
        _event_bus = EventBus()
    return _event_bus


async def _health_poll_loop():
    while True:
        try:
            if _manager:
                _manager.refresh_health()
        except Exception:
            logger.debug("Health poll error", exc_info=True)
        await asyncio.sleep(10)


@asynccontextmanager
async def lifespan(app: FastAPI):
    global _manager, _workspace_mgr, _health_task, _project_root
    global _agent_llm, _system_prompt, _agent_tools, _tool_executor

    from cvc.core.models import discover_cvc_root
    from cvc.gateway_manager import GatewayManager
    from cvc.workspace_manager import WorkspaceManager

    _configure_gateway_logging()

    _project_root = discover_cvc_root()
    if _project_root is None:
        _project_root = Path.cwd()

    # Initialize WorkspaceManager and activate the discovered workspace
    _workspace_mgr = WorkspaceManager()
    _workspace_mgr.switch_to(_project_root)

    cvc_root = _project_root / ".cvc"
    _manager = GatewayManager(cvc_root, host=_HOST)

    # Launch proxy without blocking on health check — the background
    # health poll will update its status once it's ready.
    proxy_port = int(os.environ.get("CVC_PROXY_PORT", "19333"))
    _manager.launch_proxy(proxy_port)

    # Defer Agent LLM init to a background task so the HTTP server
    # starts accepting requests immediately.
    asyncio.create_task(_init_agent_llm_background())

    _health_task = asyncio.create_task(_health_poll_loop())
    logger.info("CVC Gateway started — http://%s:%d", _HOST, _GATEWAY_PORT)

    yield

    if _health_task:
        _health_task.cancel()
    if _agent_llm:
        try:
            await _agent_llm.close()
        except Exception:
            pass
    if _workspace_mgr:
        _workspace_mgr.close_all()
    logger.info("CVC Gateway shut down.")


async def _init_agent_llm_background() -> None:
    """Initialise Agent LLM + ToolExecutor in a background task.

    Runs after the event loop is already serving HTTP, so the dashboard
    loads instantly.  Chat endpoints gracefully handle _agent_llm=None
    until this completes.
    """
    global _agent_llm, _system_prompt, _agent_tools, _tool_executor

    # Yield control so uvicorn can bind the port first
    await asyncio.sleep(0)

    try:
        from cvc.core.models import GlobalConfig
        from cvc.agent.llm import AgentLLM
        from cvc.agent.system_prompt import build_system_prompt
        from cvc.agent.tools import AGENT_TOOLS
        from cvc.agent.executor import ToolExecutor

        gc = GlobalConfig.load()
        provider = os.getenv("CVC_PROVIDER", gc.provider)
        model = os.getenv("CVC_MODEL", gc.model)

        # Resolve API key (same logic as CLI agent)
        env_map = {
            "anthropic": "ANTHROPIC_API_KEY",
            "openai": "OPENAI_API_KEY",
            "google": "GOOGLE_API_KEY",
            "ollama": "",
            "lmstudio": "",
        }
        env_key = env_map.get(provider, "")
        api_key = os.getenv(env_key, "") if env_key else ""
        if not api_key:
            api_key = gc.api_keys.get(provider, "")

        base_url_map = {
            "anthropic": "https://api.anthropic.com",
            "openai": "https://api.openai.com",
            "google": "https://generativelanguage.googleapis.com",
            "ollama": os.getenv("OLLAMA_HOST", "http://localhost:11434"),
            "lmstudio": os.getenv("LMSTUDIO_HOST", "http://localhost:1234"),
        }
        base_url = base_url_map.get(provider, "")

        _agent_llm = AgentLLM(
            provider=provider,
            api_key=api_key,
            model=model,
            base_url=base_url,
        )

        _system_prompt = build_system_prompt(
            workspace=str(_project_root),
            provider=provider,
            model=model,
            branch=_workspace_mgr.engine.active_branch if _workspace_mgr.engine else "main",
        )

        # Dashboard-safe tool subset (same definitions as CLI agent)
        _DASHBOARD_TOOL_NAMES = {
            "read_file", "write_file", "edit_file", "patch_file",
            "bash", "glob", "grep", "list_dir", "web_search",
            "cvc_status", "cvc_log", "cvc_commit", "cvc_branch",
            "cvc_restore", "cvc_merge", "cvc_search", "cvc_smart_search", "cvc_diff",
            "cvc_switch_workspace",
        }
        _agent_tools = [t for t in AGENT_TOOLS if t.get("function", {}).get("name") in _DASHBOARD_TOOL_NAMES]

        # Create ToolExecutor scoped to the current workspace
        _tool_executor = ToolExecutor(_project_root, _workspace_mgr.engine)

        # Pre-warm the TCP + TLS connection
        asyncio.create_task(_agent_llm.warm_connection())
        logger.info("Agent LLM initialised — %s/%s (%d tools)", provider, model, len(_agent_tools))
    except Exception:
        logger.warning("Agent LLM init failed — chat will fall back to proxy", exc_info=True)


# ---------------------------------------------------------------------------
# FastAPI App
# ---------------------------------------------------------------------------

try:
    from cvc import __version__ as _cvc_version
except ImportError:
    _cvc_version = "2.0.0"

app = FastAPI(
    title="CVC Gateway",
    version=_cvc_version,
    lifespan=lifespan,
)

app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_methods=["*"],
    allow_headers=["*"],
)


def _ensure(what="gateway"):
    if not _manager or not _workspace_mgr or not _workspace_mgr.db or not _workspace_mgr.config:
        raise HTTPException(status_code=503, detail=f"{what} not initialised yet")


# Thin accessors — route handlers use these instead of module-level singletons.
# All delegate to _workspace_mgr so the active workspace can change at runtime.

def _get_db():
    return _workspace_mgr.db if _workspace_mgr else None


def _get_config():
    return _workspace_mgr.config if _workspace_mgr else None


def _get_engine():
    return _workspace_mgr.engine if _workspace_mgr else None


# ═══════════════════════════════════════════════════════════════════════
# 1. HEALTH
# ═══════════════════════════════════════════════════════════════════════

@app.get("/health")
async def health():
    return {"status": "ok", "service": "cvc-gateway", "version": _cvc_version}


# ═══════════════════════════════════════════════════════════════════════
# 1b. WORKSPACE MANAGEMENT — Multi-workspace isolation
# ═══════════════════════════════════════════════════════════════════════

@app.get("/api/workspace/current")
async def workspace_current():
    """Return the currently active workspace path and stats."""
    if not _workspace_mgr or not _workspace_mgr.current:
        raise HTTPException(503, "No workspace active")
    inst = _workspace_mgr.current
    info = {
        "path": str(inst.path),
        "last_accessed": inst.last_accessed,
        "initialized": inst.initialized,
    }
    if inst.engine:
        info["branch"] = inst.engine.active_branch
        try:
            commits = inst.db.index.list_all_commits(limit=9999)
            info["commit_count"] = len(commits)
        except Exception:
            info["commit_count"] = 0
    return info


@app.get("/api/workspace/list")
async def workspace_list():
    """List all known workspaces (open + from persistent registry)."""
    if not _workspace_mgr:
        raise HTTPException(503, "Workspace manager not initialised")
    return {"workspaces": _workspace_mgr.list_workspaces()}


@app.post("/api/workspace/switch")
async def workspace_switch(request: Request):
    """Switch the active workspace. Auto-initializes .cvc/ if needed."""
    global _tool_executor, _system_prompt, _project_root
    if not _workspace_mgr:
        raise HTTPException(503, "Workspace manager not initialised")
    body = await request.json()
    target_path = body.get("path", "")
    if not target_path:
        raise HTTPException(400, "Missing 'path' in request body")

    target = Path(target_path).resolve()
    if not target.exists():
        raise HTTPException(404, f"Directory does not exist: {target}")
    if not target.is_dir():
        raise HTTPException(400, f"Not a directory: {target}")

    inst = _workspace_mgr.switch_to(target)

    # Re-scope tool executor and system prompt to new workspace
    _project_root = inst.path
    try:
        from cvc.agent.executor import ToolExecutor
        _tool_executor = ToolExecutor(inst.path, inst.engine)
    except Exception:
        logger.warning("Failed to re-scope ToolExecutor", exc_info=True)

    try:
        from cvc.agent.system_prompt import build_system_prompt
        provider = _agent_llm.provider if _agent_llm else inst.config.provider
        model = _agent_llm.model if _agent_llm else inst.config.model
        _system_prompt = build_system_prompt(
            workspace=str(inst.path),
            provider=provider,
            model=model,
            branch=inst.engine.active_branch,
        )
    except Exception:
        logger.warning("Failed to rebuild system prompt", exc_info=True)

    return {
        "status": "ok",
        "path": str(inst.path),
        "branch": inst.engine.active_branch if inst.engine else "main",
    }


@app.post("/api/workspace/init")
async def workspace_init(request: Request):
    """Explicitly initialize .cvc/ in a directory (without switching)."""
    if not _workspace_mgr:
        raise HTTPException(503, "Workspace manager not initialised")
    body = await request.json()
    target_path = body.get("path", "")
    if not target_path:
        raise HTTPException(400, "Missing 'path' in request body")

    target = Path(target_path).resolve()
    inst = _workspace_mgr.init_workspace(target)
    return {
        "status": "ok",
        "path": str(inst.path),
        "branch": inst.engine.active_branch if inst.engine else "main",
    }


@app.delete("/api/workspace/close")
async def workspace_close(request: Request):
    """Close a workspace's DB connections (does not delete .cvc/)."""
    if not _workspace_mgr:
        raise HTTPException(503, "Workspace manager not initialised")
    body = await request.json()
    target_path = body.get("path", "")
    if not target_path:
        raise HTTPException(400, "Missing 'path' in request body")

    closed = _workspace_mgr.close_workspace(target_path)
    if not closed:
        raise HTTPException(404, f"Workspace not open: {target_path}")
    return {"status": "ok", "path": target_path, "closed": True}


# ═══════════════════════════════════════════════════════════════════════
# 2. SERVICE MANAGEMENT
# ═══════════════════════════════════════════════════════════════════════

@app.get("/api/gateway/services")
async def get_services():
    _ensure()
    services = _manager.service_status()
    enriched = {}
    for svc in services:
        name = svc["name"]
        enriched[name] = svc
        enriched[name]["uptime"] = _manager.get_service(name).uptime_display
    if _get_db():
        try:
            agents = _get_db().index.list_agents() if hasattr(_get_db().index, "list_agents") else []
            enriched.get("sdk", {})["registered_agents"] = len(agents)
        except Exception:
            pass
        try:
            total = len(_get_db().index.list_all_commits(limit=9999)) if hasattr(_get_db().index, "list_all_commits") else 0
            enriched.get("sdk", {})["total_commits"] = total
        except Exception:
            pass
    return enriched


@app.post("/api/gateway/services/{service_name}/{action}")
async def service_action(service_name: str, action: str):
    _ensure()
    if service_name == "proxy":
        port = _manager.get_service("proxy").port or _PROXY_PORT
        actions = {
            "start": lambda: _manager.start_proxy(port),
            "stop": lambda: _manager.stop_proxy(),
            "restart": lambda: _manager.restart_proxy(port),
            "pause": lambda: _manager.pause_proxy(),
            "resume": lambda: _manager.resume_proxy(),
        }
    else:
        raise HTTPException(400, f"Service '{service_name}' does not support lifecycle actions")
    if action not in actions:
        raise HTTPException(400, f"Unknown action '{action}'. Use: start, stop, restart, pause, resume")
    result = actions[action]()
    _get_event_bus().emit("service.action", {"service": service_name, "action": action, "status": result.status})
    return result.to_dict()


# ═══════════════════════════════════════════════════════════════════════
# 3. ANALYTICS & COMMITS
# ═══════════════════════════════════════════════════════════════════════

@app.get("/api/gateway/analytics")
async def get_analytics():
    _ensure()
    analytics: dict[str, Any] = {
        "commits": {"total": 0, "by_type": {}, "by_branch": {}},
        "branches": [],
        "tokens": {"total": 0, "estimated_cost_usd": 0.0},
        "recent_activity": [],
    }
    try:
        idx = _get_db().index
        commits = idx.list_all_commits(limit=9999) if hasattr(idx, "list_all_commits") else []
        analytics["commits"]["total"] = len(commits)
        by_type: dict[str, int] = {}
        for c in commits:
            ct = _field(c, "commit_type", "unknown")
            by_type[ct] = by_type.get(ct, 0) + 1
        analytics["commits"]["by_type"] = by_type

        branches = idx.list_branches() if hasattr(idx, "list_branches") else []
        analytics["branches"] = [
            {
                "name": b.name if hasattr(b, "name") else b.get("name", ""),
                "head": (b.head_hash if hasattr(b, "head_hash") else b.get("head_hash", ""))[:12],
                "status": b.status if hasattr(b, "status") else b.get("status", "active"),
            }
            for b in branches
        ]

        if hasattr(idx, "query_audit_log"):
            audit = idx.query_audit_log(limit=500)
            total_tokens = sum(e.get("token_count", 0) for e in audit if isinstance(e, dict))
            analytics["tokens"]["total"] = total_tokens
            analytics["tokens"]["estimated_cost_usd"] = round(total_tokens * 3.0 / 1_000_000, 4)

        recent = commits[-20:] if len(commits) > 20 else commits
        analytics["recent_activity"] = [
            {
                "hash": _field(c, "commit_hash", "")[:12],
                "type": _field(c, "commit_type", ""),
                "message": _field(c, "message", "")[:80],
                "agent_id": _meta(c, "agent_id", ""),
                "created_at": _ts(c),
            }
            for c in recent
        ]
    except Exception:
        logger.debug("Analytics query error", exc_info=True)
    return analytics


@app.get("/api/gateway/commits")
async def get_commits(limit: int = 50, offset: int = 0, branch: str | None = None, agent_id: str | None = None):
    _ensure()
    try:
        commits = _get_db().index.list_all_commits(limit=9999) if hasattr(_get_db().index, "list_all_commits") else []
        commits = list(reversed(commits))
        if branch:
            commits = [c for c in commits if _meta(c, "branch") == branch]
        if agent_id:
            commits = [c for c in commits if _meta(c, "agent_id") == agent_id]
        total = len(commits)
        page = commits[offset:offset + limit]
        return {
            "total": total, "offset": offset, "limit": limit,
            "commits": [
                {
                    "hash": _field(c, "commit_hash", "")[:12],
                    "type": _field(c, "commit_type", ""),
                    "message": _field(c, "message", ""),
                    "created_at": _ts(c),
                    "agent_id": _meta(c, "agent_id", ""),
                    "branch": _meta(c, "branch", ""),
                }
                for c in page
            ],
        }
    except Exception:
        logger.debug("Commits query error", exc_info=True)
        return {"total": 0, "offset": offset, "limit": limit, "commits": []}


# ═══════════════════════════════════════════════════════════════════════
# 4. OPERATIONS — commit, branch, merge, restore, recall, diff
# ═══════════════════════════════════════════════════════════════════════

@app.post("/api/ops/commit")
async def ops_commit(request: Request):
    _ensure("engine")
    body = await request.json()
    from cvc.core.models import CommitRequest
    req = CommitRequest(
        message=body.get("message", "Dashboard commit"),
        content=body.get("content", ""),
        commit_type=body.get("type", "checkpoint"),
        tags=body.get("tags", []),
    )
    result = _get_engine().commit(req)
    _get_event_bus().emit("commit.created", {"hash": result.commit_hash[:12], "message": req.message})
    return {"commit_hash": result.commit_hash[:12], "message": req.message, "status": "ok"}


@app.post("/api/ops/branch")
async def ops_branch(request: Request):
    _ensure("engine")
    body = await request.json()
    from cvc.core.models import BranchRequest
    req = BranchRequest(name=body["name"])
    result = _get_engine().branch(req)
    return {"branch": result.name, "head": result.head_hash[:12] if result.head_hash else "", "status": "ok"}


@app.post("/api/ops/merge")
async def ops_merge(request: Request):
    _ensure("engine")
    body = await request.json()
    from cvc.core.models import MergeRequest
    req = MergeRequest(source_branch=body["source_branch"], strategy=body.get("strategy", "semantic"))
    result = _get_engine().merge(req)
    return {"merge_commit": result.commit_hash[:12] if hasattr(result, "commit_hash") else "", "status": "ok"}


@app.post("/api/ops/restore")
async def ops_restore(request: Request):
    _ensure("engine")
    body = await request.json()
    from cvc.core.models import RestoreRequest
    hash_val = body.get("hash") or body.get("commit_hash", "")
    req = RestoreRequest(commit_hash=hash_val)
    _get_engine().restore(req)
    return {"restored_to": hash_val, "status": "ok"}


@app.get("/api/ops/status")
async def ops_status():
    _ensure("engine")
    try:
        branches = _get_db().index.list_branches() if hasattr(_get_db().index, "list_branches") else []
        commits = _get_db().index.list_all_commits(limit=9999) if hasattr(_get_db().index, "list_all_commits") else []
        active = _get_config().default_branch
        head = ""
        for b in branches:
            name = b.name if hasattr(b, "name") else b.get("name", "")
            if name == active:
                head = (b.head_hash if hasattr(b, "head_hash") else b.get("head_hash", ""))[:12]
                break
        db_path = _get_config().cvc_root / "cvc.db"
        blobs_dir = _get_config().cvc_root / "objects"
        return {
            "branch": active, "head": head,
            "total_branches": len(branches), "total_commits": len(commits),
            "database_size_bytes": db_path.stat().st_size if db_path.exists() else 0,
            "blob_count": len(list(blobs_dir.glob("*"))) if blobs_dir.exists() else 0,
            "cvc_root": str(_get_config().cvc_root),
        }
    except Exception as exc:
        return {"error": str(exc)}


@app.get("/api/ops/recall")
async def ops_recall(query: str, limit: int = 10):
    _ensure()
    try:
        results = _get_db().search_conversations(query, limit=limit)
        return {"results": results, "total": len(results)}
    except Exception as exc:
        logger.debug("Recall search error", exc_info=True)
        return {"results": [], "total": 0, "error": str(exc)}


@app.get("/api/ops/diff")
async def ops_diff(hash1: str, hash2: str):
    _ensure()
    try:
        idx = _get_db().index
        c1 = idx.get_commit(hash1) if hasattr(idx, "get_commit") else None
        c2 = idx.get_commit(hash2) if hasattr(idx, "get_commit") else None
        if not c1 or not c2:
            raise HTTPException(404, "One or both commits not found")

        # Build a meaningful diff from commit metadata + blob content
        diff_lines = []
        diff_lines.append(f"--- Commit A: {hash1[:12]}")
        diff_lines.append(f"+++ Commit B: {hash2[:12]}")
        diff_lines.append(f"")

        m1, m2 = _field(c1, "message", ""), _field(c2, "message", "")
        t1, t2 = _field(c1, "commit_type", ""), _field(c2, "commit_type", "")
        diff_lines.append(f"Message A: {m1}")
        diff_lines.append(f"Message B: {m2}")
        if t1 != t2:
            diff_lines.append(f"Type: {t1} → {t2}")

        # Try to diff blob contents
        try:
            h1_full = _field(c1, "commit_hash", hash1)
            h2_full = _field(c2, "commit_hash", hash2)
            blob1 = _get_db().retrieve_blob(h1_full)
            blob2 = _get_db().retrieve_blob(h2_full)
            if blob1 and blob2:
                msgs1 = [f"[{m.role}] {m.content[:100]}" for m in (blob1.messages or [])]
                msgs2 = [f"[{m.role}] {m.content[:100]}" for m in (blob2.messages or [])]
                diff_lines.append(f"")
                diff_lines.append(f"Context A: {len(blob1.messages or [])} messages")
                diff_lines.append(f"Context B: {len(blob2.messages or [])} messages")
                # Show what's new in B
                msgs1_set = set(msgs1)
                for m in msgs2:
                    if m not in msgs1_set:
                        diff_lines.append(f"+ {m}")
                msgs2_set = set(msgs2)
                for m in msgs1:
                    if m not in msgs2_set:
                        diff_lines.append(f"- {m}")
        except Exception:
            pass

        return {
            "commit_a": {"hash": hash1, "message": m1, "type": t1},
            "commit_b": {"hash": hash2, "message": m2, "type": t2},
            "diff": "\n".join(diff_lines),
        }
    except HTTPException:
        raise
    except Exception as exc:
        return {"error": str(exc)}


@app.get("/api/ops/timeline")
async def ops_timeline(limit: int = 100):
    _ensure()
    try:
        commits = _get_db().index.list_all_commits(limit=limit) if hasattr(_get_db().index, "list_all_commits") else []
        commits = list(reversed(commits))[:limit]
        return {
            "timeline": [
                {
                    "hash": _field(c, "commit_hash", "")[:12],
                    "type": _field(c, "commit_type", ""),
                    "message": _field(c, "message", ""),
                    "created_at": _ts(c),
                    "agent_id": _meta(c, "agent_id", ""),
                    "branch": _meta(c, "branch", ""),
                }
                for c in commits
            ]
        }
    except Exception as exc:
        return {"timeline": [], "error": str(exc)}


@app.get("/api/ops/branches")
async def ops_branches():
    _ensure()
    try:
        branches = _get_db().index.list_branches() if hasattr(_get_db().index, "list_branches") else []
        return {
            "branches": [
                {
                    "name": b.name if hasattr(b, "name") else b.get("name", ""),
                    "head": (b.head_hash if hasattr(b, "head_hash") else b.get("head_hash", ""))[:12],
                    "status": b.status if hasattr(b, "status") else b.get("status", "active"),
                }
                for b in branches
            ]
        }
    except Exception as exc:
        return {"branches": [], "error": str(exc)}


# ═══════════════════════════════════════════════════════════════════════
# 5. MEMORY & CONTEXT
# ═══════════════════════════════════════════════════════════════════════

@app.get("/api/memory/context")
async def get_context(limit: int = 50):
    _ensure()
    try:
        ctx = _get_engine().context_window if _get_engine() else []
        messages = ctx[:limit]
        token_count = sum(len(getattr(m, 'content', '').split()) for m in ctx)
        return {
            "messages": [
                {"role": getattr(m, 'role', ''), "content": getattr(m, 'content', '')[:500], "timestamp": getattr(m, 'timestamp', 0)}
                for m in messages
            ],
            "total": len(ctx),
            "token_count": token_count,
        }
    except Exception as exc:
        return {"messages": [], "total": 0, "token_count": 0, "error": str(exc)}


@app.get("/api/memory/blobs")
async def get_blobs(limit: int = 20):
    _ensure()
    try:
        blobs_dir = _get_config().cvc_root / "objects"
        if not blobs_dir.exists():
            return {"blobs": [], "total": 0}
        blob_files = sorted(blobs_dir.glob("*"), key=lambda p: p.stat().st_mtime, reverse=True)[:limit]
        return {
            "blobs": [{"hash": f.name, "size_bytes": f.stat().st_size, "modified": f.stat().st_mtime} for f in blob_files],
            "total": len(list(blobs_dir.glob("*"))),
        }
    except Exception as exc:
        return {"blobs": [], "total": 0, "error": str(exc)}


@app.get("/api/memory/stats")
async def get_memory_stats():
    _ensure()
    try:
        db_path = _get_config().cvc_root / "cvc.db"
        blobs_dir = _get_config().cvc_root / "objects"
        return {
            "database_size_bytes": db_path.stat().st_size if db_path.exists() else 0,
            "blob_count": len(list(blobs_dir.glob("*"))) if blobs_dir.exists() else 0,
            "blob_total_bytes": sum(f.stat().st_size for f in blobs_dir.glob("*")) if blobs_dir.exists() else 0,
            "cvc_root": str(_get_config().cvc_root),
        }
    except Exception as exc:
        return {"error": str(exc)}


# ═══════════════════════════════════════════════════════════════════════
# 6. MODEL CATALOG & PROVIDER
# ═══════════════════════════════════════════════════════════════════════

@app.get("/api/models/catalog")
async def get_model_catalog():
    try:
        from cvc.adapters import PROVIDER_DEFAULTS
        # Build a proper model catalog per provider
        MODEL_CATALOG = {
            "anthropic": [
                {"id": "claude-opus-4-6", "name": "Claude Opus 4.6", "context_window": 200000},
                {"id": "claude-sonnet-4-6", "name": "Claude Sonnet 4.6", "context_window": 200000},
                {"id": "claude-opus-4-5", "name": "Claude Opus 4.5", "context_window": 200000},
                {"id": "claude-sonnet-4-5", "name": "Claude Sonnet 4.5", "context_window": 200000},
                {"id": "claude-haiku-4-5", "name": "Claude Haiku 4.5", "context_window": 200000},
            ],
            "openai": [
                {"id": "gpt-5.3", "name": "GPT-5.3", "context_window": 128000},
                {"id": "gpt-5.2", "name": "GPT-5.2", "context_window": 128000},
                {"id": "gpt-5.2-codex", "name": "GPT-5.2 Codex", "context_window": 128000},
                {"id": "gpt-5-mini", "name": "GPT-5 Mini", "context_window": 128000},
            ],
            "google": [
                {"id": "gemini-3-pro", "name": "Gemini 3 Pro", "context_window": 1000000},
                {"id": "gemini-3-flash", "name": "Gemini 3 Flash", "context_window": 1000000},
                {"id": "gemini-3.1-pro-preview", "name": "Gemini 3.1 Pro Preview", "context_window": 1000000},
                {"id": "gemini-2.5-pro", "name": "Gemini 2.5 Pro", "context_window": 1000000},
                {"id": "gemini-2.5-flash", "name": "Gemini 2.5 Flash", "context_window": 1000000},
            ],
            "ollama": [
                {"id": "qwen2.5-coder:7b", "name": "Qwen 2.5 Coder 7B", "context_window": 32768},
                {"id": "qwen3-coder:latest", "name": "Qwen 3 Coder", "context_window": 32768},
                {"id": "deepseek-r1:latest", "name": "DeepSeek-R1", "context_window": 32768},
                {"id": "llama3:8b", "name": "Llama 3 8B", "context_window": 8192},
            ],
            "lmstudio": [
                {"id": "loaded-model", "name": "LM Studio (loaded model)", "context_window": 0},
            ],
        }
        catalog = {}
        for provider, defaults in PROVIDER_DEFAULTS.items():
            catalog[provider] = MODEL_CATALOG.get(provider, [{"id": defaults["model"], "name": defaults["model"]}])
        return {
            "providers": catalog,
            "current_provider": _get_config().provider if _get_config() else "",
            "current_model": _get_config().model if _get_config() else "",
        }
    except Exception as exc:
        return {"providers": {}, "error": str(exc)}


@app.get("/api/models/current")
async def get_current_model():
    _ensure()
    from cvc.core.models import GlobalConfig as GC
    gc = GC.load()
    # Prefer the live agent LLM config (matches what chat actually uses)
    provider = _agent_llm.provider if _agent_llm else _get_config().provider
    model = _agent_llm.model if _agent_llm else _get_config().model
    return {
        "provider": provider,
        "model": model,
        "api_key_set": bool(_get_config().api_key) or bool(_agent_llm),
        "api_keys_stored": {k: bool(v) for k, v in gc.api_keys.items()},
    }


@app.post("/api/models/switch")
async def switch_model(request: Request):
    global _agent_llm, _system_prompt
    _ensure()
    body = await request.json()
    new_provider = body.get("provider", _get_config().provider)
    new_model = body.get("model", _get_config().model)
    from cvc.core.models import GlobalConfig as GC
    gc = GC.load()
    gc.provider = new_provider
    gc.model = new_model
    gc.save()
    _get_config().provider = new_provider
    _get_config().model = new_model

    # Re-create the AgentLLM with the new provider/model
    try:
        from cvc.agent.llm import AgentLLM
        from cvc.agent.system_prompt import build_system_prompt

        env_map = {"anthropic": "ANTHROPIC_API_KEY", "openai": "OPENAI_API_KEY", "google": "GOOGLE_API_KEY", "ollama": "", "lmstudio": ""}
        env_key = env_map.get(new_provider, "")
        api_key = os.getenv(env_key, "") if env_key else ""
        if not api_key:
            api_key = gc.api_keys.get(new_provider, "")
        base_url_map = {
            "anthropic": "https://api.anthropic.com", "openai": "https://api.openai.com",
            "google": "https://generativelanguage.googleapis.com",
            "ollama": os.getenv("OLLAMA_HOST", "http://localhost:11434"),
            "lmstudio": os.getenv("LMSTUDIO_HOST", "http://localhost:1234"),
        }
        if _agent_llm:
            try:
                await _agent_llm.close()
            except Exception:
                pass
        _agent_llm = AgentLLM(provider=new_provider, api_key=api_key, model=new_model, base_url=base_url_map.get(new_provider, ""))
        _system_prompt = build_system_prompt(workspace=str(_project_root), provider=new_provider, model=new_model, branch=_get_engine().active_branch)
        asyncio.create_task(_agent_llm.warm_connection())
        logger.info("Agent LLM switched to %s/%s", new_provider, new_model)
    except Exception:
        logger.warning("Failed to re-create AgentLLM after model switch", exc_info=True)

    # Notify the proxy to reload config (best-effort)
    try:
        import httpx
        httpx.post(f"http://{_HOST}:{_PROXY_PORT}/cvc/reload-config", timeout=3.0)
    except Exception:
        pass
    return {"provider": new_provider, "model": new_model, "status": "ok"}


# ═══════════════════════════════════════════════════════════════════════
# 7. HIVE MIND / SDK
# ═══════════════════════════════════════════════════════════════════════

@app.get("/api/gateway/config")
async def get_config():
    _ensure()
    provider = _agent_llm.provider if _agent_llm else _get_config().provider
    model = _agent_llm.model if _agent_llm else _get_config().model
    return {
        "provider": provider,
        "model": model,
        "agent_id": _get_config().agent_id,
        "default_branch": _get_config().default_branch,
        "proxy_port": _PROXY_PORT,
        "gateway_port": _GATEWAY_PORT,
        "host": _HOST,
        "api_key_set": bool(_get_config().api_key) or bool(_agent_llm),
        "cvc_root": str(_get_config().cvc_root),
    }


@app.get("/api/hivemind/agents")
async def get_hivemind_agents():
    _ensure()
    try:
        agents = _get_db().index.list_agents() if hasattr(_get_db().index, "list_agents") else []
        result = []
        for a in agents:
            if isinstance(a, dict):
                result.append(a)
            else:
                d = {}
                for attr in ('agent_id', 'name', 'role', 'rank', 'squad', 'status'):
                    if hasattr(a, attr):
                        d[attr] = getattr(a, attr)
                result.append(d)
        return {"agents": result, "total": len(result)}
    except Exception:
        return {"agents": [], "total": 0}


@app.post("/api/hivemind/register")
async def register_agent(request: Request):
    _ensure()
    body = await request.json()
    try:
        agent_id = body["agent_id"]
        _get_db().index.insert_agent(
            agent_id=agent_id,
            name=body.get("name", agent_id),
            role=body.get("role", "worker"),
            rank=body.get("rank", "private"),
            squad=body.get("squad", "alpha"),
        )
        return {"agent_id": agent_id, "status": "registered"}
    except Exception as exc:
        raise HTTPException(400, str(exc))


@app.delete("/api/hivemind/agents/{agent_id}")
async def remove_agent(agent_id: str):
    _ensure()
    try:
        if hasattr(_get_db().index, "delete_agent") and _get_db().index.delete_agent(agent_id):
            return {"agent_id": agent_id, "status": "removed"}
        raise HTTPException(404, f"Agent '{agent_id}' not found")
    except HTTPException:
        raise
    except Exception as exc:
        raise HTTPException(400, str(exc))


# ═══════════════════════════════════════════════════════════════════════
# 7b. SETTINGS — Comprehensive Configuration Management
# ═══════════════════════════════════════════════════════════════════════

@app.get("/api/settings")
async def get_settings():
    """Return merged settings from all levels + global config."""
    _ensure()
    from cvc.agent.settings import load_settings
    from cvc.core.models import GlobalConfig, SETTINGS_SCHEMA

    gc = GlobalConfig.load()
    workspace = Path(_project_root) if _project_root else Path.cwd()
    settings = load_settings(workspace)

    # Mask API keys
    masked_keys = {}
    for prov, key in gc.api_keys.items():
        if key and len(key) > 8:
            masked_keys[prov] = f"{key[:4]}***{key[-4:]}"
        elif key:
            masked_keys[prov] = "***"
        else:
            masked_keys[prov] = ""

    return {
        "global": {
            "provider": gc.provider,
            "model": gc.model,
            "agent_id": gc.agent_id,
            "api_keys": masked_keys,
        },
        "project": settings.to_dict(),
        "schema": SETTINGS_SCHEMA,
    }


@app.put("/api/settings/global")
async def update_global_settings(request: Request):
    """Update global config fields (provider, model, agent_id)."""
    _ensure()
    from cvc.core.models import GlobalConfig
    body = await request.json()
    gc = GlobalConfig.load()

    allowed_fields = {"provider", "model", "agent_id"}
    for key, value in body.items():
        if key in allowed_fields and isinstance(value, str):
            setattr(gc, key, value)

    gc.save()

    # Live-update the agent LLM if running
    global _agent_llm
    if _agent_llm:
        if "provider" in body or "model" in body:
            try:
                from cvc.adapters import get_adapter
                new_provider = body.get("provider", gc.provider)
                new_model = body.get("model", gc.model)
                _agent_llm = get_adapter(
                    new_provider, model=new_model,
                    api_key=gc.api_keys.get(new_provider, ""),
                )
            except Exception:
                pass

    return {"status": "ok", "saved": list(body.keys())}


@app.put("/api/settings/project")
async def update_project_settings(request: Request):
    """Update .cvc/settings.json (shared project settings)."""
    _ensure()
    from cvc.agent.settings import save_project_settings
    body = await request.json()
    workspace = Path(_project_root) if _project_root else Path.cwd()

    for key, value in body.items():
        save_project_settings(workspace, key, value, local=False)

    return {"status": "ok", "saved": list(body.keys())}


@app.put("/api/settings/local")
async def update_local_settings(request: Request):
    """Update .cvc/settings.local.json (local, not committed)."""
    _ensure()
    from cvc.agent.settings import save_project_settings
    body = await request.json()
    workspace = Path(_project_root) if _project_root else Path.cwd()

    for key, value in body.items():
        save_project_settings(workspace, key, value, local=True)

    return {"status": "ok", "saved": list(body.keys())}


@app.get("/api/settings/schema")
async def get_settings_schema():
    """Return JSON schema for dynamic form rendering."""
    from cvc.core.models import SETTINGS_SCHEMA
    return SETTINGS_SCHEMA


@app.post("/api/settings/reset")
async def reset_settings(request: Request):
    """Reset a settings level to defaults."""
    _ensure()
    body = await request.json()
    level = body.get("level", "local")
    workspace = Path(_project_root) if _project_root else Path.cwd()

    if level == "global":
        from cvc.core.models import GlobalConfig
        gc = GlobalConfig()
        gc.save()
    elif level == "project":
        path = workspace / ".cvc" / "settings.json"
        if path.exists():
            path.write_text("{}\n", encoding="utf-8")
    elif level == "local":
        path = workspace / ".cvc" / "settings.local.json"
        if path.exists():
            path.write_text("{}\n", encoding="utf-8")

    return {"status": "ok", "level": level}


@app.get("/api/settings/keys")
async def get_api_keys():
    """List configured API key providers (keys masked)."""
    from cvc.core.models import GlobalConfig
    gc = GlobalConfig.load()
    result = {}
    for prov, key in gc.api_keys.items():
        if key and len(key) > 8:
            result[prov] = {"masked": f"{key[:4]}***{key[-4:]}", "set": True}
        elif key:
            result[prov] = {"masked": "***", "set": True}
        else:
            result[prov] = {"masked": "", "set": False}
    return {"keys": result}


@app.put("/api/settings/keys/{provider}")
async def set_api_key(provider: str, request: Request):
    """Set or update an API key for a provider."""
    from cvc.core.models import GlobalConfig
    body = await request.json()
    api_key = body.get("api_key", "")
    if not api_key:
        raise HTTPException(400, "api_key is required")

    gc = GlobalConfig.load()
    gc.api_keys[provider] = api_key
    gc.save()
    return {"provider": provider, "status": "ok"}


@app.delete("/api/settings/keys/{provider}")
async def remove_api_key(provider: str):
    """Remove an API key for a provider."""
    from cvc.core.models import GlobalConfig
    gc = GlobalConfig.load()
    gc.api_keys.pop(provider, None)
    gc.save()
    return {"provider": provider, "status": "removed"}


@app.post("/api/settings/keys/{provider}/test")
async def test_api_key(provider: str):
    """Test if the configured API key for a provider works."""
    from cvc.core.models import GlobalConfig
    gc = GlobalConfig.load()
    api_key = gc.api_keys.get(provider, "")
    if not api_key:
        return {"provider": provider, "valid": False, "error": "No API key configured"}

    try:
        import httpx
        if provider == "anthropic":
            r = httpx.get(
                "https://api.anthropic.com/v1/models",
                headers={"x-api-key": api_key, "anthropic-version": "2023-06-01"},
                timeout=10.0,
            )
            valid = r.status_code == 200
        elif provider == "openai":
            r = httpx.get(
                "https://api.openai.com/v1/models",
                headers={"Authorization": f"Bearer {api_key}"},
                timeout=10.0,
            )
            valid = r.status_code == 200
        elif provider == "google":
            r = httpx.get(
                f"https://generativelanguage.googleapis.com/v1/models?key={api_key}",
                timeout=10.0,
            )
            valid = r.status_code == 200
        elif provider == "ollama":
            host = os.getenv("OLLAMA_HOST", "http://localhost:11434")
            r = httpx.get(f"{host}/api/tags", timeout=5.0)
            valid = r.status_code == 200
        elif provider == "lmstudio":
            r = httpx.get("http://localhost:1234/v1/models", timeout=5.0)
            valid = r.status_code == 200
        else:
            return {"provider": provider, "valid": False, "error": "Unknown provider"}

        return {"provider": provider, "valid": valid, "status_code": r.status_code}
    except Exception as exc:
        return {"provider": provider, "valid": False, "error": str(exc)}


@app.get("/api/settings/hooks")
async def get_hooks():
    """List all configured hooks."""
    _ensure()
    from cvc.agent.settings import load_settings
    workspace = Path(_project_root) if _project_root else Path.cwd()
    settings = load_settings(workspace)
    return {"hooks": settings.hooks}


@app.post("/api/settings/hooks")
async def add_hook(request: Request):
    """Add a new hook."""
    _ensure()
    from cvc.agent.settings import save_project_settings, load_settings
    body = await request.json()
    event = body.get("event", "PreToolUse")
    hook_config = body.get("config", {})
    workspace = Path(_project_root) if _project_root else Path.cwd()

    settings = load_settings(workspace)
    hooks = dict(settings.hooks)
    if event not in hooks:
        hooks[event] = []
    hooks[event].append(hook_config)
    save_project_settings(workspace, "hooks", hooks, local=False)
    return {"status": "ok", "event": event}


@app.delete("/api/settings/hooks/{event}/{index}")
async def remove_hook(event: str, index: int):
    """Remove a hook by event type and index."""
    _ensure()
    from cvc.agent.settings import save_project_settings, load_settings
    workspace = Path(_project_root) if _project_root else Path.cwd()

    settings = load_settings(workspace)
    hooks = dict(settings.hooks)
    if event in hooks and 0 <= index < len(hooks[event]):
        hooks[event].pop(index)
        if not hooks[event]:
            del hooks[event]
        save_project_settings(workspace, "hooks", hooks, local=False)
        return {"status": "ok"}
    raise HTTPException(404, "Hook not found")


@app.get("/api/settings/env")
async def get_env_vars():
    """Return CVC-relevant environment variables."""
    cvc_vars = {}
    for key, val in os.environ.items():
        if key.startswith("CVC_") or key.endswith("_API_KEY") or key == "OLLAMA_HOST":
            if "KEY" in key and val and len(val) > 8:
                cvc_vars[key] = f"{val[:4]}***{val[-4:]}"
            elif "KEY" in key and val:
                cvc_vars[key] = "***"
            else:
                cvc_vars[key] = val
    return {"env": cvc_vars}


# ═══════════════════════════════════════════════════════════════════════
# 7c. AGENT TEMPLATES — Create, manage, and deploy custom agents
# ═══════════════════════════════════════════════════════════════════════

@app.get("/api/agents")
async def list_agents():
    """List all agents (registered + templates)."""
    _ensure()
    from cvc.core.models import AgentTemplate

    # Registered agents from DB
    registered = []
    try:
        agents = _get_db().index.list_agents() if hasattr(_get_db().index, "list_agents") else []
        for a in agents:
            d = a if isinstance(a, dict) else {}
            d["source"] = "registered"
            registered.append(d)
    except Exception:
        pass

    # Agent templates from .cvc/agent_templates/
    templates = []
    workspace = Path(_project_root) if _project_root else Path.cwd()
    tpl_dir = workspace / ".cvc" / "agent_templates"
    if tpl_dir.exists():
        for fp in sorted(tpl_dir.glob("*.json")):
            try:
                data = json.loads(fp.read_text(encoding="utf-8"))
                data["source"] = "template"
                templates.append(data)
            except (json.JSONDecodeError, OSError):
                pass

    # Builtin agents
    builtins = _get_builtin_agents()

    return {
        "registered": registered,
        "templates": templates,
        "builtins": builtins,
        "total": len(registered) + len(templates) + len(builtins),
    }


@app.get("/api/agents/builtin")
async def get_builtin_agents():
    """List the built-in sub-agent types with their configs."""
    return _get_builtin_agents()


def _get_builtin_agents() -> list[dict[str, Any]]:
    """Return builtin agent definitions (subagent builtins + enterprise template library)."""
    result = []

    # 1. Core sub-agent builtins
    try:
        from cvc.agent.subagent import BUILTIN_AGENTS
        for name, cfg in BUILTIN_AGENTS.items():
            result.append({
                "id": name.lower(),
                "name": cfg.name if hasattr(cfg, "name") else name,
                "description": cfg.description if hasattr(cfg, "description") else "",
                "system_prompt": cfg.system_prompt if hasattr(cfg, "system_prompt") else "",
                "tools_allow": list(cfg.tools) if hasattr(cfg, "tools") else [],
                "tools_deny": [],
                "tool_tier": _infer_tool_tier(cfg.tools) if hasattr(cfg, "tools") else "coding",
                "max_turns": cfg.max_turns if hasattr(cfg, "max_turns") else 10,
                "rank": "specialist",
                "squad": "",
                "capabilities": [],
                "skills": [],
                "auto_respond": False,
                "auto_share_to_hive": True,
                "model_override": "",
                "provider_override": "",
                "is_builtin": True,
                "source": "builtin",
            })
    except ImportError:
        pass

    # 2. Enterprise template library
    try:
        from cvc.sdk.agent_templates import get_enterprise_templates
        seen_ids = {r["id"] for r in result}
        for tpl in get_enterprise_templates():
            if tpl["id"] not in seen_ids:
                result.append(tpl)
                seen_ids.add(tpl["id"])
    except ImportError:
        pass

    return result


def _infer_tool_tier(tools: frozenset[str] | set[str]) -> str:
    """Infer tool tier from tool set."""
    tool_names = {str(t) for t in tools}
    if any("agent" in t or "spawn" in t or "delegat" in t for t in tool_names):
        return "orchestration"
    if any("web" in t or "fetch" in t for t in tool_names):
        return "research"
    if any("write" in t or "edit" in t or "bash" in t or "patch" in t for t in tool_names):
        return "coding"
    return "read_only"


@app.post("/api/agents")
async def create_agent(request: Request):
    """Create a new agent from explicit config."""
    _ensure()
    body = await request.json()
    workspace = Path(_project_root) if _project_root else Path.cwd()
    tpl_dir = workspace / ".cvc" / "agent_templates"
    tpl_dir.mkdir(parents=True, exist_ok=True)

    from cvc.core.models import AgentTemplate
    template = AgentTemplate(**body)
    template.updated_at = time.time()

    # Save template
    tpl_path = tpl_dir / f"{template.id}.json"
    tpl_path.write_text(
        json.dumps(template.model_dump(), indent=2, default=str),
        encoding="utf-8",
    )

    # Also register in the hive mind registry
    try:
        _get_db().index.insert_agent(
            agent_id=template.id,
            name=template.name,
            role=template.description[:50] if template.description else "custom",
            rank=template.rank,
            squad=template.squad or None,
        )
    except Exception:
        pass

    return {"id": template.id, "status": "created", "template": template.model_dump()}


@app.post("/api/agents/from-prompt")
async def create_agent_from_prompt(request: Request):
    """
    Create an agent from a natural language description.

    Uses the current LLM to analyze the description and generate
    an AgentTemplate with appropriate tools, model, and config.
    """
    _ensure()
    body = await request.json()
    description = body.get("prompt", "") or body.get("description", "")
    if not description:
        raise HTTPException(400, "A prompt or description is required")

    # Build generation prompt
    gen_prompt = f"""You are an agent configuration generator. Based on the user's description,
generate a JSON configuration for a new AI agent.

User's description: "{description}"

Available tool tiers:
- "read_only": read_file, grep, glob, list_dir, cvc_search, cvc_status, cvc_log
- "coding": All read_only + write_file, edit_file, patch_file, bash
- "research": All read_only + web_search, web_fetch
- "orchestration": All coding + agent spawning, delegation

Available ranks: "specialist", "captain", "mission_controller", "zeus"

Respond with ONLY valid JSON (no markdown, no explanation):
{{
  "name": "descriptive agent name",
  "description": "what this agent does",
  "system_prompt": "detailed instructions for the agent",
  "tool_tier": "one of: read_only, coding, research, orchestration",
  "tools_allow": ["specific tools to allow"],
  "tools_deny": ["specific tools to deny"],
  "max_turns": 20,
  "rank": "specialist",
  "squad": "",
  "capabilities": ["list", "of", "capabilities"],
  "skills": ["list", "of", "skills"],
  "auto_respond": false,
  "auto_share_to_hive": true
}}"""

    # Use the current LLM to generate the config
    if _agent_llm:
        try:
            response = await _agent_llm.chat(
                messages=[
                    {"role": "system", "content": "You are a precise JSON generator. Output ONLY valid JSON."},
                    {"role": "user", "content": gen_prompt},
                ],
                tools=[],
                temperature=0.7,
                max_tokens=8192,
            )
            # Parse the LLMResponse
            content = response.text if hasattr(response, 'text') else str(response)
            # Extract JSON from response
            import re
            json_match = re.search(r'\{[\s\S]*\}', content)
            if json_match:
                config = json.loads(json_match.group())
                from cvc.core.models import AgentTemplate
                template = AgentTemplate(**config)
                return {
                    "status": "generated",
                    "template": template.model_dump(),
                    "raw_description": description,
                }
        except Exception as exc:
            raise HTTPException(500, f"LLM generation failed: {exc}")

    # Fallback: generate a reasonable template from keyword analysis when no LLM is available
    from cvc.core.models import AgentTemplate
    template = _generate_template_from_keywords(description)
    return {
        "status": "generated",
        "template": template.model_dump(),
        "raw_description": description,
        "note": "Generated via keyword analysis (no LLM active). Configure an LLM provider for richer results.",
    }


def _generate_template_from_keywords(description: str) -> "AgentTemplate":
    """Generate an AgentTemplate from keyword analysis when no LLM is available."""
    from cvc.core.models import AgentTemplate
    import re as _re
    desc_lower = description.lower()

    # Detect tool tier
    if any(w in desc_lower for w in ("orchestrat", "delegat", "multi-agent", "spawn", "coordinate")):
        tool_tier = "orchestration"
        rank = "mission_controller"
    elif any(w in desc_lower for w in ("research", "search", "web", "browse", "fetch", "scrape", "internet")):
        tool_tier = "research"
        rank = "specialist"
    elif any(w in desc_lower for w in ("write", "edit", "code", "build", "creat", "implement", "fix", "refactor",
                                        "develop", "deploy", "test", "debug", "generat")):
        tool_tier = "coding"
        rank = "specialist"
    else:
        tool_tier = "read_only"
        rank = "specialist"

    # Detect capabilities
    capabilities = []
    cap_keywords = {
        "code_generation": ["code", "implement", "build", "develop", "generat"],
        "analysis": ["analy", "review", "audit", "inspect", "assess"],
        "testing": ["test", "qa", "quality", "verify", "validat"],
        "documentation": ["document", "doc", "readme", "guide", "wiki"],
        "security": ["secur", "vulnerab", "owasp", "audit", "penetrat"],
        "data_processing": ["data", "csv", "json", "etl", "pipeline", "transform"],
        "devops": ["deploy", "ci/cd", "docker", "kubernetes", "infra"],
        "communication": ["report", "email", "notify", "alert", "messag"],
        "memory_management": ["memory", "context", "remember", "recall", "knowledge"],
        "optimization": ["optim", "perform", "speed", "effici", "improv"],
    }
    for cap, keywords in cap_keywords.items():
        if any(k in desc_lower for k in keywords):
            capabilities.append(cap)
    if not capabilities:
        capabilities = ["general_purpose"]

    # Extract a name from the description
    words = _re.sub(r"[^a-zA-Z\s]", "", description).split()
    name_words = [w.capitalize() for w in words[:4] if len(w) > 2]
    name = " ".join(name_words[:3]) + " Agent" if name_words else "Custom Agent"

    # Build system prompt from description
    system_prompt = (
        f"You are a specialized AI agent. Your purpose: {description}\n\n"
        f"Core responsibilities:\n"
    )
    for i, cap in enumerate(capabilities, 1):
        system_prompt += f"  {i}. {cap.replace('_', ' ').title()}\n"
    system_prompt += (
        f"\nOperate with the '{tool_tier}' tool tier. "
        f"Be thorough, accurate, and proactive. Share results to the hive mind."
    )

    agent_id = f"agent-{_re.sub(r'[^a-z0-9]', '-', name.lower()).strip('-')[:30]}"

    return AgentTemplate(
        id=agent_id,
        name=name,
        description=description[:200],
        system_prompt=system_prompt,
        tool_tier=tool_tier,
        max_turns=20,
        rank=rank,
        capabilities=capabilities,
        auto_share_to_hive=True,
    )


@app.post("/api/agents/squad")
async def create_squad(request: Request):
    """Create a new squad (auto-creates the squad branch)."""
    _ensure()
    body = await request.json()
    squad_name = body.get("name", "")
    if not squad_name:
        raise HTTPException(400, "Squad name is required")

    try:
        from cvc.core.models import CVCBranchRequest
        branch_name = f"squad/{squad_name}"
        bp = _get_db().index.get_branch(branch_name)
        if bp is None:
            _get_engine().branch(CVCBranchRequest(
                name=branch_name,
                description=f"Squad: {squad_name}",
            ))
        return {"squad": squad_name, "branch": branch_name, "status": "created"}
    except Exception as exc:
        raise HTTPException(400, str(exc))


@app.get("/api/agents/squads")
async def list_squads():
    """List all squads with their member agents."""
    _ensure()
    try:
        branches = _get_db().index.list_branches()
        squads = []
        for bp in branches:
            if bp.name.startswith("squad/"):
                squad_name = bp.name.removeprefix("squad/")
                agents = _get_db().index.list_agents(squad=squad_name) if hasattr(_get_db().index, "list_agents") else []
                squads.append({
                    "name": squad_name,
                    "branch": bp.name,
                    "agents": [a if isinstance(a, dict) else {} for a in agents],
                    "agent_count": len(agents),
                })
        return {"squads": squads}
    except Exception:
        return {"squads": []}


@app.get("/api/agents/{agent_id}")
async def get_agent_detail(agent_id: str):
    """Get detailed info about an agent."""
    _ensure()
    workspace = Path(_project_root) if _project_root else Path.cwd()

    # Check template file
    tpl_path = workspace / ".cvc" / "agent_templates" / f"{agent_id}.json"
    if tpl_path.exists():
        try:
            data = json.loads(tpl_path.read_text(encoding="utf-8"))
            data["source"] = "template"
            return data
        except (json.JSONDecodeError, OSError):
            pass

    # Check registry
    try:
        profile = _get_db().index.get_agent(agent_id)
        if profile:
            profile["source"] = "registered"
            return profile
    except Exception:
        pass

    # Check builtin agents
    for b in _get_builtin_agents():
        if b.get("id") == agent_id:
            return b

    raise HTTPException(404, f"Agent '{agent_id}' not found")


@app.put("/api/agents/{agent_id}")
async def update_agent(agent_id: str, request: Request):
    """Update an agent's configuration."""
    _ensure()
    body = await request.json()
    workspace = Path(_project_root) if _project_root else Path.cwd()

    # Update template file if it exists
    tpl_path = workspace / ".cvc" / "agent_templates" / f"{agent_id}.json"
    if tpl_path.exists():
        try:
            data = json.loads(tpl_path.read_text(encoding="utf-8"))
            data.update(body)
            data["updated_at"] = time.time()
            tpl_path.write_text(
                json.dumps(data, indent=2, default=str),
                encoding="utf-8",
            )
            return {"id": agent_id, "status": "updated"}
        except (json.JSONDecodeError, OSError) as exc:
            raise HTTPException(500, str(exc))

    # Update registry
    try:
        _get_db().index.update_agent(agent_id, **body)
        return {"id": agent_id, "status": "updated"}
    except Exception as exc:
        raise HTTPException(400, str(exc))


@app.delete("/api/agents/{agent_id}")
async def delete_agent(agent_id: str):
    """Delete an agent (template + registry)."""
    _ensure()
    workspace = Path(_project_root) if _project_root else Path.cwd()

    # Remove template
    tpl_path = workspace / ".cvc" / "agent_templates" / f"{agent_id}.json"
    if tpl_path.exists():
        tpl_path.unlink()

    # Remove from registry
    try:
        if hasattr(_get_db().index, "delete_agent"):
            _get_db().index.delete_agent(agent_id)
    except Exception:
        pass

    return {"id": agent_id, "status": "deleted"}


@app.get("/api/agents/{agent_id}/history")
async def get_agent_history(agent_id: str, limit: int = 50):
    """Get an agent's commit history."""
    _ensure()
    try:
        commits = _get_db().index.list_commits_by_agent(agent_id, limit=limit)
        return {
            "agent_id": agent_id,
            "commits": [
                {
                    "hash": c.commit_hash[:12] if hasattr(c, "commit_hash") else "",
                    "message": c.message if hasattr(c, "message") else "",
                    "type": str(c.commit_type) if hasattr(c, "commit_type") else "",
                    "timestamp": c.metadata.timestamp if hasattr(c, "metadata") else 0,
                }
                for c in commits
            ],
            "total": len(commits),
        }
    except Exception:
        return {"agent_id": agent_id, "commits": [], "total": 0}


# ═══════════════════════════════════════════════════════════════════════
# 7d. HIVE MEMORY — Shared cognitive space
# ═══════════════════════════════════════════════════════════════════════

@app.get("/api/hivemind/memory")
async def get_hive_memory(
    query: str = "",
    category: str | None = None,
    agent_id: str | None = None,
    limit: int = 20,
):
    """Read from the shared hive memory."""
    _ensure()
    try:
        from cvc.sdk.hivemind import HiveMind, HiveMemory
        workspace = Path(_project_root) if _project_root else Path.cwd()
        cvc_root = workspace / ".cvc"
        hive = HiveMind(str(cvc_root))
        memory = HiveMemory(hive)
        entries = memory.read(query, category=category, agent_id=agent_id, limit=limit)
        return {"entries": entries, "total": len(entries)}
    except Exception as exc:
        logger.debug("Hive memory read error: %s", exc)
        return {"entries": [], "total": 0}


@app.post("/api/hivemind/memory")
async def write_hive_memory(request: Request):
    """Write an entry to the shared hive memory."""
    _ensure()
    body = await request.json()
    content = body.get("content", "")
    if not content:
        raise HTTPException(400, "content is required")

    try:
        from cvc.sdk.hivemind import HiveMind, HiveMemory
        workspace = Path(_project_root) if _project_root else Path.cwd()
        cvc_root = workspace / ".cvc"
        hive = HiveMind(str(cvc_root))
        memory = HiveMemory(hive)
        commit_hash = memory.write(
            agent_id=body.get("agent_id", "human"),
            content=content,
            category=body.get("category", "general"),
            tags=body.get("tags", []),
        )
        return {"commit_hash": commit_hash, "status": "ok"}
    except Exception as exc:
        raise HTTPException(500, str(exc))


@app.get("/api/hivemind/memory/stats")
async def get_hive_memory_stats():
    """Get hive memory statistics."""
    _ensure()
    try:
        from cvc.sdk.hivemind import HiveMind, HiveMemory
        workspace = Path(_project_root) if _project_root else Path.cwd()
        cvc_root = workspace / ".cvc"
        hive = HiveMind(str(cvc_root))
        memory = HiveMemory(hive)
        return memory.stats()
    except Exception:
        return {"total_entries": 0, "categories": {}, "contributing_agents": [], "agent_count": 0}


@app.get("/api/hivemind/memory/summary")
async def get_hive_memory_summary(limit: int = 10):
    """Get a text summary of the hive memory state."""
    _ensure()
    try:
        from cvc.sdk.hivemind import HiveMind, HiveMemory
        workspace = Path(_project_root) if _project_root else Path.cwd()
        cvc_root = workspace / ".cvc"
        hive = HiveMind(str(cvc_root))
        memory = HiveMemory(hive)
        summary = memory.summary_context(limit=limit)
        return {"context": summary}
    except Exception:
        return {"context": "No hive memory available."}


@app.post("/api/hivemind/memory/compact")
async def compact_hive_memory(request: Request):
    """Trigger compaction of old hive memory entries."""
    _ensure()
    body = await request.json()
    try:
        from cvc.sdk.hivemind import HiveMind
        workspace = Path(_project_root) if _project_root else Path.cwd()
        cvc_root = workspace / ".cvc"
        hive = HiveMind(str(cvc_root))
        result = hive.compact(
            branch="hive/memory",
            max_age_hours=body.get("max_age_hours", 48),
        )
        return {
            "status": "ok",
            "compacted": result.commits_compacted if hasattr(result, "commits_compacted") else 0,
        }
    except Exception as exc:
        raise HTTPException(500, str(exc))


# ═══════════════════════════════════════════════════════════════════════
# 8. SESSIONS
# ═══════════════════════════════════════════════════════════════════════

@app.get("/api/sessions")
async def get_sessions():
    import httpx
    try:
        r = httpx.get(f"http://{_HOST}:{_PROXY_PORT}/cvc/sessions", timeout=5.0)
        return r.json()
    except Exception:
        return {"sessions": [], "error": "Proxy not reachable"}


# ═══════════════════════════════════════════════════════════════════════
# 9. AGENT CHAT — Full Agentic Loop (same brain as CLI `cvc agent`)
#
# 9a. Workspace switch helper (used by the agentic loop)
# ═══════════════════════════════════════════════════════════════════════

async def _execute_workspace_switch(arguments: dict) -> str:
    """
    Execute a workspace switch from within the agentic chat loop.
    This is a gateway-level operation that re-scopes the tool executor,
    system prompt, and all CVC operations to the new workspace.
    """
    global _tool_executor, _system_prompt, _project_root

    target_path = arguments.get("path", "")
    if not target_path:
        return "Error: 'path' argument is required for cvc_switch_workspace."

    target = Path(target_path).resolve()
    if not target.exists():
        return f"Error: Directory does not exist: {target}"
    if not target.is_dir():
        return f"Error: Not a directory: {target}"

    try:
        inst = _workspace_mgr.switch_to(target)
        _project_root = inst.path

        # Re-scope tool executor to the new workspace
        from cvc.agent.executor import ToolExecutor
        _tool_executor = ToolExecutor(inst.path, inst.engine)

        # Rebuild system prompt with new workspace context
        from cvc.agent.system_prompt import build_system_prompt
        provider = _agent_llm.provider if _agent_llm else inst.config.provider
        model = _agent_llm.model if _agent_llm else inst.config.model
        _system_prompt = build_system_prompt(
            workspace=str(inst.path),
            provider=provider,
            model=model,
            branch=inst.engine.active_branch,
        )

        return (
            f"Successfully switched to workspace: {inst.path}\n"
            f"Branch: {inst.engine.active_branch}\n"
            f"CVC root: {inst.config.cvc_root}\n"
            f"All CVC operations now target this workspace."
        )
    except Exception as exc:
        return f"Error switching workspace: {exc}"


# ═══════════════════════════════════════════════════════════════════════
# 9b. AGENT CHAT — Full Agentic Loop
#
#    Architecture:  Server-side agentic loop.
#    1. Send messages + tools → LLM
#    2. Stream text tokens to the browser in real-time (SSE)
#    3. If LLM returns tool_calls → execute server-side → stream progress
#    4. Feed tool results back to LLM → goto 1
#    5. When LLM returns only text (no tool calls) → done
#
#    SSE event types:
#      {"type":"text",       "content":"…"}
#      {"type":"tool_start", "name":"…", "args":{…}}
#      {"type":"tool_result","name":"…", "output":"…"}
#      {"type":"status",     "message":"…"}
#      [DONE]
# ═══════════════════════════════════════════════════════════════════════

def _sse(payload) -> str:
    """Format a single SSE data line."""
    return f"data: {json.dumps(payload)}\n\n"


@app.post("/api/chat")
async def agent_chat(request: Request):
    global _chat_abort
    _chat_abort = False  # Reset abort flag for new chat turn
    body = await request.json()
    messages = body.get("messages", [])
    stream = body.get("stream", True)

    if _agent_llm is None or not _agent_tools:
        return await _proxy_chat_fallback(messages, stream)

    # Build the LLM message list: system + conversation history from client
    llm_messages: list[dict[str, Any]] = [{"role": "system", "content": _system_prompt}]
    for m in messages:
        llm_messages.append({"role": m.get("role", "user"), "content": m.get("content", "")})

    if not stream:
        try:
            resp = await _agent_llm.chat(llm_messages, tools=_agent_tools, temperature=0.7, max_tokens=8192)
            return {
                "choices": [{"message": {"role": "assistant", "content": resp.text or ""}, "finish_reason": "stop"}],
                "model": _agent_llm.model,
            }
        except Exception as exc:
            raise HTTPException(502, f"LLM error: {exc}")

    # ── Streaming agentic loop ───────────────────────────────────────
    async def agentic_stream():
        global _session_trust_all
        nonlocal llm_messages
        iteration = 0

        try:
            while iteration < _MAX_AGENT_ITERATIONS:
                # ── Abort check ──────────────────────────────────
                if _chat_abort:
                    yield _sse({"type": "status", "message": "Stopped by user."})
                    break

                iteration += 1
                response_text = ""
                tool_calls = []
                current_tool_call = None
                tool_call_args_buffer: dict[int, str] = {}
                gemini_parts = None

                # Adaptive parameters: first call is conversational, subsequent are tool-oriented
                max_tok = 8192 if iteration == 1 else 16384
                temp = 0.7 if iteration == 1 else 0.5

                async for event in _agent_llm.chat_stream(
                    messages=llm_messages,
                    tools=_agent_tools,
                    temperature=temp,
                    max_tokens=max_tok,
                ):
                    if event.type == "text_delta" and event.text:
                        response_text += event.text
                        yield _sse({"type": "text", "content": event.text})

                    elif event.type == "tool_call_start" and event.tool_call:
                        tc = event.tool_call
                        tool_calls.append(tc)
                        tool_call_args_buffer[event.tool_call_index] = ""

                    elif event.type == "tool_call_delta":
                        idx = event.tool_call_index
                        if idx in tool_call_args_buffer:
                            tool_call_args_buffer[idx] += event.args_delta
                        # Try to parse accumulated args into the tool call
                        if tool_calls and idx < len(tool_calls):
                            try:
                                tool_calls[idx].arguments = json.loads(tool_call_args_buffer[idx])
                            except (json.JSONDecodeError, ValueError):
                                pass  # Still accumulating

                    elif event.type == "done":
                        # Capture raw Gemini parts — preserves thoughtSignature
                        # required by Gemini 3 for multi-turn function calling.
                        if event._provider_meta.get("gemini_parts"):
                            gemini_parts = event._provider_meta["gemini_parts"]

                # ── No tool calls → final text response, break loop ──
                if not tool_calls:
                    if response_text:
                        llm_messages.append({"role": "assistant", "content": response_text})
                    break

                # ── Tool calls detected → execute and loop ───────────
                # Build the assistant message with tool calls for the LLM history
                # (OpenAI/Google format: assistant message carries tool_calls)
                assistant_msg: dict[str, Any] = {"role": "assistant", "content": response_text or ""}
                if _agent_llm.provider in ("openai", "ollama", "lmstudio"):
                    assistant_msg["tool_calls"] = [
                        {"id": tc.id, "type": "function", "function": {"name": tc.name, "arguments": json.dumps(tc.arguments)}}
                        for tc in tool_calls
                    ]
                elif _agent_llm.provider == "anthropic":
                    # Anthropic uses content blocks
                    content_blocks = []
                    if response_text:
                        content_blocks.append({"type": "text", "text": response_text})
                    for tc in tool_calls:
                        content_blocks.append({"type": "tool_use", "id": tc.id, "name": tc.name, "input": tc.arguments})
                    assistant_msg = {"role": "assistant", "content": content_blocks}
                elif _agent_llm.provider == "google":
                    # Google uses function_call in content parts — the LLM module handles this
                    assistant_msg["tool_calls"] = [
                        {"id": tc.id, "type": "function", "function": {"name": tc.name, "arguments": json.dumps(tc.arguments)}}
                        for tc in tool_calls
                    ]
                    # Preserve raw Gemini parts (includes thoughtSignature)
                    # so _build_gemini_body sends them back verbatim.
                    if gemini_parts:
                        assistant_msg["_gemini_parts"] = gemini_parts
                llm_messages.append(assistant_msg)

                # Notify client about status
                yield _sse({"type": "status", "message": f"Executing {len(tool_calls)} tool{'s' if len(tool_calls) != 1 else ''}..."})

                # Execute each tool
                for tc in tool_calls:
                    # Stream tool_start to client
                    # Truncate args for display (don't send huge file contents)
                    display_args = {}
                    for k, v in tc.arguments.items():
                        sv = str(v)
                        display_args[k] = sv[:200] + "…" if len(sv) > 200 else sv

                    # ── Permission check ─────────────────────────────
                    # 1. Safe (read-only) tools → auto-approve
                    # 2. Autopilot mode → auto-approve all
                    # 3. Session trust-all → auto-approve all
                    # 4. Session trusted tool → auto-approve this tool
                    # 5. Otherwise → ask user with 5-option card
                    needs_confirm = (
                        not _autopilot
                        and not _session_trust_all
                        and tc.name not in _SAFE_TOOLS
                        and tc.name not in _session_trusted_tools
                    )

                    if needs_confirm:
                        _confirm_evt = asyncio.Event()
                        _set_tool_confirm(evt=_confirm_evt, result="")
                        yield _sse({
                            "type": "tool_confirm",
                            "name": tc.name,
                            "args": display_args,
                        })
                        # Wait for user to POST /api/chat/confirm
                        try:
                            await asyncio.wait_for(
                                _confirm_evt.wait(), timeout=120.0
                            )
                        except asyncio.TimeoutError:
                            _set_tool_confirm(evt=None, result="deny")
                        decision = _get_tool_confirm_result()
                        _set_tool_confirm(evt=None, result="")

                        # ── Handle enhanced permission decisions ──
                        if decision == "allow_always":
                            _session_trusted_tools.add(tc.name)
                        elif decision == "trust_all":
                            _session_trust_all = True
                        elif decision == "deny":
                            result = f"Tool '{tc.name}' was denied by the user."
                            yield _sse({"type": "tool_result", "name": tc.name, "output": result})
                            llm_messages.append({
                                "role": "tool",
                                "tool_call_id": tc.id,
                                "name": tc.name,
                                "content": result,
                            })
                            continue
                        elif decision == "deny_suggest":
                            result = (
                                f"Tool '{tc.name}' was denied by the user. "
                                f"Please suggest an alternative approach that doesn't require this tool."
                            )
                            yield _sse({"type": "tool_result", "name": tc.name, "output": result})
                            llm_messages.append({
                                "role": "tool",
                                "tool_call_id": tc.id,
                                "name": tc.name,
                                "content": result,
                            })
                            continue
                        # allow_once (or legacy "allow") → fall through to execution

                    yield _sse({"type": "tool_start", "name": tc.name, "args": display_args})

                    # Execute the tool server-side
                    try:
                        # Special handling for workspace switching (gateway-level operation)
                        if tc.name == "cvc_switch_workspace":
                            result = await _execute_workspace_switch(tc.arguments)
                        else:
                            loop = asyncio.get_event_loop()
                            result = await loop.run_in_executor(
                                None, _tool_executor.execute, tc.name, tc.arguments
                            )
                    except Exception as exc:
                        result = f"Error executing {tc.name}: {exc}"

                    # Truncate for LLM context
                    llm_result = result[:_MAX_TOOL_OUTPUT_LLM] if len(result) > _MAX_TOOL_OUTPUT_LLM else result
                    if len(result) > _MAX_TOOL_OUTPUT_LLM:
                        llm_result += f"\n... (truncated, {len(result) - _MAX_TOOL_OUTPUT_LLM:,} chars omitted)"

                    # Stream tool_result to client (truncated for display)
                    display_output = result[:3000] + f"\n... ({len(result):,} chars total)" if len(result) > 3000 else result
                    yield _sse({"type": "tool_result", "name": tc.name, "output": display_output})

                    # Add tool result to LLM message history
                    llm_messages.append({
                        "role": "tool",
                        "tool_call_id": tc.id,
                        "name": tc.name,
                        "content": llm_result,
                    })

                    # ── Abort check after each tool ──────────────
                    if _chat_abort:
                        yield _sse({"type": "status", "message": "Stopped by user."})
                        break

                # Break outer loop too if aborted during tool execution
                if _chat_abort:
                    break

                # Loop continues — LLM will process tool results

            # If we hit max iterations, notify
            if iteration >= _MAX_AGENT_ITERATIONS:
                yield _sse({"type": "status", "message": "Reached maximum iterations."})

        except Exception as exc:
            logger.error("Agentic chat error: %s", exc, exc_info=True)
            yield _sse({"type": "text", "content": f"\n\n**Error:** {exc}"})

        yield "data: [DONE]\n\n"

    return StreamingResponse(
        agentic_stream(),
        media_type="text/event-stream",
        headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
    )


# ── Tool Permission Endpoints ────────────────────────────────────────

@app.post("/api/chat/confirm")
async def confirm_tool(request: Request):
    """User confirms or denies a pending tool execution (5-option system)."""
    body = await request.json()
    raw = body.get("decision", "deny")
    # Map legacy "allow" → "allow_once" for backward compatibility
    if raw == "allow":
        raw = "allow_once"
    decision = raw if raw in _VALID_DECISIONS else "deny"
    _set_tool_confirm(evt=_tool_confirm_event, result=decision)
    if _tool_confirm_event is not None:
        _tool_confirm_event.set()
    return {"ok": True, "decision": decision}


@app.post("/api/chat/abort")
async def abort_chat():
    """Stop the running agentic chat loop."""
    global _chat_abort
    _chat_abort = True
    # Also unblock any pending permission wait so the loop can exit
    if _tool_confirm_event is not None:
        _set_tool_confirm(evt=_tool_confirm_event, result="deny")
        _tool_confirm_event.set()
    return {"ok": True}


@app.get("/api/chat/autopilot")
async def get_autopilot():
    """Get current auto-pilot state."""
    return {"autopilot": _autopilot}


@app.post("/api/chat/autopilot")
async def set_autopilot(request: Request):
    """Toggle auto-pilot mode. When on, tools execute without asking."""
    global _autopilot
    body = await request.json()
    _autopilot = bool(body.get("autopilot", False))
    logger.info("Auto-pilot %s", "ON" if _autopilot else "OFF")
    return {"autopilot": _autopilot}


async def _proxy_chat_fallback(messages: list, stream: bool):
    """Fallback to proxy-based chat if AgentLLM is not available."""
    import httpx
    proxy_url = f"http://{_HOST}:{_PROXY_PORT}/v1/chat/completions"
    model = _get_config().model if _get_config() else ""
    chat_body = {"model": model, "messages": messages, "stream": False}

    if not stream:
        try:
            async with httpx.AsyncClient(timeout=120.0) as client:
                r = await client.post(proxy_url, json=chat_body)
                return r.json()
        except Exception as exc:
            raise HTTPException(502, f"Proxy error: {exc}")

    async def gen():
        try:
            async with httpx.AsyncClient(timeout=120.0) as client:
                r = await client.post(proxy_url, json=chat_body)
                data = r.json()
                content = ""
                if "choices" in data and data["choices"]:
                    content = data["choices"][0].get("message", {}).get("content", "")
                if not content:
                    yield _sse({"type": "text", "content": "No response from model."})
                else:
                    words = content.split(" ")
                    for i in range(0, len(words), 4):
                        chunk = " ".join(words[i:i + 4])
                        if i > 0:
                            chunk = " " + chunk
                        yield _sse({"type": "text", "content": chunk})
            yield "data: [DONE]\n\n"
        except Exception as exc:
            yield _sse({"type": "text", "content": f"Error: {exc}"})
            yield "data: [DONE]\n\n"

    return StreamingResponse(gen(), media_type="text/event-stream", headers={"Cache-Control": "no-cache"})


@app.get("/api/chat/models")
async def chat_models():
    if _agent_llm:
        return {"data": [{"id": _agent_llm.model, "object": "model", "owned_by": _agent_llm.provider}]}
    import httpx
    try:
        r = httpx.get(f"http://{_HOST}:{_PROXY_PORT}/v1/models", timeout=5.0)
        return r.json()
    except Exception:
        return {"data": [{"id": _get_config().model, "object": "model", "owned_by": _get_config().provider}] if _get_config() else []}


# ═══════════════════════════════════════════════════════════════════════
# 10. MCP SERVER INFO
# ═══════════════════════════════════════════════════════════════════════

@app.get("/api/mcp/tools")
async def mcp_tools():
    try:
        from cvc.mcp_server import TOOL_DEFINITIONS
        return {"tools": TOOL_DEFINITIONS, "total": len(TOOL_DEFINITIONS)}
    except ImportError:
        tools = [
            "cvc_status", "cvc_commit", "cvc_branch", "cvc_merge", "cvc_restore",
            "cvc_log", "cvc_capture_context", "cvc_set_workspace", "cvc_get_context",
            "cvc_recall", "cvc_export", "cvc_inject", "cvc_diff", "cvc_stats",
            "cvc_compact", "cvc_timeline", "cvc_audit",
            "cvc_sync_push", "cvc_sync_pull", "cvc_sync_status",
            "cvc_register_agent", "cvc_agent_commit", "cvc_agent_recall",
            "cvc_list_agents", "cvc_agent_context", "cvc_hive_status",
        ]
        return {"tools": [{"name": t} for t in tools], "total": len(tools)}
    except Exception:
        return {"tools": [], "total": 0}


@app.get("/api/mcp/status")
async def mcp_status():
    return {
        "transport": "stdio",
        "status": "available",
        "tools_count": 26,
        "note": "MCP server runs as a subprocess launched by IDEs, not as a persistent service",
    }


# ═══════════════════════════════════════════════════════════════════════
# 11. GIT / VCS BRIDGE
# ═══════════════════════════════════════════════════════════════════════

@app.get("/api/vcs/status")
async def vcs_status():
    _ensure()
    try:
        import subprocess as _sp
        git_dir = _project_root / ".git" if _project_root else None
        has_repo = bool(git_dir and git_dir.exists())
        hooks_installed = False
        current_branch = ""
        repo_root = str(_project_root) if _project_root else ""
        if has_repo:
            post_commit = git_dir / "hooks" / "post-commit"
            hooks_installed = post_commit.exists() and "cvc" in post_commit.read_text(encoding="utf-8", errors="ignore").lower() if post_commit.exists() else False
            try:
                result = _sp.run(["git", "rev-parse", "--abbrev-ref", "HEAD"], capture_output=True, text=True, cwd=str(_project_root), timeout=5)
                if result.returncode == 0:
                    current_branch = result.stdout.strip()
            except Exception:
                pass
        return {
            "provider": "git" if has_repo else "",
            "has_repo": has_repo,
            "repo_root": repo_root,
            "current_branch": current_branch,
            "hooks_installed": hooks_installed,
        }
    except Exception as exc:
        return {"provider": "", "has_repo": False, "repo_root": "", "current_branch": "", "error": str(exc)}


# ═══════════════════════════════════════════════════════════════════════
# 12. AUDIT & STATS
# ═══════════════════════════════════════════════════════════════════════

@app.get("/api/audit")
async def get_audit(limit: int = 100):
    _ensure()
    try:
        if hasattr(_get_db().index, "query_audit_log"):
            entries = _get_db().index.query_audit_log(limit=limit)
            return {"entries": entries, "total": len(entries)}
        return {"entries": [], "total": 0}
    except Exception as exc:
        return {"entries": [], "error": str(exc)}


@app.get("/api/stats")
async def get_stats():
    _ensure()
    try:
        commits = _get_db().index.list_all_commits(limit=9999) if hasattr(_get_db().index, "list_all_commits") else []
        branches = _get_db().index.list_branches() if hasattr(_get_db().index, "list_branches") else []
        agents = _get_db().index.list_agents() if hasattr(_get_db().index, "list_agents") else []
        by_type: dict[str, int] = {}
        total_tokens = 0
        for c in commits:
            ct = _field(c, "commit_type", "unknown")
            by_type[ct] = by_type.get(ct, 0) + 1
            total_tokens += _meta_dict(c).get("token_count", 0)
        db_path = _get_config().cvc_root / "cvc.db"
        blobs_dir = _get_config().cvc_root / "objects"
        return {
            "total_commits": len(commits), "total_branches": len(branches), "total_agents": len(agents),
            "commits_by_type": by_type, "total_tokens": total_tokens,
            "estimated_cost_usd": round(total_tokens * 3.0 / 1_000_000, 4),
            "database_size_bytes": db_path.stat().st_size if db_path.exists() else 0,
            "blob_count": len(list(blobs_dir.glob("*"))) if blobs_dir.exists() else 0,
        }
    except Exception as exc:
        return {"error": str(exc)}


# ═══════════════════════════════════════════════════════════════════════
# 13. CONNECTIONS
# ═══════════════════════════════════════════════════════════════════════

@app.get("/api/connections")
async def get_connections():
    base = f"http://{_HOST}:{_PROXY_PORT}"
    return {
        "proxy_url": f"{base}/v1",
        "api_key": "cvc",
        "tools": [
            {"name": "VS Code", "type": "ide", "method": "BYOK settings.json", "icon": "vscode"},
            {"name": "Cursor", "type": "ide", "method": "BYOK settings.json", "icon": "cursor"},
            {"name": "Antigravity", "type": "ide", "method": "MCP config", "icon": "antigravity"},
            {"name": "Windsurf", "type": "ide", "method": "MCP config", "icon": "windsurf"},
            {"name": "Claude Code", "type": "cli", "method": "ANTHROPIC_BASE_URL env", "icon": "claude"},
            {"name": "Aider", "type": "cli", "method": "OPENAI_API_BASE env", "icon": "aider"},
            {"name": "Codex CLI", "type": "cli", "method": "OPENAI_BASE_URL env", "icon": "codex"},
            {"name": "Gemini CLI", "type": "cli", "method": "CVC MCP", "icon": "gemini"},
            {"name": "Continue.dev", "type": "extension", "method": "config.json", "icon": "continue"},
            {"name": "Cline", "type": "extension", "method": "MCP or API endpoint", "icon": "cline"},
            {"name": "Open WebUI", "type": "web", "method": "OpenAI API endpoint", "icon": "openwebui"},
        ],
    }


# ═══════════════════════════════════════════════════════════════════════
# WEBSOCKET — Real-Time Dashboard Events
# ═══════════════════════════════════════════════════════════════════════

@app.websocket("/ws/dashboard")
async def ws_dashboard(websocket: WebSocket):
    await websocket.accept()
    bus = _get_event_bus()
    sub_id = f"dashboard-{id(websocket)}"
    sub = bus.subscribe(sub_id, replay=True)
    try:
        if _manager:
            await websocket.send_json({
                "event": "services.snapshot",
                "data": {s.name: s.to_dict() for s in _manager.all_services().values()},
                "timestamp": time.time(),
            })
        while sub.active:
            try:
                envelope = await asyncio.wait_for(sub.get(), timeout=10.0)
                await websocket.send_json({"event": envelope.event, "data": envelope.data, "timestamp": envelope.timestamp})
            except asyncio.TimeoutError:
                if _manager:
                    _manager.refresh_health()
                    await websocket.send_json({
                        "event": "services.snapshot",
                        "data": {s.name: s.to_dict() for s in _manager.all_services().values()},
                        "timestamp": time.time(),
                    })
    except WebSocketDisconnect:
        pass
    except Exception:
        logger.debug("Dashboard WebSocket error", exc_info=True)
    finally:
        bus.unsubscribe(sub_id)


# ═══════════════════════════════════════════════════════════════════════
# STATIC FILES — Dashboard SPA
# ═══════════════════════════════════════════════════════════════════════

_DASHBOARD_DIR = Path(__file__).parent / "dashboard" / "static"

if _DASHBOARD_DIR.exists():
    app.mount("/static", StaticFiles(directory=str(_DASHBOARD_DIR)), name="dashboard-static")


@app.get("/", include_in_schema=False)
async def serve_dashboard():
    index_path = _DASHBOARD_DIR / "index.html"
    if index_path.exists():
        return FileResponse(str(index_path), media_type="text/html")
    return JSONResponse({"message": "CVC Gateway running.", "api": "/api/gateway/services"}, status_code=200)


# ═══════════════════════════════════════════════════════════════════════
# HELPERS
# ═══════════════════════════════════════════════════════════════════════

def _field(obj: Any, key: str, default: Any = "") -> Any:
    if isinstance(obj, dict):
        return obj.get(key, default)
    return getattr(obj, key, default)


def _ts(obj: Any) -> float:
    """Extract Unix timestamp (seconds) from a commit object."""
    if isinstance(obj, dict):
        # Raw DB row or dict
        if "created_at" in obj and obj["created_at"]:
            return float(obj["created_at"])
        md = obj.get("metadata_json", "")
        if isinstance(md, str) and md:
            try:
                return float(json.loads(md).get("timestamp", 0))
            except (json.JSONDecodeError, TypeError, ValueError):
                return 0.0
        return 0.0
    # Pydantic CognitiveCommit
    meta = getattr(obj, "metadata", None)
    if meta is not None:
        return float(getattr(meta, "timestamp", 0))
    return 0.0


def _meta_dict(obj: Any) -> dict:
    # Pydantic CognitiveCommit — metadata is a CommitMetadata model
    meta = getattr(obj, "metadata", None)
    if meta is not None and hasattr(meta, "model_dump"):
        return meta.model_dump()
    # Raw DB row — metadata_json is a JSON string column
    raw = _field(obj, "metadata_json", "")
    if not raw:
        return {}
    try:
        return json.loads(raw) if isinstance(raw, str) else raw
    except (json.JSONDecodeError, TypeError):
        return {}


def _meta(obj: Any, key: str, default: Any = "") -> Any:
    return _meta_dict(obj).get(key, default)
