"""Contains tests for the ecoMAX device."""

from datetime import timedelta
import logging
from math import isclose
from typing import Any, Literal, cast
from unittest.mock import AsyncMock, Mock, patch

import pytest

from pyplumio.const import (
    ATTR_FRAME_ERRORS,
    ATTR_INDEX,
    ATTR_OFFSET,
    ATTR_PARAMETER,
    ATTR_SCHEDULE,
    ATTR_SENSORS,
    ATTR_SETUP,
    ATTR_SIZE,
    ATTR_STATE,
    ATTR_SWITCH,
    ATTR_TYPE,
    ATTR_VALUE,
    STATE_OFF,
    STATE_ON,
    DeviceState,
    DeviceType,
    FrameType,
    UnitOfMeasurement,
)
from pyplumio.devices.ecomax import (
    ATTR_FUEL_BURNED,
    ATTR_MIXERS,
    ATTR_THERMOSTATS,
    REQUIRED,
    EcoMAX,
)
from pyplumio.devices.mixer import Mixer
from pyplumio.devices.thermostat import Thermostat
from pyplumio.exceptions import RequestError
from pyplumio.frames import Request, Response
from pyplumio.frames.messages import SensorDataMessage
from pyplumio.frames.requests import (
    AlertsRequest,
    EcomaxControlRequest,
    SetEcomaxParameterRequest,
    SetScheduleRequest,
    SetThermostatParameterRequest,
)
from pyplumio.frames.responses import (
    EcomaxParametersResponse,
    MixerParametersResponse,
    SchedulesResponse,
    ThermostatParametersResponse,
)
from pyplumio.helpers.schedule import Schedule, ScheduleDay
from pyplumio.parameters.ecomax import PARAMETER_TYPES, EcomaxNumber, EcomaxSwitch
from pyplumio.structures.ecomax_parameters import ATTR_ECOMAX_CONTROL
from pyplumio.structures.frame_versions import ATTR_FRAME_VERSIONS
from pyplumio.structures.fuel_consumption import ATTR_FUEL_CONSUMPTION
from pyplumio.structures.mixer_parameters import ATTR_MIXER_PARAMETERS
from pyplumio.structures.mixer_sensors import (
    ATTR_MIXER_SENSORS,
    ATTR_MIXERS_AVAILABLE,
    ATTR_MIXERS_CONNECTED,
)
from pyplumio.structures.network_info import ATTR_NETWORK, NetworkInfo
from pyplumio.structures.schedules import (
    ATTR_SCHEDULE_PARAMETER,
    ATTR_SCHEDULE_SWITCH,
    ATTR_SCHEDULES,
    ScheduleNumber,
    ScheduleSwitch,
)
from pyplumio.structures.thermostat_parameters import (
    ATTR_THERMOSTAT_PARAMETERS,
    ATTR_THERMOSTAT_PROFILE,
)
from pyplumio.structures.thermostat_sensors import (
    ATTR_THERMOSTAT_SENSORS,
    ATTR_THERMOSTATS_AVAILABLE,
    ATTR_THERMOSTATS_CONNECTED,
)
from tests.conftest import DEFAULT_TOLERANCE, UNDEFINED, class_from_json, json_test_data


@patch("asyncio.Queue.put_nowait")
@patch("pyplumio.devices.PhysicalDevice.handle_frame")
@patch("pyplumio.frames.Request.response", return_value=Mock(spec=Response))
async def test_ecomax_handle_frame(
    mock_response, mock_handle_frame, mock_put_nowait, ecomax: EcoMAX
) -> None:
    """Test ecoMAX frame handling."""
    request = Request()
    ecomax.handle_frame(request)
    args = mock_response.call_args[1]
    assert isinstance(args["data"][ATTR_NETWORK], NetworkInfo)
    mock_put_nowait.assert_called_once_with(mock_response.return_value)
    mock_handle_frame.assert_called_once_with(request)


@patch("asyncio.Queue.put_nowait")
@class_from_json(SensorDataMessage, "messages/sensor_data.json", arguments=("message",))
async def test_frame_versions_tracker(
    mock_put_nowait, ecomax: EcoMAX, sensor_data: SensorDataMessage
) -> None:
    """Test frame version tracker."""

    def _frame_version_data(frame: FrameType, version: int) -> dict[str, Any]:
        """Replace frame data with new version information."""
        return {ATTR_SENSORS: {ATTR_FRAME_VERSIONS: {frame: version}}}

    # Test with frame type that are handled during setup.
    sensor_data.data = _frame_version_data(FrameType.REQUEST_ALERTS, 1)
    ecomax.data.clear()
    ecomax.handle_frame(sensor_data)
    await ecomax.wait_until_done()
    mock_put_nowait.assert_not_called()

    # Test with same frame type after setup done.
    mock_put_nowait.reset_mock()
    sensor_data.data = _frame_version_data(FrameType.REQUEST_ALERTS, 2)
    ecomax.data.clear()
    ecomax.data[ATTR_SETUP] = True
    ecomax.handle_frame(sensor_data)
    await ecomax.wait_until_done()
    mock_put_nowait.assert_called_once_with(AlertsRequest(recipient=DeviceType.ECOMAX))


@pytest.mark.parametrize("state", [STATE_ON, STATE_OFF])
async def test_ecomax_control(
    state: Literal["on", "off"], ecomax: EcoMAX, caplog
) -> None:
    """Test ecoMAX control."""
    coro = getattr(ecomax, f"turn_{state}")
    await coro()
    assert "control is not available" in caplog.text
    switch = AsyncMock(spec=EcomaxSwitch)
    ecomax.data[ATTR_ECOMAX_CONTROL] = switch
    await coro()
    switch.set.assert_awaited_once_with(state)


@pytest.mark.parametrize("state", [STATE_ON, STATE_OFF])
@patch("pyplumio.devices.ecomax.EcoMAX.create_task")
def test_ecomax_control_nowait(
    mock_create_task, ecomax: EcoMAX, state: Literal["on", "off"]
) -> None:
    """Test ecoMAX control without waiting for result."""
    func = getattr(ecomax, f"turn_{state}_nowait")
    with patch(
        f"pyplumio.devices.ecomax.EcoMAX.turn_{state}", new_callable=Mock
    ) as mock_coro:
        func()

    mock_coro.assert_called_once()
    mock_create_task.assert_called_once_with(mock_coro.return_value)


@patch("pyplumio.devices.mixer.Mixer.shutdown")
@patch("pyplumio.devices.thermostat.Thermostat.shutdown")
@patch("pyplumio.devices.Device.cancel_tasks")
@json_test_data("messages/sensor_data.json", selector="message")
async def test_ecomax_shutdown(
    mock_cancel_tasks,
    mock_thermostat_shutdown,
    mock_mixer_shutdown,
    ecomax: EcoMAX,
    sensor_data_message,
) -> None:
    """Test shutting down the ecoMAX and children tasks."""
    ecomax.handle_frame(SensorDataMessage(message=sensor_data_message))
    await ecomax.wait_until_done()

    with patch("pyplumio.devices.Device.wait_until_done") as mock_wait_until_done:
        await ecomax.shutdown()

    mock_wait_until_done.assert_awaited_once()
    mock_cancel_tasks.assert_called_once()
    mock_thermostat_shutdown.assert_awaited_once()
    mock_mixer_shutdown.assert_awaited_once()


@patch("pyplumio.devices.ecomax.EcoMAX.request")
@patch("pyplumio.devices.ecomax.EcoMAX.wait_for")
async def test_ecomax_setup(mock_wait_for, mock_request, ecomax: EcoMAX) -> None:
    """Test setting up an ecoMAX entry."""
    ecomax.dispatch_nowait(ATTR_SETUP, True)
    await ecomax.wait_until_done()
    assert ATTR_FRAME_ERRORS not in ecomax.data
    assert mock_request.await_count == len(REQUIRED)
    mock_wait_for.assert_awaited_once_with(ATTR_SENSORS)


@patch(
    "pyplumio.devices.ecomax.EcoMAX.request",
    side_effect=RequestError("test", FrameType.REQUEST_ALERTS),
)
@patch("pyplumio.devices.ecomax.EcoMAX.wait_for")
async def test_ecomax_setup_errors(mock_wait_for, mock_request, ecomax: EcoMAX) -> None:
    """Test setting up an ecoMAX entry with frame errors."""
    ecomax.dispatch_nowait(ATTR_SETUP, True)
    await ecomax.wait_until_done()
    assert FrameType.REQUEST_ALERTS in ecomax.get_nowait(ATTR_FRAME_ERRORS, [])
    assert mock_request.await_count == len(REQUIRED)
    mock_wait_for.assert_awaited_once_with(ATTR_SENSORS)


@patch("asyncio.Queue.put")
@class_from_json(
    EcomaxParametersResponse,
    "responses/ecomax_parameters.json",
    arguments=("message",),
)
async def test_ecomax_parameters_event_listener(
    mock_put, ecomax: EcoMAX, ecomax_parameters: EcomaxParametersResponse
) -> None:
    """Test event listener for ecoMAX parameters."""
    ecomax.handle_frame(ecomax_parameters)
    await ecomax.wait_until_done()
    fuzzy_logic = ecomax.get_nowait("fuzzy_logic")
    assert isinstance(fuzzy_logic, EcomaxSwitch)
    assert fuzzy_logic == STATE_ON
    assert fuzzy_logic.value == STATE_ON
    fuzzy_logic_request = await fuzzy_logic.create_request()
    assert isinstance(fuzzy_logic_request, SetEcomaxParameterRequest)
    assert fuzzy_logic_request.data == {
        ATTR_INDEX: 18,
        ATTR_VALUE: 1,
    }

    # Test that parameter instance is not recreated on subsequent calls.
    ecomax.handle_frame(ecomax_parameters)
    await ecomax.wait_until_done()
    assert ecomax.get_nowait("fuzzy_logic") is fuzzy_logic

    # Test parameter with the step (heating_heat_curve)
    fuel_calorific_value = ecomax.get_nowait("fuel_calorific_value")
    assert isinstance(fuel_calorific_value, EcomaxNumber)
    assert isclose(fuel_calorific_value.value, 4.6, rel_tol=DEFAULT_TOLERANCE)
    assert isclose(fuel_calorific_value.min_value, 0.1, rel_tol=DEFAULT_TOLERANCE)
    assert isclose(fuel_calorific_value.max_value, 25.0, rel_tol=DEFAULT_TOLERANCE)

    # Test setting parameter with the step.
    await fuel_calorific_value.set(2.5)
    request = mock_put.call_args[0][0]
    assert isinstance(request, SetEcomaxParameterRequest)
    assert request.data[ATTR_VALUE] == 25

    # Test parameter with the offset (heating_heat_curve_shift)
    heating_heat_curve_shift = ecomax.get_nowait("heating_curve_shift")
    assert isinstance(heating_heat_curve_shift, EcomaxNumber)
    assert isclose(heating_heat_curve_shift.value, 0.0, rel_tol=DEFAULT_TOLERANCE)
    assert isclose(heating_heat_curve_shift.min_value, -20.0, rel_tol=DEFAULT_TOLERANCE)
    assert isclose(heating_heat_curve_shift.max_value, 20.0, rel_tol=DEFAULT_TOLERANCE)
    assert heating_heat_curve_shift.unit_of_measurement == UnitOfMeasurement.CELSIUS

    # Test setting the parameter with the offset.
    await heating_heat_curve_shift.set(1)
    request = mock_put.call_args[0][0]
    assert isinstance(request, SetEcomaxParameterRequest)
    assert request.data[ATTR_VALUE] == 21


@class_from_json(
    EcomaxParametersResponse,
    "unknown/unknown_ecomax_parameter.json",
    arguments=("message",),
)
async def test_unknown_ecomax_parameter(
    ecomax: EcoMAX, caplog, unknown_ecomax_parameter: EcomaxParametersResponse
) -> None:
    """Test unknown ecoMAX parameter."""
    with caplog.at_level(logging.WARNING):
        ecomax.handle_frame(unknown_ecomax_parameter)
        await ecomax.wait_until_done()

    assert "unknown ecoMAX parameter (139)" in caplog.text
    assert "ParameterValues(value=1, min_value=1, max_value=1)" in caplog.text
    assert "ecoMAX 350P2-ZF" in caplog.text


@class_from_json(SensorDataMessage, "messages/sensor_data.json", arguments=("message",))
async def test_name_collisions(ecomax: EcoMAX, sensor_data: SensorDataMessage) -> None:
    """Test that the sensor names don't collide with the parameters."""
    ecomax.handle_frame(sensor_data)
    await ecomax.wait_until_done()
    for descriptions in PARAMETER_TYPES.values():
        collisions = [
            description.name
            for description in descriptions
            if description.name in ecomax.data
        ]
        assert not collisions, f"found collisions: {collisions}"


async def test_fuel_consumption_event_listener(
    frozen_time, caplog, ecomax: EcoMAX
) -> None:
    """Test event listener for fuel consumption."""
    frozen_time.tick(timedelta(seconds=10))
    ecomax.handle_frame(Response(data={ATTR_FUEL_CONSUMPTION: 3.6}))
    await ecomax.wait_until_done()
    fuel_burned = cast(float, ecomax.get_nowait(ATTR_FUEL_BURNED))
    assert isclose(fuel_burned, 0.01, rel_tol=DEFAULT_TOLERANCE)

    # Test with outdated data.
    message = "Skipping outdated fuel consumption"
    frozen_time.tick(timedelta(minutes=5, seconds=10))
    ecomax.handle_frame(Response(data={ATTR_FUEL_CONSUMPTION: 1}))
    await ecomax.wait_until_done()
    fuel_burned = cast(float, ecomax.get_nowait(ATTR_FUEL_BURNED))
    assert isclose(fuel_burned, 0.01, rel_tol=DEFAULT_TOLERANCE)
    assert message in caplog.text
    caplog.clear()

    frozen_time.tick(timedelta(seconds=10))
    ecomax.handle_frame(Response(data={ATTR_FUEL_CONSUMPTION: 7.2}))
    await ecomax.wait_until_done()
    assert message not in caplog.text
    fuel_burned = cast(float, ecomax.get_nowait(ATTR_FUEL_BURNED))
    assert isclose(fuel_burned, 0.02, rel_tol=DEFAULT_TOLERANCE)


@class_from_json(
    MixerParametersResponse,
    "responses/mixer_parameters.json",
    arguments=("message",),
)
async def test_mixer_parameters_event_listener(
    ecomax: EcoMAX, mixer_parameters: MixerParametersResponse
) -> None:
    """Test event listener for mixer parameters."""
    ecomax.handle_frame(mixer_parameters)
    await ecomax.wait_until_done()
    mixers = cast(dict[int, Mixer], ecomax.get_nowait(ATTR_MIXERS))
    assert isinstance(mixers, dict)
    assert len(mixers) == 1
    assert all(isinstance(mixer, Mixer) for mixer in mixers.values())


async def test_mixer_parameters_event_listener_without_mixers(ecomax: EcoMAX) -> None:
    """Test event listener for mixer parameters without mixers."""
    ecomax.handle_frame(MixerParametersResponse(data={ATTR_MIXER_PARAMETERS: {}}))
    await ecomax.wait_until_done()
    assert not ecomax.get_nowait(ATTR_MIXER_PARAMETERS, UNDEFINED)


@class_from_json(SensorDataMessage, "messages/sensor_data.json", arguments=("message",))
async def test_mixer_sensors_event_listener(
    ecomax: EcoMAX, sensor_data: SensorDataMessage
) -> None:
    """Test event listener for mixer sensors."""
    ecomax.handle_frame(sensor_data)
    await ecomax.wait_until_done()
    mixers = cast(dict[int, Mixer], ecomax.get_nowait(ATTR_MIXERS))
    assert isinstance(mixers, dict)
    assert len(mixers) == 1
    assert all(isinstance(mixer, Mixer) for mixer in mixers.values())
    assert ecomax.get_nowait(ATTR_MIXERS_AVAILABLE) == 5
    assert ecomax.get_nowait(ATTR_MIXERS_CONNECTED) == 1


async def test_mixer_sensors_callbacks_without_mixers(ecomax: EcoMAX) -> None:
    """Test event listener for mixer sensors without mixers."""
    ecomax.handle_frame(MixerParametersResponse(data={ATTR_MIXER_SENSORS: {}}))
    await ecomax.wait_until_done()
    assert ecomax.get_nowait(ATTR_MIXER_SENSORS, UNDEFINED) is False


@class_from_json(SensorDataMessage, "messages/sensor_data.json", arguments=("message",))
async def test_ecomax_sensors_event_listener(
    ecomax: EcoMAX, sensor_data: SensorDataMessage
) -> None:
    """Test event listener for ecoMAX sensors."""
    ecomax.handle_frame(sensor_data)
    await ecomax.wait_until_done()
    heating_target = cast(float, ecomax.get_nowait("heating_target"))
    assert isclose(heating_target, 41.0, rel_tol=DEFAULT_TOLERANCE)

    ecomax_control = ecomax.get_nowait(ATTR_ECOMAX_CONTROL)
    assert isinstance(ecomax_control, EcomaxSwitch)
    assert ecomax_control.value == STATE_OFF

    ecomax_control_request = await ecomax_control.create_request()
    assert isinstance(ecomax_control_request, EcomaxControlRequest)
    assert ecomax_control_request.data == {ATTR_VALUE: 0}

    # Check ecomax_control reference equility.
    sensor_data_message2 = SensorDataMessage(data={ATTR_STATE: DeviceState.WORKING})
    ecomax.handle_frame(sensor_data_message2)
    await ecomax.wait_until_done()
    ecomax_control2 = ecomax.get_nowait(ATTR_ECOMAX_CONTROL)
    assert isinstance(ecomax_control2, EcomaxSwitch)
    assert ecomax_control2 is ecomax_control
    assert ecomax_control2.value == STATE_ON
    ecomax_control2_request = await ecomax_control.create_request()
    assert ecomax_control2_request.data == {ATTR_VALUE: 1}


@class_from_json(
    ThermostatParametersResponse,
    "responses/thermostat_parameters.json",
    arguments=("message",),
)
async def test_thermostat_parameters_event_listener(
    ecomax: EcoMAX, thermostat_parameters: ThermostatParametersResponse
) -> None:
    """Test event listener for thermostat parameters."""
    ecomax.data[ATTR_THERMOSTATS_AVAILABLE] = 3
    ecomax.handle_frame(thermostat_parameters)
    await ecomax.wait_until_done()
    thermostats = cast(dict[int, Thermostat], ecomax.get_nowait(ATTR_THERMOSTATS))
    assert isinstance(thermostats, dict)
    assert len(thermostats) == 1
    assert all(
        isinstance(thermostat, Thermostat) for thermostat in thermostats.values()
    )


@class_from_json(
    ThermostatParametersResponse,
    "responses/thermostat_parameters.json",
    arguments=("message",),
)
async def test_thermostat_parameters_event_listener_without_thermostats(
    ecomax: EcoMAX, thermostat_parameters: ThermostatParametersResponse
) -> None:
    """Test event listener for thermostat parameters without any thermostats."""
    ecomax.data[ATTR_THERMOSTATS_AVAILABLE] = 0
    ecomax.handle_frame(thermostat_parameters)
    await ecomax.wait_until_done()
    assert ecomax.get_nowait(ATTR_THERMOSTAT_PARAMETERS, UNDEFINED) is False


@class_from_json(
    ThermostatParametersResponse,
    "responses/thermostat_parameters.json",
    arguments=("message",),
)
async def test_thermostat_profile_event_listener(
    ecomax: EcoMAX, thermostat_parameters: ThermostatParametersResponse
) -> None:
    """Test event listener for thermostat profile."""
    ecomax.data[ATTR_THERMOSTATS_AVAILABLE] = 3
    ecomax.handle_frame(thermostat_parameters)
    await ecomax.wait_until_done()
    thermostat_profile = ecomax.get_nowait(ATTR_THERMOSTAT_PROFILE)
    assert isinstance(thermostat_profile, EcomaxNumber)
    assert isclose(thermostat_profile.value, 0.0, rel_tol=DEFAULT_TOLERANCE)
    assert isclose(thermostat_profile.min_value, 0.0, rel_tol=DEFAULT_TOLERANCE)
    assert isclose(thermostat_profile.max_value, 5.0, rel_tol=DEFAULT_TOLERANCE)
    thermostat_profile_request = await thermostat_profile.create_request()
    assert isinstance(thermostat_profile_request, SetThermostatParameterRequest)
    assert thermostat_profile_request.data == {
        ATTR_INDEX: 0,
        ATTR_VALUE: 0,
        ATTR_OFFSET: 0,
        ATTR_SIZE: 1,
    }

    # Test when thermostat profile is none.
    ecomax.handle_frame(Response(data={ATTR_THERMOSTAT_PROFILE: None}))
    await ecomax.wait_until_done()
    assert ecomax.get_nowait(ATTR_THERMOSTAT_PROFILE, UNDEFINED) is None


@class_from_json(SensorDataMessage, "messages/sensor_data.json", arguments=("message",))
async def test_thermostat_sensors_event_listener(
    ecomax: EcoMAX, sensor_data: SensorDataMessage
) -> None:
    """Test event listener for thermostat sensors."""
    ecomax.handle_frame(sensor_data)
    await ecomax.wait_until_done()
    thermostats = cast(dict[int, Thermostat], ecomax.get_nowait(ATTR_THERMOSTATS))
    assert isinstance(thermostats, dict)
    assert len(thermostats) == 1
    assert all(
        isinstance(thermostat, Thermostat) for thermostat in thermostats.values()
    )
    assert ecomax.get_nowait(ATTR_THERMOSTATS_AVAILABLE) == 2
    assert ecomax.get_nowait(ATTR_THERMOSTATS_CONNECTED) == 1


async def test_thermostat_sensors_event_listener_without_thermostats(
    ecomax: EcoMAX,
) -> None:
    """Test event lister for thermostat sensors without thermostats."""
    ecomax.handle_frame(
        ThermostatParametersResponse(data={ATTR_THERMOSTAT_SENSORS: {}})
    )
    await ecomax.wait_until_done()
    assert ecomax.get_nowait(ATTR_THERMOSTAT_SENSORS, UNDEFINED) is False


@class_from_json(SchedulesResponse, "responses/schedules.json", arguments=("message",))
@json_test_data("responses/schedules.json", selector="data")
async def test_ecomax_schedules_event_listener(
    ecomax: EcoMAX, schedules: SchedulesResponse, schedules_data
) -> None:
    """Test event listener for schedules."""
    ecomax.handle_frame(schedules)
    await ecomax.wait_until_done()
    schedules_arg = cast(dict[str, Schedule], ecomax.get_nowait(ATTR_SCHEDULES))
    assert isinstance(schedules_arg, dict)
    assert len(schedules_arg) == 2
    heating_schedule = schedules_arg["heating"]
    assert isinstance(heating_schedule, Schedule)

    heating_schedule_switch = ecomax.get_nowait("heating_schedule_switch")
    assert isinstance(heating_schedule_switch, ScheduleSwitch)
    assert heating_schedule_switch.value == STATE_OFF

    water_heater_schedule_parameter = ecomax.get_nowait(
        "water_heater_schedule_parameter"
    )
    assert isinstance(water_heater_schedule_parameter, ScheduleNumber)
    assert water_heater_schedule_parameter.value == 5
    assert water_heater_schedule_parameter.min_value == 0
    assert water_heater_schedule_parameter.max_value == 30
    water_heater_schedule_parameter_request = (
        await water_heater_schedule_parameter.create_request()
    )
    assert isinstance(water_heater_schedule_parameter_request, SetScheduleRequest)
    assert water_heater_schedule_parameter_request.data == {
        ATTR_TYPE: "water_heater",
        ATTR_SWITCH: ecomax.data[f"water_heater_{ATTR_SCHEDULE_SWITCH}"],
        ATTR_PARAMETER: ecomax.data[f"water_heater_{ATTR_SCHEDULE_PARAMETER}"],
        ATTR_SCHEDULE: ecomax.data[ATTR_SCHEDULES]["water_heater"],
    }

    schedule_data = schedules_data[ATTR_SCHEDULES][0][1]
    for index, weekday in enumerate(
        (
            "monday",
            "tuesday",
            "wednesday",
            "thursday",
            "friday",
            "saturday",
            "sunday",
        )
    ):
        schedule = getattr(heating_schedule, weekday)
        assert (
            schedule.schedule
            == ScheduleDay.from_iterable(schedule_data[index]).schedule
        )

    # Test that parameter instance is not recreated on subsequent calls.
    ecomax.handle_frame(schedules)
    await ecomax.wait_until_done()
    assert ecomax.get_nowait("heating_schedule_switch") is heating_schedule_switch
