# this file should be compatible with both Python 2 and Python 3!
from __future__ import division
from __future__ import print_function
from __future__ import unicode_literals
from io import open
from six import string_types

import ast
import json
import math
import os
import re
import shutil
import subprocess
import sys
import threading
import traceback


##############################################################################################################
#
# NOTE !!!
#
# THIS FILE MAY BE AUTOGENERATED.
# ONLY MODIFY THE ORIGINAL FILE IN THE /sharedData/ FOLDER!
# OTHER INSTANCES OF THIS FILE ARE COPIES!
#
# NOTE !!!
#
##############################################################################################################


def debugging_message(source, message):
    """
    A debugging function.
    takes a message from a given source, identified by a string, and prints it.
    This function is supposed to be modified as needed to make debugging easier, and serves no purpose beyond that.
    """
    show = True
    if show:
        msg = "%s: %s: %s" % (threading.get_ident(), source, message,)
        print(msg)
        sys.stdout.flush()


def get_error_message_details(exception=None):
    """
    Get a nicely formatted string for an error message collected with sys.exc_info().
    """
    if exception is None:
        exception = sys.exc_info()
    exc_type, exc_obj, exc_trace = exception
    trace = traceback.extract_tb(exc_trace)
    error_msg = "Traceback is:\n"
    for (file,linenumber,affected,line) in trace:
        error_msg += "\t> Error at function %s\n" % (affected)
        error_msg += "\t  At: %s:%s\n" % (file,linenumber)
        error_msg += "\t  Source: %s\n" % (line)
    error_msg += "%s\n%s" % (exc_type, exc_obj,)
    return error_msg


def execute_terminal_command(cmd, get_output=False, ignore_errors=False, in_folder=None):
    """
    Executes a terminal/console command.
    Returns the console output if get_output=True, else returns a result object of subprocess.run()
    Raises an error if something goes wrong.
    # TODO later:
    use a single method instead of using different ones depending on the argument?
    This method is intended as a helper function, and to make it easier later to reroute Docker commands into virtual machines instead of executing them directly, although that last part is not implemented yet as of the time of this writing (2018-08-19).
    """
    with cd(in_folder):
        error_message = None
        if get_output:
            try:
                res = subprocess.check_output(cmd)
                res = res.decode("utf-8")
            except subprocess.CalledProcessError as e:
                error_message = "failed to execute terminal/console command with output:\n%s\n%s" % (cmd, vars(e))
        else:
            if sys.version_info[0] == 2:
                # This is a fallback to add support for Python 2.7
                fnull = open(os.devnull, 'w')
                res, retcode, stdout, stderr = backport_of_subprocess_run_for_python_2(cmd, stdout=fnull, stderr=subprocess.PIPE, cwd=in_folder)
            else:
                res = subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, cwd=in_folder)
                retcode = res.returncode
                stderr = res.stderr
            if retcode != 0:
                contained_error_message = stderr.decode('unicode_escape')
                error_message = "failed to execute terminal/console command:\n%s\n%s" % (cmd, contained_error_message)
        if error_message is not None and not ignore_errors:
            raise Exception(error_message)
        return res


def backport_of_subprocess_run_for_python_2(*popenargs, **kwargs):
    """
    This is a helper function to replace subprocess.run on Python 2.7.
    I copied this code from the internet (Stackoverflow) and just assumed it's correct.
    """
    input = kwargs.get('input', None)
    check = kwargs.get('check', False)
    if input is not None:
        if 'stdin' in kwargs:
            raise ValueError('stdin and input arguments may not both be used.')
        kwargs['stdin'] = subprocess.PIPE
    process = subprocess.Popen(*popenargs, **kwargs)
    try:
        stdout, stderr = process.communicate(input)
    except:
        process.kill()
        process.wait()
        raise
    retcode = process.poll()
    if check and retcode:
        raise subprocess.CalledProcessError(retcode, process.args, output=stdout, stderr=stderr)
    return process, retcode, stdout, stderr


class cd:
    """
    Context manager for changing the current working directory
    """
    def __init__(self, newPath):
        self.newPath = None if newPath is None else os.path.expanduser(newPath)
    def __enter__(self):
        self.savedPath = os.getcwd()
        if self.newPath is not None:
            os.chdir(self.newPath)
    def __exit__(self, etype, value, traceback):
        if self.newPath is not None:
            os.chdir(self.savedPath)


def delete_file_or_folder(path):
    """
    Deletes a file or a folder if it exists.
    Doesn't raise an Error if that file or folder does not exist.
    """
    if os.path.exists(path):
        if os.path.isfile(path):
            os.remove(path)
        else:
            shutil.rmtree(path)


def copy_and_overwrite(from_path, to_path):
    """
    Copy and overwrite a file or a directory structure.
    Overwrites the directory structure if it already exists.
    Created missing path elements if the target path does not exist yet.
    """
    if os.path.exists(to_path):
        if os.path.isfile(to_path):
            os.remove(to_path)
        else:
            shutil.rmtree(to_path)
    destination_parent_folder = os.path.dirname(to_path)
    if not os.path.exists(destination_parent_folder):
        os.makedirs(destination_parent_folder)
    if os.path.isfile(from_path):
        shutil.copy(from_path, to_path)
    else:
        shutil.copytree(from_path, to_path)


def zipdir(path, zip_handle, includeEmptyDIr=True):
    """
    zip a directory
    """
    def get_relative_path(root, file):
        return os.path.relpath(os.path.join(root, file), os.path.join(path, '..'))
    empty_dirs = []
    for root, dirs, files in os.walk(path):
        empty_dirs.extend([dir for dir in dirs if os.listdir(os.path.join(root, dir)) == []])
        for file in files:
            zip_handle.write(os.path.join(root, file), get_relative_path(root, file))
        if includeEmptyDIr:
            for dir in empty_dirs:
                zif = zipfile.ZipInfo(get_relative_path(root, dir) + "/")
                zip_handle.writestr(zif, "")
        empty_dirs = []


def smart_truncate(content, length=100, suffix='...'):
    """
    Truncate a text so that the last word is removed, and it doesn't cut out in the middle of a word.
    """
    if len(content) <= length:
        return content
    else:
        return ' '.join(content[:length+1].split(' ')[0:-1]) + suffix


##############################################################################################################
# fuck this, I'm writing my own JSON parser!
# With blackjack and hookers!
# ---
# This is a JSON parser that isn't as strict as the normal json libraries.
# I can't believe a library for this didn't already exist and I had to write it myself...
##############################################################################################################


class JsonParsingException(Exception):
    pass


def parse_loosely_defined_json(text):
    """
    This function parses a string that represents a JSON object and isn't as strict as the normal json libraries.
    It has the following features:
    -supports Unicode (implicitly, since it just uses whatever string format python is using)
    -supports escape characters like normal JSON does.
    -supports extra commas
    -supports unquoted strings for several kinds of characters that can often occur in Rules and Options
    -supports linebreaks in quoted strings (they are treated the same as writing \n, except that any spaces and tabs following them are ignored.)
    -supports both ' and " as quotation marks
    -supports null/None
    -supports True/true, False/false
    -does not support infinite or NaN numbers
    -has useful error messages
    """
    parser = LooseJsonParser(text)
    raised_error = None
    try:
        res = parser.get_object()
    except Exception as e:
        # don't raise the exception here directly because python will "helpfully" chain the exceptions together
        raised_error = e
        raised_error_details = get_error_message_details()
    if raised_error is not None:
        raise JsonParsingException("exception while parsing text into JSON format.\nException occured at line %d, column %d, for character '%s':\n%s" % (parser.line, parser.col, parser.chars[parser.pos], str(raised_error),))
    # convert to JSON string and back again, just to be sure it works and any error arises now and not later
    res = json.loads(json.dumps(res))
    return res


class LooseJsonParser:
    def __init__(self, text):
        self.pos = 0
        self.line = 1
        self.col = 1
        self.chars = list(text)
        self.unquoted_characters = '[a-zA-Z0-9.?!\-_]'
        self.EOF = object()
        self.chars.append(self.EOF)
    def get_object(self):
        """
        Starting at the current position, continues parsing new characters until it has parsed a complete object, then returns that object.
        When this starts, self.pos should be at the first character of the object (or leading whitespace)
        and when it returns self.pos will be at the last character of the object.
        """
        task = None
        while self.pos < len(self.chars):
            char = self.chars[self.pos]
            if char == self.EOF:
                raise JsonParsingException("reached the end of the file without encountering anything to parse.")
            # update line and column on a linebreak
            if char == '\n':
                self.line += 1
                self.col = 1
            # how to handle the character depends on what is currently being done
            if task is None:
                if re.match('\s', char):
                    # while there is no task yet, ignore whitespace and continue looking for an object
                    pass
                elif char == '[':
                    task = 'building_list'
                    res_builder = []
                    expecting_comma = False
                elif char == '{':
                    task = 'building_dict'
                    res_builder = {}
                    stage = 'expecting_key'
                elif char == '"':
                    task = 'building_primitive'
                    quote_type = 'double_quotes'
                    res_builder = []
                    string_escape = False
                elif char == "'":
                    task = 'building_primitive'
                    quote_type = 'single_quotes'
                    res_builder = []
                    string_escape = False
                elif re.match(self.unquoted_characters, char):
                    task = 'building_primitive'
                    quote_type = 'no_quotes'
                    res_builder = [char]
                    string_escape = False
                    is_finished, res = self._unquoted_text_lookahead_and_optionally_finish(res_builder)
                    if is_finished:
                        return res
                else:
                    raise JsonParsingException("reached an unexpected character while looking for the start of the next object: %s" % char)
            elif task == 'building_list':
                if re.match('\s', char):
                    pass # skip whitespace in a list
                elif char == ',':
                    if expecting_comma:
                        expecting_comma = False
                    else:
                        raise JsonParsingException("encountered multiple commas after another while parsing a list. Did you forget a list element?")
                elif char == ']':
                    # the end of the list has been reached.
                    return res_builder
                else:
                    if expecting_comma:
                        raise JsonParsingException("expected a comma before the next list element.")
                    else:
                        # recurse to get the next element
                        next_list_element = self.get_object()
                        res_builder.append(next_list_element)
                        expecting_comma = True
            elif task == 'building_dict':
                if re.match('\s', char):
                    pass # skip whitespace in a dictionary
                elif char == '}':
                    if stage in ['expecting_key', 'expecting_comma']:
                        return res_builder
                    else:
                        raise JsonParsingException("the dictionary was closed too early. It's missing a value to go with the last key.")
                else:
                    if stage == 'expecting_key':
                        # recurse to get the next element, and verify it's a string and it's new
                        next_dict_key = self.get_object()
                        if not isinstance(next_dict_key, string_types):
                            # if the key is not a string, but is a primitive, coerce it into a string representing the JSON object
                            # (this uses str(json.dumps(next_dict_key)) instead of just str() so that None/null get turned to 'null' instead of 'None')
                            if isinstance(next_dict_key, (int, float, bool)):
                                next_dict_key = str(json.dumps(next_dict_key))
                        if next_dict_key in res_builder:
                            raise JsonParsingException("this string has already been used as a key of this dictionary. No duplicate keys are allowed:\n%s" % next_dict_key)
                        stage = 'expecting_colon'
                    elif stage == 'expecting_colon':
                        if char == ':':
                            stage = 'expecting_value'
                        else:
                            raise JsonParsingException("expected a colon separating the dictionary's key from its value")
                    elif stage == 'expecting_value':
                        # recurse to get the next element
                        next_dict_value = self.get_object()
                        res_builder[next_dict_key] = next_dict_value
                        stage = 'expecting_comma'
                    elif stage == 'expecting_comma':
                        if char == ',':
                            stage = 'expecting_key'
                        else:
                            raise JsonParsingException("expected a comma before the next dictionary key.")
                    else:
                        raise Exception("Programming error: undefined stage of dictionary parsing: %s" % stage)
            elif task == 'building_primitive':
                if quote_type in ['double_quotes', 'single_quotes']:
                    if quote_type == 'double_quotes':
                        limiting_quote = '"'
                    else:
                        limiting_quote = "'"
                    if char == limiting_quote and not string_escape:
                        # the end of the string has been reached. Build the string.
                        # before evaluating the string, do some preprocessing that makes linebreaks possible
                        tmp = []
                        encountered_linebreak = False
                        for chr in res_builder:
                            if chr == '\n':
                                encountered_linebreak = True
                                tmp.append('\\')
                                tmp.append('n')
                            elif (chr == ' ' or chr == '\t') and encountered_linebreak:
                                # ignore any spaces and tabs following a linebreak
                                pass
                            else:
                                encountered_linebreak = False
                                tmp.append(chr)
                        # combine the characters into a string and evaluate it
                        res = "".join(tmp)
                        res = ast.literal_eval(limiting_quote + res + limiting_quote)
                        return res
                    # add the current character to the list
                    # (we already know it's valid because of an earlier call to
                    # self._unquoted_text_lookahead_and_optionally_finish())
                    res_builder.append(char)
                    # if a backslash occurs, enter escape mode unless escape mode is already active,
                    # else deactivate escape mode
                    if char == '\\' and not string_escape:
                        string_escape = True
                    else:
                        string_escape = False
                elif quote_type == 'no_quotes':
                    if not re.match(self.unquoted_characters, char):
                        raise Exception("Programming error: this should have never been reached because of _unquoted_text_lookahead_and_optionally_finish().")
                    # add the element
                    res_builder.append(char)
                    # look ahead, and possibly finish up
                    is_finished, res = self._unquoted_text_lookahead_and_optionally_finish(res_builder)
                    if is_finished:
                        return res
                else:
                    raise Exception("Programming error: undefined kind of string quotation: %s" % quote_type)
            else:
                raise Exception("Programming error: undefined task: %s" % task)
            # increment the position and column
            self.pos += 1
            self.col += 1
        raise JsonParsingException("Programming Error: reached the end of the file, but this should have been noticed earlier, when reaching the self.EOF object.")
    def _unquoted_text_lookahead_and_optionally_finish(self, res_builder):
        """
        Check if the next position is EOF or a character that is invalid for unquoted objects.
        If so, finish up and return the unquoted object.
        """
        next_char = self.chars[self.pos+1]
        if next_char != self.EOF and re.match(self.unquoted_characters, next_char):
            return (False, None)
        # we have encountered a value that is not a valid part of the parser
        # try parsing the result in various ways before returning it
        res = "".join(res_builder)
        # booleans
        if res in ['true', 'True']:
            return (True, True)
        if res in ['false', 'False']:
            return (True, False)
        # null / None
        if res in ['null', 'None']:
            return (True, None)
        # int
        try:
            return (True, int(res))
        except:
            pass
        # float
        error = None
        try:
            flt = float(res)
            if math.isnan(flt) or math.isinf(flt):
                error = "NaN and infinite are not valid JSON values!"
            else:
                return (True, flt)
        except:
            pass
        if error is not None:
            raise JsonParsingException(error)
        # default: string
        return (True, res)


##############################################################################################################
# fuck this, I'm writing my own open() function for python 2 and 3!
# With blackjack and hookers!
# ---
# This is a file writer that doesn't complain about unicode/string mismatches. It works for both python 2.7 and 3+.
# This probably (hopefully?) does already exist, but after searching for an hour I gave up and just wrote the stupid thing myself.
# ---
# This is really hacky and probably not super stable. It works as far as I have tested it, though.
##############################################################################################################


class open_with_blackjack_and_hookers(object):
    """
    This is a file writer that doesn't complain about unicode/string mismatches. It works for both python 2.7 and 3+.
    """
    def __init__(self, file_path):
        super(open_with_blackjack_and_hookers, self).__init__()
        self.file_path = file_path
    def __enter__(self):
        self.file_writer = open(self.file_path, 'w', encoding='utf8')
        self.file_writer.__enter__()
        return self
    def write(self, s):
        if not isinstance(s, string_types):
            raise ValueError("the text to print must be a valid string type")
        # make sure it's unicode (important for python 2)
        if isinstance(s, str):
            if hasattr(s, 'decode'):
                s = s.decode('utf-8')
        # write unicode to file
        self.file_writer.write(s)
    def __exit__(self, exc_type, exc_val, exc_tb):
        return self.file_writer.__exit__(exc_type, exc_val, exc_tb)
