"""Test hardware helpers."""

from collections.abc import Callable
import logging
from unittest.mock import AsyncMock, MagicMock, Mock, call, patch

import pytest

from homeassistant.components.homeassistant_hardware.const import DATA_COMPONENT, DOMAIN
from homeassistant.components.homeassistant_hardware.helpers import (
    async_firmware_update_context,
    async_is_firmware_update_in_progress,
    async_notify_firmware_info,
    async_register_firmware_info_callback,
    async_register_firmware_info_provider,
    async_register_firmware_update_in_progress,
    async_unregister_firmware_update_in_progress,
)
from homeassistant.components.homeassistant_hardware.util import (
    ApplicationType,
    FirmwareInfo,
)
from homeassistant.components.usb import DOMAIN as USB_DOMAIN, USBDevice
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component

from tests.common import MockConfigEntry

FIRMWARE_INFO_EZSP = FirmwareInfo(
    device="/dev/serial/by-id/device1",
    firmware_type=ApplicationType.EZSP,
    firmware_version=None,
    source="zha",
    owners=[AsyncMock(is_running=AsyncMock(return_value=True))],
)

FIRMWARE_INFO_SPINEL = FirmwareInfo(
    device="/dev/serial/by-id/device2",
    firmware_type=ApplicationType.SPINEL,
    firmware_version=None,
    source="otbr",
    owners=[AsyncMock(is_running=AsyncMock(return_value=True))],
)


async def test_dispatcher_registration(hass: HomeAssistant) -> None:
    """Test HardwareInfoDispatcher registration."""

    await async_setup_component(hass, DOMAIN, {})

    # Mock provider 1 with a synchronous method to pull firmware info
    provider1_config_entry = MockConfigEntry(
        domain="zha",
        unique_id="some_unique_id1",
        data={},
    )
    provider1_config_entry.add_to_hass(hass)
    provider1_config_entry.mock_state(hass, ConfigEntryState.LOADED)

    provider1_firmware = MagicMock(spec=["get_firmware_info"])
    provider1_firmware.get_firmware_info = MagicMock(return_value=FIRMWARE_INFO_EZSP)
    async_register_firmware_info_provider(hass, "zha", provider1_firmware)

    # Mock provider 2 with an asynchronous method to pull firmware info
    provider2_config_entry = MockConfigEntry(
        domain="otbr",
        unique_id="some_unique_id2",
        data={},
    )
    provider2_config_entry.add_to_hass(hass)
    provider2_config_entry.mock_state(hass, ConfigEntryState.LOADED)

    provider2_firmware = MagicMock(spec=["async_get_firmware_info"])
    provider2_firmware.async_get_firmware_info = AsyncMock(
        return_value=FIRMWARE_INFO_SPINEL
    )
    async_register_firmware_info_provider(hass, "otbr", provider2_firmware)

    # Double registration won't work
    with pytest.raises(ValueError, match="Domain zha is already registered"):
        async_register_firmware_info_provider(hass, "zha", provider1_firmware)

    # We can iterate over the results
    info = [i async for i in hass.data[DATA_COMPONENT].iter_firmware_info()]
    assert info == [
        FIRMWARE_INFO_EZSP,
        FIRMWARE_INFO_SPINEL,
    ]

    callback1 = Mock()
    cancel1 = async_register_firmware_info_callback(
        hass, "/dev/serial/by-id/device1", callback1
    )

    callback2 = Mock()
    cancel2 = async_register_firmware_info_callback(
        hass, "/dev/serial/by-id/device2", callback2
    )

    # And receive notification callbacks
    await async_notify_firmware_info(hass, "zha", firmware_info=FIRMWARE_INFO_EZSP)
    await async_notify_firmware_info(hass, "otbr", firmware_info=FIRMWARE_INFO_SPINEL)
    await async_notify_firmware_info(hass, "zha", firmware_info=FIRMWARE_INFO_EZSP)
    cancel1()
    await async_notify_firmware_info(hass, "zha", firmware_info=FIRMWARE_INFO_EZSP)
    await async_notify_firmware_info(hass, "otbr", firmware_info=FIRMWARE_INFO_SPINEL)
    cancel2()

    assert callback1.mock_calls == [
        call(FIRMWARE_INFO_EZSP),
        call(FIRMWARE_INFO_EZSP),
    ]

    assert callback2.mock_calls == [
        call(FIRMWARE_INFO_SPINEL),
        call(FIRMWARE_INFO_SPINEL),
    ]


async def test_dispatcher_iter_error_handling(
    hass: HomeAssistant, caplog: pytest.LogCaptureFixture
) -> None:
    """Test HardwareInfoDispatcher ignoring errors from firmware info providers."""

    await async_setup_component(hass, DOMAIN, {})

    provider1_config_entry = MockConfigEntry(
        domain="zha",
        unique_id="some_unique_id1",
        data={},
    )
    provider1_config_entry.add_to_hass(hass)
    provider1_config_entry.mock_state(hass, ConfigEntryState.LOADED)

    provider1_firmware = MagicMock(spec=["get_firmware_info"])
    provider1_firmware.get_firmware_info = MagicMock(side_effect=Exception("Boom!"))
    async_register_firmware_info_provider(hass, "zha", provider1_firmware)

    provider2_config_entry = MockConfigEntry(
        domain="otbr",
        unique_id="some_unique_id2",
        data={},
    )
    provider2_config_entry.add_to_hass(hass)
    provider2_config_entry.mock_state(hass, ConfigEntryState.LOADED)

    provider2_firmware = MagicMock(spec=["async_get_firmware_info"])
    provider2_firmware.async_get_firmware_info = AsyncMock(
        return_value=FIRMWARE_INFO_SPINEL
    )
    async_register_firmware_info_provider(hass, "otbr", provider2_firmware)

    with caplog.at_level(logging.ERROR):
        info = [i async for i in hass.data[DATA_COMPONENT].iter_firmware_info()]

    assert info == [FIRMWARE_INFO_SPINEL]
    assert "Error while getting firmware info from" in caplog.text


async def test_dispatcher_callback_error_handling(
    hass: HomeAssistant, caplog: pytest.LogCaptureFixture
) -> None:
    """Test HardwareInfoDispatcher ignoring errors from firmware info callbacks."""

    await async_setup_component(hass, DOMAIN, {})
    provider1_config_entry = MockConfigEntry(
        domain="zha",
        unique_id="some_unique_id1",
        data={},
    )
    provider1_config_entry.add_to_hass(hass)
    provider1_config_entry.mock_state(hass, ConfigEntryState.LOADED)

    provider1_firmware = MagicMock(spec=["get_firmware_info"])
    provider1_firmware.get_firmware_info = MagicMock(return_value=FIRMWARE_INFO_EZSP)
    async_register_firmware_info_provider(hass, "zha", provider1_firmware)

    callback1 = Mock(side_effect=Exception("Some error"))
    async_register_firmware_info_callback(hass, "/dev/serial/by-id/device1", callback1)

    callback2 = Mock()
    async_register_firmware_info_callback(hass, "/dev/serial/by-id/device1", callback2)

    with caplog.at_level(logging.ERROR):
        await async_notify_firmware_info(hass, "zha", firmware_info=FIRMWARE_INFO_EZSP)

    assert "Error while notifying firmware info listener" in caplog.text

    assert callback1.mock_calls == [call(FIRMWARE_INFO_EZSP)]
    assert callback2.mock_calls == [call(FIRMWARE_INFO_EZSP)]


async def test_firmware_update_tracking(hass: HomeAssistant) -> None:
    """Test firmware update tracking API."""
    await async_setup_component(hass, DOMAIN, {})

    device_path = "/dev/ttyUSB0"

    assert not async_is_firmware_update_in_progress(hass, device_path)

    # Register an update in progress
    async_register_firmware_update_in_progress(hass, device_path, "zha")
    assert async_is_firmware_update_in_progress(hass, device_path)

    with pytest.raises(ValueError, match="Firmware update already in progress"):
        async_register_firmware_update_in_progress(hass, device_path, "skyconnect")

    assert async_is_firmware_update_in_progress(hass, device_path)

    # Unregister the update with correct domain
    async_unregister_firmware_update_in_progress(hass, device_path, "zha")
    assert not async_is_firmware_update_in_progress(hass, device_path)

    # Test unregistering with wrong domain should raise an error
    async_register_firmware_update_in_progress(hass, device_path, "zha")
    with pytest.raises(ValueError, match="is owned by zha, not skyconnect"):
        async_unregister_firmware_update_in_progress(hass, device_path, "skyconnect")

    # Still registered to zha
    assert async_is_firmware_update_in_progress(hass, device_path)
    async_unregister_firmware_update_in_progress(hass, device_path, "zha")
    assert not async_is_firmware_update_in_progress(hass, device_path)


async def test_firmware_update_context_manager(hass: HomeAssistant) -> None:
    """Test firmware update progress context manager."""
    await async_setup_component(hass, DOMAIN, {})

    device_path = "/dev/ttyUSB0"

    # Initially no updates in progress
    assert not async_is_firmware_update_in_progress(hass, device_path)

    # Test successful completion
    async with async_firmware_update_context(hass, device_path, "zha"):
        assert async_is_firmware_update_in_progress(hass, device_path)

    # Should be cleaned up after context
    assert not async_is_firmware_update_in_progress(hass, device_path)

    # Test exception handling
    with pytest.raises(ValueError, match="test error"):  # noqa: PT012
        async with async_firmware_update_context(hass, device_path, "zha"):
            assert async_is_firmware_update_in_progress(hass, device_path)
            raise ValueError("test error")

    # Should still be cleaned up after exception
    assert not async_is_firmware_update_in_progress(hass, device_path)

    # Test concurrent context manager attempts should fail
    async with async_firmware_update_context(hass, device_path, "zha"):
        assert async_is_firmware_update_in_progress(hass, device_path)

        # Second context manager should fail to register
        with pytest.raises(ValueError, match="Firmware update already in progress"):
            async with async_firmware_update_context(hass, device_path, "skyconnect"):
                pytest.fail("We should not enter this context manager")

    # Should be cleaned up after first context
    assert not async_is_firmware_update_in_progress(hass, device_path)


async def test_dispatcher_callback_self_unregister(hass: HomeAssistant) -> None:
    """Test callbacks can unregister themselves during notification."""
    await async_setup_component(hass, DOMAIN, {})

    called_callbacks = []
    unregister_funcs = {}

    def create_self_unregistering_callback(name: str) -> Callable[[FirmwareInfo], None]:
        def callback(firmware_info: FirmwareInfo) -> None:
            called_callbacks.append(name)
            unregister_funcs[name]()

        return callback

    callback1 = create_self_unregistering_callback("callback1")
    callback2 = create_self_unregistering_callback("callback2")
    callback3 = create_self_unregistering_callback("callback3")

    # Register all three callbacks and store their unregister functions
    unregister_funcs["callback1"] = async_register_firmware_info_callback(
        hass, "/dev/serial/by-id/device1", callback1
    )
    unregister_funcs["callback2"] = async_register_firmware_info_callback(
        hass, "/dev/serial/by-id/device1", callback2
    )
    unregister_funcs["callback3"] = async_register_firmware_info_callback(
        hass, "/dev/serial/by-id/device1", callback3
    )

    # All callbacks should be called and unregister themselves
    await async_notify_firmware_info(hass, "zha", firmware_info=FIRMWARE_INFO_EZSP)
    assert set(called_callbacks) == {"callback1", "callback2", "callback3"}

    # No callbacks should be called since they all unregistered
    called_callbacks.clear()
    await async_notify_firmware_info(hass, "zha", firmware_info=FIRMWARE_INFO_EZSP)
    assert not called_callbacks


async def test_firmware_callback_no_usb_device(
    hass: HomeAssistant, caplog: pytest.LogCaptureFixture
) -> None:
    """Test firmware notification when usb_device_from_path returns None."""
    await async_setup_component(hass, DOMAIN, {})
    await async_setup_component(hass, USB_DOMAIN, {})

    with (
        patch(
            "homeassistant.components.homeassistant_hardware.helpers.usb_device_from_path",
            return_value=None,
        ),
        caplog.at_level(logging.DEBUG),
    ):
        await async_notify_firmware_info(
            hass,
            "zha",
            FirmwareInfo(
                device="/dev/ttyUSB99",
                firmware_type=ApplicationType.EZSP,
                firmware_version="7.4.4.0",
                owners=[],
                source="zha",
            ),
        )
        await hass.async_block_till_done()

    # This isn't a codepath that's expected but we won't fail in this case, just log
    assert "Cannot find USB for path /dev/ttyUSB99" in caplog.text


async def test_firmware_callback_no_hardware_domain(
    hass: HomeAssistant, caplog: pytest.LogCaptureFixture
) -> None:
    """Test firmware notification when no hardware domain is found for device."""
    await async_setup_component(hass, DOMAIN, {})
    await async_setup_component(hass, USB_DOMAIN, {})

    # Create a USB device that doesn't match any hardware integration
    usb_device = USBDevice(
        device="/dev/ttyUSB0",
        vid="9999",
        pid="9999",
        serial_number="TEST123",
        manufacturer="Test Manufacturer",
        description="Test Device",
    )

    with (
        patch(
            "homeassistant.components.homeassistant_hardware.helpers.usb_device_from_path",
            return_value=usb_device,
        ),
        caplog.at_level(logging.DEBUG),
    ):
        await async_notify_firmware_info(
            hass,
            "zha",
            FirmwareInfo(
                device="/dev/ttyUSB0",
                firmware_type=ApplicationType.EZSP,
                firmware_version="7.4.4.0",
                owners=[],
                source="zha",
            ),
        )
        await hass.async_block_till_done()

    assert "No hardware integration found for device" in caplog.text
