"""VoxCast Server — FastAPI app factory.

A headless transcription server that speaks the OpenAI wire protocol around
VoxCast's own faster-whisper engine. Runs under real uvicorn asyncio (no Qt),
so the shared engine's async paths work unchanged.

Deploy behind nginx for TLS on a public FQDN (hosted offering); on a LAN it
serves plain HTTP with a client-side "unencrypted" warning.
"""
from __future__ import annotations

import contextlib
import time
import uuid
from pathlib import Path

from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from loguru import logger

from .auth import BearerAuth, ensure_api_key
from .config import ServerConfig, config_file_path
from .discovery import DiscoveryResponder
from .engine import EngineHost
from .jobs import JobRunner, MemoryJobStore
from .routes import (
    enhance as enhance_routes,
    health,
    jobs as jobs_routes,
    models,
    realtime,
    transcriptions,
)


async def _check_update(engine: EngineHost) -> None:
    """Best-effort: ask getvoxcast whether a newer server build exists and
    expose it in /v1/status. Never self-updates; never raises."""
    import httpx

    from version import SERVER_VERSION
    url = "https://getvoxcast.com/api/v1/update/latest"
    try:
        loop = __import__("asyncio").get_running_loop()

        def _get() -> dict | None:
            try:
                with httpx.Client(timeout=httpx.Timeout(10.0, connect=5.0)) as c:
                    r = c.get(url, params={"channel": "stable", "variant": "server"})
                if r.status_code == 200:
                    return r.json()
            except Exception:  # noqa: BLE001
                return None
            return None

        data = await loop.run_in_executor(None, _get)
        if data and data.get("available"):
            remote = str(data.get("version") or "")
            if remote and remote != SERVER_VERSION:
                engine.update_available = remote
                logger.info("Server update available: {} (running {})",
                            remote, SERVER_VERSION)
    except Exception as e:  # noqa: BLE001
        logger.debug("Server update check skipped: {}", e)


def _lan_urls(config: ServerConfig) -> list[str]:
    """Concrete URLs clients can use. For a wildcard bind list every local
    IPv4 so the operator can read the address straight off the console."""
    import socket
    scheme = "https" if config.tls_enabled else "http"
    if config.host not in ("0.0.0.0", "", "::"):
        return [f"{scheme}://{config.host}:{config.port}"]
    ips: list[str] = []
    try:
        for info in socket.getaddrinfo(socket.gethostname(), None,
                                       socket.AF_INET):
            ip = info[4][0]
            if not ip.startswith("127.") and ip not in ips:
                ips.append(ip)
    except OSError:
        pass
    return [f"{scheme}://{ip}:{config.port}" for ip in ips] or \
        [f"{scheme}://<this-machine>:{config.port}"]


def _print_key_banner(key: str, config: ServerConfig) -> None:
    import os
    import sys as _sys

    bar = "=" * 60
    urls = "\n".join(f"   {u}" for u in _lan_urls(config))
    # As a Windows service, stderr is captured into WinSW log FILES in
    # Program Files (world-readable) that also survive uninstall — never put
    # the key there; point at server.toml instead. Interactive console runs
    # (isatty) and Docker/systemd (root-only logs, and the log is the
    # documented way to read the key there) keep printing it.
    if os.name == "nt" and not _sys.stderr.isatty():
        key = "(see server.toml next to VoxCastServer.exe, or the Start menu entry \"VoxCast Server API key\")"
    logger.info("\n{bar}\n VoxCast Server is running. Reachable at:\n{urls}\n"
                "\n API key (paste into VoxCast Settings > Remote server,\n"
                " or use \"Find server\" in the client):\n   {key}\n{bar}",
                bar=bar, urls=urls, key=key)


def create_app(config: ServerConfig | None = None,
               engine: EngineHost | None = None) -> FastAPI:
    """Build the app. `engine` may be injected by tests (FakeEngine)."""
    config = config or ServerConfig()
    api_key = ensure_api_key(config, config_file_path())
    # ensure_api_key may have generated a fresh key; write it back onto config
    # so paths that read config.api_key directly (WS auth) see the real key on
    # the first run instead of the empty default.
    config.api_key = api_key
    # A key always exists (auto-generated + persisted on first run), so the
    # server is never unauthenticated. Warn loudly on a LAN/public bind so the
    # operator knows audio endpoints are reachable off-box.
    if config.host not in ("127.0.0.1", "localhost", "::1"):
        logger.warning("Binding non-loopback host {} — the server is reachable "
                       "on the network. Access requires the API key.", config.host)

    engine = engine or EngineHost(config)
    store = MemoryJobStore()
    runner = JobRunner(engine, store, config.job_ttl_s)
    auth = BearerAuth(api_key)

    @contextlib.asynccontextmanager
    async def lifespan(app: FastAPI):
        responder: DiscoveryResponder | None = None
        if not getattr(engine, "_test_prestarted", False):
            _print_key_banner(api_key, config)
            await engine.start()
            _check_update_task = __import__("asyncio").create_task(_check_update(engine))
            app.state._update_task = _check_update_task  # strong ref
            # LAN discovery: only when reachable off-box — a loopback-bound
            # server must not advertise an endpoint clients cannot connect to.
            if config.discovery_enabled and \
                    config.host not in ("127.0.0.1", "localhost", "::1"):
                from version import SERVER_VERSION
                scheme = "https" if config.tls_enabled else "http"
                responder = DiscoveryResponder(config.port, SERVER_VERSION,
                                               http_host=config.host,
                                               scheme=scheme)
                if not responder.start():
                    responder = None
        runner.start()
        try:
            yield
        finally:
            if responder is not None:
                responder.stop()
            await runner.stop()
            with contextlib.suppress(Exception):
                await engine.stop()

    app = FastAPI(
        title="VoxCast Server",
        docs_url=None,
        redoc_url=None,
        lifespan=lifespan,
    )
    app.state.config = config
    app.state.engine = engine
    app.state.auth = auth
    app.state.store = store
    app.state.runner = runner

    @app.middleware("http")
    async def _headers_and_request_id(request: Request, call_next):
        request_id = request.headers.get("x-request-id") or uuid.uuid4().hex
        # Exceptions are handled by the exception_handler below (Starlette's
        # error middleware sits outside this one), so no try/except-log here —
        # that would double-log every 500.
        resp = await call_next(request)
        h = resp.headers
        h.setdefault("X-Content-Type-Options", "nosniff")
        h.setdefault("Referrer-Policy", "no-referrer")
        h.setdefault("X-Frame-Options", "DENY")
        h.setdefault("Cache-Control", "no-store")
        h["x-request-id"] = request_id
        return resp

    @app.exception_handler(Exception)
    async def _unhandled(request: Request, exc: Exception):
        # Never leak internals to callers.
        logger.exception("Unhandled error on {} {}", request.method, request.url.path)
        return JSONResponse(
            {"error": {"message": "Internal server error", "type": "server_error"}},
            status_code=500,
        )

    app.include_router(health.router)
    app.include_router(models.router)
    app.include_router(transcriptions.router)
    app.include_router(jobs_routes.router)
    app.include_router(realtime.router)
    app.include_router(enhance_routes.router)
    return app
