"""Pluggable storage / forwarding backends for the collector gateway (L1).

The collector speaks the wire protocol (NDJSON ``LogRecord``s + ``LogStats``) on
the inbound side — that contract never changes — and hands what it receives to a
:class:`Backend`. The backend is the *only* place that knows where logs actually
go, so the storage/forwarding choice is swappable without touching any shipper.

* :class:`SqliteBackend` — the store-now stub (also the durable spool).
* :class:`OpenObserveBackend` — forwards records to an OpenObserve instance.
* :class:`SpoolingBackend` — composes them: forward to a primary; on failure,
  spool to a local sqlite (no data loss, fail-open) and :meth:`flush` later.

Keeping the protocol + the spool means a transient OpenObserve outage never
drops a record and never propagates an error back to the shipping app.
"""

from __future__ import annotations

import base64
import json
import sqlite3
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Protocol, runtime_checkable

if TYPE_CHECKING:
    from collections.abc import Callable

__all__ = [
    "Backend",
    "BackendError",
    "OpenObserveBackend",
    "SpoolingBackend",
    "SqliteBackend",
]


class BackendError(Exception):
    """A backend could not durably accept records/stats (caller may spool/retry)."""


@runtime_checkable
class Backend(Protocol):
    """What the collector needs from any storage/forwarding target."""

    def store_records(self, records: list[dict[str, object]]) -> None: ...
    def store_stats(self, payload: dict[str, object]) -> None: ...


def _now() -> str:
    return datetime.now(UTC).isoformat()


# --------------------------------------------------------------------------- #
# sqlite — store-now stub + the durable spool
# --------------------------------------------------------------------------- #
_COUNT_SQL = {"logs": "SELECT COUNT(*) FROM logs", "stats": "SELECT COUNT(*) FROM stats"}


class SqliteBackend:
    """Stdlib-sqlite store. Doubles as the spool for :class:`SpoolingBackend`."""

    def __init__(self, db_path: str) -> None:
        self.db_path = db_path
        conn = sqlite3.connect(self.db_path)
        try:
            conn.execute(
                "CREATE TABLE IF NOT EXISTS logs ("
                "id INTEGER PRIMARY KEY AUTOINCREMENT, received_at TEXT, app TEXT, "
                "ts TEXT, level TEXT, logger TEXT, msg TEXT, raw TEXT)"
            )
            conn.execute(
                "CREATE TABLE IF NOT EXISTS stats ("
                "id INTEGER PRIMARY KEY AUTOINCREMENT, received_at TEXT, app TEXT, raw TEXT)"
            )
            conn.commit()
        finally:
            conn.close()

    def store_records(self, records: list[dict[str, object]]) -> None:
        now = _now()
        conn = sqlite3.connect(self.db_path)
        try:
            conn.executemany(
                "INSERT INTO logs (received_at, app, ts, level, logger, msg, raw) "
                "VALUES (?,?,?,?,?,?,?)",
                [
                    (
                        now,
                        str(r.get("app", "")),
                        str(r.get("ts", "")),
                        str(r.get("level", "")),
                        str(r.get("logger", "")),
                        str(r.get("msg", "")),
                        json.dumps(r, separators=(",", ":"), ensure_ascii=False),
                    )
                    for r in records
                ],
            )
            conn.commit()
        finally:
            conn.close()

    def store_stats(self, payload: dict[str, object]) -> None:
        conn = sqlite3.connect(self.db_path)
        try:
            conn.execute(
                "INSERT INTO stats (received_at, app, raw) VALUES (?,?,?)",
                (
                    _now(),
                    str(payload.get("app", "")),
                    json.dumps(payload, separators=(",", ":"), ensure_ascii=False),
                ),
            )
            conn.commit()
        finally:
            conn.close()

    def count(self, table: str) -> int:
        if table not in _COUNT_SQL:
            raise ValueError(table)
        conn = sqlite3.connect(self.db_path)
        try:
            (n,) = conn.execute(_COUNT_SQL[table]).fetchone()
        finally:
            conn.close()
        return int(n)

    # ---- spool support (used when this instance is a SpoolingBackend spool) --
    def fetch_records(self, limit: int = 1000) -> list[tuple[int, dict[str, object]]]:
        conn = sqlite3.connect(self.db_path)
        try:
            rows = conn.execute(
                "SELECT id, raw FROM logs ORDER BY id LIMIT ?", (limit,)
            ).fetchall()
        finally:
            conn.close()
        return [(int(rid), json.loads(raw)) for rid, raw in rows]

    def delete_records(self, ids: list[int]) -> None:
        if not ids:
            return
        conn = sqlite3.connect(self.db_path)
        try:
            conn.executemany("DELETE FROM logs WHERE id = ?", [(i,) for i in ids])
            conn.commit()
        finally:
            conn.close()


# --------------------------------------------------------------------------- #
# OpenObserve forwarder
# --------------------------------------------------------------------------- #
# A poster is `(url, json_payload, headers, timeout) -> http_status`, injected so
# the backend is testable without httpx / a live server; the default uses httpx.
def _httpx_poster(url: str, payload: object, headers: dict[str, str], timeout: float) -> int:
    import httpx  # lazy: only the [collector] extra needs it

    return httpx.post(url, json=payload, headers=headers, timeout=timeout).status_code


def _auth_headers(
    token: str | None, user: str | None, password: str | None
) -> dict[str, str]:
    headers = {"Content-Type": "application/json"}
    if token:
        headers["Authorization"] = f"Bearer {token}"
    elif user and password:
        creds = base64.b64encode(f"{user}:{password}".encode()).decode("ascii")
        headers["Authorization"] = f"Basic {creds}"
    return headers


class OpenObserveBackend:
    """Forward records/stats to OpenObserve's ``/_json`` ingest API."""

    def __init__(
        self,
        base_url: str,
        org: str,
        log_stream: str,
        stats_stream: str,
        *,
        token: str | None = None,
        user: str | None = None,
        password: str | None = None,
        timeout: float = 10.0,
        retries: int = 1,
        poster: Callable[[str, object, dict[str, str], float], int] | None = None,
    ) -> None:
        self.base_url = base_url.rstrip("/")
        self.org = org
        self.log_stream = log_stream
        self.stats_stream = stats_stream
        self._headers = _auth_headers(token, user, password)
        self._timeout = timeout
        self._retries = retries
        self._post = poster or _httpx_poster

    def _ingest(self, stream: str, payload: list[dict[str, object]]) -> None:
        url = f"{self.base_url}/api/{self.org}/{stream}/_json"
        last: object = "no attempt"
        for _ in range(self._retries + 1):
            try:
                status = self._post(url, payload, self._headers, self._timeout)
            except Exception as exc:  # network/transport error → retry, then BackendError
                last = repr(exc)
                continue
            if 200 <= status < 300:
                return
            last = status
        raise BackendError(f"OpenObserve POST {url} failed (last={last})")

    def store_records(self, records: list[dict[str, object]]) -> None:
        if records:
            self._ingest(self.log_stream, records)

    def store_stats(self, payload: dict[str, object]) -> None:
        self._ingest(self.stats_stream, [payload])


# --------------------------------------------------------------------------- #
# spooling composition — forward, fall back to spool, flush on recovery
# --------------------------------------------------------------------------- #
class SpoolingBackend:
    """Forward to ``primary``; on :class:`BackendError`, spool to ``spool`` (durable).

    Never raises on store (fail-open — a shipping app must not break on a log
    sink outage). :meth:`flush` drains the spool back into the primary once it
    recovers; records that still can't be forwarded stay spooled.
    """

    def __init__(self, primary: Backend, spool: SqliteBackend) -> None:
        self.primary = primary
        self.spool = spool

    def store_records(self, records: list[dict[str, object]]) -> None:
        try:
            self.primary.store_records(records)
        except BackendError:
            self.spool.store_records(records)

    def store_stats(self, payload: dict[str, object]) -> None:
        try:
            self.primary.store_stats(payload)
        except BackendError:
            self.spool.store_stats(payload)

    def flush(self) -> int:
        """Drain spooled records into the primary. Returns how many forwarded."""
        items = self.spool.fetch_records()
        if not items:
            return 0
        try:
            self.primary.store_records([record for _, record in items])
        except BackendError:
            return 0  # primary still down — leave them spooled
        self.spool.delete_records([rid for rid, _ in items])
        return len(items)
