"""
Tests for TX lock serialisation and duty-cycle TOCTOU fix.
Addresses the three concerns raised in PR 190 review:

  1. Concurrent delayed_sends must not interleave send_packet calls.
  2. Duty-cycle TOCTOU must be fixed: the second packet is dropped when the
     first consumes the airtime budget inside the lock.
  3. Local retry must NOT hold _tx_lock during the 1-second backoff sleep —
     other queued packets must be able to transmit during that window.

Run with:
    python -m pytest tests/test_tx_lock.py -v
or:
    python -m unittest tests.test_tx_lock
"""

import asyncio
import time
import unittest
from unittest.mock import AsyncMock, MagicMock


# ---------------------------------------------------------------------------
# Minimal handler factory
# ---------------------------------------------------------------------------


def _make_handler():
    """
    Return a RepeaterHandler instance with all external I/O mocked.

    Uses __new__ + manual attribute injection to bypass StorageCollector,
    radio hardware, and other heavy dependencies that are irrelevant to the
    TX lock behaviour under test.
    """
    from repeater.engine import RepeaterHandler

    radio = MagicMock()
    radio.spreading_factor = 9
    radio.bandwidth = 62500
    radio.coding_rate = 5
    radio.preamble_length = 17
    radio.frequency = 915_000_000
    radio.tx_power = 14

    dispatcher = MagicMock()
    dispatcher.radio = radio
    dispatcher.local_identity = None
    dispatcher.send_packet = AsyncMock()

    h = RepeaterHandler.__new__(RepeaterHandler)
    h.config = {
        "repeater": {"mode": "forward", "cache_ttl": 3600, "send_advert_interval_hours": 0},
        "delays": {"tx_delay_factor": 1.0, "direct_tx_delay_factor": 0.5},
        "duty_cycle": {"enforcement_enabled": True, "max_airtime_per_minute": 3600},
        "storage": {},
        "mesh": {},
    }
    h.dispatcher = dispatcher
    h.airtime_mgr = MagicMock()
    h.airtime_mgr.can_transmit.return_value = (True, 0.0)
    h.airtime_mgr.calculate_airtime.return_value = 100.0
    h._tx_lock = asyncio.Lock()
    h.sent_flood_count = 0
    h.sent_direct_count = 0
    # Stub out _record_packet_sent so it doesn't touch packet.header constants
    h._record_packet_sent = MagicMock()
    return h


def _make_packet(size: int = 50) -> MagicMock:
    pkt = MagicMock()
    pkt.get_raw_length.return_value = size
    pkt.header = 0x00
    return pkt


# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------


class TestTxLockSerialisation(unittest.IsolatedAsyncioTestCase):
    # ── Test 1: no interleaving ─────────────────────────────────────────────

    async def test_concurrent_sends_do_not_interleave(self):
        """
        Two delayed_sends with identical delays race to the radio.
        send_packet must never be called while another call is already
        in-flight — i.e. _tx_lock must gate them sequentially.
        """
        h = _make_handler()
        pkt = _make_packet()

        in_flight = [False]
        overlap_detected = [False]

        async def send_with_overlap_check(*args, **kwargs):
            if in_flight[0]:
                overlap_detected[0] = True
            in_flight[0] = True
            await asyncio.sleep(0.05)  # simulate ~50ms radio TX
            in_flight[0] = False

        h.dispatcher.send_packet.side_effect = send_with_overlap_check

        # Both tasks use the same tiny delay so their timers expire together.
        t1 = await h.schedule_retransmit(pkt, delay=0.01, airtime_ms=0)
        t2 = await h.schedule_retransmit(pkt, delay=0.01, airtime_ms=0)
        await asyncio.gather(t1, t2, return_exceptions=True)

        self.assertFalse(
            overlap_detected[0],
            "send_packet was entered while another call was already in-flight "
            "— _tx_lock is not serialising correctly",
        )
        self.assertEqual(
            h.dispatcher.send_packet.call_count, 2, "Expected exactly 2 send_packet calls"
        )

    # ── Test 2: TOCTOU duty-cycle fix ──────────────────────────────────────

    async def test_duty_cycle_toctou_is_fixed(self):
        """
        When two tasks both pass the advisory can_transmit() check in __call__
        before either has recorded airtime, the authoritative check inside
        _tx_lock must ensure only one of them actually transmits.

        Simulated here by making can_transmit return True for the first
        in-lock check and False for every subsequent one.
        """
        h = _make_handler()
        pkt = _make_packet()
        airtime_ms = 100.0

        # First lock-holder gets True; second gets False (budget consumed).
        allow = [True]

        def can_tx(ms):
            if allow[0]:
                allow[0] = False
                return (True, 0.0)
            return (False, 5.0)

        h.airtime_mgr.can_transmit.side_effect = can_tx

        # Both tasks start simultaneously (delay=0).
        t1 = await h.schedule_retransmit(pkt, delay=0.0, airtime_ms=airtime_ms)
        t2 = await h.schedule_retransmit(pkt, delay=0.0, airtime_ms=airtime_ms)
        await asyncio.gather(t1, t2, return_exceptions=True)

        self.assertEqual(
            h.dispatcher.send_packet.call_count,
            1,
            "Both packets were sent — duty-cycle TOCTOU race was NOT fixed",
        )

    # ── Test 3: retry backoff does not hold the lock ────────────────────────

    async def test_local_retry_releases_lock_during_backoff(self):
        """
        When a local_transmission send fails on the first attempt, the 1-second
        backoff sleep must happen with _tx_lock released.

        We schedule:
          - pkt_local: local_transmission=True, delay=0s, fails on attempt 1
          - pkt_other: local_transmission=False, delay=0.1s

        pkt_other fires at ~0.1s.  Without the fix, the backoff sleep holds
        the lock until ~1.0s, so pkt_other would have to wait.  With the fix,
        pkt_other sends freely at ~0.1s, well before pkt_local retries at ~1.0s.
        """
        h = _make_handler()
        pkt_local = _make_packet()
        pkt_other = _make_packet()

        send_times: dict[int, float] = {}
        first_local_call = [True]

        async def tracked_send(*args, **kwargs):
            pkt = args[0]
            if pkt is pkt_local and first_local_call[0]:
                first_local_call[0] = False
                raise RuntimeError("simulated transient radio failure")
            send_times[id(pkt)] = time.monotonic()

        h.dispatcher.send_packet.side_effect = tracked_send

        t_local = await h.schedule_retransmit(
            pkt_local, delay=0.0, airtime_ms=0, local_transmission=True
        )
        t_other = await h.schedule_retransmit(
            pkt_other, delay=0.1, airtime_ms=0, local_transmission=False
        )
        await asyncio.gather(t_local, t_other, return_exceptions=True)

        self.assertIn(id(pkt_other), send_times, "pkt_other was never sent")
        self.assertIn(id(pkt_local), send_times, "pkt_local retry was never sent")

        # pkt_other fires at ~0.1s; pkt_local retry fires at ~1.0s.
        # If the lock were held during backoff, pkt_other would block until ~1.0s
        # and would be recorded AFTER pkt_local's retry — the assertion below
        # would fail.
        self.assertLess(
            send_times[id(pkt_other)],
            send_times[id(pkt_local)],
            "pkt_other sent AFTER pkt_local retry — "
            "_tx_lock was still held during the 1-second backoff sleep",
        )

    # ── Test 4: non-local single-attempt re-raises on failure ──────────────

    async def test_non_local_failure_propagates(self):
        """A relayed (non-local) packet that fails send_packet raises immediately."""
        h = _make_handler()
        pkt = _make_packet()

        h.dispatcher.send_packet.side_effect = RuntimeError("radio error")

        task = await h.schedule_retransmit(pkt, delay=0.0, airtime_ms=0, local_transmission=False)
        with self.assertRaises(RuntimeError):
            await task

        # Only one attempt should have been made.
        self.assertEqual(h.dispatcher.send_packet.call_count, 1)

    # ── Test 5: duty-cycle check re-runs after backoff ──────────────────────

    async def test_duty_cycle_rechecked_on_retry(self):
        """
        If the duty cycle is exhausted during the 1-second backoff, the retry
        attempt must still be dropped — i.e. the duty-cycle gate runs on every
        lock acquisition, not just the first.
        """
        h = _make_handler()
        pkt = _make_packet()

        # First attempt: send_packet raises → triggers backoff.
        # Between attempts the budget is consumed, so the retry lock sees False.
        transmit_seq = iter([(True, 0.0), (False, 5.0)])
        h.airtime_mgr.can_transmit.side_effect = lambda ms: next(transmit_seq)

        send_calls = [0]

        async def failing_then_gone(*args, **kwargs):
            send_calls[0] += 1
            if send_calls[0] == 1:
                raise RuntimeError("transient failure")
            # Should not reach here on attempt 1 (gate rejects)
            pytest_fail = AssertionError("send_packet called on attempt 1 despite gate rejection")
            raise pytest_fail

        h.dispatcher.send_packet.side_effect = failing_then_gone

        task = await h.schedule_retransmit(
            pkt, delay=0.0, airtime_ms=100.0, local_transmission=True
        )
        await task  # should complete without error (gate returns silently)

        self.assertEqual(
            send_calls[0], 1, "send_packet called on retry despite duty-cycle rejection"
        )


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