# tests/test_trap.py
import asyncio
from unittest.mock import AsyncMock, MagicMock, patch

import pytest

from snmpv3_utils.core.trap import listen, send_trap, stress_trap


@pytest.fixture
def usm(usm_no_auth):
    return usm_no_auth


class TestSendTrap:
    @patch("snmpv3_utils.core.trap.sendNotification")
    def test_send_trap_returns_ok(self, mock_send, usm):
        mock_send.return_value = iter([(None, None, None, [])])
        result = send_trap("192.168.1.1", usm, inform=False)
        assert result.get("status") == "ok"
        assert set(result.keys()) == {"status", "host", "type", "inform"}

    @patch("snmpv3_utils.core.trap.sendNotification")
    def test_send_inform_returns_ok(self, mock_send, usm):
        mock_send.return_value = iter([(None, None, None, [])])
        result = send_trap("192.168.1.1", usm, inform=True)
        assert result.get("status") == "ok"
        assert result.get("inform") is True
        assert set(result.keys()) == {"status", "host", "type", "inform"}

    @patch("snmpv3_utils.core.trap.sendNotification")
    def test_send_trap_returns_error_on_failure(self, mock_send, usm):
        mock_send.return_value = iter([("RequestTimedOut", None, None, [])])
        result = send_trap("192.168.1.1", usm, inform=False)
        assert "error" in result
        assert set(result.keys()) == {"error", "host", "type", "inform"}

    @patch("snmpv3_utils.core.trap.sendNotification")
    def test_send_trap_returns_error_on_no_response(self, mock_send, usm):
        mock_send.return_value = iter([])
        result = send_trap("192.168.1.1", usm, inform=False)
        assert "error" in result
        assert result.get("type") == "trap"
        assert result.get("inform") is False
        assert set(result.keys()) == {"error", "host", "type", "inform"}


class TestSendOne:
    @pytest.mark.asyncio
    @patch("snmpv3_utils.core.trap._async_send_notification", new_callable=AsyncMock)
    async def test_send_one_returns_none_on_success(self, mock_send):
        from snmpv3_utils.core.trap import _send_one

        mock_send.return_value = (None, None, None, [])
        sem = asyncio.Semaphore(10)
        result = await _send_one(
            engine=object(),
            usm=object(),
            transport=object(),
            oid="1.3.6.1.6.3.1.1.5.1",
            inform=False,
            sem=sem,
        )
        assert result is None
        mock_send.assert_called_once()

    @pytest.mark.asyncio
    @patch("snmpv3_utils.core.trap._async_send_notification", new_callable=AsyncMock)
    async def test_send_one_returns_error_indication(self, mock_send):
        from snmpv3_utils.core.trap import _send_one

        mock_send.return_value = ("RequestTimedOut", None, None, [])
        sem = asyncio.Semaphore(10)
        result = await _send_one(
            engine=object(),
            usm=object(),
            transport=object(),
            oid="1.3.6.1.6.3.1.1.5.1",
            inform=False,
            sem=sem,
        )
        assert result == "RequestTimedOut"

    @pytest.mark.asyncio
    @patch("snmpv3_utils.core.trap._async_send_notification", new_callable=AsyncMock)
    async def test_send_one_returns_error_status(self, mock_send):
        from snmpv3_utils.core.trap import _send_one

        mock_send.return_value = (None, "genErr", None, [])
        sem = asyncio.Semaphore(10)
        result = await _send_one(
            engine=object(),
            usm=object(),
            transport=object(),
            oid="1.3.6.1.6.3.1.1.5.1",
            inform=False,
            sem=sem,
        )
        assert result == "genErr"

    @pytest.mark.asyncio
    @patch("snmpv3_utils.core.trap._async_send_notification", new_callable=AsyncMock)
    async def test_send_one_passes_inform_type(self, mock_send):
        from snmpv3_utils.core.trap import _send_one

        mock_send.return_value = (None, None, None, [])
        sem = asyncio.Semaphore(10)
        await _send_one(
            engine=object(),
            usm=object(),
            transport=object(),
            oid="1.3.6.1.6.3.1.1.5.1",
            inform=True,
            sem=sem,
        )
        call_args = mock_send.call_args
        assert call_args[0][4] == "inform"


class TestStressTrap:
    @patch("snmpv3_utils.core.trap.UdpTransportTarget", autospec=True)
    @patch("snmpv3_utils.core.trap._async_send_notification", new_callable=AsyncMock)
    def test_count_mode_sends_correct_number(self, mock_send, mock_transport):
        mock_send.return_value = (None, None, None, [])
        mock_transport.create = AsyncMock(return_value=MagicMock())
        result = stress_trap("192.168.1.1", usm=MagicMock(), count=10, rate=0)
        assert result["sent"] == 10
        assert result["errors"] == 0
        assert result["host"] == "192.168.1.1"
        assert mock_send.call_count == 10

    @patch("snmpv3_utils.core.trap.UdpTransportTarget", autospec=True)
    @patch("snmpv3_utils.core.trap._async_send_notification", new_callable=AsyncMock)
    def test_error_counting(self, mock_send, mock_transport):
        side_effects = [(None, None, None, [])] * 7 + [("timeout", None, None, [])] * 3
        mock_send.side_effect = side_effects
        mock_transport.create = AsyncMock(return_value=MagicMock())
        result = stress_trap("192.168.1.1", usm=MagicMock(), count=10, rate=0)
        assert result["sent"] == 10
        assert result["errors"] == 3

    @patch("snmpv3_utils.core.trap.UdpTransportTarget", autospec=True)
    @patch("snmpv3_utils.core.trap._async_send_notification", new_callable=AsyncMock)
    def test_success_rate_calculation(self, mock_send, mock_transport):
        side_effects = [(None, None, None, [])] * 8 + [("err", None, None, [])] * 2
        mock_send.side_effect = side_effects
        mock_transport.create = AsyncMock(return_value=MagicMock())
        result = stress_trap("192.168.1.1", usm=MagicMock(), count=10, rate=0)
        assert result["success_rate"] == "80.0%"

    @patch("snmpv3_utils.core.trap.UdpTransportTarget", autospec=True)
    @patch("snmpv3_utils.core.trap._async_send_notification", new_callable=AsyncMock)
    def test_error_samples_dedup_keeps_first_five(self, mock_send, mock_transport):
        side_effects = [(f"error_{i}", None, None, []) for i in range(8)]
        mock_send.side_effect = side_effects
        mock_transport.create = AsyncMock(return_value=MagicMock())
        result = stress_trap("192.168.1.1", usm=MagicMock(), count=8, rate=0)
        assert len(result["error_samples"]) == 5
        assert result["error_samples"] == ["error_0", "error_1", "error_2", "error_3", "error_4"]

    @patch("snmpv3_utils.core.trap.UdpTransportTarget", autospec=True)
    @patch("snmpv3_utils.core.trap._async_send_notification", new_callable=AsyncMock)
    def test_zero_count_no_division_error(self, mock_send, mock_transport):
        mock_transport.create = AsyncMock(return_value=MagicMock())
        result = stress_trap("192.168.1.1", usm=MagicMock(), count=0, rate=0)
        assert result["sent"] == 0
        assert result["success_rate"] == "N/A"
        assert result["rate_achieved"] == 0.0
        mock_send.assert_not_called()

    @patch("snmpv3_utils.core.trap.UdpTransportTarget", autospec=True)
    @patch("snmpv3_utils.core.trap._async_send_notification", new_callable=AsyncMock)
    def test_result_shape(self, mock_send, mock_transport):
        mock_send.return_value = (None, None, None, [])
        mock_transport.create = AsyncMock(return_value=MagicMock())
        result = stress_trap("192.168.1.1", usm=MagicMock(), count=1, rate=0)
        expected_keys = {
            "host",
            "sent",
            "errors",
            "success_rate",
            "duration_s",
            "rate_achieved",
            "error_samples",
        }
        assert set(result.keys()) == expected_keys

    @patch("snmpv3_utils.core.trap.UdpTransportTarget", autospec=True)
    @patch("snmpv3_utils.core.trap._async_send_notification", new_callable=AsyncMock)
    def test_on_progress_callback_called(self, mock_send, mock_transport):
        mock_send.return_value = (None, None, None, [])
        mock_transport.create = AsyncMock(return_value=MagicMock())
        progress_calls = []
        stress_trap(
            "192.168.1.1",
            usm=MagicMock(),
            count=5,
            rate=0,
            on_progress=lambda dispatched, total: progress_calls.append((dispatched, total)),
        )
        assert len(progress_calls) == 5
        assert progress_calls[0] == (1, 5)
        assert progress_calls[-1] == (5, 5)

    @patch("snmpv3_utils.core.trap.UdpTransportTarget", autospec=True)
    @patch("snmpv3_utils.core.trap._async_send_notification", new_callable=AsyncMock)
    def test_concurrency_limits_inflight(self, mock_send, mock_transport):
        """Verify semaphore limits concurrent in-flight sends."""
        max_concurrent_seen = 0
        current_concurrent = 0
        lock = asyncio.Lock()

        original_return = (None, None, None, [])

        async def _slow_send(*args, **kwargs):
            nonlocal max_concurrent_seen, current_concurrent
            async with lock:
                current_concurrent += 1
                if current_concurrent > max_concurrent_seen:
                    max_concurrent_seen = current_concurrent
            await asyncio.sleep(0.05)
            async with lock:
                current_concurrent -= 1
            return original_return

        mock_send.side_effect = _slow_send
        mock_transport.create = AsyncMock(return_value=MagicMock())
        result = stress_trap("192.168.1.1", usm=MagicMock(), count=10, rate=0, concurrency=3)
        assert result["sent"] == 10
        assert max_concurrent_seen <= 3

    @patch("snmpv3_utils.core.trap.time")
    @patch("snmpv3_utils.core.trap.UdpTransportTarget", autospec=True)
    @patch("snmpv3_utils.core.trap._async_send_notification", new_callable=AsyncMock)
    def test_duration_mode_stops_after_elapsed(self, mock_send, mock_transport, mock_time):
        mock_send.return_value = (None, None, None, [])
        mock_transport.create = AsyncMock(return_value=MagicMock())
        # Calls: start=0.0, loop check=0.0 (dispatch 1), loop check=0.5 (dispatch 2),
        # loop check=1.5 (stop), elapsed=1.5
        mock_time.monotonic.side_effect = [0.0, 0.0, 0.5, 1.5, 1.5]
        result = stress_trap("192.168.1.1", usm=MagicMock(), count=1000, duration=1, rate=0)
        assert result["sent"] == 2


class TestListen:
    def test_listen_raises_value_error_on_empty_users(self):
        with pytest.raises(ValueError, match="users list must not be empty"):
            listen(16299, [], on_trap=lambda r: None)

    @patch("snmpv3_utils.core.trap.snmp_config.add_v3_user")
    @patch("snmpv3_utils.core.trap.snmp_config.add_transport")
    @patch("snmpv3_utils.core.trap.udp_transport.UdpAsyncioTransport")
    @patch("snmpv3_utils.core.trap.ntfrcv.NotificationReceiver")
    @patch("snmpv3_utils.core.trap.SnmpEngine")
    def test_listen_calls_on_trap_with_correct_shape(
        self, mock_engine_cls, mock_ntfrcv, mock_udp_transport, mock_add_transport, mock_add_v3_user
    ):
        captured = {}

        def capture_ntfrcv(engine, cb):
            captured["cb"] = cb
            return MagicMock()

        mock_ntfrcv.side_effect = capture_ntfrcv

        def fake_run_dispatcher():
            mock_oid = MagicMock()
            mock_oid.__str__ = lambda self: "1.3.6.1.2.1.1.3.0"
            mock_val = MagicMock()
            mock_val.__str__ = lambda self: "12345"
            captured["cb"](MagicMock(), "ref1", b"", b"", [(mock_oid, mock_val)], None)

        mock_engine_cls.return_value.transport_dispatcher.run_dispatcher.side_effect = (
            fake_run_dispatcher
        )

        received = []
        usm = MagicMock()
        listen(16299, [usm], on_trap=received.append)

        assert len(received) == 1
        record = received[0]
        assert "host" in record
        assert "timestamp" in record
        assert "varbinds" in record
        assert isinstance(record["varbinds"], list)
        assert len(record["varbinds"]) == 1
        assert record["varbinds"][0]["oid"] == "1.3.6.1.2.1.1.3.0"
        assert record["varbinds"][0]["value"] == "12345"

    @patch("snmpv3_utils.core.trap.snmp_config.add_v3_user")
    @patch("snmpv3_utils.core.trap.snmp_config.add_transport")
    @patch("snmpv3_utils.core.trap.udp_transport.UdpAsyncioTransport")
    @patch("snmpv3_utils.core.trap.ntfrcv.NotificationReceiver")
    @patch("snmpv3_utils.core.trap.SnmpEngine")
    def test_listen_registers_all_usm_users(
        self, mock_engine_cls, mock_ntfrcv, mock_udp_transport, mock_add_transport, mock_add_v3_user
    ):
        mock_engine_cls.return_value.transport_dispatcher.run_dispatcher.return_value = None

        usm1 = MagicMock()
        usm1.userName = "alice"
        usm2 = MagicMock()
        usm2.userName = "bob"

        listen(16299, [usm1, usm2], on_trap=None)

        assert mock_add_v3_user.call_count == 2
        names_registered = [call.args[1] for call in mock_add_v3_user.call_args_list]
        assert "alice" in names_registered
        assert "bob" in names_registered

    @patch("snmpv3_utils.core.trap.snmp_config.add_v3_user")
    @patch("snmpv3_utils.core.trap.snmp_config.add_transport")
    @patch("snmpv3_utils.core.trap.udp_transport.UdpAsyncioTransport")
    @patch("snmpv3_utils.core.trap.ntfrcv.NotificationReceiver")
    @patch("snmpv3_utils.core.trap.SnmpEngine")
    def test_listen_exits_cleanly_on_keyboard_interrupt(
        self, mock_engine_cls, mock_ntfrcv, mock_udp_transport, mock_add_transport, mock_add_v3_user
    ):
        mock_engine_cls.return_value.transport_dispatcher.run_dispatcher.side_effect = (
            KeyboardInterrupt
        )

        usm = MagicMock()
        listen(16299, [usm], on_trap=None)  # must not raise

        mock_engine_cls.return_value.transport_dispatcher.close_dispatcher.assert_called_once()

    @patch("snmpv3_utils.core.trap.snmp_config.add_v3_user")
    @patch("snmpv3_utils.core.trap.snmp_config.add_transport")
    @patch("snmpv3_utils.core.trap.udp_transport.UdpAsyncioTransport")
    @patch("snmpv3_utils.core.trap.ntfrcv.NotificationReceiver")
    @patch("snmpv3_utils.core.trap.SnmpEngine")
    def test_listen_on_trap_exception_does_not_crash_listener(
        self, mock_engine_cls, mock_ntfrcv, mock_udp, mock_add_transport, mock_add_v3_user
    ):
        """If on_trap raises, the exception is logged and the listener continues."""
        captured = {}

        def capture_ntfrcv(engine, cb):
            captured["cb"] = cb
            return MagicMock()

        mock_ntfrcv.side_effect = capture_ntfrcv

        call_count = 0

        def fake_run_dispatcher():
            nonlocal call_count
            mock_oid = MagicMock()
            mock_oid.__str__ = lambda self: "1.3.6.1.2.1.1.3.0"
            mock_val = MagicMock()
            mock_val.__str__ = lambda self: "0"
            # Fire the callback twice — first call raises, second should still succeed
            captured["cb"](MagicMock(), "ref1", b"", b"", [(mock_oid, mock_val)], None)
            captured["cb"](MagicMock(), "ref2", b"", b"", [(mock_oid, mock_val)], None)

        mock_engine_cls.return_value.transport_dispatcher.run_dispatcher.side_effect = (
            fake_run_dispatcher
        )

        received = []
        call_count = 0

        def flaky_on_trap(record):
            nonlocal call_count
            call_count += 1
            if call_count == 1:
                raise RuntimeError("output failure")
            received.append(record)

        usm = MagicMock()
        listen(16299, [usm], on_trap=flaky_on_trap)

        # Second trap must still be delivered despite first on_trap raising
        assert len(received) == 1

    @patch("snmpv3_utils.core.trap.snmp_config.add_v3_user")
    @patch("snmpv3_utils.core.trap.snmp_config.add_transport")
    @patch("snmpv3_utils.core.trap.udp_transport.UdpAsyncioTransport")
    @patch("snmpv3_utils.core.trap.ntfrcv.NotificationReceiver")
    @patch("snmpv3_utils.core.trap.SnmpEngine")
    def test_listen_timestamp_is_utc_iso8601(
        self, mock_engine_cls, mock_ntfrcv, mock_udp, mock_add_transport, mock_add_v3_user
    ):
        """Timestamp in TrapReceived must be UTC ISO-8601 with Z suffix."""
        import re

        captured = {}

        def capture_ntfrcv(engine, cb):
            captured["cb"] = cb
            return MagicMock()

        mock_ntfrcv.side_effect = capture_ntfrcv

        def fake_run_dispatcher():
            mock_oid = MagicMock()
            mock_oid.__str__ = lambda self: "1.3.6.1.2.1.1.3.0"
            mock_val = MagicMock()
            mock_val.__str__ = lambda self: "0"
            captured["cb"](MagicMock(), "ref1", b"", b"", [(mock_oid, mock_val)], None)

        mock_engine_cls.return_value.transport_dispatcher.run_dispatcher.side_effect = (
            fake_run_dispatcher
        )

        received = []
        usm = MagicMock()
        listen(16299, [usm], on_trap=received.append)

        assert len(received) == 1
        ts = received[0]["timestamp"]
        assert re.match(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$", ts), (
            f"Expected UTC ISO-8601 timestamp with Z suffix, got: {ts!r}"
        )
