#!/usr/bin/env python3

# SPDX-FileCopyrightText: 2021 Aaron Dewes <aaron.dewes@protonmail.com>
#
# SPDX-License-Identifier: MIT

import stat
import tempfile
import threading
from typing import List
from sys import argv
import os
import requests
import shutil
import json
import yaml
import subprocess

from lib.composegenerator.v0.generate import createComposeConfigFromV0
from lib.composegenerator.v1.generate import createComposeConfigFromV1
from lib.appymlgenerator import convertComposeYMLToAppYML
from lib.validate import findAndValidateApps
from lib.metadata import getAppRegistry, getSimpleAppRegistry
from lib.entropy import deriveEntropy

# For an array of threads, join them and wait for them to finish


def joinThreads(threads: List[threading.Thread]):
    for thread in threads:
        thread.join()


# The directory with this script
scriptDir = os.path.dirname(os.path.realpath(__file__))
nodeRoot = os.path.join(scriptDir, "..", "..")
appsDir = os.path.join(nodeRoot, "apps")
appSystemDir = os.path.join(nodeRoot, "app-system")
sourcesList = os.path.join(appSystemDir, "sources.list")
appDataDir = os.path.join(nodeRoot, "app-data")
userFile = os.path.join(nodeRoot, "db", "user.json")
legacyScript = os.path.join(nodeRoot, "scripts", "app")


def runCompose(app: str, args: str):
    compose(app, args)

# Returns a list of every argument after the second one in sys.argv joined into a string by spaces


def getArguments():
    arguments = ""
    for i in range(3, len(argv)):
        arguments += argv[i] + " "
    return arguments


def getAppYml(name):
    url = 'https://raw.githubusercontent.com/runcitadel/compose-nonfree/main/apps/' + \
        name + '/' + 'app.yml'
    response = requests.get(url)
    if response.status_code == 200:
        return response.text
    else:
        return False


def getAppYmlPath(app):
    return os.path.join(appsDir, app, 'app.yml')


def composeToAppYml(app):
    composeFile = os.path.join(appsDir, app, "docker-compose.yml")
    appYml = os.path.join(appsDir, app, "app.yml")
    # Read the compose file and parse it
    with open(composeFile, "r") as f:
        compose = yaml.safe_load(f)
    registry = os.path.join(appsDir, "registry.json")
    # Load the registry
    with open(registry, "r") as f:
        registryData = json.load(f)
    converted = convertComposeYMLToAppYML(compose, app, registryData)
    # Put converted into the app.yml after encoding it as YAML
    with open(appYml, "w") as f:
        f.write(yaml.dump(converted, sort_keys=False))


def update(verbose: bool = False):
    apps = findAndValidateApps(appsDir)
    # The compose generation process updates the registry, so we need to get it set up with the basics before that
    registry = getAppRegistry(apps, appsDir)
    with open(os.path.join(appsDir, "registry.json"), "w") as f:
        json.dump(registry, f, indent=4, sort_keys=True)
    print("Wrote registry to registry.json")

    simpleRegistry = getSimpleAppRegistry(apps, appsDir)
    with open(os.path.join(appSystemDir, "apps.json"), "w") as f:
        json.dump(simpleRegistry, f, indent=4, sort_keys=True)
    print("Wrote version information to apps.json")

    # Loop through the apps and generate valid compose files from them, then put these into the app dir
    for app in apps:
        composeFile = os.path.join(appsDir, app, "docker-compose.yml")
        appYml = os.path.join(appsDir, app, "app.yml")
        with open(composeFile, "w") as f:
            appCompose = getApp(appYml, app)
            if(appCompose):
                f.write(yaml.dump(appCompose, sort_keys=False))
                if verbose:
                    print("Wrote " + app + " to " + composeFile)
    print("Generated configuration successfully")


def download(app: str = None):
    if(app is None):
        apps = findAndValidateApps(appsDir)
        for app in apps:
            data = getAppYml(app)
            if data:
                with open(getAppYmlPath(app), 'w') as f:
                    f.write(data)
            else:
                print("Warning: Could not download " + app)
    else:
        data = getAppYml(app)
        if data:
            with open(getAppYmlPath(app), 'w') as f:
                f.write(data)
        else:
            print("Warning: Could not download " + app)


def getUserData():
    userData = {}
    if os.path.isfile(userFile):
        with open(userFile, "r") as f:
            userData = json.load(f)
    return userData


def startInstalled():
    # If userfile doen't exist, just do nothing
    userData = {}
    if os.path.isfile(userFile):
        with open(userFile, "r") as f:
            userData = json.load(f)
    threads = []
    for app in userData["installedApps"]:
        print("Starting app {}...".format(app))
        # Run runCompose(args.app, "up --detach") asynchrounously for all apps, then exit(0) when all are finished
        thread = threading.Thread(target=runCompose, args=(app, "up --detach"))
        thread.start()
        threads.append(thread)
    joinThreads(threads)


def stopInstalled():
    # If userfile doen't exist, just do nothing
    userData = {}
    if os.path.isfile(userFile):
        with open(userFile, "r") as f:
            userData = json.load(f)
    threads = []
    for app in userData["installedApps"]:
        print("Stopping app {}...".format(app))
        # Run runCompose(args.app, "up --detach") asynchrounously for all apps, then exit(0) when all are finished
        thread = threading.Thread(
            target=runCompose, args=(app, "rm --force --stop"))
        thread.start()
        threads.append(thread)
    joinThreads(threads)

# Loads an app.yml and converts it to a docker-compose.yml


def getApp(appFile: str, appId: str):
    with open(appFile, 'r') as f:
        app = yaml.safe_load(f)

    if not "metadata" in app:
        raise Exception("Error: Could not find metadata in " + appFile)
    app["metadata"]["id"] = appId

    if('version' in app and str(app['version']) == "1"):
        return createComposeConfigFromV1(app, nodeRoot)
    else:
        return createComposeConfigFromV0(app)


def compose(app, arguments):
    # Runs a compose command in the app dir
    # Before that, check if a docker-compose.yml exists in the app dir
    composeFile = os.path.join(appsDir, app, "docker-compose.yml")
    commonComposeFile = os.path.join(appSystemDir, "docker-compose.common.yml")
    os.environ["APP_DOMAIN"] = subprocess.check_output(
        "hostname -s 2>/dev/null || echo 'umbrel'", shell=True).decode("utf-8") + ".local"
    os.environ["APP_HIDDEN_SERVICE"] = subprocess.check_output("cat {} 2>/dev/null || echo 'notyetset.onion'".format(
        os.path.join(nodeRoot, "tor", "data", "app-{}/hostname".format(app))), shell=True).decode("utf-8")
    os.environ["APP_SEED"] = deriveEntropy("app-{}-seed".format(app))
    # Allow more app seeds, with random numbers from 1-5 assigned in a loop
    for i in range(1, 6):
        os.environ["APP_SEED_{}".format(i)] = deriveEntropy("app-{}-seed{}".format(app, i))
    os.environ["APP_DATA_DIR"] = os.path.join(appDataDir, app)
    os.environ["BITCOIN_DATA_DIR"] = os.path.join(nodeRoot, "bitcoin")
    os.environ["LND_DATA_DIR"] = os.path.join(nodeRoot, "lnd")
    # List all hidden services for an app and put their hostname in the environment
    hiddenServices: List[str] = getAppHiddenServices(app)
    for service in hiddenServices:
        appHiddenServiceFile = os.path.join(
            nodeRoot, "tor", "data", "app-{}-{}/hostname".format(app, service))
        os.environ["APP_HIDDEN_SERVICE_{}".format(service.upper().replace("-", "_"))] = subprocess.check_output("cat {} 2>/dev/null || echo 'notyetset.onion'".format(
            appHiddenServiceFile), shell=True).decode("utf-8")

    if not os.path.isfile(composeFile):
        print("Error: Could not find docker-compose.yml in " + app)
        exit(1)
    os.system(
        "docker compose --env-file '{}' --project-name '{}' --file '{}' --file '{}' {}".format(
            os.path.join(nodeRoot, ".env"), app, commonComposeFile, composeFile, arguments))


def remove_readonly(func, path, _):
    os.chmod(path, stat.S_IWRITE)
    func(path)


def deleteData(app: str):
    dataDir = os.path.join(appDataDir, app)
    try:
        shutil.rmtree(dataDir, onerror=remove_readonly)
    except FileNotFoundError:
        pass


def createDataDir(app: str):
    dataDir = os.path.join(appDataDir, app)
    appDir = os.path.join(appsDir, app)
    if os.path.isdir(dataDir):
        deleteData(app)
    # Recursively copy everything from appDir to dataDir while excluding .gitignore
    shutil.copytree(appDir, dataDir, symlinks=False,
                    ignore=shutil.ignore_patterns(".gitignore"))
    # Chown and chmod dataDir to have the same owner and permissions as appDir
    os.chown(dataDir, os.stat(appDir).st_uid, os.stat(appDir).st_gid)
    os.chmod(dataDir, os.stat(appDir).st_mode)


def setInstalled(app: str):
    userData = getUserData()
    if not "installedApps" in userData:
        userData["installedApps"] = []
    userData["installedApps"].append(app)
    userData["installedApps"] = list(set(userData["installedApps"]))
    with open(userFile, "w") as f:
        json.dump(userData, f)


def setRemoved(app: str):
    userData = getUserData()
    if not "installedApps" in userData:
        return
    userData["installedApps"] = list(set(userData["installedApps"]))
    userData["installedApps"].remove(app)
    with open(userFile, "w") as f:
        json.dump(userData, f)


def getAppHiddenServices(app: str):
    torDir = os.path.join(nodeRoot, "tor", "data")
    # List all subdirectories of torDir which start with app-${APP}-
    # but return them without the app-${APP}- prefix
    results = []
    for subdir in os.listdir(torDir):
        if subdir.startswith("app-{}-".format(app)):
            results.append(subdir[len("app-{}-".format(app)):])
    return results


# Parse the sources.list repo file, which contains a list of sources in the format
# <git-url> <branch>
# For every line, clone the repo to a temporary dir and checkout the branch
# Then, check that repos apps in the temporary dir/apps and for every app,
# overwrite the current app dir with the contents of the temporary dir/apps/app
# Also, keep a list of apps from every repo, a repo later in the file may not overwrite an app from a repo earlier in the file
def updateRepos():
    # Get the list of repos
    repos = []
    with open(sourcesList) as f:
        repos = f.readlines()
    # For each repo, clone the repo to a temporary dir, checkout the branch,
    # and overwrite the current app dir with the contents of the temporary dir/apps/app
    alreadyInstalled = []
    for repo in repos:
        repo = repo.strip()
        if repo == "":
            continue
        # Split the repo into the git url and the branch
        repo = repo.split(" ")
        if len(repo) != 2:
            print("Error: Invalid repo format in " + sourcesList)
            exit(1)
        gitUrl = repo[0]
        branch = repo[1]
        # Clone the repo to a temporary dir
        tempDir = tempfile.mkdtemp()
        print("Cloning the repository")
        # Git clone with a depth of 1 to avoid cloning the entire repo
        # Dont print anything to stdout, as we don't want to see the git clone output
        subprocess.run("git clone --depth 1 {} {}".format(gitUrl, tempDir), shell=True, stdout=subprocess.DEVNULL)
        # Overwrite the current app dir with the contents of the temporary dir/apps/app
        for app in os.listdir(os.path.join(tempDir, "apps")):
            # if the app is already installed, don't overwrite it
            if app in alreadyInstalled:
                continue
            if os.path.isdir(os.path.join(appsDir, app)):
                shutil.rmtree(os.path.join(appsDir, app), onerror=remove_readonly)
            if os.path.isdir(os.path.join(tempDir, "apps", app)):
                shutil.copytree(os.path.join(tempDir, "apps", app), os.path.join(appsDir, app),
                                symlinks=False, ignore=shutil.ignore_patterns(".gitignore"))
                alreadyInstalled.append(app)
        # Remove the temporary dir
        shutil.rmtree(tempDir)

[(36, 'os.path.join', 'os.path.join', False, 'import os\n'), (37, 'os.path.join', 'os.path.join', False, 'import os\n'), (38, 'os.path.join', 'os.path.join', False, 'import os\n'), (39, 'os.path.join', 'os.path.join', False, 'import os\n'), (40, 'os.path.join', 'os.path.join', False, 'import os\n'), (41, 'os.path.join', 'os.path.join', False, 'import os\n'), (42, 'os.path.join', 'os.path.join', False, 'import os\n'), (35, 'os.path.realpath', 'os.path.realpath', False, 'import os\n'), (61, 'requests.get', 'requests.get', False, 'import requests\n'), (69, 'os.path.join', 'os.path.join', False, 'import os\n'), (73, 'os.path.join', 'os.path.join', False, 'import os\n'), (74, 'os.path.join', 'os.path.join', False, 'import os\n'), (78, 'os.path.join', 'os.path.join', False, 'import os\n'), (82, 'lib.appymlgenerator.convertComposeYMLToAppYML', 'convertComposeYMLToAppYML', False, 'from lib.appymlgenerator import convertComposeYMLToAppYML\n'), (89, 'lib.validate.findAndValidateApps', 'findAndValidateApps', False, 'from lib.validate import findAndValidateApps\n'), (91, 'lib.metadata.getAppRegistry', 'getAppRegistry', False, 'from lib.metadata import getAppRegistry, getSimpleAppRegistry\n'), (96, 'lib.metadata.getSimpleAppRegistry', 'getSimpleAppRegistry', False, 'from lib.metadata import getAppRegistry, getSimpleAppRegistry\n'), (135, 'os.path.isfile', 'os.path.isfile', False, 'import os\n'), (144, 'os.path.isfile', 'os.path.isfile', False, 'import os\n'), (160, 'os.path.isfile', 'os.path.isfile', False, 'import os\n'), (193, 'os.path.join', 'os.path.join', False, 'import os\n'), (194, 'os.path.join', 'os.path.join', False, 'import os\n'), (203, 'os.path.join', 'os.path.join', False, 'import os\n'), (204, 'os.path.join', 'os.path.join', False, 'import os\n'), (205, 'os.path.join', 'os.path.join', False, 'import os\n'), (223, 'os.chmod', 'os.chmod', False, 'import os\n'), (228, 'os.path.join', 'os.path.join', False, 'import os\n'), (236, 'os.path.join', 'os.path.join', False, 'import os\n'), (237, 'os.path.join', 'os.path.join', False, 'import os\n'), (238, 'os.path.isdir', 'os.path.isdir', False, 'import os\n'), (269, 'os.path.join', 'os.path.join', False, 'import os\n'), (273, 'os.listdir', 'os.listdir', False, 'import os\n'), (77, 'yaml.safe_load', 'yaml.safe_load', False, 'import yaml\n'), (81, 'json.load', 'json.load', False, 'import json\n'), (93, 'json.dump', 'json.dump', False, 'import json\n'), (98, 'json.dump', 'json.dump', False, 'import json\n'), (103, 'os.path.join', 'os.path.join', False, 'import os\n'), (104, 'os.path.join', 'os.path.join', False, 'import os\n'), (116, 'lib.validate.findAndValidateApps', 'findAndValidateApps', False, 'from lib.validate import findAndValidateApps\n'), (151, 'threading.Thread', 'threading.Thread', False, 'import threading\n'), (167, 'threading.Thread', 'threading.Thread', False, 'import threading\n'), (178, 'yaml.safe_load', 'yaml.safe_load', False, 'import yaml\n'), (185, 'lib.composegenerator.v1.generate.createComposeConfigFromV1', 'createComposeConfigFromV1', False, 'from lib.composegenerator.v1.generate import createComposeConfigFromV1\n'), (187, 'lib.composegenerator.v0.generate.createComposeConfigFromV0', 'createComposeConfigFromV0', False, 'from lib.composegenerator.v0.generate import createComposeConfigFromV0\n'), (214, 'os.path.isfile', 'os.path.isfile', False, 'import os\n'), (230, 'shutil.rmtree', 'shutil.rmtree', False, 'import shutil\n'), (255, 'json.dump', 'json.dump', False, 'import json\n'), (265, 'json.dump', 'json.dump', False, 'import json\n'), (305, 'tempfile.mkdtemp', 'tempfile.mkdtemp', False, 'import tempfile\n'), (322, 'shutil.rmtree', 'shutil.rmtree', False, 'import shutil\n'), (85, 'yaml.dump', 'yaml.dump', False, 'import yaml\n'), (92, 'os.path.join', 'os.path.join', False, 'import os\n'), (97, 'os.path.join', 'os.path.join', False, 'import os\n'), (137, 'json.load', 'json.load', False, 'import json\n'), (146, 'json.load', 'json.load', False, 'import json\n'), (162, 'json.load', 'json.load', False, 'import json\n'), (219, 'os.path.join', 'os.path.join', False, 'import os\n'), (242, 'shutil.ignore_patterns', 'shutil.ignore_patterns', False, 'import shutil\n'), (244, 'os.stat', 'os.stat', False, 'import os\n'), (244, 'os.stat', 'os.stat', False, 'import os\n'), (245, 'os.stat', 'os.stat', False, 'import os\n'), (311, 'os.path.join', 'os.path.join', False, 'import os\n'), (195, 'subprocess.check_output', 'subprocess.check_output', False, 'import subprocess\n'), (315, 'os.path.join', 'os.path.join', False, 'import os\n'), (317, 'os.path.join', 'os.path.join', False, 'import os\n'), (108, 'yaml.dump', 'yaml.dump', False, 'import yaml\n'), (316, 'os.path.join', 'os.path.join', False, 'import os\n'), (318, 'os.path.join', 'os.path.join', False, 'import os\n'), (318, 'os.path.join', 'os.path.join', False, 'import os\n'), (319, 'shutil.ignore_patterns', 'shutil.ignore_patterns', False, 'import shutil\n')]