#!/usr/bin/env python3

import _add_root_dir_to_path

from ci._config import *
from ci._view import *
from ci._qloud import *
from ci._types import *
from lib.aux import *
from lib.jenkins import *
from lib.local_storage import *
from lib.docker_registry import *
from lib.crt import *
from lib.repository import ASGRepository

import yaml
import requests

import json
import os.path
import sys
import subprocess
import datetime
import argparse
import time
import uuid
import re
import copy

class ci:
    def __init__(self):
        self.config = load_config('global.yml')
        self.apps = load_app_config('app.yml')
        self.storage = LocalStorage(self.config.storage)
        self.task_storage = TasksStorage(self.config.storage)
        self.registry = DockerRegistry(self.config.registry, self.apps)
        self.vcs = ASGRepository(self.config.asg)
        self.qloud = Qloud(self.config.qloud, self.apps, self.registry)
        self.crt = Crt(self.config.crt)

    def _check_args(self, app: str, env: str = None):
        """ Check whether requested app and env are correctly found in the CI tool config """
        res = app in self.apps
        if not res:
            raise Exception('no config for app {0}'.format(app))
        if res and (env is not None):
            res = res and (env in self.apps[app]['envs'])
            if not res:
                raise Exception('no environment {1} for app {0}'.format(app, env))
        return res

    def decode_version(self, version: str):
        try:
            date = datetime.datetime.now() - datetime.datetime.strptime(version[1:17], '%Y-%m-%d-%H-%M')
            return (version[17:], str(date)[:-10].replace(',', ' ') + ' ago')
        except Exception as e:
            return (version, None)

    def _printable_diff(self, version: str, old_version: str):
        if (version == old_version):
            return 'done'
        (old_branch,old_date) = self.decode_version(old_version)
        (new_branch,new_date) = self.decode_version(version)
        return '{0} ({1}) -> {2} ({3})'.format(old_branch,old_date,new_branch,new_date)

    def generate_deploy_steps(self, qloud_config, qloud_status, app: str, env: str, revision: str, new_version: str, skip_canary: bool, pause_after: list):
        """
        Generates a complete deploy step for a single `env`.
        `new_session` can be empty if no docker image has been built yet.
        """
        if not self._check_args(app, env):
            return None # raise ex

        registry_path = self.apps[app]['registry_path']

        canary = DeployStep(
            app = app,
            env = env,
            components = [],
            requires_pause_after = any([s for s in pause_after if s == 'canary'])
        )

        step = DeployStep(
            app = app,
            env = env,
            components = [],
            requires_pause_after = any([s for s in pause_after if s == env])
        )

        prev_changelog = ''

        for component in qloud_config['components']:
            repo = component['properties']['repository']
            if not registry_path in repo:
                continue
            name = component['componentName']
            old_version = repo.split(':')[1]
            old_version_tag = self.apps[app]['source_repository_tags_prefix'] + '-' + old_version[1:]
            old_revision = self.vcs.get_revision(old_version_tag)

            if old_revision == revision:
                continue

            changelog = self.vcs.get_changelog(self.apps[app]["source_code_path"], old_revision, revision)

            component = ComponentDiff(
                name = name,
                old_version = old_version,
                changelog = changelog,
                prev_cloud_env_version = qloud_status['version'],
                changelog_pretty = changelog if changelog != '' and changelog != prev_changelog else '<same>'
            )

            if changelog != prev_changelog:
                prev_changelog = changelog

            if not skip_canary and name == 'canary':
                canary.components.append(component)
            else:
                step.components.append(component)

        ret = []
        if len(canary.components):
            ret.append(canary)
        if len(step.components):
            ret.append(step)
        return ret

    def prepare_deploy_task(self, app: str, branch: str, envs: list, comment: str, skip_canary: bool, pause_after: list):
        """
        Generates a deploy plan for all `app`'s envs specified in config.
        `branch` is an ASG repository branch name.
        `new_session` can be empty if no docker image has been built yet.
        """
        if not self._check_args(app):
            return None # exception

        vcs_revision = self.vcs.get_revision('origin/' + branch)
        task_id = str(uuid.uuid1())

        plan = Task(
            id = task_id,
            app = app,
            branch = branch,
            revision = vcs_revision,
            steps = [],
            next_step = 0,
            status = TaskStatus.nobuild,
            comment = task_id + '\n' + comment if comment else ''
        )

        for env in envs:
            qloud_dump = self.qloud.get_dump(app, env) # use the dump of the stable env
            qloud_status = self.qloud.get_status(app, env) # use the dump of the stable env
            for step in self.generate_deploy_steps(qloud_dump, qloud_status, app, env, vcs_revision, comment, skip_canary, pause_after):
                plan.steps.append(step)

        return plan

    def build(self, prj, branch, resume=False):
        jenkins = Jenkins(self.config.jenkins, self.apps)
        ctx = {}
        info = None
        if resume:
            ctx = self.storage.get('build_ctx')
            if ctx is None:
                raise Exception('Nothing to resume')
        else:
            ctx = { 'prj': prj, 'branch': branch, 'state': 'init' }
        with View() as view:
            if ctx['state'] == 'init':
                ctx['state'] = 'started'
                ctx['build_num'] = jenkins.start_build(ctx['prj'], ctx['branch'])
                self.storage.set('build_ctx', ctx)
            view.display_build_starting(ctx['build_num'])
            while True:
                info = jenkins.get_build_info_raw(ctx['prj'], ctx['build_num'])
                if info is None:
                    time.sleep(1)
                    continue
                if ctx['state'] == 'finished':
                    break
                if info['building'] == False:
                    ctx['state'] = 'finished'
                    ctx['result'] = info['result']
                    self.storage.set('build_ctx', ctx)
                    break
                perc = jenkins.get_done_percent(info)
                log = jenkins.get_log(ctx['prj'], ctx['build_num'])
                if log:
                    view.display_build_info(ctx['prj'], ctx['branch'], info, log, perc)
                time.sleep(1)
            view.display_build_finished(info)
        if ctx['result'] == 'SUCCESS':
            repo = info['description'].split(' ')[0]
            stdout(repo)
            return repo
        else:
            stderr(ctx['result'])
            log = jenkins.get_log(ctx['prj'], ctx['build_num'])
            if log:
                stderr('\n'.join(log))
            else:
                stderr('Failed to get build log')
            return None

class certs:
    def __init__(self, worker):
        self.worker = worker

    def select_host(self, app, env):
        self.worker._check_args(app, env)
        app_conf = self.worker.apps[app]
        if 'certs' not in app_conf or env not in app_conf['certs']:
            raise Exception('no certs for {} {}'.format(app, env))
        host_candidates = app_conf['certs'][env]
        if len(host_candidates) == 1:
            return host_candidates[0]
        elif len(host_candidates) > 1:
            idx = query_select('select cert host', host_candidates)
            return host_candidates[idx]
        else:
            raise Exception('certs for {} {} not found'.format(app, env))

    def check_cert(self, cert):
        if cert['status'] != 'issued':
            raise Exception('bad status: {}'.format(cert['status']))
        days_to_expire = self._days_to_expire(cert)
        if days_to_expire < 30:
            raise Exception('cert expires in {} days'.format(days_to_expire))

    def select_prev_cert(self, app, env):
        prev_cert_candidates = []
        prev_cert_candidates_descrs = []
        dump = self.worker.qloud.get_dump(app, env)
        for dump_component in dump['components']:
            for secret in dump_component['secrets']:
                if secret['target'][-4:] != '.pem':
                    continue
                secret_name = self.get_secret_name_from_id(secret['objectId'])
                secret_descr = "{} ({})".format(secret_name, secret['target'])
                if secret_descr in prev_cert_candidates_descrs:
                    continue
                prev_cert_candidates.append(secret)
                prev_cert_candidates_descrs.append(secret_descr)
        if len(prev_cert_candidates) == 1:
            idx = 0
        elif len(prev_cert_candidates) > 1:
            idx = query_select('select previous version of certificate', prev_cert_candidates_descrs)
        else:
            raise Exception('secret for previous version of certificate not found')
        return prev_cert_candidates[idx]

    def confirm(self, cert, prev_cert_secret):
        prev_cert_secret_name = self.get_secret_name_from_id(prev_cert_secret['objectId'])
        question = (
            'certificate:\n'
            '  hosts:\n'
            '    {}\n'
            '  valid until: {}\n'
            '  target: {}\n'
            'previous certificate:\n'
            '  qloud secret name: {}\n'
            'ship?').format("\n    ".join(cert['hosts']), cert['end_date'], prev_cert_secret['target'], prev_cert_secret_name)
        return query_yes_no(question, default='no')

    def generate_secret_name(self, cert_name, end_date):
        formated_name = cert_name.replace('.', '-')
        formated_date = end_date[:end_date.find('+')] # Remove timezone
        formated_date = formated_date.replace(':', '-').replace('T', '-') # Format 2022:05:13T16:22:50 to 2022-05-13-16-22-50
        return "{}-{}-ssl".format(formated_name, formated_date)

    def create_secret_and_copy_grants(self, cert, secret_name, prev_cert_secret):
        cert_data = self.worker.crt.fetch_cert(cert)
        prev_cert_secret_name = self.get_secret_name_from_id(prev_cert_secret['objectId'])
        grants = self.worker.qloud.get_secret_grants(prev_cert_secret_name)['acls']
        secret_clients = self.worker.qloud.get_secret_clients(prev_cert_secret_name)
        stdout('create qloud secret {}...'.format(secret_name))
        res = self.worker.qloud.create_cert(secret_name, cert_data)
        time.sleep(15) # Wait grants
        stdout('copy grants...')
        self._set_grants(grants, secret_name)
        self._set_secret_clients(secret_clients, secret_name)

    def prepare_dump_with_new_cert(self, app, env, component, secret_name, prev_cert_secret):
        dump = self.worker.qloud.get_dump(app, env)
        all_skipped = True
        for dump_component in dump['components']:
            if component and dump_component['componentName'] != component:
                stdout('skip component {}'.format(dump_component['componentName']))
                continue
            target = self._find_target(dump_component, prev_cert_secret['objectId'])
            if target:
                stdout('add cert to {} component'.format(dump_component['componentName']))
                all_skipped = False
                backup_target = '{}.backup'.format(target)
                self._remove_old_backcup(dump_component, backup_target)
                self._move_prev_cert_secret_to_backup(dump_component, prev_cert_secret['objectId'], backup_target)
                self._add_new_cert(dump_component, secret_name, target)
            else:
                stdout('skip component {}'.format(dump_component['componentName']))
        if all_skipped:
            raise Exception('nothing to ship')
        return dump

    def _days_to_expire(self, cert):
        expire_date = datetime.datetime.fromisoformat(cert['end_date']).date()
        today = datetime.datetime.now().date()
        return (expire_date - today).days

    def get_secret_name_from_id(self, id):
        return id[7:] # Remove 'secret.' prefix

    def _set_grants(self, grants, dst_secret):
        for grant in grants:
            if grant['grantedObjectLevel'] == 'Application':
                grant['grantedObjectName'] = dst_secret
                grant['grantedObjectId'] = 'secret.{}'.format(dst_secret)
                self.worker.qloud.add_grant_to_secret(grant)

    def _set_secret_clients(self, clients, dst_secret):
        for client in clients:
            self.worker.qloud.add_client_to_secret(dst_secret, client)

    def _find_target(self, component, secret_id):
        for secret in component['secrets']:
            if secret['objectId'] == secret_id:
                return secret['target']
        return None

    def _remove_old_backcup(self, component, backup_target):
        for secret in component['secrets']:
            if secret['target'] == backup_target:
                component['secrets'].remove(secret)
                break

    def _move_prev_cert_secret_to_backup(self, component, old_secret_id, backup_target):
        for secret in component['secrets']:
            if secret['objectId'] == old_secret_id:
                secret['target'] = backup_target
                break

    def _add_new_cert(self, component, secret_name, target):
        secret_id = 'secret.{}'.format(secret_name)
        secret = { 'objectId': secret_id, 'target': target, 'used': True }
        component['secrets'].append(secret)

def parse_args():
    parser = argparse.ArgumentParser()
    subparser = parser.add_subparsers(dest='operation', help='sub-command help')

    ls_tags_parser = subparser.add_parser('ls-tags')
    ls_tags_parser.add_argument('app', help='qloud application')

    ls_apps_parser = subparser.add_parser('ls-apps')

    ls_tasks_parser = subparser.add_parser('ls-tasks')

    build_parser = subparser.add_parser('build')
    build_parser.add_argument('prj', help='project in arcadia')
    build_parser.add_argument('-b', '--branch', help='git branch to build', required=True)
    build_parser.add_argument('-r', '--resume', help='continue previous build', action='store_true')

    ship_parser = subparser.add_parser('ship')
    ship_parser.add_argument('app', help='qloud application')
    ship_parser.add_argument('-b','--branch', help='asg repository branch', required=True)
    ship_parser.add_argument('-e','--env', action='append', help='<Required> environments', required=False)
    ship_parser.add_argument('--build_number', help='<Optional> build number', required=False)
    ship_parser.add_argument('-c', '--comment', help='<Optional> comment', required=False)
    ship_parser.add_argument('--skip-canary', help='don\'t create special steps for canaries', action='store_true')
    ship_parser.add_argument('--pause-after',  nargs='+', default=[], help='list of environments and/or "canary"; after deploying these envs/canary deploy will pause')

    cancel_task_parser = subparser.add_parser('cancel-task')
    cancel_task_parser.add_argument('id', help='task ID')

    pause_task_parser = subparser.add_parser('pause-task')
    pause_task_parser.add_argument('id', help='task ID or "all" to pause all tasks')

    resume_task_parser = subparser.add_parser('resume-task')
    resume_task_parser.add_argument('id', help='task ID or "all" to resume all tasks')

    deploy_parser = subparser.add_parser('deploy')
    deploy_parser.add_argument('app', help='qloud application')
    deploy_parser.add_argument('env', help='qloud enviroment')
    deploy_parser.add_argument('version', help='version')
    deploy_parser.add_argument('-f', '--force', help='deploy without confirmation', action='store_true')

    dump_parser = subparser.add_parser('dump')
    dump_parser.add_argument('app', help='qloud application')
    dump_parser.add_argument('env', help='qloud enviroment')

    clone_components_parser = subparser.add_parser('clone-components')
    clone_components_parser.add_argument('app', help='qloud application')
    clone_components_parser.add_argument('env', help='qloud enviroment')
    clone_components_parser.add_argument('--source', help='source component name')
    clone_components_parser.add_argument('--regexp', help='target components name regexp')

    upload_parser = subparser.add_parser('upload')

    apply_app_config_parser = subparser.add_parser('apply-app-config')
    apply_app_config_parser.add_argument('app', help='qloud application')
    apply_app_config_parser.add_argument('env', help='qloud enviroment')

    status = subparser.add_parser('status')
    status.add_argument('app', help='qloud application')
    status.add_argument('env', help='qloud enviroment')

    ls_certs_parser = subparser.add_parser('ls-certs')

    find_cert_parser = subparser.add_parser('find-cert')
    find_cert_parser.add_argument('app', help='qloud application')
    find_cert_parser.add_argument('env', help='qloud enviroment')
    find_cert_parser.add_argument('--host', help='cert hostname', default=None)

    issue_cert_parser = subparser.add_parser('issue-cert')
    issue_cert_parser.add_argument('app', help='qloud application')
    issue_cert_parser.add_argument('env', help='qloud enviroment')
    issue_cert_parser.add_argument('--host', help='cert hostname', default=None)

    ship_cert_parser = subparser.add_parser('ship-cert')
    ship_cert_parser.add_argument('app', help='qloud application')
    ship_cert_parser.add_argument('env', help='qloud enviroment')
    ship_cert_parser.add_argument('component', help='qloud component', default=None, nargs='?')
    ship_cert_parser.add_argument('--host', help='cert hostname', default=None)
    ship_cert_parser.add_argument('--yes', dest='assume_yes', help='automatic yes to prompts', action='store_true')
    ship_cert_parser.set_defaults(assume_yes=False)

    return parser.parse_args()

def main():
    args = parse_args()
    worker = ci()
    certs_worker = certs(worker)

    if args.operation == 'ls-tags':
        if not worker._check_args(args.app):
            return 1
        res = worker.vcs.registry_tag_list(args.app)
        res['tags'].sort()
        for item in res['tags'][-worker.config.list_max_items:]:
            printable = worker.decode_version(item)
            stdout(item.ljust(50), printable[0].ljust(30), printable[1])

    elif args.operation == 'ls-apps':
        for a in worker.apps:
            if not 'envs' in worker.apps[a]:
                continue
            for e in worker.apps[a]['envs']:
                stdout(a, e)

    elif args.operation == 'ls-tasks':
        tasks = worker.task_storage.get_queue()
        canceled_tasks = worker.task_storage.get_canceled()
        paused_tasks = worker.task_storage.get_paused()
        for t in tasks:
            task = Task.load(t)
            task_status = str(TaskStatus(task.status))
            if task.id in paused_tasks: task_status += ' PAUSED'
            if task.id in canceled_tasks: task_status += ' CANCELED'
            stdout(task.app, task.id, task_status)

    elif args.operation == 'build':
        res = worker.build(args.prj, args.branch, args.resume)
        if res is None:
            return 1

    elif args.operation == 'ship':
        envs = args.env if args.env else worker.apps[args.app]['envs']
        for env in envs:
            worker._check_args(args.app, env)

        stdout('preparing changelogs, please wait...\n')
        task = worker.prepare_deploy_task(args.app, args.branch, envs, args.comment, args.skip_canary, args.pause_after)
        if args.build_number:
            task = task.update(build_number = args.build_number)

        stdout('task:'.ljust(12), task.id)
        stdout('app:'.ljust(12), task.app)
        stdout('branch:'.ljust(12), task.branch)
        stdout('revision:'.ljust(12), task.revision)
        stdout()
        for i in range(0, len(task.steps)):
            step = task.steps[i]
            stdout('step', i, '-', step.env)
            for c in step.components:
                stdout()
                stdout(' '*16, 'component:', c.name)
                stdout(' '*16, 'changelog:')
                stdout(' '*16, c.changelog_pretty.replace('\n', '\n' + ' '*16))
            if step.requires_pause_after:
                stdout()
                stdout('... pause ...')
                stdout()

        stdout()
        if not query_yes_no('ship?', default='no'):
            stdout('aborted')
            return 1

        queue = worker.task_storage.get_queue()
        if not queue:
            queue = list()
        queue.append(task)
        worker.task_storage.set_queue(queue)

        stdout('task created')

    elif args.operation == 'cancel-task':
        canceled_tasks = worker.task_storage.get_canceled()
        if not canceled_tasks:
            canceled_tasks = list()
        canceled_tasks.append(args.id)
        worker.task_storage.set_canceled(canceled_tasks)

    elif args.operation == 'pause-task':
        paused_tasks = worker.task_storage.get_paused()
        if args.id == 'all':
            queued_tasks = worker.task_storage.get_queue()
            paused_tasks = [Task.load(t).id for t in queued_tasks]
        else:
            paused_tasks.append(args.id)
        worker.task_storage.set_paused(paused_tasks)

    elif args.operation == 'resume-task':
        paused_tasks = worker.task_storage.get_paused()
        queued_tasks = worker.task_storage.get_queue()
        resumed_tasks = list()

        if args.id == 'all':
            resumed_tasks = paused_tasks
            paused_tasks = []
        else:
            paused_tasks = [id for id in paused_tasks if id != args.id]
            resumed_tasks.append(args.id)

        for i in range(0, len(queued_tasks)):
            task = Task.load(queued_tasks[i])
            if task.id in resumed_tasks:
                task = task.update()
                queued_tasks[i] = task
                stdout('resuming task:', task.app, task.id)

        worker.task_storage.set_paused(paused_tasks)
        worker.task_storage.set_queue(queued_tasks)

    elif args.operation == 'dump':
        data = worker.qloud.get_dump(args.app, args.env)
        stdout(json.dumps(data, indent=4))

    elif args.operation == 'clone-components':
        data = worker.qloud.get_dump(args.app, args.env)

        # find source component
        source = None
        for c in data['components']:
            if args.source == c['componentName']:
                source = c
                break
        if not source:
            raise Exception('source component', args.source, 'not found')

        # clone
        for i, c in enumerate(data['components']):
            if re.match(args.regexp, c['componentName']):
                stderr('cloning', c['componentName'])
                c_template = copy.deepcopy(source)
                c_template['componentName'] = c['componentName']
                data['components'][i] = c_template

        stdout(json.dumps(data, indent=4))

    elif args.operation == 'apply-app-config':
        data = worker.qloud.get_dump(args.app, args.env)
        app_qloud_config = worker.apps[args.app]['qloud_config'][args.env]

        for i, c in enumerate(data['components']):
            for level1 in app_qloud_config:
                if isinstance(app_qloud_config[level1], dict):
                    for level2 in app_qloud_config[level1]:
                        data['components'][i][level1][level2] = app_qloud_config[level1][level2]
                else:
                    data['components'][i][level1] = app_qloud_config[level1]

        stdout(json.dumps(data, indent=4))

    elif args.operation == 'upload':
        dump = json.loads((sys.stdin.read()))
        version = worker.qloud.run_deploy_from_dump(dump)
        stdout(worker.config.qloud['urls']['web'] + '/' + dump['objectId'].replace('.','/')
            + "?version=" + str(version))

    elif args.operation == 'status':
        stdout(json.dumps(worker.qloud.get_status(args.app, args.env), indent=4))

    elif args.operation == 'ls-certs':
        stdout (json.dumps(worker.crt.get_certs(), indent=4))

    elif args.operation == 'find-cert':
        if args.host:
            host = args.host
        else:
            host = certs_worker.select_host(args.app, args.env)
        stdout (json.dumps(worker.crt.get_cert_by_host(host), indent=4))

    elif args.operation == 'issue-cert':
        stdout('issue cert for {} {} {}'.format(args.app, args.env, args.host if args.host else ''))
        if args.host:
            host = args.host
        else:
            host = certs_worker.select_host(args.app, args.env)
        cert = worker.crt.get_cert_by_host(host)
        res = worker.crt.update_cert(cert)
        stdout(json.dumps(res, indent=4))

    elif args.operation == 'ship-cert':
        stdout('ship cert for {} {} {}'.format(args.app, args.env, args.host if args.host else ''))
        if args.host:
            host = args.host
        else:
            host = certs_worker.select_host(args.app, args.env)
        cert = worker.crt.get_cert_by_host(host)
        prev_cert_secret = certs_worker.select_prev_cert(args.app, args.env)
        secret_name = certs_worker.generate_secret_name(cert['common_name'], cert['end_date'])
        if secret_name == certs_worker.get_secret_name_from_id(prev_cert_secret['objectId']):
            stdout('cert already shipped')
            return 0
        certs_worker.check_cert(cert)
        if not args.assume_yes and not certs_worker.confirm(cert, prev_cert_secret):
            stdout('aborted')
            return 1
        if not worker.qloud.secret_exists(secret_name):
            certs_worker.create_secret_and_copy_grants(cert, secret_name, prev_cert_secret)
        dump = certs_worker.prepare_dump_with_new_cert(args.app, args.env, args.component, secret_name, prev_cert_secret)
        worker.qloud.run_deploy_from_dump(dump)
        stdout('deploy started')

    return 0

if __name__ == '__main__':
    requests.packages.urllib3.disable_warnings()
    main()
