#!/usr/bin/env python3

import os
import re
import subprocess
import glob
import json

import perfact.generic

import helpers

env = helpers.create_env()

build_path = env.build_path
mount_path = env.mount_path
config = helpers.Namespace(
    **perfact.generic.load_config(
        os.path.join(env.config_path, 'rollout_conf.py')
    )
)

_run = helpers.run
_sudo = helpers.sudo
_chroot_cmds = helpers.chroot_cmds


RULES_SUFFIX = '.in.py'


def configs():
    '''
    Walk through configuration templates and adjust configs in target system
    accordingly.
    '''
    templates = os.path.join(env.config_path, 'templates')

    def _walk_templates():
        '''
        Walk through templates directory, yielding tuples of (folder, filename)
        for configuration or source files, excluding any pycache or swap files.
        '''
        for folder, dirs, files in os.walk(templates):
            if '__pycache__' in folder:
                continue
            folder = folder[len(templates)+1:]
            for filename in files:
                if filename.startswith('.') and filename.endswith('.swp'):
                    continue
                yield (folder, filename)

    for folder, filename in _walk_templates():
        print('Processing config template: ', os.path.join(folder, filename))
        # check if we have a ruleset to modify the file in-place or a complete
        # file that is to substitute the target file
        modify = (filename.endswith(RULES_SUFFIX))
        if modify:
            module = perfact.generic.load_config(
                os.path.join(templates, folder, filename)
            )
            filename = filename[:-len(RULES_SUFFIX)]
            src_dir = mount_path
        else:
            src_dir = templates

        def _fname(prefix):
            """Returns filename, but if the module defines an overriding
            function, call that and return its result."""
            func = prefix + '_filename'
            if modify and func in module:
                return module[func](config)
            return filename

        # read source file either from template or from target
        src_filename = os.path.join(src_dir, folder, _fname('src'))
        tgt_filename = os.path.join(mount_path, folder, _fname('tgt'))

        content = _run(
            ['sudo', 'cat', src_filename],
            text=True,
            capture_output=True,
            check=False,
        ).stdout

        # signal objects that are passed to generate methods. If the generate
        # method returns the same object (not only an equal one), the file in
        # question is removed or renamed to '.disabled'
        signals = {
            'remove': {},
            'rename': {},
        }
        if not modify:
            # Simply replace variables
            content = content.format(**config)

        if modify and 'generate' in module:
            # Use a generating function
            content = module['generate'](content, config, signals)

        if modify and isinstance(content, str) and 'rules' in module:
            # Use a ruleset for replacements.
            # Rules should be a list of tuples where each tuple contains a
            # regular expression and a replacement line. If a line in content
            # matches one of the expressions, it is replaced by the replacement
            # line. Variables in the replacement line are also substituted
            # according to the values in config.

            # compile regex and substitute variables in replacement
            rules = [(re.compile(item[0]), item[1].format(**config))
                     for item in module['rules']]
            result = []
            for line in content.split('\n'):
                for rule, replacement in rules:
                    if rule.search(line) is not None:
                        result.append(replacement)
                        break
                else:  # no match, keep line
                    result.append(line)
            content = '\n'.join(result)

        # create target folder if it does not exist
        tgt_folder = os.path.join(mount_path, folder)
        _sudo(['mkdir', '-p', tgt_folder])

        # write result to target file. Remove or rename instead if
        # special content is returned
        if content is signals['remove']:
            _sudo(['rm', '-f', tgt_filename])
        elif content is signals['rename']:
            _run(
                ['sudo', 'mv', tgt_filename, tgt_filename + '.disabled'],
                check=False,
            )
        else:
            _run(
                ['sudo', 'tee', tgt_filename],
                input=content,
                text=True,
                stdout=subprocess.DEVNULL,
            )


def reset_git_repos():
    '''
    Re-initialize git-repositories on the target system. Requires chroot.
    '''
    if not config.get('reset_git_repos', True):
        print('Skipping reset of git histories as requested in config.')
        return

    repos_perfact = [
        '/opt/perfact/dbutils-pgrepo',
    ]
    repos = [
        '/etc',
        '/var/lib/zope2.13',
        '/var/lib/zope4',
    ] + repos_perfact

    for repo in repos:
        path = os.path.join(mount_path, repo[1:], '.git')
        if not os.path.exists(path):
            print('Path of git repo does not exist', repo)
            continue
        print('Resetting git repo', repo)
        _sudo(['rm', '-r', path])

        cmd_prefix = ['sudo', 'chroot', mount_path]
        if repo in repos_perfact:
            cmd_prefix.extend(['sudo', '-u', 'perfact'])
        cmd_prefix.extend(['git', '-C', repo])

        cmds = [
            ['init'],
            ['config', 'gc.auto', '0'],
            ['add', '.'],
            ['commit', '-m', 'Initial commit'],
            ['gc'],
            ['config', 'gc.auto', '1'],
        ]
        for cmd in cmds:
            _run(cmd_prefix + cmd, stdout=subprocess.DEVNULL)


def ssh_keys():
    '''
    Renew keys. This includes:
    * SSH server key
    * SSH private keys of root, phonehome, zope and mpr (which is a second key
      belonging to zope)
    This will also output the public key for phonehome to be entered on the
    perfact phonehome server.
    '''

    keys = glob.glob(os.path.join(mount_path, 'etc/ssh/ssh_host_*'))
    if len(keys):
        print("Removing", ',\n    '.join(keys))
        _sudo(['rm'] + keys)

    phonehome_pubkeypath = 'home/zope/.ssh/id_rsa.pub'

    # check if the new version of phonehome is installed
    new_phonehome = _chroot_cmds(
        ['test', '-f', '/etc/perfact/phonehome/id_rsa'],
        check=False
    )[0].returncode == 0

    commands = [
        ['dpkg-reconfigure', 'openssh-server'],

        ['rm', '-f', '/root/.ssh/id_rsa', '/root/.ssh/id_rsa.pub',
         '/home/zope/.ssh/id_rsa', '/home/zope/.ssh/id_rsa.pub',
         ],

        ['ssh-keygen', '-f' '/root/.ssh/id_rsa', '-N', '',
         '-C', 'root@{hostname}'.format(**config)],

        ['sudo', '-u', 'zope',
         'ssh-keygen', '-N', '', '-C', 'zope@{hostname}'.format(**config),
         '-f', '/home/zope/.ssh/id_rsa', ],
    ]

    if config.get('mpa_mpr_maintenance_keygen'):
        commands.append(
            ['rm', '-f',
             '/home/zope/.ssh/mpr-id_rsa',
             '/home/zope/.ssh/mpr-id_rsa.pub',
             '/home/mpaproxy/.ssh/mpr-id_rsa.pub',
             ]
        )
        commands.append(
            ['sudo', '-u', 'zope',
             'ssh-keygen', '-N', '', '-C', 'zope@{hostname}'.format(**config),
             '-f', '/home/zope/.ssh/mpr-id_rsa', ],
        )
        # The key needs to be accessible in 'proxy' mode as well
        # (e.g. on dial-in nodes)
        commands.append(
            ['sudo',
             'cp',
             '/home/zope/.ssh/mpr-id_rsa',
             '/home/mpaproxy/.ssh/mpr-id_rsa',
            ],
        )
        commands.append(
            ['sudo',
             'chown', 'mpaproxy',
             '/home/mpaproxy/.ssh/mpr-id_rsa',
            ],
        )


    if new_phonehome:
        # regenerate phonehome key
        commands[1].append('/etc/perfact/phonehome/id_rsa')
        commands.append(
            ['ssh-keygen', '-N', '', '-C', 'zope@{hostname}'.format(**config),
             '-b', '4096', '-f', '/etc/perfact/phonehome/id_rsa', ],
        )
        commands.append(
            ['sudo', 'chgrp', 'pfphonehome', '/etc/perfact/phonehome/id_rsa',
             '/etc/perfact/phonehome/id_rsa.pub', ],
        )
        # adjust extracted keypath
        phonehome_pubkeypath = 'etc/perfact/phonehome/id_rsa.pub'

    _chroot_cmds(*commands)

    # include pubkey of root in authorized_keys of root (allow loopback),
    # and include pubkeys of zope and root in authorized_keys of mpaproxy.
    pubkey = {}
    for path in ['root', 'home/zope']:
        pubkey[path] = subprocess.run(
            ['sudo', 'cat', os.path.join(mount_path, path, '.ssh/id_rsa.pub')],
            capture_output=True,
            text=True,
            check=True,
        ).stdout
    subprocess.run(
        ['sudo', 'tee', os.path.join(mount_path, 'root/.ssh/authorized_keys')],
        input='from="127.0.0.1" ' + pubkey['root'],
        text=True,
        stdout=subprocess.DEVNULL,
    )
    subprocess.run(
        ['sudo', 'tee', os.path.join(mount_path,
                                     'home/mpaproxy/.ssh/authorized_keys')],
        input='\n'.join([
            'from="127.0.0.1" {}'.format(key)
            for key in pubkey.values()
        ]),
        text=True,
        stdout=subprocess.DEVNULL,
    )

    # include server key into known_hosts
    with open(os.path.join(mount_path, 'etc/ssh/ssh_host_ecdsa_key.pub')) as f:
        pubkey = ' '.join(f.read().strip().split()[:2])
        print(pubkey)
    known_hosts = os.path.join(mount_path, 'root/.ssh/known_hosts')
    subprocess.run(
        ['sudo', 'tee', known_hosts],
        input='localhost ' + pubkey,
        text=True,
        stdout=subprocess.DEVNULL,
        check=True,
    )
    # hash result
    subprocess.run(
        ['sudo', 'ssh-keygen', '-H', '-f', known_hosts],
        check=True,
    )
    # XXX TODO: There may be other known_hosts files missing here...

    # write pubkey of zope into build/phonehome.pub so it can be transferred to
    # the perfact phonehome server
    with open(os.path.join(mount_path, phonehome_pubkeypath)) as f:
        pubkey = f.read()
    with open(os.path.join(build_path, 'phonehome.pub'), 'w') as f:
        print(pubkey, file=f)


def services():
    '''
    Disable services by default according to the configuration.
    '''
    if not config['disable_services']:
        return
    subprocess.run(
        [
            'sudo', 'chroot', mount_path,
            'systemctl', 'disable'
        ] + config['disable_services'],
        check=True,
    )


def packages_purge():
    if not config.get('purge_packages'):
        return
    proc = subprocess.Popen(
        [
            'sudo', 'chroot', mount_path,
            'apt-get', 'purge', '-y'
        ] + config['purge_packages'],
        stderr=subprocess.PIPE,
    )
    with open(os.path.join(build_path, 'apt-errors.log'), 'w') as f:
        for line in proc.stderr:
            print(line)
            print(line, file=f)
    proc.wait()


def passwords():
    '''
    Generate random passwords and write them to build/passwords
    '''
    if not config['randomize_passwords']:
        return

    outfile = open(os.path.join(build_path, 'passwords'), 'w')
    print(config['systemname'], file=outfile)

    # Change passwords for Ubuntu
    # Generate dict {username: password, ...}
    pw = {
        user: perfact.generic.generate_random_string(
            length=12,
            valid_chars=(
                'abcdefghijklmnopqrstuvwx'
                'ABCDEFGHIJKLMNOPQRSTUVWX'
                '0123456789'
            ),
        )
        for user in ['root', 'maint']
    }

    # Set the passwords
    subprocess.run(
        ['sudo', 'chroot', mount_path, 'chpasswd'],
        input='\n'.join([
            '{}:{}'.format(user, pw[user])
            for user in ['root', 'maint']
        ]),
        check=True,
        universal_newlines=True,
    )

    # Write Ubuntu passwords to file
    for user in ['root', 'maint']:
        print('{}@ubuntu: {}'.format(user, pw[user]), file=outfile)

    # call script inside chroot to change Zope passwords
    interpreter = '/usr/share/perfact/zope{}/bin/python'.format(
        config.get('zope_version', '2,13')
    )
    proc = subprocess.run(
        ['sudo', 'chroot', mount_path, 'sudo', '-u', 'zope',
         interpreter,
         '/opt/perfact/migration/src/inchroot/zope-modify-passwd',
         ],
        stdout=subprocess.PIPE,
        check=True,
        universal_newlines=True,
    )
    data = json.loads(proc.stdout)
    pw = data['passwords']

    print('perfact@zope: {}'.format(pw['perfact']), file=outfile)

    outfile.close()

    netrc_files = [
        ('assign', 'assign_worker'),
        ('cron', 'cron'),
        ('cachetrigger', 'cachetrigger'),
    ]

    for fname, user in netrc_files:
        if user not in pw:
            continue
        _sudo(
            ['tee', os.path.join(mount_path, 'root/.netrc-{}'.format(fname))],
            stdout=subprocess.DEVNULL,
            universal_newlines=True,
            input='machine localhost login {} password {}'.format(
                user, pw[user]
            ),
        )

    metafile = os.path.join(
        mount_path,
        'opt/perfact/dbutils-zoperepo/__root__/acl_users/__meta__'
    )
    _sudo(
        ['tee', metafile],
        stdout=subprocess.DEVNULL,
        universal_newlines=True,
        input=data['metafile'],
    )
    _chroot_cmds(
        ['sudo', '-u', 'perfact', 'git', '-C',
         '/opt/perfact/dbutils-zoperepo', 'commit', '-a', '-m',
         'Update passwords'],
        stdout=subprocess.DEVNULL,
        # Newer templates have this in .gitignore, so no commit is done
        check=False,
    )


def ssl_cert():
    '''
    Call script in chroot to regenerate the HAProxy SSL certificate.
    '''
    _chroot_cmds(
        ['bash', '-c', 'chown zope:zope /etc/haproxy/ssl/*'],
        ['chown', 'root:haproxy', '/etc/haproxy/ssl'],
        ['chmod', 'g+rwx', '/etc/haproxy/ssl'],
        ['chmod', 'o-rwx', '/etc/haproxy/ssl'],
        ['sudo', '-u', 'zope',
         '/opt/perfact/migration/src/inchroot/haproxy-renew-cert',
         config['cert_subject']
        ],
    )


def postfix_postmap(files=None):
    '''
    Rebuild the mapping files for postfix (using postmap)
    '''
    if files is None:
        # default value
        files = ['/etc/postfix/transport_closed']
    if not isinstance(files, list):
        files = [files]
    for path in files:
        subprocess.run(
            ['sudo', 'chroot', mount_path, 'sudo',
             '/usr/sbin/postmap', path
             ],
            check=True,
        )
