from __future__ import annotations

import json
import queue
import threading
import time
from copy import copy
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Callable

from . import Alert, Notifier

_SENTINEL = object()


@dataclass
class _Stats:
    sent: int = 0
    failed: int = 0
    dropped: int = 0
    retries: int = 0


class FanoutNotifier:
    def __init__(
        self,
        backends: list[Notifier],
        dead_letter_path: Path,
        queue_size: int = 50,
        max_retries: int = 3,
        backoff_base: float = 0.5,
        on_drop: Callable[[str, Alert], None] | None = None,
    ) -> None:
        self._backends = backends
        self._dead_letter_path = Path(dead_letter_path)
        self._queue_size = queue_size
        self._max_retries = max_retries
        self._backoff_base = backoff_base
        self._on_drop = on_drop
        self._dl_lock = threading.Lock()

        self._queues: dict[str, queue.Queue[Any]] = {}
        self._stats: dict[str, _Stats] = {}
        self._workers: list[threading.Thread] = []

        for backend in backends:
            q: queue.Queue[Any] = queue.Queue(maxsize=queue_size)
            self._queues[backend.name] = q
            self._stats[backend.name] = _Stats()
            t = threading.Thread(
                target=self._worker,
                args=(backend, q),
                daemon=True,
                name=f"fanout-{backend.name}",
            )
            t.start()
            self._workers.append(t)

    def send(self, alert: Alert) -> None:
        alert_copy = copy(alert)
        for backend in self._backends:
            q = self._queues[backend.name]
            stats = self._stats[backend.name]
            if q.full():
                try:
                    dropped = q.get_nowait()
                    stats.dropped += 1
                    if self._on_drop is not None and isinstance(dropped, Alert):
                        try:
                            self._on_drop(backend.name, dropped)
                        except Exception:
                            pass  # audit failure must never break dispatch
                except queue.Empty:
                    pass
            q.put(alert_copy)

    def stop(self, timeout: float = 5.0) -> None:
        for q in self._queues.values():
            q.put(_SENTINEL)
        for w in self._workers:
            w.join(timeout=timeout)

    def stats(self) -> dict[str, dict[str, int]]:
        return {
            name: {
                "sent": s.sent,
                "failed": s.failed,
                "dropped": s.dropped,
                "retries": s.retries,
            }
            for name, s in self._stats.items()
        }

    def _worker(self, backend: Notifier, q: queue.Queue[Any]) -> None:
        stats = self._stats[backend.name]
        while True:
            item = q.get()
            if item is _SENTINEL:
                break
            self._dispatch(backend, item, stats)

    def _dispatch(self, backend: Notifier, alert: Alert, stats: _Stats) -> None:
        last_exc: Exception | None = None
        for attempt in range(self._max_retries + 1):
            if attempt > 0:
                delay = self._backoff_base * (2 ** (attempt - 1))
                time.sleep(delay)
                stats.retries += 1
            try:
                backend.send(alert)
                stats.sent += 1
                return
            except Exception as exc:
                last_exc = exc

        # Exhausted all retries
        stats.failed += 1
        self._write_dead_letter(backend.name, alert, last_exc)

    def _write_dead_letter(
        self, backend_name: str, alert: Alert, exc: Exception | None
    ) -> None:
        record = {
            "backend": backend_name,
            "alert_title": alert.title,
            "alert_kind": alert.kind,
            "error_str": str(exc) if exc else "",
            "timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
        }
        line = json.dumps(record) + "\n"
        with self._dl_lock:
            self._dead_letter_path.parent.mkdir(parents=True, exist_ok=True)
            with open(self._dead_letter_path, "a", encoding="utf-8") as fh:
                fh.write(line)
