# 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 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 = 30

    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):

        self.session = None
        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.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'))
        if username is None:
            return 'Auth error: no username'
        elif password is None:
            return 'Auth error: no password'
        else:
            auth_helper = requests.auth.HTTPDigestAuth(username, password)
            try:
                resp = requests.get(self.host + self.PATH_API + self.PATH_INFO, auth=auth_helper, timeout=self.TIMEOUT)
                if resp.ok:
                    self.session = requests.Session()
                    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: ' + resp.status_code
            except (requests.exceptions.ConnectTimeout, requests.exceptions.ConnectionError, TimeoutError):
                return 'Connection timeout'
            except Exception as e:
                self.logger.exception('Unexpected connection error:' + str(e))
                return 'Unexpected connection error'

    def status_loop(self):
        while not self.stop_flag:
            try:
                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):
                self.logger.warning('Connection lost')
                self.operational_flag = False
            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
            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')) * 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:
                self.temps[0] = round(bed_temp, self.TEMPERATURE_SIGNS_AFTER_DOT)
            if nozzle_temp is not None:
                self.temps[1] = round(nozzle_temp, self.TEMPERATURE_SIGNS_AFTER_DOT)
            if bed_ttemp is not None:
                self.target_temps[0] = round(bed_ttemp, self.TEMPERATURE_SIGNS_AFTER_DOT)
            if nozzle_ttemp is not None:
                self.target_temps[1] = round(nozzle_ttemp, self.TEMPERATURE_SIGNS_AFTER_DOT)
            if x_coord is not None:
                self.position[0] = float(x_coord)
            if y_coord is not None:
                self.position[1] = float(y_coord)
            if y_coord is not None:
                self.position[2] = float(z_coord)

    def gcodes(self, filepath, keep_file=False):
        if self.is_printing():
            self.parent.register_error(254, "Already printing", job_fail=False)
            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 str(filepath).endswith(".zip"):
            filepath = self.unzip_file(filepath, not keep_file)
        if filepath.endswith('.bgcode'):
            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
        try:
            self.session.delete(url, timeout=self.TIMEOUT)
        except:
            self.logger.info('File delete request error')
        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(None)
        try:
            self.force_upload_in_progress = True
            with open(filepath, "rb") as f:
                resp = requests.put(url, data=f, headers={'Print-After-Upload': '1', 'Overwrite': '1'}, \
                        auth=requests.auth.HTTPDigestAuth(username, password), timeout=self.UPLOAD_TIMEOUT)
        except Exception as e:
            self.logger.warning('Upload error without a response. Exception: ' + str(e))
            self.register_error(3597, "Upload error without any response", job_fail=True)
        else:
            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
        if not keep_file:
            try:
                os.remove(filepath)
            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:
                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))
        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:
                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))
        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:
                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')
        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)
        if self.session:
            self.session.close()
