"""Unit tests for ``mgf.common.plugins`` — entry-points-based plugin
discovery (PAPER-10 closure in 0.36.0)."""

from __future__ import annotations

import importlib.metadata
import logging
from typing import Protocol, runtime_checkable
from unittest.mock import MagicMock

import pytest

from mgf.common.plugins import PluginRecord, PluginRejection, discover

# ---------------------------------------------------------------------------
# A test Protocol + dummy entry-point fixtures
# ---------------------------------------------------------------------------


@runtime_checkable
class _SampleProvider(Protocol):
    name: str

    def fetch(self, key: str) -> str: ...


class _ConformantImpl:
    name = "conformant"

    def fetch(self, key: str) -> str:
        return f"value-for-{key}"


class _NonConformantImpl:
    # Missing `name` AND `fetch`.
    pass


def _make_fake_ep(
    *,
    name: str,
    obj,
    dist_name: str,
    dist_version: str,
    raises: Exception | None = None,
):
    """Construct a stand-in for importlib.metadata.EntryPoint."""
    ep = MagicMock()
    ep.name = name
    if raises is not None:
        ep.load.side_effect = raises
    else:
        ep.load.return_value = obj
    ep.dist = MagicMock()
    ep.dist.name = dist_name
    ep.dist.version = dist_version
    return ep


@pytest.fixture
def patch_entry_points(monkeypatch: pytest.MonkeyPatch):
    """Replace importlib.metadata.entry_points with a function that
    returns whatever the test sets via .set(group, [eps...])."""
    registry: dict[str, list] = {}

    def _eps(*, group: str):
        return registry.get(group, [])

    monkeypatch.setattr(importlib.metadata, "entry_points", _eps)

    class _Setter:
        def set(self, group: str, eps: list) -> None:
            registry[group] = eps

    return _Setter()


# ---------------------------------------------------------------------------
# Happy paths
# ---------------------------------------------------------------------------


class TestDiscoverHappyPaths:
    def test_empty_group_returns_empty_list(self, patch_entry_points) -> None:
        result = discover("no.such.group")
        assert result == []

    def test_loads_without_filter(self, patch_entry_points) -> None:
        obj = _ConformantImpl()
        patch_entry_points.set(
            "test.group",
            [
                _make_fake_ep(name="a", obj=obj, dist_name="pkg-a", dist_version="1.0"),
            ],
        )
        result = discover("test.group")
        assert len(result) == 1
        assert result[0] == PluginRecord(name="a", obj=obj, distribution="pkg-a", version="1.0")

    def test_loads_with_conformant_filter(self, patch_entry_points) -> None:
        obj = _ConformantImpl()
        patch_entry_points.set(
            "test.group",
            [
                _make_fake_ep(name="a", obj=obj, dist_name="pkg-a", dist_version="1.0"),
            ],
        )
        result = discover("test.group", required=_SampleProvider)
        assert len(result) == 1
        assert result[0].obj is obj

    def test_multiple_entries(self, patch_entry_points) -> None:
        eps = [
            _make_fake_ep(name="a", obj=_ConformantImpl(), dist_name="pkg-a", dist_version="1.0"),
            _make_fake_ep(name="b", obj=_ConformantImpl(), dist_name="pkg-b", dist_version="2.0"),
            _make_fake_ep(name="c", obj=_ConformantImpl(), dist_name="pkg-c", dist_version="3.0"),
        ]
        patch_entry_points.set("test.group", eps)
        result = discover("test.group", required=_SampleProvider)
        assert {r.name for r in result} == {"a", "b", "c"}


# ---------------------------------------------------------------------------
# Rejection paths
# ---------------------------------------------------------------------------


class TestDiscoverRejections:
    def test_load_failure_skipped(
        self,
        patch_entry_points,
        caplog: pytest.LogCaptureFixture,
    ) -> None:
        good = _ConformantImpl()
        patch_entry_points.set(
            "test.group",
            [
                _make_fake_ep(
                    name="bad",
                    obj=None,
                    dist_name="bad-pkg",
                    dist_version="0.1",
                    raises=ImportError("cannot import buggy.module"),
                ),
                _make_fake_ep(name="good", obj=good, dist_name="good-pkg", dist_version="1.0"),
            ],
        )

        with caplog.at_level(logging.WARNING, logger="mgf.common.plugins"):
            result = discover("test.group")

        assert len(result) == 1
        assert result[0].name == "good"
        # Rejection logged.
        rej_records = [
            r
            for r in caplog.records
            if getattr(r, "event", None) == "plugin.rejected"
            and getattr(r, "plugin_name", "") == "bad"
        ]
        assert len(rej_records) == 1
        assert rej_records[0].reason == "load-failed"

    def test_non_conformant_skipped(
        self,
        patch_entry_points,
        caplog: pytest.LogCaptureFixture,
    ) -> None:
        patch_entry_points.set(
            "test.group",
            [
                _make_fake_ep(
                    name="bad", obj=_NonConformantImpl(), dist_name="bad-pkg", dist_version="0.1"
                ),
                _make_fake_ep(
                    name="good", obj=_ConformantImpl(), dist_name="good-pkg", dist_version="1.0"
                ),
            ],
        )

        with caplog.at_level(logging.WARNING, logger="mgf.common.plugins"):
            result = discover("test.group", required=_SampleProvider)

        assert len(result) == 1
        assert result[0].name == "good"
        rej_records = [
            r
            for r in caplog.records
            if getattr(r, "event", None) == "plugin.rejected"
            and getattr(r, "plugin_name", "") == "bad"
        ]
        assert len(rej_records) == 1
        assert rej_records[0].reason == "non-conformant"

    def test_return_rejections_tuple(self, patch_entry_points) -> None:
        patch_entry_points.set(
            "test.group",
            [
                _make_fake_ep(
                    name="a", obj=_ConformantImpl(), dist_name="pkg-a", dist_version="1.0"
                ),
                _make_fake_ep(
                    name="b", obj=_NonConformantImpl(), dist_name="pkg-b", dist_version="2.0"
                ),
            ],
        )

        records, rejections = discover(
            "test.group",
            required=_SampleProvider,
            return_rejections=True,
        )
        assert len(records) == 1
        assert records[0].name == "a"
        assert len(rejections) == 1
        assert rejections[0].name == "b"
        assert rejections[0].reason == "non-conformant"
        assert isinstance(rejections[0], PluginRejection)


# ---------------------------------------------------------------------------
# Audit emission
# ---------------------------------------------------------------------------


class TestAuditEmission:
    def test_discovery_emits_audit_log(
        self,
        patch_entry_points,
        caplog: pytest.LogCaptureFixture,
    ) -> None:
        patch_entry_points.set(
            "test.group",
            [
                _make_fake_ep(
                    name="a", obj=_ConformantImpl(), dist_name="pkg-a", dist_version="1.0"
                ),
            ],
        )

        with caplog.at_level(logging.INFO, logger="mgf.common.plugins"):
            discover("test.group", required=_SampleProvider)

        audit_records = [
            r for r in caplog.records if getattr(r, "event", None) == "plugin.discovery"
        ]
        assert len(audit_records) == 1
        rec = audit_records[0]
        assert rec.audit is True
        assert rec.group == "test.group"
        assert rec.loaded_count == 1
        assert rec.skipped_count == 0
        assert rec.required_protocol == "_SampleProvider"

    def test_empty_group_still_emits_audit(
        self,
        patch_entry_points,
        caplog: pytest.LogCaptureFixture,
    ) -> None:
        # Even for an empty group: audit emission. The "we asked and
        # got nothing" signal is operationally valuable.
        with caplog.at_level(logging.INFO, logger="mgf.common.plugins"):
            discover("no.such.group")

        audit_records = [
            r for r in caplog.records if getattr(r, "event", None) == "plugin.discovery"
        ]
        assert len(audit_records) == 1
        assert audit_records[0].loaded_count == 0
