"""Index backend protocols for the ACMS indexing layer.

Defines the **write-side** backend protocols used by :class:`UKOIndexer`
to persist analysed content into text, vector, and graph stores.  These
are distinct from the **read-side** query protocols in ``backends.py``
(``TextBackend``, ``VectorBackend``, ``GraphBackend``) which are used by
context strategies at query time.

Each protocol is ``@runtime_checkable`` so that DI containers can verify
implementations satisfy the contract at registration time.

+-------------------------+-----------------------------+
| Protocol                | Purpose                     |
+=========================+=============================+
| ``TextIndexBackend``    | Full-text document indexing  |
| ``VectorIndexBackend``  | Embedding vector indexing    |
| ``GraphIndexBackend``   | UKO triple storage          |
+-------------------------+-----------------------------+

Based on ``docs/specification.md`` > ACMS > Custom Index Backends
and Real-time Index Synchronization.
"""

from __future__ import annotations

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

# ---------------------------------------------------------------------------
# Result types
# ---------------------------------------------------------------------------


@dataclass(frozen=True)
class IndexedDocument:
    """Record of a document successfully indexed in a text backend.

    Attributes:
        project: Namespaced project name that owns the document.
        doc_id: Unique document identifier (typically a UKO URI).
        char_count: Number of characters indexed.
    """

    project: str
    doc_id: str
    char_count: int = 0

    def __post_init__(self) -> None:
        if not self.project or not self.project.strip():
            raise ValueError("project must be a non-empty string")
        if not self.doc_id or not self.doc_id.strip():
            raise ValueError("doc_id must be a non-empty string")
        if self.char_count < 0:
            raise ValueError(f"char_count must be non-negative, got {self.char_count}")


@dataclass(frozen=True)
class SearchResult:
    """Result returned by :meth:`TextIndexBackend.search`.

    Attributes:
        doc_id: Document identifier.
        content: Matched text snippet.
        score: Relevance score in ``[0.0, 1.0]``.
        metadata: Backend-specific metadata.
    """

    doc_id: str
    content: str
    score: float
    metadata: dict[str, str] = field(default_factory=dict)

    def __post_init__(self) -> None:
        if not self.doc_id or not self.doc_id.strip():
            raise ValueError("doc_id must be a non-empty string")
        if not (0.0 <= self.score <= 1.0):
            raise ValueError(f"score must be between 0.0 and 1.0, got {self.score}")


# ---------------------------------------------------------------------------
# TextIndexBackend
# ---------------------------------------------------------------------------


@runtime_checkable
class TextIndexBackend(Protocol):
    """Protocol for full-text document indexing backends.

    Write-side counterpart to :class:`TextBackend` (query-side).
    Implementations wrap stores such as Tantivy, SQLite FTS, or
    Elasticsearch for indexing documents produced by analyzers.

    Based on ``specification.md`` > ACMS > Custom Index Backends.
    """

    def index_document(
        self,
        project: str,
        doc_id: str,
        content: str,
        metadata: dict[str, str],
    ) -> IndexedDocument:
        """Index a document for full-text search.

        Args:
            project: Namespaced project name (e.g. ``"local/my-app"``).
            doc_id: Unique document identifier (typically a UKO URI).
            content: Full text content to index.
            metadata: Key-value metadata to store alongside the document.

        Returns:
            Record of the indexed document.

        Raises:
            ValueError: If *project*, *doc_id*, or *content* is empty
                or whitespace-only.
        """
        ...  # pragma: no cover

    def search(
        self,
        project: str,
        query: str,
        limit: int = 20,
    ) -> list[SearchResult]:
        """Search indexed documents for *query*.

        Args:
            project: Namespaced project name to search within.
            query: Search query string.
            limit: Maximum number of results.  Must be positive.

        Returns:
            List of :class:`SearchResult` ordered by descending score.

        Raises:
            ValueError: If *project* or *query* is empty or
                whitespace-only, or *limit* < 1.
        """
        ...  # pragma: no cover

    def remove_document(
        self,
        project: str,
        doc_id: str,
    ) -> None:
        """Remove a document from the index.

        Args:
            project: Namespaced project name.
            doc_id: Document identifier to remove.

        Raises:
            ValueError: If *project* or *doc_id* is empty or
                whitespace-only.
        """
        ...  # pragma: no cover

    def rebuild_index(self, project: str) -> None:
        """Rebuild the full-text index for *project*.

        Drops and recreates the index.  Used during maintenance
        reindex operations.

        Args:
            project: Namespaced project name.

        Raises:
            ValueError: If *project* is empty or whitespace-only.
        """
        ...  # pragma: no cover


# ---------------------------------------------------------------------------
# VectorIndexBackend
# ---------------------------------------------------------------------------


@runtime_checkable
class VectorIndexBackend(Protocol):
    """Protocol for vector embedding indexing backends.

    Write-side counterpart to :class:`VectorBackend` (query-side).
    Implementations wrap stores such as FAISS, Qdrant, or Weaviate.

    Based on ``specification.md`` > ACMS > Custom Index Backends.
    """

    def index_embedding(
        self,
        project: str,
        doc_id: str,
        embedding: list[float],
        metadata: dict[str, str],
    ) -> None:
        """Index a vector embedding.

        Args:
            project: Namespaced project name.
            doc_id: Unique document identifier.
            embedding: Embedding vector.  Must be non-empty.
            metadata: Key-value metadata.

        Raises:
            ValueError: If *project* or *doc_id* is empty or
                whitespace-only, or *embedding* is empty.
        """
        ...  # pragma: no cover

    def search_similar(
        self,
        project: str,
        query_embedding: list[float],
        limit: int = 20,
        min_relevance: float = 0.0,
    ) -> list[SearchResult]:
        """Find vectors most similar to *query_embedding*.

        Args:
            project: Namespaced project name.
            query_embedding: Query embedding vector.
            limit: Maximum number of results.  Must be positive.
            min_relevance: Minimum similarity threshold in
                ``[0.0, 1.0]``.

        Returns:
            List of :class:`SearchResult` ordered by descending score.

        Raises:
            ValueError: If *project* is empty or whitespace-only,
                *query_embedding* is empty, or *limit* < 1.
        """
        ...  # pragma: no cover

    def remove_embedding(
        self,
        project: str,
        doc_id: str,
    ) -> None:
        """Remove an embedding from the index.

        Args:
            project: Namespaced project name.
            doc_id: Document identifier to remove.

        Raises:
            ValueError: If *project* or *doc_id* is empty or
                whitespace-only.
        """
        ...  # pragma: no cover


# ---------------------------------------------------------------------------
# GraphIndexBackend
# ---------------------------------------------------------------------------


@runtime_checkable
class GraphIndexBackend(Protocol):
    """Protocol for knowledge-graph indexing backends.

    Write-side counterpart to :class:`GraphBackend` (query-side).
    Implementations wrap stores such as Blazegraph, Neo4j, or RDFLib.

    Based on ``specification.md`` > ACMS > Custom Index Backends.
    """

    def add_triple(
        self,
        project: str,
        subject: str,
        predicate: str,
        obj: str,
    ) -> None:
        """Add a single triple to the graph.

        Args:
            project: Namespaced project name.
            subject: Subject URI.
            predicate: Predicate URI.
            obj: Object URI or literal value.

        Raises:
            ValueError: If *project*, *subject*, *predicate*, or *obj*
                is empty or whitespace-only.
        """
        ...  # pragma: no cover

    def query(
        self,
        project: str,
        sparql: str,
    ) -> list[dict[str, str]]:
        """Execute a SPARQL query against the graph.

        .. note::

            Implementations **must** parameterise or sanitise the
            *sparql* argument before passing it to the underlying store.
            Never interpolate untrusted user input directly into the
            query string.

        Args:
            project: Namespaced project name.
            sparql: SPARQL query string.

        Returns:
            List of binding dictionaries.

        Raises:
            ValueError: If *project* or *sparql* is empty or
                whitespace-only.
        """
        ...  # pragma: no cover

    def remove_triples(
        self,
        project: str,
        subject: str | None,
        predicate: str | None,
        obj: str | None,
    ) -> None:
        """Remove triples matching the given pattern.

        Pass ``None`` for any component to match all values for that
        position (wildcard).

        Args:
            project: Namespaced project name.
            subject: Subject URI filter, or ``None`` for wildcard.
            predicate: Predicate URI filter, or ``None`` for wildcard.
            obj: Object filter, or ``None`` for wildcard.

        Raises:
            ValueError: If *project* is empty or whitespace-only, or
                all three filters are ``None`` (would delete everything).
        """
        ...  # pragma: no cover


# ---------------------------------------------------------------------------
# Module exports
# ---------------------------------------------------------------------------

__all__: list[str] = [
    "GraphIndexBackend",
    "IndexedDocument",
    "SearchResult",
    "TextIndexBackend",
    "VectorIndexBackend",
]
