"""``LogShipper`` — batch + ship canonical log records to a collector.

A :class:`logging.Handler` that turns each emitted record into a canonical
mgf-common ``LogRecord`` dict, **redacts it** (the data is about to leave the
box — design §8 hard gate), and enqueues it. A background daemon thread drains
the queue, batches records, and ``POST``s them as NDJSON to the collector over
the :mod:`mgf.logship._protocol` wire.

Replaceability (DP-08): the swappable component is the COLLECTOR — point the
shipper at any endpoint speaking the :mod:`mgf.logship._protocol` NDJSON wire
(``SHIP_PROTOCOL_VERSION``), or write a new collector. The wire is versioned and
language-neutral precisely so the collector can be replaced without touching the
handler; the bundled :mod:`mgf.logship.collector` is a dev/test stub, not the only sink.

Three invariants, in priority order:

1. **Fail-open** — a slow, unreachable, or erroring collector must NEVER block
   or crash the app. ``emit`` only enqueues (O(1), never raises); the thread
   swallows transport errors and drops the batch.
2. **Bounded** — the queue is capped (``max_queue``); on overflow the OLDEST
   record is dropped and :attr:`dropped_count` increments (recent logs are the
   ones you want during an incident).
3. **Redacted before ship** — every record passes through a
   :class:`~mgf.common.observability.Redactor` before it enters the queue.

Optionally ships periodic :class:`~mgf.common.observability.LogStats` snapshots
(pass ``stats_provider=current_log_stats``).
"""

from __future__ import annotations

import json
import logging
import threading
import time
import urllib.error
import urllib.request
import uuid
from collections import deque
from typing import TYPE_CHECKING

from mgf.common.observability import LogRecord, Redactor

from mgf.logship._protocol import (
    HEADER_APP,
    HEADER_BATCH,
    HEADER_PROTOCOL,
    INGEST_PATH,
    JSON_CONTENT_TYPE,
    NDJSON_CONTENT_TYPE,
    SHIP_PROTOCOL_VERSION,
    STATS_PATH,
    encode_ndjson,
)

if TYPE_CHECKING:
    from collections.abc import Callable

    from mgf.common.observability import LogStats


class LogShipper(logging.Handler):
    """Ship canonical log records to a collector. Fail-open + bounded."""

    def __init__(
        self,
        endpoint: str,
        *,
        token: str | None = None,
        app: str | None = None,
        env: str | None = None,
        batch_max: int = 100,
        flush_interval: float = 2.0,
        max_queue: int = 10_000,
        timeout: float = 5.0,
        redactor: Redactor | None = None,
        stats_provider: Callable[[], LogStats | None] | None = None,
        stats_interval: float = 30.0,
        level: int = logging.NOTSET,
    ) -> None:
        super().__init__(level=level)
        self._base = endpoint.rstrip("/")
        self._token = token
        self._app = app
        self._env = env
        self._batch_max = max(1, batch_max)
        self._flush_interval = flush_interval
        self._timeout = timeout
        self._redactor = redactor or Redactor.default()
        self._stats_provider = stats_provider
        self._stats_interval = stats_interval

        self._q: deque[dict[str, object]] = deque(maxlen=max_queue)
        self._lock = threading.Lock()
        self._dropped = 0
        self._shipped = 0
        self._failed = 0
        self._stop = threading.Event()
        self._thread: threading.Thread | None = None
        self._started = False
        self._last_stats = 0.0

    # ------------------------------------------------------------------ #
    # logging.Handler API
    # ------------------------------------------------------------------ #
    def emit(self, record: logging.LogRecord) -> None:
        # Never raise into the caller, never block.
        try:
            wire = self._to_wire(record)
        except Exception:  # fail-open: serialization must not crash logging
            self.handleError(record)
            return
        with self._lock:
            if len(self._q) == self._q.maxlen:
                self._dropped += 1  # deque will evict the oldest on append
            self._q.append(wire)
        self._ensure_running()

    def close(self) -> None:
        """Flush remaining records + stop the worker (idempotent)."""
        self._stop.set()
        thread = self._thread
        if thread is not None and thread.is_alive():
            thread.join(timeout=self._timeout + self._flush_interval)
        self._drain_and_post()  # final synchronous flush
        super().close()

    # ------------------------------------------------------------------ #
    # counters
    # ------------------------------------------------------------------ #
    @property
    def dropped_count(self) -> int:
        return self._dropped

    @property
    def shipped_count(self) -> int:
        return self._shipped

    @property
    def failed_count(self) -> int:
        return self._failed

    # ------------------------------------------------------------------ #
    # internals
    # ------------------------------------------------------------------ #
    def _to_wire(self, record: logging.LogRecord) -> dict[str, object]:
        from mgf.common import app_name, app_version  # lazy — avoid import cycle

        wire = LogRecord.from_python(
            record,
            app=self._app or app_name(),
            app_version=app_version(),
            env=self._env,
        ).to_dict()
        self._redactor.redact(wire)  # mandatory: leaves the process
        return wire

    def _ensure_running(self) -> None:
        if self._started:
            return
        with self._lock:
            if self._started:
                return
            self._thread = threading.Thread(target=self._run, name="mgf-logship", daemon=True)
            self._started = True
        self._thread.start()

    def _run(self) -> None:
        while not self._stop.wait(self._flush_interval):
            self._drain_and_post()
            self._maybe_ship_stats()

    def _drain_and_post(self) -> None:
        while True:
            with self._lock:
                if not self._q:
                    return
                batch = [self._q.popleft() for _ in range(min(self._batch_max, len(self._q)))]
            self._post_ndjson(batch)

    def _post_ndjson(self, batch: list[dict[str, object]]) -> None:
        if not batch:
            return
        headers = {
            "Content-Type": NDJSON_CONTENT_TYPE,
            HEADER_PROTOCOL: SHIP_PROTOCOL_VERSION,
            HEADER_BATCH: uuid.uuid4().hex,
        }
        if self._app:
            headers[HEADER_APP] = self._app
        self._post(self._base + INGEST_PATH, encode_ndjson(batch), headers, len(batch))

    def _maybe_ship_stats(self) -> None:
        if self._stats_provider is None:
            return
        now = time.monotonic()
        if now - self._last_stats < self._stats_interval:
            return
        self._last_stats = now
        try:
            stats = self._stats_provider()
        except Exception:  # provider must not break shipping
            return
        if stats is None:
            return
        body = json.dumps(stats.to_dict(), separators=(",", ":"), default=str).encode()
        headers = {"Content-Type": JSON_CONTENT_TYPE, HEADER_PROTOCOL: SHIP_PROTOCOL_VERSION}
        self._post(self._base + STATS_PATH, body, headers, 0)

    def _post(self, url: str, body: bytes, headers: dict[str, str], n_records: int) -> None:
        if self._token:
            headers["Authorization"] = f"Bearer {self._token}"
        req = urllib.request.Request(  # noqa: S310 — operator-supplied collector URL (trusted config)
            url, data=body, headers=headers, method="POST"
        )
        try:
            with urllib.request.urlopen(req, timeout=self._timeout):  # noqa: S310
                pass
            self._shipped += n_records
        except (urllib.error.URLError, OSError, ValueError):
            # Fail-open: a down/slow collector drops this batch, never crashes
            # the app. The batch is gone (at-most-once); recent logs matter most.
            self._failed += 1


__all__ = ["LogShipper"]
