"""Support for Broadlink IR/RF remotes."""
import asyncio
from base64 import b64encode
from binascii import hexlify
from collections import defaultdict
from datetime import timedelta
from ipaddress import ip_address
from itertools import product
import logging
import socket

import broadlink
import voluptuous as vol

from homeassistant.components.remote import (
    ATTR_ALTERNATIVE,
    ATTR_COMMAND,
    ATTR_DELAY_SECS,
    ATTR_DEVICE,
    ATTR_NUM_REPEATS,
    ATTR_TIMEOUT,
    DEFAULT_DELAY_SECS,
    DOMAIN as COMPONENT,
    PLATFORM_SCHEMA,
    SUPPORT_LEARN_COMMAND,
    RemoteDevice,
)
from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME, CONF_TIMEOUT
from homeassistant.core import callback
from homeassistant.exceptions import HomeAssistantError, PlatformNotReady
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.storage import Store
from homeassistant.util.dt import utcnow

from . import DOMAIN, data_packet, hostname, mac_address

_LOGGER = logging.getLogger(__name__)

DEFAULT_LEARNING_TIMEOUT = 20
DEFAULT_NAME = "Broadlink"
DEFAULT_PORT = 80
DEFAULT_RETRY = 3
DEFAULT_TIMEOUT = 5

SCAN_INTERVAL = timedelta(minutes=2)

CODE_STORAGE_KEY = "broadlink_{}_codes"
CODE_STORAGE_VERSION = 1
FLAG_STORAGE_KEY = "broadlink_{}_flags"
FLAG_STORAGE_VERSION = 1
FLAG_SAVE_DELAY = 15

MINIMUM_SERVICE_SCHEMA = vol.Schema(
    {
        vol.Required(ATTR_COMMAND): vol.All(
            cv.ensure_list, [vol.All(cv.string, vol.Length(min=1))], vol.Length(min=1)
        ),
        vol.Required(ATTR_DEVICE): vol.All(cv.string, vol.Length(min=1)),
    },
    extra=vol.ALLOW_EXTRA,
)

SERVICE_SEND_SCHEMA = MINIMUM_SERVICE_SCHEMA.extend(
    {vol.Optional(ATTR_DELAY_SECS, default=DEFAULT_DELAY_SECS): vol.Coerce(float)}
)

SERVICE_LEARN_SCHEMA = MINIMUM_SERVICE_SCHEMA.extend(
    {
        vol.Optional(ATTR_ALTERNATIVE, default=False): cv.boolean,
        vol.Optional(ATTR_TIMEOUT, default=DEFAULT_LEARNING_TIMEOUT): cv.positive_int,
    }
)

PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
    {
        vol.Required(CONF_HOST): vol.All(vol.Any(hostname, ip_address), cv.string),
        vol.Required(CONF_MAC): mac_address,
        vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int,
        vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
    }
)


async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
    """Set up the Broadlink remote."""
    host = config[CONF_HOST]
    mac_addr = config[CONF_MAC]
    timeout = config[CONF_TIMEOUT]
    name = config[CONF_NAME]
    unique_id = f"remote_{hexlify(mac_addr).decode('utf-8')}"

    if unique_id in hass.data.setdefault(DOMAIN, {}).setdefault(COMPONENT, []):
        _LOGGER.error("Duplicate: %s", unique_id)
        return
    hass.data[DOMAIN][COMPONENT].append(unique_id)

    api = broadlink.rm((host, DEFAULT_PORT), mac_addr, None)
    api.timeout = timeout
    code_storage = Store(hass, CODE_STORAGE_VERSION, CODE_STORAGE_KEY.format(unique_id))
    flag_storage = Store(hass, FLAG_STORAGE_VERSION, FLAG_STORAGE_KEY.format(unique_id))
    remote = BroadlinkRemote(name, unique_id, api, code_storage, flag_storage)

    connected, loaded = (False, False)
    try:
        connected, loaded = await asyncio.gather(
            hass.async_add_executor_job(api.auth), remote.async_load_storage_files()
        )
    except socket.error:
        pass
    if not connected:
        hass.data[DOMAIN][COMPONENT].remove(unique_id)
        raise PlatformNotReady
    if not loaded:
        _LOGGER.error("Failed to set up %s", unique_id)
        hass.data[DOMAIN][COMPONENT].remove(unique_id)
        return
    async_add_entities([remote], False)


class BroadlinkRemote(RemoteDevice):
    """Representation of a Broadlink remote."""

    def __init__(self, name, unique_id, api, code_storage, flag_storage):
        """Initialize the remote."""
        self._name = name
        self._unique_id = unique_id
        self._api = api
        self._code_storage = code_storage
        self._flag_storage = flag_storage
        self._codes = {}
        self._flags = defaultdict(int)
        self._state = True
        self._available = True

    @property
    def name(self):
        """Return the name of the remote."""
        return self._name

    @property
    def unique_id(self):
        """Return the unique ID of the remote."""
        return self._unique_id

    @property
    def is_on(self):
        """Return True if the remote is on."""
        return self._state

    @property
    def available(self):
        """Return True if the remote is available."""
        return self._available

    @property
    def supported_features(self):
        """Flag supported features."""
        return SUPPORT_LEARN_COMMAND

    @callback
    def get_flags(self):
        """Return dictionary of toggle flags.

        A toggle flag indicates whether `self._async_send_code()`
        should send an alternative code for a key device.
        """
        return self._flags

    async def async_turn_on(self, **kwargs):
        """Turn the remote on."""
        self._state = True

    async def async_turn_off(self, **kwargs):
        """Turn the remote off."""
        self._state = False

    async def async_update(self):
        """Update the availability of the remote."""
        if not self.available:
            await self._async_connect()

    async def async_load_storage_files(self):
        """Load codes and toggle flags from storage files."""
        try:
            self._codes.update(await self._code_storage.async_load() or {})
            self._flags.update(await self._flag_storage.async_load() or {})
        except HomeAssistantError:
            return False
        return True

    async def async_send_command(self, command, **kwargs):
        """Send a list of commands to a device."""
        kwargs[ATTR_COMMAND] = command
        kwargs = SERVICE_SEND_SCHEMA(kwargs)
        commands = kwargs[ATTR_COMMAND]
        device = kwargs[ATTR_DEVICE]
        repeat = kwargs[ATTR_NUM_REPEATS]
        delay = kwargs[ATTR_DELAY_SECS]

        if not self._state:
            return

        should_delay = False
        for _, cmd in product(range(repeat), commands):
            try:
                should_delay = await self._async_send_code(
                    cmd, device, delay if should_delay else 0
                )
            except ConnectionError:
                break

        self._flag_storage.async_delay_save(self.get_flags, FLAG_SAVE_DELAY)

    async def _async_send_code(self, command, device, delay):
        """Send a code to a device.

        For toggle commands, alternate between codes in a list,
        ensuring that the same code is never sent twice in a row.
        """
        try:
            code = self._codes[device][command]
        except KeyError:
            _LOGGER.error("Failed to send '%s/%s': command not found", command, device)
            return False

        if isinstance(code, list):
            code = code[self._flags[device]]
            should_alternate = True
        else:
            should_alternate = False
        await asyncio.sleep(delay)

        try:
            await self._async_attempt(self._api.send_data, data_packet(code))
        except ValueError:
            _LOGGER.error("Failed to send '%s/%s': invalid code", command, device)
            return False
        except ConnectionError:
            _LOGGER.error("Failed to send '%s/%s': remote is offline", command, device)
            raise

        if should_alternate:
            self._flags[device] ^= 1

        return True

    async def async_learn_command(self, **kwargs):
        """Learn a list of commands from a remote."""
        kwargs = SERVICE_LEARN_SCHEMA(kwargs)
        commands = kwargs[ATTR_COMMAND]
        device = kwargs[ATTR_DEVICE]
        toggle = kwargs[ATTR_ALTERNATIVE]
        timeout = kwargs[ATTR_TIMEOUT]

        if not self._state:
            return

        should_store = False
        for command in commands:
            try:
                should_store |= await self._async_learn_code(
                    command, device, toggle, timeout
                )
            except ConnectionError:
                break

        if should_store:
            await self._code_storage.async_save(self._codes)

    async def _async_learn_code(self, command, device, toggle, timeout):
        """Learn a code from a remote.

        Capture an aditional code for toggle commands.
        """
        try:
            if not toggle:
                code = await self._async_capture_code(command, timeout)
            else:
                code = [
                    await self._async_capture_code(command, timeout),
                    await self._async_capture_code(command, timeout),
                ]
        except (ValueError, TimeoutError):
            _LOGGER.error(
                "Failed to learn '%s/%s': no signal received", command, device
            )
            return False
        except ConnectionError:
            _LOGGER.error("Failed to learn '%s/%s': remote is offline", command, device)
            raise

        self._codes.setdefault(device, {}).update({command: code})

        return True

    async def _async_capture_code(self, command, timeout):
        """Enter learning mode and capture a code from a remote."""
        await self._async_attempt(self._api.enter_learning)

        self.hass.components.persistent_notification.async_create(
            f"Press the '{command}' button.",
            title="Learn command",
            notification_id="learn_command",
        )

        code = None
        start_time = utcnow()
        while (utcnow() - start_time) < timedelta(seconds=timeout):
            code = await self.hass.async_add_executor_job(self._api.check_data)
            if code:
                break
            await asyncio.sleep(1)

        self.hass.components.persistent_notification.async_dismiss(
            notification_id="learn_command"
        )

        if not code:
            raise TimeoutError
        if all(not value for value in code):
            raise ValueError

        return b64encode(code).decode("utf8")

    async def _async_attempt(self, function, *args):
        """Retry a socket-related function until it succeeds."""
        for retry in range(DEFAULT_RETRY):
            if retry and not await self._async_connect():
                continue
            try:
                await self.hass.async_add_executor_job(function, *args)
            except socket.error:
                continue
            return
        raise ConnectionError

    async def _async_connect(self):
        """Connect to the remote."""
        try:
            auth = await self.hass.async_add_executor_job(self._api.auth)
        except socket.error:
            auth = False
        if auth and not self._available:
            _LOGGER.warning("Connected to the remote")
            self._available = True
        elif not auth and self._available:
            _LOGGER.warning("Disconnected from the remote")
            self._available = False
        return auth
