#!/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 sys
import time
import errno
import zipfile
import logging
import logging.handlers
import traceback
import platform
import os.path
import json
import functools

import certifi

import paths
from paths import CURRENT_SETTINGS_FOLDER
import platforms

try:
    import requests
except ImportError: # to prevent a crash due to requests importing pycache with import simplejson in it
    print("Exception on import of module requests:")
    print(sys.exc_info())
    print("Trying to fix by removing cache files and empty folders")
    paths.cleanup_caches()
    sys.exit(1) #dont try to reimport requests

import http_client
import config
import version
import subprocess

CAMERA_BASE = "3dprinteros_camera"
MAIN_LOG_NAME = os.path.join(CURRENT_SETTINGS_FOLDER, "3dprinteros_client.log")
CAMERA_LOG_FILE = os.path.join(CURRENT_SETTINGS_FOLDER, CAMERA_BASE + ".log")
EXCEPTIONS_LOG_FILE = os.path.join(CURRENT_SETTINGS_FOLDER, 'critical_errors.log')
JOURNAL_DUMP_FILE = os.path.join(CURRENT_SETTINGS_FOLDER, 'journal.txt')
REPORT_FILE_NAME = 'problem_report.txt'
DETECTION_REPORT_NAME = 'integration_request.txt'
ATTACHMENTS_FOLDER_NAME = 'attachments'
USER_SETTINGS_FILE_NAME = 'user_settings.json'
REPORT_FILE = os.path.join(CURRENT_SETTINGS_FOLDER, REPORT_FILE_NAME)
DETECTION_REPORT_FILE = os.path.join(CURRENT_SETTINGS_FOLDER, DETECTION_REPORT_NAME)
PRINTERS_SUBFOLDER = 'printer_logs'
FORBIDDEN_LOG_NAME_CHARS = (".", "/", "\\", ">", "<")

#total max long 0 + 1 = 50MB + 50MB = 100MB
DEFAULT_LOG_BACKUPS = 1
LOG_BACKUPS = config.get_settings()['logging'].get('backup_rolls', DEFAULT_LOG_BACKUPS)
LOG_FILE_SIZE = 1024 * 1024 * config.get_settings()['logging'].get('size_mb', 100) / (LOG_BACKUPS + 1) # 100MB by default)
#REQUEST_SKIP = config.get_settings()['logging']['request_skip']
REQUEST_SKIP = 0

TAIL_LINES = 100
AVERAGE_LINE_LENGTH = 200


class SkipRequestsFilter(logging.Filter):

    REQUEST_RECORD_START = "Request:"

    def __init__(self):
        self.counter = 0

    def filter(self, record):
        if record.getMessage().startswith(self.REQUEST_RECORD_START):
            if self.counter < REQUEST_SKIP:
                self.counter += 1
                return False
            else:
                self.counter = 0
        return True


class HeaderedRotatingFileHandler(logging.handlers.RotatingFileHandler):

    def __init__(self, filename, mode='a', maxBytes=0, backupCount=0,
                 encoding=None, delay=False, errors=None, header=None):
        self.header_lines = []
        if not header:
            pass
        elif isinstance(header, str):
            self.updateHeader(header)
        elif isinstance(header, (bytes)):
            header = header.decode(errors='ignore')
            self.updateHeader(header)
        elif isinstance(header, (list, tuple)):
            for line in header:
                if line:
                    self.updateHeader(line)
        else:
            raise TypeError('Log header must be one of: str, bytes, list, tuple')
        super().__init__(filename, mode, maxBytes, backupCount,
                 encoding, delay, errors)

    def doRollover(self):
        super().doRollover()
        if self.stream is not None:
            for line in self.header_lines:
                self.stream.write(line + "\n")
            self.stream.write("*"*40 + "\n\n")

    def updateHeader(self, header_line):
        self.header_lines.append(str(header_line))


def create_logger(logger_name, log_file_name=None, subfolder=None, header=None):
    logger = logging.getLogger(logger_name)
    if logger.propagate: #prevent reattach copies of handlers to the existing logger
        logger.propagate = False
        if config.get_settings()['logging'].get('debug', False):
            level = logging.DEBUG
        else:
            level = logging.INFO
        logger.setLevel(level)
        std_handler = logging.StreamHandler(stream=sys.stdout)
        std_handler.setLevel(level)
        logger.addHandler(std_handler)
        if not logger_name and not log_file_name and not subfolder:
            log_file_name=MAIN_LOG_NAME
        elif not log_file_name and subfolder:
            log_file_name = logger_name
        if log_file_name and config.get_settings().get('logging', {}).get('enabled'):
            if config.get_settings().get('logging', {}).get('erase_on_start'):
                clear_logs()
            try:
                if subfolder:
                    subfolder = os.path.join(paths.CURRENT_SETTINGS_FOLDER, subfolder)
                    if not os.path.isdir(subfolder):
                        if os.path.isfile(subfolder) or os.path.islink(subfolder):
                            try:
                                os.remove(subfolder)
                            except:
                                pass
                        os.mkdir(subfolder)
                    log_file_name = os.path.join(subfolder, log_file_name + ".log")
                file_handler = HeaderedRotatingFileHandler(log_file_name, 'a', LOG_FILE_SIZE, LOG_BACKUPS, header=header)
                file_handler.setFormatter(logging.Formatter('%(asctime)s\t%(threadName)s/%(funcName)s\t%(message)s'))
                file_handler.setLevel(level)
                logger.updateHeader = file_handler.updateHeader #pyright: ignore #noqa
                if REQUEST_SKIP:
                    skip_requests_filter = SkipRequestsFilter()
                    file_handler.addFilter(skip_requests_filter)
                logger.addHandler(file_handler)
                print("File logger created: " + log_file_name)
            except Exception as e:
                print('Could not create log file because' + str(e) + '\n.No log mode.')
    return logger


def log_exception(func_or_method=None, close_on_error=True, exit_on_error=True):
    assert callable(func_or_method) or func_or_method == None
    def _decorator(deco_func):
        @functools.wraps(deco_func)
        def wrapper(*args, **kwargs):
            try:
                result = deco_func(*args, **kwargs)
            except SystemExit:
                pass
            except Exception as e:
                if (not isinstance(e, OSError) or \
                   getattr(e, 'errno', None) != errno.EINTR):
                    report_exception()
                    if close_on_error and args and hasattr(args[0], "close"): # args[0] is self for object methods
                        try:
                            args[0].close()
                        except:
                            pass
                    if exit_on_error:
                        sys.exit(1)
            else:
                return result
        return wrapper
    if func_or_method:
        return _decorator(func_or_method)
    else:
        return _decorator


def report_exception():
    trace = traceback.format_exc()
    try:
        logging.getLogger(__name__).error(trace)
    except:
        print(trace)
    repeat_flag = trace in config.get_last_exception()
    if not repeat_flag:
        config.set_last_exception(trace)
    try:
        with open(EXCEPTIONS_LOG_FILE, "a+") as f:
            if not repeat_flag:
                f.seek(0)
                prev_exceptions = f.read()
                repeat_flag = trace and trace in prev_exceptions
            if repeat_flag:
                last_line = trace.strip().strip("\n").split("\n")[-1]
                f.write("Repeat: %s\n" % last_line)
            else:
                f.write(form_log_title() + time.ctime() + "\n" + trace + "\n")
    except:
        pass
    if not repeat_flag and config.get_settings()['logging']['auto_report_exceptions'] and\
       not getattr(config.get_app(), 'offline_mode', True):
        try:
            send_logs()
        except:
            pass


def compress_logs():
    logger = logging.getLogger(__name__)
    try:
        log_file_names = os.listdir(CURRENT_SETTINGS_FOLDER)
    except:
        logger.warning('No logs to pack')
    else:
        for name in log_file_names[:]:
            if not (name.startswith(os.path.basename(MAIN_LOG_NAME))
                    or name.startswith(CAMERA_BASE)
                    or name == os.path.basename(EXCEPTIONS_LOG_FILE)
                    or name == REPORT_FILE_NAME
                    or name == DETECTION_REPORT_NAME
                    or name == os.path.basename(JOURNAL_DUMP_FILE)
                    or name == USER_SETTINGS_FILE_NAME):
                log_file_names.remove(name)
        for root, dirs, files in os.walk(os.path.join(CURRENT_SETTINGS_FOLDER, ATTACHMENTS_FOLDER_NAME)):
            for filename in files:
                log_file_names.append(os.path.join(root, filename))
        if log_file_names:
            zip_file_name = time.strftime("%Y_%m_%d___%H_%M_%S", time.localtime()) + ".zip"
            zip_file_name_path = os.path.abspath(os.path.join(CURRENT_SETTINGS_FOLDER, zip_file_name))
            logger.info('Creating zip file: %s' % zip_file_name)
            try:
                with zipfile.ZipFile(zip_file_name_path, mode='w') as zf:
                    for name in log_file_names:
                        zf.write(os.path.join(CURRENT_SETTINGS_FOLDER, name), name, compress_type=zipfile.ZIP_DEFLATED)
                    printer_logs_subdir = os.path.join(CURRENT_SETTINGS_FOLDER, PRINTERS_SUBFOLDER)
                    if os.path.isdir(printer_logs_subdir):
                        for name in os.listdir(printer_logs_subdir):
                            zf.write(os.path.join(printer_logs_subdir, name), PRINTERS_SUBFOLDER + "/" + name, compress_type=zipfile.ZIP_DEFLATED)
            except Exception as e:
                logger.warning("Error while creating logs archive " + zip_file_name_path + ': ' + str(e))
                if os.path.isfile(zip_file_name_path):
                    os.remove(zip_file_name_path)
            else:
                return zip_file_name_path


def upload_compressed_logs(zip_file_path, token):
    # NOTE this upload should not be moved to http_client, because it only works reliably with requests(lib)
    # otherwise you will need to implement chunk uploading using http client
    # WARNING you will need a valid report_file for logs uploading to work!
    logger = logging.getLogger(__name__)
    connection_class = http_client.get_printerinterface_protocol_connection()
    if connection_class.HTTPS_MODE:
        prefix = 'https://'
    else:
        prefix = 'http://'
    url = prefix \
        + connection_class.URL \
        + connection_class.API_PREFIX \
        + connection_class.TOKEN_SEND_LOGS
    files = {}
    try:
        url = connection_class.patch_api_prefix(url)
        data = {connection_class.SEND_LOGS_TOKEN_FIELD_NAME: token}
        logger.info('Sending logs to %s' % url)
        response = None
        with open(zip_file_path, 'rb') as zip_file:
            files['file_data'] = zip_file
            report_file = None
            if os.path.isfile(REPORT_FILE):
                report_file = open(REPORT_FILE, 'rb')
                files['report_file'] = report_file
            if os.path.isfile(DETECTION_REPORT_FILE):
                integration_file = open(DETECTION_REPORT_FILE, 'rb')
                files['integration_request_file'] = integration_file
            response = requests.post(url, data=data, files=files, stream=True, timeout=connection_class.MAX_TIMEOUT, verify=certifi.where())
    except Exception as e:
        return 'Error while sending logs: ' + str(e)
    else:
        if response is None:
            result = response.text
            logger.debug("Log sending response: " + result)
            if response.status_code != 200:
                return 'Error while uploading logs: response code is not 200 (OK)'
            try:
                answer = json.loads(result)
            except Exception as e:
                return 'Error while uploading logs: ' + str(e)
            if isinstance(answer, dict) and not answer.get('success'):
                return result
    finally:
        for f in files:
            try:
                if not files[f].closed:
                    files[f].close()
            except:
                pass


def create_journal_dump():
    if platforms.get_platform() in ('rpi', 'linux'):
        try:
            subprocess.call("/usr/bin/journalctl -q --no-pager -S -24h > " + JOURNAL_DUMP_FILE, timeout=3)
        except (OSError, subprocess.SubprocessError) as e:
            logging.getLogger(__name__).error('Error on dumping journal log: ' + str(e))
            

def send_logs(force=False, token=None):
    logger = logging.getLogger(__name__)
    if force or config.get_settings()['logging']['logs_sending']:
        if not token:
            if config.get_settings()["protocol"].get("user_login"):
                token = config.get_app().user_login.user_token
            else:
                try:
                    token = config.get_app().user_login.auth_tokens[-1][1]
                except:
                    token = None
            if not token:
                return 'Error logs uploading failed: no auth token'
        zip_file_path = compress_logs()
        if not zip_file_path:
            return 'Error while packing logs'
        error = upload_compressed_logs(zip_file_path, token)
        if os.path.isfile(zip_file_path):
            try:
                os.remove(zip_file_path)
            except:
                pass
        if error:
            logger.warning(error)
            return error
        logger.debug('Logs successfully sent')
        return clear_logs()
    else:
        logger.warning("Can't send logs - disabled in config")


def clear_logs():
    for handler in logging.getLogger().handlers:
        try:
            handler.flush()
        except:
            pass
        if issubclass(handler.__class__, logging.handlers.BaseRotatingHandler):
            try:
                handler.doRollover() # pyright: ignore
            except:
                pass
    for number in range(1, LOG_BACKUPS + 1):
        suffix = "." + str(number)
        try:
            os.remove(MAIN_LOG_NAME + suffix)
        except (OSError, IOError):
            break
    try:
        with open(MAIN_LOG_NAME, 'w') as f:
            f.write(form_log_title() + "\n")
    except OSError:
        pass
    try:
        if os.path.exists(EXCEPTIONS_LOG_FILE):
            os.remove(EXCEPTIONS_LOG_FILE)
    except OSError:
        pass
    for filepath in (REPORT_FILE, DETECTION_REPORT_FILE):
        if os.path.isfile(filepath):
            clear_log_file(filepath)
    printer_logs_subdir = os.path.join(CURRENT_SETTINGS_FOLDER, PRINTERS_SUBFOLDER)
    if os.path.isdir(printer_logs_subdir):
        for name in os.listdir(printer_logs_subdir):
            clear_log_file(os.path.join(printer_logs_subdir, name))
    if os.path.isdir(CURRENT_SETTINGS_FOLDER):
        for name in os.listdir(CURRENT_SETTINGS_FOLDER):
            if name.startswith(CAMERA_BASE):
                clear_log_file(os.path.join(CURRENT_SETTINGS_FOLDER, name))
    logging.getLogger(__name__).info('Logs successfully cleared')


def clear_log_file(filepath):
    for number in list(range(1, LOG_BACKUPS + 1)):
        suffix = '.' + str(number)
        if filepath.endswith(suffix):
            try:
                os.remove(filepath)
            except OSError:
                pass
            return 
    try:
        with open(filepath, "w") as f:
            f.write("Log clear at " + time.ctime() + "\n")
    except OSError:
        pass


def get_file_tail(file_path):
    tail_lines_list = []
    if os.path.isfile(file_path):
        with open(file_path) as f:
            f.seek(0, os.SEEK_END)
            size = f.tell()
            tail_length = min(TAIL_LINES * AVERAGE_LINE_LENGTH, size)
            position = size - tail_length
            f.seek(position)
            tail = f.read(tail_length)
            if tail:
                tail_lines_list = tail.split("\n")[1:]
                tail_lines_list.reverse()
    return tail_lines_list


def form_log_title(printer_id_str=''):
    return f'Version: {version.full_version_string()}\n' + \
           f'Platform: {platform.platform()}\n' + \
           f'Operating system: {platform.system()}\n' + \
           f'Python: {sys.version}\n' + \
           f'Printer: {printer_id_str}\n'


def create_report(report_text, printer_id_str=''):
    report_text = form_log_title(printer_id_str) + '\n\n' + report_text
    paths.check_and_create_dirs(REPORT_FILE)
    with open(REPORT_FILE, 'w') as f:
        f.write(report_text)


def report_problem(report_text, token=None, printer_id_str=''):
    create_report(report_text, printer_id_str)
    result = send_logs(token=token)
    logging.getLogger(__name__).info(f"Report send({printer_id_str}):\n{report_text}")
    return result


def open_settings_folder():
    path = os.path.abspath(CURRENT_SETTINGS_FOLDER)
    if sys.platform.startswith('darwin'):
        subprocess.Popen(['open', path], close_fds=True)

    elif sys.platform.startswith('linux'):
        subprocess.Popen(['xdg-open', path], close_fds=True)
    elif sys.platform.startswith('win'):
        subprocess.Popen(['explorer', path], close_fds=True, creationflags=subprocess.CREATE_NO_WINDOW) #pyright: ignore #noqa


def get_all_printer_logs_paths():
    printer_logs_subdir = os.path.join(CURRENT_SETTINGS_FOLDER, PRINTERS_SUBFOLDER)
    if os.path.isdir(printer_logs_subdir):
        return os.path.isdir(printer_logs_subdir)
    return []


def get_printer_log_file_path(printer_id):
    return os.path.join(paths.CURRENT_SETTINGS_FOLDER, PRINTERS_SUBFOLDER, printer_id + ".log")

if __name__ == "__main__":
    report_text = " ".join(sys.argv[1:])
    if report_text:
        create_report(report_text)
    create_journal_dump()
    import user_login
    ul = user_login.UserLogin(None)
    if ul.user_token:
        token = ul.user_token
    elif ul.auth_tokens:
        printer_id, token = ul.auth_tokens[-1][1]
        print('Using auth token of printer ' + str(printer_id))
    else:
        print('Error unable to get token to unload logs')
        sys.exit(1)
    error = send_logs(True, token)
    if error:
        print('Error sending logs: ' + str(error))
        sys.exit(2)
    else:
        print('Logs send')
