# Copyright 3D Control Systems, Inc. All Rights Reserved 2017-2019.
# Built in San Francisco.

# This software is distributed under a commercial license for personal,
# educational, corporate or any other use.
# The software as a whole or any parts of it is prohibited for distribution or
# use without obtaining a license from 3D Control Systems, Inc.

# All software licenses are subject to the 3DPrinterOS terms of use
# (available at https://www.3dprinteros.com/terms-and-conditions/),
# and privacy policy (available at https://www.3dprinteros.com/privacy-policy/)


import re
import sys
import time
import simplejson as json
import uuid
import http.client
import logging
import subprocess
import threading

import config
import version
import platforms
import client_ssl_context


def get_printerinterface_protocol_connection():
    protocol = config.get_settings()["protocol"]
    protobuf = False
    user_login = protocol["user_login"]
    zmq = protocol["ZMQ"]
    if user_login:
        if protobuf:
            if zmq:
                from zmq_client import ZMQClient
                connection = ZMQClient
            else:
                connection = ProtobufPrinterHTTPClient
        else:
            connection = HTTPClient
    else:
        if not protobuf and not zmq:
            connection = HTTPClientPrinterAPIV1
        else:
            raise AssertionError("Incompatible protocols parameters: protobuf without user_login or ZMQ")
    return connection


def remove_same_errors(message_pack):
    if 'errors' in message_pack:
        errors_set = set()
        errors = []
        for error in message_pack['errors']:
            error_tuple = tuple(error.items())
            if error_tuple not in errors_set:
                errors_set.add(error_tuple)
                errors.append(error)
        message_pack['errors'] = errors


class HTTPClient:

    URL = config.get_settings()['URL']
    HTTPS_MODE = config.get_settings()['protocol']['encryption']
    CUSTOM_PORT = config.get_settings()['protocol'].get('custom_port', 0)
    BASE_TIMEOUT = 6
    MAX_TIMEOUT = BASE_TIMEOUT * 5
    RECONNECTION_ATTEMPT_DELAY = BASE_TIMEOUT / 2

    API_PREFIX = '/streamerapi/'
    USER_LOGIN =  'user_login'
    PRINTER_LOGIN = 'printer_login'
    COMMAND = 'command'
    TOKEN_SEND_LOGS = 'sendlogs'
    CAMERA = 'camera' #json['image': base64_image ]
    CAMERA_IMAGEJPEG = 'camera_image_jpeg' # body is pure binary jpeg data, but header got id information
    DEFAULT_HEADERS = {"Content-Type": "application/json"}
    EMPTY_COMMAND = {"command" : None}
    SEND_LOGS_TOKE_FIELD_NAME = 'user_token'

    def __init__(self, parent, keep_connection_flag = True, debug = True, exit_on_fail=False):
        self.logger = logging.getLogger(__name__)
        self.parent = parent
        self.parent_usb_info = getattr(parent, 'usb_info', None)
        if debug:
            self.logger.setLevel('DEBUG')
        else:
            self.logger.setLevel('INFO')
        self.connection_lock = threading.Lock()
        self.keep_connection_flag = keep_connection_flag
        self.exit_on_fail = exit_on_fail
        self.timeout = self.BASE_TIMEOUT
        if hasattr(parent, 'parent'): #TODO refactor mess with non universal mac and local_ip
            app = parent.parent
        else:
            app = parent
        serial_number = self.get_serial_number()
        if serial_number:
            self.mac = serial_number
        else:
            self.mac = getattr(getattr(app, 'user_login', None), 'mac', None)
        self.local_ip = None
        self.lock = threading.RLock()
        if self.CUSTOM_PORT:
            self.port = self.CUSTOM_PORT
        elif self.HTTPS_MODE:
            self.port = 443
        else:
            self.port = 80
        self.connection = self.connect()

    def get_mac_add_or_serial_number(self):
        mac = self.get_serial_number()
        if not mac:
            mac = self.get_mac_for_current_ip()
        if not mac:
            time.sleep(1)
            mac = self.get_mac_for_current_ip()
        if not mac:
            self.logger.warning("Warning! Can't get MAC address! Using uuid.getnode()")
            mac = hex(uuid.getnode())
        return mac 

    # machine id is mac address, but on RPi we use machine serial
    @staticmethod
    def get_serial_number():
        if sys.platform.startswith('linux'):
            stdout = subprocess.run(['cat', '/proc/cpuinfo'], stdout=subprocess.PIPE, universal_newlines=True).stdout
            stdout = stdout.replace('\t', '').split('\n')
            for item in stdout:
                if 'Serial: ' in item:
                    serial = item.replace('Serial: ', '').strip()
                    if serial and serial == '0' * len(serial):
                        return None
                    return serial

    @staticmethod
    def format_mac_addr(mac):
        return '0x' + mac.replace(':', '').replace('-', '').lower() + 'L'

    def get_mac_for_current_ip(self):
        if platforms.PLATFORM in ("rpi", 'linux', "mac"):
            stdout = subprocess.run(['ifconfig'], stdout=subprocess.PIPE, universal_newlines=True).stdout
            for splitter in ("flags=", "Link"):
                if splitter in stdout:
                    interfaces = stdout.split(splitter) #can get name of interface wrong, but we don't need name
                    break
            else:
                return False
            for interface in interfaces:
                if 'inet ' + self.local_ip in interface:
                    search = re.search('ether\s([0-9a-f\:]+)', interface)
                    if search:
                        return self.format_mac_addr(search.group(1))
                elif 'inet addr:' + self.local_ip in interface:
                        search = re.search('HWaddr\s([0-9a-f\:]+)', interface)
                        if search:
                            return self.format_mac_addr(search.group(1))
        else:
            stdout = subprocess.run(['ipconfig', '/all'], stdout=subprocess.PIPE, universal_newlines=True).stdout
            interfaces = stdout.split("\n\n")
            for interface in interfaces:
                search = re.search('IP.*:\s(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})', interface)
                if search:
                    ip = search.group(1)
                    if ip == self.local_ip:
                        search = re.search('[A-F0-9]{2}\-[A-F0-9]{2}\-[A-F0-9]{2}\-[A-F0-9]{2}\-[A-F0-9]{2}\-[A-F0-9]{2}', interface)
                        if search:
                            return self.format_mac_addr(search.group(0))

    def connect(self):
        self.logger.debug('{ Connecting...')
        while not self.parent.stop_flag and not getattr(self.parent, "offline_mode", False):
            with self.connection_lock:
                if self.HTTPS_MODE:
                    connection_class = http.client.HTTPSConnection
                    kwargs = {'context': client_ssl_context.SSL_CONTEXT}
                else:
                    connection_class = http.client.HTTPConnection
                    kwargs = {}
                try:
                    connection = connection_class(self.URL, port = self.port, timeout = self.timeout, **kwargs)
                    connection.connect()
                    self.local_ip = connection.sock.getsockname()[0]
                    if not self.mac:
                        self.mac = self.get_mac_add_or_serial_number()
                except Exception as e:
                    self.parent.register_error(5, 'Error during HTTP connection: ' + str(e))
                    self.logger.debug('...failed }')
                    self.logger.warning('Warning: connection to %s failed.' % self.URL)
                    if self.exit_on_fail:
                        return
                    if self.timeout < self.MAX_TIMEOUT:
                        self.timeout += self.BASE_TIMEOUT
                    time.sleep(1)
                else:
                    self.logger.debug('...success }')
                    self.logger.info('Connected to server from: %s %s' % (self.local_ip, self.mac))
                    return connection

    def request(self, method, connection, path, payload, headers=None):
        self.logger.debug('{ Requesting...')
        if headers is None:
            headers = self.DEFAULT_HEADERS
            headers = {"Content-Type": "application/json"}
        headers["Content-Length"] = len(payload)
        if self.keep_connection_flag:
            headers['Connection'] = 'keep-alive'
        try:
            connection.request(method, path, payload, headers)
            resp = connection.getresponse()
        except Exception as e:
            self.parent.register_error(6, 'Error during HTTP request:' + str(e), is_info=True)
            time.sleep(1)
        else:
            self.logger.debug('Response status: %s %s' % (resp.status, resp.reason))
            try:
                received = resp.read()
            except Exception as e:
                self.parent.register_error(7, 'Error reading response: ' + str(e), is_info=True)
            else:
                if resp.status == http.client.OK and resp.reason == "OK":
                    self.logger.debug("...success }")
                    return received
                else:
                    message = 'Error: server responded with non 200 OK:\t%s %s %s' %\
                            (resp.status, resp.reason, received)
                    self.parent.register_error(8, message, is_info=True)
        self.logger.debug('...failed }')
        self.logger.warning('Warning: HTTP request failed!')

    def pack_and_send(self, target, *payloads, **kwargs_payloads):
        with self.lock:
            path, packed_message = self.pack(target, *payloads, **kwargs_payloads)
            self.logger.debug("Message: %s\n%s" % (path, str(packed_message)))
            return self.send(path, packed_message)

    def send(self, path, data, headers = None):
        while not self.parent.stop_flag and not getattr(self.parent, "offline_mode", False):
            if not self.connection:
                self.connection = self.connect()
            if self.connection:
                answer = self.request('POST', self.connection, path, data, headers)
            elif self.exit_on_fail:
                return
            else:
                answer = None
            if answer == None or not self.keep_connection_flag:
                self.close()
                time.sleep(self.RECONNECTION_ATTEMPT_DELAY) # Some delay before retry reconnection
            return self.unpack(answer, path)

    def pack(self, target, *args, **kwargs):
        if target == self.USER_LOGIN:
            message = { 'login': {'user': args[0], 'password': args[1]},
                     'platform': platforms.PLATFORM, 'host_mac': self.mac,
                     'local_ip': self.local_ip, 'version': version.version }
            if 'disposable_token' in kwargs:
                message['login']['disposable_token'] = kwargs['disposable_token']
        elif target == self.PRINTER_LOGIN:
            message = { 'user_token': args[0], 'printer': args[1], 'version': version.version,
                     'message_time': time.ctime(), 'camera': config.get_app().camera_controller.get_current_camera_name() }
        elif target == self.COMMAND:
            message = { 'printer_token': args[0], 'report': args[1], 'command_ack': args[2] }
            if not message['command_ack']:
                message.pop('command_ack')
        elif target == self.CAMERA:
            message = { 'user_token': args[0], 'camera_number': args[1], 'camera_name': args[2],
                     'file_data': args[3], 'host_mac': self.mac }
        elif target == self.CAMERA_IMAGEJPEG:
            message = { 'user_token': args[0], 'camera_number': args[1], 'camera_name': args[2], 'host_mac': self.mac }
        else:
            self.parent.register_error(4, 'No such target for packaging: ' + target)
            message, target = None, None
        for key, value in list(kwargs.items()):
            message[key] = value
        remove_same_errors(message)
        #self.logger.info(f"Message: {target} {message}")
        return self.API_PREFIX + target, json.dumps(message)

    def unpack(self, jdata, path):
        try:
            if jdata:
                data = json.loads(jdata)
            else:
                data = self.EMPTY_COMMAND
                self.logger.debug(f"Empty response on path {path}")
        except (ValueError, TypeError):
            self.parent.register_error(2, f'Response on {path} is not valid json: {jdata}')
        else:
            if data == []: # this is needed to support '[]' answer that exist in the protocol due to shitcode
                data = self.EMPTY_COMMAND
            if type(data) == dict or type(data) == list: # NOTE == list is used only for printer profiles
                return data
            else:
                message = f'Response on {path} is not dictionary or list. {type(data)} {data}'
                self.parent.register_error(3, message)

    def get_parent_name(self):
        parent_name = "None"
        parent = getattr(self, "parent")
        if parent:
            parent_name = str(parent.__class__.__name__)
        return parent_name

    def close(self):
        self.logger.info("Closing connection to server")
        with self.connection_lock:
            if self.connection:
                self.connection.close()
                self.connection = None


class HTTPClientPrinterAPIV1(HTTPClient):

    API_PREFIX = '/apiprinter/v1/printer/'
    REGISTER = 'register'
    PRINTER_PROFILES = 'get_printer_profiles'
    GET_JOBS = 'get_queued_jobs'
    START_JOB = 'start_queued_job'
    SEND_LOGS_TOKE_FIELD_NAME = 'auth_token'

    @staticmethod
    def patch_api_prefix(url):
        vendor = str(config.get_settings().get('vendor', {}).get('name', '')).lower()
        if vendor:
            return url.replace("/v1", "/v1/" + vendor)
        else:
            return url

    def __init__(self, parent, keep_connection_flag = True, debug = False, exit_on_fail = False):
        self.logger = logging.getLogger(__name__)
        self.API_PREFIX = self.patch_api_prefix(self.API_PREFIX)
        self.logger.info("Switching URL path to " + self.API_PREFIX)
        super().__init__(parent, keep_connection_flag, debug, exit_on_fail)

    def pack(self, target, *args, **kwargs):
        if target == self.PRINTER_PROFILES:
            message = {}
        elif target == self.REGISTER:
            message = { 'mac': self.mac, 'version': version.version }
            for key in ('VID', 'PID', 'SNR', 'type'):
                message[key] = kwargs[key]
            for key in ('registration_code', 'registration_code_ttl'):
                if key in kwargs:
                    message[key] = kwargs[key]
        elif target == self.COMMAND:
            message = { 'auth_token': args[0], 'report': args[1], 'command_ack': args[2] }
            if not message['command_ack']:
                message.pop('command_ack')
        elif target == self.CAMERA:
            message = { 'auth_token': args[0], 'image': args[3] }
        elif target == self.CAMERA_IMAGEJPEG:
           message = "Binary jpeg camera path is not supported by APIprinter(user_login : false)"
           self.logger.error(message)
           raise RuntimeError(message)
        elif target == self.GET_JOBS:
            message = {"auth_token": args[0]}
        elif target == self.START_JOB:
            message = {"auth_token": args[0], "job_id": args[1] }
        else:
           self.logger.error(f"Invalid http_client pack call:{target}, {args}, {kwargs}")
           return self.COMMAND, {}
        message.update(kwargs)
        remove_same_errors(message)
        #self.logger.info(f"Message: {target} {message}")
        return self.API_PREFIX + target, json.dumps(message)

    def get_jobs_list(self, token):
        jobs_list_or_error = self.pack_and_send(self.GET_JOBS, token)
        self.logger.debug(f"Jobs list: {jobs_list_or_error}")
        if not jobs_list_or_error:
            self.logger.debug("Empty jobs queue")
            return [], None
        if isinstance(jobs_list_or_error, dict):
            error = jobs_list_or_error.get('error')
            code = jobs_list_or_error.get('code')
            if error:
                return [], jobs_list_or_error
            else:
                self.logger.warning("Empty jobs queue")
                return [], None
        elif isinstance(jobs_list_or_error, list):   
            return jobs_list_or_error, None
        self.logger.warning('Unexpected response on get queued jobs list request!\n' + str(jobs_list_or_error))
        return [], None

    def start_job_by_id(self, token, job_id):
        response = self.pack_and_send(self.START_JOB, token, job_id)
        self.logger.debug(f"Start job result: {response}")
        if response == None:
            return False
        elif response == "" or response == []:
            return True
        elif response:
            if isinstance(response, dict):
                if 'error' in response:
                    self.logger.warning('Error stating job: ' + str(response))
                    return False
                else:
                    return True
        self.logger.warning('Unexpected response on start job request!')
        return False

    #  def start_next_job(self, token):
    #      jobs_list_or_error = self.pack_and_send(self.GET_JOBS, token)
    #      if jobs_list_or_error:
    #          self.logger.info(f"Jobs list: {jobs_list_or_error}\n")
    #          if isinstance(jobs_list_or_error, dict):
    #              error = jobs_list_or_error.get('error')
    #              code = jobs_list_or_error.get('code')
    #              if not error:
    #                  self.logger.warning("Empty jobs queue")
    #                  return False
    #          elif isinstance(jobs_list_or_error, list):   
    #              first_job_dict = jobs_list_or_error[0]
    #              first_job_id = first_job_dict.get("id")
    #              if first_job_id:
    #                  if self.pack_and_send(self.START_JOB, token, first_job_id) != None:
    #                      return True
    #              else:
    #                  self.logger.warning("No job id to send start job requests to the cloud")
    #      else:
    #          self.logger.warning("Empty jobs queue")
    #      return False
