# 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 os
import time
import threading
import pprint

import requests
import requests.auth
import base_sender


class Sender(base_sender.BaseSender):
    PATH_API = "/api/v1/"
    PATH_INFO = "info"
    PATH_STATUS = "status"
    PATH_JOB = "job"
    PATH_FILES = "files"
    PATH_TRANSFER = "transfer"

    TEXT_UPLOAD_FILENAME = "3DPOS.GCO"
    BINARY_UPLOAD_FILENAME = "3DPOS.BGC"

    TIMEOUT = 2
    UPLOAD_TIMEOUT = 300

    PRINT_START_TIMEOUT = 12

    IDLE_STATE = "IDLE"
    READY_STATE = "READY"
    BUSY_STATE = "BUSY"
    FINISHED_STATE = "FINISHED"
    PRINTING_STATE = "PRINTING"
    PAUSED_STATE = "PAUSED"
    ERROR_STATE = "ERROR"
    STOPPED_STATE = "STOPPED"
    ATTENTION_STATE = "ATTENTION"

    READY_STATES = (READY_STATE, IDLE_STATE, BUSY_STATE, STOPPED_STATE, FINISHED_STATE)

    DEFAULT_USERNAME = "maker"

    STATUS_LOOP_SLEEP = 1

    NATIVE_FILE_EXTENSIONS = (".gcode", ".g", ".bgcode")

    def __init__(self, parent, usb_info, profile):
        super().__init__(parent, usb_info, profile)
        if usb_info.get("HTTPS"):
            self.host = "https://"
        else:
            self.host = "http://"
        ip = usb_info.get("IP")
        if not ip:
            raise RuntimeError("No IP provided for network sender")
        self.host += self.wrap_in_brakets_if_ipv6(ip)
        port = usb_info.get("PORT")
        if port:
            self.host += ":" + str(port)
        self.printer_job_filename = None
        self.printer_info = {}
        self.storage_path = "/usb/"  # TODO get a correct storage
        self.username = self.DEFAULT_USERNAME
        self.files_url = self.host + self.PATH_API + self.PATH_FILES
        self.status_url = self.host + self.PATH_API + self.PATH_STATUS
        self.job_url = self.host + self.PATH_API + self.PATH_JOB
        auth_error = self.authenticate()
        if auth_error:
            self.logger.warning(auth_error)
            raise RuntimeError(auth_error)
        self.request_lock = threading.RLock()
        self.status_thread = threading.Thread(target=self.status_loop)
        self.status_thread.start()
        self.serial_number = None
        self.attention = False
        self.force_upload_in_progress = False
        self.ignore_next_job_stop = False

    def authenticate(self):
        username = self.settings.get("user", self.DEFAULT_USERNAME)
        password = self.settings.get("password", self.usb_info.get("PASS"))
        self.session = requests.Session()
        if username is None:
            return "Auth error: no username"
        elif password is None:
            return "Auth error: no password"
        else:
            try:
                auth_helper = requests.auth.HTTPDigestAuth(username, password)
                resp = requests.get(
                    self.host + self.PATH_API + self.PATH_INFO,
                    auth=auth_helper,
                    timeout=self.TIMEOUT
                )
                if resp.ok:
                    self.logger.info("Printer info: %s", pprint.pformat(resp.json()))
                    self.session.auth = auth_helper
                    return
                if resp.status_code == 401:
                    message = "Auth error. Check credentials."
                    self.register_error(255, message, job_fail=False)
                    return message
                return "Connection error: " + str(resp.status_code)
            except (
                requests.exceptions.ConnectTimeout,
                requests.exceptions.ConnectionError,
                requests.exceptions.ReadTimeout,
                TimeoutError,
            ):
                return "Connection timeout"
            except Exception as e:
                self.logger.exception("Unexpected connection error:" + str(e))
                return "Unexpected connection error"

    def status_loop(self):
        health = 0
        while not self.stop_flag:
            status_dict = {}
            try:
                self.request_lock.acquire()
                resp = self.session.get(self.status_url, timeout=self.TIMEOUT)
                if resp.ok:
                    status_dict = resp.json()
                    if self.verbose:
                        self.logger.info("STATUS: %s", status_dict)
                    if status_dict:
                        self.parse_status(status_dict)
            except (
                requests.exceptions.ConnectTimeout,
                requests.exceptions.HTTPError,
                requests.exceptions.RequestException,
                requests.exceptions.ConnectionError,
            ):
                if health < -1:
                    self.logger.warning("Connection lost")
                    self.operational_flag = False
                else:
                    health -= 1
            except Exception as e:
                self.logger.error("Status parsing error: %s", status_dict)
                self.logger.exception(
                    "Exception while parsing status response: " + str(e)
                )
                self.operational_flag = False
                self.health = -2
            else:
                if health > 1:
                    pass
                elif health < 0:
                    self.logger.warning("Connection restored")
                    health = 0
                else:
                    health += 1
            finally:
                self.request_lock.release()
            time.sleep(self.STATUS_LOOP_SLEEP)

    def parse_status(self, status_dict):
        try:
            upload_status = status_dict.get("transfer")
            if not isinstance(upload_status, dict):
                raise ValueError("Invalid printer status response")
            upload_percent = float(upload_status.get("progress", 0)) * 100
            if upload_percent != self.upload_percent:
                self.logger.info(f"Upload progress: {upload_percent:.0f}%")
                self.upload_percent = upload_percent
            self.upload_in_progress = True
        except (ValueError, AttributeError, TypeError):
            self.upload_in_progress = False
            self.upload_percent = 0.0
        try:
            printer_status = status_dict.get("printer")
            if not isinstance(printer_status, dict):
                raise ValueError("Invalid printer status response")
        except (ValueError, AttributeError):
            pass
        else:
            job_info = status_dict.get("job")
            percent = 0.0
            job_id = None
            time_printing = None
            if isinstance(job_info, dict):
                job_id = job_info.get("id")
                if job_id is not None:
                    job_id = str(job_id)
                if not self.preconnect_printer_job_id:
                    try:
                        percent = float(job_info.get("progress", 0.0))
                    except (TypeError, ValueError):
                        percent = 0.0
                    time_printing = job_info.get("time_printing")
                    if time_printing == 0:
                        percent = 0.0
                if self.allow_estm_by_printer:
                    print_time_left = job_info.get("time_remaining")
                    if print_time_left is not None:
                        self.print_time_left = print_time_left
                    if time_printing is not None and not self.est_print_time:
                        if print_time_left is not None:
                            self.est_print_time = time_printing + print_time_left
                # job_file_dict = job_info.get('file')
            if self.preconnect_printer_job_id:
                if 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 3DPrinterOS Client was offline.",
                        job_fail=True,
                    )
                self.preconnect_printer_job_id = None
            state = printer_status.get("state")
            if state in self.READY_STATES:
                self.operational_flag = True
                self.pause_flag = False
                self.printing_flag = False
            elif state == self.PRINTING_STATE:
                self.printing_flag = True
                self.pause_flag = False
                self.operational_flag = True
            elif state == self.PAUSED_STATE:
                self.pause_flag = True
                self.operational_flag = True
            elif state == self.ATTENTION_STATE:
                if percent == 100.0:
                    percent = 0.0
                if job_id:
                    self.printing_flag = True
                self.pause_flag = False
                if not self.attention:
                    self.register_error(
                        1172,
                        "The printer got into Attention state. It this persists, please resolve it using the printer's screen",
                        job_fail=False,
                    )
                    self.attention = True
            else:
                self.attention = False
            self.percent = percent
            if state == self.FINISHED_STATE:
                self.operational_flag = True
                self.pause_flag = False
                self.printing_flag = False
                if self.printers_job_id is not None:
                    self.register_print_finished_event()
                    self.cancel_job(self.printers_job_id, raise_error=False)
            elif state == self.STOPPED_STATE:
                self.pause_flag = False
                self.printing_flag = False
                if self.printers_job_id is not None:
                    if self.ignore_next_job_stop:
                        self.ignore_next_job_stop = False
                    else:
                        self.register_error(
                            1171,
                            "The job was canceled locally or failed due to an unknown reason",
                            job_fail=True,
                        )
                    self.cancel_job(self.printers_job_id, raise_error=False)
                elif job_id is not None:
                    if self.ignore_next_job_stop:
                        self.ignore_next_job_stop = False
                    else:
                        self.register_error(
                            1171,
                            "The job was canceled locally or failed due to an unknown reason",
                            job_fail=True,
                        )
                    self.cancel_job(job_id, raise_error=False)
            if state == self.ERROR_STATE:
                self.operational_flag = False
                self.printing_flag = False
                self.pause_flag = False
            # NOTE only here we actually update job_id attribute,
            # because for some state checks we need job_id from previous a status report,
            # because after finish/cancel/error there will be no job_id
            self.printers_job_id = job_id
            extra_status = printer_status.get("status_printer")
            if extra_status and isinstance(extra_status, dict):
                self.operational_flag = False
                message = extra_status.get("message")
                if message:
                    if state == self.ERROR_STATE:
                        blocking = True
                        self.operational_flag = False
                        message_prefix = "Error: "
                    else:
                        blocking = False
                        message_prefix = "Warning: "
                    self.register_error(
                        3599, message_prefix + str(message), job_fail=blocking
                    )
            bed_temp = printer_status.get("temp_bed")
            nozzle_temp = printer_status.get("temp_nozzle")
            bed_ttemp = printer_status.get("target_bed")
            nozzle_ttemp = printer_status.get("target_nozzle")
            x_coord = printer_status.get("axis_x")
            y_coord = printer_status.get("axis_y")
            z_coord = printer_status.get("axis_z")
            if bed_temp is not None:
                try:
                    self.temps[0] = round(bed_temp, self.TEMPERATURE_SIGNS_AFTER_DOT)
                except (TypeError, ValueError) as e:
                    self.logger.warning(f"Error assigning bed_temp: {e}")
            if nozzle_temp is not None:
                try:
                    self.temps[1] = round(nozzle_temp, self.TEMPERATURE_SIGNS_AFTER_DOT)
                except (TypeError, ValueError) as e:
                    self.logger.warning(f"Error assigning nozzle_temp: {e}")
            if bed_ttemp is not None:
                try:
                    self.target_temps[0] = round(bed_ttemp, self.TEMPERATURE_SIGNS_AFTER_DOT)
                except (TypeError, ValueError) as e:
                    self.logger.warning(f"Error assigning bed_ttemp: {e}")
            if nozzle_ttemp is not None:
                try:
                    self.target_temps[1] = round(nozzle_ttemp, self.TEMPERATURE_SIGNS_AFTER_DOT)
                except (TypeError, ValueError) as e:
                    self.logger.warning(f"Error assigning nozzle_ttemp: {e}")
            if x_coord is not None:
                try:
                    self.position[0] = float(x_coord)
                except (TypeError, ValueError) as e:
                    self.logger.warning(f"Error assigning x_coord: {e}")
            if y_coord is not None:
                try:
                    self.position[1] = float(y_coord)
                except (TypeError, ValueError) as e:
                    self.logger.warning(f"Error assigning y_coord: {e}")
            if z_coord is not None:
                try:
                    self.position[2] = float(z_coord)
                except (TypeError, ValueError) as e:
                    self.logger.warning(f"Error assigning z_coord: {e}")

    def gcodes(self, filepath, keep_file=False):
        if self.is_printing():
            self.parent.register_error(254, "Already printing", job_fail=False)
            return False
        if not self.session:
            self.parent.register_error(259, "Connection lost while starting a print", job_fail=True)
            return False
        # unstuck the printer from Attention or Error states
        if self.printers_job_id:
            self.cancel_job(raise_error=False)
            self.printers_job_id = None
        success = False
        url = self.files_url + self.storage_path
        if filepath.endswith(".zip"):
            filepath = self.unzip_file(filepath, not keep_file)
        if filepath.endswith(".bgcode"): #pyright: ignore
            self.logger.info(
                f"Uploading {self.full_filename} as {self.BINARY_UPLOAD_FILENAME}"
            )
            url += self.BINARY_UPLOAD_FILENAME
        else:
            self.logger.info(
                f"Uploading {self.full_filename} as {self.TEXT_UPLOAD_FILENAME}"
            )
            url += self.TEXT_UPLOAD_FILENAME
        time.sleep(0.1)
        username = self.settings.get("user", self.DEFAULT_USERNAME)
        password = self.settings.get("password", self.usb_info.get("PASS", ""))
        self.set_estimated_print_time(0)
        upload_start_time = time.monotonic()
        self.force_upload_in_progress = True
        try:
            self.request_lock.acquire()
            auth = requests.auth.HTTPDigestAuth(username, password)
            self.logger.info('Deleting previous print file...')
            resp = requests.delete(url, auth=auth, timeout = self.TIMEOUT*4)
            if resp.ok:
                if resp.status_code == 200:
                    self.logger.info('Successfully deleted previous print file')
                elif resp.status_code == 203:
                    self.logger.info('No file to delete')
            else:
                #Prusa always responding with 404 when no file found, not with 203,
                #so this code is useless until Prusa will implement status 203
                #self.logger.warning(f'Failed delete the print file. Status_code: {str(resp.status_code)}. Body: {resp.text}')
                self.logger.info('No file to delete')
            time.sleep(0.5)
            with open(filepath, "rb") as f: #pyright: ignore
                # headers = {"Print-After-Upload": "1", "Overwrite": "1"}
                headers = {"Print-After-Upload": "1"}
                resp = requests.put(
                    url,
                    auth=auth,
                    data=f,
                    headers=headers,
                    timeout=self.UPLOAD_TIMEOUT,
                )
        except Exception as e:
            self.request_lock.release()
            self.logger.warning("Upload error without a response. Exception: " + str(e))
            self.register_error(
                3597, "Upload error without any response", job_fail=True
            )
        else:
            self.request_lock.release()
            if not resp.ok:
                error_text = f"Upload error. Printer: {resp.status_code} {resp.text}"
                self.register_error(3598, error_text, job_fail=True)
            else:
                self.logger.info("Upload success. Waiting for printer to start...")
                time_left = self.PRINT_START_TIMEOUT
                while not self.stop_flag and time_left:
                    if self.printing_flag or (self.attention and self.printers_job_id):
                        success = True
                        self.save_current_printer_job_id(self.printers_job_id)
                        self.logger.info("Print started")
                        break
                    time.sleep(0.1)
                    time_left -= 0.1
        finally:
            self.force_upload_in_progress = False
            self.logger.info(
                f"Upload time:{time.monotonic() - upload_start_time: .1f}s"
            )
            upload_start_time = time.monotonic()
        if not keep_file:
            try:
                os.remove(filepath) #pyright: ignore
            except:
                pass
        return success

    def is_uploading(self):
        if self.force_upload_in_progress:
            return True
        return self.upload_in_progress

    def pause(self):
        printers_job_id = self.printers_job_id
        if not self.is_printing() or printers_job_id is None:
            self.parent.register_error(254, "No print to pause", job_fail=False)
        elif self.is_paused():
            self.parent.register_error(254, "Already in pause", job_fail=False)
        else:
            try:
                self.request_lock.acquire()
                resp = self.session.put(
                    self.job_url + "/" + printers_job_id + "/pause",
                    timeout=self.TIMEOUT * 4,
                )
                return resp.ok
            except Exception as e:
                self.logger.exception("Error sending a pause request: " + str(e))
            finally:
                self.request_lock.release()
        return False

    def resume(self):
        printers_job_id = self.printers_job_id
        if not self.is_paused() and printers_job_id is not None:
            self.parent.register_error(254, "Can't resume - not paused", job_fail=False)
        else:
            try:
                self.request_lock.acquire()
                resp = self.session.put(
                    self.job_url + "/" + printers_job_id + "/resume",
                    timeout=self.TIMEOUT * 4,
                )
                return resp.ok
            except Exception as e:
                self.logger.exception("Error sending a resume request: " + str(e))
            finally:
                self.request_lock.release()
        return False

    def unpause(self):
        return self.resume()

    def cancel(self):
        self.ignore_next_job_stop = True
        result = self.cancel_job()
        if result:
            while self.printing_flag and not self.stop_flag:
                time.sleep(0.01)
        if not result:
            self.logger.warning("Cancel failed")
        return result

    def cancel_job(self, printers_job_id=None, raise_error=True):
        if printers_job_id is None:
            printers_job_id = self.printers_job_id
        self.logger.info(f"Removing job {printers_job_id} from printer")
        if printers_job_id is not None:
            try:
                self.request_lock.acquire()
                resp = self.session.delete(
                    self.job_url + "/" + printers_job_id, timeout=self.TIMEOUT * 4
                )
                self.save_current_printer_job_id(None)
                self.printers_job_id = None
                if resp.ok:
                    return True
            except:
                self.logger.exception("Error sending removing job from printer")
            finally:
                self.request_lock.release()
        if raise_error:
            self.parent.register_error(257, "Cancel failed", job_fail=False)
        return False

    def close(self):
        self.stop_flag = True
        self.status_thread.join(1.1 * self.TIMEOUT)
        with self.request_lock:
            if self.session:
                self.session.close()
