"""Scoped backend view filtering for project-resource isolation.

Core types: ``ResourceScope``, ``ScopeViolationError``,
``ScopedBackendView``, ``ScopedBackendSet``, ``create_scoped_backend_set``.

Resolution helpers live in ``scope_resolution.py``.

Based on ``docs/specification.md`` lines 42501--42566 and issue #193.
"""

from __future__ import annotations

from pathlib import PurePath

import structlog
from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, field_validator

from cleveragents.domain.models.acms.backends import (
    GraphBackend,
    GraphResult,
    TextBackend,
    TextResult,
    VectorBackend,
    VectorResult,
)

logger = structlog.get_logger(__name__)


# ---------------------------------------------------------------------------
# Exceptions
# ---------------------------------------------------------------------------


class ScopeViolationError(Exception):
    """Raised when a context request references resources outside scope.

    Attributes:
        resource_ids: The out-of-scope resource identifiers.
        scope_project_names: The project names that define the scope.
    """

    def __init__(
        self,
        message: str,
        *,
        resource_ids: tuple[str, ...] = (),
        scope_project_names: tuple[str, ...] = (),
    ) -> None:
        super().__init__(message)
        self.resource_ids = resource_ids
        self.scope_project_names = scope_project_names


# ---------------------------------------------------------------------------
# ResourceScope
# ---------------------------------------------------------------------------


class ResourceScope(BaseModel):
    """Resolved set of resources and projects visible to a plan.

    Immutable once created. Based on ``specification.md`` lines 42510--42525.
    """

    resource_ids: frozenset[str] = Field(
        ...,
        description="Resource ULIDs in scope (empty = no resources after filtering)",
    )
    project_names: frozenset[str] = Field(
        ...,
        min_length=1,
        description="Namespaced project names in scope",
    )
    temporal_scope: str = Field(
        default="current",
        description="Temporal scope for UKO node resolution",
        pattern=r"^(current|recent|all)$",
    )
    include_resources: tuple[str, ...] = Field(
        default=(),
        max_length=256,
        description="Resource allowlist (names or aliases). Empty = all.",
    )
    exclude_resources: tuple[str, ...] = Field(
        default=(),
        max_length=256,
        description="Resource denylist (names or aliases). Applied after allowlist.",
    )
    include_paths: tuple[str, ...] = Field(
        default=(),
        max_length=256,
        description="Path glob allowlist. Empty = all.",
    )
    exclude_paths: tuple[str, ...] = Field(
        default=(),
        max_length=256,
        description="Path glob denylist. Applied after allowlist.",
    )

    model_config = ConfigDict(frozen=True)

    def contains_resource(self, resource_id: str) -> bool:
        """Return True if *resource_id* is within this scope.

        Args:
            resource_id: A resource ULID to check.

        Returns:
            Whether the resource is in scope.
        """
        return resource_id in self.resource_ids

    def contains_project(self, project_name: str) -> bool:
        """Return True if *project_name* is within this scope.

        Args:
            project_name: A namespaced project name to check.

        Returns:
            Whether the project is in scope.
        """
        return project_name in self.project_names

    def matches_path(self, path: str) -> bool:
        """Return True if *path* passes include/exclude path filters.

        Uses ``PurePath.full_match()`` (requires Python >=3.12; this
        project requires >=3.13 per ``pyproject.toml``).
        """
        pp = PurePath(path)
        if self.include_paths and not any(
            pp.full_match(pat) for pat in self.include_paths
        ):
            return False
        return not (
            self.exclude_paths and any(pp.full_match(pat) for pat in self.exclude_paths)
        )


# ---------------------------------------------------------------------------
# ScopedBackendView
# ---------------------------------------------------------------------------


class ScopedBackendView(BaseModel):
    """Wraps a backend to restrict queries to a resource scope.

    Operates at two levels: backend-level (injects ``scope`` into
    queries) and fragment-level (``is_visible()`` filters by project
    and resource). Based on ``specification.md`` lines 42548--42566.
    """

    allowed_projects: frozenset[str] = Field(
        ...,
        min_length=1,
        description="Project names the actor is permitted to access",
    )
    resource_ids: frozenset[str] = Field(
        default=frozenset(),
        description="Resource ULIDs in scope (empty = allow all for backward compat)",
    )
    denied_resource_ids: frozenset[str] = Field(
        default=frozenset(),
        description="Resource ULIDs explicitly denied (denylist)",
    )

    _effective_scope_cached: frozenset[str] = PrivateAttr(
        default=frozenset(),
    )

    model_config = ConfigDict(
        frozen=True,
    )

    def model_post_init(self, __context: object) -> None:
        """Pre-compute effective scope (model is frozen after init)."""
        if not self.resource_ids:
            self._effective_scope_cached = frozenset()
        else:
            self._effective_scope_cached = self.resource_ids - self.denied_resource_ids

    @field_validator("allowed_projects", mode="before")
    @classmethod
    def _coerce_allowed_projects(
        cls: type[ScopedBackendView],
        v: frozenset[str] | list[str] | set[str] | tuple[str, ...],
    ) -> frozenset[str]:
        """Accept list/set/tuple and convert to frozenset."""
        if isinstance(v, (list, set, tuple)):
            return frozenset(v)
        return v

    @field_validator("resource_ids", mode="before")
    @classmethod
    def _coerce_resource_ids(
        cls: type[ScopedBackendView],
        v: frozenset[str] | list[str] | set[str] | tuple[str, ...],
    ) -> frozenset[str]:
        """Accept list/set/tuple and convert to frozenset."""
        if isinstance(v, (list, set, tuple)):
            return frozenset(v)
        return v

    @field_validator("denied_resource_ids", mode="before")
    @classmethod
    def _coerce_denied_resource_ids(
        cls: type[ScopedBackendView],
        v: frozenset[str] | list[str] | set[str] | tuple[str, ...],
    ) -> frozenset[str]:
        """Accept list/set/tuple and convert to frozenset."""
        if isinstance(v, (list, set, tuple)):
            return frozenset(v)
        return v

    # -- Resource scope checks -----------------------------------------------

    def is_resource_in_scope(self, resource_id: str) -> bool:
        """Return True if *resource_id* is allowed by this view.

        A resource is in scope when:
        1. It is in ``resource_ids`` (or ``resource_ids`` is empty), AND
        2. It is NOT in ``denied_resource_ids``.

        Args:
            resource_id: A resource ULID to check.

        Returns:
            Whether the resource passes the scope filter.
        """
        if resource_id in self.denied_resource_ids:
            return False
        if self.resource_ids:
            return resource_id in self.resource_ids
        return True

    def effective_scope(self) -> frozenset[str]:
        """Return ``resource_ids - denied_resource_ids`` (cached at init).

        Empty frozenset when ``resource_ids`` is empty (project-only mode).
        """
        return self._effective_scope_cached

    # -- Fragment-level filtering (backward compat with tier service) ---------

    def is_visible(self, fragment: object) -> bool:
        """Return True if *fragment* belongs to an allowed project.

        Fragments with an empty ``project_name`` are **excluded** by
        default to prevent information leakage.

        When ``resource_ids`` is populated (ULID-level filtering is
        active), fragments **must** have a non-empty ``resource_id``
        that passes ``is_resource_in_scope()``.  Fragments with an
        empty ``resource_id`` are denied to enforce resource-level
        isolation (deny-by-default).

        Args:
            fragment: A ``TieredFragment`` or any object with
                ``project_name`` and optionally ``resource_id`` attrs.

        Returns:
            Whether the fragment is visible in this view.
        """
        project_name: str = getattr(fragment, "project_name", "")
        if not project_name:
            return False
        if project_name not in self.allowed_projects:
            return False

        # Resource-level filtering when resource_ids are set
        resource_id: str = getattr(fragment, "resource_id", "")
        if self.resource_ids:
            # Deny-by-default: fragments without a resource_id are
            # excluded when resource-level filtering is active.
            if not resource_id:
                return False
            return self.is_resource_in_scope(resource_id)

        return True

    # -- Backend-level query proxying ----------------------------------------

    def _require_effective_scope(self) -> frozenset[str]:
        """Return the effective scope or raise if all resources are denied.

        Returns an empty frozenset in project-only mode (no resource
        filter).  Raises ``ScopeViolationError`` when ``resource_ids``
        is populated but all entries are denied.
        """
        scope = self.effective_scope()
        if self.resource_ids and not scope:
            logger.warning(
                "scope.empty_after_denylist",
                allowed_projects=sorted(self.allowed_projects),
            )
            raise ScopeViolationError(
                "All resources in scope are denied",
                scope_project_names=tuple(sorted(self.allowed_projects)),
            )
        return scope

    def search_text(
        self,
        backend: TextBackend,
        query: str,
        *,
        max_results: int = 20,
    ) -> list[TextResult]:
        """Search a text backend with scope injected."""
        scope = self._require_effective_scope()
        return backend.search(query, scope=scope, max_results=max_results)

    def search_vector(
        self,
        backend: VectorBackend,
        embedding: list[float],
        *,
        top_k: int = 20,
    ) -> list[VectorResult]:
        """Search a vector backend with scope injected."""
        scope = self._require_effective_scope()
        return backend.similarity_search(embedding, scope=scope, top_k=top_k)

    def search_graph(
        self,
        backend: GraphBackend,
        query: str,
    ) -> GraphResult:
        """Execute a SPARQL query on a graph backend with scope injected."""
        scope = self._require_effective_scope()
        return backend.sparql_query(query, scope=scope)

    @classmethod
    def from_resource_scope(cls, scope: ResourceScope) -> ScopedBackendView:
        """Create a ``ScopedBackendView`` from a ``ResourceScope``.

        .. note::

            ``denied_resource_ids`` is **not** forwarded because
            ``resolve_resource_scope()`` already subtracts denied IDs
            from ``resource_ids`` before constructing the
            ``ResourceScope``.  The scope's ``resource_ids`` is
            therefore already the effective (post-deny) set.

        Args:
            scope: The resolved resource scope.

        Returns:
            A new ``ScopedBackendView`` configured with the scope's
            resource IDs and project names.
        """
        return cls(
            allowed_projects=scope.project_names,
            resource_ids=scope.resource_ids,
        )


# ---------------------------------------------------------------------------
# ScopedBackendSet
# ---------------------------------------------------------------------------


class ScopedBackendSet(BaseModel):
    """Bundle of scoped backend views for text, vector, and graph.

    Created by ``create_scoped_backend_set()`` during context assembly.
    Based on ``specification.md`` line 43050.
    """

    view: ScopedBackendView = Field(
        ...,
        description="The scoped view enforcing resource isolation",
    )
    text_backend: TextBackend | None = Field(
        default=None,
        description="Text search backend (None if unavailable)",
    )
    vector_backend: VectorBackend | None = Field(
        default=None,
        description="Vector search backend (None if unavailable)",
    )
    graph_backend: GraphBackend | None = Field(
        default=None,
        description="Graph query backend (None if unavailable)",
    )

    model_config = ConfigDict(arbitrary_types_allowed=True)

    def search_text(
        self,
        query: str,
        *,
        max_results: int = 20,
    ) -> list[TextResult]:
        """Search text backend with scope enforcement.

        Args:
            query: Natural-language or keyword query.
            max_results: Maximum results to return.

        Returns:
            Scoped text results (empty list if no text backend).
        """
        if self.text_backend is None:
            return []
        return self.view.search_text(self.text_backend, query, max_results=max_results)

    def search_vector(
        self,
        embedding: list[float],
        *,
        top_k: int = 20,
    ) -> list[VectorResult]:
        """Search vector backend with scope enforcement.

        Args:
            embedding: Query embedding vector.
            top_k: Maximum results to return.

        Returns:
            Scoped vector results.
        """
        if self.vector_backend is None:
            return []
        return self.view.search_vector(self.vector_backend, embedding, top_k=top_k)

    def search_graph(
        self,
        query: str,
    ) -> GraphResult:
        """Execute SPARQL query with scope enforcement.

        Args:
            query: SPARQL query string.

        Returns:
            Scoped graph results.
        """
        if self.graph_backend is None:
            return GraphResult()
        return self.view.search_graph(self.graph_backend, query)


def create_scoped_backend_set(
    scope: ResourceScope,
    *,
    text_backend: TextBackend | None = None,
    vector_backend: VectorBackend | None = None,
    graph_backend: GraphBackend | None = None,
) -> ScopedBackendSet:
    """Create a ``ScopedBackendSet`` from a ``ResourceScope``."""
    view = ScopedBackendView.from_resource_scope(scope)
    logger.info(
        "scoped_backend_set.created",
        project_names=sorted(scope.project_names),
        resource_count=len(scope.resource_ids),
    )
    return ScopedBackendSet(
        view=view,
        text_backend=text_backend,
        vector_backend=vector_backend,
        graph_backend=graph_backend,
    )


__all__: list[str] = [
    "ResourceScope",
    "ScopeViolationError",
    "ScopedBackendSet",
    "ScopedBackendView",
    "create_scoped_backend_set",
]
