"""ACMS v1 context fragment domain models.

Defines the core value objects for the Adaptive Context Management System
(ACMS) context assembly pipeline: ``ContextFragment``, ``FragmentProvenance``,
``ContextBudget``, and ``ContextPayload``.  All models are frozen Pydantic v2
value objects that **extend** the mutable CRP base types defined in
:mod:`cleveragents.domain.models.acms.crp`.

The inheritance hierarchy ensures that every core ``ContextFragment`` is also
an ``isinstance`` of the CRP ``ContextFragment``, eliminating the previous
duplication and enabling strategies written against either interface to
interoperate seamlessly.

Based on ``docs/specification.md`` ~line 25075 (ContextFragment) and ~line
25092 (AssembledContext).
"""

from __future__ import annotations

import copy
import hashlib
from datetime import UTC, datetime
from typing import Any, Literal

from pydantic import BaseModel, Field, field_validator, model_validator
from ulid import ULID

from cleveragents.domain.models.acms.crp import (
    AssembledContext as CRPAssembledContext,
)
from cleveragents.domain.models.acms.crp import (
    ContextBudget as CRPContextBudget,
)
from cleveragents.domain.models.acms.crp import (
    ContextFragment as CRPContextFragment,
)
from cleveragents.domain.models.acms.crp import (
    FragmentProvenance as CRPFragmentProvenance,
)
from cleveragents.domain.models.core.plan import ULID_PATTERN

# Maximum content length (characters).  Enforced to prevent unbounded memory
# consumption when callers create fragments from large files.
MAX_CONTENT_LENGTH: int = 1_000_000  # ~1 MB of text

# Maximum number of metadata entries per fragment.
MAX_METADATA_ENTRIES: int = 64


class FragmentProvenance(CRPFragmentProvenance, frozen=True):
    """Provenance trace for a context fragment.

    Extends the CRP ``FragmentProvenance`` with an additional
    ``resource_type`` field for richer auditability.  Frozen so that
    provenance records are immutable value objects.

    Based on ``docs/specification.md`` ~line 25088.
    """

    resource_type: str = Field(
        default="unknown",
        description=(
            "Type of the originating resource (e.g. 'git-checkout', 'fs-directory')"
        ),
    )


class ContextFragment(CRPContextFragment, frozen=True):
    """A single piece of context assembled by the ACMS pipeline.

    Extends the CRP ``ContextFragment`` with ``fragment_id``, ``tier``,
    ``created_at``, and stricter validation.  Frozen so that fragments
    are immutable value objects.

    Based on ``docs/specification.md`` ~line 25081.
    """

    fragment_id: str = Field(default_factory=lambda: str(ULID()))

    content: str = Field(
        ...,
        max_length=MAX_CONTENT_LENGTH,
        description="Rendered text content",
    )

    detail_depth: int = Field(
        default=0,
        ge=0,
        le=9,
        description=(
            "Resolved integer depth: 0 (MODULE_LISTING) through 9 (FULL_SOURCE)"
        ),
    )

    relevance_score: float = Field(
        ge=0.0,
        le=1.0,
        default=0.5,
        description="Relevance to the current request (0.0-1.0)",
    )

    provenance: FragmentProvenance = Field(
        ...,
        description="Trace back to resource and location",
    )

    strategy_source: str = Field(
        default="",
        description=(
            "Name of the strategy that produced this fragment.  Empty string "
            "means the strategy is unknown or not yet assigned."
        ),
    )

    tier: Literal["hot", "warm", "cold"] = Field(default="warm")

    metadata: dict[str, str] = Field(default_factory=dict)

    created_at: datetime = Field(
        default_factory=lambda: datetime.now(UTC),
        description="When the fragment was created",
    )

    @field_validator("metadata")
    @classmethod
    def _validate_metadata_size(cls, v: dict[str, str]) -> dict[str, str]:
        if len(v) > MAX_METADATA_ENTRIES:
            msg = (
                f"metadata must have at most {MAX_METADATA_ENTRIES} entries, "
                f"got {len(v)}"
            )
            raise ValueError(msg)
        # Defensive copy so callers cannot mutate the model's internal state.
        return dict(v)


class ContextBudget(CRPContextBudget, frozen=True):
    """Token budget for context assembly.

    Extends the CRP ``ContextBudget`` with stricter defaults and
    validation: ``reserved_tokens`` must be strictly less than
    ``max_tokens``.  The effective minimum for ``max_tokens`` is
    therefore ``reserved_tokens + 1``.
    """

    max_tokens: int = Field(ge=1, default=4096)
    reserved_tokens: int = Field(ge=0, default=512)

    @model_validator(mode="after")
    def _check_reserved_within_max(self) -> ContextBudget:
        if self.reserved_tokens >= self.max_tokens:
            msg = (
                f"reserved_tokens ({self.reserved_tokens}) must be less than "
                f"max_tokens ({self.max_tokens})"
            )
            raise ValueError(msg)
        return self


class ContextPayload(CRPAssembledContext, frozen=True):
    """Assembled context payload ready for actor consumption.

    Extends the CRP ``AssembledContext`` with ``payload_id``, ``plan_id``,
    ``budget``, ``assembled_at``, and immutable tuple-based collections.

    Corresponds to the spec's ``AssembledContext`` (~line 25098).  Includes
    ``budget_used`` fraction, ``strategies_used`` list, ``context_hash`` for
    snapshot integrity, optional ``preamble``, and ``provenance_map``.
    """

    payload_id: str = Field(default_factory=lambda: str(ULID()))
    plan_id: str = Field(
        ...,
        pattern=ULID_PATTERN,
        description=(
            "Plan identifier.  Must be a valid ULID (26-char Crockford "
            "Base32), consistent with PlanIdentity and other core models."
        ),
    )

    fragments: tuple[ContextFragment, ...] = ()
    total_tokens: int = 0
    budget: ContextBudget = Field(default_factory=ContextBudget)

    budget_used: float = Field(
        default=0.0,
        ge=0.0,
        le=1.0,
        description="Fraction of budget consumed (0.0-1.0)",
    )

    strategies_used: tuple[str, ...] = Field(
        default=(),
        description="Which strategies contributed to this payload",
    )

    context_hash: str = Field(
        default="",
        description="SHA-256 hash of assembled content for snapshot integrity",
    )

    preamble: str | None = Field(
        default=None,
        description="Optional structure summary prepended to context",
    )

    provenance_map: dict[str, Any] = Field(
        default_factory=dict,
        description="Fragment ID -> provenance mapping for traceability",
    )

    skeleton_fragments: tuple[ContextFragment, ...] = Field(
        default=(),
        description=(
            "Compressed parent-context fragments for propagation to child plans. "
            "Produced by SkeletonCompressor during Phase 3 finalization. "
            "Empty when no parent context is available or skeleton_ratio is 0."
        ),
    )

    @field_validator("provenance_map")
    @classmethod
    def _freeze_provenance_map(cls, v: dict[str, Any]) -> dict[str, Any]:
        """Deep defensive copy so callers cannot mutate nested state."""
        return copy.deepcopy(v)

    assembled_at: datetime = Field(
        default_factory=lambda: datetime.now(UTC),
        description="When the payload was assembled",
    )

    @property
    def remaining_tokens(self) -> int:
        """Return tokens still available in the budget."""
        return self.budget.available_tokens - self.total_tokens

    @property
    def is_within_budget(self) -> bool:
        """Return True if total tokens do not exceed available budget."""
        return self.total_tokens <= self.budget.available_tokens


class ScoredFragment(BaseModel, frozen=True):
    """A ``ContextFragment`` annotated with a composite score.

    Produced by the ``FragmentScorer`` pipeline component and consumed by
    the ``BudgetPacker``.  The ``composite_score`` combines relevance,
    hierarchy weight, strategy quality, and recency into a single ranking
    value.  ``score_components`` preserves the individual factor
    contributions for observability.

    Based on ``docs/specification.md`` ~line 42825.
    """

    fragment: ContextFragment
    composite_score: float = Field(
        ge=0.0,
        le=1.0,
        description="Combined ranking score (0.0-1.0)",
    )
    score_components: dict[str, float] = Field(
        default_factory=dict,
        description=(
            'Breakdown of scoring factors, e.g. {"relevance": 0.9, '
            '"hierarchy": 0.8, "quality": 0.7, "recency": 1.0}'
        ),
    )


def compute_context_hash(fragments: tuple[ContextFragment, ...]) -> str:
    """Compute a SHA-256 hash over the ordered fragment contents.

    Used for snapshot integrity checks and caching.  Each fragment's
    content is length-prefixed (8-byte big-endian) so that different
    fragment boundary splits produce distinct hashes.
    """
    hasher = hashlib.sha256()
    for frag in fragments:
        encoded = frag.content.encode("utf-8")
        hasher.update(len(encoded).to_bytes(8, "big"))
        hasher.update(encoded)
    return hasher.hexdigest()


def build_provenance_map(
    fragments: tuple[ContextFragment, ...],
) -> dict[str, Any]:
    """Build a provenance map from fragment IDs to their provenance data."""
    return {
        frag.fragment_id: {
            "resource_uri": frag.provenance.resource_uri,
            "location": frag.provenance.location,
            "resource_type": frag.provenance.resource_type,
        }
        for frag in fragments
    }
