"""Provider shutdown joins every plugin thread within one budget (#37632, #33485, #60616)."""

import logging
import threading
import time
from types import SimpleNamespace
from unittest.mock import MagicMock

import pytest

from plugins.memory.honcho import HonchoMemoryProvider
from plugins.memory.honcho import session as session_module
from plugins.memory.honcho.client import (
    HonchoClientConfig,
    close_honcho_clients,
    join_plugin_threads,
    spawn_context_thread,
)
from plugins.memory.honcho.client_cache import _client_slots, _client_slots_lock
from plugins.memory.honcho.session import HonchoSession, HonchoSessionManager
from plugins.plugin_utils import SingletonSlot


@pytest.fixture
def async_manager(monkeypatch):
    fake = MagicMock()
    monkeypatch.setattr(session_module, "get_honcho_client", lambda *a, **k: fake)
    cfg = HonchoClientConfig(write_frequency="async", api_key="test-key", enabled=True)
    mgr = HonchoSessionManager(honcho=fake, config=cfg)
    mgr.fake_client = fake
    yield mgr
    mgr.shutdown()


class _Owner:
    pass


class TestThreadRegistry:
    def test_join_covers_only_the_given_owners(self):
        mine, theirs = _Owner(), _Owner()
        release = threading.Event()
        own = spawn_context_thread(lambda: release.wait(timeout=2), name="own", owner=mine)
        other = spawn_context_thread(lambda: release.wait(timeout=2), name="other", owner=theirs)
        own.start()
        other.start()
        try:
            started = time.monotonic()
            assert join_plugin_threads((theirs, None), timeout=0.05) == ["other"]
            release.set()
            assert join_plugin_threads((mine,), timeout=2) == []
            assert time.monotonic() - started < 2.0
        finally:
            release.set()
            own.join(timeout=1)
            other.join(timeout=1)


class TestProviderShutdown:
    def _provider(self, manager, cfg=None):
        provider = HonchoMemoryProvider()
        provider._manager = manager
        provider._config = cfg or manager._config
        provider._session_key = "test-session"
        provider._session_initialized = True
        return provider

    def _shutdown_blocks_until(self, provider, release):
        """shutdown() off-thread must still be running until ``release`` is set, then finish."""
        done = threading.Event()
        threading.Thread(target=lambda: (provider.shutdown(), done.set()), daemon=True).start()
        try:
            assert not done.wait(timeout=0.05)
            release.set()
            assert done.wait(timeout=2)
        finally:
            release.set()

    def test_shutdown_waits_for_an_in_flight_context_prefetch(self, async_manager):
        provider = self._provider(async_manager)
        started, release = threading.Event(), threading.Event()

        def slow_prefetch(session_key, user_message=None):
            started.set()
            release.wait(timeout=2)
            return {"representation": "ready"}

        async_manager.get_prefetch_context = slow_prefetch
        async_manager.prefetch_context("test-session", "query")
        assert started.wait(timeout=1)
        self._shutdown_blocks_until(provider, release)
        assert not any(t.name == "honcho-context-prefetch" and t.is_alive() for t in threading.enumerate())

    def test_shutdown_joins_a_stalled_init_thread_within_the_budget(self, monkeypatch, caplog):
        monkeypatch.setattr(HonchoMemoryProvider, "_SHUTDOWN_JOIN_FLOOR", 0.2)
        cfg = HonchoClientConfig(api_key="test-key", enabled=True, timeout=0.1)
        release = threading.Event()
        entered = threading.Event()

        class StalledManager:
            def __init__(self, *args, **kwargs):
                pass

            def get_or_create(self, session_key):
                entered.set()
                release.wait(timeout=5)
                return SimpleNamespace(messages=[])

            def migrate_memory_files(self, *a, **k):
                pass

        monkeypatch.setattr("plugins.memory.honcho.client.HonchoClientConfig.from_global_config", lambda: cfg)
        monkeypatch.setattr("plugins.memory.honcho.client.get_honcho_client", lambda cfg: object())
        monkeypatch.setattr("plugins.memory.honcho.session.HonchoSessionManager", StalledManager)
        provider = HonchoMemoryProvider()
        provider.initialize("session-1", platform="cli")
        try:
            assert entered.wait(timeout=1)
            started = time.monotonic()
            with caplog.at_level(logging.WARNING, logger="plugins.memory.honcho"):
                provider.shutdown()
            assert 0.15 <= time.monotonic() - started < 2.0
            assert "honcho-session-init" in caplog.text
            assert "timed out after 0.2s" in caplog.text
        finally:
            release.set()
            provider._init_thread.join(timeout=2)

    def test_shutdown_joins_the_prewarm_dialectic_thread(self, async_manager):
        provider = self._provider(async_manager)
        release = threading.Event()
        async_manager.dialectic_query = lambda *a, **k: release.wait(timeout=2) and "answer"
        provider._spawn_dialectic("who?", thread_name="honcho-prewarm-dialectic", fired_at=0, log_label="prewarm")
        self._shutdown_blocks_until(provider, release)
        assert not provider._prefetch_thread.is_alive()

    def test_recall_sync_worker_is_registered_under_the_provider(self, async_manager):
        from plugins.memory.honcho.recall_sync import prefetch_sync

        provider = self._provider(async_manager, cfg=HonchoClientConfig(api_key="test-key", enabled=True, timeout=0.05))
        entered, release = threading.Event(), threading.Event()

        def blocked(session_key, query, **kwargs):
            entered.set()
            release.wait(timeout=2)
            return {}

        async_manager.get_prefetch_context = blocked
        provider.on_turn_start(1, "what did we decide about the schema?")
        try:
            assert prefetch_sync(provider, "what did we decide about the schema?") == ""
            assert entered.wait(timeout=1)
            assert join_plugin_threads((provider,), timeout=0.01) == ["honcho-recall-sync"]
            release.set()
            assert join_plugin_threads((provider,), timeout=2) == []
        finally:
            release.set()
            provider._recall_sync_thread.join(timeout=2)

    def test_shutdown_skips_a_flush_whose_lock_an_upload_holds_past_the_budget(self, async_manager, monkeypatch, caplog):
        provider = self._provider(async_manager)
        monkeypatch.setattr(provider, "_shutdown_join_budget", lambda: 0.2)
        session = HonchoSession("test-session", "user", "assistant", "sid")
        session.add_message("user", "pending")
        async_manager._cache["test-session"] = session
        entered, release = threading.Event(), threading.Event()

        def upload(messages):
            entered.set()
            release.wait(timeout=5)

        async_manager._sessions_cache["sid"] = SimpleNamespace(add_messages=upload)
        async_manager.save(session)
        try:
            assert entered.wait(timeout=1)
            started = time.monotonic()
            with caplog.at_level(logging.WARNING, logger="plugins.memory.honcho"):
                provider.shutdown()
            assert time.monotonic() - started < 2.0
            assert "1 message(s) in 1 session(s) still unsynced" in caplog.text
            assert session.messages[0].get("_synced") is None
        finally:
            release.set()

    def test_shutdown_does_not_close_the_shared_client(self, async_manager):
        provider = self._provider(async_manager)
        provider.shutdown()
        async_manager.fake_client._http.close.assert_not_called()


@pytest.mark.parametrize("timeout, budget", [(60.0, 60.0), (2.0, 5.0), (None, 30.0)])
def test_shutdown_join_budget_is_the_floor_or_the_longer_timeout(timeout, budget):
    """Unset timeout resolves to the client's 30s default, so the join waits as long as a blocked call can."""
    provider = HonchoMemoryProvider()
    provider._config = HonchoClientConfig(api_key="k", timeout=timeout)
    assert provider._shutdown_join_budget() == budget


class TestManagerAfterShutdown:
    def test_prefetch_is_skipped_after_shutdown(self, async_manager):
        calls = []
        async_manager.get_prefetch_context = lambda *a, **k: calls.append(a) or {}
        async_manager.shutdown()
        async_manager.prefetch_context("test-session", "query")
        time.sleep(0.05)
        assert calls == []


def test_close_honcho_clients_closes_every_pool_and_drops_the_slots():
    client = MagicMock()
    slot = SingletonSlot()
    slot.get(lambda: client)
    key = ("test", "close-me")
    with _client_slots_lock:
        _client_slots[key] = slot
    try:
        close_honcho_clients()
    finally:
        with _client_slots_lock:
            _client_slots.pop(key, None)
    client._http.close.assert_called_once_with()
    assert key not in _client_slots
