"""Temporal Data Model (Revision-Aware RDF) with 3 storage tiers.

Provides the temporal metadata, revision chain, and tier-aware query
models for the ACMS.  When code changes, existing UKO nodes are **not
deleted** -- they are marked historical (``valid_until`` set,
``is_current`` set to ``False``) and a new revision node is created
with ``is_revision_of`` linking back to the predecessor.

Domain models (all frozen / immutable):

| Type                   | Role                                          |
|------------------------|-----------------------------------------------|
| ``TemporalMetadata``   | Temporal fields on a UKO InformationUnit      |
| ``TemporalNode``       | A UKO node carrying temporal metadata         |
| ``RevisionChain``      | Ordered chain of node versions (oldest-first) |
| ``TierQueryResult``    | Result from a tier-aware temporal query        |
| ``TierRetentionConfig``| Warm/cold tier retention policy                |

Protocol:

| Protocol             | Role                                            |
|----------------------|-------------------------------------------------|
| ``TemporalBackend``  | Interface for temporal storage and queries       |

Based on ``docs/specification.md`` lines 41940--42499 (UKO temporal
properties, Revision-Aware RDF, Three Storage Tiers).
"""

from __future__ import annotations

from datetime import UTC, datetime
from typing import Protocol, runtime_checkable

from pydantic import BaseModel, Field, field_validator, model_validator

from cleveragents.domain.models.acms._validation import (
    validate_non_blank as _validate_non_blank,
)
from cleveragents.domain.models.acms.tiers import ContextTier
from cleveragents.domain.models.core.project import TemporalScope

__all__ = [
    "RevisionChain",
    "TemporalBackend",
    "TemporalMetadata",
    "TemporalNode",
    "TierQueryResult",
    "TierRetentionConfig",
]

# ---------------------------------------------------------------------------
# TemporalMetadata — temporal fields on a UKO InformationUnit
# ---------------------------------------------------------------------------


class TemporalMetadata(BaseModel, frozen=True):
    """Temporal metadata carried by every UKO InformationUnit.

    Based on ``docs/specification.md`` lines 41940--41957.

    Attributes:
        valid_from: UTC timestamp when this node version became valid.
            Maps to ``uko:validFrom`` (``xsd:dateTime``).
        valid_until: UTC timestamp when this node version was superseded.
            ``None`` for current (active) nodes.
            Maps to ``uko:validUntil`` (``xsd:dateTime``).
        is_current: Whether this is the current/active version.
            Maps to ``uko:isCurrent`` (``xsd:boolean``).
        is_revision_of: URI of the predecessor node, or ``None`` for the
            first version in a revision chain.
            Maps to ``uko:isRevisionOf`` (``owl:ObjectProperty``).
    """

    valid_from: datetime = Field(
        ...,
        description="UTC timestamp when this node version became valid",
    )
    valid_until: datetime | None = Field(
        default=None,
        description="UTC timestamp when superseded; None if current",
    )
    is_current: bool = Field(
        default=True,
        description="Whether this is the current/active version",
    )
    is_revision_of: str | None = Field(
        default=None,
        description="URI of predecessor node; None for first version",
    )

    @field_validator("valid_from", mode="after")
    @classmethod
    def _ensure_valid_from_tz(
        cls: type[TemporalMetadata],
        v: datetime,
    ) -> datetime:
        """Coerce ``valid_from`` to UTC (naive → assume UTC, aware → convert)."""
        if v.tzinfo is None:
            return v.replace(tzinfo=UTC)
        return v.astimezone(UTC)

    @field_validator("valid_until", mode="after")
    @classmethod
    def _ensure_valid_until_tz(
        cls: type[TemporalMetadata],
        v: datetime | None,
    ) -> datetime | None:
        """Coerce ``valid_until`` to UTC when set."""
        if v is None:
            return None
        if v.tzinfo is None:
            return v.replace(tzinfo=UTC)
        return v.astimezone(UTC)

    @field_validator("is_revision_of")
    @classmethod
    def _validate_revision_ref(
        cls: type[TemporalMetadata],
        v: str | None,
    ) -> str | None:
        """Reject empty, whitespace-only, or invisible revision references."""
        if v is not None:
            _validate_non_blank(v, "is_revision_of")
        return v

    @model_validator(mode="after")
    def _validate_temporal_invariants(self) -> TemporalMetadata:
        """Enforce cross-field temporal invariants."""
        if self.valid_until is not None and self.valid_until < self.valid_from:
            msg = (
                "valid_until must not be earlier than valid_from "
                f"({self.valid_until} < {self.valid_from})"
            )
            raise ValueError(msg)
        if self.is_current and self.valid_until is not None:
            msg = (
                "is_current=True contradicts valid_until being set; "
                "a current node cannot have an expiry timestamp"
            )
            raise ValueError(msg)
        if not self.is_current and self.valid_until is None:
            msg = (
                "is_current=False requires valid_until to be set; "
                "a historical node must record when it was superseded"
            )
            raise ValueError(msg)
        return self


# ---------------------------------------------------------------------------
# TemporalNode — a UKO node with temporal metadata
# ---------------------------------------------------------------------------


class TemporalNode(BaseModel, frozen=True):
    """A UKO InformationUnit carrying full temporal metadata.

    Represents a single versioned node in the knowledge graph.  Each
    node has provenance back to the originating source resource and
    carries a :class:`TemporalMetadata` for revision tracking.

    Based on ``docs/specification.md`` lines 42454--42491.

    Attributes:
        node_uri: Unique UKO URI for this node version
            (e.g., ``uko-py:class/AuthManager_v2``).
        source_resource: ULID of the originating resource.
        source_path: File path within the resource.
        source_range: Optional source range (e.g., ``"15:1-87:0"``).
        temporal: Embedded temporal metadata.
    """

    node_uri: str = Field(
        ...,
        min_length=1,
        description="Unique UKO URI for this node version",
    )
    source_resource: str = Field(
        ...,
        min_length=1,
        description="ULID of the originating resource",
    )
    source_path: str = Field(
        ...,
        min_length=1,
        description="File path within the resource",
    )
    source_range: str | None = Field(
        default=None,
        description='Optional source range (e.g., "15:1-87:0")',
    )
    temporal: TemporalMetadata = Field(
        ...,
        description="Temporal metadata for revision tracking",
    )

    @field_validator("node_uri")
    @classmethod
    def _validate_node_uri(
        cls: type[TemporalNode],
        v: str,
    ) -> str:
        """Reject whitespace-only node URIs."""
        return _validate_non_blank(v, "node_uri")

    @field_validator("source_resource")
    @classmethod
    def _validate_source_resource(
        cls: type[TemporalNode],
        v: str,
    ) -> str:
        """Reject whitespace-only source resource IDs."""
        return _validate_non_blank(v, "source_resource")

    @field_validator("source_path")
    @classmethod
    def _validate_source_path(
        cls: type[TemporalNode],
        v: str,
    ) -> str:
        """Reject whitespace-only source paths."""
        return _validate_non_blank(v, "source_path")

    @field_validator("source_range")
    @classmethod
    def _validate_source_range(
        cls: type[TemporalNode],
        v: str | None,
    ) -> str | None:
        """Reject empty or whitespace-only source ranges."""
        if v is not None and (not v or not v.strip()):
            msg = "source_range must be a non-empty, non-whitespace string or None"
            raise ValueError(msg)
        return v


# ---------------------------------------------------------------------------
# RevisionChain — ordered chain of node versions
# ---------------------------------------------------------------------------


class RevisionChain(BaseModel, frozen=True):
    """Ordered chain of temporal node versions for a single UKO concept.

    The chain captures the full revision history: ``predecessors`` are
    ordered oldest-first, and ``current_uri`` identifies the active
    (``isCurrent = true``) head of the chain.

    Based on ``docs/specification.md`` lines 42468--42491.

    Attributes:
        current_uri: URI of the current (active) head node.
        predecessors: URIs of predecessor nodes, oldest-first.
    """

    current_uri: str = Field(
        ...,
        min_length=1,
        description="URI of the current (active) head node",
    )
    predecessors: tuple[str, ...] = Field(
        default=(),
        description="URIs of predecessor nodes, oldest-first",
    )

    @field_validator("current_uri")
    @classmethod
    def _validate_current_uri(
        cls: type[RevisionChain],
        v: str,
    ) -> str:
        """Reject whitespace-only current URIs."""
        return _validate_non_blank(v, "current_uri")

    @field_validator("predecessors")
    @classmethod
    def _validate_predecessors(
        cls: type[RevisionChain],
        v: tuple[str, ...],
    ) -> tuple[str, ...]:
        """Reject empty, whitespace-only, or invisible predecessor URIs."""
        for i, uri in enumerate(v):
            _validate_non_blank(uri, f"predecessors[{i}]")
        return v

    @model_validator(mode="after")
    def _validate_chain_integrity(self) -> RevisionChain:
        """Ensure chain URIs are unique and current is not a predecessor."""
        if self.current_uri in self.predecessors:
            msg = f"current_uri {self.current_uri!r} must not appear in predecessors"
            raise ValueError(msg)
        if len(self.predecessors) != len(set(self.predecessors)):
            msg = "predecessors must not contain duplicate URIs"
            raise ValueError(msg)
        return self

    @property
    def depth(self) -> int:
        """Number of revisions in the chain (predecessors + current)."""
        return len(self.predecessors) + 1

    @property
    def all_uris(self) -> tuple[str, ...]:
        """All URIs in order: predecessors (oldest-first) then current."""
        return (*self.predecessors, self.current_uri)


# ---------------------------------------------------------------------------
# TierRetentionConfig — warm/cold tier retention policy
# ---------------------------------------------------------------------------


class TierRetentionConfig(BaseModel, frozen=True):
    """Retention policy for warm and cold storage tiers.

    Based on ``docs/specification.md`` lines 28692--28693.

    Attributes:
        warm_retention_hours: Hours warm-tier entries are retained
            before demotion to cold.  Maps to config key
            ``context.tiers.warm.retention-hours``
            (env: ``CLEVERAGENTS_CTX_WARM_HOURS``).
        cold_retention_days: Days cold-tier entries are retained
            before archival/expiry.  Maps to config key
            ``context.tiers.cold.retention-days``
            (env: ``CLEVERAGENTS_CTX_COLD_DAYS``).
    """

    warm_retention_hours: int = Field(
        default=24,
        ge=1,
        description="Hours warm-tier entries are retained (default 24h)",
    )
    cold_retention_days: int = Field(
        default=90,
        ge=1,
        description="Days cold-tier entries are retained (default 90d)",
    )


# ---------------------------------------------------------------------------
# TierQueryResult — result from a tier-aware temporal query
# ---------------------------------------------------------------------------


class TierQueryResult(BaseModel, frozen=True):
    """Result from a tier-aware temporal query.

    Based on ``docs/specification.md`` lines 42493--42499 (Three
    Storage Tiers with Temporal Alignment).

    Attributes:
        nodes: Matching temporal nodes.
        tier: Which storage tier was queried.
        temporal_scope: Temporal scope applied to the query.
    """

    nodes: tuple[TemporalNode, ...] = Field(
        default=(),
        description="Matching temporal nodes",
    )
    tier: ContextTier = Field(
        ...,
        description="Storage tier that was queried (HOT/WARM/COLD)",
    )
    temporal_scope: TemporalScope = Field(
        ...,
        description="Temporal scope applied (CURRENT/RECENT/ALL)",
    )


# ---------------------------------------------------------------------------
# TemporalBackend — protocol for temporal storage and queries
# ---------------------------------------------------------------------------


@runtime_checkable
class TemporalBackend(Protocol):
    """Protocol for temporal storage and revision-aware queries.

    Replaces the ``BackendSet.temporal: object | None`` placeholder
    with a formal typed interface.  Based on spec lines 42468--42591.

    Lifecycle::

        backend = InMemoryTemporalBackend()
        node = backend.create_revision(
            "uko-py:class/Foo_v1", new_node, datetime.now(tz=UTC))
        chain = backend.get_revision_chain("uko-py:class/Foo_v2")
    """

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

        Raises:
            ValueError: If *current_uri* is blank, equals
                ``new_node.node_uri``, the node is already historical,
                ``new_node.node_uri`` already exists,
                ``new_node.temporal.is_revision_of != current_uri``,
                or ``new_node.temporal.is_current`` is not ``True``.
            KeyError: If *current_uri* does not exist.
        """
        ...

    def get_current(
        self,
        node_uri_base: str,
    ) -> TemporalNode | None:
        """Return the current (``isCurrent = true``) version.

        Raises:
            ValueError: If *node_uri_base* is empty or whitespace-only.
        """
        ...

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

        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``.

        Raises:
            ValueError: If *node_uri_base* is empty or whitespace-only.
        """
        ...

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

        Raises:
            ValueError: If *node_uri* is empty or whitespace-only.
            KeyError: If *node_uri* does not exist.
        """
        ...

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

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

        Used by :meth:`TemporalService.store_initial_node` to persist
        the first version of a node that has no predecessor.

        Raises:
            ValueError: If a node with the same ``node_uri`` already
                exists.
        """
        ...

    def mark_historical(
        self,
        node_uri: str,
        timestamp: datetime,
    ) -> TemporalNode:
        """Mark a node historical (``valid_until`` set, ``is_current`` cleared).

        Raises:
            ValueError: If *node_uri* is empty or whitespace-only,
                or if the node is already historical.
            KeyError: If *node_uri* does not exist.
        """
        ...
