"""Explicit plugin registry used by the configuration compiler.

Plugins are registered under stable, versioned identifiers.  Runtime
configuration never imports an arbitrary ``module:object`` path.
"""

from __future__ import annotations

from collections.abc import Callable, Mapping
from dataclasses import dataclass, field
from enum import Enum
from importlib import metadata
from types import MappingProxyType
from typing import Any, cast


class PluginError(RuntimeError):
    """Base class for plugin registration and lookup failures."""


class DuplicatePluginError(PluginError):
    """Raised when a plugin identifier is registered twice."""


class UnknownPluginError(PluginError):
    """Raised when configuration references an unknown plugin."""


class PluginKind(str, Enum):
    SOURCE = "source"
    OPERATOR = "operator"
    SINK = "sink"


PluginFactory = Callable[[str, Mapping[str, Any]], Any]


@dataclass(frozen=True, slots=True)
class PluginSpec:
    """Static metadata and factory for a pipeline component."""

    plugin_id: str
    kind: PluginKind
    factory: PluginFactory
    inputs: Mapping[str, str] = field(default_factory=dict)
    outputs: Mapping[str, str] = field(default_factory=dict)
    description: str = ""
    tags: frozenset[str] = field(default_factory=frozenset)

    def __post_init__(self) -> None:
        if not isinstance(self.plugin_id, str) or "@" not in self.plugin_id:
            raise ValueError(
                f"plugin id must be versioned (for example name@1): {self.plugin_id}"
            )
        name, version = self.plugin_id.rsplit("@", maxsplit=1)
        if not name or not version.isdigit() or int(version) < 1:
            raise ValueError(
                f"plugin id must end in a positive numeric version: {self.plugin_id}"
            )
        try:
            parsed_kind = PluginKind(self.kind)
        except ValueError as exc:
            raise ValueError(f"unknown plugin kind: {self.kind!r}") from exc
        if not callable(self.factory):
            raise TypeError("plugin factory must be callable")

        inputs = _validated_ports("input", self.inputs)
        outputs = _validated_ports("output", self.outputs)
        tags = frozenset(self.tags)
        if any(not isinstance(tag, str) or not tag for tag in tags):
            raise ValueError("plugin tags must be non-empty strings")

        object.__setattr__(self, "kind", parsed_kind)
        object.__setattr__(self, "inputs", MappingProxyType(inputs))
        object.__setattr__(self, "outputs", MappingProxyType(outputs))
        object.__setattr__(self, "tags", tags)
        if parsed_kind is PluginKind.SOURCE and inputs:
            raise ValueError("source plugins cannot declare input ports")
        if parsed_kind is PluginKind.SINK and outputs:
            raise ValueError("sink plugins cannot declare output ports")


class PluginRegistry:
    """In-memory registry with optional Python entry-point discovery."""

    ENTRY_POINT_GROUP = "cmvr_edge_ai.plugins"

    def __init__(self) -> None:
        self._plugins: dict[str, PluginSpec] = {}

    def register(self, spec: PluginSpec) -> PluginSpec:
        if spec.plugin_id in self._plugins:
            raise DuplicatePluginError(f"plugin already registered: {spec.plugin_id}")
        self._plugins[spec.plugin_id] = spec
        return spec

    def resolve(self, plugin_id: str) -> PluginSpec:
        try:
            return self._plugins[plugin_id]
        except KeyError as exc:
            available = ", ".join(sorted(self._plugins)) or "<none>"
            raise UnknownPluginError(
                f"unknown plugin {plugin_id!r}; available plugins: {available}"
            ) from exc

    def create(
        self, plugin_id: str, node_id: str, params: Mapping[str, Any] | None = None
    ) -> Any:
        return self.resolve(plugin_id).factory(node_id, params or {})

    def specs(self) -> tuple[PluginSpec, ...]:
        return tuple(self._plugins[key] for key in sorted(self._plugins))

    def load_entry_points(self) -> None:
        """Load plugin registration callbacks from installed distributions.

        An entry point may expose either a ``PluginSpec`` or a callable taking
        this registry.  Discovery remains explicit and restricted to the
        dedicated entry-point group.
        """

        discovered = cast(Any, metadata.entry_points())
        selected = (
            discovered.select(group=self.ENTRY_POINT_GROUP)
            if hasattr(discovered, "select")
            else discovered.get(self.ENTRY_POINT_GROUP, ())
        )
        for entry_point in selected:
            loaded = entry_point.load()
            if isinstance(loaded, PluginSpec):
                self.register(loaded)
            elif callable(loaded):
                loaded(self)
            else:
                raise PluginError(
                    f"entry point {entry_point.name!r} must expose PluginSpec "
                    "or a registry callback"
                )


def schemas_compatible(source_schema: str, target_schema: str) -> bool:
    """Return whether two declared port schemas can be connected."""

    return (
        source_schema == target_schema or source_schema == "*" or target_schema == "*"
    )


def _validated_ports(direction: str, ports: Mapping[str, str]) -> dict[str, str]:
    if not isinstance(ports, Mapping):
        raise TypeError(f"plugin {direction} ports must be a mapping")
    copied = dict(ports)
    invalid = sorted(
        repr(name)
        for name, schema in copied.items()
        if not isinstance(name, str)
        or not name
        or not isinstance(schema, str)
        or not schema
    )
    if invalid:
        raise ValueError(
            f"plugin {direction} ports require non-empty string names and schemas: "
            + ", ".join(invalid)
        )
    return copied
