# 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 base64
import collections
import io
import json
import logging
import os
import pprint
import re
import string
import time
import threading
import zipfile
from typing import Any, AnyStr, Union

import config
import base_sender
import log
import paths
from serial_connection import SerialConnection
from emulated_serial_connection import EmulatedSerialConnection


try:
    import pygame
except ImportError:
    pygame = None
    logging.getLogger(__name__).error('Unable to load pygame module')


class Sender(base_sender.BaseSender):

    DEFAULT_BAUDRATE = 250000
    CONFIG_INI_FILENAME = "config.ini"
    SETTINGS_JSON_FILENAME = "3dposdplsettings.json"
    FILES_FOLDER_PATH = paths.STORAGE_FOLDER
    LAYER_IMAGE_FILE_FORMATS = (".png", ".jpeg", ".jpg", ".bmp", ".svg")
    COLOR_BLACK = (0, 0, 0)
    DEFAULT_MOVE_UP_GCODE_TEMPLATE = "G0 Z{layer_height} F{feedrate}"
    DEFAULT_START_GCODES = "G28 Z\nG91\n"
    DEFAULT_END_GCODES = "G90\nG0 Z100\nG91\n"
    OK = b"ok"
    ERROR = b"error"
    DEFAULT_FEEDRATE = 300
    FILE_READ_BUFFER = 256*256 #64kB, used for file copying
    DEFAULT_DISPLAY_NUMBER = 1
    DEFAULT_RESOLUTION = (1440, 2540)
    LAYER_IMAGES_IN_MEMORY = 8
    MOVE_UP_TIMEOUT = 6
    HOMING_TIMEOUT = 30 # per gcode in start gcodes 
    PAUSE_DWELL = 0.1

    def __init__(self, parent: Any, usb_info: dict, profile: dict):
        super().__init__(parent, usb_info, profile)
        self.zip_file = None
        self.layers_filenames = collections.deque()
        self.layers_images = collections.deque()
        self.layers_projected = 0
        self.total_layers = 0
        self.exposition_time = 0
        self.projection_xy_offset = (0, 0)
        self.settings = {}
        self.thumbnails = {}
        self.remove_file_after_print = True
        self.forced_exposition_time = usb_info.get('EXP_TIME') #DEBUG ONLY
        if self.forced_exposition_time:
            self.logger.warning(f'Exposition time forced to {self.forced_exposition_time} seconds.'
                                 'FOR DEBUG ONLY! If you see this in release version, then it is a bug!')
        baudrate = profile.get('baudrate', [self.DEFAULT_BAUDRATE])[0]
        if usb_info.get('EMU'):
            connection_class = EmulatedSerialConnection
        else:
            connection_class = SerialConnection
        self.motor_board_connection = connection_class(usb_info['COM'], baudrate)
        if not self.motor_board_connection.port:
            raise RuntimeError('Error while connecting to motor board')
        self._init_projection_display()
        self.image_load_thread = None
        self.print_thread = None
        self.operational_flag = True
        self.printing_flag = False

    def _init_projection_display(self) -> None:
        if not pygame:
            raise RuntimeError('Graphical backend loading error')
        try:
            pygame.display.init()
            #pygame.event.set_blocked(pygame.MOUSEMOTION)
            # for now as int to make linter happy, overwise it didnt recognize MOUSEMOTION as valid pygame attr
            pygame.event.set_blocked(1024)
            pygame.display.set_allow_screensaver(False)
            pygame.event.set_grab(False)
            resolution = self.profile.get('projector_resolution', self.DEFAULT_RESOLUTION)
            display_number = self.profile.get('projector_display_number', self.DEFAULT_DISPLAY_NUMBER)
            self.image_xy_offset = self.profile.get('projection_xy_offset', (0, 0))
            #TODO in future we can autodetect maximum resolution with line below, but this need to be tested first
            #self.resolution = pygame.display.get_modes(display=self.DISPLAY_NUMBER)[0]
            self.screen = pygame.display.set_mode(resolution, display=display_number, vsync=True)
            self.screen.fill(self.COLOR_BLACK)
            pygame.display.flip()
        except Exception as e:
            self.logger.exception("Exception on projection backend initialization:")
            raise RuntimeError('Graphical backend loading error')

    def gcodes(self, filepath: Union[AnyStr, io.BytesIO], keep_file: bool = False) -> bool:
        success = self.load_gcodes(filepath)
        if not self.keep_print_files and not keep_file:
            try:
                os.remove(filepath)
            except:
                pass
        return success

    def unbuffered_gcodes(self, gcodes: AnyStr) -> bool: # TODO: discuss/fix
        gcodes = self.preprocess_gcodes(gcodes)
        for gcode in gcodes:
            self._send_gcode_and_wait_ok(gcode)

    def load_gcodes(self, zip_file_name: str) -> bool:
        if self.printing_flag:
            self.parent.register_error(260, "Error: already printing - unable to start a new print", job_fail=False)
            return False
        self.layers_projected = 0
        self.total_layers = 0
        total_layers = 0
        try:
            self.logger.info(f'Printing file: {zip_file_name}')
            self.zip_file = zipfile.ZipFile(zip_file_name)
        except (OSError, IOError):
            self.parent.register_error(9987, "Print file error")
            return False
        except MemoryError:
            self.parent.register_error(9988, "Out of memory")
            return False
        except zipfile.error:
            self.parent.register_error(9989, "Bad zip file")
            return False
        layer_file_extension = ""
        for filename in sorted(self.zip_file.namelist()):
            self.logger.debug('Processing file: ' + filename)
            if filename == self.CONFIG_INI_FILENAME:
                try:
                    with self.zip_file.open(filename) as f:
                        config_dict = self._read_ini_config(f)
                        settings = self._translate_config_values_to_settings(config_dict)
                    self.logger.debug('Applying settings from: ' + filename)
                    self._apply_settings(settings)
                except:
                    self.logger.exception('Exception while reading config.ini:')
            elif filename == self.SETTINGS_JSON_FILENAME:
                try:
                    with self.zip_file.open(filename) as f:
                        settings = json.loads(f.read())
                    self.logger.debug('Applying settings from: ' + filename)
                    self._apply_settings(settings)
                except:
                    self.logger.exception(f'Exception while reading settings.json:')
            elif "thumbnail" in filename:
                try:
                    with self.zip_file.open(filename) as f: 
                        self.thumbnails[filename] = f.read()
                except Exception:
                    self.logger.exception(f'Error reading thumbnail {filename}:')
            elif layer_file_extension and filename.endswith(layer_file_extension):
                self.layers_filenames.append(filename)
                total_layers += 1
            elif not layer_file_extension:
                for ext in self.LAYER_IMAGE_FILE_FORMATS:
                    layer_file_extension = ext
                    if filename.endswith(ext):
                        self.layers_filenames.append(filename)
                        total_layers += 1
                        break
        self.total_layers = total_layers
        self.stop_flag = False
        self.image_load_thread = threading.Thread(target=self._image_load_loop)
        self.image_load_thread.start()
        self.print_thread = threading.Thread(target=self._print_loop)
        self.print_thread.start()
        return True

    @log.log_exception
    def _image_load_loop(self) -> None:
        while not self.stop_flag and not self.parent.stop_flag:
            if len(self.layers_images) < self.LAYER_IMAGES_IN_MEMORY:
                if self.layers_filenames:
                    filename = self.layers_filenames.popleft()
                    self.logger.debug(f'Loading: {filename}')
                    self._read_png_file_to_memory_bitmap(filename)
                else:
                    self.logger.info('Last layer image loaded')
                    break
            else:
                time.sleep(self.exposition_time - 0.1)

    def _read_png_file_to_memory_bitmap(self, filename: str) -> None:
        try:
            with self.zip_file.open(filename) as f:
                self.layers_images.append(pygame.image.load_extended(f))
        except Exception:
            self.logger.exception(f'Error loading layer image {filename}:')

    def _read_json_settings(self, json_file: str) -> dict:
        self.logger.info(f"Parsing config file: {json_file}")
        try:
            with open(json_file) as f:
                return json.loads(f.read())
        except: 
            self.register_error(9777, "Invalid settings in print file")
            self.logger.exception(f"Error opening or parsing json config:")

    def _translate_config_values_to_settings(self, sl_dict: dict) -> dict:
        settings = {}
        try:
            settings['exposition'] = sl_dict['expTime']
            settings['first_exposition'] = sl_dict.get('expTimeFirst', settings['exposition'])
            settings['layer_height'] = sl_dict['layerHeight']
        except KeyError as e:
            self.register_error(9778, 'Print file error: ' + str(e))
        try:
            settings['total_print_time'] = int(float(sl_dict.get('printTime', 0)))
        except:
            self.logger.info('Invalid estimated print time in config file')
        return settings

    def _read_ini_config(self, ini_file: AnyStr) -> dict:
        self.logger.debug(f"Parsing config file: {ini_file}")
        config_dict = {}
        for line in ini_file:
            line = line.decode("utf-8")
            line = line.split("#")[0].split(";")[0]
            key_and_value = line.split("=")
            if len(key_and_value) != 2:
                self.logger.warning(f"Unable to parse config line: {line}")
            else:
                key = key_and_value[0].strip()
                value = key_and_value[1].strip()
                if key:
                    #TODO remake this in future - following parsing it unsafe
                    try:
                        if "." in value:
                            value = float(value)
                        else:
                            value = int(value)
                    except (ValueError, TypeError):
                        value = value
                    self.logger.debug(f"Parsing {line} to {key}={value}")
                    config_dict[key] = value
        return config_dict

    def _apply_settings(self, settings: dict) -> None:
        self.first_exposition_time = settings['first_exposition']
        if self.forced_exposition_time: # only for debug purposes
            self.first_exposition_time = self.forced_exposition_time * 2
            self.exposition_time = self.forced_exposition_time
        else:
            self.exposition_time = settings['exposition']
        gcodes_template = settings.get('move_up_gcodes', self.DEFAULT_MOVE_UP_GCODE_TEMPLATE)
        try:
            layer_height=settings['layer_height']
            feedrate=settings.get('feedrate', self.profile.get('feedrate', self.DEFAULT_FEEDRATE))
            self.move_bed_up_gcodes = self.preprocess_gcodes(
                gcodes_template.format(layer_height=layer_height, feedrate=feedrate))
            self.start_gcodes = self.preprocess_gcodes(
                settings.get('start_gcodes', self.profile.get('start_gcodes', self.DEFAULT_START_GCODES)))
            self.end_gcodes = self.preprocess_gcodes(
                settings.get('end_gcodes', self.profile.get('end_gcodes', self.DEFAULT_END_GCODES)))
        except (KeyError, TypeError, ValueError) as e:
            self.register_error(9777, "Error in print file settings") 
            self.logger.exception("Error in print file settings:")
        self.add_move_up_gcodes = []
        for gcode in settings.get('additional_move_up_gcodes', []):
            self.add_move_up_gcodes.append(gcode.encode())
        est_print_time = settings.get('total_print_time', None)
        if est_print_time:
            self.logger.info(f'Setting estimated print time by value from file: {est_print_time}')
            self.set_estimated_print_time(est_print_time)
        self.logger.info('Print settings from print file were applied')
        self.logger.debug('Print settings:' + pprint.pformat(settings))
        self.settings = settings

    def _send_gcode_and_wait_ok(self, gcode: AnyStr, timeout: int = MOVE_UP_TIMEOUT) -> bool:
        self.motor_board_connection.send(gcode)
        complete_response = b""
        begin_time = time.monotonic()
        while not self.OK in complete_response:
            resp = self.motor_board_connection.recv()
            if resp:
                complete_response += resp
            if self.ERROR in complete_response.lower():
                self.register_error(f'Motor board reported an error: {complete_response}', info=True)
                return False
            if time.monotonic() > begin_time + timeout:
                self.logger.error(f'Timeout waiting for ok ack on gcode: {gcode}. Ack: {complete_response}')
                return False
        return True

    def _move_bed_up(self):
        for gcode in self.move_bed_up_gcodes:
            self._send_gcode_and_wait_ok(gcode)
    
    @log.log_exception
    def _print_loop(self) -> None:
        slow_warning_shown = False
        self.logger.info('Sending start gcodes')
        self.printing_flag = True
        for gcode in self.start_gcodes:
            self._send_gcode_and_wait_ok(gcode, self.HOMING_TIMEOUT)
        self.logger.info('All start gcodes executed')
        while not self.stop_flag and not self.parent.stop_flag:
            if self.pause_flag:
                time.sleep(self.PAUSE_DWELL)
            else:
                if not self.layers_images:
                    if not slow_warning_shown:
                        self.logger.warning('Image loading is too slow!')
                    time.sleep(0.1)
                    slow_warning_shown = True
                else:
                    slow_warning_shown = False
                    self.screen.blit(self.layers_images.popleft(), self.projection_xy_offset)
                    pygame.display.flip()
                    if self.layers_projected:
                        time.sleep(self.exposition_time)
                    else:
                        time.sleep(self.first_exposition_time)
                    self.screen.fill(self.COLOR_BLACK)
                    self.logger.debug('Display flip')
                    pygame.display.flip()
                    self.layers_projected += 1
                    if self.layers_projected == self.total_layers:
                        self.logger.info('Last layer was projected')
                        break
                    self._move_bed_up()
        self.logger.info('Sending end gcodes')
        for gcode in self.end_gcodes:
            self._send_gcode_and_wait_ok(gcode, self.HOMING_TIMEOUT)
        self.est_print_time = 0 
        self.logger.info(f"Print finished. Success: {not self.stop_flag and not self.parent.stop_flag}")
        try:
            self._image_load_loop.join(self.MOVE_UP_TIMEOUT)
        except (AttributeError, RuntimeError):
            pass
        self.printing_flag = False
        self.pause_flag = False
        self.stop_flag = False

    def pause(self) -> None:
        self.pause_flag = True

    def unpause(self) -> None:
        self.pause_flag = False

    def cancel(self) -> bool:
        if self.print_thread and self.print_thread.is_alive():
            self.stop_flag = True
            return True

    def get_percent(self) -> float:
        if self.total_layers:
            return round(self.layers_projected / self.total_layers * 100, 2)
        return 0

    def get_current_line_number(self) -> int:
        return self.layers_projected

    def _remove_print_zip_file(self) -> None:
        try:
            if self.zip_file:
                self.logger.info('Closing zip file')
                self.zip_file.close()
        except (OSError, AttributeError): 
            self.logger.error("Error closing dlp zip file")
        if self._remove_print_zip_file:
            try:
                if self.zip_file:
                    self.logger.info('Removing zip file')
                    os.remove(self.zip_file.name)
            except (OSError, AttributeError): 
                self.logger.error("Error removing print zip file")
        self.zip_file = None

    def close(self) -> None:
        self.stop_flag = True
        try:
            self._print_loop.join(self.MOVE_UP_TIMEOUT + self.exposition_time + 0.1)
        except (AttributeError, RuntimeError):
            pass
        try:
            self._image_load_loop.join(self.MOVE_UP_TIMEOUT + 0.01)
        except (AttributeError, RuntimeError):
            pass
        try:
            if self.screen:
                pygame.display.quit()
        except:
            self.logger.error('Error on closing projector display')
        self._remove_print_zip_file()
        if self.motor_board_connection:
            self.motor_board_connection.close()


