"""Tests for the per-runtime plugin adaptor resolver.

Covers:
  - Resolution order (registry > plugin-shipped > raw-drop)
  - Both adaptor-module conventions (Adaptor class + get_adaptor factory)
  - RawDropAdaptor copies plugin files and surfaces a warning
  - resolve() never raises — always returns a usable adaptor
"""

from __future__ import annotations

import logging
import sys
import textwrap
from pathlib import Path

import pytest

# Resolve workspace/ so `import plugins_registry` works in CI without
# requiring an installed package.
_WS_TEMPLATE = Path(__file__).resolve().parents[1]
if str(_WS_TEMPLATE) not in sys.path:
    sys.path.insert(0, str(_WS_TEMPLATE))

from plugins_registry import (  # noqa: E402
    AdaptorSource,
    InstallContext,
    PluginAdaptor,
    RawDropAdaptor,
    resolve,
)


# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------

@pytest.fixture
def configs_dir(tmp_path: Path) -> Path:
    d = tmp_path / "configs"
    d.mkdir()
    return d


@pytest.fixture
def plugin_root(tmp_path: Path) -> Path:
    p = tmp_path / "demo-plugin"
    (p / "rules").mkdir(parents=True)
    (p / "rules" / "rules.md").write_text("- be excellent\n")
    (p / "plugin.yaml").write_text("name: demo-plugin\nruntimes: [test_runtime]\n")
    return p


def _ctx(configs_dir: Path, plugin_root: Path, runtime: str = "test_runtime") -> InstallContext:
    return InstallContext(
        configs_dir=configs_dir,
        workspace_id="ws-test",
        runtime=runtime,
        plugin_root=plugin_root,
        logger=logging.getLogger("test"),
    )


# ---------------------------------------------------------------------------
# RawDropAdaptor
# ---------------------------------------------------------------------------

async def test_raw_drop_copies_plugin_and_warns(configs_dir: Path, plugin_root: Path):
    adaptor = RawDropAdaptor("demo-plugin", "test_runtime")
    result = await adaptor.install(_ctx(configs_dir, plugin_root))

    dst = configs_dir / "plugins" / "demo-plugin"
    assert dst.exists()
    assert (dst / "rules" / "rules.md").read_text() == "- be excellent\n"
    assert result.source == "raw_drop"
    assert any("no adaptor" in w for w in result.warnings)
    assert result.tools_registered == []


async def test_raw_drop_is_idempotent(configs_dir: Path, plugin_root: Path):
    adaptor = RawDropAdaptor("demo-plugin", "test_runtime")
    await adaptor.install(_ctx(configs_dir, plugin_root))
    # Second install must not raise (shutil.copytree would otherwise complain)
    result = await adaptor.install(_ctx(configs_dir, plugin_root))
    assert result.source == "raw_drop"


async def test_raw_drop_uninstall_removes_files(configs_dir: Path, plugin_root: Path):
    adaptor = RawDropAdaptor("demo-plugin", "test_runtime")
    ctx = _ctx(configs_dir, plugin_root)
    await adaptor.install(ctx)
    await adaptor.uninstall(ctx)
    assert not (configs_dir / "plugins" / "demo-plugin").exists()


# ---------------------------------------------------------------------------
# resolve() — order: registry > plugin-shipped > raw_drop
# ---------------------------------------------------------------------------

def test_resolve_falls_back_to_raw_drop_when_no_adaptor(plugin_root: Path):
    adaptor, source = resolve("nonexistent-plugin", "claude_code", plugin_root)
    assert source == AdaptorSource.RAW_DROP
    assert isinstance(adaptor, RawDropAdaptor)


def test_resolve_prefers_plugin_shipped_over_raw_drop(plugin_root: Path):
    """Plugin ships its own adaptor → must beat raw-drop."""
    (plugin_root / "adapters").mkdir()
    (plugin_root / "adapters" / "test_runtime.py").write_text(textwrap.dedent("""
        from plugins_registry.protocol import InstallResult

        class Adaptor:
            def __init__(self, plugin_name, runtime):
                self.plugin_name = plugin_name
                self.runtime = runtime
            async def install(self, ctx):
                return InstallResult(plugin_name=self.plugin_name, runtime=self.runtime, source="plugin")
            async def uninstall(self, ctx):
                pass
    """))

    adaptor, source = resolve("demo-plugin", "test_runtime", plugin_root)
    assert source == AdaptorSource.PLUGIN
    assert not isinstance(adaptor, RawDropAdaptor)


def test_resolve_supports_get_adaptor_factory(plugin_root: Path):
    """Adaptor module exposing get_adaptor() instead of Adaptor class."""
    (plugin_root / "adapters").mkdir()
    (plugin_root / "adapters" / "test_runtime.py").write_text(textwrap.dedent("""
        from plugins_registry.protocol import InstallResult

        class _Impl:
            def __init__(self, plugin_name, runtime):
                self.plugin_name = plugin_name
                self.runtime = runtime
            async def install(self, ctx):
                return InstallResult(plugin_name=self.plugin_name, runtime=self.runtime, source="plugin")
            async def uninstall(self, ctx):
                pass

        def get_adaptor(plugin_name, runtime):
            return _Impl(plugin_name, runtime)
    """))

    adaptor, source = resolve("demo-plugin", "test_runtime", plugin_root)
    assert source == AdaptorSource.PLUGIN


async def test_resolve_get_adaptor_factory_install(plugin_root: Path, tmp_path: Path):
    """Installing an adaptor returned by get_adaptor() works end-to-end."""
    (plugin_root / "adapters").mkdir()
    (plugin_root / "adapters" / "test_runtime.py").write_text(textwrap.dedent("""
        from plugins_registry.protocol import InstallResult
        class _Impl:
            def __init__(self, plugin_name, runtime):
                self.plugin_name = plugin_name
                self.runtime = runtime
            async def install(self, ctx):
                return InstallResult(plugin_name=self.plugin_name, runtime=self.runtime, source="plugin")
            async def uninstall(self, ctx): pass
        def get_adaptor(plugin_name, runtime):
            return _Impl(plugin_name, runtime)
    """))
    adaptor, _ = resolve("demo-plugin", "test_runtime", plugin_root)
    result = await adaptor.install(_ctx(tmp_path, plugin_root))
    assert result.source == "plugin"


async def test_resolve_registry_beats_plugin_shipped(plugin_root: Path, monkeypatch, tmp_path: Path):
    """Platform registry must override plugin-shipped adaptor (promote-to-default path)."""
    # Plant a plugin-shipped adaptor first.
    (plugin_root / "adapters").mkdir()
    (plugin_root / "adapters" / "test_runtime.py").write_text(textwrap.dedent("""
        from plugins_registry.protocol import InstallResult
        class Adaptor:
            def __init__(self, plugin_name, runtime):
                self.plugin_name = plugin_name
                self.runtime = runtime
            async def install(self, ctx):
                return InstallResult(plugin_name=self.plugin_name, runtime=self.runtime, source="plugin")
            async def uninstall(self, ctx): pass
    """))

    # Now plant a registry override by monkeypatching _REGISTRY_ROOT to a temp dir.
    fake_registry = tmp_path / "fake_registry"
    (fake_registry / "demo-plugin").mkdir(parents=True)
    (fake_registry / "demo-plugin" / "test_runtime.py").write_text(textwrap.dedent("""
        from plugins_registry.protocol import InstallResult
        class Adaptor:
            def __init__(self, plugin_name, runtime):
                self.plugin_name = plugin_name
                self.runtime = runtime
            async def install(self, ctx):
                return InstallResult(plugin_name=self.plugin_name, runtime=self.runtime, source="registry")
            async def uninstall(self, ctx): pass
    """))

    import plugins_registry as pr
    monkeypatch.setattr(pr, "_REGISTRY_ROOT", fake_registry)

    adaptor, source = pr.resolve("demo-plugin", "test_runtime", plugin_root)
    assert source == AdaptorSource.REGISTRY
    result = await adaptor.install(_ctx(tmp_path, plugin_root))
    assert result.source == "registry"


def test_resolve_handles_broken_adaptor_module(plugin_root: Path):
    """Broken adaptor file falls back gracefully — never crashes the install."""
    (plugin_root / "adapters").mkdir()
    (plugin_root / "adapters" / "test_runtime.py").write_text("syntax error this is not python")

    adaptor, source = resolve("demo-plugin", "test_runtime", plugin_root)
    # Falls through to raw-drop because the broken module fails to import.
    assert source == AdaptorSource.RAW_DROP


def test_protocol_runtime_check():
    """RawDropAdaptor must satisfy the Protocol at runtime."""
    assert isinstance(RawDropAdaptor("p", "r"), PluginAdaptor)


# ---------------------------------------------------------------------------
# Edge cases on adaptor loading
# ---------------------------------------------------------------------------

def test_resolve_module_with_neither_adaptor_nor_factory(plugin_root: Path):
    """Adaptor file that defines neither ``Adaptor`` nor ``get_adaptor()``
    falls back to raw-drop (can't instantiate anything)."""
    (plugin_root / "adapters").mkdir()
    (plugin_root / "adapters" / "test_runtime.py").write_text(
        "# no Adaptor, no get_adaptor — just a valid module\nX = 1\n"
    )
    _, source = resolve("demo-plugin", "test_runtime", plugin_root)
    assert source == AdaptorSource.RAW_DROP


def test_resolve_get_adaptor_factory_raises(plugin_root: Path):
    """get_adaptor() that raises → falls back to raw-drop gracefully."""
    (plugin_root / "adapters").mkdir()
    (plugin_root / "adapters" / "test_runtime.py").write_text(textwrap.dedent("""
        def get_adaptor(plugin_name, runtime):
            raise ValueError("kaboom")
    """))
    _, source = resolve("demo-plugin", "test_runtime", plugin_root)
    assert source == AdaptorSource.RAW_DROP


def test_resolve_adaptor_class_construction_raises(plugin_root: Path):
    """Adaptor class whose __init__ raises → falls back to raw-drop."""
    (plugin_root / "adapters").mkdir()
    (plugin_root / "adapters" / "test_runtime.py").write_text(textwrap.dedent("""
        class Adaptor:
            def __init__(self, *args, **kwargs):
                raise RuntimeError("nope")
    """))
    _, source = resolve("demo-plugin", "test_runtime", plugin_root)
    assert source == AdaptorSource.RAW_DROP


def test_resolve_adaptor_class_zero_arg_fallback(plugin_root: Path):
    """Adaptor class whose (name, runtime) ctor raises TypeError → try zero-arg."""
    (plugin_root / "adapters").mkdir()
    (plugin_root / "adapters" / "test_runtime.py").write_text(textwrap.dedent("""
        from plugins_registry.protocol import InstallResult
        class Adaptor:
            plugin_name = "demo-plugin"
            runtime = "test_runtime"
            def __init__(self):
                pass
            async def install(self, ctx):
                return InstallResult(plugin_name=self.plugin_name, runtime=self.runtime, source="plugin")
            async def uninstall(self, ctx):
                pass
    """))
    # TypeError forces the fallback path: `cls(plugin_name, runtime)` fails
    # because the class takes no args, so we retry with `cls()`.
    _, source = resolve("demo-plugin", "test_runtime", plugin_root)
    assert source == AdaptorSource.PLUGIN


def test_load_module_bailout_when_spec_is_none(monkeypatch, plugin_root: Path):
    """Defensive path: ``spec_from_file_location`` returns None. Forced via
    monkeypatch since real filesystems never trigger it for .py files."""
    import importlib.util as iu
    import plugins_registry as pr

    (plugin_root / "adapters").mkdir()
    (plugin_root / "adapters" / "test_runtime.py").write_text("class Adaptor: pass\n")

    real = iu.spec_from_file_location
    def fake_spec(name, path, *a, **kw):
        if path.name == "test_runtime.py":
            return None
        return real(name, path, *a, **kw)
    monkeypatch.setattr(pr.importlib.util, "spec_from_file_location", fake_spec)

    _, source = pr.resolve("demo-plugin", "test_runtime", plugin_root)
    assert source == AdaptorSource.RAW_DROP


def test_resolve_registry_bails_when_load_returns_none(monkeypatch, tmp_path: Path, plugin_root: Path):
    """Registry path exists but the module fails to load → falls through to
    plugin-shipped (or raw-drop if that's also missing). Exercises the
    ``if module is None: return None`` bail-out in ``_resolve_registry``."""
    import plugins_registry as pr

    fake_registry = tmp_path / "fake_registry"
    (fake_registry / "demo-plugin").mkdir(parents=True)
    (fake_registry / "demo-plugin" / "test_runtime.py").write_text("class Adaptor: pass\n")
    monkeypatch.setattr(pr, "_REGISTRY_ROOT", fake_registry)

    # Force _load_module_from_path to return None when asked for this module.
    monkeypatch.setattr(pr, "_load_module_from_path", lambda name, path: None)

    _, source = pr.resolve("demo-plugin", "test_runtime", plugin_root)
    # Both registry and plugin-shipped now yield None → raw-drop.
    assert source == AdaptorSource.RAW_DROP


def test_resolve_registry_missing_module_falls_through(monkeypatch, tmp_path: Path, plugin_root: Path):
    """Registry root exists but has neither plugin dir for this name →
    plugin-shipped or raw-drop takes over (not a crash)."""
    import plugins_registry as pr
    monkeypatch.setattr(pr, "_REGISTRY_ROOT", tmp_path / "empty-registry")
    _, source = pr.resolve("demo-plugin", "test_runtime", plugin_root)
    assert source == AdaptorSource.RAW_DROP
