"""Tests for race condition fixes in sorcar.

Verifies:
1. Base._class_lock protects agent_counter and global_budget_used
2. stop_event set/clear happens inside running_lock (no stop→run race)
3. task_done broadcast happens AFTER running=False (no 409 on immediate re-submit)
4. _history_cache is protected by _history_lock (no lost updates)
5. shutdown_timer is protected by shutdown_lock
"""

import threading
import time

from kiss.agents.sorcar.browser_ui import BaseBrowserPrinter
from kiss.agents.sorcar.task_history import (
    _add_task,
    _load_history,
    _save_history,
    _set_latest_result,
)
from kiss.core.base import Base


class TestBaseClassLock:
    """Verify Base._class_lock protects agent_counter and global_budget_used."""

    def test_class_lock_exists(self):
        assert hasattr(Base, "_class_lock")
        assert isinstance(Base._class_lock, type(threading.Lock()))

    def test_concurrent_agent_counter_with_lock(self):
        """Many threads creating Base instances should get unique IDs."""
        num = 100
        ids: list[int] = []
        lock = threading.Lock()
        barrier = threading.Barrier(num)

        def create():
            barrier.wait()
            b = Base("test")
            with lock:
                ids.append(b.id)

        threads = [threading.Thread(target=create) for _ in range(num)]
        for t in threads:
            t.start()
        for t in threads:
            t.join()
        assert len(set(ids)) == num

    def test_concurrent_budget_updates_with_lock(self):
        """Concurrent budget increments under _class_lock should not lose updates."""
        initial = Base.global_budget_used
        num = 100
        barrier = threading.Barrier(num)

        def update():
            barrier.wait()
            with Base._class_lock:
                Base.global_budget_used += 1.0

        threads = [threading.Thread(target=update) for _ in range(num)]
        for t in threads:
            t.start()
        for t in threads:
            t.join()
        expected = initial + num
        assert abs(Base.global_budget_used - expected) < 1e-9
        # Reset for other tests
        Base.global_budget_used = initial


class TestStopEventRace:
    """Verify stop_event set/clear inside running_lock prevents the stop→run race."""

    def test_stop_event_not_leaked_to_new_task(self):
        """Rapid stop→start should not leave stop_event set for new task."""
        printer = BaseBrowserPrinter()
        running = False
        agent_thread: threading.Thread | None = None
        running_lock = threading.Lock()
        results: list[str] = []

        def agent_work(label: str):
            # Simulate agent checking stop_event
            if printer.stop_event.is_set():
                results.append(f"{label}:stopped_early")
            else:
                results.append(f"{label}:completed")

        def stop_agent():
            nonlocal running, agent_thread
            with running_lock:
                thread = agent_thread
                if thread is None:
                    return False
                running = False
                agent_thread = None
                printer.stop_event.set()  # Inside lock
            return True

        def start_agent(label: str):
            nonlocal running, agent_thread
            t = threading.Thread(target=agent_work, args=(label,), daemon=True)
            with running_lock:
                printer.stop_event.clear()  # Inside lock
                running = True
                agent_thread = t
            t.start()
            return t

        # Start first task
        t1 = start_agent("task1")
        t1.join(timeout=2)

        # Stop (even though done, simulates the pattern)
        printer.stop_event.clear()

        # Now simulate rapid stop→start sequence
        # Start a long-running task
        long_started = threading.Event()
        long_can_check = threading.Event()

        def long_agent():
            long_started.set()
            long_can_check.wait(timeout=5)
            if printer.stop_event.is_set():
                results.append("long:stopped")
            else:
                results.append("long:completed")

        with running_lock:
            printer.stop_event.clear()
            running = True
            t2 = threading.Thread(target=long_agent, daemon=True)
            agent_thread = t2
        t2.start()
        long_started.wait(timeout=2)

        # Stop it
        stop_agent()

        # Immediately start new task
        new_results: list[str] = []

        def new_agent():
            if printer.stop_event.is_set():
                new_results.append("new:stopped_early")
            else:
                new_results.append("new:completed")

        t3 = threading.Thread(target=new_agent, daemon=True)
        with running_lock:
            printer.stop_event.clear()  # Clear inside lock
            running = True
            agent_thread = t3
        t3.start()
        t3.join(timeout=2)

        # The new task should NOT see stop_event
        assert "new:completed" in new_results
        assert "new:stopped_early" not in new_results

        # Let the old thread finish
        long_can_check.set()
        t2.join(timeout=2)


class TestTaskDoneAfterRunningFalse:
    """Verify task_done is broadcast AFTER running=False."""

    def test_no_409_on_immediate_resubmit(self):
        """Client should be able to start a new task immediately after task_done."""
        running = False
        agent_thread: threading.Thread | None = None
        running_lock = threading.Lock()
        events: list[dict] = []
        events_lock = threading.Lock()
        can_check = threading.Event()

        def broadcast(event):
            with events_lock:
                events.append(event)
            if event.get("type") == "task_done":
                can_check.set()

        def run_agent_thread():
            nonlocal running, agent_thread
            current = threading.current_thread()
            done_event = {"type": "task_done"}
            # Simulate the fixed pattern:
            # set running=False THEN broadcast
            with running_lock:
                if agent_thread is not current:
                    return
                running = False
                agent_thread = None
            broadcast(done_event)

        def start_task():
            nonlocal running, agent_thread
            t = threading.Thread(target=run_agent_thread, daemon=True)
            with running_lock:
                if running:
                    return False
                running = True
                agent_thread = t
            t.start()
            return True

        # Start first task
        assert start_task()
        can_check.wait(timeout=5)

        # Immediately try to start another — should succeed, not get 409
        assert start_task()

        # Wait for second to complete
        time.sleep(0.2)
        with running_lock:
            assert not running


class TestHistoryLock:
    """Verify _history_lock protects _history_cache from concurrent corruption."""

    def test_history_lock_exists(self):
        from kiss.agents.sorcar import task_history
        assert hasattr(task_history, "_history_lock")
        assert isinstance(task_history._history_lock, type(threading.Lock()))

    def test_concurrent_add_task_no_lost_updates(self):
        """Many threads adding tasks simultaneously should not lose any."""
        from kiss.agents.sorcar import task_history

        # Save original cache and restore after test
        orig_cache = task_history._history_cache
        orig_file_content = None
        if task_history.HISTORY_FILE.exists():
            orig_file_content = task_history.HISTORY_FILE.read_text()

        try:
            task_history._history_cache = []
            _save_history([])

            num = 20
            barrier = threading.Barrier(num)

            def add(i):
                barrier.wait()
                _add_task(f"concurrent_task_{i}")

            threads = [threading.Thread(target=add, args=(i,)) for i in range(num)]
            for t in threads:
                t.start()
            for t in threads:
                t.join()

            history = _load_history()
            tasks = {e["task"] for e in history}
            for i in range(num):
                assert f"concurrent_task_{i}" in tasks, f"concurrent_task_{i} missing"
        finally:
            task_history._history_cache = orig_cache
            if orig_file_content is not None:
                task_history.HISTORY_FILE.write_text(orig_file_content)

    def test_concurrent_set_result_and_add_task(self):
        """Concurrent _set_latest_result and _add_task should not corrupt state."""
        from kiss.agents.sorcar import task_history

        orig_cache = task_history._history_cache
        orig_file_content = None
        if task_history.HISTORY_FILE.exists():
            orig_file_content = task_history.HISTORY_FILE.read_text()

        try:
            task_history._history_cache = None
            _save_history([{"task": "initial_task", "result": ""}])
            task_history._history_cache = None  # Force reload

            errors: list[Exception] = []
            barrier = threading.Barrier(2)

            def add_tasks():
                barrier.wait()
                for i in range(10):
                    try:
                        _add_task(f"add_task_{i}")
                    except Exception as e:
                        errors.append(e)

            def set_results():
                barrier.wait()
                for i in range(10):
                    try:
                        _set_latest_result(f"result_{i}")
                    except Exception as e:
                        errors.append(e)

            t1 = threading.Thread(target=add_tasks)
            t2 = threading.Thread(target=set_results)
            t1.start()
            t2.start()
            t1.join()
            t2.join()

            assert errors == [], f"Errors during concurrent access: {errors}"
            # History should be valid (no corruption)
            history = _load_history()
            assert isinstance(history, list)
            assert len(history) > 0
        finally:
            task_history._history_cache = orig_cache
            if orig_file_content is not None:
                task_history.HISTORY_FILE.write_text(orig_file_content)


class TestShutdownTimerLock:
    """Verify shutdown_timer access is protected by shutdown_lock."""

    def test_concurrent_schedule_shutdown_no_error(self):
        """Multiple concurrent _schedule_shutdown calls should not raise."""
        shutdown_timer: threading.Timer | None = None
        shutdown_lock = threading.Lock()
        shutdowns_called = []

        def _do_shutdown():
            shutdowns_called.append(1)

        def _schedule_shutdown():
            nonlocal shutdown_timer
            with shutdown_lock:
                if shutdown_timer is not None:
                    shutdown_timer.cancel()
                shutdown_timer = threading.Timer(1.0, _do_shutdown)
                shutdown_timer.daemon = True
                shutdown_timer.start()

        num = 20
        barrier = threading.Barrier(num)
        errors: list[Exception] = []

        def do_schedule():
            barrier.wait()
            try:
                _schedule_shutdown()
            except Exception as e:
                errors.append(e)

        threads = [threading.Thread(target=do_schedule) for _ in range(num)]
        for t in threads:
            t.start()
        for t in threads:
            t.join()

        assert errors == []
        # Clean up timer
        with shutdown_lock:
            if shutdown_timer is not None:
                shutdown_timer.cancel()
