# 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 ftplib
import json
import random
import os
import ssl
import threading
import time
import uuid
import zipfile
import typing
import copy

import paho.mqtt.client as mqtt

import config
import paths
import base_sender
import bambulab_errors

class P1P_FTP_TLS(ftplib.FTP_TLS):
    """FTP_TLS subclass that automatically wraps sockets in SSL to support FTPS."""

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self._sock = None

    @property
    def sock(self):
        """Return the socket."""
        return self._sock

    @sock.setter
    def sock(self, value):
        """When modifying the socket, ensure that it is ssl wrapped."""
        if value is not None and not isinstance(value, ssl.SSLSocket):
            value = self.context.wrap_socket(value)
        self._sock = value


class X1E_FTP_TLS(P1P_FTP_TLS):

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)

    """Explicit FTPS, with shared TLS session"""
    def ntransfercmd(self, cmd, rest=None):
        conn, size = ftplib.FTP.ntransfercmd(self, cmd, rest)
        if self._prot_p:
            conn = self.context.wrap_socket(conn,
                                            server_hostname=self.host,
                                            session=self.sock.session)  # this is the fix
        return conn, size


class X1_FTP_TLS(X1E_FTP_TLS):

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)

    """Force FTPS passive mode"""
    def makepasv(self):
        _, port = super().makepasv()
        return self.host, port


class Sender(base_sender.BaseSender):

    MQTT_ACTION_QOS = 1
    MQTT_PORT = 8883
    FTP_PORT = 990
    CAMERA_PORT = 322
    USERNAME = "bblp"
    LOOP_SLEEP_SEC = 5
    RETRY_SLEEP = 0.2
    UPLOAD_RETRY_SLEEP = 4
    FTP_TIMEOUT = 15
    PROJECT_PRINT_PATH = "Metadata/plate_1.gcode"
    PROJECT_METADATA_PATH = "Metadata/plate_1.json"
    SLICE_INFO_METADATA_PATH = "Metadata/slice_info.config"
    CAMERA_CLOSE_TIMEOUT = 12
    JOB_START_WAIT_TIMEOUT = 120
    SUPPORT_JOBS = True
    CONNECT_TIMEOUT = 6
    COMMAND_SEND_SLEEP = 3
    CHUNK_SIZE = 1024 * 1024

    CONNECT_RC_DESC = {
        0: "Connection successful",
        1: "Connection refused - incorrect protocol version",
        2: "Connection refused - invalid client identifier",
        3: "Connection refused - server unavailable",
        4: "Connection refused - bad username or password",
        5: "Connection refused - not authorised"
    }

    DEFAULT_SETTINGS = {
        "use_ams" : True,
        "timelapse" : False,
        "bed_leveling" : True,
        "flow_cali" : False,
        "vibration_cali" : False,
        "layer_inspect" : True
    }

    AUTO_BED_TYPE = "auto"

    BED_TYPES = {
        "Textured PEI Plate": "textured_plate",
        "High Temp Plate": "hot_plate",
        "Engineering Plate": "eng_plate",
        "Cool Plate": "cool_plate",
        "Supertack Plate": "supertack_plate"
    }

    CURRENT_BED_ARG = "curr_bed_type"

    DEFAULT_METADATA = {
        "bbox_all": [
            0,
            0,
            0,
            0
        ],
        "bbox_objects": [
            {
                "area": 0,
                "bbox": [
                    0,
                    0,
                    0,
                    0
                ],
                "id": 69,
                "layer_height": 0,
                "name": "3DPrinterOS.stl"
            }
        ],
        "bed_type": "auto",
        "filament_colors": [
            "#000000"
        ],
        "filament_ids": [
            0
        ],
        "first_extruder": 0,
        "is_seq_print": False,
        "nozzle_diameter": 0,
        "version": 2
    }

    SLICE_INFO_DATA = {
        'total filament length [mm]': [],
        'total filament weight [g]': [],
        'filament': [],
        'filament_colour': [],
        'filament_ids': [],
        'filament_type': [],
        'filament_is_support': [],
        'nozzle_diameter': [],
        'filament_nozzle_map': [],
        'filament_map': [],
        'nozzle_volume_type': []
    }

    # required inside 3mf for the P2S print start
    SLICE_INFO_CONTENT_MASK = '''
<?xml version="1.0" encoding="UTF-8"?>
<config>
  <header>
    <header_item key="X-BBL-Client-Type" value="slicer"/>
    <header_item key="X-BBL-Client-Version" value=""/>
  </header>
  <plate>
    <metadata key="index" value="1"/>
    %s
  </plate>
</config>
    '''

    NATIVE_FILE_EXTENSION = ".3mf"
    LEFT_EXTERNAL_SPOOL_ID = "254"
    RIGHT_EXTERNAL_SPOOL_ID = "255"

    def __init__(self, parent: typing.Any, usb_info: dict, profile: dict = {}):
        super().__init__(parent, usb_info, profile)
        self.serial_number = usb_info.get("SNR", "").upper()
        self.host = usb_info.get("IP", "")
        self.run_once = usb_info.get('RUNONCE')
        self.password = usb_info.get('PASS')
        self.client = mqtt.Client()
        self.client.on_connect = self._on_connect
        self.client.on_message = self._on_message
        self.client.on_disconnect = self._on_disconnect
        self.client.on_log = self._on_log
        self.last_message = ""
        self.user_id = '3DPrinterOS_Cloud_Client' + str(random.randint(1000, 9999))
        self.logger.info(f'UserID for BambuLab requests for {str(self.host)}: {self.user_id}')
        self.current_sequence_id = 0
        self.printer_has_sdcard = False
        # Set the TLS/SSL options for the client
        self.client.tls_set(tls_version=ssl.PROTOCOL_TLS, cert_reqs=ssl.CERT_NONE)
        self.client.tls_insecure_set(True)
        self.client.reconnect_delay_set(min_delay=1, max_delay=1)
        self.client.username_pw_set(self.USERNAME, self.password)
        self.status_thread = threading.Thread(target=self._status_loop, name="BambuLabStatusThread_" + str(self.serial_number))
        self.publish_lock = threading.RLock()
        self.sequence_lock = threading.RLock()
        self.last_err_lock = threading.RLock()
        self.material_names = None
        self.material_desc = None
        self.material_colors = None
        self.ams_extruders = None
        self.ams_ids = None
        self.ams_slot_ids = None
        self.next_print_options = None
        self.has_ams = False
        self.mqtt_device_prefix = "device/%s/" % self.serial_number
        self.settings.update(self.DEFAULT_SETTINGS)
        self.load_printer_settings()
        self.camera_number = 1
        self.camera_url = None
        self.bed_not_clear = False
        self.last_start_print_time = None
        self.custom_camera_token = None
        self.wait_print_start = False
        self.last_err_code = None
        self.verbose = config.get_settings().get('verbose', False)
        self.keep_file = False
        self.last_print_start_payload = None
        self.print_start_response_received = False
        self.add_slice_info = self.profile.get("add_slice_info", False)
        if self.profile.get("camera_url_template"):
            self.camera_url = self.profile.get("camera_url_template") % (self.USERNAME, self.password, self.host, self.CAMERA_PORT, self.camera_number)
        else:
            self.camera_url = f"{self.host} BAMB_AP1 {self.password}"
        self.number_of_extruders = self.profile.get("extruder_count", 1)
        if not self.run_once:
            self._connect_loop()
        # Wait for connection
        start_time = time.time()
        while not self.operational_flag:
            if time.time() - start_time > self.CONNECT_TIMEOUT:
                self.close()
                raise RuntimeError("Max wait time to connect printer reached. Possible reason: Wrong serial number or printer firmware update required")
            time.sleep(1)

    def _on_connect(self, client, userdata, flags, rc) -> None:
        self.logger.info(f"Bambulab: Connected to {self.host} with result code {rc}")
        if rc != 0:
            if rc in self.CONNECT_RC_DESC:
                desc = self.CONNECT_RC_DESC[rc]
            else:
                desc = "Return code " + str(rc)
            message = f"Error connecting Bambu Lab Printer on {self.host}. Description: {desc}"
            self.logger.error(message)
            self.client.disconnect()
            self.register_error(1100, message, disconnect=True)
            return
        client.subscribe(self.mqtt_device_prefix + 'report')
        self._send_get_version()
        if not self.status_thread.is_alive():
            self.status_thread.start()
        else:
            self.logger.warning("Status thread already running. Skip")
        if self.camera_url:
            self.add_camera_url(self.camera_url)

    def _on_disconnect(self, client, userdata, rc):
        if not self.stop_flag:
            if rc == 7 and not self.operational_flag:
                self.register_error(11198, "Disconnected. Possible Reason: Wrong serial number or printer firmware update required", job_fail=False, disconnect=True)
            else:
                self.register_error(11199, f"BambuLab had disconnected with code {rc}.", job_fail=False, disconnect=True)
            self.operational_flag = False
            self.stop_flag = True
            if self.status_thread and self.status_thread.is_alive():
                self.status_thread.join(10)
            self.logger.info(f"BambuLab: Disconnected from {str(self.host)}. with return code : {rc}. Joining status thread")

    def is_bed_not_clear(self):
        return self.bed_not_clear

    def _get_sequence_id(self):
        with self.sequence_lock:
            self.current_sequence_id += 1
            return self.current_sequence_id

    def _get_flag_bits(self, hex_str: str, start: int, count: int) -> int:
        if hex_str:
            try:
                decimal_value = int(hex_str, 16)
                mask = (1 << count) - 1
                flag = (decimal_value >> start) & mask
                return flag
            except Exception as e:
                self.logger.error(f"Get flag Bits from hex f{hex_str} catched error. Desc: {e}")
        return 0

    def check_preconnect_printer_job(self):
        if self.preconnect_printer_job_id:
            if self.printers_job_id != self.preconnect_printer_job_id:
                self._ftp_remove_last_uploaded_project()
                self.register_error(999,
                                    'After reconnection to the printer, it is not running cloud print job. Assuming that job had failed, when print was offline.',
                                    job_fail=True)
            self.preconnect_printer_job_id = None

    def save_current_job_filename(self, job_filename: str):
        self.logger.info(f'Saving current printer({self.parent}) job filename {job_filename}')
        self.update_printer_settings({'printer_job_filename': job_filename})

    def _parse_response(self, print_sect):
        if print_sect.get("command") == "project_file":
            self.logger.info(f"Received from {self.host}. Desc: {print_sect}")
            self.print_start_response_received = True
            if print_sect.get("err_code", 0) == bambulab_errors.NO_DEV_MODE_PRINT_START_ERROR:
                self.register_error(bambulab_errors.NO_DEV_MODE_PRINT_START_ERROR,
                                    "Print start failed. Possible reason: Developer Mode is not enabled.", job_fail=True)
                self.wait_print_start = False
                self.last_print_start_payload = None
                self._ftp_remove_last_uploaded_project()
            else:
                self.last_start_print_time = time.monotonic()
        if print_sect.get("command") == "push_status":
            if "print_error" in print_sect:
                print_err_code = print_sect.get("print_error", self.last_err_code)
                if print_err_code != self.last_err_code:
                    with self.last_err_lock:
                        self.last_err_code = print_err_code
                    if print_err_code == bambulab_errors.LOCAL_CANCEL_CODE:
                        self.wait_print_start = False
                        self.last_print_start_payload = None
                        self._ftp_remove_last_uploaded_project()
                        self._send_clean_print_error()
                        time.sleep(self.COMMAND_SEND_SLEEP) # little wait before cancel to ensure error cleaned
                        if self.parent:
                            self.parent.cancel_locally()
                        else:
                            self.logger.warning('Job cancelled from printer')
                    else:
                        if print_err_code in bambulab_errors.ALL_ERRORS:
                            abort_job = print_err_code in bambulab_errors.ERROR_CODES_JOB_FAILED
                            self.register_error(int(print_err_code), bambulab_errors.ALL_ERRORS[print_err_code],
                                                job_fail=abort_job, info=not abort_job)
                            if print_err_code in bambulab_errors.ERROR_CODES_TO_ABORT_JOB:
                                abort_job = True
                                self.cancel()
                            else:
                                self._send_clean_print_error()
                            if abort_job:
                                self.wait_print_start = False
                                self.last_print_start_payload = None
                                self._ftp_remove_last_uploaded_project()
                        elif print_err_code != 0:
                            self.logger.warning(f"Received undefined print error code: {print_err_code}")
                            self.register_error(print_err_code, f"Unknown print error ({print_err_code}). See the printer screen for more information", info=True)
            self.operational_flag = True
            # TODO: Discuss: in index 0 always must be external spool information (mostly user did not set it and it's undefined don't know even will we use it somehow in he future)
            # external_spool = print_sect.get("vt_tray", {})
            # self.material_names.append(external_spool.get("tray_type", ""))
            # self.material_colors.append(external_spool.get("tray_color", ""))
            # self.material_desc.append(external_spool.get("tray_sub_brands", ""))
            material_names = []
            material_colors = []
            material_desc = []
            material_ams_ids = []
            material_ams_slot_ids = []
            material_extruders = []
            if "ams" in print_sect:
                # parse AMS materials info
                ams = print_sect.get("ams", {}).get("ams", [])
                self.has_ams = isinstance(ams, list) and ams
                if self.has_ams:
                    for item in ams:
                        ams_trays = item.get("tray")
                        ams_id = str(item.get("id", "0"))
                        ext_id = str(self._get_flag_bits(item.get("info", ""), 8, 4))
                        if isinstance(ams_trays, list):
                            for tray in ams_trays:
                                if isinstance(tray, dict):
                                    material_names.append(tray.get("tray_type", ""))
                                    material_colors.append(tray.get("tray_color", ""))
                                    material_desc.append(tray.get("tray_sub_brands", ""))
                                    material_ams_slot_ids.append(str(tray.get("id", "")))
                                    material_ams_ids.append(ams_id)
                                    if self.number_of_extruders == 2:
                                        material_extruders.append(ext_id)
                self.material_names = material_names
                self.material_colors = material_colors
                self.material_desc = material_desc
                self.ams_ids = material_ams_ids
                self.ams_slot_ids = material_ams_slot_ids
                self.ams_extruders = material_extruders
            # TODO: discuss if it's requires, for now we ignore it in the AMS popup on the cloud UI
            if "vir_slot" in print_sect and self.number_of_extruders == 2:
                # parse external spools info for H2D
                external_spools = print_sect.get("vir_slot", [])
                if external_spools and isinstance(external_spools, list):
                    for spool in external_spools:
                        if isinstance(spool, dict):
                            spool_id = spool.get("id", self.RIGHT_EXTERNAL_SPOOL_ID) # 255 id for right for extruder, 254 - left
                            material_names.append(spool.get("tray_type", ""))
                            material_colors.append(spool.get("tray_color", ""))
                            material_desc.append(spool.get("tray_sub_brands", ""))
                            material_ams_ids.append(spool_id)
                            material_ams_slot_ids.append("0")
                            if spool_id == self.RIGHT_EXTERNAL_SPOOL_ID:
                                material_extruders.append("0")
                            else:
                                material_extruders.append("1")
                self.material_names = material_names
                self.material_colors = material_colors
                self.material_desc = material_desc
                self.ams_ids = material_ams_ids
                self.ams_slot_ids = material_ams_slot_ids
                self.ams_extruders = material_extruders
            #### End of discuss ####
            self.temps[0] = round(print_sect.get("bed_temper", self.temps[0]), 2)
            self.temps[1] = round(print_sect.get("nozzle_temper", self.temps[1]), 2)
            self.target_temps[0] = round(print_sect.get("bed_target_temper", self.target_temps[0]), 2)
            self.target_temps[1] = round(print_sect.get("nozzle_target_temper", self.target_temps[1]), 2)
            if self.number_of_extruders == 2:
                # TODO: get temps of the second extruder
                extruder_info_dict = print_sect.get("extruder", {}).get("info")
            self.printer_has_sdcard = print_sect.get("sdcard", self.printer_has_sdcard)
            if "gcode_state" in print_sect: # need to check if key exist first for P1P model
                gcode_state = print_sect.get("gcode_state")
                self.printing_flag = gcode_state == "RUNNING" # TODO: Set to 'PREPARE' state if required (it shows 100% of the previous job before starting the new one)
                self.pause_flag = gcode_state == "PAUSE"
                if gcode_state == "FINISH" and not self.wait_print_start:
                    self._ftp_remove_last_uploaded_project()
                # self.bed_not_clear = gcode_state == "FINISH" # TODO: discuss, on FINISH state user can run a new job
                if self.last_start_print_time and time.monotonic() - self.last_start_print_time > self.JOB_START_WAIT_TIMEOUT:
                    if self.last_print_start_payload and not self.print_start_response_received and not self.cancel_upload_to_printer_flag:
                        self.register_error(1104,
                                            "The wait time for the the job start has elapsed. Second attempt to start the print.",
                                            info=True)
                        self.last_start_print_time = time.monotonic()
                        self._publish(self.last_print_start_payload)
                        self.last_print_start_payload = None
                    else:
                        self.logger.warning("The wait time for the job start has elapsed. Clean flags ...")
                        self.wait_print_start = False
                        self.last_start_print_time = None
                        self._ftp_remove_last_uploaded_project()
                        self.register_error(1103, "The wait time for the job start has elapsed. Cancelling ...", job_fail=True)
                        if self.cancel_upload_to_printer_flag:
                            self.cancel_upload_to_printer_flag = False
                            self.logger.info("Job was canceled during job uploading or starting. Cancelling ...")
                            self.cancel()
                if self.printing_flag or self.pause_flag:
                    self.wait_print_start = False
                    self.last_print_start_payload = None
                    self.printers_job_id = print_sect.get("task_id", self.printers_job_id)
                    if (self.last_start_print_time and self.printers_job_id
                            and time.monotonic() - self.last_start_print_time < self.JOB_START_WAIT_TIMEOUT):
                        self.last_start_print_time = None
                        if self.cancel_upload_to_printer_flag:
                            self.cancel_upload_to_printer_flag = False
                            # Little wait to not damage the printer with sudden cancellation (scary sound on P1S)
                            time.sleep(self.LOOP_SLEEP_SEC)
                            self.logger.info("Job was canceled during job uploading or starting. Canceling ...")
                            self.cancel()
                        else:
                            self.save_current_printer_job_id(self.printers_job_id)
                    time_left_min = print_sect.get("mc_remaining_time")
                    if time_left_min is not None:
                        self.print_time_left = int(time_left_min) * 60
                    self.percent = float(print_sect.get("mc_percent", 0))
                    self.current_line_number = int(print_sect.get("layer_num", 0))
                else:
                    self.percent = 0
                    self.current_line_number = 0
                    self.printers_job_id = None
                self.check_preconnect_printer_job()

    def _on_message(self, client, userdata, message):
        try:
            response = json.loads(message.payload.decode())
            if self.verbose:
                self.logger.info(f"Received from {self.host}\n{message.payload.decode()}")
            if not isinstance(response, dict):
                raise TypeError("Response should be dict")
            info_sect = response.get("info")
            if info_sect and isinstance(info_sect, dict):
                if info_sect.get("command") == "get_version":
                    self.logger.info(f"Printer version: {info_sect}")
            print_sect = response.get("print")
            if print_sect:
                if isinstance(print_sect, dict):
                    self._parse_response(print_sect)
                else:
                    self.logger.warning(f"Invalid print section: {print_sect}")
        except Exception as e:
            self.logger.error(f"Error updating printer info. Desc: {e}")

    def _on_log(self, client, userdata, level, buf):
        if self.verbose:
            # level integers map to: mqtt.MQTT_LOG_INFO, MQTT_LOG_NOTICE, MQTT_LOG_WARNING, MQTT_LOG_ERR, MQTT_LOG_DEBUG
            self.logger.info(f"[LOG] level={level} msg={buf}")

    def get_material_names(self) -> typing.List[str]:
        return self.material_names

    def get_material_colors_hex(self) -> typing.List[str]:
        return self.material_colors

    def get_material_desc(self) -> typing.List[str]:
        return self.material_desc

    def get_ams_extruders(self) -> typing.List[str]:
        return self.ams_extruders

    def get_ams_ids(self) -> typing.List[str]:
        return self.ams_ids

    def get_ams_slot_ids(self) -> typing.List[str]:
        return self.ams_slot_ids

    def is_printing(self) -> bool:
        return self.printing_flag

    def _status_loop(self) -> None:
        while not self.stop_flag and self.client.is_connected():
            self._send_pushall()
            time.sleep(self.LOOP_SLEEP_SEC)
        self.stop_flag = True
        self.client.disconnect()
        self.client.loop_stop()
        self.logger.info('Disconnected and stopped status loop')
        if not self.stop_flag:
            self.register_error(1102, f"Status loop exit for BambuLab", job_fail=True, disconnect=True)

    def _connect_loop(self) -> None:
        self.logger.info(f"Connecting Bambu Lab printer on {self.host}")
        try:
            self.client.connect(self.host, self.MQTT_PORT)
            self.client.loop_start()
        except Exception as e:
            if self.verbose:
                self.logger.exception(e)
            msg = f"Error connecting Bambu Lab printer on {self.host}. Description: {e}"
            self.register_error(1002, msg)

    def _publish(self, msg: dict, qos: int = 0) -> bool:
        with self.publish_lock:
            self.logger.debug(f"publish msg: {json.dumps(msg)}")
            try:
                result = self.client.publish(self.mqtt_device_prefix + "request", json.dumps(msg), qos)
                status = result[0]
            except Exception as e:
                self.logger.error(f"Publish with {msg} failed. Desc {str(e)}")
                return False
            if status == 0:
                self.logger.debug(f"Sent {msg} to topic {self.mqtt_device_prefix} request")
                return True
            msg = f"Failed to send message {msg} to topic {self.mqtt_device_prefix} request on {self.host}."
            self.logger.warning(msg)
            self.register_error(1003, msg)
            return False

    def _send_print_command(self, command: str, param: typing.Union[str, dict] = None) -> bool:
        request = {"print": {"sequence_id": str(self._get_sequence_id()), "command": command}, "user_id": self.user_id}
        if param:
            request["print"]["param"] = param
        return self._publish(request, self.MQTT_ACTION_QOS)

    def _send_pushall(self) -> bool:
        request = {"pushing": {"sequence_id": str(self._get_sequence_id()), "command": "pushall"}, "user_id": self.user_id}
        return self._publish(request)

    def _send_get_version(self) -> bool:
        request = {"info": {"sequence_id": str(self._get_sequence_id()), "command": "get_version"}, "user_id": self.user_id}
        return self._publish(request, self.MQTT_ACTION_QOS)

    def _send_clean_print_error(self) -> bool:
        request = {"print": {"sequence_id": str(self._get_sequence_id()), "subtask_id": "0", "command": "clean_print_error", "print_error": self.last_err_code}, "user_id": self.user_id}
        return self._publish(request, self.MQTT_ACTION_QOS)

    def _check_error_clear(self) -> None:
        with self.last_err_lock:
            if self.last_err_code:
                self._send_clean_print_error()
                time.sleep(self.RETRY_SLEEP)
                self.last_err_code = None

    def pause(self) -> bool:
        return self._send_print_command("pause")

    def unpause(self) -> bool:
        self._check_error_clear()
        return self._send_print_command("resume")

    def cancel(self) -> bool:
        success = False
        if self.wait_print_start:
            self.logger.info("Job will be canceled after print start or job start timer elapsed")
            self.cancel_upload_to_printer_flag = True
            return False
        if self.client.is_connected():
            self._check_error_clear()
            success = self._send_print_command("stop")
        else:
            self.logger.info("Skip sending stop command. Not connected to MQTT")
        self._ftp_remove_last_uploaded_project()
        time.sleep(self.COMMAND_SEND_SLEEP)
        self.register_error(1101, "Reconnecting after job cancel", job_fail=True, disconnect=True)
        return success

    def _get_gcode_comments_data(self, filepath):
        # possible comments examples
        # ; total filament weight [g] : 0.22,0.16
        # ; filament_density: 1.26, 1.26, 1.25
        # ; filament_colour = #FFFFFF;#F72323;#FCECD6
        # ; filament_map = 2, 2, 1
        results = copy.deepcopy(self.SLICE_INFO_DATA)
        required_keys = list(results.keys())
        try:
            with open(filepath, 'r', errors="ignore") as file:
                for line in file:
                    clean_line = line.strip()
                    if clean_line:
                        if not clean_line.startswith(';'):
                            # seems all slice settings comments to parse have finished and started gcode lines
                            break
                        clean_line = line.lstrip(";")
                        key_value_list = clean_line.replace(":", "=").split("=", 1)
                        try:
                            key = key_value_list[0].strip()
                            value = key_value_list[1]
                        except IndexError:
                            continue
                        if key in required_keys:
                            val_list = value.replace(";", ",").split(",")
                            results[key] = list(filter(None, map(str.strip, val_list)))
                            required_keys.remove(key)
                            if not required_keys:
                                break
        except Exception as e:
            self.logger.warning("Failed to get required keys. Details: " + str(e))
            return {}
        # validate if all required comments parsed ok
        if any(not value for value in results.values()):
            self.logger.warning("Failed to get all required keys from the gcode comments")
            if self.verbose:
                self.logger.info(results)
            return {}
        return results

    def _generate_slice_info(self, filepath):
        plate_content = ""
        comments_dict = self._get_gcode_comments_data(filepath)
        if self.verbose:
            self.logger.info(comments_dict)
        if comments_dict:
            try:
                for i, f_id in enumerate(comments_dict["filament"]):
                    f_index = int(f_id) - 1
                    used_m = str(round(float(comments_dict["total filament length [mm]"][i]) / 1000, 2))
                    extruder_index = int(comments_dict["filament_map"][f_index])-1
                    nozzle_diameter = f'{float(comments_dict["nozzle_diameter"][extruder_index]):.2f}'
                    volume_type = comments_dict["nozzle_volume_type"][extruder_index]
                    used_for_object = "true"  # We can't parse this info from comments so just forcing it to true
                    used_for_support = str(comments_dict["filament_is_support"][f_index] == "1").lower()
                    plate_content += (
                        f'<filament id="{str(f_id)}" tray_info_idx="{comments_dict["filament_ids"][f_index]}"'
                        f' type="{comments_dict["filament_type"][f_index]}"'
                        f' color="{comments_dict["filament_colour"][f_index]}" used_m="{used_m}"'
                        f' used_g="{comments_dict["total filament weight [g]"][i]}"'
                        f' used_for_object="{used_for_object}" used_for_support="{used_for_support}"'
                        f' group_id="{comments_dict["filament_nozzle_map"][f_index]}"'
                        f' nozzle_diameter="{nozzle_diameter}" volume_type="{volume_type}"/>'
                        f'\n'
                    )
            except Exception as e:
                self.logger.info("Failed to generate slice info. Desc: " + str(e))
                plate_content = ""
        else:
            self.logger.warning("Failed to generate slice info for <plate>."
                                " Required keys were missing from the G-code comments."
                                " Using a default empty <plate> instead")
        return self.SLICE_INFO_CONTENT_MASK % plate_content

    def _create_project(self, filename, bed_type) -> str:
        tmp_file = os.path.join(paths.DOWNLOAD_FOLDER, str(uuid.uuid4()) + '.3mf')
        metadata_content = copy.deepcopy(self.DEFAULT_METADATA)
        metadata_content["bed_type"] = bed_type
        try:
            with zipfile.ZipFile(tmp_file, 'w', zipfile.ZIP_DEFLATED) as zipf:
                zipf.write(filename, self.PROJECT_PRINT_PATH)
                if self.add_slice_info:
                    slice_info = self._generate_slice_info(filename)
                    if self.verbose:
                        self.logger.info(f"Add {self.SLICE_INFO_METADATA_PATH} to 3MF with next data: {slice_info}")
                    zipf.writestr(self.SLICE_INFO_METADATA_PATH, slice_info)
                if bed_type != self.AUTO_BED_TYPE:
                    zipf.writestr(self.PROJECT_METADATA_PATH, json.dumps(metadata_content))
        except Exception as e:
            self.logger.error(f"3MF creation failed. Desc: f{str(e)}")
        else:
            return tmp_file

    def _create_ftp_tls(self) -> ftplib.FTP_TLS:
        if self.usb_info.get("PID") == "0P1P":
            return P1P_FTP_TLS()
        elif self.usb_info.get("PID") == "0X1E":
            # NOTE: use X1E FTPS connection without passive mod
            return X1E_FTP_TLS()
        else:
            return X1_FTP_TLS()

    def _ftp_remove_last_uploaded_project(self):
        last_project_filename = self.settings.get("printer_job_filename")
        if not last_project_filename:
            return
        try:
            ftp_session = self._create_ftp_tls()
            ftp_session.encoding = "utf-8"
            ftp_session.timeout = self.FTP_TIMEOUT
            ftp_session.connect(host=self.host, port=self.FTP_PORT, timeout=self.FTP_TIMEOUT)
            ftp_session.login(self.USERNAME, self.password)
            ftp_session.prot_p()
            ftp_session.cwd('/')
            ftp_session.delete(last_project_filename)
        except ftplib.all_errors as err:
            self.logger.error(f"Remove project files on {self.host} failed. Description: {str(err)}")
        except Exception as e:
            self.logger.error(f"Remove project files on  {self.host} failed. Description:" + str(e))
        finally:
            try:
                self.save_current_job_filename("")
                ftp_session.quit()
            except:
                pass

    def _check_ftp_file(self, local_size: int, ftp_file: str) -> bool:
        try:
            ftp_session = self._create_ftp_tls()
            ftp_session.encoding = "utf-8"
            ftp_session.timeout = self.FTP_TIMEOUT
            ftp_session.connect(host=self.host, port=self.FTP_PORT, timeout=self.FTP_TIMEOUT)
            ftp_session.login(self.USERNAME, self.password)
            ftp_session.prot_p()
            for f in ftp_session.nlst():
                if ftp_file in f:
                    file_exists = True
                    break
            else:
                file_exists = False
            if not file_exists:
                self.logger.info(f"File {ftp_file} not found on FTP server")
                return False
            ftp_size = ftp_session.size(ftp_file)
            if ftp_size == 0 and ftp_size != local_size:
                self.logger.warning(f"File {ftp_file} has zero or different size on FTP server. ftp: {ftp_size} / local: {local_size}")
                return False
            self.logger.info(f"File {ftp_file} found on FTP server with the same size, ftp: {ftp_size} / local: {local_size}")
            return True
        except ftplib.all_errors as err:
            self.logger.error(f"Check file on {self.host} failed. Description: {str(err)}")
        except Exception as e:
            self.logger.error(f"Check file on {self.host} failed. Description:" + str(e))
        finally:
            try:
                ftp_session.quit()
            except:
                pass
        return False

    def _upload(self, project_src: str, file_name: str, retries_left=2) -> bool:
        result = False
        tmp_size = os.path.getsize(project_src)
        self.logger.info(f"Start uploading {file_name} to {self.host}")
        try:
            with open(project_src, "rb") as f:
                ftp_session = self._create_ftp_tls()
                ftp_session.encoding = "utf-8"
                ftp_session.timeout = self.FTP_TIMEOUT
                ftp_session.connect(host=self.host, port=self.FTP_PORT, timeout=self.FTP_TIMEOUT)
                ftp_session.login(self.USERNAME, self.password)
                ftp_session.prot_p()
                ftp_session.cwd("/")
                ftp_session.storbinary(f"STOR {file_name}", f)
                self.logger.info(f"Successful uploaded {file_name} on {self.host}")
        except ftplib.all_errors as err:
            self.logger.warning(f"Upload to printer finished with errors. Description: {str(err)}")
        except OSError as e:
            self.logger.error(f"Could not open/read file to upload. Description: {str(e)}")
        except Exception as e:
            self.logger.error(f"Undefined Exception on file upload. Description: {str(e)}")
        finally:
            try:
                ftp_session.quit()
            except:
                pass
        time.sleep(self.UPLOAD_RETRY_SLEEP)
        result = self._check_ftp_file(tmp_size, file_name)
        if not result:
            if retries_left:
                retries_left -= 1
                self.register_error(1007, f"Upload to {self.host} failed. Retries left: {retries_left}", info=True)
                time.sleep(self.UPLOAD_RETRY_SLEEP)
                return self._upload(project_src, file_name, retries_left)
            else:
                self.register_error(1005, f"Upload to {self.host} failed.", job_fail=True)
        return result

    # NOTE: command from Bambulab Studio API Source. Does not work with files for print (Tested on X1C Carbon)
    # def _run_gcode_file(self, sdcard_filepath):
    #     return self._send_print_command("gcode_file", sdcard_filepath)

    def unbuffered_gcodes(self, gcodes: str) -> bool:
        self.logger.info(f"Enter unbuffered_gcodes: {gcodes}")
        try:
            gcodes = self.preprocess_gcodes(gcodes)
        except Exception as e:
            self.logger.exception(e)
            return False
        success = True
        for command in gcodes:
            request = {"print": {"command": "gcode_line","param": command.decode('utf-8', errors="ignore") + "\n"}, "user_id": self.user_id}
            success &= self._publish(request)
        return success

    def _detect_gcode_bed_type(self, gcode_file_path):
        bed_type_snake = self.AUTO_BED_TYPE
        try:
            with open(gcode_file_path, 'r', errors="ignore") as file:
                for line in file:
                    if self.CURRENT_BED_ARG in line:
                        try:
                            bed_type = line.split('=')[1].strip()
                            bed_type_snake = self.BED_TYPES.get(bed_type)
                            if not bed_type_snake:
                                self.logger.warning('Unknown bed type: ' + str(bed_type))
                        except IndexError:
                            pass
                        break
        except Exception as e:
            self.logger.error('Error while getting bed type from a gcode file: ' + str(e))
        return bed_type_snake

    def _clean_files(self, files_to_del, keep_file=False):
        if not keep_file or self.keep_print_files:
            for filepath in files_to_del:
                try:
                    os.remove(filepath)
                except:
                    pass

    def gcodes(self, filepath: str, keep_file: bool = False) -> bool:
        self.keep_file = keep_file
        return base_sender.BaseSender.gcodes(self, filepath, keep_file)

    def process_gcodes_file(self, filepath: str) -> bool:
        keep_file = self.keep_file
        if not self.printer_has_sdcard:
            self._clean_files((filepath,), keep_file)
            self.register_error(1004, f"Can't upload file on {self.host}. Description: no SD card", job_fail=True)
            return False
        # bed_type required for detection for X1/A1 series if build plate detect options is enabled on printer
        bed_type = self._detect_gcode_bed_type(filepath)
        project_src = self._create_project(filepath, bed_type)
        if not project_src:
            self._clean_files((filepath,), keep_file)
            self.register_error(1006, f"Error creating 3mf for upload", job_fail=True)
            return False
        # project_src = filepath # to test with 3mf
        project_filename = self.get_filename() + '.3mf'
        success = self._upload(project_src, project_filename)
        self._clean_files((filepath, project_src), keep_file)
        if not success:
            self.next_print_options = None
            return False
        task_id = self.get_clouds_job_id()
        if not task_id:
            task_id = str(random.randint(1000, 99999))
        time.sleep(self.LOOP_SLEEP_SEC)  # WORKAROUND: need little wait for P1S to start print
        request = {
            "print": {
                "sequence_id": "50000", # WORKAROUND: this magic number should be used after update to 01.06.00.00 on P1S
                "command": "project_file",
                "bed_type": bed_type,
                "flow_cali": True,
                "layer_inspect": True,
                "profile_id": "0",
                "project_id": "0",
                "subtask_id": "0",
                "task_id": task_id,
                "file": project_filename,
                "param": self.PROJECT_PRINT_PATH,
                "subtask_name": self.get_filename(),
                "url": f"ftp://{project_filename}",
                "use_ams": self.has_ams and self.settings['use_ams']
            }
        }
        # FIXME: Temporary disable load settings from file before AMS support not released
        # if self.settings != self.DEFAULT_SETTINGS:
        #     request["print"].update(self.settings)
        if self.next_print_options:
            request["print"].update(self.next_print_options)
            self.next_print_options = None
        self.last_print_start_payload = request
        self.print_start_response_received = False
        self.logger.info("Sending print start: " + json.dumps(request))
        self.save_current_job_filename(project_filename)
        self.wait_print_start = True
        self.last_start_print_time = time.monotonic()
        result = self._publish(request, self.MQTT_ACTION_QOS)
        if not result:
            self._ftp_remove_last_uploaded_project()
            self.register_error(1008, "Error during job start. Canceling print ...", job_fail=True, disconnect=True)
        return result

    def load_gcodes(self, filepath: str) -> bool:
        # we don't need any processing for Bambulab but BaseSender.gcodes calls it
        return True

    def set_next_print_options(self, options: dict):
        self.next_print_options = options

    def is_uploading(self):
        return self.wait_print_start

    def close(self):
        self.stop_flag = True
        self.remove_camera_url(self.camera_url)
        if self.status_thread:
            try:
                self.status_thread.join(10)
            except RuntimeError:
                pass
        try:
            self.client.disconnect()
        except:
           pass
        time.sleep(0.5) # give some for _on_disconnect to run properly
        try:
            self.client.loop_stop()
        except:
           pass
        time.sleep(0.5) # give some for _on_disconnect to run properly
