"""Tests for the access_code module.

This module contains comprehensive tests for TLV8 encoding logic,
access code message building, and Device access code methods.
"""

from __future__ import annotations

import base64
import json
from unittest.mock import AsyncMock

import pytest

from aiokwikset.access_code import (
    AccessCodeResult,
    AccessCodeSchedule,
    DayOfWeek,
    LockMqResponse,
    MqttCommandType,
    ScheduleType,
    SingleAccessCodeCrc,
    _build_tlv8_record,
    _encode_schedule,
    _pack_date,
    _pack_time,
    _TxCommand,
    build_access_code_message,
    build_delete_all_message,
    build_delete_message,
    encode_bcd,
    parse_lock_mq_response,
    parse_single_access_code_crc,
)
from aiokwikset.device import Device
from aiokwikset.errors import MinimumAccessCodeError
from tests.conftest import (
    MOCK_ACCESS_CODE_RESPONSE,
    MOCK_ACCESS_CODE_STATUS_RESPONSE,
    MOCK_ACCESS_CODES_EMPTY,
    MOCK_ACCESS_CODES_MULTIPLE,
    MOCK_ACCESS_CODES_SINGLE,
    MOCK_DELETE_RESPONSE,
)

# ============================================================================
# ScheduleType Enum Tests
# ============================================================================


class TestScheduleType:
    """Tests for ScheduleType enum."""

    def test_all_day_value(self) -> None:
        """Test ALL_DAY has correct value."""
        assert ScheduleType.ALL_DAY == 1

    def test_date_range_value(self) -> None:
        """Test DATE_RANGE has correct value."""
        assert ScheduleType.DATE_RANGE == 3

    def test_weekly_value(self) -> None:
        """Test WEEKLY has correct value."""
        assert ScheduleType.WEEKLY == 4

    def test_one_time_unlimited_value(self) -> None:
        """Test ONE_TIME_UNLIMITED has correct value."""
        assert ScheduleType.ONE_TIME_UNLIMITED == 5

    def test_one_time_24_hour_value(self) -> None:
        """Test ONE_TIME_24_HOUR has correct value."""
        assert ScheduleType.ONE_TIME_24_HOUR == 6


# ============================================================================
# DayOfWeek Enum Tests
# ============================================================================


class TestDayOfWeek:
    """Tests for DayOfWeek IntFlag enum."""

    def test_individual_day_values(self) -> None:
        """Test each day has the correct bitmask value."""
        assert DayOfWeek.SUNDAY == 0x01
        assert DayOfWeek.MONDAY == 0x02
        assert DayOfWeek.TUESDAY == 0x04
        assert DayOfWeek.WEDNESDAY == 0x08
        assert DayOfWeek.THURSDAY == 0x10
        assert DayOfWeek.FRIDAY == 0x20
        assert DayOfWeek.SATURDAY == 0x40

    def test_combination_weekdays(self) -> None:
        """Test combining weekday flags."""
        weekdays = (
            DayOfWeek.MONDAY
            | DayOfWeek.TUESDAY
            | DayOfWeek.WEDNESDAY
            | DayOfWeek.THURSDAY
            | DayOfWeek.FRIDAY
        )
        assert int(weekdays) == 0x3E

    def test_combination_all_days(self) -> None:
        """Test combining all day flags."""
        all_days = (
            DayOfWeek.SUNDAY
            | DayOfWeek.MONDAY
            | DayOfWeek.TUESDAY
            | DayOfWeek.WEDNESDAY
            | DayOfWeek.THURSDAY
            | DayOfWeek.FRIDAY
            | DayOfWeek.SATURDAY
        )
        assert int(all_days) == 0x7F


# ============================================================================
# AccessCodeSchedule Dataclass Tests
# ============================================================================


class TestAccessCodeSchedule:
    """Tests for AccessCodeSchedule dataclass."""

    def test_default_values(self) -> None:
        """Test default field values."""
        schedule = AccessCodeSchedule(schedule_type=ScheduleType.ALL_DAY)
        assert schedule.start_hour == 0
        assert schedule.start_minute == 0
        assert schedule.end_hour == 23
        assert schedule.end_minute == 59
        assert schedule.start_month == 1
        assert schedule.start_day == 1
        assert schedule.start_year == 2025
        assert schedule.end_month == 12
        assert schedule.end_day == 31
        assert schedule.end_year == 2025
        assert schedule.days_of_week is None

    def test_frozen_immutable(self) -> None:
        """Test that the dataclass is frozen (immutable)."""
        schedule = AccessCodeSchedule(schedule_type=ScheduleType.ALL_DAY)
        with pytest.raises(AttributeError):
            schedule.start_hour = 5  # type: ignore[misc]


# ============================================================================
# AccessCodeResult Dataclass Tests
# ============================================================================


class TestAccessCodeResult:
    """Tests for AccessCodeResult dataclass."""

    def test_creation(self) -> None:
        """Test creating an AccessCodeResult."""
        result = AccessCodeResult(token="abc123", last_update_status=1771097606)
        assert result.token == "abc123"
        assert result.last_update_status == 1771097606

    def test_frozen_immutable(self) -> None:
        """Test that the dataclass is frozen (immutable)."""
        result = AccessCodeResult(token="abc", last_update_status=0)
        with pytest.raises(AttributeError):
            result.token = "new"  # type: ignore[misc]


# ============================================================================
# encode_bcd Tests
# ============================================================================


class TestEncodeBcd:
    """Tests for encode_bcd function."""

    def test_even_length_code(self) -> None:
        """Test BCD encoding of even-length code."""
        assert encode_bcd("1234") == bytes([0x12, 0x34])

    def test_six_digit_code(self) -> None:
        """Test BCD encoding of 6-digit code."""
        assert encode_bcd("163950") == bytes([0x16, 0x39, 0x50])

    def test_eight_digit_code(self) -> None:
        """Test BCD encoding of 8-digit code."""
        assert encode_bcd("12345678") == bytes([0x12, 0x34, 0x56, 0x78])

    def test_odd_length_code(self) -> None:
        """Test BCD encoding of odd-length code pads with 0xF."""
        assert encode_bcd("12345") == bytes([0x12, 0x34, 0x5F])

    def test_five_digit_code(self) -> None:
        """Test BCD encoding of another odd-length code."""
        assert encode_bcd("98765") == bytes([0x98, 0x76, 0x5F])

    def test_seven_digit_code(self) -> None:
        """Test BCD encoding of 7-digit code."""
        assert encode_bcd("1234567") == bytes([0x12, 0x34, 0x56, 0x7F])

    def test_all_zeros(self) -> None:
        """Test BCD encoding of all-zero code."""
        assert encode_bcd("0000") == bytes([0x00, 0x00])

    def test_all_nines(self) -> None:
        """Test BCD encoding of all-nine code."""
        assert encode_bcd("9999") == bytes([0x99, 0x99])

    def test_empty_string_raises(self) -> None:
        """Test empty string raises ValueError."""
        with pytest.raises(ValueError, match="non-empty numeric string"):
            encode_bcd("")

    def test_non_numeric_raises(self) -> None:
        """Test non-numeric string raises ValueError."""
        with pytest.raises(ValueError, match="non-empty numeric string"):
            encode_bcd("12ab")

    def test_too_short_raises(self) -> None:
        """Test code shorter than 4 digits raises ValueError."""
        with pytest.raises(ValueError, match="4-8 digits"):
            encode_bcd("123")

    def test_too_long_raises(self) -> None:
        """Test code longer than 8 digits raises ValueError."""
        with pytest.raises(ValueError, match="4-8 digits"):
            encode_bcd("123456789")


# ============================================================================
# _pack_time Tests
# ============================================================================


class TestPackTime:
    """Tests for _pack_time function."""

    def test_verified_capture_time(self) -> None:
        """Test time packing with verified capture values (13:35 to 14:45)."""
        result = _pack_time(13, 35, 14, 45)
        assert result == bytes([0x8C, 0x6D, 0x73])

    def test_midnight_to_midnight(self) -> None:
        """Test time packing for midnight to midnight."""
        result = _pack_time(0, 0, 0, 0)
        assert result == bytes([0x00, 0x00, 0x00])

    def test_full_day(self) -> None:
        """Test time packing for full day (0:00 to 23:59)."""
        result = _pack_time(0, 0, 23, 59)
        b0 = (0 << 2) & 0xFF  # 0x00
        b1 = ((59 & 0x3F) | (0 << 6)) & 0xFF  # 0x3B
        b2 = ((0 >> 2) | (23 << 3)) & 0xFF  # 0xB8
        assert result == bytes([b0, b1, b2])

    def test_noon_to_one(self) -> None:
        """Test time packing for 12:00 to 13:00."""
        result = _pack_time(12, 0, 13, 0)
        b0 = (0 << 2) & 0xFF  # 0x00
        b1 = ((0 & 0x3F) | (12 << 6)) & 0xFF  # 0x00 | 0x00 = 0x00 (12<<6 = 768, &0xFF = 0x00)
        b2 = ((12 >> 2) | (13 << 3)) & 0xFF  # 3 | 104 = 107 = 0x6B
        assert result == bytes([b0, b1, b2])


# ============================================================================
# _pack_date Tests
# ============================================================================


class TestPackDate:
    """Tests for _pack_date function."""

    def test_verified_capture_date(self) -> None:
        """Test date packing with verified capture values (Feb 14 2026)."""
        result = _pack_date(2, 14, 2026, 2, 14, 2026)
        assert result == bytes([0x22, 0xCE, 0x69, 0x34])

    def test_jan_1_to_dec_31(self) -> None:
        """Test date packing for Jan 1 to Dec 31 of 2025."""
        result = _pack_date(1, 1, 2025, 12, 31, 2025)
        value = 1 | (12 << 4) | (1 << 8) | (31 << 13) | (25 << 18) | (25 << 25)
        expected = value.to_bytes(4, byteorder="little")
        assert result == expected

    def test_year_2000_base(self) -> None:
        """Test date packing with year 2000 (offset 0)."""
        result = _pack_date(1, 1, 2000, 1, 1, 2000)
        value = 1 | (1 << 4) | (1 << 8) | (1 << 13)
        expected = value.to_bytes(4, byteorder="little")
        assert result == expected


# ============================================================================
# _build_tlv8_record Tests
# ============================================================================


class TestBuildTlv8Record:
    """Tests for _build_tlv8_record function."""

    def test_basic_record(self) -> None:
        """Test building a basic TLV8 record."""
        result = _build_tlv8_record(0x00, b"\x01\x02\x03")
        assert result == bytes([0x00, 0x03, 0x01, 0x02, 0x03])

    def test_empty_data(self) -> None:
        """Test building a TLV8 record with no data."""
        result = _build_tlv8_record(0x01, b"")
        assert result == bytes([0x01, 0x00])

    def test_friendly_name_record(self) -> None:
        """Test building a friendly name TLV8 record."""
        name_bytes = b"HA"
        result = _build_tlv8_record(0x02, name_bytes)
        assert result == bytes([0x02, 0x02, 0x48, 0x41])


# ============================================================================
# _encode_schedule Tests
# ============================================================================


class TestEncodeSchedule:
    """Tests for _encode_schedule function."""

    def test_all_day_returns_empty(self) -> None:
        """Test ALL_DAY schedule produces no schedule data."""
        schedule = AccessCodeSchedule(schedule_type=ScheduleType.ALL_DAY)
        assert _encode_schedule(schedule) == b""

    def test_one_time_unlimited_returns_empty(self) -> None:
        """Test ONE_TIME_UNLIMITED schedule produces no schedule data."""
        schedule = AccessCodeSchedule(schedule_type=ScheduleType.ONE_TIME_UNLIMITED)
        assert _encode_schedule(schedule) == b""

    def test_one_time_24_hour_returns_empty(self) -> None:
        """Test ONE_TIME_24_HOUR schedule produces no schedule data."""
        schedule = AccessCodeSchedule(schedule_type=ScheduleType.ONE_TIME_24_HOUR)
        assert _encode_schedule(schedule) == b""

    def test_date_range_returns_7_bytes(self) -> None:
        """Test DATE_RANGE schedule produces 7 bytes (3 time + 4 date)."""
        schedule = AccessCodeSchedule(
            schedule_type=ScheduleType.DATE_RANGE,
            start_hour=13,
            start_minute=35,
            end_hour=14,
            end_minute=45,
            start_month=2,
            start_day=14,
            start_year=2026,
            end_month=2,
            end_day=14,
            end_year=2026,
        )
        result = _encode_schedule(schedule)
        assert len(result) == 7
        assert result == bytes([0x8C, 0x6D, 0x73, 0x22, 0xCE, 0x69, 0x34])

    def test_weekly_returns_4_bytes(self) -> None:
        """Test WEEKLY schedule produces 4 bytes (3 time + 1 day bitmask)."""
        schedule = AccessCodeSchedule(
            schedule_type=ScheduleType.WEEKLY,
            start_hour=9,
            start_minute=0,
            end_hour=17,
            end_minute=0,
            days_of_week=DayOfWeek.MONDAY | DayOfWeek.WEDNESDAY | DayOfWeek.FRIDAY,
        )
        result = _encode_schedule(schedule)
        assert len(result) == 4
        # Day byte: (0x02 | 0x08 | 0x20) | 0x80 = 0x2A | 0x80 = 0xAA
        assert result[3] == 0xAA

    def test_weekly_no_days_sets_bit7(self) -> None:
        """Test WEEKLY schedule with no days still sets bit 7."""
        schedule = AccessCodeSchedule(
            schedule_type=ScheduleType.WEEKLY,
            start_hour=0,
            start_minute=0,
            end_hour=0,
            end_minute=0,
        )
        result = _encode_schedule(schedule)
        assert result[3] == 0x80


# ============================================================================
# build_access_code_message Tests — Verified Protocol
# ============================================================================


class TestBuildAccessCodeMessage:
    """Tests for build_access_code_message function."""

    def test_verified_capture(self) -> None:
        """Test building a message matches the verified captured request.

        This is the golden test case verified against actual network traffic
        from the Kwikset Android app.
        """
        schedule = AccessCodeSchedule(
            schedule_type=ScheduleType.DATE_RANGE,
            start_hour=13,
            start_minute=35,
            end_hour=14,
            end_minute=45,
            start_month=2,
            start_day=14,
            start_year=2026,
            end_month=2,
            end_day=14,
            end_year=2026,
        )
        result = build_access_code_message(slot=0, code="163950", name="HA", schedule=schedule)
        assert result == "AAUAMRY5UAEHjG1zIs5pNAICSEE="

    def test_verified_capture_hex(self) -> None:
        """Test the raw bytes match the expected hex from captured traffic."""
        schedule = AccessCodeSchedule(
            schedule_type=ScheduleType.DATE_RANGE,
            start_hour=13,
            start_minute=35,
            end_hour=14,
            end_minute=45,
            start_month=2,
            start_day=14,
            start_year=2026,
            end_month=2,
            end_day=14,
            end_year=2026,
        )
        result = build_access_code_message(slot=0, code="163950", name="HA", schedule=schedule)
        raw = base64.b64decode(result)
        expected_hex = bytes(
            [
                0x00,
                0x05,
                0x00,
                0x31,
                0x16,
                0x39,
                0x50,
                0x01,
                0x07,
                0x8C,
                0x6D,
                0x73,
                0x22,
                0xCE,
                0x69,
                0x34,
                0x02,
                0x02,
                0x48,
                0x41,
            ]
        )
        assert raw == expected_hex

    def test_all_day_schedule(self) -> None:
        """Test building a message with ALL_DAY schedule (no schedule TLV)."""
        schedule = AccessCodeSchedule(schedule_type=ScheduleType.ALL_DAY)
        result = build_access_code_message(slot=1, code="1234", name="Test", schedule=schedule)
        raw = base64.b64decode(result)
        # Access code TLV: type=0x00, len=4, data=[0x01, 0x11, 0x12, 0x34]
        # No schedule TLV
        # Name TLV: type=0x02, len=4, data=b"Test"
        assert raw[0] == 0x00  # access code type
        assert raw[1] == 0x04  # length: slot + status + 2 BCD bytes
        assert raw[2] == 0x01  # slot
        assert raw[3] == 0x11  # (1 << 4) | 1 = ALL_DAY enabled
        assert raw[4:6] == bytes([0x12, 0x34])  # BCD
        # Next should be friendly name TLV (no schedule)
        assert raw[6] == 0x02  # friendly name type
        assert raw[7] == 0x04  # length of "Test"
        assert raw[8:12] == b"Test"

    def test_disabled_code(self) -> None:
        """Test building a message with disabled access code."""
        schedule = AccessCodeSchedule(schedule_type=ScheduleType.ALL_DAY)
        result = build_access_code_message(
            slot=0, code="1234", name="Off", schedule=schedule, enabled=False
        )
        raw = base64.b64decode(result)
        # status_byte should be (1 << 4) | 0 = 0x10
        assert raw[3] == 0x10

    def test_weekly_schedule(self) -> None:
        """Test building a message with WEEKLY schedule."""
        schedule = AccessCodeSchedule(
            schedule_type=ScheduleType.WEEKLY,
            start_hour=8,
            start_minute=0,
            end_hour=18,
            end_minute=0,
            days_of_week=DayOfWeek.MONDAY | DayOfWeek.FRIDAY,
        )
        result = build_access_code_message(slot=2, code="5678", name="Weekly", schedule=schedule)
        raw = base64.b64decode(result)
        # Verify it has schedule TLV with type=0x01
        # Access code TLV: 00 04 02 41 56 78
        # Schedule TLV: 01 04 <time_bytes> <day_byte>
        assert raw[0] == 0x00  # access code type
        assert raw[6] == 0x01  # schedule type
        assert raw[7] == 0x04  # schedule length (3 time + 1 day)

    def test_invalid_slot_negative(self) -> None:
        """Test negative slot raises ValueError."""
        schedule = AccessCodeSchedule(schedule_type=ScheduleType.ALL_DAY)
        with pytest.raises(ValueError, match="Slot must be 0-255"):
            build_access_code_message(slot=-1, code="1234", name="X", schedule=schedule)

    def test_invalid_slot_too_large(self) -> None:
        """Test slot > 255 raises ValueError."""
        schedule = AccessCodeSchedule(schedule_type=ScheduleType.ALL_DAY)
        with pytest.raises(ValueError, match="Slot must be 0-255"):
            build_access_code_message(slot=256, code="1234", name="X", schedule=schedule)

    def test_non_numeric_code(self) -> None:
        """Test non-numeric code raises ValueError."""
        schedule = AccessCodeSchedule(schedule_type=ScheduleType.ALL_DAY)
        with pytest.raises(ValueError, match="non-empty numeric string"):
            build_access_code_message(slot=0, code="abcd", name="X", schedule=schedule)

    def test_empty_code(self) -> None:
        """Test empty code raises ValueError."""
        schedule = AccessCodeSchedule(schedule_type=ScheduleType.ALL_DAY)
        with pytest.raises(ValueError, match="non-empty numeric string"):
            build_access_code_message(slot=0, code="", name="X", schedule=schedule)

    def test_code_too_short(self) -> None:
        """Test code shorter than 4 digits raises ValueError."""
        schedule = AccessCodeSchedule(schedule_type=ScheduleType.ALL_DAY)
        with pytest.raises(ValueError, match="4-8 digits"):
            build_access_code_message(slot=0, code="123", name="X", schedule=schedule)

    def test_code_too_long(self) -> None:
        """Test code longer than 8 digits raises ValueError."""
        schedule = AccessCodeSchedule(schedule_type=ScheduleType.ALL_DAY)
        with pytest.raises(ValueError, match="4-8 digits"):
            build_access_code_message(slot=0, code="123456789", name="X", schedule=schedule)

    def test_empty_name(self) -> None:
        """Test empty name raises ValueError."""
        schedule = AccessCodeSchedule(schedule_type=ScheduleType.ALL_DAY)
        with pytest.raises(ValueError, match="Name cannot be empty"):
            build_access_code_message(slot=0, code="1234", name="", schedule=schedule)


# ============================================================================
# build_delete_message Tests
# ============================================================================


class TestBuildDeleteMessage:
    """Tests for build_delete_message function."""

    def test_delete_slot_0(self) -> None:
        """Test delete message for slot 0."""
        result = build_delete_message(0)
        raw = base64.b64decode(result)
        assert raw == bytes([_TxCommand.DELETE_DEVICE_ACCESS, 0x00])

    def test_delete_slot_5(self) -> None:
        """Test delete message for slot 5."""
        result = build_delete_message(5)
        raw = base64.b64decode(result)
        assert raw == bytes([0x05, 0x05])

    def test_delete_slot_255(self) -> None:
        """Test delete message for maximum slot."""
        result = build_delete_message(255)
        raw = base64.b64decode(result)
        assert raw == bytes([0x05, 0xFF])

    def test_invalid_slot_negative(self) -> None:
        """Test negative slot raises ValueError."""
        with pytest.raises(ValueError, match="Slot must be 0-255"):
            build_delete_message(-1)

    def test_invalid_slot_too_large(self) -> None:
        """Test slot > 255 raises ValueError."""
        with pytest.raises(ValueError, match="Slot must be 0-255"):
            build_delete_message(256)


# ============================================================================
# build_delete_all_message Tests
# ============================================================================


class TestBuildDeleteAllMessage:
    """Tests for build_delete_all_message function."""

    def test_delete_all(self) -> None:
        """Test delete all message."""
        result = build_delete_all_message()
        raw = base64.b64decode(result)
        assert raw == bytes([_TxCommand.DELETE_ALL_ACCESS_CODES])
        assert raw == bytes([0x0A])


# ============================================================================
# Device Access Code Method Tests
# ============================================================================


class TestDeviceCreateAccessCode:
    """Tests for Device.create_access_code method."""

    @pytest.mark.asyncio
    async def test_create_access_code_success(self) -> None:
        """Test creating an access code successfully."""
        mock_request = AsyncMock(return_value=MOCK_ACCESS_CODE_RESPONSE)
        device = Device(mock_request)

        schedule = AccessCodeSchedule(schedule_type=ScheduleType.ALL_DAY)
        result = await device.create_access_code(
            device_id="SN12345",
            code="1234",
            name="Test",
            schedule=schedule,
        )

        assert isinstance(result, AccessCodeResult)
        assert result.token == "90250706"
        assert result.last_update_status == 1771097606
        mock_request.assert_called_once()
        call_args = mock_request.call_args
        assert call_args[0][0] == "post"
        assert "SN12345" in call_args[0][1]
        assert "accesscode" in call_args[0][1]

    @pytest.mark.asyncio
    async def test_create_access_code_payload(self) -> None:
        """Test that the payload contains the correct message format."""
        mock_request = AsyncMock(return_value=MOCK_ACCESS_CODE_RESPONSE)
        device = Device(mock_request)

        schedule = AccessCodeSchedule(schedule_type=ScheduleType.ALL_DAY)
        await device.create_access_code(
            device_id="SN12345",
            code="1234",
            name="Test",
            schedule=schedule,
        )

        call_kwargs = mock_request.call_args[1]
        payload = json.loads(call_kwargs["data"])
        assert "message" in payload
        # Verify it's valid Base64
        base64.b64decode(payload["message"])

    @pytest.mark.asyncio
    async def test_create_access_code_empty_device_id(self) -> None:
        """Test empty device_id raises ValueError."""
        mock_request = AsyncMock()
        device = Device(mock_request)

        schedule = AccessCodeSchedule(schedule_type=ScheduleType.ALL_DAY)
        with pytest.raises(ValueError, match="device_id cannot be empty"):
            await device.create_access_code(
                device_id="",
                code="1234",
                name="Test",
                schedule=schedule,
            )

    @pytest.mark.asyncio
    async def test_create_access_code_empty_response_data(self) -> None:
        """Test handling of empty response data."""
        mock_request = AsyncMock(return_value={"data": []})
        device = Device(mock_request)

        schedule = AccessCodeSchedule(schedule_type=ScheduleType.ALL_DAY)
        result = await device.create_access_code(
            device_id="SN12345",
            code="1234",
            name="Test",
            schedule=schedule,
        )

        assert result.token == ""
        assert result.last_update_status == 0


class TestDeviceEditAccessCode:
    """Tests for Device.edit_access_code method."""

    @pytest.mark.asyncio
    async def test_edit_access_code_success(self) -> None:
        """Test editing an access code successfully."""
        mock_request = AsyncMock(return_value=MOCK_ACCESS_CODE_RESPONSE)
        device = Device(mock_request)

        schedule = AccessCodeSchedule(schedule_type=ScheduleType.ALL_DAY)
        result = await device.edit_access_code(
            device_id="SN12345",
            code="5678",
            name="Updated",
            schedule=schedule,
            slot=1,
        )

        assert isinstance(result, AccessCodeResult)
        assert result.token == "90250706"
        mock_request.assert_called_once()
        call_args = mock_request.call_args
        assert call_args[0][0] == "patch"

    @pytest.mark.asyncio
    async def test_edit_access_code_empty_device_id(self) -> None:
        """Test empty device_id raises ValueError."""
        mock_request = AsyncMock()
        device = Device(mock_request)

        schedule = AccessCodeSchedule(schedule_type=ScheduleType.ALL_DAY)
        with pytest.raises(ValueError, match="device_id cannot be empty"):
            await device.edit_access_code(
                device_id="",
                code="1234",
                name="Test",
                schedule=schedule,
                slot=0,
            )


class TestDeviceDeleteAccessCode:
    """Tests for Device.delete_access_code method."""

    @pytest.mark.asyncio
    async def test_delete_access_code_success(self) -> None:
        """Test deleting an access code when 2+ codes exist."""
        mock_request = AsyncMock(side_effect=[MOCK_ACCESS_CODES_MULTIPLE, MOCK_DELETE_RESPONSE])
        device = Device(mock_request)

        result = await device.delete_access_code(device_id="SN12345", slot=0)

        assert result == MOCK_DELETE_RESPONSE
        assert mock_request.call_count == 2
        # First call: get_access_codes
        assert mock_request.call_args_list[0][0][0] == "get"
        # Second call: delete
        assert mock_request.call_args_list[1][0][0] == "delete"

    @pytest.mark.asyncio
    async def test_delete_access_code_payload(self) -> None:
        """Test that the delete payload contains the correct message."""
        mock_request = AsyncMock(side_effect=[MOCK_ACCESS_CODES_MULTIPLE, MOCK_DELETE_RESPONSE])
        device = Device(mock_request)

        await device.delete_access_code(device_id="SN12345", slot=3)

        call_kwargs = mock_request.call_args_list[1][1]
        payload = json.loads(call_kwargs["data"])
        assert "message" in payload
        raw = base64.b64decode(payload["message"])
        assert raw == bytes([0x05, 0x03])

    @pytest.mark.asyncio
    async def test_delete_access_code_empty_device_id(self) -> None:
        """Test empty device_id raises ValueError."""
        mock_request = AsyncMock()
        device = Device(mock_request)

        with pytest.raises(ValueError, match="device_id cannot be empty"):
            await device.delete_access_code(device_id="", slot=0)

    @pytest.mark.asyncio
    async def test_delete_access_code_blocked_single_code(self) -> None:
        """Test delete is blocked when only 1 code exists."""
        mock_request = AsyncMock(return_value=MOCK_ACCESS_CODES_SINGLE)
        device = Device(mock_request)

        with pytest.raises(MinimumAccessCodeError) as exc_info:
            await device.delete_access_code(device_id="SN12345", slot=0)

        assert exc_info.value.device_id == "SN12345"
        assert exc_info.value.current_count == 1
        # Only the get_access_codes call should have been made
        mock_request.assert_called_once()

    @pytest.mark.asyncio
    async def test_delete_access_code_blocked_zero_codes(self) -> None:
        """Test delete is blocked when 0 codes exist."""
        mock_request = AsyncMock(return_value=MOCK_ACCESS_CODES_EMPTY)
        device = Device(mock_request)

        with pytest.raises(MinimumAccessCodeError) as exc_info:
            await device.delete_access_code(device_id="SN12345", slot=0)

        assert exc_info.value.device_id == "SN12345"
        assert exc_info.value.current_count == 0

    @pytest.mark.asyncio
    async def test_delete_access_code_error_has_default_message(self) -> None:
        """Test MinimumAccessCodeError has a descriptive default message."""
        mock_request = AsyncMock(return_value=MOCK_ACCESS_CODES_SINGLE)
        device = Device(mock_request)

        with pytest.raises(MinimumAccessCodeError, match="Cannot delete the last access code"):
            await device.delete_access_code(device_id="SN12345", slot=0)


class TestDeviceDeleteAllAccessCodes:
    """Tests for Device.delete_all_access_codes method."""

    @pytest.mark.asyncio
    async def test_delete_all_access_codes_always_raises(self) -> None:
        """Test delete_all_access_codes always raises MinimumAccessCodeError."""
        mock_request = AsyncMock()
        device = Device(mock_request)

        with pytest.raises(MinimumAccessCodeError) as exc_info:
            await device.delete_all_access_codes(device_id="SN12345")

        assert exc_info.value.device_id == "SN12345"
        assert "Cannot delete all access codes" in str(exc_info.value)
        # The request should never be called
        mock_request.assert_not_called()

    @pytest.mark.asyncio
    async def test_delete_all_access_codes_empty_device_id(self) -> None:
        """Test empty device_id raises ValueError before MinimumAccessCodeError."""
        mock_request = AsyncMock()
        device = Device(mock_request)

        with pytest.raises(ValueError, match="device_id cannot be empty"):
            await device.delete_all_access_codes(device_id="")

    @pytest.mark.asyncio
    async def test_delete_all_access_codes_error_message(self) -> None:
        """Test error message mentions deleting all codes."""
        mock_request = AsyncMock()
        device = Device(mock_request)

        with pytest.raises(MinimumAccessCodeError, match="Cannot delete all access codes"):
            await device.delete_all_access_codes(device_id="SN12345")


class TestDeviceGetAccessCodes:
    """Tests for Device.get_access_codes method."""

    @pytest.mark.asyncio
    async def test_get_access_codes_success(self) -> None:
        """Test getting access codes returns list of dicts."""
        mock_request = AsyncMock(return_value=MOCK_ACCESS_CODES_MULTIPLE)
        device = Device(mock_request)

        result = await device.get_access_codes(device_id="SN12345")

        assert isinstance(result, list)
        assert len(result) == 2
        assert result[0]["token"] == "90250706"
        assert result[1]["token"] == "12345678"
        mock_request.assert_called_once()
        call_args = mock_request.call_args
        assert call_args[0][0] == "get"
        assert "SN12345" in call_args[0][1]

    @pytest.mark.asyncio
    async def test_get_access_codes_empty(self) -> None:
        """Test getting access codes returns empty list when no codes exist."""
        mock_request = AsyncMock(return_value=MOCK_ACCESS_CODES_EMPTY)
        device = Device(mock_request)

        result = await device.get_access_codes(device_id="SN12345")

        assert result == []

    @pytest.mark.asyncio
    async def test_get_access_codes_empty_device_id(self) -> None:
        """Test empty device_id raises ValueError."""
        mock_request = AsyncMock()
        device = Device(mock_request)

        with pytest.raises(ValueError, match="device_id cannot be empty"):
            await device.get_access_codes(device_id="")

    @pytest.mark.asyncio
    async def test_get_access_codes_missing_data_key(self) -> None:
        """Test response without data key returns empty list."""
        mock_request = AsyncMock(return_value={"total": 0})
        device = Device(mock_request)

        result = await device.get_access_codes(device_id="SN12345")

        assert result == []


class TestDeviceGetAccessCodeStatus:
    """Tests for Device.get_access_code_status method."""

    @pytest.mark.asyncio
    async def test_get_access_code_status_success(self) -> None:
        """Test getting access code status successfully."""
        mock_request = AsyncMock(return_value=MOCK_ACCESS_CODE_STATUS_RESPONSE)
        device = Device(mock_request)

        result = await device.get_access_code_status(device_id="SN12345", token="90250706")

        assert result == MOCK_ACCESS_CODE_STATUS_RESPONSE
        mock_request.assert_called_once()
        call_args = mock_request.call_args
        assert call_args[0][0] == "get"
        assert "SN12345" in call_args[0][1]
        assert "90250706" in call_args[0][1]

    @pytest.mark.asyncio
    async def test_get_access_code_status_empty_device_id(self) -> None:
        """Test empty device_id raises ValueError."""
        mock_request = AsyncMock()
        device = Device(mock_request)

        with pytest.raises(ValueError, match="device_id cannot be empty"):
            await device.get_access_code_status(device_id="", token="abc")

    @pytest.mark.asyncio
    async def test_get_access_code_status_empty_token(self) -> None:
        """Test empty token raises ValueError."""
        mock_request = AsyncMock()
        device = Device(mock_request)

        with pytest.raises(ValueError, match="token cannot be empty"):
            await device.get_access_code_status(device_id="SN12345", token="")


class TestDeviceDisableAccessCode:
    """Tests for Device.disable_access_code method."""

    @pytest.mark.asyncio
    async def test_disable_access_code_success(self) -> None:
        """Test disabling an access code successfully."""
        mock_request = AsyncMock(return_value=MOCK_ACCESS_CODE_RESPONSE)
        device = Device(mock_request)

        schedule = AccessCodeSchedule(schedule_type=ScheduleType.ALL_DAY)
        result = await device.disable_access_code(
            device_id="SN12345",
            code="1234",
            name="Test",
            schedule=schedule,
            slot=5,
        )

        assert isinstance(result, AccessCodeResult)
        assert result.token == "90250706"
        mock_request.assert_called_once()
        call_args = mock_request.call_args
        assert call_args[0][0] == "patch"

    @pytest.mark.asyncio
    async def test_disable_access_code_sets_enabled_false(self) -> None:
        """Test that disable sends enabled=False in the TLV8 message."""
        mock_request = AsyncMock(return_value=MOCK_ACCESS_CODE_RESPONSE)
        device = Device(mock_request)

        schedule = AccessCodeSchedule(schedule_type=ScheduleType.ALL_DAY)
        await device.disable_access_code(
            device_id="SN12345",
            code="1234",
            name="Test",
            schedule=schedule,
            slot=5,
        )

        call_kwargs = mock_request.call_args[1]
        payload = json.loads(call_kwargs["data"])
        raw = base64.b64decode(payload["message"])
        # Byte [3] is the status byte: (schedType << 4) | enabled
        # AllDay=1, enabled=0 → (1 << 4) | 0 = 0x10
        assert raw[3] == 0x10

    @pytest.mark.asyncio
    async def test_disable_access_code_verified_capture(self) -> None:
        """Test disable produces the exact bytes from captured network traffic."""
        mock_request = AsyncMock(return_value=MOCK_ACCESS_CODE_RESPONSE)
        device = Device(mock_request)

        schedule = AccessCodeSchedule(
            schedule_type=ScheduleType.DATE_RANGE,
            start_hour=13,
            start_minute=35,
            end_hour=14,
            end_minute=45,
            start_month=2,
            start_day=14,
            start_year=2026,
            end_month=2,
            end_day=14,
            end_year=2026,
        )
        await device.disable_access_code(
            device_id="1041d5b8b6eb2ea917",
            code="163950",
            name="HA",
            schedule=schedule,
            slot=11,
        )

        call_kwargs = mock_request.call_args[1]
        payload = json.loads(call_kwargs["data"])
        assert payload["message"] == "AAULMBY5UAEHjG1zIs5pNAICSEE="

    @pytest.mark.asyncio
    async def test_disable_access_code_empty_device_id(self) -> None:
        """Test empty device_id raises ValueError."""
        mock_request = AsyncMock()
        device = Device(mock_request)

        schedule = AccessCodeSchedule(schedule_type=ScheduleType.ALL_DAY)
        with pytest.raises(ValueError, match="device_id cannot be empty"):
            await device.disable_access_code(
                device_id="",
                code="1234",
                name="Test",
                schedule=schedule,
                slot=0,
            )


class TestDeviceEnableAccessCode:
    """Tests for Device.enable_access_code method."""

    @pytest.mark.asyncio
    async def test_enable_access_code_success(self) -> None:
        """Test enabling an access code successfully."""
        mock_request = AsyncMock(return_value=MOCK_ACCESS_CODE_RESPONSE)
        device = Device(mock_request)

        schedule = AccessCodeSchedule(schedule_type=ScheduleType.ALL_DAY)
        result = await device.enable_access_code(
            device_id="SN12345",
            code="1234",
            name="Test",
            schedule=schedule,
            slot=5,
        )

        assert isinstance(result, AccessCodeResult)
        assert result.token == "90250706"
        mock_request.assert_called_once()
        call_args = mock_request.call_args
        assert call_args[0][0] == "patch"

    @pytest.mark.asyncio
    async def test_enable_access_code_sets_enabled_true(self) -> None:
        """Test that enable sends enabled=True in the TLV8 message."""
        mock_request = AsyncMock(return_value=MOCK_ACCESS_CODE_RESPONSE)
        device = Device(mock_request)

        schedule = AccessCodeSchedule(schedule_type=ScheduleType.ALL_DAY)
        await device.enable_access_code(
            device_id="SN12345",
            code="1234",
            name="Test",
            schedule=schedule,
            slot=5,
        )

        call_kwargs = mock_request.call_args[1]
        payload = json.loads(call_kwargs["data"])
        raw = base64.b64decode(payload["message"])
        # Byte [3] is the status byte: (schedType << 4) | enabled
        # AllDay=1, enabled=1 → (1 << 4) | 1 = 0x11
        assert raw[3] == 0x11

    @pytest.mark.asyncio
    async def test_enable_access_code_empty_device_id(self) -> None:
        """Test empty device_id raises ValueError."""
        mock_request = AsyncMock()
        device = Device(mock_request)

        schedule = AccessCodeSchedule(schedule_type=ScheduleType.ALL_DAY)
        with pytest.raises(ValueError, match="device_id cannot be empty"):
            await device.enable_access_code(
                device_id="",
                code="1234",
                name="Test",
                schedule=schedule,
                slot=0,
            )


# ============================================================================
# MqttCommandType Enum Tests
# ============================================================================


class TestMqttCommandType:
    """Tests for MqttCommandType enum."""

    def test_set_access_code_value(self) -> None:
        """Test SET_ACCESS_CODE has correct value."""
        assert MqttCommandType.SET_ACCESS_CODE == 0x11

    def test_edit_access_code_value(self) -> None:
        """Test EDIT_ACCESS_CODE has correct value."""
        assert MqttCommandType.EDIT_ACCESS_CODE == 0x15

    def test_delete_access_code_value(self) -> None:
        """Test DELETE_ACCESS_CODE has correct value."""
        assert MqttCommandType.DELETE_ACCESS_CODE == 0x19


# ============================================================================
# LockMqResponse Dataclass Tests
# ============================================================================


class TestLockMqResponse:
    """Tests for LockMqResponse dataclass."""

    def test_frozen_dataclass(self) -> None:
        """Test that the dataclass is frozen (immutable)."""
        resp = LockMqResponse(
            crc="12345678", command=MqttCommandType.SET_ACCESS_CODE, slot_index=10
        )
        with pytest.raises(AttributeError):
            resp.crc = "other"  # type: ignore[misc]

    def test_default_none_fields(self) -> None:
        """Test default None values for optional fields."""
        resp = LockMqResponse(crc="12345678", command=MqttCommandType.DELETE_ACCESS_CODE)
        assert resp.slot_index is None
        assert resp.schedule_type is None
        assert resp.enabled is None


# ============================================================================
# parse_lock_mq_response Tests
# ============================================================================


class TestParseLockMqResponse:
    """Tests for parse_lock_mq_response function."""

    # -- Real captured data tests --

    def test_create_no_expire_code(self) -> None:
        """Verified capture: No Expire Code 553059."""
        result = parse_lock_mq_response("37659173,03010A,11")
        assert result.crc == "37659173"
        assert result.command == MqttCommandType.SET_ACCESS_CODE
        assert result.schedule_type == 3  # DateRange
        assert result.enabled is True
        assert result.slot_index == 10

    def test_create_second_code(self) -> None:
        """Verified capture: Code 334715 without deleting first."""
        result = parse_lock_mq_response("98071819,03010C,11")
        assert result.crc == "98071819"
        assert result.command == MqttCommandType.SET_ACCESS_CODE
        assert result.schedule_type == 3
        assert result.enabled is True
        assert result.slot_index == 12  # Skipped slot 11 (occupied)

    def test_disable_code(self) -> None:
        """Verified capture: Disable code 553059."""
        result = parse_lock_mq_response("22585319,,15")
        assert result.crc == "22585319"
        assert result.command == MqttCommandType.EDIT_ACCESS_CODE
        assert result.slot_index is None
        assert result.schedule_type is None
        assert result.enabled is None

    def test_delete_code(self) -> None:
        """Verified capture: Delete access code."""
        result = parse_lock_mq_response("24311576,,19")
        assert result.crc == "24311576"
        assert result.command == MqttCommandType.DELETE_ACCESS_CODE
        assert result.slot_index is None

    # -- Earlier captures --

    def test_earlier_create_capture(self) -> None:
        """Test earlier create capture data."""
        result = parse_lock_mq_response("18134323,03010A,11")
        assert result.command == MqttCommandType.SET_ACCESS_CODE
        assert result.slot_index == 10

    def test_earlier_delete_capture(self) -> None:
        """Test earlier delete capture data."""
        result = parse_lock_mq_response("53228666,,19")
        assert result.command == MqttCommandType.DELETE_ACCESS_CODE

    # -- Error cases --

    def test_empty_string_raises(self) -> None:
        """Test empty string raises ValueError."""
        with pytest.raises(ValueError, match="non-empty"):
            parse_lock_mq_response("")

    def test_none_raises(self) -> None:
        """Test None raises ValueError."""
        with pytest.raises(ValueError, match="non-empty"):
            parse_lock_mq_response(None)  # type: ignore[arg-type]

    def test_wrong_field_count_raises(self) -> None:
        """Test wrong number of fields raises ValueError."""
        with pytest.raises(ValueError, match="3 comma-separated"):
            parse_lock_mq_response("12345678,11")

    def test_empty_crc_raises(self) -> None:
        """Test empty CRC field raises ValueError."""
        with pytest.raises(ValueError, match="CRC field"):
            parse_lock_mq_response(",03010A,11")

    def test_empty_command_raises(self) -> None:
        """Test empty command field raises ValueError."""
        with pytest.raises(ValueError, match="Command field"):
            parse_lock_mq_response("12345678,03010A,")

    def test_unknown_command_raises(self) -> None:
        """Test unknown command type raises ValueError."""
        with pytest.raises(ValueError, match="Unknown MQTT command"):
            parse_lock_mq_response("12345678,03010A,FF")

    # -- Edge cases --

    def test_allday_schedule_type(self) -> None:
        """AllDay schedule would have type 1."""
        result = parse_lock_mq_response("AABBCCDD,01010A,11")
        assert result.schedule_type == 1  # AllDay
        assert result.enabled is True
        assert result.slot_index == 10

    def test_disabled_flag_in_status(self) -> None:
        """Status with enabled=0."""
        result = parse_lock_mq_response("AABBCCDD,030005,11")
        assert result.enabled is False
        assert result.slot_index == 5

    def test_invalid_hex_in_status_raises(self) -> None:
        """Non-hex characters in status field should raise ValueError."""
        with pytest.raises(ValueError, match="Invalid hex in status field"):
            parse_lock_mq_response("12345678,ZZZZZZ,11")

    def test_high_slot_index(self) -> None:
        """Slot index near maximum (250 is max valid)."""
        result = parse_lock_mq_response("AABBCCDD,0301FA,11")
        assert result.slot_index == 250


# ============================================================================
# SingleAccessCodeCrc Dataclass Tests
# ============================================================================


class TestSingleAccessCodeCrc:
    """Tests for SingleAccessCodeCrc dataclass."""

    def test_frozen_dataclass(self) -> None:
        """Test that the dataclass is frozen (immutable)."""
        crc = SingleAccessCodeCrc(
            raw="ABCD0Atoken123", slot_index=10, crc_prefix="ABCD", crc_token="token123"
        )
        with pytest.raises(AttributeError):
            crc.raw = "other"  # type: ignore[misc]

    def test_fields(self) -> None:
        """Test that all fields are accessible and correct."""
        crc = SingleAccessCodeCrc(
            raw="ABCD0Atoken123", slot_index=10, crc_prefix="ABCD", crc_token="token123"
        )
        assert crc.raw == "ABCD0Atoken123"
        assert crc.slot_index == 10
        assert crc.crc_prefix == "ABCD"
        assert crc.crc_token == "token123"


# ============================================================================
# parse_single_access_code_crc Tests
# ============================================================================


class TestParseSingleAccessCodeCrc:
    """Tests for parse_single_access_code_crc function."""

    def test_slot_index_0a(self) -> None:
        """Slot 10 (0x0A) — matches captured lockmqresponse '37659173,03010A,11'."""
        result = parse_single_access_code_crc("XXXX0Arestoftoken")
        assert result.slot_index == 10
        assert result.crc_prefix == "XXXX"
        assert result.crc_token == "restoftoken"
        assert result.raw == "XXXX0Arestoftoken"

    def test_slot_index_0c(self) -> None:
        """Slot 12 (0x0C) — matches captured lockmqresponse '98071819,03010C,11'."""
        result = parse_single_access_code_crc("YYYY0Crestoftoken")
        assert result.slot_index == 12

    def test_slot_index_01(self) -> None:
        """Lowest meaningful slot."""
        result = parse_single_access_code_crc("AAAA01rest")
        assert result.slot_index == 1

    def test_slot_index_fa(self) -> None:
        """Highest valid slot (250)."""
        result = parse_single_access_code_crc("AAAAFArest")
        assert result.slot_index == 250

    def test_slot_index_00(self) -> None:
        """Slot 0."""
        result = parse_single_access_code_crc("ZZZZ00rest")
        assert result.slot_index == 0

    def test_slot_index_ff(self) -> None:
        """Slot 255 (0xFF)."""
        result = parse_single_access_code_crc("XXXXFFrest")
        assert result.slot_index == 255

    def test_minimum_length_exactly_6(self) -> None:
        """Minimum valid string is exactly 6 characters."""
        result = parse_single_access_code_crc("ABCD0A")
        assert result.slot_index == 10
        assert result.crc_prefix == "ABCD"
        assert result.crc_token == ""

    def test_long_crc_string(self) -> None:
        """Long CRC string preserves full token."""
        raw = "PREF0B" + "a" * 100
        result = parse_single_access_code_crc(raw)
        assert result.slot_index == 11
        assert result.crc_token == "a" * 100

    def test_lowercase_hex(self) -> None:
        """Lowercase hex should parse correctly."""
        result = parse_single_access_code_crc("abcd0atoken")
        assert result.slot_index == 10

    # -- Error cases --

    def test_empty_string_raises(self) -> None:
        """Test empty string raises ValueError."""
        with pytest.raises(ValueError, match="non-empty"):
            parse_single_access_code_crc("")

    def test_none_raises(self) -> None:
        """Test None input raises ValueError."""
        with pytest.raises(ValueError, match="non-empty"):
            parse_single_access_code_crc(None)  # type: ignore[arg-type]

    def test_too_short_raises(self) -> None:
        """Test string shorter than 6 chars raises ValueError."""
        with pytest.raises(ValueError, match="at least 6 characters"):
            parse_single_access_code_crc("ABCDE")

    def test_5_chars_raises(self) -> None:
        """Test exactly 5 chars raises ValueError."""
        with pytest.raises(ValueError, match="at least 6 characters"):
            parse_single_access_code_crc("12345")

    def test_invalid_hex_at_slot_position(self) -> None:
        """Test non-hex characters at positions [4:6] raises ValueError."""
        with pytest.raises(ValueError, match="Invalid hex in slot index"):
            parse_single_access_code_crc("ABCDZZrest")
