"""Plugin auto-discovery — entry-points-based plugin loading.

Stability tier: **experimental** (AP-11) — added in 0.36.0,
closes PAPER-10 (DEFERRED since 2026-04-XX). Promoted at v1.0
per the federation roadmap.

Why this module exists
----------------------

Closes the long-deferred PAPER-10. The evidence finally came in:
PlasmaMapper's `TelematicsProvider` plugin shape (Teltonika first,
Queclink + Ruptela queued for Phase 8). Federation rule: 2nd
consumer = primitive. PlasmaMapper IS the 2nd-consumer evidence;
without auto-discovery they hand-roll an entry-point loop in
their startup hook.

.. note:: **Trust model.** Resolving an entry point IMPORTS the
   plugin's module, so its import-time code runs *before* the
   Protocol conformance check can accept or reject it. Conformance
   is therefore a SHAPE gate, not a security boundary or a sandbox:
   a plugin is an installed package in the host's environment —
   already-trusted code that can run arbitrary import-time side
   effects regardless. What discovery DOES guarantee is isolation
   of *failures*: each entry point is loaded under its own
   try/except, so one plugin that raises on import (or fails
   conformance) is logged and skipped without aborting discovery of
   the others. Do not rely on conformance to contain hostile code.

This module wraps :func:`importlib.metadata.entry_points` with:

- **Type-filtering** via a Protocol class — load only plugins
  conforming to the expected interface; warn + skip on
  non-conforming entries (closed-box discipline — bad plugins
  shouldn't crash the host).
- **Audit-log emission** per discovery — operators see which
  plugins loaded + which were rejected.
- **Stable shape** — the return type is a frozen dataclass with
  name + obj + distribution + version (consumers don't have to
  poke at `importlib.metadata` internals).

Usage
-----

Define a Protocol for your plugin group::

    @runtime_checkable
    class TelematicsProvider(Protocol):
        name: str
        def fetch_position(self, device_id: str) -> Position: ...

Plugin package (third-party) declares an entry point in its
``pyproject.toml``::

    [project.entry-points."plasmamapper.telematics"]
    teltonika = "plasmamapper_teltonika:TeltonikaProvider"

Host (consumer) discovers all conforming plugins::

    from mgf.common.plugins import discover

    providers = discover("plasmamapper.telematics", required=TelematicsProvider)
    for record in providers:
        print(f"loaded {record.name} v{record.version} from {record.distribution}")
        ctx.providers[record.obj.name] = record.obj

Discovery behaviour
-------------------

- **No-conform-filter** (``required=None``): every entry point in
  the group is loaded + returned, regardless of shape. Useful
  for prototype / scratch.
- **With-filter** (``required=SomeProtocol``): non-conforming
  entries are SKIPPED with a WARN log + audit emission. The
  host gets a clean list of guaranteed-conformant plugins.
- **Load failures** (importable but the module crashes on load):
  logged + skipped (NOT raised). One bad plugin doesn't take
  down the host's startup.
- **Empty group**: empty list returned, no warning (silence is
  golden — many groups are optional).

Audit emission
--------------

Every :func:`discover` call emits a structured INFO log line
with ``event="plugin.discovery"`` + ``audit=True`` per
LG-04 + SC-17. Fields: ``group``, ``loaded_count``,
``skipped_count``, ``skipped_reasons`` (list).

Each REJECTED entry (load failure OR non-conformance) also
emits its own WARN with ``event="plugin.rejected"``.

Cross-references
----------------

- **PAPER-10** — finally closed at v0.36.0.
- **AP-11** — stability tiers (this module ships
  ``experimental``).
- **LG-04** — audit-log records carry ``audit=True``.
- **SC-17** — security audit-log discipline.
- **EH-02** — third-party exceptions translated at boundaries
  (we wrap importlib errors in WARN logs, never propagate raw).

Cross-consumer pattern: see
`docs/inprogress/pre_release_additions.md` §4.3 (PAPER-10
finally unblocked).
"""

from __future__ import annotations

import importlib.metadata
import logging
from dataclasses import dataclass
from typing import (
    Any,
    TypeVar,
)

_LOGGER = logging.getLogger(__name__)

T = TypeVar("T")


__all__ = [
    "PluginRecord",
    "PluginRejection",
    "discover",
]


@dataclass(frozen=True)
class PluginRecord:
    """A successfully-loaded plugin entry.

    Returned by :func:`discover` for every entry point that loaded
    AND (if ``required`` was supplied) conformed to the required
    Protocol.
    """

    name: str
    """The entry-point name (the LHS of the
    ``name = "pkg:obj"`` declaration)."""

    obj: Any
    """The loaded object (whatever the entry point points at —
    function, class, module-level instance). Consumer's
    responsibility to call / instantiate as appropriate."""

    distribution: str
    """The PyPI distribution name that shipped this entry point
    (e.g., ``"plasmamapper-teltonika"``)."""

    version: str
    """The distribution's version string."""


@dataclass(frozen=True)
class PluginRejection:
    """An entry-point that was found but NOT loaded.

    Surfaces in :func:`discover`'s return value (alongside
    :class:`PluginRecord`) when ``return_rejections=True``. The
    default discard mode just logs.
    """

    name: str
    """The entry-point name."""

    distribution: str
    """The distribution that declared it."""

    reason: str
    """Why this entry was rejected. One of:
    ``"load-failed"`` (importlib error), ``"non-conformant"``
    (didn't match the required Protocol)."""

    detail: str
    """Human-readable detail — exception message or
    "missing attributes: ['foo', 'bar']"."""


def _check_conformance(
    obj: Any,
    required: type[T] | None,
) -> tuple[bool, str]:
    """Return ``(is_conformant, detail)``. ``detail`` is empty when
    conformant, populated when not."""
    if required is None:
        return True, ""

    # Use the Protocol's runtime check if available; otherwise
    # fall back to isinstance.
    try:
        ok = isinstance(obj, required)
    except TypeError as exc:
        # Non-@runtime_checkable Protocol passed in. We could fall
        # back to attribute-by-attribute inspection, but the
        # cleaner story is: tell the consumer to mark it.
        return False, (
            f"required type {required.__name__!r} is not "
            f"@runtime_checkable; can't check at runtime. "
            f"Detail: {exc}"
        )

    if ok:
        return True, ""
    return False, f"object {type(obj).__name__!r} does not match Protocol {required.__name__!r}"


def discover(
    group: str,
    *,
    required: type[T] | None = None,
    return_rejections: bool = False,
) -> list[PluginRecord] | tuple[list[PluginRecord], list[PluginRejection]]:
    """Discover + load every entry point in ``group``.

    Parameters
    ----------
    group
        The entry-points group name. Convention: dotted lowercase
        matching the consumer's namespace (e.g.,
        ``"plasmamapper.telematics"``, ``"mgf.common.vault_backends"``).
        See :pep:`517` + ``importlib.metadata.entry_points`` docs.
    required
        Optional Protocol class. When supplied, entries that load
        but don't conform are rejected with a WARN log. The
        Protocol SHOULD be ``@runtime_checkable``; non-checkable
        Protocols are also rejected (with a clear detail message).
    return_rejections
        When ``True``, returns a tuple ``(records, rejections)``.
        Useful for diagnostics — the consumer's ``mgf-common
        doctor`` integration can surface "you have 3 plugins
        installed; 2 loaded; 1 rejected because…". Default
        ``False`` returns just the records list.

    Returns
    -------
    list[PluginRecord] OR tuple[list[PluginRecord], list[PluginRejection]]
        Depending on ``return_rejections``.

    Emits an audit-log INFO line per call (LG-04 / SC-17) with
    the counts; rejection emits its own WARN per entry.
    """
    eps = importlib.metadata.entry_points(group=group)

    records: list[PluginRecord] = []
    rejections: list[PluginRejection] = []

    for ep in eps:
        # Each EntryPoint has .name + .value + .dist
        dist_name = ep.dist.name if ep.dist is not None else "<unknown>"
        dist_version = ep.dist.version if ep.dist is not None else "<unknown>"

        # Step 1: load the object. Wrap in try/except to keep one
        # bad plugin from taking down the host.
        try:
            obj = ep.load()
        except Exception as exc:
            detail = f"{type(exc).__name__}: {exc}"
            rejection = PluginRejection(
                name=ep.name,
                distribution=dist_name,
                reason="load-failed",
                detail=detail,
            )
            rejections.append(rejection)
            _LOGGER.warning(
                "plugin rejected: load failed",
                extra={
                    "event": "plugin.rejected",
                    "audit": True,
                    "group": group,
                    "plugin_name": ep.name,
                    "distribution": dist_name,
                    "distribution_version": dist_version,
                    "reason": "load-failed",
                    "detail": detail,
                },
                exc_info=False,
            )
            continue

        # Step 2: check conformance.
        ok, detail = _check_conformance(obj, required)
        if not ok:
            rejection = PluginRejection(
                name=ep.name,
                distribution=dist_name,
                reason="non-conformant",
                detail=detail,
            )
            rejections.append(rejection)
            _LOGGER.warning(
                "plugin rejected: non-conformant",
                extra={
                    "event": "plugin.rejected",
                    "audit": True,
                    "group": group,
                    "plugin_name": ep.name,
                    "distribution": dist_name,
                    "distribution_version": dist_version,
                    "reason": "non-conformant",
                    "detail": detail,
                    "required_protocol": required.__name__ if required else None,
                },
            )
            continue

        # Loaded + conformant. Record it.
        records.append(
            PluginRecord(
                name=ep.name,
                obj=obj,
                distribution=dist_name,
                version=dist_version,
            )
        )

    # Aggregate audit emission — one line per discover() call.
    _LOGGER.info(
        "plugin discovery complete",
        extra={
            "event": "plugin.discovery",
            "audit": True,
            "group": group,
            "loaded_count": len(records),
            "skipped_count": len(rejections),
            "skipped_reasons": [r.reason for r in rejections],
            "required_protocol": required.__name__ if required else None,
        },
    )

    if return_rejections:
        return records, rejections
    return records
