# -*- coding: utf-8 -*-

import hashlib
import json
import os
import requests
import shutil
import subprocess

from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives.serialization import load_pem_public_key

from . import tools

PUB_FILE = '/etc/vmmgr/packages.pub'
LXC_ROOT = '/var/lib/lxc'

class InstallItem:
    def __init__(self, total):
        # Stage 0 = download, 1 = deps install, 2 = app install
        self.stage = 0
        self.total = total
        self.downloaded = 0

    def __str__(self):
        # Limit the displayed percentage to 0 - 99
        return str(min(99, round(self.downloaded / self.total * 100)))

class AppMgr:
    def __init__(self, conf):
        self.conf = conf
        self.online_packages = {}

    def get_repo_resource(self, url, stream=False):
        return requests.get('{}/{}'.format(self.conf['repo']['url'], url), auth=(self.conf['repo']['user'], self.conf['repo']['pwd']), timeout=5, stream=stream)

    def fetch_online_packages(self):
        # Fetches and verifies online packages. Can raise InvalidSignature
        online_packages = {}
        packages = self.get_repo_resource('packages').content
        packages_sig = self.get_repo_resource('packages.sig').content
        with open(PUB_FILE, 'rb') as f:
            pub_key = load_pem_public_key(f.read(), default_backend())
        pub_key.verify(packages_sig, packages, ec.ECDSA(hashes.SHA512()))
        online_packages = json.loads(packages)
        # Minimze the time when self.online_packages is out of sync
        self.online_packages = online_packages

    def start_app(self, item):
        # Start the actual app service
        app = item.key
        if app in self.conf['apps'] and not tools.is_service_started(app):
            tools.start_service(app)

    def stop_app(self, item):
        # Stop the actual app service
        app = item.key
        if app in self.conf['apps'] and tools.is_service_started(app):
            tools.stop_service(app)
            # Stop the app service's dependencies if they are not used by another running app
            deps = self.get_services_deps()
            for dep in tools.get_service_deps(app):
                if not any([tools.is_service_started(d) for d in deps[dep]]):
                    tools.stop_service(dep)

    def update_app_visibility(self, app, visible):
        # Update visibility for the app in the configuration
        if app in self.conf['apps']:
            self.conf['apps'][app]['visible'] = visible
            self.conf.save()

    def update_app_autostart(self, app, enabled):
        # Add/remove the app to OpenRC default runlevel
        if app in self.conf['apps']:
            subprocess.run(['/sbin/rc-update', 'add' if enabled else 'del', app])

    def install_app(self, item):
        # Main installation function. Wrapper for download, registration and install script
        app = item.key
        # Clean packages which previously failed to install
        self.clean_pending_packages()
        # Get all packages on which the app depends and which have not been installed yet
        deps = [d for d in self.get_install_deps(app) if d not in self.conf['packages'] or 'pending' in self.conf['packages'][d]]
        item.data = InstallItem(sum(self.online_packages[d]['size'] for d in deps))
        for dep in deps:
            self.download_package(dep, item.data)
        for dep in deps:
            item.data.stage = 2 if dep == deps[-1] else 1
            # Purge old data before unpacking to clean previous failed installation
            self.purge_package(dep)
            self.unpack_package(dep)
            # Run uninstall script before installation to clean previous failed installation
            self.run_uninstall_script(dep)
            self.register_package(dep)
            self.run_install_script(dep)
            self.finalize_installation(dep)

    def uninstall_app(self, item):
        # Main uninstallation function. Wrapper for uninstall script, filesystem purge and unregistration
        app = item.key
        self.stop_app(item)
        if tools.is_service_autostarted(app):
            self.update_app_autostart(app, False)
        deps = self.get_install_deps(app, False)[::-1]
        for dep in deps:
            if dep not in self.get_uninstall_deps():
                self.run_uninstall_script(dep)
                self.purge_package(dep)
                self.unregister_package(dep)

    def download_package(self, name, installitem):
        tmp_archive = '/tmp/{}.tar.xz'.format(name)
        r = self.get_repo_resource('{}.tar.xz'.format(name), True)
        with open(tmp_archive, 'wb') as f:
            for chunk in r.iter_content(chunk_size=65536):
                if chunk:
                    installitem.downloaded += f.write(chunk)
        # Verify hash
        if self.online_packages[name]['sha512'] != hash_file(tmp_archive):
            raise InvalidSignature(name)

    def unpack_package(self, name):
        # Unpack archive
        tmp_archive = '/tmp/{}.tar.xz'.format(name)
        subprocess.run(['tar', 'xJf', tmp_archive], cwd='/', check=True)
        os.unlink(tmp_archive)

    def purge_package(self, name):
        # Removes package and shared data from filesystem
        lxcpath = self.conf['packages'][name]['lxcpath'] if name in self.conf['packages'] else self.online_packages[name]['lxcpath']
        lxc_dir = os.path.join(LXC_ROOT, lxcpath)
        if os.path.exists(lxc_dir):
            shutil.rmtree(lxc_dir)
        srv_dir = os.path.join('/srv/', name)
        if os.path.exists(srv_dir):
            shutil.rmtree(srv_dir)
        lxc_log = '/var/log/lxc/{}.log'.format(name)
        if os.path.exists(lxc_log):
            os.unlink(lxc_log)

    def register_package(self, name):
        # Registers a package in local configuration
        metadata = self.online_packages[name].copy()
        del metadata['sha512']
        del metadata['size']
        metadata['pending'] = True
        self.conf['packages'][name] = metadata
        self.conf.save()

    def unregister_package(self, name):
        # Removes a package from local configuration
        if name in self.conf['apps']:
            del self.conf['apps'][name]
        del self.conf['packages'][name]
        self.conf.save()

    def finalize_installation(self, name):
        # If the install script called vmmgr register-app, perform the app registration
        # This can't be done directly from install script due to possible race conditions
        cred_file = '/tmp/{}.credentials'.format(name)
        if os.path.exists(cred_file):
            with open(cred_file, 'r') as f:
                cred = f.read().splitlines()
            os.unlink(cred_file)
            self.conf['apps'][name] = {
                'login': cred[0],
                'password': cred[1],
                'visible': False
            }
        # Finally, mark the package as fully installed
        del self.conf['packages'][name]['pending']
        self.conf.save()

    def clean_pending_packages(self):
        # Remove registeres packages with pending flag set from previously failed installation
        for name in self.conf['packages'].copy():
            if 'pending' in self.conf['packages'][name]:
                self.unregister_package(name)
        self.conf.save()

    def run_install_script(self, name):
        # Runs install.sh for a package, if the script is present
        install_dir = os.path.join('/srv/', name, 'install')
        install_script = os.path.join('/srv/', name, 'install.sh')
        if os.path.exists(install_script):
            subprocess.run(install_script, check=True)
            os.unlink(install_script)
        if os.path.exists(install_dir):
            shutil.rmtree(install_dir)

    def run_uninstall_script(self, name):
        # Runs uninstall.sh for a package, if the script is present
        uninstall_script = os.path.join('/srv/', name, 'uninstall.sh')
        if os.path.exists(uninstall_script):
            subprocess.run(uninstall_script, check=True)

    def get_install_deps(self, name, online=True):
        # Flatten dependency tree for a package while preserving the dependency order
        packages = self.online_packages if online else self.conf['packages']
        deps = packages[name]['deps'].copy()
        for dep in deps[::-1]:
            deps[:0] = [d for d in self.get_install_deps(dep, online)]
        deps = list(dict.fromkeys(deps + [name]))
        return deps

    def get_uninstall_deps(self):
        # Create reverse dependency tree for all installed packages
        deps = {}
        for name in self.conf['packages'].copy():
            for d in self.conf['packages'][name]['deps']:
                deps.setdefault(d, []).append(name)
        return deps

    def get_services_deps(self):
        # Fisrt, build a dictionary of {app: [needs]}
        needs = {}
        for app in self.conf['apps'].copy():
            needs[app] = tools.get_service_deps(app)
        # Then reverse it to {need: [apps]}
        deps = {}
        for app, need in needs.items():
            for n in need:
                deps.setdefault(n, []).append(app)
        return deps

    def update_common_settings(self, email, gmaps_api_key):
        # Update common configuration values
        self.conf['common']['email'] = email
        self.conf['common']['gmaps-api-key'] = gmaps_api_key
        self.conf.save()

    def update_repo_settings(self, url, user, pwd):
        # Update lxc repository configuration
        self.conf['repo']['url'] = url
        self.conf['repo']['user'] = user
        self.conf['repo']['pwd'] = pwd
        self.conf.save()

def hash_file(file_path):
    sha512 = hashlib.sha512()
    with open(file_path, 'rb') as f:
        while True:
            data = f.read(65536)
            if not data:
                break
            sha512.update(data)
    return sha512.hexdigest()
