# 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 http.client
import logging
import os
import threading
import time
import paths
import log
import typing
import config
from base_sender import BaseSender
from makerbotapi import Makerbot
from makerbotapi import MakerBotError
from m5th_cam import M5thCamera


class BirdwingNetworkConnection(Makerbot):

    UPLOAD_TIMEOUT = 20

    def __init__(self, ip, auth_code=None, verbose=False, auth_timeout=120, logger=None):
        if logger:
            self.logger = logger.getChild('connection')
        else:
            self.logger = logging.getLogger(self.__class__.__name__)
        Makerbot.__init__(self, ip, auth_code)
        self.debug_http = verbose
        self.debug_jsonrpc = verbose
        self.debug_fcgi = verbose
        self.auth_timeout = auth_timeout

    def _debug_print(self, protocol, direction, content):
        if self.logger:
            self.logger.debug("(%s) %s: %s" % (protocol, direction, content))
        else:
            Makerbot._debug_print(self, protocol, direction, content)

    def put_file(self, file_obj, remote_path):
        try:
            token = self.get_access_token(context='put')
            if not token:
                raise Exception("No access token for PUT file")
            put_token = str(token)
            host = "%s:%i" % (self.host, 80)
            con = http.client.HTTPConnection(host, timeout=self.UPLOAD_TIMEOUT)
            remote_path = "%s?token=%s" % (remote_path, put_token)
            if self.debug_http:
                self._debug_print('HTTP', 'REQUEST', "PUT FILE:  %s%s" % (host, remote_path))
            size = 0
            while True:
                try:
                    chunk = file_obj.read(1024*64)
                    if chunk:
                        size += len(chunk)
                    else:
                        break
                except OSError as e:
                    self.logger.error("Error on reading file chunks: " + str(e))
                    return False
            file_obj.seek(0)
            headers = {'Content-Length': str(size)}
            con.request("PUT", remote_path, file_obj, headers=headers)
            resp = con.getresponse()
            status_code = resp.status
            if self.debug_http:
                self._debug_print('HTTP', 'RESPONSE', "PUT FILE: status code: %s" % status_code)
            msg = resp.read()
            if self.debug_http:
                self._debug_print('HTTP', 'RESPONSE', 'Received http code %d, message\n%s' % (status_code, msg))
                # raise httplib.HTTPException(errcode)
            return status_code == 200
        except Exception as e:
            self.logger.error("File loading error due to " + str(e))
            return False

    def start_printing_file(self, file):
        success = False
        try:
            success = self.rpc_request_response("print", {"filepath": os.path.basename(file)})
            if success:
                self.rpc_request_response("process_method", {"params": {}, "method": "build_plate_cleared"})
        except MakerBotError as e:
            self.logger.warning('Exception on handling RPC response: ' + str(e))
        return success

    def cancel_print(self):
        try:
            return self.rpc_request_response("cancel", {})
        except MakerBotError as e:
            self.logger.warning('Exception on handling RPC response: ' + str(e))

    def pause_print(self):
        try:
            return self.rpc_request_response("process_method", {"params": {}, "method": "suspend"})
        except MakerBotError as e:
            self.logger.warning('Exception on handling RPC response: ' + str(e))

    def unpause_print(self):
        try:
            return self.rpc_request_response("process_method", {"params": {}, "method": "resume"})
        except MakerBotError as e:
            self.logger.warning('Exception on handling RPC response: ' + str(e))

    def authenticate_to_printer(self):
        try:
            self.authenticate_json_rpc()
            return True
        except:
            if self.debug_jsonrpc:
                self.logger.exception("Authentication error:")


class Sender(BaseSender):

    REMOTE_URL_PATH = "/current_thing/"
    DEFAULT_FILE_NAME = "3dprinteros"
    DEFAULT_FILE_EXTENSION = ".makerbot"
    STEP_PRINTING_NAME = "printing"
    UPLOAD_RETRIES = 3

    def __init__(self, parent: typing.Any, usb_info: dict, profile: dict = {}):
        BaseSender.__init__(self, parent, usb_info, profile)
        self.operational_flag = False
        self.printing_flag = False
        self.printing_started_flag = False
        self.pairing_needed = False
        self.percent = 0
        self.ip = usb_info.get('IP')
        self.available_methods = []
        self.serial_number = usb_info['SNR']
        self.timeout = profile["operational_timeout"]
        self.makerbot = None
        self.camera = None
        self.camera_thread = None
        self.verbose = config.get_settings()['verbose']
        if not self.ip:
            self.auth_and_monitoring_thread = None
            return
        self.path_to_file_with_auth_code = os.path.join(
             paths.CURRENT_SETTINGS_FOLDER,
             "%s_%s.auth_code" % (self.ip.replace(".", "-"), self.serial_number.replace(".", "-"))
        )
        self.auth_and_monitoring_thread = threading.Thread(target=self._auth_and_monitoring, daemon=True)
        self.auth_and_monitoring_thread.start()
        #  TODO: Develop report to server info about this printer (in botstate)

    @log.log_exception
    def _auth_and_monitoring(self) -> None:
        self.logger.info("Connecting to BirdWing printer...")
        retries = 20
        while True:
            try:
                self.makerbot = BirdwingNetworkConnection(
                    self.ip, self._read_auth_code(), verbose=self.verbose, auth_timeout=self.timeout, logger=self.logger)
                self.makerbot.connect()
                break
            except Exception as e:
                retries -= 1
                self.logger.info('Printer connecting retries left: %s' % retries)
                self._kill_makerbot_object()
                if self.stop_flag:
                    return
                disconnect = retries <= 0
                message = "Cannot connect to printer, error message: %s" % str(e)
                self.parent.register_error(708, message, disconnect=disconnect)
                if disconnect:
                    return
                time.sleep(5)
        if not self.makerbot.authenticate_to_printer():
            self.pairing_needed = True
            self.logger.debug("Press the flashing action button on your printer now")
            try:
                self.makerbot.pair_device()
            except Exception as e:
                self._kill_makerbot_object()
                message = "Birdwing module failed in pairing your printer, error message: %s" % str(e)
                self.parent.register_error(707, message, disconnect=True)
                return
            self.logger.debug("Authenticated with code: %s" % self.makerbot.auth_code)
            self._write_auth_code(self.makerbot.auth_code)
            if not self.makerbot.authenticate_to_printer():
                self._kill_makerbot_object()
                message = "Birdwing module can't authenticate printer after pairing."
                self.parent.register_error(706, message, disconnect=True)
                return
        self.pairing_needed = False
        self.logger.info("...connected!")
        assert_retries_cnt = 0
        while not self.stop_flag:
            if self.parent.current_camera == 'Disable camera':
                self._stop_camera()
            else:
                self._start_camera()
            printer_state = None
            try:
                printer_state = self.makerbot.get_system_information()
            except AssertionError:
                assert_retries_cnt += 1
                if assert_retries_cnt < 10:
                    printer_state = True
                self.logger.info("Empty response (timeout, retry: %d)" % assert_retries_cnt)
                time.sleep(1)
                continue
            except Exception as e:
                self.parent.register_error(700, "Crash BirdWing module, exception: %s" % e, job_fail=True)
                break
            finally:
                self.operational_flag = bool(printer_state)
            assert_retries_cnt = 0
            if printer_state:
                # print self.makerbot.firmware_version
                # print self.makerbot.iserial
                # print self.makerbot.machine_name
                # print self.makerbot.machine_type
                # print self.makerbot.vid
                # print self.makerbot.pid
                # print self.makerbot.bot_type
                toolhead = printer_state.toolheads[0]
                self.temps[1] = toolhead.current_temperature
                self.target_temps[1] = toolhead.target_temperature
                process = printer_state.current_process
                if process:
                    self.available_methods = process.methods
                    self.printing_flag = (process.step != 'completed')
                    if process.step == 'error_step' or process.step == 'failed':
                        self.logger.warning('error_step: %s' % process)
                        if process.error:
                            error = process.error
                            if error.get('code') == 1052:
                                error['description'] = 'Model has no raft. Please confirm from printer'
                            elif error.get('code') == 1049:
                                error['description'] = 'Print machine mismatch'
                        else:
                            error = {}
                        self.parent.register_error(
                            608, "Error step: %s" % error, info=True)
                    if process.step == self.STEP_PRINTING_NAME and process.progress:
                        # Dirty hack, because this printer can answer on first step "printing" 98%!
                        if self.percent != 0 or process.progress < 50:
                            self.percent = process.progress
                else:
                    if self.printing_flag and self.printing_started_flag and self.percent < 95:
                        self.parent.register_error(607, "Cancelled manually", job_fail=True)
                    self.printing_flag = False
            time.sleep(0.5)
        self._kill_makerbot_object()

    def _kill_makerbot_object(self) -> None:
        if self.makerbot:
            self.makerbot.disconnect_json_rpc()
            del self.makerbot
            self.makerbot = None

    def _start_camera(self) -> None:
        if not self.camera:
            self.camera = M5thCamera(self)
        if self.camera_thread:
            if self.camera_thread.is_alive():
                return
            del self.camera_thread
        self.camera.stop_flag = False
        self.camera_thread = threading.Thread(target=self.camera.main_loop)
        self.camera_thread.start()

    def _stop_camera(self) -> None:
        if self.camera_thread and self.camera_thread.is_alive():
            self.camera.stop_flag = True

    def process_gcodes_file(self, filepath: str) -> str:
        return filepath

    def load_gcodes(self, filepath: str) -> bool:
        try:
            with open(filepath, "rb") as f:
                if self.printing_flag:
                    self.parent.register_error(260, "Error: already printing - unable to start a new print", job_fail=False)
                    return False
                else:
                    if not self.filename:
                        self.filename = self.DEFAULT_FILE_NAME
                    self.logger.info("Filename after fixing: %s", self.filename)
                    path_on_printer = self.REMOTE_URL_PATH + self.filename + self.DEFAULT_FILE_EXTENSION
                    for _ in range(0, self.UPLOAD_RETRIES):
                        if self.makerbot.put_file(f, path_on_printer):
                            break
                        f.seek(0)
                        time.sleep(1)
                    else:
                        self.parent.register_error(701, "Cannot upload file to printer", job_fail=True)
                        return False
                    for _ in range(0, self.UPLOAD_RETRIES):
                        if self.makerbot.start_printing_file(path_on_printer):
                            break
                    else:
                        self.parent.register_error(702, "Cannot start print", job_fail=True)
                        return False
                    self.percent = 0
                    self.printing_started_flag = True
                    self.logger.info("Success start print")
        except OSError:
            self.parent.register_error(85, "File loading error. Cancelling...", job_fail=True)
            return False

    def is_printing(self) -> bool:
        return self.printing_flag

    def is_paused(self) -> bool:
        return self.pause_flag

    def is_operational(self) -> bool:
        if not self.ip:
            msg = "Makerbot 5th Generation is only supported over WiFi/Ethernet connection, not USB."
            self.parent.forced_state = 'error'
            self.parent.register_error(709, msg)
            return False
        return self.operational_flag

    def pause(self) -> bool:
        # FIXME: Its ugly, need move to BirdwingConnector or better MakerBotAPI
        if not self.pause_flag and "suspend" in self.available_methods:
            self.makerbot.pause_print()
            self.pause_flag = True
            return True
        else:
            self.parent.register_error(703, "For now, cannot use command pause.", job_fail=False)
            return False

    def unpause(self) -> bool:
        # FIXME: Its ugly, need move to BirdwingConnector or better MakerBotAPI
        if self.pause_flag and "resume" in self.available_methods:
            self.makerbot.unpause_print()
            self.pause_flag = False
            return True
        else:
            self.parent.register_error(704, "For now, cannot use command resume.", job_fail=False)
            return False

    def cancel(self) -> bool:
        # Protection from some problems with cancel on Replicator+
        # First cancel doesn't not work if we have step: failed
        for i in range(1, 4):
            if self.printing_flag:
                self.logger.info("Cancel print #"+str(i))
                try:
                    self.makerbot.cancel_print()
                except MakerBotError as e:
                    if 'RPC Error code=-32000 ' in str(e):
                        break
                    raise e
                time.sleep(0.2)
            else:
                return True
        return False

    def get_percent(self) -> float:
        return self.percent

    def set_total_gcodes(self, length: str) -> None:
        pass

    def get_current_line_number(self) -> int:
        return 0

    def unbuffered_gcodes(self, gcodes: typing.AnyStr) -> bool:
        # TODO: Add report for not support this
        self.parent.register_error(705, "This printer don't support Gcodes", job_fail=False)
        return False

    def close(self) -> None:
        self.logger.info("Closing BirdWingSender module...")
        self.stop_flag = True
        if self.auth_and_monitoring_thread and self.auth_and_monitoring_thread.is_alive():
            self.auth_and_monitoring_thread.join()
        self.logger.info("...closed")

    def _read_auth_code(self) -> str:
        try:
            self.logger.info("Birdwing reading auth code from file: %s" % self.path_to_file_with_auth_code)
            with open(self.path_to_file_with_auth_code, "r") as f:
                auth_code = f.read()
            self.logger.info("Birdwing read auth code: %s" % auth_code)
            return auth_code
        except Exception as e:
            self.logger.info(
                "Cannot read from file: %s, have exception: %s" % (self.path_to_file_with_auth_code, str(e)))

    def _write_auth_code(self, auth_code: str) -> None:
        try:
            self.logger.info(
                "Birdwing writing auth code: %s to file: %s" % (auth_code, self.path_to_file_with_auth_code))
            with open(self.path_to_file_with_auth_code, "w") as f:
                f.write(auth_code)
        except Exception as e:
            self.logger.info(
                "Cannot write to file: %s, have exception: %s" % (self.path_to_file_with_auth_code, str(e)))
