#
# Installation tasks
#
# Copyright (C) 2019 Red Hat, Inc.
#
# This copyrighted material is made available to anyone wishing to use,
# modify, copy, or redistribute it subject to the terms and conditions of
# the GNU General Public License v.2, or (at your option) any later version.
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY expressed or implied, including the implied warranties of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General
# Public License for more details.  You should have received a copy of the
# GNU General Public License along with this program; if not, write to the
# Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
# 02110-1301, USA.  Any Red Hat trademarks that are incorporated in the
# source code or documentation are not subject to the GNU General Public
# License and may only be used or replicated with the express permission of
# Red Hat, Inc.
#
import os
import parted

from datetime import timedelta
from time import sleep

from blivet import callbacks as blivet_callbacks, util as blivet_util, arch
from blivet.errors import FSResizeError, FormatResizeError, StorageError
from blivet.util import get_current_entropy

from pyanaconda.anaconda_loggers import get_module_logger
from pyanaconda.core.i18n import _
from pyanaconda.core.configuration.anaconda import conf
from pyanaconda.modules.common.constants.objects import ISCSI, FCOE, ZFCP
from pyanaconda.modules.common.constants.services import STORAGE
from pyanaconda.modules.common.errors.installation import StorageInstallationError
from pyanaconda.modules.common.task import Task

import gi
gi.require_version("BlockDev", "2.0")
from gi.repository import BlockDev as blockdev

log = get_module_logger(__name__)


__all__ = ["CreateStorageLayoutTask", "MountFilesystemsTask", "WriteConfigurationTask"]


class CreateStorageLayoutTask(Task):
    """Installation task for execution of the storage configuration."""

    def __init__(self, storage, entropy_timeout=600):
        """Create a new task.

        :param storage: the storage model
        :param entropy_timeout: a number of seconds for entropy gathering
        """
        super().__init__()
        self._storage = storage
        self._entropy_timeout = entropy_timeout

    @property
    def name(self):
        return "Create storage layout"

    def run(self):
        """Do the execution.

        :raise: StorageInstallationError if the execution fails
        """
        if conf.target.is_directory:
            log.debug("Don't create the storage layout during "
                      "the installation to a directory.")
            return

        register = blivet_callbacks.create_new_callbacks_register(
            create_format_pre=self._report_message,
            resize_format_pre=self._report_message,
            wait_for_entropy=self._wait_for_entropy
        )

        try:
            self._turn_on_filesystems(
                self._storage,
                callbacks=register
            )
        except (FSResizeError, FormatResizeError) as e:
            log.exception("Failed to resize device %s: %s", e.details, str(e))
            message = _("An error occurred while resizing the device {}: {}").format(
                e.details, str(e)
            )
            raise StorageInstallationError(message) from None
        except StorageError as e:
            log.exception("Failed to create storage layout: %s", str(e))
            raise StorageInstallationError(str(e)) from None

    def _report_message(self, data):
        """Report a Blivet message.

        :param data: Blivet's callback data
        """
        self.report_progress(data.msg)

    def _wait_for_entropy(self, data):
        """Wait for entropy.

        :param data: Blivet's callback data
        :return: True if we are out of time, otherwise False
        """
        log.debug(data.msg)
        required_entropy = data.min_entropy
        total_time = self._entropy_timeout
        current_time = 0

        while True:
            # Report the current status.
            current_entropy = get_current_entropy()
            current_percents = min(int(current_entropy / required_entropy * 100), 100)
            remaining_time = max(total_time - current_time, 0)
            self._report_entropy_message(current_percents, remaining_time)

            sleep(5)
            current_time += 5

            # Enough entropy gathered.
            if current_percents == 100:
                return False

            # Out of time.
            if remaining_time == 0:
                return True

    def _report_entropy_message(self, percents, time):
        """Report an entropy message.

        :param percents: the percentage of gathered entropy
        :param time: a number of seconds of remaining time
        """
        if percents == 100:
            self.report_progress(_("Gathering entropy 100%"))
            return

        if time == 0:
            self.report_progress(_("Gathering entropy (time ran out)"))
            return

        message = _("Gathering entropy {percents}% (remaining time {time})").format(
            percents=percents,
            time=timedelta(seconds=time)
        )

        self.report_progress(message)

    def _turn_on_filesystems(self, storage, callbacks=None):
        """Perform installer-specific execution of storage configuration.

        :param storage: the storage object
        :type storage: an instance of InstallerStorage
        :param callbacks: callbacks to be invoked when actions are executed
        :type callbacks: return value of the :func:`blivet.callbacks.create_new_callbacks_register`
        """
        storage.devicetree.teardown_all()
        storage.do_it(callbacks)
        self._setup_bootable_devices(storage)
        storage.dump_state("final")
        storage.turn_on_swap()

    def _setup_bootable_devices(self, storage):
        """Set up the bootable devices.

        Mark the boot devices as bootable.

        :param storage: an instance of the storage
        """
        if storage.bootloader.skip_bootloader:
            return

        if storage.bootloader.stage2_bootable:
            boot = storage.boot_device
        else:
            boot = storage.bootloader.stage1_device

        if boot.type == "mdarray":
            boot_devs = boot.parents
        else:
            boot_devs = [boot]

        for dev in boot_devs:
            if not hasattr(dev, "bootable"):
                log.info("Skipping %s, not bootable", dev)
                continue

            # Dos labels can only have one partition marked as active
            # and unmarking ie the windows partition is not a good idea
            skip = False
            if dev.disk.format.parted_disk.type == "msdos":
                for p in dev.disk.format.parted_disk.partitions:
                    if p.type == parted.PARTITION_NORMAL and \
                            p.getFlag(parted.PARTITION_BOOT):
                        skip = True
                        break

            # GPT labeled disks should only have bootable set on the
            # EFI system partition (parted sets the EFI System GUID on
            # GPT partitions with the boot flag)
            if dev.disk.format.label_type == "gpt" and \
                    dev.format.type not in ["efi", "macefi"]:
                skip = True

            if skip:
                log.info("Skipping %s", dev.name)
                continue

            # hfs+ partitions on gpt can't be marked bootable via parted
            if dev.disk.format.parted_disk.type != "gpt" or \
                    dev.format.type not in ["hfs+", "macefi"]:
                log.info("setting boot flag on %s", dev.name)
                dev.bootable = True

            # Set the boot partition's name on disk labels that support it
            if dev.parted_partition.disk.supportsFeature(parted.DISK_TYPE_PARTITION_NAME):
                ped_partition = dev.parted_partition.getPedPartition()
                ped_partition.set_name(dev.format.name)
                log.info("Setting label on %s to '%s'", dev, dev.format.name)

            dev.disk.setup()
            dev.disk.format.commit_to_disk()


class MountFilesystemsTask(Task):
    """Installation task for mounting the filesystems."""

    def __init__(self, storage):
        """Create a new task."""
        super().__init__()
        self._storage = storage

    @property
    def name(self):
        return "Mount filesystems"

    def run(self):
        """Mount the filesystems."""
        self._storage.mount_filesystems()


class WriteConfigurationTask(Task):
    """Installation task for writing out the storage configuration."""

    def __init__(self, storage):
        """Create a new task."""
        super().__init__()
        self._storage = storage

    @property
    def name(self):
        return "Write the storage configuration"

    def run(self):
        """Mount the filesystems."""
        if conf.target.is_directory:
            log.debug("Don't write the storage configuration "
                      "during the installation to a directory.")
            return

        self._write_storage_configuration(self._storage)

    def _write_storage_configuration(self, storage, sysroot=None):
        """Write the storage configuration to sysroot.

        :param storage: the storage object
        :param sysroot: a path to the target OS installation
        """
        if sysroot is None:
            sysroot = conf.target.system_root

        if not os.path.isdir("%s/etc" % sysroot):
            os.mkdir("%s/etc" % sysroot)

        self._write_escrow_packets(storage, sysroot)

        storage.make_mtab()
        storage.fsset.write()

        iscsi_proxy = STORAGE.get_proxy(ISCSI)
        iscsi_proxy.WriteConfiguration()

        fcoe_proxy = STORAGE.get_proxy(FCOE)
        fcoe_proxy.WriteConfiguration()

        zfcp_proxy = STORAGE.get_proxy(ZFCP)
        zfcp_proxy.WriteConfiguration()

        self._write_dasd_conf(storage, sysroot)

    def _write_escrow_packets(self, storage, sysroot):
        """Write the escrow packets.

        :param storage: the storage object
        :type storage: an instance of InstallerStorage
        :param sysroot: a path to the target OS installation
        :type sysroot: str
        """
        escrow_devices = [
            d for d in storage.devices
            if d.format.type == 'luks' and d.format.escrow_cert
        ]

        if not escrow_devices:
            return

        log.debug("escrow: write_escrow_packets start")
        backup_passphrase = blockdev.crypto.generate_backup_passphrase()

        try:
            escrow_dir = sysroot + "/root"
            log.debug("escrow: writing escrow packets to %s", escrow_dir)
            blivet_util.makedirs(escrow_dir)
            for device in escrow_devices:
                log.debug("escrow: device %s: %s",
                          repr(device.path), repr(device.format.type))
                device.format.escrow(escrow_dir,
                                     backup_passphrase)

        except (OSError, RuntimeError) as e:
            # TODO: real error handling
            log.error("failed to store encryption key: %s", e)

        log.debug("escrow: write_escrow_packets done")

    def _write_dasd_conf(self, storage, sysroot):
        """Write DASD configuration to sysroot.

        Write /etc/dasd.conf to target system for all DASD devices
        configured during installation.

        :param storage: the storage object
        :param sysroot: a path to the target OS installation
        """
        dasds = [d for d in storage.devices if d.type == "dasd"]
        dasds.sort(key=lambda d: d.name)
        if not (arch.is_s390() and dasds):
            return

        with open(os.path.realpath(sysroot + "/etc/dasd.conf"), "w") as f:
            for dasd in dasds:
                fields = [dasd.busid] + dasd.get_opts()
                f.write("%s\n" % " ".join(fields),)

        # check for hyper PAV aliases; they need to get added to dasd.conf as well
        sysfs = "/sys/bus/ccw/drivers/dasd-eckd"

        # in the case that someone is installing with *only* FBA DASDs,the above
        # sysfs path will not exist; so check for it and just bail out of here if
        # that's the case
        if not os.path.exists(sysfs):
            return

        # this does catch every DASD, even non-aliases, but we're only going to be
        # checking for a very specific flag, so there won't be any duplicate entries
        # in dasd.conf
        devs = [d for d in os.listdir(sysfs) if d.startswith("0.0")]
        with open(os.path.realpath(sysroot + "/etc/dasd.conf"), "a") as f:
            for d in devs:
                aliasfile = "%s/%s/alias" % (sysfs, d)
                with open(aliasfile, "r") as falias:
                    alias = falias.read().strip()

                # if alias == 1, then the device is an alias; otherwise it is a
                # normal dasd (alias == 0) and we can skip it, since it will have
                # been added to dasd.conf in the above block of code
                if alias == "1":
                    f.write("%s\n" % d)
