"""In-memory stub backend for the ACMS Temporal Data Model.

Provides :class:`InMemoryTemporalBackend`, a zero-dependency
implementation of the :class:`TemporalBackend` protocol that stores
:class:`TemporalNode` instances in a ``dict`` keyed by ``node_uri``.

Unlike the BAL stubs in ``stubs.py`` (which return empty results),
this backend stores real data so revision chain creation and temporal
queries can be exercised in tests without external infrastructure.

Based on ``docs/specification.md`` lines 42468--42499 (Temporal Data
Model / Revision-Aware RDF / Three Storage Tiers).
"""

from __future__ import annotations

import warnings
from datetime import UTC, datetime, timedelta

from cleveragents.domain.models.acms._validation import (
    validate_non_blank as _validate_non_blank,
)
from cleveragents.domain.models.acms.temporal import (
    RevisionChain,
    TemporalMetadata,
    TemporalNode,
    TierQueryResult,
    TierRetentionConfig,
)
from cleveragents.domain.models.acms.tiers import ContextTier
from cleveragents.domain.models.core.project import TemporalScope

__all__ = [
    "InMemoryTemporalBackend",
]


def _uri_matches_base(node_uri: str, base: str) -> bool:
    """Check whether *node_uri* belongs to the URI family of *base*.

    Returns ``True`` when *node_uri* starts with *base* **and** the
    character immediately after the base prefix is either ``_`` (the
    version delimiter) or the string ends exactly at *base*.  This
    prevents ``"uko-py:class/Auth"`` from matching
    ``"uko-py:class/AuthManager_v1"``.
    """
    if not node_uri.startswith(base):
        return False
    # Exact match or next char is the version delimiter ``_``
    return len(node_uri) == len(base) or node_uri[len(base)] == "_"


class InMemoryTemporalBackend:
    """Stub :class:`TemporalBackend` with in-memory node storage.

    Stores :class:`TemporalNode` instances in a ``dict`` keyed by
    ``node_uri`` so revision chain creation and temporal queries can
    be exercised in tests without external infrastructure.

    Based on ``docs/specification.md`` lines 42468--42499.
    """

    # INVARIANTS (must hold after every public method returns):
    # 1. Every key in _nodes is the node_uri of its corresponding value.
    # 2. At most one node with a given base URI has is_current == True.
    # 3. _nodes values are TemporalNode (frozen); internal state never
    #    exposes mutable references.

    def __init__(self) -> None:
        """Initialise with an empty node store."""
        self._nodes: dict[str, TemporalNode] = {}
        # Reverse index: predecessor_uri → list of successor URIs.
        # Maintained by store_node / create_revision for O(k) chain walks.
        self._successors: dict[str, list[str]] = {}

    def create_revision(
        self,
        current_uri: str,
        new_node: TemporalNode,
        timestamp: datetime,
    ) -> TemporalNode:
        """Create a new revision, marking *current_uri* as historical.

        Args:
            current_uri: URI of the existing current node.
            new_node: New temporal node to store.
            timestamp: UTC timestamp of the revision.

        Returns:
            The stored new node.

        Raises:
            ValueError: If *current_uri* is empty or whitespace-only,
                if ``new_node.node_uri == current_uri``, if the
                node at *current_uri* is already historical, if
                ``new_node.node_uri`` already exists in the store,
                if ``new_node.temporal.is_revision_of != current_uri``,
                or if ``new_node.temporal.is_current`` is not ``True``.
            KeyError: If *current_uri* does not exist.
        """
        _validate_non_blank(current_uri, "current_uri")
        if current_uri not in self._nodes:
            raise KeyError(current_uri)
        if new_node.node_uri == current_uri:
            msg = (
                "new_node.node_uri must differ from current_uri; "
                "same URI would overwrite the historical record"
            )
            raise ValueError(msg)

        if new_node.node_uri in self._nodes:
            msg = (
                f"new_node.node_uri {new_node.node_uri!r} already exists; "
                "create_revision would overwrite an unrelated node"
            )
            raise ValueError(msg)

        if new_node.temporal.is_revision_of != current_uri:
            msg = (
                f"new_node.temporal.is_revision_of must be {current_uri!r}, "
                f"got {new_node.temporal.is_revision_of!r}"
            )
            raise ValueError(msg)

        if not new_node.temporal.is_current:
            msg = "new_node.temporal.is_current must be True for a new revision"
            raise ValueError(msg)

        old_node = self._nodes[current_uri]
        if not old_node.temporal.is_current:
            msg = (
                f"cannot revise non-current node {current_uri!r}; "
                "node is already historical"
            )
            raise ValueError(msg)

        # Mark old node as historical
        historical_meta = TemporalMetadata(
            valid_from=old_node.temporal.valid_from,
            valid_until=timestamp,
            is_current=False,
            is_revision_of=old_node.temporal.is_revision_of,
        )
        historical_node = TemporalNode(
            node_uri=old_node.node_uri,
            source_resource=old_node.source_resource,
            source_path=old_node.source_path,
            source_range=old_node.source_range,
            temporal=historical_meta,
        )
        # Update tracking BEFORE storing (lesson learned from PR #612)
        self._nodes[current_uri] = historical_node

        # Store new node and update successor index.
        self._nodes[new_node.node_uri] = new_node
        pred = new_node.temporal.is_revision_of
        if pred is not None:
            self._successors.setdefault(pred, []).append(
                new_node.node_uri,
            )
        return new_node

    def get_current(
        self,
        node_uri_base: str,
    ) -> TemporalNode | None:
        """Return the current version matching *node_uri_base*.

        Note:
            URI matching is delimiter-aware: the base must be followed
            by ``_`` or match exactly, preventing prefix collisions
            (e.g., ``"Auth"`` will not match ``"AuthManager_v1"``).

        Raises:
            ValueError: If *node_uri_base* is empty or whitespace-only.
        """
        _validate_non_blank(node_uri_base, "node_uri_base")
        for node in self._nodes.values():
            if (
                _uri_matches_base(node.node_uri, node_uri_base)
                and node.temporal.is_current
            ):
                return node
        return None

    def get_history(
        self,
        node_uri_base: str,
        temporal_scope: TemporalScope,
        retention: TierRetentionConfig | None = None,
    ) -> tuple[TemporalNode, ...]:
        """Return node versions filtered by temporal scope.

        Args:
            node_uri_base: Base URI pattern to match.
            temporal_scope: Scope controlling which versions to include.
            retention: Optional retention config for RECENT scope.
                Defaults to :class:`TierRetentionConfig` defaults when
                ``None`` (24 h warm, 90 d cold).

        Returns:
            Matching nodes ordered by ``valid_from`` descending.

        Note:
            URI matching is delimiter-aware: the base must be followed
            by ``_`` or match exactly, preventing prefix collisions.

        Raises:
            ValueError: If *node_uri_base* is empty or whitespace-only.
        """
        _validate_non_blank(node_uri_base, "node_uri_base")
        matching = [
            n
            for n in self._nodes.values()
            if _uri_matches_base(n.node_uri, node_uri_base)
        ]

        if temporal_scope == TemporalScope.CURRENT:
            matching = [n for n in matching if n.temporal.is_current]
        elif temporal_scope == TemporalScope.RECENT:
            effective = retention or TierRetentionConfig()
            cutoff = datetime.now(tz=UTC) - timedelta(
                hours=effective.warm_retention_hours,
            )
            matching = [
                n
                for n in matching
                if n.temporal.is_current
                or (
                    n.temporal.valid_until is not None
                    and n.temporal.valid_until >= cutoff
                )
            ]
        # TemporalScope.ALL returns everything

        return tuple(
            sorted(
                matching,
                key=lambda n: n.temporal.valid_from,
                reverse=True,
            ),
        )

    def get_revision_chain(
        self,
        node_uri: str,
    ) -> RevisionChain:
        """Traverse the full revision chain for *node_uri*.

        Args:
            node_uri: URI of any node in the chain.

        Returns:
            The complete revision chain.

        Note:
            Uses backward-walk from *node_uri* to the root, then a
            single-successor forward-walk via the ``_successors``
            index to the current head.  This avoids branch
            contamination: only the linear path through the queried
            node is returned.  ``O(k)`` where *k* is chain length.

        Raises:
            ValueError: If *node_uri* is empty or whitespace-only,
                or if no current node exists in the chain (all
                historical).
            KeyError: If *node_uri* does not exist.
        """
        _validate_non_blank(node_uri, "node_uri")
        if node_uri not in self._nodes:
            raise KeyError(node_uri)

        # Walk backwards from the given node to the root.
        backward: list[str] = [node_uri]
        backward_set: set[str] = {node_uri}
        walker = self._nodes[node_uri]
        while walker.temporal.is_revision_of is not None:
            pred_uri = walker.temporal.is_revision_of
            if pred_uri in backward_set:
                break  # cycle guard
            if pred_uri not in self._nodes:
                warnings.warn(
                    f"predecessor {pred_uri!r} not found in store; "
                    f"chain for {node_uri!r} may be incomplete",
                    stacklevel=2,
                )
                break
            backward.append(pred_uri)
            backward_set.add(pred_uri)
            walker = self._nodes[pred_uri]

        # Reverse to get root → queried_node order.
        chain_uris = list(reversed(backward))

        # Walk forward from queried_node to current head using the
        # _successors index (O(k) instead of O(n*k) full scan).
        tip = node_uri
        forward_seen: set[str] = set(chain_uris)
        while True:
            children = self._successors.get(tip, [])
            # Pick successor by (latest valid_from, lexicographic URI)
            # for fully deterministic branch choice regardless of
            # insertion order.
            successor: str | None = None
            best_key: tuple[datetime, str] | None = None
            for child_uri in children:
                if child_uri in forward_seen:
                    continue
                vf = self._nodes[child_uri].temporal.valid_from
                key = (vf, child_uri)
                if best_key is None or key > best_key:
                    successor = child_uri
                    best_key = key
            if successor is None:
                break
            chain_uris.append(successor)
            forward_seen.add(successor)
            tip = successor

        # Find the current head explicitly.
        head_uri: str | None = None
        for uri in chain_uris:
            if self._nodes[uri].temporal.is_current:
                head_uri = uri
                break
        if head_uri is None:
            msg = (
                f"no current node in revision chain for {node_uri!r}; "
                "all nodes are historical"
            )
            raise ValueError(msg)

        predecessors_tuple = tuple(u for u in chain_uris if u != head_uri)

        return RevisionChain(
            current_uri=head_uri,
            predecessors=predecessors_tuple,
        )

    def query_by_tier(
        self,
        tier: ContextTier,
        temporal_scope: TemporalScope,
        retention: TierRetentionConfig,
    ) -> TierQueryResult:
        """Query nodes aligned to a specific storage tier.

        Args:
            tier: Storage tier to query.
            temporal_scope: Temporal scope for filtering.
            retention: Retention configuration.

        Returns:
            A :class:`TierQueryResult` with matching nodes.
        """
        now = datetime.now(tz=UTC)
        all_nodes = list(self._nodes.values())

        # Step 1: Tier determines the data pool (retention window).
        if tier == ContextTier.HOT:
            pool = [n for n in all_nodes if n.temporal.is_current]
        elif tier == ContextTier.WARM:
            cutoff = now - timedelta(hours=retention.warm_retention_hours)
            pool = [
                n
                for n in all_nodes
                if n.temporal.is_current
                or (
                    n.temporal.valid_until is not None
                    and n.temporal.valid_until >= cutoff
                )
            ]
        else:
            # Cold: all temporal versions within cold retention
            cutoff = now - timedelta(days=retention.cold_retention_days)
            pool = [
                n
                for n in all_nodes
                if n.temporal.is_current
                or (
                    n.temporal.valid_until is not None
                    and n.temporal.valid_until >= cutoff
                )
            ]

        # Step 2: temporal_scope filters within the tier pool.
        if temporal_scope == TemporalScope.CURRENT:
            filtered = [n for n in pool if n.temporal.is_current]
        elif temporal_scope == TemporalScope.RECENT:
            scope_cutoff = now - timedelta(
                hours=retention.warm_retention_hours,
            )
            filtered = [
                n
                for n in pool
                if n.temporal.is_current
                or (
                    n.temporal.valid_until is not None
                    and n.temporal.valid_until >= scope_cutoff
                )
            ]
        else:
            # TemporalScope.ALL — no additional filtering
            filtered = pool

        nodes_tuple = tuple(
            sorted(
                filtered,
                key=lambda n: n.temporal.valid_from,
                reverse=True,
            ),
        )

        return TierQueryResult(
            nodes=nodes_tuple,
            tier=tier,
            temporal_scope=temporal_scope,
        )

    def mark_historical(
        self,
        node_uri: str,
        timestamp: datetime,
    ) -> TemporalNode:
        """Mark a node as historical.

        Args:
            node_uri: URI of the node to mark.
            timestamp: UTC timestamp when superseded.

        Returns:
            The updated node.

        Raises:
            ValueError: If *node_uri* is empty or whitespace-only,
                or if the node is already historical.
            KeyError: If *node_uri* does not exist.
        """
        _validate_non_blank(node_uri, "node_uri")
        if node_uri not in self._nodes:
            raise KeyError(node_uri)

        old_node = self._nodes[node_uri]
        if not old_node.temporal.is_current:
            msg = (
                f"node {node_uri!r} is already historical "
                f"(valid_until={old_node.temporal.valid_until})"
            )
            raise ValueError(msg)
        historical_meta = TemporalMetadata(
            valid_from=old_node.temporal.valid_from,
            valid_until=timestamp,
            is_current=False,
            is_revision_of=old_node.temporal.is_revision_of,
        )
        updated = TemporalNode(
            node_uri=old_node.node_uri,
            source_resource=old_node.source_resource,
            source_path=old_node.source_path,
            source_range=old_node.source_range,
            temporal=historical_meta,
        )
        self._nodes[node_uri] = updated
        return updated

    def store_node(self, node: TemporalNode) -> None:
        """Store a temporal node directly (initial insert).

        Args:
            node: The temporal node to store.

        Raises:
            ValueError: If a node with the same ``node_uri`` already
                exists.
        """
        if node.node_uri in self._nodes:
            msg = f"node {node.node_uri!r} already exists"
            raise ValueError(msg)
        self._nodes[node.node_uri] = node
        pred = node.temporal.is_revision_of
        if pred is not None:
            self._successors.setdefault(pred, []).append(node.node_uri)
