"""Tests for BCM4775 firmware updater.

Tests bootloader package parsing, chip ID validation, FSM state
transitions with mock transport, and abort handling.
"""

import struct
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock

import pytest

from lc29h.bcm_firmware import BootloaderPackage, FirmwareUpdater
from lc29h.bcm_models import BCM_CHIP_IDS, FirmwareDownloaderState, RpcCommandId
from lc29h.bcm_protocol import (
    BOOTLOADER_HEADER_SIZE,
    BOOTLOADER_MAGIC,
    BcmReliableTransport,
)
from lc29h.bcm_rpc import FLASH_SECTOR_SIZE, compute_local_crc

# Path to real bootloader.pkg in the repo
BOOTLOADER_PKG_PATH = (
    Path(__file__).parent.parent
    / "downloads" / "extracted" / "QGNSS_V1.8" / "bootloader.pkg"
)


# ---------------------------------------------------------------------------
# BootloaderPackage tests
# ---------------------------------------------------------------------------


class TestBootloaderPackage:
    def test_parse_real_bootloader(self):
        """Parse the actual bootloader.pkg from downloads."""
        if not BOOTLOADER_PKG_PATH.exists():
            pytest.skip("bootloader.pkg not available")

        pkg = BootloaderPackage.from_file(BOOTLOADER_PKG_PATH)
        assert pkg.total_size == 20092
        assert pkg.payload_length == 20060  # 0x4E5C
        assert len(pkg.payload) == 20060
        assert len(pkg.header) == BOOTLOADER_HEADER_SIZE

    def test_parse_real_magic(self):
        """Verify the magic bytes of the real bootloader."""
        if not BOOTLOADER_PKG_PATH.exists():
            pytest.skip("bootloader.pkg not available")

        pkg = BootloaderPackage.from_file(BOOTLOADER_PKG_PATH)
        assert pkg.header[:4] == BOOTLOADER_MAGIC

    def test_parse_synthetic_valid(self):
        """Parse a synthetic valid bootloader package."""
        payload = b"\x00" * 100
        header = BOOTLOADER_MAGIC
        header += struct.pack("<I", 0x11C1DDAC)  # identifier
        header += struct.pack("<I", 0x20171121)  # build info
        header += struct.pack("<I", len(payload))  # payload length
        header += b"\x00" * 16  # reserved
        data = header + payload

        pkg = BootloaderPackage(data)
        assert pkg.payload_length == 100
        assert pkg.payload == payload
        assert pkg.identifier == 0x11C1DDAC
        assert pkg.build_info == 0x20171121

    def test_reject_bad_magic(self):
        """Reject package with wrong magic bytes."""
        data = b"\x00\x00\x00\x00" + b"\x00" * 60
        with pytest.raises(ValueError, match="Invalid bootloader magic"):
            BootloaderPackage(data)

    def test_reject_too_small(self):
        """Reject package smaller than header size."""
        data = BOOTLOADER_MAGIC + b"\x00" * 10
        with pytest.raises(ValueError, match="too small"):
            BootloaderPackage(data)

    def test_reject_truncated(self):
        """Reject package where payload length exceeds file size."""
        header = BOOTLOADER_MAGIC
        header += b"\x00" * 4  # identifier
        header += b"\x00" * 4  # build info
        header += struct.pack("<I", 50000)  # claims 50KB payload
        header += b"\x00" * 16
        data = header + b"\x00" * 100  # only 100 bytes of payload

        with pytest.raises(ValueError, match="truncated"):
            BootloaderPackage(data)

    def test_from_file_not_found(self):
        with pytest.raises(FileNotFoundError):
            BootloaderPackage.from_file("/nonexistent/path/bootloader.pkg")


# ---------------------------------------------------------------------------
# Chip ID validation tests
# ---------------------------------------------------------------------------


class TestChipIdValidation:
    def test_known_chip_ids(self):
        """All 13 known chip IDs should be in the table."""
        assert len(BCM_CHIP_IDS) == 13

    def test_bcm4775_b0(self):
        assert 0x004775B0 in BCM_CHIP_IDS
        assert "BCM4775" in BCM_CHIP_IDS[0x004775B0]

    def test_bcm4775_b1(self):
        assert 0x004775B1 in BCM_CHIP_IDS
        assert "BCM4775" in BCM_CHIP_IDS[0x004775B1]

    def test_bcm4773(self):
        assert 0x47730A30 in BCM_CHIP_IDS
        assert "BCM4773" in BCM_CHIP_IDS[0x47730A30]

    def test_bcm4774(self):
        assert 0x004774A0 in BCM_CHIP_IDS
        assert "BCM4774" in BCM_CHIP_IDS[0x004774A0]

    def test_unknown_id(self):
        assert 0xDEADBEEF not in BCM_CHIP_IDS

    def test_variant_prefix_chips(self):
        """BCM4775 B0 has 4 variants (0x03, 0x07, 0x05, 0x04 prefix)."""
        variants = [0x034775B0, 0x074775B0, 0x054775B0, 0x044775B0]
        for vid in variants:
            assert vid in BCM_CHIP_IDS
            assert "variant" in BCM_CHIP_IDS[vid]


# ---------------------------------------------------------------------------
# FSM state enum tests
# ---------------------------------------------------------------------------


class TestFirmwareDownloaderState:
    def test_all_18_states(self):
        assert len(FirmwareDownloaderState) == 18

    def test_idle_is_zero(self):
        assert FirmwareDownloaderState.FD_IDLE == 0

    def test_done_is_0x11(self):
        assert FirmwareDownloaderState.FD_DONE == 0x11

    def test_state_names(self):
        assert FirmwareDownloaderState.FD_DOWNLOADING.name == "FD_DOWNLOADING"
        assert FirmwareDownloaderState.FD_GOT_SYNC_CONFIRM.name == "FD_GOT_SYNC_CONFIRM"


# ---------------------------------------------------------------------------
# RPC command ID tests
# ---------------------------------------------------------------------------


class TestRpcCommandId:
    def test_flash_read(self):
        assert RpcCommandId.FLASH_READ == 6

    def test_flash_write(self):
        assert RpcCommandId.FLASH_WRITE == 9

    def test_flash_erase(self):
        assert RpcCommandId.FLASH_ERASE == 10

    def test_config_query(self):
        assert RpcCommandId.CONFIG_QUERY == 35

    def test_crc_verify(self):
        assert RpcCommandId.CRC_VERIFY == 36

    def test_status(self):
        assert RpcCommandId.STATUS == 39


# ---------------------------------------------------------------------------
# Local CRC computation tests
# ---------------------------------------------------------------------------


class TestLocalCrc:
    def test_empty(self):
        assert compute_local_crc(b"") == 0

    def test_single_byte(self):
        assert compute_local_crc(b"\xAB") == 0xAB

    def test_known_pattern(self):
        # XOR of 0xFF, 0xFF = 0x00
        assert compute_local_crc(b"\xFF\xFF") == 0

    def test_returns_byte_range(self):
        for val in [0x00, 0x7F, 0xFF]:
            result = compute_local_crc(bytes([val]))
            assert 0 <= result <= 0xFF


# ---------------------------------------------------------------------------
# Flash sector alignment tests
# ---------------------------------------------------------------------------


class TestSectorAlignment:
    def test_sector_size(self):
        assert FLASH_SECTOR_SIZE == 4096

    def test_aligned_addresses(self):
        """Sector-aligned addresses should pass validation."""
        for addr in [0x0, 0x1000, 0x2000, 0x10000, 0x100000]:
            assert addr % FLASH_SECTOR_SIZE == 0

    def test_unaligned_addresses(self):
        """Non-aligned addresses should fail."""
        for addr in [0x1, 0x100, 0x1001, 0xFFF]:
            assert addr % FLASH_SECTOR_SIZE != 0


# ---------------------------------------------------------------------------
# FirmwareUpdater initialization tests
# ---------------------------------------------------------------------------


class TestFirmwareUpdater:
    def _make_mock_transport(self):
        """Create a mock BcmReliableTransport."""
        raw = MagicMock()
        raw.flush_input = AsyncMock(return_value=0)
        raw.write_raw = AsyncMock()
        raw.read_raw = AsyncMock()
        transport = MagicMock(spec=BcmReliableTransport)
        transport.raw_transport = raw
        transport.send_autobaud = AsyncMock()
        transport.send_sync = AsyncMock(return_value=True)
        transport.send_packet = AsyncMock()
        transport.receive_packet = AsyncMock()
        return transport

    def test_initial_state(self):
        transport = self._make_mock_transport()
        updater = FirmwareUpdater(transport)
        assert updater.state == FirmwareDownloaderState.FD_IDLE
        assert updater.chip_id == 0
        assert updater.bootloader_running is False

    def test_no_bootloader_enter_fails(self):
        """enter_bootloader() without a bootloader package should raise."""
        transport = self._make_mock_transport()
        updater = FirmwareUpdater(transport)
        with pytest.raises(ValueError, match="No bootloader"):
            import asyncio
            asyncio.get_event_loop().run_until_complete(updater.enter_bootloader())

    def test_abort_sets_flag(self):
        transport = self._make_mock_transport()
        updater = FirmwareUpdater(transport)
        assert updater._abort_requested is False
        updater.abort()
        assert updater._abort_requested is True

    def test_chip_name_unknown(self):
        transport = self._make_mock_transport()
        updater = FirmwareUpdater(transport)
        updater._chip_id = 0xDEADBEEF
        assert "Unknown" in updater.chip_name

    def test_chip_name_known(self):
        transport = self._make_mock_transport()
        updater = FirmwareUpdater(transport)
        updater._chip_id = 0x004775B0
        assert "BCM4775" in updater.chip_name

    def test_get_info_safe(self):
        """get_info should work without bootloader."""
        transport = self._make_mock_transport()
        updater = FirmwareUpdater(transport)
        import asyncio
        info = asyncio.get_event_loop().run_until_complete(updater.get_info())
        assert info.bootloader_active is False
        assert info.chip_id == 0

    def test_update_unaligned_offset(self):
        """update_firmware should reject non-aligned flash offset."""
        transport = self._make_mock_transport()
        updater = FirmwareUpdater(transport)

        import asyncio
        import tempfile

        with tempfile.NamedTemporaryFile(suffix=".bin", delete=False) as f:
            f.write(b"\x00" * 4096)
            f.flush()
            result = asyncio.get_event_loop().run_until_complete(
                updater.update_firmware(f.name, flash_offset=0x123)
            )
            assert not result.success
            assert "sector aligned" in result.error

    def test_update_missing_file(self):
        """update_firmware should handle missing firmware file."""
        transport = self._make_mock_transport()
        updater = FirmwareUpdater(transport)
        import asyncio
        result = asyncio.get_event_loop().run_until_complete(
            updater.update_firmware("/nonexistent/firmware.bin")
        )
        assert not result.success
        assert "not found" in result.error

    def test_progress_callback(self):
        """Progress callback should be stored and callable."""
        transport = self._make_mock_transport()
        calls = []

        def on_progress(current, total, msg):
            calls.append((current, total, msg))

        updater = FirmwareUpdater(transport, progress=on_progress)
        updater._progress(50, 100, "test")
        assert len(calls) == 1
        assert calls[0] == (50, 100, "test")
