"""Backend Abstraction Layer (BAL) protocol definitions for the ACMS.

The BAL provides a uniform interface to heterogeneous data stores used
by the Advanced Context Management System.  Each backend type has a
protocol and a corresponding frozen result dataclass:

| Protocol          | Result Type    | Purpose                         |
|-------------------|----------------|---------------------------------|
| ``TextBackend``   | ``TextResult`` | Full-text search over resources |
| ``VectorBackend`` | ``VectorResult``| Embedding similarity search    |
| ``GraphBackend``  | ``GraphResult``| SPARQL queries and traversals  |

Strategies issue queries through ``ScopedView`` wrappers that inject
a ``resource_filter`` restricting results to in-scope resources.  The
protocols below accept a ``scope`` parameter (``frozenset[str]`` of
resource ULIDs) to support this filtering.

Based on ``docs/specification.md`` > ACMS > Backend Abstraction Layer
(BAL) and ADR-014.
"""

from __future__ import annotations

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

# ---------------------------------------------------------------------------
# Result dataclasses (frozen / immutable)
# ---------------------------------------------------------------------------


@dataclass(frozen=True)
class TextResult:
    """Immutable result returned by a :class:`TextBackend` search.

    Attributes:
        uko_uri: UKO URI of the matching information unit.
        content: Matched text content (snippet or full).
        score: Relevance score (0.0 -- 1.0, higher is more relevant).
        metadata: Arbitrary backend-specific metadata.
    """

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

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


@dataclass(frozen=True)
class VectorResult:
    """Immutable result returned by a :class:`VectorBackend` search.

    Attributes:
        uko_uri: UKO URI of the matching information unit.
        content: Text content of the matched chunk.
        score: Cosine similarity score (0.0 -- 1.0).
        metadata: Arbitrary backend-specific metadata.
    """

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

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


@dataclass(frozen=True)
class GraphResult:
    """Immutable result returned by a :class:`GraphBackend` query.

    Attributes:
        triples: List of (subject, predicate, object) triples.
        metadata: Arbitrary backend-specific metadata.
    """

    triples: list[tuple[str, str, str]] = field(default_factory=list)
    metadata: dict[str, str] = field(default_factory=dict)

    def __post_init__(self) -> None:
        """Validate field constraints."""
        for triple in self.triples:
            if len(triple) != 3:
                raise ValueError(
                    f"Each triple must have exactly 3 elements, got {len(triple)}"
                )
            if not all(isinstance(elem, str) for elem in triple):
                raise ValueError("All triple elements must be strings")


# ---------------------------------------------------------------------------
# Backend protocols
# ---------------------------------------------------------------------------


@runtime_checkable
class TextBackend(Protocol):
    """Protocol for full-text search backends.

    Implementations wrap stores such as Tantivy, SQLite FTS, or
    Elasticsearch.  The ``scope`` parameter restricts results to
    resources whose ULIDs are in the given set.

    Lifecycle::

        backend = InMemoryTextBackend()
        results = backend.search("auth flow", scope=frozenset({"RES01"}))
    """

    def search(
        self,
        query: str,
        *,
        scope: frozenset[str],
        max_results: int = 20,
    ) -> list[TextResult]:
        """Search for text matching *query* within *scope*.

        Args:
            query: Natural-language or keyword query string.
                Must be non-empty.
            scope: Frozenset of resource ULIDs to restrict results to.
            max_results: Maximum number of results to return.
                Must be positive.

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

        Raises:
            ValueError: If *query* is empty or *max_results* is not
                positive.
        """
        ...


@runtime_checkable
class VectorBackend(Protocol):
    """Protocol for vector similarity search backends.

    Implementations wrap stores such as FAISS, Qdrant, or Weaviate.

    Lifecycle::

        backend = InMemoryVectorBackend()
        results = backend.similarity_search(
            embedding=[0.1, 0.2, 0.3],
            scope=frozenset({"RES01"}),
        )
    """

    def similarity_search(
        self,
        embedding: list[float],
        *,
        scope: frozenset[str],
        top_k: int = 20,
    ) -> list[VectorResult]:
        """Find vectors most similar to *embedding* within *scope*.

        Args:
            embedding: Query embedding vector.  Must be non-empty.
            scope: Frozenset of resource ULIDs to restrict results to.
            top_k: Maximum number of results to return.
                Must be positive.

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

        Raises:
            ValueError: If *embedding* is empty or *top_k* is not
                positive.
        """
        ...


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

    Implementations wrap stores such as Blazegraph, Jena, Neo4j, or
    Stardog that hold UKO triples.

    Lifecycle::

        backend = InMemoryGraphBackend()
        result = backend.sparql_query(
            "SELECT ?s WHERE { ?s a uko:Container }",
            scope=frozenset({"RES01"}),
        )
    """

    def sparql_query(
        self,
        query: str,
        *,
        scope: frozenset[str],
    ) -> GraphResult:
        """Execute a SPARQL query within *scope*.

        Args:
            query: SPARQL query string.  Must be non-empty.
            scope: Frozenset of resource ULIDs to restrict results to.

        Returns:
            A :class:`GraphResult` containing matched triples.

        Raises:
            ValueError: If *query* is empty.
        """
        ...

    def get_triples(
        self,
        subject: str,
    ) -> GraphResult:
        """Retrieve all triples for a given *subject*.

        Args:
            subject: The UKO URI of the subject node.  Must be
                non-empty.

        Returns:
            A :class:`GraphResult` containing the subject's triples.

        Raises:
            ValueError: If *subject* is empty.
        """
        ...

    def traverse(
        self,
        start: str,
        *,
        depth: int = 2,
    ) -> GraphResult:
        """Traverse the graph from *start* up to *depth* hops.

        Args:
            start: UKO URI to begin traversal from.  Must be
                non-empty.
            depth: Maximum number of hops from *start*.  Must be
                non-negative.

        Returns:
            A :class:`GraphResult` containing discovered triples.

        Raises:
            ValueError: If *start* is empty or *depth* is negative.
        """
        ...
