"""Resource scope resolution, alias mapping, and validation guards.

Extracted from ``scoped_view.py`` to keep modules under the 500-line
guideline.  This module provides:

| Type / Function            | Role                                            |
|----------------------------|-------------------------------------------------|
| ``ResourceAliasResolver``  | Resolves resource aliases to canonical ULIDs    |
| ``validate_project_scope`` | Guard for mixed-project scope requests          |
| ``validate_resource_scope``| Guard for out-of-scope resource requests        |
| ``resolve_resource_scope`` | Builds a ``ResourceScope`` with allow/deny list |

Based on ``docs/specification.md`` > ACMS > Scoped Views and Plan
Subgraph Projection (lines 42501--42566) and Forgejo issue #193.
"""

from __future__ import annotations

from collections.abc import Callable
from typing import TYPE_CHECKING

import structlog

from cleveragents.domain.models.acms.scoped_view import (
    ResourceScope,
    ScopeViolationError,
)

if TYPE_CHECKING:
    from cleveragents.domain.models.core.project import (
        NamespacedProject,
    )

logger = structlog.get_logger(__name__)


# ---------------------------------------------------------------------------
# ResourceAliasResolver
# ---------------------------------------------------------------------------


class ResourceAliasResolver:
    """Resolves resource aliases to resource IDs with uniqueness validation.

    Stateless helper that operates on project data passed to each
    method.  Used during scope resolution to translate user-facing
    resource names and aliases into canonical ULID identifiers.

    Based on ``docs/specification.md`` LinkedResource alias field
    (line 19715) and Forgejo issue #193.
    """

    def resolve(
        self,
        project: NamespacedProject,
        alias_or_id: str,
    ) -> str | None:
        """Resolve a resource alias or ID to a canonical resource ID.

        Checks aliases first (exact match), then falls back to
        resource_id matching.

        Args:
            project: The project containing linked resources.
            alias_or_id: An alias or resource ULID to resolve.

        Returns:
            The canonical resource ULID, or ``None`` if not found.

        Raises:
            ValueError: If *alias_or_id* is empty or whitespace-only.
        """
        if not alias_or_id or not alias_or_id.strip():
            raise ValueError("alias_or_id cannot be empty")

        # Try alias match first
        by_alias = project.get_linked_resource_by_alias(alias_or_id)
        if by_alias is not None:
            return by_alias.resource_id

        # Try direct resource_id match
        by_id = project.get_linked_resource(alias_or_id)
        if by_id is not None:
            return by_id.resource_id

        return None

    def resolve_many(
        self,
        project: NamespacedProject,
        names: tuple[str, ...],
    ) -> frozenset[str]:
        """Resolve multiple aliases/IDs to canonical resource IDs.

        Unresolvable names are logged and skipped.

        Args:
            project: The project containing linked resources.
            names: Aliases or resource ULIDs to resolve.

        Returns:
            Frozenset of resolved resource ULIDs.
        """
        resolved: set[str] = set()
        for name in names:
            rid = self.resolve(project, name)
            if rid is not None:
                resolved.add(rid)
            else:
                logger.warning(
                    "alias.unresolved",
                    alias=name,
                    project=project.namespaced_name,
                )
        return frozenset(resolved)

    def validate_uniqueness(
        self,
        project: NamespacedProject,
    ) -> list[str]:
        """Validate that all aliases in a project are unique.

        Args:
            project: The project to validate.

        Returns:
            List of error messages for duplicate aliases (empty if valid).
        """
        seen: dict[str, list[str]] = {}
        for lr in project.linked_resources:
            if lr.alias is not None:
                seen.setdefault(lr.alias, []).append(lr.resource_id)

        errors: list[str] = []
        for alias, resource_ids in seen.items():
            if len(resource_ids) > 1:
                errors.append(
                    f"Duplicate alias '{alias}' maps to resources: "
                    f"{', '.join(sorted(resource_ids))}"
                )
        return errors


# ---------------------------------------------------------------------------
# Mixed project scope guard
# ---------------------------------------------------------------------------


def validate_project_scope(
    requested_projects: frozenset[str],
    available_projects: frozenset[str],
) -> None:
    """Validate that all requested projects are available.

    Raises ``ScopeViolationError`` if any requested project is not
    in the available set.  This guards against context requests that
    span unlinked projects.

    Args:
        requested_projects: Project names the request references.
        available_projects: Project names available in the scope.

    Raises:
        ScopeViolationError: If any requested project is not available.
        ValueError: If either argument is empty.
    """
    if not requested_projects:
        raise ValueError("requested_projects must be non-empty")
    if not available_projects:
        raise ValueError("available_projects must be non-empty")

    out_of_scope = requested_projects - available_projects
    if out_of_scope:
        sorted_bad = sorted(out_of_scope)
        logger.warning(
            "scope.project_violation",
            out_of_scope=sorted_bad,
            available=sorted(available_projects),
        )
        raise ScopeViolationError(
            f"Projects not in scope: {', '.join(sorted_bad)}. "
            f"Available: {', '.join(sorted(available_projects))}",
            scope_project_names=tuple(sorted(available_projects)),
        )


def validate_resource_scope(
    requested_resource_ids: frozenset[str],
    scope: ResourceScope,
) -> None:
    """Validate that all requested resource IDs are within scope.

    Raises ``ScopeViolationError`` if any resource ID is not in the
    scope's ``resource_ids``.

    Args:
        requested_resource_ids: Resource ULIDs the request references.
        scope: The active resource scope.

    Raises:
        ScopeViolationError: If any resource is out of scope.
    """
    if not requested_resource_ids:
        return

    out_of_scope = requested_resource_ids - scope.resource_ids
    if out_of_scope:
        sorted_bad = sorted(out_of_scope)
        logger.warning(
            "scope.resource_violation",
            out_of_scope=sorted_bad,
            scope_resource_count=len(scope.resource_ids),
        )
        raise ScopeViolationError(
            f"Resources not in scope: {', '.join(sorted_bad)}",
            resource_ids=tuple(sorted_bad),
            scope_project_names=tuple(sorted(scope.project_names)),
        )


# ---------------------------------------------------------------------------
# Allowlist/denylist resolution
# ---------------------------------------------------------------------------


def resolve_resource_scope(
    projects: list[NamespacedProject],
    *,
    include_resources: tuple[str, ...] = (),
    exclude_resources: tuple[str, ...] = (),
    include_paths: tuple[str, ...] = (),
    exclude_paths: tuple[str, ...] = (),
    temporal_scope: str = "current",
    registry_lookup: Callable[[str], frozenset[str]] | None = None,
) -> ResourceScope:
    """Resolve a ``ResourceScope`` from projects and filter arguments.

    Collects all resource IDs from linked resources across the given
    projects, then applies allowlist/denylist filtering using alias
    resolution.

    When *registry_lookup* is provided, each resource ID is expanded
    to include its DAG descendants (per ``docs/specification.md``
    lines 42517--42521).  The callback receives a resource ULID and
    returns the frozenset of descendant ULIDs.  When ``None``, no
    DAG expansion is performed (flat ULID-set mode).

    Args:
        projects: Projects whose linked resources form the base scope.
        include_resources: Resource allowlist (names/aliases).
            Empty means include all.
        exclude_resources: Resource denylist (names/aliases).
            Applied after allowlist.
        include_paths: Path glob allowlist.
        exclude_paths: Path glob denylist.
        temporal_scope: One of ``"current"``, ``"recent"``, ``"all"``.
        registry_lookup: Optional callback to expand a resource ULID
            to its DAG descendants.  Signature:
            ``(resource_id: str) -> frozenset[str]``.

    Returns:
        A ``ResourceScope`` with the resolved resource IDs and filters.

    Raises:
        ValueError: If *projects* is empty.
    """
    if not projects:
        raise ValueError("projects must be non-empty")

    resolver = ResourceAliasResolver()
    all_resource_ids: set[str] = set()
    project_names: set[str] = set()

    for project in projects:
        project_names.add(project.namespaced_name)
        for lr in project.linked_resources:
            all_resource_ids.add(lr.resource_id)

    # DAG expansion: include child resources per spec §42517-42521
    if registry_lookup is not None:
        expanded: set[str] = set()
        for rid in all_resource_ids:
            expanded.add(rid)
            expanded.update(registry_lookup(rid))
        all_resource_ids = expanded

    # Apply allowlist: resolve names/aliases to resource IDs
    if include_resources:
        allowed: set[str] = set()
        for project in projects:
            allowed.update(resolver.resolve_many(project, include_resources))
        all_resource_ids &= allowed

    # Apply denylist: resolve names/aliases to resource IDs to exclude
    if exclude_resources:
        denied: set[str] = set()
        for project in projects:
            denied.update(resolver.resolve_many(project, exclude_resources))
        all_resource_ids -= denied

    if not all_resource_ids:
        logger.warning(
            "scope.empty_after_filtering",
            project_names=sorted(project_names),
            include_resources=include_resources,
            exclude_resources=exclude_resources,
        )

    return ResourceScope(
        resource_ids=frozenset(all_resource_ids),
        project_names=frozenset(project_names),
        temporal_scope=temporal_scope,
        include_resources=include_resources,
        exclude_resources=exclude_resources,
        include_paths=include_paths,
        exclude_paths=exclude_paths,
    )


__all__: list[str] = [
    "ResourceAliasResolver",
    "resolve_resource_scope",
    "validate_project_scope",
    "validate_resource_scope",
]
