"""Test ESPHome update entities."""
import asyncio
from collections.abc import Awaitable, Callable
import dataclasses
from unittest.mock import Mock, patch

from aioesphomeapi import APIClient, EntityInfo, EntityState, UserService
import pytest

from homeassistant.components.esphome.dashboard import async_get_dashboard
from homeassistant.components.update import UpdateEntityFeature
from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.dispatcher import async_dispatcher_send

from .conftest import MockESPHomeDevice


@pytest.fixture
def stub_reconnect():
    """Stub reconnect."""
    with patch("homeassistant.components.esphome.manager.ReconnectLogic.start"):
        yield


@pytest.mark.parametrize(
    ("devices_payload", "expected_state", "expected_attributes"),
    [
        (
            [
                {
                    "name": "test",
                    "current_version": "2023.2.0-dev",
                    "configuration": "test.yaml",
                }
            ],
            STATE_ON,
            {
                "latest_version": "2023.2.0-dev",
                "installed_version": "1.0.0",
                "supported_features": UpdateEntityFeature.INSTALL,
            },
        ),
        (
            [
                {
                    "name": "test",
                    "current_version": "1.0.0",
                },
            ],
            STATE_OFF,
            {
                "latest_version": "1.0.0",
                "installed_version": "1.0.0",
                "supported_features": 0,
            },
        ),
        (
            [],
            STATE_UNKNOWN,  # dashboard is available but device is unknown
            {"supported_features": 0},
        ),
    ],
)
async def test_update_entity(
    hass: HomeAssistant,
    stub_reconnect,
    mock_config_entry,
    mock_device_info,
    mock_dashboard,
    devices_payload,
    expected_state,
    expected_attributes,
) -> None:
    """Test ESPHome update entity."""
    mock_dashboard["configured"] = devices_payload
    await async_get_dashboard(hass).async_refresh()

    with patch(
        "homeassistant.components.esphome.update.DomainData.get_entry_data",
        return_value=Mock(available=True, device_info=mock_device_info),
    ):
        assert await hass.config_entries.async_forward_entry_setup(
            mock_config_entry, "update"
        )

    state = hass.states.get("update.none_firmware")
    assert state is not None
    assert state.state == expected_state
    for key, expected_value in expected_attributes.items():
        assert state.attributes.get(key) == expected_value

    if expected_state != "on":
        return

    # Compile failed, don't try to upload
    with patch(
        "esphome_dashboard_api.ESPHomeDashboardAPI.compile", return_value=False
    ) as mock_compile, patch(
        "esphome_dashboard_api.ESPHomeDashboardAPI.upload", return_value=True
    ) as mock_upload, pytest.raises(
        HomeAssistantError, match="compiling"
    ):
        await hass.services.async_call(
            "update",
            "install",
            {"entity_id": "update.none_firmware"},
            blocking=True,
        )

    assert len(mock_compile.mock_calls) == 1
    assert mock_compile.mock_calls[0][1][0] == "test.yaml"

    assert len(mock_upload.mock_calls) == 0

    # Compile success, upload fails
    with patch(
        "esphome_dashboard_api.ESPHomeDashboardAPI.compile", return_value=True
    ) as mock_compile, patch(
        "esphome_dashboard_api.ESPHomeDashboardAPI.upload", return_value=False
    ) as mock_upload, pytest.raises(
        HomeAssistantError, match="OTA"
    ):
        await hass.services.async_call(
            "update",
            "install",
            {"entity_id": "update.none_firmware"},
            blocking=True,
        )

    assert len(mock_compile.mock_calls) == 1
    assert mock_compile.mock_calls[0][1][0] == "test.yaml"

    assert len(mock_upload.mock_calls) == 1
    assert mock_upload.mock_calls[0][1][0] == "test.yaml"

    # Everything works
    with patch(
        "esphome_dashboard_api.ESPHomeDashboardAPI.compile", return_value=True
    ) as mock_compile, patch(
        "esphome_dashboard_api.ESPHomeDashboardAPI.upload", return_value=True
    ) as mock_upload:
        await hass.services.async_call(
            "update",
            "install",
            {"entity_id": "update.none_firmware"},
            blocking=True,
        )

    assert len(mock_compile.mock_calls) == 1
    assert mock_compile.mock_calls[0][1][0] == "test.yaml"

    assert len(mock_upload.mock_calls) == 1
    assert mock_upload.mock_calls[0][1][0] == "test.yaml"


async def test_update_static_info(
    hass: HomeAssistant,
    stub_reconnect,
    mock_config_entry,
    mock_device_info,
    mock_dashboard,
) -> None:
    """Test ESPHome update entity."""
    mock_dashboard["configured"] = [
        {
            "name": "test",
            "current_version": "1.2.3",
        },
    ]
    await async_get_dashboard(hass).async_refresh()

    signal_static_info_updated = f"esphome_{mock_config_entry.entry_id}_on_list"
    runtime_data = Mock(
        available=True,
        device_info=mock_device_info,
        signal_static_info_updated=signal_static_info_updated,
    )

    with patch(
        "homeassistant.components.esphome.update.DomainData.get_entry_data",
        return_value=runtime_data,
    ):
        assert await hass.config_entries.async_forward_entry_setup(
            mock_config_entry, "update"
        )

    state = hass.states.get("update.none_firmware")
    assert state is not None
    assert state.state == "on"

    runtime_data.device_info = dataclasses.replace(
        runtime_data.device_info, esphome_version="1.2.3"
    )
    async_dispatcher_send(hass, signal_static_info_updated, [])

    state = hass.states.get("update.none_firmware")
    assert state.state == "off"


@pytest.mark.parametrize(
    "expected_disconnect_state", [(True, STATE_ON), (False, STATE_UNAVAILABLE)]
)
async def test_update_device_state_for_availability(
    hass: HomeAssistant,
    stub_reconnect,
    expected_disconnect_state: tuple[bool, str],
    mock_config_entry,
    mock_device_info,
    mock_dashboard,
) -> None:
    """Test ESPHome update entity changes availability with the device."""
    mock_dashboard["configured"] = [
        {
            "name": "test",
            "current_version": "1.2.3",
        },
    ]
    await async_get_dashboard(hass).async_refresh()

    signal_device_updated = f"esphome_{mock_config_entry.entry_id}_on_device_update"
    runtime_data = Mock(
        available=True,
        expected_disconnect=False,
        device_info=mock_device_info,
        signal_device_updated=signal_device_updated,
    )

    with patch(
        "homeassistant.components.esphome.update.DomainData.get_entry_data",
        return_value=runtime_data,
    ):
        assert await hass.config_entries.async_forward_entry_setup(
            mock_config_entry, "update"
        )

    state = hass.states.get("update.none_firmware")
    assert state is not None
    assert state.state == "on"

    expected_disconnect, expected_state = expected_disconnect_state

    runtime_data.available = False
    runtime_data.expected_disconnect = expected_disconnect
    async_dispatcher_send(hass, signal_device_updated)

    state = hass.states.get("update.none_firmware")
    assert state.state == expected_state

    # Deep sleep devices should still be available
    runtime_data.device_info = dataclasses.replace(
        runtime_data.device_info, has_deep_sleep=True
    )

    async_dispatcher_send(hass, signal_device_updated)

    state = hass.states.get("update.none_firmware")
    assert state.state == "on"


async def test_update_entity_dashboard_not_available_startup(
    hass: HomeAssistant,
    stub_reconnect,
    mock_config_entry,
    mock_device_info,
    mock_dashboard,
) -> None:
    """Test ESPHome update entity when dashboard is not available at startup."""
    with patch(
        "homeassistant.components.esphome.update.DomainData.get_entry_data",
        return_value=Mock(available=True, device_info=mock_device_info),
    ), patch(
        "esphome_dashboard_api.ESPHomeDashboardAPI.get_devices",
        side_effect=asyncio.TimeoutError,
    ):
        await async_get_dashboard(hass).async_refresh()
        assert await hass.config_entries.async_forward_entry_setup(
            mock_config_entry, "update"
        )

    # We have a dashboard but it is not available
    state = hass.states.get("update.none_firmware")
    assert state is None

    mock_dashboard["configured"] = [
        {
            "name": "test",
            "current_version": "2023.2.0-dev",
            "configuration": "test.yaml",
        }
    ]
    await async_get_dashboard(hass).async_refresh()
    await hass.async_block_till_done()

    state = hass.states.get("update.none_firmware")
    assert state.state == STATE_ON
    expected_attributes = {
        "latest_version": "2023.2.0-dev",
        "installed_version": "1.0.0",
        "supported_features": UpdateEntityFeature.INSTALL,
    }
    for key, expected_value in expected_attributes.items():
        assert state.attributes.get(key) == expected_value


async def test_update_entity_dashboard_discovered_after_startup_but_update_failed(
    hass: HomeAssistant,
    mock_client: APIClient,
    mock_esphome_device: Callable[
        [APIClient, list[EntityInfo], list[UserService], list[EntityState]],
        Awaitable[MockESPHomeDevice],
    ],
    mock_dashboard,
) -> None:
    """Test ESPHome update entity when dashboard is discovered after startup and the first update fails."""
    with patch(
        "esphome_dashboard_api.ESPHomeDashboardAPI.get_devices",
        side_effect=asyncio.TimeoutError,
    ):
        await async_get_dashboard(hass).async_refresh()
        await hass.async_block_till_done()
        mock_device = await mock_esphome_device(
            mock_client=mock_client,
            entity_info=[],
            user_service=[],
            states=[],
        )
        await hass.async_block_till_done()
    state = hass.states.get("update.test_firmware")
    assert state is None

    await mock_device.mock_disconnect(False)

    mock_dashboard["configured"] = [
        {
            "name": "test",
            "current_version": "2023.2.0-dev",
            "configuration": "test.yaml",
        }
    ]
    # Device goes unavailable, and dashboard becomes available
    await async_get_dashboard(hass).async_refresh()
    await hass.async_block_till_done()

    state = hass.states.get("update.test_firmware")
    assert state is None

    # Finally both are available
    await mock_device.mock_connect()
    await async_get_dashboard(hass).async_refresh()
    await hass.async_block_till_done()
    state = hass.states.get("update.test_firmware")
    assert state is not None


async def test_update_entity_not_present_without_dashboard(
    hass: HomeAssistant, stub_reconnect, mock_config_entry, mock_device_info
) -> None:
    """Test ESPHome update entity does not get created if there is no dashboard."""
    with patch(
        "homeassistant.components.esphome.update.DomainData.get_entry_data",
        return_value=Mock(available=True, device_info=mock_device_info),
    ):
        assert await hass.config_entries.async_forward_entry_setup(
            mock_config_entry, "update"
        )

    state = hass.states.get("update.none_firmware")
    assert state is None