"""Support for ESPHome climate devices."""

from functools import partial
from math import isfinite
from typing import Any, cast, override

from aioesphomeapi import (
    ClimateAction,
    ClimateFanMode,
    ClimateFeature,
    ClimateInfo,
    ClimateMode,
    ClimatePreset,
    ClimateState,
    ClimateSwingMode,
    EntityInfo,
)

from homeassistant.components.climate import (
    ATTR_HVAC_MODE,
    ATTR_TARGET_TEMP_HIGH,
    ATTR_TARGET_TEMP_LOW,
    FAN_AUTO,
    FAN_DIFFUSE,
    FAN_FOCUS,
    FAN_HIGH,
    FAN_LOW,
    FAN_MEDIUM,
    FAN_MIDDLE,
    FAN_OFF,
    FAN_ON,
    PRESET_ACTIVITY,
    PRESET_AWAY,
    PRESET_BOOST,
    PRESET_COMFORT,
    PRESET_ECO,
    PRESET_HOME,
    PRESET_NONE,
    PRESET_SLEEP,
    SWING_BOTH,
    SWING_HORIZONTAL,
    SWING_OFF,
    SWING_VERTICAL,
    ClimateEntity,
    ClimateEntityFeature,
    HVACAction,
    HVACMode,
)
from homeassistant.const import (
    ATTR_TEMPERATURE,
    PRECISION_HALVES,
    PRECISION_TENTHS,
    PRECISION_WHOLE,
    UnitOfTemperature,
)
from homeassistant.core import callback
from homeassistant.exceptions import ServiceValidationError

from .const import DOMAIN
from .entity import (
    EsphomeEntity,
    convert_api_error_ha_error,
    esphome_float_state_property,
    esphome_state_property,
    platform_async_setup_entry,
)
from .enum_mapper import EsphomeEnumMapper

PARALLEL_UPDATES = 0

FAN_QUIET = "quiet"


_CLIMATE_MODES: EsphomeEnumMapper[ClimateMode, HVACMode] = EsphomeEnumMapper(
    {
        ClimateMode.OFF: HVACMode.OFF,
        ClimateMode.HEAT_COOL: HVACMode.HEAT_COOL,
        ClimateMode.COOL: HVACMode.COOL,
        ClimateMode.HEAT: HVACMode.HEAT,
        ClimateMode.FAN_ONLY: HVACMode.FAN_ONLY,
        ClimateMode.DRY: HVACMode.DRY,
        ClimateMode.AUTO: HVACMode.AUTO,
    }
)
_CLIMATE_ACTIONS: EsphomeEnumMapper[ClimateAction, HVACAction] = EsphomeEnumMapper(
    {
        ClimateAction.OFF: HVACAction.OFF,
        ClimateAction.COOLING: HVACAction.COOLING,
        ClimateAction.HEATING: HVACAction.HEATING,
        ClimateAction.IDLE: HVACAction.IDLE,
        ClimateAction.DRYING: HVACAction.DRYING,
        ClimateAction.FAN: HVACAction.FAN,
    }
)
_FAN_MODES: EsphomeEnumMapper[ClimateFanMode, str] = EsphomeEnumMapper(
    {
        ClimateFanMode.ON: FAN_ON,
        ClimateFanMode.OFF: FAN_OFF,
        ClimateFanMode.AUTO: FAN_AUTO,
        ClimateFanMode.LOW: FAN_LOW,
        ClimateFanMode.MEDIUM: FAN_MEDIUM,
        ClimateFanMode.HIGH: FAN_HIGH,
        ClimateFanMode.MIDDLE: FAN_MIDDLE,
        ClimateFanMode.FOCUS: FAN_FOCUS,
        ClimateFanMode.DIFFUSE: FAN_DIFFUSE,
        ClimateFanMode.QUIET: FAN_QUIET,
    }
)
_SWING_MODES: EsphomeEnumMapper[ClimateSwingMode, str] = EsphomeEnumMapper(
    {
        ClimateSwingMode.OFF: SWING_OFF,
        ClimateSwingMode.BOTH: SWING_BOTH,
        ClimateSwingMode.VERTICAL: SWING_VERTICAL,
        ClimateSwingMode.HORIZONTAL: SWING_HORIZONTAL,
    }
)
_PRESETS: EsphomeEnumMapper[ClimatePreset, str] = EsphomeEnumMapper(
    {
        ClimatePreset.NONE: PRESET_NONE,
        ClimatePreset.HOME: PRESET_HOME,
        ClimatePreset.AWAY: PRESET_AWAY,
        ClimatePreset.BOOST: PRESET_BOOST,
        ClimatePreset.COMFORT: PRESET_COMFORT,
        ClimatePreset.ECO: PRESET_ECO,
        ClimatePreset.SLEEP: PRESET_SLEEP,
        ClimatePreset.ACTIVITY: PRESET_ACTIVITY,
    }
)


class EsphomeClimateEntity(EsphomeEntity[ClimateInfo, ClimateState], ClimateEntity):
    """A climate implementation for ESPHome."""

    _attr_temperature_unit = UnitOfTemperature.CELSIUS
    _attr_translation_key = "climate"
    _feature_flags = ClimateFeature(0)

    @callback
    @override
    def _on_static_info_update(self, static_info: EntityInfo) -> None:
        """Set attrs from static info."""
        super()._on_static_info_update(static_info)
        static_info = self._static_info
        self._feature_flags = ClimateFeature(
            static_info.supported_feature_flags_compat(self._api_version)
        )
        self._attr_precision = self._get_precision()
        self._attr_hvac_modes = [
            _CLIMATE_MODES.from_esphome(mode) for mode in static_info.supported_modes
        ]
        self._attr_fan_modes = [
            _FAN_MODES.from_esphome(mode) for mode in static_info.supported_fan_modes
        ] + static_info.supported_custom_fan_modes
        self._attr_preset_modes = [
            _PRESETS.from_esphome(preset)
            for preset in static_info.supported_presets_compat(self._api_version)
        ] + static_info.supported_custom_presets
        self._attr_swing_modes = [
            _SWING_MODES.from_esphome(mode)
            for mode in static_info.supported_swing_modes
        ]
        # Round to one digit because of floating point math
        self._attr_target_temperature_step = round(
            static_info.visual_target_temperature_step, 1
        )
        self._attr_min_temp = static_info.visual_min_temperature
        self._attr_max_temp = static_info.visual_max_temperature
        self._attr_min_humidity = round(static_info.visual_min_humidity)
        self._attr_max_humidity = round(static_info.visual_max_humidity)
        features = ClimateEntityFeature(0)
        if self._feature_flags & ClimateFeature.SUPPORTS_TARGET_HUMIDITY:
            features |= ClimateEntityFeature.TARGET_HUMIDITY
        if self._feature_flags & ClimateFeature.REQUIRES_TWO_POINT_TARGET_TEMPERATURE:
            features |= ClimateEntityFeature.TARGET_TEMPERATURE_RANGE
        else:
            features |= ClimateEntityFeature.TARGET_TEMPERATURE
            if (
                self._feature_flags
                & ClimateFeature.SUPPORTS_TWO_POINT_TARGET_TEMPERATURE
            ):
                features |= ClimateEntityFeature.TARGET_TEMPERATURE_RANGE
        if self.preset_modes:
            features |= ClimateEntityFeature.PRESET_MODE
        if self.fan_modes:
            features |= ClimateEntityFeature.FAN_MODE
        if self.swing_modes:
            features |= ClimateEntityFeature.SWING_MODE
        if len(self.hvac_modes) > 1 and HVACMode.OFF in self.hvac_modes:
            features |= ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON
        self._attr_supported_features = features

    def _get_precision(self) -> float:
        """Return the precision of the climate device."""
        precisions = [PRECISION_WHOLE, PRECISION_HALVES, PRECISION_TENTHS]
        static_info = self._static_info
        if static_info.visual_current_temperature_step != 0:
            step = static_info.visual_current_temperature_step
        else:
            step = static_info.visual_target_temperature_step
        for prec in precisions:
            if step >= prec:
                return prec
        # Fall back to highest precision, tenths
        return PRECISION_TENTHS

    @property
    @esphome_state_property
    @override
    def hvac_mode(self) -> HVACMode | None:
        """Return current operation ie. heat, cool, idle."""
        return _CLIMATE_MODES.from_esphome(self._state.mode)

    @property
    @esphome_state_property
    @override
    def hvac_action(self) -> HVACAction | None:
        """Return current action."""
        # HA has no support feature field for hvac_action
        if not self._feature_flags & ClimateFeature.SUPPORTS_ACTION:
            return None
        return _CLIMATE_ACTIONS.from_esphome(self._state.action)

    @property
    @esphome_state_property
    @override
    def fan_mode(self) -> str | None:
        """Return current fan setting."""
        state = self._state
        return state.custom_fan_mode or _FAN_MODES.from_esphome(state.fan_mode)

    @property
    @esphome_state_property
    @override
    def preset_mode(self) -> str | None:
        """Return current preset mode."""
        state = self._state
        return state.custom_preset or _PRESETS.from_esphome(
            state.preset_compat(self._api_version)
        )

    @property
    @esphome_state_property
    @override
    def swing_mode(self) -> str | None:
        """Return current swing mode."""
        return _SWING_MODES.from_esphome(self._state.swing_mode)

    @property
    @esphome_float_state_property
    @override
    def current_temperature(self) -> float | None:
        """Return the current temperature."""
        if not self._feature_flags & ClimateFeature.SUPPORTS_CURRENT_TEMPERATURE:
            return None
        return self._state.current_temperature

    @property
    @esphome_state_property
    @override
    def current_humidity(self) -> int | None:
        """Return the current humidity."""
        if (
            (not self._feature_flags & ClimateFeature.SUPPORTS_CURRENT_HUMIDITY)
            or (val := self._state.current_humidity) is None
            or not isfinite(val)
        ):
            return None
        return round(val)

    @property
    @esphome_float_state_property
    @override
    def target_temperature(self) -> float | None:
        """Return the temperature we try to reach."""
        if (
            not self._feature_flags
            & (
                ClimateFeature.REQUIRES_TWO_POINT_TARGET_TEMPERATURE
                | ClimateFeature.SUPPORTS_TWO_POINT_TARGET_TEMPERATURE
            )
            and self.hvac_mode != HVACMode.AUTO
        ):
            return self._state.target_temperature
        if self.hvac_mode == HVACMode.HEAT:
            return self._state.target_temperature_low
        if self.hvac_mode == HVACMode.COOL:
            return self._state.target_temperature_high
        return None

    @property
    @esphome_float_state_property
    @override
    def target_temperature_low(self) -> float | None:
        """Return the lowbound target temperature we try to reach."""
        if self.hvac_mode == HVACMode.AUTO:
            return None
        return self._state.target_temperature_low

    @property
    @esphome_float_state_property
    @override
    def target_temperature_high(self) -> float | None:
        """Return the highbound target temperature we try to reach."""
        if self.hvac_mode == HVACMode.AUTO:
            return None
        return self._state.target_temperature_high

    @property
    @esphome_state_property
    @override
    def target_humidity(self) -> int:
        """Return the humidity we try to reach."""
        return round(self._state.target_humidity)

    @convert_api_error_ha_error
    @override
    async def async_set_temperature(self, **kwargs: Any) -> None:
        """Set new target temperature (and operation mode if set)."""
        data: dict[str, Any] = {"key": self._key}
        if ATTR_HVAC_MODE in kwargs:
            data["mode"] = _CLIMATE_MODES.from_hass(
                cast(HVACMode, kwargs[ATTR_HVAC_MODE])
            )
        if ATTR_TEMPERATURE in kwargs:
            if not self._feature_flags & (
                ClimateFeature.REQUIRES_TWO_POINT_TARGET_TEMPERATURE
                | ClimateFeature.SUPPORTS_TWO_POINT_TARGET_TEMPERATURE
            ):
                data["target_temperature"] = kwargs[ATTR_TEMPERATURE]
            else:
                hvac_mode = kwargs.get(ATTR_HVAC_MODE) or self.hvac_mode
                if hvac_mode == HVACMode.HEAT:
                    data["target_temperature_low"] = kwargs[ATTR_TEMPERATURE]
                elif hvac_mode == HVACMode.COOL:
                    data["target_temperature_high"] = kwargs[ATTR_TEMPERATURE]
                else:
                    raise ServiceValidationError(
                        translation_domain=DOMAIN,
                        translation_key="action_call_failed",
                        translation_placeholders={
                            "call_name": "climate.set_temperature",
                            "device_name": self._static_info.name,
                            "error": (
                                f"Setting target_temperature is only supported in "
                                f"{HVACMode.HEAT} or {HVACMode.COOL} modes"
                            ),
                        },
                    )
        if ATTR_TARGET_TEMP_LOW in kwargs:
            data["target_temperature_low"] = kwargs[ATTR_TARGET_TEMP_LOW]
        if ATTR_TARGET_TEMP_HIGH in kwargs:
            data["target_temperature_high"] = kwargs[ATTR_TARGET_TEMP_HIGH]
        self._client.climate_command(**data, device_id=self._static_info.device_id)

    @convert_api_error_ha_error
    @override
    async def async_set_humidity(self, humidity: int) -> None:
        """Set new target humidity."""
        self._client.climate_command(
            key=self._key,
            target_humidity=humidity,
            device_id=self._static_info.device_id,
        )

    @convert_api_error_ha_error
    @override
    async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
        """Set new target operation mode."""
        self._client.climate_command(
            key=self._key,
            mode=_CLIMATE_MODES.from_hass(hvac_mode),
            device_id=self._static_info.device_id,
        )

    @convert_api_error_ha_error
    @override
    async def async_set_preset_mode(self, preset_mode: str) -> None:
        """Set preset mode."""
        kwargs: dict[str, Any] = {"key": self._key}
        if preset_mode in self._static_info.supported_custom_presets:
            kwargs["custom_preset"] = preset_mode
        else:
            kwargs["preset"] = _PRESETS.from_hass(preset_mode)
        self._client.climate_command(**kwargs, device_id=self._static_info.device_id)

    @convert_api_error_ha_error
    @override
    async def async_set_fan_mode(self, fan_mode: str) -> None:
        """Set new fan mode."""
        kwargs: dict[str, Any] = {"key": self._key}
        if fan_mode in self._static_info.supported_custom_fan_modes:
            kwargs["custom_fan_mode"] = fan_mode
        else:
            kwargs["fan_mode"] = _FAN_MODES.from_hass(fan_mode)
        self._client.climate_command(**kwargs, device_id=self._static_info.device_id)

    @convert_api_error_ha_error
    @override
    async def async_set_swing_mode(self, swing_mode: str) -> None:
        """Set new swing mode."""
        self._client.climate_command(
            key=self._key,
            swing_mode=_SWING_MODES.from_hass(swing_mode),
            device_id=self._static_info.device_id,
        )


async_setup_entry = partial(
    platform_async_setup_entry,
    info_type=ClimateInfo,
    entity_type=EsphomeClimateEntity,
    state_type=ClimateState,
)
