"""Support for Climate devices of (EMEA/EU-based) Honeywell evohome systems."""
from datetime import datetime, timedelta
import logging

import requests.exceptions

from homeassistant.components.climate import ClimateDevice
from homeassistant.components.climate.const import (
    STATE_AUTO, STATE_ECO, STATE_MANUAL, SUPPORT_AWAY_MODE, SUPPORT_ON_OFF,
    SUPPORT_OPERATION_MODE, SUPPORT_TARGET_TEMPERATURE)
from homeassistant.const import (
    CONF_SCAN_INTERVAL, HTTP_SERVICE_UNAVAILABLE, HTTP_TOO_MANY_REQUESTS,
    PRECISION_HALVES, STATE_OFF, TEMP_CELSIUS)
from homeassistant.core import callback
from homeassistant.helpers.dispatcher import (
    async_dispatcher_connect, dispatcher_send)

from . import (
    CONF_LOCATION_IDX, DATA_EVOHOME, DISPATCHER_EVOHOME, EVO_CHILD, EVO_PARENT,
    GWS, TCS)

_LOGGER = logging.getLogger(__name__)

# The Controller's opmode/state and the zone's (inherited) state
EVO_RESET = 'AutoWithReset'
EVO_AUTO = 'Auto'
EVO_AUTOECO = 'AutoWithEco'
EVO_AWAY = 'Away'
EVO_DAYOFF = 'DayOff'
EVO_CUSTOM = 'Custom'
EVO_HEATOFF = 'HeatingOff'

# These are for Zones' opmode, and state
EVO_FOLLOW = 'FollowSchedule'
EVO_TEMPOVER = 'TemporaryOverride'
EVO_PERMOVER = 'PermanentOverride'

# For the Controller. NB: evohome treats Away mode as a mode in/of itself,
# where HA considers it to 'override' the exising operating mode
TCS_STATE_TO_HA = {
    EVO_RESET: STATE_AUTO,
    EVO_AUTO: STATE_AUTO,
    EVO_AUTOECO: STATE_ECO,
    EVO_AWAY: STATE_AUTO,
    EVO_DAYOFF: STATE_AUTO,
    EVO_CUSTOM: STATE_AUTO,
    EVO_HEATOFF: STATE_OFF
}
HA_STATE_TO_TCS = {
    STATE_AUTO: EVO_AUTO,
    STATE_ECO: EVO_AUTOECO,
    STATE_OFF: EVO_HEATOFF
}
TCS_OP_LIST = list(HA_STATE_TO_TCS)

# the Zones' opmode; their state is usually 'inherited' from the TCS
EVO_FOLLOW = 'FollowSchedule'
EVO_TEMPOVER = 'TemporaryOverride'
EVO_PERMOVER = 'PermanentOverride'

# for the Zones...
ZONE_STATE_TO_HA = {
    EVO_FOLLOW: STATE_AUTO,
    EVO_TEMPOVER: STATE_MANUAL,
    EVO_PERMOVER: STATE_MANUAL
}
HA_STATE_TO_ZONE = {
    STATE_AUTO: EVO_FOLLOW,
    STATE_MANUAL: EVO_PERMOVER
}
ZONE_OP_LIST = list(HA_STATE_TO_ZONE)


async def async_setup_platform(hass, hass_config, async_add_entities,
                               discovery_info=None):
    """Create the evohome Controller, and its Zones, if any."""
    evo_data = hass.data[DATA_EVOHOME]

    client = evo_data['client']
    loc_idx = evo_data['params'][CONF_LOCATION_IDX]

    # evohomeclient has exposed no means of accessing non-default location
    # (i.e. loc_idx > 0) other than using a protected member, such as below
    tcs_obj_ref = client.locations[loc_idx]._gateways[0]._control_systems[0]  # noqa: E501; pylint: disable=protected-access

    _LOGGER.debug(
        "Found Controller, id=%s [%s], name=%s (location_idx=%s)",
        tcs_obj_ref.systemId, tcs_obj_ref.modelType, tcs_obj_ref.location.name,
        loc_idx)

    controller = EvoController(evo_data, client, tcs_obj_ref)
    zones = []

    for zone_idx in tcs_obj_ref.zones:
        zone_obj_ref = tcs_obj_ref.zones[zone_idx]
        _LOGGER.debug(
            "Found Zone, id=%s [%s], name=%s",
            zone_obj_ref.zoneId, zone_obj_ref.zone_type, zone_obj_ref.name)
        zones.append(EvoZone(evo_data, client, zone_obj_ref))

    entities = [controller] + zones

    async_add_entities(entities, update_before_add=False)


class EvoClimateDevice(ClimateDevice):
    """Base for a Honeywell evohome Climate device."""

    # pylint: disable=no-member

    def __init__(self, evo_data, client, obj_ref):
        """Initialize the evohome entity."""
        self._client = client
        self._obj = obj_ref

        self._params = evo_data['params']
        self._timers = evo_data['timers']
        self._status = {}

        self._available = False  # should become True after first update()

    async def async_added_to_hass(self):
        """Run when entity about to be added."""
        async_dispatcher_connect(self.hass, DISPATCHER_EVOHOME, self._connect)

    @callback
    def _connect(self, packet):
        if packet['to'] & self._type and packet['signal'] == 'refresh':
            self.async_schedule_update_ha_state(force_refresh=True)

    def _handle_exception(self, err):
        try:
            import evohomeclient2
            raise err

        except evohomeclient2.AuthenticationError:
            _LOGGER.error(
                "Failed to (re)authenticate with the vendor's server. "
                "This may be a temporary error. Message is: %s",
                err
            )

        except requests.exceptions.ConnectionError:
            # this appears to be common with Honeywell's servers
            _LOGGER.warning(
                "Unable to connect with the vendor's server. "
                "Check your network and the vendor's status page."
            )

        except requests.exceptions.HTTPError:
            if err.response.status_code == HTTP_SERVICE_UNAVAILABLE:
                _LOGGER.warning(
                    "Vendor says their server is currently unavailable. "
                    "This may be temporary; check the vendor's status page."
                )

            elif err.response.status_code == HTTP_TOO_MANY_REQUESTS:
                _LOGGER.warning(
                    "The vendor's API rate limit has been exceeded. "
                    "So will cease polling, and will resume after %s seconds.",
                    (self._params[CONF_SCAN_INTERVAL] * 3).total_seconds()
                )
                self._timers['statusUpdated'] = datetime.now() + \
                    self._params[CONF_SCAN_INTERVAL] * 3

            else:
                raise  # we don't expect/handle any other HTTPErrors

    @property
    def name(self) -> str:
        """Return the name to use in the frontend UI."""
        return self._name

    @property
    def icon(self):
        """Return the icon to use in the frontend UI."""
        return self._icon

    @property
    def device_state_attributes(self):
        """Return the device state attributes of the evohome Climate device.

        This is state data that is not available otherwise, due to the
        restrictions placed upon ClimateDevice properties, etc. by HA.
        """
        return {'status': self._status}

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

    @property
    def supported_features(self):
        """Get the list of supported features of the device."""
        return self._supported_features

    @property
    def operation_list(self):
        """Return the list of available operations."""
        return self._operation_list

    @property
    def temperature_unit(self):
        """Return the temperature unit to use in the frontend UI."""
        return TEMP_CELSIUS

    @property
    def precision(self):
        """Return the temperature precision to use in the frontend UI."""
        return PRECISION_HALVES


class EvoZone(EvoClimateDevice):
    """Base for a Honeywell evohome Zone device."""

    def __init__(self, evo_data, client, obj_ref):
        """Initialize the evohome Zone."""
        super().__init__(evo_data, client, obj_ref)

        self._id = obj_ref.zoneId
        self._name = obj_ref.name
        self._icon = "mdi:radiator"
        self._type = EVO_CHILD

        for _zone in evo_data['config'][GWS][0][TCS][0]['zones']:
            if _zone['zoneId'] == self._id:
                self._config = _zone
                break
        self._status = {}

        self._operation_list = ZONE_OP_LIST
        self._supported_features = \
            SUPPORT_OPERATION_MODE | \
            SUPPORT_TARGET_TEMPERATURE | \
            SUPPORT_ON_OFF

    @property
    def min_temp(self):
        """Return the minimum target temperature of a evohome Zone.

        The default is 5 (in Celsius), but it is configurable within 5-35.
        """
        return self._config['setpointCapabilities']['minHeatSetpoint']

    @property
    def max_temp(self):
        """Return the minimum target temperature of a evohome Zone.

        The default is 35 (in Celsius), but it is configurable within 5-35.
        """
        return self._config['setpointCapabilities']['maxHeatSetpoint']

    @property
    def target_temperature(self):
        """Return the target temperature of the evohome Zone."""
        return self._status['setpointStatus']['targetHeatTemperature']

    @property
    def current_temperature(self):
        """Return the current temperature of the evohome Zone."""
        return (self._status['temperatureStatus']['temperature']
                if self._status['temperatureStatus']['isAvailable'] else None)

    @property
    def current_operation(self):
        """Return the current operating mode of the evohome Zone.

        The evohome Zones that are in 'FollowSchedule' mode inherit their
        actual operating mode from the Controller.
        """
        evo_data = self.hass.data[DATA_EVOHOME]

        system_mode = evo_data['status']['systemModeStatus']['mode']
        setpoint_mode = self._status['setpointStatus']['setpointMode']

        if setpoint_mode == EVO_FOLLOW:
            # then inherit state from the controller
            if system_mode == EVO_RESET:
                current_operation = TCS_STATE_TO_HA.get(EVO_AUTO)
            else:
                current_operation = TCS_STATE_TO_HA.get(system_mode)
        else:
            current_operation = ZONE_STATE_TO_HA.get(setpoint_mode)

        return current_operation

    @property
    def is_on(self) -> bool:
        """Return True if the evohome Zone is off.

        A Zone is considered off if its target temp is set to its minimum, and
        it is not following its schedule (i.e. not in 'FollowSchedule' mode).
        """
        is_off = \
            self.target_temperature == self.min_temp and \
            self._status['setpointStatus']['setpointMode'] == EVO_PERMOVER
        return not is_off

    def _set_temperature(self, temperature, until=None):
        """Set the new target temperature of a Zone.

        temperature is required, until can be:
          - strftime('%Y-%m-%dT%H:%M:%SZ') for TemporaryOverride, or
          - None for PermanentOverride (i.e. indefinitely)
        """
        try:
            import evohomeclient2
            self._obj.set_temperature(temperature, until)
        except (requests.exceptions.RequestException,
                evohomeclient2.AuthenticationError) as err:
            self._handle_exception(err)

    def set_temperature(self, **kwargs):
        """Set new target temperature, indefinitely."""
        self._set_temperature(kwargs['temperature'], until=None)

    def turn_on(self):
        """Turn the evohome Zone on.

        This is achieved by setting the Zone to its 'FollowSchedule' mode.
        """
        self._set_operation_mode(EVO_FOLLOW)

    def turn_off(self):
        """Turn the evohome Zone off.

        This is achieved by setting the Zone to its minimum temperature,
        indefinitely (i.e. 'PermanentOverride' mode).
        """
        self._set_temperature(self.min_temp, until=None)

    def set_operation_mode(self, operation_mode):
        """Set an operating mode for a Zone.

        Currently limited to 'Auto' & 'Manual'. If 'Off' is needed, it can be
        enabled via turn_off method.

        NB: evohome Zones do not have an operating mode as understood by HA.
        Instead they usually 'inherit' an operating mode from their controller.

        More correctly, these Zones are in a follow mode, 'FollowSchedule',
        where their setpoint temperatures are a function of their schedule, and
        the Controller's operating_mode, e.g. Economy mode is their scheduled
        setpoint less (usually) 3C.

        Thus, you cannot set a Zone to Away mode, but the location (i.e. the
        Controller) is set to Away and each Zones's setpoints are adjusted
        accordingly to some lower temperature.

        However, Zones can override these setpoints, either for a specified
        period of time, 'TemporaryOverride', after which they will revert back
        to 'FollowSchedule' mode, or indefinitely, 'PermanentOverride'.
        """
        self._set_operation_mode(HA_STATE_TO_ZONE.get(operation_mode))

    def _set_operation_mode(self, operation_mode):
        if operation_mode == EVO_FOLLOW:
            try:
                import evohomeclient2
                self._obj.cancel_temp_override()
            except (requests.exceptions.RequestException,
                    evohomeclient2.AuthenticationError) as err:
                self._handle_exception(err)

        elif operation_mode == EVO_TEMPOVER:
            _LOGGER.error(
                "_set_operation_mode(op_mode=%s): mode not yet implemented",
                operation_mode
            )

        elif operation_mode == EVO_PERMOVER:
            self._set_temperature(self.target_temperature, until=None)

        else:
            _LOGGER.error(
                "_set_operation_mode(op_mode=%s): mode not valid",
                operation_mode
            )

    @property
    def should_poll(self) -> bool:
        """Return False as evohome child devices should never be polled.

        The evohome Controller will inform its children when to update().
        """
        return False

    def update(self):
        """Process the evohome Zone's state data."""
        evo_data = self.hass.data[DATA_EVOHOME]

        for _zone in evo_data['status']['zones']:
            if _zone['zoneId'] == self._id:
                self._status = _zone
                break

        self._available = True


class EvoController(EvoClimateDevice):
    """Base for a Honeywell evohome hub/Controller device.

    The Controller (aka TCS, temperature control system) is the parent of all
    the child (CH/DHW) devices.  It is also a Climate device.
    """

    def __init__(self, evo_data, client, obj_ref):
        """Initialize the evohome Controller (hub)."""
        super().__init__(evo_data, client, obj_ref)

        self._id = obj_ref.systemId
        self._name = '_{}'.format(obj_ref.location.name)
        self._icon = "mdi:thermostat"
        self._type = EVO_PARENT

        self._config = evo_data['config'][GWS][0][TCS][0]
        self._status = evo_data['status']
        self._timers['statusUpdated'] = datetime.min

        self._operation_list = TCS_OP_LIST
        self._supported_features = \
            SUPPORT_OPERATION_MODE | \
            SUPPORT_AWAY_MODE

    @property
    def device_state_attributes(self):
        """Return the device state attributes of the evohome Controller.

        This is state data that is not available otherwise, due to the
        restrictions placed upon ClimateDevice properties, etc. by HA.
        """
        status = dict(self._status)

        if 'zones' in status:
            del status['zones']
        if 'dhw' in status:
            del status['dhw']

        return {'status': status}

    @property
    def current_operation(self):
        """Return the current operating mode of the evohome Controller."""
        return TCS_STATE_TO_HA.get(self._status['systemModeStatus']['mode'])

    @property
    def min_temp(self):
        """Return the minimum target temperature of a evohome Controller.

        Although evohome Controllers do not have a minimum target temp, one is
        expected by the HA schema; the default for an evohome HR92 is used.
        """
        return 5

    @property
    def max_temp(self):
        """Return the minimum target temperature of a evohome Controller.

        Although evohome Controllers do not have a maximum target temp, one is
        expected by the HA schema; the default for an evohome HR92 is used.
        """
        return 35

    @property
    def target_temperature(self):
        """Return the average target temperature of the Heating/DHW zones.

        Although evohome Controllers do not have a target temp, one is
        expected by the HA schema.
        """
        temps = [zone['setpointStatus']['targetHeatTemperature']
                 for zone in self._status['zones']]

        avg_temp = round(sum(temps) / len(temps), 1) if temps else None
        return avg_temp

    @property
    def current_temperature(self):
        """Return the average current temperature of the Heating/DHW zones.

        Although evohome Controllers do not have a target temp, one is
        expected by the HA schema.
        """
        tmp_list = [x for x in self._status['zones']
                    if x['temperatureStatus']['isAvailable'] is True]
        temps = [zone['temperatureStatus']['temperature'] for zone in tmp_list]

        avg_temp = round(sum(temps) / len(temps), 1) if temps else None
        return avg_temp

    @property
    def is_on(self) -> bool:
        """Return True as evohome Controllers are always on.

        For example, evohome Controllers have a 'HeatingOff' mode, but even
        then the DHW would remain on.
        """
        return True

    @property
    def is_away_mode_on(self) -> bool:
        """Return True if away mode is on."""
        return self._status['systemModeStatus']['mode'] == EVO_AWAY

    def turn_away_mode_on(self):
        """Turn away mode on.

        The evohome Controller will not remember is previous operating mode.
        """
        self._set_operation_mode(EVO_AWAY)

    def turn_away_mode_off(self):
        """Turn away mode off.

        The evohome Controller can not recall its previous operating mode (as
        intimated by the HA schema), so this method is achieved by setting the
        Controller's mode back to Auto.
        """
        self._set_operation_mode(EVO_AUTO)

    def _set_operation_mode(self, operation_mode):
        try:
            import evohomeclient2
            self._obj._set_status(operation_mode)  # noqa: E501; pylint: disable=protected-access
        except (requests.exceptions.RequestException,
                evohomeclient2.AuthenticationError) as err:
            self._handle_exception(err)

    def set_operation_mode(self, operation_mode):
        """Set new target operation mode for the TCS.

        Currently limited to 'Auto', 'AutoWithEco' & 'HeatingOff'. If 'Away'
        mode is needed, it can be enabled via turn_away_mode_on method.
        """
        self._set_operation_mode(HA_STATE_TO_TCS.get(operation_mode))

    @property
    def should_poll(self) -> bool:
        """Return True as the evohome Controller should always be polled."""
        return True

    def update(self):
        """Get the latest state data of the entire evohome Location.

        This includes state data for the Controller and all its child devices,
        such as the operating mode of the Controller and the current temp of
        its children (e.g. Zones, DHW controller).
        """
        # should the latest evohome state data be retreived this cycle?
        timeout = datetime.now() + timedelta(seconds=55)
        expired = timeout > self._timers['statusUpdated'] + \
            self._params[CONF_SCAN_INTERVAL]

        if not expired:
            return

        # Retrieve the latest state data via the client API
        loc_idx = self._params[CONF_LOCATION_IDX]

        try:
            import evohomeclient2
            self._status.update(
                self._client.locations[loc_idx].status()[GWS][0][TCS][0])
        except (requests.exceptions.RequestException,
                evohomeclient2.AuthenticationError) as err:
            self._handle_exception(err)
        else:
            self._timers['statusUpdated'] = datetime.now()
            self._available = True

        _LOGGER.debug("Status = %s", self._status)

        # inform the child devices that state data has been updated
        pkt = {'sender': 'controller', 'signal': 'refresh', 'to': EVO_CHILD}
        dispatcher_send(self.hass, DISPATCHER_EVOHOME, pkt)
