"""Tests for the Q10 B01 status trait."""

import asyncio
import pathlib
from collections.abc import AsyncGenerator
from typing import Any
from unittest.mock import AsyncMock, Mock

import pytest

from roborock.data import HomeDataDevice, HomeDataProduct
from roborock.data.b01_q10.b01_q10_code_mappings import (
    B01_Q10_DP,
    YXAreaUnit,
    YXCarpetCleanType,
    YXCleanLine,
    YXCleanType,
    YXDeviceCleanTask,
    YXDeviceDustCollectionFrequency,
    YXDeviceState,
    YXFanLevel,
    YXFault,
    YXWaterLevel,
)
from roborock.data.b01_q10.b01_q10_containers import dpNetInfo, dpNotDisturbExpand, dpTimeZone
from roborock.devices.device import RoborockDevice
from roborock.devices.traits.b01.q10 import Q10PropertiesApi, create
from roborock.protocols.b01_q10_protocol import Q10Message, decode_message
from roborock.roborock_message import RoborockMessage, RoborockMessageProtocol
from tests import mock_data

from .conftest import FakeB01Q10Channel

TEST_DATA_DIR = pathlib.Path("tests/protocols/testdata/b01_q10_protocol")

TESTDATA_DP_STATUS_DP_CLEAN_TASK_TYPE = (TEST_DATA_DIR / "dpStatus-dpCleanTaskType.json").read_bytes()
TESTDATA_DP_REQUEST_DPS = (TEST_DATA_DIR / "dpRequetdps.json").read_bytes()


@pytest.fixture(name="message_queue")
def message_queue_fixture() -> asyncio.Queue[Q10Message]:
    """Fixture for a message queue used by the mock stream."""
    return asyncio.Queue()


@pytest.fixture(name="mock_channel")
def mock_channel_fixture(message_queue: asyncio.Queue[Q10Message]) -> FakeB01Q10Channel:
    """Fixture to mock the subscribe_stream method to yield from a queue."""
    channel = FakeB01Q10Channel()

    async def mock_stream() -> AsyncGenerator[Q10Message, None]:
        while True:
            yield await message_queue.get()

    setattr(channel, "subscribe_stream", Mock(side_effect=mock_stream))
    return channel


@pytest.fixture(name="q10_api")
async def q10_api_fixture(mock_channel: FakeB01Q10Channel) -> AsyncGenerator[Q10PropertiesApi, None]:
    """Fixture to create and manage the Q10PropertiesApi."""
    api = create(mock_channel)
    await api.start()
    yield api
    await api.close()


def build_q10_message(payload: bytes) -> Q10Message:
    """Helper to build a Q10Message for testing."""
    msg = RoborockMessage(
        protocol=RoborockMessageProtocol.RPC_RESPONSE,
        payload=payload,
        version=b"B01",
    )
    result = decode_message(msg)
    assert result is not None
    return result


async def wait_for_attribute_value(obj: Any, attribute: str, value: Any, timeout: float = 2.0) -> None:
    """Wait for an attribute on an object to reach a specific value."""
    for _ in range(int(timeout / 0.1)):
        if getattr(obj, attribute) == value:
            return
        await asyncio.sleep(0.1)
    pytest.fail(f"Timeout waiting for {attribute} to become {value} on {obj}")


async def test_status_trait_streaming(
    q10_api: Q10PropertiesApi,
    message_queue: asyncio.Queue[Q10Message],
) -> None:
    """Test that the StatusTrait updates its state from streaming messages."""
    # status (121) = 8 (CHARGING_STATE)
    # clean_task_type (138) = 0 (IDLE)
    message = build_q10_message(TESTDATA_DP_STATUS_DP_CLEAN_TASK_TYPE)

    assert q10_api.status.status is None
    assert q10_api.status.clean_task_type is None

    # Push the message into the queue
    message_queue.put_nowait(message)

    # Wait for the update
    await wait_for_attribute_value(q10_api.status, "status", YXDeviceState.CHARGING)

    # Verify trait attributes are updated
    assert q10_api.status.status == YXDeviceState.CHARGING
    assert q10_api.status.clean_task_type == YXDeviceCleanTask.IDLE


async def test_status_trait_refresh(
    q10_api: Q10PropertiesApi,
    mock_channel: FakeB01Q10Channel,
    message_queue: asyncio.Queue[Q10Message],
) -> None:
    """Test that the StatusTrait sends a refresh command and updates state."""
    assert q10_api.status.battery is None
    assert q10_api.status.status is None
    assert q10_api.status.fan_level is None
    assert q10_api.status.total_clean_count is None
    assert q10_api.consumable.main_brush_life is None
    assert q10_api.status.cleaning_progress is None
    assert q10_api.status.fault is None

    # Mock the response to refresh
    # battery (122) = 100
    # status (121) = 8 (CHARGING_STATE)
    # fan_level (123) = 2 (BALANCED)
    message = build_q10_message(TESTDATA_DP_REQUEST_DPS)

    # Send a refresh command
    await q10_api.refresh()
    assert mock_channel.published_commands == [(B01_Q10_DP.REQUEST_DPS, {})]

    # Push the response message into the queue
    message_queue.put_nowait(message)

    # Wait for the update
    await wait_for_attribute_value(q10_api.status, "battery", 100)

    # Verify trait attributes are updated
    assert q10_api.status.battery == 100
    assert q10_api.status.status == YXDeviceState.CHARGING
    assert q10_api.status.fan_level == YXFanLevel.BALANCED
    assert q10_api.status.total_clean_area == 0
    assert q10_api.status.total_clean_count == 0
    assert q10_api.status.total_clean_time == 0
    assert q10_api.consumable.main_brush_life == 0
    assert q10_api.consumable.side_brush_life == 0
    assert q10_api.consumable.filter_life == 0
    assert q10_api.consumable.sensor_life == 0
    assert q10_api.status.cleaning_progress == 100
    assert q10_api.status.fault is YXFault.NONE
    assert q10_api.status.clean_mode == YXCleanType.VAC_AND_MOP
    assert q10_api.status.water_level == YXWaterLevel.LOW

    # Settings with dedicated traits are read from those traits.
    assert q10_api.volume.volume == 74
    assert q10_api.do_not_disturb.not_disturb is True
    assert q10_api.do_not_disturb.is_on is True
    assert q10_api.child_lock.child_lock is False
    assert q10_api.child_lock.is_on is False
    assert q10_api.dust_collection.dust_switch is True
    assert q10_api.dust_collection.is_on is True
    assert q10_api.dust_collection.dust_setting == YXDeviceDustCollectionFrequency.REGULAR

    # Additional state captured on the Status trait.
    assert q10_api.status.mop_state is True
    assert q10_api.status.ground_clean is False
    assert q10_api.status.carpet_clean_type == YXCarpetCleanType.RISE
    assert q10_api.status.area_unit == YXAreaUnit.SQUARE_METER
    assert q10_api.status.auto_boost is False
    assert q10_api.status.map_save_switch is True
    assert q10_api.status.recent_clean_record is False
    assert q10_api.status.valley_point_charging is False
    assert q10_api.status.clean_line == YXCleanLine.FAST
    assert q10_api.status.line_laser_obstacle_avoidance is True
    assert q10_api.status.robot_country_code == "us"
    assert q10_api.status.robot_type == 1
    assert q10_api.status.time_zone == dpTimeZone(time_zone_city="America/Los_Angeles", time_zone_sec=-28800)

    # Nested containers are parsed into their dataclasses on their own traits.
    assert q10_api.do_not_disturb.not_disturb_expand == dpNotDisturbExpand(
        disturb_dust_enable=1, disturb_light=1, disturb_resume_clean=1, disturb_voice=1
    )
    assert q10_api.network_info.net_info == dpNetInfo(
        wifi_name="wifi-network-name", ip_adress="1.1.1.2", mac="99:AA:88:BB:77:CC", signal=-50
    )


async def test_status_trait_vacuum_only_refresh(
    q10_api: Q10PropertiesApi,
    message_queue: asyncio.Queue[Q10Message],
) -> None:
    """Test decoding a full status dump from a vacuum-only (no mop) Q10."""
    payload = (TEST_DATA_DIR / "dpRequestDps_vacuum_only.json").read_bytes()
    message_queue.put_nowait(build_q10_message(payload))

    await wait_for_attribute_value(q10_api.status, "battery", 75)

    assert q10_api.status.fan_level == YXFanLevel.MAX_PLUS
    assert q10_api.status.water_level == YXWaterLevel.MEDIUM
    assert q10_api.status.clean_mode == YXCleanType.VACUUM
    assert q10_api.status.recent_clean_record is True
    assert q10_api.status.total_clean_count == 7


def test_status_trait_update_listener(q10_api: Q10PropertiesApi) -> None:
    """Test that status listeners receive updates and can unsubscribe."""
    event = asyncio.Event()

    unsubscribe = q10_api.status.add_update_listener(event.set)

    first_update = {B01_Q10_DP.BATTERY: 88}
    q10_api.status.update_from_dps(first_update)

    assert event.is_set()
    event.clear()

    unsubscribe()

    second_update = {B01_Q10_DP.BATTERY: 87}
    q10_api.status.update_from_dps(second_update)

    assert not event.is_set()


def test_status_trait_update_listener_ignores_value(q10_api: Q10PropertiesApi) -> None:
    """Test that status listeners are not notified for unrelated updates."""
    event = asyncio.Event()

    unsubscribe = q10_api.status.add_update_listener(event.set)

    first_update = {B01_Q10_DP.HEARTBEAT: 1}  # Not a value in `Status` dataclass
    q10_api.status.update_from_dps(first_update)

    assert not event.is_set()

    unsubscribe()


async def test_q10_properties_as_dict_empty(
    q10_api: Q10PropertiesApi,
    snapshot: Any,
) -> None:
    """Test Q10PropertiesApi.as_dict() when traits are empty."""
    assert q10_api.as_dict() == snapshot


async def test_q10_properties_as_dict_and_diagnostic_data(
    q10_api: Q10PropertiesApi,
    message_queue: asyncio.Queue[Q10Message],
    snapshot: Any,
) -> None:
    """Test Q10PropertiesApi.as_dict() and diagnostic_data()."""
    # Mock the response to refresh
    message = build_q10_message(TESTDATA_DP_REQUEST_DPS)
    message_queue.put_nowait(message)

    # Wait for the update (e.g. wait for battery to be 100)
    await wait_for_attribute_value(q10_api.status, "battery", 100)

    # Now as_dict() should return traits data
    data = q10_api.as_dict()
    assert data == snapshot
    assert "map" in data
    assert "imageContent" not in data["map"]
    assert "mapData" not in data["map"]

    # Instantiate RoborockDevice with this Q10 trait using mock fixtures
    device_info = HomeDataDevice.from_dict(mock_data.Q10_DEVICE_DATA)
    product = HomeDataProduct.from_dict(mock_data.SS07_PRODUCT_DATA)
    device = RoborockDevice(
        device_info=device_info,
        product=product,
        channel=AsyncMock(),
        trait=q10_api,
    )

    # Verify diagnostic_data extracts the properties correctly
    diagnostics = device.diagnostic_data()
    assert diagnostics == snapshot
