from __future__ import annotations

from dataclasses import dataclass, field
from typing import Protocol, runtime_checkable


class VectorIndexError(Exception):
    """Raised when a vector index operation fails.

    All public methods on :class:`VectorIndex` implementations raise this on
    failure so callers can handle vector-specific errors uniformly.
    """


@dataclass
class SearchResult:
    """Single result returned by :meth:`VectorIndex.search`.

    Attributes:
        snapshot_id: Unique identifier of the matched snapshot.
        score: Cosine-similarity score normalised to ``[0, 1]``.
        metadata: Arbitrary key-value pairs stored alongside the vector.
    """

    snapshot_id: str
    score: float
    metadata: dict = field(default_factory=dict)


@runtime_checkable
class VectorIndex(Protocol):
    """Protocol that every vector-index backend must satisfy.

    Implementors do **not** need to inherit from this class  structural
    subtyping (duck typing) is sufficient.  The ``@runtime_checkable``
    decorator allows ``isinstance(obj, VectorIndex)`` checks at runtime.

    Example::

        class MyBackend:
            def index(self, snapshot_id, vector, metadata=None): ...
            def search(self, vector, topk=10, filter=None): ...
            def delete(self, snapshot_id): ...
            def health_check(self): ...

        assert isinstance(MyBackend(), VectorIndex)
    """

    def index(
        self,
        snapshot_id: str,
        vector: list[float],
        metadata: dict | None = None,
    ) -> None:
        """Upsert *vector* for *snapshot_id* into the index.

        Args:
            snapshot_id: Unique identifier for the snapshot.
            vector: Dense fp32 embedding to store.
            metadata: Optional arbitrary metadata to persist alongside the
                vector.  Must be JSON-serialisable.

        Raises:
            VectorIndexError: If the upsert operation fails.
        """
        ...

    def search(
        self,
        vector: list[float],
        topk: int = 10,
        filter: str | None = None,
    ) -> list[SearchResult]:
        """Return the *topk* nearest neighbours of *vector*.

        Args:
            vector: Query embedding (must match the indexed dimension).
            topk: Maximum number of results to return.
            filter: Optional scalar filter expression.  Syntax is
                backend-defined (e.g. ``"snapshot_id != 'old-snap'"``).

        Returns:
            A list of :class:`SearchResult` ordered by descending similarity,
            containing at most *topk* elements.

        Raises:
            VectorIndexError: If the search operation fails.
        """
        ...

    def delete(self, snapshot_id: str) -> bool:
        """Remove a snapshot from the index.

        Args:
            snapshot_id: Identifier of the snapshot to remove.

        Returns:
            ``True`` if the entry existed and was deleted; ``False`` if the
            ID was not found.

        Raises:
            VectorIndexError: If the delete operation fails unexpectedly.
        """
        ...

    def health_check(self) -> bool:
        """Verify the index is accessible and operational.

        Returns:
            ``True`` when the backend is healthy.

        Raises:
            VectorIndexError: If the health check itself errors.
        """
        ...
