"""Durable replay tracking for external triggers."""

from __future__ import annotations

import json
from collections.abc import Mapping
from dataclasses import dataclass, field
from enum import StrEnum
from typing import TYPE_CHECKING, Literal, TypedDict, TypeGuard, cast

from mindroom.durable_write import write_json_file_durable
from mindroom.file_locks import advisory_file_lock

if TYPE_CHECKING:
    from pathlib import Path


class ExternalTriggerEventClaim(StrEnum):
    """State returned when claiming an external trigger event id."""

    FRESH = "fresh"
    IN_PROGRESS = "in_progress"
    DELIVERED = "delivered"


class ExternalTriggerReplayStoreError(RuntimeError):
    """Raised when durable replay state cannot be trusted."""


class _SerializedNonce(TypedDict):
    expires_at: int


class _SerializedEvent(TypedDict):
    state: Literal["in_progress", "delivered"]
    expires_at: int


class _SerializedReplayStore(TypedDict):
    nonces: dict[str, dict[str, _SerializedNonce]]
    events: dict[str, dict[str, _SerializedEvent]]


@dataclass
class ExternalTriggerReplayStore:
    """JSON-backed replay store for external trigger nonces and event ids."""

    control_state_root: Path
    _store_path: Path = field(init=False)
    _lock_path: Path = field(init=False)

    def __post_init__(self) -> None:
        """Bind this store to its durable state path."""
        self._store_path = self.control_state_root / "external_triggers" / "replay.json"
        self._lock_path = self._store_path.with_suffix(".json.lock")

    def claim_nonce(self, replay_scope: str, nonce: str, *, now: int, ttl_seconds: int) -> bool:
        """Return True only for the first unexpired nonce claim."""
        with advisory_file_lock(self._lock_path):
            store = self._read_store()
            _prune_expired(store, now=now)
            replay_nonces = store["nonces"].setdefault(replay_scope, {})
            if nonce in replay_nonces:
                return False
            replay_nonces[nonce] = {"expires_at": now + ttl_seconds}
            self._write_store(store)
            return True

    def claim_event_id(
        self,
        replay_scope: str,
        event_id: str,
        *,
        now: int,
        ttl_seconds: int,
    ) -> ExternalTriggerEventClaim:
        """Claim one external event id and return its replay state."""
        with advisory_file_lock(self._lock_path):
            store = self._read_store()
            _prune_expired(store, now=now)
            replay_events = store["events"].setdefault(replay_scope, {})
            event = replay_events.get(event_id)
            if event is not None:
                if event["state"] == "delivered":
                    return ExternalTriggerEventClaim.DELIVERED
                return ExternalTriggerEventClaim.IN_PROGRESS
            replay_events[event_id] = {
                "state": ExternalTriggerEventClaim.IN_PROGRESS.value,
                "expires_at": now + ttl_seconds,
            }
            self._write_store(store)
            return ExternalTriggerEventClaim.FRESH

    def mark_event_delivered(self, replay_scope: str, event_id: str, *, now: int, ttl_seconds: int) -> None:
        """Record that one external event id reached Matrix delivery."""
        with advisory_file_lock(self._lock_path):
            store = self._read_store()
            _prune_expired(store, now=now)
            replay_events = store["events"].setdefault(replay_scope, {})
            replay_events[event_id] = {
                "state": ExternalTriggerEventClaim.DELIVERED.value,
                "expires_at": now + ttl_seconds,
            }
            self._write_store(store)

    def release_event_id(self, replay_scope: str, event_id: str) -> None:
        """Remove an event id claim after delivery failure."""
        with advisory_file_lock(self._lock_path):
            store = self._read_store()
            replay_events = store["events"].get(replay_scope)
            if replay_events is None:
                return
            replay_events.pop(event_id, None)
            if not replay_events:
                store["events"].pop(replay_scope, None)
            self._write_store(store)

    def _read_store(self) -> _SerializedReplayStore:
        try:
            raw_store_text = self._store_path.read_text(encoding="utf-8")
        except FileNotFoundError:
            return _empty_store()
        except OSError as exc:
            msg = "external trigger replay store is unavailable"
            raise ExternalTriggerReplayStoreError(msg) from exc
        try:
            raw_store = json.loads(raw_store_text)
        except json.JSONDecodeError as exc:
            msg = "invalid external trigger replay store JSON"
            raise ExternalTriggerReplayStoreError(msg) from exc
        return _normalize_store(raw_store)

    def _write_store(self, store: _SerializedReplayStore) -> None:
        try:
            write_json_file_durable(self._store_path, store, indent=2, sort_keys=True)
        except OSError as exc:
            msg = "external trigger replay store is unavailable"
            raise ExternalTriggerReplayStoreError(msg) from exc


def _empty_store() -> _SerializedReplayStore:
    return {"nonces": {}, "events": {}}


def _normalize_store(raw_store: object) -> _SerializedReplayStore:
    if not isinstance(raw_store, Mapping):
        raise _invalid_store_structure()
    store_mapping = cast("Mapping[object, object]", raw_store)
    if "nonces" not in store_mapping or "events" not in store_mapping:
        raise _invalid_store_structure()
    raw_nonces = store_mapping["nonces"]
    raw_events = store_mapping["events"]
    if not isinstance(raw_nonces, Mapping) or not isinstance(raw_events, Mapping):
        raise _invalid_store_structure()
    return {
        "nonces": _normalize_nonces(cast("Mapping[object, object]", raw_nonces)),
        "events": _normalize_events(cast("Mapping[object, object]", raw_events)),
    }


def _invalid_store_structure() -> ExternalTriggerReplayStoreError:
    return ExternalTriggerReplayStoreError("invalid external trigger replay store structure")


def _is_json_int(value: object) -> TypeGuard[int]:
    return isinstance(value, int) and not isinstance(value, bool)


def _normalize_nonces(raw_nonces: Mapping[object, object]) -> dict[str, dict[str, _SerializedNonce]]:
    nonces: dict[str, dict[str, _SerializedNonce]] = {}
    for trigger_id, trigger_nonces in raw_nonces.items():
        if not isinstance(trigger_id, str) or not isinstance(trigger_nonces, Mapping):
            raise _invalid_store_structure()
        trigger_nonce_mapping = cast("Mapping[object, object]", trigger_nonces)
        normalized_trigger_nonces: dict[str, _SerializedNonce] = {}
        for nonce, record in trigger_nonce_mapping.items():
            if not isinstance(nonce, str) or not isinstance(record, Mapping):
                raise _invalid_store_structure()
            record_mapping = cast("Mapping[object, object]", record)
            expires_at = record_mapping.get("expires_at")
            if not _is_json_int(expires_at):
                raise _invalid_store_structure()
            normalized_trigger_nonces[nonce] = {"expires_at": expires_at}
        if normalized_trigger_nonces:
            nonces[trigger_id] = normalized_trigger_nonces
    return nonces


def _normalize_events(raw_events: Mapping[object, object]) -> dict[str, dict[str, _SerializedEvent]]:
    events: dict[str, dict[str, _SerializedEvent]] = {}
    for trigger_id, trigger_events in raw_events.items():
        if not isinstance(trigger_id, str) or not isinstance(trigger_events, Mapping):
            raise _invalid_store_structure()
        trigger_event_mapping = cast("Mapping[object, object]", trigger_events)
        normalized_trigger_events: dict[str, _SerializedEvent] = {}
        for event_id, record in trigger_event_mapping.items():
            if not isinstance(event_id, str) or not isinstance(record, Mapping):
                raise _invalid_store_structure()
            record_mapping = cast("Mapping[object, object]", record)
            state = record_mapping.get("state")
            expires_at = record_mapping.get("expires_at")
            if state not in {"in_progress", "delivered"} or not _is_json_int(expires_at):
                raise _invalid_store_structure()
            event_state = cast("Literal['in_progress', 'delivered']", state)
            normalized_trigger_events[event_id] = {
                "state": event_state,
                "expires_at": expires_at,
            }
        if normalized_trigger_events:
            events[trigger_id] = normalized_trigger_events
    return events


def _prune_expired(store: _SerializedReplayStore, *, now: int) -> None:
    for trigger_id, trigger_nonces in list(store["nonces"].items()):
        store["nonces"][trigger_id] = {
            nonce: record for nonce, record in trigger_nonces.items() if record["expires_at"] >= now
        }
        if not store["nonces"][trigger_id]:
            store["nonces"].pop(trigger_id)

    for trigger_id, trigger_events in list(store["events"].items()):
        store["events"][trigger_id] = {
            event_id: record for event_id, record in trigger_events.items() if record["expires_at"] >= now
        }
        if not store["events"][trigger_id]:
            store["events"].pop(trigger_id)
