#!/usr/bin/env python
# -*- coding: utf-8 -*-
# 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 re
import time
import logging
import threading
from typing import Any, AnyStr, Union

import config
import log
import pyusb_connection
from base_sender import BaseSender


class DremelConnection(pyusb_connection.PyUSBConnection):

    def format_message(self, raw_message):
        return b"~" + raw_message + b"\r\n"


class Sender(BaseSender):

    INIT_GCODES = [b"M115", b"M650", b"M114", b"M105", b"M119", b"M105"]
    FILE_PACKET_SIZE = 1024*4
    FILE_NAME = b"3dprinteros.g3drem"
    STATE_READY = b"ready"
    STATE_CANCEL = b"cancel_build"
    STATE_BUILDING_COMPLETED = b"building_completed"
    STATE_UNKNOWN = b"unknown"

    def __init__(self, parent: Any, usb_info: dict, profile: dict):
        BaseSender.__init__(self, parent, usb_info, profile)
        self.verbose = config.get_settings()['verbose']
        self.pause_flag = False
        self.monitoring_stop = False
        self.operational_flag = False
        self.printing_flag = False
        # Flag to prevent double printer interface restart after cancel from client's web interface
        # local cancel -> restart pi -> printer can still be printing -> second pi's restart on printer ready
        self.printing_started_flag = False
        # Flag to prevent "Cancelled manually" after print start
        self.real_printing_flag = False
        self.bed_not_clear_flag = False
        self.lines_sent = 0
        self.total_gcodes = 0
        self.percent = 0
        self.upload_percent = 0
        self.monitoring_function = None
        self.status = self.STATE_UNKNOWN
        self.job_status = None
        self.percent_v2 = None
        self.answer_bytes = None
        self.camera = None
        self.camera_thread = None
        self._define_regexps()
        self._create_connection(usb_info, profile)
        self.send_and_wait_lock = threading.Lock()
        self._handshake()
        self._connect()
        self.monitoring_thread = None
        self._monitoring_thread_start()

    def _create_connection(self, usb_info, profile):
        self.connection = DremelConnection(usb_info, profile, verbose=self.verbose, logger=self.logger)

    def _handshake(self):
        if not self._send_and_wait_for(b"M601 S0", b"ok")[0]:
            raise RuntimeError("Cannot handshake printer and receive \"OK\"")

    def _define_regexps(self):
        # T0:32 /0 T1:40 /0 B:38 /0\nok
        # T0:26 /0 B:0/0\nok\n
        # T0:200 /230 B:0/0\nok\n
        # T0:203 /220 B:0 /0\nok\n
        # T0:36 /0 B:0 /0\nok\n
        # T0:28/255 B:27/80 C:23 E:0ok
        self.temp_re = re.compile(
            b'.*T0:([\d\.]+)[ ]*/([\d\.]+)(?:.*T1:(-?[\d\.]+)[ ]*/(-?[\d\.]+)|).*B:(-?[\d\.]+)[ ]*/(-?[\d\.]+).*',
            flags=re.DOTALL
        )
        self.progress_re = re.compile(b".*SD printing byte (\d+)\/(\d+).*", flags=re.DOTALL)

    def _connect(self):
        answer = b""
        for command in self.INIT_GCODES:
            ok, text = self._send_and_wait_for(command, b"ok")
            answer += text
        if b"ok" not in answer: #TODO check this
            raise RuntimeError("Cannot connect to printer and receive \"OK\"")

    def _readout(self, wait_for: bytes = None) -> bytes:
        retries = 10
        full_answer = b""
        while not self.stop_flag:
            answer = self.connection.recv()
            if answer:
                full_answer += answer
                if wait_for and wait_for in answer:
                    return full_answer
            else:
                retries -= 1
                if (not wait_for and full_answer) or not retries:
                    return full_answer

    def _send_and_wait_for(self, message, wait_for=None, endpoint=None, raw=False, timeout=None):
        with self.send_and_wait_lock:
            if self.connection.send(message, endpoint, raw, timeout):
                text = self._readout(wait_for)
                return text and wait_for in text, text
            return False, b""

    def _get_monitoring_data_function(self):
        self.logger.info("Get monitoring data function")
        states_ok, answer_states = self._send_and_wait_for(b"M119", b"ok")
        if not answer_states:
            self.parent.register_error(610, "Problems on getting monitoring function", job_fail=False)
            return False
        self.logger.debug('M119 results: '+str(answer_states))
        if b"MachineStatus: " in answer_states and b"MoveMode: " in answer_states:
            self.logger.info("Monitoring data function: v1")
            return self._read_monitoring_data_v1
        else:
            self.logger.info("Monitoring data function: v2")
            return self._read_monitoring_data_v2

    def _read_monitoring_data_v1(self) -> bool:
        temps_ok, answer_temps = self._send_and_wait_for(b"M105", b"ok")
        # protection from 'Cancelled manually' on printing start (order must be M27, M119)
        bytes_ok, self.answer_bytes = self._send_and_wait_for(b"M27", b"ok")
        states_ok, answer_states = self._send_and_wait_for(b"M119", b"ok")
        # TODO check if "and" is right operation here
        if not answer_temps and not answer_states and not self.answer_bytes:
            return False
        self._parse_temperature(answer_temps)
        if answer_states:
            if b"MachineStatus: READY" in answer_states:
                self.status = self.STATE_READY
            elif b"MachineStatus: CANCEL_BUILD" in answer_states:
                self.status = self.STATE_CANCEL
            elif b"MachineStatus: BUILDING_COMPLETED" in answer_states:
                self.status = self.STATE_BUILDING_COMPLETED
            else:
                self.status = self.STATE_UNKNOWN
        if self.verbose:
            self.logger.debug(
                "Monitoring v1 results: status=%s, percent_v2=%s, job_status=%s, temps=%s, target_temps=%s,"
                " answer_bytes=%s" % (self.status, self.percent_v2, self.job_status, self.temps, self.target_temps,
                                      self.answer_bytes))
        return True

    def _read_monitoring_data_v2(self):
        # firmware_version: v3.0_R02.08.08
        states_ok, answer_states = self._send_and_wait_for(b"M119", b"ok")
        if not answer_states:
            return False
        states = {}
        for line in answer_states.split(b"\n"):
            data = line.split(b":", 2)
            if len(data) == 2:
                states[data[0]] = data[1].strip()
        self.temps = [float(states.get(b"platform_temperature", "0")), float(states.get(b"temperature", "0"))]
        self.target_temps = [
            float(states.get(b"buildPlate_target_temperature", "0")),
            float(states.get(b"extruder_target_temperature", "0"))
        ]
        self.status = states.get(b"status", self.STATE_UNKNOWN)
        self.job_status = states.get(b"jobstatus", None)
        self.percent_v2 = float(states.get(b"progress", "0"))
        if self.verbose:
            self.logger.debug(
                "Monitoring v2 results: status=%s, percent_v2=%s, job_status=%s, temps=%s, target_temps=%s,"
                " states=%s" % (self.status, self.percent_v2, self.job_status, self.temps, self.target_temps, states))
        return True

    def _read_monitoring_data(self):
        if not self.monitoring_function:
            self.monitoring_function = self._get_monitoring_data_function()
            if not self.monitoring_function:
                return False
        return self.monitoring_function()

    def _update_percent(self) -> None:
        if self.percent_v2 is not None:
            self.percent = int(self.percent_v2)
        elif self.answer_bytes:
            progress_match = self.progress_re.match(self.answer_bytes)
            if progress_match:
                self.lines_sent = int(progress_match.group(1))
                self.total_gcodes = int(progress_match.group(2))
                if self.total_gcodes > 0:
                    self.percent = int(self.lines_sent / float(self.total_gcodes) * 100)

    @log.log_exception
    def _monitoring(self) -> None:
        self.logger.info("Monitoring thread started")
        count = 0
        previous_status = self.STATE_READY # this variable used as protection against false error on print end
        while not self.monitoring_stop and not self.stop_flag:
            if self.parent.current_camera == 'Disable camera':
                self._stop_camera()
            else:
                self._start_camera()
            if not self._read_monitoring_data():
                if count < 5:
                    count += 1
                    self.logger.info("Printer not answering on monitoring requests. Retry number %d out of 5" % count)
                    continue
                self.operational_flag = False
                message = "Printer not answering in monitoring. Printer lost."
                self.parent.register_error(606, message, job_fail=True, disconnect=True)
                break
            count = 0
            self.operational_flag = True
            if self.status == self.STATE_READY:
                if self.job_status is not None:
                    self.bed_not_clear_flag = self.job_status != b""
                else:
                    self.bed_not_clear_flag = False
                if self.printing_flag and previous_status == self.STATE_READY:
                    self.printing_flag = False
                    if self.printing_started_flag and self.real_printing_flag and self.percent < 100:
                        self.parent.register_error(607, "Cancelled manually", job_fail=True)
            elif self.printing_flag or self.status != self.STATE_CANCEL:
                if self.job_status is None:
                    is_building_completed = self.status == self.STATE_BUILDING_COMPLETED
                    self.bed_not_clear_flag = is_building_completed
                    self.printing_flag = not is_building_completed
                else:
                    self.printing_flag = True
                self.real_printing_flag = True
                self._update_percent()
                if self.job_status is not None:
                    self.pause_flag = self.job_status == b'!pausing' or self.job_status == b'pausing'
            time.sleep(1)
            previous_status = self.status
        self.logger.info("Monitoring thread stopped")

    def _add_header(self, gcodes: AnyStr) -> bytes:
        current_dir = os.path.dirname(__file__)
        # header is just a dummy to display something on the print screen
        with open(os.path.join(current_dir, 'firmware', 'head_g3drem.bin'), "rb") as f:
            header = f.read()
        return header + gcodes

    def process_gcodes_file(self, gcodes_file: str) -> AnyStr:  # TODO: discuss because in base_sender returns dequeue
        with open(gcodes_file, "rb") as f:
            return f.read()

    def load_gcodes(self, gcodes: AnyStr) -> bool:
        if self.operational_flag and not self.is_printing():
            if gcodes[:7] == b'g3drem ':
                self.logger.info('Build file already have g3drem header. Skip adding another one.')
            else:
                gcodes = self._add_header(gcodes)
            return self._upload_gcodes_and_print(gcodes)
        else:
            self.parent.register_error(604, "Printer already printing.", job_fail=False)
            return False

    def unbuffered_gcodes(self, gcodes: AnyStr) -> None:
        self.logger.info("Gcodes to send now: " + str(gcodes))
        for gcode in self.preprocess_gcodes(gcodes):
            self._send_and_wait_for(gcode, b"ok")
        self.logger.info("Gcodes were sent to printer")

    def _upload_gcodes_and_print(self, gcodes: AnyStr) -> bool:
        self._monitoring_thread_stop()
        self.percent = 0
        self.upload_percent = 0
        self.lines_sent = 0
        # self._send_and_wait_for(b"M104 S0 T0", b"ok")
        # self._send_and_wait_for(b"M104 S0 T1", b"ok")
        # self._send_and_wait_for(b"M140 S0", b"ok")
        file_size = len(gcodes)
        if not self.connection.send(b"M28 %d 0:/user/%s" % (file_size, self.FILE_NAME), None, False, 10000):
            self.parent.register_error(609, "Failed to execute M28 gcode", job_fail=True)
            return False
        answers = b""
        while not self.stop_flag:
            answer = self._readout(b"ok")
            if not answer:
                message = "Failed start transfer file"
                self.parent.register_error(605, message, job_fail=True)
                return False
            answers += answer
            if b"ok" in answers and b"Writing to file" in answers:
                self.logger.info("Received: Writing to file")
                break
            elif b"open failed" in answers:
                message = "Transfer to printer failed"
                self.parent.register_error(602, message, job_fail=True)
                return False
            elif b"Disk read error" in answers:
                message = 'Transfer to printer failed, error: "Disk read error"'
                self.parent.register_error(608, message, job_fail=True)
                return False
        chunk_start_index = 0
        counter = 0
        self.logger.info("Start uploading file...")
        while True:
            if self.stop_flag:
                return False
            chunk_end_index = min(chunk_start_index + self.FILE_PACKET_SIZE, file_size)
            chunk = gcodes[chunk_start_index:chunk_end_index]
            if not chunk:
                break
            send_start_time = time.monotonic()
            if self.connection.send(chunk, endpoint=self.connection.file_transfer_endpoint_out, raw=True):
                counter += 1
                if counter > 4:
                    counter = 0
                    upload_percent = 100.0 * chunk_end_index / file_size
                    self.logger.info("Sent: %.2f%% %d/%d" % (self.upload_percent, chunk_end_index, file_size))
                    self.upload_percent = int(upload_percent)
            else:
                self.parent.register_error(603, "File transfer interrupted", job_fail=True)
                return False
            chunk_start_index += self.FILE_PACKET_SIZE
            send_dt = time.monotonic() - send_start_time
            if send_dt >= 0.1:
                self.logger.info("Packet sent dt is too big: %.3f. Extra waiting." % send_dt)
                time.sleep(min(send_dt, 0.5))
        time.sleep(1)  # some delay between final file packet and sending a gcode command (it is around 0.5sec in FlashPrint)
        message = "File transfer interrupted"
        if self._send_and_wait_for(b"M29", b"ok", timeout=10000)[0]:
            ok, answer = self._send_and_wait_for(b"M23 0:/user/%s" % self.FILE_NAME, b"ok", timeout=10000)
            if not answer:
                message = "Printer not answered anything after send M23"
            elif b"Disk read error" in answer:
                message = "Disk read error"
            elif b"File selected" in answer:
                self.logger.info("File transfer to printer successful")
                self.printing_flag = True
                self.printing_started_flag = True
                self.real_printing_flag = False
                time.sleep(2)  # to double-protect from 'Cancelled manually' on printing start
                self._monitoring_thread_start()
                return True
        self.parent.register_error(604, message, job_fail=True)
        return False

    def pause(self) -> bool:
        if not self.pause_flag:
            if self._send_and_wait_for(b"M25", b"ok")[0]:
                self.pause_flag = True
                return True
            else:
                return False

    def unpause(self) -> bool:
        if self.pause_flag:
            if self._send_and_wait_for(b"M24", b"ok")[0]:
                self.pause_flag = False
                return True
            else:
                return False

    def cancel(self) -> bool:
        if self._send_and_wait_for(b"M26", b"ok")[0]:
            self.pause_flag = False
            self.printing_flag = False
            if not self._send_and_wait_for(b"G1 X0 Y0 Z140", b"ok")[0]:
                self.logger.info('Cancel error on G1')
            self.logger.info('Cancelled!')
            return True
        else:
            self.logger.info('Cancel error on M26')
            return False

    def is_operational(self) -> bool:
        return self.operational_flag

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

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

    def is_bed_not_clear(self) -> bool:
        return self.bed_not_clear_flag

    def reset(self) -> None:
        self.connection.reset()

    def get_downloading_percent(self) -> float:
        percent = self.parent.downloader.get_percent() / 2
        if not self.percent:  # means that we started a new upload
            percent += self.upload_percent / 2
        return percent

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

    def get_current_line_number(self) -> int:
        if self.printing_flag:
            return self.lines_sent
        else:
            return 0

    def _parse_temperature(self, line: AnyStr) -> bool:
        match = self.temp_re.match(line)
        if match:
            tool_temp_right = float(match.group(1))
            tool_target_temp_right = float(match.group(2))
            tool_temp_left = match.group(3)
            tool_target_temp_left = match.group(4)
            platform_temp = float(match.group(5))
            platform_target_temp = float(match.group(6))
            self.temps = [platform_temp, tool_temp_right]
            self.target_temps = [platform_target_temp, tool_target_temp_right]
            if tool_temp_left and tool_target_temp_left:
                self.temps.append(float(tool_temp_left))
                self.target_temps.append(float(tool_target_temp_left))
            return True

    def close(self) -> None:
        self._monitoring_thread_stop()
        self._send_and_wait_for(b"M602", b"ok")
        self.stop_flag = True
        self.connection.close()
        BaseSender.close(self)

    def _monitoring_thread_start(self):
        self.monitoring_stop = False
        self.monitoring_thread = threading.Thread(target=self._monitoring, daemon=True)
        self.monitoring_thread.start()

    def _monitoring_thread_stop(self):
        self.monitoring_stop = True
        if self.monitoring_thread:
            self.monitoring_thread.join()

    def _start_camera(self) -> None:
        pass

    def _stop_camera(self) -> None:
        pass


if __name__ == "__main__":
    logging.basicConfig(level=logging.DEBUG)
    your_file_name = "1.gcode" #replace me
    sender = None
    try:
        # with open('dremel_profile.json') as fp:
        #     sender = Sender(json.load(fp), {"VID": "2a89", "PID": "888b"})
        # time.sleep(3)
        # sender.cancel()

        sender = Sender({}, {"VID": "2a89", "PID": "8889"}, {})
        time.sleep(3)
        # print ">>>START PRINTING!!!!"
        # with open(your_file_name, "rb") as f:
        #     your_file_content = f.read()
        # sender.load_gcodes(your_file_content)
    except Exception:
        if sender:
            sender.close()
    try:
        time.sleep(10)
    except:
        pass
    if sender:
        sender.close()
