"""In-memory stub implementations for index backend protocols.

These stubs satisfy :class:`TextIndexBackend`,
:class:`VectorIndexBackend`, and :class:`GraphIndexBackend` with
minimal in-memory implementations.  They serve as:

1. **Development placeholders** while physical store integrations
   (Tantivy, FAISS, Blazegraph, etc.) are built — selected at
   startup via DI container provider configuration.
2. **Reference implementations** documenting the expected behaviour
   and argument validation of each protocol method.
3. **Test doubles** for indexer and pipeline tests without external
   infrastructure (imported by ``features/mocks/`` and Robot helpers).

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

from __future__ import annotations

import structlog

from cleveragents.domain.models.acms.index_backends import (
    GraphIndexBackend,
    IndexedDocument,
    SearchResult,
    TextIndexBackend,
    VectorIndexBackend,
)

logger = structlog.get_logger(__name__)

#: Default maximum entries per stub to prevent unbounded memory growth.
DEFAULT_MAX_STUB_ENTRIES: int = 100_000


# ---------------------------------------------------------------------------
# Validation helpers
# ---------------------------------------------------------------------------


def _require_non_empty(value: str, name: str) -> None:
    """Raise ``ValueError`` if *value* is empty or whitespace-only."""
    if not value or not value.strip():
        raise ValueError(f"{name} must be a non-empty string")


# ---------------------------------------------------------------------------
# InMemoryTextIndexBackend
# ---------------------------------------------------------------------------


class InMemoryTextIndexBackend:
    """Stub :class:`TextIndexBackend` — dict-based, with max_entries cap."""

    def __init__(self, *, max_entries: int = DEFAULT_MAX_STUB_ENTRIES) -> None:
        self._docs: dict[tuple[str, str], tuple[str, dict[str, str]]] = {}
        self._max_entries = max_entries

    def index_document(
        self,
        project: str,
        doc_id: str,
        content: str,
        metadata: dict[str, str],
    ) -> IndexedDocument:
        """Index a document in the in-memory store."""
        _require_non_empty(project, "project")
        _require_non_empty(doc_id, "doc_id")
        _require_non_empty(content, "content")
        if (project, doc_id) not in self._docs and len(self._docs) >= self._max_entries:
            raise RuntimeError(
                f"InMemoryTextIndexBackend: max_entries ({self._max_entries}) reached"
            )
        self._docs[(project, doc_id)] = (content, dict(metadata))
        logger.debug(
            "index.text.document_indexed",
            project=project,
            doc_id=doc_id,
            char_count=len(content),
        )
        return IndexedDocument(
            project=project,
            doc_id=doc_id,
            char_count=len(content),
        )

    def search(
        self,
        project: str,
        query: str,
        limit: int = 20,
    ) -> list[SearchResult]:
        """Search documents via naive substring matching."""
        _require_non_empty(project, "project")
        _require_non_empty(query, "query")
        if limit < 1:
            raise ValueError(f"limit must be positive, got {limit}")
        results: list[SearchResult] = []
        query_lower = query.lower()
        for (proj, doc_id), (content, meta) in self._docs.items():
            if proj != project:
                continue
            if query_lower in content.lower():
                results.append(
                    SearchResult(
                        doc_id=doc_id,
                        content=content[:200],
                        score=1.0,
                        metadata=dict(meta),
                    )
                )
                if len(results) >= limit:
                    break
        return results

    def remove_document(self, project: str, doc_id: str) -> None:
        """Remove a document from the in-memory store."""
        _require_non_empty(project, "project")
        _require_non_empty(doc_id, "doc_id")
        self._docs.pop((project, doc_id), None)
        logger.debug("index.text.document_removed", project=project, doc_id=doc_id)

    def rebuild_index(self, project: str) -> None:
        """Remove all documents for *project* (simulates rebuild)."""
        _require_non_empty(project, "project")
        keys_to_remove = [k for k in self._docs if k[0] == project]
        for k in keys_to_remove:
            del self._docs[k]
        logger.debug(
            "index.text.index_rebuilt",
            project=project,
            removed_count=len(keys_to_remove),
        )

    @property
    def document_count(self) -> int:
        """Total number of indexed documents (test helper)."""
        return len(self._docs)


# ---------------------------------------------------------------------------
# InMemoryVectorIndexBackend
# ---------------------------------------------------------------------------


class InMemoryVectorIndexBackend:
    """Stub :class:`VectorIndexBackend` — dict-based, with max_entries cap."""

    def __init__(self, *, max_entries: int = DEFAULT_MAX_STUB_ENTRIES) -> None:
        self._embeddings: dict[tuple[str, str], tuple[list[float], dict[str, str]]] = {}
        self._max_entries = max_entries

    def index_embedding(
        self,
        project: str,
        doc_id: str,
        embedding: list[float],
        metadata: dict[str, str],
    ) -> None:
        """Store an embedding in the in-memory store."""
        _require_non_empty(project, "project")
        _require_non_empty(doc_id, "doc_id")
        if not embedding:
            raise ValueError("embedding must be a non-empty list")
        if (project, doc_id) not in self._embeddings and len(
            self._embeddings
        ) >= self._max_entries:
            raise RuntimeError(
                f"InMemoryVectorIndexBackend: max_entries ({self._max_entries}) reached"
            )
        self._embeddings[(project, doc_id)] = (list(embedding), dict(metadata))
        logger.debug(
            "index.vector.embedding_indexed",
            project=project,
            doc_id=doc_id,
            dimensions=len(embedding),
        )

    def search_similar(
        self,
        project: str,
        query_embedding: list[float],
        limit: int = 20,
        min_relevance: float = 0.0,
    ) -> list[SearchResult]:
        """Return stored embeddings for *project* (no real similarity)."""
        _require_non_empty(project, "project")
        if not query_embedding:
            raise ValueError("query_embedding must be a non-empty list")
        if limit < 1:
            raise ValueError(f"limit must be positive, got {limit}")
        if not (0.0 <= min_relevance <= 1.0):
            raise ValueError(
                f"min_relevance must be in [0.0, 1.0], got {min_relevance}"
            )
        results: list[SearchResult] = []
        for (proj, doc_id), (_emb, meta) in self._embeddings.items():
            if proj != project:
                continue
            score = 1.0  # stub always returns perfect score
            if score < min_relevance:
                continue
            results.append(
                SearchResult(
                    doc_id=doc_id,
                    content=f"embedding:{doc_id}",
                    score=score,
                    metadata=dict(meta),
                )
            )
            if len(results) >= limit:
                break
        return results

    def remove_embedding(self, project: str, doc_id: str) -> None:
        """Remove an embedding from the in-memory store."""
        _require_non_empty(project, "project")
        _require_non_empty(doc_id, "doc_id")
        self._embeddings.pop((project, doc_id), None)
        logger.debug("index.vector.embedding_removed", project=project, doc_id=doc_id)

    @property
    def embedding_count(self) -> int:
        """Total number of stored embeddings (test helper)."""
        return len(self._embeddings)


# ---------------------------------------------------------------------------
# InMemoryGraphIndexBackend
# ---------------------------------------------------------------------------


class InMemoryGraphIndexBackend:
    """Stub :class:`GraphIndexBackend` — list-based, with max_entries cap."""

    def __init__(self, *, max_entries: int = DEFAULT_MAX_STUB_ENTRIES) -> None:
        self._triples: dict[str, list[tuple[str, str, str]]] = {}
        self._triple_sets: dict[str, set[tuple[str, str, str]]] = {}
        self._max_entries = max_entries

    def add_triple(self, project: str, subject: str, predicate: str, obj: str) -> None:
        """Add a triple to the in-memory graph."""
        _require_non_empty(project, "project")
        _require_non_empty(subject, "subject")
        _require_non_empty(predicate, "predicate")
        _require_non_empty(obj, "obj")
        triple = (subject, predicate, obj)
        if project not in self._triples:
            self._triples[project] = []
            self._triple_sets[project] = set()
        if triple not in self._triple_sets[project]:
            if self.triple_count() >= self._max_entries:
                raise RuntimeError(
                    f"InMemoryGraphIndexBackend: max_entries "
                    f"({self._max_entries}) reached"
                )
            self._triple_sets[project].add(triple)
            self._triples[project].append(triple)
            logger.debug(
                "index.graph.triple_added",
                project=project,
                subject=subject,
                predicate=predicate,
            )

    def query(self, project: str, sparql: str) -> list[dict[str, str]]:
        """Return all triples for *project* as binding dicts (stub)."""
        _require_non_empty(project, "project")
        _require_non_empty(sparql, "sparql")
        triples = self._triples.get(project, [])
        return [{"s": s, "p": p, "o": o} for s, p, o in triples]

    def remove_triples(
        self,
        project: str,
        subject: str | None,
        predicate: str | None,
        obj: str | None,
    ) -> None:
        """Remove triples matching the given pattern."""
        _require_non_empty(project, "project")
        if subject is None and predicate is None and obj is None:
            raise ValueError(
                "At least one of subject, predicate, obj must be "
                "non-None to avoid deleting all triples"
            )
        # Treat empty strings as programming errors — callers should
        # pass None for wildcard, not "".
        if subject is not None and not subject.strip():
            raise ValueError("subject filter must be non-empty if provided")
        if predicate is not None and not predicate.strip():
            raise ValueError("predicate filter must be non-empty if provided")
        if obj is not None and not obj.strip():
            raise ValueError("obj filter must be non-empty if provided")
        if project not in self._triples:
            return
        original = self._triples[project]
        filtered = [
            (s, p, o)
            for s, p, o in original
            if not (
                (subject is None or s == subject)
                and (predicate is None or p == predicate)
                and (obj is None or o == obj)
            )
        ]
        removed = len(original) - len(filtered)
        self._triples[project] = filtered
        self._triple_sets[project] = set(filtered)
        logger.debug(
            "index.graph.triples_removed",
            project=project,
            subject=subject,
            predicate=predicate,
            obj=obj,
            removed_count=removed,
        )

    def triple_count(self, project: str | None = None) -> int:
        """Count stored triples, optionally filtered by *project*."""
        if project is not None:
            return len(self._triples.get(project, []))
        return sum(len(ts) for ts in self._triples.values())


# ---------------------------------------------------------------------------
# Protocol compliance assertions (static verification by Pyright)
# ---------------------------------------------------------------------------

_assert_text: type[TextIndexBackend] = InMemoryTextIndexBackend
_assert_vector: type[VectorIndexBackend] = InMemoryVectorIndexBackend
_assert_graph: type[GraphIndexBackend] = InMemoryGraphIndexBackend

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

__all__: list[str] = [
    "DEFAULT_MAX_STUB_ENTRIES",
    "InMemoryGraphIndexBackend",
    "InMemoryTextIndexBackend",
    "InMemoryVectorIndexBackend",
]
