"""Sandbox boundary algebra for CleverAgents.

Implements the ``sandbox_boundary(r)`` function defined in the specification:
walk up containment edges in the resource DAG to the nearest sandboxable
ancestor.  All resources sharing the same boundary share one sandbox instance.

**Definitions** (from spec §24659-24674):

* **Sandbox boundary**: A resource ``b`` where
  ``b.capabilities.sandboxable is True`` and
  ``b.sandbox_strategy != "none"``.

* **sandbox_boundary(r)**: The nearest ancestor of ``r`` (inclusive)
  along ``contains`` edges that is a sandbox boundary.  If ``r`` itself
  is a boundary, returns ``r``.  If no boundary exists in ``r``'s
  ancestor chain, returns ``None`` (unsandboxable).

* **Sandbox domain**: The set ``{r : sandbox_boundary(r) == b}`` —
  resources whose nearest sandbox boundary is ``b``.

Stage M6 - Sandbox Boundary Algebra (#548).
"""

from __future__ import annotations

import logging
import threading
from collections.abc import Iterable, Mapping

from cleveragents.domain.models.core.resource import Resource, SandboxStrategy

logger = logging.getLogger(__name__)

# Maximum DAG depth to prevent infinite loops from cyclic parent references.
_MAX_DAG_DEPTH = 200


class SandboxBoundaryError(Exception):
    """Raised when sandbox boundary computation encounters an error."""


class NoSandboxBoundaryError(SandboxBoundaryError):
    """Raised when a resource has no sandboxable ancestor in its chain."""


def is_sandbox_boundary(resource: Resource) -> bool:
    """Check whether *resource* is a sandbox boundary.

    A resource is a sandbox boundary when it declares itself sandboxable
    **and** has a non-``none`` sandbox strategy.

    Args:
        resource: The resource to check.

    Returns:
        ``True`` if the resource is a sandbox boundary.

    Raises:
        ValueError: If *resource* is ``None``.
    """
    if resource is None:
        raise ValueError("resource cannot be None")

    return (
        resource.capabilities.sandboxable
        and resource.sandbox_strategy is not None
        and resource.sandbox_strategy != SandboxStrategy.NONE
    )


def sandbox_boundary(
    resource: Resource,
    resource_registry: Mapping[str, Resource],
) -> Resource | None:
    """Walk up containment edges to the nearest sandboxable ancestor.

    If *resource* itself is a sandbox boundary, it is returned immediately.
    Otherwise we walk the ``parents`` list (containment edges) upward
    until a boundary is found or the root is reached.

    Args:
        resource: Starting resource in the DAG.
        resource_registry: Mapping of ``resource_id`` → ``Resource`` for
            the entire DAG (or at least the ancestor chain).

    Returns:
        The nearest sandbox-boundary ancestor, or ``None`` if none exists.

    Raises:
        ValueError: If *resource* or *resource_registry* is ``None``.
    """
    if resource is None:
        raise ValueError("resource cannot be None")
    if resource_registry is None:
        raise ValueError("resource_registry cannot be None")

    # Self-check first (inclusive).
    if is_sandbox_boundary(resource):
        return resource

    visited: set[str] = {resource.resource_id}
    frontier: list[str] = list(resource.parents)
    depth = 0

    while frontier and depth < _MAX_DAG_DEPTH:
        parent_id = frontier.pop(0)
        if parent_id in visited:
            continue
        visited.add(parent_id)
        depth += 1

        parent = resource_registry.get(parent_id)
        if parent is None:
            logger.warning(
                "Parent resource %s not found in registry while computing "
                "sandbox boundary for %s",
                parent_id,
                resource.resource_id,
            )
            continue

        if is_sandbox_boundary(parent):
            return parent

        # Continue walking upward.
        for grandparent_id in parent.parents:
            if grandparent_id not in visited:
                frontier.append(grandparent_id)

    return None


def compute_sandbox_domains(
    resources: Iterable[Resource],
    resource_registry: Mapping[str, Resource],
) -> dict[str, list[Resource]]:
    """Group resources by their sandbox boundary.

    Returns a mapping of ``boundary_resource_id`` → list of resources
    in that domain.  Resources with no sandbox boundary are collected
    under the key ``"__unsandboxable__"``.

    Args:
        resources: Iterable of resources to partition.
        resource_registry: Full resource registry for ancestor lookup.

    Returns:
        Domain mapping (boundary_id → resource list).

    Raises:
        ValueError: If *resources* or *resource_registry* is ``None``.
    """
    if resources is None:
        raise ValueError("resources cannot be None")
    if resource_registry is None:
        raise ValueError("resource_registry cannot be None")

    domains: dict[str, list[Resource]] = {}
    for res in resources:
        boundary = sandbox_boundary(res, resource_registry)
        key = boundary.resource_id if boundary is not None else "__unsandboxable__"
        domains.setdefault(key, []).append(res)

    return domains


class BoundaryCache:
    """Thread-safe cache for sandbox boundary lookups.

    The resource DAG does not change during a plan execution, so boundary
    results can be cached for the duration of a plan run.  Call
    :meth:`clear` at the start of each execution or when the DAG changes.

    Thread-safe: all mutable state is protected by ``_lock``.
    """

    def __init__(self) -> None:
        self._cache: dict[str, str | None] = {}
        self._lock: threading.RLock = threading.RLock()

    def get_boundary(
        self,
        resource: Resource,
        resource_registry: Mapping[str, Resource],
    ) -> Resource | None:
        """Return the sandbox boundary for *resource*, using the cache.

        Args:
            resource: The resource to look up.
            resource_registry: Full resource registry for ancestor walk.

        Returns:
            The boundary resource, or ``None`` if unsandboxable.

        Raises:
            ValueError: If *resource* or *resource_registry* is ``None``.
        """
        if resource is None:
            raise ValueError("resource cannot be None")
        if resource_registry is None:
            raise ValueError("resource_registry cannot be None")

        with self._lock:
            if resource.resource_id in self._cache:
                cached_id = self._cache[resource.resource_id]
                if cached_id is None:
                    return None
                return resource_registry.get(cached_id)

        # Compute (outside lock to avoid holding it during DAG walk).
        boundary = sandbox_boundary(resource, resource_registry)
        boundary_id = boundary.resource_id if boundary is not None else None

        with self._lock:
            self._cache[resource.resource_id] = boundary_id

        return boundary

    def clear(self) -> None:
        """Clear all cached boundary lookups."""
        with self._lock:
            self._cache.clear()

    @property
    def size(self) -> int:
        """Return the number of cached entries."""
        with self._lock:
            return len(self._cache)
