"""FastAPI application with WebRTC signaling and streaming-only voice pipeline."""

import asyncio
import logging
import os
from contextlib import asynccontextmanager
from pathlib import Path

from fastapi import Depends, FastAPI, Request, WebSocket
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel

from roohai.pipeline import Rooh
from roohai.registry import registry

from .auth import is_auth_enabled, require_auth, verify_ws_token
from .cors import DynamicCORSMiddleware
from .domains import is_origin_allowed, seed_from_env
from .internal import router as internal_router
from .prepare import prepare_agent
from .callbacks import shutdown as shutdown_callbacks
from .streaming import handle_streaming
from .webrtc import cleanup_all, create_offer_response
from .wizard import router as wizard_router

# Configure logging for all server modules
logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s %(levelname)s [%(name)s] %(message)s",
    datefmt="%H:%M:%S",
)

logger = logging.getLogger(__name__)

# --- Pydantic models ---


class WebRTCOffer(BaseModel):
    sdp: str
    type: str = "offer"


class ModelSwapRequest(BaseModel):
    model_type: str  # "stt", "llm", or "tts"
    model_name: str


# --- App state ---

pipeline: Rooh | None = None

import roohai as _roohai_pkg

_PACKAGE_DIR = Path(_roohai_pkg.__file__).resolve().parent
CONFIG_PATH = _PACKAGE_DIR / "default_config.yaml"
FRONTEND_DIR = _PACKAGE_DIR / "frontend"


# --- Langfuse → OTEL env var mapping ---


def _set_langfuse_otel_env(langfuse_secrets: dict) -> None:
    """Map Langfuse credentials to OTEL env vars for Strands' built-in tracing.

    Strands uses OpenTelemetry with OTLP export. Langfuse accepts OTLP traces
    at its /api/public/otel endpoint with Basic auth (public_key:secret_key).
    """
    import base64

    public_key = langfuse_secrets.get("public_key", "")
    secret_key = langfuse_secrets.get("secret_key", "")
    host = langfuse_secrets.get("host", "https://cloud.langfuse.com")

    # OTLP endpoint — Langfuse's OTEL ingestion endpoint
    os.environ["OTEL_EXPORTER_OTLP_ENDPOINT"] = f"{host.rstrip('/')}/api/public/otel"

    # OTLP headers — Basic auth with base64-encoded public_key:secret_key
    credentials = base64.b64encode(f"{public_key}:{secret_key}".encode()).decode()
    os.environ["OTEL_EXPORTER_OTLP_HEADERS"] = f"Authorization=Basic {credentials}"

    logger.info("OTEL endpoint set to %s", os.environ["OTEL_EXPORTER_OTLP_ENDPOINT"])


# --- Lifespan ---


@asynccontextmanager
async def lifespan(app: FastAPI):
    global pipeline
    from roohai.config import load_config_from_env

    env_config = load_config_from_env()
    if env_config is not None:
        logger.info("Loading config from AGENT_CONFIG_YAML env var")
        # Merge ROOHAI_SECRET_* env vars into model configs before loading
        agent_name = os.environ.get("AGENT_NAME", env_config.get("name", ""))
        if agent_name:
            from roohai.secrets import get_agent_secrets
            secrets = get_agent_secrets(agent_name)
            if secrets:
                logger.info("Merging secrets for agent '%s' into %d model(s)", agent_name, len(secrets))
                models = env_config.get("models", {})
                for model_name, model_secrets in secrets.items():
                    if model_name in models:
                        models[model_name].update(model_secrets)
                    else:
                        logger.debug("Secret model '%s' not in config models, skipping", model_name)

                # Map Langfuse secrets to OTEL env vars for Strands' built-in tracing
                langfuse_secrets = secrets.get("langfuse", {})
                if langfuse_secrets.get("public_key"):
                    _set_langfuse_otel_env(langfuse_secrets)
                    logger.info("OTEL env vars set from agent Langfuse secrets")
        pipeline = Rooh(config_dict=env_config)
    else:
        logger.info("Loading config from %s", CONFIG_PATH)
        pipeline = Rooh(str(CONFIG_PATH))
    await asyncio.to_thread(pipeline.load)
    logger.info("Pipeline initialized and models loaded")

    seed_from_env()

    if is_auth_enabled():
        logger.info("JWT authentication ENABLED (JWKS: %s)", os.environ.get("ROOHAI_AUTH_JWKS_URL"))
    else:
        logger.info("JWT authentication disabled (set ROOHAI_AUTH_JWKS_URL to enable)")

    # Auto-create and activate a Default agent on first boot
    from roohai.agents import list_agents, get_agent, save_agent
    from datetime import datetime, timezone

    agents = list_agents()

    if not agents:
        # Use already-loaded pipeline models instead of hardcoded defaults
        stt_name = pipeline._stt_name or "whisper-tiny"
        llm_name = pipeline._llm_name or "bedrock-claude-haiku"
        tts_name = pipeline._tts_name or "piper"
        vad_name = "silero"
        # Use AGENT_NAME from platform if available, otherwise "Default"
        first_agent_name = os.environ.get("AGENT_NAME", "Default")
        logger.info("No agents found — creating '%s' agent", first_agent_name)

        # For custom-endpoint (webhook) agents, the env YAML has llm_hook/webhook
        # at the top level but no pipeline.llm — carry those fields through.
        first_config = {
            "name": first_agent_name,
            "created_at": datetime.now(timezone.utc).isoformat(),
            "status": "active",
            "pipeline": {
                "stt": stt_name,
                "tts": tts_name,
                "vad": vad_name,
            },
            "models": {
                stt_name: {"class": stt_name, "type": "stt"},
                tts_name: {"class": tts_name, "type": "tts"},
                vad_name: {"class": vad_name, "type": "vad"},
            },
        }
        # Only include LLM in pipeline if it's not a webhook agent
        if llm_name and not (env_config and env_config.get("llm_hook")):
            first_config["pipeline"]["llm"] = llm_name
            first_config["models"][llm_name] = {"class": llm_name, "type": "llm"}

        # Propagate webhook/hook config from env YAML to the saved agent
        if env_config:
            for key in ("llm_hook", "llm_stream_hook", "llm_streaming", "webhook", "system_prompt"):
                if key in env_config:
                    first_config[key] = env_config[key]
        save_agent(first_config)
        agents = list_agents()

    # Find an active agent, or fall back to Default
    active_agent = None
    for a in agents:
        if a.get("status") == "active":
            active_agent = a
            break
    if not active_agent:
        active_agent = get_agent("Default") or agents[0]
        active_agent["status"] = "active"
        save_agent(active_agent)

    # Activate the agent into the pipeline.  If anything fails (bad hook,
    # missing model, etc.) deactivate the agent and fall back to Default.
    await _activate_agent(active_agent, pipeline)

    yield
    # Clean up all Strands agent sessions
    from roohai.llm.strands_hook import clear_all_sessions
    clear_all_sessions()
    await cleanup_all()
    await shutdown_callbacks()
    logger.info("Server shutdown complete")


async def _activate_agent(agent: dict, pipeline) -> None:
    """Validate and activate an agent's config into the pipeline.

    On any error (unresolvable hook, bad model, etc.) the agent is deactivated
    and the Default agent is activated instead.
    """
    import importlib
    from roohai.agents import get_agent, save_agent
    from roohai.pipeline import _register_from_config
    from roohai.secrets import get_agent_secrets

    agent_name = agent["name"]
    logger.info("Activating agent '%s'", agent_name)

    try:
        # 1. Validate hooks BEFORE touching the pipeline
        #    Add the agents directory to sys.path so agent-local hook
        #    modules (e.g. hooks.py alongside agent YAMLs) are importable.
        import sys
        from roohai.agents import AGENTS_DIR
        agents_dir_str = str(AGENTS_DIR)
        if agents_dir_str not in sys.path:
            sys.path.insert(0, agents_dir_str)

        hook_fn = None
        hook_path = agent.get("llm_hook")
        if hook_path:
            module_path, _, attr = hook_path.rpartition(".")
            mod = importlib.import_module(module_path)
            hook_fn = getattr(mod, attr)

        stream_hook_fn = None
        stream_hook_path = agent.get("llm_stream_hook")
        if stream_hook_path:
            module_path, _, attr = stream_hook_path.rpartition(".")
            mod = importlib.import_module(module_path)
            stream_hook_fn = getattr(mod, attr)

        # 2. Register and swap models
        secrets = get_agent_secrets(agent_name)
        models = agent.get("models", {})
        for model_name, model_cfg in models.items():
            model_secrets = secrets.get(model_name, {})
            if model_secrets:
                model_cfg.update(model_secrets)
        _register_from_config({"models": models})

        # Map Langfuse secrets to OTEL env vars for Strands' built-in tracing
        langfuse_secrets = secrets.get("langfuse", {})
        if langfuse_secrets.get("public_key"):
            _set_langfuse_otel_env(langfuse_secrets)
            logger.info("OTEL env vars set from agent '%s' Langfuse secrets", agent_name)

        agent_pipeline = agent.get("pipeline", {})
        current_models = {
            "stt": pipeline._stt_name,
            "tts": pipeline._tts_name,
            "llm": pipeline._llm_name,
            "vad": pipeline._vad_name,
        }
        for model_type in ("stt", "tts", "llm", "vad"):
            model_name = agent_pipeline.get(model_type)
            if model_name and model_name != current_models.get(model_type):
                await asyncio.to_thread(pipeline.swap_model, model_type, model_name)

        # 3. Configure webhook if present
        webhook_cfg = agent.get("webhook")
        if webhook_cfg:
            from roohai import webhook
            webhook.configure(**webhook_cfg)

        # 4. Apply config
        pipeline.set_system_prompt(agent.get("system_prompt"))
        pipeline.llm_streaming = agent.get("llm_streaming", True)
        pipeline.barge_in_enabled = agent_pipeline.get("barge_in_enabled", True)
        pipeline.silence_duration_ms = int(agent_pipeline.get("silence_duration_ms", 1000))
        pipeline.vad_threshold = float(agent_pipeline.get("vad_threshold", 0.7))

        if hook_fn:
            # Custom hook explicitly set in agent YAML — use it as-is
            pipeline.set_llm_hook(hook_fn, stream_hook=stream_hook_fn)
        else:
            # Build the LLM config dict to decide routing
            llm_name = agent_pipeline.get("llm", pipeline._llm_name)
            llm_config = models.get(llm_name, {})
            llm_class = llm_config.get("class", "")

            # Local models (e.g. TinyLlama via HuggingFace transformers) don't
            # have a Strands model provider — skip the Strands hook and let the
            # pipeline fall back to the loaded LocalLLM directly.
            _STRANDS_SUPPORTED_PROVIDERS = {"bedrock", "openai", "anthropic", "gemini", "ollama"}

            if llm_class in _STRANDS_SUPPORTED_PROVIDERS:
                # ── Wire up Strands Agent as the LLM hook ────────────────
                # Gives every agent conversation memory + tool use for free.
                from roohai.llm.strands_hook import configure as configure_strands
                from roohai.llm.strands_hook import strands_hook, clear_session, reset_session

                strands_cfg = agent.get("strands", {})
                tool_names = strands_cfg.get("tools")  # None = use defaults
                window_size = int(strands_cfg.get("conversation_window", 40))

                configure_strands(
                    llm_config=llm_config,
                    system_prompt=agent.get("system_prompt"),
                    tool_names=tool_names,
                    window_size=window_size,
                )
                pipeline.set_llm_hook(strands_hook)
                pipeline.set_session_disconnect_hook(clear_session)

                async def _on_barge_in(session_id: str, generation_id: int, **_kw) -> None:
                    reset_session(session_id)

                pipeline.set_barge_in_hook(_on_barge_in)
                logger.info("Strands Agent hook wired for agent '%s'", agent_name)
            else:
                # No Strands support — clear any previously set hooks so the
                # pipeline uses the loaded LLM model directly.
                logger.info(
                    "LLM class '%s' not supported by Strands — using direct pipeline LLM fallback for agent '%s'",
                    llm_class, agent_name,
                )
                pipeline.set_llm_hook(None)

        logger.info("Agent '%s' activated successfully", agent_name)

    except Exception as exc:
        logger.warning(
            "Failed to activate agent '%s': %s — falling back to Default",
            agent_name, exc,
        )
        agent["status"] = "inactive"
        save_agent(agent)

        fallback = get_agent("Default")
        if fallback and fallback["name"] != agent_name:
            fallback["status"] = "active"
            save_agent(fallback)
            await _activate_agent(fallback, pipeline)
        else:
            logger.warning("No Default agent to fall back to")


# --- FastAPI app ---

app = FastAPI(title="RoohAI", lifespan=lifespan)

app.add_middleware(DynamicCORSMiddleware)

app.include_router(wizard_router)
app.include_router(internal_router)


# --- Health ---


@app.get("/api/health")
async def health():
    from roohai import __version__

    active = {}
    if pipeline:
        if pipeline._stt_name:
            active["stt"] = pipeline._stt_name
        if pipeline._tts_name:
            active["tts"] = pipeline._tts_name
        if pipeline._llm_name:
            active["llm"] = pipeline._llm_name
    return {"status": "ok", "version": __version__, "models": active}


# --- WebSocket streaming ---


async def _verify_ws_origin(websocket: WebSocket) -> None:
    """Reject WebSocket connections from non-whitelisted origins."""
    origin = websocket.headers.get("origin")
    if not is_origin_allowed(origin):
        await websocket.close(code=4003, reason="Origin not allowed")
        raise RuntimeError(f"WebSocket origin rejected: {origin}")


@app.websocket("/ws/voice-stream")
async def ws_voice_stream(websocket: WebSocket):
    await _verify_ws_origin(websocket)
    await verify_ws_token(websocket)
    await handle_streaming(websocket, pipeline)


# --- WebRTC signaling ---


@app.post("/api/webrtc/offer")
async def webrtc_offer(request: Request, offer: WebRTCOffer, claims=Depends(require_auth)):
    """Accept a WebRTC SDP offer and return an SDP answer."""
    from fastapi import HTTPException

    origin = request.headers.get("origin")
    if not is_origin_allowed(origin):
        raise HTTPException(status_code=403, detail="Origin not allowed")

    try:
        result = await create_offer_response(offer.sdp, offer.type, pipeline)
        return result
    except Exception:
        logger.exception("WebRTC offer failed")
        raise HTTPException(status_code=500, detail="WebRTC connection failed")


@app.get("/api/webrtc/config")
async def webrtc_config(claims=Depends(require_auth)):
    """Return ICE server configuration for WebRTC clients.

    Clients should fetch this before creating a RTCPeerConnection so they
    have STUN/TURN servers for NAT traversal. TURN credentials are
    time-limited and regenerated on each call.
    """
    from .turn import get_ice_servers

    return {"iceServers": get_ice_servers()}


@app.post("/api/webrtc/ice")
async def webrtc_ice(claims=Depends(require_auth)):
    """ICE candidate exchange endpoint (trickle ICE).

    For localhost connections, ICE candidates are typically gathered
    automatically during the offer/answer exchange. This endpoint
    exists for future use with STUN/TURN servers.
    """
    return {"status": "ok"}


# --- Model management ---


@app.post("/api/models/swap")
async def swap_model(req: ModelSwapRequest, claims=Depends(require_auth)):
    """Hot-swap a model at runtime."""
    await asyncio.to_thread(pipeline.swap_model, req.model_type, req.model_name)
    active = {}
    if pipeline._stt_name:
        active["stt"] = pipeline._stt_name
    if pipeline._tts_name:
        active["tts"] = pipeline._tts_name
    if pipeline._llm_name:
        active["llm"] = pipeline._llm_name
    return {
        "status": "ok",
        "model_type": req.model_type,
        "model_name": req.model_name,
        "active_models": active,
    }


@app.get("/api/models")
async def list_models(claims=Depends(require_auth)):
    """List available models and which are currently active."""
    return {
        "stt": registry.list_stt(),
        "tts": registry.list_tts(),
        "llm": registry.list_llm(),
        "active_stt": pipeline._stt_name,
        "active_tts": pipeline._tts_name,
        "active_llm": pipeline._llm_name,
    }


# --- WebSocket: Agent preparation ---


@app.websocket("/ws/agent-prepare")
async def ws_agent_prepare(websocket: WebSocket):
    """WebSocket endpoint for agent preparation with live progress."""
    await _verify_ws_origin(websocket)
    await verify_ws_token(websocket)
    agent_name = websocket.query_params.get("name", "")
    await websocket.accept()
    try:
        if not agent_name:
            await websocket.send_json({"status": "error", "message": "No agent name provided"})
            return

        from roohai.agents import get_agent
        from roohai.secrets import get_agent_secrets

        agent_config = get_agent(agent_name)
        if not agent_config:
            await websocket.send_json({"status": "error", "message": f"Agent '{agent_name}' not found"})
            return

        # Merge stored secrets into model configs so credential validation works
        secrets = get_agent_secrets(agent_name)
        for model_name, model_cfg in agent_config.get("models", {}).items():
            model_secrets = secrets.get(model_name, {})
            if model_secrets:
                model_cfg.update(model_secrets)

        await prepare_agent(agent_config, websocket)
    except Exception:
        logger.exception("Agent preparation WebSocket error")
        try:
            await websocket.send_json({"status": "error", "message": "Preparation failed"})
        except Exception:
            pass
    finally:
        try:
            await websocket.close()
        except Exception:
            pass


# --- Documentation ---

DOCS_DIR = _PACKAGE_DIR / "docs"

# Ordered navigation items: (slug, label, icon)
_NAV_ITEMS = [
    ("index", "Home", "&#9776;"),
    ("getting-started", "Getting Started", "&#9654;"),
    ("architecture", "Architecture", "&#9881;"),
    ("models", "Models", "&#9733;"),
    ("configuration", "Configuration", "&#9881;"),
    ("api", "API Reference", "&#8644;"),
    ("custom-models", "Custom Models", "&#10010;"),
    ("examples", "Examples", "&#128196;")
]

_DOCS_HTML_TEMPLATE = """\
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{title} — RoohAI Docs</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/github-markdown-css@5/github-markdown-light.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11/build/styles/github.min.css">
<style>
  :root {{ --sidebar-w: 240px; --toc-w: 200px; --accent: #6c5ce7; --accent-light: #a29bfe; --bg: #fff; --sidebar-bg: #f8f9fa; --border: #e2e8f0; --text: #1a202c; --text-muted: #64748b; }}
  * {{ box-sizing: border-box; margin: 0; padding: 0; }}
  html {{ scroll-behavior: smooth; scroll-padding-top: 80px; }}
  body {{ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: var(--bg); color: var(--text); display: flex; min-height: 100vh; }}

  /* ── Sidebar ── */
  .sidebar {{ width: var(--sidebar-w); position: fixed; top: 0; left: 0; bottom: 0; background: var(--sidebar-bg); border-right: 1px solid var(--border); overflow-y: auto; z-index: 10; display: flex; flex-direction: column; }}
  .sidebar-header {{ padding: 20px 16px 12px; border-bottom: 1px solid var(--border); }}
  .sidebar-header h2 {{ font-size: 16px; font-weight: 700; color: var(--accent); }}
  .sidebar-header p {{ font-size: 11px; color: var(--text-muted); margin-top: 2px; }}
  .sidebar-search {{ padding: 8px 12px; }}
  .sidebar-search input {{ width: 100%; padding: 7px 10px 7px 30px; border: 1px solid var(--border); border-radius: 6px; font-size: 13px; outline: none; background: #fff url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 24 24' fill='none' stroke='%2394a3b8' stroke-width='2'%3E%3Ccircle cx='11' cy='11' r='8'/%3E%3Cline x1='21' y1='21' x2='16.65' y2='16.65'/%3E%3C/svg%3E") 9px center no-repeat; }}
  .sidebar-search input:focus {{ border-color: var(--accent); box-shadow: 0 0 0 2px rgba(108,92,231,.15); }}
  .sidebar-nav {{ flex: 1; padding: 8px 0; }}
  .sidebar-nav a {{ display: flex; align-items: center; gap: 8px; padding: 7px 16px; font-size: 13px; color: var(--text-muted); text-decoration: none; border-left: 3px solid transparent; transition: all .15s; }}
  .sidebar-nav a:hover {{ color: var(--text); background: rgba(108,92,231,.05); }}
  .sidebar-nav a.active {{ color: var(--accent); border-left-color: var(--accent); background: rgba(108,92,231,.08); font-weight: 600; }}
  .sidebar-nav .nav-icon {{ font-size: 14px; width: 18px; text-align: center; }}

  /* ── Search results overlay ── */
  .search-results {{ display: none; position: absolute; left: 12px; right: 12px; top: 44px; background: #fff; border: 1px solid var(--border); border-radius: 8px; box-shadow: 0 8px 24px rgba(0,0,0,.12); max-height: 400px; overflow-y: auto; z-index: 100; }}
  .search-results.visible {{ display: block; }}
  .search-result {{ padding: 10px 14px; border-bottom: 1px solid #f1f5f9; cursor: pointer; }}
  .search-result:last-child {{ border-bottom: none; }}
  .search-result:hover {{ background: #f8f9fa; }}
  .search-result .sr-page {{ font-size: 11px; color: var(--accent); font-weight: 600; text-transform: uppercase; letter-spacing: .5px; }}
  .search-result .sr-heading {{ font-size: 13px; font-weight: 600; margin: 2px 0; }}
  .search-result .sr-snippet {{ font-size: 12px; color: var(--text-muted); line-height: 1.4; }}
  .search-result mark {{ background: #fef3c7; border-radius: 2px; padding: 0 1px; }}
  .search-empty {{ padding: 20px 14px; text-align: center; font-size: 13px; color: var(--text-muted); }}
  .search-hint {{ padding: 8px 14px; font-size: 11px; color: var(--text-muted); border-bottom: 1px solid #f1f5f9; }}

  /* ── Main content ── */
  .main {{ margin-left: var(--sidebar-w); flex: 1; display: flex; justify-content: center; }}
  .content-wrap {{ max-width: 780px; width: 100%; padding: 32px 40px 80px; }}
  .markdown-body {{ font-size: 15px; line-height: 1.7; }}
  .markdown-body h1 {{ font-size: 28px; margin-bottom: 8px; padding-bottom: 8px; border-bottom: 2px solid var(--accent); }}
  .markdown-body h2 {{ font-size: 22px; margin-top: 40px; margin-bottom: 12px; padding-bottom: 6px; border-bottom: 1px solid var(--border); }}
  .markdown-body h3 {{ font-size: 17px; margin-top: 28px; margin-bottom: 8px; }}
  .markdown-body pre {{ background: #f6f8fa; border-radius: 8px; padding: 16px; overflow-x: auto; border: 1px solid #e8ecf0; }}
  .markdown-body code {{ font-size: 13px; font-family: "SF Mono", "Fira Code", "Consolas", monospace; }}
  .markdown-body :not(pre) > code {{ background: #f0f2f5; padding: 2px 6px; border-radius: 4px; font-size: 12px; }}
  .markdown-body table {{ border-collapse: collapse; width: 100%; margin: 16px 0; }}
  .markdown-body th, .markdown-body td {{ border: 1px solid var(--border); padding: 8px 12px; text-align: left; font-size: 13px; }}
  .markdown-body th {{ background: #f8f9fa; font-weight: 600; }}
  .markdown-body blockquote {{ border-left: 3px solid var(--accent-light); padding: 8px 16px; margin: 16px 0; background: #fafafe; color: var(--text-muted); }}
  .markdown-body hr {{ border: none; border-top: 1px solid var(--border); margin: 32px 0; }}
  .markdown-body a {{ color: var(--accent); }}

  /* ── On-page TOC ── */
  .toc {{ position: fixed; right: max(20px, calc((100vw - var(--sidebar-w) - 780px) / 2 - var(--toc-w) - 20px)); top: 80px; width: var(--toc-w); max-height: calc(100vh - 120px); overflow-y: auto; font-size: 12px; }}
  .toc-title {{ font-size: 11px; font-weight: 700; color: var(--text-muted); text-transform: uppercase; letter-spacing: .8px; margin-bottom: 8px; }}
  .toc a {{ display: block; padding: 3px 0 3px 12px; color: var(--text-muted); text-decoration: none; border-left: 2px solid transparent; line-height: 1.5; transition: all .15s; }}
  .toc a:hover {{ color: var(--text); }}
  .toc a.active {{ color: var(--accent); border-left-color: var(--accent); }}
  .toc a.toc-h3 {{ padding-left: 24px; font-size: 11px; }}

  /* ── Mobile ── */
  .mobile-toggle {{ display: none; position: fixed; top: 12px; left: 12px; z-index: 20; background: var(--accent); color: #fff; border: none; border-radius: 8px; padding: 8px 12px; font-size: 16px; cursor: pointer; }}
  @media (max-width: 1100px) {{ .toc {{ display: none; }} }}
  @media (max-width: 768px) {{
    .sidebar {{ transform: translateX(-100%); transition: transform .2s; }}
    .sidebar.open {{ transform: translateX(0); }}
    .mobile-toggle {{ display: block; }}
    .main {{ margin-left: 0; }}
    .content-wrap {{ padding: 60px 16px 60px; }}
  }}
</style>
</head>
<body>

<button class="mobile-toggle" onclick="document.querySelector('.sidebar').classList.toggle('open')">&#9776;</button>

<aside class="sidebar">
  <div class="sidebar-header">
    <h2>RoohAI</h2>
    <p>Documentation</p>
  </div>
  <div class="sidebar-search" style="position:relative">
    <input type="text" id="search-input" placeholder="Search docs..." autocomplete="off">
    <div class="search-results" id="search-results"></div>
  </div>
  <nav class="sidebar-nav">
{nav_links}
  </nav>
</aside>

<div class="main">
  <div class="content-wrap">
    <article class="markdown-body" id="content"></article>
  </div>
</div>

<aside class="toc" id="toc"></aside>

<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script src="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11/build/highlight.min.js"></script>
<script>
(function() {{
  // ── Render markdown ──
  marked.setOptions({{
    highlight: function(code, lang) {{
      if (lang && hljs.getLanguage(lang)) return hljs.highlight(code, {{language: lang}}).value;
      return hljs.highlightAuto(code).value;
    }}
  }});
  var md = {markdown_json};
  document.getElementById("content").innerHTML = marked.parse(md);

  // ── Build table of contents from rendered headings ──
  var toc = document.getElementById("toc");
  var headings = document.querySelectorAll(".markdown-body h2, .markdown-body h3");
  if (headings.length > 1) {{
    var html = '<div class="toc-title">On this page</div>';
    headings.forEach(function(h, i) {{
      var id = "heading-" + i;
      h.id = id;
      var cls = h.tagName === "H3" ? "toc-h3" : "";
      html += '<a href="#' + id + '" class="' + cls + '">' + h.textContent + '</a>';
    }});
    toc.innerHTML = html;

    // Highlight active TOC item on scroll
    var tocLinks = toc.querySelectorAll("a");
    var observer = new IntersectionObserver(function(entries) {{
      entries.forEach(function(entry) {{
        if (entry.isIntersecting) {{
          tocLinks.forEach(function(a) {{ a.classList.remove("active"); }});
          var link = toc.querySelector('a[href="#' + entry.target.id + '"]');
          if (link) link.classList.add("active");
        }}
      }});
    }}, {{ rootMargin: "-80px 0px -70% 0px" }});
    headings.forEach(function(h) {{ observer.observe(h); }});
  }}

  // ── Search ──
  var searchInput = document.getElementById("search-input");
  var searchResults = document.getElementById("search-results");
  var searchTimeout = null;

  searchInput.addEventListener("input", function() {{
    clearTimeout(searchTimeout);
    var q = this.value.trim();
    if (q.length < 2) {{ searchResults.classList.remove("visible"); return; }}
    searchTimeout = setTimeout(function() {{
      fetch("/api/docs/search?q=" + encodeURIComponent(q))
        .then(function(r) {{ return r.json(); }})
        .then(function(data) {{
          if (!data.results || data.results.length === 0) {{
            searchResults.innerHTML = '<div class="search-empty">No results for "' + q.replace(/</g,"&lt;") + '"</div>';
          }} else {{
            var html = '<div class="search-hint">' + data.results.length + ' result' + (data.results.length > 1 ? 's' : '') + '</div>';
            data.results.forEach(function(r) {{
              html += '<div class="search-result" onclick="window.location.href=\\'' + r.url + '\\'">'
                + '<div class="sr-page">' + r.page + '</div>'
                + '<div class="sr-heading">' + r.heading + '</div>'
                + '<div class="sr-snippet">' + r.snippet + '</div>'
                + '</div>';
            }});
            searchResults.innerHTML = html;
          }}
          searchResults.classList.add("visible");
        }});
    }}, 200);
  }});

  // Close search on outside click
  document.addEventListener("click", function(e) {{
    if (!e.target.closest(".sidebar-search")) searchResults.classList.remove("visible");
  }});

  // Keyboard shortcut: "/" to focus search
  document.addEventListener("keydown", function(e) {{
    if (e.key === "/" && document.activeElement.tagName !== "INPUT") {{
      e.preventDefault();
      searchInput.focus();
    }}
  }});

  // Close mobile sidebar on link click
  document.querySelectorAll(".sidebar-nav a").forEach(function(a) {{
    a.addEventListener("click", function() {{ document.querySelector(".sidebar").classList.remove("open"); }});
  }});
}})();
</script>
</body>
</html>
"""


def _build_nav_links(active_page: str) -> str:
    """Build sidebar navigation HTML."""
    lines = []
    for slug, label, icon in _NAV_ITEMS:
        href = "/guide" if slug == "index" else f"/guide/{slug}"
        cls = ' class="active"' if slug == active_page else ""
        lines.append(f'    <a href="{href}"{cls}><span class="nav-icon">{icon}</span> {label}</a>')
    return "\n".join(lines)


def _build_search_index() -> list[dict]:
    """Build a search index from all markdown files. Each entry has page, heading, text, url."""
    import re
    index = []
    if not DOCS_DIR.exists():
        return index
    for md_file in sorted(DOCS_DIR.glob("*.md")):
        slug = md_file.stem
        page_title = slug.replace("-", " ").title()
        content = md_file.read_text(encoding="utf-8")
        # Get proper page title from first heading
        for line in content.splitlines():
            if line.startswith("# "):
                page_title = line[2:].strip()
                break
        # Split into sections by headings
        sections = re.split(r'^(#{1,3})\s+(.+)$', content, flags=re.MULTILINE)
        # sections = [pre, level, heading, text, level, heading, text, ...]
        if sections[0].strip():
            index.append({"page": page_title, "heading": page_title, "text": sections[0].strip(), "slug": slug, "anchor": ""})
        i = 1
        while i < len(sections) - 2:
            _level = sections[i]
            heading = sections[i + 1]
            text = sections[i + 2] if i + 2 < len(sections) else ""
            # Strip code blocks for cleaner search text
            clean = re.sub(r'```[\s\S]*?```', '', text)
            clean = re.sub(r'`[^`]+`', '', clean)
            clean = re.sub(r'\[([^\]]+)\]\([^)]+\)', r'\1', clean)
            clean = re.sub(r'[#*|_>-]', ' ', clean)
            clean = ' '.join(clean.split())
            if clean.strip():
                anchor = re.sub(r'[^a-z0-9]+', '-', heading.lower()).strip('-')
                index.append({"page": page_title, "heading": heading, "text": clean[:500], "slug": slug, "anchor": anchor})
            i += 3
    return index


# Pre-build search index (rebuilt on each server restart)
_search_index: list[dict] = []


@app.get("/guide/{page:path}", response_class=HTMLResponse)
@app.get("/guide", response_class=HTMLResponse)
async def serve_docs(page: str = "index"):
    global _search_index
    if not _search_index:
        _search_index = _build_search_index()

    page = page.strip("/") or "index"
    md_file = DOCS_DIR / f"{page}.md"
    if not md_file.exists() or not md_file.is_relative_to(DOCS_DIR):
        md_file = DOCS_DIR / "index.md"
        page = "index"

    content = md_file.read_text(encoding="utf-8")

    title = "Docs"
    for line in content.splitlines():
        if line.startswith("# "):
            title = line[2:].strip()
            break

    import json as _json
    # Escape </script> so it doesn't break the enclosing <script> tag
    markdown_json = _json.dumps(content).replace("</", "<\\/")
    html = _DOCS_HTML_TEMPLATE.format(
        title=title,
        markdown_json=markdown_json,
        nav_links=_build_nav_links(page),
    )
    return HTMLResponse(html)


@app.get("/api/docs")
async def list_docs():
    """List available documentation pages."""
    if not DOCS_DIR.exists():
        return JSONResponse({"pages": []})
    pages = []
    for f in sorted(DOCS_DIR.glob("*.md")):
        name = f.stem
        title = name.replace("-", " ").title()
        for line in f.read_text(encoding="utf-8").splitlines():
            if line.startswith("# "):
                title = line[2:].strip()
                break
        pages.append({"slug": name, "title": title, "url": f"/guide/{name}"})
    return JSONResponse({"pages": pages})


@app.get("/api/docs/search")
async def search_docs(q: str = ""):
    """Full-text search across all documentation pages."""
    global _search_index
    if not _search_index:
        _search_index = _build_search_index()

    q = q.strip().lower()
    if len(q) < 2:
        return JSONResponse({"results": []})

    import re
    terms = q.split()
    results = []
    for entry in _search_index:
        text_lower = entry["text"].lower()
        heading_lower = entry["heading"].lower()
        # All terms must appear in heading or text
        if not all(t in text_lower or t in heading_lower for t in terms):
            continue
        # Build snippet with highlights
        snippet = entry["text"][:200]
        for term in terms:
            pattern = re.compile(re.escape(term), re.IGNORECASE)
            snippet = pattern.sub(lambda m: f"<mark>{m.group()}</mark>", snippet)
        url = "/guide" if entry["slug"] == "index" else f"/guide/{entry['slug']}"
        results.append({
            "page": entry["page"],
            "heading": entry["heading"],
            "snippet": snippet,
            "url": url,
        })
        if len(results) >= 20:
            break
    return JSONResponse({"results": results})


# --- Static files & frontend ---

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


@app.get("/")
async def serve_index():
    index = FRONTEND_DIR / "index.html"
    if index.exists():
        return FileResponse(str(index))
    return {"message": "RoohAI API is running. Frontend not found."}
