"""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:
            raw_value = ep.value
            try:
                module_path, class_name = self._parse_entry_point_value(raw_value)
            except PluginLoadError as exc:
                self._logger.warning(
                    "plugin.entry_point_invalid_value",
                    name=ep.name,
                    value=raw_value,
                    group=group,
                    error=str(exc),
                )
                continue

            try:
                self._validate_module_prefix(module_path)
            except PluginLoadError as exc:
                self._logger.warning(
                    "plugin.entry_point_disallowed_prefix",
                    name=ep.name,
                    value=raw_value,
                    group=group,
                    error=str(exc),
                )
                continue

            try:
                ep.load()
                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=raw_value,
                    group=group,
                )
            except Exception as exc:
                self._logger.warning(
                    "plugin.entry_point_failed",
                    name=ep.name,
                    value=raw_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.

        Uses structural type checking via ``issubclass`` to validate the
        protocol without instantiating the class. This prevents arbitrary
        code execution from untrusted plugin constructors during validation.

        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 structural check first (safe, no instantiation).
        # This prevents arbitrary code execution from plugin constructors.
        issubclass_raised_type_error = False
        try:
            if issubclass(klass, protocol):
                return True
        except TypeError:
            # issubclass raises TypeError if protocol is not a valid
            # @runtime_checkable Protocol (e.g. has a custom metaclass that
            # overrides __subclasscheck__). Fall through to structural check.
            issubclass_raised_type_error = True

        # Fallback: perform a conservative structural check against the
        # Protocol definition without instantiating the class. We inspect
        # the protocol's declared members (callables and annotated names)
        # and ensure the candidate class exposes them as class-level
        # attributes or descriptors. This approximates structural
        # conformance while avoiding constructor execution.
        required: set[str] = set()

        # Collect names from protocol __dict__ (methods, properties, etc.)
        prot_dict = getattr(protocol, "__dict__", {})
        for name, _value in prot_dict.items():
            if name.startswith("__"):
                continue
            # Methods and descriptors will appear in the dict; annotations
            # are handled below. We treat any non-data-magic name as a
            # required member.
            required.add(name)

        # Also include annotated names (those declared with type hints)
        ann = getattr(protocol, "__annotations__", {}) or {}
        for name in ann:
            if name.startswith("__"):
                continue
            required.add(name)

        # If issubclass raised TypeError and the protocol declares no
        # inspectable members, we cannot validate conformance — treat this
        # as a mismatch rather than silently returning True for an
        # unverifiable protocol.
        if issubclass_raised_type_error and not required:
            msg = (
                f"Class '{getattr(klass, '__name__', str(klass))}' cannot be "
                f"validated against protocol "
                f"'{getattr(protocol, '__name__', str(protocol))}': "
                f"issubclass raised TypeError and the protocol declares no "
                f"inspectable members. Ensure the protocol is a valid "
                f"@runtime_checkable Protocol."
            )
            raise ProtocolMismatchError(msg)

        # Validate that the candidate class exposes each required name.
        missing: list[str] = []
        for name in sorted(required):
            # getattr on the class returns descriptors (functions, property,
            # staticmethod, etc.) without instantiation. hasattr is safe.
            if not hasattr(klass, name):
                missing.append(name)
                continue
            # If the protocol declared a callable (method), ensure the
            # attribute on the class is callable or a descriptor.
            prot_val = prot_dict.get(name)
            if callable(prot_val):
                cand = getattr(klass, name)
                # Properties are descriptors and typically not callable; we
                # accept them as present. For methods, require callable.
                if not (callable(cand) or hasattr(cand, "fget")):
                    missing.append(name)

        if not missing:
            return True

        msg = (
            f"Class '{getattr(klass, '__name__', str(klass))}' does not "
            f"satisfy protocol '{getattr(protocol, '__name__', str(protocol))}'. "
            f"Missing attributes: {', '.join(missing)}. Ensure the class "
            f"implements all required methods and descriptors."
        )
        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)

    @staticmethod
    def _parse_entry_point_value(value: str) -> tuple[str, str]:
        """Split an entry point value into module and class components.

        The returned ``module_path`` is intentionally not validated beyond
        basic shape checks; prefix allowlist enforcement and any stricter
        validation must be performed by :meth:`_validate_module_prefix` to
        preserve the layered defense order (parse → validate prefix → load).
        """
        normalized_value = value.strip()
        if ":" not in normalized_value:
            raise PluginLoadError(
                "Entry point value must be in 'module:ClassName' format."
            )

        module_path, class_name = (
            part.strip() for part in normalized_value.split(":", 1)
        )
        if not module_path or not class_name:
            raise PluginLoadError(
                "Entry point value must include both module path and class name."
            )

        return module_path, class_name
