# 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 logging
import threading
import queue
import sys
import re
from PySide6 import QtCore

import config
import log


class NoPrinter(Exception):
    pass


class PrinterModel(QtCore.QObject):

    DONT_SHOW_ERRORS_WITH_CODES = [2, 3, 4, 5, 6, 7, 8, 117, 119]

    SLEEP_ON_PRINTER_INTERFACE_FAIL = 0.5

    UPDATE_LOOP_TIME = 1
    TRANSMIT_CALLBACKS_LOOP_TIME = 0.1

    QUEUE_SIZE = 1000

    Z_OFFSET_STEP = 0.01

    #"X:0.000 Y:0.000 Z:127.000 E:0.000 E0:0.0 E1:0.0 E2:0.0 E3:0.0 E4:0.0 E5:0.0 E6:0.0 E7:0.0 E8:0.0 Count 0 0 80000 Machine 0.000 0.000 100.000 Bed comp 0.000"
    Z_COORD_RE = re.compile(r"Z:(-?[\d\.]+)")
    #Z_PROBE_TRIGGER_HEIGHT_RE = re.compile("trigger height (-?[\d\.]+)")

    DOOR_AXIS_NAME = 'U'

    # QProperty signals
    on_token = QtCore.Signal()
    on_firstExtruderTemp = QtCore.Signal()
    on_secondExtruderTemp = QtCore.Signal()
    on_heatBedTemp = QtCore.Signal()
    on_chamberTemp = QtCore.Signal()
    on_firstExtruderTargetTemp = QtCore.Signal()
    on_secondExtruderTargetTemp = QtCore.Signal()
    on_heatBedTargetTemp = QtCore.Signal()
    on_chamberTargetTemp = QtCore.Signal()
    on_currentGCodeLine = QtCore.Signal()
    on_totalGCodeLine = QtCore.Signal()
    on_percent = QtCore.Signal()
    on_zOffset = QtCore.Signal(float)
    on_joystickFeedrate = QtCore.Signal()
    on_printerError = QtCore.Signal()
    on_authToken = QtCore.Signal()
    on_responseCallback = QtCore.Signal(str, int)
    on_printTimeLeftStr = QtCore.Signal()
    on_nextJobAvailable = QtCore.Signal()
    on_door = QtCore.Signal()

    def __init__(self, core_model):
        super().__init__()
        self.logger = logging.getLogger(__name__)
        self._coreModel = core_model
        self._app = core_model._app
        self._firstExtruderTemp = 0
        self._secondExtruderTemp = 0
        self._heatBedTemp = 0
        self._chamberTemp = 0
        self._firstExtruderTargetTemp = 0
        self._secondExtruderTargetTemp = 0
        self._heatBedTargetTemp = 0
        self._chamberTargetTemp= 0
        self._percent = 0
        self._running = False
        self._token = None
        self._currentGCodeLine = 0
        self._totalGCodeLine = 0
        self._joystickFeedrate = 300
        self._printerError = ""
        self._authToken = ""
        self._responsesQueue = queue.SimpleQueue()
        self._zOffset = 0.0
        self._printTimeLeftStr = ""
        self._nextJobAvailable = False #TODO: update value
        self._nextCloudsQueueJobId = None
        self._door_open = None
        self._printerUpdateThread = threading.Thread(target=self._printerUpdateLoop)
        self._printerUpdateThread.start()
        self._responseCallbacksThread = threading.Thread(target=self._responseCallbacksLoop)
        self._responseCallbacksThread.start()

    def _getPrinterInterface(self):
        try:
            return self._app.printer_interfaces[0]
        except (IndexError, AttributeError):
            self.logger.warning("Warning: no printer interface")
            time.sleep(self.SLEEP_ON_PRINTER_INTERFACE_FAIL)

    def _getPrinter(self):
        while not self._app.stop_flag:
            pi = self._getPrinterInterface()
            if pi:
                printer = getattr(pi, "sender", None)
                if printer:
                    return pi.sender
            time.sleep(self.SLEEP_ON_PRINTER_INTERFACE_FAIL)

    @log.log_exception
    def _printerUpdateLoop(self):
        while not self._app.stop_flag:
            try:
                printer_interface = self._getPrinterInterface()
                if printer_interface:
                    self._authToken = printer_interface.printer_token
                    self.on_authToken.emit()
                    if printer_interface.sender:
                        new_door_state = printer_interface.sender.min_endstops.get(self.DOOR_AXIS_NAME)
                        if new_door_state != self._door_open:
                            self._door_open = new_door_state
                            self.on_door.emit()
                            self.logger.info('Door status change emit: ' + str(new_door_state))
                    status_report = printer_interface.status_report()
                    state = status_report['state']
                    if state not in ('connecting', 'closing', 'error'):
                        temps = list(status_report['temps'])
                        while len(temps) <= 4:
                            temps.append(0)
                        self._heatBedTemp = int(temps[0])
                        self._firstExtruderTemp = int(temps[1])
                        self._secondExtruderTemp = int(temps[2])
                        self._chamberTemp = int(temps[3])
                        # TODO refactor this
                        for signal in (self.on_firstExtruderTemp,
                                        self.on_secondExtruderTemp,
                                        self.on_heatBedTemp,
                                        self.on_chamberTemp):
                            signal.emit()
                        target_temps = list(status_report['target_temps'])
                        while len(target_temps) <= 4:
                            target_temps.append(0)
                        self._heatBedTargetTemp = int(target_temps[0])
                        self._firstExtruderTargetTemp = int(target_temps[1])
                        self._secondExtruderTargetTemp = int(target_temps[2])
                        self._chamberTargetTemp = int(target_temps[3])
                        for signal in (self.on_firstExtruderTargetTemp,
                                        self.on_secondExtruderTargetTemp,
                                        self.on_heatBedTargetTemp,
                                        self.on_chamberTargetTemp):
                            signal.emit()
                        self._percent = status_report['percent']
                        self._currentGCodeLine = status_report.get('line_number', 0)
                        self._totalGCodeLine = printer_interface.sender.total_gcodes
                        self.on_currentGCodeLine.emit()
                        self.on_totalGCodeLine.emit()
                        self.on_percent.emit()
                    if state == 'printing':
                        state = self._coreModel.STATE_PRINTING
                    elif state == 'downloading':
                        state = self._coreModel.STATE_DOWNLOADING
                    elif state == 'paused':
                        state = self._coreModel.STATE_PAUSED
                    elif state == 'ready' or state == 'local_mode':
                        state = self._coreModel.STATE_IDLE
                    else:
                        code = printer_interface.registration_code
                        if state == 'error':
                            self.on_printerError.emit()
                            if printer_interface:
                                error = printer_interface.get_last_error_to_display('QTUI')
                                if error and error.get('code') not in self.DONT_SHOW_ERRORS_WITH_CODES:
                                    level = config.get_settings()['qt_interface'].get('show_error_level', 40)
                                    if error.get('level', 0) >= level:
                                        self._coreModel.showMessage.emit("error", error["message"], "Close")
                        elif code:
                            if printer_interface and not printer_interface.offline_mode:
                                self._token = code
                                self.on_token.emit()
                                state = self._coreModel.STATE_REGISTRATION
                        elif state == 'closing' or state == 'connecting':
                            state = self._coreModel.STATE_CONNECTING
                    self._coreModel.setState(state)
            except NoPrinter:
                pass
            except:
                self.logger.exception("Exception in updateLoop:")
            finally:
                time.sleep(self.UPDATE_LOOP_TIME)

    @log.log_exception
    def _responseCallbacksLoop(self):
        while not self._app.stop_flag:
            try:
                response = self._responsesQueue.get(block=True, timeout=1)
                line = response[0].decode(errors='ignore')
                if response[1] == None:
                    code = -1
                elif response[1] == True:
                    code = 0
                else:
                    code = 1
            except queue.Empty:
                time.sleep(self.TRANSMIT_CALLBACKS_LOOP_TIME)
            except (IndexError, TypeError):
                self.logger.error(f"Invalid callback response format: {response}")
            else:
                self.on_responseCallback.emit(line, code)

    def loadGcodes(self, gcodes, print_file=True):
        while not self._app.stop_flag:
            pi = self._getPrinterInterface()
            if pi:
                printer = pi.sender
                if printer:
                    if print_file:
                        printer.gcodes(gcodes, keep_file=True)
                    else:
                        printer.unbuffered_gcodes(gcodes)
                    return True
            else:
                self.logger.error("No printer to execute gcodes")
            time.sleep(self.UPDATE_LOOP_TIME)

    def addToResponsesQueue(self, line, success):
        try:
            self._responsesQueue.put_nowait((line, success))
        except queue.Full:
            self.logger.warning("Full gcode response queue")

    def parseZcoord(self, line, success):
        if line:
            self.logger.info("Searching for Z coord in: %s" % line)
            zOffset = None
            if type(line) == bytes:
                line = line.decode('utf-8')
            match = self.Z_COORD_RE.search(line)
            if match:
                try:
                    zOffset = float(match.group(1))
                    self.logger.info("Got Z coord: %s" % zOffset)
                except:
                    pass
            if zOffset is not None:
                # self._getPrinter().flush_response_callbacks()
                self.loadGcodes(b"G91\n", print_file=False)
                self._zOffset = zOffset
                self.on_zOffset.emit(zOffset)
                self.logger.info("Found Z coord: %s" % zOffset)

    def homeAndGetZoffsetFromPrinter(self):
        try:
            self._getPrinter().add_response_callback(self.parseZcoord)
            # self._getPrinter().add_get_pos_to_all_gcodes = True
            time.sleep(1)
            self.loadGcodes(b'M98 P"prep_z_calib.g"\n', print_file=False)
        except:
            self.logger.info("No printer to add response callback")
        else:
            self.logger.info("Coords parsing callback is set and homing is started")

    def moveZ(self, z_value):
        z_value = format(z_value, '.2f')
        gcode = "G1 Z%s H2\n" % z_value
        self.loadGcodes(gcode.encode('ascii'), print_file=False)

    def sendGcode(self, gcode):
        try:
            gcode = gcode.encode()
        except:
            self.logger.error('Unable to encode: ' + str(gcode))
        else:
            self.loadGcodes(gcode, print_file=False)
            return True
        return False

    def writeFileOnDuetSD(self, filename, data, string=False):
        if not filename or not isinstance(filename, str):
            self.logger.error("Invalid filename " + str(filename))
            return False
        if string:
            try:
                data = data.encode()
            except:
                self.logger.error("Encode error on write to file on Duet's SD")
                return False
        printer = self._getPrinter()
        if printer:
            printer.stop_temp_requesting()
            if self.sendGcode(f"M28 {filename}\n"):
                if self.sendGcode(data):
                    if self.sendGcode("M29\n"):
                        printer.start_temp_requesting()
                        return True
            printer.start_temp_requesting()
        self.logger.error("Error saving file to Duet's SD")
        return False

    @QtCore.Slot()
    def startNextJob(self):
        self.logger.info("TODO: Start next job if available")

    @QtCore.Slot()
    def startZoffsetCalibration(self):
        self.logger.info("Starting Z Offset calibration thread")
        threading.Thread(target=self.homeAndGetZoffsetFromPrinter).start()

    @QtCore.Slot()
    def disconnect_printer(self):
        try:
            self._getPrinterInterface().close()
        except AttributeError:
            pass

    @QtCore.Slot()
    def cancelPrint(self):
        self._getPrinterInterface().cancel_locally()

    @QtCore.Slot()
    def pausePrint(self):
        self._getPrinter().pause()

    @QtCore.Slot()
    def resumePrint(self):
        self._getPrinter().unpause()

    @QtCore.Slot(str)
    def executeGcodeLine(self, gcode_line):
        self.logger.info("Executing gcodes: " + str(gcode_line))
        if gcode_line:
            if not gcode_line.endswith("\n"):
                gcode_line += "\n"
            self.loadGcodes(gcode_line.encode("ascii"), print_file=False)

    @QtCore.Slot()
    def enableResponseCallbacks(self):
        try:
            self._getPrinter().add_response_callback(self.addToResponsesQueue)
        except:
            pass

    @QtCore.Slot()
    def disableResponseCallbacks(self):
        try:
            self._getPrinter().flush_response_callbacks()
            # self._getPrinter().add_get_pos_to_all_gcodes = False
        except:
            pass

    @QtCore.Slot()
    def localModeOn(self):
        self._getPrinterInterface().turn_on_local_mode()

    @QtCore.Slot()
    def localModeOff(self):
        self._getPrinterInterface().turn_off_local_mode()

    @QtCore.Slot()
    def resetBoard(self):
        self._getPrinterInterface().sender.reset()

    @QtCore.Slot()
    def moveZOneStepUp(self):
        self.moveZ(0-self.Z_OFFSET_STEP)

    @QtCore.Slot()
    def moveZOneStepDown(self):
        self.moveZ(self.Z_OFFSET_STEP)

    @QtCore.Property(int, notify=on_firstExtruderTemp)
    def firstExtruderTemp(self):
        return self._firstExtruderTemp

    @QtCore.Property(int, notify=on_secondExtruderTemp)
    def secondExtruderTemp(self):
        return self._secondExtruderTemp

    @QtCore.Property(int, notify=on_heatBedTemp)
    def heatBedTemp(self):
        return self._heatBedTemp

    @QtCore.Property(int, notify=on_chamberTemp)
    def chamberTemp(self):
        return self._chamberTemp

    @QtCore.Property(int, notify=on_firstExtruderTargetTemp)
    def firstExtruderTargetTemp(self):
        return self._firstExtruderTargetTemp

    @QtCore.Property(int, notify=on_secondExtruderTargetTemp)
    def secondExtruderTargetTemp(self):
        return self._secondExtruderTargetTemp

    @QtCore.Property(int, notify=on_heatBedTargetTemp)
    def heatBedTargetTemp(self):
        return self._heatBedTargetTemp

    @QtCore.Property(int, notify=on_chamberTargetTemp)
    def chamberTargetTemp(self):
        return self._chamberTargetTemp

    @QtCore.Property(int, notify=on_percent)
    def percent(self):
        return self._percent

    @QtCore.Property(str, notify=on_token)
    def token(self):
        return self._token

    @QtCore.Property(int, notify=on_currentGCodeLine)
    def currentGCodeLine(self):
        return self._currentGCodeLine

    @QtCore.Property(int, notify=on_totalGCodeLine) #TODO rename ...Line to Lines
    def totalGCodeLine(self):
        return self._totalGCodeLine

    @QtCore.Property(int, notify=on_joystickFeedrate)
    def joystickFeedrate(self):
        return self._joystickFeedrate

    @QtCore.Property(str, notify=on_printerError)
    def printerError(self):
        return self._printerError

    @QtCore.Property(bool, notify=on_authToken)
    def gotAuthToken(self):
        return bool(self._authToken)

    @QtCore.Property(bool, notify=on_nextJobAvailable)
    def nextJobAvailable(self):
        return bool(self._nextJobAvailable)

    @QtCore.Property(str, notify=on_printTimeLeftStr)
    def printTimeLeftStr(self):
        try:
            if self._app.printer_interfaces:
                pi = self._app.printer_interfaces[0]
                if not pi:
                    raise IndexError
            else:
                raise IndexError
        except IndexError:
            self.logger.debug("No printer interface")
        else:
            printer = getattr(pi, "sender", None)
            if printer:
                time_str = printer.get_remaining_print_time_string()
                self.logger.info("Time left: " + str(time_str))
                return time_str
            self.logger.debug("Printer interface got no connection to printer sender")
        return ""

    @QtCore.Property(bool, notify=on_door)
    def doorOpen(self):
        return self._door_open

    def getZOffset(self):
        self.logger.info("Z offset property read: %s" % self._zOffset)
        return float(self._zOffset)

    def setZOffset(self, z_offset):
        self._zOffset = float(z_offset)
        self.logger.info("Z offset property set: %s" % z_offset)

    def _saveZOffset(self, new_zoffset): #TODO redo using writeFileOnDuetSD after it is tested
        printer = None
        printer = self._getPrinter()
        if not printer:
            self._coreModel.showMessage.emit(None, "Lost connection to the printer", None)
        else:
            self.logger.info("Saving new offset: %s" % new_zoffset)
            printer.stop_temp_requesting()
            printer.flush_send_now_buffer()
            time.sleep(0.1)
            self.loadGcodes(b"M28 /sys/z_offset.g", print_file=False)
            time.sleep(0.1)
            printer._release_ready_for_command()
            self.loadGcodes(b"G92 Z%f" % new_zoffset, print_file=False)
            time.sleep(0.1)
            printer._release_ready_for_command()
            self.loadGcodes(b"M29", print_file=False)
            time.sleep(0.1)
            printer._release_ready_for_command()
            printer.start_temp_requesting()
            self._zOffset = new_zoffset
            self.on_zOffset.emit(self._zOffset)
            self.loadGcodes(b"G90", print_file=False)
            self.loadGcodes(b"M104 S0", print_file=False)
            self.loadGcodes(b"M140 S0", print_file=False)
            printer.start_temp_requesting()
            self.logger.info("Done")

    #  @zOffset.setter
    #  def setProbeZOffset(self, new_zoffset):
    #      if self._zOffset != new_zoffset:
    #          self._zOffset = new_zoffset
    #          self.on_zOffset.emit(self._zOffset)
    #          gcode_line = "G31 Z%s\n" % new_zoffset
    #          self.loadGcodes(gcode_line.encode("ascii"), print_file=False)
    #          self.loadGcodes(b"M500 P31\n", print_file=False)

    zOffset = QtCore.Property(float, fget=getZOffset, fset=setZOffset, notify=on_zOffset)

    def zOffsetStep(self):
        return self.Z_OFFSET_STEP
    zOffsetStep = QtCore.Property(float, fget=zOffsetStep, constant=True)

    @QtCore.Slot()
    def saveZOffset(self):
        self._saveZOffset(self.zOffset)

    @QtCore.Slot()
    def checkCloudsQueue(self):
        if self._coreModel.state != self._coreModel.STATE_IDLE:
            self._coreModel.showMessage.emit(None, "Not ready for a next print", None)
        else:
            pi = self._getPrinterInterface()
            if pi:
                jobs_list, error = pi.get_jobs_list()
                if error:
                    message = error.get("message", "Unknown")
                    self._coreModel.showMessage.emit(None, message, None)
                elif not jobs_list:
                    self._coreModel.showMessage.emit(None, "Empty jobs queue", None)
                else:
                    try:
                        next_job = jobs_list[0]
                        job_id = next_job.get('id')
                        if not job_id:
                            raise TypeError
                        filename = next_job.get('filename', '')
                        weight = next_job.get('weight', '')
                        duration_str = next_job.get('printing_duration', '')
                    except (IndexError, TypeError, AttributeError):
                        self.logger.warning('Invalid jobs_list: ' + str(jobs_list))
                        #TODO remake this is proper message
                        self._coreModel.showMessage.emit(None, "Empty jobs queue", None)
                    else:
                        try:
                            duration = int(duration_str.split(".")[0])
                            duration_str = pi.sender.get_remaining_print_time_string(seconds=duration)
                        except (AttributeError, ValueError):
                            self.logger.warning('Error on conversion of job duration')
                        else:
                            message = f"Next job:\nID:{job_id}\nFile name:{filename}\nWeight:{weight}g\nDuration:{duration_str}"
                            self._nextCloudsQueueJobId = job_id
                            self._coreModel.showConfirm.emit(None, message, "Print", "Cancel", "printerModel.startNextCloudsJob()")
                return
            self._coreModel.showMessage.emit(None, "Reconnecting to printer", None)

    @QtCore.Slot()
    def startNextCloudsJob(self):
        self.logger.info('Sending jobs start by ID %s to printer interface' % self._nextCloudsQueueJobId)
        if not self._nextCloudsQueueJobId:
            self._coreModel.showMessage.emit(None, "Unable to start cloud's job", None)
        try:
            pi = self._getPrinterInterface()
            if pi:
                if not pi.start_job_by_id(self._nextCloudsQueueJobId):
                    self.logger.warning("Error starting cloud's job " + str(self._nextCloudsQueueJobId))
                else:
                    self._coreModel.hideSettings.emit()
        except AttributeError:
            self._coreModel.showMessage.emit(None, "Not ready for a next print", None)
