"""
name: cust_info_service_subscriber
title: customer information subscriber
author: cooper
date: 20/04/2020

desc:
This is the module that handles obtaining data from the CustomerInformationService, be it via Request or Subscription.
Before data can be obtained, it is required to know the location (CIS Server) and where in the location it is (HTTP Path)

The location can be obtained via DNS-SD, but this cannot be said for the the HTTP path.  There is a bit of a grey area
with regards to this.  HTTP path appears to be optional in the TXT record.  So if it isnt available in the SRV, then we
need to store it in the config file

An example XML file can be found here:  http://git/Onion/HanIP/-/blob/ibisip-ret-tled-support.cc/test_scripts/ibis-ip/cis_data/

so much crap just to send destination and line...

"""
import time
import hashlib
import http.client
import threading
import logging

import xmltodict

from bottle import request, Bottle
from hanip.ibis_ip import ibisip_service_discovery

class CustomerInformationServiceSubscriber(object):
    def __init__(self, config_dict, hw_dict):
        self.config_dict = config_dict
        self.hw_dict = hw_dict

        self.poll_interval = 5      #When module is in request mode, the frequency to request data
        self.cis_server_found = False
        self.preferred_cis_version = self.config_dict.get("IBISIP_cis_ver", "2.2")

        self.subscribe = self.config_dict.get("IBISIP_subscribe", True)
        self.subscribed = False     #When a subscirbe active message is returned
        self.subscription_timeout = self.config_dict.get("IBISIP_subscription_timeout", 300)    #This is the timeout for CIS subscribe
        self.subscription_timer = 0
        self.server_port = self.config_dict.get("IBISIP_server_port", 8080)

        self.new_data = False
        self.message_md5 = None     #Currently unused but used to compare messages
        self.raw_xml = None         #This supercedes self.display_data
        self.data_lock = threading.Lock()

        self.service_ip = None  #IP address of the service provider
        self.service_port = None    #Port of the service provider
        self.service_path_request = None    #Request HTTP path
        self.service_path_subscribe = None  #Subscribe HTTP path

        if self.subscribe:
            #In subscribe mode, the module needs to initiate a HTTP server to receive POST messages
            self.setup_http_server()

    """
    ###################################################################################################################
    HTTP Server setup
    """

    def setup_http_server(self):
        """
        Sets up a HTTP server that listens on all interfaces with the port number defined in the config file.  This HTTP
        server is run as a thread.
        """
        self.http_server = Bottle()

        logging.info("Initiating CIS Webserver port %s" % self.server_port)

        self.http_server.route('/', method="POST", callback=self.handle_cis_data_post)
        self.http_server.route('/CustomerInformationSubscriber/CurrentDisplayContentSub', method="POST", callback=self.handle_cis_data_post)
        self.http_server.route('/CustomerInformationSubscriber/CurrentStopPointSub', method="POST", callback=self.handle_cis_data_post)

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

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

        logging.info("CISS: HTTP Server ready")

    def determine_data_paths(self):
        """
        This is for figuring out what HTTP Paths to use depending on the hardware.
        :return: HTTP path for request, HTTP path for subscribe
        """
        if self.hw_dict["hw_type"] == "ext" or self.hw_dict["hw_type"] == "con":
            request_path = self.config_dict.get("IBISIP_service_path_request_ext", "/CustomerInformationService/GetCurrentDisplayContent")
            subscribe_path = self.config_dict.get("IBISIP_service_path_subscribe_ext", "/CustomerInformationService/SubscribeCurrentDisplayContent")
        else:
            request_path = self.config_dict.get("IBISIP_service_path_request_int", "/CustomerInformationService/GetCurrentDisplayContent")
            subscribe_path = self.config_dict.get("IBISIP_service_path_subscribe_int", "/CustomerInformationService/SubscribeCurrentDisplayContent")

        return request_path, subscribe_path

    def apply_config_paths(self):
        """
        Applies the parameters for the CIS server from the configuration file, and sets the server_found flag as True
        """
        logging.info("\tusing configured parameters")
        self.service_ip = self.config_dict["IBISIP_service_ip"]
        self.service_port = self.config_dict["IBISIP_service_port"]
        request_path, subscribe_path = self.determine_data_paths()
        self.service_path_request = request_path
        self.service_path_subscribe = subscribe_path
        self.cis_server_found = True

    """
    ###################################################################################################################
    Service Discovery
    """

    def look_for_service(self):
        """
        If enabled, this will wait forever until a service is found, unless a timeout is configured.  Otherwise it will
        use values defined in the config file as a timeout.  Once a service has been discovered it will set the
        server_found flag to True and then terminate
        """
        logging.info("CISS: Looking for service")
        ibisipsd = ibisip_service_discovery.IBISIPServiceDiscovery(self.config_dict)

        try:
            timeout = int(self.config_dict["IBISIP_timeout"])   #Default to no timeout if incorrect parameter
        except ValueError:
            timeout = 0

        discovered_services = ibisipsd.discover_relevant_services("CustomerInformationService", timeout) or []

        if len(discovered_services) < 1:
            self.apply_config_paths()
            return

        logging.info("CISS: Services found")
        chosen_service = self.parse_services(discovered_services)

        self.service_ip = chosen_service["address"]
        self.service_port = chosen_service["port"]
        logging.info("%s:%s" % (self.service_ip, self.service_port))
        request_path, subscribe_path = self.determine_data_paths()

        try:
            #As paths are optional in the DNS-SD, if there is a path, it must be included.
            service_root_path = chosen_service["txt_records"][b"path"].decode("utf-8")
            if service_root_path[0] != "/":
                service_root_path = "/" + service_root_path

            self.service_path_request = service_root_path + request_path
            self.service_path_subscribe = service_root_path + subscribe_path
        except KeyError:        #Theres no guarantee that the path is always supplied via DNS-SD
            logging.info("CISS: No path supplied")
            self.service_path_request = request_path
            self.service_path_subscribe = subscribe_path

        self.cis_server_found = True

    def parse_services(self, services: list) -> dict:
        """
        This parses the services found to establish the required one or the best available.

        Given it is looking in the txt records for a specific version of IBISIP it isn't a general parser.
        It will loop through the services that it has been given to look for the default version defined at the top
        of this module, or if its overriden via config.

        If it cannot find a match, it will just return the first one it found.  Perhaps not the ideal, but I don't
        know what would be ideal.

        """
        versions_found = []

        if len(services) == 1:
            logging.info("CIS: Only one service found")
            return services[0]

        for service in services:
            #Go through each found service in turn and look for the txt_record
            logging.info("CIS: Found %s" % service["name"])
            try:
                txt_record = service["txt_records"]
                version = txt_record[b"ver"].decode("utf-8")
            except (UnicodeDecodeError, KeyError):
                continue

            if version == self.preferred_cis_version:
                logging.info("CIS: Using %s" % service["name"])
                return service
            else:
                #Append in the original order so that the index is used to pick later
                versions_found.append(version)

        # Goes into this bit if an exact match is not found, I guess find the closest?
        logging.info("CIS: Preferred version not found, using next best")
        preferred_version = self.preferred_cis_version.replace(".", "")     #Ditch the dot so that can be converted into an int
        for index, version in enumerate(versions_found):
            try:
                _pref_ver = int(preferred_version)
                _ver = int(version.replace(".", ""))

                if _ver > _pref_ver:
                    continue
                else:
                    logging.info("CIS: Using: ")
                    return services[index]
            except ValueError:
                continue

        #Hm if we have reached here then there wasn't a match or a suitable version then what do we do here?
        return services[0]


    """
    ##################################################################################################################
    CIS Request
    """

    def get_http_data(self, path):
        """
        In request mode, this fires off HTTP GET messages to the CIS server
        :param path: URL of the data
        :return: If there are no errors, returns the body of the HTTP message, otherwise returns None
        """
        logging.info("Connecting to %s %s" % (self.service_ip, self.service_port))
        conn = http.client.HTTPConnection(self.service_ip, self.service_port)

        #The header is for completeness sake, not entirely necessary but for this packet to show up in wireshark as HTTP
        #then it is needed
        header = {
            "Content-type": "text/xml"
        }

        try:
            conn.request("GET", path, headers=header)
            response = conn.getresponse()
            logging.info("%s %s" % (response.status, response.reason))
        except ConnectionRefusedError as e:
            logging.error(e)
            return None
        except TimeoutError as e:
            logging.error(e)
            return None
        except OSError as e:    #For when connecting to a host that doesn't exist
            logging.error(e)
            return None

        if response.status == 404:      #HTTP Not Found
            logging.error("CISS: NOT FOUND")
            return None
        else:
            data = response.read().decode("utf-8")
            return data

    """
    ##################################################################################################################
    CIS Subscribe
    """

    def send_subscription_request(self):
        """
        In subscribe mode, we must send a subscription message to the CIS server via the appropriate path.  This message
        contains the reply path, and reply port.
        Annoyingly, the "keys" are inconsistent, Reply-Path has a hyphen in it in the spec but RET omits this so we
        need to be careful.

        If a successful subscription has occurred then it will set the subscribed flag to true.

        :return: None only if there is an error
        """
        subscription_dict = {
            "SubscribeRequest": {
                "Client-IP-Address": {
                    "Value": self.hw_dict["unit_IP"]
                },
                "ReplyPort": {
                    "Value": self.config_dict["IBISIP_server_port"]
                },
                "ReplyPath": {
                    "Value": self.config_dict["IBISIP_server_path"]
                }
            }
        }

        subscribe_xml = xmltodict.unparse(subscription_dict, pretty=True)

        logging.info("Sending request to %s:%s%s" % (self.service_ip, self.service_port, self.service_path_subscribe))

        #The header is for completeness sake, not entirely necessary but for this packet to show up in wireshark as HTTP
        #then it is needed
        header = {
            "Content-type": "text/xml"
        }

        #Keep sending subscription messages till something happens
        while 1:
            try:
                conn = http.client.HTTPConnection("%s:%s" % (self.service_ip, self.service_port))
                conn.request("POST", self.service_path_subscribe, subscribe_xml, header)
                reply = conn.getresponse()
            except ConnectionRefusedError as e:
                logging.error(e)
                return None
            except TimeoutError as e:
                logging.error(e)
                return None
            except OSError as e:  # For when connecting to a host that doesn't exist
                logging.error(e)
                return None

            status = reply.status
            body = reply.read().decode("utf-8")

            # logging.info(status)
            # logging.info(body)

            if reply.status == 200:     #200 is HTTP for OK
                if self.parse_subscription_response(body):
                    self.subscribed = True
                    self.subscription_timer = time.time()
                    break
                else:
                    time.sleep(1)
            else:
                time.sleep(1)

    def parse_subscription_response(self, raw_xml_data: str) -> bool:
        """
        Parses the Subscription Response
        """
        try:
            raw_xml_dict = xmltodict.parse(raw_xml_data, "utf-8", dict_constructor=dict)
        except Exception as e:  # Exception is xml.parsers.expat.ExpatError but cannot use it directly
            logging.warning("XML issue: %s" % e)
            return False
        else:
            active = raw_xml_dict["SubscribeResponse"]["Active"]["Value"]
            if active.lower() == "true" or active == "1":
                return True
            else:
                return False

    def handle_cis_data_post(self):
        """
        This is the hook for Bottle to process the POST, in the unlikely event of a subscription reply is sent it just
        ignores it for now.
        When new data is POSTed it processes the data and sets new_data flag to True
        """
        xmldata = request.body.read().decode("utf-8")

        if "Subscribe" in xmldata:
            #To handle subscribe replies WIP
            pass
        else:
            self.update_raw_xml(xmldata)

    """
    ##################################################################################################################
    XML Stuff
    """
    def generate_md5(self, text):
        """
        Converts string to MD5, Not currently used.
        :param text: String to be hashed
        :return: Ascii hex representation of the MD5 hash
        """
        new_md5_hash = hashlib.md5(text).hexdigest()
        return new_md5_hash

    def get_cis_data(self):
        """
        The main call to this module that requests data.
        If in subscribe mode, if the CIS server has not yet POSTed any data, then the application will request it so that
        there is always something to show.
        If in non-subscribe mode then it will always use GET to obtain data.

        :return: If a cis server available, return display data otherwise None
        """
        logging.info("#Getting data:")

        if self.cis_server_found:
            if self.subscribe:
                if self.raw_xml == None:
                    logging.info("GETting...")
                    #This is only valid on the first run and when the CIS has not posted any data yet
                    xmldata = self.get_http_data(self.service_path_request)
                    self.update_raw_xml(xmldata)
                else:
                    logging.info("Returning POST data")
            else:
                xmldata = self.get_http_data(self.service_path_request)
                self.update_raw_xml(xmldata)

            return self.retrieve_raw_xml(True)
        else:
            return None

    def update_raw_xml(self, xmldata: str) -> None:
        """
        This is intended to make the data thread safe and sets it
        """
        with self.data_lock:
            self.new_data = True
            self.subscription_timer = time.time()
            self.raw_xml = xmldata

    def retrieve_raw_xml(self, force: bool = False):
        """
        This allows another thread to obtain the data from here,  by default it will only return the raw xml if there
        is a new version of it, but can be forced to return whatever is stored.
        """
        with self.data_lock:
            if force:
                return self.raw_xml
            else:
                if self.new_data:
                    self.new_data = False
                    return self.raw_xml
                else:
                    return None

    def run(self, debug: bool=False):
        """
        This appears to be used for debugging purposes only not to be used by anything else, although it maybe a good
        idea to...
        """
        # If running as a thread call this.
        if self.config_dict["IBISIP_subscribe"]:
            while 1:
                self.send_subscription_request()
                if self.subscribed:
                    logging.info("Subscribed to CIS!")
                    break

        while 1:
            raw_xml = self.get_cis_data()

            if debug:
                print(raw_xml)


            time.sleep(5)

if __name__ == "__main__":
    import sys
    logging.basicConfig(stream=sys.stdout, level=logging.DEBUG)

    configs = {
        "IBISIP_discover": False,
        "IBISIP_cis_ver": "2.3",
        "IBISIP_timeout": 0,
        "IBISIP_service_ip": "192.168.10.1",
        "IBISIP_service_port": 6003,
        "IBISIP_service_path_request_ext": "/CustomerInformationService/GetCurrentDisplayContent",
        "IBISIP_service_path_subscribe_ext": "/CustomerInformationService/SubscribeCurrentDisplayContent",
        # "IBISIP_service_path_request_ext": "/CustomerInformationService/GetAllData",
        # "IBISIP_service_path_subscribe_ext": "/CustomerInformationService/SubscribeAllData",
        "IBISIP_service_path_request_int": "/CustomerInformationService/GetCurrentDisplayContent",
        "IBISIP_service_path_subscribe_int": "/CustomerInformationService/SubscribeCurrentDisplayContent",
        "IBISIP_subscribe": True,
        "IBISIP_server_port": 8080,
        "IBISIP_server_path": "/CustomerInformationSubscriber/CurrentDisplayContentSub",
        "IBISIP_cis_preamble": "CustomerInformationService.GetCurrentDisplayContentResponse",
        "IBISIP_time_stamp": "CurrentDisplayContentData;TimeStamp;Value",
        "IBISIP_line_number": "CurrentDisplayContentData;CurrentDisplayContent;LineInformation;LineRef;Value",
        "IBISIP_destination": "CurrentDisplayContentData;CurrentDisplayContent;Destination;DestinationRef;Value",
    }

    hw = {
        "unit_IP": "192.168.10.31",
        "hw_type": "con"
    }

    ciss = CustomerInformationServiceSubscriber(configs, hw)
    ciss.look_for_service()
    ciss.run(True)