"""Module to maintain AVR state information and network interface."""
import asyncio
import logging
import time
import struct
import re
from pyeiscp import commands
from pyeiscp.utils import ValueRange
from collections import namedtuple

__all__ = "AVR"

# In Python 3.4.4, `async` was renamed to `ensure_future`.
try:
    ensure_future = asyncio.ensure_future
except AttributeError:
    ensure_future = getattr(asyncio, "async")


class ISCPMessage(object):
    """Deals with formatting and parsing data wrapped in an ISCP
    containers. The docs say:
        ISCP (Integra Serial Control Protocol) consists of three
        command characters and parameter character(s) of variable
        length.
    It seems this was the original protocol used for communicating
    via a serial cable.
    """

    def __init__(self, data):
        self.data = data

    def __str__(self):
        # ! = start character
        # 1 = destination unit type, 1 means receiver
        # End character may be CR, LF or CR+LF, according to doc
        return "!1{}\r".format(self.data)

    @classmethod
    def parse(cls, data):
        EOF = "\x1a"
        TERMINATORS = ["\n", "\r"]
        assert data[:2] == "!1"
        eof_offset = -1
        # EOF can be followed by CR/LF/CR+LF
        if data[eof_offset] in TERMINATORS:
            eof_offset -= 1
            if data[eof_offset] in TERMINATORS:
                eof_offset -= 1
        assert data[eof_offset] == EOF
        return data[2:eof_offset]


class eISCPPacket(object):
    """For communicating over Ethernet, traditional ISCP messages are
    wrapped inside an eISCP package.
    """

    header = namedtuple("header", ("magic, header_size, data_size, version, reserved"))

    def __init__(self, iscp_message):
        iscp_message = str(iscp_message)
        # We attach data separately, because Python's struct module does
        # not support variable length strings,
        header = struct.pack(
            "! 4s I I b 3s",
            b"ISCP",  # magic
            16,  # header size (16 bytes)
            len(iscp_message),  # data size
            0x01,  # version
            b"\x00\x00\x00",  # reserved
        )

        self._bytes = header + iscp_message.encode("utf-8")
        # __new__, string subclass?

    def __str__(self):
        return self._bytes.decode("utf-8")

    def get_raw(self):
        return self._bytes

    @classmethod
    def parse(cls, bytes):
        """Parse the eISCP package given by ``bytes``.
        """
        h = cls.parse_header(bytes[:16])
        data = bytes[h.header_size : h.header_size + h.data_size].decode()
        assert len(data) == h.data_size
        return data

    @classmethod
    def parse_header(cls, bytes):
        """Parse the header of an eISCP package.
        This is useful when reading data in a streaming fashion,
        because you can subsequently know the number of bytes to
        expect in the packet.
        """
        # A header is always 16 bytes in length
        assert len(bytes) == 16

        # Parse the header
        magic, header_size, data_size, version, reserved = struct.unpack(
            "! 4s I I b 3s", bytes
        )

        magic = magic.decode()
        reserved = reserved.decode()

        # Strangly, the header contains a header_size field.
        assert magic == "ISCP"
        assert header_size == 16

        return eISCPPacket.header(magic, header_size, data_size, version, reserved)

    @classmethod
    def parse_info(cls, bytes):
        response = cls.parse(bytes)
        # Return string looks something like this:
        # !1ECNTX-NR609/60128/DX
        info = re.match(r'''
            !
            (?P<device_category>\d)
            ECN
            (?P<model_name>[^/]*)/
            (?P<iscp_port>\d{5})/
            (?P<area_code>\w{2})/
            (?P<identifier>.{0,12})
        ''', response.strip(), re.VERBOSE)

        if info:
            return info.groupdict()


def command_to_packet(command):
    """Convert an ascii command like (PVR00) to the binary data we
    need to send to the receiver.
    """
    return eISCPPacket(ISCPMessage(command)).get_raw()


def normalize_command(command):
    """Ensures that various ways to refer to a command can be used."""
    command = command.lower()
    command = command.replace("_", " ")
    command = command.replace("-", " ")
    return command


def command_to_iscp(command, arguments=None, zone=None):
    """Transform the given given high-level command to a
    low-level ISCP message.
    Raises :class:`ValueError` if `command` is not valid.
    This exposes a system of human-readable, "pretty"
    commands, which is organized into three parts: the zone, the
    command, and arguments. For example::
        command('power', 'on')
        command('power', 'on', zone='main')
        command('volume', 66, zone='zone2')
    As you can see, if no zone is given, the main zone is assumed.
    Instead of passing three different parameters, you may put the
    whole thing in a single string, which is helpful when taking
    input from users::
        command('power on')
        command('zone2 volume 66')
    To further simplify things, for example when taking user input
    from a command line, where whitespace needs escaping, the
    following is also supported:
        command('power=on')
        command('zone2.volume=66')
    """
    default_zone = "main"
    command_sep = r"[. ]"
    norm = lambda s: s.strip().lower()

    # If parts are not explicitly given, parse the command
    if arguments is None and zone is None:
        # Separating command and args with colon allows multiple args
        if ":" in command or "=" in command:
            base, arguments = re.split(r"[:=]", command, 1)
            parts = [norm(c) for c in re.split(command_sep, base)]
            if len(parts) == 2:
                zone, command = parts
            else:
                zone = default_zone
                command = parts[0]
            # Split arguments by comma or space
            arguments = [norm(a) for a in re.split(r"[ ,]", arguments)]
        else:
            # Split command part by space or dot
            parts = [norm(c) for c in re.split(command_sep, command)]
            if len(parts) >= 3:
                zone, command = parts[:2]
                arguments = parts[3:]
            elif len(parts) == 2:
                zone = default_zone
                command = parts[0]
                arguments = parts[1:]
            else:
                raise ValueError("Need at least command and argument")

    # Find the command in our database, resolve to internal eISCP command
    group = commands.ZONE_MAPPINGS.get(zone, zone)
    if not zone in commands.COMMANDS:
        raise ValueError('"{}" is not a valid zone'.format(zone))

    prefix = commands.COMMAND_MAPPINGS[group].get(command, command)
    if not prefix in commands.COMMANDS[group]:
        raise ValueError(
            '"{}" is not a valid command in zone "{}"'.format(command, zone)
        )

    # Resolve the argument to the command. This is a bit more involved,
    # because some commands support ranges (volume) or patterns
    # (setting tuning frequency). In some cases, we might imagine
    # providing the user an API with multiple arguments (TODO: not
    # currently supported).
    argument = arguments[0]

    # 1. Consider if there is a alias, e.g. level-up for UP.
    try:
        value = commands.VALUE_MAPPINGS[group][prefix][argument]
    except KeyError:
        # 2. See if we can match a range or pattern
        for possible_arg in commands.VALUE_MAPPINGS[group][prefix]:
            if argument.isdigit():
                if isinstance(possible_arg, ValueRange):
                    if int(argument) in possible_arg:
                        # We need to send the format "FF", hex() gives us 0xff
                        value = hex(int(argument))[2:].zfill(2).upper()
                        break

            # TODO: patterns not yet supported
        else:
            raise ValueError(
                '"{}" is not a valid argument for command '
                '"{}" in zone "{}"'.format(argument, command, zone)
            )

    return "{}{}".format(prefix, value)


def iscp_to_command(iscp_message):
    for zone, zone_cmds in commands.COMMANDS.items():
        # For now, ISCP commands are always three characters, which
        # makes this easy.
        command, args = iscp_message[:3], iscp_message[3:]
        if command in zone_cmds:
            if args in zone_cmds[command]["values"]:
                if "," in zone_cmds[command]["values"][args]["name"]:
                    value = tuple(zone_cmds[command]["values"][args]["name"].split(","))
                else:
                    value = zone_cmds[command]["values"][args]["name"]

                return (
                    zone,
                    zone_cmds[command]["name"],
                    value
                )
            else:
                match = re.match("[+-]?[0-9a-f]+$", args, re.IGNORECASE)
                if match:
                    return zone, zone_cmds[command]["name"], int(args, 16)
                else:
                    if "," in args:
                        value = tuple(args.split(","))
                    else:
                        value = args

                    return zone, zone_cmds[command]["name"], value

    else:
        raise ValueError(
            "Cannot convert ISCP message to command: {}".format(iscp_message)
        )


# pylint: disable=too-many-instance-attributes, too-many-public-methods
class AVR(asyncio.Protocol):
    """The Anthem AVR IP control protocol handler."""

    def __init__(self,
        update_callback=None,
        connect_callback=None,
        loop=None,
        connection_lost_callback=None,
    ):
        """Protocol handler that handles all status and changes on AVR.

        This class is expected to be wrapped inside a Connection class object
        which will maintain the socket and handle auto-reconnects.

            :param update_callback:
                called if any state information changes in device (optional)
            :param connection_lost_callback:
                called when connection is lost to device (optional)
            :param loop:
                asyncio event loop (optional)

            :type update_callback:
                callable
            :type: connection_lost_callback:
                callable
            :type loop:
                asyncio.loop
        """
        self._loop = loop
        self.log = logging.getLogger(__name__)
        self._connection_lost_callback = connection_lost_callback
        self._update_callback = update_callback
        self._connect_callback = connect_callback
        self.buffer = b""
        self._input_names = {}
        self._input_numbers = {}
        self.transport = None

    def command(self, command, arguments=None, zone=None):
        """Issue a formatted command to the device.

        This function sends a message to the device without waiting for a response.

            :param command: Any command as documented in the readme
            :param arguments: The value to send with the command
            :param zone: One of dock, main, zone1, zone2, zone3, zone4
            :type command: str
            :type arguments: str
            :type zone: str

        :Example:

        >>> command(volume, 55, main)
        or
        >>> command(main.volume=55)
        """
        try:
            iscp_message = command_to_iscp(command, arguments, zone)
        except ValueError as error:
            self.log.error(f"Invalid message. {error}")
            return

        self.log.debug("> %s", command)
        try:
            self.transport.write(command_to_packet(iscp_message))
        except:
            self.log.warning("No transport found, unable to send command")

    #
    # asyncio network functions
    #

    def connection_made(self, transport):
        """Called when asyncio.Protocol establishes the network connection."""
        self.log.info("Connection established to AVR")
        self.transport = transport

        if self._connect_callback:
            self._loop.call_soon(self._connect_callback)

        # self.transport.set_write_buffer_limits(0)
        limit_low, limit_high = self.transport.get_write_buffer_limits()
        self.log.debug("Write buffer limits %d to %d", limit_low, limit_high)

    def data_received(self, data):
        """Called when asyncio.Protocol detects received data from network."""
        self.buffer += data
        self.log.debug("Received %d bytes from AVR: %s", len(self.buffer), self.buffer)
        self._assemble_buffer()

    def connection_lost(self, exc):
        """Called when asyncio.Protocol loses the network connection."""
        if exc is not None:
            self.log.warning("Lost connection to receiver: %s", exc)

        self.transport = None

        if self._connection_lost_callback:
            self._loop.call_soon(self._connection_lost_callback)

    def _assemble_buffer(self):
        """Data for a command may not arrive all in one go.
        First read the header to determin the total command size, then wait
        until we have that much data before decoding it.
        """
        self.transport.pause_reading()

        if len(self.buffer) >= 16:
            size = eISCPPacket.parse_header(self.buffer[:16]).data_size
            if len(self.buffer) - 16 >= size:
                data = self.buffer[16 : 16 + size]
                try:
                    message = iscp_to_command(ISCPMessage.parse(data.decode()))
                    if self._update_callback:
                        self._loop.call_soon(self._update_callback, message)
                except:
                    self.log.warning("Unable to parse recieved message: %s", data.decode('utf-8', 'backslashreplace').rstrip())

                self.buffer = self.buffer[16 + size :]  # shift data to start
                # If there is still data in the buffer,
                # don't wait for more, process it now!
                if len(self.buffer):
                    self._assemble_buffer()

        self.transport.resume_reading()
        return
