"""
TeCoxBeta HTTP API.

Routes
  GET  /                 search interface
  GET  /docs             documentation
  GET  /api              interactive OpenAPI explorer
  POST /search           search — JSON, or SSE when stream=true
  GET  /search           same, via query params
  GET  /stream           convenience alias of /search?stream=true
  POST /answer           answer + citations only
  GET  /sources          retrieval only, no synthesis
  GET  /models           capability tiers
  GET  /usage            token ledger
  GET  /health           diagnostics
  GET  /providers        live source engines
  GET  /depths           depth profiles
  POST /cache/clear      flush caches
"""
from __future__ import annotations

import asyncio
import json
import logging
import time
from collections import defaultdict, deque
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Any, Deque, Dict, Optional

from fastapi import Body, FastAPI, Header, HTTPException, Query, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import (FileResponse, HTMLResponse, JSONResponse,
                               StreamingResponse)
from fastapi.staticfiles import StaticFiles

from tecox.rewind import catalog, rewind

from .config import DEPTH_PROFILES, settings
from .core import answer_cache, close_client, page_cache, serp_cache
from .engine import engine, health
from .providers import provider_report
from .schemas import SearchRequest, SearchResponse

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
log = logging.getLogger("tecox.api")

STATIC_DIR = Path(__file__).resolve().parent.parent / "static"


@asynccontextmanager
async def lifespan(app: FastAPI):
    log.info("TeCoxBeta starting - sources: %d",
             len(provider_report()["active"]))
    try:
        await rewind.ready()
        log.info("TeCoxBeta synthesis layer ready")
    except Exception as e:  # noqa: BLE001
        log.warning("synthesis warmup deferred: %s", e)
    yield
    await close_client()
    await rewind.close()
    log.info("TeCoxBeta stopped")


app = FastAPI(
    docs_url="/api",                      # interactive OpenAPI explorer
    redoc_url=None,
    swagger_ui_oauth2_redirect_url=None,  # avoid clashing with /docs
    title="TeCoxBeta API",
    version="1.0.0",
    description=(
        "Self-hosted AI search. Plans a question, searches dozens of engines "
        "in parallel, reads the pages, and returns a cited answer. "
        "Streaming is built into /search."
    ),
    lifespan=lifespan,
)


def _custom_openapi():
    """Hide internal/derived fields so the documented payload stays minimal."""
    if app.openapi_schema:
        return app.openapi_schema
    from fastapi.openapi.utils import get_openapi
    schema = get_openapi(title=app.title, version=app.version,
                         description=app.description, routes=app.routes)
    props = (schema.get("components", {}).get("schemas", {})
             .get("SearchRequest", {}).get("properties", {}))
    props.pop("unfiltered", None)
    app.openapi_schema = schema
    return schema


app.openapi = _custom_openapi  # type: ignore[assignment]

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


# --------------------------------------------------------------------------
# optional auth + rate limiting (disabled by default)
# --------------------------------------------------------------------------
_hits: Dict[str, Deque[float]] = defaultdict(deque)


def _rate_ok(ip: str) -> bool:
    if settings.rate_limit_per_min <= 0:
        return True
    now = time.time()
    q = _hits[ip]
    while q and now - q[0] > 60:
        q.popleft()
    if len(q) >= settings.rate_limit_per_min:
        return False
    q.append(now)
    return True


def _auth(x_api_key: Optional[str]) -> None:
    if settings.api_key and x_api_key != settings.api_key:
        raise HTTPException(status_code=401, detail="invalid or missing X-API-Key")


@app.middleware("http")
async def guard(request: Request, call_next):
    if request.url.path in ("/search", "/stream", "/answer", "/sources"):
        ip = request.client.host if request.client else "unknown"
        if not _rate_ok(ip):
            return JSONResponse(
                status_code=429,
                content={"error": "rate limit exceeded",
                         "limit_per_min": settings.rate_limit_per_min},
            )
    return await call_next(request)


# --------------------------------------------------------------------------
# pages + assets
# --------------------------------------------------------------------------
if STATIC_DIR.exists():
    app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")


@app.get("/", response_class=HTMLResponse, include_in_schema=False)
async def index() -> HTMLResponse:
    f = STATIC_DIR / "index.html"
    if f.exists():
        return HTMLResponse(f.read_text(encoding="utf-8"))
    return HTMLResponse("<h1>TeCoxBeta</h1><p>UI not found. "
                        "Docs at <a href='/docs'>/docs</a></p>")


@app.get("/docs", response_class=HTMLResponse, include_in_schema=False)
async def docs_page() -> HTMLResponse:
    f = STATIC_DIR / "docs.html"
    if f.exists():
        return HTMLResponse(f.read_text(encoding="utf-8"))
    return HTMLResponse("<h1>TeCoxBeta</h1><p>Docs not found. "
                        "API explorer at <a href='/api'>/api</a></p>")


@app.get("/favicon.ico", include_in_schema=False)
async def favicon():
    svg = STATIC_DIR / "logo.svg"
    if svg.exists():
        return FileResponse(str(svg), media_type="image/svg+xml")
    png = STATIC_DIR / "favicon.png"
    if png.exists():
        return FileResponse(str(png), media_type="image/png")
    return JSONResponse(status_code=204, content=None)


# --------------------------------------------------------------------------
# search
# --------------------------------------------------------------------------
def _sse(event: str, data: Any) -> str:
    return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"


def _sse_stream(req: SearchRequest) -> StreamingResponse:
    """Shared SSE responder used by /search (stream=true) and /stream."""
    async def gen():
        try:
            async for evt in engine.stream_search(req):
                yield _sse(evt["event"], evt["data"])
        except asyncio.CancelledError:
            raise
        except Exception as e:  # noqa: BLE001
            log.exception("stream failed")
            yield _sse("error", {"message": str(e)})
            yield _sse("done", {})

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


@app.post("/search", summary="Search — JSON or live stream")
async def post_search(
    req: SearchRequest,
    x_api_key: Optional[str] = Header(default=None, alias="X-API-Key"),
):
    """
    One endpoint, two modes.

    * `"stream": false` (default) — a single JSON response.
    * `"stream": true` — the same pipeline as Server-Sent Events, so you can
      show live progress and type the answer out as it is written.
    """
    _auth(x_api_key)
    if req.stream:
        req.cache = False      # streaming a cached answer defeats the point
        return _sse_stream(req)
    try:
        return await engine.search(req)
    except Exception as e:  # noqa: BLE001
        log.exception("search failed")
        raise HTTPException(status_code=500, detail=f"search failed: {e}") from e


@app.get("/search", summary="Search — JSON or live stream (query params)")
async def get_search(
    q: str = Query(..., min_length=1, description="Your question"),
    depth: str = Query("fast", description="instant | fast | deep | extreme | ultra"),
    safe: bool = Query(True, description="false = unrestricted sources + synthesis"),
    stream: bool = Query(False, description="true = Server-Sent Events"),
    model: str = Query("TeD", description="TeF | TeD | TeM"),
    site: Optional[str] = Query(None, description="Read only this site, e.g. genius.com"),
    max_results: int = Query(12, ge=1, le=60),
    answer: bool = Query(True),
    freshness: Optional[str] = Query(None, description="day | week | month | year"),
    language: str = Query("en"),
    include_domains: Optional[str] = Query(None, description="comma separated"),
    exclude_domains: Optional[str] = Query(None, description="comma separated"),
    providers: Optional[str] = Query(None, description="comma separated"),
    cache: bool = Query(True),
    x_api_key: Optional[str] = Header(default=None, alias="X-API-Key"),
):
    _auth(x_api_key)
    req = SearchRequest(
        query=q, depth=depth, safe=safe, stream=stream, model=model, site=site,
        max_results=max_results, answer=answer, freshness=freshness,
        language=language, cache=cache,
        include_domains=[d.strip() for d in (include_domains or "").split(",") if d.strip()],
        exclude_domains=[d.strip() for d in (exclude_domains or "").split(",") if d.strip()],
        providers=[p.strip() for p in (providers or "").split(",") if p.strip()],
    )
    if req.stream:
        req.cache = False
        return _sse_stream(req)
    return await engine.search(req)


@app.get("/stream", summary="Live stream (alias of /search?stream=true)")
async def get_stream(
    q: str = Query(..., min_length=1),
    depth: str = Query("fast"),
    safe: bool = Query(True, description="false = unrestricted sources + synthesis"),
    model: str = Query("TeD", description="TeF | TeD | TeM"),
    max_results: int = Query(12, ge=1, le=60),
    answer: bool = Query(True),
    freshness: Optional[str] = Query(None),
    language: str = Query("en"),
    x_api_key: Optional[str] = Header(default=None, alias="X-API-Key"),
) -> StreamingResponse:
    """Identical to `/search` with `stream=true`."""
    _auth(x_api_key)
    return _sse_stream(SearchRequest(
        query=q, depth=depth, safe=safe, model=model, max_results=max_results,
        answer=answer, freshness=freshness, language=language,
        stream=True, cache=False))


@app.get("/sources", summary="Retrieval only (no synthesis)")
async def get_sources(
    q: str = Query(..., min_length=1),
    depth: str = Query("fast"),
    safe: bool = Query(True),
    model: str = Query("TeD", description="TeF | TeD | TeM"),
    max_results: int = Query(15, ge=1, le=60),
    x_api_key: Optional[str] = Header(default=None, alias="X-API-Key"),
) -> Dict[str, Any]:
    _auth(x_api_key)
    res = await engine.search(SearchRequest(
        query=q, depth=depth, max_results=max_results, answer=False,
        safe=safe, model=model,
    ))
    return {
        "query": res.query, "count": len(res.sources),
        "sources": [s.model_dump() for s in res.sources],
        "provider_stats": res.provider_stats, "elapsed_ms": res.elapsed_ms,
    }


@app.post("/answer", summary="Answer only (compact payload)")
async def post_answer(
    payload: Dict[str, Any] = Body(...),
    x_api_key: Optional[str] = Header(default=None, alias="X-API-Key"),
) -> Dict[str, Any]:
    _auth(x_api_key)
    payload.pop("stream", None)          # this route is always JSON
    req = SearchRequest(**payload)
    res = await engine.search(req)
    return {
        "query": res.query,
        "answer": res.answer,
        "key_points": res.key_points,
        "citations": [{"id": s.id, "url": s.url, "title": s.title}
                      for s in res.sources],
        "tokens": res.tokens,
        "elapsed_ms": res.elapsed_ms,
    }


# --------------------------------------------------------------------------
# info / ops
# --------------------------------------------------------------------------
@app.get("/models", summary="Capability tiers")
async def get_models() -> Dict[str, Any]:
    """
    The three selectable tiers. Pass one as `model` in a search request.

    Raw backend model ids are intentionally not accepted: this pipeline pushes
    very large evidence contexts through synthesis, and an under-powered model
    would silently truncate or fail on them.
    """
    return {
        "default": catalog.DEFAULT_TIER,
        "tiers": [
            {"id": "TeF", "label": "Fast",
             "summary": "Fast and smart.",
             "best_for": "Quick answers, autocomplete, chat replies",
             "pairs_with": ["instant", "fast"]},
            {"id": "TeD", "label": "Deep",
             "summary": "Deep and smart.",
             "best_for": "Thorough research and long, structured synthesis",
             "pairs_with": ["fast", "deep", "extreme"]},
            {"id": "TeM", "label": "Max",
             "summary": "Maximum capability.",
             "best_for": "Largest context, fullest reasoning, report writing",
             "pairs_with": ["deep", "extreme", "ultra"]},
        ],
    }


@app.get("/usage", summary="Token accounting")
async def get_usage() -> Dict[str, Any]:
    """Process-lifetime token ledger."""
    await rewind.ready()
    rep = rewind.report()
    return {
        "tokens": rep["tokens"],
        "calls": rep["calls"],
        "failures": rep["failures"],
    }


@app.get("/health", summary="Diagnostics")
async def get_health() -> Dict[str, Any]:
    h = health()
    try:
        await rewind.ready()
        rep = rewind.report()
        h["synthesis"] = {
            "ready": True,
            "tiers": ["TeF", "TeD", "TeM"],
            "unrestricted_available": True,
            "calls": rep["calls"],
            "failures": rep["failures"],
        }
        h["tokens"] = rep["tokens"]
    except Exception as e:  # noqa: BLE001
        h["synthesis"] = {"ready": False, "error": str(e)[:120]}
    return h


@app.get("/providers", summary="Source engine availability")
async def get_providers() -> Dict[str, Any]:
    return provider_report()


@app.get("/depths", summary="Depth profiles")
async def get_depths() -> Dict[str, Any]:
    return {
        name: {
            "sub_queries": p.sub_queries,
            "results_per_provider": p.results_per_provider,
            "pages_to_read": p.pages_to_read,
            "max_chunks": p.max_chunks,
            "llm_rerank": p.llm_rerank,
            "planner": p.planner,
            "budget_seconds": p.budget_seconds,
        }
        for name, p in DEPTH_PROFILES.items()
    }


@app.post("/cache/clear", summary="Flush all caches")
async def clear_cache(
    x_api_key: Optional[str] = Header(default=None, alias="X-API-Key"),
) -> Dict[str, str]:
    _auth(x_api_key)
    serp_cache.clear()
    page_cache.clear()
    answer_cache.clear()
    return {"status": "cleared"}
