# pylint: disable=unused-argument, line-too-long, too-many-lines, broad-exception-caught

"""Underlying entities classes"""
import re
from typing import Any, Dict, List, Optional, Tuple, TypeVar
from collections.abc import Callable
from datetime import datetime, timedelta

from enum import StrEnum

from homeassistant.const import ATTR_ENTITY_ID, STATE_ON, STATE_OFF, STATE_UNAVAILABLE, STATE_UNKNOWN, UnitOfTemperature
from homeassistant.core import State

from homeassistant.exceptions import ServiceNotFound

from homeassistant.core import HomeAssistant, CALLBACK_TYPE, Context, ServiceResponse
from homeassistant.components.climate import (
    ClimateEntityFeature,
    DOMAIN as CLIMATE_DOMAIN,
    HVACAction,
    HVACMode,
    SERVICE_SET_HVAC_MODE,
    SERVICE_SET_FAN_MODE,
    SERVICE_SET_HUMIDITY,
    SERVICE_SET_SWING_MODE,
    SERVICE_SET_SWING_HORIZONTAL_MODE,
    SERVICE_TURN_OFF,
    SERVICE_TURN_ON,
    SERVICE_SET_TEMPERATURE,
)

from homeassistant.components.number import SERVICE_SET_VALUE

from homeassistant.helpers.event import async_call_later
from homeassistant.util import dt as dt_util

from vtherm_api.log_collector import get_vtherm_logger
from .opening_degree_algorithm import OpeningClosingDegreeCalculation


from .const import *  # pylint: disable=wildcard-import, unused-wildcard-import
from .vtherm_hvac_mode import VThermHvacMode, from_ha_hvac_mode, to_legacy_ha_hvac_mode
from .keep_alive import IntervalCaller
from .vtherm_central_api import VersatileThermostatAPI
from .underlying_state_manager import UnderlyingStateManager

resend_delay_sec = 2

_LOGGER = get_vtherm_logger(__name__)

class UnderlyingEntityType(StrEnum):
    """All underlying device type"""

    # A switch
    SWITCH = "switch"

    # a climate
    CLIMATE = "climate"

    # a valve
    VALVE = "valve"

    # a direct valve regulation
    VALVE_REGULATION = "valve_regulation"

# ----------------------------------------------------------------
# UnderlyingEntity
# ----------------------------------------------------------------
class UnderlyingEntity:
    """Represent a underlying device which could be a switch or a climate"""

    def __init__(
        self,
        hass: HomeAssistant,
        thermostat: Any,
        entity_type: UnderlyingEntityType,
        entity_id: str,
    ) -> None:
        """Initialize the underlying entity"""
        self._hass: HomeAssistant = hass
        self._thermostat: Any = thermostat
        self._type: UnderlyingEntityType = entity_type
        self._entity_id: str = entity_id
        self._power_reservation_key = entity_id
        self._hvac_mode: VThermHvacMode | None = None
        self._on_cycle_start_callbacks: list[Callable] = []
        self._last_command_sent_datetime: datetime = dt_util.utc_from_timestamp(0)
        # Use UnderlyingStateManager to track underlying entity state
        self._state_manager: UnderlyingStateManager = UnderlyingStateManager(self._hass, on_change=self._underlying_changed)
        self._is_initialized: bool = False
        self._api = VersatileThermostatAPI.get_vtherm_api(hass)

    def register_cycle_callback(self, on_start: Callable):
        """Register a callback for cycle start"""
        self._on_cycle_start_callbacks.append(on_start)

    def __str__(self):
        return str(self._thermostat) + "-" + self._entity_id

    @property
    def entity_id(self):
        """The entity id represented by this class"""
        return self._entity_id

    @property
    def power_reservation_key(self) -> str:
        """Return the stable power reservation key for this underlying."""
        return self._power_reservation_key

    @property
    def entity_type(self) -> UnderlyingEntityType:
        """The entity type represented by this class"""
        return self._type

    @property
    def is_initialized(self) -> bool:
        """True if the underlying is initialized and have received a non None state"""
        return self._is_initialized

    def startup(self):
        """Startup the Entity. Listen to the underlying state changes"""
        # starts listening and can provide the initial cached state.
        self._state_manager.add_underlying_entities([self._entity_id])

    async def _underlying_changed(self, entity_id: str, new_state: Optional[State], old_state: Optional[State] = None):
        """Handle underlying state change notified by UnderlyingStateManager.

        `new_state` may be None when the entity is removed/unavailable.
        Runs the initial state checks when all underlying entities are initialized.
        """
        _LOGGER.debug("%s - --------> Underlying state change received: '%s'", self, new_state)

        # If not yet initialized and we received a valid initial state, run initial checks
        if not self.is_initialized:
            # Check if we have a valid state for all underlying entities for the first time
            if self._state_manager.is_all_states_initialized:
                self._is_initialized = True
                _LOGGER.debug("%s - All underlying states are now initialized", self)
                await self.check_initial_state()
                # if all underlying of the vtherm are initialized, notify the parent thermostat
                if self._thermostat.is_initialized:
                    await self._thermostat.init_underlyings_completed(self._entity_id)
                return
            else:
                _LOGGER.debug("%s - Underlying state still not yet initialized", self)
        # Otherwise, the manager holds the latest state
        # Update the hvac_action of the parent which could have changed
        if self._thermostat.hvac_action != self._thermostat.calculate_hvac_action():
            self._thermostat.update_custom_attributes()
            self._thermostat.async_write_ha_state()

        # update the sensor which count the number of active devices is vtherm is used by central boiler
        if (
            self._thermostat.is_used_by_central_boiler
            and self._api.central_boiler_manager is not None
            and self._api.central_boiler_manager.nb_device_active_for_boiler_entity is not None
        ):
            await self._api.central_boiler_manager.nb_device_active_for_boiler_entity.calculate_nb_active_devices(None)

    async def set_hvac_mode(self, hvac_mode: VThermHvacMode):
        """Set the HVACmode"""
        self._hvac_mode = hvac_mode
        return

    async def set_temperature(self, temperature, max_temp, min_temp):
        """Set the target temperature"""
        return

    # This should be the correct way to handle turn_off and turn_on but this breaks the unit test
    # will an not understandable error: TypeError: object MagicMock can't be used in 'await' expression
    async def turn_off(self):
        """Turn off the underlying equipement.
        Need to be overriden"""
        return NotImplementedError

    async def turn_on(self):
        """Turn off the underlying equipement.
        Need to be overriden"""
        return NotImplementedError

    def remove_entity(self):
        """Remove the underlying entity"""
        self._on_cycle_start_callbacks.clear()
        # Stop the state manager listener for this entity
        self._state_manager.stop()

    async def check_initial_state(self):
        """Prevent the underlying to be on but thermostat is off"""
        return NotImplementedError

    # override to be able to mock the call
    def call_later(
        self, hass: HomeAssistant, delay_sec: int, called_method
    ) -> CALLBACK_TYPE:
        """Call the method after a delay"""
        return async_call_later(hass, delay_sec, called_method)

    async def hass_services_async_call(
        self,
        domain: str,
        service: str,
        service_data: dict[str, Any] | None = None,
        blocking: bool = False,
        context: Context | None = None,
        target: dict[str, Any] | None = None,
        return_response: bool = False,
    ) -> ServiceResponse:
        """Wrapper for HASS service calls"""
        try:
            response: ServiceResponse = await self._hass.services.async_call(domain, service, service_data, blocking, context, target, return_response)

            self._last_command_sent_datetime = self._thermostat.now
            return response
        except Exception as err:
            _LOGGER.error("%s - Error calling service %s.%s: %s. The underlying will not change its state.", self, domain, service, err)

    def clamp_sent_value(self, value) -> float:
        """capping of the value send to the underlying eqt"""
        return value

    async def check_overpowering(self) -> bool:
        """Check that a underlying can be turned on, else
        activate the overpowering state of the VTherm associated.
        Returns True if the check is ok (no overpowering needed)"""
        ret, _ = await self._thermostat.power_manager.check_power_available(
            self.power_reservation_key
        )
        if not ret:
            _LOGGER.debug("%s - overpowering is detected", self)
            await self._thermostat.power_manager.set_overpowering(True)
            return False

        return True

    async def send_value_to_number(self, number_entity_id: str, value: int):
        """Send a value to a number entity"""
        try:
            data = {"value": value}
            target = {ATTR_ENTITY_ID: number_entity_id}
            domain = number_entity_id.split(".")[0]
            await self.hass_services_async_call(
                domain=domain,
                service=SERVICE_SET_VALUE,
                service_data=data,
                target=target,
            )
        except ServiceNotFound as err:
            _LOGGER.error(err)
            # This could happens in unit test if input_number domain is not yet loaded
            # raise err

    @property
    def hvac_mode(self) -> VThermHvacMode | None:
        """Return the current hvac_mode"""
        return self._hvac_mode

    @property
    def should_device_be_active(self) -> bool | None:
        """If the underlying device should currently be active.
        Need to be overriden"""
        return NotImplementedError

    @property
    def is_device_active(self) -> bool | None:
        """If the underlying device is currently active.
        Need to be overriden"""
        return NotImplementedError

    @property
    def hvac_action(self) -> HVACAction:
        """Calculate a hvac_action"""
        raise NotImplementedError

    @property
    def is_inversed(self):
        """Tells if the switch command should be inversed"""
        return False

    @property
    def state_manager(self) -> UnderlyingStateManager:
        """Return the underlying state manager"""
        return self._state_manager

    # For testing compatibility
    @property
    def _last_known_underlying_state(self) -> Optional[State]:
        """Return the last known underlying state"""
        return self._state_manager.get_state(self._entity_id)

    @property
    def last_change(self) -> datetime | None:
        """Return the last_changed datetime of the underlying device if known."""
        state = self._state_manager.get_state(self._entity_id)
        return state.last_changed if state else None

    async def check_and_repair(self) -> bool:
        """Check if the underlying device state matches the desired state and repair if needed.
        Returns True if a repair was performed."""
        should_be_active = self.should_device_be_active
        is_active = self.is_device_active

        _LOGGER.debug("%s - Checking if underlying state needs repair. should_be_on=%s, is_on=%s", self, should_be_active, is_active)

        if should_be_active is None or is_active is None:
            return False

        if should_be_active == is_active:
            return False

        if should_be_active:
            await self.turn_on()
        else:
            await self.turn_off()
        return True


# ----------------------------------------------------------------
# UnderlyingSwitch
# ----------------------------------------------------------------
class UnderlyingSwitch(UnderlyingEntity):
    """Represent a underlying switch"""

    def __init__(
        self, hass: HomeAssistant, thermostat: Any, switch_entity_id: str, keep_alive_sec: float, vswitch_on: str = None, vswitch_off: str = None
    ) -> None:
        """Initialize the underlying switch"""

        super().__init__(
            hass=hass,
            thermostat=thermostat,
            entity_type=UnderlyingEntityType.SWITCH,
            entity_id=switch_entity_id,
        )
        self._on_time_sec = 0
        self._off_time_sec = 0
        self._new_on_time_sec = 0
        self._new_off_time_sec = 0
        self._is_removed = False
        self._keep_alive = IntervalCaller(hass, keep_alive_sec)
        self._vswitch_on = vswitch_on.strip() if vswitch_on else None
        self._vswitch_off = vswitch_off.strip() if vswitch_off else None
        self._domain = self._entity_id.split(".")[0]
        # build command
        command, data, state_on = self.build_command(use_on=True)
        self._on_command = {"command": command, "data": data, "state": state_on}
        command, data, state_off = self.build_command(use_on=False)
        self._off_command = {"command": command, "data": data, "state": state_off}

        # the underlying is cycling with on_percent > 0
        self._should_be_on = False

        # true if the on part of the cycle is running. Off if the off part is running
        self._is_on_part_running = False

    @overrides
    @property
    def is_inversed(self) -> bool:
        """Tells if the switch command should be inversed"""
        return self._thermostat.is_inversed

    @property
    def keep_alive_sec(self) -> float:
        """Return the switch keep-alive interval in seconds."""
        return self._keep_alive.interval_sec

    @overrides
    def startup(self):
        super().startup()
        self._keep_alive.set_async_action(self._keep_alive_callback)

    # @overrides this breaks some unit tests TypeError: object MagicMock can't be used in 'await' expression
    async def set_hvac_mode(self, hvac_mode: VThermHvacMode) -> bool:
        """Set the HVACmode. Returns true if something have change"""

        if hvac_mode == VThermHvacMode_OFF:
            if self.is_device_active:
                await self.turn_off()

        if self.hvac_mode != hvac_mode:
            await super().set_hvac_mode(hvac_mode)
            self._calculate_should_be_on()
            return True
        else:
            return False

    def _calculate_should_be_on(self, should_be_on: bool | None = None) -> bool:
        """Calculate and update the _should_be_on flag"""
        self._should_be_on = self._hvac_mode in [VThermHvacMode_HEAT, VThermHvacMode_COOL] and self._on_time_sec > 0

        return self._should_be_on

    @property
    def should_device_be_active(self) -> bool:
        """If the toggleable device is currently active."""
        return self._calculate_should_be_on()

    @property
    def is_device_active(self) -> bool | None:
        """If the toggleable device is currently active."""
        if not self.is_initialized:
            return None
        state = self._state_manager.get_state(self._entity_id)
        if state is None:
            return None

        is_on = state.state == self._on_command.get("state")
        return is_on

    async def check_initial_state(self):
        """Prevent the underlying to be on but thermostat is off"""
        hvac_mode = self._thermostat.vtherm_hvac_mode

        if hvac_mode == VThermHvacMode_OFF and self.is_device_active:
            _LOGGER.info(
                "%s - The hvac mode is OFF, but the underlying device is ON. Turning off device %s",
                self,
                self._entity_id,
            )
            await self.turn_off()
        # elif hvac_mode != VThermHvacMode_OFF and not is_device_active:
        # it is normal, the cycle could be started later or in off phase

    async def _keep_alive_callback(self):
        """Keep alive: Turn on if already turned on, turn off if already turned off."""
        timer = self._keep_alive.backoff_timer
        state: State | None = self._hass.states.get(self._entity_id)
        # Normal, expected state.state values are "on" and "off". An absent
        # underlying MQTT switch was observed to produce either state == None
        # or state.state == STATE_UNAVAILABLE ("unavailable").
        if state is None or state.state == STATE_UNAVAILABLE:
            if timer.is_ready():
                _LOGGER.warning(
                    "%s - Entity %s is not available (state: %s). Will keep trying " "keep alive calls, but won't log this condition every time.",
                    self,
                    self._entity_id,
                    state.state if state else "None",
                )
        else:
            if timer.in_progress:
                timer.reset()
                _LOGGER.warning(
                    "%s - Entity %s has recovered (state: %s).",
                    self,
                    self._entity_id,
                    state.state,
                )
            await (self.turn_on() if self._is_on_part_running else self.turn_off())

    def build_command(self, use_on: bool) -> Tuple[str, Dict[str, str]]:
        """Build a command and returns a command and a dict as data"""

        value = None
        data = {ATTR_ENTITY_ID: self._entity_id}
        take_on = (use_on and not self.is_inversed) or (not use_on and self.is_inversed)
        vswitch = self._vswitch_on if take_on else self._vswitch_off
        if vswitch:
            pattern = r"^(?P<command>[^\s/]+)(?:/(?P<argument>[^\s:]+)(?::(?P<value>[^\s]+))?)?$"
            match = re.match(pattern, vswitch)

            if match:
                # Extraire les groupes nommés
                command = match.group("command")
                argument = match.group("argument")
                value = match.group("value")
                if argument is not None and value is not None:
                    data.update({argument: value})
            else:
                raise ValueError(f"Invalid input format: {vswitch}. Must be conform to 'command[/argument[:value]]'")

        else:
            command = SERVICE_TURN_ON if take_on else SERVICE_TURN_OFF

        if value is None:
            value = STATE_ON if take_on else STATE_OFF

        return command, data, value

    async def turn_off(self):
        """Turn heater toggleable device off."""
        self._keep_alive.cancel()  # Cancel early to avoid a turn_on/turn_off race condition
        _LOGGER.debug("%s - Stopping underlying entity %s", self, self._entity_id)

        command = self._off_command.get("command")
        data = self._off_command.get("data")

        # This may fails if called after shutdown
        try:
            try:
                self._thermostat.power_manager.sub_power_consumption_to_central_power_manager(
                    self.power_reservation_key
                )
                _LOGGER.debug("%s - Sending command %s with data=%s", self, command, data)
                await self._hass.services.async_call(self._domain, command, data)
                self._is_on_part_running = False
                self._keep_alive.set_async_action(self._keep_alive_callback)
            except Exception:
                self._keep_alive.cancel()
                raise
        except ServiceNotFound as err:
            _LOGGER.error(err)

    async def turn_on(self):
        """Turn heater toggleable device on."""
        self._keep_alive.cancel()  # Cancel early to avoid a turn_on/turn_off race condition
        _LOGGER.debug("%s - Starting underlying entity %s", self, self._entity_id)

        if not await self.check_overpowering():
            self._thermostat.requested_state.force_changed()
            await self._thermostat.update_states(force=True)

            return False

        command = self._on_command.get("command")
        data = self._on_command.get("data")
        try:
            try:
                self._thermostat.power_manager.add_power_consumption_to_central_power_manager(
                    self.power_reservation_key
                )
                _LOGGER.debug("%s - Sending command %s with data=%s", self, command, data)
                await self._hass.services.async_call(self._domain, command, data)
                self._is_on_part_running = True
                self._keep_alive.set_async_action(self._keep_alive_callback)
                return True
            except Exception:
                self._keep_alive.cancel()
                raise
        except ServiceNotFound as err:
            _LOGGER.error(err)

    @overrides
    def remove_entity(self):
        """Remove the entity"""
        self._is_removed = True
        self._keep_alive.cancel()
        super().remove_entity()

    def hvac_action(self) -> HVACAction:
        """Calculate a hvac_action based on the current state and should_be_on"""
        if not self.is_initialized:
            return HVACAction.OFF

        return HVACAction.HEATING if self.should_device_be_active is True else HVACAction.OFF

    async def check_and_repair(self) -> bool:
        """Check if the underlying device state matches the desired state and repair if needed.
        Returns True if a repair was performed."""
        should_be_on = self._is_on_part_running
        is_on = self.is_device_active

        _LOGGER.debug("%s - Checking if underlying switch state needs repair. should_be_on=%s, is_on=%s", self, should_be_on, is_on)

        if should_be_on is None or is_on is None:
            return False

        if should_be_on == is_on:
            return False

        if should_be_on:
            await self.turn_on()
            return True
        else:
            await self.turn_off()
            return True


# ----------------------------------------------------------------
# UnderlyingClimate
# ----------------------------------------------------------------
class UnderlyingClimate(UnderlyingEntity):
    """Represent a underlying climate"""

    def __init__(
        self,
        hass: HomeAssistant,
        thermostat: Any,
        climate_entity_id: str,
    ) -> None:
        """Initialize the underlying climate"""

        super().__init__(
            hass=hass,
            thermostat=thermostat,
            entity_type=UnderlyingEntityType.CLIMATE,
            entity_id=climate_entity_id,
        )
        self._last_sent_temperature: Optional[float] = None
        self._cancel_set_fan_mode_later: Optional[Callable[[], None]] = None
        self._cancel_set_temperature_later: Optional[Callable[[], None]] = None
        self._min_sync_entity: float = None
        self._max_sync_entity: float = None
        self._step_sync_entity: float = None

    async def set_hvac_mode(self, hvac_mode: VThermHvacMode) -> bool:
        """Set the HVACmode of the underlying climate. Returns true if something have change"""
        state = self._state_manager.get_state(self._entity_id)
        if state is None:
            return False

        if state.state == to_ha_hvac_mode(hvac_mode) and self._hvac_mode == hvac_mode:
            _LOGGER.debug(
                "%s - hvac_mode is already is requested state %s. Do not send any command",
                self,
                hvac_mode,
            )
            return False

        # When turning on a climate, check that power is available
        if hvac_mode in (VThermHvacMode_HEAT, VThermHvacMode_COOL) and not await self.check_overpowering():
            return False

        await super().set_hvac_mode(hvac_mode)

        data = {ATTR_ENTITY_ID: self._entity_id, "hvac_mode": to_legacy_ha_hvac_mode(hvac_mode)}
        await self.hass_services_async_call(
            CLIMATE_DOMAIN,
            SERVICE_SET_HVAC_MODE,
            data,
        )

        # if restart the climate, then resend the target temperature 2 sec later for lazy SonoffTRVZB
        if hvac_mode in (VThermHvacMode_HEAT, VThermHvacMode_COOL):

            async def callback_resend_temp(_):
                await self.set_temperature(self._thermostat.target_temperature, None, None)

            if self._cancel_set_temperature_later:
                self._cancel_set_temperature_later()
            self._cancel_set_temperature_later = async_call_later(self._hass, resend_delay_sec, callback_resend_temp)

        return True

    @overrides
    def remove_entity(self):
        """Remove the entity"""
        if self._cancel_set_fan_mode_later:
            self._cancel_set_fan_mode_later()
            self._cancel_set_fan_mode_later = None
        if self._cancel_set_temperature_later:
            self._cancel_set_temperature_later()
            self._cancel_set_temperature_later = None
        super().remove_entity()

    @property
    def should_device_be_active(self):
        """If the toggleable device is currently active."""
        return self.hvac_mode != VThermHvacMode_OFF and self.hvac_action not in [
            HVACAction.IDLE,
            HVACAction.OFF,
            None,
        ]

    @property
    def is_device_active(self):
        """If the toggleable device is currently active."""
        state = self._state_manager.get_state(self._entity_id)
        if state is None:
            return None

        # Issue 1890 - unavailable/unknown entities should not be counted as active
        if state.state in [STATE_UNAVAILABLE, STATE_UNKNOWN]:
            return None

        # Issue 1779 - if no hvac_action is available use a fake hvac_action based on temperature
        hvac_action = state.attributes.get("hvac_action", None)
        if not hvac_action:
            hvac_action = self.hvac_action

        # The device is active if hvac_mode is not OFF/IDLE and hvac_action is not OFF/IDLE. hvac_action could be None because it is not always implemented by all climate entities
        return state.state != HVACMode.OFF and (hvac_action not in [HVACAction.IDLE, HVACAction.OFF])

    async def check_initial_state(self):
        """Prevent the underlying to be on but thermostat is off"""
        underlying_state = self._state_manager.get_state(self._entity_id)
        underlying_hvac_mode = from_ha_hvac_mode(underlying_state.state) if underlying_state else None
        self._hvac_mode = underlying_hvac_mode

        is_device_active = underlying_state.state not in [HVACMode.OFF, STATE_UNAVAILABLE, STATE_UNKNOWN]
        hvac_mode = self._thermostat.vtherm_hvac_mode

        if hvac_mode == VThermHvacMode_OFF and is_device_active:
            _LOGGER.info(
                "%s - The hvac mode is OFF, but the underlying device is ON. Turning off device %s",
                self,
                self._entity_id,
            )
            await self.set_hvac_mode(hvac_mode)
        elif hvac_mode != VThermHvacMode_OFF and not is_device_active:
            _LOGGER.info(
                "%s - The hvac mode is %s, but the underlying device is not ON. Turning on device %s if needed",
                self,
                hvac_mode,
                self._entity_id,
            )
            await self.set_hvac_mode(hvac_mode)

    async def _underlying_changed(self, entity_id: str, new_state: Optional[State], old_state: Optional[State] = None):
        """Handle underlying state change notified by UnderlyingStateManager.

        `new_state` may be None when the entity is removed/unavailable.
        Runs the initial state checks when all underlying entities are initialized.
        """
        await super()._underlying_changed(entity_id, new_state, old_state)
        if not self.is_initialized or new_state is None:
            return

        # Check if one attributes has changed that could impact the thermostat
        new_hvac_mode = VThermHvacMode(new_state.state)
        old_hvac_mode = VThermHvacMode(old_state.state) if old_state else None
        new_hvac_action = new_state.attributes.get("hvac_action") if new_state.attributes else None
        old_hvac_action = old_state.attributes.get("hvac_action") if old_state and old_state.attributes else None
        new_fan_mode = new_state.attributes.get("fan_mode") if new_state.attributes else None
        new_target_temp = new_state.attributes.get("temperature") if new_state.attributes else None

        # Ignore new target temperature when out of range
        if (
            not new_target_temp is None
            and not self._thermostat.min_temp is None
            and not self._thermostat.max_temp is None
            and not (self._thermostat.min_temp <= new_target_temp <= self._thermostat.max_temp)
        ):
            _LOGGER.debug(
                "%s - underlying sent a target temperature (%s) which is out of configured min/max range (%s / %s). The value will be ignored",
                self,
                new_target_temp,
                self._thermostat.min_temp,
                self._thermostat.max_temp,
            )
            new_target_temp = None

        under_temp_diff = 0
        if new_target_temp is not None:
            last_sent_temperature = self.last_sent_temperature or 0
            under_temp_diff = new_target_temp - last_sent_temperature

            # check the dtemp is > step
            step = self._thermostat.target_temperature_step or 1
            if -step < under_temp_diff < step:
                under_temp_diff = 0
                new_target_temp = None

        # Forget event when the event holds no real changes
        if new_hvac_mode == self._thermostat.hvac_mode:
            new_hvac_mode = None
        if new_hvac_action == old_hvac_action:
            new_hvac_action = None
        if new_fan_mode == self._thermostat.fan_mode:
            new_fan_mode = None
        if under_temp_diff == 0:
            new_target_temp = None

        if new_hvac_mode is None and new_hvac_action is None and new_target_temp is None and new_fan_mode is None:
            _LOGGER.debug(
                "%s - a underlying state change event is received but no real change have been found. Forget the event",
                self,
            )
            return

        await self._thermostat.underlying_changed(self, new_hvac_mode, new_hvac_action, new_target_temp, new_fan_mode, new_state, old_state)

    async def set_fan_mode(self, fan_mode):
        """Set new target fan mode."""
        if not self.is_initialized:
            return

        data = {
            ATTR_ENTITY_ID: self._entity_id,
            "fan_mode": fan_mode,
        }

        if self._cancel_set_fan_mode_later:
            self._cancel_set_fan_mode_later()
            self._cancel_set_fan_mode_later = None

        delay: float = resend_delay_sec
        now = self._thermostat.now
        last_command_sent = self._last_command_sent_datetime

        if now > last_command_sent + timedelta(seconds=delay):
            await self.hass_services_async_call(
                CLIMATE_DOMAIN,
                SERVICE_SET_FAN_MODE,
                data,
            )

            return

        # Add a delay if last command was sent less than delay seconds ago
        # Some AC units (e.g. Daikin) do not handle multiple consecutive commands well
        _LOGGER.debug(
            "%s - #1458 - Delaying command set_fan_mode for underlying %s by %.2fs",
            self,
            self._entity_id,
            delay,
        )

        async def callback_set_fan_mode(_):
            await self.set_fan_mode(fan_mode)

        self._cancel_set_fan_mode_later = async_call_later(self._hass, delay, callback_set_fan_mode)

    async def set_humidity(self, humidity: int):
        """Set new target humidity."""
        _LOGGER.info("%s - Set humidity: %s", self, humidity)
        if not self.is_initialized:
            return
        data = {
            ATTR_ENTITY_ID: self._entity_id,
            "humidity": humidity,
        }

        await self.hass_services_async_call(
            CLIMATE_DOMAIN,
            SERVICE_SET_HUMIDITY,
            data,
        )

    async def set_swing_mode(self, swing_mode):
        """Set new target swing operation."""
        _LOGGER.info("%s - Set swing mode: %s", self, swing_mode)
        if not self.is_initialized:
            return
        data = {
            ATTR_ENTITY_ID: self._entity_id,
            "swing_mode": swing_mode,
        }

        await self.hass_services_async_call(
            CLIMATE_DOMAIN,
            SERVICE_SET_SWING_MODE,
            data,
        )

    async def set_swing_horizontal_mode(self, swing_horizontal_mode):
        """Set new target swing horizontal operation."""
        _LOGGER.info("%s - Set swing horizontal mode: %s", self, swing_horizontal_mode)
        if not self.is_initialized:
            return
        data = {
            ATTR_ENTITY_ID: self._entity_id,
            "swing_horizontal_mode": swing_horizontal_mode,
        }

        await self.hass_services_async_call(
            CLIMATE_DOMAIN,
            SERVICE_SET_SWING_HORIZONTAL_MODE,
            data,
        )

    async def set_temperature(self, temperature, max_temp, min_temp):
        """Set the target temperature"""
        if not self.is_initialized:
            return

        # Issue 508 we have to take care of service set_temperature or set_range
        target_temp = self.clamp_sent_value(temperature)
        data = {
            ATTR_ENTITY_ID: self._entity_id,
        }

        _LOGGER.info("%s - Set setpoint temperature to: %s", self, target_temp)

        # Issue 807 add TARGET_TEMPERATURE only if in the features.
        # Use a bitwise test (not the `in` operator): self.supported_features is read
        # from the underlying entity's state attributes, where Home Assistant may store
        # it as a plain int (e.g. for restored states). `EnumMember in <int>` raises
        # `TypeError: argument of type 'int' is not a container or iterable`, whereas
        # the bitwise `&` works for both a plain int and a ClimateEntityFeature IntFlag.
        # This matches the bitwise pattern already used elsewhere in this class
        # (fan_modes / swing_modes).
        if self.supported_features & ClimateEntityFeature.TARGET_TEMPERATURE_RANGE:
            data.update(
                {
                    "target_temp_high": target_temp,
                    "target_temp_low": target_temp,
                }
            )

        if self.supported_features & ClimateEntityFeature.TARGET_TEMPERATURE:
            data["temperature"] = target_temp

        try:
            await self.hass_services_async_call(
                CLIMATE_DOMAIN,
                SERVICE_SET_TEMPERATURE,
                data,
            )
        except Exception as ex:
            _LOGGER.error("%s - Error while sending set_temperature: %s", self, ex)
            raise ex

        self._last_sent_temperature = target_temp
        _LOGGER.debug("%s - Last_sent_temperature is now: %s", self, self._last_sent_temperature)

    @property
    def last_sent_temperature(self) -> float | None:
        """Get the last send temperature. None if no temperature have been sent yet"""
        return self._last_sent_temperature

    @property
    def hvac_action(self) -> HVACAction | None:
        """Get the hvac action of the underlying"""
        if not self.is_initialized:
            return None

        hvac_action = self.underlying_hvac_action
        if hvac_action is None:
            # simulate hvac action if not provided by underlying climate
            underlying_target = self.underlying_target_temperature
            underlying_current = self.underlying_current_temperature

            if underlying_target is not None and underlying_current is not None:
                # Both values come from the underlying device — use them as a consistent pair
                target = underlying_target
                current = underlying_current
            else:
                # Fall back entirely to VTherm-managed temperatures (room sensor)
                # This prevents mixing device-internal and room sensor temperatures
                target = self._thermostat.target_temperature
                current = self._thermostat.current_temperature
            hvac_mode = self.hvac_mode

            _LOGGER.debug(
                "%s - hvac_action simulation target=%s, current=%s, hvac_mode=%s",
                self,
                target,
                current,
                hvac_mode,
            )
            hvac_action = HVACAction.IDLE
            if target is not None and current is not None:
                dtemp = target - current

                if hvac_mode == VThermHvacMode_COOL and dtemp < 0:
                    hvac_action = HVACAction.COOLING
                elif hvac_mode in [VThermHvacMode_HEAT, VThermHvacMode_HEAT_COOL] and dtemp > 0:
                    hvac_action = HVACAction.HEATING

        return hvac_action

    @property
    def hvac_mode(self) -> VThermHvacMode | None:
        """Get the hvac mode of the underlying"""
        if not self.is_initialized:
            return None
        state = self._state_manager.get_state(self._entity_id)
        return state.state if state is not None else None

    @property
    def fan_mode(self) -> str | None:
        """Get the fan_mode of the underlying"""
        if not self.is_initialized or self.supported_features & ClimateEntityFeature.FAN_MODE == 0:
            return None
        return self.get_underlying_attribute("fan_mode")

    @property
    def swing_mode(self) -> str | None:
        """Get the swing_mode of the underlying"""
        if not self.is_initialized or self.supported_features & ClimateEntityFeature.SWING_MODE == 0:
            return None
        return self.get_underlying_attribute("swing_mode")

    @property
    def swing_horizontal_mode(self) -> str | None:
        """Get the swing_horizontal_mode of the underlying"""
        return self.get_underlying_attribute("swing_horizontal_mode")

    @property
    def supported_features(self) -> ClimateEntityFeature:
        """Get the supported features of the climate"""
        return self.get_underlying_attribute("supported_features") or ClimateEntityFeature(0)

    @property
    def hvac_modes(self) -> list[VThermHvacMode]:
        """Get the hvac_modes"""
        if not self.is_initialized:
            return []
        return self.get_underlying_attribute("hvac_modes")

    @property
    def current_humidity(self) -> float | None:
        """Get the humidity"""
        return self.get_underlying_attribute("current_humidity")

    @property
    def fan_modes(self) -> list[str]:
        """Get the fan_modes"""
        if not self.is_initialized or self.supported_features & ClimateEntityFeature.FAN_MODE == 0:
            return []
        return self.get_underlying_attribute("fan_modes")

    @property
    def swing_modes(self) -> list[str]:
        """Get the swing_modes"""
        if not self.is_initialized or self.supported_features & ClimateEntityFeature.SWING_MODE == 0:
            return []
        return self.get_underlying_attribute("swing_modes")

    @property
    def swing_horizontal_modes(self) -> list[str]:
        """Get the swing_horizontal_modes"""
        if not self.is_initialized or self.supported_features & ClimateEntityFeature.SWING_HORIZONTAL_MODE == 0:
            return []
        return self.get_underlying_attribute("swing_horizontal_modes")

    @property
    def temperature_unit(self) -> str:
        """Get the temperature_unit"""
        return self.get_underlying_attribute("temperature_unit") or UnitOfTemperature.CELSIUS

    @property
    def min_temp(self) -> str:
        """Get the min_temp"""
        return self.get_underlying_attribute("min_temp")

    @property
    def max_temp(self) -> str:
        """Get the max_temp"""
        return self.get_underlying_attribute("max_temp")

    @property
    def target_temperature_step(self) -> float:
        """Get the target_temperature_step"""
        return self.get_underlying_attribute("target_temperature_step")

    @property
    def target_temperature_high(self) -> float:
        """Get the target_temperature_high"""
        return self.get_underlying_attribute("target_temperature_high")

    @property
    def target_temperature_low(self) -> float:
        """Get the target_temperature_low"""
        return self.get_underlying_attribute("target_temperature_low")

    @property
    def underlying_target_temperature(self) -> float:
        """Get the target_temperature"""
        value = self.get_underlying_attribute("target_temperature")
        if value is not None:
            return value
        return self.get_underlying_attribute("temperature")

    @property
    def underlying_current_temperature(self) -> float | None:
        """Get the underlying current_temperature if it exists
        and if initialized"""
        return self.get_underlying_attribute("current_temperature")

    @property
    def underlying_hvac_action(self) -> HVACAction | None:
        """Get the underlying hvac_action if it exists
        and if initialized"""
        return self.get_underlying_attribute("hvac_action")

    @property
    def is_aux_heat(self) -> bool:
        """Get the is_aux_heat"""
        return self.get_underlying_attribute("is_aux_heat")

    def get_underlying_attribute(self, attribute_name: str) -> Any:
        """Get an attribute from the underlying climate"""
        if not self.is_initialized:
            return None
        state = self._state_manager.get_state(self._entity_id)
        if state is None:
            return None
        return state.attributes.get(attribute_name, None)

    @overrides
    def clamp_sent_value(self, value) -> float:
        """Try to adapt the target temp value to the min_temp / max_temp found
        in the underlying entity (if any)"""
        min_val = None
        max_val = None

        if not self.is_initialized:
            raise RuntimeError(f"{self} - cannot cap sent value because underlying is not initialized")
            # return value

        # Gets the min_temp and max_temp.
        # Values from state attributes are already in HA's configured unit (same as value),
        # because HA's climate component normalizes them via show_temp() before storing.
        # No unit conversion is needed here.

        if self.min_temp is not None:
            min_val = self.min_temp
            max_val = self.max_temp

            new_value = max(min_val, min(value, max_val))
        else:
            _LOGGER.debug("%s - no min and max attributes on underlying", self)
            new_value = value

        if new_value != value:
            _LOGGER.info(
                "%s - Target temp have been updated due min, max of the underlying entity. new_value=%.0f value=%.0f min=%.0f max=%.0f",
                self,
                new_value,
                value,
                min_val,
                max_val,
            )

        return new_value

    def set_min_max_step_sync_entity(
        self,
        min_sync_entity: float,
        max_sync_entity: float,
        step_sync_entity: float,
    ):
        """Set the min, max and step for the offset calibration synchronization"""
        self._min_sync_entity = min_sync_entity
        self._max_sync_entity = max_sync_entity
        self._step_sync_entity = step_sync_entity

    async def check_and_repair(self) -> bool:
        """Check if the underlying device state matches the desired state and repair if needed.
        Returns True if a repair was performed."""
        hvac_mode = self._thermostat.vtherm_hvac_mode

        under_hvac_mode = self.hvac_mode

        _LOGGER.debug("%s - Checking if underlying climate state needs repair. hvac_mode=%s, under_hvac_mode=%s", self, hvac_mode, under_hvac_mode)

        if hvac_mode is None or under_hvac_mode is None:
            return False

        if str(hvac_mode) == str(under_hvac_mode):
            return False

        await self.set_hvac_mode(hvac_mode)
        return True

    @property
    def min_sync_entity(self) -> float:
        """Get the min sync entity"""
        return self._min_sync_entity

    @property
    def max_sync_entity(self) -> float:
        """Get the max sync entity"""
        return self._max_sync_entity

    @property
    def step_sync_entity(self) -> float:
        """Get the step sync entity"""
        return self._step_sync_entity

# ----------------------------------------------------------------
# UnderlyingValve
# ----------------------------------------------------------------
class UnderlyingValve(UnderlyingEntity):
    """Represent a underlying switch"""

    def __init__(
        self,
        hass: HomeAssistant,
        thermostat: Any,
        valve_entity_id: str,
        entity_type: UnderlyingEntityType = UnderlyingEntityType.VALVE,
    ) -> None:
        """Initialize the underlying valve"""

        super().__init__(
            hass=hass,
            thermostat=thermostat,
            entity_type=UnderlyingEntityType.VALVE,
            entity_id=valve_entity_id,
        )
        self._hvac_mode = None
        self._percent_open: int | None = None  # self._thermostat.valve_open_percent
        self._min_open: float | None = None
        self._max_open: float | None = None
        self._last_sent_temperature = None
        self._last_sent_opening_value: int | None = None

    def init_valve_state_min_max_open(self):
        """Initialize the min and max open percent"""
        if not self.is_initialized:
            raise RuntimeError(f"{self} - cannot init min/max open because underlying is not initialized")

        valve_state = self._state_manager.get_state(self.entity_id)
        valve_open: float = get_safe_float_value(valve_state.state)
        if valve_open is None:
            # should not happen
            raise ValueError(f"{self} - cannot check_initial_state because underlying entity {self._entity_id} value {valve_state.state} is not a valid float")

        self._last_sent_opening_value = valve_open

        if "min" in valve_state.attributes and "max" in valve_state.attributes:
            self._min_open = valve_state.attributes["min"]
            self._max_open = valve_state.attributes["max"]
        else:
            self._min_open = 0
            self._max_open = 100

    @overrides
    async def check_initial_state(self):
        """Handle initial valve state change to get the min and max open percent"""
        # Initialize percent_open to current state

        self.init_valve_state_min_max_open()

        should_device_be_active = self.should_device_be_active
        is_device_active = self.is_device_active

        if should_device_be_active and not is_device_active:
            _LOGGER.info(
                "%s - The valve should be active (percent_open=%.0f), but the underlying valve is closed (current_valve_opening=%.0f). Opening valve %s",
                self,
                self._percent_open or 9999,
                self._last_sent_opening_value or 9999,
                self._entity_id,
            )
            await self.send_percent_open()
        elif not should_device_be_active and is_device_active:
            _LOGGER.info(
                "%s - The valve should not be active (percent_open=%.0f), but the underlying valve is open (current_valve_opening=%.0f). Closing valve %s",
                self,
                self._percent_open or 9999,
                self._last_sent_opening_value or 9999,
                self._entity_id,
            )
            await self.send_percent_open(fixed_value=self._min_open)

    async def send_percent_open(self, fixed_value: int = None):
        """Send the percent open to the underlying valve"""
        # This may fails if called after shutdown
        value = self._percent_open if fixed_value is None else fixed_value
        await self.send_value_to_number(self._entity_id, value)
        self._last_sent_opening_value = value

    async def turn_off(self):
        """Turn heater toggleable device off."""
        _LOGGER.debug("%s - Stopping underlying valve entity %s", self, self._entity_id)
        # Issue 341
        is_active = self.is_device_active
        self._percent_open = self.clamp_sent_value(0)
        if is_active:
            await self.send_percent_open()

    async def turn_on(self):
        """Nothing to do for Valve because it cannot be turned on"""
        await self.set_valve_open_percent()

    async def set_hvac_mode(self, hvac_mode: VThermHvacMode) -> bool:
        """Set the HVACmode. Returns true if something have change"""

        if hvac_mode == VThermHvacMode_OFF and self.is_device_active:
            await self.turn_off()

        if hvac_mode != VThermHvacMode_OFF and not self.is_device_active:
            await self.turn_on()

        if self._hvac_mode != hvac_mode:
            self._hvac_mode = hvac_mode
            return True
        else:
            return False

    @property
    def should_device_be_active(self) -> bool:
        """If the toggleable device is currently active."""
        try:
            return self._percent_open > (self._min_open or 0) if isinstance(self._percent_open, (int, float)) else False
        except Exception:  # pylint: disable=broad-exception-caught
            return False

    @property
    def is_device_active(self) -> bool | None:
        """If the toggleable device is currently active."""
        if (current_opening := self.current_valve_opening) is None:
            return None

        return current_opening > (self._min_open or 0)

    @overrides
    def clamp_sent_value(self, value) -> float:
        """Try to adapt the open_percent value to the min / max found
        in the underlying entity (if any)"""
        if not self.is_initialized:
            raise RuntimeError(f"{self} - cannot clamp sent value because underlying is not initialized")
            # return value

        # Gets the last number state
        new_value = round(max(self._min_open, min(value / 100 * self._max_open, self._max_open)))

        if new_value != value:
            _LOGGER.info(
                "%s - Valve open percent have been updated due min, max of the underlying entity. new_value=%.0f value=%.0f min=%.0f max=%.0f",
                self,
                new_value,
                value,
                self._min_open,
                self._max_open,
            )

        return new_value

    async def set_valve_open_percent(self):
        """Update the valve open percent"""
        caped_val = self.clamp_sent_value(self._thermostat.valve_open_percent)
        if self._percent_open == caped_val:
            # No changes
            return

        self._percent_open = caped_val
        # Send the new command to valve via a service call

        _LOGGER.info(
            "%s - Setting valve ouverture percent to %s", self, self._percent_open
        )
        # Send the change to the valve, in background
        # self._hass.create_task(self.send_percent_open())
        await self.send_percent_open()

    def remove_entity(self):
        """Remove the entity"""
        super().remove_entity()

    @property
    def percent_open(self) -> int:
        """The current percent open"""
        return self._percent_open

    @property
    def last_sent_opening_value(self) -> int | None:
        """Return the last sent value to the valve"""
        return self._last_sent_opening_value

    @overrides
    async def check_and_repair(self) -> bool:
        """Check if the valve opening matches the last sent value and repair if needed.
        Returns True if a repair was performed."""
        last_sent = self._last_sent_opening_value
        current = self.current_valve_opening

        if last_sent is None or current is None:
            return False

        _LOGGER.debug("%s - Checking if underlying valve state needs repair. last_sent=%s, current=%s", self, last_sent, current)

        if abs(current - last_sent) <= 0.5:
            return False

        await self.send_percent_open()
        return True

    @property
    def current_valve_opening(self) -> float | None:
        """Get the current valve opening from the underlying entity"""

        if (valve_state := self._state_manager.get_state(self.entity_id)) is None:
            return None
        valve_open: float = get_safe_float_value(valve_state.state)
        return valve_open

# ----------------------------------------------------------------
# UnderlyingValveRegulation
# ----------------------------------------------------------------
class UnderlyingValveRegulation(UnderlyingValve):
    """A specific underlying class for Valve regulation"""

    def __init__(
        self,
        hass: HomeAssistant,
        thermostat: Any,
        opening_degree_entity_id: str,
        closing_degree_entity_id: str,
        climate_underlying: UnderlyingClimate,
        min_opening_degree: int = 0,
        max_opening_degree: int = 100,
        max_closing_degree: int = 100,
        opening_threshold: int = 0,
    ) -> None:
        """Initialize the underlying TRV with valve regulation"""
        super().__init__(
            hass,
            thermostat,
            opening_degree_entity_id,
            entity_type=UnderlyingEntityType.VALVE_REGULATION,
        )
        self._opening_degree_entity_id: str = opening_degree_entity_id
        self._closing_degree_entity_id: str = closing_degree_entity_id
        self._has_max_closing_degree: bool = closing_degree_entity_id is not None
        self._climate_underlying = climate_underlying
        self._max_opening_degree: float = max_opening_degree
        self._min_opening_degree: int = min_opening_degree
        self._max_closing_degree: int = max_closing_degree
        self._opening_threshold: int = opening_threshold

        if self._min_opening_degree >= self._max_opening_degree:
            self._min_opening_degree = self._opening_threshold
            _LOGGER.error(
                "min_opening_degree must be less than the max value of the opening degree of the entity {self._opening_degree_entity_id}. Value has been defaulted to opening threshold ({self._opening_threshold})"
            )

    @overrides
    async def check_initial_state(self):
        """Handle initial valve state change and hvac_mode"""

        _LOGGER.debug("%s - Starting initial state check for valve regulation underlying", self)

        # Initialize valve state and min max opening
        self.init_valve_state_min_max_open()

        hvac_mode = self._thermostat.vtherm_hvac_mode
        device_valve_opening = self.current_valve_opening  # the real opening value

        # cuurent_hvac_mode_is_active = self._state_manager.get_state(self._climate_underlying.entity_id).state not in [HVACMode.OFF, STATE_UNAVAILABLE, STATE_UNKNOWN]

        should_be_on = hvac_mode != VThermHvacMode_OFF and not self._thermostat.is_sleeping
        # is_on = cuurent_hvac_mode_is_active or (device_valve_opening is not None and device_valve_opening > self._opening_threshold)
        is_on = device_valve_opening is not None and device_valve_opening > self._opening_threshold

        if should_be_on and not is_on:
            _LOGGER.debug(
                "%s - The valve should be active (hvac_mode=%s, sleeping=%s, percent_open=%.0f), but the underlying device is off or below threshold (current_valve_opening=%.0f). Opening valve %s",
                self,
                hvac_mode,
                self._thermostat.is_sleeping,
                self._percent_open or 9999,
                device_valve_opening or 9999,
                self._entity_id,
            )
            # await self._climate_underlying.set_hvac_mode(hvac_mode)
            if self._thermostat.is_sleeping:
                self._percent_open = 100
                _LOGGER.debug("%s - is sleeping, setting percent_open to 100", self)
                await self.send_percent_open()
            else:
                calculated_percent = self._thermostat.valve_open_percent
                if calculated_percent is not None and calculated_percent > 0:
                    # TPI says heating is needed — respect the calculated value
                    self._percent_open = calculated_percent
                    _LOGGER.debug("%s - TPI says %.0f%% heating needed — opening valve", self, calculated_percent)
                    await self.send_percent_open()
                else:
                    # TPI says 0% (no heating needed) — do not open the valve here.
                    # The normal startup flow will send the correct 0% command separately.
                    _LOGGER.debug(
                        "%s - Should be on (hvac_mode=%s) but TPI says 0%% or not yet calculated — leaving valve closed to avoid race condition",
                        self,
                        hvac_mode,
                    )

        elif not should_be_on and is_on:
            _LOGGER.debug(
                "%s - The hvac mode is OFF and not sleeping, but the underlying device is not at off. Setting to %d%% device %s",
                self,
                self._opening_threshold,
                self._entity_id,
            )
            self._percent_open = self._opening_threshold
            _LOGGER.debug("%s - off and not sleeping, setting percent_open to %d", self, self._percent_open)
            await self.send_percent_open()
            # await self._climate_underlying.set_hvac_mode(hvac_mode)

    def startup(self):
        """Startup the Entity. Listen to the underlying state changes"""

        # Register the valve listener
        # super().startup()

        # starts listening and can provide the initial cached state.
        # TODO peut être que écouter self._opening_degree_entity_id ne sert à rien ici puisque c'est super() qui le fait
        entities = [self._opening_degree_entity_id]
        if self._has_max_closing_degree:
            entities.append(self._closing_degree_entity_id)
        self._state_manager.add_underlying_entities(entities)

    async def send_percent_open(self, fixed_value: int = None):
        """Send the percent open to the underlying valve"""
        # Caclulate percent_open
        value = self._percent_open if fixed_value is None else fixed_value
        opening_degree, closing_degree = OpeningClosingDegreeCalculation.calculate_opening_closing_degree(
            brut_valve_open_percent=value,
            min_opening_degree=self._min_opening_degree,
            max_closing_degree=self._max_closing_degree,
            max_opening_degree=self._max_opening_degree,
            opening_threshold=self._opening_threshold,
        )

        # Send opening_degree in UnderlyingValve
        await super().send_percent_open(opening_degree)

        if self.has_closing_degree_entity:
            await self.send_value_to_number(self._closing_degree_entity_id, closing_degree)

        _LOGGER.debug(
            "%s - valve regulation - I have sent opening_degree=%s closing_degree=%s",
            self,
            opening_degree,
            closing_degree,
        )

    @property
    def opening_degree_entity_id(self) -> str:
        """The offset_calibration_entity_id"""
        return self._opening_degree_entity_id

    @property
    def closing_degree_entity_id(self) -> str:
        """The offset_calibration_entity_id"""
        return self._closing_degree_entity_id

    @property
    def min_opening_degree(self) -> int:
        """The minimum opening degree"""
        return self._min_opening_degree

    @property
    def has_closing_degree_entity(self) -> bool:
        """Return True if the underlying have a closing_degree entity"""
        return self._closing_degree_entity_id is not None

    @property
    def hvac_modes(self) -> list[VThermHvacMode]:
        """Get the hvac_modes"""
        if not self.is_initialized:
            return []
        return [VThermHvacMode_HEAT, VThermHvacMode_SLEEP, VThermHvacMode_OFF]

    @property
    def is_device_active(self):
        """If the opening valve is open. The real opening value is used here. So we need to compare with 100 - max_closing which the max closing value if the valve is closed."""
        val = self.current_valve_opening
        return val > (100 - self._max_closing_degree) if isinstance(val, (int, float)) else False

    @property
    def should_device_be_active(self):
        """If the opening valve is open. The real opening value is used here. So we need to compare with 100 - max_closing which the max closing value if the valve is closed."""
        # return value > (100 - self._max_closing_degree) # TODO why this ?
        return self.hvac_mode not in [VThermHvacMode_OFF] and self._percent_open > self._opening_threshold if isinstance(self._percent_open, (int, float)) else False

    @property
    def hvac_action(self) -> HVACAction:
        """Calculate a hvac_action"""
        if (value := self.last_sent_opening_value) is None:
            return HVACAction.OFF

        # Align the behavior with is_device_active
        if self.is_device_active:
            return HVACAction.HEATING
        elif value > 0:
            return HVACAction.IDLE
        else:
            return HVACAction.OFF

    @property
    def valve_entity_ids(self) -> List[str]:
        """get an arrary with all entityd id of the valve"""
        ret = []
        for entity in [
            self.opening_degree_entity_id,
            self.closing_degree_entity_id,
        ]:
            if entity:
                ret.append(entity)
        return ret

    @overrides
    async def turn_off(self):
        """Turn valve off. In that context it means set the valve to the minimum opening degree."""
        _LOGGER.debug("%s - Stopping underlying entity %s", self, self._entity_id)
        self._percent_open = 0
        await self.send_percent_open()
        await self._climate_underlying.set_hvac_mode(VThermHvacMode_OFF)

T = TypeVar("T", bound=UnderlyingEntity)
