#!/usr/bin/env python
# -*- coding: utf-8 -*-
#
# Copyright 2023 Vogel's Products
#
# This file is part of python-MotionMount
#
# SPDX-License-Identifier:    MIT
#

import collections

import asyncio
import struct
from typing import Optional, Callable, Deque, Any, Union, List
from enum import Enum, IntEnum, IntFlag, auto


class MotionMountResponse(IntEnum):
    """
    Enum representing possible response codes from MotionMount.
    These are derived from HTTP response code and have a corresponding meaning.
    """
    Unknown = 0,
    Accepted = 202,
    BadRequest = 400,
    Unauthorised = 401,
    Forbidden = 403,
    NotFound = 404,
    MethodNotAllowed = 405,
    URITooLong = 414,


class MotionMountValueType(Enum):
    """
    Enum representing possible value types for MotionMount requests.
    """
    Integer = 0,
    String = 1,
    Bytes = 2,
    Bool = 3,
    IPv4 = 4,
    Void = 5,


class MotionMountSystemError(IntFlag):
    """
    Enum representing the (aggregated) system errors of the MotionMount.
    """
    MotorError = auto()
    HDMICECError = auto()
    ObstructionDetected = auto()
    TVWidthConstraintError = auto()
    InternalError = auto()

class MotionMountError(Exception):
    """
    Base exception class for MotionMount errors.
    """
    pass


class NotConnectedError(MotionMountError):
    """
    Exception raised when not connected to MotionMount.
    """
    def __str__(self):
        return "Not connected to MotionMount"


class MotionMountResponseError(MotionMountError):
    """
    Exception raised for MotionMount response errors.

    Attributes:
        response_value (MotionMountResponse): The response code received.
    """
    def __init__(self, value: MotionMountResponse):
        self.response_value = value


class Request:
    """
    Represents a request to be sent to the MotionMount.

    Args:
        key (str): The key for the request.
        value_type (MotionMountValueType): The value type for the request.
        value (Optional[str]): The optional value for the request.

    Attributes:
        key (str): The key for the request.
        value_type (MotionMountValueType): The value type for the request.
        value (Optional[str]): The optional value for the request.
        future (asyncio.Future): The asyncio Future associated with the request.
    """
    def __init__(self, key: str, value_type: MotionMountValueType, value: Optional[str] = None):
        self.key = key
        self.value = value
        self.value_type = value_type

        event_loop = asyncio.get_event_loop()
        self.future = event_loop.create_future()

    def encoded(self) -> bytes:
        """
        Encodes this request for sending over the network.

        Returns:
            bytes: The encoded request.
        """
        if self.value is None:
            return f"{self.key}\n".encode()
        else:
            return f"{self.key} = {self.value}\n".encode()


def _convert_value(value, value_type: MotionMountValueType):
    """
    Convert a value to the specified MotionMount value type.

    Args:
        value: The value to convert.
        value_type (MotionMountValueType): The target value type.

    Returns:
        Any: The converted value.
    """
    if value_type == MotionMountValueType.Integer:
        return int(value)
    elif value_type == MotionMountValueType.String:
        return value.strip("\"")
    elif value_type == MotionMountValueType.Bytes:
        return bytes.fromhex(value.strip("[]"))
    elif value_type == MotionMountValueType.Bool:
        return bool(int(value))
    elif value_type == MotionMountValueType.Void:
        return value
    else:
        raise ValueError("Unknown value type")

class Preset:
    """Class for storing preset related data"""
    index: int
    name: str
    extension: int
    turn: int

    def __init__(self, index, name, extension, turn):
        self.index = index
        self.name = name
        self.extension = extension
        self.turn = turn

class MotionMount:
    """
    Class to represent a MotionMount.

    You can create one with the IP address and port number which should be used to connect.

    After creation, you should call `connect` first, before accessing other properties.

    When you're done, call `disconnect` to clean up resources.

    Args:
        address (str): The IP address of the MotionMount.
        port (int): The port number to use for the connection.
        notification_callback: Will be called when a notification has been received.
    """
    def __init__(self, address: str, port: int):
        self.address = address
        self.port = port
        
        self._callbacks: list[Callable[[], None]] = []

        self._requests: Deque['Request'] = collections.deque()

        self._writer: Optional[asyncio.StreamWriter] = None
        self._reader_task: Optional[asyncio.Task[Any]] = None

        self._mac = b'\x00\x00\x00\x00\x00\x00'
        self._name = None

        self._extension = None
        self._turn = None
        self._is_moving = False
        self._target_extension = None
        self._target_turn = None
        self._error_status = None
        self._authentication_status = 0x0

    @property
    def mac(self) -> bytes:
        """Returns the (primary) mac address"""
        return self._mac
        
    @property
    def name(self) -> Optional[str]:
        """Returns the name"""
        return self._name

    @property
    def extension(self) -> Optional[int]:
        """The current extension of the MotionMount, normally between 0 - 100
        but slight excursions can occur due to calibration errors, mechanical play and round-off errors"""
        return self._extension

    @property
    def turn(self) -> Optional[int]:
        """The current rotation of the MotionMount, normally between -100 - 100
        but slight excursions can occur due to calibration errors, mechanical play and round-off errors"""
        return self._turn

    @property
    def is_moving(self) -> Optional[bool]:
        """When true the MotionMount is (electrically) moving to another position"""
        return self._is_moving

    @property
    def target_extension(self) -> Optional[int]:
        """The most recent extension the MotionMount tried to move to"""
        return self._target_extension

    @property
    def target_turn(self) -> Optional[int]:
        """The most recent turn the MotionMount tried to move to"""
        return self._target_turn

    @property
    def error_status(self) -> Optional[int]:
        """The error status of the MotionMount.
        See the protocol documentation for details.
        It's recommended to use the `system_status` property for pre-parsed status
        information"""
        return self._error_status

    @property
    def system_status(self) -> MotionMountSystemError:
        """The status of the MotionMount.
        These flags are aggregated errors from the underlying detailed errors."""
        errors = ((self.error_status or 0) >> 16) & 0x7fff # We're only interested in the active errors
        status = MotionMountSystemError(0)

        if errors == 0:
            # We don't have new-style errors, so decode old-style
            error_status = (self.error_status or 0)

            if error_status & (1 << 31):
                if error_status & (1 << 10):
                    status |= MotionMountSystemError.MotorError
                elif error_status & (1 << 4):
                    status |= MotionMountSystemError.HDMICECError
                else:
                    status |= MotionMountSystemError.InternalError
        else:
            if errors & (1 << 10):
                status |= MotionMountSystemError.MotorError
            if errors & (1 << 4):
                status |= MotionMountSystemError.HDMICECError
            if errors & (1 << 11):
                status |= MotionMountSystemError.ObstructionDetected
            if errors & (1 << 12):
                status |= MotionMountSystemError.TVWidthConstraintError
            if errors & (0b1111100000):
                status |= MotionMountSystemError.InternalError

        return status

    @property
    def is_authenticated(self) -> bool:
        """Indicates whether we're authenticated to the MotionMount (or no
        authentication is needed)."""
        return self._authentication_status & 0x80 == 0x80

    @property
    def can_authenticate(self) -> Union[bool, int]:
        """Indicates whether we can authenticate.
        When there are too many failed authentication attempts the MotionMount enforces
        a backoff time.
        This propperty either returns `True` if authentication is possible or the (last
        known) backoff time."""
        if self.is_authenticated or self._authentication_status <= 3:
            return True
        else:
            return (self._authentication_status-3) * 3

    async def connect(self) -> None:
        """
        Connect to the MotionMount.

        Properties that are updated by notifications are pre-fetched
        """
        connection_future = asyncio.open_connection(self.address, self.port)
        reader, writer = await asyncio.wait_for(connection_future, timeout=15)

        self._writer = writer
        self._reader_task = asyncio.create_task(self._reader(reader))

        try:
            await self._update_mac()
        except MotionMountResponseError as e:
            # We're fine with a #404, as older firmware doesn't support the mac property
            if e.response_value != MotionMountResponse.NotFound:
                raise
        await self.update_name()
        await self.update_position()
        await self.update_error_status()
        await self.update_authentication_status()

    async def disconnect(self) -> None:
        """
        Disconnect from the MotionMount.
        """
        writer = self._writer

        # Close the stream
        if self._reader_task is not None:
            self._reader_task.cancel()
        if self._writer is not None:
            self._writer.close()

        # Cancel all waiting requests
        for request in self._requests:
            request.future.cancel()

        # Clean up our state
        self._writer = None
        self._reader_task = None
        self._requests.clear()

        # Let listeners know that we've a changed state (disconnected)
        for callback in self._callbacks:
            callback()

        # Wait for the stream to really close
        if writer is not None:
            try:
                await writer.wait_closed()
            except:
                pass # We're not interested in exceptions

    def add_listener(self, callback: Callable[[], None]) -> None:
        """Register callback as a listener for updates."""
        self._callbacks.append(callback)
        
    def remove_listener(self, callback: Callable[[], None]) -> None:
        self._callbacks.remove(callback)
        
    @property
    def is_connected(self) -> bool:
        return self._writer is not None

    async def _update_mac(self):
        """Update the mac address"""
        await self._request(Request("mac", MotionMountValueType.Void))

    async def update_name(self):
        """Update the name of the MotionMount."""
        await self._request(Request("configuration/name", MotionMountValueType.Void))

    async def update_position(self):
        """
        Fetch the current position of the MotionMount.
        """
        # We mark the value types as Void, as we've no further interest in the actual value
        # We just want to trigger the notification logic
        await self._request(Request("mount/extension/current", MotionMountValueType.Void))
        await self._request(Request("mount/turn/current", MotionMountValueType.Void))

    async def update_error_status(self):
        """Fetch the error status from the MotionMount"""
        # We mark the value types as Void, as we've no further interest in the actual value
        # We just want to trigger the notification logic
        await self._request(Request("mount/errorStatus", MotionMountValueType.Void))

    async def update_authentication_status(self):
        """Fetch authentication status from the MotionMount."""
        # We mark the value types as Void, as we've no further interest in the actual value
        # We just want to trigger the notification logic
        await self._request(Request("configuration/authentication/status", MotionMountValueType.Void))

    async def get_presets(self) -> List[Preset]:
        """Gets the valid user presets from the device."""
        presets = []

        for i in range(1,8):
            valid = await self._request(Request(f"mount/preset/{i}/active", MotionMountValueType.Bool))

            if valid:
                name = await self._request(Request(f"mount/preset/{i}/name", MotionMountValueType.String))
                extension = await self._request(Request(f"mount/preset/{i}/extension", MotionMountValueType.Integer))
                turn = await self._request(Request(f"mount/preset/{i}/turn", MotionMountValueType.Integer))

                preset = Preset(i, name, extension, turn)

                presets.append(preset)

        return presets
        
    async def go_to_preset(self, position: int):
        """
        Go to a preset position.
        Preset 0 is the (fixed) Wall position.

        Args:
            position (int): The preset position to go to (0 - 7).

        Raises:
            ValueError: If the position is out of range.
        """
        if position < 0 or position > 7:
            raise ValueError("position must be in the range [0...7]")

        await self._request(Request(f"mount/preset/index = {position}", MotionMountValueType.Void))

    async def go_to_position(self, extension: int, turn: int):
        """
        Go to a specific position.

        Args:
            extension (int): The extension value (0 - 100)
            turn (int): The turn value. (-100 - 100)

        Raises:
            ValueError: If the extension or turn values are out of range.
        """
        if extension < 0 or extension > 100:
            raise ValueError("extension must be in the range [0...100]")
        if turn < -100 or turn > 100:
            raise ValueError("turn must be in the range [-100...100]")

        value_bytes = struct.pack('>Hh', extension, turn)
        await self._request(Request(f"mount/preset/position = [{value_bytes.hex()}]", MotionMountValueType.Void))

    async def set_extension(self, extension: int):
        """
        Set the extension value.

        Args:
            extension (int): The extension value (0 - 100).

        Raises:
            ValueError: If the extension value is out of range.
        """
        if extension < 0 or extension > 100:
            raise ValueError("extension must be in the range [0...100]")
        await self._request(Request(f"mount/extension/target = {extension}", MotionMountValueType.Void))

    async def set_turn(self, turn: int):
        """
        Set the turn value.

        Args:
            turn (int): The turn value (-100 - 100).

        Raises:
            ValueError: If the turn value is out of range.
        """
        if turn < -100 or turn > 100:
            raise ValueError("turn must be in the range [-100...100]")
        await self._request(Request(f"mount/turn/target = {turn}", MotionMountValueType.Void))

    async def authenticate(self, pin: int):
        """
        Provide a pin to authenticate.

        Args:
            pin (int): The pin code for the 'User' level to authenticate with (1-9999)

        Raises:
            ValueError: If the pin code is outside the range.
        """
        if pin < 1 or pin > 9999:
            raise ValueError("pin must be in the range [1...9999]")
        await self._request(Request(f"configuration/authentication/pin = {pin}", MotionMountValueType.Void))
        await self.update_authentication_status()

    async def _request(self, request: Request):
        """
        Enqueues a request, waits for possible earlier requests, and then waits for the request to finish.

        Args:
            request (Request): The request to send.

        Returns:
            Any: The response value.
        """
        # Ignore requests when we're not connected
        if self._writer is None:
            raise NotConnectedError

        try:
            # Check a possible previous request and wait for it to finish
            previous_request = None
            if len(self._requests) > 0:
                previous_request = self._requests[len(self._requests) - 1]

            # Add ourselves to the queue
            self._requests.append(request)

            # Wait for the previous request
            if previous_request is not None:
                await previous_request.future

            # We're ready to go!
            self._writer.write(request.encoded())
            await self._writer.drain()

            # Wait for our request to finish
            value_any = await asyncio.wait_for(request.future, timeout=5.0)
            value = _convert_value(value_any, request.value_type)
            return value
        except MotionMountResponseError:
            pass
        except:
            # Make sure we disconnect when there was a failure
            await self.disconnect()
            raise

    def _update_properties(self, key: str, value: str):
        """
        Update internal properties based on key-value pairs received from MotionMount.

        Args:
            key (str): The key from MotionMount.
            value (str): The corresponding value.
        """
        if key == "mount/extension/current":
            self._extension = _convert_value(value, MotionMountValueType.Integer)
        elif key == "mount/turn/current":
            self._turn = _convert_value(value, MotionMountValueType.Integer)
        elif key == "mount/isMoving":
            self._is_moving = _convert_value(value, MotionMountValueType.Bool)
        elif key == "mount/extension/target":
            self._target_extension = _convert_value(value, MotionMountValueType.Integer)
        elif key == "mount/turn/target":
            self._target_turn = _convert_value(value, MotionMountValueType.Integer)
        elif key == "mount/errorStatus":
            self._error_status = _convert_value(value, MotionMountValueType.Integer)
        elif key == "configuration/authentication/status":
            self._authentication_status = _convert_value(value, MotionMountValueType.Bytes)[0]
        elif key == "mac":
            self._mac = _convert_value(value, MotionMountValueType.Bytes)
        elif key == "configuration/name":
            self._name = _convert_value(value, MotionMountValueType.String)

    async def _reader(self, reader: asyncio.StreamReader) -> None:
        """
        Infinite loop to receive data from the MotionMount and dispatch it to waiting requests.

        Args:
            reader (asyncio.StreamReader): The stream reader for receiving data.
        """
        while not reader.at_eof():
            data = await reader.readline()

            if len(data) == 0:
                # Connection was closed
                if len(self._requests) > 0:
                    # There is a request waiting, we will let that request know about the error
                    popped = self._requests.popleft()
                    popped.future.set_exception(NotConnectedError)

                await self.disconnect()
                break

            # Check to see what kind of response this is
            response = data.decode().strip()
            if response[0] == "#":
                try:
                    response_value = MotionMountResponse(int(response[1:]))
                except ValueError:
                    response_value = MotionMountResponse.Unknown

                try:
                    request = self._requests.popleft()

                    if response_value == MotionMountResponse.Accepted:
                        request.future.set_result(True)
                    else:
                        request.future.set_exception(MotionMountResponseError(response_value))
                except IndexError:
                    # No request was waiting, only log this error
                    print(f"Error code: {response}")
            else:
                parts = response.split("=", 1)
                key = parts[0].strip()
                value = parts[1].strip()

                self._update_properties(key, value)

                if len(self._requests) > 0 and self._requests[0].key == key:
                    # We received the response to this request, we can pop it
                    popped = self._requests.popleft()
                    popped.future.set_result(value)
                else:
                    for callback in self._callbacks:
                        try:
                            callback()
                        except Exception as e:
                            # TODO: How to properly let the caller know something went wrong?
                            print(f"Exception during notification: {e}")
