# Copyright 3D Control Systems, Inc. All Rights Reserved 2017-2019.
# Built in San Francisco.

import json
import os
import threading
import time
import typing
import hashlib
import secrets

import requests

import base_mqtt_sender

class Sender(base_mqtt_sender.BaseMQTTSender):

    HTTP_PORT = 80
    HTTP_DEV_PORT = 8010
    USERNAME = "elegoo"
    HEARTBEAT_INTERVAL = 10
    CONNECT_TIMEOUT = 10
    COMMAND_SEND_SLEEP = 3
    CHUNK_SIZE = 1024 * 1024
    UPLOAD_TIMEOUT = 120
    CMD_GET_STATUS = 1002
    CMD_GET_CANVAS_STATUS = 2005
    CMD_START_PRINT = 1020
    CMD_PAUSE_PRINT = 1021
    CMD_STOP_PRINT = 1022
    CMD_RESUME_PRINT = 1023
    CMD_DELETE_FILE = 1047
    EVENT_ON_PRINTER_STATUS = 6000
    STATUS_PRINTING = 2
    SUB_STATUS_PAUSED = 2502
    SUB_STATUS_PAUSED_2 = 2505
    JOB_WAIT_FINISHED_TIMEOUT = 6

    # Error codes from https://wiki.elegoo.com/en/centauri-carbon-2-combo
    CC2_ERRORS = {
        101: "Please troubleshoot the heated bed and wiring (with the device powered off)",
        102: "The temperature sensor of the heated bed is disconnected.",
        103: "Please troubleshoot the printhead and wiring (with the device powered off)",
        104: "An exception occurred. Please check the printer. Nozzle temperature sensor disconnected",
        109: "Air filtration system is about to expire. Please replace the filter in time to ensure filtration quality.",
        205: "Chamber temperature sensor disconnected;Chamber temperature sensor shorted",
        206: "Chamber temperature sensor disconnected;Chamber temperature sensor shorted",
        304: "Z-axis motor error, Please troubleshoot the motor and (with the device powered off)",
        605: "Pressure sensor data error",
        701: "Mainboard fan error. Please check the fan and wiring",
        702: "Heatbreak fan error. Please troubleshoot the fan and wiring (with the devlice powered off)",
        703: "Model fan error. Please check the fan and wiring",
        704: "Leveling failed. Please try again",
        705: "Auxiliary fan error",
        706: "Case fan error. Please check the fan and wiring",
        707: "Toolhead front cover detached. Please check if it is loose or abnormal",
        801: "Extruder and control board communication error. Please troubleshoot connections (with thedevice powered off)",
        802: "Control board communication error of the leveling sensor.",
        1003: "Error job start, Corrupted print start options", # took from logs when print options are wrong
        1101: "Exhaust vent failed to open. Check if it's blocked",
        1102: "Exhaust vent failed to close. Check if it's jammed",
        1211: "CANVAS filament run out Insert filament into the same slot on CANVAS and tap Continue",
        1220: "Abnormal filament detected in extruder. Please unload manual",
        1231: "Filament cut failed. Please check if the cutter block has been removed",
        1241: "Loading error",
        1242: "Filament unload failed at the toolhead. The filament may have slipped or jammed Inside CANVAS",
        1243: "Filament extrusion failed at the toolhead. Check if the toolhead is clogged",
        1251: "Filament unload failed at the toolhead. The filament may have slipped or jammed inside CANVAS",
        1252: "Filament unload failed at the toolhead. The nozzle may be clogged or filament stuck inside the extruder",
        1263: "Filament extrusion failed at the toolhead. The filament may be tangled or the spool is jammed.",
        1265: "Filament detection failed at the toolhead. The filament may be jammed Try removing it manually",
        1266: "Abnormal filament trigger detected at the toolhead. Please check and clean the filament in the toolhead and PTFE tube before resuming printing. If the issue persists, please contact after sales support",
        1267: "Abnormal filament trigger detected at the toolhead. Please check and clean the filament in the toolhead and PTFE tube before resuming printing. If the issue persists, please contact after sales support"
    }

    ERROR_CODES_JOB_FAILED = (101, 103, 304, 702, 801, 802, 1003)

    DEFAULT_PASSWORD = '123456'

    def __init__(self, parent: typing.Any, usb_info: dict, profile: dict = {}):
        timestamp_hex = format(int(time.time() * 1000), "x")[-5:]
        random_hex = format(secrets.randbelow(4096), "x")
        self.client_id = f"0cli{timestamp_hex}{random_hex}"[:10]
        super().__init__(parent, usb_info, profile, client_id=self.client_id)
        self.password = usb_info.get('PASS') or self.DEFAULT_PASSWORD
        self.request_id = secrets.token_hex(16) + format(int(time.time() * 1000), "x")
        self.client.username_pw_set(self.USERNAME, self.password)
        self.client.reconnect_delay_set(min_delay=1, max_delay=5)
        self.status_thread = threading.Thread(target=self._status_loop, name="ElegooCC2StatusThread_" + str(self.serial_number))
        self.heartbeat_thread = threading.Thread(target=self._heartbeat_loop, name="ElegooCC2HeartbeatThread_" + str(self.serial_number))
        self.mqtt_topic_prefix = f"elegoo/{self.serial_number}/"
        self.camera_url = f"http://{self.host}:8080/?action=stream"
        self.cached_status = {}
        self.registration_success = False
        self.next_print_options = None
        self.has_canvas = False
        self.last_canvas_info = {}
        self.last_exception_status = []
        self.last_printing_timestamp = None
        if not self.run_once:
            self._connect_loop()
        self._wait_for_connect("Max wait time to connect Elegoo CC2 printer reached.")

    def _on_connect(self, client, userdata, flags, rc) -> None:
        self.logger.info(f"Elegoo CC2: Connected to {self.host} with result code {rc}")
        if rc != 0:
            desc = self.CONNECT_RC_DESC.get(rc, f"Return code {rc}")
            message = f"Error connecting Elegoo CC2 Printer on {self.host}. Description: {desc}"
            self.logger.error(message)
            self.client.disconnect()
            self.register_error(1100, message, disconnect=True)
            return
        # Subscribe to registration response
        client.subscribe(self.mqtt_topic_prefix + self.request_id + "/register_response")
        # Subscribe to printer status
        client.subscribe(self.mqtt_topic_prefix + "api_status")
        # Subscribe to printer command responses
        client.subscribe(self.mqtt_topic_prefix + self.client_id + "/api_response")
        # Publish registration request
        reg_payload = {
            "client_id": self.client_id,
            "request_id": self.request_id
        }
        client.publish(self.mqtt_topic_prefix + "api_register", json.dumps(reg_payload))
        if self.camera_url:
            self.add_camera_url(self.camera_url)

    def _on_disconnect(self, client, userdata, rc):
        if not self.stop_flag:
            self.register_error(11199, f"Elegoo CC2 disconnected with code {rc}.", job_fail=False, disconnect=True)
            self.operational_flag = False
            self.stop_flag = True

    def _on_message(self, client, userdata, message):
        try:
            payload_str = message.payload.decode()
            if self.verbose:
                self.logger.info(f"Received from {self.host} on {message.topic}\n{payload_str}")
            response = json.loads(payload_str)
            if not isinstance(response, dict):
                return
            topic = message.topic
            method = response.get("method")
            if f"{self.request_id}/register_response" in topic:
                error = response.get("error")
                if error == "ok":
                    self.logger.info("Elegoo CC2 registration successful")
                    self.registration_success = True
                    # Request initial full status and canvas status
                    self._send_command(self.CMD_GET_STATUS)
                    self._send_command(self.CMD_GET_CANVAS_STATUS)
                    if not self.status_thread.is_alive():
                        self.status_thread.start()
                    if not self.heartbeat_thread.is_alive():
                        self.heartbeat_thread.start()
                else:
                    self.logger.error(f"Elegoo CC2 registration failed: {error}")
                    self.register_error(1101, f"Registration failed: {error}", disconnect=True)
            elif "api_status" in topic or method == self.EVENT_ON_PRINTER_STATUS or method == self.CMD_GET_STATUS:
                self._parse_status(response)
            elif method == self.CMD_GET_CANVAS_STATUS:
                self._parse_canvas_info(response.get("result", {}))
            elif method == self.CMD_START_PRINT:
                err_code = response.get("result", {}).get("error_code")
                if err_code != 0:
                    self._remove_last_uploaded_file()
                    err_name = self.CC2_ERRORS.get(err_code, f"UNKNOWN_{err_code}. Print start failed")
                    self.register_error(err_code, err_name, job_fail=True, info=False)
        except Exception as e:
            self.logger.error(f"Error processing MQTT message: {e}")

    def _parse_status(self, response: dict):
        result = response.get("result", {})
        if not result:
            return
        self.operational_flag = True
        # Handle full status (method 6000 first update or 1002 response) or delta updates
        self.cached_status = self._deep_merge(self.cached_status, result)
        # Parse temperatures
        extruder = self.cached_status.get("extruder", {})
        bed = self.cached_status.get("heater_bed", {})
        if extruder and isinstance(extruder, dict):
            self.temps[1] = round(extruder.get("temperature", self.temps[1]), 2)
            self.target_temps[1] = round(extruder.get("target", self.target_temps[1]), 2)
        if bed and isinstance(bed, dict):
            self.temps[0] = round(bed.get("temperature", self.temps[0]), 2)
            self.target_temps[0] = round(bed.get("target", self.target_temps[0]), 2)
        # Parse machine and print status
        machine_status = self.cached_status.get("machine_status", {})
        print_status = self.cached_status.get("print_status", {})
        status = machine_status.get("status")
        # Status code 2 = PRINTING, 1 = IDLE, etc.
        printing_flag = (status == self.STATUS_PRINTING)
        if self.printing_flag and self.printing_flag != printing_flag and not self.last_printing_timestamp:
            # print has finished remember last printing timestamp. need this logic to catch local cancel
            self.last_printing_timestamp = time.monotonic()
        if self.last_printing_timestamp and time.monotonic() - self.last_printing_timestamp > self.JOB_WAIT_FINISHED_TIMEOUT:
            self.last_printing_timestamp = None
            if self.parent:
                self.parent.cancel_locally()
            else:
                self.logger.warning('Job cancelled from printer')
        self.printing_flag = printing_flag
        self.pause_flag = (machine_status.get("sub_status") in [self.SUB_STATUS_PAUSED, self.SUB_STATUS_PAUSED_2])
        if self.printing_flag:
            self.percent = float(print_status.get("progress") or machine_status.get("progress", self.percent))
            self.current_line_number = print_status.get("current_layer", 0)
        self._parse_errors(machine_status)
        state = print_status.get("state")
        if state and state == "complete":
            self.percent = 100.00
            self.last_printing_timestamp = None
        # Periodically or on status push, if canvas/AMS status present or query needed
        if "canvas_info" in result:
            self._parse_canvas_info(result)

    def _parse_canvas_info(self, result_sect: dict):
        canvas_info = result_sect.get("canvas_info", self.last_canvas_info)
        if canvas_info and isinstance(canvas_info, dict):
            self.last_canvas_info = canvas_info
            canvas_list = canvas_info.get("canvas_list", [])
            if not isinstance(canvas_list, list):
                self.logger.info("Received wrong canvas list type. Skip parsing material")
                return
            material_names = []
            material_colors = []
            material_desc = []
            material_ids = []
            ams_ids = []
            ams_slot_ids = []
            ams_extruders = []
            for canvas_item in canvas_list:
                if not isinstance(canvas_item, dict):
                    continue
                canvas_id = str(canvas_item.get("canvas_id", 0))
                tray_list = canvas_item.get("tray_list", [])
                for tray in tray_list:
                    if not isinstance(tray, dict):
                        continue
                    tray_id = str(tray.get("tray_id", 0))
                    material_names.append(tray.get("filament_type", ""))
                    material_colors.append(tray.get("filament_color", "").replace("#", ''))
                    material_desc.append(tray.get("filament_name", ""))
                    # material_ids.append(str(tray.get("filament_code", ""))) # no need for now. no API to set CANVAS slot info
                    ams_ids.append(canvas_id)
                    ams_slot_ids.append(tray_id)
            if material_names:
                self.material_names = material_names
                self.material_colors = material_colors
                self.material_desc = material_desc
                self.material_ids = material_ids
                self.ams_ids = ams_ids
                self.ams_slot_ids = ams_slot_ids
                self.ams_extruders = ams_extruders

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

    def _deep_merge(self, base: dict, update: dict) -> dict:
        result = base.copy()
        for key, value in update.items():
            if key in result and isinstance(result[key], dict) and isinstance(value, dict):
                result[key] = self._deep_merge(result[key], value)
            else:
                result[key] = value
        return result

    def _parse_errors(self, machine_status):
        exception_status = machine_status.get("exception_status", [])
        if isinstance(exception_status, list) and exception_status != self.last_exception_status:
            new_errors = [e for e in exception_status if e not in self.last_exception_status]
            for err_code in new_errors:
                err_name = self.CC2_ERRORS.get(err_code, f"UNKNOWN_{err_code}. Check the printer screen")
                abort = err_code in self.ERROR_CODES_JOB_FAILED
                self.register_error(err_code, err_name, job_fail=abort, info=not abort)
                if abort:
                    self._remove_last_uploaded_file()
                    self.last_printing_timestamp = None
            resolved = [e for e in self.last_exception_status if e not in exception_status]
            if resolved:
                self.logger.info(f"Elegoo CC2 errors resolved: {resolved}")
            self.last_exception_status = exception_status

    def _parse_response(self, response: dict):
        if self.verbose:
            self.logger.info("Command response: " + response)

    def _status_loop(self) -> None:
        while not self.stop_flag and self.client.is_connected():
            # Periodically poll status and canvas status to ensure sync
            self._send_command(self.CMD_GET_STATUS)
            self._send_command(self.CMD_GET_CANVAS_STATUS)
            time.sleep(self.LOOP_SLEEP_SEC)

    def _heartbeat_loop(self) -> None:
        while not self.stop_flag and self.client.is_connected():
            self._publish(f"{self.mqtt_topic_prefix}{self.client_id}/api_request", {"type": "PING"})
            time.sleep(self.HEARTBEAT_INTERVAL)

    def _send_command(self, method: int, params: typing.Optional[dict] = None) -> bool:
        request_id = self._get_sequence_id()
        payload = {
            "id": request_id,
            "method": method
        }
        if params:
            payload["params"] = params
        topic = f"{self.mqtt_topic_prefix}{self.client_id}/api_request"
        return self._publish(topic, payload, self.MQTT_ACTION_QOS)

    def pause(self) -> bool:
        return self._send_command(self.CMD_PAUSE_PRINT)

    def unpause(self) -> bool:
        return self._send_command(self.CMD_RESUME_PRINT)

    def _remove_last_uploaded_file(self):
        last_project_filename = self.settings.get("printer_job_filename")
        if not last_project_filename:
            return
        self.logger.info(f"Deleting file {last_project_filename} from printer")
        params = {"storage_media": "local", "filename": last_project_filename}
        self._send_command(self.CMD_DELETE_FILE, params)
        self.save_current_job_filename("")

    def cancel(self) -> bool:
        success = self._send_command(self.CMD_STOP_PRINT)
        time.sleep(self.COMMAND_SEND_SLEEP)
        self._remove_last_uploaded_file()
        return success

    def _upload(self, filepath: str, retries_left: int = 2) -> bool:
        self.logger.info(f"Start uploading gcode file {filepath} to Elegoo CC2")
        file_size = os.path.getsize(filepath)
        filename = os.path.basename(filepath)
        url = f"http://{self.host}:{self.HTTP_PORT}/upload"
        try:
            with open(filepath, "rb") as f:
                md5_hash = hashlib.md5()
                while True:
                    if self.stop_flag:
                        return False
                    chunk = f.read(self.CHUNK_SIZE)
                    if not chunk:
                        break
                    md5_hash.update(chunk)
                file_md5 = md5_hash.hexdigest()
                f.seek(0)
                headers = {
                    "Content-Type": "application/octet-stream",
                    "Content-Range": f"bytes 0-{file_size-1}/{file_size}",
                    "X-File-Name": filename,
                    "X-File-MD5": file_md5,
                    "X-Token": self.password
                }
                resp = requests.put(url, data=f, headers=headers, timeout=self.UPLOAD_TIMEOUT)
                if resp.ok:
                    self.logger.info(f"Successfully uploaded {filename} on {self.host}")
                    resp.close()
                    self.save_current_job_filename(filename)
                    return True
                else:
                    self.logger.error(f"Upload failed with status {resp.status_code}: {resp.text}")
                    resp.close()
        except Exception as e:
            self.logger.error(f"Error uploading file to Elegoo CC2: {e}")
        if retries_left > 0:
            self.logger.info(f"Retrying upload, retries left: {retries_left}")
            time.sleep(3)
            return self._upload(filepath, retries_left - 1)
        return False

    def process_gcodes_file(self, filepath: str) -> bool:
        filename = os.path.basename(filepath)
        success = self._upload(filepath)
        if not success:
            self.next_print_options = None
            return False
        self.logger.info("Upload successful, starting print...")
        config_dict = {
            "delay_video": False,
            "printer_check": False
        }
        if self.next_print_options:
            config_dict.update(self.next_print_options)
            self.next_print_options = None
        start_params = {
            "storage_media": "local",
            "filename": filename,
            "config": config_dict
        }
        return self._send_command(self.CMD_START_PRINT, start_params)

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

    def close(self):
        try:
            if self.camera_url:
                self.remove_camera_url(self.camera_url)
        except:
            pass
        super().close()
