"""TTL cache and Dead Letter Queue for otelctx."""

from __future__ import annotations

import threading
import time
from dataclasses import dataclass, field
from typing import Any

from sqlalchemy import Column, DateTime, Integer, String, Text, func, select
from sqlalchemy.orm import DeclarativeBase


# ---------------------------------------------------------------------------
# TTL Cache
# ---------------------------------------------------------------------------

@dataclass
class _CacheEntry:
    value: Any
    expires_at: float


class TTLCache:
    """Thread-safe in-memory cache with per-entry TTL."""

    def __init__(self, default_ttl: float = 300.0, max_size: int = 10_000) -> None:
        self.default_ttl = default_ttl
        self.max_size = max_size
        self._data: dict[str, _CacheEntry] = {}
        self._lock = threading.RLock()

    def set(self, key: str, value: Any, ttl: float | None = None) -> None:
        expires = time.monotonic() + (ttl if ttl is not None else self.default_ttl)
        with self._lock:
            if len(self._data) >= self.max_size:
                self._evict_expired()
            self._data[key] = _CacheEntry(value=value, expires_at=expires)

    def get(self, key: str) -> Any | None:
        with self._lock:
            entry = self._data.get(key)
            if entry is None:
                return None
            if time.monotonic() > entry.expires_at:
                del self._data[key]
                return None
            return entry.value

    def delete(self, key: str) -> None:
        with self._lock:
            self._data.pop(key, None)

    def _evict_expired(self) -> int:
        now = time.monotonic()
        expired = [k for k, e in self._data.items() if now > e.expires_at]
        for k in expired:
            del self._data[k]
        return len(expired)

    def purge_expired(self) -> int:
        with self._lock:
            return self._evict_expired()

    def size(self) -> int:
        with self._lock:
            return len(self._data)

    def stats(self) -> dict:
        with self._lock:
            now = time.monotonic()
            live = sum(1 for e in self._data.values() if now <= e.expires_at)
            return {
                "total_entries": len(self._data),
                "live_entries": live,
                "expired_entries": len(self._data) - live,
                "max_size": self.max_size,
            }


# ---------------------------------------------------------------------------
# Dead Letter Queue (SQLite-backed)
# ---------------------------------------------------------------------------

class _DlqBase(DeclarativeBase):
    pass


class DlqEntry(_DlqBase):
    __tablename__ = "dlq_entries"

    id = Column(Integer, primary_key=True, autoincrement=True)
    payload = Column(Text, nullable=False)
    error_message = Column(Text, nullable=True)
    signal = Column(String(32), nullable=False, default="unknown")
    attempt_count = Column(Integer, nullable=False, default=1)
    created_at = Column(DateTime(timezone=True), server_default=func.now())
    last_attempted_at = Column(DateTime(timezone=True), server_default=func.now())


class DeadLetterQueue:
    """SQLite-backed Dead Letter Queue for failed ingest payloads."""

    def __init__(self, session_factory) -> None:
        self._session_factory = session_factory

    def push(self, payload: str, error_message: str, signal: str = "unknown") -> int:
        """Add a failed payload to the DLQ. Returns entry id."""
        import json as _json
        from common.db import session_scope
        with session_scope(self._session_factory) as session:
            entry = DlqEntry(
                payload=payload,
                error_message=error_message,
                signal=signal,
                attempt_count=1,
            )
            session.add(entry)
            session.flush()
            return entry.id

    def list_entries(self, signal: str | None = None, limit: int = 100) -> list[dict]:
        from common.db import session_scope
        with session_scope(self._session_factory) as session:
            stmt = select(DlqEntry).order_by(DlqEntry.created_at.desc()).limit(limit)
            if signal:
                stmt = stmt.where(DlqEntry.signal == signal)
            rows = session.execute(stmt).scalars().all()
            return [
                {
                    "id": r.id,
                    "signal": r.signal,
                    "error_message": r.error_message,
                    "attempt_count": r.attempt_count,
                    "created_at": r.created_at.isoformat() if r.created_at else None,
                    "last_attempted_at": r.last_attempted_at.isoformat() if r.last_attempted_at else None,
                    "payload_preview": r.payload[:200] if r.payload else "",
                }
                for r in rows
            ]

    def ack(self, entry_id: int) -> bool:
        """Remove an entry from the DLQ (successfully reprocessed)."""
        from common.db import session_scope
        with session_scope(self._session_factory) as session:
            entry = session.get(DlqEntry, entry_id)
            if not entry:
                return False
            session.delete(entry)
        return True

    def count(self, signal: str | None = None) -> int:
        from common.db import session_scope
        with session_scope(self._session_factory) as session:
            from sqlalchemy import func as _func
            stmt = select(_func.count(DlqEntry.id))
            if signal:
                stmt = stmt.where(DlqEntry.signal == signal)
            return session.execute(stmt).scalar() or 0


# ---------------------------------------------------------------------------
# DLQ replay worker
# ---------------------------------------------------------------------------

class DlqReplayWorker:
    """Background thread that periodically retries DLQ entries.

    ``ingest_fn`` receives the raw payload string and must return truthy on
    success or raise / return falsy on failure.
    """

    def __init__(
        self,
        dlq: DeadLetterQueue,
        ingest_fn,
        *,
        interval_seconds: float = 60.0,
        max_retries: int = 5,
    ) -> None:
        self._dlq = dlq
        self._ingest_fn = ingest_fn
        self._interval = interval_seconds
        self._max_retries = max_retries
        self._stop_event = threading.Event()
        self._thread: threading.Thread | None = None

    # ------------------------------------------------------------------
    # Public API
    # ------------------------------------------------------------------

    def start(self) -> None:
        """Start the background replay thread."""
        if self._thread is not None and self._thread.is_alive():
            return
        self._stop_event.clear()
        self._thread = threading.Thread(target=self._run, daemon=True, name="dlq-replay")
        self._thread.start()

    def stop(self, timeout: float = 5.0) -> None:
        """Signal the worker to stop and wait for it to exit."""
        self._stop_event.set()
        if self._thread is not None:
            self._thread.join(timeout=timeout)

    # ------------------------------------------------------------------
    # Internal
    # ------------------------------------------------------------------

    def _run(self) -> None:
        while not self._stop_event.wait(timeout=self._interval):
            self._replay_once()

    def _replay_once(self) -> None:
        from common.db import session_scope
        from sqlalchemy import update as _update

        try:
            entries = self._dlq.list_entries(limit=50)
        except Exception:
            return

        for entry in entries:
            eid = entry["id"]
            attempts = entry.get("attempt_count", 1)

            if attempts > self._max_retries:
                continue

            try:
                # Fetch full payload from DB
                with session_scope(self._dlq._session_factory) as session:
                    row = session.get(DlqEntry, eid)
                    if row is None:
                        continue
                    payload = row.payload

                result = self._ingest_fn(payload)
                if result:
                    self._dlq.ack(eid)
                else:
                    self._increment_attempt(eid, attempts)
            except Exception:  # noqa: BLE001
                self._increment_attempt(eid, attempts)

    def _increment_attempt(self, entry_id: int, current_attempts: int) -> None:
        from common.db import session_scope
        with session_scope(self._dlq._session_factory) as session:
            row = session.get(DlqEntry, entry_id)
            if row is None:
                return
            row.attempt_count = current_attempts + 1
            row.last_attempted_at = func.now()
            # Exponential backoff: skip replaying until enough time has passed
            # (enforced by the caller interval; here we just update the counter)
