"""Support for the iZone HVAC."""
from __future__ import annotations

import logging

from pizone import Controller, Zone
import voluptuous as vol

from homeassistant.components.climate import ClimateEntity
from homeassistant.components.climate.const import (
    FAN_AUTO,
    FAN_HIGH,
    FAN_LOW,
    FAN_MEDIUM,
    FAN_TOP,
    HVAC_MODE_COOL,
    HVAC_MODE_DRY,
    HVAC_MODE_FAN_ONLY,
    HVAC_MODE_HEAT,
    HVAC_MODE_HEAT_COOL,
    HVAC_MODE_OFF,
    PRESET_ECO,
    PRESET_NONE,
    SUPPORT_FAN_MODE,
    SUPPORT_PRESET_MODE,
    SUPPORT_TARGET_TEMPERATURE,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
    ATTR_TEMPERATURE,
    CONF_EXCLUDE,
    PRECISION_HALVES,
    PRECISION_TENTHS,
    TEMP_CELSIUS,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_platform
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.temperature import display_temp as show_temp
from homeassistant.helpers.typing import ConfigType

from .const import (
    DATA_CONFIG,
    DATA_DISCOVERY_SERVICE,
    DISPATCH_CONTROLLER_DISCONNECTED,
    DISPATCH_CONTROLLER_DISCOVERED,
    DISPATCH_CONTROLLER_RECONNECTED,
    DISPATCH_CONTROLLER_UPDATE,
    DISPATCH_ZONE_UPDATE,
    IZONE,
)

_LOGGER = logging.getLogger(__name__)

_IZONE_FAN_TO_HA = {
    Controller.Fan.LOW: FAN_LOW,
    Controller.Fan.MED: FAN_MEDIUM,
    Controller.Fan.HIGH: FAN_HIGH,
    Controller.Fan.TOP: FAN_TOP,
    Controller.Fan.AUTO: FAN_AUTO,
}

ATTR_AIRFLOW = "airflow"

IZONE_SERVICE_AIRFLOW_MIN = "airflow_min"
IZONE_SERVICE_AIRFLOW_MAX = "airflow_max"

IZONE_SERVICE_AIRFLOW_SCHEMA = {
    vol.Required(ATTR_AIRFLOW): vol.All(
        vol.Coerce(int), vol.Range(min=0, max=100), msg="invalid airflow"
    ),
}


async def async_setup_entry(
    hass: HomeAssistant, config: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
    """Initialize an IZone Controller."""
    disco = hass.data[DATA_DISCOVERY_SERVICE]

    @callback
    def init_controller(ctrl: Controller):
        """Register the controller device and the containing zones."""
        conf: ConfigType = hass.data.get(DATA_CONFIG)

        # Filter out any entities excluded in the config file
        if conf and ctrl.device_uid in conf[CONF_EXCLUDE]:
            _LOGGER.info("Controller UID=%s ignored as excluded", ctrl.device_uid)
            return
        _LOGGER.info("Controller UID=%s discovered", ctrl.device_uid)

        device = ControllerDevice(ctrl)
        async_add_entities([device])
        async_add_entities(device.zones.values())

    # create any components not yet created
    for controller in disco.pi_disco.controllers.values():
        init_controller(controller)

    # connect to register any further components
    async_dispatcher_connect(hass, DISPATCH_CONTROLLER_DISCOVERED, init_controller)

    platform = entity_platform.async_get_current_platform()
    platform.async_register_entity_service(
        IZONE_SERVICE_AIRFLOW_MIN,
        IZONE_SERVICE_AIRFLOW_SCHEMA,
        "async_set_airflow_min",
    )
    platform.async_register_entity_service(
        IZONE_SERVICE_AIRFLOW_MAX,
        IZONE_SERVICE_AIRFLOW_SCHEMA,
        "async_set_airflow_max",
    )

    return True


def _return_on_connection_error(ret=None):
    def wrap(func):
        def wrapped_f(*args, **kwargs):
            if not args[0].available:
                return ret
            try:
                return func(*args, **kwargs)
            except ConnectionError:
                return ret

        return wrapped_f

    return wrap


class ControllerDevice(ClimateEntity):
    """Representation of iZone Controller."""

    def __init__(self, controller: Controller) -> None:
        """Initialise ControllerDevice."""
        self._controller = controller

        self._supported_features = SUPPORT_FAN_MODE

        # If mode RAS, or mode master with CtrlZone 13 then can set master temperature,
        # otherwise the unit determines which zone to use as target. See interface manual p. 8
        if (
            controller.ras_mode == "master" and controller.zone_ctrl == 13
        ) or controller.ras_mode == "RAS":
            self._supported_features |= SUPPORT_TARGET_TEMPERATURE

        self._state_to_pizone = {
            HVAC_MODE_COOL: Controller.Mode.COOL,
            HVAC_MODE_HEAT: Controller.Mode.HEAT,
            HVAC_MODE_HEAT_COOL: Controller.Mode.AUTO,
            HVAC_MODE_FAN_ONLY: Controller.Mode.VENT,
            HVAC_MODE_DRY: Controller.Mode.DRY,
        }
        if controller.free_air_enabled:
            self._supported_features |= SUPPORT_PRESET_MODE

        self._fan_to_pizone = {}
        for fan in controller.fan_modes:
            self._fan_to_pizone[_IZONE_FAN_TO_HA[fan]] = fan
        self._available = True

        self._attr_device_info = DeviceInfo(
            identifiers={(IZONE, self.unique_id)},
            manufacturer="IZone",
            model=self._controller.sys_type,
            name=self.name,
        )

        # Create the zones
        self.zones = {}
        for zone in controller.zones:
            self.zones[zone] = ZoneDevice(self, zone)

    async def async_added_to_hass(self):
        """Call on adding to hass."""
        # Register for connect/disconnect/update events
        @callback
        def controller_disconnected(ctrl: Controller, ex: Exception) -> None:
            """Disconnected from controller."""
            if ctrl is not self._controller:
                return
            self.set_available(False, ex)

        self.async_on_remove(
            async_dispatcher_connect(
                self.hass, DISPATCH_CONTROLLER_DISCONNECTED, controller_disconnected
            )
        )

        @callback
        def controller_reconnected(ctrl: Controller) -> None:
            """Reconnected to controller."""
            if ctrl is not self._controller:
                return
            self.set_available(True)

        self.async_on_remove(
            async_dispatcher_connect(
                self.hass, DISPATCH_CONTROLLER_RECONNECTED, controller_reconnected
            )
        )

        @callback
        def controller_update(ctrl: Controller) -> None:
            """Handle controller data updates."""
            if ctrl is not self._controller:
                return
            if not self.available:
                return
            self.async_write_ha_state()

        self.async_on_remove(
            async_dispatcher_connect(
                self.hass, DISPATCH_CONTROLLER_UPDATE, controller_update
            )
        )

    @property
    def available(self) -> bool:
        """Return True if entity is available."""
        return self._available

    @callback
    def set_available(self, available: bool, ex: Exception = None) -> None:
        """
        Set availability for the controller.

        Also sets zone availability as they follow the same availability.
        """
        if self.available == available:
            return

        if available:
            _LOGGER.info("Reconnected controller %s ", self._controller.device_uid)
        else:
            _LOGGER.info(
                "Controller %s disconnected due to exception: %s",
                self._controller.device_uid,
                ex,
            )

        self._available = available
        self.async_write_ha_state()
        for zone in self.zones.values():
            if zone.hass is not None:
                zone.async_schedule_update_ha_state()

    @property
    def unique_id(self):
        """Return the ID of the controller device."""
        return self._controller.device_uid

    @property
    def name(self) -> str:
        """Return the name of the entity."""
        return f"iZone Controller {self._controller.device_uid}"

    @property
    def should_poll(self) -> bool:
        """Return True if entity has to be polled for state.

        False if entity pushes its state to HA.
        """
        return False

    @property
    def supported_features(self) -> int:
        """Return the list of supported features."""
        return self._supported_features

    @property
    def temperature_unit(self) -> str:
        """Return the unit of measurement which this thermostat uses."""
        return TEMP_CELSIUS

    @property
    def precision(self) -> float:
        """Return the precision of the system."""
        return PRECISION_TENTHS

    @property
    def extra_state_attributes(self):
        """Return the optional state attributes."""
        return {
            "supply_temperature": show_temp(
                self.hass,
                self.supply_temperature,
                self.temperature_unit,
                self.precision,
            ),
            "temp_setpoint": show_temp(
                self.hass,
                self._controller.temp_setpoint,
                self.temperature_unit,
                PRECISION_HALVES,
            ),
            "control_zone": self._controller.zone_ctrl,
            "control_zone_name": self.control_zone_name,
            # Feature SUPPORT_TARGET_TEMPERATURE controls both displaying target temp & setting it
            # As the feature is turned off for zone control, report target temp as extra state attribute
            "control_zone_setpoint": show_temp(
                self.hass,
                self.control_zone_setpoint,
                self.temperature_unit,
                PRECISION_HALVES,
            ),
        }

    @property
    def hvac_mode(self) -> str:
        """Return current operation ie. heat, cool, idle."""
        if not self._controller.is_on:
            return HVAC_MODE_OFF
        if (mode := self._controller.mode) == Controller.Mode.FREE_AIR:
            return HVAC_MODE_FAN_ONLY
        for (key, value) in self._state_to_pizone.items():
            if value == mode:
                return key
        assert False, "Should be unreachable"

    @property
    @_return_on_connection_error([])
    def hvac_modes(self) -> list[str]:
        """Return the list of available operation modes."""
        if self._controller.free_air:
            return [HVAC_MODE_OFF, HVAC_MODE_FAN_ONLY]
        return [HVAC_MODE_OFF, *self._state_to_pizone]

    @property
    @_return_on_connection_error(PRESET_NONE)
    def preset_mode(self):
        """Eco mode is external air."""
        return PRESET_ECO if self._controller.free_air else PRESET_NONE

    @property
    @_return_on_connection_error([PRESET_NONE])
    def preset_modes(self):
        """Available preset modes, normal or eco."""
        if self._controller.free_air_enabled:
            return [PRESET_NONE, PRESET_ECO]
        return [PRESET_NONE]

    @property
    @_return_on_connection_error()
    def current_temperature(self) -> float | None:
        """Return the current temperature."""
        if self._controller.mode == Controller.Mode.FREE_AIR:
            return self._controller.temp_supply
        return self._controller.temp_return

    @property
    def control_zone_name(self):
        """Return the zone that currently controls the AC unit (if target temp not set by controller)."""
        if self._supported_features & SUPPORT_TARGET_TEMPERATURE:
            return None
        zone_ctrl = self._controller.zone_ctrl
        zone = next((z for z in self.zones.values() if z.zone_index == zone_ctrl), None)
        if zone is None:
            return None
        return zone.name

    @property
    def control_zone_setpoint(self) -> float | None:
        """Return the temperature setpoint of the zone that currently controls the AC unit (if target temp not set by controller)."""
        if self._supported_features & SUPPORT_TARGET_TEMPERATURE:
            return None
        zone_ctrl = self._controller.zone_ctrl
        zone = next((z for z in self.zones.values() if z.zone_index == zone_ctrl), None)
        if zone is None:
            return None
        return zone.target_temperature

    @property
    @_return_on_connection_error()
    def target_temperature(self) -> float | None:
        """Return the temperature we try to reach (either from control zone or master unit)."""
        if self._supported_features & SUPPORT_TARGET_TEMPERATURE:
            return self._controller.temp_setpoint
        return self.control_zone_setpoint

    @property
    def supply_temperature(self) -> float:
        """Return the current supply, or in duct, temperature."""
        return self._controller.temp_supply

    @property
    def target_temperature_step(self) -> float | None:
        """Return the supported step of target temperature."""
        return 0.5

    @property
    def fan_mode(self) -> str | None:
        """Return the fan setting."""
        return _IZONE_FAN_TO_HA[self._controller.fan]

    @property
    def fan_modes(self) -> list[str] | None:
        """Return the list of available fan modes."""
        return list(self._fan_to_pizone)

    @property
    @_return_on_connection_error(0.0)
    def min_temp(self) -> float:
        """Return the minimum temperature."""
        return self._controller.temp_min

    @property
    @_return_on_connection_error(50.0)
    def max_temp(self) -> float:
        """Return the maximum temperature."""
        return self._controller.temp_max

    async def wrap_and_catch(self, coro):
        """Catch any connection errors and set unavailable."""
        try:
            await coro
        except ConnectionError as ex:
            self.set_available(False, ex)
        else:
            self.set_available(True)

    async def async_set_temperature(self, **kwargs) -> None:
        """Set new target temperature."""
        if not self.supported_features & SUPPORT_TARGET_TEMPERATURE:
            self.async_schedule_update_ha_state(True)
            return
        if (temp := kwargs.get(ATTR_TEMPERATURE)) is not None:
            await self.wrap_and_catch(self._controller.set_temp_setpoint(temp))

    async def async_set_fan_mode(self, fan_mode: str) -> None:
        """Set new target fan mode."""
        fan = self._fan_to_pizone[fan_mode]
        await self.wrap_and_catch(self._controller.set_fan(fan))

    async def async_set_hvac_mode(self, hvac_mode: str) -> None:
        """Set new target operation mode."""
        if hvac_mode == HVAC_MODE_OFF:
            await self.wrap_and_catch(self._controller.set_on(False))
            return
        if not self._controller.is_on:
            await self.wrap_and_catch(self._controller.set_on(True))
        if self._controller.free_air:
            return
        mode = self._state_to_pizone[hvac_mode]
        await self.wrap_and_catch(self._controller.set_mode(mode))

    async def async_set_preset_mode(self, preset_mode: str) -> None:
        """Set the preset mode."""
        await self.wrap_and_catch(
            self._controller.set_free_air(preset_mode == PRESET_ECO)
        )

    async def async_turn_on(self) -> None:
        """Turn the entity on."""
        await self.wrap_and_catch(self._controller.set_on(True))


class ZoneDevice(ClimateEntity):
    """Representation of iZone Zone."""

    def __init__(self, controller: ControllerDevice, zone: Zone) -> None:
        """Initialise ZoneDevice."""
        self._controller = controller
        self._zone = zone
        self._name = zone.name.title()

        self._supported_features = 0
        if zone.type != Zone.Type.AUTO:
            self._state_to_pizone = {
                HVAC_MODE_OFF: Zone.Mode.CLOSE,
                HVAC_MODE_FAN_ONLY: Zone.Mode.OPEN,
            }
        else:
            self._state_to_pizone = {
                HVAC_MODE_OFF: Zone.Mode.CLOSE,
                HVAC_MODE_FAN_ONLY: Zone.Mode.OPEN,
                HVAC_MODE_HEAT_COOL: Zone.Mode.AUTO,
            }
            self._supported_features |= SUPPORT_TARGET_TEMPERATURE

        self._attr_device_info = DeviceInfo(
            identifiers={(IZONE, controller.unique_id, zone.index)},
            manufacturer="IZone",
            model=zone.type.name.title(),
            name=self.name,
            via_device=(IZONE, controller.unique_id),
        )

    async def async_added_to_hass(self):
        """Call on adding to hass."""

        @callback
        def controller_update(ctrl: Controller) -> None:
            """Handle controller data updates."""
            if ctrl.device_uid != self._controller.unique_id:
                return
            if not self.available:
                return
            self.async_write_ha_state()

        self.async_on_remove(
            async_dispatcher_connect(
                self.hass, DISPATCH_CONTROLLER_UPDATE, controller_update
            )
        )

        @callback
        def zone_update(ctrl: Controller, zone: Zone) -> None:
            """Handle zone data updates."""
            if zone is not self._zone:
                return
            if not self.available:
                return
            self._name = zone.name.title()
            self.async_write_ha_state()

        self.async_on_remove(
            async_dispatcher_connect(self.hass, DISPATCH_ZONE_UPDATE, zone_update)
        )

    @property
    def available(self) -> bool:
        """Return True if entity is available."""
        return self._controller.available

    @property
    def unique_id(self):
        """Return the ID of the controller device."""
        return f"{self._controller.unique_id}_z{self._zone.index + 1}"

    @property
    def name(self) -> str:
        """Return the name of the entity."""
        return self._name

    @property
    def should_poll(self) -> bool:
        """Return True if entity has to be polled for state.

        False if entity pushes its state to HA.
        """
        return False

    @property
    @_return_on_connection_error(0)
    def supported_features(self):
        """Return the list of supported features."""
        if self._zone.mode == Zone.Mode.AUTO:
            return self._supported_features
        return self._supported_features & ~SUPPORT_TARGET_TEMPERATURE

    @property
    def temperature_unit(self):
        """Return the unit of measurement which this thermostat uses."""
        return TEMP_CELSIUS

    @property
    def precision(self):
        """Return the precision of the system."""
        return PRECISION_TENTHS

    @property
    def hvac_mode(self):
        """Return current operation ie. heat, cool, idle."""
        mode = self._zone.mode
        for (key, value) in self._state_to_pizone.items():
            if value == mode:
                return key
        return None

    @property
    def hvac_modes(self):
        """Return the list of available operation modes."""
        return list(self._state_to_pizone)

    @property
    def current_temperature(self):
        """Return the current temperature."""
        return self._zone.temp_current

    @property
    def target_temperature(self):
        """Return the temperature we try to reach."""
        if self._zone.type != Zone.Type.AUTO:
            return None
        return self._zone.temp_setpoint

    @property
    def target_temperature_step(self):
        """Return the supported step of target temperature."""
        return 0.5

    @property
    def min_temp(self):
        """Return the minimum temperature."""
        return self._controller.min_temp

    @property
    def max_temp(self):
        """Return the maximum temperature."""
        return self._controller.max_temp

    @property
    def airflow_min(self):
        """Return the minimum air flow."""
        return self._zone.airflow_min

    @property
    def airflow_max(self):
        """Return the maximum air flow."""
        return self._zone.airflow_max

    async def async_set_airflow_min(self, **kwargs):
        """Set new airflow minimum."""
        await self._controller.wrap_and_catch(
            self._zone.set_airflow_min(int(kwargs[ATTR_AIRFLOW]))
        )
        self.async_write_ha_state()

    async def async_set_airflow_max(self, **kwargs):
        """Set new airflow maximum."""
        await self._controller.wrap_and_catch(
            self._zone.set_airflow_max(int(kwargs[ATTR_AIRFLOW]))
        )
        self.async_write_ha_state()

    async def async_set_temperature(self, **kwargs):
        """Set new target temperature."""
        if self._zone.mode != Zone.Mode.AUTO:
            return
        if (temp := kwargs.get(ATTR_TEMPERATURE)) is not None:
            await self._controller.wrap_and_catch(self._zone.set_temp_setpoint(temp))

    async def async_set_hvac_mode(self, hvac_mode: str) -> None:
        """Set new target operation mode."""
        mode = self._state_to_pizone[hvac_mode]
        await self._controller.wrap_and_catch(self._zone.set_mode(mode))
        self.async_write_ha_state()

    @property
    def is_on(self):
        """Return true if on."""
        return self._zone.mode != Zone.Mode.CLOSE

    async def async_turn_on(self):
        """Turn device on (open zone)."""
        if self._zone.type == Zone.Type.AUTO:
            await self._controller.wrap_and_catch(self._zone.set_mode(Zone.Mode.AUTO))
        else:
            await self._controller.wrap_and_catch(self._zone.set_mode(Zone.Mode.OPEN))
        self.async_write_ha_state()

    async def async_turn_off(self):
        """Turn device off (close zone)."""
        await self._controller.wrap_and_catch(self._zone.set_mode(Zone.Mode.CLOSE))
        self.async_write_ha_state()

    @property
    def zone_index(self):
        """Return the zone index for matching to CtrlZone."""
        return self._zone.index

    @property
    def extra_state_attributes(self):
        """Return the optional state attributes."""
        return {
            "airflow_max": self._zone.airflow_max,
            "airflow_min": self._zone.airflow_min,
            "zone_index": self.zone_index,
        }
