"""
Tests for Phase 39: Extensible Plugin System.

Covers: registration, discovery, lifecycle, hooks, tools, config,
search, stats, error isolation, duplicate rejection, and thread safety.
"""

import json
import threading
from pathlib import Path

import pytest


@pytest.fixture(autouse=True)
def _isolate(tmp_path, monkeypatch):
    """Redirect all plugin storage to a temp directory and reset in-memory state."""
    channels = tmp_path / "channels"
    sessions = tmp_path / "sessions"
    cursors = tmp_path / "cursors"
    plugins = tmp_path / "plugins"

    for d in (channels, sessions, cursors, plugins):
        d.mkdir(parents=True, exist_ok=True)

    import brynq.server as srv
    monkeypatch.setattr(srv, "BROKER_DIR", tmp_path)
    monkeypatch.setattr(srv, "CHANNELS_DIR", channels)
    monkeypatch.setattr(srv, "SESSIONS_DIR", sessions)
    monkeypatch.setattr(srv, "CURSORS_DIR", cursors)

    import brynq.plugin_system as ps
    monkeypatch.setattr(ps, "PLUGINS_DIR", plugins)
    monkeypatch.setattr(ps, "STATS_FILE", plugins / "stats.jsonl")

    # Reset in-memory registries so tests don't leak state
    monkeypatch.setattr(ps, "_hook_registry", {})
    monkeypatch.setattr(ps, "_tool_registry", {})


# ── Helpers ────────────────────────────────────────────────────────────────


def _simple_plugin(**overrides):
    """Defaults for a minimal plugin registration."""
    defaults = {
        "name": "test_plugin",
        "version": "1.0.0",
        "author": "Test Author",
        "description": "A test plugin",
    }
    defaults.update(overrides)
    return defaults


# ── Test: Register and List Plugins ────────────────────────────────────────


class TestRegisterAndList:
    def test_register_returns_plugin_id(self):
        from brynq.plugin_system import register_plugin
        result = register_plugin(**_simple_plugin())
        assert "plugin_id" in result
        assert result["name"] == "test_plugin"
        assert result["status"] == "installed"

    def test_register_persists_to_disk(self, tmp_path):
        from brynq.plugin_system import register_plugin
        register_plugin(**_simple_plugin())
        path = tmp_path / "plugins" / "test_plugin.json"
        assert path.exists()
        data = json.loads(path.read_text("utf-8"))
        assert data["version"] == "1.0.0"

    def test_list_plugins_returns_all(self):
        from brynq.plugin_system import register_plugin, list_plugins
        register_plugin(**_simple_plugin(name="alpha"))
        register_plugin(**_simple_plugin(name="beta"))
        plugins = list_plugins()
        names = [p["name"] for p in plugins]
        assert "alpha" in names
        assert "beta" in names

    def test_list_plugins_filter_by_status(self):
        from brynq.plugin_system import register_plugin, enable_plugin, list_plugins
        register_plugin(**_simple_plugin(name="a"))
        register_plugin(**_simple_plugin(name="b"))
        enable_plugin("a")
        enabled = list_plugins(status="enabled")
        installed = list_plugins(status="installed")
        assert len(enabled) == 1
        assert enabled[0]["name"] == "a"
        assert len(installed) == 1
        assert installed[0]["name"] == "b"

    def test_get_plugin_returns_details(self):
        from brynq.plugin_system import register_plugin, get_plugin
        register_plugin(**_simple_plugin())
        plugin = get_plugin("test_plugin")
        assert plugin is not None
        assert plugin["author"] == "Test Author"

    def test_get_plugin_nonexistent_returns_none(self):
        from brynq.plugin_system import get_plugin
        assert get_plugin("does_not_exist") is None


# ── Test: Duplicate Plugin Rejection ───────────────────────────────────────


class TestDuplicateRejection:
    def test_duplicate_name_raises(self):
        from brynq.plugin_system import register_plugin
        register_plugin(**_simple_plugin())
        with pytest.raises(ValueError, match="already registered"):
            register_plugin(**_simple_plugin())

    def test_can_reregister_after_uninstall(self):
        from brynq.plugin_system import register_plugin, uninstall_plugin
        register_plugin(**_simple_plugin())
        uninstall_plugin("test_plugin")
        # Should succeed because status is now "uninstalled"
        result = register_plugin(**_simple_plugin())
        assert result["status"] == "installed"


# ── Test: Enable / Disable / Uninstall Lifecycle ──────────────────────────


class TestLifecycle:
    def test_enable_from_installed(self):
        from brynq.plugin_system import register_plugin, enable_plugin
        register_plugin(**_simple_plugin())
        result = enable_plugin("test_plugin")
        assert result["status"] == "enabled"
        assert result["enabled_at"] is not None

    def test_disable_from_enabled(self):
        from brynq.plugin_system import register_plugin, enable_plugin, disable_plugin
        register_plugin(**_simple_plugin())
        enable_plugin("test_plugin")
        result = disable_plugin("test_plugin")
        assert result["status"] == "disabled"
        assert result["disabled_at"] is not None

    def test_reenable_from_disabled(self):
        from brynq.plugin_system import register_plugin, enable_plugin, disable_plugin
        register_plugin(**_simple_plugin())
        enable_plugin("test_plugin")
        disable_plugin("test_plugin")
        result = enable_plugin("test_plugin")
        assert result["status"] == "enabled"

    def test_uninstall_removes_hooks_and_tools(self):
        from brynq.plugin_system import (
            register_plugin, enable_plugin, uninstall_plugin,
            list_plugin_tools, _hook_registry, _tool_registry,
        )
        hook_fn = lambda ctx: ctx
        tool_handler = lambda: {"result": "ok"}
        register_plugin(
            **_simple_plugin(),
            hooks={"pre_message_post": hook_fn},
            tools=[{"name": "my_tool", "description": "t", "handler": tool_handler}],
        )
        enable_plugin("test_plugin")

        # Verify they exist before uninstall
        assert list_plugin_tools("test_plugin")
        assert any(
            pn == "test_plugin"
            for entries in _hook_registry.values()
            for pn, _ in entries
        )

        uninstall_plugin("test_plugin")

        # In-memory registries should be cleaned
        assert not list_plugin_tools("test_plugin")
        assert not any(
            pn == "test_plugin"
            for entries in _hook_registry.values()
            for pn, _ in entries
        )

    def test_enable_nonexistent_raises(self):
        from brynq.plugin_system import enable_plugin
        with pytest.raises(ValueError, match="not found"):
            enable_plugin("ghost")

    def test_disable_installed_raises(self):
        from brynq.plugin_system import register_plugin, disable_plugin
        register_plugin(**_simple_plugin())
        with pytest.raises(ValueError, match="Cannot disable"):
            disable_plugin("test_plugin")

    def test_enable_already_enabled_raises(self):
        from brynq.plugin_system import register_plugin, enable_plugin
        register_plugin(**_simple_plugin())
        enable_plugin("test_plugin")
        with pytest.raises(ValueError, match="already enabled"):
            enable_plugin("test_plugin")

    def test_uninstall_already_uninstalled_raises(self):
        from brynq.plugin_system import register_plugin, uninstall_plugin
        register_plugin(**_simple_plugin())
        uninstall_plugin("test_plugin")
        with pytest.raises(ValueError, match="already uninstalled"):
            uninstall_plugin("test_plugin")


# ── Test: Hook Registration and Execution ──────────────────────────────────


class TestHooks:
    def test_hook_registered_and_invoked(self):
        from brynq.plugin_system import register_plugin, enable_plugin, run_hooks
        calls = []
        def my_hook(ctx):
            calls.append(ctx)
            return ctx
        register_plugin(**_simple_plugin(), hooks={"pre_message_post": my_hook})
        enable_plugin("test_plugin")

        run_hooks("pre_message_post", {"message": "hello"})
        assert len(calls) == 1
        assert calls[0]["message"] == "hello"

    def test_hook_modifies_context(self):
        from brynq.plugin_system import register_plugin, enable_plugin, run_hooks
        def uppercase_hook(ctx):
            ctx["message"] = ctx["message"].upper()
            return ctx
        register_plugin(**_simple_plugin(), hooks={"pre_message_post": uppercase_hook})
        enable_plugin("test_plugin")

        result = run_hooks("pre_message_post", {"message": "hello"})
        assert result["message"] == "HELLO"

    def test_multiple_hooks_chain(self):
        from brynq.plugin_system import register_plugin, enable_plugin, run_hooks
        def add_prefix(ctx):
            ctx["message"] = "[tagged] " + ctx["message"]
            return ctx
        def add_suffix(ctx):
            ctx["message"] = ctx["message"] + " !"
            return ctx

        register_plugin(
            **_simple_plugin(name="p1"), hooks={"pre_message_post": add_prefix}
        )
        register_plugin(
            **_simple_plugin(name="p2"), hooks={"pre_message_post": add_suffix}
        )
        enable_plugin("p1")
        enable_plugin("p2")

        result = run_hooks("pre_message_post", {"message": "hi"})
        assert result["message"] == "[tagged] hi !"

    def test_hook_error_isolation(self):
        from brynq.plugin_system import register_plugin, enable_plugin, run_hooks
        def bad_hook(ctx):
            raise RuntimeError("boom")
        def good_hook(ctx):
            ctx["survived"] = True
            return ctx

        register_plugin(**_simple_plugin(name="bad"), hooks={"post_task_create": bad_hook})
        register_plugin(**_simple_plugin(name="good"), hooks={"post_task_create": good_hook})
        enable_plugin("bad")
        enable_plugin("good")

        result = run_hooks("post_task_create", {"task": "x"})
        # Good hook ran despite bad hook raising
        assert result["survived"] is True

    def test_disabled_plugin_hooks_skipped(self):
        from brynq.plugin_system import (
            register_plugin, enable_plugin, disable_plugin, run_hooks,
        )
        calls = []
        def tracking_hook(ctx):
            calls.append(1)
            return ctx
        register_plugin(**_simple_plugin(), hooks={"pre_message_post": tracking_hook})
        enable_plugin("test_plugin")
        disable_plugin("test_plugin")

        run_hooks("pre_message_post", {"message": "ignored"})
        assert len(calls) == 0

    def test_invalid_hook_name_rejected(self):
        from brynq.plugin_system import register_plugin
        with pytest.raises(ValueError, match="Invalid hook"):
            register_plugin(**_simple_plugin(), hooks={"nonexistent_hook": lambda c: c})

    def test_run_hooks_on_empty_hook(self):
        from brynq.plugin_system import run_hooks
        ctx = {"data": "value"}
        result = run_hooks("pre_message_post", ctx)
        assert result["data"] == "value"


# ── Test: Plugin Tools ────────────────────────────────────────────────────


class TestTools:
    def test_register_and_list_tools(self):
        from brynq.plugin_system import register_plugin, list_plugin_tools
        handler = lambda: {"result": 42}
        register_plugin(
            **_simple_plugin(),
            tools=[{"name": "calc", "description": "Calculate", "handler": handler}],
        )
        tools = list_plugin_tools("test_plugin")
        assert len(tools) == 1
        assert tools[0]["tool"] == "calc"

    def test_execute_tool_success(self):
        from brynq.plugin_system import register_plugin, enable_plugin, execute_tool
        def adder(a=0, b=0):
            return {"sum": a + b}
        register_plugin(
            **_simple_plugin(),
            tools=[{"name": "add", "description": "Add", "handler": adder}],
        )
        enable_plugin("test_plugin")

        result = execute_tool("test_plugin", "add", {"a": 3, "b": 5})
        assert result["ok"] is True
        assert result["result"]["sum"] == 8

    def test_execute_tool_on_disabled_plugin(self):
        from brynq.plugin_system import register_plugin, execute_tool
        register_plugin(
            **_simple_plugin(),
            tools=[{"name": "t", "description": "t", "handler": lambda: None}],
        )
        # Plugin is "installed" not "enabled"
        result = execute_tool("test_plugin", "t", {})
        assert result["ok"] is False
        assert "not enabled" in result["error"]

    def test_execute_tool_error_handling(self):
        from brynq.plugin_system import register_plugin, enable_plugin, execute_tool
        def bad_tool():
            raise ValueError("tool exploded")
        register_plugin(
            **_simple_plugin(),
            tools=[{"name": "boom", "description": "b", "handler": bad_tool}],
        )
        enable_plugin("test_plugin")

        result = execute_tool("test_plugin", "boom", {})
        assert result["ok"] is False
        assert "tool exploded" in result["error"]

    def test_execute_nonexistent_tool(self):
        from brynq.plugin_system import register_plugin, enable_plugin, execute_tool
        register_plugin(**_simple_plugin())
        enable_plugin("test_plugin")
        result = execute_tool("test_plugin", "no_such_tool", {})
        assert result["ok"] is False
        assert "not found" in result["error"]

    def test_register_tool_separately(self):
        from brynq.plugin_system import register_plugin, register_tool, list_plugin_tools
        register_plugin(**_simple_plugin())
        register_tool("test_plugin", "extra", "Extra tool", lambda: "ok")
        tools = list_plugin_tools("test_plugin")
        assert any(t["tool"] == "extra" for t in tools)

    def test_duplicate_tool_rejected(self):
        from brynq.plugin_system import register_plugin, register_tool
        register_plugin(**_simple_plugin())
        register_tool("test_plugin", "uniq", "u", lambda: None)
        with pytest.raises(ValueError, match="already registered"):
            register_tool("test_plugin", "uniq", "u", lambda: None)

    def test_list_all_tools_across_plugins(self):
        from brynq.plugin_system import register_plugin, list_plugin_tools
        register_plugin(
            **_simple_plugin(name="p1"),
            tools=[{"name": "t1", "description": "d1", "handler": lambda: 1}],
        )
        register_plugin(
            **_simple_plugin(name="p2"),
            tools=[{"name": "t2", "description": "d2", "handler": lambda: 2}],
        )
        all_tools = list_plugin_tools()
        assert len(all_tools) == 2
        names = {t["tool"] for t in all_tools}
        assert names == {"t1", "t2"}


# ── Test: Plugin Config ───────────────────────────────────────────────────


class TestConfig:
    def test_set_and_get_config(self):
        from brynq.plugin_system import register_plugin, set_plugin_config, get_plugin_config
        register_plugin(**_simple_plugin())
        set_plugin_config("test_plugin", "theme", "dark")
        assert get_plugin_config("test_plugin", "theme") == "dark"

    def test_get_config_default(self):
        from brynq.plugin_system import register_plugin, get_plugin_config
        register_plugin(**_simple_plugin())
        assert get_plugin_config("test_plugin", "missing", "fallback") == "fallback"

    def test_config_persists_to_disk(self, tmp_path):
        from brynq.plugin_system import register_plugin, set_plugin_config
        register_plugin(**_simple_plugin())
        set_plugin_config("test_plugin", "k", "v")
        path = tmp_path / "plugins" / "test_plugin.json"
        data = json.loads(path.read_text("utf-8"))
        assert data["config"]["k"] == "v"

    def test_set_config_nonexistent_raises(self):
        from brynq.plugin_system import set_plugin_config
        with pytest.raises(ValueError, match="not found"):
            set_plugin_config("ghost", "k", "v")

    def test_get_config_nonexistent_returns_default(self):
        from brynq.plugin_system import get_plugin_config
        assert get_plugin_config("ghost", "k", 42) == 42


# ── Test: Search Plugins ──────────────────────────────────────────────────


class TestSearch:
    def test_search_by_name(self):
        from brynq.plugin_system import register_plugin, search_plugins
        register_plugin(**_simple_plugin(name="analytics_plus"))
        register_plugin(**_simple_plugin(name="logger"))
        results = search_plugins("analytics")
        assert len(results) == 1
        assert results[0]["name"] == "analytics_plus"

    def test_search_by_description(self):
        from brynq.plugin_system import register_plugin, search_plugins
        register_plugin(**_simple_plugin(description="Sends Slack notifications"))
        results = search_plugins("slack")
        assert len(results) == 1

    def test_search_by_author(self):
        from brynq.plugin_system import register_plugin, search_plugins
        register_plugin(**_simple_plugin(author="Acme Corp"))
        results = search_plugins("acme")
        assert len(results) == 1

    def test_search_case_insensitive(self):
        from brynq.plugin_system import register_plugin, search_plugins
        register_plugin(**_simple_plugin(name="MyPlugin"))
        results = search_plugins("MYPLUGIN")
        assert len(results) == 1

    def test_search_no_results(self):
        from brynq.plugin_system import register_plugin, search_plugins
        register_plugin(**_simple_plugin())
        assert search_plugins("zzz_nonexistent_zzz") == []


# ── Test: Plugin Stats ────────────────────────────────────────────────────


class TestStats:
    def test_stats_after_registration(self):
        from brynq.plugin_system import register_plugin, get_plugin_stats
        register_plugin(**_simple_plugin())
        stats = get_plugin_stats("test_plugin")
        assert stats["plugin"] == "test_plugin"
        assert stats["hook_invocations"] == 0
        assert stats["tool_calls"] == 0
        # The "registered" event should appear
        events = stats["events"]
        assert any(e["event"] == "registered" for e in events)

    def test_stats_count_hook_invocations(self):
        from brynq.plugin_system import register_plugin, enable_plugin, run_hooks, get_plugin_stats
        register_plugin(**_simple_plugin(), hooks={"pre_message_post": lambda c: c})
        enable_plugin("test_plugin")
        run_hooks("pre_message_post", {"m": 1})
        run_hooks("pre_message_post", {"m": 2})
        stats = get_plugin_stats("test_plugin")
        assert stats["hook_invocations"] == 2

    def test_stats_count_tool_calls(self):
        from brynq.plugin_system import register_plugin, enable_plugin, execute_tool, get_plugin_stats
        register_plugin(
            **_simple_plugin(),
            tools=[{"name": "t", "description": "d", "handler": lambda: "ok"}],
        )
        enable_plugin("test_plugin")
        execute_tool("test_plugin", "t", {})
        stats = get_plugin_stats("test_plugin")
        assert stats["tool_calls"] == 1

    def test_stats_count_errors(self):
        from brynq.plugin_system import register_plugin, enable_plugin, run_hooks, get_plugin_stats
        def bad(ctx):
            raise RuntimeError("fail")
        register_plugin(**_simple_plugin(), hooks={"pre_message_post": bad})
        enable_plugin("test_plugin")
        run_hooks("pre_message_post", {})
        stats = get_plugin_stats("test_plugin")
        assert stats["errors"] == 1

    def test_stats_uptime_when_enabled(self):
        from brynq.plugin_system import register_plugin, enable_plugin, get_plugin_stats
        register_plugin(**_simple_plugin())
        enable_plugin("test_plugin")
        stats = get_plugin_stats("test_plugin")
        assert stats["uptime_seconds"] is not None
        assert stats["uptime_seconds"] >= 0

    def test_stats_no_uptime_when_disabled(self):
        from brynq.plugin_system import register_plugin, get_plugin_stats
        register_plugin(**_simple_plugin())
        stats = get_plugin_stats("test_plugin")
        assert stats["uptime_seconds"] is None

    def test_stats_nonexistent_plugin(self):
        from brynq.plugin_system import get_plugin_stats
        stats = get_plugin_stats("ghost")
        assert "error" in stats
