"""
Name: sign_manager
Title: Sign manager console
Author: Cooper
Date: 30/04/2020

Desc:  This is the script to be used when the console is driving the sign.  When the onion is placed in a sign, it should
use sign_task

This module should handle the following:
- Polling sign status
- Polling sign extended status
- Sign brightness parameters
- Rendering of sign data
- Sending out sign data

It should have absolute control of the secondary serial port.
There exists a bug in the sign code where if you squirt too much data at them, they crash!

This module is based on the signDiscovery done for Abu Dhabi:
With this method, the console would continually polls all possible addresses and every time it sees a new or different
sign at an address it records this in its non-volatile sign-fitted table.  This sign table is erased when a new database
is loaded.

The eric.bin stores the sign dimensions as well, if the detected sign is different to whats in the eric.bin, then the
detected size will take precedence.  If there is a sign defined in the eric.bin but is not detected, then it will just
send the message straight out as is.

"""
import re
import os
import time
import json
import serial

from hanip.debug import print_text

from hanip.onionip import hcp
from hanip.onionip import renderbox
from hanip.onionip import hwDetermine
from hanip.onionip.sign import template_generator

class SignManagerConsole(object):
    def __init__(self, config_dict, data_dir):
        self.config_dict = config_dict
        self.comport = self.config_dict.get("SERIAL_rs485_comport", "/dev/ttyS2")
        self.baud = self.config_dict.get("SERIAL_baudrate_rs485", 38400)
        self.serial_open = False
        self.data_dir = data_dir
        self.sign_table_dir = self.config_dict.get("CONSOLE_sign_table_path", "/tmp/sign_table.json")

        if "oniondebug_sm" in os.listdir("/tmp"):
            self.debug = True
        else:
            self.debug = False

        self.number_of_addresses = 15           #Range of addresses from 0 to F
        self.sign_dictionary = {}
        self.sign_status = {}
        self.sign_fault = False
        self.reset_sign_table = False
        self.database_fitted_sizes = None      #These are the sign resolutions in the eric.bin
        self.renderbox_enable = self.config_dict.get("RENDERBOX_enable", False)

        self.test_mode = False
        self.new_data_flag = False
        self.new_data = False
        self.sign_data = None      #Should be provided as a dict
        self.page_timer = None      #Used for timing between pages will be left unimplemented for now
        self.hmf6_route_number = None
        self.adhoc_message = {
            "enable": False,
            "active": False,
            "signs": [],
            "message": "",
            "brightness": 99
        }

        self.ext_status_poll_interval = 30
        self.display_task_interval = 2
        self.sign_delay_interval = 0.2       #This is the interval between each sign loop so we don't bombard signs

        self.hcp = hcp.HCP()
        self.rb = renderbox.RenderBox(self.config_dict)
        self.tg = template_generator.TemplateGenerator(self.config_dict, self.data_dir)
        self.hwd = hwDetermine.HardwareDeterminer("", 0, 0, "")

    """
    ###################################################################################################################
    Serial Port
    """

    def init_serial(self):
        """
        Initialises the serial port and sets the serial_open flag to True
        """
        timeout = 0.2 if int(self.baud) < 9601 else 0.1
        if self.debug:
            print("SM: Using rx timeout %s" % timeout)

        try:
            self.ser = serial.Serial(self.comport, self.baud, 8, "N", stopbits=1, timeout=timeout)
        except Exception as e:
            print(e)
        else:
            self.serial_open = True

    def close_serial(self):
        """
        Closes the serial port and sets the serial_open flag to False
        """
        if self.serial_open:
            self.ser.close()
            self.serial_open = False
        else:
            pass

    def transmit_message(self, message, numofbytes):
        """
        Encodes the message into a HCP packet before sending over the serial port.  If there is a reply then it is
        obtained here due to the speed of the replies
        :param message: Message to be encoded into HCP and sent
        :param numofbytes: Number of bytes to read on the serial port, if zero then it is skipped
        :return: If there are bytes to read, return those, otherwise returns an empty string
        """
        if self.serial_open:
            sanitised_msg = self.sanitise_message_for_transmission(message)

            if sanitised_msg == None:
                message = message
            else:
                message = sanitised_msg

            if message[0] == "\x02":
                encodedMsg = message
            else:
                encodedMsg = self.hcp.encodeMaster(message)

            if self.debug:
                print("SM: Transmitting data")
                print(print_text.PrintText.to_ascii(encodedMsg))

            self.ser.write(encodedMsg.encode("latin-1"))

            if numofbytes > 0:
                reply = self.ser.read(numofbytes)
                if self.debug:
                    print(reply)
                if reply == None:
                    return ""
                else:
                    return reply.decode("latin-1")
            else:
                return ""

    def sanitise_message_for_transmission(self, message):
        """
        Basically if renderbox isnt working for whatever reason, the sign data remains as is and this sign data may contain
        UTF-8 characters.  Generally this isnt an issue as most messages fit in the range <0x7F but yeah.

        The ideal thing would be to show something to indicate there is an issue and not just a blank sign.

        Pythons unicode decoder/encoders have an error handling built in so we can just replace things with ???

        This method assumes that incomming strings are strings in UTF-8.  They must be either complete sign messages with
        HCP framing, or just the sign message without the HCP control chars and check but must have the command and address
        """

        #TODO Test this further it doesnt seem to work...
        try:
            message.encode("latin-1")
        except UnicodeEncodeError:
            #Okay so if there is an issue, we need to modify the message and that also means going through all the HCP
            #shit as well.

            if message[0] == "\x02":
                stripped_msg = message[1:-3] #We want to strip off the HCP control chars and checksum
            else:
                stripped_msg = message

            msg = stripped_msg.encode("latin-1", "replace").decode("latin-1")
            return msg

        else:
            return None

    """
    ###################################################################################################################
    Status Polling
    """
    def get_panel_config(self, address):
        """
        Obtains the colour panel configuration of a sign, currently tested on COLED signs
        [Insert typical reply here]
        :param address: Address of the sign to poll
        :return: A tuple containing the panel size and handedness, both values can be None of the data cannot be
        obtained
        """
        reply = self.transmit_message("9%sCO?" % address, 100)
        reply = reply[3:-3]

        colour_panel_dict = self.hwd.parse_colour_panel_config(reply)
        return colour_panel_dict

    def get_status(self, address):
        """
        Obtains the short status of a sign (HMF2) and strips any HCP related bytes
        :param address: Address of the sign to poll
        :return: Status reply if the sign responds, else None
        """
        reply = self.transmit_message("2%s" % address, 8)

        if len(reply) > 0:
            reply = reply[3:-3]
            return reply
        else:
            return None

    def get_ext_status(self, address):
        """
        Gets the extended status (HMF9) of the sign and strips any HCP related bytes
        Splits the information and depending on the sign software family, matches the index with the information then
        puts it all into a dictionary.
        :param address: Address of the sign to poll
        :return: sign details dictionary, otherwise None if the sign is unresponsive or 1 if the sign is unsupported
        """
        reply = self.transmit_message("9%s" % address, 100)

        if len(reply) > 0:
            reply = reply[3:-3]
            extended_status_dict = self.hwd.parse_extended_status(reply)

            if extended_status_dict["sign_family"] in ["OLED", "COLEMS"]:
                colour_panel_dict = self.get_panel_config(address)
                extended_status_dict["colour_panel"] = colour_panel_dict["panel_size"]
                extended_status_dict["colour_pos"] = colour_panel_dict["handedness"]

            return extended_status_dict

        else:
            print("\t%s no response" % address)
            return None

    def poll_all_signs(self, extended=False):
        """
        Loops through the range of sign addresses for their status
        :param extended: Request extended status instead of standard
        """
        for addr in range(1, self.number_of_addresses):
            self.poll_sign(addr, extended)

    def poll_sign(self, addr, extended=False):
        """
        Polls the signs status or extended status and updates the sign_dictionary
        :param addr: Address of sign to poll
        :param extended: Request extended status instead of standard
        """
        print("Polling sign %s" % addr)
        addr_hcp = self.normalise_hcp_addr(addr)

        if extended:
            reply = self.get_ext_status(addr_hcp)
            self.sign_dictionary["sign%s" % addr] = reply
        else:
            reply = self.get_status(addr_hcp)
            self.sign_status["sign%s" % addr] = reply

    """
    ###################################################################################################################
    Data handling
    """
    def update_data_via_display_dict(self, sign_data):
        """
        This takes in a display dict and then generates a series of templates from it.
        """
        if self.sign_dictionary is not None:
            sign_data = self.tg.process_display_text(sign_data, self.sign_dictionary)
            self.update_data_from_console_task(sign_data)

    def update_data_from_console_task(self, sign_data):
        """
        This is the main entry point for when sign_manager is given data from console_task that is set to ask directly
        for sign data from the console itself.
        :return:
        """

        if self.sign_data == sign_data:
            print("SM: Same data")
            self.new_data_flag = False
        else:
            print("SM: Different data")
            self.new_data_flag = True
            self.new_data = sign_data

    def assert_adhoc_message(self, enable: bool = False):
        """
        This is to allow sign_manager to immediately show an emergency message such as the Brake Signal from SR2256, but
        it will also need to be reset when the conditions to show it are no longer met.
        """
        self.adhoc_message["enable"] = enable

    def configure_adhoc_message(self, signs, message, brightness: int = 99):
        """
        This is where the details of the message is setup so that it doesnt need to be provided everytime such as in
        situastions where the adhoc messages never change
        """
        self.adhoc_message["signs"] = signs
        self.adhoc_message["message"] = message
        self.adhoc_message["brightness"] = brightness

    def render_msg(self, addr, message):
        """
        Tales the message and renders it via renderbox but before it does that, it obtains the actual sign resolution
        that was polled, if it value does not exist then it falls back to the resolution in the eric.bin
        If also does the colour RN width correction here if width in the database doesnt match the polled width
        :param addr: Address of the sign
        :param msg: Message to render
        :return: Rendered text
        """
        font_path = os.path.join(self.data_dir, "renderbox", self.config_dict.get("RENDERBOX_fontlib", "fontlib.bin"))
        #Obtain sign resolution
        if self.sign_dictionary["sign%s" % addr] == None:
            resolution = "160x24"
        else:
            try:
                resolution = self.sign_dictionary["sign%s" % addr]["sign_size"]
            except KeyError:
                print("SM: Resolution error, using 160x24")
                resolution = "160x24"

        if self.config_dict.get("TEMPLATES_colour_rw_correction", True):
            message = self.correct_col_rw(addr, message)

        render = self.rb.getSuperXRender(
            resolution,
            font_path,
            self.config_dict.get("RENDERBOX_font_mapping", "e66:85"),
            message
        )

        if len(render) < 1:
            # If the fontlib is missing, then the renderer returns an empty string (see renderbox.py)
            print("ST: Empty render...")
            return_message = message
        elif "Error" in render:
            print("ST: Render error :( \n\t%s" % render)
            return_message = message
        else:
            return_message = render

        return return_message


    def correct_col_rw(self, addr, msg):
        """
        Corrects the colour panel width by looking for the \rw and then replacing it with the correct value but only
        if colour panel data exists for it
        :param addr: Address of the sign
        :param msg: Message to correct
        :return: Original message or corrected message
        """
        try:
            colour_panel_width = self.sign_dictionary["sign%s" % addr]["colour_panel"]
        except TypeError:       #Shouldnt this be KeyError?
            return msg
        except KeyError:
            return msg

        if colour_panel_width != None:
            panel_width = colour_panel_width.split("x")[0]
            sign_data = re.sub("rw[0-9][0-9]", "rw%s" % panel_width, msg)
            print("SM: Correcting panel width %s" % panel_width)

            return sign_data
        else:
            return msg

    def normalise_hcp_addr(self, addr):
        """
        Converts a number into the equivalent hex for HCP addressing
        :param addr: Address to convert
        :return: Converted address
        """
        return "%X" % addr      #Taken from DG's code, much cleaner!

    """
    ###################################################################################################################
    Other sign stuff
    """
    def get_sign_brightness(self):
        """
        Gets the brightness parameters from the configuration file and creates a table of brightnesses for each sign.
        If there is no entry for a given sign, then it uses the global settings in the configuration file
        :return: sign brightness table
        """
        brightness_table = []

        default_min = self.config_dict.get("BRIGHTNESS_min_brightness", 10)
        default_max = self.config_dict.get("BRIGHTNESS_max_brightness", 100)
        default_gain = self.config_dict.get("BRIGHTNESS_brightness_gain", 10)

        for sign in range(1, self.number_of_addresses+1):
            sign = self.normalise_hcp_addr(sign)
            try:
                min, max, gain = self.config_dict["BRIGHTNESS_sign%s" % sign].split(",")
                # print("Sign%s brightness override" % sign, min,max,gain)
            except KeyError:
                min = default_min
                max = default_max
                gain = default_gain

            brightness_msg = "SC=BG=%s;MB%s;MINB%s" % (
                gain,
                max,
                min,
            )

            brightness_table.append(brightness_msg)

        return brightness_table

    def battery_guard(self):
        """
        AKA EcoMode.  Not jet implemented
        :return:
        """
        pass

    def broadcast_hmf6(self):
        """
        This broadcasts the current route number for programmable RN stuff
        :return:
        """

        if self.hmf6_route_number != None:
            route_number = self.hmf6_route_number.lstrip("0")
            hmf6 = "60" + route_number
            print("SM: HMF6 %s" % route_number)
            self.transmit_message(hmf6, 0)

    """
    ###################################################################################################################
    Sign table
    """
    def open_sign_table(self):
        """
        This opens the JSON file which stores sign information
        :return: sign table if JSON exists, otherwise 1
        """
        try:
            sign_json_file = open(self.sign_table_dir, "r")
            sign_json = sign_json_file.read()
            sign_json_file.close()
            print("Sign JSON found!")
        except IOError:
            print("Sign JSON does not exist")
            return 1

        try:
            existing_sign_table = json.loads(sign_json)
        except json.decoder.JSONDecodeError:
            print("Parsing Error")
            return 1

        return existing_sign_table

    def save_sign_table(self):
        """
        Saves the sign_table dictionary into a JSON file
        """
        sign_json = json.dumps(self.sign_dictionary, indent=4, separators=(',', ': '))

        #TODO perhaps change the directory to /tmp
        sign_json_file = open(self.sign_table_dir, "w")
        sign_json_file.write(sign_json)
        sign_json_file.close()

        print("Saved fitted table")

    def compare_tables(self):
        """
        This function was initially developed for ISI Abu Dhabi:
        • Upon bootup the system will scan for signs connected.
        • If there is an existing sign table (JSON) then it will compare that with the newly discovered sign table.
        • If there is a new sign, it will be added to the sign table automatically,
        • if there is an existing sign, but the newly detected sign is different, then it will be updated with the newly discovered sign.
        • If there is no existing table, then create a new one with the discovered signs.
        • If there is an existing sign, but cannot be detected, then it will notify the operator
        • Upon subsequent bootups, if a sign that’s expected is missing, then show it on the console screen

        Sign table is reset when there is a new database
        """
        existing_table = self.open_sign_table()

        if existing_table == 1:
            self.save_sign_table()
        else:
            for addr in range(1, self.number_of_addresses):
                existing_sign = existing_table["sign%s" % addr]
                polled_sign = self.sign_dictionary["sign%s" % addr]
                if existing_sign == None and polled_sign == None:
                    continue
                if existing_sign == polled_sign:
                    print("Sign identical: %s" % addr)
                elif existing_sign == None and polled_sign != None:
                    print("New sign: %s" % addr)
                    self.reset_sign_table = True
                elif existing_sign != None and polled_sign == None:
                    # Missing sign
                    print("Sign missing: %s" % addr)
                    self.sign_fault = True

        if self.reset_sign_table:
            self.save_sign_table()
            self.reset_sign_table = False


    """
    ###################################################################################################################
    Main
    """
    def process_sign_data(self, sign_data, address):
        """
        This needs the same behaviour as sign task where it deals with data from different sources except this needs to
        take into account the sign address and not broadcast it.

        THe data can come from multiple sources so take has to be taken to deal with i
        :param sign_data:
        :param address:
        :return:
        """
        if sign_data == None or sign_data == ".":
            #Show a dot on the sign
            msg = "."
        elif "\picw" in sign_data:
            #Not sure when we would get a SuperX graphic? But either way needs dealing with cos Alex will find a way
            msg = sign_data
        elif sign_data == "IDLE":
            msg = "C%s" % self.normalise_hcp_addr(address)
            return self.hcp.encodeMaster(msg).encode("latin-1")
        elif sign_data[0] == "\x02" and sign_data[1] == "1":
            #This legacy graphic message will already be encoded as HCP!
            return sign_data
        elif self.renderbox_enable:
            if sign_data[0] == "\x02":
                stripped_msg = sign_data[3:-3]
            else:
                stripped_msg = sign_data

            render = self.render_msg(address, stripped_msg)
            msg = render
        else:
            msg = sign_data

        if msg[0] == "\x02":
            #Check if the first byte is a STX
            return msg
        else:
            msg = "0%s%s" % (self.normalise_hcp_addr(address), msg)

        return msg


    def run(self):
        """
        Main loop
        """
        self.init_serial()

        print("SM: Running!")
        #Inteded to run as a thread
        ext_status_timer = 0
        brightness_table = self.get_sign_brightness()
        # print(brightness_table)

        while 1:
            # Handle sign test here
            if self.test_mode:
                msg = "30"
                self.ser.write(self.hcp.encodeMaster(msg).encode("latin-1"))
                time.sleep(self.sign_delay_interval)
                continue

            #Deal with adhoc messages here
            """
            At the moment if it is active it will only update the appropriate signs, leaving other signs alone.  Whereas
            before it was part of the display loop. I think this is more elegant now and will stay in the adhoc loop
            until the conditions reset
            """
            if self.adhoc_message["enable"]:
                self.adhoc_message["active"] = True
                for sign in self.adhoc_message["signs"]:
                    # print("SM: Adhoc msg for sign %s" % sign)
                    self.ser.write(self.hcp.encodeMaster("9%sSC=MINB%s" % (sign, self.adhoc_message["brightness"])).encode("latin-1"))
                    self.ser.write(self.hcp.encodeMaster("0%s%s" % (sign, self.adhoc_message["message"])).encode("latin-1"))
                    time.sleep(self.sign_delay_interval)
                continue
            else:
                if self.adhoc_message["active"]:
                    self.adhoc_message["active"] = False
                    for sign in self.adhoc_message["signs"]:
                        self.ser.write(self.hcp.encodeMaster("C%s" % sign).encode("latin-1"))
                        #Do we restore message/brightness here? or leave it?

            # Get ext_status here
            if time.time() - ext_status_timer > self.ext_status_poll_interval:
                self.poll_all_signs(True)
                self.compare_tables()
                ext_status_timer = time.time()

            if self.sign_data == None:
                if self.new_data_flag:
                    self.sign_data = self.new_data
                else:
                    self.poll_all_signs()
                    print("SM: No sign data")
            else:
                self.broadcast_hmf6()

                for addr, sign_data in enumerate(self.sign_data, 1):
                    if self.adhoc_message["enable"]:
                        break
                    addr_hcp = self.normalise_hcp_addr(addr)
                    self.poll_sign(addr)

                    # Send brightness param
                    brightness_msg = brightness_table[addr - 1]
                    self.ser.write(self.hcp.encodeMaster("9%s%s" % (addr_hcp, brightness_msg)).encode("latin-1"))

                    print(addr, sign_data, brightness_msg)

                    msg = self.process_sign_data(sign_data, addr)
                    self.transmit_message(msg, 0)
                    time.sleep(self.sign_delay_interval)

                    #Putting this here allows the first sign to change its display before breaking.
                    #Although its probably better at the top of the loop?
                    if self.new_data_flag:           #Breaks the loop and restarts if there is new data
                        print("SM: New data, breaking sign loop")
                        self.new_data_flag = False
                        self.sign_data = self.new_data
                        break
                    elif self.test_mode:
                        break

            time.sleep(self.display_task_interval)


if __name__ == "__main__":
    config_dict = {
        "SERIAL_rs485_comport": r"/dev/ttyS2",
        "SERIAL_baudrate_rs485": 38400,
        "BRIGHTNESS_min_brightness": 10,
        "BRIGHTNESS_max_brightness": 90,
        "BRIGHTNESS_brightness_gain": 10,
        "RENDERBOX_enable": True
    }

    sm = SignManagerConsole(config_dict, "..")
    sm.run()