"""
Name: config_updater
Title:  Config Updater
Author: Cooper
Date: 18/05/2020
Modified: 11/04/2025

Desc:  The new and updated config updater, this previously only allowed one to upload a config file.

Now the following can be achieved:
- Show current configuration parameters
- Allow upload and download of configuration
- Allow upload and download of fontlib
- Trigger sign test
- Trigger application restart

Potential other things to support:
- Sign firmware
- Onion updates

HTML isn't the prettiest of "languages" apologies for the mess...

This module does not come automatically with sign/console task, so must be imported.
Whoever imports this would need to monitor the flag files internally to the module (for file updates) and trigger files
which

"""
import os
import time
import logging
import threading

from bottle import route, request, redirect, static_file, template, Bottle

class ConfigUpdater():
    def __init__(self, config_dict, hw_dict, config_dir):
        self.config_dict = config_dict
        self.hw_dict = hw_dict
        self.config_dir = config_dir
        self.fontlib_dir = "/usr/share/renderbox"

        self.update_in_process = False      #Flag to set so that new files cannot be uploaded until the current is done
        self.new_conf = False
        self.new_manu = False
        self.new_onion_version = False

        self.new_72k_file = False
        self._72k_filepath = ""

        self.new_sign_firmware = False
        self.signfirmware_path = "/tmp/signfirmware.bin"

    def setup_webserver(self):
        """
        Sets up the webserver and all the routes that are used before starting it as a thread as it is a blocking
        function

        Due to the way the webserver is initialised, the decorators do not work, but it
        seems there is no harm in leaving them there.  Perhaps at one point this can be overhauled so that the
        decorators do work.
        """
        self.http_server = Bottle()
        self.http_port = 4166

        self.http_server.route('/', method="GET", callback=self.root_page)
        self.http_server.route('/writemanu', method="POST", callback=self.write_manu)

        #Config related routes
        self.http_server.route('/writeconf', method="POST", callback=self.write_conf)
        self.http_server.route('/upload', method="GET", callback=self.config_form_upload)
        self.http_server.route('/upload', method="POST", callback=self.process_web_form_upload)
        self.http_server.route('/downloadconfig', method="GET", callback=self.config_download)

        #Local fontlib related routes
        self.http_server.route('/uploadfont', method="GET", callback=self.font_form_upload)
        self.http_server.route('/uploadfont', method="POST", callback=self.process_web_form_upload)
        self.http_server.route('/downloadfont', method="GET", callback=self.font_download)

        #Onion firmware related routes
        self.http_server.route('/updateonion', method="GET", callback=self.update_onion)

        #Firmloader related routes, signfirmware and 72k fontlib
        self.http_server.route('/updatesign', method="GET", callback=self.update_sign_firmware)
        self.http_server.route('/updatesign', method="POST", callback=self.process_sign_update)
        self.http_server.route('/upload72k', method="GET", callback=self.update_72k)
        self.http_server.route('/upload72k', method="POST", callback=self.process_72k_file)

        #Ancilliary stuff
        self.http_server.route('/signtest', method="GET", callback=self.sign_test_trigger)
        self.http_server.route('/rebootapp', method="GET", callback=self.reboot_app)
        self.http_server.route('/list/<path:path>', method='GET', callback=self.list_directory)

        self.http_thread = threading.Thread(target=self.http_server.run, kwargs=dict(host="0.0.0.0",
                                                                                port=self.http_port,
                                                                                debug=False
                                                                                ))

        self.http_thread.daemon = True
        self.http_thread.start()

        
    """
    ###################################################################################################################
    HTTP Paths
    """
    @route('/', method='GET')
    def root_page(self):
        """
        This returns the root page, where the hardware details are listed and some links to other sections of this module
        """
        title = r"""
        <style>
        h1 {{
            font-family: arial, sans-serif;
        }}
        p {{
            font-family: arial, sans-serif;
        }}
        </style>
        <h2>Hanover Displays {product_code} {ip_address}</h2>
        """.format(product_code=self.hw_dict["model"], ip_address=self.hw_dict["unit_IP"])
        
        hw_table = self.gen_html_table("Hardware Details", self.hw_dict)
        
        links_to_other_sections = r"""
        <style>
        h2 {{
            font-family: arial, sans-serif;
        }}
        p {{
            font-family: arial, sans-serif;
        }}
        </style>
        
        <h2>Other sections</h2>
        <p><a href="/upload">Configuration upload: {unit_ip}:4166/upload</a></p>
        <p><a href="/uploadfont">Font upload (Onion Renderbox): {unit_ip}:4166/fontupload</a></p>
        <p><a href="/upload72k">72k upload: {unit_ip}:4166/upload72k</a></p>
        <p><a href="/signtest">Sign test trigger: {unit_ip}:4166/signtest</a></p>
        <hr style="width:50%;text-align:left;margin-left:0">
        <p><a href="/updateonion">Update Onion: {unit_ip}:4166/updateonion</a></p>
        <p><a href="/updatesign">Update sign firmware: {unit_ip}:4166/updatesign</a></p>
        <hr style="width:50%;text-align:left;margin-left:0">
        <p><a href="/rebootapp">Reboot Application: {unit_ip}:4166/reboot</a></p>
        """.format(unit_ip=self.hw_dict.get("unit_IP", "127.0.0.1"))
        
        return title + hw_table + links_to_other_sections

    @route("/writemanu", method="POST")
    def write_manu(self):
        """
        For production to allow them to program in manufacturing details
        """
        data = request.body.read().decode("utf-8")

        if not self.write_file(data, "hwdetails.cfg"):
            self.new_manu = True

    @route("/writeconf", method="POST")
    def write_conf(self):
        """
        For production to allow them to program in a config
        """
        data = request.body.read().decode("utf-8")

        if not self.write_file(data, "config.cfg"):
            self.new_conf = True

    @route("/upload", method="GET")
    def config_form_upload(self):
        """
        To allow config updates via a web browser, this makes things easier for end users as they do not need to
        worry about SSH etc.
        """
        description = "Select a config file, will be automatically renamed to config.cfg"
        configs = self.gen_html_table("Current Configurations", self.config_dict)
        form_html = self.gen_upload_form(description, "config.cfg")
        download_link = """<p><a href="/downloadconfig">Configuration download: {unit_ip}:4166/downloadconfig</a></p>""".format(
            unit_ip=self.hw_dict.get("unit_IP", "127.0.0.1"))

        return form_html + download_link + configs

    @route("/downloadconfig", method="GET")
    def config_download(self):
        """
        Supplies the config file
        """
        return static_file("config.cfg", root=self.config_dir, download="config.cfg")

    @route("/uploadfont", method="GET")
    def font_form_upload(self):
        """
        Allows the font to be uploaded to the system without having to bother with SCP
        """
        configured_fontlib_name = self.config_dict.get("RENDERBOX_fontlib", "fontlib.bin")
        description = "Select an unwrapped fontlib, will be automatically renamed to %s (as per the config)" % configured_fontlib_name
        download_link = """<p><a href="/downloadfont">Font download: {unit_ip}:4166/downloadfont</a></p>""".format(
            unit_ip=self.hw_dict.get("unit_IP", "127.0.0.1"))
        
        return self.gen_upload_form("Font", description) + download_link

    @route("/downloadfont", method="GET")
    def font_download(self):
        """
        Supplies the fontlib
        """
        configured_font_name = self.config_dict.get("RENDERBOX_fontlib", "fontlib.bin")
        configured_font_path = os.path.join(self.fontlib_dir, configured_font_name)
        
        if os.path.isfile(configured_font_path):
            return static_file(configured_font_name, root=self.fontlib_dir, download=configured_font_name)
        else:
            return "Fontlib not found"

    @route("/upload", method="POST")
    @route("/uploadfont", method="POST")
    def process_web_form_upload(self):
        """
        Processes the file after the start upload button is pressed.  This is for
        """
        upload = request.files.get('upload')

        if upload != None:
            if upload.filename == "config.cfg":
                self.new_conf = True
                upload.save(os.path.join(self.config_dir, "config.cfg"), overwrite=True)  # appends upload.filename automatically
            elif "fontlib" in upload.filename:
                configured_font_name = self.config_dict.get("RENDERBOX_fontlib", "fontlib.bin")
                configured_font_path = os.path.join(self.fontlib_dir, configured_font_name)
                upload.save(configured_font_path, overwrite=True)
            else:
                return 'Incorrect file'
            
            redirect("/")

    @route("/updateonion", method="GET")
    def update_onion(self):
        """
        This is the route for updating the onion firmware, not really sure how to proceed at this point because the
        application shouldn't be able to update itself?  But either way if someone has a laptop connected to the network
        already then perhaps this method shouldn't be offered at all.
        """
        if self.update_in_process:
            return "Update in process, try again later"

        return "Not jet implemented"

    @route("/updatesign", method="GET")
    def update_sign_firmware(self):
        """
        This is the route for updating sign firmware, it will do a basic check to ascertain whether the file uploaded is
        the correct type, but it only relies on the filename being correct, doesn't check the contents of the file to
        make sure that it is.
        """
        if self.update_in_process:
            return "Update in process, try again later"

        sign_family = self.hw_dict["software_name"]

        if "colems-72" in sign_family.lower():
            processor = "72000-01-xx"
        elif "colems" in sign_family.lower() or "oled" in sign_family.lower():
            processor = "7611-01-xx"
        elif "olems" in sign_family.lower():
            processor = "7766-01-xx"
        elif "tled-7113x" in sign_family.lower():
            processor = "7113x-01-xx"
        elif "tled" in sign_family.lower():
            processor = "7630-01-xx"
        else:
            processor = "Unknown, proceed with caution"

        description = f"""Select a sign firmware file, please ensure it is the correct firmware for the sign product family!
                       <br>Current installed version: <b>{sign_family}</b>, based on processor: <b>{processor}</b>
                       <br>Caution loading the wrong firmware will render the sign inoperable!"""

        form_html = self.gen_upload_form("Sign firmware update", description, action="/updatesign")

        return form_html

    @route("/updatesign", method="POST")
    def process_sign_update(self):
        """
        This processes the sign firmware updates, it also checks if the filename matches the installed software as a
        basic sanity check.  This can be forced through if necessary by naming the file "force" just incase the filter
        is being too annoying
        """
        upload = request.files.get('upload')
        filename = upload.filename

        #Check that the filename is at least correct...
        try:
            sign_family = self.hw_dict["software_name"].lower()
        except Exception:
            logging.exception("Error determining sign family...")
            return """
            Error, cannot determine sign family, so skipping installation.  Sorry!
            """

        if sign_family in filename.lower() or "force" in filename.lower():
            upload.save(self.signfirmware_path, overwrite=True)

            self.new_sign_firmware = True

            return """
            Upload successful, please wait.  Sign will resume normal operation once process is complete
            <br>You can close this webpage"""
        else:
            return f"""
            Mismatch {sign_family}=/={filename}, skipping installation!
            """

    @route("/upload72k", method="GET")
    def update_72k(self):
        """
        The 72k based processors support uploading of various different files so there needs to be a scheme for this...
        Needs to handle fonts and configs at the minimum.

        Firmware is handled via a different route
        """
        if self.update_in_process:
            return "Update in process, try again later"

        if "colems-72" in self.hw_dict["software_name"].lower():
            description = """Select a 72k wrapped fontlib or config file
            <br>For a fontlib, this must be <b>wrapped</b> and named <b>fontlib.wbin</b>
            <br>For a config, this must be named <b>config.ini</b>
            <br>Please ensure that the file is correct for the type as no checks are done!
            """
            form_html = self.gen_upload_form("72k update", description, action="/upload72k")

            return form_html

        else:
            return "This section is only for 72k based processors only"

    @route("/upload72k", method="POST")
    def process_72k_file(self):
        """
        Checks that the filename is one of two and will save it if it meets the requirements.  This is very basic
        checking, but it is not very often that these files are changed anyway.
        """
        upload = request.files.get('upload')
        filename = upload.filename

        if filename in ["fontlib.wbin", "config.ini"]:
            filepath = os.path.join("/tmp", filename)
            upload.save(filepath, overwrite=True)

            self.new_72k_file = True
            self._72k_filepath = filepath

        else:
            return f"Uploaded file has incorrect filename: {filename}"

    @route("/signtest", method="GET")
    def sign_test_trigger(self):
        """
        Places sign_test_trigger in /tmp
        """
        with open("/tmp/trigger_sign_test", "w") as trigger:
            logging.info("Sign test triggered via HTTP")

        return "OK<br>Sign test triggered for 90s"

    @route("/rebootapp", method="GET")
    def reboot_app(self):
        """
        Places a restart app trigger in /tmp
        """
        with open("/tmp/trigger_reboot", "w") as trigger:
            logging.info("App restart triggered via HTTP")

        return "OK"

    def list_directory(self, path):
        abs_path = "/" + path

        if not os.path.exists(abs_path):
            return f"Directory '{abs_path}' does not exist."

        if not os.path.isdir(abs_path):
            return f"'{abs_path}' is not a directory."

        files = os.listdir(abs_path)
        files.sort()

        return template("""
            <h2>Contents of {{path}}</h2>
            <ul>
            % for file in files:
                <li>{{file}}</li>
            % end
            </ul>
        """, path=abs_path, files=files)

    """
    ###################################################################################################################
    HTML related calls
    """
    def gen_upload_form(self, form_name, file_desc, action="/upload"):
        """
        Generates an upload form depending on the file type needed.
        """
        html_form = r"""
        <!DOCTYPE html>
        <style>
        h1 {{
            font-family: arial, sans-serif;
        }}
        
        p {{
            font-family: arial, sans-serif;
        }}
        
        form {{
            font-family: arial, sans-serif;
        }}

        </style>
        
        <h1>{form_name}</h1>
        <p>{file_type}:</p>

        <form action="{action}" method="post" enctype="multipart/form-data">
            <input type="file" name="upload" />
            <input type="submit" value="Start upload" />
        </form>
        
        <hr style="width:50%;text-align:left;margin-left:0">
        
        
        """.format(form_name=form_name, file_type=file_desc, action=action)
        
        return html_form

    def gen_html_table(self, table_name, input):
        """
        Generates a HTML table from a dictionary
        """
        row_data = ""

        for key, value in input.items():
            row_data += """
            <tr>
                <th>{key}</th>
                <th>{value}</th> 
            </tr>
            """.format(key=key, value=value)


        html_table = r"""
       <style>
        h2 {{
            font-family: arial, sans-serif;
        }}
        
        table {{
          font-family: arial, sans-serif;
          border-collapse: collapse;
          width: 50%;
        }}
        
        td, th {{
          border: 1px solid #dddddd;
          text-align: left;
          padding: 8px;
        }}
        
        tr:nth-child(even) {{
          background-color: #dddddd;
        }}
        </style>
        
        <h2>{table_name}</h2>
        <table>
            {column}
        </table>
        """.format(table_name=table_name, column=row_data)

        return html_table
    
    """
    ###################################################################################################################
    File handling
    """
    def write_file(self, data, filename):
        """
        Writes a given file to a given location, this is only used for files that need to be persistent.
        """
        logging.info("CU: Attempting to write %s" % filename)

        try:
            filepath = os.path.join(self.config_dir, filename)
            hwfile = open(filepath, "w")

            hwfile.write(data)
            hwfile.close()
            logging.info("...success: %s bytes written" % len(data))
            return 0
        except OSError:
            logging.exception("Cannot write to file...")
            return 1

    def set_cu_busy(self, state: bool):
        """
        Sets a flag to tell config updater not to accept anything else
        """
        self.update_in_process = state


if __name__ == "__main__":
    config_dict = {
        "KEYA": "VALA",
        "KEYB": "VALB",
        "KEYC": "VALC",
        "KEYD": "VALD",
        "KEYE": "VALE",
        "RENDERBOX_fontlib": "fontlib-bino.bin"
    }

    hw_dict = {
        "model": "COL030",
        "software_version": "COLEMS 1.2.3",
        "software_name": "COLEMS",
        "manufacturer": "Hanover Displays",
        "hw_type": "ext",
        "unit_IP": "127.0.0.1"
    }

    cu = ConfigUpdater(config_dict, hw_dict, ".")
    cu.setup_webserver()

    while 1:
        time.sleep(1)