# 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 logging
import threading
import time
import typing

import log
import ssl
import socket
import json
import struct
import ctypes
import zlib
from birdwing_sender import BirdwingNetworkConnection
from birdwing_sender import Sender as BirdwingSender
from m5th_cam import M5thCamera
from makerbotapi import AuthenticationError
from makerbotapi import AuthenticationTimeout
from makerbotapi import MakerBotError


class MKBMethodNetworkConnection(BirdwingNetworkConnection):

    DEFAULT_CREDENTIALS = {
        "username": "3DPrinterOS Cloud Client",
        "local_secret": "D7&+5p<abtz**eaLwgGgZ043PYm[A4zx<p+1xs>#NK$pzrY&6Omh-&vX0<2jZBAb"
    }

    PAIR_RETRY_INTERVAL = 10

    def __init__(self, ip, auth_code=None, verbose=False, auth_timeout=120, logger=None):
        BirdwingNetworkConnection.__init__(self, ip, auth_code, logger)
        self.ssl_rpc_id = -1
        self.last_frame_data = None
        self.file_id = 0

    def put_file(self, gcode_file, remote_path):
        self.file_id += 1
        crc = 0
        size = 0
        try:
            while True:
                try:
                    chunk = gcode_file.read(1024*64)
                except OSError as e:
                    self.logger.error("Error on reading file chunks: " + str(e))
                    return False
                if not chunk:
                    break
                crc = zlib.crc32(chunk, crc)
                size += len(chunk)
            gcode_file.seek(0)
            remaining = size
            init_params = {
                "block_size": self.MAX_CHUNK_SIZE,
                "file_id": self.file_id,
                "file_path": remote_path,
                "length": size
            }
            try:
                self.rpc_request_response("put_init", init_params)
            except MakerBotError as e:
                self.logger.warning('Exception on handling RPC response: ' + str(e))
                return
            while remaining > 0 and not self.stop_flag:
                chunk_size = min(remaining, self.MAX_CHUNK_SIZE)
                params = {
                    "file_id": self.file_id,
                    "length": chunk_size
                }
                chunk_data = gcode_file.read(chunk_size)
                try:
                    self.rpc_request_response("put_raw", params, raw_data=chunk_data)
                except MakerBotError as e:
                    self.logger.warning('Exception on handling RPC response: ' + str(e))
                    return
                remaining -= chunk_size
            # finish upload
            params = {
                "crc": crc,
                "file_id": self.file_id,
                "length": size
            }
            try:
                result = self.rpc_request_response("put_term", params)
            except MakerBotError as e:
                self.logger.warning('Exception on handling RPC response: ' + str(e))
                return False
        except Exception as e:
            self.logger.error("File loading error due to " + str(e))
            return False
        return result

    def _get_raw_camera_image_data(self):
        try:
            self.rpc_request_response("request_camera_frame", {})
        except MakerBotError as e:
            self.logger.warning('Exception on handling RPC response: ' + str(e))
        if self.last_frame_data:
            return struct.unpack('!IIII{0}s'.format(len(self.last_frame_data) - ctypes.sizeof(ctypes.c_uint32 * 4)), self.last_frame_data)

    def pair_device(self):
        start_time = time.time()
        while not self.stop_flag:
            self.get_access_token()
            if self.auth_code:
                return
            if time.time() - start_time >= self.auth_timeout:
                raise AuthenticationTimeout

            time.sleep(self.PAIR_RETRY_INTERVAL)

    def get_access_token(self, context = 'jsonrpc'):
        if context == 'jsonrpc':
            params = self.DEFAULT_CREDENTIALS
            context = ssl.create_default_context()
            context.check_hostname = False
            context.verify_mode = ssl.CERT_NONE
            self.ssl_rpc_id += 1
            method = "authorize"
            if self.auth_code:
                params['local_code'] = self.auth_code.strip('\n')
                method = "reauthorize"
            jsonrpc = {'id': self.ssl_rpc_id, 'jsonrpc': '2.0', 'method': method, 'params': params}
            try:
                with socket.socket(socket.AF_INET, socket.SOCK_STREAM, 0) as rpcsslsocket:
                    with context.wrap_socket(rpcsslsocket) as rpc_socket:
                        rpc_socket.connect((self.host, self.SSL_RPC_PORT))
                        rpc_socket.settimeout(self.auth_timeout)
                        jsonrpcstr = json.dumps(jsonrpc)
                        jsonrpcstr = jsonrpcstr.encode('utf-8')
                        res = rpc_socket.sendall(jsonrpcstr)
                        response = rpc_socket.recv(self.READ_CHUNK_SIZE)
                        if response:
                            resp_json = json.loads(response)
                            if resp_json:
                                if resp_json.get("result"):
                                    if resp_json.get("result").get("local_code"):
                                        self.auth_code = resp_json.get("result").get("local_code")
                                    if resp_json.get("result").get("one_time_token"):
                                        return resp_json.get("result").get("one_time_token")
                                if resp_json.get("error"):
                                    if resp_json.get("error").get("code") == 25:
                                        error_message = "Rejected. Pairing mode is already active for this printer. " \
                                                        "Press a button on printer to disable pairing mode."
                                        raise AuthenticationError(error_message)
                                    else:
                                        error_message = "Cannot get access token from printer, error message: %s" % resp_json.get("error").get("message")
                                        self.logger.error(error_message)
            except OSError as e:
                self.logger.error(f"Socket error({e}) on get_access_token from Makerbot Method printer {self.host}")

class Sender(BirdwingSender):

    def __init__(self, parent: typing.Any, usb_info: dict, profile: dict):
        BirdwingSender.__init__(self, parent, usb_info, profile)
        self.camera_thread = None
        self.camera = None
        self.material_names = []
        self.material_volumes = []

    def get_material_names(self) -> typing.List[str]:
        return self.material_names

    def get_material_volumes(self) -> typing.List[float]:
        return self.material_volumes

    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

    @log.log_exception
    def _auth_and_monitoring(self) -> None:
        # TODO: refactor (divide in 2 methods: auth, monitor) and put to birdwing sender
        self.logger.info("Connecting to MethodX printer...")
        retries = 20
        while True:
            try:
                self.makerbot = MKBMethodNetworkConnection(
                    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
                job_fail = retries <= 0
                message = "Cannot connect to printer, error message: %s" % str(e)
                self.parent.register_error(708, message, job_fail=job_fail)
                if job_fail:
                    return
                time.sleep(5)
        if not self.makerbot.auth_code:
            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, job_fail=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, job_fail=True)
            return
        self._write_auth_code(self.makerbot.auth_code)
        self.pairing_needed = False
        # self.serial_number = self.makerbot.iserial
        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:
                self.serial_number = self.makerbot.iserial
                self.temps = [printer_state.chamber.current_temperature]
                self.target_temps = [printer_state.chamber.target_temperature]
                for toolhead in printer_state.toolheads:
                    self.temps.append(toolhead.current_temperature)
                    self.target_temps.append(toolhead.target_temperature)
                self.material_names = printer_state.material_names
                self.material_volumes = printer_state.material_volumes
                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()

if __name__ == "__main__":
    logging.basicConfig()
    logger = logging.getLogger(__name__)
    #logger.setLevel(logging.WARNING)
    logger.setLevel(logging.INFO)
    #logger.setLevel(logging.DEBUG)
    requests_log = logging.getLogger("requests.packages.urllib3")
    #requests_log.setLevel(logging.WARNING)
    requests_log.setLevel(logging.DEBUG)
    requests_log.propagate = False
    usb_info = {"IP": "127.0.0.1", "SNR": "127.0.0.1"}
    profile = {"operational_timeout": 1000}
    s = Sender(None, usb_info, profile)

    while(True):
        time.sleep(3)
