"""Plugin loader with dynamic import and entry-point discovery.

Provides :class:`PluginLoader` for importing plugin classes from
``module:ClassName`` strings and discovering plugins via
``importlib.metadata`` entry points.

Security: module imports are restricted to a configurable prefix
allowlist (default: ``("cleveragents.",)``), reusing the security
pattern from :class:`ComponentResolver`.

Based on issue #585.
"""

from __future__ import annotations

import importlib
import importlib.metadata
from typing import Any

import structlog

from cleveragents.infrastructure.plugins.exceptions import (
    PluginLoadError,
    ProtocolMismatchError,
)
from cleveragents.infrastructure.plugins.types import (
    PluginDescriptor,
    PluginState,
)

logger = structlog.get_logger(__name__)

# ---------------------------------------------------------------------------
# Default allowed module prefixes (security)
# ---------------------------------------------------------------------------

_DEFAULT_ALLOWED_PREFIXES: tuple[str, ...] = ("cleveragents.",)


class PluginLoader:
    """Loads plugin classes dynamically and discovers entry points.

    The loader provides two discovery mechanisms:

    1. **Explicit import** via :meth:`load_class` using
       ``module:ClassName`` strings.
    2. **Entry-point discovery** via :meth:`load_from_entry_points`
       using ``importlib.metadata``.

    Security:
        All module imports are validated against a configurable prefix
        allowlist.  Only modules whose fully-qualified name starts with
        an allowed prefix may be imported.  This prevents arbitrary
        code execution from untrusted configuration.

    Example::

        loader = PluginLoader()
        cls = loader.load_class(
            "cleveragents.domain.models.acms.stubs",
            "InMemoryTextBackend",
        )
        assert cls is not None

    Args:
        allowed_prefixes: Module prefixes permitted for dynamic import.
            Defaults to ``("cleveragents.",)``.  Pass an empty tuple to
            disable the allowlist (not recommended).
    """

    def __init__(
        self,
        *,
        allowed_prefixes: tuple[str, ...] | None = None,
    ) -> None:
        self._allowed_prefixes: tuple[str, ...] = (
            allowed_prefixes
            if allowed_prefixes is not None
            else _DEFAULT_ALLOWED_PREFIXES
        )
        self._logger = logger.bind(service="plugin_loader")

    # ------------------------------------------------------------------
    # Module prefix allowlist property
    # ------------------------------------------------------------------

    @property
    def allowed_prefixes(self) -> tuple[str, ...]:
        """Return the current module prefix allowlist."""
        return self._allowed_prefixes

    # ------------------------------------------------------------------
    # Dynamic class import
    # ------------------------------------------------------------------

    def load_class(self, module_path: str, class_name: str) -> type[Any]:
        """Import and return a class from a Python module.

        Args:
            module_path: Fully-qualified module path
                (e.g. ``"cleveragents.domain.models.acms.stubs"``).
            class_name: Name of the class within the module
                (e.g. ``"InMemoryTextBackend"``).

        Returns:
            The imported class object.

        Raises:
            PluginLoadError: If the module prefix is not allowed, the
                module cannot be imported, or the class is not found.
        """
        self._validate_module_prefix(module_path)

        try:
            module = importlib.import_module(module_path)
        except ImportError as exc:
            msg = (
                f"Cannot import module '{module_path}': {exc}. "
                f"Verify the module is installed and the path is correct."
            )
            self._logger.warning(
                "plugin.import_failed",
                module_path=module_path,
                class_name=class_name,
                error=str(exc),
            )
            raise PluginLoadError(msg) from exc

        cls = getattr(module, class_name, None)
        if cls is None:
            msg = (
                f"Class '{class_name}' not found in module '{module_path}'. "
                f"Verify the class name is spelled correctly."
            )
            self._logger.warning(
                "plugin.class_not_found",
                module_path=module_path,
                class_name=class_name,
            )
            raise PluginLoadError(msg)

        if not isinstance(cls, type):
            msg = (
                f"'{class_name}' in module '{module_path}' is not a class "
                f"(got {type(cls).__name__})."
            )
            raise PluginLoadError(msg)

        self._logger.debug(
            "plugin.class_loaded",
            module_path=module_path,
            class_name=class_name,
        )
        return cls

    # ------------------------------------------------------------------
    # Entry-point discovery
    # ------------------------------------------------------------------

    def load_from_entry_points(
        self,
        group: str = "cleveragents.plugins",
    ) -> list[PluginDescriptor]:
        """Discover plugins registered via ``importlib.metadata`` entry points.

        Scans the given entry-point group for plugin registrations.
        Each entry point is expected to reference a class (not an
        instance).

        Args:
            group: Entry-point group name to scan.  Defaults to
                ``"cleveragents.plugins"``.

        Returns:
            List of :class:`PluginDescriptor` for each discovered plugin.
            Plugins that fail to load are skipped with a warning.
        """
        descriptors: list[PluginDescriptor] = []
        entry_points = importlib.metadata.entry_points()

        # Python 3.12+ returns a SelectableGroups; filter by group
        eps = entry_points.select(group=group)

        for ep in eps:
            try:
                ep.load()
                module_path = ep.value.rsplit(":", 1)[0] if ":" in ep.value else ""
                class_name = ep.value.rsplit(":", 1)[1] if ":" in ep.value else ep.value

                descriptor = PluginDescriptor(
                    name=ep.name,
                    module_path=module_path,
                    class_name=class_name,
                    state=PluginState.DISCOVERED,
                )
                descriptors.append(descriptor)
                self._logger.info(
                    "plugin.discovered_entry_point",
                    name=ep.name,
                    value=ep.value,
                    group=group,
                )
            except Exception as exc:
                self._logger.warning(
                    "plugin.entry_point_failed",
                    name=ep.name,
                    value=ep.value,
                    group=group,
                    error=str(exc),
                )

        return descriptors

    # ------------------------------------------------------------------
    # Protocol validation
    # ------------------------------------------------------------------

    @staticmethod
    def validate_protocol(klass: type[Any], protocol: type[Any]) -> bool:
        """Check whether *klass* satisfies a ``@runtime_checkable`` Protocol.

        Creates a temporary instance of *klass* (using a no-arg
        constructor) and checks it against the protocol via
        ``isinstance``.  If instantiation fails, falls back to a
        structural check using ``issubclass``.

        Args:
            klass: The class to validate.
            protocol: The ``@runtime_checkable`` Protocol to check against.

        Returns:
            ``True`` if *klass* satisfies the protocol.

        Raises:
            ProtocolMismatchError: If *klass* does not satisfy the protocol.
        """
        # Try instance check first (most reliable for runtime_checkable)
        try:
            instance = klass()
            if isinstance(instance, protocol):
                return True
        except Exception:
            # If instantiation fails, try subclass check
            try:
                if issubclass(klass, protocol):
                    return True
            except TypeError:
                pass

        msg = (
            f"Class '{klass.__name__}' does not satisfy protocol "
            f"'{protocol.__name__}'. Ensure the class implements all "
            f"required methods and attributes."
        )
        raise ProtocolMismatchError(msg)

    # ------------------------------------------------------------------
    # Internal helpers
    # ------------------------------------------------------------------

    def _validate_module_prefix(self, module_path: str) -> None:
        """Raise :class:`PluginLoadError` if prefix is not in allowlist."""
        if not self._allowed_prefixes:
            return

        if not any(module_path.startswith(prefix) for prefix in self._allowed_prefixes):
            msg = (
                f"Module '{module_path}' is not in the allowed prefix list: "
                f"{self._allowed_prefixes}. Only modules under these prefixes "
                f"may be dynamically imported."
            )
            raise PluginLoadError(msg)
