# Copyright 3D Control Systems, Inc. All Rights Reserved 2017-2019.
# Built in San Francisco.
import asyncio
# 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 io
import string
import typing
import json
import time
import random
import hashlib
import aiohttp
import os
import config
import base_http_connection


# Spec: https://github.com/cbd-tech/SDCP-Smart-Device-Control-Protocol-V3.0.0/blob/main/SDCP(Smart%20Device%20Control%20Protocol)_V3.0.0_EN.md
class ElegooConnection(base_http_connection.BaseHTTPConnection):
    SERVICE_NAME = "SDCP"
    WEBSOCKET_COMMUNICATION_MODE = True
    WS_HOST_MASK = "ws://%s:%d/websocket"
    PRINTERS_JOBS_FOLDER_PATH = "/local"
    DEFAULT_WS_PORT = 3030
    DEFAULT_HTTP_PORT = 80
    TIMEOUT = 3
    SLEEP_BEFORE_PRINT_START = 3
    UPLOAD_CHUNK_SIZE = 1024 * 1024
    CMD_GET_STATUS = 0
    CMD_GET_ATTRIBUTE = 1
    CMD_START = 128
    CMD_PAUSE = 129
    CMD_CANCEL = 130
    CMD_CONTINUE = 131
    CMD_GET_FILES = 258
    CMD_REMOVE_FILE = 259
    DEFAULT_FROM = 1  # FROM_PC
    PRINTING_STATUS = 1
    PRINT_STATUS_IDLE = 0
    PRINT_STATUS_HOMING = 1  # Resetting
    PRINT_STATUS_DROPPING = 2  # Descending
    PRINT_STATUS_EXPOSING = 3
    PRINT_STATUS_LIFTING = 4
    PRINT_STATUS_PAUSING = 5
    PRINT_STATUS_PAUSED = 6
    PRINT_STATUS_STOPPING = 7
    PRINT_STATUS_STOPPED = 8
    PRINT_STATUS_COMPLETE = 9
    PRINT_STATUS_FILE_CHECKING = 10

    def __init__(self, host=None, port=None, timeout=TIMEOUT, parent=None, logger=None):
        # TODO: In elegoo it always sending empty strings, need to test with another SDCP printers
        self._mainboard_id = ""
        self._printer_id = ""
        self.upload_file_path = f"{self.PRINTERS_JOBS_FOLDER_PATH}/{self.UPLOAD_FILENAME}"
        super().__init__(host, port, timeout, parent, logger)
        self.ws_host_plus_path = self.WS_HOST_MASK % (host, self.DEFAULT_WS_PORT)
        self.idle_flag = False
        self.logger.info("self.ws_host_plus_path: " + self.ws_host_plus_path)

    async def ws_printer_info_task(self):
        self.logger.info("ws_printer_info_task start")
        # TODO: get _mainboard_id and _printer_id and parse response if required. for now we use it to activate status notify
        if not self.stop_flag:
            # subscribe to status
            await self.send_command(self.CMD_GET_STATUS, wait=False)
            await self.send_command(self.CMD_GET_ATTRIBUTE, wait=False)
        self.logger.info("ws_printer_info_task exit")

    def get_uuid(self):
        return ''.join(random.choices(string.ascii_lowercase + string.digits, k=32))

    def parse_status_data(self, data):
        status_dict = data.get("Status", {})
        if status_dict and isinstance(status_dict, dict):
            self.operational_flag = True
            if self.parent:
                self.parent.init_camera_url()
            try:
                self.temps = [
                    round(status_dict.get("TempOfHotbed", self.temps[0]), 2),
                    round(status_dict.get("TempOfNozzle", self.temps[1]), 2)
                ]
                self.ttemps = [
                    round(status_dict.get("TempTargetHotbed", self.ttemps[0]), 2),
                    round(status_dict.get("TempTargetNozzle", self.ttemps[1]), 2)
                ]
            except Exception as e:
                self.logger.info("Error parsing temperature data. Details: " + str(e))
            current_status = status_dict.get("CurrentStatus", [])
            if current_status and isinstance(current_status, list):
                self.printing_flag = current_status[0] == self.PRINTING_STATUS
                self.idle_flag = current_status[0] == self.PRINT_STATUS_IDLE
            print_info = status_dict.get("PrintInfo", {})
            if print_info and isinstance(print_info, dict):
                status = print_info.get("Status", 0)
                self.paused_flag = status == self.PRINT_STATUS_PAUSED or status == self.PRINT_STATUS_PAUSING
                if "Progress" in print_info:  # Elegoo have it but in SDCP specification - not
                    self.percent = print_info.get("Progress", self.percent)
                else:
                    curr_layer = print_info.get("CurrentLayer", 0)
                    total_layers = print_info.get("TotalLayer", 0)
                    if total_layers:
                        self.percent = round(curr_layer / total_layers * 100, 2)

    def parse_ws_text(self, text):
        try:
            ws_resp = json.loads(text)
        except json.JSONDecodeError as e:
            self.logger.error("Error parsing ws text: %s", e)
            return
        if ws_resp:
            request_id = ws_resp.get("Data", {}).get("RequestID", "")
            if request_id:
                if self.parent.verbose:
                    self.logger.info(f"Caught request id {request_id}")
                self.ws_id_responses[request_id] = ws_resp
            else:
                self.parse_status_data(ws_resp)
        else:
            self.logger.warning("Received empty json in ws")

    def parse_ws_binary(self, text):
        pass

    async def send_file_to_print(self, f, retries=2):
        # remove prev job if exist
        self.logger.info("Trying to delete previous " + self.upload_file_path)
        error = None
        del_response = await self.remove_file(self.upload_file_path) # TODO: check if removed
        if self.parent.verbose:
            self.logger.info("del_response")
            self.logger.info(del_response)
        md5_hash = hashlib.md5()
        file_size = 0
        while True:
            try:
                chunk = f.read(self.UPLOAD_CHUNK_SIZE)
            except OSError as e:
                error = "Error reading file"
                self.logger.info(e)
                return error
            if not chunk:
                break
            md5_hash.update(chunk)
            file_size += len(chunk)
        md5sum = md5_hash.hexdigest()
        f.seek(0)
        offset = 0
        uuid = self.get_uuid()
        if not self.session:
            error = 'Unable to send file due to lack of connection to host'
            self.parent.next_print_options = None
            return error
        # SDCP protocol supports only uploading by chunks maximum 1 MB
        while offset < file_size:
            try:
                chunk = f.read(self.UPLOAD_CHUNK_SIZE)
            except OSError as e:
                error = "Error reading file while uploading"
                self.logger.info(e)
                return error
            if not chunk:
                break
            form = aiohttp.FormData()
            # NOTICE: order of fields should NOT be changed to not break Elegoo Centaury file upload!
            # Otherwise, it will respond with success but no file in the printer's job list
            form.add_field('TotalSize', str(file_size))
            form.add_field('Uuid', uuid)
            form.add_field('Offset', str(offset))
            form.add_field('Check', '1')
            form.add_field('S-File-MD5', md5sum)
            form.add_field('File', chunk, filename=self.UPLOAD_FILENAME, content_type='application/octet-stream')
            if self.parent.verbose:
                self.logger.info(f"Uploading offset={offset}")
                self.logger.info(f"MD5={md5sum}")
                self.logger.info(f"TotalSize={file_size}")
                self.logger.info(f"Chunk size={len(chunk)}")
            try:
                async with self.session.post(self.host + "/uploadFile/upload", data=form,
                                             timeout=aiohttp.ClientTimeout(total=self.UPLOAD_TIMEOUT)) as resp:
                    if resp.ok:
                        # Use .text() as content-type is not return as json
                        resp_text = await resp.text()
                        data = json.loads(resp_text)
                        if not data or not data.get("success"):
                            error = f"Chunk upload failed. Aborting. Details: " + str(data)
                    else:
                        error = f'Chunk upload failed with status: {resp.status}'
                        return error
            except Exception as e:
                error = f'Error during upload: {str(e)}'
                return error
            except json.JSONDecodeError as e:
                error = "Error parsing upload response: %s", e
                return error
            offset += len(chunk)
        if offset >= file_size:
            self.logger.info('Upload complete.')
            self.logger.info(self.upload_file_path)
            # TODO:
            # recheck if file exist in the printers job list and inform user
            # files_data = await self.get_files()  # TODO: retries 2
            # if files_data and isinstance(files_data, dict):
            #     job_list = files_data.get("Data", {}).get("Data", {}).get("FileList", [])
            #     if (job_list and isinstance(job_list, list) and isinstance(job_list[0], dict)
            #             and job_list[0].get("name", "") == self.upload_file_path):
            #         start_resp = await self.start_print(self.upload_file_path)
            #         self.logger.info(f"Print start response: {start_resp}")
            #     else:
            #         error = "Can't validate job list after upload"
            await asyncio.sleep(self.SLEEP_BEFORE_PRINT_START)
            if not await self.start_print(self.upload_file_path):
                error = "Command print start failed"
        else:
            error = f"Upload incomplete. Details: upload offset {offset}, file_size: {file_size}"
        return error

    async def send_command(self, command, params=None, wait=True):
        request_id = self.get_uuid()
        timestamp = int(time.monotonic() * 1000)
        data = {
            "Cmd": command,
            "Data": {},
            "RequestID": request_id,
            "MainboardID": self._mainboard_id,
            "TimeStamp": timestamp,
            "From": self.DEFAULT_FROM
        }
        if params and isinstance(params, dict):
            data.update(params)
        request = {
            "Id": self._printer_id,
            "Data": data
        }
        self.ws_send_deque.append(json.dumps(request))
        if wait:
            return await self.wait_for_ws_response(request_id, 5)
        else:
            return True

    async def start_print(self, filepath):
        data = {
            "Data": {
                "Filename": filepath,
                "StartLayer": 0,
                "Calibration_switch": 0,
                "PrintPlatformType": 0,
                "Tlp_Switch": 0
            }
        }
        return self.check_command_errors(await self.send_command(self.CMD_START, data))

    async def get_files(self):
        data = {
            "Data": {
                "Url": "/local"
            }
        }
        return await self.send_command(self.CMD_GET_FILES, data)

    async def remove_file(self, filepath):
        data = {
            "Data": {
                "Url": filepath
            }
        }
        return await self.send_command(self.CMD_REMOVE_FILE, data)

    def check_command_errors(self, data):
        if data and isinstance(data, dict):
            ack = data.get("Data", {}).get("Data", {}).get("Ack", -1)
            if ack == 0:
                return True
            else:
                request_id = data.get("Data", {}).get("RequestID", "")
                cmd = data.get("Data", {}).get("Cmd", "unknown cmd")
                self.register_error(2001, f"Command '{cmd}' with request id {request_id} return error code: {ack}")
        else:
            self.register_error(2002, f"Command response is not valid. Details: " + str(data))
            return False

    def run_ws_command(self, cmd, data=None):
        result = self.run_async_command(self.send_command(cmd, params=data))
        return self.check_command_errors(result)

    def pause(self):
        return self.run_ws_command(self.CMD_PAUSE)

    def resume(self):
        return self.run_ws_command(self.CMD_CONTINUE)

    def cancel(self, retries=10):
        # elegoo on print start very often can't cancel job. Added retries
        if self.printing_flag:
            success = self.run_ws_command(self.CMD_CANCEL)
            if not success and retries > 0:
                retries -= 1
                self.register_error(2003, f"Error canceling print. Retry after {self.REQ_RETRY} second. Retries left: {retries}")
                time.sleep(self.REQ_RETRY)
                return self.cancel(retries)
            return success


class Sender(base_http_connection.BaseHTTPSender):
    CONNECTION_CLASS = ElegooConnection
    CAMERA_PORT = 3031

    def __init__(self, parent, usb_info, profile):
        super().__init__(parent, usb_info, profile)
        host = usb_info.get("IP", "")
        self.camera_url = ""
        if self.profile.get("camera_url_template"):
            self.camera_url = self.profile.get("camera_url_template") % (host, self.CAMERA_PORT)
        self.connect()  # TODO: remove after discuss if connect() will go to the parent init

    def init_camera_url(self):
        if self.camera_url:
            self.add_camera_url(self.camera_url)

    def close(self):
        self.remove_camera_url(self.camera_url)
        super().close()

    # TODO: remove copy pasta
    def gcodes(self, filepath: typing.Union[typing.AnyStr, io.BytesIO], keep_file: bool = False) -> bool:
        success = False
        if self.connection:
            with open(filepath, 'rb') as f:
                self.logger.info("Uploading print...")
                success = self.connection.upload(f)
                if success:
                    self.logger.info("Upload success")
                    self.gcode_filepath = filepath
                else:
                    self.logger.info("Upload fail")
        else:
            self.logger.info('Error: no connection')
        if not keep_file:
            try:
                os.remove(filepath)
            except:
                pass
        return success
