"""Homeworks.

A partial implementation of an interface to series-4 and series-8 Lutron
Homeworks systems.

The Series4/8 is connected to an RS232 port to an Ethernet adaptor (NPort).

Michael Dubno - 2018 - New York
"""

from collections.abc import Callable
from contextlib import suppress
import logging
import select
import socket
from threading import Thread
import time
from typing import Any, Final

from . import exceptions

_LOGGER = logging.getLogger(__name__)


def _p_address(arg: str) -> str:
    return arg


def _p_button(arg: str) -> int:
    return int(arg)


def _p_enabled(arg: str) -> bool:
    return arg == "enabled"


def _p_level(arg: str) -> int:
    return int(arg)


def _p_ledstate(arg: str) -> list[int]:
    return [int(num) for num in arg]


def _norm(x: str) -> tuple[str, Callable[[str], str], Callable[[str], int]]:
    return (x, _p_address, _p_button)


# Callback types
HW_BUTTON_DOUBLE_TAP = "button_double_tap"
HW_BUTTON_HOLD = "button_hold"
HW_BUTTON_PRESSED = "button_pressed"
HW_BUTTON_RELEASED = "button_released"
HW_KEYPAD_ENABLE_CHANGED = "keypad_enable_changed"
HW_KEYPAD_LED_CHANGED = "keypad_led_changed"
HW_LIGHT_CHANGED = "light_changed"
HW_LOGIN_INCORRECT = "login_incorrect"

ACTIONS: dict[str, tuple[str, Callable[[str], str], Callable[[str], Any]]] = {
    "KBP": _norm(HW_BUTTON_PRESSED),
    "KBR": _norm(HW_BUTTON_RELEASED),
    "KBH": _norm(HW_BUTTON_HOLD),
    "KBDT": _norm(HW_BUTTON_DOUBLE_TAP),
    "DBP": _norm(HW_BUTTON_PRESSED),
    "DBR": _norm(HW_BUTTON_RELEASED),
    "DBH": _norm(HW_BUTTON_HOLD),
    "DBDT": _norm(HW_BUTTON_DOUBLE_TAP),
    "SVBP": _norm(HW_BUTTON_PRESSED),
    "SVBR": _norm(HW_BUTTON_RELEASED),
    "SVBH": _norm(HW_BUTTON_HOLD),
    "SVBDT": _norm(HW_BUTTON_DOUBLE_TAP),
    "KLS": (HW_KEYPAD_LED_CHANGED, _p_address, _p_ledstate),
    "DL": (HW_LIGHT_CHANGED, _p_address, _p_level),
    "KES": (HW_KEYPAD_ENABLE_CHANGED, _p_address, _p_enabled),
}

IGNORED = {
    "Keypad button monitoring enabled",
    "GrafikEye scene monitoring enabled",
    "Dimmer level monitoring enabled",
    "Keypad led monitoring enabled",
}


class Homeworks(Thread):
    """Interface with a Lutron Homeworks 4/8 Series system."""

    COMMAND_SEPARATOR_RX: Final = b"\r"
    COMMAND_SEPARATOR_TX: Final = b"\r\n"
    LINE_ENDING_CHARACTERS: Final = (b"\r", b"\n")
    LOGIN_REQUEST: Final = b"LOGIN: "
    LOGIN_INCORRECT: Final = b"login incorrect"
    LOGIN_SUCCESSFUL: Final = b"login successful"
    POLLING_FREQ: Final = 1.0
    LOGIN_PROMPT_WAIT_TIME: Final = 0.2
    SOCKET_CONNECT_TIMEOUT: Final = 10.0

    def __init__(  # pylint: disable=too-many-arguments
        self,
        host: str,
        port: int,
        callback: Callable[[Any, Any], None],
        username: str | None = None,
        password: str | None = None,
    ) -> None:
        """Initialize."""
        Thread.__init__(self)
        self._host = host
        self._port = int(port)
        self._credentials = _format_credentials(username, password)
        self._callback = callback
        self._socket: socket.socket | None = None

        self._running = False

    def connect(self) -> None:
        """Connect to controller using host, port.

        It's not necessary to call this method, but it can be useed to attempt to
        connect to the remote device without starting the worker thread.
        """
        self._connect(False)

    def _connect(self, callback_on_login_error: bool) -> None:
        """Connect to controller using host, port."""
        try:
            self._socket = socket.create_connection(
                (self._host, self._port), self.SOCKET_CONNECT_TIMEOUT
            )
        except (OSError, ValueError) as error:
            _LOGGER.debug(
                "Failed to connect to %s:%s - %s",
                self._host,
                self._port,
                error,
                exc_info=True,
            )
            raise exceptions.HomeworksConnectionFailed(
                f"Couldn't connect to '{self._host}:{self._port}'"
            ) from error

        _LOGGER.info("Connected to '%s:%s'", self._host, self._port)

        # Wait for login prompt
        time.sleep(self.LOGIN_PROMPT_WAIT_TIME)
        buffer = self._read()
        while buffer.startswith(self.LINE_ENDING_CHARACTERS):
            buffer = buffer[1:]
        if buffer.startswith(self.LOGIN_REQUEST):
            try:
                self._handle_login_request(callback_on_login_error)
            except exceptions.HomeworksException:
                self._close()
                raise

        # Setup interface and subscribe to events
        self._subscribe()

    def _handle_login_request(self, callback_on_login_error: bool) -> None:
        if not self._credentials:
            raise exceptions.HomeworksNoCredentialsProvided
        self._send(self._credentials)

        buffer = self._read()
        while buffer.startswith(self.LINE_ENDING_CHARACTERS):
            buffer = buffer[1:]
        if buffer.startswith(self.LOGIN_INCORRECT):
            if callback_on_login_error:
                self._callback(HW_LOGIN_INCORRECT, [])
            raise exceptions.HomeworksInvalidCredentialsProvided
        if buffer.startswith(self.LOGIN_SUCCESSFUL):
            _LOGGER.debug("Login successful")

    def _read(self) -> bytes:
        readable, _, _ = select.select([self._socket], [], [], self.POLLING_FREQ)
        if not readable:
            return b""
        recv = self._socket.recv(1024)  # type: ignore[union-attr]
        if not recv:
            self._close()
            raise exceptions.HomeworksConnectionLost
        _LOGGER.debug("recv: %s", recv)
        return recv

    def _send(self, command: str) -> bool:
        _LOGGER.debug("send: %s", command)
        try:
            self._socket.send(command.encode("utf8") + self.COMMAND_SEPARATOR_TX)  # type: ignore[union-attr]
        except (ConnectionError, AttributeError):
            self._close()
            return False
        return True

    def fade_dim(
        self, intensity: float, fade_time: float, delay_time: float, addr: str
    ) -> None:
        """Change the brightness of a light.

        Intensity, fade_time and delay_time are rounded because some controllers
        don't accept decimals.
        """
        self._send(
            "FADEDIM, "
            f"{round(intensity)}, {round(fade_time)}, {round(delay_time)}, {addr}"
        )

    def request_dimmer_level(self, addr: str) -> None:
        """Request the controller to return brightness."""
        self._send(f"RDL, {addr}")

    def run(self) -> None:
        """Read and dispatch messages from the controller."""
        self._running = True
        buffer = b""
        while self._running:  # pylint: disable=too-many-nested-blocks
            if self._socket is None:
                with suppress(exceptions.HomeworksException):
                    self._connect(True)
            else:
                try:
                    buffer += self._read()
                    while True:
                        (command, separator, remainder) = buffer.partition(
                            self.COMMAND_SEPARATOR_RX
                        )
                        if separator != self.COMMAND_SEPARATOR_RX:
                            break
                        buffer = remainder
                        while buffer.startswith(self.LINE_ENDING_CHARACTERS):
                            buffer = buffer[1:]
                        if not command:
                            continue
                        self._process_received_data(command)
                except (
                    ConnectionError,
                    AttributeError,
                    exceptions.HomeworksConnectionLost,
                ):
                    _LOGGER.warning("Lost connection.")
                    self._close()
                    buffer = b""
                    if self._running:
                        time.sleep(self.POLLING_FREQ)

        self._running = False
        self._close()

    def _process_received_data(self, data_b: bytes) -> None:
        _LOGGER.debug("Raw: %s", data_b)
        try:
            data = data_b.decode("utf-8")
        except UnicodeDecodeError:
            _LOGGER.warning("Invalid data: %s", data_b)
            return
        if data in IGNORED:
            return
        try:
            raw_args = data.split(", ")
            action = ACTIONS.get(raw_args[0], None)
            if action and len(raw_args) == len(action):
                args = [parser(arg) for parser, arg in zip(action[1:], raw_args[1:])]
                self._callback(action[0], args)
            else:
                _LOGGER.warning("Not handling: %s", raw_args)
        except ValueError:
            _LOGGER.warning("Weird data: %s", data)

    def close(self) -> None:
        """Close the connection to the controller."""
        if self._running:
            raise exceptions.HomeworksException(
                "Can't call close when thread is running"
            )
        self._close()

    def _close(self) -> None:
        """Close the connection to the controller."""
        if self._socket:
            self._socket.close()
            self._socket = None

    def stop(self) -> None:
        """Wait for the worker thread to stop."""
        self._running = False
        self.join()

    def _subscribe(self) -> None:
        # Setup interface and subscribe to events
        self._send("PROMPTOFF")  # No prompt is needed
        self._send("KBMON")  # Monitor keypad events
        self._send("GSMON")  # Monitor GRAFIKEYE scenes
        self._send("DLMON")  # Monitor dimmer levels
        self._send("KLMON")  # Monitor keypad LED states


def _format_credentials(username: str | None, password: str | None) -> str | None:
    """Return a credential string from username and password."""
    if password is not None and username is None:
        raise exceptions.HomeworksInvalidCredentialsProvided(
            "Username must be provided if password is not None"
        )
    if password is not None:
        return f"{username}, {password}"
    if username is not None:
        return username
    return None
