"""The collector gateway (unified-logging design §6.3, decisions D4 + L1).

Inbound, this is the unchanging wire protocol: accept shipped ``LogRecord``
NDJSON (``/ingest``) + ``LogStats`` (``/stats``), token-gated. Outbound, it hands
them to a pluggable :class:`~mgf.logship._backend.Backend` — so the *destination*
(store-now sqlite stub, or **forward to OpenObserve**) is a config choice that no
shipper ever sees. The OpenObserve path spools to a local sqlite on outage
(fail-open, no data loss) and drains it via ``flush`` once the backend recovers.

Requires the ``[collector]`` extra (``pip install mgf-logship[collector]``)::

    from mgf.logship.collector import create_collector_app
    app = create_collector_app()                       # from $MGF_LOGSHIP_* env
    # uvicorn app:app
"""

from __future__ import annotations

import asyncio
import contextlib
import hmac
import json
from typing import TYPE_CHECKING, Any

from fastapi import FastAPI, HTTPException, Request, Response

from mgf.logship._backend import (
    Backend,
    BackendError,
    OpenObserveBackend,
    SpoolingBackend,
    SqliteBackend,
)
from mgf.logship._config import CollectorConfig
from mgf.logship._protocol import HEALTH_PATH, INGEST_PATH, STATS_PATH, decode_ndjson

if TYPE_CHECKING:
    from collections.abc import AsyncIterator

__all__ = [
    "Backend",
    "BackendError",
    "CollectorConfig",
    "OpenObserveBackend",
    "SpoolingBackend",
    "SqliteBackend",
    "build_backend",
    "create_collector_app",
]


# DoS cap (SC): the collector accepts NDJSON batches from shippers; an unbounded
# `await request.body()` lets one request exhaust the central host's memory.
# 64 MiB is far above any legitimate batch yet bounds the blast radius.
_MAX_BODY_BYTES = 64 * 1024 * 1024


async def _read_capped(request: Request, limit: int | None = None) -> bytes:
    """Read the request body with a hard size cap (413 on exceed). Fast-rejects
    on a too-large Content-Length, then bounds the actual stream (covers chunked
    / spoofed-header uploads too)."""
    if limit is None:
        limit = _MAX_BODY_BYTES
    declared = request.headers.get("content-length")
    if declared is not None and declared.isdigit() and int(declared) > limit:
        raise HTTPException(status_code=413, detail="payload too large")
    chunks: list[bytes] = []
    total = 0
    async for chunk in request.stream():
        total += len(chunk)
        if total > limit:
            raise HTTPException(status_code=413, detail="payload too large")
        chunks.append(chunk)
    return b"".join(chunks)


def build_backend(config: CollectorConfig) -> Backend:
    """Construct the backend the config selects.

    ``sqlite`` → store-now. ``openobserve`` → forward to OpenObserve, with the
    local sqlite (``db_path``) as the durable spool/fallback.
    """
    if config.backend == "openobserve":
        primary = OpenObserveBackend(
            config.openobserve_url,
            config.openobserve_org,
            config.log_stream,
            config.stats_stream,
            token=config.openobserve_token,
            user=config.openobserve_user,
            password=config.openobserve_password,
        )
        return SpoolingBackend(primary, SqliteBackend(config.db_path))
    if config.backend == "sqlite":
        return SqliteBackend(config.db_path)
    raise ValueError(f"unknown backend {config.backend!r}")


def create_collector_app(
    *,
    db_path: str | None = None,
    token: str | None = None,
    config: CollectorConfig | None = None,
    backend: Backend | None = None,
) -> FastAPI:
    """Build the collector FastAPI app.

    Back-compatible: ``create_collector_app(db_path=…, token=…)`` still builds the
    sqlite store-now stub. Otherwise ``config`` (default
    :meth:`CollectorConfig.from_env`) selects the backend; pass ``backend``
    directly to inject one (tests).
    """
    if backend is None:
        if config is None:
            if db_path is not None or token is not None:
                # back-compat stub: explicit db_path/token → sqlite store-now
                config = CollectorConfig(
                    backend="sqlite", db_path=db_path or "logship.db", token=token
                )
            else:
                # no args → resolve from the $MGF_LOGSHIP_* environment, so the
                # forwarder is selectable via `uvicorn …:create_collector_app
                # --factory` (matches this function's docstring).
                config = CollectorConfig.from_env()
        backend = build_backend(config)
    elif config is None:
        config = CollectorConfig(token=token)

    resolved: Backend = backend
    cfg: CollectorConfig = config
    auth_token = cfg.token
    count_db: SqliteBackend | None = (
        resolved
        if isinstance(resolved, SqliteBackend)
        else resolved.spool
        if isinstance(resolved, SpoolingBackend)
        else None
    )

    @contextlib.asynccontextmanager
    async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
        task: asyncio.Task[None] | None = None
        if isinstance(resolved, SpoolingBackend) and cfg.flush_interval_s > 0:
            task = asyncio.create_task(_flush_loop(resolved, cfg.flush_interval_s))
        try:
            yield
        finally:
            if task is not None:
                task.cancel()
                with contextlib.suppress(asyncio.CancelledError):
                    await task

    app = FastAPI(title="mgf-logship collector", lifespan=lifespan)

    def _check_auth(request: Request) -> None:
        if auth_token is None:
            return
        header = request.headers.get("authorization", "")
        # Constant-time compare (F-LS-001) — a plain `!=` on the token is timing-attackable.
        if not hmac.compare_digest(header, f"Bearer {auth_token}"):
            raise HTTPException(status_code=401, detail="invalid or missing token")

    @app.get(HEALTH_PATH)
    async def health() -> dict[str, str]:
        return {"status": "ok", "backend": cfg.backend}

    @app.post(INGEST_PATH)
    async def ingest(request: Request) -> Response:
        _check_auth(request)
        resolved.store_records(decode_ndjson(await _read_capped(request)))
        return Response(status_code=204)

    @app.post(STATS_PATH)
    async def stats(request: Request) -> Response:
        _check_auth(request)
        payload: dict[str, Any] = json.loads(await _read_capped(request) or b"{}")
        resolved.store_stats(payload)
        return Response(status_code=204)

    @app.post("/admin/flush")
    async def admin_flush(request: Request) -> dict[str, int]:
        _check_auth(request)
        flushed = resolved.flush() if isinstance(resolved, SpoolingBackend) else 0
        return {"flushed": flushed}

    @app.get("/logs/count")
    async def logs_count() -> dict[str, int]:
        return {"count": count_db.count("logs") if count_db else 0}

    @app.get("/stats/count")
    async def stats_count() -> dict[str, int]:
        return {"count": count_db.count("stats") if count_db else 0}

    return app


async def _flush_loop(backend: SpoolingBackend, interval_s: float) -> None:  # pragma: no cover
    """Periodically drain the spool into the primary (best-effort, never raises)."""
    while True:
        await asyncio.sleep(interval_s)
        with contextlib.suppress(Exception):
            backend.flush()
