"""Async batch transport — daemon thread flushes events to the ingestion API."""

from __future__ import annotations

import atexit
import gzip
import json
import logging
import queue
import threading
import time
import uuid
from concurrent.futures import ThreadPoolExecutor
from typing import Any

import requests

from llm_observatory.config import get_config

logger = logging.getLogger("llm_observatory")

_transport: Transport | None = None
_lock = threading.Lock()
_missing_config_warned = False
_dropped_count: int = 0

# Sentinel placed in the queue to wake the daemon immediately
_FLUSH_SENTINEL = object()


def get_transport() -> Transport:
    global _transport
    if _transport is None:
        with _lock:
            if _transport is None:
                _transport = Transport()
                _transport.start()
    return _transport


def flush_transport() -> None:
    """Synchronously flush all pending events without shutting down."""
    with _lock:
        if _transport is not None:
            _transport.flush()


def shutdown_transport() -> None:
    global _transport, _missing_config_warned, _dropped_count
    with _lock:
        if _transport is not None:
            _transport.shutdown()
            _transport = None
        _missing_config_warned = False
        _dropped_count = 0


class Transport:
    def __init__(self) -> None:
        cfg = get_config()
        self._queue: queue.Queue[dict[str, Any] | object] = queue.Queue(
            maxsize=cfg["max_queue_size"]
        )
        self._flush_interval: float = cfg["flush_interval"]
        self._flush_batch_size: int = cfg["flush_batch_size"]
        self._running = False
        self._thread: threading.Thread | None = None
        self._executor: ThreadPoolExecutor | None = None
        # Flush coordination: caller signals _flush_event, daemon signals _flush_done
        self._flush_event = threading.Event()
        self._flush_done = threading.Event()

    def start(self) -> None:
        if self._running:
            return
        self._running = True
        self._executor = ThreadPoolExecutor(max_workers=2)
        self._thread = threading.Thread(target=self._run, daemon=True)
        self._thread.start()
        atexit.register(self.shutdown)

    def enqueue(self, event: dict[str, Any]) -> None:
        global _dropped_count
        try:
            self._queue.put_nowait(event)
        except queue.Full:
            _dropped_count += 1

    def flush(self) -> None:
        """Explicit flush — ask the daemon to drain and send, then wait.

        Uses threading.Event for coordination so there is no race between
        the daemon's queue.get() and a concurrent drain.  If the daemon
        thread is not alive (e.g. interpreter shutting down), falls back
        to a direct drain-and-send on the calling thread.
        """
        if self._thread is None or not self._thread.is_alive():
            # Daemon is dead — send directly from this thread
            events = self._drain()
            if events:
                self._send_in_batches(events)
            return

        self._flush_done.clear()
        self._flush_event.set()
        # Put sentinel to wake daemon if it's blocked on queue.get()
        try:
            self._queue.put_nowait(_FLUSH_SENTINEL)
        except queue.Full:
            pass
        # Wait for daemon to finish the flush (timeout prevents deadlock)
        if not self._flush_done.wait(timeout=30.0):
            logger.warning("llm_observatory: flush timed out after 30s, draining directly")
            events = self._drain()
            if events:
                self._send_in_batches(events)

    def shutdown(self) -> None:
        self._running = False
        # Wake daemon so it exits its loop
        try:
            self._queue.put_nowait(_FLUSH_SENTINEL)
        except queue.Full:
            pass
        if self._thread is not None and self._thread.is_alive():
            self._thread.join(timeout=5.0)
        # Final drain — daemon's _run exit path handles this, but in case
        # the thread was already dead, drain from here
        remaining = self._drain()
        if remaining:
            self._send_in_batches(remaining)
        if self._executor is not None:
            self._executor.shutdown(wait=True)
            self._executor = None

    def _run(self) -> None:
        try:
            self._run_loop()
        except Exception:
            logger.exception("llm_observatory: daemon thread crashed")
        finally:
            # Unblock any flush() caller waiting on the daemon
            if self._flush_event.is_set():
                self._flush_event.clear()
                self._flush_done.set()

    def _run_loop(self) -> None:
        while self._running:
            deadline = time.monotonic() + self._flush_interval
            events: list[dict[str, Any]] = []

            while time.monotonic() < deadline and self._running:
                # Check for flush request before blocking on get()
                if self._flush_event.is_set():
                    break
                try:
                    remaining = max(0.1, deadline - time.monotonic())
                    item = self._queue.get(timeout=remaining)
                    if item is _FLUSH_SENTINEL:
                        break  # Wake up — either flush or shutdown
                    events.append(item)
                    if len(events) >= self._flush_batch_size:
                        break
                except queue.Empty:
                    continue

            # Handle explicit flush: drain everything and send synchronously
            if self._flush_event.is_set():
                # Include any events we already collected this iteration
                events.extend(self._drain())
                if events:
                    self._send_in_batches(events)
                self._flush_event.clear()
                self._flush_done.set()
                continue

            # Normal path: submit batch asynchronously
            if events and self._executor is not None:
                batch = list(events)
                try:
                    self._executor.submit(self._send, batch)
                except RuntimeError:
                    # Executor shut down during interpreter exit — send synchronously
                    self._send(batch)

        # Final drain on exit — send synchronously to ensure delivery
        remaining = self._drain()
        if remaining:
            self._send_in_batches(remaining)

    def _drain(self) -> list[dict[str, Any]]:
        events: list[dict[str, Any]] = []
        while True:
            try:
                item = self._queue.get_nowait()
                if item is not _FLUSH_SENTINEL:
                    events.append(item)
            except queue.Empty:
                break
        return events

    def _send_in_batches(self, events: list[dict[str, Any]]) -> None:
        """Send events in chunks of flush_batch_size."""
        for i in range(0, len(events), self._flush_batch_size):
            batch = events[i : i + self._flush_batch_size]
            self._send(batch)

    def _send(self, events: list[dict[str, Any]]) -> None:
        global _missing_config_warned, _dropped_count

        # Log and reset dropped count
        if _dropped_count > 0:
            count = _dropped_count
            _dropped_count = 0
            logger.warning(
                "llm_observatory: %d event(s) dropped due to full queue", count,
                extra={"event_count": count, "queue_size": self._queue.maxsize},
            )

        cfg = get_config()
        base_url = cfg.get("base_url")
        api_key = cfg.get("api_key")
        if not base_url or not api_key:
            if not _missing_config_warned:
                _missing_config_warned = True
                missing = []
                if not base_url:
                    missing.append("base_url")
                if not api_key:
                    missing.append("api_key")
                logger.warning(
                    "llm_observatory: dropping %d event(s) — %s not configured. "
                    "Call configure() or set LLM_OBSERVATORY_BASE_URL / LLM_OBSERVATORY_API_KEY.",
                    len(events),
                    " and ".join(missing),
                )
            return

        url = f"{base_url.rstrip('/')}/v1/ingest"
        body = gzip.compress(json.dumps(events).encode())
        batch_id = str(uuid.uuid4())
        headers = {
            "Content-Type": "application/json",
            "Content-Encoding": "gzip",
            "Authorization": f"Bearer {api_key}",
            "X-Request-ID": batch_id,
        }

        delays = [0, 1.0, 2.0]
        for attempt, delay in enumerate(delays):
            if delay > 0:
                time.sleep(delay)
            try:
                resp = requests.post(url, data=body, headers=headers, timeout=10)
                if resp.status_code < 500:
                    return  # Success or client error — don't retry
            except (requests.ConnectionError, requests.Timeout) as exc:
                logger.debug(
                    "llm_observatory: send attempt %d failed: %s", attempt + 1, exc
                )
                continue
            except Exception as exc:
                logger.warning("llm_observatory: send failed: %s", exc)
                return

        logger.warning("llm_observatory: failed to send %d events after 3 attempts", len(events))
