# 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 collections
import logging
import re
import string
import time
import threading
import io
from typing import Union, AnyStr, Any, List, Tuple

import config
import log
from gcodes_buffer import GcodesBuffer
from base_sender import BaseSender
from serial_connection import SerialConnection
from emulated_serial_connection import EmulatedSerialConnection


class Sender(BaseSender):

    CONNECTION_CLASS = SerialConnection

    OK_TIMEOUT = 30
    ERROR_REPORT_TIMEOUT = 3600
    CONNECTION_TIMEOUT = 4
    TEMP_REQUEST_PERIOD = 3
    PRINTER_INIT_WAIT = 3
    PRINT_JOIN_TIMEOUT = 12
    STOP_JOIN_TIMEOUT = 6
    PAUSE_ON_ERROR_READING_PORT = 0.0001 # should be much less than gcode rate
    RECONNECTION_RESET_SLEEP = 2
    CONNECTION_RETRIES_PER_BAUDRATE = 2
    CONNECTION_LINE_READ_ATTEMPTS = 6
    MAX_VALID_RESEND_RANGE = 4096
    VALID_START_TIMEOUT = 30
    RATING_TIME = 10
    LAST_SENT_RING_LEN = 16

    GET_TEMP_GCODE = b'M105'
    GET_POSITION_GCODE = b'M114'
    GET_FIRMWARE_VERSION_GCODE = b'M115'
    DWELL_GCODE = b'G4'
    BOARD_RESET_GCODE = b'M999'
    SET_EXT_TEMP_WAIT_GCODE = b'M109'
    SET_BED_TEMP_WAIT_GCODE = b'M190'
    WAIT_TEMPERATURE_GCODE = b'M116'
    CALL_MACRO_GCODE = b'M98'
    CONTROL_TOOL_TEMP_GCODE = b'M568' # a nightmare present from RepRapFirmware. TODO
    ABS_COORD_GCODE = b'G90'
    REL_COORD_GCODE = b'G91'
    ABS_EXTR_GCODE = b'M82'
    REL_EXTR_GCODE = b'M83'
    G0_GCODE = b'G0'
    G1_GCODE = b'G1'
    G10_GCODE = b'G10'
    HOMING_GCODE = b'G28'
    LEVELING_GCODE = b'G29'
    PRINT_START_MACRO = 'PRINT_START'
    KLIPPERS_LONG_MACROS = [PRINT_START_MACRO]

    GCODE_MAX_RESP_TIME = {
            SET_BED_TEMP_WAIT_GCODE : 600,
            SET_EXT_TEMP_WAIT_GCODE : 180,
            HOMING_GCODE : 60,
            LEVELING_GCODE : 180,
            CALL_MACRO_GCODE : 300,
            PRINT_START_MACRO: 600,
            DWELL_GCODE : 30 # TODO add code to get a real time from an arg
    }

    T_PREFIX = b"T"
    S_PREFIX = b"S"
    R_PREFIX = b"R"
    P_PREFIX = b"P"
    ASTERIX = b"*"

    OK = b'ok'
    ECHO = b'echo:'
    ENQUEUEIN_BYTES = b'enqueuein'
    START = b'start'
    POSITIVE_ACKS = [START, b'Grbl', b'grbl', ECHO, OK]
    RS = b'rs'
    WAIT = b"wait"
    CRASH_RECOVER_MESSAGE = b'CRASH_RECOVER'
    BUSY_PROCESSING_MESSAGE = b'busy: processing'
    BUSY_PAUSED_MESSAGE = b'busy: paused for user'
    STORED_SETTINGS_RETRIEVED = b"Stored settings retrieved"
    FIRMWARE_MESSAGE = b"Firmware"
    FIRMWARE_MESSAGE_LOWERCASE = b"firmware"
    FIRMWARE_MESSAGE_UPPERCASE = b"FIRMWARE"
    DEBUG_MESSAGE = b'DEBUG'
    ERROR_MESSAGE = b'Error'
    ERROR_MESSAGE_LOWERCASE = b'error'
    PRINTER_STOPPED_MESSAGE = b'Printer stopped due to errors'
    WARNING_MESSAGE = b'Warning'
    WARNING_MESSAGE_LOWERCASE = b'warning'
    DOUBLE_EXCL_MARK = b"!!"
    RESEND_REQUEST_PREFIXES = [b'resend', RS]
    TEMPERATURE_ACKS_PREFIXES = [b'T:', b'B:', b'T0:', b'T1:', b'T2:']
    ACTION_PREFIX = b"//action:"
    ACTION_PREFIX_UGLY = b"// action:"
    ACTION_SPLITTER = b":"
    UNKNOWN_COMMAND = b"Unknown command"

    DEFAULT_PAUSE_AT_GCODES_LIST = [b"M0", b"M1", b"M25", b"M226", b"M601", b"@pause"]
    DEFAULT_RESUME_AT_GCODES_LIST = [b"M24", b"M602"]
    TEMPR_REQ_GCODES = (GET_TEMP_GCODE, )
    DRY_RUN_COMMAND = b"M111 S8"
    IGNORE_PAUSE_MAGIC = b'3DPrinterOS pass pause'

    PRINTABLE_BYTES = string.printable.encode()

    PAUSE_LIFT_HEIGHT = 5 # in mm
    PAUSE_EXTRUDE_LENGTH = 7 # in mm

    DEFAULT_ON_PAUSE_GCODES = (b"G1 Z%.3f E-%.3f" % (PAUSE_LIFT_HEIGHT, PAUSE_EXTRUDE_LENGTH), )
    DEFAULT_ON_RESUME_GCODES = (b"G1 Z-%.3f E%.3f" % (PAUSE_LIFT_HEIGHT, PAUSE_EXTRUDE_LENGTH), )

    def __init__(self, parent: Any, usb_info: dict, profile: dict):
        super().__init__(parent, usb_info, profile)
        #TODO move CONNECTION_CLASS from class attributes to object attributes, but first refactor telnet sender
        if usb_info.get('EMU'):
            self.CONNECTION_CLASS = EmulatedSerialConnection
        self.define_regexps()
        self.operational_flag = False
        self.correct_baudrate = None
        self.connection = None
        self.firmware_info = ""
        self.gcode_filepath = None
        self.pre_pause_target_temps = []
        self.stored_position = []
        self.last_resp_words = []
        if not self._connect():
            raise RuntimeError("Error: printer is not responding")
        self.read_thread = threading.Thread(target=self._reading, name="Read thread", daemon=True)
        self.write_thread = WriteThread(self)
        self.thread_start_lock = threading.RLock()
        self.pause_gcodes_list = profile.get("pause_at_gcodes", self.DEFAULT_PAUSE_AT_GCODES_LIST)
        self.resume_gcodes_list = profile.get("resume_at_gcodes", self.DEFAULT_RESUME_AT_GCODES_LIST)
        self.no_n_and_checksum_for = set()
        for line in profile.get("no_n_and_checksum_for", []):
            try:
                self.no_n_and_checksum_for.add(line.encode('utf-8', errors='ignore'))
                self.logger.info('Skip adding line number for gcodes: ' + str(line))
            except AttributeError:
                self.logger.info('Invalid no_n_and_check_for in profile: ' + str(line))
        self.on_connection_gcodes = list(self.preprocess_gcodes(profile.get("on_connection_gcodes", profile.get("end_gcodes", []))))
        self.on_cancel_gcodes = list(self.preprocess_gcodes(profile.get("on_cancel_gcodes", profile.get("end_gcodes", []))))
        self.on_pause_gcodes = list(self.preprocess_gcodes(profile.get("on_pause_gcodes", self.DEFAULT_ON_PAUSE_GCODES)))
        self.on_resume_gcodes = list(self.preprocess_gcodes(profile.get("on_resume_gcodes", self.DEFAULT_ON_RESUME_GCODES)))
        self.cooldown_on_pause = bool(profile.get("cooldown_on_pause"))
        self.heatup_on_resume = bool(profile.get("heatup_on_resume"))
        self.disable_resend_max = config.get_settings().get('disable_resend_max', False)
        self.restore_pos_on_resume = config.get_settings().get('restore_pos_on_resume', False)
        self.initial_ok_timeout_status = config.get_settings().get('ok_timeout', False)
        self.host_action_commands_enabled = config.get_settings().get('host_action_commands', False)
        self.ok_timeout_enabled = self.initial_ok_timeout_status
        self.last_line_sent = b''
        self.last_sent_ring = collections.deque(maxlen=self.LAST_SENT_RING_LEN)
        self.in_relative_pos_mode = False
        self.in_relative_extr_mode = False
        self.was_in_relative_before_pause = False
        self.was_in_relative_ext_before_pause = False
        self.heating = False
        self.sdcard_printing = False
        self.paused_for_user = False
        self.firmware_crash_recover_happened = False
        self.ok_timeout_bonus = 0
        #self.position_update_time = 0.0
        self.read_thread.start()
        self.write_thread.start()
        time.sleep(self.PRINTER_INIT_WAIT)
        self.sync_ready_for_command(False)
        if not self.profile.get("no_homing_on_connect") and self.on_connection_gcodes:
            self._send_homing_gcodes()
        self.start_temp_requesting()

    def sync_ready_for_command(self, release: bool = True) -> None:
        self.logger.info('Ready for command event sync')
        self.write_thread.ready_for_command.set()

    def _connect(self) -> bool:
        if not self.usb_info.get('COM'):
            raise RuntimeError("No serial port detected for serial printer")
        correct_baudrate = self.settings.get('correct_baudrate')
        if correct_baudrate:
            self.logger.info(f'Using stored baudrate {correct_baudrate}')
            baudrates = [correct_baudrate, correct_baudrate, correct_baudrate] #if we know a correct baudrate we should not stop after first fail to connect
        else:
            self.logger.info('Baudrates list for %s : %s' % (self.profile['name'], self.profile['baudrate']))
            baudrates = self.profile['baudrate']
        for baudrate in baudrates:
            if type(baudrate) != int:
                try:
                    baudrate = int(baudrate)
                except (TypeError, ValueError):
                    self.logger.error(f'Error: not integer baudrate {baudrate} in profile ' + self.profile['alias'])
                    continue
            if self.connection:
                self.connection.close()
            self.logger.info("Connecting at baudrate %d" % baudrate)
            self.connection = self.CONNECTION_CLASS(self.usb_info['COM'], baudrate, logger=self.logger)
            if self.connection.port:
                retries = self.CONNECTION_RETRIES_PER_BAUDRATE
                while retries:
                    if self.stop_flag or getattr(self.parent, "stop_flag", False):
                        return False
                    if self._try_to_communicate():
                        self.logger.info("Successful connection to %s: %s" % (self.profile['alias'], str(self.usb_info)))
                        if self.settings.get('correct_baudrate') != baudrate:
                            self.update_printer_settings({'correct_baudrate' : baudrate})
                        return True
                    retries -= 1
            if not self.profile.get("no_DTR"):
                self.connection.reset()
                time.sleep(self.RECONNECTION_RESET_SLEEP)
            self.logger.warning("Error connecting to printer at %d" % baudrate)
            self.connection.close()
        if correct_baudrate:
            try:
                del self.settings['correct_baudrate']
                self.save_printer_settings()
                self.logger.info(f'Removed stored baudrate {correct_baudrate} due to connection error')
            except KeyError:
                pass
        return False

    def _try_to_communicate(self) -> bool:
        if not self.profile.get('line_end_on_connect', True) or self.connection.send(b""): # end a possible leftover from last connection, etc
            if self.connection.send(self.GET_TEMP_GCODE):
                time.sleep(0.1)
                line_read_attempts = self.CONNECTION_LINE_READ_ATTEMPTS
                recv_text = b""
                while not self.stop_flag and line_read_attempts:
                    recv_line = self.connection.recv()
                    if recv_line:
                        recv_text += recv_line
                        self.logger.info('On connection received: ' + recv_line.decode(errors="ignore"))
                    recv_words = recv_text.split()
                    if self.BOARD_RESET_GCODE in recv_words:
                        self.logger.info("Printer's board requested reset. Resetting...")
                        return False
                    for ack in self.POSITIVE_ACKS:
                        if ack in recv_words:
                            self.logger.info("Printer is online")
                            return True
                    for ack in self.TEMPERATURE_ACKS_PREFIXES:
                        if ack in recv_words:
                            self.logger.info("Printer is online, but in a heating mode.")
                            self.heating = True
                            return True
                    line_read_attempts -= 1
        time.sleep(0.5)

    # TODO: recheck type and return
    def _parse_wait_for_temperature(self, words: List[AnyStr]) -> Tuple[int, float]:
        target_temp_index = 1
        temp = None
        for word in words:
            if word.startswith(self.T_PREFIX) or word.startswith(self.P_PREFIX):
                try:
                    target_temp_index = int(word[1:]) + 1
                except:
                    self.logger.warning("Invalid tool number format: %s", word)
                    target_temp_index = None
                if temp is not None:
                    break
            if word.startswith(self.S_PREFIX) or word.startswith(self.R_PREFIX):
                try:
                    temp = float(word[1:])
                except:
                    self.logger.warning("Invalid temperature format: %s", word)
        return target_temp_index, temp

    def _analyze_sent_line(self, line: AnyStr) -> None:
        self.last_line_sent = line
        #optimization, since allocation of list produces by line.split is very CPU consuming
        if line:
            self.last_sent_ring.append(line)
            words = line.split(self.ASTERIX)[0].split()
            command = b''
            if words:
                if words[0].startswith(b'N'):
                    if len(words) > 1:
                        command = words[1]
                else:
                    command = words[0]
            if command == self.SET_EXT_TEMP_WAIT_GCODE:
                target_temp_index, temp = self._parse_wait_for_temperature(words)
                if temp is not None:
                    if target_temp_index < len(self.target_temps):
                        self.target_temps[target_temp_index] = temp
                    else:
                        self.logger.warning(f"No such tool for temps index {target_temp_index}")
            elif command == self.SET_BED_TEMP_WAIT_GCODE:
                _, temp = self._parse_wait_for_temperature(words)
                if temp is not None:
                    self.target_temps[0] = temp
            elif command == self.G10_GCODE:
                if self.S_PREFIX in line and self.R_PREFIX in line:
                    tool = 1
                    temp = 0
                    for word in words[1:]:
                        if word.startswith(self.S_PREFIX):
                            try:
                                temp = float(word[1:])
                            except:
                                pass
                        elif word.startswith(self.P_PREFIX):
                            try:
                                tool = int(word[1:])
                            except:
                                pass
                    if tool < len(self.target_temps):
                        self.target_temps[tool] = temp
                    else:
                        self.logger.warning()
            elif command == self.REL_COORD_GCODE:
                self.in_relative_pos_mode = True
                self.in_relative_extr_mode = True
            elif command == self.ABS_COORD_GCODE:
                self.in_relative_pos_mode = False
                self.in_relative_extr_mode = False
            elif command == self.REL_EXTR_GCODE:
                self.in_relative_extr_mode = True
            elif command == self.ABS_EXTR_GCODE:
                self.in_relative_extr_mode = False
            if command == self.G1_GCODE or command == self.G0_GCODE:
                self.heating = False
            elif command != self.GET_TEMP_GCODE and command != self.GET_POSITION_GCODE:
                self.heating = command == self.SET_EXT_TEMP_WAIT_GCODE or \
                               command == self.SET_BED_TEMP_WAIT_GCODE or \
                               command == self.WAIT_TEMPERATURE_GCODE or \
                               command in self.KLIPPERS_LONG_MACROS
            if command in self.GCODE_MAX_RESP_TIME:
                self.ok_timeout_bonus = self.GCODE_MAX_RESP_TIME[command]
            else:
                self.ok_timeout_bonus = 0

    def _send_now(self, line_or_lines_list: Union[List[AnyStr], AnyStr]) -> None:
        if isinstance(line_or_lines_list, str):
            line_or_lines_list = line_or_lines_list.encode('utf-8')
        self.write_thread.send_now(line_or_lines_list)

    def _send_homing_gcodes(self) -> None:
        if self.on_connection_gcodes:
            self._send_now(self.GET_FIRMWARE_VERSION_GCODE)
            self._send_now(self.on_connection_gcodes)

    def define_regexps(self) -> None:
        digits_re = rb"(-?[\d\.]+)"
        self.temp_re = re.compile(rb'T:dr\s?/dr B:dr\s?/dr'.replace(b'dr', digits_re))
        self.position_re = re.compile(rb'X:dr.?Y:dr.?Z:dr.*'.replace(b'dr', digits_re))
        self.position_extr_re = re.compile(rb'.*E:dr.*'.replace(b'dr', digits_re))
        self.wait_tool_temp_re = re.compile(rb'T\d?:dr'.replace(b'dr', digits_re))
        self.wait_platform_temp_re = re.compile(rb'B:dr'.replace(b'dr', digits_re))
        self.wait_tool_target_temp_re = re.compile(rb'T\d?:. /dr'.replace(b'dr', digits_re))
        self.wait_platform_target_temp_re = re.compile(rb'B\d?:. /dr'.replace(b'dr', digits_re))
        self.bed_re = re.compile(rb'B:dr\s?/dr'.replace(b'dr', digits_re))
        self.current_tool_re = re.compile(rb'T:dr\s?/dr'.replace(b'dr', digits_re))
        self.first_tool_re = re.compile(rb'T0:dr\s?/dr'.replace(b'dr', digits_re))
        self.second_tool_re = re.compile(rb'T1:dr\s?/dr'.replace(b'dr', digits_re))
        self.targetless_temp_re = re.compile(rb'T:dr\s?B:dr'.replace(b'dr', digits_re))
        self.resend_number_re = re.compile(rb'(\d+)')
        self.ignore_pause_resume_re = re.compile(rb'M[0|1]\s+[PS]\d+')

    @log.log_exception
    def _reading(self) -> None:
        self.firmware_crash_recover_happened = False
        last_reply_time = time.monotonic()
        last_line = b""
        while not self.stop_flag:
            line = self.connection.recv()
            if line is None: # None means error reading from printer, but "" is ok
                self.operational_flag = False
                time.sleep(self.PAUSE_ON_ERROR_READING_PORT)
                if self.paused_for_user:
                    self.parent.register_error(299, "Unexpected printer's firmware reboot", is_blocking=True)
            else:
                line = line.strip()
                if line:
                    callback_status = None
                    last_reply_time = time.monotonic()
                    if line == self.WAIT:
                        self.operational_flag = True
                    elif line.startswith(self.OK) or line.startswith(self.RS):
                        self.operational_flag = True
                        self.paused_for_user = False
                        self.firmware_crash_recover_happened = False
                        if not self._check_temperature(line):
                            callback_status = True
                        self.write_thread.ready_for_command.set()
                    elif self.BUSY_PROCESSING_MESSAGE in line:
                        self.logger.info('Printer busy: %s' % line.decode("utf-8", errors='ignore'))
                        self.operational_flag = True
                    elif self._parse_waiting_temperature_updates(line):
                        self.operational_flag = True
                    elif self._check_position(line):
                        self.operational_flag = True
                        self.logger.info(f"Position: {self.position}")
                    elif self.ENQUEUEIN_BYTES in line:
                        self.logger.error("Printer executing: %s" % line.decode("utf-8", errors='ignore'))
                    elif self._check_for_resend_requests(line):
                        self.logger.info('Last sent line: %s' % self.last_line_sent.decode("utf-8", errors='ignore'))
                    elif self.CRASH_RECOVER_MESSAGE in line:
                        self.firmware_crash_recover_happened = True
                        self.write_thread.ready_for_command.set()
                    elif line == self.START:
                        self.paused_for_user = False
                        if self.firmware_crash_recover_happened:
                            self.firmware_crash_recover_happened = False
                            self.operational_flag = True
                        else:
                            self.parent.register_error(299, "Unexpected printer's firmware reboot", is_blocking=True)
                        self.write_thread.ready_for_command.set()
                    elif self.DOUBLE_EXCL_MARK in line or self.ERROR_MESSAGE in line or self.WARNING_MESSAGE in line or self.ERROR_MESSAGE_LOWERCASE in line or self.WARNING_MESSAGE_LOWERCASE in line or self.PRINTER_STOPPED_MESSAGE in line:
                        callback_status = False
                        self.operational_flag = False
                        self.logger.info(f'Warning/Error: {line}. As response to line: {self.last_line_sent}')
                        for warning in (self.BOARD_RESET_GCODE, self.PRINTER_STOPPED_MESSAGE, b'disabling all for safety!', b"kill", b"halt", b"critical"):
                            if warning in line.lower() or warning in line:
                                is_blocking = True
                                break
                        else:
                            is_blocking = False
                        self.parent.register_error(201, "Printer: " + line.decode(errors='ignore'), is_blocking=is_blocking)
                    elif self.FIRMWARE_MESSAGE in line or self.FIRMWARE_MESSAGE_LOWERCASE in line or self.FIRMWARE_MESSAGE_UPPERCASE in line:
                        self.firmware_info = line.decode('utf-8', errors='ignore')
                        self.logger.info("Firmware info: " + self.firmware_info)
                        if self.paused_for_user:
                            self.paused_for_user = False
                    elif line.startswith(self.DEBUG_MESSAGE):
                        self.logger.debug("Debug %s" % line)
                    elif self.BUSY_PAUSED_MESSAGE in line:
                        self.logger.warning('Received: paused for user')
                        self.operational_flag = True
                        if not self.paused_for_user:
                            self.paused_for_user = True
                            self.parent.register_error(252, 'Received: Paused for user', is_info=True)
                    elif self.STORED_SETTINGS_RETRIEVED in line:
                        if self.paused_for_user:
                            self.paused_for_user = False
                    elif b"Not SD printing" in line:
                        self.sdcard_printing = False
                        self._parse_sdcard_printing_progress(line)
                    elif b"SD printing byte" in line:
                        self.sdcard_printing = True
                        self._parse_sdcard_printing_progress(line)
                    elif b"Deletion failed" in line:
                        self.logger.info('SDcard file deletion on failed: ' + line.decode(errors='ignore'))
                        if self.OK in line:
                            self.write_thread.ready_for_command.set()
                    elif line.startswith(self.ACTION_PREFIX) or line.startswith(self.ACTION_PREFIX_UGLY):
                        self.logger.info("Host action command received: " + line.decode(errors='ignore'))
                        command = line.split(self.ACTION_SPLITTER)[-1].strip()
                        if not self.host_action_commands_enabled:
                            self.logger.warning(f'Not executing command {command}, due to host action commands disabled in settings.')
                        else:
                            if command == b"start":
                                self.logger.warning("Printer requested a job start")
                                if not self.parent.start_next_job():
                                    self.parent.register_error(3004,"Unable to start a next job", is_blocking=False)
                            elif command == b"cancel":
                                self.parent.cancel_locally()
                            elif command == b"pause":
                                self.pause(no_gcodes=True)
                            elif command == b"resume":
                                self.unpause(no_gcodes=True)
                            elif command == b"disconnect":
                                self.parent.register_error(3003, "Printer requested host disconnection", is_blocking=True)
                            elif command == b"paused":
                                with self.thread_start_lock:
                                    self.ok_timeout_enabled = False
                                    self.write_thread.paused = True
                            elif command == b"resumed":
                                self.ok_timeout_enabled = self.initial_ok_timeout_status
                                self.write_thread.paused = False
                                self.paused_for_user = False
                            elif b"out_of_filament" in command:
                                self.parent.register_error(3001,"Printer is out of filament", is_blocking=False)
                            elif command.startswith(b'notification'):
                                self.parent.register_error(3005,"Printer notification: " + command.strip(b'notification').decode(errors='ignore'), is_blocking=False, is_info=True)
                            else:
                                self.parent.register_error(3000, "Unknown host action received: " + \
                                    line.decode(errors='ignore'), is_blocking=False, is_info=True)
                    elif line.startswith(self.ECHO) and self.UNKNOWN_COMMAND in line:
                        self.register_error(258, "Printer echo:" + line.strip(self.ECHO).decode(errors='ignore'), is_blocking=False)
                        self.write_thread.ready_for_command.set()
                    else:
                        self._log_strange_acks(line)
                    with self.callbacks_lock:
                        if self.response_callbacks:
                            if callback_status != None:
                                self.execute_callback(line, callback_status)
            if last_reply_time + self.OK_TIMEOUT + self.ok_timeout_bonus < time.monotonic():
                last_reply_time = time.monotonic()
                if self.ok_timeout_enabled and not self.paused_for_user and not self.heating and not self.is_paused() and not self.sdcard_printing:
                    message = "Warning! Timeout while waiting for ok."
                    self.parent.register_error(251, message, is_blocking=False)
                    self.logger.warning(f"Attempting to recover printing process by stop to wait for ok.\n\
                                        Last line received: {last_line}.\nLast line send: {self.last_line_sent}")
                    self.ok_timeout_bonus = 180
                    self.write_thread.ready_for_command.set()
        self.logger.debug("Reading thread exit")

    def _log_strange_acks(self, line: AnyStr) -> None:
        self.logger.info('Received: %s' % line)

    def _parse_sdcard_printing_progress(self, _: AnyStr) -> None:
        pass

    def start_temp_requesting(self):
        self.write_thread.temp_requesting_flag = True

    def stop_temp_requesting(self, wait=True):
        self.write_thread.temp_requesting_flag = False

    def _parse_temperature_old_way(self, line: AnyStr) -> bool:
        self.logger.debug(line)
        match = self.temp_re.search(line)
        if match:
            try:
                tool_temp = float(match.group(1))
                tool_target_temp = float(match.group(2))
                platform_temp = float(match.group(3))
                platform_target_temp = float(match.group(4))
                self.temps = [platform_temp, tool_temp]
                self.target_temps = [platform_target_temp, tool_target_temp]
                return True
            except Exception:
                self.logger.exception("Error parsing temperatures from line:" + str(line))
        return False

    def _parse_temperature_new_way(self, line: AnyStr) -> bool:
        bed_match = self.bed_re.search(line)
        first_tool_match = self.first_tool_re.search(line)
        second_tool_match = self.second_tool_re.search(line)
        temps = [0.0, 0.0]
        target_temps = [0.0, 0.0]
        got_temps = False
        try:
            if bed_match:
                temps[0] = float(bed_match.group(1))
                target_temps[0] = float(bed_match.group(2))
                got_temps = True
            if first_tool_match:
                temps[1] = float(first_tool_match.group(1))
                target_temps[1] = float(first_tool_match.group(2))
                got_temps = True
            if second_tool_match:
                temps.append(float(second_tool_match.group(1)))
                target_temps.append(float(second_tool_match.group(2)))
                got_temps = True
        except Exception:
            self.logger.exception("Error parsing temperatures from line:" + str(line))
        else:
            self.temps = temps
            self.target_temps = target_temps
        return got_temps

    def _parse_temperature_for_targetless(self, line: AnyStr) -> bool:
        targetless_match = self.targetless_temp_re.search(line)
        if targetless_match:
            self.temps = [(float(targetless_match.group(2))), (float(targetless_match.group(1)))]
            return True
        return False

    def _parse_temperature(self, line: AnyStr) -> bool:
        self.logger.debug(line)
        return self._parse_temperature_old_way(line) or self._parse_temperature_new_way(line) or self._parse_temperature_new_way(line)

    def _parse_waiting_temperature_updates(self, line: AnyStr) -> bool:
        is_temperatures_line = False
        if line.startswith(b'T') or line.startswith(b'B:'): # performance optimization
            match = self.wait_platform_temp_re.search(line)
            if match:
                try:
                    self.temps[0] = float(match.group(1))
                    is_temperatures_line = True
                except ValueError:
                    self.logger.error("Invalid temp formant in line:%s" % line)
            match = self.wait_tool_temp_re.search(line)
            if match:
                try:
                    self.temps[1] = float(match.group(1))
                    is_temperatures_line = True
                except ValueError:
                    self.logger.error("Invalid temp formant in line:%s" % line)
        return is_temperatures_line

    def _check_for_resend_requests(self, line: AnyStr) -> bool:
        for prefix in self.RESEND_REQUEST_PREFIXES:
            if line.lower().startswith(prefix):
                match = self.resend_number_re.search(line)
                if match:
                    failed_line_number = int(match.group(1))
                    self.logger.info("Request to resend line N%d" % failed_line_number)
                    try:
                        if self.write_thread:
                            with self.write_thread.resend_lock:
                                if not self.disable_resend_max and self.write_thread.lines_sent - self.write_thread.n_offset > failed_line_number + self.MAX_VALID_RESEND_RANGE:
                                    message = "Printer request resent for too old line number. Assuming firmware reboot."
                                    self.parent.register_error(261, message, is_blocking=True)
                                self.write_thread.line_number_to_resend = failed_line_number
                                self.flush_send_now_buffer()
                                self.write_thread.ready_for_command.set()
                                self.logger.info("Due to line resend request switching current line number to %s" % failed_line_number)
                                #self._send_now(b"M110 N%d" % failed_line_number)
                                #while self.ready_for_command.acquire(blocking=False):
                                #    self.logger.info("Flushing command ready semaphore for correct line resend")
                    except AttributeError: # print_thread could be deleted by other thread
                        pass
                else:
                    self.logger.warning("Can't parse line number from resent request: %s" % line)
                return True
        return False

    def _check_temperature(self, line: AnyStr) -> bool:
        for prefix in self.TEMPERATURE_ACKS_PREFIXES:
            if prefix in line:
                return self._parse_temperature(line.replace(b'== ', b''))
        return False

    def _check_position(self, line: AnyStr) -> bool:
        if b"X:" in line:
            match = self.position_re.search(line)
            if match:
                try:
                    x, y, z = float(match.group(1)), float(match.group(2)), float(match.group(3))
                except ValueError:
                    return False
                match = self.position_extr_re.search(line)
                e = self.position[3]
                if match:
                    try:
                        e = float(match.group(1))
                    except ValueError:
                        pass
                self.position = [x, y, z, e]
                #self.position_update_time = time.monotonic()
                return True
        return False

    def gcodes(self, filepath: Union[AnyStr, io.BytesIO], keep_file: bool = False) -> bool:
        self.logger.info(f'Threaded sender is loading: {filepath}')
        if config.get_settings().get('dynamic_gcodes_buffer'):
            self.gcode_filepath = filepath
            return self.load_gcodes(GcodesBuffer(filepath, self, self.MAX_VALID_RESEND_RANGE, blocking_count=True, keep_file=keep_file or self.keep_print_files))
        self.gcode_filepath = None
        return BaseSender.gcodes(self, filepath)

    def load_gcodes(self, gcodes: GcodesBuffer, dont_add_checksums : bool = False, start_temp_requesting: bool = True) -> bool:
        self.logger.info("Loading gcodes in ThreadedSender")
        self.stop_temp_requesting()
        if self.is_printing():
            self.parent.register_error(254, "Already printing", is_blocking=False)
            return False
        self.write_thread._set_checksum_flag(not dont_add_checksums)
        self.logger.info('Loading gcodes into the new print thread')
        self.write_thread.load_gcodes(gcodes)
        self.init_speed_calculation_thread()
        self.logger.info("Print thread started")
        if start_temp_requesting:
            self.start_temp_requesting()
        return True

    def is_printing(self) -> bool:
        with self.thread_start_lock:
            try:
                return self.write_thread and self.write_thread.is_alive() and self.write_thread.buffer
            except AttributeError:
                return False

    def is_paused(self) -> bool:
        with self.thread_start_lock:
            try:
                return self.write_thread and self.write_thread.is_alive() and \
                       (self.write_thread.paused or self.paused_for_user)
            except AttributeError:
                return False

    def is_operational(self) -> bool:
        return self.operational_flag

    def pause(self, no_gcodes: bool = False) -> bool:
        if not self.is_printing():
            self.parent.register_error(254, "No print to pause", is_blocking=False)
        elif self.is_paused():
            self.parent.register_error(254, "Already in pause", is_blocking=False)
        elif self.heating:
            message = "Can't pause during heating."
            self.parent.register_error(254, message, is_blocking=False)
        elif self.paused_for_user:
            message = "Can't pause during \"paused for user\"."
            self.parent.register_error(256, message, is_blocking=False)
        else:
            self.write_thread.paused = True
            self.was_in_relative_before_pause = self.in_relative_pos_mode
            self.was_in_relative_ext_before_pause = self.in_relative_extr_mode
            self.pre_pause_target_temps = list(self.target_temps) # copy not a link due to list()
            #self.update_pause_time_and_duration()
            if not no_gcodes:
                cooldown_on_pause_gcode = []
                if self.on_pause_gcodes:
                    if not self.in_relative_pos_mode or not self.in_relative_extr_mode:
                        self._send_now(b"G91")
                    self._send_now(self.on_pause_gcodes)
                if self.restore_pos_on_resume:
                    self._store_position()
                if self.cooldown_on_pause:
                    if len(self.target_temps) > 2 and self.target_temps[2]:
                        for target_temp_number, _ in enumerate(self.pre_pause_target_temps):
                            if not target_temp_number:
                                cooldown_on_pause_gcode.append(b"M190 S0")
                            else:
                                cooldown_on_pause_gcode.append(b"M109 T%d S0" % target_temp_number)
                    else:
                        cooldown_on_pause_gcode.append(b"M190 S0")
                        cooldown_on_pause_gcode.append(b"M109 S0")
                    self._send_now(cooldown_on_pause_gcode)
            return True
        return False

    def unpause(self, no_gcodes: bool = False) -> bool:
        if not self.is_printing():
            self.parent.register_error(254, "No print job to resume", is_blocking=False)
        elif not self.is_paused():
            self.parent.register_error(254, "Can't resume - not paused", is_blocking=False)
        elif self.paused_for_user:
            self.parent.register_error(256, "Paused by printer's board firmware, unable to resume", is_blocking=False)
        else:
            if not no_gcodes:
                heatup_on_resume_gcodes = []
                if self.heatup_on_resume or self.cooldown_on_pause:
                    if len(self.pre_pause_target_temps) > 2 and self.pre_pause_target_temps[2]:
                        for target_temp_number, target_temp in enumerate(self.pre_pause_target_temps):
                            if not target_temp_number:
                                heatup_on_resume_gcodes.append(b"M190 S%d" % target_temp)
                            else:
                                heatup_on_resume_gcodes.append(b"M109 T%d S%d" % (target_temp_number, target_temp))
                    else:
                        heatup_on_resume_gcodes.append(b"M104 S%d" % self.pre_pause_target_temps[1])
                        heatup_on_resume_gcodes.append(b"M190 S%d" % self.pre_pause_target_temps[0])
                        heatup_on_resume_gcodes.append(b"M109 S%d" % self.pre_pause_target_temps[1])
                if heatup_on_resume_gcodes:
                    self._send_now(heatup_on_resume_gcodes)
                if self.restore_pos_on_resume:
                    self._send_now(self.ABS_COORD_GCODE)
                    self._restore_position()
                if self.on_resume_gcodes:
                    self._send_now(self.REL_COORD_GCODE)
                    self._send_now(self.on_resume_gcodes)
                if self.was_in_relative_before_pause:
                    self._send_now(self.REL_COORD_GCODE)
                    if not self.was_in_relative_ext_before_pause:
                        self._send_now(self.ABS_EXTR_GCODE)
                else:
                    self._send_now(self.ABS_COORD_GCODE)
                    if self.was_in_relative_ext_before_pause:
                        self._send_now(self.REL_EXTR_GCODE)
            self.write_thread.paused = False
            return True
        return False

    def cancel(self) -> bool:
        if self.profile.get("no_DTR") and self.heating:
            message = "Canceling of heating is not supporting by the printer."
            self.parent.register_error(257, message, is_blocking=False)
            return False
        force_board_reset = self.heating or self.write_thread.line_number_to_resend != None
        self.write_thread.cancel()
        if force_board_reset:
            self.reset()
            time.sleep(1)
            self.sync_ready_for_command(False)
        self.operational_flag = False
        self._send_now(self.on_cancel_gcodes)
        self.logger.info("Successful cancel")
        return True

    def reset(self) -> bool:
        if not self.connection:
            self.logger.warning("No connection to printer to reset")
            return False
        elif self.profile.get('no_DTR'):
            self.logger.warning("DTR reset is not supported for this printer type. Canceling using gcodes.")
            self.connection.send(self.BOARD_RESET_GCODE)
        else:
            self.connection.reset()
            self.logger.info("Successful reset")

    def get_percent(self) -> float:
        if self.write_thread:
            return self.write_thread.get_percent()
        else:
            return 0.0

    def get_current_line_number(self) -> int:
        lines_sent = 0
        try:
            if self.write_thread.buffer.total_gcodes:
                lines_sent = self.write_thread.lines_sent
        except AttributeError:
            lines_sent = 0
        return lines_sent

    def unbuffered_gcodes(self, gcodes: AnyStr) -> bool:
        self.logger.info("Gcodes to send now: " + str(gcodes))
        try:
            if type(gcodes) == str:
                gcodes = gcodes.encode()
            gcodes = self.preprocess_gcodes(gcodes)
        except Exception as e:
            self.logger.exception(e)
            return False
        self.logger.info("Gcodes to send now(processed): " + str(gcodes))
        #gcodes.append(self.GET_POSITION_GCODE)
        self._send_now(gcodes)
        self.logger.info("Gcodes were sent to printer")
        return True

    #USE only to to fix stupid behaviour as after M28
    #TODO: discuss pseudo private
    def _release_ready_for_command(self) -> None:
        self.write_thread.ready_for_command.set()

    def flush_send_now_buffer(self) -> None:
        with self.thread_start_lock:
            if self.write_thread and self.write_thread.is_alive():
                try:
                    self.write_thread.send_now_buffer.clear()
                except:
                    pass

    def set_total_gcodes(self, total_gcode_number: int) -> None:
        with self.thread_start_lock:
            try:
                self.write_thread.total_gcodes = total_gcode_number
            except:
                pass

    def get_total_gcodes(self) -> int:
        if self.write_thread:
            try:
                return self.write_thread.total_gcodes
            except:
                pass
        return 0

    def _store_position(self) -> None:
        self._send_now(self.DWELL_GCODE + b" P100")
        self._send_now(self.GET_POSITION_GCODE)
        time_left = 10
        while not self.GET_POSITION_GCODE in self.last_sent_ring and not self.stop_flag and time_left > 0:
            time.sleep(0.1)
            time_left -= 0.1
        time.sleep(1)
        self.stored_position = list(self.position)
        self.logger.info(f"Storing pre pause position: {self.stored_position}")

    def _restore_position(self) -> None:
        if self.stored_position:
            gcode = b"G0 X%f Y%f Z%f" % tuple(self.stored_position[:3])
            self._send_now(gcode)
            self.logger.info(f"Restoring pre pause position: {gcode.decode()}")
            self.stored_position = []

    def close(self) -> None:
        self.logger.debug("Joining reading thread...")
        self.stop_flag = True
        self.stop_temp_requesting()
        try:
            if self.read_thread:
                self.read_thread.join(self.STOP_JOIN_TIMEOUT)
        except (AttributeError, RuntimeError):
            pass
        self.logger.info("...done")
        with self.thread_start_lock:
            if getattr(self, "print_thread", None):
                self.logger.debug("Joining printing thread...")
                try:
                    self.write_thread.join(self.STOP_JOIN_TIMEOUT)
                except (AttributeError, RuntimeError):
                    self.logger.debug("...no running print thread to joint")
                else:
                    self.logger.debug("...done")
        if self.connection:
            self.connection.close()
        self.logger.info("Threaded sender is closed")


class WriteThread(threading.Thread):

    RESUME_WAITING_STEP = 0.1
    OK_WAITING_STEP = 0.01
    GO_TO_LINE_N_GCODE = b"M110"
    COMMENT_CHAR = b";"
    END_OF_LINE = b"\n"

    #TODO move "with *.thread_start_lock:" here
    def __init__(self, sender: Sender, name: str = ""):
        self.sender = sender
        self.lines_sent = 0 # for thread-safety(lines_sent count be read before load gcodes)
        self.total_gcodes = 0 # needed for multithreading purposes(lines_sent count be read before load gcodes)
        self.paused = False
        self.buffer = None
        self.temp_requesting_flag = True
        self.last_sent = b""
        self.last_about_to_be_sent = b""
        self.send_now_buffer = collections.deque()
        self.line_number_to_resend = None
        self.ready_for_command = threading.Event()
        self.resend_lock = threading.Lock()
        self.n_offset = 0
        self.add_checksums_flag = True
        if not name:
            name = self.__class__.__name__
        self.logger = sender.logger.getChild(self.__class__.__name__)
        super().__init__(name=name, daemon=True)

    def calc_checksum(self, command: AnyStr) -> int:
        checksum = 0
        for char in command:
            checksum ^= char
        return checksum

    def load_gcodes(self, gcodes: Any) -> None:
        self.logger.info("Starting gcodes loading...")
        try:
            self.resend_lock.release()
        except:
            pass
        if type(gcodes) in (self.sender.BUFFER_CLASS, GcodesBuffer):
            self.buffer = gcodes
        else:
            self.logger.info(f'Creating a buffer({self.sender.BUFFER_CLASS.__name__}) from loaded gcodes')
            self.buffer = self.sender.BUFFER_CLASS(gcodes)
        #TODO move this to send_now_buffer and adjust code using lines_sent and total_gcodes accordingly
        if self.add_checksums_flag:
            gcodes.insert(0, self.GO_TO_LINE_N_GCODE)
        self.line_number_to_resend = None
        self.lines_sent = 0
        self.n_offset = 0
        self.total_gcodes = len(gcodes)
        self.logger.info(f"...done loading gcodes. Buffer: {self.buffer.__class__.__name__}. Len:{self.total_gcodes}")

    def send_now(self, gcodes):
        if isinstance(gcodes, bytes):
            gcodes = gcodes.split(self.END_OF_LINE)
        self.send_now_buffer.extend(gcodes)

    def add_line_number_and_checksum(self, line: AnyStr) -> bytes:
        # threaded sender is counting all lines(including skipped like comment only or M0/M1)
        # but printer's firmware needs get N+1 with offset for skipped(usually comments only) lines
        command = b"N%d %s" % (self.lines_sent-self.n_offset, line)
        command += b"*%d" % self.calc_checksum(command)
        return command

    def strip_comments(self, line: AnyStr) -> str:
        index = line.find(self.COMMENT_CHAR)
        if index != -1:
            line = line[:index]
        return line.strip()

    def send(self, line: AnyStr, analyze_sent_line: bool = True) -> bool:
        self.last_about_to_be_sent = line
        self.ready_for_command.clear()
        if self.sender.connection.send(line):
            if analyze_sent_line:
                self.sender._analyze_sent_line(line)
            self.last_sent = line
            return True
        self.ready_for_command.set()
        self.sender.parent.register_error(250, "Unable to write to serial port", is_blocking=False)
        return False

    def rewind_lines_send_and_offset(self, printers_new_n_line_number: int) -> None:
        if printers_new_n_line_number > self.lines_sent - self.n_offset:
            if printers_new_n_line_number >= self.total_gcodes:
                self.logger.warning('Fast-forward jumped over the last line in the buffer. Breaking')
                self.sender.register_error(259, f'Printer requested resend of non-existing line number {printers_new_n_line_number}', is_blocking=True)
            else:
                while printers_new_n_line_number != self.lines_sent - self.n_offset:
                    try:
                        last_line = self.buffer[self.lines_sent+1]
                    except IndexError:
                        break
                    else:
                        if not self.strip_comments(last_line) or self._check_pause_or_resume(last_line):
                            self.n_offset += 1
                        self.lines_sent += 1
                    if self.sender.stop_flag:
                        return
                    self.logger.debug(f'fast-forward lines_sent:{self.lines_sent} offset:{self.n_offset}. line:{last_line}')
        else:
            while printers_new_n_line_number != self.lines_sent - self.n_offset:
                last_line = self.buffer[self.lines_sent]
                if not self.strip_comments(last_line) or self._check_pause_or_resume(last_line):
                    self.n_offset -= 1
                self.lines_sent -= 1
                if self.sender.stop_flag:
                    return
                if self.lines_sent <= 0:
                    self.lines_sent = 0
                    self.n_offset = 0
                    self.logger.warning('Line number zero was reached during resend rewind. Breaking')
                    break
                self.logger.debug(f'rewind lines_sent:{self.lines_sent} offset:{self.n_offset}. line:{last_line}')
        self.logger.info(f"Resend changed lines_sent to:{self.lines_sent} and offset to:{self.n_offset}")

    @log.log_exception
    def run(self) -> None:
        last_temp_req_time = time.monotonic()
        while not self.sender.stop_flag:
            while not self.ready_for_command.wait(self.OK_WAITING_STEP):
                if self.sender.stop_flag:
                    if self.buffer:
                        self.buffer.clear()
                        self.buffer = None
                    return
            if not self.send_now_buffer:
                now = time.monotonic()
                if now > last_temp_req_time + self.sender.TEMP_REQUEST_PERIOD:
                    last_temp_req_time = now
                    if self.temp_requesting_flag:
                        self.send_now_buffer.extend(self.sender.TEMPR_REQ_GCODES)
            if self.send_now_buffer:
                try:
                    line = self.strip_comments(self.send_now_buffer.popleft())
                except IndexError:
                    self.sender.ready_for_command.set()
                else:
                    self.send(line)
            elif self.paused or self.sender.paused_for_user or not self.buffer:
                time.sleep(self.RESUME_WAITING_STEP)
            else:
                try:
                    with self.resend_lock:
                        if self.line_number_to_resend is not None:
                            self.rewind_lines_send_and_offset(self.line_number_to_resend)
                            self.line_number_to_resend = None
                    if not self.buffer or self.total_gcodes == self.lines_sent:
                        raise IndexError #last gcode line is sent - finish the print
                    line = self.buffer[self.lines_sent]
                except (IndexError, TypeError):
                    if self.buffer is not None:
                        try:
                            self.buffer.clear()
                            self.buffer = None
                        except:
                            pass
                        self.logger.info(f"Print finished. Lines: {self.lines_sent}/{self.total_gcodes}")
                        self.sender.register_print_finished_event()
                else:
                    line = self.strip_comments(line)
                    if line and not self._check_pause_or_resume(line):
                        if self.add_checksums_flag and not line in self.sender.no_n_and_checksum_for:
                            line = self.add_line_number_and_checksum(line)
                        if self.send(line):
                            self.lines_sent += 1
                    else:
                        self.logger.debug(f'Skipped lines offset: {self.n_offset}. Line: {line}')
                        self.n_offset += 1
                        self.lines_sent += 1

    def pause(self) -> None:
        self.paused = True

    def unpause(self) -> None:
        self.paused = False

    def cancel(self) -> None:
        try:
            self.send_now_buffer.clear()
        except:
            pass
        try:
            self.buffer.clear()
        except:
            pass
        self.paused = False
        self.temp_requesting_flag = True
        with self.resend_lock:
            self.line_number_to_resend = None

    def get_percent(self) -> float:
        if self.lines_sent and self.total_gcodes:
            return round(self.lines_sent / self.total_gcodes * 100, 2)
        return 0.0

    def _check_pause_or_resume(self, line: AnyStr) -> bool:
        if self.sender.intercept_pause:
            lines_words = line.split()
            for gcode in self.sender.pause_gcodes_list:
                if gcode in lines_words:
                    self.sender.parent.register_error(601, f"Pause by gcode {line}", is_blocking=False, is_info=True)
                    self.sender.pause()
                    return True
            for gcode in self.sender.resume_gcodes_list:
                if gcode in lines_words:
                    self.sender.parent.register_error(602, f"Resume by gcode {line}", is_blocking=False, is_info=True)
                    self.sender.unpause()
                    return True
        return False

    def _set_checksum_flag(self, value: bool) -> None:
        self.add_checksums_flag = value
