"""LLMHosts metrics collector -- in-memory + SQLite metrics for real-time dashboards.

Maintains fast in-memory counters and rolling windows for the real-time
dashboard, with periodic flush to SQLite for historical timeseries queries.
"""

from __future__ import annotations

import logging
import time
from collections import deque
from datetime import datetime, timedelta, timezone
from typing import TYPE_CHECKING

import aiosqlite
from pydantic import BaseModel

if TYPE_CHECKING:
    from pathlib import Path

logger = logging.getLogger(__name__)

# ---------------------------------------------------------------------------
# SQL
# ---------------------------------------------------------------------------

_CREATE_SNAPSHOTS_TABLE = """
CREATE TABLE IF NOT EXISTS metrics_snapshots (
    id              INTEGER PRIMARY KEY AUTOINCREMENT,
    timestamp       TEXT NOT NULL,
    total_requests  INTEGER NOT NULL DEFAULT 0,
    cache_hits      INTEGER NOT NULL DEFAULT 0,
    cache_misses    INTEGER NOT NULL DEFAULT 0,
    total_tokens    INTEGER NOT NULL DEFAULT 0,
    total_cost      REAL NOT NULL DEFAULT 0.0,
    total_savings   REAL NOT NULL DEFAULT 0.0,
    avg_latency_ms  REAL NOT NULL DEFAULT 0.0,
    requests_by_backend TEXT NOT NULL DEFAULT '{}'
);
"""

_CREATE_TIMESERIES_TABLE = """
CREATE TABLE IF NOT EXISTS metrics_timeseries (
    id         INTEGER PRIMARY KEY AUTOINCREMENT,
    timestamp  TEXT NOT NULL,
    metric     TEXT NOT NULL,
    value      REAL NOT NULL
);
"""

_CREATE_INDEXES = [
    "CREATE INDEX IF NOT EXISTS idx_snap_ts ON metrics_snapshots (timestamp);",
    "CREATE INDEX IF NOT EXISTS idx_ts_metric_ts ON metrics_timeseries (metric, timestamp);",
]


# ---------------------------------------------------------------------------
# Data models
# ---------------------------------------------------------------------------


class MetricsSnapshot(BaseModel):
    """Current metrics for real-time display on the dashboard."""

    uptime_seconds: float
    total_requests: int
    requests_per_minute: float
    cache_hit_rate: float
    avg_latency_ms: float
    total_tokens: int
    total_cost: float
    total_savings: float
    savings_percent: float
    requests_by_backend: dict[str, int]
    active_models: list[str]


class TimeseriesPoint(BaseModel):
    """A single data point in a timeseries for dashboard charts."""

    timestamp: datetime
    value: float


# ---------------------------------------------------------------------------
# Request sample (internal)
# ---------------------------------------------------------------------------


class _RequestSample:
    """Lightweight struct for a single request observation."""

    __slots__ = ("backend", "cached", "cost", "latency_ms", "model", "savings", "timestamp", "tokens")

    def __init__(
        self,
        timestamp: float,
        latency_ms: float,
        tokens: int,
        cost: float,
        savings: float,
        cached: bool,
        backend: str,
        model: str,
    ) -> None:
        self.timestamp = timestamp
        self.latency_ms = latency_ms
        self.tokens = tokens
        self.cost = cost
        self.savings = savings
        self.cached = cached
        self.backend = backend
        self.model = model


# ---------------------------------------------------------------------------
# Metrics collector
# ---------------------------------------------------------------------------


class MetricsCollector:
    """In-memory + SQLite metrics collection for dashboard display.

    Real-time counters live in memory for instant snapshot access.
    :meth:`persist` flushes a point-in-time snapshot to SQLite so
    historical timeseries can be queried by :meth:`get_timeseries`.
    """

    _ROLLING_WINDOW = 300  # 5 minutes of samples for averages
    _RPM_WINDOW = 60  # 1 minute window for requests-per-minute

    def __init__(self, db_path: Path) -> None:
        self._db_path = db_path
        self._db: aiosqlite.Connection | None = None
        self._start_time: float = time.monotonic()

        # Aggregate counters
        self._request_count: int = 0
        self._cache_hits: int = 0
        self._cache_misses: int = 0
        self._total_tokens: int = 0
        self._total_cost: float = 0.0
        self._total_savings: float = 0.0

        # Rolling windows for real-time averages
        self._latency_samples: deque[tuple[float, float]] = deque()  # (timestamp, latency_ms)
        self._request_timestamps: deque[float] = deque()  # for RPM calculation

        # Breakdowns
        self._requests_by_backend: dict[str, int] = {}
        self._active_models: set[str] = set()

        # Flush tracking
        self._last_persist_counts: dict[str, int | float] = {
            "request_count": 0,
            "cache_hits": 0,
            "cache_misses": 0,
            "total_tokens": 0,
            "total_cost": 0.0,
            "total_savings": 0.0,
        }

    # ------------------------------------------------------------------
    # Lifecycle
    # ------------------------------------------------------------------

    async def initialize(self) -> None:
        """Create metrics tables and indexes.  Enable WAL."""
        self._db_path.parent.mkdir(parents=True, exist_ok=True)
        self._db = await aiosqlite.connect(str(self._db_path))
        await self._db.execute("PRAGMA journal_mode=WAL;")
        await self._db.execute("PRAGMA synchronous=NORMAL;")
        await self._db.execute(_CREATE_SNAPSHOTS_TABLE)
        await self._db.execute(_CREATE_TIMESERIES_TABLE)
        for idx_sql in _CREATE_INDEXES:
            await self._db.execute(idx_sql)
        await self._db.commit()
        self._start_time = time.monotonic()
        logger.info("MetricsCollector initialised (db=%s)", self._db_path)

    async def close(self) -> None:
        """Close database connection."""
        if self._db is not None:
            await self._db.close()
            self._db = None
            logger.debug("MetricsCollector closed")

    def _ensure_db(self) -> aiosqlite.Connection:
        if self._db is None:
            raise RuntimeError("MetricsCollector not initialised -- call initialize() first")
        return self._db

    # ------------------------------------------------------------------
    # Recording (in-memory, fast)
    # ------------------------------------------------------------------

    def record_request(
        self,
        latency_ms: float,
        tokens: int,
        cost: float,
        savings: float,
        cached: bool,
        backend: str,
        model: str = "",
    ) -> None:
        """Record a completed request.  Updates in-memory counters only (fast path).

        Parameters
        ----------
        latency_ms:
            End-to-end request latency in milliseconds.
        tokens:
            Total tokens consumed (input + output).
        cost:
            Actual cost of this request in USD.
        savings:
            Cloud-equivalent savings in USD.
        cached:
            Whether the response was served from cache.
        backend:
            Backend that served the request (e.g. "ollama", "openai").
        model:
            Model identifier used for the request.
        """
        now = time.monotonic()
        self._request_count += 1
        self._total_tokens += tokens
        self._total_cost += cost
        self._total_savings += savings

        if cached:
            self._cache_hits += 1
        else:
            self._cache_misses += 1

        # Rolling latency window
        self._latency_samples.append((now, latency_ms))
        self._trim_window(self._latency_samples, now, self._ROLLING_WINDOW)

        # RPM tracking
        self._request_timestamps.append(now)
        self._trim_timestamps(self._request_timestamps, now, self._RPM_WINDOW)

        # Backend breakdown
        self._requests_by_backend[backend] = self._requests_by_backend.get(backend, 0) + 1

        # Track active models
        if model:
            self._active_models.add(model)

    @staticmethod
    def _trim_window(window: deque[tuple[float, float]], now: float, max_age: float) -> None:
        """Remove entries older than max_age seconds from a timestamped deque."""
        cutoff = now - max_age
        while window and window[0][0] < cutoff:
            window.popleft()

    @staticmethod
    def _trim_timestamps(window: deque[float], now: float, max_age: float) -> None:
        """Remove timestamps older than max_age seconds."""
        cutoff = now - max_age
        while window and window[0] < cutoff:
            window.popleft()

    # ------------------------------------------------------------------
    # Snapshot (real-time read)
    # ------------------------------------------------------------------

    def snapshot(self) -> MetricsSnapshot:
        """Get current metrics snapshot for the dashboard.  Pure in-memory, fast."""
        now = time.monotonic()
        uptime = now - self._start_time

        # Trim windows before reading
        self._trim_window(self._latency_samples, now, self._ROLLING_WINDOW)
        self._trim_timestamps(self._request_timestamps, now, self._RPM_WINDOW)

        # Average latency over rolling window
        if self._latency_samples:
            avg_latency = sum(s[1] for s in self._latency_samples) / len(self._latency_samples)
        else:
            avg_latency = 0.0

        # Requests per minute
        rpm = len(self._request_timestamps) * (60.0 / self._RPM_WINDOW) if self._request_timestamps else 0.0

        # Cache hit rate
        total_cache = self._cache_hits + self._cache_misses
        cache_hit_rate = self._cache_hits / total_cache if total_cache > 0 else 0.0

        # Savings percent
        total_potential = self._total_cost + self._total_savings
        savings_pct = (self._total_savings / total_potential * 100.0) if total_potential > 0 else 0.0

        return MetricsSnapshot(
            uptime_seconds=round(uptime, 1),
            total_requests=self._request_count,
            requests_per_minute=round(rpm, 2),
            cache_hit_rate=round(cache_hit_rate, 4),
            avg_latency_ms=round(avg_latency, 2),
            total_tokens=self._total_tokens,
            total_cost=round(self._total_cost, 6),
            total_savings=round(self._total_savings, 6),
            savings_percent=round(savings_pct, 2),
            requests_by_backend=dict(self._requests_by_backend),
            active_models=sorted(self._active_models),
        )

    # ------------------------------------------------------------------
    # Persistence
    # ------------------------------------------------------------------

    async def persist(self) -> None:
        """Flush current in-memory counters to SQLite.

        Called periodically (e.g. every 60s) by the server lifecycle.
        Writes a snapshot row and individual timeseries points.
        """
        import json

        db = self._ensure_db()
        now = datetime.now(timezone.utc)
        now_iso = now.isoformat()
        snap = self.snapshot()

        # Write snapshot row
        await db.execute(
            "INSERT INTO metrics_snapshots "
            "(timestamp, total_requests, cache_hits, cache_misses, total_tokens, "
            "total_cost, total_savings, avg_latency_ms, requests_by_backend) "
            "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
            (
                now_iso,
                self._request_count,
                self._cache_hits,
                self._cache_misses,
                self._total_tokens,
                self._total_cost,
                self._total_savings,
                snap.avg_latency_ms,
                json.dumps(self._requests_by_backend),
            ),
        )

        # Write timeseries points for key metrics
        ts_points = [
            ("requests_total", float(self._request_count)),
            ("cache_hit_rate", snap.cache_hit_rate),
            ("avg_latency_ms", snap.avg_latency_ms),
            ("total_tokens", float(self._total_tokens)),
            ("total_cost", self._total_cost),
            ("total_savings", self._total_savings),
            ("requests_per_minute", snap.requests_per_minute),
        ]
        await db.executemany(
            "INSERT INTO metrics_timeseries (timestamp, metric, value) VALUES (?, ?, ?)",
            [(now_iso, metric, value) for metric, value in ts_points],
        )

        await db.commit()
        logger.debug(
            "Metrics persisted: requests=%d tokens=%d cost=%.4f",
            self._request_count,
            self._total_tokens,
            self._total_cost,
        )

    # ------------------------------------------------------------------
    # Timeseries retrieval
    # ------------------------------------------------------------------

    async def get_timeseries(
        self,
        metric: str,
        period: str = "day",
        granularity: str = "hour",
    ) -> list[TimeseriesPoint]:
        """Get timeseries data for dashboard charts.

        Parameters
        ----------
        metric:
            Metric name (e.g. "requests_total", "cache_hit_rate", "avg_latency_ms").
        period:
            Time window to retrieve: "hour", "day", "week", "month", "all".
        granularity:
            Bucket size for aggregation: "minute", "hour", "day".

        Returns
        -------
        list[TimeseriesPoint]
            Time-ordered list of data points.
        """
        db = self._ensure_db()
        now = datetime.now(timezone.utc)

        period_deltas: dict[str, timedelta | None] = {
            "hour": timedelta(hours=1),
            "day": timedelta(days=1),
            "week": timedelta(weeks=1),
            "month": timedelta(days=30),
            "all": None,
        }
        delta = period_deltas.get(period)
        if delta is None and period != "all":
            delta = timedelta(days=1)

        # SQLite strftime format for bucketing
        granularity_formats: dict[str, str] = {
            "minute": "%Y-%m-%dT%H:%M:00",
            "hour": "%Y-%m-%dT%H:00:00",
            "day": "%Y-%m-%dT00:00:00",
        }
        bucket_fmt = granularity_formats.get(granularity, "%Y-%m-%dT%H:00:00")

        conditions = ["metric = ?"]
        params: list[str] = [metric]

        if delta is not None:
            start = now - delta
            conditions.append("timestamp >= ?")
            params.append(start.isoformat())

        where = " AND ".join(conditions)
        sql = (
            f"SELECT strftime('{bucket_fmt}', timestamp) AS bucket, AVG(value) "  # nosec B608
            f"FROM metrics_timeseries WHERE {where} "
            f"GROUP BY bucket ORDER BY bucket ASC"
        )

        points: list[TimeseriesPoint] = []
        async with db.execute(sql, params) as cursor:
            async for row in cursor:
                if row[0] is not None:
                    points.append(
                        TimeseriesPoint(
                            timestamp=datetime.fromisoformat(row[0]).replace(tzinfo=timezone.utc),
                            value=round(row[1], 4),
                        )
                    )

        return points
