"""Tests for the LLMHosts plugin system.

Covers plugin discovery (directory + entry points), loading, registration,
lifecycle hooks, configuration, error handling, and integration with
LLMHostsConfig.
"""

from __future__ import annotations

import textwrap
from typing import TYPE_CHECKING, Any
from unittest.mock import MagicMock, patch

import pytest
from fastapi import FastAPI

from llmhosts.config import LLMHostsConfig, PluginConfig
from llmhosts.plugins import PluginInfo, PluginManager, _FunctionPlugin
from llmhosts.plugins.base import (
    BackendPlugin,
    HookPlugin,
    LLMHostPlugin,
    MiddlewarePlugin,
    PluginType,
)

if TYPE_CHECKING:
    from pathlib import Path


# ---------------------------------------------------------------------------
# Helpers -- concrete plugin implementations for testing
# ---------------------------------------------------------------------------


class _DummyPlugin(LLMHostPlugin):
    """Minimal concrete plugin for testing."""

    @property
    def name(self) -> str:
        return "dummy"

    @property
    def version(self) -> str:
        return "1.0.0"

    @property
    def description(self) -> str:
        return "A dummy test plugin"

    def register(self, app: Any, config: Any) -> None:
        app.state.dummy_registered = True


class _DummyMiddleware(MiddlewarePlugin):
    """Concrete middleware plugin for testing."""

    @property
    def name(self) -> str:
        return "dummy-middleware"

    @property
    def version(self) -> str:
        return "0.1.0"

    @property
    def description(self) -> str:
        return "Dummy middleware"

    def register(self, app: Any, config: Any) -> None:
        app.state.middleware_registered = True


class _DummyBackend(BackendPlugin):
    """Concrete backend plugin for testing."""

    @property
    def name(self) -> str:
        return "dummy-backend"

    @property
    def version(self) -> str:
        return "2.0.0"

    @property
    def description(self) -> str:
        return "Dummy backend"

    def register(self, app: Any, config: Any) -> None:
        app.state.backend_registered = True


class _DummyHook(HookPlugin):
    """Concrete hook plugin with startup/shutdown tracking."""

    started: bool = False
    stopped: bool = False

    @property
    def name(self) -> str:
        return "dummy-hook"

    @property
    def version(self) -> str:
        return "0.5.0"

    @property
    def description(self) -> str:
        return "Dummy hook"

    def register(self, app: Any, config: Any) -> None:
        pass

    async def on_startup(self) -> None:
        self.started = True

    async def on_shutdown(self) -> None:
        self.stopped = True


class _ExplodingPlugin(LLMHostPlugin):
    """Plugin that raises on register -- for error handling tests."""

    @property
    def name(self) -> str:
        return "exploding"

    @property
    def version(self) -> str:
        return "0.0.1"

    @property
    def description(self) -> str:
        return "Always raises"

    def register(self, app: Any, config: Any) -> None:
        raise RuntimeError("boom")

    async def on_startup(self) -> None:
        raise RuntimeError("startup boom")

    async def on_shutdown(self) -> None:
        raise RuntimeError("shutdown boom")


# ---------------------------------------------------------------------------
# PluginConfig defaults
# ---------------------------------------------------------------------------


@pytest.mark.unit
class TestPluginConfig:
    """Tests for the PluginConfig Pydantic model."""

    def test_defaults(self) -> None:
        """Default config enables the system with the standard plugin dir."""
        cfg = PluginConfig()
        assert cfg.enabled is True
        assert cfg.plugin_dirs == ["~/.llmhosts/plugins"]
        assert cfg.disabled_plugins == []

    def test_disabled(self) -> None:
        """Setting enabled=False should be respected."""
        cfg = PluginConfig(enabled=False)
        assert cfg.enabled is False

    def test_custom_dirs(self) -> None:
        """Custom plugin directories are stored."""
        cfg = PluginConfig(plugin_dirs=["/opt/plugins", "/tmp/extra"])
        assert cfg.plugin_dirs == ["/opt/plugins", "/tmp/extra"]

    def test_disabled_plugins_list(self) -> None:
        """Specifying plugins to skip."""
        cfg = PluginConfig(disabled_plugins=["bad-plugin", "unwanted"])
        assert "bad-plugin" in cfg.disabled_plugins
        assert len(cfg.disabled_plugins) == 2

    def test_serialisation_round_trip(self) -> None:
        """Config should survive dump/load round trip."""
        original = PluginConfig(enabled=False, plugin_dirs=["/x"], disabled_plugins=["foo"])
        data = original.model_dump()
        restored = PluginConfig.model_validate(data)
        assert restored == original


# ---------------------------------------------------------------------------
# PluginInfo model
# ---------------------------------------------------------------------------


@pytest.mark.unit
class TestPluginInfo:
    """Tests for the PluginInfo Pydantic model."""

    def test_required_fields(self) -> None:
        info = PluginInfo(name="test", version="1.0.0", description="A test")
        assert info.name == "test"
        assert info.version == "1.0.0"
        assert info.author == ""
        assert info.plugin_type == "hook"

    def test_custom_type(self) -> None:
        info = PluginInfo(name="x", version="0.1", description="mw", plugin_type="middleware")
        assert info.plugin_type == "middleware"


# ---------------------------------------------------------------------------
# Base class hierarchy
# ---------------------------------------------------------------------------


@pytest.mark.unit
class TestBasePluginClasses:
    """Tests for LLMHostPlugin and its sub-classes."""

    def test_plugin_type_default(self) -> None:
        """Base LLMHostPlugin defaults to hook type."""
        p = _DummyPlugin()
        assert p.plugin_type == PluginType.hook

    def test_middleware_type(self) -> None:
        p = _DummyMiddleware()
        assert p.plugin_type == PluginType.middleware

    def test_backend_type(self) -> None:
        p = _DummyBackend()
        assert p.plugin_type == PluginType.backend

    def test_hook_type(self) -> None:
        p = _DummyHook()
        assert p.plugin_type == PluginType.hook

    def test_repr(self) -> None:
        p = _DummyPlugin()
        r = repr(p)
        assert "dummy" in r
        assert "1.0.0" in r

    async def test_default_on_startup_is_noop(self) -> None:
        """Base on_startup should not raise."""
        p = _DummyPlugin()
        await p.on_startup()

    async def test_default_on_shutdown_is_noop(self) -> None:
        p = _DummyPlugin()
        await p.on_shutdown()


# ---------------------------------------------------------------------------
# PluginManager -- construction and properties
# ---------------------------------------------------------------------------


@pytest.mark.unit
class TestPluginManagerBasics:
    """Constructor, properties, and empty-state behaviour."""

    def test_default_construction(self) -> None:
        mgr = PluginManager()
        assert mgr.plugin_count == 0
        assert mgr.plugins == []

    def test_construction_with_config(self) -> None:
        cfg = PluginConfig(enabled=False)
        mgr = PluginManager(config=cfg)
        assert mgr._config.enabled is False

    def test_plugins_returns_copy(self) -> None:
        mgr = PluginManager()
        mgr._plugins.append(_DummyPlugin())
        plugins = mgr.plugins
        plugins.clear()
        assert mgr.plugin_count == 1  # internal list unchanged

    def test_get_plugin_found(self) -> None:
        mgr = PluginManager()
        mgr._plugins.append(_DummyPlugin())
        assert mgr.get_plugin("dummy") is not None

    def test_get_plugin_not_found(self) -> None:
        mgr = PluginManager()
        assert mgr.get_plugin("nonexistent") is None


# ---------------------------------------------------------------------------
# PluginManager -- discovery from directory
# ---------------------------------------------------------------------------


@pytest.mark.unit
class TestPluginManagerDirectoryDiscovery:
    """Tests for loading plugins from the filesystem."""

    def test_discover_from_empty_dir(self, tmp_path: Path) -> None:
        """An empty directory yields no plugins."""
        cfg = PluginConfig(plugin_dirs=[str(tmp_path)])
        mgr = PluginManager(config=cfg)
        result = mgr.discover()
        assert result == []

    def test_discover_class_based_plugin(self, tmp_path: Path) -> None:
        """A .py file with an LLMHostPlugin subclass should be discovered."""
        plugin_file = tmp_path / "my_plugin.py"
        plugin_file.write_text(
            textwrap.dedent("""\
                from llmhosts.plugins.base import LLMHostPlugin

                class MyPlugin(LLMHostPlugin):
                    @property
                    def name(self): return "my-plugin"
                    @property
                    def version(self): return "1.0.0"
                    @property
                    def description(self): return "Test"
                    def register(self, app, config): pass
            """),
            encoding="utf-8",
        )
        cfg = PluginConfig(plugin_dirs=[str(tmp_path)])
        mgr = PluginManager(config=cfg)
        result = mgr.discover()
        assert len(result) == 1
        assert result[0].name == "my-plugin"

    def test_discover_function_based_plugin(self, tmp_path: Path) -> None:
        """A .py file with register() should be wrapped as _FunctionPlugin."""
        plugin_file = tmp_path / "simple.py"
        plugin_file.write_text(
            textwrap.dedent("""\
                PLUGIN_NAME = "simple-func"
                PLUGIN_VERSION = "0.2.0"
                PLUGIN_DESCRIPTION = "Simple function plugin"

                def register(app, config):
                    app.state.simple_registered = True
            """),
            encoding="utf-8",
        )
        cfg = PluginConfig(plugin_dirs=[str(tmp_path)])
        mgr = PluginManager(config=cfg)
        result = mgr.discover()
        assert len(result) == 1
        assert result[0].name == "simple-func"
        assert result[0].version == "0.2.0"

    def test_discover_function_plugin_defaults(self, tmp_path: Path) -> None:
        """A register() without PLUGIN_NAME uses the filename stem."""
        plugin_file = tmp_path / "bare_register.py"
        plugin_file.write_text("def register(app, config): pass\n", encoding="utf-8")
        cfg = PluginConfig(plugin_dirs=[str(tmp_path)])
        mgr = PluginManager(config=cfg)
        result = mgr.discover()
        assert len(result) == 1
        assert result[0].name == "bare_register"
        assert result[0].version == "0.0.0"

    def test_discover_skips_underscore_files(self, tmp_path: Path) -> None:
        """Files starting with _ (e.g. __init__.py) should be ignored."""
        (tmp_path / "__init__.py").write_text("def register(app, config): pass\n", encoding="utf-8")
        (tmp_path / "_private.py").write_text("def register(app, config): pass\n", encoding="utf-8")
        cfg = PluginConfig(plugin_dirs=[str(tmp_path)])
        mgr = PluginManager(config=cfg)
        result = mgr.discover()
        assert result == []

    def test_discover_skips_file_without_plugin_or_register(self, tmp_path: Path) -> None:
        """A .py file with no LLMHostPlugin class and no register() is skipped."""
        (tmp_path / "empty.py").write_text("x = 42\n", encoding="utf-8")
        cfg = PluginConfig(plugin_dirs=[str(tmp_path)])
        mgr = PluginManager(config=cfg)
        result = mgr.discover()
        assert result == []

    def test_discover_handles_syntax_error(self, tmp_path: Path) -> None:
        """A plugin file with a syntax error should be logged, not crash."""
        (tmp_path / "broken.py").write_text("def register(app config): pass\n", encoding="utf-8")
        cfg = PluginConfig(plugin_dirs=[str(tmp_path)])
        mgr = PluginManager(config=cfg)
        result = mgr.discover()
        assert result == []

    def test_discover_handles_import_error(self, tmp_path: Path) -> None:
        """A plugin file that raises ImportError should be logged, not crash."""
        (tmp_path / "bad_import.py").write_text("import nonexistent_module_xyz\n", encoding="utf-8")
        cfg = PluginConfig(plugin_dirs=[str(tmp_path)])
        mgr = PluginManager(config=cfg)
        result = mgr.discover()
        assert result == []

    def test_discover_nonexistent_dir(self) -> None:
        """A non-existent plugin directory is silently skipped."""
        cfg = PluginConfig(plugin_dirs=["/nonexistent/dir/abc123"])
        mgr = PluginManager(config=cfg)
        result = mgr.discover()
        assert result == []

    def test_discover_multiple_dirs(self, tmp_path: Path) -> None:
        """Plugins from multiple directories are aggregated."""
        dir_a = tmp_path / "a"
        dir_b = tmp_path / "b"
        dir_a.mkdir()
        dir_b.mkdir()
        (dir_a / "p1.py").write_text(
            textwrap.dedent("""\
                from llmhosts.plugins.base import LLMHostPlugin
                class P1(LLMHostPlugin):
                    @property
                    def name(self): return "p1"
                    @property
                    def version(self): return "1.0.0"
                    @property
                    def description(self): return "P1"
                    def register(self, app, config): pass
            """),
            encoding="utf-8",
        )
        (dir_b / "p2.py").write_text(
            textwrap.dedent("""\
                PLUGIN_NAME = "p2"
                PLUGIN_VERSION = "2.0.0"
                PLUGIN_DESCRIPTION = "P2"
                def register(app, config): pass
            """),
            encoding="utf-8",
        )
        cfg = PluginConfig(plugin_dirs=[str(dir_a), str(dir_b)])
        mgr = PluginManager(config=cfg)
        result = mgr.discover()
        names = {p.name for p in result}
        assert names == {"p1", "p2"}


# ---------------------------------------------------------------------------
# PluginManager -- discovery from entry points
# ---------------------------------------------------------------------------


@pytest.mark.unit
class TestPluginManagerEntryPointDiscovery:
    """Tests for loading plugins via importlib.metadata entry points."""

    def test_discover_from_entry_points(self) -> None:
        """Mock entry points returning a valid plugin class."""
        mock_ep = MagicMock()
        mock_ep.name = "ep-plugin"
        mock_ep.load.return_value = _DummyPlugin

        with patch("importlib.metadata.entry_points") as mock_eps:
            mock_eps.return_value.select.return_value = [mock_ep]
            cfg = PluginConfig(plugin_dirs=[])
            mgr = PluginManager(config=cfg)
            result = mgr.discover()

        assert len(result) == 1
        assert result[0].name == "dummy"

    def test_entry_point_non_plugin_class_skipped(self) -> None:
        """An entry point that does not resolve to LLMHostPlugin is skipped."""
        mock_ep = MagicMock()
        mock_ep.name = "bad-ep"
        mock_ep.load.return_value = str  # not an LLMHostPlugin subclass

        with patch("importlib.metadata.entry_points") as mock_eps:
            mock_eps.return_value.select.return_value = [mock_ep]
            cfg = PluginConfig(plugin_dirs=[])
            mgr = PluginManager(config=cfg)
            result = mgr.discover()

        assert result == []

    def test_entry_point_load_error(self) -> None:
        """An entry point that raises on load is caught gracefully."""
        mock_ep = MagicMock()
        mock_ep.name = "broken-ep"
        mock_ep.load.side_effect = ImportError("no such module")

        with patch("importlib.metadata.entry_points") as mock_eps:
            mock_eps.return_value.select.return_value = [mock_ep]
            cfg = PluginConfig(plugin_dirs=[])
            mgr = PluginManager(config=cfg)
            result = mgr.discover()

        assert result == []


# ---------------------------------------------------------------------------
# PluginManager -- disabled plugins
# ---------------------------------------------------------------------------


@pytest.mark.unit
class TestPluginManagerDisabled:
    """Tests for the disabled_plugins filter and master switch."""

    def test_disabled_system_skips_all_discovery(self) -> None:
        """When enabled=False, no discovery takes place."""
        cfg = PluginConfig(enabled=False)
        mgr = PluginManager(config=cfg)
        result = mgr.discover()
        assert result == []

    def test_disabled_plugin_by_name(self, tmp_path: Path) -> None:
        """A plugin whose name is in disabled_plugins is filtered out."""
        (tmp_path / "skip_me.py").write_text(
            textwrap.dedent("""\
                PLUGIN_NAME = "skip-me"
                def register(app, config): pass
            """),
            encoding="utf-8",
        )
        cfg = PluginConfig(plugin_dirs=[str(tmp_path)], disabled_plugins=["skip-me"])
        mgr = PluginManager(config=cfg)
        result = mgr.discover()
        assert result == []

    def test_disabled_only_target_plugin(self, tmp_path: Path) -> None:
        """Only the named plugin is disabled; others still load."""
        (tmp_path / "keep.py").write_text(
            textwrap.dedent("""\
                PLUGIN_NAME = "keep"
                def register(app, config): pass
            """),
            encoding="utf-8",
        )
        (tmp_path / "drop.py").write_text(
            textwrap.dedent("""\
                PLUGIN_NAME = "drop"
                def register(app, config): pass
            """),
            encoding="utf-8",
        )
        cfg = PluginConfig(plugin_dirs=[str(tmp_path)], disabled_plugins=["drop"])
        mgr = PluginManager(config=cfg)
        result = mgr.discover()
        assert len(result) == 1
        assert result[0].name == "keep"


# ---------------------------------------------------------------------------
# PluginManager -- registration
# ---------------------------------------------------------------------------


@pytest.mark.unit
class TestPluginManagerRegistration:
    """Tests for register_all()."""

    def test_register_all_calls_register(self) -> None:
        """Each plugin's register(app, config) is invoked."""
        mgr = PluginManager()
        mgr._plugins.append(_DummyPlugin())
        app = FastAPI()
        config = LLMHostsConfig()
        mgr.register_all(app, config)
        assert getattr(app.state, "dummy_registered", False) is True

    def test_register_error_does_not_crash(self) -> None:
        """A plugin that raises during register() is logged, not fatal."""
        mgr = PluginManager()
        mgr._plugins.append(_ExplodingPlugin())
        mgr._plugins.append(_DummyPlugin())
        app = FastAPI()
        config = LLMHostsConfig()
        mgr.register_all(app, config)
        # The second plugin still got registered
        assert getattr(app.state, "dummy_registered", False) is True

    def test_register_all_sets_registered_flag(self) -> None:
        mgr = PluginManager()
        app = FastAPI()
        config = LLMHostsConfig()
        mgr.register_all(app, config)
        assert mgr._registered is True


# ---------------------------------------------------------------------------
# PluginManager -- lifecycle hooks
# ---------------------------------------------------------------------------


@pytest.mark.unit
class TestPluginManagerLifecycle:
    """Tests for startup() and shutdown() async hooks."""

    async def test_startup_calls_on_startup(self) -> None:
        hook = _DummyHook()
        mgr = PluginManager()
        mgr._plugins.append(hook)
        await mgr.startup()
        assert hook.started is True

    async def test_shutdown_calls_on_shutdown(self) -> None:
        hook = _DummyHook()
        mgr = PluginManager()
        mgr._plugins.append(hook)
        await mgr.shutdown()
        assert hook.stopped is True

    async def test_startup_error_does_not_crash(self) -> None:
        """A plugin that raises during on_startup() is logged, not fatal."""
        mgr = PluginManager()
        mgr._plugins.append(_ExplodingPlugin())
        # Should not raise
        await mgr.startup()

    async def test_shutdown_error_does_not_crash(self) -> None:
        mgr = PluginManager()
        mgr._plugins.append(_ExplodingPlugin())
        await mgr.shutdown()

    async def test_startup_multiple_plugins(self) -> None:
        h1, h2 = _DummyHook(), _DummyHook()
        mgr = PluginManager()
        mgr._plugins.extend([h1, h2])
        await mgr.startup()
        assert h1.started and h2.started

    async def test_shutdown_multiple_plugins(self) -> None:
        h1, h2 = _DummyHook(), _DummyHook()
        mgr = PluginManager()
        mgr._plugins.extend([h1, h2])
        await mgr.shutdown()
        assert h1.stopped and h2.stopped


# ---------------------------------------------------------------------------
# PluginManager -- introspection
# ---------------------------------------------------------------------------


@pytest.mark.unit
class TestPluginManagerIntrospection:
    """Tests for list_plugins() and get_plugin()."""

    def test_list_plugins_empty(self) -> None:
        mgr = PluginManager()
        assert mgr.list_plugins() == []

    def test_list_plugins_returns_info_objects(self) -> None:
        mgr = PluginManager()
        mgr._plugins.extend([_DummyPlugin(), _DummyMiddleware()])
        infos = mgr.list_plugins()
        assert len(infos) == 2
        assert all(isinstance(i, PluginInfo) for i in infos)
        names = {i.name for i in infos}
        assert names == {"dummy", "dummy-middleware"}

    def test_list_plugins_type_field(self) -> None:
        mgr = PluginManager()
        mgr._plugins.append(_DummyMiddleware())
        infos = mgr.list_plugins()
        assert infos[0].plugin_type == "middleware"

    def test_get_plugin_by_name(self) -> None:
        mgr = PluginManager()
        p = _DummyPlugin()
        mgr._plugins.append(p)
        assert mgr.get_plugin("dummy") is p

    def test_get_plugin_returns_none_for_missing(self) -> None:
        mgr = PluginManager()
        mgr._plugins.append(_DummyPlugin())
        assert mgr.get_plugin("nope") is None


# ---------------------------------------------------------------------------
# _FunctionPlugin adapter
# ---------------------------------------------------------------------------


@pytest.mark.unit
class TestFunctionPluginAdapter:
    """Tests for the internal _FunctionPlugin wrapper."""

    def test_properties(self) -> None:
        fn = MagicMock()
        p = _FunctionPlugin(register_fn=fn, plugin_name="fn-p", plugin_version="3.0.0", plugin_description="desc")
        assert p.name == "fn-p"
        assert p.version == "3.0.0"
        assert p.description == "desc"

    def test_register_delegates(self) -> None:
        fn = MagicMock()
        p = _FunctionPlugin(register_fn=fn)
        p.register("app", "config")
        fn.assert_called_once_with("app", "config")

    def test_default_values(self) -> None:
        p = _FunctionPlugin(register_fn=lambda a, c: None)
        assert p.name == "unnamed"
        assert p.version == "0.0.0"


# ---------------------------------------------------------------------------
# Integration with LLMHostsConfig
# ---------------------------------------------------------------------------


@pytest.mark.unit
class TestConfigIntegration:
    """Tests for PluginConfig as a field of LLMHostsConfig."""

    def test_default_config_has_plugins(self) -> None:
        cfg = LLMHostsConfig()
        assert isinstance(cfg.plugins, PluginConfig)
        assert cfg.plugins.enabled is True

    def test_plugins_config_overrides(self) -> None:
        cfg = LLMHostsConfig(plugins=PluginConfig(enabled=False, disabled_plugins=["x"]))
        assert cfg.plugins.enabled is False
        assert cfg.plugins.disabled_plugins == ["x"]

    def test_plugins_config_from_dict(self) -> None:
        data = {"plugins": {"enabled": False, "plugin_dirs": ["/custom"]}}
        cfg = LLMHostsConfig.model_validate(data)
        assert cfg.plugins.enabled is False
        assert cfg.plugins.plugin_dirs == ["/custom"]

    def test_plugins_config_dump_round_trip(self) -> None:
        original = LLMHostsConfig(plugins=PluginConfig(disabled_plugins=["a", "b"]))
        data = original.model_dump()
        restored = LLMHostsConfig.model_validate(data)
        assert restored.plugins.disabled_plugins == ["a", "b"]

    def test_discover_clears_previous_results(self, tmp_path: Path) -> None:
        """Calling discover() again resets the plugin list."""
        (tmp_path / "p.py").write_text("def register(app, config): pass\nPLUGIN_NAME='p'\n", encoding="utf-8")
        cfg = PluginConfig(plugin_dirs=[str(tmp_path)])
        mgr = PluginManager(config=cfg)
        mgr.discover()
        assert mgr.plugin_count == 1
        # Remove the file and re-discover
        (tmp_path / "p.py").unlink()
        mgr.discover()
        assert mgr.plugin_count == 0


# ---------------------------------------------------------------------------
# PluginType enum
# ---------------------------------------------------------------------------


@pytest.mark.unit
class TestPluginTypeEnum:
    """Tests for the PluginType enum."""

    def test_values(self) -> None:
        assert PluginType.middleware.value == "middleware"
        assert PluginType.backend.value == "backend"
        assert PluginType.hook.value == "hook"

    def test_string_comparison(self) -> None:
        assert PluginType.middleware == "middleware"
