#!/usr/bin/python3

"""Operator package to UCS server deployment

Script accept optional argument with package source. It can be URL or local file.
By default use package in /usr/share/ucs-operator/operator.tar.gz

Configuration structure in deploy.yaml:

deploy:
  operator:                            # create relative directory to /var/www
    index.html:                        # create /var/www/operator/index.html (from index.dist.html)
    osl.html:                          # create /var/www/operator/osl.html
      config_pre: osl.pre              # put content of /etc/ucs/operator/osl.pre before <script src="config.js">
      config_post: osl.post            # put content of /etc/ucs/operator/osl.post after <script src="config.js">
      body_begin: osl.begin            # put content of /etc/ucs/operator/osl.begin after <body> tag
      body_end: osl.end                # put content of /etc/ucs/operator/osl.end before </body> tag
  salesforce:                          # create /var/www/salesforce
    config.js: salesforce_config.js    # copy /etc/ucs/operator/salesforce_config.js to /var/www/salesforce/config.js
    index.html:                        # create /var/www/salesforce/index.html (from index.dist.html)
    static:                            # copy following files from /etc/ucs/operator/static to /var/www/salesforce
      - bridge.js
      - integration.js
      - interaction.js
permissions:                           # change all installed files permissions (both user and group are optional)
  owner: www-data                      # files owner user (uid can be used too)
  group: www-data                      # files owner group (gid can be used too)

If default config /etc/ucs/operator/config.js does not exist then it is created
from package config.dist.js. If "config.js:" definition is not specified for
directory then /etc/ucs/operator/config.js is used by default.

If target directory already exists, then backup YYYY-MM-DD_HH-MM-SS_<directory>.tar.gz
is stored in /opt/backups.
"""


import argparse
import grp
import os
import pwd
import shutil
import sys
import tarfile
import tempfile
import time
import urllib.request

from typing import Optional

import yaml


SOURCE = '/usr/share/ucs-operator/operator.tar.gz'

CONFIG_DIR = '/etc/ucs/operator'
CONFIG_FILE = 'deploy.yaml'
CONFIG_STATIC = 'static'

TARGET = '/var/www'

BACKUP = '/opt/backups'


def load_config(filename: str) -> dict:
    """Load YAML serialized deploy configuration file"""
    try:
        with open(filename, encoding='utf-8') as file:
            config = yaml.safe_load(file)
    except Exception as error:
        print(f'Unable to load deploy configuration: {error}')
        sys.exit(1)

    if not isinstance(config, dict):
        print('Corrupted configuration file, missing "deploy:" section')
        sys.exit(1)

    return config


def handle_permissions(permissions: dict, files: list):
    """Prepare permissions"""
    uid, gid = None, None
    if not isinstance(permissions, dict):
        return

    owner = permissions.get('owner')
    if owner:
        try:
            uid = pwd.getpwnam(owner).pw_uid
        except KeyError:
            print(f'Unknown user "{owner}"')

    group = permissions.get('group')
    if group:
        try:
            gid = grp.getgrnam(group).gr_gid
        except KeyError:
            print(f'Unknown user "{group}"')

    if not uid and not gid:
        return

    for file in files:
        try:
            shutil.chown(file, uid, gid)
        except Exception as error:
            print(f'Unable to set permissions for {file}: {error}')


def process(source_dir: Optional[str], config: dict):
    """Process deploy configuration"""
    files = []
    directories = config.get('deploy')
    if not isinstance(directories, dict) or not directories:
        print('Corrupted configuration file, missing directories under "deploy:" section')
        sys.exit(1)

    default_config = os.path.join(CONFIG_DIR, 'config.js')
    if not os.path.isfile(default_config):
        if source_dir:
            source_config = os.path.join(source_dir, 'config.dist.js')
            shutil.copy(source_config, default_config)
            print(f'Copied config.dist.js to {default_config}')
        else:
            print(f'Will copy config.dist.js to {default_config}')

    for directory in directories:
        target_dir = os.path.abspath(os.path.join(TARGET, directory))
        if target_dir == TARGET:
            print('Invalid configuration. Target directory must not be empty!')
            sys.exit(1)

        if not target_dir.startswith(TARGET):
            print(f'Invalid configuration. Target directory is outside {TARGET}: {target_dir}')
            sys.exit(1)

        if not prepare_directory(source_dir, target_dir):
            continue

        files.append(target_dir)

        files.extend(copy_files(source_dir, target_dir))

        if not isinstance(directories[directory], dict):
            directories[directory] = {'index.html': None}

        entries = directories[directory]
        if not isinstance(entries, dict):
            print('  - Invalid configuration. Missing entry points!')
            sys.exit(1)

        files.extend(process_directory(source_dir, target_dir, entries))

    if source_dir:  # dry run is without source_dir
        handle_permissions(config.get('permissions'), files)


def prepare_directory(source_dir: Optional[str], target_dir: str):
    """Handle directory (backup/create)"""
    if os.path.isdir(target_dir):
        if source_dir:
            try:
                archive = make_backup(target_dir)
                print(f'* {target_dir} ({archive})')
            except Exception as error:
                print(f'* {target_dir} (kept, unable to backup: {error})')
                return False
            try:
                shutil.rmtree(target_dir)
                os.makedirs(target_dir)
            except Exception as error:
                print(f'  - Unable to remove and create empty {target_dir}: {error}')
                sys.exit(1)
        else:
            print(f'* {target_dir} (will backup)')
    else:
        if source_dir:
            try:
                os.makedirs(target_dir, exist_ok=True)
                print(f'* {target_dir} (created)')
            except Exception as error:
                print(f'* {target_dir} (skipped, unable to create: {error})')
                return False
        else:
            print(f'* {target_dir} (will create)')

    return True


def copy_files(source_dir: Optional[str], target_dir: str):
    """Handle directory (backup/create)"""
    files = []

    if not source_dir:
        return files

    for file in os.listdir(source_dir):
        if file in ('index.dist.html', 'config.dist.js'):
            continue

        target = os.path.join(target_dir, file.replace('.dist.', '.'))
        shutil.copy(os.path.join(source_dir, file), target)
        files.append(target)

    return files


def process_directory(source_dir: Optional[str], target_dir: str, entries: dict):
    """Process deploy directories"""
    config_source = entries.get('config.js') or 'config.js'
    if not isinstance(config_source, str) or not config_source:
        print('  - Invalid configuration. Value of config.js must be a string!')
        sys.exit(1)

    files = []

    files.extend(process_config(source_dir, target_dir, config_source))

    for entry, items in entries.items():
        if entry.endswith('.html'):
            target = os.path.join(target_dir, entry)
            if not isinstance(items, dict):
                items = {}
            print(f'  - {entry}')
            files.extend(process_html(source_dir, items, target))
        elif entry == 'static':
            if not isinstance(items, list) or not items:
                print('  - Invalid configuration. Empty static declaration!')
                sys.exit(1)

            files.extend(process_static(source_dir, target_dir, items))
        elif entry != 'config.js':
            print(f'  - Invalid configuration. Unknown directory entry: {entry}')
            sys.exit(1)

    return files


def process_html(source_dir: Optional[str], modifications: dict, target: str):
    """Process HTML modifications"""
    dry = source_dir is None

    config_prepend = get_modification(modifications.get('config_pre'), 'config prepend')
    config_postpend = get_modification(modifications.get('config_post'), 'config postpend')
    body_begin = get_modification(modifications.get('body_begin'), 'body begin')
    body_end = get_modification(modifications.get('body_end'), 'body end')

    if dry:
        return []

    source = os.path.join(source_dir, 'index.dist.html')
    with open(source, encoding='utf-8') as src:
        with open(target, 'w', encoding='utf-8') as dst:
            for line in src:
                if line.find('src="config.js"') != -1:
                    dst.write(config_prepend)
                    dst.write(line)
                    dst.write(config_postpend)
                elif line.find('<body') != -1:
                    dst.write(line)
                    dst.write(body_begin)
                elif line.find('</body>') != -1:
                    dst.write(body_end)
                    dst.write(line)
                else:
                    dst.write(line)

    return [target]


def get_modification(modification: str, part: str):
    """Get HTML file modifications"""
    if not modification:
        return ''

    filename = os.path.join(CONFIG_DIR, modification)
    if not os.path.isfile(filename):
        print(f'    - Missing configured modification file: {filename}')
        sys.exit(1)

    print(f'    - {part}: {filename}')
    with open(filename, encoding='utf-8') as file:
        return file.read()


def process_config(source_dir: Optional[str], target_dir: str, config_source: str):
    """Process config.js file"""
    filename = os.path.join(CONFIG_DIR, config_source)
    if not os.path.isfile(filename):
        print(f'    - Missing configured source of config.js file: {filename}')
        sys.exit(1)

    files = []
    print(f'  - config.js: {filename}')
    if source_dir:
        target = os.path.join(target_dir, 'config.js')
        shutil.copy(filename, target)
        files.append(target)

    return files


def process_static(source_dir: Optional[str], target_dir: str, items: list):
    """Process static files"""
    files = []
    for item in items:
        source = os.path.join(CONFIG_DIR, CONFIG_STATIC, item)
        if not os.path.isfile(source):
            print(f'    - Missing static file: {source}')
            sys.exit(1)

        print(f'  - {item}')
        if source_dir:
            shutil.copy(source, target_dir)

            files.append(os.path.join(target_dir, item))

    return files


def make_backup(directory: str):
    """Make backup of existing operator directory"""
    if not os.path.isdir(BACKUP):
        os.makedirs(BACKUP, exist_ok=True)

    basename = os.path.basename(directory)
    timestamp = time.strftime('%Y-%m-%d_%H-%M-%S')
    filename = os.path.join(BACKUP, f'{timestamp}_{basename}.tar.gz')

    with tarfile.open(filename, 'w:gz') as tar:
        for item in os.listdir(directory):
            tar.add(os.path.join(directory, item), item)

    return filename


def parse_args():
    """Parse command line arguments"""
    parser = argparse.ArgumentParser(
        prog='Operator deploy',
        description=f'Install UCS Operator according to {CONFIG_DIR}/{CONFIG_FILE}',
        epilog=f'Positional source_package has precedence over -s/--source argument. Default source is {SOURCE}',
    )
    parser.add_argument('-f', '--force', help='Force installation without interaction.', action='store_true')
    parser.add_argument('-s', '--source', help='URL or local file', default=SOURCE)
    parser.add_argument('-c', '--config', help='Configuration file (default deploy.yaml)', default=CONFIG_FILE)
    parser.add_argument('source_package', nargs='?', help='URL or local file')
    return parser.parse_args()


def main(args: argparse.Namespace):
    """Install Operator according to deploy.yaml"""
    config = load_config(os.path.join(CONFIG_DIR, args.config))
    source = args.source_package or args.source

    print(f'Will deploy "{source}" to:')
    process(None, config)  # source_directory=None do dry run
    if args.force is False:
        try:
            confirmation = input('\nTo deploy Operator application type YES: ')
            if confirmation != 'YES':
                sys.exit(1)
        except KeyboardInterrupt:
            print('')
            sys.exit(1)

    if source.startswith('http://') or source.startswith('https://'):
        try:
            source, _headers = urllib.request.urlretrieve(source)
        except Exception as error:
            print(f'Unable to download {source}: {error}')
            sys.exit(1)

    with tempfile.TemporaryDirectory() as tmpdirname:
        with tarfile.open(source) as tar:
            tar.extractall(tmpdirname)

        process(os.path.join(tmpdirname, 'build'), config)

        # context manager automatically delete temporary directory


if __name__ == '__main__':
    main(parse_args())
