from unittest.mock import AsyncMock, MagicMock

import pytest
from bleak.backends.device import BLEDevice

from switchbot import SwitchBotAdvertisement, SwitchbotModel
from switchbot.const.light import ColorMode
from switchbot.devices import light_strip
from switchbot.devices.base_light import SwitchbotBaseLight
from switchbot.devices.device import SwitchbotOperationError

from . import (
    CANDLE_WARMER_LAMP_INFO,
    FLOOR_LAMP_INFO,
    PERMANENT_OUTDOOR_LIGHT_INFO,
    RGBIC_NEON_LIGHT_INFO,
    RGBICWW_CEILING_LIGHT_INFO,
    RGBICWW_FLOOR_LAMP_INFO,
    RGBICWW_STRIP_LIGHT_INFO,
    STRIP_LIGHT_3_INFO,
)
from .test_adv_parser import AdvTestCase, generate_ble_device

ALL_LIGHT_CASES = [
    (STRIP_LIGHT_3_INFO, light_strip.SwitchbotStripLight3),
    (FLOOR_LAMP_INFO, light_strip.SwitchbotStripLight3),
    (CANDLE_WARMER_LAMP_INFO, light_strip.SwitchbotCandleWarmerLamp),
    (RGBICWW_STRIP_LIGHT_INFO, light_strip.SwitchbotRgbicLight),
    (RGBICWW_FLOOR_LAMP_INFO, light_strip.SwitchbotRgbicLight),
    (PERMANENT_OUTDOOR_LIGHT_INFO, light_strip.SwitchbotPermanentOutdoorLight),
    (RGBIC_NEON_LIGHT_INFO, light_strip.SwitchbotRgbicNeonLight),
    (RGBICWW_CEILING_LIGHT_INFO, light_strip.SwitchbotRgbicwwCeilingLight),
]

# RGB + color-temp devices. Excludes brightness-only lights (CWL) and
# RGB-only lights (RGBIC Neon) whose color_modes differ from the rest.
RGB_LIGHT_CASES = [
    case
    for case in ALL_LIGHT_CASES
    if case[1]
    not in (light_strip.SwitchbotCandleWarmerLamp, light_strip.SwitchbotRgbicNeonLight)
]


@pytest.fixture(params=RGB_LIGHT_CASES)
def device_case(request):
    return request.param


@pytest.fixture
def expected_effects(device_case):
    adv_info, _dev_cls = device_case
    EXPECTED = {
        SwitchbotModel.STRIP_LIGHT_3: ("christmas", "halloween", "sunset"),
        SwitchbotModel.FLOOR_LAMP: ("christmas", "halloween", "sunset"),
        SwitchbotModel.RGBICWW_STRIP_LIGHT: ("romance", "energy", "heartbeat"),
        SwitchbotModel.RGBICWW_FLOOR_LAMP: ("romance", "energy", "heartbeat"),
        SwitchbotModel.RGBICWW_CEILING_LIGHT: ("romance", "energy", "heartbeat"),
        SwitchbotModel.PERMANENT_OUTDOOR_LIGHT: ("romance", "energy", "heartbeat"),
    }
    return EXPECTED[adv_info.modelName]


def create_device_for_command_testing(
    adv_info: AdvTestCase,
    dev_cls: type[SwitchbotBaseLight],
    init_data: dict | None = None,
):
    ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
    device = dev_cls(
        ble_device, "ff", "ffffffffffffffffffffffffffffffff", model=adv_info.modelName
    )
    device.update_from_advertisement(
        make_advertisement_data(ble_device, adv_info, init_data)
    )
    device._send_command = AsyncMock()
    device._check_command_result = MagicMock()
    device.update = AsyncMock()
    return device


def make_advertisement_data(
    ble_device: BLEDevice, adv_info: AdvTestCase, init_data: dict | None = None
):
    """Set advertisement data with defaults."""
    if init_data is None:
        init_data = {}

    return SwitchBotAdvertisement(
        address="aa:bb:cc:dd:ee:ff",
        data={
            "rawAdvData": adv_info.service_data,
            "data": adv_info.data | init_data,
            "isEncrypted": False,
            "model": adv_info.model,
            "modelFriendlyName": adv_info.modelFriendlyName,
            "modelName": adv_info.modelName,
        },
        device=ble_device,
        rssi=-80,
        active=True,
    )


@pytest.mark.asyncio
async def test_default_info(device_case, expected_effects):
    """Test default initialization of the strip light."""
    adv_info, dev_cls = device_case
    device = create_device_for_command_testing(adv_info, dev_cls)

    assert device.rgb is None

    device._state = {"r": 30, "g": 0, "b": 0, "cw": 3200}

    assert device.is_on() is True
    assert device.on is True
    assert device.color_mode == ColorMode.RGB
    assert device.color_modes == {ColorMode.RGB, ColorMode.COLOR_TEMP}
    assert device.rgb == (30, 0, 0)
    assert device.color_temp == 3200
    assert device.brightness == adv_info.data["brightness"]
    assert device.min_temp == 2700
    assert device.max_temp == 6500
    # Check that effect list contains expected lowercase effect names
    effect_list = device.get_effect_list
    assert effect_list is not None
    assert all(effect.islower() for effect in effect_list)
    # Verify some known effects are present
    for effect in expected_effects:
        assert effect in effect_list


@pytest.mark.asyncio
async def test_rgbic_neon_light_info() -> None:
    """Test color_mode / color_modes on SwitchbotRgbicNeonLight (RGB only)."""
    adv_info, dev_cls = RGBIC_NEON_LIGHT_INFO, light_strip.SwitchbotRgbicNeonLight
    device = create_device_for_command_testing(adv_info, dev_cls)
    assert device.color_mode == ColorMode.RGB
    assert device.color_modes == {ColorMode.RGB}


@pytest.mark.asyncio
async def test_candle_warmer_lamp_info() -> None:
    """Test default initialization of the candle warmer lamp."""
    adv_info, dev_cls = CANDLE_WARMER_LAMP_INFO, light_strip.SwitchbotCandleWarmerLamp
    device = create_device_for_command_testing(adv_info, dev_cls)
    assert device.rgb is None
    assert device.is_on() is True
    assert device.on is True
    assert device.color_mode == ColorMode.BRIGHTNESS
    assert device.color_modes == {
        ColorMode.BRIGHTNESS,
    }
    assert device.brightness == adv_info.data["brightness"]
    assert device.get_effect_list is None


@pytest.mark.asyncio
async def test_candle_warmer_lamp_unsupported_operations() -> None:
    """Test that RGB/color-temp/effect operations are not supported on CWL."""
    device = create_device_for_command_testing(
        CANDLE_WARMER_LAMP_INFO, light_strip.SwitchbotCandleWarmerLamp
    )
    with pytest.raises(
        SwitchbotOperationError,
        match="does not support this functionality",
    ):
        await device.set_rgb(100, 255, 128, 64)
    with pytest.raises(
        SwitchbotOperationError,
        match="does not support this functionality",
    ):
        await device.set_color_temp(100, 4000)
    with pytest.raises(SwitchbotOperationError, match="not supported"):
        await device.set_effect("sunset")


@pytest.mark.asyncio
@pytest.mark.parametrize(
    ("basic_info", "version_info"), [(True, False), (False, True), (False, False)]
)
@pytest.mark.parametrize(("adv_info", "dev_cls"), ALL_LIGHT_CASES)
async def test_get_basic_info_returns_none(
    basic_info, version_info, adv_info, dev_cls
) -> None:
    """Test that get_basic_info returns None if no data is available."""
    device = create_device_for_command_testing(adv_info, dev_cls)

    device._send_command = AsyncMock(side_effect=[version_info, basic_info])
    device._check_command_result = MagicMock(
        side_effect=[bool(version_info), bool(basic_info)]
    )

    assert await device.get_basic_info() is None


@pytest.mark.asyncio
@pytest.mark.parametrize(
    "device_case",
    [
        case
        for case in RGB_LIGHT_CASES
        if case[1] is not light_strip.SwitchbotRgbicwwCeilingLight
    ],
)
@pytest.mark.parametrize(
    ("info_data", "result"),
    [
        (
            {
                "basic_info": b"\x01\x00<\xff\x00\xd8\x00\x19d\x00\x03",
                "version_info": b"\x01\x01\n",
            },
            [False, 60, 255, 0, 216, 6500, 3, 1.0],
        ),
        (
            {
                "basic_info": b"\x01\x80NK\xff:\x00\x19d\xff\x02",
                "version_info": b"\x01\x01\n",
            },
            [True, 78, 75, 255, 58, 6500, 2, 1.0],
        ),
        (
            {
                "basic_info": b"\x01\x80$K\xff:\x00\x13\xf9\xff\x06",
                "version_info": b"\x01\x01\n",
            },
            [True, 36, 75, 255, 58, 5113, 6, 1.0],
        ),
    ],
)
async def test_strip_light_get_basic_info(info_data, result, device_case):
    """Test getting basic info from the strip light."""
    adv_info, dev_cls = device_case
    device = create_device_for_command_testing(adv_info, dev_cls)

    device._send_command = AsyncMock(
        side_effect=[info_data["version_info"], info_data["basic_info"]]
    )
    device._check_command_result = MagicMock(side_effect=[True, True])
    info = await device.get_basic_info()

    assert info["isOn"] is result[0]
    assert info["brightness"] == result[1]
    assert info["r"] == result[2]
    assert info["g"] == result[3]
    assert info["b"] == result[4]
    assert info["cw"] == result[5]
    assert info["color_mode"] == result[6]
    assert info["firmware"] == result[7]


@pytest.mark.asyncio
async def test_set_color_temp(device_case):
    """Test setting color temperature."""
    adv_info, dev_cls = device_case
    device = create_device_for_command_testing(adv_info, dev_cls)

    await device.set_color_temp(50, 3000)

    device._send_command.assert_called_with(
        device._set_color_temp_command.format("320BB8")
    )


@pytest.mark.asyncio
async def test_turn_on(device_case):
    """Test turning on the strip light."""
    init_data = {"isOn": True}
    adv_info, dev_cls = device_case
    device = create_device_for_command_testing(adv_info, dev_cls, init_data)

    await device.turn_on()

    device._send_command.assert_called_with(device._turn_on_command)

    assert device.is_on() is True


@pytest.mark.asyncio
async def test_turn_off(device_case):
    """Test turning off the strip light."""
    init_data = {"isOn": False}
    adv_info, dev_cls = device_case
    device = create_device_for_command_testing(adv_info, dev_cls, init_data)

    await device.turn_off()

    device._send_command.assert_called_with(device._turn_off_command)

    assert device.is_on() is False


@pytest.mark.asyncio
async def test_set_brightness(device_case):
    """Test setting brightness."""
    adv_info, dev_cls = device_case
    device = create_device_for_command_testing(adv_info, dev_cls)

    await device.set_brightness(75)

    device._send_command.assert_called_with(device._set_brightness_command.format("4B"))


@pytest.mark.asyncio
async def test_set_rgb(device_case):
    """Test setting RGB values."""
    adv_info, dev_cls = device_case
    device = create_device_for_command_testing(adv_info, dev_cls)

    await device.set_rgb(100, 255, 128, 64)

    device._send_command.assert_called_with(device._set_rgb_command.format("64FF8040"))


@pytest.mark.asyncio
async def test_set_effect_with_invalid_effect(device_case):
    """Test setting an invalid effect."""
    adv_info, dev_cls = device_case
    device = create_device_for_command_testing(adv_info, dev_cls)

    with pytest.raises(
        SwitchbotOperationError, match="Effect invalid_effect not supported"
    ):
        await device.set_effect("invalid_effect")


@pytest.mark.asyncio
async def test_set_effect_with_valid_effect(device_case):
    """Test setting a valid effect."""
    adv_info, dev_cls = device_case
    device = create_device_for_command_testing(adv_info, dev_cls)
    device._send_multiple_commands = AsyncMock()

    await device.set_effect("christmas")

    device._send_multiple_commands.assert_called_with(device._effect_dict["christmas"])

    assert device.get_effect() == "christmas"


@pytest.mark.asyncio
async def test_effect_list_contains_lowercase_names(device_case, expected_effects):
    """Test that all effect names in get_effect_list are lowercase."""
    adv_info, dev_cls = device_case
    device = create_device_for_command_testing(adv_info, dev_cls)

    effect_list = device.get_effect_list

    assert effect_list is not None, "Effect list should not be None"
    # All effect names should be lowercase
    for effect_name in effect_list:
        assert effect_name.islower(), f"Effect name '{effect_name}' is not lowercase"
    # Verify some known effects are present
    for expected_effect in expected_effects:
        assert expected_effect in effect_list, (
            f"Expected effect '{expected_effect}' not found"
        )


@pytest.mark.asyncio
async def test_set_effect_normalizes_case(device_case):
    """Test that set_effect normalizes effect names to lowercase."""
    adv_info, dev_cls = device_case
    device = create_device_for_command_testing(adv_info, dev_cls)
    device._send_multiple_commands = AsyncMock()

    # Test various case combinations
    test_cases = ["CHRISTMAS", "Christmas", "ChRiStMaS", "christmas"]

    for test_effect in test_cases:
        await device.set_effect(test_effect)
        # Should always work regardless of case
        device._send_multiple_commands.assert_called()
        assert device.get_effect() == test_effect  # Stored as provided


@pytest.mark.parametrize(
    ("dev_cls", "expected_model"),
    [
        (light_strip.SwitchbotStripLight3, SwitchbotModel.STRIP_LIGHT_3),
        (light_strip.SwitchbotRgbicLight, SwitchbotModel.RGBICWW_STRIP_LIGHT),
        (
            light_strip.SwitchbotPermanentOutdoorLight,
            SwitchbotModel.PERMANENT_OUTDOOR_LIGHT,
        ),
        (light_strip.SwitchbotRgbicNeonLight, SwitchbotModel.RGBIC_NEON_ROPE_LIGHT),
        (light_strip.SwitchbotCandleWarmerLamp, SwitchbotModel.CANDLE_WARMER_LAMP),
        (
            light_strip.SwitchbotRgbicwwCeilingLight,
            SwitchbotModel.RGBICWW_CEILING_LIGHT,
        ),
    ],
)
def test_default_model_classvar(dev_cls, expected_model):
    ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
    device = dev_cls(ble_device, "ff", "ffffffffffffffffffffffffffffffff")
    assert device._model == expected_model


def create_strip_light_device(init_data: dict | None = None):
    ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
    return light_strip.SwitchbotLightStrip(ble_device)


@pytest.mark.asyncio
async def test_strip_light_supported_color_modes():
    """Test that the strip light supports the expected color modes."""
    device = create_strip_light_device()

    assert device.color_modes == {
        ColorMode.RGB,
    }


@pytest.mark.asyncio
@pytest.mark.parametrize(
    ("commands", "results", "final_result"),
    [
        (("command1", "command2"), [(b"\x01", False), (None, False)], False),
        (("command1", "command2"), [(None, False), (b"\x01", True)], True),
        (("command1", "command2"), [(b"\x01", True), (b"\x01", False)], True),
    ],
)
async def test_send_multiple_commands(commands, results, final_result, device_case):
    """Test sending multiple commands."""
    adv_info, dev_cls = device_case
    device = create_device_for_command_testing(adv_info, dev_cls)

    device._send_command = AsyncMock(side_effect=[r[0] for r in results])

    device._check_command_result = MagicMock(side_effect=[r[1] for r in results])

    result = await device._send_multiple_commands(list(commands))

    assert result is final_result


@pytest.mark.asyncio
async def test_unimplemented_color_mode():
    class TestDevice(SwitchbotBaseLight):
        pass

    ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
    device = TestDevice(ble_device)

    with pytest.raises(NotImplementedError):
        _ = device.color_mode


@pytest.mark.asyncio
async def test_exception_with_wrong_model():
    class TestDevice(SwitchbotBaseLight):
        def __init__(self, device: BLEDevice, model: str = "unknown") -> None:
            super().__init__(device, model=model)

    ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
    device = TestDevice(ble_device)

    with pytest.raises(
        SwitchbotOperationError,
        match="Current device aa:bb:cc:dd:ee:ff does not support this functionality",
    ):
        await device.set_rgb(100, 255, 128, 64)


@pytest.mark.asyncio
@pytest.mark.parametrize(
    ("color_mode_value", "expected_color_mode"),
    [
        (1, ColorMode.EFFECT),  # SEGMENTED
        (2, ColorMode.RGB),
        (3, ColorMode.EFFECT),  # SCENE
        (4, ColorMode.EFFECT),  # MUSIC
        (5, ColorMode.EFFECT),  # CONTROLLER
        (6, ColorMode.COLOR_TEMP),
        (7, ColorMode.EFFECT),  # EFFECT (RGBIC-specific)
        (10, ColorMode.OFF),  # UNKNOWN
    ],
)
async def test_permanent_outdoor_light_color_mode(
    color_mode_value, expected_color_mode
):
    """Test that POL correctly handles all RGBICStripLightColorMode values including EFFECT (7)."""
    device = create_device_for_command_testing(
        PERMANENT_OUTDOOR_LIGHT_INFO,
        light_strip.SwitchbotPermanentOutdoorLight,
        init_data={"color_mode": color_mode_value},
    )
    assert device.color_mode == expected_color_mode


@pytest.mark.asyncio
async def test_rgbicww_ceiling_light_main_sub_light_state() -> None:
    """Test the main (warm-white) sub-light state surface."""
    device = create_device_for_command_testing(
        RGBICWW_CEILING_LIGHT_INFO, light_strip.SwitchbotRgbicwwCeilingLight
    )
    assert device.is_main_on is True
    assert device.main_brightness == 32


@pytest.mark.asyncio
@pytest.mark.parametrize(
    ("method", "expected_command_attr"),
    [
        ("turn_on_main", "_turn_on_main_command"),
        ("turn_off_main", "_turn_off_main_command"),
        ("turn_on_color", "_turn_on_color_command"),
        ("turn_off_color", "_turn_off_color_command"),
    ],
)
async def test_rgbicww_ceiling_light_sub_light_power(
    method: str, expected_command_attr: str
) -> None:
    """Test the per-sub-light power commands."""
    device = create_device_for_command_testing(
        RGBICWW_CEILING_LIGHT_INFO, light_strip.SwitchbotRgbicwwCeilingLight
    )
    await getattr(device, method)()
    device._send_command.assert_called_with(getattr(device, expected_command_attr))


@pytest.mark.asyncio
async def test_rgbicww_ceiling_light_set_main_brightness() -> None:
    """Test setting main (warm-white) sub-light brightness."""
    device = create_device_for_command_testing(
        RGBICWW_CEILING_LIGHT_INFO, light_strip.SwitchbotRgbicwwCeilingLight
    )
    await device.set_main_brightness(75)
    device._send_command.assert_called_with(
        device._set_main_brightness_command.format("4B")
    )


@pytest.mark.asyncio
async def test_rgbicww_ceiling_light_set_main_color_temp() -> None:
    """Test setting main (warm-white) sub-light color temperature."""
    device = create_device_for_command_testing(
        RGBICWW_CEILING_LIGHT_INFO, light_strip.SwitchbotRgbicwwCeilingLight
    )
    await device.set_main_color_temp(3000)
    device._send_command.assert_called_with(
        device._set_main_color_temp_command.format("0BB8")
    )


@pytest.mark.asyncio
async def test_rgbicww_ceiling_light_color_modes() -> None:
    """Test the supported color modes set."""
    device = create_device_for_command_testing(
        RGBICWW_CEILING_LIGHT_INFO, light_strip.SwitchbotRgbicwwCeilingLight
    )
    assert device.color_modes == {ColorMode.RGB, ColorMode.COLOR_TEMP}


@pytest.mark.asyncio
@pytest.mark.parametrize(
    ("color_mode_value", "expected_color_mode"),
    [
        (1, ColorMode.EFFECT),  # SEGMENTED
        (2, ColorMode.RGB),  # COLOR
        (3, ColorMode.EFFECT),  # SCENE
        (4, ColorMode.EFFECT),  # MUSIC
        (5, ColorMode.EFFECT),  # CONTROLLER
        (6, ColorMode.COLOR_TEMP),  # WARMWHITE
        (7, ColorMode.EFFECT),  # EFFECT
        (10, ColorMode.OFF),  # UNKNOWN
    ],
)
async def test_rgbicww_ceiling_light_color_mode(
    color_mode_value, expected_color_mode
) -> None:
    """Test color_mode mapping for the ceiling light."""
    device = create_device_for_command_testing(
        RGBICWW_CEILING_LIGHT_INFO,
        light_strip.SwitchbotRgbicwwCeilingLight,
        init_data={"color_mode": color_mode_value},
    )
    assert device.color_mode == expected_color_mode


@pytest.mark.asyncio
async def test_rgbicww_ceiling_light_get_basic_info_with_main_state() -> None:
    """
    Test that the ceiling light's get_basic_info reads RGB over GATT.

    Power/brightness/mode/color-temp come from the advertisement, not GATT,
    so they are not part of the get_basic_info result.
    """
    device = create_device_for_command_testing(
        RGBICWW_CEILING_LIGHT_INFO, light_strip.SwitchbotRgbicwwCeilingLight
    )
    device._send_command = AsyncMock(
        side_effect=[b"\x01\x01\n", b"\x01\x80NK\xff:\xa0\x19d\xff\x02"]
    )
    device._check_command_result = MagicMock(side_effect=[True, True])

    info = await device.get_basic_info()

    assert info["r"] == 75
    assert info["g"] == 255
    assert info["b"] == 58
    assert info["firmware"] == 1.0
    # Power state must not be sourced from GATT (advertisement is authoritative).
    assert "isOn" not in info
    assert "main_isOn" not in info
