"""
Brynq Runtime — Local API Server.

The desktop app (app/) talks to this server on localhost:8003.
This is the user-facing interface for the runtime. It:
    1. Detects available local models (Ollama).
    2. Calls the cloud to get execution plans.
    3. Executes plans locally (user's machine, user's keys).
    4. Returns results to the desktop app.

Endpoints:
    POST /chat                      — Simple single-model chat (with chat history)
    POST /chain                     — Multi-model chain execution
    POST /chats                     — Create a new chat conversation
    GET  /chats                     — List all chats (sorted by updated_at desc)
    GET  /chats/{id}                — Get full chat with messages
    PUT  /chats/{id}                — Update chat title/project_id
    DELETE /chats/{id}              — Delete a chat
    GET  /models                    — List available models (local + cloud BYOK)
    GET  /agents                    — List installed + marketplace agents
    GET  /status                    — Runtime health (Ollama, cloud, keys)
    POST /keys                      — Store an API key locally
    GET  /keys                      — List stored keys (masked)
    DELETE /keys/{provider}         — Remove a key
    POST /auth/login/{provider}     — Start OAuth flow, returns auth URL
    GET  /auth/callback/{provider}  — OAuth redirect handler
    GET  /auth/connections          — List connected providers with status
    POST /auth/logout/{provider}    — Disconnect a provider

Zero proprietary logic here. This is a thin executor.
Runs on port 8003 — separate from cloud API (8002 dev) and web app (3000).
"""

from __future__ import annotations

import json
import logging
import time
import urllib.error
import urllib.request
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Any

from fastapi import FastAPI, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse

from runtime.auth.oauth_manager import OAuthManager, OAuthTokenError
from runtime.cloud_client import CloudClient, CloudError, CloudRejected, CloudUnavailable
from runtime.config import RuntimeConfig, load_config

logger = logging.getLogger(__name__)

# ── Ollama integration ─────────────────────────────────────────────────


def _ollama_list_models(ollama_url: str, timeout: float = 5.0) -> list[dict[str, Any]]:
    """Query Ollama for locally available models.

    Returns:
        List of model dicts with "name", "size", "details" fields.
        Empty list if Ollama is unreachable.
    """
    try:
        req = urllib.request.Request(f"{ollama_url}/api/tags", method="GET")
        with urllib.request.urlopen(req, timeout=timeout) as resp:
            data = json.loads(resp.read().decode("utf-8"))
            return data.get("models", [])
    except Exception:
        return []


def _ollama_chat(
    ollama_url: str,
    model: str,
    prompt: str,
    system_prompt: str | None = None,
    temperature: float | None = None,
    timeout: float = 120.0,
    history: list[dict[str, str]] | None = None,
) -> dict[str, Any]:
    """Send a chat completion request to Ollama.

    Returns:
        Dict with "content", "model", "tokens", "duration_ms".

    Raises:
        HTTPException: If Ollama is unreachable or the model call fails.
    """
    messages: list[dict[str, str]] = []
    if system_prompt:
        messages.append({"role": "system", "content": system_prompt})
    # Include conversation history for memory
    if history:
        messages.extend(history)
    messages.append({"role": "user", "content": prompt})

    body: dict[str, Any] = {"model": model, "messages": messages, "stream": False}
    if temperature is not None:
        body["options"] = {"temperature": temperature}

    data = json.dumps(body).encode("utf-8")
    req = urllib.request.Request(
        f"{ollama_url}/api/chat",
        data=data,
        headers={"Content-Type": "application/json"},
        method="POST",
    )
    try:
        with urllib.request.urlopen(req, timeout=timeout) as resp:
            result = json.loads(resp.read().decode("utf-8"))
            message = result.get("message", {})
            return {
                "content": message.get("content", ""),
                "model": model,
                "tokens": result.get("eval_count", 0),
                "duration_ms": int(result.get("total_duration", 0) / 1_000_000),
            }
    except urllib.error.URLError as e:
        raise HTTPException(status_code=502, detail=f"Ollama unreachable: {e.reason}")
    except Exception as e:
        raise HTTPException(status_code=500, detail=f"Ollama error: {e}")


def _ollama_is_running(ollama_url: str) -> bool:
    """Check if Ollama is reachable."""
    try:
        req = urllib.request.Request(f"{ollama_url}/api/tags", method="GET")
        with urllib.request.urlopen(req, timeout=3.0):
            return True
    except Exception:
        return False


# ── Local key management ───────────────────────────────────────────────

VALID_PROVIDERS = frozenset({
    "openai", "anthropic", "google", "mistral", "cohere", "groq",
})


def _keys_file(data_dir: Path) -> Path:
    return data_dir / "keys.json"


def _read_keys(data_dir: Path) -> dict[str, str]:
    """Read stored API keys from disk."""
    path = _keys_file(data_dir)
    if not path.exists():
        return {}
    try:
        return json.loads(path.read_text("utf-8"))
    except (json.JSONDecodeError, OSError):
        return {}


def _write_keys(data_dir: Path, keys: dict[str, str]) -> None:
    """Write API keys to disk."""
    data_dir.mkdir(parents=True, exist_ok=True)
    _keys_file(data_dir).write_text(json.dumps(keys, indent=2), "utf-8")


def _mask_key(key: str) -> str:
    """Mask an API key for display: show first 4 and last 4 chars."""
    if len(key) <= 12:
        return key[:4] + "..." + key[-2:]
    return key[:4] + "..." + key[-4:]


# ── Project storage ────────────────────────────────────────────────────


def _projects_file(data_dir: Path) -> Path:
    return data_dir / "projects.json"


def _read_projects(data_dir: Path) -> list[dict[str, Any]]:
    """Read the projects list from disk."""
    path = _projects_file(data_dir)
    if not path.exists():
        return []
    try:
        data = json.loads(path.read_text("utf-8"))
        return data.get("projects", [])
    except (json.JSONDecodeError, OSError):
        return []


def _write_projects(data_dir: Path, projects: list[dict[str, Any]]) -> None:
    """Write the projects list to disk."""
    data_dir.mkdir(parents=True, exist_ok=True)
    _projects_file(data_dir).write_text(
        json.dumps({"projects": projects}, indent=2), "utf-8",
    )


def _find_project(projects: list[dict[str, Any]], project_id: str) -> dict[str, Any] | None:
    """Find a project by ID. Returns None if not found."""
    for proj in projects:
        if proj["id"] == project_id:
            return proj
    return None


def _now_iso() -> str:
    """Return current UTC time as ISO 8601 string."""
    return datetime.now(timezone.utc).isoformat()


# ── Chat history storage ──────────────────────────────────────────────


def _chats_dir(data_dir: Path) -> Path:
    """Return the ~/.brynq/chats/ directory, creating it if needed."""
    d = data_dir / "chats"
    d.mkdir(parents=True, exist_ok=True)
    return d


def _chat_index_path(data_dir: Path) -> Path:
    return _chats_dir(data_dir) / "index.json"


def _chat_file_path(data_dir: Path, chat_id: str) -> Path:
    return _chats_dir(data_dir) / f"{chat_id}.json"


def _read_chat_index(data_dir: Path) -> list[dict[str, Any]]:
    """Read the chat index (lightweight list of all chats)."""
    path = _chat_index_path(data_dir)
    if not path.exists():
        return []
    try:
        return json.loads(path.read_text("utf-8"))
    except (json.JSONDecodeError, OSError):
        return []


def _write_chat_index(data_dir: Path, index: list[dict[str, Any]]) -> None:
    """Write the chat index to disk."""
    _chat_index_path(data_dir).write_text(json.dumps(index, indent=2), "utf-8")


def _read_chat(data_dir: Path, chat_id: str) -> dict[str, Any] | None:
    """Read a full chat object from its JSON file. Returns None if not found."""
    path = _chat_file_path(data_dir, chat_id)
    if not path.exists():
        return None
    try:
        return json.loads(path.read_text("utf-8"))
    except (json.JSONDecodeError, OSError):
        return None


def _write_chat(data_dir: Path, chat: dict[str, Any]) -> None:
    """Write a full chat object to its JSON file."""
    path = _chat_file_path(data_dir, chat["id"])
    path.write_text(json.dumps(chat, indent=2), "utf-8")


def _delete_chat_file(data_dir: Path, chat_id: str) -> None:
    """Delete a chat's JSON file from disk."""
    path = _chat_file_path(data_dir, chat_id)
    if path.exists():
        path.unlink()


def _create_chat(
    data_dir: Path,
    title: str | None = None,
    project_id: str | None = None,
    chat_id: str | None = None,
) -> dict[str, Any]:
    """Create a new chat object, persist it, and update the index.

    Returns:
        The newly created chat dict.
    """
    now = _now_iso()
    if not chat_id:
        chat_id = f"chat_{uuid.uuid4().hex[:12]}"
    chat: dict[str, Any] = {
        "id": chat_id,
        "title": title,
        "project_id": project_id,
        "messages": [],
        "created_at": now,
        "updated_at": now,
    }
    _write_chat(data_dir, chat)

    # Add to index
    index = _read_chat_index(data_dir)
    index.append({
        "id": chat_id,
        "title": title,
        "project_id": project_id,
        "last_message": None,
        "updated_at": now,
    })
    _write_chat_index(data_dir, index)

    return chat


def _update_chat_index_entry(
    data_dir: Path,
    chat_id: str,
    **updates: Any,
) -> None:
    """Update fields of a specific chat in the index."""
    index = _read_chat_index(data_dir)
    for entry in index:
        if entry["id"] == chat_id:
            entry.update(updates)
            break
    _write_chat_index(data_dir, index)


def _auto_title(message: str) -> str:
    """Generate an auto-title from the first user message (first 50 chars)."""
    title = message.strip().replace("\n", " ")
    if len(title) > 50:
        title = title[:50] + "..."
    return title


# ── Cloud LLM API calls (BYOK) ────────────────────────────────────────


def _call_cloud_api(
    backend: str,
    model: str,
    prompt: str,
    config: dict[str, Any],
    keys: dict[str, str],
) -> tuple[str, int]:
    """Call a cloud LLM API using the user's BYOK key.

    Returns:
        Tuple of (response_text, token_count).

    Raises:
        HTTPException: If the API key is missing or the call fails.
    """
    provider_map: dict[str, str] = {
        "claude": "anthropic",
        "openai": "openai",
        "gpt": "openai",
        "gemini": "google",
    }
    provider = provider_map.get(backend)
    if not provider:
        raise HTTPException(status_code=400, detail=f"Unsupported backend: {backend}")

    api_key = keys.get(provider)
    if not api_key:
        raise HTTPException(
            status_code=400,
            detail=f"No API key stored for {provider}. Add one via POST /keys.",
        )

    try:
        if provider == "anthropic":
            return _call_anthropic(api_key, model, prompt, config)
        elif provider == "openai":
            return _call_openai(api_key, model, prompt, config)
        elif provider == "google":
            return _call_google(api_key, model, prompt, config)
        else:
            raise HTTPException(status_code=400, detail=f"Unsupported provider: {provider}")
    except HTTPException:
        raise
    except Exception as e:
        raise HTTPException(status_code=502, detail=f"{provider} API error: {e}")


def _call_anthropic(
    api_key: str, model: str, prompt: str, config: dict[str, Any],
) -> tuple[str, int]:
    """Call Anthropic Messages API."""
    body: dict[str, Any] = {
        "model": model or "claude-sonnet-4-20250514",
        "max_tokens": config.get("max_tokens", 4096),
        "messages": [{"role": "user", "content": prompt}],
    }
    if config.get("system_prompt"):
        body["system"] = config["system_prompt"]
    if config.get("temperature") is not None:
        body["temperature"] = config["temperature"]

    data = json.dumps(body).encode("utf-8")
    req = urllib.request.Request(
        "https://api.anthropic.com/v1/messages",
        data=data,
        headers={
            "Content-Type": "application/json",
            "x-api-key": api_key,
            "anthropic-version": "2023-06-01",
        },
        method="POST",
    )
    with urllib.request.urlopen(req, timeout=120) as resp:
        result = json.loads(resp.read().decode("utf-8"))
        blocks = result.get("content", [])
        text = "".join(b.get("text", "") for b in blocks if b.get("type") == "text")
        usage = result.get("usage", {})
        tokens = usage.get("input_tokens", 0) + usage.get("output_tokens", 0)
        return text, tokens


def _call_openai(
    api_key: str, model: str, prompt: str, config: dict[str, Any],
) -> tuple[str, int]:
    """Call OpenAI Chat Completions API."""
    messages: list[dict[str, str]] = []
    if config.get("system_prompt"):
        messages.append({"role": "system", "content": config["system_prompt"]})
    messages.append({"role": "user", "content": prompt})

    body: dict[str, Any] = {"model": model or "gpt-4o", "messages": messages}
    if config.get("temperature") is not None:
        body["temperature"] = config["temperature"]
    if config.get("max_tokens"):
        body["max_tokens"] = config["max_tokens"]

    data = json.dumps(body).encode("utf-8")
    req = urllib.request.Request(
        "https://api.openai.com/v1/chat/completions",
        data=data,
        headers={
            "Content-Type": "application/json",
            "Authorization": f"Bearer {api_key}",
        },
        method="POST",
    )
    with urllib.request.urlopen(req, timeout=120) as resp:
        result = json.loads(resp.read().decode("utf-8"))
        choices = result.get("choices", [])
        text = choices[0]["message"]["content"] if choices else ""
        tokens = result.get("usage", {}).get("total_tokens", 0)
        return text, tokens


def _call_google(
    api_key: str, model: str, prompt: str, config: dict[str, Any],
) -> tuple[str, int]:
    """Call Google Generative Language API."""
    model = model or "gemini-2.0-flash"
    url = (
        f"https://generativelanguage.googleapis.com/v1beta"
        f"/models/{model}:generateContent?key={api_key}"
    )
    body: dict[str, Any] = {"contents": [{"parts": [{"text": prompt}]}]}
    if config.get("temperature") is not None:
        body["generationConfig"] = {"temperature": config["temperature"]}

    data = json.dumps(body).encode("utf-8")
    req = urllib.request.Request(
        url, data=data, headers={"Content-Type": "application/json"}, method="POST",
    )
    with urllib.request.urlopen(req, timeout=120) as resp:
        result = json.loads(resp.read().decode("utf-8"))
        candidates = result.get("candidates", [])
        text = ""
        if candidates:
            parts = candidates[0].get("content", {}).get("parts", [])
            text = "".join(p.get("text", "") for p in parts)
        tokens = result.get("usageMetadata", {}).get("totalTokenCount", 0)
        return text, tokens


def _call_agent(endpoint: str, prompt: str, config: dict[str, Any]) -> str:
    """Call a local or remote agent endpoint.

    Returns:
        Agent response text, or an error message on failure.
    """
    if not endpoint.startswith("http"):
        endpoint = f"http://{endpoint}"

    body = {"input": prompt, "config": config}
    data = json.dumps(body).encode("utf-8")
    req = urllib.request.Request(
        endpoint, data=data, headers={"Content-Type": "application/json"}, method="POST",
    )
    try:
        with urllib.request.urlopen(req, timeout=120) as resp:
            result = json.loads(resp.read().decode("utf-8"))
            return result.get("output", result.get("content", json.dumps(result)))
    except Exception as e:
        return f"Agent error: {e}"


# ── Plan execution ─────────────────────────────────────────────────────


def _execute_plan_steps(
    plan: dict[str, Any],
    config: RuntimeConfig,
    keys: dict[str, str],
) -> dict[str, Any]:
    """Execute an execution plan step by step.

    Routes each step to the correct backend (Ollama, cloud API, agent).
    Captures outputs per step and builds metadata for cloud reporting.

    Returns:
        Dict with "outputs" (list of step result dicts) and
        "metadata" (list of timing/token dicts for reporting).
    """
    steps = plan.get("steps", [])
    step_outputs: dict[str, str] = {}  # step_id -> output text
    results: list[dict[str, Any]] = []
    metadata: list[dict[str, Any]] = []
    original_input = plan.get("original_input", "")

    for step in steps:
        step_id = step.get("step_id", "")
        step_type = step.get("type", "")
        backend = step.get("backend", "")
        model = step.get("model", "")
        prompt = step.get("prompt", "")
        context_refs = step.get("context_refs", [])
        step_config = step.get("config", {})

        # Resolve context references from prior step outputs
        context_parts: list[str] = []
        for ref in context_refs:
            if ref == "original_input":
                context_parts.append(original_input)
            elif ref in step_outputs:
                context_parts.append(step_outputs[ref])

        full_prompt = "\n\n".join(context_parts + [prompt]) if context_parts else prompt

        start = time.time()
        output = ""
        token_count = 0
        success = True
        error_msg: str | None = None

        try:
            if step_type == "local":
                result = _ollama_chat(
                    config.ollama_url, model, full_prompt,
                    system_prompt=step_config.get("system_prompt"),
                    temperature=step_config.get("temperature"),
                    timeout=config.ollama_timeout,
                )
                output = result["content"]
                token_count = result.get("tokens", 0)

            elif step_type == "cloud":
                output, token_count = _call_cloud_api(
                    backend, model, full_prompt, step_config, keys,
                )

            elif step_type in ("local_agent", "remote_agent"):
                endpoint = step.get("endpoint", "")
                output = _call_agent(endpoint, full_prompt, step_config)

            else:
                error_msg = f"Unknown step type: {step_type}"
                success = False

        except HTTPException:
            raise
        except Exception as e:
            error_msg = str(e)
            success = False
            logger.error("Step %s failed: %s", step_id, e)

        duration_ms = int((time.time() - start) * 1000)
        step_outputs[step_id] = output

        results.append({
            "step_id": step_id,
            "content": output,
            "model": model,
            "success": success,
            "error": error_msg,
        })
        metadata.append({
            "step_id": step_id,
            "duration_ms": duration_ms,
            "token_count": token_count,
            "success": success,
        })

    return {"outputs": results, "metadata": metadata}


# ── Available models helper ────────────────────────────────────────────


def _collect_available_models(
    config: RuntimeConfig,
    keys: dict[str, str],
) -> tuple[list[dict[str, Any]], list[str]]:
    """Detect all available models (local + cloud BYOK).

    Returns:
        Tuple of (local_models_raw, model_id_list).
    """
    local_models = _ollama_list_models(config.ollama_url)
    model_names = [f"ollama:{m['name']}" for m in local_models]

    if "anthropic" in keys:
        model_names.append("claude-sonnet-4-20250514")
    if "openai" in keys:
        model_names.append("gpt-4o")
    if "google" in keys:
        model_names.append("gemini-2.0-flash")

    return local_models, model_names


# ── FastAPI Application ────────────────────────────────────────────────


def create_app(config: RuntimeConfig | None = None) -> FastAPI:
    """Create the runtime's local FastAPI server.

    Called by the desktop app (Electron/Tauri) on localhost:8003.

    Args:
        config: Runtime configuration. If None, loaded from environment.

    Returns:
        Configured FastAPI instance.
    """
    if config is None:
        config = load_config()

    app = FastAPI(
        title="Brynq Runtime",
        description="Local AI chain executor",
        version="0.1.0",
        docs_url="/docs" if config.debug else None,
        redoc_url=None,
    )

    app.add_middleware(
        CORSMiddleware,
        allow_origins=[
            "http://localhost:3000",
            "http://localhost:5173",
            "http://127.0.0.1:3000",
            "http://127.0.0.1:5173",
            "https://brynq.ai",
            "https://www.brynq.ai",
            "tauri://localhost",
        ],
        allow_credentials=True,
        allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
        allow_headers=["Content-Type", "Authorization"],
    )

    cloud = CloudClient(config)
    oauth = OAuthManager()
    app.state.config = config
    app.state.cloud = cloud
    app.state.oauth = oauth

    # ── Chat history CRUD ────────────────────────────────────────

    @app.post("/chats")
    async def create_chat_endpoint(request: Request) -> JSONResponse:
        """Create a new chat conversation.

        Body (all optional): {"title": "...", "project_id": "proj_xyz"}
        Returns the newly created chat object.
        """
        body = await request.json()
        title = body.get("title")
        project_id = body.get("project_id")
        chat = _create_chat(config.data_dir, title=title, project_id=project_id)
        return JSONResponse(chat, status_code=201)

    @app.get("/chats")
    async def list_chats() -> JSONResponse:
        """List all chats from index.json, sorted by updated_at descending."""
        index = _read_chat_index(config.data_dir)
        index.sort(key=lambda c: c.get("updated_at", ""), reverse=True)
        return JSONResponse({"chats": index, "count": len(index)})

    @app.get("/chats/{chat_id}")
    async def get_chat(chat_id: str) -> JSONResponse:
        """Get a full chat with all messages."""
        chat = _read_chat(config.data_dir, chat_id)
        if chat is None:
            raise HTTPException(status_code=404, detail=f"Chat {chat_id} not found")
        return JSONResponse(chat)

    @app.delete("/chats/{chat_id}")
    async def delete_chat(chat_id: str) -> JSONResponse:
        """Delete a chat and remove it from the index."""
        chat = _read_chat(config.data_dir, chat_id)
        if chat is None:
            raise HTTPException(status_code=404, detail=f"Chat {chat_id} not found")

        _delete_chat_file(config.data_dir, chat_id)

        # Remove from index
        index = _read_chat_index(config.data_dir)
        index = [e for e in index if e["id"] != chat_id]
        _write_chat_index(config.data_dir, index)

        return JSONResponse({"deleted": True, "id": chat_id})

    @app.put("/chats/{chat_id}")
    async def update_chat(chat_id: str, request: Request) -> JSONResponse:
        """Update a chat's title or project_id.

        Body: {"title": "...", "project_id": "..."}  (both optional)
        """
        chat = _read_chat(config.data_dir, chat_id)
        if chat is None:
            raise HTTPException(status_code=404, detail=f"Chat {chat_id} not found")

        body = await request.json()
        if "title" in body:
            chat["title"] = body["title"]
        if "project_id" in body:
            chat["project_id"] = body["project_id"]
        chat["updated_at"] = _now_iso()
        _write_chat(config.data_dir, chat)

        # Sync changes to index
        index_updates: dict[str, Any] = {"updated_at": chat["updated_at"]}
        if "title" in body:
            index_updates["title"] = body["title"]
        if "project_id" in body:
            index_updates["project_id"] = body["project_id"]
        _update_chat_index_entry(config.data_dir, chat_id, **index_updates)

        return JSONResponse(chat)

    # ── POST /chat ─────────────────────────────────────────────────────

    @app.post("/chat")
    async def chat(request: Request) -> JSONResponse:
        """Simple chat: detect models, get plan from cloud, execute, return.

        Falls back to direct Ollama call if the cloud is unavailable.

        Accepts optional ``chat_id`` to persist messages into a chat history.
        When ``chat_id`` is provided, prior messages are loaded as context and
        the new user message + assistant response are appended to the chat file.
        When ``chat_id`` is omitted, a new chat is created automatically.
        """
        body = await request.json()
        message = body.get("message", "").strip()
        if not message:
            raise HTTPException(status_code=400, detail="message is required")

        preferred_model = body.get("model")
        system_prompt = body.get("system_prompt")
        temperature = body.get("temperature")
        max_tokens = body.get("max_tokens")
        history = body.get("history")  # Conversation history from frontend
        chat_id = body.get("chat_id")

        # ── Resolve / create chat ──────────────────────────────────────
        chat_obj: dict[str, Any] | None = None
        if chat_id:
            chat_obj = _read_chat(config.data_dir, chat_id)
            if chat_obj is None:
                # Frontend created this ID locally — auto-create on disk
                chat_obj = _create_chat(config.data_dir, chat_id=chat_id)
            # Use stored messages as history when caller didn't supply explicit history
            if history is None and chat_obj.get("messages"):
                history = [
                    {"role": m["role"], "content": m["content"]}
                    for m in chat_obj["messages"]
                ]
        else:
            # Auto-create a new chat
            chat_obj = _create_chat(config.data_dir)
            chat_id = chat_obj["id"]

        # Default system prompt
        if not system_prompt:
            model_name = preferred_model or "an AI model"
            system_prompt = (
                f"You are {model_name}, running locally on the user's computer through Brynq. "
                f"You are NOT a cloud service — you are running directly on their hardware, "
                f"which means their data stays private and never leaves their machine. "
                f"You are part of a team of AI models that can work together. "
                f"Be helpful, concise, and friendly. If asked about yourself, explain that "
                f"you run locally via Ollama on their PC through the Brynq platform."
            )

        keys = _read_keys(config.data_dir)
        local_models, model_names = _collect_available_models(config, keys)

        if not model_names:
            raise HTTPException(
                status_code=503,
                detail="No models available. Start Ollama or add an API key.",
            )

        # ── Execute (cloud plan or local fallback) ─────────────────────
        response_text = ""
        response_model = ""
        response_tokens = 0
        response_duration = 0
        response_source = "plan"
        num_steps = 0

        try:
            plan_resp = cloud.get_plan(
                request=message,
                available_models=model_names,
                session_context={
                    "preferred_model": preferred_model,
                    "system_prompt": system_prompt,
                    "temperature": temperature,
                    "max_tokens": max_tokens,
                },
            )
            plan = plan_resp.plan

        except CloudError:
            # Cloud unavailable — fall back to direct local execution
            logger.warning("Cloud unavailable, falling back to direct Ollama chat")
            if not local_models:
                raise HTTPException(
                    status_code=503,
                    detail="Cloud unavailable and no local models found.",
                )
            target_model = preferred_model or local_models[0]["name"]
            result = _ollama_chat(
                config.ollama_url, target_model, message,
                system_prompt=system_prompt,
                temperature=temperature,
                timeout=config.ollama_timeout,
                history=history,
            )
            response_text = result["content"]
            response_model = result["model"]
            response_tokens = result.get("tokens", 0)
            response_duration = result.get("duration_ms", 0)
            response_source = "local_fallback"

        else:
            # Execute plan locally
            execution = _execute_plan_steps(plan, config, keys)
            outputs = execution["outputs"]
            meta = execution["metadata"]

            total_ms = sum(m.get("duration_ms", 0) for m in meta)
            all_ok = all(m.get("success", False) for m in meta)

            try:
                cloud.report_results(
                    plan_id=plan.get("plan_id", ""),
                    steps=meta,
                    total_duration_ms=total_ms,
                    success=all_ok,
                )
            except CloudError:
                logger.debug("Failed to report results to cloud (non-fatal)")

            last = outputs[-1] if outputs else {}
            response_text = last.get("content", "")
            response_model = last.get("model", "")
            response_tokens = sum(m.get("token_count", 0) for m in meta)
            response_duration = total_ms
            num_steps = len(outputs)

        # ── Persist to chat history ────────────────────────────────────
        now = _now_iso()
        chat_obj["messages"].append({
            "role": "user",
            "content": message,
            "timestamp": now,
        })
        chat_obj["messages"].append({
            "role": "assistant",
            "content": response_text,
            "model": response_model,
            "timestamp": now,
        })
        chat_obj["updated_at"] = now

        # Auto-title: generate from first user message when title is still None
        if chat_obj["title"] is None:
            chat_obj["title"] = _auto_title(message)

        _write_chat(config.data_dir, chat_obj)

        # Update index with last_message preview + updated_at
        preview = message[:80] + ("..." if len(message) > 80 else "")
        _update_chat_index_entry(
            config.data_dir,
            chat_id,
            title=chat_obj["title"],
            last_message=preview,
            updated_at=now,
        )

        resp_data: dict[str, Any] = {
            "response": response_text,
            "content": response_text,
            "model": response_model,
            "tokens": response_tokens,
            "duration_ms": response_duration,
            "source": response_source,
            "chat_id": chat_id,
        }
        if num_steps:
            resp_data["steps"] = num_steps

        return JSONResponse(resp_data)

    # ── POST /chain ────────────────────────────────────────────────

    @app.post("/chain")
    async def chain(request: Request) -> JSONResponse:
        """Multi-model chain: get plan from cloud, execute all steps, return results.

        Unlike /chat, chains always require the cloud (no local fallback).
        """
        body = await request.json()
        chain_request = body.get("request", "").strip()
        if not chain_request:
            raise HTTPException(status_code=400, detail="request is required")

        keys = _read_keys(config.data_dir)
        local_models, model_names = _collect_available_models(config, keys)

        if not model_names:
            raise HTTPException(
                status_code=503,
                detail="No models available. Start Ollama or add an API key.",
            )

        try:
            plan_resp = cloud.get_plan(
                request=chain_request,
                available_models=model_names,
                session_context={
                    "preferred_models": body.get("models", []),
                    **(body.get("context") or {}),
                },
            )
        except CloudUnavailable as e:
            raise HTTPException(
                status_code=503,
                detail=f"Cloud unavailable: {e}. Chains require the Brynq cloud.",
            )
        except CloudRejected as e:
            raise HTTPException(status_code=400, detail=str(e))

        plan = plan_resp.plan
        execution = _execute_plan_steps(plan, config, keys)
        outputs = execution["outputs"]
        meta = execution["metadata"]

        total_ms = sum(m.get("duration_ms", 0) for m in meta)
        all_ok = all(m.get("success", False) for m in meta)

        try:
            cloud.report_results(
                plan_id=plan_resp.plan_id,
                steps=meta,
                total_duration_ms=total_ms,
                success=all_ok,
            )
        except CloudError:
            logger.debug("Failed to report chain results to cloud (non-fatal)")

        return JSONResponse({
            "plan_id": plan_resp.plan_id,
            "steps": outputs,
            "total_duration_ms": total_ms,
            "success": all_ok,
            "source": "plan",
        })

    # ── GET /models ────────────────────────────────────────────────

    @app.get("/models")
    async def list_models() -> JSONResponse:
        """List available models: local (Ollama) + cloud (user's BYOK keys)."""
        models: list[dict[str, Any]] = []

        local_models = _ollama_list_models(config.ollama_url)
        for m in local_models:
            models.append({
                "id": f"ollama:{m['name']}",
                "name": m["name"],
                "provider": "ollama",
                "type": "local",
                "size": m.get("size", 0),
                "quantization": m.get("details", {}).get("quantization_level", ""),
                "family": m.get("details", {}).get("family", ""),
            })

        keys = _read_keys(config.data_dir)

        cloud_models: dict[str, list[str]] = {
            "anthropic": ["claude-sonnet-4-20250514", "claude-opus-4-20250514", "claude-haiku-3-5-20241022"],
            "openai": ["gpt-4o", "gpt-4o-mini", "o3-mini"],
            "google": ["gemini-2.0-flash", "gemini-2.5-pro"],
        }
        for provider, model_ids in cloud_models.items():
            if provider in keys:
                for mid in model_ids:
                    models.append({
                        "id": mid, "name": mid,
                        "provider": provider, "type": "cloud",
                    })

        return JSONResponse({"models": models, "count": len(models)})

    # ── POST /models/pull ─────────────────────────────────────────

    @app.post("/models/pull")
    async def pull_model(request: Request) -> JSONResponse:
        """Pull/install an Ollama model. Streams progress."""
        body = await request.json()
        model_name = body.get("model", "").strip()
        if not model_name:
            raise HTTPException(400, "model name is required")

        # Only allow pulling known safe models
        allowed = {"llama3", "llama3.1", "mistral", "phi3", "gemma", "codellama",
                   "deepseek-r1", "qwen2", "llama3:latest", "mistral:latest"}
        base_name = model_name.split(":")[0].lower()
        if base_name not in {a.split(":")[0] for a in allowed}:
            raise HTTPException(400, f"Model '{model_name}' is not in the allowed list")

        import urllib.request
        try:
            req = urllib.request.Request(
                f"{config.ollama_url}/api/pull",
                data=json.dumps({"name": model_name, "stream": False}).encode(),
                headers={"Content-Type": "application/json"},
                method="POST",
            )
            resp = urllib.request.urlopen(req, timeout=600)  # 10 min timeout for large models
            result = json.loads(resp.read().decode())
            return JSONResponse({
                "ok": True,
                "model": model_name,
                "status": result.get("status", "success"),
            })
        except urllib.error.URLError as e:
            raise HTTPException(503, f"Ollama not reachable: {e}")
        except Exception as e:
            raise HTTPException(500, f"Failed to pull model: {e}")

    # ── GET /agents ────────────────────────────────────────────────

    @app.get("/agents")
    async def list_agents() -> JSONResponse:
        """List installed local agents + available marketplace agents."""
        agents: list[dict[str, Any]] = []

        # Scan local agent installs
        agent_dir = config.data_dir / "agents"
        if agent_dir.exists():
            for manifest_path in agent_dir.glob("*/manifest.json"):
                try:
                    manifest = json.loads(manifest_path.read_text("utf-8"))
                    agents.append({**manifest, "installed": True, "type": "local"})
                except Exception:
                    pass

        # Marketplace agents from cloud (best-effort)
        try:
            for a in cloud.list_marketplace_agents():
                agents.append({
                    "agent_id": a.agent_id, "name": a.name,
                    "description": a.description, "category": a.category,
                    "type": a.type, "version": a.version,
                    "rating": a.rating, "installs": a.installs,
                    "installed": False,
                })
        except CloudError:
            logger.debug("Could not fetch marketplace agents")

        return JSONResponse({"agents": agents, "count": len(agents)})

    # ── GET /health ────────────────────────────────────────────────

    @app.get("/health")
    async def health() -> JSONResponse:
        """Lightweight health check for the frontend chat store.

        Returns a flat dict with top-level boolean flags that the
        frontend expects: { ollama: true/false, cloud: true/false }.
        """
        ollama_ok = _ollama_is_running(config.ollama_url)
        cloud_ok = cloud.health_check()
        return JSONResponse({
            "status": "ok",
            "ollama": ollama_ok,
            "cloud": cloud_ok,
            "version": "0.1.0",
        })

    # ── GET /status ────────────────────────────────────────────────

    @app.get("/status")
    async def status() -> JSONResponse:
        """Runtime health: Ollama, cloud connection, stored keys."""
        ollama_ok = _ollama_is_running(config.ollama_url)
        cloud_ok = cloud.health_check()
        keys = _read_keys(config.data_dir)
        local_models = _ollama_list_models(config.ollama_url) if ollama_ok else []

        return JSONResponse({
            "status": "ok",
            "ollama": {
                "connected": ollama_ok,
                "url": config.ollama_url,
                "model_count": len(local_models),
            },
            "cloud": {
                "connected": cloud_ok,
                "url": config.cloud_url,
                "authenticated": cloud.is_authenticated,
            },
            "keys": {
                "count": len(keys),
                "providers": list(keys.keys()),
            },
            "version": "0.1.0",
        })

    # ── POST /keys ─────────────────────────────────────────────────

    @app.post("/keys")
    async def store_key(request: Request) -> JSONResponse:
        """Store an API key locally. Never sent to the cloud.

        Body: {"provider": "anthropic", "api_key": "sk-..."}
        """
        body = await request.json()
        provider = body.get("provider", "").strip().lower()
        api_key = (body.get("api_key") or body.get("key") or "").strip()

        if not provider:
            raise HTTPException(status_code=400, detail="provider is required")
        if not api_key:
            raise HTTPException(status_code=400, detail="api_key is required")
        if provider not in VALID_PROVIDERS:
            raise HTTPException(
                status_code=400,
                detail=f"Unknown provider: {provider}. Valid: {', '.join(sorted(VALID_PROVIDERS))}",
            )

        keys = _read_keys(config.data_dir)
        keys[provider] = api_key
        _write_keys(config.data_dir, keys)

        return JSONResponse({
            "stored": True,
            "provider": provider,
            "masked_key": _mask_key(api_key),
        })

    # ── GET /keys ──────────────────────────────────────────────────

    @app.get("/keys")
    async def list_keys() -> JSONResponse:
        """List stored API keys with masked values. Never exposes full keys."""
        keys = _read_keys(config.data_dir)
        return JSONResponse({
            "keys": [
                {"provider": p, "masked_key": _mask_key(k)}
                for p, k in keys.items()
            ],
            "count": len(keys),
        })

    # ── DELETE /keys/{provider} ────────────────────────────────────

    @app.delete("/keys/{provider}")
    async def delete_key(provider: str) -> JSONResponse:
        """Remove a stored API key."""
        provider = provider.strip().lower()
        keys = _read_keys(config.data_dir)
        if provider not in keys:
            raise HTTPException(status_code=404, detail=f"No key stored for {provider}")

        del keys[provider]
        _write_keys(config.data_dir, keys)
        return JSONResponse({"deleted": True, "provider": provider})

    # ── POST /keys/{provider}/test ────────────────────────────────

    @app.post("/keys/{provider}/test")
    async def test_key(provider: str) -> JSONResponse:
        """Test that a stored API key works by making a minimal API call.

        Returns {"ok": true} on success, raises on failure.
        """
        provider = provider.strip().lower()
        keys = _read_keys(config.data_dir)
        api_key = keys.get(provider)
        if not api_key:
            raise HTTPException(status_code=404, detail=f"No key stored for {provider}")

        try:
            if provider == "anthropic":
                # Minimal Anthropic call — list models
                req = urllib.request.Request(
                    "https://api.anthropic.com/v1/messages",
                    data=json.dumps({
                        "model": "claude-haiku-3-5-20241022",
                        "max_tokens": 1,
                        "messages": [{"role": "user", "content": "hi"}],
                    }).encode("utf-8"),
                    headers={
                        "Content-Type": "application/json",
                        "x-api-key": api_key,
                        "anthropic-version": "2023-06-01",
                    },
                    method="POST",
                )
                with urllib.request.urlopen(req, timeout=15):
                    pass

            elif provider == "openai":
                req = urllib.request.Request(
                    "https://api.openai.com/v1/models",
                    headers={"Authorization": f"Bearer {api_key}"},
                    method="GET",
                )
                with urllib.request.urlopen(req, timeout=15):
                    pass

            elif provider == "google":
                url = (
                    "https://generativelanguage.googleapis.com/v1beta"
                    f"/models?key={api_key}"
                )
                req = urllib.request.Request(url, method="GET")
                with urllib.request.urlopen(req, timeout=15):
                    pass

            else:
                raise HTTPException(
                    status_code=400,
                    detail=f"Key testing not supported for {provider}",
                )

        except urllib.error.HTTPError as e:
            raise HTTPException(
                status_code=400,
                detail=f"Key test failed for {provider}: HTTP {e.code}",
            )
        except urllib.error.URLError as e:
            raise HTTPException(
                status_code=502,
                detail=f"Could not reach {provider}: {e.reason}",
            )

        return JSONResponse({"ok": True, "provider": provider})

    # ── POST /auth/login/{provider} ───────────────────────────────

    @app.post("/auth/login/{provider}")
    async def auth_login(provider: str) -> JSONResponse:
        """Start an OAuth login flow for a provider.

        Opens the user's browser to the provider's consent page and
        returns the authorization URL.

        Path params:
            provider: "anthropic", "google", or "openai"

        Returns:
            {"auth_url": "https://...", "provider": "anthropic"}
        """
        provider = provider.strip().lower()
        try:
            auth_url = await oauth.start_login(provider, open_browser=False)
        except ValueError as exc:
            raise HTTPException(status_code=400, detail=str(exc))

        return JSONResponse({
            "auth_url": auth_url,
            "provider": provider,
        })

    # ── GET /auth/callback/{provider} ─────────────────────────────

    @app.get("/auth/callback/{provider}")
    async def auth_callback(provider: str, request: Request) -> JSONResponse:
        """Handle the OAuth redirect from a provider.

        The provider redirects here with ?code=...&state=... after
        the user consents. Exchanges the code for tokens and stores
        them locally.

        Query params:
            code:  Authorization code from the provider.
            state: CSRF state parameter.
        """
        provider = provider.strip().lower()
        code = request.query_params.get("code")
        state = request.query_params.get("state")

        if not code:
            raise HTTPException(status_code=400, detail="Missing 'code' parameter")
        if not state:
            raise HTTPException(status_code=400, detail="Missing 'state' parameter")

        # Check for OAuth error response from provider
        error = request.query_params.get("error")
        if error:
            error_desc = request.query_params.get("error_description", "Unknown error")
            raise HTTPException(
                status_code=400,
                detail=f"OAuth error from {provider}: {error} - {error_desc}",
            )

        try:
            token_set = await oauth.handle_callback(provider, code, state)
        except ValueError as exc:
            raise HTTPException(status_code=400, detail=str(exc))
        except OAuthTokenError as exc:
            raise HTTPException(status_code=502, detail=str(exc))

        return JSONResponse({
            "connected": True,
            "provider": provider,
            "scopes": token_set.scopes,
            "user_email": token_set.user_email,
        })

    # ── GET /auth/connections ─────────────────────────────────────

    @app.get("/auth/connections")
    async def auth_connections() -> JSONResponse:
        """List all supported providers with their connection status.

        Returns a list of connections, each with provider name,
        display name, and status ("connected", "expired", or "not_connected").
        """
        connections = await oauth.list_connections()
        return JSONResponse({
            "connections": [
                {
                    "provider": c.provider,
                    "provider_name": c.provider_name,
                    "status": c.status,
                    "user_email": c.user_email,
                    "scopes": c.scopes,
                }
                for c in connections
            ],
        })

    # ── POST /auth/disconnect/{provider} (alias for logout) ─────

    @app.post("/auth/disconnect/{provider}")
    async def auth_disconnect(provider: str) -> JSONResponse:
        """Alias for /auth/logout — the frontend uses 'disconnect' naming."""
        return await auth_logout(provider)

    # ── POST /auth/logout/{provider} ──────────────────────────────

    @app.post("/auth/logout/{provider}")
    async def auth_logout(provider: str) -> JSONResponse:
        """Disconnect an OAuth provider (revoke + delete local tokens).

        Path params:
            provider: "anthropic", "google", or "openai"
        """
        provider = provider.strip().lower()
        try:
            await oauth.logout(provider)
        except ValueError as exc:
            raise HTTPException(status_code=400, detail=str(exc))

        return JSONResponse({
            "disconnected": True,
            "provider": provider,
        })

    # ── POST /projects ─────────────────────────────────────────────

    @app.post("/projects")
    async def create_project(request: Request) -> JSONResponse:
        """Create a new project.

        Body: {"name": "...", "description": "..."}
        Returns the created project with a generated ID.
        """
        body = await request.json()
        name = (body.get("name") or "").strip()
        if not name:
            raise HTTPException(status_code=400, detail="name is required")

        description = (body.get("description") or "").strip()
        now = _now_iso()
        project: dict[str, Any] = {
            "id": f"proj_{uuid.uuid4().hex[:12]}",
            "name": name,
            "description": description,
            "created_at": now,
            "updated_at": now,
            "agents": [],
            "chat_ids": [],
        }

        projects = _read_projects(config.data_dir)
        projects.append(project)
        _write_projects(config.data_dir, projects)

        return JSONResponse(project, status_code=201)

    # ── GET /projects ──────────────────────────────────────────────

    @app.get("/projects")
    async def list_projects() -> JSONResponse:
        """List all projects."""
        projects = _read_projects(config.data_dir)
        return JSONResponse({"projects": projects, "count": len(projects)})

    # ── GET /projects/{project_id} ─────────────────────────────────

    @app.get("/projects/{project_id}")
    async def get_project(project_id: str) -> JSONResponse:
        """Get a single project by ID."""
        projects = _read_projects(config.data_dir)
        project = _find_project(projects, project_id)
        if project is None:
            raise HTTPException(status_code=404, detail=f"Project {project_id} not found")
        return JSONResponse(project)

    # ── PUT /projects/{project_id} ─────────────────────────────────

    @app.put("/projects/{project_id}")
    async def update_project(project_id: str, request: Request) -> JSONResponse:
        """Update a project's name, description, or agents list.

        Body: any of {"name": "...", "description": "...", "agents": [...]}
        """
        body = await request.json()
        projects = _read_projects(config.data_dir)
        project = _find_project(projects, project_id)
        if project is None:
            raise HTTPException(status_code=404, detail=f"Project {project_id} not found")

        if "name" in body:
            name = (body["name"] or "").strip()
            if not name:
                raise HTTPException(status_code=400, detail="name cannot be empty")
            project["name"] = name
        if "description" in body:
            project["description"] = (body["description"] or "").strip()
        if "agents" in body:
            if not isinstance(body["agents"], list):
                raise HTTPException(status_code=400, detail="agents must be a list")
            project["agents"] = body["agents"]

        project["updated_at"] = _now_iso()
        _write_projects(config.data_dir, projects)

        return JSONResponse(project)

    # ── DELETE /projects/{project_id} ──────────────────────────────

    @app.delete("/projects/{project_id}")
    async def delete_project(project_id: str) -> JSONResponse:
        """Delete a project. Chat history is NOT deleted."""
        projects = _read_projects(config.data_dir)
        original_len = len(projects)
        projects = [p for p in projects if p["id"] != project_id]
        if len(projects) == original_len:
            raise HTTPException(status_code=404, detail=f"Project {project_id} not found")

        _write_projects(config.data_dir, projects)
        return JSONResponse({"deleted": True, "id": project_id})

    # ── POST /projects/{project_id}/agents ─────────────────────────

    @app.post("/projects/{project_id}/agents")
    async def add_agent_to_project(project_id: str, request: Request) -> JSONResponse:
        """Add an agent (model) to a project.

        Body: {"model": "llama3"}
        """
        body = await request.json()
        model = (body.get("model") or "").strip()
        if not model:
            raise HTTPException(status_code=400, detail="model is required")

        projects = _read_projects(config.data_dir)
        project = _find_project(projects, project_id)
        if project is None:
            raise HTTPException(status_code=404, detail=f"Project {project_id} not found")

        if model in project["agents"]:
            raise HTTPException(status_code=409, detail=f"Agent {model} already in project")

        project["agents"].append(model)
        project["updated_at"] = _now_iso()
        _write_projects(config.data_dir, projects)

        return JSONResponse({"added": True, "model": model, "agents": project["agents"]})

    # ── DELETE /projects/{project_id}/agents/{model} ───────────────

    @app.delete("/projects/{project_id}/agents/{model}")
    async def remove_agent_from_project(project_id: str, model: str) -> JSONResponse:
        """Remove an agent (model) from a project."""
        projects = _read_projects(config.data_dir)
        project = _find_project(projects, project_id)
        if project is None:
            raise HTTPException(status_code=404, detail=f"Project {project_id} not found")

        if model not in project["agents"]:
            raise HTTPException(status_code=404, detail=f"Agent {model} not in project")

        project["agents"].remove(model)
        project["updated_at"] = _now_iso()
        _write_projects(config.data_dir, projects)

        return JSONResponse({"removed": True, "model": model, "agents": project["agents"]})

    return app


# ── Module-level instance for uvicorn ──────────────────────────────────

app = create_app()

if __name__ == "__main__":
    import uvicorn

    cfg = load_config()
    uvicorn.run(
        "runtime.app:app",
        host="127.0.0.1",
        port=cfg.local_port,
        reload=cfg.debug,
    )
