#!/usr/bin/env python
# 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 string
import sys
import os
import time

# fix of broken paths for windows
if not sys.path:
    sys.path = []
path = os.getcwd()
if path not in sys.path:
    sys.path.insert(0, path)

import serial.tools.list_ports

try:
    import usb.core
    import usb.util
    import usb.backend.libusb1
    got_pyusb = True
except ImportError as e:
    print("Error importing pyusb modules:", e)
    print("PyUSB will be disabled. Warning: only serial port printers will be functional")
    print("You can try: apt-get install python3-usb or pacman -S python-pyusb")
    got_pyusb = False


import config
import paths
from base_detector import BaseDetector


class PySerialDetector(BaseDetector):

    def __init__(self, _=None):
        self.logger = logging.getLogger(self.__class__.__name__)
        self.logger.info("PySerial version:" + serial.__version__)
        self.devices_list = []
        super().__init__()


    def get_devices_list(self, add_descriptions=False):
        devices = []
        try:
            serial_ports = serial.tools.list_ports.comports()
        except Exception as e:
            serial_ports = self.devices_list
            self.logger.warning("PySerial error on calling comports:" + str(e))
        else:
            self.devices_list = serial_ports
        for port in serial_ports:
            if port.vid and port.pid:
                usb_info = {
                       'VID': self.format_vid_or_pid(port.vid),\
                       'PID': self.format_vid_or_pid(port.pid),\
                       'SNR': port.serial_number,\
                       'PRT': port.location,\
                       'COM': port.device}
                if usb_info['PRT']:
                    usb_info['PRT'] = usb_info['PRT'].split(":")[0] # remove endings like ":1.0"\
                else:
                    usb_info['PRT'] = ''
                if not usb_info['SNR']:
                    usb_info['SNR'] = ''
                if add_descriptions:
                    manufacturer = port.manufacturer
                    product = port.product
                    if not manufacturer:
                        manufacturer = ''
                    if not product:
                        product = ''
                    usb_info.update({"manufacturer": manufacturer, "product": product})
                devices.append(usb_info)
        return devices


class PyUSBDetector(BaseDetector):
    #NOTE Don't use "try:... except usb.core.USBError:" for pyusb calls, because pyusb is unstable(up to v1.0.2)
    #and can produce all kinds of unpredictable exceptions

    def __init__(self):
        self.logger = logging.getLogger(self.__class__.__name__)
        self.logger.info("PyUSB version:" + usb.__version__)
        self.critical_failure_flag = False
        try:
            self.libusb_backend = usb.backend.libusb1.get_backend(find_library=paths.get_libusb_path)
        except Exception:
            self.logger.info("PyUSB backend error:" + usb.__version__)
            self.critical_failure_flag = True
        super().__init__()

    def detect_devices(self):
        retries = 5
        devices = []
        while retries and not self.critical_failure_flag: # the retries is for mac, because it can suddenly refuse to detect usb devices
            try:
                devices = usb.core.find(find_all=True, backend=self.libusb_backend)
            except Exception as e:
                self.logger.exception("PyUSB error: %s" % e)
                devices = []
            if devices:
                break
            time.sleep(0.5)
            retries -= 1
        if not self.critical_failure_flag and not devices:
            message = "Warning: disabling PyUSB detection"
            self.logger.warning(message)
            self.critical_failure_flag = True
        return devices

    def get_devices_list(self, add_descriptions=False):
        devices = []
        try:
            for dev in self.detect_devices():
                try:
                    VID = self.format_vid_or_pid(dev.idVendor)
                    PID = self.format_vid_or_pid(dev.idProduct)
                except:
                    continue #anything without VID and PID is useless for us
                try:
                    bus = dev.bus
                    port_numbers = dev.port_numbers
                    if port_numbers is None:
                        port_numbers = (dev.port_number,)
                    PRT = "%d-%d" % (bus, port_numbers[0])
                    PRT += ".%d" * len(port_numbers[1:]) % port_numbers[1:]
                except:
                    PRT = ""
                try:
                    SNR = usb.util.get_string(dev, dev.iSerialNumber)
                    for symbol in SNR:
                        if symbol not in string.printable:
                            raise RuntimeError()
                except:
                    SNR = ""
                usb_info = {'VID': VID, 'PID': PID, 'SNR': SNR, 'PRT': PRT}
                if add_descriptions:
                    try:
                        usb_info["manufacturer"] = str(dev.manufacturer)
                    except: #some device descriptors will make libusb to crash on this action
                        usb_info["manufacturer"] = ''
                    try:
                        usb_info["product"] = str(dev.product)
                    except: #some device descriptors will make libusb to crash on this action
                        usb_info["product"] = ''
                devices.append(usb_info)
        # except (OSError, usb.core.USBError) as e: # self.detect_devices returns a generator, so we need this huge try-except ;-(
        except Exception as e: # self.detect_devices returns a generator, so we need this huge try-except ;-(
            if not self.critical_failure_flag:
                self.logger.error("Critical error in PyUSB: " + str(e))
                self.critical_failure_flag = True
        return devices


class USBDetector(BaseDetector):

    def __init__(self, _=None):
        self.logger = logging.getLogger(self.__class__.__name__)
        self.forced_types = config.get_settings().get('forced_types', {})
        self.no_empty_serials = config.get_settings().get('no_empty_serials')
        self.backward_compatible_serials = config.get_settings().get("backward_compatible_serials")
        self.detectors = []
        self.detectors.append(PySerialDetector()) #PySerial should always be first detector, otherwise we will lack COM
        if got_pyusb:
            self.detectors.append(PyUSBDetector())

    # find printers with same VID, PID, SNR and include COM or PRT name in their SNRs
    def modify_identical_devices_id(self, devices):
        if self.backward_compatible_serials:
            modification_attr = 'COM'
        else:
            modification_attr = 'PRT'
        for checked_device in devices:
            for device in devices:
                tmp_mod_attr = modification_attr
                if tmp_mod_attr == 'COM' and not device.get('COM'):
                    tmp_mod_attr = 'PRT'
                if device['VID'] == checked_device['VID'] and\
                   device['PID'] == checked_device['PID'] and\
                   device['SNR'] == checked_device['SNR'] and\
                   device.get(tmp_mod_attr) != checked_device.get(tmp_mod_attr):
                      self.update_duplicate_device(device, tmp_mod_attr)
                      self.update_duplicate_device(checked_device, tmp_mod_attr)

    def update_duplicate_device(self, device, modification_attr):
        device['ID_CLONE_WARNING'] = True
        if device.get(modification_attr):
            if not device['SNR']:
                device['SNR'] = ''
            device['SNR'] = device['SNR'] + device[modification_attr]

    def add_forced_type(self, printer):
        forced_type = self.forced_types.get(printer['VID'] + ":" + printer['PID'])
        if forced_type:
            printer['forced_type'] = forced_type

    def get_profile(self, printer, profiles):
        # profiles arg is for optimization but is optional
        if not profiles:
            profiles = config.get_profiles()
        for profile in profiles:
            if [printer['VID'], printer['PID']] in profile.get('vids_pids', []):
                return profile

    def is_same_printer(self, printer, other_printer):
        if printer['VID'] == other_printer['VID'] and printer['PID'] == other_printer['PID']:
            if printer['SNR'] and printer['SNR'] == other_printer['SNR']:
                return True
            if other_printer.get('COM') and not printer.get('COM') or printer.get('COM') == other_printer['COM']:
                    # if two printers with have vid and pid, but one lacks COM,
                    # then it is same print but from pyusb(bug of different PRTs on mswin)
                    return True
            if printer.get('PRT') and other_printer.get('PRT') and \
                 printer['PRT'] == other_printer['PRT'] and \
                 (not printer['SNR'] or printer['SNR'] == other_printer['PRT']):
                    return True
            if not printer['SNR'] and not printer.get('COM') and not printer['PRT']:
                return True
        return False

    def get_printers_list(self, ignore_profiles=False, add_descriptions=False):
        """Collects devices from all of detectors
           Filters printers already detected by previous detectors
           Filters devices without profiles(not printers)
           Clears SNR for printers with crazy serial numbers"""
        printers = []
        profiles = config.get_profiles()
        for detector in self.detectors:
            detected_devices = detector.get_devices_list(add_descriptions)
            detected_printers = []
            for device in detected_devices:
                profile = self.get_profile(device, profiles)
                if profile or ignore_profiles:
                    if profile and profile.get('crazy_serial_number'):
                        device['SNR'] = ''
                    if not device['SNR']:
                        if profile and profile.get('reject_empty_snr'):
                            continue
                        elif self.no_empty_serials:
                            device['SNR'] = device['PRT']
                    same = False
                    for existing_printer in list(printers): # list creates different copy of printers
                        if self.is_same_printer(device, existing_printer):
                            same = True
                            break
                    if not same:
                        self.add_forced_type(device)
                        detected_printers.append(device)
            printers += detected_printers
        self.modify_identical_devices_id(printers)
        return printers


if __name__ == '__main__':
    print('PyUSB v' + usb.__version__)
    print('PySerial v' + serial.__version__)
    detector = USBDetector(None)
    printers = detector.get_printers_list(ignore_profiles=True, add_descriptions=True)
    print("\nAll devices:")
    if printers:
        for printer in printers:
            print(printer)
    else:
        print(None)
    if config.get_profiles(): #we dont need this we we cant import config and profiles
        printers = detector.get_printers_list()
        print("\nPrinters with profiles:")
        for printer in printers:
            print(printer)
    printers = [x for x in printers if x.get('COM')]
    print("\nDevices with serial port:")
    if printers:
        for printer in printers:
            print(printer)
    else:
        print(None)
    print("\nCOM port devices by pyserial:")
    for port in serial.tools.list_ports.comports():
        print("Device:%s HWID:%s" % (port.device, port.hwid))
