"""Storage-agnostic Matrix event-cache contract."""

from __future__ import annotations

from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable

if TYPE_CHECKING:
    from collections.abc import Collection

    from .agent_message_snapshot import AgentMessageSnapshot
    from .thread_cache_state import ThreadAppendOutcome, ThreadCacheGap


class EventCacheBackendUnavailableError(RuntimeError):
    """Raised when cache storage is temporarily unreachable but not logically corrupt."""


@runtime_checkable
class ConversationEventCache(Protocol):
    """Principal-bound durable cache for joined-room conversation timelines.

    Every returned row belongs to ``principal_id`` and ``room_id``.
    A runtime-wide storage service must expose one bound view per authenticated Matrix
    account instead of sharing this interface directly between bots.

    Sync ingestion admits only joined-room ``timeline.events`` and deliberately
    excludes complete room state, invite and leave timelines, ephemeral typing
    and receipts, presence, account data, to-device events, and device-list
    changes.

    Point lookup is broader than visible conversation history, so any admitted
    timeline event with an event ID can be retained while thread projection
    renders only supported ``m.room.message`` content.

    Redaction envelopes are not stored as point events, while their durable
    effect removes or tombstones the target and repairs dependent indexes.

    Membership loss fences access and purges only the departed principal's
    retained joined-room history.
    """

    @property
    def principal_id(self) -> str:
        """Return the Matrix user ID that owns every row visible through this view."""

    @property
    def cache_generation(self) -> str | None:
        """Return the durable generation used to certify restart checkpoints when available."""

    @property
    def durable_writes_available(self) -> bool:
        """Return whether cache writes can durably persist data."""

    @property
    def is_initialized(self) -> bool:
        """Return whether the backing storage is currently initialized."""

    async def initialize(self) -> None:
        """Initialize any backing storage."""

    def for_principal(self, principal_id: str) -> ConversationEventCache:
        """Return a cache view that can access only ``principal_id`` rows."""

    async def close(self) -> None:
        """Close any backing storage."""

    async def get_thread_events(
        self,
        room_id: str,
        thread_id: str,
    ) -> list[dict[str, Any]] | None:
        """Return every message in one thread, sorted by timestamp.

        Collapsed, not truncated: each message arrives with the single edit that currently wins
        it rather than with every edit it ever received. No message is left out.
        """

    async def get_thread_event_ids(self, room_id: str, thread_id: str) -> set[str]:
        """Return every raw event ID this thread holds, superseded edits included.

        Distinct from ``get_thread_events``, which collapses superseded edits away and requires a
        payload. This one answers which rows this thread durably owns, which is the question the
        collapse security tests ask: a point-cached original with no membership row must not appear
        here, and a superseded edit must.
        """

    async def has_thread_snapshot(self, room_id: str, thread_id: str) -> bool:
        """Return whether any durably present snapshot rows exist for one thread.

        A gap marker says a snapshot is unusable; its absence does not say a snapshot exists. Both
        answers are needed to decide whether a thread would be served from cache, and a caller that
        asks only about the marker treats a never-cached thread as a cache hit.

        Distinct from ``get_thread_events``: this one answers existence in a single bounded probe
        instead of loading and collapsing the whole thread to test it for emptiness.
        """

    async def get_recent_room_thread_ids(self, room_id: str, *, limit: int) -> list[str]:
        """Return locally known thread IDs for one room ordered by newest cached activity."""

    async def get_thread_cache_gap(self, room_id: str, thread_id: str) -> ThreadCacheGap | None:
        """Return the durable gap marker recorded against one cached thread, if any."""

    async def get_event(self, room_id: str, event_id: str) -> dict[str, Any] | None:
        """Return one cached event payload by event ID."""

    async def get_recent_room_events(
        self,
        room_id: str,
        *,
        event_type: str,
        since_ts_ms: int,
        limit: int = 500,
    ) -> list[dict[str, Any]]:
        """Return recent cached room events of one type, newest first."""

    async def get_latest_edit(
        self,
        room_id: str,
        original_event_id: str,
        *,
        sender: str | None = None,
    ) -> dict[str, Any] | None:
        """Return the latest cached edit event for one original event."""

    async def get_latest_agent_message_snapshot(
        self,
        room_id: str,
        thread_id: str | None,
        sender: str,
        *,
        runtime_started_at: float | None,
    ) -> AgentMessageSnapshot | None:
        """Return the latest visible cached message from one sender in the given scope."""

    async def get_mxc_text(self, room_id: str, event_id: str, mxc_url: str) -> str | None:
        """Return MXC plaintext only while a visible owning event still references it."""

    async def get_mxc_texts(
        self,
        room_id: str,
        references: Collection[tuple[str, str]],
        *,
        expected_membership_epoch: int,
    ) -> dict[tuple[str, str], str]:
        """Return scoped MXC plaintext for many surviving event references in one read."""

    async def store_event(
        self,
        event_id: str,
        room_id: str,
        event_data: dict[str, Any],
        *,
        expected_membership_epoch: int | None = None,
    ) -> None:
        """Insert or replace one event without replacing clear payloads with opaque ciphertext."""

    async def store_events_batch(
        self,
        events: list[tuple[str, str, dict[str, Any]]],
        *,
        expected_membership_epoch: int | None = None,
    ) -> None:
        """Insert or replace events without replacing clear payloads with opaque ciphertext."""

    async def store_mxc_text(
        self,
        room_id: str,
        event_id: str,
        mxc_url: str,
        text: str,
        *,
        expected_membership_epoch: int | None = None,
    ) -> bool:
        """Cache MXC plaintext only for a visible, non-tombstoned owning event."""

    async def replace_thread(
        self,
        room_id: str,
        thread_id: str,
        events: list[dict[str, Any]],
        *,
        expected_membership_epoch: int,
        fetch_started_at: float,
    ) -> bool:
        """Install one fetched snapshot, clearing a gap marker the fetch covers.

        Returns whether this call left a usable snapshot behind, which is not the same as "stored":
        a fetch older than the installed one is skipped and still returns ``True``, because a fresher
        snapshot is already there and the caller has nothing to retry. ``False`` means writes were
        unavailable. A gap recorded after ``fetch_started_at`` survives the replacement at either
        scope, so the next read refetches.
        """

    async def invalidate_thread(self, room_id: str, thread_id: str) -> None:
        """Delete cached events for one thread."""

    async def invalidate_room_threads(self, room_id: str) -> None:
        """Delete every cached thread snapshot for one room."""

    async def mark_thread_gap(self, room_id: str, thread_id: str, *, reason: str) -> None:
        """Persist one durable thread gap marker."""

    async def mark_room_threads_gap(self, room_id: str, *, reason: str) -> None:
        """Persist a durable gap marker against every cached thread in one room."""

    async def apply_thread_mutation_append(
        self,
        room_id: str,
        thread_id: str,
        event: dict[str, Any],
        *,
        append_failed_reason: str,
    ) -> ThreadAppendOutcome:
        """Append one threaded mutation, recording a gap marker when it cannot land."""

    async def get_thread_id_for_event(self, room_id: str, event_id: str) -> str | None:
        """Return the cached thread ID for one event."""

    async def redact_event(
        self,
        room_id: str,
        event_id: str,
    ) -> bool:
        """Delete one cached event after a redaction."""

    async def purge_room(self, room_id: str) -> None:
        """Delete only this principal's cached ownership for one left or banned room."""

    def mark_room_departed(self, room_id: str) -> int:
        """Synchronously reject access, queue durable cleanup, and return the new room-fence epoch."""

    def room_departure_epoch(self, room_id: str) -> int:
        """Return the current room-fence epoch for ordering queued membership work."""

    async def room_membership_epoch(self, room_id: str) -> int | None:
        """Certify and return the durable room-membership transition epoch."""

    async def mark_room_joined(
        self,
        room_id: str,
        *,
        expected_departure_epoch: int,
    ) -> None:
        """Allow cache access after an authoritative rejoin finishes any pending purge."""

    async def purge_principal(self) -> None:
        """Delete every cached row owned by this principal."""

    def disable(self, reason: str) -> None:
        """Disable the advisory cache for the rest of the runtime."""

    def runtime_diagnostics(self) -> dict[str, object]:
        """Return log-safe runtime state for sync certification diagnostics."""

    def pending_durable_write_room_ids(self) -> tuple[str, ...]:
        """Return rooms with runtime-only writes that must persist before certifying a sync token."""

    async def flush_pending_durable_writes(self, room_id: str) -> None:
        """Persist runtime-only writes for one room before certifying a sync token."""


@runtime_checkable
class SharedConversationEventCache(Protocol):
    """Runtime-owned cache storage that creates isolated principal views."""

    @property
    def durable_writes_available(self) -> bool:
        """Return whether cache writes can durably persist data."""

    @property
    def is_initialized(self) -> bool:
        """Return whether the backing storage is currently initialized."""

    async def initialize(self) -> None:
        """Initialize any backing storage."""

    async def close(self) -> None:
        """Close any backing storage."""

    def disable(self, reason: str) -> None:
        """Disable the advisory cache for the rest of the runtime."""

    def for_principal(self, principal_id: str) -> ConversationEventCache:
        """Return a cache view that can access only ``principal_id`` rows."""
