"""Scope enforcement mixin for the Context Tier Service.

Extracted from ``context_tiers.py`` to keep modules under the 500-line
guideline.  Provides scope-aware retrieval, storage, validation, and
metrics methods that are mixed into ``ContextTierService``.

All methods assume access to ``_hot``, ``_warm``, ``_cold`` tier stores
and the ``_find_fragment``, ``store``, ``get`` helpers defined on the
host class.

Based on ``docs/specification.md`` ACMS scoped views (lines 42501--42566)
and Forgejo issue #193.
"""

from __future__ import annotations

import structlog

from cleveragents.domain.models.acms.scope_resolution import (
    validate_resource_scope,
)
from cleveragents.domain.models.acms.scoped_view import (
    ResourceScope,
    ScopedBackendView,
    ScopeViolationError,
)
from cleveragents.domain.models.acms.tiers import (
    TieredFragment,
    TierMetrics,
)

logger = structlog.get_logger(__name__)


class ScopedTierMixin:
    """Mixin providing scope enforcement for ``ContextTierService``.

    Requires the host class to provide:

    - ``_hot``, ``_warm``, ``_cold``: ``dict[str, TieredFragment]``
    - ``_hot_hit``, ``_hot_miss``, ``_warm_hit``, ``_warm_miss``: int
    - ``get(fragment_id: str) -> TieredFragment | None``
    - ``store(fragment: TieredFragment) -> None``
    - ``_find_fragment(fragment_id: str) -> TieredFragment | None``
    """

    # Declared for type checkers; actual attrs live on the host class.
    _hot: dict[str, TieredFragment]
    _warm: dict[str, TieredFragment]
    _cold: dict[str, TieredFragment]
    _hot_hit: int
    _hot_miss: int
    _warm_hit: int
    _warm_miss: int

    def get(self, fragment_id: str) -> TieredFragment | None: ...
    def store(self, fragment: TieredFragment) -> None: ...
    def _find_fragment(self, fragment_id: str) -> TieredFragment | None: ...

    # ------------------------------------------------------------------
    # Scope-aware retrieval
    # ------------------------------------------------------------------

    def get_scoped(
        self,
        fragment_id: str,
        scope: ResourceScope,
    ) -> TieredFragment | None:
        """Retrieve a fragment by ID with scope enforcement.

        Unlike :meth:`get`, this validates the fragment against the
        provided scope before returning it.  Returns ``None`` and
        logs a warning for out-of-scope fragments.

        Args:
            fragment_id: The fragment to retrieve.
            scope: The active resource scope to enforce.
        """
        frag = self.get(fragment_id)
        if frag is None:
            return None

        scoped = ScopedBackendView.from_resource_scope(scope)
        if not scoped.is_visible(frag):
            logger.warning(
                "tier.get_scoped_denied",
                fragment_id=fragment_id,
                fragment_project=frag.project_name,
                fragment_resource=frag.resource_id,
                scope_projects=sorted(scope.project_names),
            )
            return None
        return frag

    def get_scoped_by_resource(
        self,
        scope: ResourceScope,
    ) -> list[TieredFragment]:
        """Return fragments filtered by a full ``ResourceScope``.

        Applies both project-name and resource-ID filtering.

        Args:
            scope: The resolved resource scope for the plan.
        """
        scoped = ScopedBackendView.from_resource_scope(scope)
        all_frags: list[TieredFragment] = []
        rejected_count = 0
        for store in (self._hot, self._warm, self._cold):
            for frag in store.values():
                if scoped.is_visible(frag):
                    all_frags.append(frag)
                else:
                    rejected_count += 1
        all_frags.sort(key=lambda f: f.last_accessed, reverse=True)
        if rejected_count:
            logger.info(
                "tier.scope_enforcement",
                accepted=len(all_frags),
                rejected=rejected_count,
                project_names=sorted(scope.project_names),
                resource_count=len(scope.resource_ids),
            )
        return all_frags

    def validate_fragment_scope(
        self,
        fragment_id: str,
        scope: ResourceScope,
    ) -> None:
        """Validate that a specific fragment is within scope.

        Raises:
            ScopeViolationError: If the fragment is out of scope.
            ValueError: If the fragment does not exist.
        """
        frag = self._find_fragment(fragment_id)
        if frag is None:
            raise ValueError(f"Fragment not found: {fragment_id}")

        scoped = ScopedBackendView.from_resource_scope(scope)
        if not scoped.is_visible(frag):
            logger.warning(
                "tier.fragment_scope_violation",
                fragment_id=fragment_id,
                fragment_project=frag.project_name,
                fragment_resource=frag.resource_id,
                scope_projects=sorted(scope.project_names),
            )
            raise ScopeViolationError(
                f"Fragment '{fragment_id}' is not within the active scope",
                resource_ids=(frag.resource_id,) if frag.resource_id else (),
                scope_project_names=tuple(sorted(scope.project_names)),
            )

    def store_with_scope_check(
        self,
        fragment: TieredFragment,
        scope: ResourceScope,
    ) -> None:
        """Store a fragment after validating it is within scope.

        Checks both the fragment's project name and resource ID
        against the active scope before storing.

        .. note::

            Fragments with an empty ``project_name`` bypass the
            project check (there is no project to enforce against).
            Such fragments are still invisible to scoped reads
            because ``ScopedBackendView.is_visible()`` rejects
            empty project names.

        Raises:
            ScopeViolationError: If the fragment references an
                out-of-scope project or resource.
        """
        if fragment.project_name and not scope.contains_project(fragment.project_name):
            logger.warning(
                "tier.store_project_violation",
                fragment_id=fragment.fragment_id,
                fragment_project=fragment.project_name,
                scope_projects=sorted(scope.project_names),
            )
            raise ScopeViolationError(
                f"Fragment project '{fragment.project_name}' "
                f"is not within the active scope",
                scope_project_names=tuple(sorted(scope.project_names)),
            )
        if fragment.resource_id:
            validate_resource_scope(
                frozenset({fragment.resource_id}),
                scope,
            )
        self.store(fragment)

    def get_scoped_metrics(self, project_names: list[str]) -> TierMetrics:
        """Return metrics scoped to the given project names.

        Fragment counts reflect only fragments belonging to the
        specified projects.  Hit/miss counters remain global.

        Raises:
            ValueError: If *project_names* is empty.
        """
        if not project_names:
            raise ValueError("project_names must be non-empty")
        scoped = ScopedBackendView(
            allowed_projects=frozenset(project_names),
        )
        return TierMetrics(
            hot_count=sum(1 for f in self._hot.values() if scoped.is_visible(f)),
            warm_count=sum(1 for f in self._warm.values() if scoped.is_visible(f)),
            cold_count=sum(1 for f in self._cold.values() if scoped.is_visible(f)),
            hot_hit_count=self._hot_hit,
            hot_miss_count=self._hot_miss,
            warm_hit_count=self._warm_hit,
            warm_miss_count=self._warm_miss,
        )
