"""
Tests for CentralDispatch, SerialDispatchQueue, and ConcurrentDispatchQueue.

Covers:
- Serial ordering and mutual exclusion guarantees
- Shared-state safety (the property that makes serial queues useful)
- Exception propagation through futures
- Parallel execution in concurrent queues
- Race condition demonstration under concurrency
- CentralDispatch.future(), timer(), concat()
"""

import threading
import time
import pytest

from pyos.CentralDispatch import CentralDispatch, SerialDispatchQueue, ConcurrentDispatchQueue


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

@pytest.fixture
def sq():
    q = CentralDispatch.create_serial_queue()
    yield q
    q.task_threadpool.shutdown(wait=False)


@pytest.fixture
def cq():
    q = CentralDispatch.create_concurrent_queue(8)
    yield q
    q.task_threadpool.shutdown(wait=False)


# ---------------------------------------------------------------------------
# SerialDispatchQueue — ordering
# ---------------------------------------------------------------------------

class TestSerialOrdering:
    def test_tasks_run_in_submission_order(self, sq):
        results = []
        futures = [sq.submit_async(results.append, i) for i in range(50)]
        for f in futures:
            f.result()
        assert results == list(range(50))

    def test_high_volume_ordering_under_stress(self, sq):
        """1000 rapid submissions must still arrive in order."""
        results = []
        futures = [sq.submit_async(results.append, i) for i in range(1000)]
        for f in futures:
            f.result()
        assert results == list(range(1000))

    def test_submit_async_future_carries_return_value(self, sq):
        future = sq.submit_async(lambda: 42)
        assert future.result() == 42

    def test_submit_async_passes_args(self, sq):
        future = sq.submit_async(lambda x, y: x + y, 10, 32)
        assert future.result() == 42

    def test_await_result_blocks_until_done(self, sq):
        completed = threading.Event()

        def slow():
            time.sleep(0.05)
            completed.set()
            return 7

        result = sq.await_result(slow)
        assert completed.is_set(), "await_result returned before task finished"
        assert result == 7


# ---------------------------------------------------------------------------
# SerialDispatchQueue — mutual exclusion (the core race-condition guarantee)
# ---------------------------------------------------------------------------

class TestSerialMutualExclusion:
    def test_no_two_tasks_run_simultaneously(self, sq):
        """
        Use an 'active' Event as a mutex probe. If any two tasks run at the
        same time the second one will find the event already set.
        """
        active = threading.Event()
        overlap_detected = threading.Event()

        def task():
            if active.is_set():
                overlap_detected.set()
            active.set()
            time.sleep(0.005)   # hold long enough for a concurrent task to arrive
            active.clear()

        futures = [sq.submit_async(task) for _ in range(30)]
        for f in futures:
            f.result()

        assert not overlap_detected.is_set(), "Two serial tasks ran concurrently"

    def test_running_thread_id_is_always_the_same(self, sq):
        """
        A single-thread executor must always use the same OS thread.
        Tasks in a serial queue should all see the same thread ident.
        """
        ids = []
        lock = threading.Lock()

        def record_id():
            with lock:
                ids.append(threading.current_thread().ident)

        futures = [sq.submit_async(record_id) for _ in range(20)]
        for f in futures:
            f.result()

        assert len(set(ids)) == 1, "Serial queue used more than one thread"

    def test_shared_counter_without_lock_is_safe(self, sq):
        """
        The serial guarantee means a counter can be safely mutated without
        an explicit lock. Each task reads, yields, then writes — on a
        concurrent queue this would race; on a serial queue it cannot.
        """
        counter = [0]

        def increment():
            val = counter[0]
            time.sleep(0)        # explicit scheduler yield point
            counter[0] = val + 1

        n = 300
        futures = [sq.submit_async(increment) for _ in range(n)]
        for f in futures:
            f.result()

        assert counter[0] == n

    def test_shared_list_append_order_matches_submission(self, sq):
        """Append-then-read on a shared list without a lock must be safe."""
        log = []

        def step(i):
            log.append(i)
            _ = log[-1]        # read immediately after write

        futures = [sq.submit_async(step, i) for _ in range(1) for i in range(100)]
        for f in futures:
            f.result()

        assert log == list(range(100))


# ---------------------------------------------------------------------------
# SerialDispatchQueue — exception handling
# ---------------------------------------------------------------------------

class TestSerialExceptions:
    def test_exception_propagates_through_future(self, sq):
        def bad():
            raise ValueError("deliberate error")

        future = sq.submit_async(bad)
        with pytest.raises(ValueError, match="deliberate error"):
            future.result()

    def test_subsequent_tasks_run_after_exception(self, sq):
        """An exception in one task must not poison the queue."""
        results = []

        def bad():
            raise RuntimeError("boom")

        sq.submit_async(bad)
        future = sq.submit_async(results.append, "after")
        future.result()

        assert results == ["after"]

    def test_multiple_exceptions_each_independent(self, sq):
        """Each failing task's future independently carries its exception."""
        def fail(msg):
            raise ValueError(msg)

        f1 = sq.submit_async(fail, "first")
        f2 = sq.submit_async(fail, "second")
        f3 = sq.submit_async(lambda: "ok")

        with pytest.raises(ValueError, match="first"):
            f1.result()
        with pytest.raises(ValueError, match="second"):
            f2.result()
        assert f3.result() == "ok"


# ---------------------------------------------------------------------------
# SerialDispatchQueue — finish_work
# ---------------------------------------------------------------------------

class TestFinishWork:
    def test_finish_work_waits_for_all_pending_tasks(self, sq):
        results = []
        for i in range(20):
            sq.submit_async(results.append, i)

        sq.finish_work().result()
        assert len(results) == 20

    def test_finish_work_after_slow_tasks(self, sq):
        completed = threading.Event()

        def slow():
            time.sleep(0.05)
            completed.set()

        sq.submit_async(slow)
        sq.finish_work().result()
        assert completed.is_set()


# ---------------------------------------------------------------------------
# ConcurrentDispatchQueue — parallelism
# ---------------------------------------------------------------------------

class TestConcurrentQueue:
    def test_all_tasks_run(self, cq):
        results = []
        lock = threading.Lock()

        def record(val):
            with lock:
                results.append(val)

        futures = [cq.submit_async(record, i) for i in range(20)]
        for f in futures:
            f.result()

        assert sorted(results) == list(range(20))

    def test_tasks_actually_run_in_parallel(self):
        """
        Use a Barrier so that N tasks must all be running simultaneously
        before any can proceed. On a serial queue this would deadlock.
        """
        n = 4
        q = CentralDispatch.create_concurrent_queue(n)
        barrier = threading.Barrier(n, timeout=3.0)
        reached = []
        lock = threading.Lock()

        def rendezvous(i):
            barrier.wait()          # blocks until all n tasks are here
            with lock:
                reached.append(i)

        futures = [q.submit_async(rendezvous, i) for i in range(n)]
        for f in futures:
            f.result()              # would hang forever if not truly parallel

        assert sorted(reached) == list(range(n))
        q.task_threadpool.shutdown(wait=False)

    def test_unprotected_counter_races_under_concurrency(self):
        """
        Demonstrate that unsynchronized mutation IS racy on a concurrent queue.
        We submit many increment tasks with a yield point inside each one.
        We don't assert a specific wrong value (that would be flaky), but we
        verify the framework does not crash and the counter is plausibly wrong.
        This is a contrast test — the same pattern is safe on a serial queue.
        """
        q = CentralDispatch.create_concurrent_queue(8)
        counter = [0]

        def increment():
            val = counter[0]
            time.sleep(0)           # yield — maximise chance of interleaving
            counter[0] = val + 1

        n = 500
        futures = [q.submit_async(increment) for _ in range(n)]
        for f in futures:
            f.result()

        # The queue itself must not crash or deadlock.
        # We deliberately do NOT assert counter[0] == n here.
        assert 0 < counter[0] <= n
        q.task_threadpool.shutdown(wait=False)

    def test_exception_propagates_in_concurrent_queue(self, cq):
        def bad():
            raise ValueError("concurrent error")

        future = cq.submit_async(bad)
        with pytest.raises(ValueError, match="concurrent error"):
            future.result()


# ---------------------------------------------------------------------------
# CentralDispatch.future()
# ---------------------------------------------------------------------------

class TestCentralDispatchFuture:
    def test_returns_scalar_result(self):
        f = CentralDispatch.future(lambda: 99)
        assert f.result() == 99

    def test_passes_positional_args(self):
        f = CentralDispatch.future(lambda x, y: x * y, 6, 7)
        assert f.result() == 42

    def test_exception_propagates(self):
        def bad():
            raise TypeError("future error")

        f = CentralDispatch.future(bad)
        with pytest.raises(TypeError, match="future error"):
            f.result()

    def test_multiple_futures_are_independent(self):
        """Each call to CentralDispatch.future() runs in its own queue."""
        results = []
        lock = threading.Lock()

        def record(val):
            with lock:
                results.append(val)

        futures = [CentralDispatch.future(record, i) for i in range(10)]
        for f in futures:
            f.result()

        assert sorted(results) == list(range(10))

    def test_future_result_is_available_after_done(self):
        f = CentralDispatch.future(lambda: "hello")
        f.result()          # ensure it finishes
        assert f.done()


# ---------------------------------------------------------------------------
# CentralDispatch.timer()
# ---------------------------------------------------------------------------

class TestCentralDispatchTimer:
    def test_timer_fires_after_delay(self):
        fired = threading.Event()
        timer = CentralDispatch.timer(0.05, fired.set)
        timer.start()
        assert fired.wait(timeout=2.0), "Timer did not fire"

    def test_timer_passes_args_to_callback(self):
        received = []
        lock = threading.Lock()

        def capture(val):
            with lock:
                received.append(val)

        timer = CentralDispatch.timer(0.05, capture, 123)
        timer.start()
        timer.join(timeout=2.0)
        assert received == [123]

    def test_timer_is_daemon(self):
        timer = CentralDispatch.timer(60.0, lambda: None)
        assert timer.daemon is True

    def test_timer_does_not_fire_if_cancelled(self):
        fired = threading.Event()
        timer = CentralDispatch.timer(0.3, fired.set)
        timer.start()
        timer.cancel()
        fired.wait(timeout=0.6)
        assert not fired.is_set(), "Timer fired after cancel()"


# ---------------------------------------------------------------------------
# CentralDispatch.concat()
# ---------------------------------------------------------------------------

class TestConcat:
    def test_concat_waits_for_all_futures(self):
        results = []
        lock = threading.Lock()

        barrier = threading.Barrier(2, timeout=3.0)

        def slow(val):
            barrier.wait()
            with lock:
                results.append(val)

        f1 = CentralDispatch.future(slow, "a")
        f2 = CentralDispatch.future(slow, "b")

        CentralDispatch.concat(f1, f2).result()

        assert sorted(results) == ["a", "b"]

    def test_concat_result_future_is_done_when_resolved(self):
        f1 = CentralDispatch.future(lambda: 1)
        f2 = CentralDispatch.future(lambda: 2)
        concat_future = CentralDispatch.concat(f1, f2)
        concat_future.result()
        assert concat_future.done()

    def test_concat_propagates_exception_from_constituent_future(self):
        def bad():
            raise ValueError("concat failure")

        f1 = CentralDispatch.future(bad)
        f2 = CentralDispatch.future(lambda: "fine")

        concat_future = CentralDispatch.concat(f1, f2)
        with pytest.raises(ValueError, match="concat failure"):
            concat_future.result()

    def test_concat_single_future(self):
        f = CentralDispatch.future(lambda: 7)
        result_future = CentralDispatch.concat(f)
        result_future.result()
        assert f.result() == 7


# ---------------------------------------------------------------------------
# Stress / sustained load
# ---------------------------------------------------------------------------

class TestStress:
    def test_serial_queue_sustained_load(self, sq):
        """500 tasks each doing a small amount of work, all results correct."""
        results = []
        futures = [sq.submit_async(results.append, i) for i in range(500)]
        for f in futures:
            f.result()
        assert results == list(range(500))

    def test_interleaved_submit_and_await(self, sq):
        """Submitting and awaiting results in alternating fashion stays consistent."""
        total = [0]

        for i in range(100):
            sq.await_result(lambda i=i: total.__setitem__(0, total[0] + 1))

        assert total[0] == 100

    def test_serial_queue_no_deadlock_under_rapid_exception_sequence(self, sq):
        """Rapid sequence of failing tasks must not deadlock the queue."""
        def bad():
            raise RuntimeError("stress exception")

        failing_futures = [sq.submit_async(bad) for _ in range(50)]

        # Drain exceptions
        for f in failing_futures:
            try:
                f.result()
            except RuntimeError:
                pass

        # Queue must still be usable
        future = sq.submit_async(lambda: "alive")
        assert future.result() == "alive"
