"""FactQuery — single source of read queries against ``MemoryFact``.

This module contains the architectural abstraction for all read-path queries
against the ``MemoryFact`` table. Every site in the SDK that needs to fetch
facts MUST go through ``FactQuery`` (except point-lookups by UUID and column
projections — see the whitelist in ``tests/test_fact_query_invariant.py``).

## Why

Between March and April 2026 two production bugs arose from the same
structural gap: query sites constructing ``select(MemoryFact).order_by(...)``
manually without stable tiebreakers. A survey found 26 query sites spread
across 7 files, with 25 of them lacking a deterministic ORDER BY tiebreaker
and 14 duplicating the base filter verbatim. The root cause was not
"forgetting to add .id" — it was the absence of an abstraction that forced
tiebreakers to exist.

## Design

``FactQuery`` is a mutable fluent builder. Entry points (``active``,
``all_versions``) apply the base filter. Filter methods (``for_entity``,
``with_min_confidence``, etc.) accumulate state. Ordering methods
(``order_by_recency``, ``order_by_similarity``, etc.) always embed
``MemoryFact.id`` as the final tiebreaker — **there is no ``order_by()``
method that accepts arbitrary expressions**, so constructing an unstable
query through this API is impossible by construction.

The terminal ``build()`` returns a SQLAlchemy ``Select`` statement. The
caller still executes via ``session.execute(stmt)`` — this module does
not touch the session or transactions.

## Source

Driven by PRD ``.vibeflow/prds/fact-query-abstraction.md`` and spec
``.vibeflow/specs/fact-query-abstraction-part-1.md`` (2026-04-09).
"""

from __future__ import annotations

import uuid
from collections.abc import Iterable
from datetime import datetime
from typing import Any

from sqlalchemy import Select, and_, exists, not_, or_, select
from sqlalchemy.orm import defer
from sqlalchemy.sql.elements import ColumnElement

from arandu.models import MemoryFact, MemoryFactEntityLink

__all__ = ["FactQuery"]


class FactQuery:
    """Fluent builder for read queries against ``MemoryFact``.

    Instances are obtained via the class methods ``FactQuery.active()`` or
    ``FactQuery.all_versions()``. Filters, ordering and pagination are added
    by chaining methods that return ``self``. The final ``build()`` call
    returns a ``Select`` statement ready to be executed by the caller.

    All ordering methods embed ``MemoryFact.id`` as the final tiebreaker,
    making it impossible to construct a query with unstable ordering through
    this API.

    Example:
        >>> stmt = (
        ...     FactQuery.active(agent_id)
        ...     .with_min_confidence(0.5)
        ...     .for_entity("person:pedro")
        ...     .order_by_recency()
        ...     .limit(20)
        ...     .build()
        ... )
        >>> result = await session.execute(stmt)
        >>> facts = result.scalars().all()
    """

    # --- Construction ------------------------------------------------------

    def __init__(self, agent_id: str, *, active_only: bool) -> None:
        """Private constructor. Use ``FactQuery.active()`` or ``all_versions()``.

        Args:
            agent_id: Agent identifier used as the scoping filter.
            active_only: If ``True``, the query filters out soft-deleted
                facts (``valid_to IS NULL``). If ``False``, all versions are
                included.
        """
        self._agent_id: str = agent_id
        self._active_only: bool = active_only

        self._filters: list[ColumnElement[bool]] = []
        self._entity_keys: list[str] | None = None
        self._exclude_ids: list[uuid.UUID] = []
        self._explicit_ids: list[uuid.UUID] | None = None

        self._order_clauses: list[Any] = []
        self._similarity_embedding: list[float] | None = None

        self._limit: int | None = None
        self._offset: int | None = None
        self._defer_embedding: bool = False

    @classmethod
    def active(cls, agent_id: str) -> FactQuery:
        """Start a query for active facts only (``valid_to IS NULL``).

        Args:
            agent_id: Agent whose facts to query.

        Returns:
            A ``FactQuery`` instance scoped to the given agent with the
            active-fact filter applied.
        """
        return cls(agent_id, active_only=True)

    @classmethod
    def all_versions(cls, agent_id: str) -> FactQuery:
        """Start a query over all fact versions (no ``valid_to`` filter).

        Used by point-lookup helpers that need to return soft-deleted facts,
        such as ``MemoryClient.get(fact_id)``.

        Args:
            agent_id: Agent whose facts to query.

        Returns:
            A ``FactQuery`` instance scoped to the given agent, including
            historical versions.
        """
        return cls(agent_id, active_only=False)

    # --- Filters -----------------------------------------------------------

    def with_min_confidence(self, threshold: float) -> FactQuery:
        """Filter facts with confidence >= threshold.

        Args:
            threshold: Minimum confidence score (0.0–1.0).

        Returns:
            ``self`` for chaining.
        """
        self._filters.append(MemoryFact.confidence >= threshold)
        return self

    def with_embedding(self) -> FactQuery:
        """Filter facts with a non-null ``embedding_vec``.

        Required by semantic search — facts without embeddings cannot
        participate in cosine-distance ranking.

        Returns:
            ``self`` for chaining.
        """
        self._filters.append(MemoryFact.embedding_vec.isnot(None))
        return self

    def for_entity(self, entity_key: str) -> FactQuery:
        """Filter facts linked to a specific entity.

        Uses an EXISTS subquery against ``MemoryFactEntityLink``, which
        matches any fact that links to the given entity (primary or not).

        Args:
            entity_key: Canonical entity key (e.g. ``"person:pedro_menezes"``).

        Returns:
            ``self`` for chaining.
        """
        return self.for_entities([entity_key])

    def for_entities(self, entity_keys: list[str]) -> FactQuery:
        """Filter facts linked to any of the given entities (OR logic).

        Uses an EXISTS subquery against ``MemoryFactEntityLink``. An empty
        list behaves as "filter active with no possible match" — the
        resulting query returns zero rows, which is the intended semantics
        for "caller asked for a filter but none resolved".

        Args:
            entity_keys: List of canonical entity keys. An empty list
                results in a filter that matches nothing.

        Returns:
            ``self`` for chaining.
        """
        # Last call wins — replacing is intentional to simplify composition.
        self._entity_keys = list(entity_keys)
        return self

    def for_cluster(self, cluster_id: uuid.UUID) -> FactQuery:
        """Filter facts belonging to a specific cluster.

        Args:
            cluster_id: UUID of the cluster.

        Returns:
            ``self`` for chaining.
        """
        self._filters.append(MemoryFact.cluster_id == cluster_id)
        return self

    def within_temporal_window(
        self,
        start: datetime | None,
        end: datetime | None,
    ) -> FactQuery:
        """Filter facts whose ``valid_from`` falls in ``[start, end]``.

        Either bound can be ``None`` to indicate "unbounded" on that side.

        Args:
            start: Lower bound (inclusive) or ``None``.
            end: Upper bound (inclusive) or ``None``.

        Returns:
            ``self`` for chaining.
        """
        if start is not None:
            self._filters.append(MemoryFact.valid_from >= start)
        if end is not None:
            self._filters.append(MemoryFact.valid_from <= end)
        return self

    def excluding_ids(self, fact_ids: Iterable[uuid.UUID]) -> FactQuery:
        """Filter out facts whose ``id`` is in the given set.

        Useful for "exclude already seen" patterns (spreading activation,
        hop expansion, etc.).

        Args:
            fact_ids: Iterable of fact UUIDs to exclude.

        Returns:
            ``self`` for chaining.
        """
        self._exclude_ids.extend(list(fact_ids))
        return self

    def matching_text(self, patterns: list[str]) -> FactQuery:
        """Filter facts whose ``fact_text`` ILIKE any of the patterns.

        Patterns are combined with OR. Empty pattern list is a no-op.

        Args:
            patterns: List of ILIKE patterns (with ``%`` wildcards).

        Returns:
            ``self`` for chaining.
        """
        if patterns:
            conditions = [MemoryFact.fact_text.ilike(p) for p in patterns]
            self._filters.append(or_(*conditions))
        return self

    def by_ids(self, fact_ids: list[uuid.UUID]) -> FactQuery:
        """Filter to an explicit set of fact IDs.

        Used for point-lookups (``MemoryClient.get``) and for fetching
        seed facts in spreading activation.

        Args:
            fact_ids: Exact list of fact UUIDs to fetch.

        Returns:
            ``self`` for chaining.
        """
        self._explicit_ids = list(fact_ids)
        return self

    def with_entity_text(self) -> FactQuery:
        """Filter facts whose ``entity_name`` is not null.

        Used by graph rendering when a displayable entity name is required.

        Returns:
            ``self`` for chaining.
        """
        self._filters.append(MemoryFact.entity_name.isnot(None))
        return self

    # --- Ordering (tiebreaker embedded) -----------------------------------

    def order_by_recency(self) -> FactQuery:
        """Order by ``valid_from DESC, id``.

        The ``id`` column acts as a stable tiebreaker when multiple facts
        share the same ``valid_from`` (e.g. facts created in the same
        ``write()`` call have identical server timestamps).

        Returns:
            ``self`` for chaining.
        """
        self._order_clauses = [MemoryFact.valid_from.desc(), MemoryFact.id]
        return self

    def order_by_created(self) -> FactQuery:
        """Order by ``created_at DESC, id``.

        Matches the pattern used by the pioneer ``MemoryClient.get_all()``
        pagination fix (commit ``1ce37bf``). Stable under equal timestamps.

        Returns:
            ``self`` for chaining.
        """
        self._order_clauses = [MemoryFact.created_at.desc(), MemoryFact.id]
        return self

    def order_by_confidence(self) -> FactQuery:
        """Order by ``confidence DESC, id``.

        Returns:
            ``self`` for chaining.
        """
        self._order_clauses = [MemoryFact.confidence.desc(), MemoryFact.id]
        return self

    def order_by_importance(self) -> FactQuery:
        """Order by ``importance DESC, id``.

        Used by background profile consolidation jobs that want to prioritize
        high-importance facts for LLM summary generation.

        Returns:
            ``self`` for chaining.
        """
        self._order_clauses = [MemoryFact.importance.desc(), MemoryFact.id]
        return self

    def order_by_similarity(self, embedding: list[float]) -> FactQuery:
        """Order by cosine distance to ``embedding`` ascending, then ``id``.

        When this ordering is used, ``build()`` returns a 2-column select
        (``MemoryFact`` + labeled ``similarity`` column), so callers can
        read ``(fact, similarity)`` rows and filter by ``min_similarity``.

        Near-identical embeddings can produce cosine distances that tie or
        differ only below floating-point epsilon; ``MemoryFact.id`` as
        tiebreaker guarantees that ordering is still deterministic.

        Args:
            embedding: Query embedding vector (same dimension as
                ``MemoryFact.embedding_vec``).

        Returns:
            ``self`` for chaining.
        """
        self._similarity_embedding = list(embedding)
        # Ordering clauses are computed lazily in build() because they
        # reference the same embedding expression used in the SELECT.
        return self

    # --- Pagination --------------------------------------------------------

    def limit(self, count: int) -> FactQuery:
        """Limit result size.

        Args:
            count: Maximum number of rows to return.

        Returns:
            ``self`` for chaining.
        """
        self._limit = count
        return self

    def offset(self, count: int) -> FactQuery:
        """Offset results (for pagination).

        Only meaningful when combined with an ordering method.

        Args:
            count: Number of rows to skip.

        Returns:
            ``self`` for chaining.
        """
        self._offset = count
        return self

    def with_deferred_embedding(self) -> FactQuery:
        """Defer the ``embedding`` JSONB column from the select.

        The ``embedding`` column stores a list of ~1500 floats as JSONB,
        which is expensive to transfer on the wire. Retrieval paths that
        only need the ``embedding_vec`` (pgvector) for ranking should
        defer ``embedding`` to save bytes.

        Returns:
            ``self`` for chaining.
        """
        self._defer_embedding = True
        return self

    # --- Terminal ---------------------------------------------------------

    def build(self) -> Select[Any]:
        """Build the final SQLAlchemy ``Select`` statement.

        When ``order_by_similarity`` was called, the returned statement
        selects two columns: ``MemoryFact`` and a labeled ``similarity``
        float column. Otherwise it selects ``MemoryFact`` only.

        Returns:
            A ``Select`` statement ready to pass to ``session.execute()``.
        """
        # Assemble base WHERE clause
        where_clauses: list[ColumnElement[bool]] = [MemoryFact.agent_id == self._agent_id]

        if self._active_only:
            where_clauses.append(MemoryFact.valid_to.is_(None))

        where_clauses.extend(self._filters)

        if self._explicit_ids is not None:
            where_clauses.append(MemoryFact.id.in_(self._explicit_ids))

        if self._exclude_ids:
            where_clauses.append(not_(MemoryFact.id.in_(self._exclude_ids)))

        if self._entity_keys is not None:
            if len(self._entity_keys) == 0:
                # Empty filter list → match nothing. Use a literal-false
                # predicate so the semantics are explicit in the SQL.
                where_clauses.append(MemoryFact.id == uuid.UUID(int=0))
            else:
                entity_filter = (
                    exists()
                    .where(MemoryFactEntityLink.fact_id == MemoryFact.id)
                    .where(MemoryFactEntityLink.entity_key.in_(self._entity_keys))
                )
                where_clauses.append(entity_filter)

        # Build the SELECT — similarity path is a 2-column variant
        stmt: Select[Any]
        if self._similarity_embedding is not None:
            distance_expr = MemoryFact.embedding_vec.cosine_distance(self._similarity_embedding)
            similarity_expr = (1 - distance_expr).label("similarity")
            stmt = select(MemoryFact, similarity_expr).where(and_(*where_clauses))
            stmt = stmt.order_by(distance_expr, MemoryFact.id)
        else:
            stmt = select(MemoryFact).where(and_(*where_clauses))
            if self._order_clauses:
                stmt = stmt.order_by(*self._order_clauses)

        if self._limit is not None:
            stmt = stmt.limit(self._limit)
        if self._offset is not None:
            stmt = stmt.offset(self._offset)
        if self._defer_embedding:
            stmt = stmt.options(defer(MemoryFact.embedding))

        return stmt
