"""H5 — the pure event-sequencing state machine for ``watch_events``.

``HypervisorBackend.watch_events`` (contracts.md §3) returns a
**resumable** iterator of ``VmEvent | EventGap``. The hard part is not
the libvirt subscription plumbing — it is the *correctness* of the
per-domain causal ordering and the synthesis of synthetic
:class:`~mgf.vm.core.contracts.EventGap` records when the stream may
have skipped events.

That logic is isolated here as a **pure** state machine
(:class:`EventSequencer`) with **no** libvirt dependency, so it can be
unit-tested by feeding it synthetic event sequences and asserting the
exact emission order — no live engine, no threads. The backend wires
the live ``virDomainEventRegisterAny`` callbacks into
:meth:`EventSequencer.observe`; everything causal happens here.

Design (contracts.md §3 ``watch_events`` + §10 CONF-H-10):

* **Per-domain monotonic sequence.** libvirt's lifecycle callback does
  not carry a sequence number, so the sequencer assigns one per domain:
  a monotonically increasing counter, starting at ``0`` for the first
  event seen for a domain.
* **Resume.** A consumer resumes by passing the last sequence it saw per
  domain (``resume_from``). On resume the sequencer compares the engine's
  earliest-retained sequence against the requested one.
* **Gap synthesis — default to emitting a gap when uncertain.** If the
  sequencer cannot *prove* the consumer saw every event between
  ``resume_from`` and the next live event (because the engine's retention
  window elapsed, the connection bounced, or a sequence is
  non-contiguous), it emits an :class:`EventGap` BEFORE the next real
  event. This is the conservative bias the spec mandates: a spurious gap
  costs the consumer one reconcile; a *missed* gap silently drops state.
* **Global gap.** An engine restart (signalled via
  :meth:`EventSequencer.note_engine_restart`) resets every per-domain
  counter and emits a single global gap (``domain_id=None``).

The sequencer is deliberately *not* threadsafe — the backend owns one
per stream and only ever drives it from the single delivery thread.
"""

from __future__ import annotations

from dataclasses import dataclass, field
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any

from mgf.vm.core.contracts import EventGap, VmEvent

if TYPE_CHECKING:
    from collections.abc import Iterable

    from mgf.vm.core.ids import DomainId


def _now() -> datetime:
    """UTC now (DP-12). Wrapped so tests can monkeypatch deterministically."""
    return datetime.now(UTC)


@dataclass(slots=True)
class _DomainCursor:
    """Per-domain bookkeeping inside the sequencer."""

    next_sequence: int = 0
    """The sequence number the NEXT observed event for this domain gets."""

    last_emitted: int | None = None
    """The highest sequence actually emitted to the consumer, or ``None``."""


@dataclass(slots=True)
class EventSequencer:
    """Pure per-domain sequence tracker + gap synthesiser.

    Feed it raw observations via :meth:`observe`; it yields the
    ``VmEvent`` (with an assigned monotonic per-domain sequence) plus,
    when warranted, a leading :class:`EventGap`.

    The ``resume_from`` mapping seeds the cursors when a consumer
    reconnects: ``{domain_id: last_seen_sequence}``. The first live
    observation for a resumed domain whose engine-retained history can no
    longer be proven contiguous gets a gap prepended.
    """

    resume_from: dict[DomainId, int] = field(default_factory=dict)
    retention_window: int | None = None
    """How many events the engine retains per domain. ``None`` means the
    engine gives no guarantee — so resume ALWAYS prepends a gap (the most
    conservative posture). A positive int means: if the requested
    ``resume_from`` is older than ``next - retention_window``, the history
    is gone and a gap is synthesised."""

    _cursors: dict[DomainId, _DomainCursor] = field(default_factory=dict)
    _resumed: set[DomainId] = field(default_factory=set)
    """Domains for which we have already settled the resume-gap question."""

    def _cursor(self, domain_id: DomainId) -> _DomainCursor:
        cur = self._cursors.get(domain_id)
        if cur is None:
            # Seed next_sequence one past the resumed point so assigned
            # sequences stay monotonic across a reconnect.
            start = self.resume_from.get(domain_id)
            cur = _DomainCursor(next_sequence=(start + 1) if start is not None else 0)
            self._cursors[domain_id] = cur
        return cur

    def _resume_gap_needed(self, domain_id: DomainId, cur: _DomainCursor) -> bool:
        """Decide whether a resumed domain's first event needs a leading gap.

        Conservative default: if this domain was resumed (had a
        ``resume_from`` entry) and we cannot PROVE the retained history
        covers everything since, emit a gap.
        """
        if domain_id in self._resumed:
            return False
        self._resumed.add(domain_id)
        requested = self.resume_from.get(domain_id)
        if requested is None:
            # Fresh subscription, nothing was missed by definition.
            return False
        if self.retention_window is None:
            # No retention guarantee → we cannot prove contiguity → gap.
            return True
        # History proven intact iff the requested resume point is within
        # the retention window of where the stream now resumes.
        earliest_retained = cur.next_sequence - self.retention_window
        return requested < earliest_retained

    def observe(
        self,
        domain_id: DomainId,
        payload: dict[str, Any],
        *,
        timestamp: datetime | None = None,
    ) -> list[VmEvent | EventGap]:
        """Record one raw engine observation; return what to emit.

        Returns a list because a single observation may yield a leading
        :class:`EventGap` followed by the :class:`VmEvent`. The list is
        never empty for a real observation.
        """
        cur = self._cursor(domain_id)
        out: list[VmEvent | EventGap] = []

        if self._resume_gap_needed(domain_id, cur):
            after = self.resume_from.get(domain_id, -1)
            out.append(
                EventGap(
                    domain_id=domain_id,
                    after_sequence=after,
                    detected_at=timestamp or _now(),
                )
            )

        seq = cur.next_sequence
        cur.next_sequence = seq + 1
        cur.last_emitted = seq
        out.append(
            VmEvent(
                domain_id=domain_id,
                sequence=seq,
                timestamp=timestamp or _now(),
                payload=dict(payload),
            )
        )
        return out

    def note_engine_restart(
        self,
        *,
        timestamp: datetime | None = None,
    ) -> list[VmEvent | EventGap]:
        """Engine bounced — emit a single global gap and reset cursors.

        After this, per-domain counters restart at ``0`` (the engine's
        own event identity is gone). Returns the global gap to emit.
        """
        last_global = max(
            (c.last_emitted for c in self._cursors.values() if c.last_emitted is not None),
            default=-1,
        )
        self._cursors.clear()
        self._resumed.clear()
        self.resume_from.clear()
        return [
            EventGap(
                domain_id=None,
                after_sequence=last_global,
                detected_at=timestamp or _now(),
            )
        ]

    def note_resubscribe(
        self,
        domain_ids: Iterable[DomainId],
        *,
        timestamp: datetime | None = None,
    ) -> list[VmEvent | EventGap]:
        """Reconnected to the SAME engine without a restart.

        For each domain we were tracking, conservatively decide whether a
        gap is owed based on the retention window. Domains never seen
        before are ignored (no gap until their first real event).
        """
        out: list[VmEvent | EventGap] = []
        for domain_id in domain_ids:
            cur = self._cursors.get(domain_id)
            if cur is None or cur.last_emitted is None:
                continue
            # Reset the resumed flag so the next observe() re-evaluates,
            # seeding resume_from with what we last emitted.
            self.resume_from[domain_id] = cur.last_emitted
            self._resumed.discard(domain_id)
            if self.retention_window is None:
                out.append(
                    EventGap(
                        domain_id=domain_id,
                        after_sequence=cur.last_emitted,
                        detected_at=timestamp or _now(),
                    )
                )
                # Mark resolved so observe() doesn't double-emit.
                self._resumed.add(domain_id)
        return out


__all__ = ["EventSequencer"]
