import argparse
import asyncio
import datetime
import io
import json
import logging
import os
import re
import string
import subprocess
import sys
import time
import uuid

from random import choice

from wslink import backends

logger = logging.getLogger(__name__)

STATUS_OK = 200
STATUS_BAD_REQUEST = 400
STATUS_NOT_FOUND = 404
STATUS_SERVICE_UNAVAILABLE = 503

sample_config_file = """
Here is a sample of what a configuration file could look like:

    {
        // ===============================
        // General launcher configuration
        // ===============================

        "configuration": {
            "host" : "localhost",
            "port" : 8080,
            "endpoint": "paraview",                   // SessionManager Endpoint
            "content": "/.../www",                    // Optional: Directory shared over HTTP
            "proxy_file" : "/.../proxy-mapping.txt",  // Proxy-Mapping file for Apache
            "sessionURL" : "ws://${host}:${port}/ws", // ws url used by the client to connect to the started process
            "timeout" : 25,                           // Wait time in second after process start
            "log_dir" : "/.../viz-logs",              // Directory for log files
            "fields" : ["file", "host", "port"]       // List of fields that should be send back to client
                                                             // include "secret" if you provide it as an --authKey to the app
            "sanitize": {                             // Check information coming from the client
                "cmd": {
                    "type": "inList",                 // 'cmd' must be one of the strings in 'list'
                    "list": [
                        "me", "you", "something/else/altogether", "nothing-to-do"
                    ],
                    "default": "nothing-to-do"        // If the string doesn't match, replace it with the default.
                                                      // Include the default in your list
                },
                "cmd2": {                             // 'cmd2' must match the regexp provided, example: not a quote
                    "type": "regexp",
                    "regexp": "^[^\"]*$",             // Make sure to include '^' and '$' to match the entire string!
                    "default": "nothing"
                }
            }
        },

        // ===============================
        // Useful session vars for client
        // ===============================

        "sessionData" : { "key": "value" },      // Dictionary of values interesting to the client

        // ===============================
        // Resources list for applications
        // ===============================

        "resources" : [ { "host" : "localhost", "port_range" : [9001, 9003] } ],

        // ===============================
        // Set of properties for cmd line
        // ===============================

        "properties" : {
            "vtkpython" : "/.../VTK/build/bin/vtkpython",
            "pvpython" : "/.../ParaView/build/bin/pvpython",
            "vtk_python_path": "/.../VTK/build/Wrapping/Python/vtk/web",
            "pv_python_path": "/.../ParaView/build/lib/site-packages/paraview/web",
            "plugins_path": "/.../ParaView/build/lib",
            "dataDir": "/.../path/to/data/directory"
        },

        // ===============================
        // Application list with cmd lines
        // ===============================

        "apps" : {
            "cone" : {
                "cmd" : [
                    "${vtkpython}", "${vtk_python_path}/vtk_web_cone.py", "--port", "$port" ],
                "ready_line" : "Starting factory"
            },
            "graph" : {
                "cmd" : [
                    "${vtkpython}", "${vtk_python_path}/vtk_web_graph.py", "--port", "$port",
                    "--vertices", "${numberOfVertices}", "--edges", "${numberOfEdges}" ],
                "ready_line" : "Starting factory"
            },
            "phylotree" : {
                "cmd" : [
                    "${vtkpython}", "${vtk_python_path}/vtk_web_phylogenetic_tree.py", "--port", "$port",
                    "--tree", "${dataDir}/visomics/${treeFile}", "--table", "${dataDir}/visomics/${tableFile}" ],
                "ready_line" : "Starting factory"
            },
            "filebrowser" : {
                "cmd" : [
                    "${vtkpython}", "${vtk_python_path}/vtk_web_filebrowser.py",
                    "--port", "${port}", "--data-dir", "${dataDir}" ],
                "ready_line" : "Starting factory"
            },
            "data_prober": {
                "cmd": [
                    "${pvpython}", "-dr", "${pv_python_path}/pv_web_data_prober.py",
                    "--port", "${port}", "--data-dir", "${dataDir}", "-f" ],
                "ready_line" : "Starting factory"
            },
            "visualizer": {
                "cmd": [
                    "${pvpython}", "-dr", "${pv_python_path}/pv_web_visualizer.py",
                    "--plugins", "${plugins_path}/libPointSprite_Plugin.so", "--port", "${port}",
                    "--data-dir", "${dataDir}", "--load-file", "${dataDir}/${fileToLoad}",
                    "--authKey", "${secret}", "-f" ],  // Use of ${secret} means it needs to be provided to the client, in "fields", above.
                "ready_line" : "Starting factory"
            },
            "loader": {
                "cmd": [
                    "${pvpython}", "-dr", "${pv_python_path}/pv_web_file_loader.py",
                    "--port", "${port}", "--data-dir", "${dataDir}",
                    "--load-file", "${dataDir}/${fileToLoad}", "-f" ],
                "ready_line" : "Starting factory"
            },
            "launcher" : {
                "cmd": [
                    "/.../ParaView/Web/Applications/Parallel/server/launcher.sh",
                    "${port}", "${client}", "${resources}", "${file}" ],
                "ready_line" : "Starting factory"
            },
            "your_app": {
                "cmd": [
                    "your_shell_script.sh", "--resource-host", "${host}", "--resource-port", "${port}",
                    "--session-id", "${id}", "--generated-password", "${secret}",
                    "--application-key", "${application}" ],
                "ready_line": "Output line from your shell script indicating process is ready"
        }
    }
"""

# =============================================================================
# Helper module methods
# =============================================================================


def remove_comments(json_like):
    """
    Removes C-style comments from *json_like* and returns the result.  Example::
        >>> test_json = '''\
        {
            "foo": "bar", // This is a single-line comment
            "baz": "blah" /* Multi-line
            Comment */
        }'''
        >>> remove_comments('{"foo":"bar","baz":"blah",}')
        '{\n    "foo":"bar",\n    "baz":"blah"\n}'

        From: https://gist.github.com/liftoff/ee7b81659673eca23cd9fc0d8b8e68b7
    """
    comments_re = re.compile(
        r'//.*?$|/\*.*?\*/|\'(?:\\.|[^\\\'])*\'|"(?:\\.|[^\\"])*"',
        re.DOTALL | re.MULTILINE,
    )

    def replacer(match):
        s = match.group(0)
        if s[0] == "/":
            return ""
        return s

    return comments_re.sub(replacer, json_like)


def generatePassword():
    return "".join(choice(string.ascii_letters + string.digits) for _ in range(16))


# -----------------------------------------------------------------------------


def validateKeySet(obj, expected_keys, object_name):
    all_key_found = True
    for key in expected_keys:
        if not key in obj:
            print("ERROR: %s is missing %s key." % (object_name, key))
            all_key_found = False
    return all_key_found


def checkSanitize(key_pair, sanitize):
    if not sanitize:
        return
    for key in sanitize:
        if key in key_pair:
            checkItem = sanitize[key]
            value = key_pair[key]
            if checkItem["type"] == "inList":
                if not value in checkItem["list"]:
                    logger.warning(
                        "key %s: sanitize %s with default" % (key, key_pair[key])
                    )
                    key_pair[key] = checkItem["default"]
            elif checkItem["type"] == "regexp":
                if not "compiled" in checkItem:
                    # User is responsible to add begin- and end- string symbols, to make sure entire string is matched.
                    checkItem["compiled"] = re.compile(checkItem["regexp"])
                if checkItem["compiled"].match(value) == None:
                    logger.warning(
                        "key %s: sanitize %s with default" % (key, key_pair[key])
                    )
                    key_pair[key] = checkItem["default"]


# -----------------------------------------------------------------------------
# guard against malicious clients - make sure substitution is expected, if 'sanitize' is provided
# -----------------------------------------------------------------------------


def replaceVariables(template_str, variable_list, sanitize):
    for key_pair in variable_list:
        checkSanitize(key_pair, sanitize)
        item_template = string.Template(template_str)
        template_str = item_template.safe_substitute(key_pair)

    if "$" in template_str:
        logger.error("Some properties could not be resolved: " + template_str)

    return template_str


# -----------------------------------------------------------------------------


def replaceList(template_list, variable_list, sanitize):
    result_list = []
    for template_str in template_list:
        result_list.append(replaceVariables(template_str, variable_list, sanitize))
    return result_list


# -----------------------------------------------------------------------------


def filterResponse(obj, public_keys):
    public_keys.extend(["id", "sessionURL", "sessionManagerURL"])
    filtered_output = {}
    for field in obj:
        if field in public_keys:
            filtered_output[field] = obj[field]
    return filtered_output


# -----------------------------------------------------------------------------


def extractSessionId(request):
    path = request.path.split("/")
    if len(path) < 3:
        return None
    return str(path[2])


def jsonResponse(payload):
    return json.dumps(payload, ensure_ascii=False).encode("utf8")


# =============================================================================
# Session manager
# =============================================================================


class SessionManager(object):
    def __init__(self, config, mapping):
        self.sessions = {}
        self.config = config
        self.resources = ResourceManager(config["resources"])
        self.mapping = mapping
        self.sanitize = config["configuration"]["sanitize"]

    def createSession(self, options):
        # Assign id and store options
        id = str(uuid.uuid1())

        # Assign resource to session
        host, port = self.resources.getNextResource()

        # Do we have resources
        if host:
            options["id"] = id
            options["host"] = host
            options["port"] = port
            if not "secret" in options:
                options["secret"] = generatePassword()
            options["sessionURL"] = replaceVariables(
                self.config["configuration"]["sessionURL"],
                [options, self.config["properties"]],
                self.sanitize,
            )
            options["cmd"] = replaceList(
                self.config["apps"][options["application"]]["cmd"],
                [options, self.config["properties"]],
                self.sanitize,
            )

            if "sessionData" in self.config:
                for key in self.config["sessionData"]:
                    options[key] = replaceVariables(
                        self.config["sessionData"][key],
                        [options, self.config["properties"]],
                        self.sanitize,
                    )

            self.sessions[id] = options
            self.mapping.update(self.sessions)
            return options

        return None

    def deleteSession(self, id):
        host = self.sessions[id]["host"]
        port = self.sessions[id]["port"]
        self.resources.freeResource(host, port)
        del self.sessions[id]
        self.mapping.update(self.sessions)

    def getSession(self, id):
        if id in self.sessions:
            return self.sessions[id]
        return None


# =============================================================================
# Proxy manager
# =============================================================================


class ProxyMappingManager(object):
    def update(sessions):
        pass


class ProxyMappingManagerTXT(ProxyMappingManager):
    def __init__(self, file_path, pattern="%s %s:%d\n"):
        self.file_path = file_path
        self.pattern = pattern

    def update(self, sessions):
        with io.open(self.file_path, "w", encoding="utf-8") as map_file:
            for id in sessions:
                map_file.write(
                    self.pattern % (id, sessions[id]["host"], sessions[id]["port"])
                )


# =============================================================================
# Resource manager
# =============================================================================


class ResourceManager(object):
    """
    Class that provides methods to keep track on available resources (host/port)
    """

    def __init__(self, resourceList):
        self.resources = {}
        for resource in resourceList:
            host = resource["host"]
            portList = list(
                range(resource["port_range"][0], resource["port_range"][1] + 1)
            )
            if host in self.resources:
                self.resources[host]["available"].extend(portList)
            else:
                self.resources[host] = {"available": portList, "used": []}

    def getNextResource(self):
        """
        Return a (host, port) pair if any available otherwise will return None
        """
        # find host with max availibility
        winner = None
        availibilityCount = 0
        for host in self.resources:
            if availibilityCount < len(self.resources[host]["available"]):
                availibilityCount = len(self.resources[host]["available"])
                winner = host

        if winner:
            port = self.resources[winner]["available"].pop()
            self.resources[winner]["used"].append(port)
            return (winner, port)

        return (None, None)

    def freeResource(self, host, port):
        """
        Free a previously reserved resource
        """
        if host in self.resources and port in self.resources[host]["used"]:
            self.resources[host]["used"].remove(port)
            self.resources[host]["available"].append(port)


# =============================================================================
# Process manager
# =============================================================================


class ProcessManager(object):
    def __init__(self, configuration):
        self.config = configuration
        self.log_dir = configuration["configuration"]["log_dir"]
        self.processes = {}

    def __del__(self):
        for id in self.processes:
            self.processes[id].terminate()

    def _getLogFilePath(self, id):
        return "%s%s%s.txt" % (self.log_dir, os.sep, id)

    def startProcess(self, session):
        proc = None

        # Create output log file
        logFilePath = self._getLogFilePath(session["id"])
        with io.open(logFilePath, mode="a+", buffering=1, encoding="utf-8") as log_file:
            try:
                proc = subprocess.Popen(
                    session["cmd"], stdout=log_file, stderr=log_file
                )
                self.processes[session["id"]] = proc
            except:
                logger.error("The command line failed")
                logger.error(" ".join(map(str, session["cmd"])))
                return None

        return proc

    def stopProcess(self, id):
        proc = self.processes[id]
        del self.processes[id]
        try:
            proc.terminate()
        except:
            pass  # we tried

    def listEndedProcess(self):
        session_to_release = []
        for id in self.processes:
            if self.processes[id].poll() is not None:
                session_to_release.append(id)
        return session_to_release

    def isRunning(self, id):
        return self.processes[id].poll() is None

    # ========================================================================
    # Look for ready line in process output. Return True if found, False
    # otherwise. If no ready_line is configured and process is running return
    # False. This will then rely on the timout time.
    # ========================================================================

    def isReady(self, session, count=0):
        id = session["id"]

        # The process has to be running to be ready!
        if not self.isRunning(id) and count < 60:
            return False

        # Give up after 60 seconds if still not running
        if not self.isRunning(id):
            return True

        application = self.config["apps"][session["application"]]
        ready_line = application.get("ready_line", None)

        # If no ready_line is configured and the process is running then thats
        # enough.
        if not ready_line:
            return False

        ready = False

        # Check the output for ready_line
        logFilePath = self._getLogFilePath(session["id"])
        with io.open(logFilePath, "r", 1, encoding="utf-8") as log_file:
            for line in log_file.readlines():
                if ready_line in line:
                    ready = True
                    break

        return ready


# =============================================================================
# Parse config file
# =============================================================================


def parseConfig(options):
    # Read values from the configuration file
    try:
        config_comments = remove_comments(
            io.open(options.config[0], encoding="utf-8").read()
        )
        config = json.loads(config_comments)
    except:
        message = "ERROR: Unable to read config file.\n"
        message += str(sys.exc_info()[1]) + "\n" + str(sys.exc_info()[2])
        print(message)
        print(sample_config_file)
        sys.exit(2)

    expected_keys = ["configuration", "apps", "properties", "resources"]
    if not validateKeySet(config, expected_keys, "Config file"):
        print(sample_config_file)
        sys.exit(2)

    expected_keys = [
        "endpoint",
        "host",
        "port",
        "proxy_file",
        "sessionURL",
        "timeout",
        "log_dir",
        "fields",
    ]
    if not validateKeySet(config["configuration"], expected_keys, "file.configuration"):
        print(sample_config_file)
        sys.exit(2)

    if not "content" in config["configuration"]:
        config["configuration"]["content"] = ""
    if not "sanitize" in config["configuration"]:
        config["configuration"]["sanitize"] = {}

    return config


# =============================================================================
# Setup default arguments to be parsed
#   -d, --debug
#   -t, --proxyFileType  Type of proxy file (txt, dbm)
# =============================================================================


def add_arguments(parser):
    parser.add_argument(
        "config", type=str, nargs=1, help="configuration file for the launcher"
    )
    parser.add_argument(
        "-d", "--debug", help="log debugging messages to stdout", action="store_true"
    )
    parser.add_argument(
        "--backend", help="Server implementation to use (aiohttp)", default="aiohttp"
    )

    return parser


# =============================================================================
# Parse arguments
# =============================================================================


def start(argv=None, description="wslink Web Launcher"):
    parser = argparse.ArgumentParser(
        description=description,
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog=sample_config_file,
    )
    add_arguments(parser)
    args = parser.parse_args(argv)
    config = parseConfig(args)
    backends.launcher_start(args, config, backend=args.backend)


# Used for backward compatibility


def startWebServer(args, config, backend="aiohttp"):
    return backends.launcher_start(args, config, backend=backend)


# =============================================================================
# Main
# =============================================================================

if __name__ == "__main__":
    start()
