# -*- coding: utf-8 -*-
"""
    maxcul.messages
    ~~~~~~~~~~~~~~~~~~~~~~~

    Definition of known messages, based on IDs from FHEM plugin

    :copyright: (c) 2014 by Markus Ullmann.
    :license: BSD, see LICENSE for more details.
"""

# environment constants

# python imports
from datetime import datetime
import struct

# environment imports

# custom imports
from maxcul.exceptions import (
    MoritzError, LengthNotMatchingError,
    MissingPayloadParameterError, UnknownMessageError
)

# local constants
DEVICE_TYPES = {
    0: "Cube",
    1: "HeatingThermostat",
    2: "HeatingThermostatPlus",
    3: "WallMountedThermostat",
    4: "ShutterContact",
    5: "PushButton"
}
DEVICE_TYPES_BY_NAME = dict((v, k) for k, v in DEVICE_TYPES.items())

MODE_IDS = {
    0: "auto",
    1: "manual",
    2: "temporary",
    3: "boost",
}

SHUTTER_STATES = {
    0: "close",
    2: "open",
}

DECALC_DAYS = {
    "Sat" : 0,
    "Sun" : 1,
    "Mon" : 2,
    "Tue" : 3,
    "Wed" : 4,
    "Thu" : 5,
    "Fri" : 6
}

BOOST_DURATION = {
    0 : 0,
    5 : 1,
    10: 2,
    15: 3,
    20: 4,
    25: 5,
    30: 6,
    60: 7
}


class MoritzMessage(object):
    """Represents (de)coded message as seen on Moritz Wire"""

    def __init__(self):
        self.counter = 0
        self.flag = 0
        self.sender_id = 0
        self.receiver_id = 0
        self.group_id = 0
        self.payload = ""

    @property
    def decoded_payload(self):
        raise NotImplementedError()

    @property
    def is_broadcast(self):
        return self.receiver_id == 0

    @staticmethod
    def decode_message(input_string):
        """Decodes given message and returns content in matching message class"""

        if input_string.startswith("Zs"):
            # outgoing messages can be parsed too, just cut the Z off as it doesn't matter
            input_string = input_string[1:]

        # Split MAX message
        length = int(input_string[1:3], base=16)
        counter = int(input_string[3:5], base=16)
        flag = int(input_string[5:7], base=16)
        msgtype = int(input_string[7:9], base=16)
        sender_id = int(input_string[9:15], base=16)
        receiver_id = int(input_string[15:21], base=16)
        group_id = int(input_string[21:23], base=16)
        payload = input_string[23:]

        # Length: strlen(input_string) / 2 as HEX encoding, +3 for Z and length count
        if (len(input_string) - 3) != length * 2:
            raise LengthNotMatchingError(
                "Message length %i not matching indicated length %i" % ((len(input_string) - 3) / 2, length))

        try:
            message_class = MORITZ_MESSAGE_IDS[msgtype]
        except KeyError:
            raise UnknownMessageError("Unknown message with id %x found" % msgtype)

        message = message_class()
        message.counter = counter
        message.flag = flag
        message.group_id = group_id
        message.sender_id = sender_id
        message.receiver_id = receiver_id
        message.payload = payload

        return message

    def encode_message(self, payload={}):
        """Prepare message to be sent on wire"""

        msg_ids = dict((v, k) for k, v in MORITZ_MESSAGE_IDS.items())
        msg_id = msg_ids[self.__class__]

        message = ""
        if hasattr(self, 'encode_payload'):
            self.payload = self.encode_payload(payload)
        if hasattr(self, 'encode_flag'):
            self.flag = self.encode_flag()
        for (var, length) in (
                (self.counter, 2), (self.flag, 2), (msg_id, 2), (self.sender_id, 6), (self.receiver_id, 6),
                (self.group_id, 2)):
            content = "%X".upper() % var
            message += content.zfill(length)
        if self.payload:
            message += self.payload
        length = "%X".upper() % int(len(message) / 2)
        message = "Zs" + length.zfill(2) + message
        return message

    def __repr__(self):
        return "<%s counter:%x flag:%x sender:%x receiver:%x group:%x payload:%s>" % (
            self.__class__.__name__, self.counter, self.flag, self.sender_id, self.receiver_id, self.group_id,
            self.payload
        )


class PairPingMessage(MoritzMessage):
    """Thermostats send this request on long boost keypress"""

    @property
    def decoded_payload(self):
        firmware_version, device_type, selftest_result = struct.unpack(">bBB", bytearray.fromhex(self.payload[:6]))
        device_serial = bytearray.fromhex(self.payload[6:]).decode()
        result = {
            'firmware_version': "V%i.%i" % (firmware_version / 0x10, firmware_version % 0x10),
            'device_type': DEVICE_TYPES[device_type],
            'selftest_result': selftest_result,
            'device_serial': device_serial,
            'pairmode': 'pair' if self.is_broadcast else 're-pair'
        }
        return result


class PairPongMessage(MoritzMessage):
    """Awaited after PairPingMessage is sent by component"""

    @property
    def decoded_payload(self):
        return {'devicetype': DEVICE_TYPES[int(self.payload)]}


    def encode_payload(self, payload):
        return str(DEVICE_TYPES_BY_NAME[payload['devicetype']]).zfill(2)


class AckMessage(MoritzMessage):
    """Last command received and acknowledged.
	   Occasionally if the communication is ongoing, this might get lost.
	   So don't rely on it but check state afterwards instead"""

    @property
    def decoded_payload(self):
        result = {}
        if self.payload.startswith("01"):
            result["state"] = "ok"
        elif self.payload.startswith("81"):
            result["state"] = "invalid_command"
        if len(self.payload) == 8:
            # FIXME: temporarily accepting the fact that we only handle Thermostat results
            result.update(ThermostatStateMessage.decode_status(self.payload[2:]))
        return result

    def encode_flag(self):
        return 0x4 if self.group_id else 0x0

    def encode_payload(self, payload=None):
        return payload


class TimeInformationMessage(MoritzMessage):
    """Current time is either requested or encoded. Request simply is empty payload"""

    @property
    def decoded_payload(self):
        (years_since_200, day, hour, month_minute, month_sec) = struct.unpack(">BBBBB",
                                                                              bytearray.fromhex(self.payload[:12]))
        return datetime(
            year=years_since_200 + 2000,
            minute=month_minute & 0x3F,
            month=((month_minute >> 4) & 0x0C) | ((month_sec >> 6) & 0x03),
            day=day,
            hour=hour,
            second=month_sec & 0x3F
        )

    def encode_flag(self):
        return 0x0A if not self.payload else 0x04

    def encode_payload(self, payload=None):
        # may contain empty payload to ask for timeinformation
        if payload is None:
            return ""
        encoded_payload = str("%X" % (payload.year - 2000)).zfill(2)
        encoded_payload += str("%X" % payload.day).zfill(2)
        encoded_payload += str("%X" % payload.hour).zfill(2)
        encoded_payload += str("%X" % (payload.minute | ((payload.month & 0x0C) << 4))).zfill(2)
        encoded_payload += str("%X" % (payload.second | ((payload.month & 0x03) << 6))).zfill(2)
        return encoded_payload


class ConfigWeekProfileMessage(MoritzMessage):
    pass


class ConfigTemperaturesMessage(MoritzMessage):
    """Sets temperatur config"""

    @property
    def decoded_payload(self):
        pass

    def encode_flag(self):
        return 0x4 if self.group_id else 0x0

    def encode_payload(self, payload):
        if "comfort_Temperature" not in payload:
            raise MissingPayloadParameterError("Missing comfort_Temperature in payload")
        if "eco_Temperature" not in payload:
            raise MissingPayloadParameterError("Missing eco_Temperature in payload")
        if "max_Temperature" not in payload:
            raise MissingPayloadParameterError("Missing max_Temperature in payload")
        if "min_Temperature" not in payload:
            raise MissingPayloadParameterError("Missing min_Temperature in payload")
        if "measurement_Offset" not in payload:
            raise MissingPayloadParameterError("Missing measurement_Offset in payload")
        if "window_Open_Temperatur" not in payload:
            raise MissingPayloadParameterError("Missing window_Open_Temperatur in payload")
        if "window_Open_Duration" not in payload:
            raise MissingPayloadParameterError("Missing window_Open_Duration in payload")

        comfort_Temperature = "%0.2X" % int(payload['comfort_Temperature']*2)
        eco_Temperature = "%0.2X" % int(payload['eco_Temperature']*2)
        max_Temperature = "%0.2X" % int(payload['max_Temperature']*2)
        min_Temperature = "%0.2X" % int(payload['min_Temperature']*2)
        measurement_Offset = "%0.2X" % int((payload['measurement_Offset'] + 3.5)*2)
        window_Open_Temperatur = "%0.2X" % int(payload['window_Open_Temperatur']*2)
        window_Open_Duration = "%0.2X" % int(payload['window_Open_Duration']/5)

        content = comfort_Temperature + eco_Temperature + max_Temperature + min_Temperature + measurement_Offset + window_Open_Temperatur +window_Open_Duration
        return content



class ConfigValveMessage(MoritzMessage):
    """Sets valve config"""

    @property
    def decoded_payload(self):
        pass

    def encode_flag(self):
        return 0x4 if self.group_id else 0x0

    def encode_payload(self, payload):
        if "boost_duration" not in payload:
            raise MissingPayloadParameterError("Missing boost duration in payload")
        if "boost_valve_position" not in payload:
            raise MissingPayloadParameterError("Missing boost valve position in payload")
        if "decalc_day" not in payload:
            raise MissingPayloadParameterError("Missing decalc day in payload")
        if "decalc_hour" not in payload:
            raise MissingPayloadParameterError("Missing decalc hour in payload")
        if "max_valve_position" not in payload:
            raise MissingPayloadParameterError("Missing max valve position in payload")
        if "valve_offset" not in payload:
            raise MissingPayloadParameterError("Missing valve offset in payload")

        boost = "%0.2X" % ((BOOST_DURATION[payload["boost_duration"]] << 5) | int(payload["boost_valve_position"]/5))
        decalc = "%0.2X" % ((DECALC_DAYS[payload["decalc_day"]] << 5) | payload["decalc_hour"])
        max_valve_position = "%0.2X" % int(payload["max_valve_position"]*255/100)
        valve_offset = "%0.2X" % int(payload["valve_offset"]*255/100)
        content = boost + decalc + max_valve_position + valve_offset
        return content


class AddLinkPartnerMessage(MoritzMessage):

    @property
    def decoded_payload(self):
        pass

    def encode_flag(self):
        return 0x4 if self.group_id else 0x0

    def encode_payload(self, payload):
        if "assocDevice" not in payload:
            raise MissingPayloadParameterError("Missing assocDevice in payload")
        if "assocDeviceType" not in payload:
            raise MissingPayloadParameterError("Missing assocDeviceType in payload")

        assocDevice = "%0.6X" % int(payload["assocDevice"])
        assocDeviceType = "%0.2X" % list(DEVICE_TYPES.keys())[list(DEVICE_TYPES.values()).index(payload["assocDeviceType"])]
        return assocDevice + assocDeviceType

class RemoveLinkPartnerMessage(MoritzMessage):

    @property
    def decoded_payload(self):
        pass

    def encode_flag(self):
        return 0x4 if self.group_id else 0x0

    def encode_payload(self, payload):
        if "assocDevice" not in payload:
            raise MissingPayloadParameterError("Missing assocDevice in payload")
        if "assocDeviceType" not in payload:
            raise MissingPayloadParameterError("Missing assocDeviceType in payload")

        assocDevice = payload["assocDevice"]
        assocDeviceType = "%0.2X" % DEVICE_TYPES[payload["assocDeviceType"]]
        return assocDevice + assocDeviceType


class SetGroupIdMessage(MoritzMessage):

    @property
    def decoded_payload(self):
        pass

    def encode_flag(self):
        return 0x4 if self.group_id else 0x0

    def encode_payload(self, payload):
        if "group_id" not in payload:
            raise MissingPayloadParameterError("Missing group id in payload")

        groupId = "%0.2X" % payload["group_id"]
        return groupId



class RemoveGroupIdMessage(MoritzMessage):

    @property
    def decoded_payload(self):
        pass

    def encode_flag(self):
        return 0x4 if self.group_id else 0x0

    def encode_payload(self, payload):
        overrideGroupId = "00"
        return overrideGroupId



class ShutterContactStateMessage(MoritzMessage):
    """Non-reculary sent by Thermostats to report when valve was moved or command received."""

    @staticmethod
    def decode_status(payload):
        status_bits = bin(int(payload, 16))[2:].zfill(8)
        state = int(status_bits[6:],2)
        unkbits = int(status_bits[2:6],2)
        rferror = int(status_bits[1],2)
        battery_low = int(status_bits[0],2)
        result = {
            "state": SHUTTER_STATES[state],
            "unkbits": unkbits,
            "rferror": bool(rferror),
            "battery_low": bool(battery_low)
        }
        return result

    @property
    def decoded_payload(self):
        result = ShutterContactStateMessage.decode_status(self.payload)
        return result

class SetTemperatureMessage(MoritzMessage):
    """Sets temperature for manual mode as well as mode switch between manual, auto and boost"""

    @property
    def decoded_payload(self):
        payload = struct.unpack(">B", bytearray.fromhex(self.payload[0:4]))
        return {
            'desired_temperature': ((payload[0] & 0x3F) / 2.0),
            'mode': MODE_IDS[payload[0] >> 6]
        }

    def encode_flag(self):
        return 0x4 if self.group_id else 0x0

    def encode_payload(self, payload):
        if "desired_temperature" not in payload:
            raise MissingPayloadParameterError("Missing desired_temperature in payload")
        if "mode" not in payload:
            raise MissingPayloadParameterError("Missing mode in payload")

        if payload['desired_temperature'] > 30.5:
            desired_temperature = 30.5  # "ON"
        elif payload['desired_temperature'] < 4.5:
            desired_temperature = 4.5  # "OFF"
        else:
            # always round to nearest 0.5 first
            desired_temperature = round(payload['desired_temperature'] * 2) / 2.0
        int_temperature = int(desired_temperature * 2)

        modes = dict((v, k) for (k, v) in MODE_IDS.items())
        mode = modes[payload['mode']]

        # TODO: you can add a until time for chort changes
        # from fhem
        # $until = sprintf("%06x",MAX_DateTime2Internal($args[2]." ".$args[3]));
        # $payload .= $until if(defined($until));
        content = "%X".upper() % ((mode << 6) | int_temperature)
        return content.zfill(2)


class WallThermostatControlMessage(MoritzMessage):

    @staticmethod
    def decode_status(payload):
        rawTempratures = bin(int(payload, 16))[2:].zfill(16)

        result = {
            "desired_temprature": int(rawTempratures[1:8], 2)/2,
            "temprature": ((int(rawTempratures[0], 2) << 8) + int(rawTempratures[8:], 2))/10
        }
        return result

    @property
    def decoded_payload(self):
        result = WallThermostatControlMessage.decode_status(self.payload)
        return result


class SetComfortTemperatureMessage(MoritzMessage):
    pass


class SetEcoTemperatureMessage(MoritzMessage):
    pass


class PushButtonStateMessage(MoritzMessage):
    pass


class ThermostatStateMessage(MoritzMessage):
    """Non-reculary sent by Thermostats to report when valve was moved or command received."""

    @staticmethod
    def decode_status(payload):
        status_bits, valve_position, desired_temperature = struct.unpack(">bBB", bytearray.fromhex(payload[0:6]))
        mode = status_bits & 0x3
        dstsetting = status_bits & 0x04
        langateway = status_bits & 0x08
        status_bits = status_bits >> 9
        is_locked = status_bits & 0x1
        rferror = status_bits & 0x2
        battery_low = status_bits & 0x4
        desired_temperature = (desired_temperature & 0x7F) / 2.0
        result = {
            "mode": MODE_IDS[mode],
            "dstsetting": bool(dstsetting),
            "langateway": bool(langateway),
            "is_locked": bool(is_locked),
            "rferror": bool(rferror),
            "battery_low": bool(battery_low),
            "desired_temperature": desired_temperature,
            "valve_position": valve_position,
        }
        return result

    @property
    def decoded_payload(self):
        result = ThermostatStateMessage.decode_status(self.payload)
        if len(self.payload) > 6:
            pending_payload = bytearray.fromhex(self.payload[6:])
            if len(pending_payload) == 3:
                # TODO handle date string
                pass
            elif len(pending_payload) == 2 and result['mode'] != 'temporary':
                result["measured_temperature"] = (((pending_payload[0] & 0x1) << 8) + pending_payload[1]) / 10.0
            else:
                # unknown....
                pass
        return result


class WallThermostatStateMessage(MoritzMessage):
    """Non-reculary sent by Thermostats to report when valve was moved or command received."""


    @staticmethod
    def decode_status(payload):
        status_bits = bin(int(payload[:2], 16))[2:].zfill(8)
        mode = int(status_bits[:2], 2)
        dstsetting = int(status_bits[2:3], 2)
        langateway = int(status_bits[3:4], 2)
        is_locked = int(status_bits[4:5], 2)
        rferror = int(status_bits[5:6], 2)
        battery_low = int(status_bits[6:7], 2)
        display_actual_temperature = bool(int(payload[2:4], 16))
        desired_temperature_raw = bin(int(payload[4:6], 16))[2:].zfill(8)
        desired_temperature = int(desired_temperature_raw[1:8], 2)/2
        heater_temperature = ""

        null1 = False
        if len(payload) > 6:
            null1 = payload[6:8]

        if len(payload) > 8:
            heater_temperature = payload[8:10]

        null2 = False
        if len(payload) > 10:
            null2 = payload[10:12]

        if len(payload) > 12:
            temperature = ((int(desired_temperature_raw[0], 2) << 8) + int(payload[12:], 16)) / 10

        until_str = ""
        if null1 and null2:
            until_str = parseDateTime(null1, heater_temperature, null2)
        else:
            temperature = int(heater_temperature, 16)/10

        result = {
            "mode": MODE_IDS[mode],
            "dstsetting": bool(dstsetting),
            "langateway": bool(langateway),
            "is_locked": bool(is_locked),
            "rferror": bool(rferror),
            "battery_low": bool(battery_low),
            "desired_temperature": desired_temperature,
            "display_actual_temperature": display_actual_temperature,
            "temperature": temperature,
            "until_str": until_str
        }
        return result

    @property
    def decoded_payload(self):
        result = WallThermostatStateMessage.decode_status(self.payload)
        return result


def parseDateTime(byte1, byte2, byte3):
    day = int(byte1, 16) & 0x1F
    month = ((int(byte1, 16) & 0xE0) >> 4) | (int(byte2, 16) >> 7)
    year = int(byte2, 16) & 0x3F
    time = int(byte3, 16) & 0x3F
    if time%2:
        time = int(time/2) + ':30'
    else:
        time = int(time/2) + ":00"

    return {
        "day": day,
        "month": month,
        "year": year,
        "time": time
    }


class SetDisplayActualTemperatureMessage(MoritzMessage):
    pass


class WakeUpMessage(MoritzMessage):
    pass


class ResetMessage(MoritzMessage):
    """Perform a factory reset on given device"""

    pass


# Define at bottom so we can use the class types right away
# Based on FHEM CUL_MAX module
MORITZ_MESSAGE_IDS = {
    0x00: PairPingMessage,
    0x01: PairPongMessage,
    0x02: AckMessage,
    0x03: TimeInformationMessage,

    0x10: ConfigWeekProfileMessage,
    0x11: ConfigTemperaturesMessage,
    0x12: ConfigValveMessage,

    0x20: AddLinkPartnerMessage,
    0x21: RemoveLinkPartnerMessage,
    0x22: SetGroupIdMessage,
    0x23: RemoveGroupIdMessage,

    0x30: ShutterContactStateMessage,

    0x40: SetTemperatureMessage,
    0x42: WallThermostatControlMessage,
    0x43: SetComfortTemperatureMessage,
    0x44: SetEcoTemperatureMessage,

    0x50: PushButtonStateMessage,

    0x60: ThermostatStateMessage,

    0x70: WallThermostatStateMessage,

    0x82: SetDisplayActualTemperatureMessage,

    0xF1: WakeUpMessage,
    0xF0: ResetMessage,
}
