"""Bounded event buffer with background flushing.

Accumulates normalized events and flushes them to the Sluice API via
:class:`SluiceTransport` when either:

- The batch reaches ``max_batch_size`` events (default: 50), or
- ``flush_interval_s`` seconds have elapsed since the last flush (default: 5).

The buffer runs a single daemon thread that handles periodic flushing.
All public methods are thread-safe. The buffer is bounded — if it fills
up, the oldest events are dropped (never block the caller).

**Prefork safety**: Celery's prefork pool forks child processes to execute
tasks. Threads don't survive fork(), so the flush thread only exists in the
parent. Signal handlers (task_prerun, task_postrun, task_failure) fire in
child processes. The buffer detects this via PID comparison and sends events
directly from child processes instead of buffering.
"""

from __future__ import annotations

import logging
import os
import threading
from collections import deque
from typing import TYPE_CHECKING, Any

if TYPE_CHECKING:
    from sluice._safety import MemoryGuard
    from sluice._transport import SluiceTransport

logger = logging.getLogger("sluice")

_DEFAULT_BATCH_SIZE = 50
_DEFAULT_FLUSH_INTERVAL_S = 5.0
_MAX_BUFFER_SIZE = 10_000  # Hard cap to prevent unbounded memory growth.


class EventBuffer:
    """Thread-safe bounded event buffer with periodic background flushing."""

    def __init__(
        self,
        transport: SluiceTransport,
        *,
        max_batch_size: int = _DEFAULT_BATCH_SIZE,
        flush_interval_s: float = _DEFAULT_FLUSH_INTERVAL_S,
        max_buffer_size: int = _MAX_BUFFER_SIZE,
        memory_guard: MemoryGuard | None = None,
    ) -> None:
        self._transport = transport
        self._max_batch_size = max_batch_size
        self._flush_interval_s = flush_interval_s
        self._max_buffer_size = max_buffer_size
        self._memory_guard = memory_guard
        self._dropped_count = 0
        self._drop_log_interval = 1000  # Log every N drops.

        self._buffer: deque[dict[str, Any]] = deque(maxlen=max_buffer_size)
        self._lock = threading.Lock()
        # Wakes the flush thread (batch-size trigger or periodic timeout).
        self._wake = threading.Event()
        # Signals the flush thread and transport retries to terminate.
        self._stopping = threading.Event()
        self._flush_thread: threading.Thread | None = None
        # Track PID to detect forked child processes. Threads don't survive
        # fork(), so child processes must send events synchronously.
        self._owner_pid: int = os.getpid()
        # Lightweight child transport: lazily created per-PID in forked
        # children. Uses max_retries=1 and short timeouts to cap worst-case
        # blocking at ~3s instead of ~150s with the parent transport.
        self._child_transport: SluiceTransport | None = None
        self._child_transport_pid: int | None = None

    def start(self) -> None:
        """Start the background flush thread."""
        if self._flush_thread is not None and self._flush_thread.is_alive():
            return

        self._stopping.clear()
        self._wake.clear()
        self._flush_thread = threading.Thread(
            target=self._flush_loop,
            name="sluice-flush",
            daemon=True,
        )
        self._flush_thread.start()
        logger.debug("[sluice] Event buffer started.")

    def push(self, event: dict[str, Any]) -> None:
        """Add an event to the buffer. Non-blocking.

        If the memory guard has disabled capture, the event is silently
        dropped. If the buffer is full, the oldest event is silently
        dropped (deque maxlen handles this automatically).

        In forked child processes (Celery prefork pool), events are sent
        directly via the transport since the flush thread doesn't survive fork().
        """
        if self._memory_guard is not None and self._memory_guard.is_disabled:
            return

        # Prefork safety: if we're in a forked child, the flush thread is dead.
        # Send the event directly via a lightweight child transport with
        # max_retries=1 and short timeouts. This caps worst-case blocking
        # at ~3s instead of ~150s with the parent transport (5 retries,
        # 30s max backoff each). Events may be lost if the API is down,
        # but that's acceptable — the alternative is blocking customer
        # task execution in the Celery worker.
        if os.getpid() != self._owner_pid:
            self._flush_from_child([event])
            return

        with self._lock:
            was_full = len(self._buffer) == self._max_buffer_size
            self._buffer.append(event)
            if was_full:
                self._dropped_count += 1
                count = self._dropped_count
            else:
                count = 0
            size = len(self._buffer)

        # Log on first drop and then every N drops.
        if count == 1 or (count > 0 and count % self._drop_log_interval == 0):
            logger.warning(
                "[sluice] Buffer overflow: %d events dropped. "
                "See: https://docs.sluice.sh/troubleshooting/"
                "buffer-overflow",
                count,
            )

        # If we've hit the batch size, wake the flush thread.
        if size >= self._max_batch_size:
            self._wake.set()

    def flush(self) -> int:
        """Flush all buffered events immediately. Returns count of events sent.

        Thread-safe. Can be called from any thread.
        Respects max_batch_size — large buffers are sent in multiple batches.
        """
        total = 0
        while True:
            with self._lock:
                if not self._buffer:
                    return total
                batch: list[dict[str, Any]] = []
                for _ in range(min(self._max_batch_size, len(self._buffer))):
                    batch.append(self._buffer.popleft())
            total += self._send_batch(batch)

    def stop(self) -> None:
        """Stop the background thread and flush remaining events."""
        self._stopping.set()
        self._wake.set()

        if self._flush_thread is not None and self._flush_thread.is_alive():
            self._flush_thread.join(timeout=5.0)
            self._flush_thread = None

        # Final flush of any remaining events.
        self.flush()
        logger.debug("[sluice] Event buffer stopped.")

    @property
    def pending_count(self) -> int:
        """Number of events waiting in the buffer."""
        return len(self._buffer)

    @property
    def dropped_count(self) -> int:
        """Number of events dropped due to buffer overflow."""
        return self._dropped_count

    def _flush_loop(self) -> None:
        """Background loop that flushes events periodically."""
        while not self._stopping.is_set():
            self._wake.wait(timeout=self._flush_interval_s)
            self._wake.clear()
            self._do_flush()

    def _do_flush(self) -> None:
        """Drain the buffer in batches and send."""
        while True:
            with self._lock:
                if not self._buffer:
                    return
                batch: list[dict[str, Any]] = []
                for _ in range(min(self._max_batch_size, len(self._buffer))):
                    batch.append(self._buffer.popleft())

            self._send_batch(batch)

    def _get_child_transport(self) -> SluiceTransport:
        """Return a lightweight transport for the current forked child process.

        Lazily created on first use and cached per-PID. Uses max_retries=1
        and short timeouts (1s connect, 1s read) so worst-case blocking is
        ~3s per event instead of ~150s with the parent transport.
        """
        from sluice._transport import SluiceTransport

        pid = os.getpid()
        if self._child_transport is not None and self._child_transport_pid == pid:
            return self._child_transport

        # Build a child transport that mirrors the parent's config but with
        # aggressive timeouts and minimal retries.
        parent = self._transport
        self._child_transport = SluiceTransport(
            parent._api_key,
            base_url=parent._ingest_url.rsplit("/api/ingest", 1)[0],
            connection_id=parent._connection_id,
            max_retries=1,
            timeout_connect=1.0,
            timeout_read=1.0,
        )
        self._child_transport_pid = pid
        return self._child_transport

    def _flush_from_child(self, batch: list[dict[str, Any]]) -> int:
        """Send a batch from a forked child using the lightweight transport."""
        try:
            child_transport = self._get_child_transport()
            ok = child_transport.send_batch(batch)
            if ok:
                logger.debug(
                    "[sluice] Child process sent batch of %d events.",
                    len(batch),
                )
            else:
                logger.warning(
                    "[sluice] Child process failed to send batch of %d events.",
                    len(batch),
                )
            return len(batch) if ok else 0
        except Exception:
            logger.exception(
                "[sluice] Unexpected error sending events from child process. "
                "See: https://docs.sluice.sh/troubleshooting/ingest"
            )
            return 0

    def _send_batch(self, batch: list[dict[str, Any]]) -> int:
        """Send a batch via transport. Never raises."""
        try:
            ok = self._transport.send_batch(batch)
            if ok:
                logger.debug(
                    "[sluice] Sent batch of %d events.",
                    len(batch),
                )
            else:
                logger.warning(
                    "[sluice] Failed to send batch of %d events.",
                    len(batch),
                )
            return len(batch) if ok else 0
        except Exception:
            logger.exception(
                "[sluice] Unexpected error sending event batch. "
                "See: https://docs.sluice.sh/troubleshooting/ingest"
            )
            return 0
