"""
Support for Konnected devices.

For more details about this component, please refer to the documentation at
https://home-assistant.io/components/konnected/
"""
import logging
import hmac
import json
import voluptuous as vol

from aiohttp.hdrs import AUTHORIZATION
from aiohttp.web import Request, Response

from homeassistant.components.binary_sensor import DEVICE_CLASSES_SCHEMA
from homeassistant.components.discovery import SERVICE_KONNECTED
from homeassistant.components.http import HomeAssistantView
from homeassistant.const import (
    HTTP_BAD_REQUEST, HTTP_INTERNAL_SERVER_ERROR, HTTP_UNAUTHORIZED,
    CONF_DEVICES, CONF_BINARY_SENSORS, CONF_SWITCHES, CONF_HOST, CONF_PORT,
    CONF_ID, CONF_NAME, CONF_TYPE, CONF_PIN, CONF_ZONE, CONF_ACCESS_TOKEN,
    ATTR_ENTITY_ID, ATTR_STATE)
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers import discovery
from homeassistant.helpers import config_validation as cv

_LOGGER = logging.getLogger(__name__)

REQUIREMENTS = ['konnected==0.1.2']

DOMAIN = 'konnected'

CONF_ACTIVATION = 'activation'
CONF_API_HOST = 'api_host'
STATE_LOW = 'low'
STATE_HIGH = 'high'

PIN_TO_ZONE = {1: 1, 2: 2, 5: 3, 6: 4, 7: 5, 8: 'out', 9: 6}
ZONE_TO_PIN = {zone: pin for pin, zone in PIN_TO_ZONE.items()}

_BINARY_SENSOR_SCHEMA = vol.All(
    vol.Schema({
        vol.Exclusive(CONF_PIN, 's_pin'): vol.Any(*PIN_TO_ZONE),
        vol.Exclusive(CONF_ZONE, 's_pin'): vol.Any(*ZONE_TO_PIN),
        vol.Required(CONF_TYPE): DEVICE_CLASSES_SCHEMA,
        vol.Optional(CONF_NAME): cv.string,
    }), cv.has_at_least_one_key(CONF_PIN, CONF_ZONE)
)

_SWITCH_SCHEMA = vol.All(
    vol.Schema({
        vol.Exclusive(CONF_PIN, 'a_pin'): vol.Any(*PIN_TO_ZONE),
        vol.Exclusive(CONF_ZONE, 'a_pin'): vol.Any(*ZONE_TO_PIN),
        vol.Optional(CONF_NAME): cv.string,
        vol.Optional(CONF_ACTIVATION, default=STATE_HIGH):
            vol.All(vol.Lower, vol.Any(STATE_HIGH, STATE_LOW))
    }), cv.has_at_least_one_key(CONF_PIN, CONF_ZONE)
)

# pylint: disable=no-value-for-parameter
CONFIG_SCHEMA = vol.Schema(
    {
        DOMAIN: vol.Schema({
            vol.Required(CONF_ACCESS_TOKEN): cv.string,
            vol.Optional(CONF_API_HOST): vol.Url(),
            vol.Required(CONF_DEVICES): [{
                vol.Required(CONF_ID): cv.string,
                vol.Optional(CONF_BINARY_SENSORS): vol.All(
                    cv.ensure_list, [_BINARY_SENSOR_SCHEMA]),
                vol.Optional(CONF_SWITCHES): vol.All(
                    cv.ensure_list, [_SWITCH_SCHEMA]),
            }],
        }),
    },
    extra=vol.ALLOW_EXTRA,
)

DEPENDENCIES = ['http', 'discovery']

ENDPOINT_ROOT = '/api/konnected'
UPDATE_ENDPOINT = (ENDPOINT_ROOT + r'/device/{device_id:[a-zA-Z0-9]+}')
SIGNAL_SENSOR_UPDATE = 'konnected.{}.update'


async def async_setup(hass, config):
    """Set up the Konnected platform."""
    cfg = config.get(DOMAIN)
    if cfg is None:
        cfg = {}

    access_token = cfg.get(CONF_ACCESS_TOKEN)
    if DOMAIN not in hass.data:
        hass.data[DOMAIN] = {
            CONF_ACCESS_TOKEN: access_token,
            CONF_API_HOST: cfg.get(CONF_API_HOST)
        }

    def device_discovered(service, info):
        """Call when a Konnected device has been discovered."""
        _LOGGER.debug("Discovered a new Konnected device: %s", info)
        host = info.get(CONF_HOST)
        port = info.get(CONF_PORT)

        device = KonnectedDevice(hass, host, port, cfg)
        device.setup()

    discovery.async_listen(
        hass,
        SERVICE_KONNECTED,
        device_discovered)

    hass.http.register_view(KonnectedView(access_token))

    return True


class KonnectedDevice:
    """A representation of a single Konnected device."""

    def __init__(self, hass, host, port, config):
        """Initialize the Konnected device."""
        self.hass = hass
        self.host = host
        self.port = port
        self.user_config = config

        import konnected
        self.client = konnected.Client(host, str(port))
        self.status = self.client.get_status()
        _LOGGER.info('Initialized Konnected device %s', self.device_id)

    def setup(self):
        """Set up a newly discovered Konnected device."""
        user_config = self.config()
        if user_config:
            _LOGGER.debug('Configuring Konnected device %s', self.device_id)
            self.save_data()
            self.sync_device_config()
            discovery.load_platform(
                self.hass, 'binary_sensor',
                DOMAIN, {'device_id': self.device_id})
            discovery.load_platform(
                self.hass, 'switch', DOMAIN,
                {'device_id': self.device_id})

    @property
    def device_id(self):
        """Device id is the MAC address as string with punctuation removed."""
        return self.status['mac'].replace(':', '')

    def config(self):
        """Return an object representing the user defined configuration."""
        device_id = self.device_id
        valid_keys = [device_id, device_id.upper(),
                      device_id[6:], device_id.upper()[6:]]
        configured_devices = self.user_config[CONF_DEVICES]
        return next((device for device in
                     configured_devices if device[CONF_ID] in valid_keys),
                    None)

    def save_data(self):
        """Save the device configuration to `hass.data`."""
        sensors = {}
        for entity in self.config().get(CONF_BINARY_SENSORS) or []:
            if CONF_ZONE in entity:
                pin = ZONE_TO_PIN[entity[CONF_ZONE]]
            else:
                pin = entity[CONF_PIN]

            sensor_status = next((sensor for sensor in
                                  self.status.get('sensors') if
                                  sensor.get(CONF_PIN) == pin), {})
            if sensor_status.get(ATTR_STATE):
                initial_state = bool(int(sensor_status.get(ATTR_STATE)))
            else:
                initial_state = None

            sensors[pin] = {
                CONF_TYPE: entity[CONF_TYPE],
                CONF_NAME: entity.get(CONF_NAME, 'Konnected {} Zone {}'.format(
                    self.device_id[6:], PIN_TO_ZONE[pin])),
                ATTR_STATE: initial_state
            }
            _LOGGER.debug('Set up sensor %s (initial state: %s)',
                          sensors[pin].get('name'),
                          sensors[pin].get(ATTR_STATE))

        actuators = {}
        for entity in self.config().get(CONF_SWITCHES) or []:
            if 'zone' in entity:
                pin = ZONE_TO_PIN[entity['zone']]
            else:
                pin = entity['pin']

            actuator_status = next((actuator for actuator in
                                    self.status.get('actuators') if
                                    actuator.get('pin') == pin), {})
            if actuator_status.get(ATTR_STATE):
                initial_state = bool(int(actuator_status.get(ATTR_STATE)))
            else:
                initial_state = None

            actuators[pin] = {
                CONF_NAME: entity.get(
                    CONF_NAME, 'Konnected {} Actuator {}'.format(
                        self.device_id[6:], PIN_TO_ZONE[pin])),
                ATTR_STATE: initial_state,
                CONF_ACTIVATION: entity[CONF_ACTIVATION],
            }
            _LOGGER.debug('Set up actuator %s (initial state: %s)',
                          actuators[pin].get(CONF_NAME),
                          actuators[pin].get(ATTR_STATE))

        device_data = {
            'client': self.client,
            CONF_BINARY_SENSORS: sensors,
            CONF_SWITCHES: actuators,
            CONF_HOST: self.host,
            CONF_PORT: self.port,
        }

        if CONF_DEVICES not in self.hass.data[DOMAIN]:
            self.hass.data[DOMAIN][CONF_DEVICES] = {}

        _LOGGER.debug('Storing data in hass.data[konnected]: %s', device_data)
        self.hass.data[DOMAIN][CONF_DEVICES][self.device_id] = device_data

    @property
    def stored_configuration(self):
        """Return the configuration stored in `hass.data` for this device."""
        return self.hass.data[DOMAIN][CONF_DEVICES][self.device_id]

    def sensor_configuration(self):
        """Return the configuration map for syncing sensors."""
        return [{'pin': p} for p in
                self.stored_configuration[CONF_BINARY_SENSORS]]

    def actuator_configuration(self):
        """Return the configuration map for syncing actuators."""
        return [{'pin': p,
                 'trigger': (0 if data.get(CONF_ACTIVATION) in [0, STATE_LOW]
                             else 1)}
                for p, data in
                self.stored_configuration[CONF_SWITCHES].items()]

    def sync_device_config(self):
        """Sync the new pin configuration to the Konnected device."""
        desired_sensor_configuration = self.sensor_configuration()
        current_sensor_configuration = [
            {'pin': s[CONF_PIN]} for s in self.status.get('sensors')]
        _LOGGER.debug('%s: desired sensor config: %s', self.device_id,
                      desired_sensor_configuration)
        _LOGGER.debug('%s: current sensor config: %s', self.device_id,
                      current_sensor_configuration)

        desired_actuator_config = self.actuator_configuration()
        current_actuator_config = self.status.get('actuators')
        _LOGGER.debug('%s: desired actuator config: %s', self.device_id,
                      desired_actuator_config)
        _LOGGER.debug('%s: current actuator config: %s', self.device_id,
                      current_actuator_config)

        desired_api_host = \
            self.hass.data[DOMAIN].get(CONF_API_HOST) or \
            self.hass.config.api.base_url
        desired_api_endpoint = desired_api_host + ENDPOINT_ROOT
        current_api_endpoint = self.status.get('endpoint')

        _LOGGER.debug('%s: desired api endpoint: %s', self.device_id,
                      desired_api_endpoint)
        _LOGGER.debug('%s: current api endpoint: %s', self.device_id,
                      current_api_endpoint)

        if (desired_sensor_configuration != current_sensor_configuration) or \
                (current_actuator_config != desired_actuator_config) or \
                (current_api_endpoint != desired_api_endpoint):
            _LOGGER.debug('pushing settings to device %s', self.device_id)
            self.client.put_settings(
                desired_sensor_configuration,
                desired_actuator_config,
                self.hass.data[DOMAIN].get(CONF_ACCESS_TOKEN),
                desired_api_endpoint
            )


class KonnectedView(HomeAssistantView):
    """View creates an endpoint to receive push updates from the device."""

    url = UPDATE_ENDPOINT
    extra_urls = [UPDATE_ENDPOINT + '/{pin_num}/{state}']
    name = 'api:konnected'
    requires_auth = False  # Uses access token from configuration

    def __init__(self, auth_token):
        """Initialize the view."""
        self.auth_token = auth_token

    async def put(self, request: Request, device_id,
                  pin_num=None, state=None) -> Response:
        """Receive a sensor update via PUT request and async set state."""
        hass = request.app['hass']
        data = hass.data[DOMAIN]

        try:  # Konnected 2.2.0 and above supports JSON payloads
            payload = await request.json()
            pin_num = payload['pin']
            state = payload['state']
        except json.decoder.JSONDecodeError:
            _LOGGER.warning(("Your Konnected device software may be out of "
                             "date. Visit https://help.konnected.io for "
                             "updating instructions."))

        auth = request.headers.get(AUTHORIZATION, None)
        if not hmac.compare_digest('Bearer {}'.format(self.auth_token), auth):
            return self.json_message(
                "unauthorized", status_code=HTTP_UNAUTHORIZED)
        pin_num = int(pin_num)
        state = bool(int(state))
        device = data[CONF_DEVICES].get(device_id)
        if device is None:
            return self.json_message('unregistered device',
                                     status_code=HTTP_BAD_REQUEST)
        pin_data = device[CONF_BINARY_SENSORS].get(pin_num) or \
            device[CONF_SWITCHES].get(pin_num)

        if pin_data is None:
            return self.json_message('unregistered sensor/actuator',
                                     status_code=HTTP_BAD_REQUEST)

        entity_id = pin_data.get(ATTR_ENTITY_ID)
        if entity_id is None:
            return self.json_message('uninitialized sensor/actuator',
                                     status_code=HTTP_INTERNAL_SERVER_ERROR)

        async_dispatcher_send(
            hass, SIGNAL_SENSOR_UPDATE.format(entity_id), state)
        return self.json_message('ok')
