#!/usr/bin/env python3

import hashlib
import os
import json
import sys
import shutil
import subprocess
import syslog
import argparse
import shlex
from uci import Uci


def logMsg( msg: str ):
    syslog.syslog(msg)
    sys.stdout.write(msg+"\n")


def calcMd5(fileName:str):
    hashMd5 = hashlib.md5()
    with open(fileName, "rb") as fIn:
        for chunk in iter(lambda: fIn.read(8192), b""):
            hashMd5.update(chunk)
    return hashMd5.hexdigest()


def filesAreEqual(file1: str, file2: str) -> bool :
    if os.path.islink(file1):
        try:
            if os.readlink(file1) == os.readlink(file2):
                return True
        except FileNotFoundError:
            return False
    else:
        if not os.path.exists(file1) or not os.path.exists(file2):
            return False

        if calcMd5(file1)==calcMd5(file2):
            return True

    return False


# install file in the filesystem
def installFile(pkgDir: str, params: dict, force: bool) -> bool :
    if "name" not in params:
        logMsg("File name not found on flexa install file")
        return False

    srcFileName     = pkgDir + "/" + params['name']
    fileBaseName    = os.path.basename(srcFileName)
    if "dest" not in params and "config" not in params:
        logMsg("Destination not found on flexa install file")
        return False

    destDir     = ""
    if "config" in params:
        destDir = "/barix/config/defaults/"
    else:
        destDir = params['dest']

    if not os.path.isfile(srcFileName) and not os.path.islink(srcFileName):
        logMsg("File {} not found".format(srcFileName))
        return False

    dstFileName = destDir+"/"+fileBaseName
    if not force and filesAreEqual(srcFileName,dstFileName):
        logMsg( "'{}' is up to date".format(fileBaseName))
        return False

    logMsg("Installing '{}' ...".format(srcFileName))
    if not os.path.isdir(destDir):
        os.makedirs(destDir)

    # remove destination file when installing symlink, otherwise,
    # shutil.copy() always returns an error
    if os.path.islink(srcFileName) and os.path.lexists(dstFileName):
        os.unlink(dstFileName)

    logMsg("'{}' --> '{}'".format(fileBaseName,destDir))
    shutil.copy(srcFileName,dstFileName,follow_symlinks=False)

    if "perm" in params:
        perm    = params['perm']
        logMsg( "Set permissions {} on '{}'".format(perm,dstFileName))
        os.chmod(dstFileName,perm)

    if "updaterc" in params:
        localService    = fileBaseName
        logMsg("installing service '{}'...".format(localService))
        # We need to remove the service if it already exists, because
        # otherwise, we will not be able to change its parameters
        ph = subprocess.Popen(["/usr/sbin/update-rc.d","-f",localService,"remove"])
        ph.wait()
        rcArgs = ["/usr/sbin/update-rc.d",localService] + shlex.split(params['updaterc'])
        ph = subprocess.Popen(rcArgs)
        ph.wait()

    if "config" in params and params['config']==True:
        if not os.path.isfile("/barix/config/current/"+fileBaseName):
            logMsg("also installing '{}' into current config...".format(fileBaseName))
            shutil.copy(srcFileName,"/barix/config/current")

    return True

# installs the web-ui
def installWebUI( pkgPath: str, targetPath: str ):
    if not os.path.isdir(pkgPath + "/web_ui"):
        logMsg("package has no web ui. Nothing to do.")
        return

    logMsg("installing the web UI...")
    appLink = "/usr/local/www/current/app"
    appWeb  = targetPath + "/web_ui"
    os.remove(appLink)
    os.symlink(appWeb,appLink)


def packageIsCompatible( pkgInfo: dict ) -> bool:
    with open('/barix/info/RUNTIME_VERSION','r') as fIn:
        line        = fIn.readline()
        osVersion   = int(line)

    try:
        compatInfo      = pkgInfo["runtimeCompat"]
        if "min" in compatInfo and osVersion<compatInfo["min"]:
            logMsg("version mismatch: pkg: >={}, os: {}".format(osVersion,compatInfo["min"]))
            return False

        if "max" in compatInfo and osVersion>compatInfo["max"]:
            logMsg("version mismatch: pkg: <={}, os: {}".format(osVersion,compatInfo["max"]))
            return False
    except KeyError:
        # for when runtimeCompat is not present
        pass

    return True


# Install system files
# return: True when at least one file was installed, otherwise false
def installSystemFiles( pkgDir: str, pkgInfo: dict, force=False ) -> bool:
    ret = False
    if "files" in pkgInfo:
        for fileInfo in pkgInfo['files']:
            if installFile(pkgDir,fileInfo,force):
                ret = True
    else:
        return False

    return ret

def installFlexaProvided( pkgDir: str, path: str) -> bool:
    filePath = pkgDir + '/' + path
    if not os.path.isfile(filePath):
        logMsg(f"flexa provided package does not exist: {filePath}")
        return False

    # get file extension
    splitArr = os.path.splitext(filePath)
    extension = splitArr[1]
    destPath = splitArr[0]
    cmd = ''
    if extension == '.zip':
        cmd = f'unzip -o -d {destPath} {filePath}'
    elif extension == '.tar' or extension == '.gz' or extension == '.tgz' or extension == '.xz':
        cmd = f'tar -C {destPath} -xvf {filePath}'
    else:
        logMsg(f'flexa provided package extension "{extension}" not supported')
        return False

    os.mkdir(destPath)
    ret = subprocess.Popen(cmd, shell=True)
    return ret.wait() == 0


# Install all dependencies listed at install file
def installDependencies( pkgDir: str, pkgInfo: dict):
    if 'dependencies' in pkgInfo:
        for dependency in pkgInfo['dependencies']:
            if dependency['type'] == 'flexa-provided':
                logMsg(f'installing flexa provided package: {dependency["name"]}')
                installFlexaProvided(pkgDir, dependency['path'])


# installs the flexa-package
def installPackage( srcPkg: str , dstPkg: str ) -> bool:
    logMsg("installing '{}' into '{}'...".format(srcPkg,dstPkg))

    pathInstallFile = srcPkg + '/install.json'
    if not os.path.isfile(pathInstallFile):
        logMsg("install.json not found. abort.");
        return False

    with open(pathInstallFile,'r') as fIn:
        pkgInfo = json.load(fIn)

    if not packageIsCompatible(pkgInfo):
        return False

    entryPoint      = ""
    if "entryPoint" in pkgInfo:
        entryPoint  = pkgInfo['entryPoint']
    elif "run" in pkgInfo:
        # TODO: legacy entryPoint. to be removed in the future
        entryPoint  = pkgInfo['run']
    else:
        if os.path.isfile(srcPkg+"/run.py"):
            entryPoint = "run.py"
        elif os.path.isfile(srcPkg+"/default.lua"):
            entryPoint = "default.lua"

    if entryPoint=="":
        logMsg("Invalid package. Entry point undefined and no default file found.")
        return False

    isDaemon     = "false" if "daemon" in pkgInfo and not pkgInfo['daemon'] else "true"
    useScreen    = "true" if "screen" in pkgInfo and pkgInfo["screen"] else "false"
    minFwVersion = pkgInfo.get("minFwVersion", '')
    fw_download_url    = pkgInfo.get('fwDownloadUrl','')
    envs         = pkgInfo.get("envs", [])

    sdf = {
        "enabled": "false",
        "file": "",
        "tabName": "Application"
    }
    if "sdf" in pkgInfo and pkgInfo["sdf"]["file"]:
        sdf["enabled"] = "true"
        sdf["file"] = "/mnt/data/package/" + pkgInfo["sdf"]["file"]
        if "tabName" in pkgInfo["sdf"]:
            sdf["tabName"] = pkgInfo["sdf"]["tabName"]

    logMsg("Stopping flexa application before proceeding with installation");
    ph          = subprocess.Popen(["/etc/init.d/run-flexa","stop"])
    ph.wait()

    if os.path.isdir(dstPkg):
        shutil.rmtree(dstPkg)

    shutil.copytree(srcPkg,dstPkg,symlinks=True,ignore_dangling_symlinks=True)
    installWebUI(srcPkg,dstPkg)
    shutil.rmtree(srcPkg)

    try:
        with open(dstPkg+"/manifest.json",'r') as fIn:
            manifest    = json.load(fIn)
            appName     = manifest['name']
    except FileNotFoundError:
        # Store a default, in case the name parameter is missing AND there is no manifest
        appName = pkgInfo.get('name','')

        # if manifest does not exist create one with application name
        if 'name' in pkgInfo:
            with open(dstPkg+'/manifest.json','w') as fOut:
                appName = pkgInfo['name']
                json.dump({'name': appName}, fOut)

    # when installing the package we will always want to install the files in the system
    # even if these files didn't change. It may happen the file permissions changed and
    # this doesn't change the md5 of the file. So, we use force=True here.
    installSystemFiles(dstPkg,pkgInfo,True)

    installDependencies(dstPkg,pkgInfo)

    if appName=="":
        appName = "BARIX FLEXA"

    uci = Uci()
    uci.set("flexa_agent","service","appl_name",appName)
    uci.set("flexa_agent","application","entry_point",entryPoint)
    uci.set("flexa_agent","application","daemon",isDaemon)
    uci.set("flexa_agent","application","envs",envs)
    uci.set("flexa_agent","application","screen",useScreen)
    uci.set("flexa_agent","application","min_fw_version",minFwVersion)
    uci.set("flexa_agent","application","fw_download_url",fw_download_url)
    uci.commit("flexa_agent")

    uci.set("sdf", "general", "enabled", sdf["enabled"])
    uci.set("sdf", "general", "sdf_file", sdf["file"])
    uci.set("sdf", "general", "app_config", "/mnt/data/package/config.json")
    uci.set("sdf", "general", "tab_name", sdf["tabName"])
    uci.commit("sdf")

    return True


if __name__ == "__main__":
    syslog.openlog("flexa-install")

    parser = argparse.ArgumentParser(description='Install a Flexa package in the system.')
    group = parser.add_mutually_exclusive_group(required=True)
    group.add_argument('-i', '--install', metavar='PATH', help='source directory path to be installed')
    group.add_argument('-p', '--patch-system', action='store_true', help='patch the file system following \
            install.json rules')
    args = parser.parse_args()

    if args.install is not None:
        pkgPath = args.install
        if not os.path.isdir(pkgPath):
            sys.stderr.write("'{}' is invalid. abort.\n".format(pkgPath))
            sys.exit(1)
        else:
            if os.path.isdir(pkgPath + "/package"):
                pkgPath = pkgPath + "/package"

            if not installPackage(pkgPath, "/mnt/data/package"):
                sys.exit(1)

            os.sync()
    elif args.patch_system is not None:
        with open("/mnt/data/package/install.json","r") as fIn:
            logMsg("patching system with package addons")
            pkgInfo = json.load(fIn)
            if packageIsCompatible(pkgInfo):
                if not installSystemFiles("/mnt/data/package/",pkgInfo):
                    sys.exit(1)

                os.sync()
            else:
                logMsg("Installation aborted: package is incompatible")
                sys.exit(1)
    else:
        sys.stderr.write("Incorrect Options.\n")
        parser.print_usage()
        sys.exit(1)
