"""Pluggable component resolution with 3-level scope chain.

Implements a ``ComponentResolver`` that resolves pluggable components
through a deterministic scope chain:

    plan-level  >  project-level  >  global default

This allows any Protocol-based component to be overridden at the plan,
project, or global level.  The specification defines 27 extension points
across ACMS pipeline components, context strategies, sandbox strategies,
UKO vocabulary/analyzers, and validation implementations — all of which
can be resolved through this scope chain.

Plan-level overrides are specified in plan metadata.  Project-level
overrides are specified in ``config.toml`` under ``[extensions]`` or
registered programmatically.  Global defaults are registered at
application startup.

Resolution is deterministic and cached per scope.  Missing
implementations at a scope level transparently fall through to the
next scope.

Based on ``docs/specification.md`` §§ 44369-44396 (Extension Points
Summary) and issue #552.

ISSUES CLOSED: #552
"""

from __future__ import annotations

import importlib
import threading
from dataclasses import dataclass, field
from enum import Enum
from typing import Any, Final, TypeVar

import structlog

logger = structlog.get_logger(__name__)

T = TypeVar("T")


# ---------------------------------------------------------------------------
# Public types
# ---------------------------------------------------------------------------


class ScopeLevel(Enum):
    """Scope levels from highest to lowest priority."""

    PLAN = "plan"
    PROJECT = "project"
    GLOBAL = "global"


@dataclass(frozen=True, slots=True)
class ResolutionResult:
    """Result of resolving a component through the scope chain.

    Attributes:
        component: The resolved component instance.
        scope: The scope level that provided the component.
        component_type_name: Human-readable name of the component type.
    """

    component: object
    scope: ScopeLevel
    component_type_name: str


@dataclass(frozen=True, slots=True)
class ExtensionPointEntry:
    """Metadata for a registered extension point.

    Attributes:
        component_type: The Protocol type for this extension point.
        description: Human-readable description of the extension point.
        category: Category grouping (e.g. ``acms``, ``context``, ``sandbox``).
    """

    component_type: type[Any]
    description: str
    category: str


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


class ComponentNotFoundError(KeyError):
    """Raised when no component implementation is found at any scope level."""


class ComponentRegistrationError(ValueError):
    """Raised when component registration fails validation."""


# ---------------------------------------------------------------------------
# Module-level allowed prefixes for dynamic imports (security)
# ---------------------------------------------------------------------------

_ALLOWED_MODULE_PREFIXES: tuple[str, ...] = ("cleveragents.",)
_ALLOWED_BUILTIN_COMPONENTS: Final[frozenset[str]] = frozenset(
    {
        "ConfidenceWeightedSelector",
        "ProportionalBudgetAllocator",
        "ParallelStrategyExecutor",
        "ContentHashDeduplicator",
        "MaxDepthResolver",
        "WeightedCompositeScorer",
        "GreedyKnapsackPacker",
        "RelevanceCoherenceOrderer",
        "ProvenancePreambleGenerator",
        "DepthReductionCompressor",
    }
)


# ---------------------------------------------------------------------------
# ComponentResolver
# ---------------------------------------------------------------------------


@dataclass(slots=True)
class _ScopeRegistry:
    """Internal registry for a single scope level.

    Maps ``type[T]`` to the implementing instance.
    """

    implementations: dict[type[Any], object] = field(default_factory=dict)

    def get(self, component_type: type[Any]) -> object | None:
        """Return the implementation for *component_type*, or ``None``."""
        return self.implementations.get(component_type)

    def set(self, component_type: type[Any], implementation: object) -> None:
        """Register *implementation* for *component_type*."""
        self.implementations[component_type] = implementation

    def remove(self, component_type: type[Any]) -> bool:
        """Remove the implementation for *component_type*.

        Returns ``True`` if an entry was removed, ``False`` otherwise.
        """
        if component_type in self.implementations:
            del self.implementations[component_type]
            return True
        return False

    def has(self, component_type: type[Any]) -> bool:
        """Return ``True`` if *component_type* is registered."""
        return component_type in self.implementations

    def list_types(self) -> list[type[Any]]:
        """Return all registered component types."""
        return list(self.implementations.keys())


class ComponentResolver:
    """Resolves pluggable components through a 3-level scope chain.

    The scope chain determines which implementation of a pluggable
    component is used:

    1. **Plan-level override** (highest priority) — plan metadata
       specifies a custom implementation.
    2. **Project-level override** — ``config.toml`` ``[extensions]``
       section or programmatic registration.
    3. **Global default** — built-in default registered at startup.

    Resolution is deterministic and cached per scope.  Missing
    implementations at a scope level transparently fall through to
    the next scope.

    Example::

        resolver = ComponentResolver()
        resolver.register_global(StrategySelector, ConfidenceWeightedSelector())
        resolver.register_project("my-project", StrategySelector, custom_selector)
        result = resolver.resolve(StrategySelector, project_id="my-project")
        assert result.scope == ScopeLevel.PROJECT

    Thread-safety: all public methods are thread-safe.

    Based on ``docs/specification.md`` §§ 44369-44396 and issue #552.
    """

    # Module prefixes allowed for dynamic import (security).
    ALLOWED_MODULE_PREFIXES: tuple[str, ...] = _ALLOWED_MODULE_PREFIXES

    def __init__(self) -> None:
        self._lock = threading.RLock()
        self._global = _ScopeRegistry()
        self._projects: dict[str, _ScopeRegistry] = {}
        self._plans: dict[str, _ScopeRegistry] = {}
        self._cache: dict[
            tuple[type[Any], str | None, str | None], ResolutionResult
        ] = {}
        self._extension_points: dict[type[Any], ExtensionPointEntry] = {}
        self._logger = logger.bind(service="component_resolver")

    # ------------------------------------------------------------------
    # Extension point catalog
    # ------------------------------------------------------------------

    def register_extension_point(
        self,
        component_type: type[Any],
        *,
        description: str = "",
        category: str = "",
    ) -> None:
        """Register a known extension point for documentation/validation.

        Args:
            component_type: The Protocol type for this extension point.
            description: Human-readable description.
            category: Category grouping.
        """
        with self._lock:
            self._extension_points[component_type] = ExtensionPointEntry(
                component_type=component_type,
                description=description,
                category=category,
            )

    def list_extension_points(self) -> list[ExtensionPointEntry]:
        """Return all registered extension points."""
        with self._lock:
            return list(self._extension_points.values())

    # ------------------------------------------------------------------
    # Registration — Global scope
    # ------------------------------------------------------------------

    def register_global(
        self,
        component_type: type[Any],
        implementation: object,
    ) -> None:
        """Register a global default implementation.

        Args:
            component_type: The Protocol type to register against.
            implementation: The implementing instance.

        Raises:
            ComponentRegistrationError: If *implementation* is ``None``.
        """
        if implementation is None:
            msg = (
                f"Cannot register None as global implementation "
                f"for {component_type.__name__}"
            )
            raise ComponentRegistrationError(msg)

        with self._lock:
            self._global.set(component_type, implementation)
            self._invalidate_cache_for_type(component_type)
            self._logger.debug(
                "component.registered",
                scope="global",
                component_type=component_type.__name__,
            )

    # ------------------------------------------------------------------
    # Registration — Project scope
    # ------------------------------------------------------------------

    def register_project(
        self,
        project_id: str,
        component_type: type[Any],
        implementation: object,
    ) -> None:
        """Register a project-level override.

        Args:
            project_id: Unique project identifier.
            component_type: The Protocol type to register against.
            implementation: The implementing instance.

        Raises:
            ComponentRegistrationError: If *project_id* is empty or
                *implementation* is ``None``.
        """
        if not project_id:
            msg = "project_id must not be empty"
            raise ComponentRegistrationError(msg)
        if implementation is None:
            msg = (
                f"Cannot register None as project implementation "
                f"for {component_type.__name__}"
            )
            raise ComponentRegistrationError(msg)

        with self._lock:
            if project_id not in self._projects:
                self._projects[project_id] = _ScopeRegistry()
            self._projects[project_id].set(component_type, implementation)
            self._invalidate_cache_for_type(component_type)
            self._logger.debug(
                "component.registered",
                scope="project",
                project_id=project_id,
                component_type=component_type.__name__,
            )

    # ------------------------------------------------------------------
    # Registration — Plan scope
    # ------------------------------------------------------------------

    def register_plan(
        self,
        plan_id: str,
        component_type: type[Any],
        implementation: object,
    ) -> None:
        """Register a plan-level override.

        Args:
            plan_id: Unique plan identifier.
            component_type: The Protocol type to register against.
            implementation: The implementing instance.

        Raises:
            ComponentRegistrationError: If *plan_id* is empty or
                *implementation* is ``None``.
        """
        if not plan_id:
            msg = "plan_id must not be empty"
            raise ComponentRegistrationError(msg)
        if implementation is None:
            msg = (
                f"Cannot register None as plan implementation "
                f"for {component_type.__name__}"
            )
            raise ComponentRegistrationError(msg)

        with self._lock:
            if plan_id not in self._plans:
                self._plans[plan_id] = _ScopeRegistry()
            self._plans[plan_id].set(component_type, implementation)
            self._invalidate_cache_for_type(component_type)
            self._logger.debug(
                "component.registered",
                scope="plan",
                plan_id=plan_id,
                component_type=component_type.__name__,
            )

    # ------------------------------------------------------------------
    # Resolution
    # ------------------------------------------------------------------

    def resolve(
        self,
        component_type: type[Any],
        *,
        plan_id: str | None = None,
        project_id: str | None = None,
    ) -> ResolutionResult:
        """Resolve a component through the scope chain: plan > project > global.

        Args:
            component_type: The Protocol type to resolve.
            plan_id: Optional plan identifier for plan-level lookup.
            project_id: Optional project identifier for project-level lookup.

        Returns:
            A ``ResolutionResult`` with the component instance and the
            scope level that provided it.

        Raises:
            ComponentNotFoundError: If no implementation is found at
                any scope level.
        """
        cache_key = (component_type, plan_id, project_id)

        with self._lock:
            cached = self._cache.get(cache_key)
            if cached is not None:
                return cached

            result = self._resolve_uncached(component_type, plan_id, project_id)
            self._cache[cache_key] = result
            return result

    def _resolve_uncached(
        self,
        component_type: type[Any],
        plan_id: str | None,
        project_id: str | None,
    ) -> ResolutionResult:
        """Internal resolution without cache (must hold lock)."""
        type_name = component_type.__name__

        # Level 1: Plan scope
        if plan_id:
            plan_registry = self._plans.get(plan_id)
            if plan_registry is not None:
                impl = plan_registry.get(component_type)
                if impl is not None:
                    self._logger.debug(
                        "component.resolved",
                        scope="plan",
                        plan_id=plan_id,
                        component_type=type_name,
                    )
                    return ResolutionResult(
                        component=impl,
                        scope=ScopeLevel.PLAN,
                        component_type_name=type_name,
                    )

        # Level 2: Project scope
        if project_id:
            project_registry = self._projects.get(project_id)
            if project_registry is not None:
                impl = project_registry.get(component_type)
                if impl is not None:
                    self._logger.debug(
                        "component.resolved",
                        scope="project",
                        project_id=project_id,
                        component_type=type_name,
                    )
                    return ResolutionResult(
                        component=impl,
                        scope=ScopeLevel.PROJECT,
                        component_type_name=type_name,
                    )

        # Level 3: Global scope
        impl = self._global.get(component_type)
        if impl is not None:
            self._logger.debug(
                "component.resolved",
                scope="global",
                component_type=type_name,
            )
            return ResolutionResult(
                component=impl,
                scope=ScopeLevel.GLOBAL,
                component_type_name=type_name,
            )

        msg = (
            f"No implementation found for {type_name} at any scope level. "
            f"Checked: plan={plan_id!r}, project={project_id!r}, global."
        )
        raise ComponentNotFoundError(msg)

    # ------------------------------------------------------------------
    # Config.toml extension loading
    # ------------------------------------------------------------------

    def load_project_extensions(
        self,
        project_id: str,
        extensions_config: dict[str, str],
        type_registry: dict[str, type[Any]],
    ) -> list[str]:
        """Load project-level overrides from ``config.toml`` ``[extensions]``.

        Parses a mapping of extension point names to ``"module:ClassName"``
        strings and registers them at the project scope.

        Args:
            project_id: The project to register overrides for.
            extensions_config: Mapping of extension point name to
                ``"module:ClassName"`` import path.
            type_registry: Mapping of extension point name to the
                Protocol type class.

        Returns:
            List of successfully loaded extension point names.

        Example config.toml::

            [extensions]
            strategy_selector = "my_module:CustomSelector"
            budget_allocator = "my_module:CustomAllocator"
        """
        loaded: list[str] = []
        for ext_name, module_path in extensions_config.items():
            component_type = type_registry.get(ext_name)
            if component_type is None:
                self._logger.warning(
                    "extension.unknown_type",
                    extension=ext_name,
                    project_id=project_id,
                )
                continue

            try:
                instance = self._import_component(module_path)
                self.register_project(project_id, component_type, instance)
                loaded.append(ext_name)
                self._logger.info(
                    "extension.loaded",
                    extension=ext_name,
                    module_path=module_path,
                    project_id=project_id,
                )
            except (ComponentRegistrationError, ImportError, AttributeError) as exc:
                self._logger.warning(
                    "extension.load_failed",
                    extension=ext_name,
                    module_path=module_path,
                    project_id=project_id,
                    error=str(exc),
                )

        return loaded

    def load_plan_extensions(
        self,
        plan_id: str,
        plan_metadata: dict[str, str],
        type_registry: dict[str, type[Any]],
    ) -> list[str]:
        """Load plan-level overrides from plan metadata.

        Args:
            plan_id: The plan to register overrides for.
            plan_metadata: Mapping of extension point name to
                ``"module:ClassName"`` import path from plan metadata.
            type_registry: Mapping of extension point name to the
                Protocol type class.

        Returns:
            List of successfully loaded extension point names.
        """
        loaded: list[str] = []
        for ext_name, module_path in plan_metadata.items():
            component_type = type_registry.get(ext_name)
            if component_type is None:
                self._logger.warning(
                    "extension.unknown_type",
                    extension=ext_name,
                    plan_id=plan_id,
                )
                continue

            try:
                instance = self._import_component(module_path)
                self.register_plan(plan_id, component_type, instance)
                loaded.append(ext_name)
                self._logger.info(
                    "extension.loaded",
                    extension=ext_name,
                    module_path=module_path,
                    plan_id=plan_id,
                )
            except (ComponentRegistrationError, ImportError, AttributeError) as exc:
                self._logger.warning(
                    "extension.load_failed",
                    extension=ext_name,
                    module_path=module_path,
                    plan_id=plan_id,
                    error=str(exc),
                )

        return loaded

    # ------------------------------------------------------------------
    # Introspection
    # ------------------------------------------------------------------

    def has_global(self, component_type: type[Any]) -> bool:
        """Return ``True`` if a global implementation is registered."""
        with self._lock:
            return self._global.has(component_type)

    def has_project(self, project_id: str, component_type: type[Any]) -> bool:
        """Return ``True`` if a project-level override is registered."""
        with self._lock:
            registry = self._projects.get(project_id)
            if registry is None:
                return False
            return registry.has(component_type)

    def has_plan(self, plan_id: str, component_type: type[Any]) -> bool:
        """Return ``True`` if a plan-level override is registered."""
        with self._lock:
            registry = self._plans.get(plan_id)
            if registry is None:
                return False
            return registry.has(component_type)

    def list_global_types(self) -> list[str]:
        """Return names of all globally registered component types."""
        with self._lock:
            return [t.__name__ for t in self._global.list_types()]

    def list_project_types(self, project_id: str) -> list[str]:
        """Return names of component types overridden for a project."""
        with self._lock:
            registry = self._projects.get(project_id)
            if registry is None:
                return []
            return [t.__name__ for t in registry.list_types()]

    def list_plan_types(self, plan_id: str) -> list[str]:
        """Return names of component types overridden for a plan."""
        with self._lock:
            registry = self._plans.get(plan_id)
            if registry is None:
                return []
            return [t.__name__ for t in registry.list_types()]

    # ------------------------------------------------------------------
    # Removal
    # ------------------------------------------------------------------

    def remove_global(self, component_type: type[Any]) -> bool:
        """Remove a global implementation.

        Returns ``True`` if an entry was removed.
        """
        with self._lock:
            removed = self._global.remove(component_type)
            if removed:
                self._invalidate_cache_for_type(component_type)
            return removed

    def remove_project(self, project_id: str, component_type: type[Any]) -> bool:
        """Remove a project-level override.

        Returns ``True`` if an entry was removed.
        """
        with self._lock:
            registry = self._projects.get(project_id)
            if registry is None:
                return False
            removed = registry.remove(component_type)
            if removed:
                self._invalidate_cache_for_type(component_type)
            return removed

    def remove_plan(self, plan_id: str, component_type: type[Any]) -> bool:
        """Remove a plan-level override.

        Returns ``True`` if an entry was removed.
        """
        with self._lock:
            registry = self._plans.get(plan_id)
            if registry is None:
                return False
            removed = registry.remove(component_type)
            if removed:
                self._invalidate_cache_for_type(component_type)
            return removed

    def clear(self) -> None:
        """Remove all registrations and clear the cache."""
        with self._lock:
            self._global = _ScopeRegistry()
            self._projects.clear()
            self._plans.clear()
            self._cache.clear()

    # ------------------------------------------------------------------
    # Cache management
    # ------------------------------------------------------------------

    def invalidate_cache(self) -> None:
        """Clear the entire resolution cache.

        Useful after bulk registration changes.
        """
        with self._lock:
            self._cache.clear()

    def cache_size(self) -> int:
        """Return the number of cached resolution results."""
        with self._lock:
            return len(self._cache)

    def _invalidate_cache_for_type(self, component_type: type[Any]) -> None:
        """Remove cache entries involving *component_type* (must hold lock)."""
        to_remove = [key for key in self._cache if key[0] is component_type]
        for key in to_remove:
            del self._cache[key]

    # ------------------------------------------------------------------
    # Dynamic import helper
    # ------------------------------------------------------------------

    def _import_component(self, module_path: str) -> object:
        """Import and instantiate a component from ``"module:ClassName"``.

        Args:
            module_path: Import path in ``"module:ClassName"`` format.

        Returns:
            An instance of the imported class.

        Raises:
            ComponentRegistrationError: If the format is invalid or the
                module prefix is not allowed.
            ImportError: If the module cannot be imported.
            AttributeError: If the class is not found in the module.
        """
        if ":" not in module_path:
            msg = (
                f"Invalid module_path '{module_path}': "
                f"expected 'module:ClassName' format"
            )
            raise ComponentRegistrationError(msg)

        module_name, class_name = module_path.rsplit(":", 1)

        if module_name == "builtin":
            if class_name not in _ALLOWED_BUILTIN_COMPONENTS:
                msg = (
                    f"Builtin component {class_name!r} is not in the allowed builtin "
                    f"list: {sorted(_ALLOWED_BUILTIN_COMPONENTS)}"
                )
                raise ComponentRegistrationError(msg)
            from cleveragents.application import services as builtin_services

            cls = getattr(builtin_services, class_name)
            return cls()

        # Security: restrict to allowed prefixes
        if self.ALLOWED_MODULE_PREFIXES and not any(
            module_name.startswith(prefix) for prefix in self.ALLOWED_MODULE_PREFIXES
        ):
            msg = (
                f"Module '{module_name}' is not in the allowed prefix list: "
                f"{self.ALLOWED_MODULE_PREFIXES}. Only modules under these "
                f"prefixes may be dynamically imported."
            )
            raise ComponentRegistrationError(msg)

        module = importlib.import_module(module_name)
        cls = getattr(module, class_name)
        return cls()
