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

import os
import time
import gnupg
import errno
import shutil
import getpass
import tarfile
import subprocess

from glob import glob
from functools import wraps
from datetime import datetime
from contextlib import closing


class CallingUserError(Exception):
    """Exception class for throwing CallingUserError exceptions"""
    pass


class DecryptionError(Exception):
    """Exception class for throwing DecryptionError exceptions"""
    pass


class Backup(object):
    """Parent class for backup operations"""

    def __init__(self, *args, **kwargs):
        self.name = args[0]
        self.config = kwargs.get('config', {})
        self.workdir = kwargs.get('workdir')
        self.user = self.config.get('user', 'root')
        self.encrypt = None
        self.files = None
        self.filename = None
        self.filename_abs = None
        self.mysql_databases = None
        self.filename_prefix = 'backup-{0}'.format(self.name)
        self.rotation_num = self.config.get('rotate', 3)
        self.encrypt = self.config.get('encrypt', False)
        if self.encrypt:
            self.gpg = gnupg.GPG()
            self.gpg_key_id = self.config['gpg_key_id']
        if 'files' in self.config:
            self.files = self.config['files']
        if 'mysql_databases' in self.config:
            self.mysql_databases = self.config['mysql_databases']
            self.mysql_user = self.config['mysql_user']
            self.mysql_password = self.config['mysql_password']
        self.ldap_backup = self.config.get('ldap_backup')
        if self.ldap_backup:
            self.ldap_datadir = self.ldap_backup.get('datadir', '/var/lib/ldap')
            self.ldap_system_user = self.ldap_backup.get('system_user',
                                                         'openldap')
            self.ldap_system_group = self.ldap_backup.get('system_group',
                                                          'openldap')
        self.devnull = open(os.devnull, 'w')

    def _needs_configured_user(original_function):
        """Decorator method to ensure that current user is the configured one"""
        @wraps(original_function)
        def new_function(self, *args, **kwargs):
            if getpass.getuser() != self.user:
                message = 'User "{0}" is needed to perform this action'
                raise CallingUserError(message.format(self.user))
            return original_function(self, *args, **kwargs)
        return new_function

    @staticmethod
    def _lookahead(iterable):
        """Indicator for the last element in a iterable

        Go through all values from the given iterable and enhance
        the informations if there are more values to come after the
        current one (True), or if it is the last value (False).
        """
        iterator = iter(iterable)
        last = next(iterator)
        for value in iterator:
            yield last, True
            last = value
        yield last, False

    def download(self):
        """Dummy method, will be overwritten by child classes"""
        raise NotImplementedError  # pragma: no cover

    def upload(self):
        """Will be overwritten by child class method"""
        raise NotImplementedError  # pragma: no cover

    @staticmethod
    def create_directory(directory):
        """Create the given directory, if not exist, else do nothing"""
        if not os.path.exists(directory):
            os.makedirs(directory)

    @staticmethod
    def rmfile(filename):
        """Delete the given file"""
        try:
            for file_to_remove in glob(filename):
                os.remove(file_to_remove)
        except OSError as error:
            if error.errno != errno.ENOENT:
                raise

    def copy_files(self):
        """Copy the entries from file list to its temporary target"""
        if not self.files:
            return
        for entry in self.files:
            dst = '{0}/files/{1}'.format(self.workdir, os.path.dirname(entry))
            for member in glob(entry):
                if os.path.isfile(member):
                    if not os.path.exists(dst):
                        self.create_directory(dst)
                    shutil.copy(member, dst)
                elif os.path.isdir(member):
                    move_dst = '{0}/{1}'.format(dst, os.path.basename(member))
                    shutil.copytree(member, move_dst, symlinks=True)

    @_needs_configured_user
    def create(self):
        """Collect data, encrypt it and upload the result"""
        self.copy_files()
        self.dump_database()
        self.dump_ldap()
        self.tar_workdir()
        self.encrypt_archive()
        self.upload()

    def decrypt_archive(self):
        """Decrypt the gpg file"""
        if not self.encrypt:
            return
        with open('{0}.gpg'.format(self.filename_abs), 'rb') as gpg_file:
            status = self.gpg.decrypt_file(gpg_file, self.gpg_key_id,
                                           output=self.filename_abs)
        if not status.ok:
            raise DecryptionError('Unable to decrypt backup file')

    def dump_database(self):
        """Create a sql dump of given mysql databases"""
        if self.mysql_databases is not None:
            self.create_directory('{0}/mysql'.format(self.workdir))
            for database in self.mysql_databases:
                cmd = [
                    'mysqldump',
                    '-u', self.mysql_user,
                    '-p{0}'.format(self.mysql_password),
                    '--result-file',
                    '{0}/mysql/{1}.sql'.format(self.workdir, database),
                    database
                ]
                subprocess.check_call(cmd, stdout=self.devnull,
                                      stderr=subprocess.STDOUT)

    def dump_ldap(self):
        """Create a complete ldap dump"""
        if self.ldap_backup:
            self.create_directory('{0}/ldap'.format(self.workdir))
            cmd = [
                'slapcat', '-n1', '-l',
                '{0}/ldap/dump.ldif'.format(self.workdir)
            ]
            subprocess.check_call(cmd, stdout=self.devnull,
                                  stderr=subprocess.STDOUT)

    def encrypt_archive(self):
        """Encrypt the created tarball with gpg"""
        if not self.encrypt:
            return
        gpg_target_file = "{0}.gpg".format(self.filename_abs)
        with open(self.filename_abs, 'rb') as tared_file:
            crypt = self.gpg.encrypt_file(tared_file,
                                          self.gpg_key_id,
                                          always_trust=True,
                                          output=gpg_target_file)
            if not crypt.ok:
                raise NameError('GPG encryption was not successfull!')

    @_needs_configured_user
    def restore(self):
        """Call all necessary methods to do an backup restore"""
        if self.download():
            print('Restoring backup: {0}'.format(self.name))
            self.decrypt_archive()
            self.untar_backup_file()
            self.restore_files()
            self.restore_database()
            self.restore_ldap()

    def restore_database(self):
        """Import the given sql dumps into local mysql"""
        backup_path = '{0}/mysql'.format(self.workdir)
        if os.path.isdir(backup_path):
            cmd = 'mysql -u {0} -p{1} {2} < {3}/mysql/{4}'
            for dump_file in os.listdir(backup_path):
                formatted_cmd = cmd.format(self.mysql_user,
                                           self.mysql_password,
                                           '.'.join(dump_file.split('.')[:-1]),
                                           self.workdir,
                                           dump_file)
                subprocess.check_call(formatted_cmd, shell=True)

    def restore_ldap(self):
        """Wipe ldap and import the dump from backup"""
        if os.path.isdir('{0}/ldap'.format(self.workdir)):
            subprocess.check_call('service slapd stop', shell=True)
            self.rmfile('{0}/*'.format(self.ldap_datadir))
            cmds = [
                'slapadd -l {0}/ldap/dump.ldif'.format(self.workdir),
                ('echo "set_flags DB_LOG_AUTOREMOVE" ' +
                 '>> {0}/DB_CONFIG'.format(self.ldap_datadir)),
                'chown -R {0}:{1} {2}'.format(self.ldap_system_user,
                                              self.ldap_system_group,
                                              self.ldap_datadir)
            ]
            for cmd in cmds:
                subprocess.check_call(cmd, shell=True)
            subprocess.check_call('service slapd start', shell=True)

    def restore_files(self):
        """Copy the file tree from temporary space to the system"""
        if os.path.isdir('{0}/files'.format(self.workdir)):
            cmd = 'rsync -a {0}/files/ /'.format(self.workdir)
            subprocess.check_call(cmd, shell=True)

    def tar_workdir(self):
        """Tar the prepared working directory"""
        timestamp = time.time()
        formatted_timestamp = datetime.fromtimestamp(timestamp)
        formatted_timestamp = formatted_timestamp.strftime('%Y%m%d%H%M%S')
        filename = '{0}-{1}.tar.gz'
        self.filename = filename.format(self.filename_prefix,
                                        formatted_timestamp)
        self.filename_abs = '{0}/{1}'.format(self.workdir, self.filename)
        # Python 2.6 has no support for the context manager protocol
        with closing(tarfile.open(self.filename_abs, 'w:gz')) as tar:
            tar.add(self.workdir, arcname='.')

    def untar_backup_file(self):
        """Extract the backup tarball"""
        # Python 2.6 has no support for the context manager protocol
        with closing(tarfile.open(self.filename_abs)) as tar:
            tar.extractall(path=self.workdir)

    def check_encryption_by_name(self, name):
        """Check if the given name seems to be a encrypted backup"""
        if name.split('.')[-1] == 'gpg':
            self.encrypt = True
            self.filename = '.'.join(name.split('.')[:-1])
        else:
            self.encrypt = False
            self.filename = name
        self.filename_abs = '{0}/{1}'.format(self.workdir, self.filename)
