"""Escea Network Controller module"""

import logging
import asyncio

from enum import Enum
from typing import Dict, Union
from time import time
from async_timeout import timeout
from copy import deepcopy

# Pescea imports:
from .message import (
    Message,
    CommandID,
    ResponseID,
    MIN_SET_TEMP,
    MAX_SET_TEMP,
    expected_response,
)
from .datagram import Datagram

_LOG = logging.getLogger(__name__)

# Seconds between (internal) updates under normal conditions
REFRESH_INTERVAL = 30.0

# Seconds between publishing changes to listeners
# - Updates due to changes happen immediately
NOTIFY_REFRESH_INTERVAL = 5 * 60.0

# Retry rate when first stop getting responses
# - this is needed as UDP is not guaranteed and expect missed messages
RETRY_INTERVAL = 10.0

# Timeout to stop retrying and reduce poll rate
# - plus notify listeners of disconnections
RETRY_TIMEOUT = 60.0

# Time to retry updates once we declare disconnection
DISCONNECTED_INTERVAL = 5 * 60.0

# Time to wait for fireplace to turn fire on / off
# - While busy, commands are stored locally, but not sent on
# - This period was measured from an Escea OEM remote control
ON_OFF_BUSY_WAIT_TIME = 66.0


class Controller:
    """Interface to Escea controller"""

    class Fan(Enum):
        """Supported fan modes"""

        FLAME_EFFECT = "FlameEffect"
        AUTO = "Auto"
        FAN_BOOST = "FanBoost"

    class State(Enum):
        """Controller states:

        Under normal operations:
            The Controller is READY:
                - The Controller sends commands directly to the Fireplace
                - The Controller polls at REFRESH_INTERVAL
        When toggling the fire power:
            The Controller remains BUSY for ON_OFF_BUSY_WAIT_TIME:
                - The Controller buffers requests but does not send to the Fireplace
        When responses are missed (expected as we are using UDP datagrams):
            The Controller is NON_RESPONSIVE
                - The Controller will poll at a (quicker) retry rate
        When there are no comms for a prolonged period:
            The Controller enters DISCONNECTED state
                - The Controller will continue to poll at a reduced rate
                - The Controller buffers requests but cannot send to the Fireplace
        """

        BUSY = "BusyWaiting"
        READY = "Ready"
        NON_RESPONSIVE = "NonResponsive"
        DISCONNECTED = "Disconnected"

    class Settings(Enum):
        """Available controller system settings - Internal Use Only"""

        IP_ADDRESS = "IPAddress"
        DEVICE_UID = "DeviceUId"
        CONTROLLER_STATE = "State"
        HAS_NEW_TIMERS = "HasNewTimers"
        FIRE_IS_ON = "FireIsOn"
        FAN_MODE = "FanMode"
        DESIRED_TEMP = "DesiredTemp"
        CURRENT_TEMP = "CurrentTemp"

    Value = Union[str, int, float, bool, Fan]
    ControllerData = Dict[Settings, Value]

    def __init__(self, discovery, device_uid: str, device_ip: str) -> None:
        """Create a controller interface.

        Args:
            discovery: DiscoveryService() object implementing (at least):
                        - loop
                        - sending_lock
                        - controller_update (callback)
                        - controller_disconnected (callback)
                        - controller_reconnected (callback)
            device_uid: Controller UId as a string (Serial Number of unit)
            device_addr: Device network address. Usually specified as IP
                address
        """

        self._discovery = discovery
        self._system_settings = {}  # type: Controller.ControllerData
        self._prior_settings = {}  # type: Controller.ControllerData
        self._system_settings[Controller.Settings.IP_ADDRESS] = device_ip
        self._system_settings[Controller.Settings.DEVICE_UID] = device_uid

        self._datagram = Datagram(
            self._discovery.loop, device_ip, self._discovery.sending_lock
        )

        self._interrupt_poll_loop_sleep = asyncio.Condition()

        self._initialised = False

    async def initialize(self) -> None:
        """Initialize the controller, does not complete until the firplace has
        been contacted and current settings read.
        """

        self._state = Controller.State.READY
        self._last_response = 0.0  # Tracks last valid message received
        self._busy_end_time = 0.0  # Tracks when exit BUSY state
        self._last_update = 0.0  # To 'rate limit' the notifications to discovery
        self._closed = False  # To exit poll_loop when done

        # Read current state of fireplace
        await self._refresh_system(notify=False)

        self._initialised = True

        # Start regular polling for status updates
        self._poll_loop_task = self._discovery.loop.create_task(self._poll_loop())

    async def close(self):
        """Signal loop to exit, then wait till done"""
        if self._closed:
            return

        self._closed = True
        async with self._interrupt_poll_loop_sleep:
            self._interrupt_poll_loop_sleep.notify()
        await self._poll_loop_task

    async def _poll_loop(self) -> None:
        """Regularly poll for status update from fireplace.
        If Disconnected, retry based on how long ago we last had an update.
        """
        while not self._closed:

            try:
                await self._refresh_system()
            except Exception as exc:
                _LOG.exception("Unexpected exception (EXITING): %s", repr(exc))
                self._closed = True
                return

            _LOG.debug(
                "Polling unit %s at address %s (current state is %s)",
                self._system_settings[Controller.Settings.DEVICE_UID],
                self._system_settings[Controller.Settings.IP_ADDRESS],
                self._state,
            )

            if self._state == Controller.State.READY:
                sleep_time = REFRESH_INTERVAL
            elif self._state == Controller.State.NON_RESPONSIVE:
                sleep_time = RETRY_INTERVAL
            elif self._state == Controller.State.DISCONNECTED:
                sleep_time = DISCONNECTED_INTERVAL
            elif self._state == Controller.State.BUSY:
                sleep_time = max(self._busy_end_time - time(), 0.0)

            try:
                # Sleep for poll time, allow early wakeup
                async with timeout(sleep_time):
                    async with self._interrupt_poll_loop_sleep:
                        await self._interrupt_poll_loop_sleep.wait()
            except asyncio.TimeoutError:
                pass

    @property
    def device_ip(self) -> str:
        """IP Address of the unit"""
        return self._system_settings[Controller.Settings.IP_ADDRESS]

    @property
    def device_uid(self) -> str:
        """UId of the unit (serial number)"""
        return self._system_settings[Controller.Settings.DEVICE_UID]

    @property
    def discovery(self):
        """Handle to the discovery service"""
        return self._discovery

    @property
    def state(self) -> State:
        """Controller state"""
        return self._state

    @property
    def is_on(self) -> bool:
        """True if the fire is turned on"""
        return self._get_system_state(Controller.Settings.FIRE_IS_ON)

    async def set_on(self, value: bool) -> None:
        """Turn the fire on or off.
        Does not return until command has been sent to the fireplace.
        Note: After systems receives on or off command, must wait ON_OFF_BUSY_TIMEOUT
        before sending further commands (they will be buffered internally)
        """
        await self._set_system_state(Controller.Settings.FIRE_IS_ON, value)

    @property
    def fan(self) -> Fan:
        """The current fan level."""
        return self._get_system_state(Controller.Settings.FAN_MODE)

    async def set_fan(self, value: Fan) -> None:
        """The fan level.
        Does not return until command has been sent to the fireplace.

        Note: A peculiarity of this fireplace is that if we change the fan
        setting while off, it actually turns the fire on.
        """
        await self._set_system_state(Controller.Settings.FAN_MODE, value)

    @property
    def desired_temp(self) -> float:
        """fireplace DesiredTemp temperature."""
        return float(self._get_system_state(Controller.Settings.DESIRED_TEMP))

    async def set_desired_temp(self, value: float):
        """Fireplace DesiredTemp temperature.
        Does not return until command has been sent to the fireplace.

        This is the target temp for the fire
        Args:
            value: Valid settings are in range MIN_TEMP..MAX_TEMP
            at 1 degree increments (will be rounded)
        """
        degrees = round(value)
        if degrees < MIN_SET_TEMP or degrees > MAX_SET_TEMP:
            _LOG.error(
                "Desired Temp %s is out of range (%s-%s)",
                degrees,
                MIN_SET_TEMP,
                MAX_SET_TEMP,
            )
            return

        await self._set_system_state(Controller.Settings.DESIRED_TEMP, degrees)

    @property
    def current_temp(self) -> float:
        """The room air temperature"""
        return float(self._get_system_state(Controller.Settings.CURRENT_TEMP))

    @property
    def min_temp(self) -> float:
        """The minimum valid target (desired) temperature"""
        return float(MIN_SET_TEMP)

    @property
    def max_temp(self) -> float:
        """The maximum valid target (desired) temperature"""
        return float(MAX_SET_TEMP)

    async def _refresh_system(self, notify: bool = True) -> None:
        """Request fresh status from the fireplace.
        This is also where state changes are handled.

        Approach:

            if current state BUSY (and not timed out) -> return

            request status

            New status received:
                if prior state READY
                    update local system settings from received message
                else (prior state DISCONNECTED / NON_RESPONSIVE / BUSY (timeout))
                    sync buffered commands to fireplace
                    new state READY
                    if prior state DISCONNECTED:
                        notify discovery reconnected

            No status received:
                prior state *ANY*
                    if time since last response < RETRY_TIMEOUT
                        new state NON_RESPONSIVE
                    else
                        new state DISCONNECTED
                        notify discovery disconnected
        """
        if self._state != Controller.State.BUSY or time() >= self._busy_end_time:
            # Ok to fetch new status

            prior_state = self._state
            response = await self._request_status()
            if (response is not None) and (response.response_id == ResponseID.STATUS):
                # We have a valid response - the controller is communicating

                self._state = Controller.State.READY

                # These values are readonly, so copy them in any case
                self._system_settings[
                    Controller.Settings.HAS_NEW_TIMERS
                ] = response.has_new_timers
                self._system_settings[
                    Controller.Settings.CURRENT_TEMP
                ] = response.current_temp

                if prior_state == Controller.State.READY:

                    # Normal operation, update our internal values
                    self._system_settings[
                        Controller.Settings.DESIRED_TEMP
                    ] = response.desired_temp
                    if response.fan_boost_is_on:
                        self._system_settings[
                            Controller.Settings.FAN_MODE
                        ] = Controller.Fan.FAN_BOOST
                    elif response.flame_effect:
                        self._system_settings[
                            Controller.Settings.FAN_MODE
                        ] = Controller.Fan.FLAME_EFFECT
                    else:
                        self._system_settings[
                            Controller.Settings.FAN_MODE
                        ] = Controller.Fan.AUTO
                    self._system_settings[
                        Controller.Settings.FIRE_IS_ON
                    ] = response.fire_is_on

                else:

                    # We have come back to READY state.
                    # We need to try to sync buffered settings to fireplace

                    if (
                        response.desired_temp
                        != self._system_settings[Controller.Settings.DESIRED_TEMP]
                    ):
                        await self._set_system_state(
                            Controller.Settings.DESIRED_TEMP,
                            self._system_settings[Controller.Settings.DESIRED_TEMP],
                            sync=True,
                        )

                    if response.fan_boost_is_on:
                        response_fan = Controller.Fan.FAN_BOOST
                    elif response.flame_effect:
                        response_fan = Controller.Fan.FLAME_EFFECT
                    else:
                        response_fan = Controller.Fan.AUTO
                    if not self._system_settings[Controller.Settings.FIRE_IS_ON]:
                        # Escea controller has a quirk that turning on
                        # FAN_BOOST or FLAME_EFFECT actually turns on the fire
                        # ... so must avoid setting fan when 'synching' if want it to stay off
                        self._system_settings[
                            Controller.Settings.FAN_MODE
                        ] = response_fan
                    else:
                        if (
                            response_fan
                            != self._system_settings[Controller.Settings.FAN_MODE]
                        ):
                            await self._set_system_state(
                                Controller.Settings.FAN_MODE,
                                self._system_settings[Controller.Settings.FAN_MODE],
                                sync=True,
                            )

                    # Do power last, as then we go to BUSY state
                    if (
                        response.fire_is_on
                        != self._system_settings[Controller.Settings.FIRE_IS_ON]
                    ):
                        await self._set_system_state(
                            Controller.Settings.FIRE_IS_ON,
                            self._system_settings[Controller.Settings.FIRE_IS_ON],
                            sync=True,
                        )

                    if prior_state == Controller.State.DISCONNECTED:
                        self._discovery.controller_reconnected(self)

            else:
                # No / invalid response, need to check if we need to change state
                if time() - self._last_response < RETRY_TIMEOUT:
                    self._state = Controller.State.NON_RESPONSIVE
                else:
                    self._state = Controller.State.DISCONNECTED
                    if prior_state != Controller.State.DISCONNECTED:
                        self._discovery.controller_disconnected(self, TimeoutError)

        if notify and self._state != Controller.State.DISCONNECTED:
            # send an update to discovery if there have been any changes
            # or if haven't sent one in last NOTIFY_REFRESH_INTERVAL
            changes_found = False
            for entry in self._system_settings:
                if not entry in self._prior_settings or (
                    self._prior_settings[entry] != self._system_settings[entry]
                ):
                    changes_found = True
                    break
            if changes_found or (time() - self._last_update > NOTIFY_REFRESH_INTERVAL):
                self._last_update = time()
                self._prior_settings = deepcopy(self._system_settings)
                self._discovery.controller_update(self)

    async def _request_status(self) -> Message:
        """Send command to fireplace requesting current status"""
        try:
            responses = await self._datagram.send_command(CommandID.STATUS_PLEASE)
            if len(responses) > 0:
                this_response = next(iter(responses))  # only expecting one
                if responses[this_response].response_id == expected_response(
                    CommandID.STATUS_PLEASE
                ):
                    _LOG.debug(
                        "_request_status - send_command(success): %s",
                        str(self.device_uid),
                    )
                    self._last_response = time()
                    return responses[this_response]
        except ConnectionError:
            pass
        # If we get here... did not receive a response or not valid
        if self._state != Controller.State.DISCONNECTED:
            self._state = Controller.State.NON_RESPONSIVE
        _LOG.debug(
            "_request_status - send_command(failed): %s (now: %s)",
            str(self.device_uid),
            self._state,
        )
        return None

    def refresh_address(self, address):
        """Called from discovery to point controller to the IP address"""
        if self._system_settings[Controller.Settings.IP_ADDRESS] == address:
            return

        self._datagram.set_ip(address)
        self._system_settings[Controller.Settings.IP_ADDRESS] = address

        # signal the poll loop to wake up and ask for new status
        async def signal_loop(self):
            async with self._interrupt_poll_loop_sleep:
                self._interrupt_poll_loop_sleep.notify()

        self._discovery.loop.create_task(signal_loop(self))

    def _get_system_state(self, state: Settings):
        """Locally stored (buffered) value, or received from fireplace"""
        return self._system_settings[state]

    async def _set_system_state(self, state: Settings, value, sync: bool = False):
        """Send command to fireplace to change given state.
        Args:
            sync: state/value have been buffered -> send to fireplace
        """

        # nothing to do if not synching our state, and already have right state
        if (not sync) and (self._system_settings[state] == value):
            return

        _LOG.debug(
            "_set_system_state - uid: %s | %s from:%s to:%s  (sync:%s)",
            str(self.device_uid),
            str(state),
            str(self._system_settings[state]),
            str(value),
            str(sync),
        )

        # save the new value internally
        self._system_settings[state] = value

        # send it to the fireplace if asked to sync, or fireplace is not busy
        if sync or self._state == Controller.State.READY:

            command = None

            if state == Controller.Settings.FIRE_IS_ON:
                if value:
                    command = CommandID.POWER_ON
                else:
                    command = CommandID.POWER_OFF

            elif state == Controller.Settings.DESIRED_TEMP:
                command = CommandID.NEW_SET_TEMP

            elif state == Controller.Settings.FAN_MODE:

                # Fan is implemented via separate FLAME_EFFECT and FAN_BOOST commands
                # Any change to Fan will take one or two separate commands:
                #
                # To AUTO:
                # 1. turn off FAN_BOOST
                if value == Controller.Fan.AUTO:
                    command = CommandID.FAN_BOOST_OFF

                # To FAN_BOOST:
                # 1. Turn off FLAME_EFFECT
                elif value == Controller.Fan.FAN_BOOST:
                    command = CommandID.FLAME_EFFECT_OFF

                # To FLAME_EFFECT:
                # 1. Turn off FAN_BOOST
                elif value == Controller.Fan.FLAME_EFFECT:
                    command = CommandID.FAN_BOOST_OFF

            else:
                raise (AttributeError, "Unexpected state: {0}".format(state))

            if command is not None:
                valid_response = False
                try:
                    responses = await self._datagram.send_command(command, value)
                    if (len(responses) > 0) and (
                        responses[next(iter(responses))].response_id
                        == expected_response(command)
                    ):
                        valid_response = True
                        _LOG.debug(
                            "_set_system_state - send_command(success): %s -> %s",
                            str(self.device_uid),
                            str(command),
                        )

                except ConnectionError:
                    pass
                if valid_response:
                    self._last_response = time()
                else:
                    return

            if state == Controller.Settings.FAN_MODE:
                # Fan is implemented via separate FLAME_EFFECT and FAN_BOOST commands
                # Any change will take one or two separate commands:
                #
                # To AUTO:
                # 2. turn off FLAME_EFFECT
                if value == Controller.Fan.AUTO:
                    command = CommandID.FLAME_EFFECT_OFF

                # To FAN_BOOST:
                # 2. Turn on FAN_BOOST
                elif value == Controller.Fan.FAN_BOOST:
                    command = CommandID.FAN_BOOST_ON

                # To FLAME_EFFECT:
                # 2. Turn on FLAME_EFFECT
                else:
                    command = CommandID.FLAME_EFFECT_ON

                valid_response = False
                try:
                    responses = await self._datagram.send_command(command, value)
                    if (len(responses) > 0) and (
                        responses[next(iter(responses))].response_id
                        == expected_response(command)
                    ):
                        valid_response = True
                        _LOG.debug(
                            "_set_system_state - send_command(success): %s -> %s",
                            str(self.device_uid),
                            str(command),
                        )
                except ConnectionError:
                    pass
                if valid_response:
                    self._last_response = time()
                else:
                    return

        # Need to refresh immediately after setting
        # (unless synching, in which case the poll loop will update)
        if not sync:
            await self._refresh_system()

        # If get here, and just toggled the fireplace power... need to buffer for a while
        if state == Controller.Settings.FIRE_IS_ON:
            self._state = Controller.State.BUSY
            self._busy_end_time = time() + ON_OFF_BUSY_WAIT_TIME
