#
# 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 blivet.arch
from blivet.devices import iScsiDiskDevice

from pyanaconda.modules.storage.bootloader.base import BootLoaderError, \
    is_on_non_ibft_sw_iscsi
from pyanaconda.core.configuration.anaconda import conf
from pyanaconda.core.constants import BOOTLOADER_ENABLED, BOOTLOADER_SKIPPED, \
    BOOTLOADER_LOCATION_PARTITION
from pyanaconda.core.i18n import _
from pyanaconda.modules.common.constants.objects import BOOTLOADER
from pyanaconda.modules.common.constants.services import STORAGE
from pyanaconda.core.storage import device_matches

from pyanaconda.anaconda_loggers import get_module_logger
log = get_module_logger(__name__)

__all__ = ["setup_bootloader"]


def setup_bootloader(storage, dry_run=False):
    """Resolve and setup the bootloader configuration.

    :param Blivet storage: an instance of the storage
    :param bool dry_run: don't set devices if True
    """
    executor = BootloaderExecutor()
    executor.execute(storage=storage, dry_run=dry_run)


class BootloaderExecutor(object):
    """The executor of the bootloader command."""

    def execute(self, storage, dry_run=False):
        """Execute the bootloader."""
        log.debug("Execute the bootloader with dry run %s.", dry_run)
        bootloader_proxy = STORAGE.get_proxy(BOOTLOADER)

        # Skip bootloader for s390x image installation.
        if blivet.arch.is_s390() \
                and conf.target.is_image \
                and bootloader_proxy.BootloaderMode == BOOTLOADER_ENABLED:
            bootloader_proxy.BootloaderMode = BOOTLOADER_SKIPPED

        # Is the bootloader enabled?
        if bootloader_proxy.BootloaderMode != BOOTLOADER_ENABLED:
            storage.bootloader.skip_bootloader = True
            log.debug("Bootloader is not enabled, skipping.")
            return

        # Update the disk list. Disks are already sorted by Blivet.
        storage.bootloader.set_disk_list([d for d in storage.disks if d.partitioned])

        # Apply settings related to boot devices.
        self._apply_location(storage, bootloader_proxy)
        self._apply_drive_order(storage, bootloader_proxy, dry_run=dry_run)
        self._apply_boot_drive(storage, bootloader_proxy, dry_run=dry_run)

        # Set the stage2 and stage1 devices.
        if not dry_run:
            storage.bootloader.stage2_device = storage.boot_device
            storage.bootloader.set_stage1_device(storage.devices)

    def _apply_location(self, storage, bootloader_proxy):
        """Set the location."""
        location = bootloader_proxy.PreferredLocation
        log.debug("Applying bootloader location: %s", location)

        storage.bootloader.set_preferred_stage1_type(
            "boot" if location == BOOTLOADER_LOCATION_PARTITION else "mbr"
        )

    def _is_usable_disk(self, d):
        """Is the disk usable for the bootloader?

        Throw out drives that don't exist or cannot be used
        (iSCSI device on an s390 machine).
        """
        return \
            not d.format.hidden and \
            not d.protected and \
            not (blivet.arch.is_s390() and isinstance(d, iScsiDiskDevice)) and \
            (not is_on_non_ibft_sw_iscsi(d) or conf.bootloader.nonibft_iscsi_boot)

    def _get_usable_disks(self, storage):
        """Get a list of usable disks."""
        return [d.name for d in storage.disks if self._is_usable_disk(d)]

    def _apply_drive_order(self, storage, bootloader_proxy, dry_run=False):
        """Apply the drive order.

        Drive specifications can contain | delimited variant specifications,
        such as for example: "vd*|hd*|sd*"

        So use the resolved disk identifiers returned by the device_matches()
        function in place of the original specification but still remove the
        specifications that don't match anything from the output kickstart to
        keep existing --driveorder processing behavior.
        """
        drive_order = bootloader_proxy.DriveOrder
        usable_disks = set(self._get_usable_disks(storage))
        valid_disks = []

        for drive in drive_order[:]:
            # Resolve disk identifiers.
            matched_disks = device_matches(drive, devicetree=storage.devicetree, disks_only=True)

            # Are any of the matched disks usable?
            if any(d in usable_disks for d in matched_disks):
                valid_disks.extend(matched_disks)
            else:
                drive_order.remove(drive)
                log.warning("Requested drive %s in boot drive order doesn't exist "
                            "or cannot be used.", drive)

        # Apply the drive order.
        log.debug("Applying drive order: %s", valid_disks)
        storage.bootloader.disk_order = valid_disks

        # Update the module.
        if not dry_run and bootloader_proxy.DriveOrder != drive_order:
            bootloader_proxy.DriveOrder = drive_order

    def _check_boot_drive(self, storage, boot_drive, usable_disks):
        """Check the specified boot drive."""
        # Resolve the disk identifier.
        matched_disks = device_matches(boot_drive, devicetree=storage.devicetree, disks_only=True)

        if not matched_disks:
            raise BootLoaderError(_("No match found for given boot drive "
                                    "\"{}\".").format(boot_drive))

        if len(matched_disks) > 1:
            raise BootLoaderError(_("More than one match found for given boot drive "
                                    "\"{}\".").format(boot_drive))

        if matched_disks[0] not in usable_disks:
            raise BootLoaderError(_("Requested boot drive \"{}\" doesn't exist or cannot "
                                    "be used.").format(boot_drive))

    def _find_drive_with_stage1(self, storage, usable_disks):
        """Find a drive with a valid stage1 device."""
        # Search for valid stage1 devices.
        for device in storage.devices:
            if not storage.bootloader.is_valid_stage1_device(device):
                continue

            # Search for usable disks.
            for disk in device.disks:
                drive = disk.name

                if drive not in usable_disks:
                    continue

                log.debug("Found a drive with a valid stage1: %s", drive)
                return drive

        # No usable disk found.
        log.debug("No usable drive with a valid stage1 was found.")
        return None

    def _get_boot_drive(self, storage, bootloader_proxy):
        """Get the boot drive.

        When bootloader doesn't have --boot-drive parameter then use this logic as fallback:

        1) If present, use the first valid disk from driveorder parameter.
        2) If present and usable, use a disk where a valid stage1 device is placed.
        3) Use the first usable disk from Blivet if there is one.
        4) Raise an exception.
        """
        boot_drive = bootloader_proxy.Drive
        drive_order = storage.bootloader.disk_order
        usable_disks_list = self._get_usable_disks(storage)
        usable_disks_set = set(usable_disks_list)

        # Use a disk from --boot-drive.
        if boot_drive:
            log.debug("Use the requested boot drive.")
            self._check_boot_drive(storage, boot_drive, usable_disks_set)
            return boot_drive

        # Or use the first disk from --driveorder.
        if drive_order:
            log.debug("Use the first usable drive from the drive order.")
            return drive_order[0]

        # Or find a disk with a valid stage1 device.
        found_drive = self._find_drive_with_stage1(storage, usable_disks_set)
        if found_drive:
            log.debug("Use a usable drive with a valid stage1 device.")
            return found_drive

        # Or use the first usable drive.
        if usable_disks_list:
            log.debug("Use the first usable drive.")
            return usable_disks_list[0]

        # Or raise an exception.
        raise BootLoaderError("No usable boot drive was found.")

    def _apply_boot_drive(self, storage, bootloader_proxy, dry_run=False):
        """Apply the boot drive."""
        boot_drive = self._get_boot_drive(storage, bootloader_proxy)
        log.debug("Using a boot drive: %s", boot_drive)

        # Apply the boot drive.
        drive = storage.devicetree.resolve_device(boot_drive)
        storage.bootloader.stage1_disk = drive

        # Update the bootloader module.
        if not dry_run and bootloader_proxy.Drive != boot_drive:
            bootloader_proxy.Drive = boot_drive
