"""Support for Rflink components.

For more details about this component, please refer to the documentation at
https://home-assistant.io/components/rflink/

Technical overview:

The Rflink gateway is a USB serial device (Arduino with Rflink firwmare)
connected to a 433Mhz transceiver module.

The the `rflink` Python module a asyncio transport/protocol is setup that
fires an callback for every (valid/supported) packet received by the Rflink
gateway.

This component uses this callback to distribute 'rflink packet events' over
the HASS bus which can be subscribed to by entities/platform implementations.

The platform implementions take care of creating new devices (if enabled) for
unsees incoming packet id's.

Device Entities take care of matching to the packet id, interpreting and
performing actions based on the packet contents. Common entitiy logic is
maintained in this file.

"""
import asyncio
from collections import defaultdict
import functools as ft
import logging

from homeassistant.const import (
    ATTR_ENTITY_ID, CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_STOP,
    STATE_UNKNOWN)
from homeassistant.core import CoreState, callback
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity import Entity
import voluptuous as vol

REQUIREMENTS = ['rflink==0.0.28']

DOMAIN = 'rflink'

CONF_ALIASSES = 'aliasses'
CONF_DEVICES = 'devices'
CONF_DEVICE_DEFAULTS = 'device_defaults'
CONF_FIRE_EVENT = 'fire_event'
CONF_IGNORE_DEVICES = 'ignore_devices'
CONF_NEW_DEVICES_GROUP = 'new_devices_group'
CONF_RECONNECT_INTERVAL = 'reconnect_interval'
CONF_SIGNAL_REPETITIONS = 'signal_repetitions'
CONF_WAIT_FOR_ACK = 'wait_for_ack'

DEFAULT_SIGNAL_REPETITIONS = 1
DEFAULT_RECONNECT_INTERVAL = 10

DEVICE_DEFAULTS_SCHEMA = vol.Schema({
    vol.Optional(CONF_FIRE_EVENT, default=False): cv.boolean,
    vol.Optional(CONF_SIGNAL_REPETITIONS,
                 default=DEFAULT_SIGNAL_REPETITIONS): vol.Coerce(int),
})

CONFIG_SCHEMA = vol.Schema({
    DOMAIN: vol.Schema({
        vol.Required(CONF_PORT): vol.Any(cv.port, cv.string),
        vol.Optional(CONF_HOST, default=None): cv.string,
        vol.Optional(CONF_WAIT_FOR_ACK, default=True): cv.boolean,
        vol.Optional(CONF_RECONNECT_INTERVAL,
                     default=DEFAULT_RECONNECT_INTERVAL): int,
        vol.Optional(CONF_IGNORE_DEVICES, default=[]):
            vol.All(cv.ensure_list, [cv.string]),
    }),
}, extra=vol.ALLOW_EXTRA)

ATTR_EVENT = 'event'
ATTR_STATE = 'state'

DATA_DEVICE_REGISTER = 'rflink_device_register'
DATA_ENTITY_LOOKUP = 'rflink_entity_lookup'

EVENT_BUTTON_PRESSED = 'button_pressed'

EVENT_KEY_COMMAND = 'command'
EVENT_KEY_ID = 'id'
EVENT_KEY_SENSOR = 'sensor'
EVENT_KEY_UNIT = 'unit'

_LOGGER = logging.getLogger(__name__)


def identify_event_type(event):
    """Look at event to determine type of device.

    Async friendly.

    """
    if EVENT_KEY_COMMAND in event:
        return EVENT_KEY_COMMAND
    elif EVENT_KEY_SENSOR in event:
        return EVENT_KEY_SENSOR
    else:
        return 'unknown'


@asyncio.coroutine
def async_setup(hass, config):
    """Setup the Rflink component."""
    from rflink.protocol import create_rflink_connection
    import serial

    # allow entities to register themselves by device_id to be looked up when
    # new rflink events arrive to be handled
    hass.data[DATA_ENTITY_LOOKUP] = {
        EVENT_KEY_COMMAND: defaultdict(list),
        EVENT_KEY_SENSOR: defaultdict(list),
    }

    # allow platform to specify function to register new unknown devices
    hass.data[DATA_DEVICE_REGISTER] = {}

    @callback
    def event_callback(event):
        """Handle incoming rflink events.

        Rflink events arrive as dictionaries of varying content
        depending on their type. Identify the events and distribute
        accordingly.

        """
        event_type = identify_event_type(event)
        _LOGGER.debug('event of type %s: %s', event_type, event)

        # don't propagate non entity events (eg: version string, ack response)
        if event_type not in hass.data[DATA_ENTITY_LOOKUP]:
            _LOGGER.debug('unhandled event of type: %s', event_type)
            return

        # lookup entities who registered this device id as device id or alias
        event_id = event.get('id', None)
        entities = hass.data[DATA_ENTITY_LOOKUP][event_type][event_id]

        if entities:
            # propagate event to every entity matching the device id
            for entity in entities:
                _LOGGER.debug('passing event to %s', entities)
                entity.handle_event(event)
        else:
            _LOGGER.debug('device_id not known, adding new device')

            # if device is not yet known, register with platform (if loaded)
            if event_type in hass.data[DATA_DEVICE_REGISTER]:
                hass.async_run_job(
                    hass.data[DATA_DEVICE_REGISTER][event_type], event)

    # when connecting to tcp host instead of serial port (optional)
    host = config[DOMAIN][CONF_HOST]
    # tcp port when host configured, otherwise serial port
    port = config[DOMAIN][CONF_PORT]

    @callback
    def reconnect(exc=None):
        """Schedule reconnect after connection has been unexpectedly lost."""
        # reset protocol binding before starting reconnect
        RflinkCommand.set_rflink_protocol(None)

        # if HA is not stopping, initiate new connection
        if hass.state != CoreState.stopping:
            _LOGGER.warning('disconnected from Rflink, reconnecting')
            hass.async_add_job(connect)

    @asyncio.coroutine
    def connect():
        """Setup connection and hook it into HA for reconnect/shutdown."""
        _LOGGER.info('initiating Rflink connection')

        # rflink create_rflink_connection decides based on the value of host
        # (string or None) if serial or tcp mode should be used

        # initiate serial/tcp connection to Rflink gateway
        connection = create_rflink_connection(
            port=port,
            host=host,
            event_callback=event_callback,
            disconnect_callback=reconnect,
            loop=hass.loop,
            ignore=config[DOMAIN][CONF_IGNORE_DEVICES]
        )

        try:
            transport, protocol = yield from connection
        except (serial.serialutil.SerialException, ConnectionRefusedError,
                TimeoutError) as exc:
            reconnect_interval = config[DOMAIN][CONF_RECONNECT_INTERVAL]
            _LOGGER.exception(
                'error connecting to Rflink, reconnecting in %s',
                reconnect_interval)
            hass.loop.call_later(reconnect_interval, reconnect, exc)
            return

        # bind protocol to command class to allow entities to send commands
        RflinkCommand.set_rflink_protocol(
            protocol, config[DOMAIN][CONF_WAIT_FOR_ACK])

        # handle shutdown of rflink asyncio transport
        hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP,
                                   lambda x: transport.close())

        _LOGGER.info('connected to Rflink')

    # make initial connection
    yield from connect()

    # whoo
    return True


class RflinkDevice(Entity):
    """Represents a Rflink device.

    Contains the common logic for Rflink entities.

    """

    # should be set by component implementation
    platform = None
    # default state
    _state = STATE_UNKNOWN

    def __init__(self, device_id, hass, name=None,
                 aliasses=None, fire_event=False,
                 signal_repetitions=DEFAULT_SIGNAL_REPETITIONS):
        """Initialize the device."""
        self.hass = hass

        # rflink specific attributes for every component type
        self._device_id = device_id
        if name:
            self._name = name
        else:
            self._name = device_id

        # generate list of device_ids to match against
        if aliasses:
            self._aliasses = aliasses
        else:
            self._aliasses = []

        self._should_fire_event = fire_event
        self._signal_repetitions = signal_repetitions

    def handle_event(self, event):
        """Handle incoming event for device type."""
        # call platform specific event handler
        self._handle_event(event)

        # propagate changes through ha
        self.hass.async_add_job(self.async_update_ha_state())

        # put command onto bus for user to subscribe to
        if self._should_fire_event and identify_event_type(
                event) == EVENT_KEY_COMMAND:
            self.hass.bus.fire(EVENT_BUTTON_PRESSED, {
                ATTR_ENTITY_ID: self.entity_id,
                ATTR_STATE: event[EVENT_KEY_COMMAND],
            })
            _LOGGER.debug(
                'fired bus event for %s: %s',
                self.entity_id,
                event[EVENT_KEY_COMMAND])

    def _handle_event(self, event):
        """Platform specific event handler."""
        raise NotImplementedError()

    @property
    def should_poll(self):
        """No polling needed."""
        return False

    @property
    def name(self):
        """Return a name for the device."""
        return self._name

    @property
    def is_on(self):
        """Return true if device is on."""
        if self.assumed_state:
            return False
        return self._state

    @property
    def assumed_state(self):
        """Assume device state until first device event sets state."""
        return self._state is STATE_UNKNOWN


class RflinkCommand(RflinkDevice):
    """Singleton class to make Rflink command interface available to entities.

    This class is to be inherited by every Entity class that is actionable
    (switches/lights). It exposes the Rflink command interface for these
    entities.

    The Rflink interface is managed as a class level and set during setup (and
    reset on reconnect).

    """

    # keep repetition tasks to cancel if state is changed before repetitions
    # are sent
    _repetition_task = None

    @classmethod
    def set_rflink_protocol(cls, protocol, wait_ack=None):
        """Set the Rflink asyncio protocol as a class variable."""
        cls._protocol = protocol
        if wait_ack is not None:
            cls._wait_ack = wait_ack

    @asyncio.coroutine
    def _async_handle_command(self, command, *args):
        """Do bookkeeping for command, send it to rflink and update state."""
        self.cancel_queued_send_commands()

        if command == "turn_on":
            cmd = 'on'
            self._state = True

        elif command == 'turn_off':
            cmd = 'off'
            self._state = False

        elif command == 'dim':
            # convert brightness to rflink dim level
            cmd = str(int(args[0] / 17))
            self._state = True

        # send initial command and queue repetitions
        # this allows the entity state to be updated quickly and not having to
        # wait for all repetitions to be sent
        yield from self._async_send_command(cmd, self._signal_repetitions)

        # Update state of entity
        yield from self.async_update_ha_state()

    def cancel_queued_send_commands(self):
        """Cancel queued signal repetition commands.

        For example when user changed state while repetitions are still
        queued for broadcast. Or when a incoming Rflink command (remote
        switch) changes the state.

        """
        # cancel any outstanding tasks from the previous state change
        if self._repetition_task:
            self._repetition_task.cancel()

    @asyncio.coroutine
    def _async_send_command(self, cmd, repetitions):
        """Send a command for device to Rflink gateway."""
        _LOGGER.debug('sending command: %s to rflink device: %s',
                      cmd, self._device_id)

        if self._wait_ack:
            # Puts command on outgoing buffer then waits for Rflink to confirm
            # the command has been send out in the ether.
            yield from self._protocol.send_command_ack(self._device_id, cmd)
        else:
            # Puts command on outgoing buffer and returns straight away.
            # Rflink protocol/transport handles asynchronous writing of buffer
            # to serial/tcp device. Does not wait for command send
            # confirmation.
            self.hass.loop.run_in_executor(None, ft.partial(
                self._protocol.send_command, self._device_id, cmd))

        if repetitions > 1:
            self._repetition_task = self.hass.loop.create_task(
                self._async_send_command(cmd, repetitions - 1))


class SwitchableRflinkDevice(RflinkCommand):
    """Rflink entity which can switch on/off (eg: light, switch)."""

    def _handle_event(self, event):
        """Adjust state if Rflink picks up a remote command for this device."""
        self.cancel_queued_send_commands()

        command = event['command']
        if command == 'on':
            self._state = True
        elif command == 'off':
            self._state = False

    @asyncio.coroutine
    def async_turn_on(self, **kwargs):
        """Turn the device on."""
        yield from self._async_handle_command("turn_on")

    @asyncio.coroutine
    def async_turn_off(self, **kwargs):
        """Turn the device off."""
        yield from self._async_handle_command("turn_off")
