"""Tests for smart routing: standalone CONTACT handling, PATH_UPDATED parsing,
and flood attribution chain."""

import struct
from unittest.mock import MagicMock, patch

from meshy.frame_handler import FrameHandler
from meshy.message_controller import MessageController
from meshy.protocol import (
    PUSH_CODE_PATH_UPDATED,
    RESP_CODE_CONTACT,
)
from meshy.storage import Storage

PUB_KEY = bytes(range(32))
PUB_KEY_HEX = PUB_KEY.hex()


def _contact_dict(path_len=2, path_hex="aabb", lastmod=100):
    return {
        "public_key": PUB_KEY_HEX,
        "type": 1,
        "flags": 0,
        "out_path_len": path_len,
        "path_hash_size": 1,
        "out_path": path_hex,
        "adv_name": "Test",
        "adv_lat": 0.0,
        "adv_lon": 0.0,
        "last_advert": 0,
        "lastmod": lastmod,
    }


def _build_contact_frame(pub_key=PUB_KEY, path_len_byte=2, path=None):
    """Build a raw CONTACT frame (code 3) matching the firmware format."""
    frame = bytes([RESP_CODE_CONTACT])
    frame += pub_key
    frame += bytes([1])  # type = CHAT
    frame += bytes([0])  # flags
    frame += bytes([path_len_byte])
    frame += (path or bytes(64)).ljust(64, b"\x00")[:64]
    frame += b"TestNode" + bytes(24)  # name (32 bytes)
    frame += struct.pack("<I", 1700000000)  # last_advert
    frame += struct.pack("<i", 50000000)  # lat
    frame += struct.pack("<i", 14000000)  # lon
    frame += struct.pack("<I", 100)  # lastmod
    return frame


def _make_win(storage):
    win = MagicMock()
    win._storage = storage
    win._contacts = []
    win._contacts_by_key = {}
    win._conn_ctrl.contacts_iter_busy = False
    win._conn_ctrl.synced_contact_keys = None
    win._conn_ctrl.sync_contact_current = 0
    win._conn_ctrl.syncing = False
    win._device_info = None
    win._login_callback = None
    win._ignore_next_err = False
    win._msg_ctrl = MessageController(win)
    win._region_callbacks = None
    return win


# ─── Standalone CONTACT frame (getContactByKey response) ─────


class TestStandaloneContactFrame:
    def setup_method(self):
        self.storage = Storage(db_path=":memory:")
        self.win = _make_win(self.storage)
        self.fh = FrameHandler(self.win)

    def teardown_method(self):
        self.storage.close()

    def test_standalone_contact_updates_contact(self):
        """A CONTACT frame outside bulk sync should call add_or_update_contact."""
        self.win._conn_ctrl.contacts_iter_busy = False
        frame = _build_contact_frame()
        with patch.object(self.fh, "add_or_update_contact") as mock_add:
            self.fh.handle_frame(frame)
            mock_add.assert_called_once()

    def test_standalone_contact_skips_sync_bookkeeping(self):
        """With contacts_iter_busy=False, sync counter must not increment."""
        self.win._conn_ctrl.contacts_iter_busy = False
        self.win._conn_ctrl.sync_contact_current = 0
        frame = _build_contact_frame()
        with patch.object(self.fh, "add_or_update_contact"):
            self.fh.handle_frame(frame)
        assert self.win._conn_ctrl.sync_contact_current == 0
        self.win._conn_ctrl._start_contacts_sync_timeout.assert_not_called()

    def test_bulk_sync_contact_does_bookkeeping(self):
        """With contacts_iter_busy=True, sync counter must increment."""
        self.win._conn_ctrl.contacts_iter_busy = True
        self.win._conn_ctrl.sync_contact_current = 0
        self.win._conn_ctrl.synced_contact_keys = set()
        frame = _build_contact_frame()
        with (
            patch.object(self.fh, "add_or_update_contact"),
            patch.object(self.fh, "dict_to_contact") as mock_dtc,
        ):
            mock_contact = MagicMock()
            mock_contact.public_key_hex = PUB_KEY_HEX
            mock_dtc.return_value = mock_contact
            self.fh.handle_frame(frame)
        assert self.win._conn_ctrl.sync_contact_current == 1
        self.win._conn_ctrl._start_contacts_sync_timeout.assert_called_once()
        assert PUB_KEY_HEX in self.win._conn_ctrl.synced_contact_keys


# ─── PATH_UPDATED parsing ────────────────────────────────────


class TestPathUpdatedParsing:
    def setup_method(self):
        self.storage = Storage(db_path=":memory:")
        self.win = _make_win(self.storage)
        self.fh = FrameHandler(self.win)

    def teardown_method(self):
        self.storage.close()

    def test_extracts_pub_key_and_fetches(self):
        """PATH_UPDATED with 32-byte pub key should call refresh_contact."""
        frame = bytes([PUSH_CODE_PATH_UPDATED]) + PUB_KEY
        with patch.object(self.fh, "refresh_contact") as mock_refresh:
            self.fh.handle_frame(frame)
        mock_refresh.assert_called_once_with(PUB_KEY_HEX)

    def test_short_frame_skips_fetch(self):
        """PATH_UPDATED with too-short data should fall back to bulk refetch."""
        frame = bytes([PUSH_CODE_PATH_UPDATED]) + bytes(10)
        with patch.object(self.fh, "refresh_contact") as mock_refresh:
            with patch.object(self.fh, "schedule_contacts_refetch") as mock_refetch:
                self.fh.handle_frame(frame)
            mock_refresh.assert_not_called()
            mock_refetch.assert_called_once()

    def test_still_triggers_bulk_refetch(self):
        """PATH_UPDATED with pub key calls refresh_contact which includes bulk refetch."""
        frame = bytes([PUSH_CODE_PATH_UPDATED]) + PUB_KEY
        with patch.object(self.fh, "send_get_contact_by_key"):
            with patch.object(self.fh, "schedule_contacts_refetch") as mock_refetch:
                self.fh.handle_frame(frame)
            mock_refetch.assert_called_once()


# ─── Flood attribution chain ─────────────────────────────────


class TestFloodAttributionChain:
    def setup_method(self):
        self.storage = Storage(db_path=":memory:")
        self.win = _make_win(self.storage)
        self.fh = FrameHandler(self.win)

    def teardown_method(self):
        self.storage.close()

    def test_attribution_consumed_when_contact_has_path(self):
        """pending_flood_attributions should be consumed and path recorded
        when add_or_update_contact sees path_length >= 0."""
        self.win._msg_ctrl.pending_flood_attributions[PUB_KEY_HEX] = [(5000, False)]
        self.fh.add_or_update_contact(_contact_dict(path_len=2, path_hex="aabb"))

        assert PUB_KEY_HEX not in self.win._msg_ctrl.pending_flood_attributions
        history = self.storage.get_path_history(PUB_KEY_HEX)
        assert len(history) == 1
        assert history[0]["last_trip_ms"] == 5000

    def test_attribution_not_consumed_when_flood(self):
        """pending_flood_attributions should remain when contact has path_length < 0."""
        self.win._msg_ctrl.pending_flood_attributions[PUB_KEY_HEX] = [(5000, False)]
        self.fh.add_or_update_contact(_contact_dict(path_len=-1, path_hex=""))

        assert PUB_KEY_HEX in self.win._msg_ctrl.pending_flood_attributions
        assert self.storage.get_path_history(PUB_KEY_HEX) == []

    def test_seed_from_contact_when_no_pending_attribution(self):
        """Without pending attributions, a contact with a path should be seeded."""
        self.fh.add_or_update_contact(_contact_dict(path_len=2, path_hex="ccdd"))

        history = self.storage.get_path_history(PUB_KEY_HEX)
        assert len(history) == 1
        assert history[0]["path_bytes"] == "ccdd"
        assert history[0]["route_weight"] == 1.0

    def test_late_attribution_uses_reduced_weight(self):
        """Late flood attribution should use GRACE_WEIGHT_INCREMENT (0.2),
        not the default 0.5, when updating an existing path record."""
        self.storage.upsert_path_record(
            PUB_KEY_HEX,
            "eeff",
            1,
            2,
            success=True,
            trip_ms=5000,
        )
        baseline = self.storage.get_path_history(PUB_KEY_HEX)[0]["route_weight"]

        self.win._msg_ctrl.pending_flood_attributions[PUB_KEY_HEX] = [(8000, True)]
        self.fh.add_or_update_contact(_contact_dict(path_len=2, path_hex="eeff"))

        history = self.storage.get_path_history(PUB_KEY_HEX)
        assert len(history) == 1
        assert history[0]["route_weight"] == baseline + 0.2
        assert history[0]["last_trip_ms"] == 8000
