#!/usr/bin/env python3
"""hos-delta rootfs module: Support operation on root filesystem

This module provides support for handling root filesystems.
"""

import gzip
import hashlib
import json
import os
import re
import shutil
import tarfile
import tempfile

from hos_delta import utils

TMP = "/tmp/hos-delta"

class RootFS:
    """Class for representing a root filesystem object.

    Parameters
    ----------
    rootfs_path : str
        The full filesystem mount point path.

    fingerprint_path : str
        The path to the fingerprint file associated to the the root filesystem.

    Attributes
    ----------
    __rootfs_path : str
        The path to the root filesystem mount point.
    __delta_dir : str
        The directory path where to store the delta.
    __fingerprint_path : str
        The file path to the fingerprint file.
    __entries : dict
        A dictionary where each key is a filesystem entry and each value is a
        dict which describes the respective filesystem entry.
    __approx_rootfs_size : int
        The size of the root filesystem for which this delta was generated. It
        doesn't include the fingerprint.
    __version : str
        The HanoverOS version for which this delta was generated.

    """
    def __init__(self, rootfs_path, fingerprint_path):
        self.__rootfs_path = rootfs_path
        self.__delta_dir = os.path.dirname(rootfs_path)
        self.__fingerprint_path = fingerprint_path

        # Parse the fingerprint file
        with open(self.__fingerprint_path, 'rb') as f_json:
            gzipped = f_json.read(2) == utils.GZIP_MAGIC_NUMBER
        if gzipped:
            with gzip.GzipFile(self.__fingerprint_path, 'r') as f_json:
                fingerprint_bytes = f_json.read()
            fingerprint_str = fingerprint_bytes.decode('utf-8')
            fingerprint = json.loads(fingerprint_str)
        else:
            with open(self.__fingerprint_path, 'r') as f_json:
                fingerprint = json.load(f_json)

        self.__entries = fingerprint['entries']
        self.__approx_rootfs_size = fingerprint['approx_rootfs_size']
        self.__version = fingerprint['hanoveros_version']

    @property
    def entries(self):
        """dict: filesystem entries"""
        return self.__entries

    @property
    def version(self):
        """str: root filesystem version"""
        return self.__version

    def generate_delta(self, baseline, intermediates, validate, delta_precentage_limit, log):
        """Generate an update delta.

        Parameters
        ----------
        obj : baseline
            A RootFS object describing the baseline.
        array(obj) : intermediates
            A list of RootFS objects decribing the intermediate releases in
            between baseline and target.
        bool : validate
            Selects if a validation of each possible update path is to be
            checked.
        float : delta_precentage_limit
            The delta/rootfs ratio limit above which the process bails out.
        log : object
            A logging object.

        Returns
        -------
        None

        Raises
        ------
        FileExistsError
            The delta archive already exists.
        OverflowError
            The delta precentage limit was passed.
        ValueError
            One of the validations failed when checking for blob hashes.

        """
        # The delta filename based on the fingerprint filename.
        delta_filename = os.path.basename(self.__fingerprint_path)
        delta_filename = delta_filename[:delta_filename.index('.')]
        delta_filename += '.delta-from-%s.tar.gz' % baseline.version
        delta_path = os.path.join(self.__delta_dir, delta_filename)

        if os.path.isfile(os.path.join(delta_path)):
            raise FileExistsError

        if not os.path.exists(TMP):
            os.makedirs(TMP)
        tmpdirname = tempfile.mkdtemp(dir=TMP)

        # Before doing anything, we check that the fingerprint and the root
        # filesystem are consistent for the baseline, intermediate and target.
        # We do that just in case the build system generation of fingerprint
        # introduced issues.
        for rootfs in [baseline] + intermediates + [self]:
            log.debug("Validating fingerprint consistency for %s...", rootfs.version)
            rootfs.validate()

        for entry in self.__entries:
            # We are only interested in regular files. All the other filesystem
            # entries can be regenerated directly from the fingerprint.
            if self.__entries[entry]['type'] != 'f':
                continue
            entry_hash = self.__entries[entry]['f_hash']
            if modified_at_least_once(entry_hash, entry, [baseline] + intermediates):
                with tarfile.open(self.__rootfs_path, 'r') as archive:
                    with archive.extractfile('./' + entry) as archive_member:
                        dst = os.path.join(tmpdirname, entry_hash[0:2])
                        if not os.path.exists(dst):
                            os.makedirs(dst)
                        with open(os.path.join(dst, entry_hash), 'wb') as output:
                            output.write(archive_member.read())

        # Validate delta size against the rootfs size
        delta_size = 0
        seen_ino = {}
        for root, dirs, files in os.walk(tmpdirname):
            for entry in files + dirs:
                st_obj = os.lstat(os.path.join(root, entry))
                try:
                    seen_ino[st_obj.st_ino]
                except KeyError:
                    seen_ino[st_obj.st_ino] = True
                    delta_size += st_obj.st_size
        delta_size_precentage = delta_size / self.__approx_rootfs_size * 100
        log.debug("Delta/rootfs ratio: %f%%.", delta_size_precentage)
        if delta_size_precentage > delta_precentage_limit:
            raise OverflowError

        # We validate the generated delta by making sure that the release can
        # be generated from baseline and all the intermediates.
        if validate:
            for rel in [baseline] + intermediates:
                log.debug('Delta validation %s -> %s ...', rel.version, self.version)
                for entry in self.entries:
                    if self.entries[entry]['type'] != 'f':
                        continue
                    entry_hash = self.entries[entry]['f_hash']
                    entry_delta = os.path.join(tmpdirname, entry_hash[0:2], entry_hash)
                    if not os.path.islink(entry_delta) and os.path.isfile(entry_delta):
                        # The blob is part of the delta so we validate that it has
                        # the right hash.
                        hobj = hashlib.sha1()
                        with open(entry_delta, 'rb') as entry_f:
                            data = entry_f.read()
                        hobj.update(data)
                        if hobj.hexdigest() != entry_hash:
                            raise ValueError('hash check failed: %s expected %s but %s in the delta'
                                             % (entry, entry_hash, hobj.hexdigest()))
                    else:
                        # The blob is not part of the delta so we need to check
                        # that the release has it.
                        if entry_hash != rel.entries[entry]['f_hash']:
                            raise ValueError('hash check failed: %s, %s' % (rel.version, entry))
        else:
            log.warn("Delta validation skipped as requested.")

        # Archive the delta
        clean_fingerprint_name = re.match(r'.*(fingerprint.*)',
                                          os.path.basename(self.__fingerprint_path)).group(1)
        shutil.copyfile(self.__fingerprint_path, os.path.join(tmpdirname, clean_fingerprint_name))
        with tarfile.open(delta_path, 'w:gz') as delta_archive:
            for root, dirs, files in os.walk(tmpdirname):
                for _entry in files + dirs:
                    delta_archive.add(os.path.join(root, _entry), arcname=_entry)
                del dirs[:]

        # Generation done, validation done - we can cleanup.
        shutil.rmtree(tmpdirname)

    def validate(self):
        """Validate that the fingerprint and the root filesystem are consistent.

        Parameters
        ----------
        None

        Returns
        -------
        None

        Raises
        ------
        ValueError
            An unknown filesystem entry found.
        AssertionError
            Validation failed.

        """
        not_found = []
        for entry in self.entries:
            not_found.append(entry)

        # rootfs_path can be a directory where the rootfs is unpacked (for
        # example at runtime) or a tar archive.
        if os.path.isfile(self.__rootfs_path) and tarfile.is_tarfile(self.__rootfs_path):
            with tarfile.open(self.__rootfs_path) as tar:
                for entry_tar in tar.getmembers():
                    entry_relpath = os.path.relpath(entry_tar.name, './')
                    if entry_relpath in ['fingerprint.json', 'fingerprint.json.gz', '.',
                                         'lost+found']:
                        continue
                    if  entry_tar.issym():
                        entry_type = 'l'
                    elif entry_tar.isfile() or entry_tar.islnk():
                        entry_type = 'f'
                    elif entry_tar.isdir():
                        entry_type = 'd'
                    else:
                        raise ValueError

                    perm = oct(entry_tar.mode)[-3:]
                    uid = entry_tar.uid
                    gid = entry_tar.gid

                    l_target = ""
                    if entry_tar.issym():
                        l_target = entry_tar.linkname

                    f_hash = ""
                    if entry_tar.isreg() or entry_tar.islnk():
                        __entry_tar = entry_tar
                        if entry_tar.islnk():
                            __entry_tar = entry_tar.linkname
                        with tar.extractfile(__entry_tar) as archive_member:
                            data = archive_member.read()
                        hobj = hashlib.sha1()
                        hobj.update(data)
                        f_hash = hobj.hexdigest()

                    if entry_relpath not in self.entries or \
                        self.entries[entry_relpath]['type'] != entry_type or \
                        self.entries[entry_relpath]['perm'] != perm or \
                        self.entries[entry_relpath]['uid'] != uid or \
                        self.entries[entry_relpath]['gid'] != gid or \
                        self.entries[entry_relpath]['l_target'] != l_target or \
                            self.entries[entry_relpath]['f_hash'] != f_hash:
                        raise AssertionError('validation failed for %s' % entry_relpath)

                    not_found.remove(entry_relpath)
        else:
            for root, dirs, files in os.walk(self.__rootfs_path):
                for entry in files + dirs:
                    if entry in ['fingerprint.json', 'fingerprint.json.gz',
                                 'lost+found']:
                        continue

                    if os.path.islink(os.path.join(root, entry)):
                        entry_type = 'l'
                    elif os.path.isfile(os.path.join(root, entry)):
                        entry_type = 'f'
                    elif os.path.isdir(os.path.join(root, entry)):
                        entry_type = 'd'
                    else:
                        raise ValueError

                    perm = oct(os.lstat(os.path.join(root, entry)).st_mode)[-3:]
                    uid = os.lstat(os.path.join(root, entry)).st_uid
                    gid = os.lstat(os.path.join(root, entry)).st_gid

                    l_target = ""
                    if os.path.islink(os.path.join(root, entry)):
                        l_target = os.readlink(os.path.join(root, entry))

                    f_hash = ""
                    if not os.path.islink(os.path.join(root, entry)) and \
                        os.path.isfile(os.path.join(root, entry)):
                        hobj = hashlib.sha1()
                        with open(os.path.join(root, entry), 'rb') as fobj:
                            data = fobj.read()
                        hobj.update(data)
                        f_hash = hobj.hexdigest()

                    entry_relpath = os.path.relpath(os.path.join(root, entry), self.__rootfs_path)

                    if entry_relpath not in self.entries or \
                        self.entries[entry_relpath]['type'] != entry_type or \
                        self.entries[entry_relpath]['perm'] != perm or \
                        self.entries[entry_relpath]['uid'] != uid or \
                        self.entries[entry_relpath]['gid'] != gid or \
                        self.entries[entry_relpath]['l_target'] != l_target or \
                        self.entries[entry_relpath]['f_hash'] != f_hash:
                        raise AssertionError('validation failed for %s' % entry)

                    not_found.remove(entry_relpath)

        if len(not_found) > 0:
            raise AssertionError('active root filesystem incomplete - %s' % not_found)

def modified_at_least_once(target_hash, entry, releases):
    """Verify if an entry is modifed at least once in a list of releases.

    Parameters
    ----------
    str : target_hash
        The expected hash of the entry.
    str : entry
        The entry to check for modifications.
    list(RootFs) : release
        A list of RootFs objects in which to check for modifications of the
        entry.

    Returns
    -------
    bool
        Returns if the entry was modified at least one in the releases.

    Raises
    ------
    FileExistsError
        The delta archive already exists.
    OverflowError
        The delta precentage limit was passed.
    ValueError
        One of the validations failed when checking for blob hashes.

    """
    for rel in releases:
        try:
            if rel.entries[entry]['f_hash'] == target_hash:
                continue
            return True
        except KeyError:
            return True
    return False
