"""C6 tile cache Protocols (AZ-303).

Three ``runtime_checkable`` ``typing.Protocol`` declarations:

- :class:`TileStore` — filesystem-resident JPEG body I/O.
- :class:`TileMetadataStore` — Postgres-backed spatial + LRU + voting bookkeeping.
- :class:`DescriptorIndex` — per-flight FAISS HNSW vector index.

The contract files at ``_docs/02_document/contracts/c6_tile_cache/``
are the authoritative human-readable shape; this module mirrors them
1:1. Concrete impls live in sibling modules
(``postgres_filesystem_store``, ``faiss_descriptor_index``); the
composition root selects between them via
``runtime_root.storage_factory``.
"""

from __future__ import annotations

from datetime import datetime
from pathlib import Path
from typing import TYPE_CHECKING, Protocol, runtime_checkable

from gps_denied_onboard.components.c6_tile_cache._tile_pixel_handle import (
    TilePixelHandle,
)
from gps_denied_onboard.components.c6_tile_cache._types import (
    Bbox,
    HnswParams,
    IndexMetadata,
    TileId,
    TileMetadata,
    TileMetadataPersistent,
    TileSource,
    VotingStatus,
)

if TYPE_CHECKING:
    import numpy as np

__all__ = [
    "DescriptorIndex",
    "TileMetadataStore",
    "TileStore",
]


@runtime_checkable
class TileStore(Protocol):
    """JPEG body store. See ``tile_store.md`` v1.0.0.

    The four-method surface is intentionally minimal: read, write,
    exists, delete. Spatial queries live on :class:`TileMetadataStore`;
    descriptor lookups live on :class:`DescriptorIndex`.
    """

    def read_tile_pixels(self, tile_id: TileId) -> TilePixelHandle:
        """Return a read-only mmap handle to the tile's JPEG bytes.

        Raises :class:`TileNotFoundError` if the tile is absent;
        :class:`TileMetadataError` if the row exists but the JPEG file
        is missing (or vice-versa — Invariant I-8).
        """
        ...

    def write_tile(self, tile_blob: bytes, metadata: TileMetadata) -> None:
        """Atomically persist a JPEG body + its sidecar + metadata row.

        Raises :class:`ContentHashMismatchError` if
        ``sha256(tile_blob) != metadata.content_sha256_hex``;
        :class:`FreshnessRejectionError` if the C6 freshness gate
        rejects the insert; :class:`TileFsError` /
        :class:`TileMetadataError` on infrastructure failure.
        """
        ...

    def tile_exists(self, tile_id: TileId) -> bool:
        """Cheap existence check (page-cache lookup). Does not load bytes."""
        ...

    def delete_tile(self, tile_id: TileId) -> bool:
        """Remove the JPEG + sidecar; return ``True`` if a file was removed.

        Returns ``False`` (no exception) if the tile was already
        missing — the LRU evictor relies on this no-error path
        (Invariant I-6).
        """
        ...


@runtime_checkable
class TileMetadataStore(Protocol):
    """Postgres-backed metadata + spatial index + LRU bookkeeping.

    See ``tile_metadata_store.md`` v1.0.0 for the nine-method surface
    and the I-1..I-9 invariants.
    """

    def query_by_bbox(
        self,
        bbox: Bbox,
        zoom: int,
        *,
        voting_filter: VotingStatus | None = None,
        source_filter: TileSource | None = None,
    ) -> list[TileMetadata]:
        """Spatial query, optionally filtered by voting status / source."""
        ...

    def insert_metadata(self, metadata: TileMetadata) -> None:
        """Insert a row; freshness gate runs atomically (Invariant I-7).

        Raises :class:`FreshnessRejectionError` in
        ``ACTIVE_CONFLICT`` sectors when the tile is too old;
        :class:`TileMetadataError` on composite-key collision.
        """
        ...

    def update_voting_status(
        self, tile_id: TileId, status: VotingStatus
    ) -> None:
        """Forward-only state transition. Backward transitions raise."""
        ...

    def mark_uploaded(self, tile_id: TileId, uploaded_at: datetime) -> None:
        """Stamp ``uploaded_at`` so the row leaves ``pending_uploads``."""
        ...

    def pending_uploads(self) -> list[TileMetadata]:
        """Rows where ``source == ONBOARD_INGEST`` and ``uploaded_at IS NULL``.

        Single source of truth for C11 ``TileUploader`` (Invariant I-9);
        the uploader MUST NOT scan the filesystem.
        """
        ...

    def record_lru_access(
        self, tile_id: TileId, accessed_at: datetime
    ) -> None:
        """Update ``accessed_at = max(current, supplied)`` (Invariant I-4)."""
        ...

    def lru_candidates(
        self, *, max_count: int
    ) -> list[TileMetadataPersistent]:
        """Return up to ``max_count`` oldest-``accessed_at``-first rows."""
        ...

    def total_disk_bytes(self) -> int:
        """``SUM(disk_bytes) WHERE voting_status != REJECTED`` (Invariant I-5)."""
        ...

    def get_by_id(self, tile_id: TileId) -> TileMetadata | None:
        """Point lookup; returns ``None`` if absent (NOT raises)."""
        ...


@runtime_checkable
class DescriptorIndex(Protocol):
    """Per-flight FAISS HNSW vector index.

    See ``descriptor_index.md`` v1.0.0 for the five-method surface
    and the I-1..I-8 invariants. The single in-flight consumer is C2
    VPR; the offline producer is C10 ``CacheProvisioner``.
    """

    def search_topk(
        self, query: "np.ndarray", k: int
    ) -> list[tuple[TileId, float]]:
        """Top-K nearest neighbour search.

        ``query`` must be ``(descriptor_dim,)`` ``float32`` C-contiguous;
        dimension mismatch raises :class:`IndexUnavailableError`
        (Invariant I-3). Returns ≤ k results, distance-ascending.
        """
        ...

    def descriptor_dim(self) -> int:
        """Indexed-vector dimension; fixed at build time (Invariant I-3)."""
        ...

    def mmap_handle(self) -> Path:
        """Absolute path to the ``.index`` file.

        Used by C12 operator post-flight inspection tooling; in-flight
        callers MUST go through ``search_topk``.
        """
        ...

    def rebuild_from_descriptors(
        self,
        descriptors: "np.ndarray",
        tile_ids: list[TileId],
        hnsw_params: HnswParams,
    ) -> None:
        """Offline full-rebuild (C10 pre-flight; Invariant I-5 atomic).

        Raises :class:`IndexBuildError` on dtype/shape mismatch or
        underlying FAISS failure; :class:`TileFsError` on disk write
        failure.
        """
        ...

    def index_metadata(self) -> IndexMetadata:
        """Sidecar metadata block (Invariant I-6 sidecar coherence)."""
        ...
