# 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 paths
import typing
import paho.mqtt.client as mqtt
import base_sender
import bambulab_errors
import copy
from enum import Enum

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"
    CAMERA_CLOSE_TIMEOUT = 12
    JOB_START_WAIT_TIMEOUT = 120
    SUPPORT_JOBS = True
    CONNECT_TIMEOUT = 6
    ERROR_CLEAR_TIMEOUT = 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
    }

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

    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.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 = False
        self.keep_file = 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:
        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.save_current_printer_job_id(None)
                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}")
            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._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._ftp_remove_last_uploaded_project()
                        self._send_clean_print_error()
                        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._ftp_remove_last_uploaded_project()
                        elif print_err_code != 0:
                            self.logger.warning(f"Received undefined print error code: {print_err_code}")
            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) # 254 id for right for extruder, 255 - 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" or gcode_state == "PREPARE"
                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:
                    self.logger.warning("Wait time for job start elapsed. Clean flags ...")
                    self.wait_print_start = False
                    self.last_start_print_time = None
                    self._ftp_remove_last_uploaded_project()
                    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. Canceling ...")
                        self.cancel()
                if self.printing_flag or self.pause_flag:
                    self.wait_print_start = False
                    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 get_material_names(self) -> list[str]:
        return self.material_names

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

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

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

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

    def get_ams_slot_ids(self) -> 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 OSError as e:
            msg = f"Error connecting Bambu Lab printer on {self.host}. Description: {e}"
            self.logger.error(msg)
            if self.parent:
                self.parent.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)
            if self.parent:
                self.parent.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:
        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
        self._check_error_clear()
        success = self._send_print_command("stop")
        self._ftp_remove_last_uploaded_project()
        self.register_error(1101, "Reconnecting after job cancel", job_fail=True, disconnect=True)
        return success

    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 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}")
                result = True
        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
        if not result:
            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') 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.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()
        return self._publish(request, self.MQTT_ACTION_QOS)

    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
