"""Adds support for generic thermostat units."""

import asyncio
from collections.abc import Mapping
from datetime import datetime, timedelta
from functools import partial
import logging
import math
import time
from typing import Any, override

import voluptuous as vol

from homeassistant.components.climate import (
    ATTR_HVAC_MODE,
    PLATFORM_SCHEMA as CLIMATE_PLATFORM_SCHEMA,
    PRESET_NONE,
    ClimateEntity,
    ClimateEntityFeature,
    ClimateEntityStateAttribute,
    HVACAction,
    HVACMode,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
    ATTR_ENTITY_ID,
    ATTR_TEMPERATURE,
    CONF_NAME,
    CONF_UNIQUE_ID,
    EVENT_HOMEASSISTANT_START,
    PRECISION_HALVES,
    PRECISION_TENTHS,
    PRECISION_WHOLE,
    SERVICE_TURN_OFF,
    SERVICE_TURN_ON,
    STATE_ON,
    STATE_UNAVAILABLE,
    STATE_UNKNOWN,
    UnitOfTemperature,
)
from homeassistant.core import (
    CALLBACK_TYPE,
    DOMAIN as HOMEASSISTANT_DOMAIN,
    Context,
    CoreState,
    Event,
    EventStateChangedData,
    HomeAssistant,
    State,
    callback,
)
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.device import async_entity_id_to_device
from homeassistant.helpers.device_registry import DeviceEntry
from homeassistant.helpers.entity import CONTEXT_RECENT_TIME_SECONDS
from homeassistant.helpers.entity_platform import (
    AddConfigEntryEntitiesCallback,
    AddEntitiesCallback,
)
from homeassistant.helpers.event import (
    async_call_later,
    async_track_state_change_event,
    async_track_time_interval,
)
from homeassistant.helpers.reload import async_setup_reload_service
from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, VolDictType
from homeassistant.util import dt as dt_util

from .const import (
    CONF_AC_MODE,
    CONF_COLD_TOLERANCE,
    CONF_DUR_COOLDOWN,
    CONF_HEATER,
    CONF_HOT_TOLERANCE,
    CONF_KEEP_ALIVE,
    CONF_MAX_DUR,
    CONF_MAX_TEMP,
    CONF_MIN_DUR,
    CONF_MIN_TEMP,
    CONF_PRESETS,
    CONF_SENSOR,
    DEFAULT_TOLERANCE,
    DOMAIN,
    PLATFORMS,
)

_LOGGER = logging.getLogger(__name__)

DEFAULT_NAME = "Generic Thermostat"

CONF_INITIAL_HVAC_MODE = "initial_hvac_mode"
CONF_PRECISION = "precision"
CONF_TARGET_TEMP = "target_temp"
CONF_TEMP_STEP = "target_temp_step"


PRESETS_SCHEMA: VolDictType = {
    vol.Optional(v): vol.Coerce(float) for v in CONF_PRESETS.values()
}

PLATFORM_SCHEMA_COMMON = vol.Schema(
    {
        vol.Required(CONF_HEATER): cv.entity_id,
        vol.Required(CONF_SENSOR): cv.entity_id,
        vol.Optional(CONF_AC_MODE): cv.boolean,
        vol.Optional(CONF_MAX_TEMP): vol.Coerce(float),
        vol.Optional(CONF_MIN_DUR): cv.positive_time_period,
        vol.Optional(CONF_MAX_DUR): cv.positive_time_period,
        vol.Optional(CONF_DUR_COOLDOWN): cv.positive_time_period,
        vol.Optional(CONF_MIN_TEMP): vol.Coerce(float),
        vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
        vol.Optional(CONF_COLD_TOLERANCE, default=DEFAULT_TOLERANCE): vol.Coerce(float),
        vol.Optional(CONF_HOT_TOLERANCE, default=DEFAULT_TOLERANCE): vol.Coerce(float),
        vol.Optional(CONF_TARGET_TEMP): vol.Coerce(float),
        vol.Optional(CONF_KEEP_ALIVE): cv.positive_time_period,
        vol.Optional(CONF_INITIAL_HVAC_MODE): vol.In(
            [HVACMode.COOL, HVACMode.HEAT, HVACMode.OFF]
        ),
        vol.Optional(CONF_PRECISION): vol.All(
            vol.Coerce(float),
            vol.In([PRECISION_TENTHS, PRECISION_HALVES, PRECISION_WHOLE]),
        ),
        vol.Optional(CONF_TEMP_STEP): vol.All(
            vol.In([PRECISION_TENTHS, PRECISION_HALVES, PRECISION_WHOLE])
        ),
        vol.Optional(CONF_UNIQUE_ID): cv.string,
        **PRESETS_SCHEMA,
    }
)


PLATFORM_SCHEMA = CLIMATE_PLATFORM_SCHEMA.extend(PLATFORM_SCHEMA_COMMON.schema)


async def async_setup_entry(
    hass: HomeAssistant,
    config_entry: ConfigEntry,
    async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
    """Initialize config entry."""
    await _async_setup_config(
        hass,
        PLATFORM_SCHEMA_COMMON(dict(config_entry.options)),
        config_entry.entry_id,
        async_add_entities,
        device=async_entity_id_to_device(hass, config_entry.options[CONF_HEATER]),
    )


async def async_setup_platform(
    hass: HomeAssistant,
    config: ConfigType,
    async_add_entities: AddEntitiesCallback,
    discovery_info: DiscoveryInfoType | None = None,
) -> None:
    """Set up the generic thermostat platform."""

    await async_setup_reload_service(hass, DOMAIN, PLATFORMS)
    await _async_setup_config(
        hass, config, config.get(CONF_UNIQUE_ID), async_add_entities
    )


async def _async_setup_config(
    hass: HomeAssistant,
    config: Mapping[str, Any],
    unique_id: str | None,
    async_add_entities: AddEntitiesCallback | AddConfigEntryEntitiesCallback,
    device: DeviceEntry | None = None,
) -> None:
    """Set up the generic thermostat platform."""

    name: str = config[CONF_NAME]
    heater_entity_id: str = config[CONF_HEATER]
    sensor_entity_id: str = config[CONF_SENSOR]
    min_temp: float | None = config.get(CONF_MIN_TEMP)
    max_temp: float | None = config.get(CONF_MAX_TEMP)
    target_temp: float | None = config.get(CONF_TARGET_TEMP)
    ac_mode: bool | None = config.get(CONF_AC_MODE)
    min_cycle_duration: timedelta | None = config.get(CONF_MIN_DUR)
    max_cycle_duration: timedelta | None = config.get(CONF_MAX_DUR)
    cycle_cooldown: timedelta | None = config.get(CONF_DUR_COOLDOWN)
    cold_tolerance: float = config[CONF_COLD_TOLERANCE]
    hot_tolerance: float = config[CONF_HOT_TOLERANCE]
    keep_alive: timedelta | None = config.get(CONF_KEEP_ALIVE)
    initial_hvac_mode: HVACMode | None = config.get(CONF_INITIAL_HVAC_MODE)
    presets: dict[str, float] = {
        key: config[value] for key, value in CONF_PRESETS.items() if value in config
    }
    precision: float | None = config.get(CONF_PRECISION)
    target_temperature_step: float | None = config.get(CONF_TEMP_STEP)
    unit = hass.config.units.temperature_unit

    async_add_entities(
        [
            GenericThermostat(
                name=name,
                heater_entity_id=heater_entity_id,
                sensor_entity_id=sensor_entity_id,
                min_temp=min_temp,
                max_temp=max_temp,
                target_temp=target_temp,
                ac_mode=ac_mode,
                min_cycle_duration=min_cycle_duration,
                max_cycle_duration=max_cycle_duration,
                cycle_cooldown=cycle_cooldown,
                cold_tolerance=cold_tolerance,
                hot_tolerance=hot_tolerance,
                keep_alive=keep_alive,
                initial_hvac_mode=initial_hvac_mode,
                presets=presets,
                precision=precision,
                target_temperature_step=target_temperature_step,
                unit=unit,
                unique_id=unique_id,
                device=device,
            )
        ]
    )


class GenericThermostat(ClimateEntity, RestoreEntity):
    """Representation of a Generic Thermostat device."""

    _attr_should_poll = False

    def __init__(
        self,
        *,
        name: str,
        heater_entity_id: str,
        sensor_entity_id: str,
        min_temp: float | None,
        max_temp: float | None,
        target_temp: float | None,
        ac_mode: bool | None,
        min_cycle_duration: timedelta | None,
        max_cycle_duration: timedelta | None,
        cycle_cooldown: timedelta | None,
        cold_tolerance: float,
        hot_tolerance: float,
        keep_alive: timedelta | None,
        initial_hvac_mode: HVACMode | None,
        presets: dict[str, float],
        precision: float | None,
        target_temperature_step: float | None,
        unit: UnitOfTemperature,
        unique_id: str | None,
        device: DeviceEntry | None = None,
    ) -> None:
        """Initialize the thermostat."""
        self._attr_name = name
        self.heater_entity_id = heater_entity_id
        self.sensor_entity_id = sensor_entity_id
        self.device_entry = device
        self.ac_mode = ac_mode
        self.min_cycle_duration = min_cycle_duration or timedelta()
        self.max_cycle_duration = max_cycle_duration
        self.cycle_cooldown = cycle_cooldown or timedelta()
        self._cold_tolerance = cold_tolerance
        # Subtract the cooldown so it doesn't impact startup
        self._last_toggled_time = dt_util.utcnow() - self.cycle_cooldown
        self._cycle_callback: CALLBACK_TYPE | None = None
        self._check_callback: CALLBACK_TYPE | None = None
        # Context ID used to detect our own toggles
        self._last_context_id: str | None = None
        self._hot_tolerance = hot_tolerance
        self._keep_alive = keep_alive
        self._hvac_mode = initial_hvac_mode
        self._saved_target_temp = target_temp or next(iter(presets.values()), None)
        self._temp_precision = precision
        self._temp_target_temperature_step = target_temperature_step
        if self.ac_mode:
            self._attr_hvac_modes = [HVACMode.COOL, HVACMode.OFF]
        else:
            self._attr_hvac_modes = [HVACMode.HEAT, HVACMode.OFF]
        self._active = False
        self._cur_temp: float | None = None
        self._temp_lock = asyncio.Lock()
        self._min_temp = min_temp
        self._max_temp = max_temp
        self._attr_preset_mode = PRESET_NONE
        self._target_temp = target_temp
        self._attr_temperature_unit = unit
        self._attr_unique_id = unique_id
        self._attr_supported_features = (
            ClimateEntityFeature.TARGET_TEMPERATURE
            | ClimateEntityFeature.TURN_OFF
            | ClimateEntityFeature.TURN_ON
        )
        if len(presets):
            self._attr_supported_features |= ClimateEntityFeature.PRESET_MODE
            self._attr_preset_modes = [PRESET_NONE, *presets.keys()]
        else:
            self._attr_preset_modes = [PRESET_NONE]
        self._presets = presets
        self._presets_inv = {v: k for k, v in presets.items()}

    @override
    async def async_added_to_hass(self) -> None:
        """Run when entity about to be added."""
        await super().async_added_to_hass()

        # Add listener
        self.async_on_remove(
            async_track_state_change_event(
                self.hass, [self.sensor_entity_id], self._async_sensor_changed
            )
        )
        self.async_on_remove(
            async_track_state_change_event(
                self.hass, [self.heater_entity_id], self._async_switch_changed
            )
        )
        self.async_on_remove(self._cancel_timers)

        if self._keep_alive:
            self.async_on_remove(
                async_track_time_interval(
                    self.hass, self._async_control_heating, self._keep_alive
                )
            )

        @callback
        def _async_startup(_: Event | None = None) -> None:
            """Init on startup."""
            sensor_state = self.hass.states.get(self.sensor_entity_id)
            if sensor_state and sensor_state.state not in (
                STATE_UNAVAILABLE,
                STATE_UNKNOWN,
            ):
                self._async_update_temp(sensor_state)
                self.async_write_ha_state()
            switch_state = self.hass.states.get(self.heater_entity_id)
            if switch_state and switch_state.state not in (
                STATE_UNAVAILABLE,
                STATE_UNKNOWN,
            ):
                self.hass.async_create_task(
                    self._check_switch_initial_state(), eager_start=True
                )

        if self.hass.state is CoreState.running:
            _async_startup()
        else:
            self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, _async_startup)

        # Check If we have an old state
        if (old_state := await self.async_get_last_state()) is not None:
            # If we have no initial temperature, restore
            if self._target_temp is None:
                # If we have a previously saved temperature
                if (
                    old_state.attributes.get(
                        ClimateEntityStateAttribute.TARGET_TEMPERATURE
                    )
                    is None
                ):
                    if self.ac_mode:
                        self._target_temp = self.max_temp
                    else:
                        self._target_temp = self.min_temp
                    _LOGGER.warning(
                        "Undefined target temperature, falling back to %s",
                        self._target_temp,
                    )
                else:
                    self._target_temp = float(
                        old_state.attributes[
                            ClimateEntityStateAttribute.TARGET_TEMPERATURE
                        ]
                    )
            if (
                self.preset_modes
                and old_state.attributes.get(ClimateEntityStateAttribute.PRESET_MODE)
                in self.preset_modes
            ):
                self._attr_preset_mode = old_state.attributes.get(
                    ClimateEntityStateAttribute.PRESET_MODE
                )
            if not self._hvac_mode and old_state.state:
                self._hvac_mode = HVACMode(old_state.state)

        else:
            # No previous state, try and restore defaults
            if self._target_temp is None:
                if self.ac_mode:
                    self._target_temp = self.max_temp
                else:
                    self._target_temp = self.min_temp
            _LOGGER.warning(
                "No previously saved temperature, setting to %s", self._target_temp
            )

        # Set default state to off
        if not self._hvac_mode:
            self._hvac_mode = HVACMode.OFF

    @property
    @override
    def precision(self) -> float:
        """Return the precision of the system."""
        if self._temp_precision is not None:
            return self._temp_precision
        return super().precision

    @property
    @override
    def target_temperature_step(self) -> float:
        """Return the supported step of target temperature."""
        if self._temp_target_temperature_step is not None:
            return self._temp_target_temperature_step
        # if a target_temperature_step is not defined, fallback to equal the precision
        return self.precision

    @property
    @override
    def current_temperature(self) -> float | None:
        """Return the sensor temperature."""
        return self._cur_temp

    @property
    @override
    def hvac_mode(self) -> HVACMode | None:
        """Return current operation."""
        return self._hvac_mode

    @property
    @override
    def hvac_action(self) -> HVACAction:
        """Return the current running hvac operation if supported.

        Need to be one of CURRENT_HVAC_*.
        """
        if self._hvac_mode == HVACMode.OFF:
            return HVACAction.OFF
        if not self._is_device_active:
            return HVACAction.IDLE
        if self.ac_mode:
            return HVACAction.COOLING
        return HVACAction.HEATING

    @property
    @override
    def target_temperature(self) -> float | None:
        """Return the temperature we try to reach."""
        return self._target_temp

    @override
    async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
        """Set hvac mode."""
        if hvac_mode == HVACMode.HEAT:
            self._hvac_mode = HVACMode.HEAT
            await self._async_control_heating(force=True)
        elif hvac_mode == HVACMode.COOL:
            self._hvac_mode = HVACMode.COOL
            await self._async_control_heating(force=True)
        elif hvac_mode == HVACMode.OFF:
            self._hvac_mode = HVACMode.OFF
            if self._is_device_active:
                await self._async_heater_turn_off()
        else:
            _LOGGER.error("Unrecognized hvac mode: %s", hvac_mode)
            return
        # Ensure we update the current operation after changing the mode
        self.async_write_ha_state()

    @override
    async def async_set_temperature(self, **kwargs: Any) -> None:
        """Set new target temperature."""
        if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None:
            return
        self._attr_preset_mode = self._presets_inv.get(temperature, PRESET_NONE)
        self._target_temp = temperature
        if (hvac_mode := kwargs.get(ATTR_HVAC_MODE)) is not None:
            await self.async_set_hvac_mode(hvac_mode)
            return
        await self._async_control_heating(force=True)
        self.async_write_ha_state()

    @property
    @override
    def min_temp(self) -> float:
        """Return the minimum temperature."""
        if self._min_temp is not None:
            return self._min_temp

        # get default temp from super class
        return super().min_temp

    @property
    @override
    def max_temp(self) -> float:
        """Return the maximum temperature."""
        if self._max_temp is not None:
            return self._max_temp

        # Get default temp from super class
        return super().max_temp

    async def _async_sensor_changed(self, event: Event[EventStateChangedData]) -> None:
        """Handle temperature changes."""
        new_state = event.data["new_state"]
        if new_state is None or new_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN):
            return

        self.async_set_context(event.context)
        self._async_update_temp(new_state)
        await self._async_control_heating()
        self.async_write_ha_state()

    async def _check_switch_initial_state(self) -> None:
        """Prevent the device from keep running if HVACMode.OFF."""
        if self._hvac_mode == HVACMode.OFF and self._is_device_active:
            _LOGGER.warning(
                (
                    "The climate mode is OFF, but the switch device is ON. Turning off"
                    " device %s"
                ),
                self.heater_entity_id,
            )
            await self._async_heater_turn_off()

    @callback
    def _async_switch_changed(self, event: Event[EventStateChangedData]) -> None:
        """Handle heater switch state changes."""
        new_state = event.data["new_state"]
        old_state = event.data["old_state"]
        if new_state is None:
            return
        if old_state is None:
            self.hass.async_create_task(
                self._check_switch_initial_state(), eager_start=True
            )

        # Update timestamp on toggle
        self._last_toggled_time = new_state.last_changed

        # If the user toggles the switch, assume they want control and clear the timers.
        # Note: If a manual interaction occurs within the 2s context window of a switch
        # toggle initiated by us, we may not detect manual control. Users are advised to
        # use the climate entity for reliable control, not the switch entity.
        if new_state.context.id != self._last_context_id:
            _LOGGER.debug("External switch change detected, clearing timers")
            self._last_context_id = None
            self._cancel_timers()
        self.async_write_ha_state()

    @callback
    def _async_update_temp(self, state: State) -> None:
        """Update thermostat with latest state from sensor."""
        try:
            cur_temp = float(state.state)
            if not math.isfinite(cur_temp):
                raise ValueError(f"Sensor has illegal state {state.state}")  # noqa: TRY301
            self._cur_temp = cur_temp
        except ValueError as ex:
            _LOGGER.error("Unable to update from sensor: %s", ex)

    async def _async_control_heating(
        self, _time: datetime | None = None, force: bool = False
    ) -> None:
        """Check if we need to turn heating on or off."""
        called_by_timer = _time is not None

        async with self._temp_lock:
            if not self._active and None not in (
                self._cur_temp,
                self._target_temp,
            ):
                self._active = True
                _LOGGER.debug(
                    (
                        "Obtained current and target temperature. "
                        "Generic thermostat active. %s, %s"
                    ),
                    self._cur_temp,
                    self._target_temp,
                )

            if not self._active or self._hvac_mode == HVACMode.OFF:
                return

            if force and called_by_timer and self.max_cycle_duration:
                # We were invoked due to `max_cycle_duration`, so turn off
                _LOGGER.debug(
                    "Turning off heater %s due to max cycle time of %s",
                    self.heater_entity_id,
                    self.max_cycle_duration,
                )
                self._cancel_cycle_timer()
                await self._async_heater_turn_off()
                return

            assert self._cur_temp is not None and self._target_temp is not None
            too_cold = self._target_temp > self._cur_temp + self._cold_tolerance
            too_hot = self._target_temp < self._cur_temp - self._hot_tolerance
            now = dt_util.utcnow()

            if self._is_device_active:
                if (self.ac_mode and too_cold) or (not self.ac_mode and too_hot):
                    # Make sure it's past the `min_cycle_duration` before turning off
                    if (
                        self._last_toggled_time + self.min_cycle_duration <= now
                        or force
                    ):
                        _LOGGER.debug("Turning off heater %s", self.heater_entity_id)
                        await self._async_heater_turn_off()
                    elif self._check_callback is None:
                        _LOGGER.debug(
                            "Minimum cycle time not reached, check again at %s",
                            self._last_toggled_time + self.min_cycle_duration,
                        )
                        self._check_callback = async_call_later(
                            self.hass,
                            now - self._last_toggled_time + self.min_cycle_duration,
                            self._async_timer_control_heating,
                        )
                elif called_by_timer:
                    # This is a keep-alive call, so ensure it's on
                    _LOGGER.debug(
                        "Keep-alive - Turning on heater %s",
                        self.heater_entity_id,
                    )
                    await self._async_heater_turn_on(keepalive=True)
            elif (self.ac_mode and too_hot) or (not self.ac_mode and too_cold):
                # Make sure it's past the `cycle_cooldown` before turning on
                if self._last_toggled_time + self.cycle_cooldown <= now or force:
                    _LOGGER.debug("Turning on heater %s", self.heater_entity_id)
                    await self._async_heater_turn_on()
                elif self._check_callback is None:
                    _LOGGER.debug(
                        "Cooldown time not reached, check again at %s",
                        self._last_toggled_time + self.cycle_cooldown,
                    )
                    self._check_callback = async_call_later(
                        self.hass,
                        now - self._last_toggled_time + self.cycle_cooldown,
                        self._async_timer_control_heating,
                    )
            elif called_by_timer:
                # This is a keep-alive call, so ensure it's off
                _LOGGER.debug(
                    "Keep-alive - Turning off heater %s", self.heater_entity_id
                )
                await self._async_heater_turn_off(keepalive=True)

    @property
    def _is_device_active(self) -> bool | None:
        """If the toggleable device is currently active."""
        if not self.hass.states.get(self.heater_entity_id):
            return None

        return self.hass.states.is_state(self.heater_entity_id, STATE_ON)

    def _get_current_context(self) -> Context | None:
        """Return the current context if it is still recent, or None."""
        if (
            self._context_set is not None
            and time.time() - self._context_set > CONTEXT_RECENT_TIME_SECONDS
        ):
            self._context = None
            self._context_set = None
        return self._context

    async def _async_heater_turn_on(self, keepalive: bool = False) -> None:
        """Turn heater toggleable device on."""
        data = {ATTR_ENTITY_ID: self.heater_entity_id}
        # Create a child context for the switch service call so we can
        # identify the resulting state change event as originating from us.
        # Don't set it as our own context — the climate entity's state changes
        # should remain attributed to the parent context (e.g., set_hvac_mode).
        current_context = self._get_current_context()
        new_context = Context(parent_id=current_context.id if current_context else None)
        self._last_context_id = new_context.id
        await self.hass.services.async_call(
            HOMEASSISTANT_DOMAIN, SERVICE_TURN_ON, data, context=new_context
        )
        if not keepalive:
            # Update timestamp on turn on
            self._last_toggled_time = dt_util.utcnow()
            self._cancel_check_timer()
            if self.max_cycle_duration:
                _LOGGER.debug(
                    "Scheduling maximum run-time shut-off for %s",
                    self._last_toggled_time + self.max_cycle_duration,
                )
                self._cancel_cycle_timer()
                self._cycle_callback = async_call_later(
                    self.hass,
                    self.max_cycle_duration,
                    partial(self._async_control_heating, force=True),
                )

    async def _async_heater_turn_off(self, keepalive: bool = False) -> None:
        """Turn heater toggleable device off."""
        data = {ATTR_ENTITY_ID: self.heater_entity_id}
        # Create a child context for the switch service call so we can
        # identify the resulting state change event as originating from us.
        # Don't set it as our own context — the climate entity's state changes
        # should remain attributed to the parent context (e.g., set_hvac_mode).
        current_context = self._get_current_context()
        new_context = Context(parent_id=current_context.id if current_context else None)
        self._last_context_id = new_context.id
        await self.hass.services.async_call(
            HOMEASSISTANT_DOMAIN, SERVICE_TURN_OFF, data, context=new_context
        )
        if not keepalive:
            # Update timestamp on turn off
            self._last_toggled_time = dt_util.utcnow()
            self._cancel_timers()

    @override
    async def async_set_preset_mode(self, preset_mode: str) -> None:
        """Set new preset mode."""
        if preset_mode not in (self.preset_modes or []):
            raise ValueError(
                f"Got unsupported preset_mode {preset_mode}. Must be one of"
                f" {self.preset_modes}"
            )
        if preset_mode == self._attr_preset_mode:
            # I don't think we need to call async_write_ha_state
            # if we didn't change the state
            return
        if preset_mode == PRESET_NONE:
            self._attr_preset_mode = PRESET_NONE
            self._target_temp = self._saved_target_temp
            await self._async_control_heating(force=True)
        else:
            if self._attr_preset_mode == PRESET_NONE:
                self._saved_target_temp = self._target_temp
            self._attr_preset_mode = preset_mode
            self._target_temp = self._presets[preset_mode]
            await self._async_control_heating(force=True)

        self.async_write_ha_state()

    async def _async_timer_control_heating(self, _: datetime | None = None) -> None:
        """Reset check timer and control heating."""
        self._check_callback = None
        await self._async_control_heating()

    @callback
    def _cancel_check_timer(self) -> None:
        """Reset check timer."""
        if self._check_callback:
            _LOGGER.debug("Cancelling scheduled state check")
            self._check_callback()
            self._check_callback = None

    @callback
    def _cancel_cycle_timer(self) -> None:
        """Reset cycle timer."""
        if self._cycle_callback:
            _LOGGER.debug("Cancelling scheduled shut-off")
            self._cycle_callback()
            self._cycle_callback = None

    @callback
    def _cancel_timers(self) -> None:
        """Reset timers."""
        self._cancel_check_timer()
        self._cancel_cycle_timer()
