# 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 time
import simplejson as json
import logging
import threading
import pprint

import config
import log
import downloader
import http_client


class PrinterInterface(threading.Thread):

    DEFAULT_OPERATIONAL_TIMEOUT = 10
    APIPRINTER_REG_PERIOD = 2

    def __init__(self, parent, usb_info, command_request_period = 5):
        self.command_request_period = command_request_period
        self.logger = logging.getLogger(__name__)
        self.creation_time = time.monotonic()
        self.stop_flag = False
        self.stop_in_next_loop_flag = False
        self.usb_info = usb_info
        self.parent = parent
        self.server_connection = None
        self.server_connection_class = http_client.get_printerinterface_protocol_connection()
        self.printer = None
        self.printer_profile = {}
        self.downloader = None
        self.forced_state = "connecting"
        self.printer_name = ""
        self.job_id = ""
        self.groups = []
        self.errors = []
        self.errors_lock = threading.Lock()
        self.last_error = None
        self.local_mode = False
        self.requests_to_server = {}
        self.requests_lock = threading.Lock()
        self.show_printer_type_selector = False
        self.timeout = self.DEFAULT_OPERATIONAL_TIMEOUT
        self.possible_printer_types = self.get_possible_printer_types()
        # self.current_camera = self.parent.camera_controller.get_current_camera_name()
        self.current_camera = "" # camera name will be selected and sent on a first command request
        self.try_load_auth_token()
        self.registration_code = None
        self.force_printer_type()
        self.offline_mode = parent.offline_mode
        self.logger.info('New printer interface for %s' % str(usb_info))
        super().__init__(name="PrinterInterface%s" % self.usb_info)

    def connect_to_server(self):  # TODO refactor this mess. move answer processing to protocol and overall API
        self.logger.info("Connecting to server with printer: %s" % str(self.usb_info))
        if self.printer_token:
            self.server_connection = self.server_connection_class(self)
            return True
        while not self.stop_flag and not self.parent.stop_flag:
            if self.offline_mode:
                return True
            if [error for error in self.errors if error['is_blocking'] and error.get('sent') and error.get('displayed')]:
                self.stop_flag = True
                return False
            if self.parent.user_login.user_token:
                if not self.server_connection:
                    self.server_connection = self.server_connection_class(self)
                message = [http_client.HTTPClient.PRINTER_LOGIN, self.parent.user_login.user_token, self.usb_info]
                kw_message = {}
                with self.requests_lock:
                    if 'select_printer_type' in self.requests_to_server:
                        kw_message['select_printer_type'] = self.requests_to_server['select_printer_type']
                        del self.requests_to_server['select_printer_type']
                self.logger.info('Printer login request:\n%s\n%s ' % (str(message), str(kw_message)))
            else:
                if not self.server_connection:
                    self.server_connection = self.server_connection_class(self)
                message = [http_client.HTTPClientPrinterAPIV1.REGISTER]
                kw_message = dict(self.usb_info)
                kw_message['type'] = self.printer_profile['alias']
                if self.registration_code:
                    kw_message['registration_code'] = self.registration_code
                self.logger.debug('Printer registration request:\n%s\n%s ' % (str(message), str(kw_message)))
            answer = self.server_connection.pack_and_send(*message, **kw_message)
            if not answer:
                self.logger.warning("Error on printer login. No connection or answer from server.")
                time.sleep(self.command_request_period)
            elif self.parent.user_login.user_token:
                error = answer.get('error')
                self.printer_name = answer.get('name', '')
                if error:
                    self.logger.warning("Error while login %s:" % str(self.usb_info))
                    self.logger.warning(str(error['code']) + " " + error["message"])
                    if str(error['code']) == '8':
                        self.show_printer_type_selector = True
                        time.sleep(self.APIPRINTER_REG_PERIOD)
                        continue
                    else:
                        self.register_error(26, "Server had returned error: %s" % str(error))
                        return False
                elif self.parent.user_login.user_token and answer.get('printer_profile') == "":
                    self.register_error(62, "Server return message with empty printer_profile field: %s" % str(error))
                    time.sleep(2)
                else:
                    groups = answer.get('groups')
                    if groups:
                        self.set_groups(groups)
                    self.show_printer_type_selector = False
                    self.printer_rename_request = False
                    self.logger.info('Successfully connected to server.')
                    self.printer_token = answer['printer_token']
                    self.logger.info('Received answer: ' + str(answer))
                    self.printer_profile = json.loads(answer["printer_profile"])
                    custom_timeout = self.printer_profile.get("operational_timeout", None)
                    if custom_timeout:
                        self.timeout = custom_timeout
                    with self.requests_lock: # to be able to send printer type reset request
                        if self.requests_to_server:
                            message, kw_message = self.form_command_request(None)
                            kw_message.update(self.requests_to_server)
                            self.server_connection.pack_and_send('command', *message, **kw_message)
                    self.logger.debug('Setting profile: ' + str(self.printer_profile))
                    return True
            else:
                error = answer.get('error')
                if error:
                    self.logger.warning("Error while registing %s:" % str(self.usb_info))
                    self.logger.warning(str(error['code']) + " " + error["message"])
                else:
                    registration_code = answer.get('registration_code')
                    if registration_code:
                        self.registration_code = registration_code
                        self.logger.info("Registration code received: %s" % str(registration_code))
                    auth_token = answer.get('auth_token')
                    if auth_token:
                        self.registration_code = None
                        self.logger.info("Auth token received")
                        self.printer_token = auth_token
                        self.parent.user_login.save_printer_auth_token(self.usb_info, auth_token)
                        self.restart_camera(auth_token)
                        return True
                time.sleep(self.command_request_period)

    def connect_to_printer(self):
        sender_name = self.printer_profile['sender']
        try:
            printer_sender = __import__(sender_name)
        except ImportError:
            message = "Printer type %s not supported by our version of 3DPrinterOS client." % sender_name
            self.register_error(128, message, is_blocking=True)
        else:
            self.logger.info("Connecting with profile: " + str(self.printer_profile))
            try:
                printer = printer_sender.Sender(self, self.usb_info, self.printer_profile)
            except RuntimeError as e:
                message = "Can't connect to printer %s %s\nReason: %s" %\
                          (self.printer_profile['name'], str(self.usb_info), str(e))
                self.register_error(119, message, is_blocking=True)
            else:
                self.logger.info("Successful connection to %s!" % (self.printer_profile['name']))
                return printer

    def form_command_request(self, acknowledge):
        message = [self.printer_token, self.state_report(), acknowledge]
        kw_message = {}
        errors_to_send = self.get_errors_to_send()
        if errors_to_send:
            kw_message['error'] = errors_to_send
        self.check_camera_name()
        with self.requests_lock:
            kw_message.update(self.requests_to_server)
            self.requests_to_server = {}
        requests_to_stop_after = ['reset_printer_type']
        for stop_after in requests_to_stop_after:
            if stop_after in kw_message:
                self.stop_in_next_loop_flag = True
                break
        self.logger.info("Request:\n%s\n%s" % (str(message), str(kw_message)))
        return message, kw_message

    def check_operational_status(self):
        if not self.printer or self.stop_flag:
            pass
        elif self.printer.stop_flag:
            pass
        elif self.printer.is_operational():
            self.last_operational_time = time.monotonic()
            self.forced_state = None
            self.last_errors = []
            if self.offline_mode:
                self.errors = [] 
        elif not self.forced_state:
            message = "Printer is not operational"
            self.register_error(77, message, is_blocking=False)
        elif self.forced_state != "error" and self.last_operational_time + self.timeout < time.monotonic():
            message = "Not operational timeout reached"
            self.register_error(78, message, is_blocking=True)
        for error in self.errors:
            if error['is_blocking']:
                self.forced_state = "error"
                #self.close_printer_sender()
                self.stop_in_next_loop_flag = True
            elif not self.forced_state and not error.get('is_info'):
                self.forced_state = "connecting"

    @log.log_exception
    def run(self):
        if self.offline_mode or self.connect_to_server():
            if not self.printer_profile:
                self.logger.warning("Can't connect to printer in a local_mode due to lack of printer profile.")
                return
            self.printer = self.connect_to_printer()
            self.last_operational_time = time.monotonic()
            acknowledge = None
            send_reset_job = True
            while not self.parent.stop_flag and not self.stop_flag:
                self.check_operational_status()
                message, kw_message = self.form_command_request(acknowledge)
                if not self.offline_mode:
                    if send_reset_job:
                        kw_message['reset_job'] = True
                        send_reset_job = False
                    answer = self.server_connection.pack_and_send('command', *message, **kw_message)
                    if answer:
                        self.flush_errors(kw_message.get("error", []))
                        self.logger.info("Answer: " + str(answer))
                        acknowledge = self.execute_server_command(answer)
                    if self.stop_in_next_loop_flag:
                        self.stop_flag = True
                if not self.stop_flag and not self.parent.stop_flag:
                    time.sleep(self.command_request_period)
            if self.server_connection:
                self.server_connection.close()
            self.close_printer_sender()
            self.logger.info('Printer interface was stopped')

    def validate_command(self, server_message):
        command = server_message.get('command')
        if command:
            number = server_message.get('number')
            false_ack = {"number": number, "result": False}
            if self.local_mode:
                self.register_error(111, "Can't execute command %s while in local_mode!" % command, is_blocking=False)
                return false_ack
            if type(number) != int:
                message = "Error in number field of server's message: " + str(server_message)
                self.register_error(41, message,  is_blocking=False)
                return false_ack
            if not hasattr(self, command) and not hasattr(self.printer, command):
                self.register_error(40, "Unknown command:'%s' " % str(command), is_blocking=False)
                return false_ack

    def execute_server_command(self, server_message):
        validation_error = self.validate_command(server_message)
        if validation_error:
            if validation_error['number']:
                return validation_error
        error = server_message.get('error')
        if error:
            self.logger.warning("@ Server return error: %s\t%s" % (error.get('code'), error.get('message')))
        command, number = server_message.get('command'), server_message.get('number')
        if command:
            self.logger.info(f"Command received: " + pprint.pformat(server_message))
            if [error for error in self.errors if not error.get('sent')]:
                self.logger.error("Cannot execute server command in error state.")
                return { "number": number, "result": False }
            self.logger.info("Executing command number %i : %s" % (number, str(command)))
            method = getattr(self, command, None)
            if not method:
                method = getattr(self.printer, command)
            payload = server_message.get('payload')
            if server_message.get('is_link'):
                if self.downloader and self.downloader.is_alive():
                    self.register_error(108, "Can't start new download, because previous download isn't finished.")
                    result = False
                else:
                    if command == 'gcodes':
                        filename = server_message.get('filename')
                        if filename: # gcodes as text should not set a print duration
                            self.printer.set_filename(filename)
                    if config.get_settings().get('print_estimation', {}).get('by_cloud', False):
                        print_time = server_message.get('printing_duration', 0)
                    else:
                        print_time = 0
                    self.printer.set_estimated_print_time(print_time)
                    self.downloader = downloader.Downloader(self, payload, method, is_zip=bool(server_message.get('zip')))
                    self.downloader.start()
                    result = True
            else:
                if payload:
                    arguments = [payload]
                else:
                    arguments = []
                try:
                    result = method(*arguments)
                    # to reduce needless 'return True' in methods, we assume that return of None, that is a successful call
                    result = result or result == None
                except Exception as e:
                    message = "! Error while executing command %s, number %d.\t%s" % (command, number, str(e))
                    self.register_error(109, message, is_blocking=False)
                    self.logger.exception(message)
                    result = False
            ack = { "number": number, "result": result }
            return ack

    def get_printer_state(self):
        if self.forced_state:
            state = self.forced_state
        elif (self.printer and self.printer.stop_flag) or not self.printer or self.stop_flag:
            state = 'closing'
        elif self.printer.is_paused():
            state = "paused"
        elif self.downloader and self.downloader.is_alive():
            state = "downloading"
        elif self.printer.is_printing():
            state = "printing"
        elif self.local_mode:
            state = 'local_mode'
        else:
            state = "ready"
        return state

    def state_report(self):
        report = {"state": self.get_printer_state()}
        if self.printer:
            try:
                if self.is_downloading():
                    report["percent"] = self.printer.get_downloading_percent()
                else:
                    report["percent"] = self.printer.get_percent()
                report["temps"] = self.printer.get_temps()
                report["target_temps"] = self.printer.get_target_temps()
                report["line_number"] = self.printer.get_current_line_number()
                report["coords"] = self.printer.get_position()
                if self.printer.responses:
                    report["response"] = self.printer.responses[:]
                    self.printer.responses = []
                report.update(self.printer.get_nonstandart_data())
            except Exception as e:
                # update printer state if in was disconnected during report
                report["state"] = self.get_printer_state()
                self.logger.warning("! Exception while forming printer report: " + str(e))
        return report

    def get_errors_to_send(self):
        errors_to_send = []
        with self.errors_lock:
            if self.errors:
                for error in self.errors:
                    if not error.get('sent'):
                        if not error in errors_to_send:
                            errors_to_send.append(error)
        return errors_to_send

    def get_last_error_to_display(self):
        with self.errors_lock:
            if self.errors:
                try:
                    last_error = [error for error in self.errors \
                            if not error.get('displayed') and not error.get('cloud_only')][-1]
                    displayed_error = dict(last_error)
                    last_error['displayed'] = True
                    return displayed_error
                except IndexError:
                    pass

    def flush_errors(self, sent_errors):
        with self.errors_lock:
            for error in sent_errors:
                error['sent'] = True
            for error in self.errors:
                #if error.get('sent') and error.get('displayed'):
                if error.get('sent'):
                    self.errors.remove(error)
                
    def register_error(self, code, message, is_blocking=False, static=False, is_info=False, only_cloud=False):
        with self.errors_lock:
            error = {"code": code, "message": message, "is_blocking": is_blocking}
            if is_info:
                error['is_info'] = True
            if static:
                error['static'] = True
            if only_cloud:
                error['only_cloud'] = True
            for existing_error in self.errors:
                if error['code'] == existing_error['code']:
                    self.logger.info("Error(repeat) N%d. %s" % (code, message))
                    break 
            else:
                self.errors.append(error)
            self.logger.warning("Error N%d. %s" % (code, message))

    def check_camera_name(self):
        camera_name = self.parent.camera_controller.get_current_camera_name()
        if self.current_camera != camera_name:
            self.logger.info("Camera change detected")
            with self.requests_lock:
                self.requests_to_server['camera_change'] = camera_name
            self.current_camera = camera_name

    def get_possible_printer_types(self):
        all_printer_profiles = self.parent.user_login.profiles
        current_printer_vid_pid = [self.usb_info['VID'], self.usb_info['PID']]
        possible_types = [profile for profile in all_printer_profiles if current_printer_vid_pid in profile['vids_pids']]
        possible_types = list(sorted(possible_types, key=lambda pt: pt['name']))
        self.logger.info("Possible printer types: " + str(possible_types))
        return possible_types

    def request_printer_type_selection(self, printer_profile_or_alias):
        with self.requests_lock:
            if type(printer_profile_or_alias) == dict:
                alias = printer_profile_or_alias['alias']
            elif type(printer_profile_or_alias) == str:
                alias = printer_profile_or_alias
            self.requests_to_server['select_printer_type'] = alias
        self.logger.info("Requesting printer type selection: " + 'alias')

    def request_printer_groups_selection(self, groups):
        with self.requests_lock:
            self.requests_to_server['selected_groups'] = groups
        self.logger.info("Groups to select: " + str(groups))

    def request_printer_rename(self, name):
        with self.requests_lock:
            self.requests_to_server['select_name'] = name
        self.logger.info("Requesting printer rename: " + str(name))

    def request_reset_printer_type(self):
        with self.requests_lock:
            self.requests_to_server['reset_printer_type'] = True
        self.logger.info("Setting up flag to reset print type in next command request")

    def set_printer_name(self, name):
        self.printer_name = name

    def get_groups(self):
        return self.groups

    def set_groups(self, groups):
        if type(groups) == list:
            self.logger.info("Groups are set correctly for %s %s" % (str(self.usb_info), str(groups)))
            self.groups = groups
        else:
            self.register_error(999, "Invalid workgroups format - type must be list: " + str(groups), is_blocking=False)

    def turn_on_local_mode(self):
        self.local_mode = True

    def turn_off_local_mode(self):
        self.local_mode = False
        try:
            self.printer.flush_response_callbacks()
        except (AttributeError, RuntimeError):
            pass

    def upload_logs(self):
        self.logger.info("Sending logs")
        if log.report_problem('logs'):
            return False

    def switch_camera(self, module):
        self.logger.info('Changing camera module to %s due to server request' % module)
        self.parent.camera_controller.switch_camera(module, self.printer_token)
        return True

    def restart_camera(self, token=None):
        self.logger.info('Executing camera restart command from server')
        self.parent.camera_controller.restart_camera(token)

    def update_software(self):
        self.logger.info('Executing update command from server')
        self.parent.updater.update()

    def quit_application(self):
        self.logger.info('Received quit command from server!')
        self.parent.stop_flag = True

    def set_name(self, name):
        self.logger.info("Setting printer name: " + str(name))
        self.set_printer_name(name)

    def is_downloading(self):
        return self.downloader and self.downloader.is_alive()

    def cancel_locally(self):
        cancel_result = self.cancel() 
        if cancel_result or cancel_result == None:
            self.register_error(117, "Canceled locally", is_blocking=True, only_cloud=True)
        else:
            self.register_error(1170, "Failed to canceled locally", is_blocking=False, is_info=True)

    def cancel(self):
        if self.is_downloading():
            self.logger.info("Canceling downloading")
            self.downloader.cancel()
            return True
        else:
            self.logger.info("Canceling print")
            if self.printer:
                return self.printer.cancel()

    def send_bed_clear(self):
        self.logger.info("Adding bed clear to request")
        with self.requests_lock:
            self.requests_to_server['bed_clear'] = True
        return True

    def close_printer_sender(self):
        if self.printer and not self.printer.stop_flag:
            self.logger.info('Closing ' + str(self.printer_profile))
            self.printer.close()
            self.logger.info('...closed.')

    def close(self):
        self.logger.info('Closing printer interface of %s %s' % (getattr(self, "printer_name", "nameless printer"), str(self.usb_info)))
        self.stop_flag = True

    def report_problem(self, problem_description):
        log.report_problem(problem_description)

    def try_load_auth_token(self):
        self.printer_token = None
        for item in self.parent.user_login.auth_tokens:
            auth_token_usb_info = dict(item[0])
            auth_token_usb_info['COM'] = None
            usb_info = dict(self.usb_info)
            usb_info['COM'] = None
            if auth_token_usb_info == usb_info:
                self.printer_token = item[1]
                self.logger.info("Auth token load success: " + str(self.printer_token))
                break

    def get_remaining_print_time(self):
        try:
            if self.printer:
                self.printer.get_remaining_print_time()
        except AttributeError:
            pass

    def force_printer_type(self):
        forced_type = self.usb_info.get('forced_type')
        if self.parent.user_login.user_token:
            if 'forced_type' in self.usb_info:
                self.request_printer_type_selection(forced_type)
        else:
            for profile in self.parent.user_login.profiles:
                if forced_type:
                    if profile['alias'] == forced_type:
                        self.printer_profile = profile
                        break
                elif [self.usb_info['VID'], self.usb_info['PID']] in profile['vids_pids']:
                    self.printer_profile = profile
                    return

    def get_jobs_list(self):
        self.logger.info("Requesting a jobs list")
        if self.server_connection:
            jobs_list = self.server_connection.get_jobs_list(self.printer_token)
            self.logger.info("Cloud's jobs list:\n" + pprint.pformat(jobs_list))
            return jobs_list
        return [], {"message": "No connection to server", "code": 9}

    def start_job_by_id(self, job_id):
        self.logger.info("Sending a request to starting a next job")
        if self.server_connection:
            return self.server_connection.start_job_by_id(self.printer_token, job_id)

    #  def start_next_job(self):
    #      self.logger.info("Sending a request to starting a next job")
    #      if self.server_connection:
    #          return self.server_connection.start_next_job(self.printer_token)
