"""Component to create an interface to a Pilight daemon."""

from collections.abc import Callable
from datetime import timedelta
import functools
import logging
import threading
from typing import Any

from pilight import pilight
import voluptuous as vol

from homeassistant.const import (
    CONF_HOST,
    CONF_PORT,
    CONF_PROTOCOL,
    CONF_WHITELIST,
    EVENT_HOMEASSISTANT_START,
    EVENT_HOMEASSISTANT_STOP,
)
from homeassistant.core import Event, HomeAssistant, ServiceCall
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.event import track_point_in_utc_time
from homeassistant.helpers.typing import ConfigType
from homeassistant.util import dt as dt_util
from homeassistant.util.async_ import run_callback_threadsafe

_LOGGER = logging.getLogger(__name__)

CONF_SEND_DELAY = "send_delay"

DEFAULT_HOST = "127.0.0.1"
DEFAULT_PORT = 5001
DEFAULT_SEND_DELAY = 0.0
DOMAIN = "pilight"

EVENT = "pilight_received"
type EVENT_TYPE = Event[dict[str, Any]]

# The Pilight code schema depends on the protocol. Thus only require to have
# the protocol information. Ensure that protocol is in a list otherwise
# segfault in pilight-daemon, https://github.com/pilight/pilight/issues/296
RF_CODE_SCHEMA = vol.Schema(
    {vol.Required(CONF_PROTOCOL): vol.All(cv.ensure_list, [cv.string])},
    extra=vol.ALLOW_EXTRA,
)

SERVICE_NAME = "send"

CONFIG_SCHEMA = vol.Schema(
    {
        DOMAIN: vol.Schema(
            {
                vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string,
                vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
                vol.Optional(CONF_WHITELIST, default={}): {cv.string: [cv.string]},
                vol.Optional(CONF_SEND_DELAY, default=DEFAULT_SEND_DELAY): vol.Coerce(
                    float
                ),
            }
        )
    },
    extra=vol.ALLOW_EXTRA,
)


def setup(hass: HomeAssistant, config: ConfigType) -> bool:
    """Set up the Pilight component."""

    host = config[DOMAIN][CONF_HOST]
    port = config[DOMAIN][CONF_PORT]
    send_throttler = CallRateDelayThrottle(hass, config[DOMAIN][CONF_SEND_DELAY])

    try:
        pilight_client = pilight.Client(host=host, port=port)
    except (OSError, TimeoutError) as err:
        _LOGGER.error("Unable to connect to %s on port %s: %s", host, port, err)
        return False

    def start_pilight_client(_):
        """Run when Home Assistant starts."""
        pilight_client.start()

    hass.bus.listen_once(EVENT_HOMEASSISTANT_START, start_pilight_client)

    def stop_pilight_client(_):
        """Run once when Home Assistant stops."""
        pilight_client.stop()

    hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop_pilight_client)

    @send_throttler.limited
    def send_code(call: ServiceCall) -> None:
        """Send RF code to the pilight-daemon."""
        # Change type to dict from mappingproxy since data has to be JSON
        # serializable
        message_data = dict(call.data)

        try:
            pilight_client.send_code(message_data)
        # pylint: disable-next=home-assistant-action-swallowed-exception
        except OSError:
            _LOGGER.error("Pilight send failed for %s", str(message_data))

    def _register_service() -> None:
        hass.services.async_register(
            DOMAIN,
            SERVICE_NAME,
            send_code,
            schema=RF_CODE_SCHEMA,
            description_placeholders={
                "pilight_protocols_docs_url": "https://manual.pilight.org/protocols/index.html"
            },
        )

    run_callback_threadsafe(hass.loop, _register_service).result()

    # Publish received codes on the HA event bus
    # A whitelist of codes to be published in the event bus
    whitelist = config[DOMAIN].get(CONF_WHITELIST)

    def handle_received_code(data):
        """Run when RF codes are received."""
        # Unravel dict of dicts to make event_data cut in automation rule
        # possible
        data = dict(
            {"protocol": data["protocol"], "uuid": data["uuid"]}, **data["message"]
        )

        # No whitelist defined or data matches whitelist, put data on event bus
        if not whitelist or all(str(data[key]) in whitelist[key] for key in whitelist):
            hass.bus.fire(EVENT, data)

    pilight_client.set_callback(handle_received_code)

    return True


class CallRateDelayThrottle:
    """Helper class to provide service call rate throttling.

    This class provides a decorator to decorate service methods that need
    to be throttled to not exceed a certain call rate per second.
    One instance can be used on multiple service methods to archive
    an overall throttling.

    As this uses track_point_in_utc_time to schedule delayed executions
    it should not block the mainloop.
    """

    def __init__(self, hass: HomeAssistant, delay_seconds: float) -> None:
        """Initialize the delay handler."""
        self._delay = timedelta(seconds=max(0.0, delay_seconds))
        self._queue: list[Callable[[Any], None]] = []
        self._active = False
        self._lock = threading.Lock()
        self._next_ts = dt_util.utcnow()
        self._schedule = functools.partial(track_point_in_utc_time, hass)

    def limited[**_P](self, method: Callable[_P, Any]) -> Callable[_P, None]:
        """Decorate to delay calls on a certain method."""

        @functools.wraps(method)
        def decorated(*args: _P.args, **kwargs: _P.kwargs) -> None:
            """Delay a call."""
            if self._delay.total_seconds() == 0.0:
                method(*args, **kwargs)
                return

            def action(event: Any) -> None:
                """Wrap an action that gets scheduled."""
                method(*args, **kwargs)

                with self._lock:
                    self._next_ts = dt_util.utcnow() + self._delay

                    if not self._queue:
                        self._active = False
                    else:
                        next_action = self._queue.pop(0)
                        self._schedule(next_action, self._next_ts)

            with self._lock:
                if self._active:
                    self._queue.append(action)
                else:
                    self._active = True
                    schedule_ts = max(dt_util.utcnow(), self._next_ts)
                    self._schedule(action, schedule_ts)

        return decorated
