"""Tests for the Tuya component."""

import pathlib
from typing import Any
from unittest.mock import MagicMock, patch

from freezegun.api import FrozenDateTimeFactory
from tuya_sharing import (
    CustomerApi,
    CustomerDevice,
    DeviceFunction,
    DeviceStatusRange,
    Manager,
    SharingDeviceListener,
)

from homeassistant.components.tuya.const import DOMAIN
from homeassistant.const import STATE_UNAVAILABLE
from homeassistant.core import HomeAssistant
from homeassistant.helpers.json import json_dumps
from homeassistant.util import dt as dt_util

from tests.common import MockConfigEntry, async_load_json_object_fixture

FIXTURES_DIR = pathlib.Path(__file__).parent / "fixtures"
DEVICE_MOCKS = sorted(
    str(path.relative_to(FIXTURES_DIR).with_suffix(""))
    for path in FIXTURES_DIR.glob("*.json")
)


class TuyaNotificationHelper:
    """Helper to raise manager events to device listeners."""

    def __init__(self, hass: HomeAssistant, manager: Manager) -> None:
        """Initialize the helper."""
        self.hass = hass
        self.manager = manager

    async def _async_update_device(
        self,
        device: CustomerDevice,
        updated_status_properties: list[str] | None,
        dp_timestamps: dict[str, int] | None,
    ) -> None:
        """Trigger dispatcher_send for device update and wait for tasks."""
        for listener in self.manager.device_listeners:
            listener.update_device(device, updated_status_properties, dp_timestamps)
        await self.hass.async_block_till_done()

    async def async_send_add_device(self, device: CustomerDevice) -> None:
        """Mock add device from the manager."""
        self.manager.device_map[device.id] = device
        for listener in self.manager.device_listeners:
            listener.add_device(device)
        await self.hass.async_block_till_done()

    async def async_send_remove_device(self, device: CustomerDevice) -> None:
        """Mock remove device from the manager."""
        self.manager.device_map.pop(device.id, None)
        for listener in self.manager.device_listeners:
            listener.remove_device(device.id)
        await self.hass.async_block_till_done()

    async def async_mock_online(self, device: CustomerDevice) -> None:
        """Mock online event from the manager."""
        device.online = True
        await self._async_update_device(device, None, None)

    async def async_mock_offline(self, device: CustomerDevice) -> None:
        """Mock offline event from the manager."""
        device.online = False
        await self._async_update_device(device, None, None)

    async def async_send_device_update(
        self,
        device: CustomerDevice,
        updated_status_properties: dict[str, Any] | None = None,
        dp_timestamps: dict[str, int] | None = None,
    ) -> None:
        """Mock update device method."""
        property_list: list[str] | None = None
        if updated_status_properties is not None:
            property_list = []
            for key, value in updated_status_properties.items():
                if key not in device.status:
                    raise ValueError(
                        f"Property {key} not found in device status: {device.status}"
                    )
                device.status[key] = value
                property_list.append(key)
        await self._async_update_device(device, property_list, dp_timestamps)


async def create_device(hass: HomeAssistant, mock_device_code: str) -> CustomerDevice:
    """Create a CustomerDevice for testing."""
    details = await async_load_json_object_fixture(
        hass, f"{mock_device_code}.json", DOMAIN
    )
    device = MagicMock(spec=CustomerDevice)

    # Use reverse of the product_id for testing
    device.id = mock_device_code.replace("_", "")[::-1]

    device.name = details["name"]
    device.category = details["category"]
    device.product_id = details["product_id"]
    device.product_name = details["product_name"]
    device.online = details["online"]
    device.sub = details.get("sub")
    device.time_zone = details.get("time_zone")
    device.active_time = details.get("active_time")
    if device.active_time:
        device.active_time = int(dt_util.as_timestamp(device.active_time))
    device.create_time = details.get("create_time")
    if device.create_time:
        device.create_time = int(dt_util.as_timestamp(device.create_time))
    device.update_time = details.get("update_time")
    if device.update_time:
        device.update_time = int(dt_util.as_timestamp(device.update_time))
    device.support_local = details.get("support_local")
    device.local_strategy = details.get("local_strategy")
    device.mqtt_connected = details.get("mqtt_connected")

    device.function = {
        key: DeviceFunction(
            code=key,
            type=value["type"],
            values=(
                values
                if isinstance(values := value["value"], str)
                else json_dumps(values)
            ),
        )
        for key, value in details["function"].items()
    }
    device.status_range = {
        key: DeviceStatusRange(
            code=key,
            report_type=value.get("report_type"),
            type=value["type"],
            values=(
                values
                if isinstance(values := value["value"], str)
                else json_dumps(values)
            ),
        )
        for key, value in details["status_range"].items()
    }
    device.status = details["status"]
    for key, value in device.status.items():
        # Some devices do not provide a status_range for all status DPs
        # Others set the type as String in status_range and as Json in function
        if ((dp_type := device.status_range.get(key)) and dp_type.type == "Json") or (
            (dp_type := device.function.get(key)) and dp_type.type == "Json"
        ):
            device.status[key] = json_dumps(value)
        if value == "**REDACTED**":
            # It was redacted, which may cause issue with b64decode
            device.status[key] = ""
    return device


def create_manager(
    terminal_id: str = "7cd96aff-6ec8-4006-b093-3dbff7947591",
) -> Manager:
    """Create a Manager for testing."""
    manager = MagicMock(spec=Manager)
    manager.device_map = {}
    manager.device_listeners = set()
    manager.mq = MagicMock()
    manager.mq.client = MagicMock()
    manager.mq.client.is_connected = MagicMock(return_value=True)
    manager.customer_api = MagicMock(spec=CustomerApi)
    # Meaningless URL / UUIDs
    manager.customer_api.endpoint = "https://apigw.tuyaeu.com"
    manager.terminal_id = terminal_id

    def _add_device_listener(listener: SharingDeviceListener):
        """Add device listener."""
        manager.device_listeners.add(listener)

    def _remove_device_listener(listener: SharingDeviceListener):
        """Remove device listener."""
        manager.device_listeners.discard(listener)

    manager.add_device_listener.side_effect = _add_device_listener
    manager.remove_device_listener.side_effect = _remove_device_listener

    return manager


async def initialize_entry(
    hass: HomeAssistant,
    mock_manager: Manager,
    mock_config_entry: MockConfigEntry,
    mock_devices: CustomerDevice | list[CustomerDevice],
) -> None:
    """Initialize the Tuya component with a mock manager and config entry."""
    if not isinstance(mock_devices, list):
        mock_devices = [mock_devices]
    mock_manager.device_map = {device.id: device for device in mock_devices}

    # Setup
    mock_config_entry.add_to_hass(hass)

    # Initialize the component
    with patch(
        "homeassistant.components.tuya.coordinator.Manager", return_value=mock_manager
    ):
        await hass.config_entries.async_setup(mock_config_entry.entry_id)
        await hass.async_block_till_done()


async def check_selective_state_update(
    hass: HomeAssistant,
    mock_device: CustomerDevice,
    notification_helper: TuyaNotificationHelper,
    freezer: FrozenDateTimeFactory,
    *,
    entity_id: str,
    dpcode: str,
    initial_state: str,
    updates: dict[str, Any],
    expected_state: str,
    last_reported: str,
) -> None:
    """Test selective state update.

    This test verifies that when an update event comes with properties that do NOT
    include the dpcode (e.g., a battery event for a door sensor),
    the entity state is not changed and last_reported is not updated.
    """
    initial_reported = "2024-01-01T00:00:00+00:00"
    unavailable_reported = "2024-01-01T00:00:10+00:00"
    available_reported = "2024-01-01T00:00:20+00:00"
    assert hass.states.get(entity_id).state == initial_state
    assert hass.states.get(entity_id).last_reported.isoformat() == initial_reported

    # Trigger device offline
    freezer.tick(10)
    await notification_helper.async_mock_offline(mock_device)
    assert hass.states.get(entity_id).state == STATE_UNAVAILABLE
    assert hass.states.get(entity_id).last_reported.isoformat() == unavailable_reported

    # Trigger device online
    freezer.tick(10)
    await notification_helper.async_mock_online(mock_device)
    assert hass.states.get(entity_id).state == initial_state
    assert hass.states.get(entity_id).last_reported.isoformat() == available_reported

    # Force update the dpcode and trigger device update without the dpcode
    # in updated properties - state should not change
    freezer.tick(10)
    mock_device.status[dpcode] = None
    await notification_helper.async_send_device_update(mock_device, {})
    assert hass.states.get(entity_id).state == initial_state
    assert hass.states.get(entity_id).last_reported.isoformat() == available_reported

    # Trigger device update with provided updates
    freezer.tick(30)
    await notification_helper.async_send_device_update(mock_device, updates)
    assert hass.states.get(entity_id).state == expected_state
    assert hass.states.get(entity_id).last_reported.isoformat() == last_reported
