"""``FakeMonitorHistory`` — in-memory implementation of :class:`MonitorHistory`.

A first-class fake (D-F3), not a mock: it records events + metric
samples in memory and answers every query with the same ordering and
filtering semantics the sqlite ``History`` promises. Downstream test
suites, the SDK's in-process transport, and the dev loop wire this in
with zero file-I/O.

Time handling mirrors the real store: ``record_lifecycle`` /
``record_config_change`` stamp ``occurred_at`` from an injectable
clock (default :func:`datetime.now` in UTC), naïve ``since`` cutoffs
are treated as UTC, and rollups bucket by top-of-hour UTC.
"""

from __future__ import annotations

import threading
from collections import defaultdict
from datetime import UTC, datetime, timedelta
from typing import TYPE_CHECKING

from mgf.vm.core.monitor.history import EventFilter, EventRow, HourlyMetrics

if TYPE_CHECKING:
    from collections.abc import Callable, Iterable

    from mgf.vm.core.domain import VMHandle, VMMetrics, VMStatus


def _normalise(dt: datetime) -> datetime:
    """Return a UTC-aware datetime. Naïve inputs are treated as UTC."""
    if dt.tzinfo is None:
        return dt.replace(tzinfo=UTC)
    return dt.astimezone(UTC)


class FakeMonitorHistory:
    """In-memory :class:`MonitorHistory` for tests, fakes, and the dev loop.

    Satisfies the :class:`~mgf.vm.core.monitor.history.MonitorHistory`
    Protocol structurally. Thread-safe via a single
    :class:`threading.RLock`, matching the real store's contract.
    """

    def __init__(self, *, clock: Callable[[], datetime] | None = None) -> None:
        self._clock = clock if clock is not None else lambda: datetime.now(UTC)
        self._lock = threading.RLock()
        self._events: list[EventRow] = []
        self._metrics: dict[str, list[VMMetrics]] = defaultdict(list)
        self._hourly: dict[tuple[str, datetime], HourlyMetrics] = {}
        self._closed = False

    # ------------------------------------------------------------------
    # Audit events
    # ------------------------------------------------------------------

    def record_lifecycle(
        self,
        vm_name: str,
        from_state: VMStatus | None,
        to_state: VMStatus,
        *,
        trigger_source: str = "user",
    ) -> None:
        detail = trigger_source or "user"
        self._append_event(
            vm_name=vm_name,
            from_state=from_state.value if from_state is not None else None,
            to_state=to_state.value,
            trigger="lifecycle",
            detail=detail,
        )

    def record_config_change(
        self,
        handle: VMHandle,
        subsystem: str,
        detail: str,
    ) -> None:
        self._append_event(
            vm_name=handle.name,
            from_state=None,
            to_state=None,
            trigger=f"config_change:{subsystem}",
            detail=detail,
        )

    def query_events(
        self,
        vm_name: str | None = None,
        *,
        since: datetime | None = None,
        filter: EventFilter = "all",  # noqa: A002 — matches the CLI flag name
        limit: int | None = None,
    ) -> list[EventRow]:
        if filter not in ("all", "lifecycle", "config"):
            from mgf.vm.core.exceptions import OperationError

            raise OperationError(
                f"invalid filter {filter!r}; use one of 'all', 'lifecycle', 'config'",
            )
        since_n = _normalise(since) if since is not None else None
        with self._lock:
            rows = [
                row
                for row in self._events
                if (vm_name is None or row.vm_name == vm_name)
                and (since_n is None or row.occurred_at >= since_n)
                and self._matches_filter(row, filter)
            ]
        # Newest-first; insertion order is the tie-breaker (mirrors the
        # sqlite ``ORDER BY occurred_at DESC, id DESC``).
        rows.sort(key=lambda r: r.occurred_at, reverse=True)
        if limit is not None:
            rows = rows[:limit]
        return rows

    @staticmethod
    def _matches_filter(row: EventRow, filter: EventFilter) -> bool:  # noqa: A002
        if filter == "lifecycle":
            return row.trigger == "lifecycle"
        if filter == "config":
            return row.trigger.startswith("config_change:")
        return True

    def _append_event(
        self,
        *,
        vm_name: str,
        from_state: str | None,
        to_state: str | None,
        trigger: str,
        detail: str,
    ) -> None:
        occurred_at = _normalise(self._clock())
        with self._lock:
            self._events.append(
                EventRow(
                    vm_name=vm_name,
                    occurred_at=occurred_at,
                    from_state=from_state,
                    to_state=to_state,
                    trigger=trigger,
                    detail=detail,
                ),
            )

    # ------------------------------------------------------------------
    # Raw metrics
    # ------------------------------------------------------------------

    def record_metrics(self, vm_name: str, metrics: VMMetrics) -> None:
        self.record_metrics_batch([(vm_name, metrics)])

    def record_metrics_batch(self, samples: Iterable[tuple[str, VMMetrics]]) -> None:
        materialised = list(samples)
        if not materialised:
            return
        with self._lock:
            for vm_name, metrics in materialised:
                self._metrics[vm_name].append(metrics)

    def query_metrics(
        self,
        vm_name: str,
        *,
        since: datetime | None = None,
        limit: int | None = None,
    ) -> list[VMMetrics]:
        since_n = _normalise(since) if since is not None else None
        with self._lock:
            samples = [
                m
                for m in self._metrics.get(vm_name, [])
                if since_n is None or _normalise(m.sampled_at) >= since_n
            ]
        samples.sort(key=lambda m: _normalise(m.sampled_at), reverse=True)
        if limit is not None:
            samples = samples[:limit]
        return samples

    def prune_metrics_older_than(self, cutoff: datetime) -> int:
        cutoff_n = _normalise(cutoff)
        removed = 0
        with self._lock:
            for vm_name, samples in self._metrics.items():
                kept = [m for m in samples if _normalise(m.sampled_at) >= cutoff_n]
                removed += len(samples) - len(kept)
                self._metrics[vm_name] = kept
        return removed

    # ------------------------------------------------------------------
    # Hourly rollup
    # ------------------------------------------------------------------

    def rollup_hourly_metrics(
        self,
        *,
        since: datetime | None = None,
        now: datetime | None = None,
    ) -> int:
        now_n = _normalise(now) if now is not None else _normalise(self._clock())
        end = now_n.replace(minute=0, second=0, microsecond=0)
        if since is None:
            start = end - timedelta(days=7)
        else:
            start = _normalise(since).replace(minute=0, second=0, microsecond=0)
        if start >= end:
            return 0

        # Bucket every in-window raw sample by (vm_name, top-of-hour).
        buckets: dict[tuple[str, datetime], list[VMMetrics]] = defaultdict(list)
        with self._lock:
            for vm_name, samples in self._metrics.items():
                for m in samples:
                    sampled = _normalise(m.sampled_at)
                    if start <= sampled < end:
                        hour = sampled.replace(minute=0, second=0, microsecond=0)
                        buckets[(vm_name, hour)].append(m)

            written = 0
            for (vm_name, hour), group in buckets.items():
                self._hourly[(vm_name, hour)] = self._aggregate(vm_name, hour, group)
                written += 1
        return written

    @staticmethod
    def _aggregate(vm_name: str, hour: datetime, group: list[VMMetrics]) -> HourlyMetrics:
        count = len(group)

        def avg(values: list[float]) -> float:
            return sum(values) / count

        return HourlyMetrics(
            vm_name=vm_name,
            hour=hour,
            sample_count=count,
            cpu_pct_avg=avg([m.cpu_pct for m in group]),
            cpu_pct_max=max(m.cpu_pct for m in group),
            ram_used_mb_avg=avg([float(m.ram_used_mb) for m in group]),
            ram_used_mb_max=max(m.ram_used_mb for m in group),
            disk_read_bps_avg=avg([float(m.disk_read_bps) for m in group]),
            disk_read_bps_max=max(m.disk_read_bps for m in group),
            disk_write_bps_avg=avg([float(m.disk_write_bps) for m in group]),
            disk_write_bps_max=max(m.disk_write_bps for m in group),
            net_rx_bps_avg=avg([float(m.net_rx_bps) for m in group]),
            net_rx_bps_max=max(m.net_rx_bps for m in group),
            net_tx_bps_avg=avg([float(m.net_tx_bps) for m in group]),
            net_tx_bps_max=max(m.net_tx_bps for m in group),
        )

    def query_metrics_hourly(
        self,
        vm_name: str,
        *,
        since: datetime | None = None,
        limit: int | None = None,
    ) -> list[HourlyMetrics]:
        since_n = _normalise(since) if since is not None else None
        with self._lock:
            rows = [
                row
                for (name, _hour), row in self._hourly.items()
                if name == vm_name and (since_n is None or row.hour >= since_n)
            ]
        rows.sort(key=lambda r: r.hour, reverse=True)
        if limit is not None:
            rows = rows[:limit]
        return rows

    def prune_metrics_hourly_older_than(self, cutoff: datetime) -> int:
        cutoff_n = _normalise(cutoff)
        with self._lock:
            stale = [key for key, row in self._hourly.items() if row.hour < cutoff_n]
            for key in stale:
                del self._hourly[key]
        return len(stale)

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

    def close(self) -> None:
        with self._lock:
            self._closed = True

    @property
    def closed(self) -> bool:
        """``True`` once :meth:`close` has been called. Test/inspection aid."""
        return self._closed


__all__ = ["FakeMonitorHistory"]
