"""Project context policy domain model for CleverAgents v3.

A **ProjectContextPolicy** controls what context (resources and files) is
available during each ACMS phase.  It uses *view inheritance*:

    default → strategize → execute → apply

Each phase can override or inherit from its parent.  An empty policy
(``ProjectContextPolicy()``) defaults to including everything.

## View Inheritance

``resolve_view(phase)`` walks the inheritance chain and returns the
first explicitly-set ``ContextView`` for that phase (or defaults).

| Phase      | Inherits from |
|------------|---------------|
| default    | (none)        |
| strategize | default       |
| execute    | strategize    |
| apply      | execute       |

## Budget Enforcement

``enforce_size_budget`` filters context fragments against the byte-size
limits defined in a ``ContextView``.  It returns a
``BudgetEnforcementResult`` containing the accepted fragments and a
structured list of ``BudgetViolation`` entries describing which
fragments were excluded and why.

Based on ``docs/specification.md`` Context section and ADR-004.
"""

from __future__ import annotations

from collections.abc import Sequence
from typing import TYPE_CHECKING

from pydantic import BaseModel, ConfigDict, Field, field_validator

if TYPE_CHECKING:
    from cleveragents.domain.models.core.context_fragment import ContextFragment

# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------

VALID_PHASES: frozenset[str] = frozenset({"default", "strategize", "execute", "apply"})

_INHERITANCE_CHAIN: dict[str, list[str]] = {
    "default": ["default"],
    "strategize": ["strategize", "default"],
    "execute": ["execute", "strategize", "default"],
    "apply": ["apply", "execute", "strategize", "default"],
}


# ---------------------------------------------------------------------------
# ContextView
# ---------------------------------------------------------------------------


class ContextView(BaseModel):
    """Defines what resources and files are visible in a phase.

    Empty lists for ``include_resources`` and ``include_paths`` mean
    "include everything" (no filtering).  Exclusions always take
    precedence over inclusions.
    """

    include_resources: list[str] = Field(
        default_factory=list,
        description=("Resource names/patterns to include (empty = all)"),
    )
    exclude_resources: list[str] = Field(
        default_factory=list,
        description="Resource names/patterns to exclude",
    )
    include_paths: list[str] = Field(
        default_factory=list,
        description=("File path globs to include (empty = all)"),
    )
    exclude_paths: list[str] = Field(
        default_factory=list,
        description="File path globs to exclude",
    )
    max_file_size: int | None = Field(
        default=None,
        description=("Max file size in bytes to include in context (None = no limit)"),
    )
    max_total_size: int | None = Field(
        default=None,
        description=("Max total context size in bytes (None = no limit)"),
    )

    @field_validator("max_file_size", "max_total_size")
    @classmethod
    def _validate_positive_size(
        cls: type[ContextView],
        v: int | None,
    ) -> int | None:
        """Size limits must be positive when set."""
        if v is not None and v <= 0:
            raise ValueError("Size limit must be a positive integer or None")
        return v

    model_config = ConfigDict(
        str_strip_whitespace=True,
        validate_assignment=True,
    )


# ---------------------------------------------------------------------------
# ProjectContextPolicy
# ---------------------------------------------------------------------------


class ProjectContextPolicy(BaseModel):
    """Controls what context is available during each ACMS phase.

    Uses view inheritance: ``default`` → ``strategize`` →
    ``execute`` → ``apply``.  Each phase can override or inherit
    from its parent.

    An empty ``ProjectContextPolicy()`` defaults to including
    everything (the ``default_view`` has empty include lists which
    means "all").
    """

    default_view: ContextView = Field(
        default_factory=ContextView,
        description="Base defaults for all phases",
    )
    strategize_view: ContextView | None = Field(
        default=None,
        description=("Overrides for Strategize (inherits from default if None)"),
    )
    execute_view: ContextView | None = Field(
        default=None,
        description=("Overrides for Execute (inherits from strategize if None)"),
    )
    apply_view: ContextView | None = Field(
        default=None,
        description=("Overrides for Apply (inherits from execute if None)"),
    )

    def resolve_view(self, phase: str) -> ContextView:
        """Resolve the effective view for a given phase.

        Walks the inheritance chain and returns the first
        explicitly-set ``ContextView``.  If no overrides are found,
        returns ``default_view``.

        Args:
            phase: One of ``"default"``, ``"strategize"``,
                ``"execute"``, or ``"apply"``.

        Returns:
            The resolved ``ContextView`` for the phase.

        Raises:
            ValueError: If *phase* is not a valid ACMS phase.
        """
        if phase not in VALID_PHASES:
            raise ValueError(
                f"Invalid phase '{phase}': must be one of {sorted(VALID_PHASES)}"
            )

        view_map: dict[str, ContextView | None] = {
            "default": self.default_view,
            "strategize": self.strategize_view,
            "execute": self.execute_view,
            "apply": self.apply_view,
        }

        for ancestor in _INHERITANCE_CHAIN[phase]:
            view = view_map[ancestor]
            if view is not None:
                return view

        # Should never reach here; default_view is always set.
        return self.default_view  # pragma: no cover

    model_config = ConfigDict(
        str_strip_whitespace=True,
        validate_assignment=True,
    )


# ---------------------------------------------------------------------------
# Budget enforcement
# ---------------------------------------------------------------------------


class BudgetViolation(BaseModel, frozen=True):
    """A single budget violation recording why a fragment was excluded.

    Produced by :func:`enforce_size_budget` when a fragment exceeds the
    ``max_file_size`` limit or would push cumulative size past
    ``max_total_size``.
    """

    fragment_id: str = Field(
        ...,
        description="ID of the excluded fragment",
    )
    reason: str = Field(
        ...,
        description=(
            "Human-readable explanation, e.g. 'exceeded max_file_size (2048 > 1024)'"
        ),
    )
    content_size: int = Field(
        ...,
        ge=0,
        description="Size in bytes of the fragment content (UTF-8 encoded)",
    )
    limit: int | None = Field(
        default=None,
        description="The limit that was breached (bytes), or None if N/A",
    )
    violation_type: str = Field(
        ...,
        description="'max_file_size' or 'max_total_size'",
    )

    model_config = ConfigDict(str_strip_whitespace=True)


class BudgetEnforcementResult(BaseModel, frozen=True):
    """Result of enforcing byte-size budget limits on context fragments.

    Contains both the accepted fragments and a structured report of
    violations so callers can surface diagnostics to the user.
    """

    accepted: tuple[str, ...] = Field(
        default=(),
        description="Fragment IDs that passed budget enforcement",
    )
    violations: tuple[BudgetViolation, ...] = Field(
        default=(),
        description="Details on each excluded fragment",
    )
    total_size: int = Field(
        default=0,
        ge=0,
        description="Cumulative byte size of all accepted fragments",
    )

    model_config = ConfigDict(str_strip_whitespace=True)


def enforce_size_budget(
    fragments: Sequence[ContextFragment],
    view: ContextView,
) -> BudgetEnforcementResult:
    """Filter *fragments* against the byte-size limits in *view*.

    Applies two constraints from the resolved ``ContextView``:

    1. **max_file_size** — any individual fragment whose UTF-8 encoded
       content exceeds this limit is excluded.
    2. **max_total_size** — once the cumulative size of accepted
       fragments reaches this limit, remaining fragments are excluded.

    Fragments are evaluated in the order supplied.  ``None`` limits
    mean "no restriction".

    Args:
        fragments: Sequence of ``ContextFragment`` objects to evaluate.
        view: The ``ContextView`` whose limits should be enforced.

    Returns:
        A :class:`BudgetEnforcementResult` with accepted fragment IDs,
        violation details, and cumulative size.
    """
    max_file = view.max_file_size
    max_total = view.max_total_size

    # Fast path: when no limits are set, accept everything without
    # incurring per-fragment encode() overhead.
    if max_file is None and max_total is None:
        return BudgetEnforcementResult(
            accepted=tuple(f.fragment_id for f in fragments),
            violations=(),
            total_size=sum(
                len(f.content.encode("utf-8", errors="replace")) for f in fragments
            ),
        )

    accepted_ids: list[str] = []
    violations: list[BudgetViolation] = []
    total_size = 0

    for fragment in fragments:
        content_bytes = len(fragment.content.encode("utf-8", errors="replace"))

        # Check per-fragment size limit
        if max_file is not None and content_bytes > max_file:
            violations.append(
                BudgetViolation(
                    fragment_id=fragment.fragment_id,
                    reason=(f"exceeded max_file_size ({content_bytes} > {max_file})"),
                    content_size=content_bytes,
                    limit=max_file,
                    violation_type="max_file_size",
                ),
            )
            continue

        # Check cumulative size limit
        if max_total is not None and total_size + content_bytes > max_total:
            violations.append(
                BudgetViolation(
                    fragment_id=fragment.fragment_id,
                    reason=(
                        f"would exceed max_total_size "
                        f"({total_size} + {content_bytes} > {max_total})"
                    ),
                    content_size=content_bytes,
                    limit=max_total,
                    violation_type="max_total_size",
                ),
            )
            continue

        accepted_ids.append(fragment.fragment_id)
        total_size += content_bytes

    return BudgetEnforcementResult(
        accepted=tuple(accepted_ids),
        violations=tuple(violations),
        total_size=total_size,
    )
