"""Unit tests for HandoffQueue, in isolation (no gRPC / asyncio).

Exercises the cancellation-aware out-of-band semantics directly: FIFO order,
bounded backpressure that unblocks on a drain, stop()/producer_done() behavior,
and -- the whole point -- that stop()/producer_done() wake a BLOCKED put/drain
promptly (asserted via a worker thread + a short join, not a timeout) with no
lost wakeups.
"""
import threading
import time
import unittest

from preseem.handoff_queue import HandoffQueue


class TestHandoffQueue(unittest.TestCase):

    def test_rejects_nonpositive_maxsize(self):
        # maxsize < 1 would deadlock put(), so it is rejected up front.
        self.assertRaises(ValueError, HandoffQueue, 0)
        self.assertRaises(ValueError, HandoffQueue, -1)

    def test_rejects_nonpositive_drain_max(self):
        q = HandoffQueue(10)
        q.put("a")
        self.assertRaises(ValueError, q.drain, 0)

    def test_fifo_order(self):
        q = HandoffQueue(10)
        for i in range(5):
            self.assertTrue(q.put(i))
        batch, done = q.drain(10)
        self.assertEqual(batch, [0, 1, 2, 3, 4])
        self.assertFalse(done)  # not stopped, producer not done

    def test_drain_caps_at_max_items(self):
        q = HandoffQueue(10)
        for i in range(10):
            q.put(i)
        batch, done = q.drain(4)
        self.assertEqual(batch, [0, 1, 2, 3])
        self.assertFalse(done)
        batch, _ = q.drain(100)
        self.assertEqual(batch, [4, 5, 6, 7, 8, 9])

    def test_put_returns_false_after_stop(self):
        q = HandoffQueue(10)
        q.stop()
        self.assertFalse(q.put("x"))
        # Nothing was enqueued.
        batch, done = q.drain(10)
        self.assertEqual(batch, [])
        self.assertTrue(done)

    def test_drain_done_when_stopped(self):
        q = HandoffQueue(10)
        q.put("a")
        q.stop()
        # Stopped -> done is True even though an item remains; the item is still
        # returned so the consumer can finish what was handed over.
        batch, done = q.drain(10)
        self.assertEqual(batch, ["a"])
        self.assertTrue(done)

    def test_drain_done_when_producer_done_and_empty(self):
        q = HandoffQueue(10)
        q.put("a")
        q.producer_done()
        # Items remain -> NOT done yet; the backlog must be applied first.
        batch, done = q.drain(10)
        self.assertEqual(batch, ["a"])
        self.assertTrue(done)  # producer done AND queue now empty

    def test_drain_not_done_with_items_remaining(self):
        q = HandoffQueue(10)
        for i in range(5):
            q.put(i)
        q.producer_done()
        batch, done = q.drain(2)  # leaves 3 behind
        self.assertEqual(batch, [0, 1])
        self.assertFalse(done)  # producer done but items remain

    def test_put_blocks_when_full_then_unblocks_on_drain(self):
        q = HandoffQueue(2)
        self.assertTrue(q.put(1))
        self.assertTrue(q.put(2))  # now full
        put_returned = threading.Event()

        def _blocked_put():
            q.put(3)  # blocks until the drain frees a slot
            put_returned.set()

        t = threading.Thread(target=_blocked_put)
        t.start()
        # Give the putter a moment to actually park on the full queue.
        self.assertFalse(put_returned.wait(timeout=0.2),
                         "put did not block on a full queue")
        # Draining frees space -> the blocked put completes promptly.
        batch, _ = q.drain(2)
        self.assertEqual(batch, [1, 2])
        self.assertTrue(put_returned.wait(timeout=1.0),
                        "put not unblocked promptly after drain freed space")
        t.join(timeout=1.0)
        self.assertFalse(t.is_alive())

    def test_stop_wakes_a_blocked_put(self):
        q = HandoffQueue(1)
        q.put(1)  # full
        result = {}

        def _blocked_put():
            result["ok"] = q.put(2)  # blocks until stop()

        t = threading.Thread(target=_blocked_put)
        t.start()
        time.sleep(0.1)  # let it park
        t0 = time.time()
        q.stop()
        t.join(timeout=1.0)
        self.assertFalse(t.is_alive(), "blocked put not woken by stop()")
        self.assertLess(time.time() - t0, 0.5)  # prompt, not a timeout
        self.assertFalse(result["ok"])  # stopped -> put returns False

    def test_stop_wakes_a_blocked_drain(self):
        q = HandoffQueue(10)  # empty
        result = {}

        def _blocked_drain():
            result["ret"] = q.drain(10)  # blocks until stop()

        t = threading.Thread(target=_blocked_drain)
        t.start()
        time.sleep(0.1)
        t0 = time.time()
        q.stop()
        t.join(timeout=1.0)
        self.assertFalse(t.is_alive(), "blocked drain not woken by stop()")
        self.assertLess(time.time() - t0, 0.5)
        self.assertEqual(result["ret"], ([], True))

    def test_producer_done_wakes_a_blocked_drain(self):
        q = HandoffQueue(10)  # empty
        result = {}

        def _blocked_drain():
            result["ret"] = q.drain(10)

        t = threading.Thread(target=_blocked_drain)
        t.start()
        time.sleep(0.1)
        t0 = time.time()
        q.producer_done()
        t.join(timeout=1.0)
        self.assertFalse(t.is_alive(), "blocked drain not woken by producer_done()")
        self.assertLess(time.time() - t0, 0.5)
        self.assertEqual(result["ret"], ([], True))  # done, empty

    def test_put_wakes_a_blocked_drain(self):
        q = HandoffQueue(10)  # empty
        result = {}

        def _blocked_drain():
            result["ret"] = q.drain(10)

        t = threading.Thread(target=_blocked_drain)
        t.start()
        time.sleep(0.1)
        t0 = time.time()
        q.put("x")
        t.join(timeout=1.0)
        self.assertFalse(t.is_alive(), "blocked drain not woken by put()")
        self.assertLess(time.time() - t0, 0.5)
        self.assertEqual(result["ret"], (["x"], False))

    def test_no_lost_wakeup_put_just_before_wait(self):
        """An item put before a drain ever blocks must still be returned (the
        predicate is re-checked under the lock, not lost)."""
        q = HandoffQueue(10)
        q.put("early")
        # Drain does not block because the predicate (items present) is already
        # true; no wait, no lost wakeup.
        batch, done = q.drain(10)
        self.assertEqual(batch, ["early"])
        self.assertFalse(done)

    def test_concurrent_producer_consumer_delivers_all_in_order(self):
        q = HandoffQueue(8)  # small, so the producer hits backpressure
        n = 5000
        received = []

        def _consumer():
            while True:
                batch, done = q.drain(64)
                received.extend(batch)
                if done:
                    return

        c = threading.Thread(target=_consumer)
        c.start()
        for i in range(n):
            self.assertTrue(q.put(i))
        q.producer_done()
        c.join(timeout=10.0)
        self.assertFalse(c.is_alive())
        self.assertEqual(received, list(range(n)))  # all, in FIFO order


if __name__ == "__main__":
    unittest.main()
