# Copyright Red Hat
#
# boom/command.py - Boom BLS bootloader command interface
#
# This file is part of the boom project.
#
# SPDX-License-Identifier: Apache-2.0
"""The ``boom.command`` module provides both the Boom command line
interface infrastructure, and a simple procedural interface to the
``boom`` library modules.

The procedural interface is used by the ``boom`` command line tool,
and may be used by application programs, or interactively in the
Python shell by users who do not require all the features present
in the Boom object API.

In addition the module contains definitions for ``Report``
object types and fields that may be of use in implementing custom
reports using the ``boom.report`` module.
"""
from os import environ, uname, getcwd, makedirs
from os.path import basename, exists as path_exists, isabs, isdir, join, sep, abspath
from typing import Any, Dict, Callable, List, Optional, Set, Tuple, Union
from argparse import Namespace, ArgumentParser
from stat import filemode
import platform
import logging
import sys
import re

from boom import (
    BoomError,
    SubsystemFilter,
    BOOM_CONFIG_FILE,
    MIN_ID_WIDTH,
    BOOM_DEBUG_PROFILE,
    BOOM_DEBUG_ENTRY,
    BOOM_DEBUG_REPORT,
    BOOM_DEBUG_COMMAND,
    BOOM_DEBUG_ALL,
    BOOM_SUBSYSTEM_COMMAND,
    DEFAULT_BOOT_PATH,
    Selection,
    get_boom_config,
    parse_btrfs_subvol,
    set_debug_mask,
    set_boot_path,
    get_boot_path,
    get_boom_config_path,
    set_boom_config_path,
    get_boom_path,
    set_boom_path,
)
from boom.osprofile import (
    BOOM_OS_ID,
    BOOM_OS_NAME,
    BOOM_OS_SHORT_NAME,
    BOOM_OS_VERSION,
    BOOM_OS_VERSION_ID,
    BOOM_OS_OPTIONS,
    BOOM_OS_UNAME_PATTERN,
    BOOM_OS_KERNEL_PATTERN,
    BOOM_OS_INITRAMFS_PATTERN,
    BOOM_OS_ROOT_OPTS_LVM2,
    BOOM_OS_ROOT_OPTS_BTRFS,
    BOOM_OS_OPTIONAL_KEYS,
    OsProfile,
    find_profiles,
    match_os_profile_by_version,
    boom_profiles_path,
)
from boom.report import (
    REP_STR,
    REP_SHA,
    REP_NUM,
    Report,
    ReportOpts,
    ReportObjType,
    FieldType,
)
from boom.bootloader import (
    BoomRootDeviceError,
    check_root_device,
    DEV_PATTERN,
    BOOM_ENTRY_GRUB_USERS,
    BOOM_ENTRY_GRUB_ARG,
    BOOM_ENTRY_GRUB_CLASS,
    BOOM_ENTRY_GRUB_ID,
    BOOM_ENTRY_MACHINE_ID,
    OPTIONAL_KEYS,
    BootEntry,
    BootParams,
    boom_entries_path,
    key_to_bls_name,
    optional_key_default,
    find_entries,
)
from boom.hostprofile import (
    BOOM_HOST_NAME,
    BOOM_HOST_LABEL,
    BOOM_HOST_ADD_OPTS,
    BOOM_HOST_DEL_OPTS,
    HostProfile,
    find_host_profiles,
    boom_host_profiles_path,
)
from boom.legacy import (
    BOOM_LOADER_GRUB1,
    BoomLegacyFormatError,
    find_legacy_loader,
    write_legacy_loader,
    clear_legacy_loader,
)
from boom.config import (
    BoomConfigError,
    load_boom_config,
    write_boom_config,
)
from boom.cache import (
    find_cache_paths,
    find_cache_images,
    load_cache,
    cache_path,
    backup_path,
    clean_cache,
    CacheEntry,
)
from boom.mounts import (
    parse_mount_units,
    parse_swap_units,
)

#: The environment variable from which to take the location of the
#: ``/boot`` file system.
BOOM_BOOT_PATH_ENV = "BOOM_BOOT_PATH"

#: Path to the system machine-id file
_MACHINE_ID = "/etc/machine-id"
#: Path to the legacy system machine-id file
_DBUS_MACHINE_ID = "/var/lib/dbus/machine-id"

# Module logging configuration
_log = logging.getLogger(__name__)

_log_debug = _log.debug
_log_info = _log.info
_log_warn = _log.warning
_log_error = _log.error


def _log_debug_cmd(msg, *args, **kwargs):
    """A wrapper for command subsystem debug logs."""
    _log.debug(msg, *args, extra={"subsystem": BOOM_SUBSYSTEM_COMMAND}, **kwargs)


_default_log_level = logging.WARNING
_console_handler = None


#
# Reporting object types
#


class ReportObj:
    """ReportObj()
    The universal object type used for all reports generated by
    the Boom CLI. Individual fields map to one of the contained
    objects via the ``ReportObjType`` object's ``data_fn``
    method. It is an error to attempt to report an object that
    is undefined: the ReportObj used for a report must
    contain values for each object type that the specified list
    of fields will attempt to access.

    This allows a single report to include fields from both a
    ''``BootEntry`` object and an attached ``OsProfile``.
    """

    be = None
    osp = None
    hp = None
    ce = None

    def __init__(
        self,
        boot_entry: Optional[BootEntry] = None,
        os_profile: Optional[Union[OsProfile, HostProfile]] = None,
        host_profile: Optional[HostProfile] = None,
        cache_entry: Optional[CacheEntry] = None,
    ):
        """Initialise new ReportObj objects.

        Construct a new ReportObj object containing the
        specified BootEntry and or OsProfile objects.

        :returns: a new ReportObj.
        :rtype: ``ReportObj``
        """
        self.be = boot_entry
        self.osp = os_profile
        self.hp = host_profile
        self.ce = cache_entry


#: BootEntry report object type
BR_ENTRY = 1
#: OsProfile report object type
BR_PROFILE = 2
#: BootParams report object type
BR_PARAMS = 4
#: HostProfile report object type
BR_HOST = 8
#: CacheEntry report object type
BR_CACHE = 16

#: Report object type table for ``boom.command`` reports.
_report_obj_types = [
    ReportObjType(BR_ENTRY, "Boot loader entries", "entry_", lambda o: o.be),
    ReportObjType(BR_PROFILE, "OS profiles", "profile_", lambda o: o.osp),
    ReportObjType(BR_PARAMS, "Boot parameters", "param_", lambda o: o.be.bp),
    ReportObjType(BR_HOST, "Host profiles", "host_", lambda o: o.hp),
    ReportObjType(BR_CACHE, "Cache entries", "cache_", lambda o: o.ce),
]

#
# Reporting field definitions
#

#: Fields derived from OsProfile data.
_profile_fields = [
    FieldType(
        BR_PROFILE,
        "osid",
        "OsID",
        "OS identifier",
        MIN_ID_WIDTH,
        REP_SHA,
        lambda f, d: f.report_sha(d.os_id),
    ),
    FieldType(
        BR_PROFILE,
        "osname",
        "Name",
        "OS name",
        24,
        REP_STR,
        lambda f, d: f.report_str(d.os_name),
    ),
    FieldType(
        BR_PROFILE,
        "osshortname",
        "OsShortName",
        "OS short name",
        12,
        REP_STR,
        lambda f, d: f.report_str(d.os_short_name),
    ),
    FieldType(
        BR_PROFILE,
        "osversion",
        "OsVersion",
        "OS version",
        10,
        REP_STR,
        lambda f, d: f.report_str(d.os_version),
    ),
    FieldType(
        BR_PROFILE,
        "osversion_id",
        "VersionID",
        "Version identifier",
        10,
        REP_STR,
        lambda f, d: f.report_str(d.os_version_id),
    ),
    FieldType(
        BR_PROFILE,
        "unamepattern",
        "UnamePattern",
        "UTS name pattern",
        12,
        REP_STR,
        lambda f, d: f.report_str(d.uname_pattern),
    ),
    FieldType(
        BR_PROFILE,
        "kernelpattern",
        "KernPattern",
        "Kernel image pattern",
        13,
        REP_STR,
        lambda f, d: f.report_str(d.kernel_pattern),
    ),
    FieldType(
        BR_PROFILE,
        "initrdpattern",
        "InitrdPattern",
        "Initrd pattern",
        13,
        REP_STR,
        lambda f, d: f.report_str(d.initramfs_pattern),
    ),
    FieldType(
        BR_PROFILE,
        "lvm2opts",
        "LVM2Opts",
        "LVM2 options",
        12,
        REP_STR,
        lambda f, d: f.report_str(d.root_opts_lvm2),
    ),
    FieldType(
        BR_PROFILE,
        "btrfsopts",
        "BTRFSOpts",
        "BTRFS options",
        13,
        REP_STR,
        lambda f, d: f.report_str(d.root_opts_btrfs),
    ),
    FieldType(
        BR_PROFILE,
        "options",
        "Options",
        "Kernel options",
        24,
        REP_STR,
        lambda f, d: f.report_str(d.options),
    ),
    FieldType(
        BR_PROFILE,
        "profilepath",
        "ProfilePath",
        "On-disk profile path",
        12,
        REP_STR,
        lambda f, d: f.report_str(d._profile_path()),
    ),
]

_default_profile_fields = "osid,osname,osversion"

_host_fields = [
    FieldType(
        BR_HOST,
        "hostid",
        "HostID",
        "Host identifier",
        MIN_ID_WIDTH,
        REP_SHA,
        lambda f, d: f.report_sha(d.host_id),
    ),
    FieldType(
        BR_HOST,
        "machineid",
        "MachineID",
        "Machine identifier",
        10,
        REP_SHA,
        lambda f, d: f.report_sha(d.disp_machine_id),
    ),
    FieldType(
        BR_HOST,
        "osid",
        "OsID",
        "OS identifier",
        MIN_ID_WIDTH,
        REP_SHA,
        lambda f, d: f.report_sha(d.os_id),
    ),
    FieldType(
        BR_HOST,
        "hostname",
        "HostName",
        "Host name",
        28,
        REP_STR,
        lambda f, d: f.report_str(d.host_name),
    ),
    FieldType(
        BR_HOST,
        "label",
        "Label",
        "Host label",
        12,
        REP_STR,
        lambda f, d: f.report_str(d.label),
    ),
    FieldType(
        BR_HOST,
        "kernelpattern",
        "KernPattern",
        "Kernel image pattern",
        13,
        REP_STR,
        lambda f, d: f.report_str(d.kernel_pattern),
    ),
    FieldType(
        BR_HOST,
        "initrdpattern",
        "InitrdPattern",
        "Initrd pattern",
        13,
        REP_STR,
        lambda f, d: f.report_str(d.initramfs_pattern),
    ),
    FieldType(
        BR_HOST,
        "lvm2opts",
        "LVM2Opts",
        "LVM2 options",
        12,
        REP_STR,
        lambda f, d: f.report_str(d.root_opts_lvm2),
    ),
    FieldType(
        BR_HOST,
        "btrfsopts",
        "BTRFSOpts",
        "BTRFS options",
        13,
        REP_STR,
        lambda f, d: f.report_str(d.root_opts_btrfs),
    ),
    FieldType(
        BR_HOST,
        "options",
        "Options",
        "Kernel options",
        24,
        REP_STR,
        lambda f, d: f.report_str(d.options),
    ),
    FieldType(
        BR_HOST,
        "profilepath",
        "ProfilePath",
        "On-disk profile path",
        12,
        REP_STR,
        lambda f, d: f.report_str(d._profile_path()),
    ),
    FieldType(
        BR_HOST,
        "addopts",
        "AddOptions",
        "Added Options",
        12,
        REP_STR,
        lambda f, d: f.report_str(d.add_opts),
    ),
    FieldType(
        BR_HOST,
        "delopts",
        "DelOptions",
        "Deleted Options",
        12,
        REP_STR,
        lambda f, d: f.report_str(d.del_opts),
    ),
]

_default_host_fields = "hostid,hostname,machineid,osid"


def _int_if_val(val: Optional[str]) -> Optional[int]:
    """Return an int if val is defined or None otherwise.

    A TypeError exception is raised if val is defined but does
    not contain a parsable integer value.

    :param val: The value to convert
    :returns: None if val is None or an integer representation of
              the string val
    :raises: TypeError if val is not str, bytes-like, or number.
             ValueError if val cannot be converted to an int.
    """
    return int(val) if val is not None else None


def _bool_to_yes_no(bval):
    """Return the string 'yes' if ``bval`` is ``True`` or 'no' otherwise."""
    return "yes" if bval else "no"


#: Fields derived from BootEntry data.
_entry_fields = [
    FieldType(
        BR_ENTRY,
        "bootid",
        "BootID",
        "Boot identifier",
        MIN_ID_WIDTH,
        REP_SHA,
        lambda f, d: f.report_sha(d.boot_id),
    ),
    FieldType(
        BR_ENTRY,
        "title",
        "Title",
        "Entry title",
        24,
        REP_STR,
        lambda f, d: f.report_str(d.title),
    ),
    FieldType(
        BR_ENTRY,
        "options",
        "Options",
        "Kernel options",
        24,
        REP_STR,
        lambda f, d: f.report_str(d.options),
    ),
    FieldType(
        BR_ENTRY,
        "kernel",
        "Kernel",
        "Kernel image",
        32,
        REP_STR,
        lambda f, d: f.report_str(d.linux),
    ),
    FieldType(
        BR_ENTRY,
        "initramfs",
        "Initramfs",
        "Initramfs image",
        40,
        REP_STR,
        lambda f, d: f.report_str(d.initrd),
    ),
    FieldType(
        BR_ENTRY,
        "machineid",
        "MachineID",
        "Machine identifier",
        10,
        REP_SHA,
        lambda f, d: f.report_sha(d.machine_id),
    ),
    FieldType(
        BR_ENTRY,
        "entrypath",
        "EntryPath",
        "On-disk entry path",
        12,
        REP_STR,
        lambda f, d: f.report_str(d.entry_path),
    ),
    FieldType(
        BR_ENTRY,
        "entryfile",
        "EntryFile",
        "On-disk entry file name",
        12,
        REP_STR,
        lambda f, d: f.report_str(basename(d.entry_path)),
    ),
    FieldType(
        BR_ENTRY,
        "readonly",
        "ReadOnly",
        "Entry is read-only",
        9,
        REP_STR,
        lambda f, d: f.report_str(_bool_to_yes_no(d.read_only)),
    ),
]

#: Fields derived from BootEntry data, with bootloader variables expanded.
_expand_entry_fields = [
    FieldType(
        BR_ENTRY,
        "bootid",
        "BootID",
        "Boot identifier",
        MIN_ID_WIDTH,
        REP_SHA,
        lambda f, d: f.report_sha(d.boot_id),
    ),
    FieldType(
        BR_ENTRY,
        "title",
        "Title",
        "Entry title",
        24,
        REP_STR,
        lambda f, d: f.report_str(d.title),
    ),
    FieldType(
        BR_ENTRY,
        "options",
        "Options",
        "Kernel options",
        24,
        REP_STR,
        lambda f, d: f.report_str(d.expand_options),
    ),
    FieldType(
        BR_ENTRY,
        "kernel",
        "Kernel",
        "Kernel image",
        32,
        REP_STR,
        lambda f, d: f.report_str(d.linux),
    ),
    FieldType(
        BR_ENTRY,
        "initramfs",
        "Initramfs",
        "Initramfs image",
        40,
        REP_STR,
        lambda f, d: f.report_str(d.initrd),
    ),
    FieldType(
        BR_ENTRY,
        "machineid",
        "MachineID",
        "Machine identifier",
        10,
        REP_SHA,
        lambda f, d: f.report_sha(d.machine_id),
    ),
    FieldType(
        BR_ENTRY,
        "entrypath",
        "EntryPath",
        "On-disk entry path",
        12,
        REP_STR,
        lambda f, d: f.report_str(d.entry_path),
    ),
    FieldType(
        BR_ENTRY,
        "entryfile",
        "EntryFile",
        "On-disk entry file name",
        12,
        REP_STR,
        lambda f, d: f.report_str(basename(d.entry_path)),
    ),
    FieldType(
        BR_ENTRY,
        "readonly",
        "ReadOnly",
        "Entry is read-only",
        9,
        REP_STR,
        lambda f, d: f.report_str(_bool_to_yes_no(d.read_only)),
    ),
]

#: Fields derived from BootParams data
_params_fields = [
    FieldType(
        BR_PARAMS,
        "version",
        "Version",
        "Kernel version",
        24,
        REP_STR,
        lambda f, d: f.report_str(d.version),
    ),
    FieldType(
        BR_PARAMS,
        "rootdev",
        "RootDevice",
        "Root device",
        10,
        REP_STR,
        lambda f, d: f.report_str(d.root_device),
    ),
    FieldType(
        BR_PARAMS,
        "rootlv",
        "RootLV",
        "Root logical volume",
        6,
        REP_STR,
        lambda f, d: f.report_str(d.lvm_root_lv or ""),
    ),
    FieldType(
        BR_PARAMS,
        "subvolpath",
        "SubvolPath",
        "BTRFS subvolume path",
        10,
        REP_STR,
        lambda f, d: f.report_str(d.btrfs_subvol_path or ""),
    ),
    FieldType(
        BR_PARAMS,
        "subvolid",
        "SubvolID",
        "BTRFS subvolume ID",
        8,
        REP_NUM,
        lambda f, d: f.report_num(_int_if_val(d.btrfs_subvol_id)),
    ),
]

_default_entry_fields = "bootid,version,osname,rootdev"


#: Fields derived from CacheEntry data
_cache_fields = [
    FieldType(
        BR_CACHE,
        "imgid",
        "ImageID",
        "Image identifier",
        MIN_ID_WIDTH,
        REP_SHA,
        lambda f, d: f.report_sha(d.img_id),
    ),
    FieldType(
        BR_CACHE,
        "path",
        "Path",
        "Image path",
        24,
        REP_STR,
        lambda f, d: f.report_str(d.path),
    ),
    FieldType(
        BR_CACHE,
        "mode",
        "Mode",
        "Path mode",
        8,
        REP_STR,
        lambda f, d: f.report_str(filemode(d.mode)),
    ),
    FieldType(
        BR_CACHE, "uid", "User", "User ID", 5, REP_NUM, lambda f, d: f.report_num(d.uid)
    ),
    FieldType(
        BR_CACHE,
        "gid",
        "Group",
        "Group ID",
        5,
        REP_NUM,
        lambda f, d: f.report_num(d.gid),
    ),
    FieldType(
        BR_CACHE,
        "ts",
        "Timestamp",
        "Timestamp",
        10,
        REP_NUM,
        lambda f, d: f.report_num(d.timestamp),
    ),
    FieldType(
        BR_CACHE,
        "state",
        "State",
        "State",
        8,
        REP_STR,
        lambda f, d: f.report_str(d.state),
    ),
    FieldType(
        BR_CACHE,
        "count",
        "Count",
        "Use Count",
        5,
        REP_NUM,
        lambda f, d: f.report_num(d.count),
    ),
]

_default_cache_fields = "path,imgid,ts,state"


def get_machine_id() -> str:
    """Return the current host's machine-id.

    Get the machine-id value for the running system by reading from
    ``/etc/machine-id`` and return it as a string.

    :returns: The ``machine_id`` as a string
    :rtype: str
    """
    if path_exists(_MACHINE_ID):
        path = _MACHINE_ID
    elif path_exists(_DBUS_MACHINE_ID):  # pragma: no cover
        path = _DBUS_MACHINE_ID
    else:  # pragma: no cover
        return ""

    try:
        with open(path, "r", encoding="utf8") as f:
            machine_id = f.read().strip()
    except (OSError, UnicodeDecodeError) as err:  # pragma: no cover
        _log_error("Could not read machine-id from '%s': %s", path, err)
        machine_id = ""
    return machine_id


def _str_indent(strdata: str, indent: int) -> str:
    """Indent all lines of a multi-line string.

    Indent each line of the multi line string ``strdata`` to the
    specified indentation level.

    :param strdata: The string to be indented
    :param indent: The number of characters to indent by
    :returns: str
    """
    outstr = ""
    for line in strdata.splitlines():
        outstr += indent * " " + line + "\n"
    return outstr.rstrip("\n")


def _canonicalize_lv_name(lvname: str) -> str:
    """Canonicalize an LVM2 logical volume name as "VG/LV", removing any
    "/dev/" prefix and return the result as a string.

    The use of "/dev/mapper/VG-LV" names is not supported.
    """
    dev_prefix = DEV_PATTERN % ""
    if lvname.startswith(dev_prefix + "mapper/"):
        raise ValueError(
            "Logical volume names in /dev/mapper/VG-LV format are not supported."
        )
    if lvname.startswith(dev_prefix):
        lvname = lvname[len(dev_prefix) :]
    if "/" not in lvname or lvname.count("/") != 1:
        raise ValueError("Root logical volume name must be in VG/LV format.")
    return lvname


def __write_legacy():
    """Synchronise boom boot entries with the configured legacy
    bootloader format.
    """
    config = get_boom_config()
    if config.legacy_enable and config.legacy_sync:  # pragma: no cover
        clear_legacy_loader()
        write_legacy_loader(selection=Selection(), loader=config.legacy_format)


def _do_print_type(
    report_fields: List[FieldType],
    selected: List[ReportObj],
    output_fields: Optional[str] = None,
    opts: Optional[ReportOpts] = None,
    sort_keys: Optional[str] = None,
    title: Optional[str] = None,
) -> None:
    """Print an object type report (entry, osprofile, hostprofile).

    Helper for list function that generate Reports.

    Format a set of entry or profile objects matching the given
    criteria and format them as a report, returning the output
    as a string.

    Selection criteria may be expressed via a Selection object
    passed to the call using the ``selection`` parameter.

    :param selection: A Selection object giving selection
                      criteria for the operation
    :param output_fields: a comma-separated list of output fields
    :param opts: output formatting and control options
    :param sort_keys: a comma-separated list of sort keys
    :rtype: ``None``
    """
    opts = opts if opts is not None else ReportOpts()

    br = Report(
        _report_obj_types,
        report_fields,
        output_fields,
        opts,
        sort_keys,
        title,
    )

    for obj in selected:
        # Fixme: handle bes with embedded hp (class test)
        br.report_object(obj)

    return br.report_output()


def _merge_add_del_opts(
    bp: BootParams, add_opts: Optional[str], del_opts: Optional[str]
) -> Tuple[List[str], List[str]]:
    """Merge a set of existing bootparams option alterations with
    a set of command-line provided values to produce a single
    set of options to add or remove from a cloned or edited
    ``BootEntry``.

    The sets are merged giving precedence to alterations on the
    current command line: i.e. if an option is present in both
    ``bp.del_opts`` and ``add_opts`` (or vice versa) then the
    option taken from the current command line will be effective.

    :param bp: A ``BootParams`` object with the original ``add_opts``
               and ``del_opts`` values.
    :param add_opts: A space-separated string containing a list of
                     additional options taken from the current
                     command line.
    :param del_opts: A space-separated string containing a list of
                     options to delete taken from the current
                     command line.
    :returns: A tuple ``(effective_add_opts, effective_del_opts)``
              giving the final effective values as a list of
              strings, one per option word.
    """

    def _merge_opts(orig_opts, opts, r_opts):
        # Merge new and cloned kernel options
        all_opts = []
        if orig_opts:
            for opt in orig_opts:
                if opt not in all_opts:
                    all_opts.append(opt)
        if opts:
            for opt in opts:
                if opt not in all_opts:
                    all_opts.append(opt)
        return [o for o in all_opts if o not in r_opts]

    _log_debug_cmd("Add opts: %s", add_opts)
    _log_debug_cmd("Del opts: %s", del_opts)
    _log_debug_cmd("Original add_opts: %s", bp.add_opts)
    _log_debug_cmd("Original del_opts: %s", bp.del_opts)

    r_del_opts = []
    r_add_opts = []

    add_opts_list = add_opts.split() if add_opts else []
    del_opts_list = del_opts.split() if del_opts else []

    for add_opt in add_opts_list.copy():
        # Do not allow conflicting command line add/del opts
        if add_opt in del_opts_list:
            raise ValueError(
                f"Conflicting --add-opts {add_opt} and --del-opts {del_opts}"
            )

        if add_opt in bp.del_opts:
            r_del_opts.append(add_opt)
            add_opts_list.remove(add_opt)

    for del_opt in del_opts_list.copy():
        if del_opt in bp.add_opts:
            r_add_opts.append(del_opt)
            del_opts_list.remove(del_opt)

    add_opts_list = _merge_opts(bp.add_opts, add_opts_list, r_add_opts)
    del_opts_list = _merge_opts(bp.del_opts, del_opts_list, r_del_opts)

    _log_debug_cmd("Effective add options: %s", add_opts_list)
    _log_debug_cmd("Effective del options: %s", del_opts_list)

    return (add_opts_list, del_opts_list)


#
# Command driven API: BootEntry and OsProfile management and reporting.
#

# Boot image cache modes

#: Use original image (no caching)
I_NONE = None
I_CACHE = "cache"
I_BACKUP = "backup"


#
# BootEntry manipulation
#


def _cache_image(img_path: str, backup: bool, update: bool = False) -> str:
    """Cache the image found at ``img_path`` and optionally create
    a backup copy.
    """
    if "." in img_path:
        ext = img_path.rsplit(".", 1)[1]
        if ext.startswith("boom") and ext[4:].isdigit():
            if find_cache_paths(Selection(path=img_path)):
                return img_path
    try:
        if backup:
            ce = backup_path(img_path, update=update)
        else:
            ce = cache_path(img_path, update=update)
    except (OSError, ValueError) as err:  # pragma: no cover
        # Let callers handle logging
        raise
    return ce.path


def _find_one_entry(select: Selection) -> BootEntry:
    """Find exactly one entry, and raise ValueError if zero or more
    than one entry is found.

    :param select: An instance of ``Selection`` specifying match criteria.
    :returns: A single instance of ``BootEntry``
    :raises: ValueError if selection results are empty or non-unique
    """
    bes = find_entries(select)
    if not bes:
        raise ValueError(f"No matching entry found for boot ID {select.boot_id}")
    if len(bes) > 1:
        raise ValueError("Selection criteria must match exactly one entry")
    return bes[0]


def _apply_btrfs_subvol_exclusive(
    bp: BootParams, subvol_path: Optional[str], subvol_id: Optional[str]
) -> None:
    """
    Apply btrfs subvolume parameters to ``BootParams``.

    :param bp: ``BootParams`` to update.
    :param subvol_path: An optional btrfs subvolume path to set.
    :param subvol_id: An optional btrfs subvolume ID to set.
    """
    sv_path = subvol_path if subvol_path else None
    sv_id = subvol_id if subvol_id else None
    if sv_path and sv_id:
        raise ValueError("Cannot set btrfs_subvol_path and btrfs_subvol_id")
    if sv_path is not None:
        bp.btrfs_subvol_path = sv_path
        bp.btrfs_subvol_id = None
    elif sv_id is not None:
        bp.btrfs_subvol_id = sv_id
        bp.btrfs_subvol_path = None


def _apply_no_fstab(
    add_opts: List[str], del_opts: List[str]
) -> Tuple[List[str], List[str]]:
    """
    Apply no_fstab handling by copying and modifying the ``add_opts`` and
    ``del_opts`` lists and returning the modified copies.

    :param add_opts: add_opts list to modify.
    :param del_opts: del_opts list to modify.
    :returns: A tuple of new `(add_opts_list, del_opts_list)`.
    """
    add_opts_list = add_opts.copy()
    del_opts_list = del_opts.copy()

    if "fstab=no" not in add_opts_list:
        add_opts_list.append("fstab=no")

    if "ro" in add_opts_list:
        add_opts_list.remove("ro")
    if "rw" in del_opts_list:
        del_opts_list.remove("rw")

    if "ro" not in del_opts_list:
        del_opts_list.append("ro")
    if "rw" not in add_opts_list:
        add_opts_list.append("rw")

    return add_opts_list, del_opts_list


def create_entry(
    title: str,
    version: str,
    machine_id: str,
    root_device: str,
    lvm_root_lv: Optional[str] = None,
    btrfs_subvol_path: Optional[str] = None,
    btrfs_subvol_id: Optional[str] = None,
    profile: Optional[Union[OsProfile, HostProfile]] = None,
    add_opts: Optional[str] = None,
    del_opts: Optional[str] = None,
    write: bool = True,
    architecture: Optional[str] = None,
    expand: bool = False,
    allow_no_dev: bool = False,
    images: Optional[str] = I_NONE,
    update: bool = False,
    no_fstab: bool = False,
    mounts: Optional[List[str]] = None,
    swaps: Optional[List[str]] = None,
) -> BootEntry:
    """Create new boot loader entry.

    Create the specified boot entry in the configured loader directory.
    An error is raised if a matching entry already exists.

    :param title: the title of the new entry.
    :param version: the version string for the new entry.
    :param machine_id: the machine id for the new entry.
    :param root_device: the root device path for the new entry.
    :param lvm_root_lv: an optional LVM2 root logical volume.
    :param btrfs_subvol_path: an optional BTRFS subvolume path.
    :param btrfs_subvol_id: an optional BTRFS subvolume id.
    :param profile: A profile to use for this entry.
    :param add_opts: A list of additional kernel options to append.
    :param del_opts: A list of template-supplied options to drop.
    :param write: ``True`` if the entry should be written to disk,
                  or ``False`` otherwise.
    :param architecture: An optional BLS architecture string.
    :param expand: Expand bootloader environment variables.
    :param allow_no_dev: Accept a non-existent or invalid root dev.
    :param images: Whether to cache or backup boot images in the new
                   entry.
    :param update: Whether to update or reuse an existing cached
                   boot image.
    :param no_fstab: Disable parsing of the fstab for the new entry.
    :param mounts: A list of colon separated command-line mount
                   specifications for the new entry.
    :param swaps: A list of colon separated command-line swap
                  specifications for the new entry.
    :returns: a ``BootEntry`` object corresponding to the new entry.
    :rtype: ``BootEntry``
    :raises: ``ValueError`` if either required values are missing or
             a duplicate entry exists, or ``OSError`` if an error
             occurs while writing the entry file.
    """
    if not profile:
        raise ValueError("Profile is required (OsProfile or HostProfile).")

    if not title and not profile.title:
        raise ValueError("Entry title cannot be empty.")

    if not version:
        raise ValueError("Entry version cannot be empty.")

    if not machine_id:
        raise ValueError("Entry machine_id cannot be empty.")

    if not root_device:
        raise ValueError("Entry requires a root_device.")

    bc = get_boom_config()
    if images is not I_NONE and not bc.cache_enable:
        raise BoomConfigError(
            f"Cannot use images={images} with image cache disabled"
            " (config.cache_enable=False)"
        )

    add_opts_list = add_opts.split() if add_opts else []
    del_opts_list = del_opts.split() if del_opts else []

    if no_fstab:
        add_opts_list, del_opts_list = _apply_no_fstab(add_opts_list, del_opts_list)

    if mounts:
        mount_units = parse_mount_units(mounts)
        add_opts_list.extend(mount_units)

    if swaps:
        swap_units = parse_swap_units(swaps)
        add_opts_list.extend(swap_units)

    _log_debug_cmd("Effective add options: %s", add_opts_list)
    _log_debug_cmd("Effective del options: %s", del_opts_list)

    bp = BootParams(
        version,
        root_device,
        lvm_root_lv=lvm_root_lv,
        btrfs_subvol_path=btrfs_subvol_path,
        btrfs_subvol_id=btrfs_subvol_id,
        add_opts=add_opts_list,
        del_opts=del_opts_list,
    )

    be = BootEntry(
        title=title,
        machine_id=machine_id,
        osprofile=profile,
        boot_params=bp,
        architecture=architecture,
        allow_no_dev=allow_no_dev,
    )

    if images in (I_BACKUP, I_CACHE):
        if be.initrd:
            be.initrd = _cache_image(be.initrd, images == I_BACKUP, update=update)
        if be.linux:
            be.linux = _cache_image(be.linux, images == I_BACKUP, update=update)

    if find_entries(Selection(boot_id=be.boot_id)):
        raise ValueError(f"Entry already exists (boot_id={be.disp_boot_id}).")

    if write:
        be.write_entry(expand=expand)
        __write_legacy()

    return be


def delete_entries(selection: Optional[Selection] = None) -> int:
    """Delete entries matching selection criteria.

    Delete the specified boot entry or entries from the configured
    loader directory. If ``boot_id`` is used, or if the criteria
    specified match exactly one entry, a single entry is removed.
    If ``boot_id`` is not used, and more than one matching entry
    is present, all matching entries will be removed.

    Selection criteria may also be expressed via a Selection
    object passed to the call using the ``selection`` parameter.

    On success the number of entries removed is returned.

    :param selection: A Selection object giving selection
                      criteria for the operation.
    :returns: the number of entries removed.
    :rtype: ``int``
    """
    bes = find_entries(selection=selection)

    if not bes:
        raise IndexError("No matching entry found.")

    deleted = 0
    for be in bes:
        be.delete_entry()
        deleted += 1

    __write_legacy()

    return deleted


def clone_entry(
    selection: Optional[Selection] = None,
    title: Optional[str] = None,
    version: Optional[str] = None,
    machine_id: Optional[str] = None,
    root_device: Optional[str] = None,
    lvm_root_lv: Optional[str] = None,
    btrfs_subvol_path: Optional[str] = None,
    btrfs_subvol_id: Optional[str] = None,
    profile: Optional[Union[OsProfile, HostProfile]] = None,
    architecture: Optional[str] = None,
    add_opts: Optional[str] = None,
    del_opts: Optional[str] = None,
    write: bool = True,
    expand: bool = False,
    allow_no_dev: bool = False,
    images: Optional[str] = I_NONE,
    update: bool = False,
    no_fstab: bool = False,
    mounts: Optional[List[str]] = None,
    swaps: Optional[List[str]] = None,
) -> BootEntry:
    """Clone an existing boot loader entry.

    Create the specified boot entry in the configured loader directory
    by cloning all un-set parameters from the boot entry selected by
    the ``selection`` argument.

    An error is raised if a matching entry already exists.

    :param selection: criteria matching the entry to clone.
    :param title: the title of the new entry.
    :param version: the version string for the new entry.
    :param machine_id: the machine id for the new entry.
    :param root_device: the root device path for the new entry.
    :param lvm_root_lv: an optional LVM2 root logical volume.
    :param btrfs_subvol_path: an optional BTRFS subvolume path.
    :param btrfs_subvol_id: an optional BTRFS subvolume id.
    :param profile: A profile to use for this entry.
    :param architecture: An optional BLS architecture string.
    :param add_opts: A list of additional kernel options to append.
    :param del_opts: A list of template-supplied options to drop.
    :param write: ``True`` if the entry should be written to disk,
                  or ``False`` otherwise.
    :param expand: Expand bootloader environment variables.
    :param allow_no_dev: Allow the block device to not exist.
    :param images: Whether to cache or backup boot images in the new
                   entry.
    :param update: Whether to update or reuse an existing cached boot
                   image.
    :param no_fstab: Disable parsing of the fstab for the new entry.
    :param mounts: A list of colon-separated command-line mount
                   specifications.
    :param swaps: A list of colon-separated command-line swap
                  specifications.
    :returns: a ``BootEntry`` object corresponding to the new entry.
    :rtype: ``BootEntry``
    :raises: ``ValueError`` if either required values are missing or
             a duplicate entry exists, or ``OSError`` if an error
             occurs while writing the entry file.
    """
    if not selection:
        raise ValueError("clone requires selection criteria")
    if not selection.boot_id or selection.boot_id is None:
        raise ValueError("clone requires boot_id")

    all_args = (
        title,
        version,
        machine_id,
        root_device,
        lvm_root_lv,
        btrfs_subvol_path,
        btrfs_subvol_id,
        profile,
    )

    if not any(all_args):
        raise ValueError(
            "clone requires one or more of:\ntitle, version, "
            "machine_id, root_device, lvm_root_lv, "
            "btrfs_subvol_path, btrfs_subvol_id, profile"
        )

    bc = get_boom_config()
    if images is not I_NONE and not bc.cache_enable:
        raise BoomConfigError(
            f"Cannot use images={images} with image cache disabled"
            " (config.cache_enable=False)"
        )

    be = _find_one_entry(selection)

    _log_debug("Cloning entry with boot_id='%s'", be.disp_boot_id)

    title = title if title else be.title
    version = version if version else be.version
    machine_id = machine_id if machine_id else be.machine_id
    profile = profile if profile else be._osp

    bp = BootParams.from_entry(be, expand=expand)
    (add_opts_list, del_opts_list) = _merge_add_del_opts(bp, add_opts, del_opts)

    if no_fstab:
        add_opts_list, del_opts_list = _apply_no_fstab(add_opts_list, del_opts_list)

    if mounts:
        mount_units = parse_mount_units(mounts)
        add_opts_list.extend(mount_units)

    if swaps:
        swap_units = parse_swap_units(swaps)
        add_opts_list.extend(swap_units)

    bp.version = version if version else bp.version
    bp.root_device = root_device if root_device else bp.root_device
    bp.lvm_root_lv = lvm_root_lv if lvm_root_lv else bp.lvm_root_lv

    _apply_btrfs_subvol_exclusive(bp, btrfs_subvol_path, btrfs_subvol_id)

    clone_be = BootEntry(
        title=title,
        machine_id=machine_id,
        osprofile=profile,
        boot_params=bp,
        architecture=architecture,
        allow_no_dev=allow_no_dev,
    )

    if be.options != be.expand_options and not expand:
        clone_be.options = be.options
    else:
        if clone_be.bp:
            clone_be.bp.add_opts = add_opts_list
            clone_be.bp.del_opts = del_opts_list

    # Clone optional keys allowed by profile
    if be._osp:
        for optional_key in be._osp.optional_keys.split():
            if clone_be._osp and optional_key in clone_be._osp.optional_keys:
                if hasattr(be, optional_key):
                    setattr(clone_be, optional_key, getattr(be, optional_key))

    # Boot image overrides?
    if be.initrd != clone_be.initrd:
        clone_be.initrd = be.initrd
    if be.linux != clone_be.linux:
        clone_be.linux = be.linux

    if images in (I_BACKUP, I_CACHE):
        if clone_be.initrd:
            clone_be.initrd = _cache_image(
                clone_be.initrd, images == I_BACKUP, update=update
            )
        if clone_be.linux:
            clone_be.linux = _cache_image(
                clone_be.linux, images == I_BACKUP, update=update
            )

    if find_entries(Selection(boot_id=clone_be.boot_id)):
        raise ValueError(f"Entry already exists (boot_id={clone_be.disp_boot_id}).")

    if write:
        clone_be.write_entry()
        __write_legacy()

    return clone_be


def edit_entry(
    selection: Optional[Selection] = None,
    title: Optional[str] = None,
    version: Optional[str] = None,
    machine_id: Optional[str] = None,
    root_device: Optional[str] = None,
    lvm_root_lv: Optional[str] = None,
    btrfs_subvol_path: Optional[str] = None,
    btrfs_subvol_id: Optional[str] = None,
    profile: Optional[Union[OsProfile, HostProfile]] = None,
    architecture: Optional[str] = None,
    add_opts: Optional[str] = None,
    del_opts: Optional[str] = None,
    expand: bool = False,
    allow_no_dev: bool = False,
    images: Optional[str] = I_NONE,
    update: bool = False,
    no_fstab: bool = False,
    mounts: Optional[List[str]] = None,
    swaps: Optional[List[str]] = None,
) -> BootEntry:
    """Edit an existing boot loader entry.

    Modify an existing BootEntry by changing one or more of the
    entry values or boot parameters.

    The modified BootEntry is written to disk and returned on
    success.

    Modifying a BootEntry causes the entry's boot_id to change,
    since the ID is based on the values of all configured boot
    keys.

    :param selection: A Selection specifying the boot_id to edit
    :param title: The new entry title
    :param version: The new entry version
    :param machine_id: The new machine_id
    :param root_device: The new root device
    :param lvm_root_lv: The new LVM root LV
    :param btrfs_subvol_path: The new BTRFS subvolume path
    :param btrfs_subvol_id: The new BTRFS subvolume ID
    :param profile: The host or OS profile for the edited entry
    :param architecture: An optional BLS architecture string.
    :param add_opts: A list of additional kernel options to append.
    :param del_opts: A list of template-supplied options to drop.
    :param expand: Expand bootloader environment variables.
    :param allow_no_dev: Accept a non-existent or invalid root device.
    :param images: Whether to cache or back up boot images.
    :param update: Whether to refresh existing cached images.
    :param no_fstab: Disable parsing of fstab for the entry.
    :param mounts: Colon-separated mount unit specifications.
    :param swaps: Colon-separated swap unit specifications.

    :returns: The modified ``BootEntry``
    :rtype: ``BootEntry``
    """
    all_args = (
        title,
        version,
        machine_id,
        root_device,
        lvm_root_lv,
        btrfs_subvol_path,
        btrfs_subvol_id,
        profile,
    )

    if not selection:
        raise ValueError("edit requires selection criteria")
    if not selection.boot_id or selection.boot_id is None:
        raise ValueError("edit requires boot_id")

    if not any(all_args):
        raise ValueError(
            "edit requires one or more of:\ntitle, version, "
            "machine_id, root_device, lvm_root_lv, "
            "btrfs_subvol_path, btrfs_subvol_id, profile"
        )

    bc = get_boom_config()
    if images is not I_NONE and not bc.cache_enable:
        raise BoomConfigError(
            f"Cannot use images={images} with image cache disabled"
            " (config.cache_enable=False)"
        )

    # Discard all selection criteria but boot_id.
    selection = Selection(boot_id=selection.boot_id)

    be = _find_one_entry(selection)

    _log_debug("Editing entry with boot_id='%s'", be.disp_boot_id)

    # Use a matching HostProfile is one exists, or the command line
    # OsProfile argument if set.
    machine_id = machine_id or be.machine_id
    version = version or be.version

    if be.read_only:
        raise ValueError(f"Cannot edit read-only entry with boot_id={be.disp_boot_id}")

    if not be.bp:
        raise BoomError(
            f"Internal error: BootEntry with boot_id={be.disp_boot_id} has no BootParams"
        )

    add_opts_list, del_opts_list = _merge_add_del_opts(be.bp, add_opts, del_opts)

    if no_fstab:
        add_opts_list, del_opts_list = _apply_no_fstab(add_opts_list, del_opts_list)

    if mounts:
        mount_units = parse_mount_units(mounts)
        add_opts_list.extend(mount_units)

    if swaps:
        swap_units = parse_swap_units(swaps)
        add_opts_list.extend(swap_units)

    _apply_btrfs_subvol_exclusive(be.bp, btrfs_subvol_path, btrfs_subvol_id)

    be._osp = profile or be._osp
    be.title = title or be.title
    be.machine_id = machine_id or be.machine_id
    be.architecture = architecture or be.architecture
    be.bp.version = version
    be.bp.root_device = root_device or be.bp.root_device
    be.bp.lvm_root_lv = lvm_root_lv or be.bp.lvm_root_lv
    be.bp.add_opts = add_opts_list
    be.bp.del_opts = del_opts_list

    if not allow_no_dev:
        check_root_device(be.bp.root_device)

    if images in (I_BACKUP, I_CACHE):
        if be.initrd:
            be.initrd = _cache_image(be.initrd, images == I_BACKUP, update=update)
        if be.linux:
            be.linux = _cache_image(be.linux, images == I_BACKUP, update=update)

    # Is the entry now identical to an existing entry?
    if len(find_entries(Selection(boot_id=be.boot_id))) > 1:
        raise ValueError(f"Entry already exists (boot_id={be.disp_boot_id}).")

    be.update_entry(expand=expand)
    __write_legacy()

    return be


def list_entries(selection: Optional[Selection] = None) -> List[Union[Any, BootEntry]]:
    """List entries matching selection criteria.

    Return a list of ``boom.bootloader.BootEntry`` objects matching
    the given criteria.

    Selection criteria may be expressed via a Selection object
    passed to the call using the ``selection`` parameter.

    :param selection: A Selection object giving selection
                      criteria for the operation.
    :returns: A list of matching BootEntry objects.
    :rtype: list
    """
    bes = find_entries(selection=selection)

    return bes


def _expand_fields(default_fields: str, output_fields: Optional[str]) -> str:
    """Expand output fields list from command line arguments."""

    if not output_fields:
        output_fields = default_fields
    elif output_fields.startswith("+"):
        output_fields = default_fields + "," + output_fields[1:]
    return output_fields


def print_entries(
    selection: Optional[Selection] = None,
    output_fields: Optional[str] = None,
    opts: Optional[ReportOpts] = None,
    sort_keys: Optional[str] = None,
    expand: Optional[bool] = None,
) -> None:
    """Print boot loader entries matching selection criteria.

    Format a set of ``boom.bootloader.BootEntry`` objects matching
    the given criteria, and output them as a report.

    Selection criteria may be expressed via a Selection object
    passed to the call using the ``selection`` parameter.

    :param selection: A Selection object giving selection
                      criteria for the operation
    :param output_fields: a comma-separated list of output fields
    :param opts: output formatting and control options
    :param sort_keys: a comma-separated list of sort keys
    :param expand: Expand bootloader environment variables
    :rtype: ``None``
    """
    output_fields = _expand_fields(_default_entry_fields, output_fields)

    bes = find_entries(selection=selection)
    selected = [ReportObj(be, be._osp, None, None) for be in bes]

    entry_fields = _expand_entry_fields if expand else _entry_fields
    report_fields = entry_fields + _profile_fields + _params_fields

    return _do_print_type(
        report_fields,
        selected,
        output_fields=output_fields,
        opts=opts,
        sort_keys=sort_keys,
        title="Entries",
    )


#
# OsProfile manipulation
#


def _find_profile(
    cmd_args: Namespace,
    version: str,
    machine_id: str,
    command: str,
    optional: bool = True,
) -> Optional[Union[OsProfile, HostProfile]]:
    """Find a matching profile (HostProfile or OsProfile) for this
    combination of version, machine_id, label and command line
    profile arguments

    :param cmd_args: The command argument namespace
    :param version: A version string to match
    :machine_id: The machine identifier to match
    :command: The command name to use in error messages
    :returns: A matching ``OsProfile``, ``HostProfile``, or ``None``
              if no match is found.
    """
    if not cmd_args.profile:
        # Attempt to find a matching OsProfile by version string
        osp = match_os_profile_by_version(version)
        os_id = osp.os_id if osp else None
        if not osp:
            print(f"No matching OsProfile found for version '{version}'")
    else:
        os_id = cmd_args.profile

    osps = find_profiles(Selection(os_id=os_id)) if os_id else None

    # Fail if an explicit profile was given and it is not found.
    if not osps and os_id is not None and os_id == cmd_args.profile:
        print(f"OsProfile not found: {os_id}")
        return None

    if osps and len(osps) > 1:
        print(f"OsProfile ID '{os_id}' is ambiguous")
        return None

    osp = osps[0] if osps else None

    if osp:
        _log_debug("Found OsProfile: %s", osp.os_id)

    # Attempt to match a host profile to the running host
    label = cmd_args.label or ""
    host_select = Selection(machine_id=machine_id, host_label=label)
    hps = find_host_profiles(host_select)
    hp = hps[0] if hps else None
    if len(hps) > 1:
        # This can only occur if host profiles have been edited outside
        # boom's control, such that there are one or more profiles with
        # matching machine_id and label.
        _log_error("Ambiguous host profile selection")
        return None
    if len(hps) == 1:
        _log_debug("Found HostProfile: %s", hps[0].host_id)
        if (hp and osp) and not osp.os_id.startswith(hp.os_id):
            _log_error(
                "Active host profile (host_id=%s, os_id=%s) "
                "conflicts with --profile=%s",
                hp.disp_host_id,
                hp.disp_os_id,
                osp.disp_os_id,
            )
            return None
    if not osp and not hps:
        if not optional:
            _log_error(
                "%s requires --profile or a matching OsProfile or HostProfile", command
            )
        return None

    return hp or osp


def _uname_heuristic(name: str, version_id: str):
    """Attempt to guess a uname pattern for a given OS name and
    version_id value.

    This is currently supported for Red Hat Enterprise Linux,
    CentOS Stream and Fedora since all distributions provide a
    fixed string in the UTS release string that can be used to
    match candidate kernel versions against.

    :returns: A ``uname_pattern`` string if a match is found for
              the running system or ``None`` otherwise.
    """
    el_uname = "el"
    fc_uname = "fc"
    _name_to_uname = {
        "Red Hat Enterprise Linux": el_uname,
        "Red Hat Enterprise Linux Server": el_uname,
        "Red Hat Enterprise Linux Workstation": el_uname,
        "CentOS Stream": el_uname,
        "Fedora Linux": fc_uname,
        "Fedora": fc_uname,
    }

    # Strip trailing minor version ident from elX_Y
    if "_" in version_id:
        version_id = version_id[0 : version_id.find("_")]
    if "." in version_id:
        version_id = version_id[0 : version_id.find(".")]

    if name in _name_to_uname:
        return f"{_name_to_uname[name]}{version_id}"
    return None


def _default_optional_keys(osp: OsProfile) -> str:
    """Set default optional keys for OsProfile

    Attempt to set default optional keys for a given OsProfile
    if the distribution is known to support the Red Hat BLS
    extensions.
    """
    all_optional_keys = "grub_users grub_arg grub_class id"
    _default_optional_keys = [
        "Red Hat Enterprise Linux",
        "Red Hat Enterprise Linux Server",
        "Red Hat Enterprise Linux Workstation",
        "CentOS Linux",
        "Fedora Linux",
        "Fedora",
    ]
    if osp.os_name in _default_optional_keys:
        return all_optional_keys
    return ""


def _os_profile_from_file(
    os_release: str, uname_pattern: str, profile_data: Optional[Dict[str, str]] = None
) -> OsProfile:
    """Create OsProfile from os-release file.

    Construct a new ``OsProfile`` object from the specified path,
    substituting each set kwarg parameter with the supplied value
    in the resulting object.

    :param os_release: The os-release file to read
    :param uname_pattern: A replacement uname_pattern value
    :param profile_data: Dictionary of profile key:value pairs
    :returns: A new OsProfile
    :rtype: OsProfile
    """
    if profile_data is None:
        profile_data = {}

    profile_data[BOOM_OS_UNAME_PATTERN] = uname_pattern
    osp = OsProfile.from_os_release_file(os_release, profile_data=profile_data)

    # When creating an OsProfile from an os-release file we cannot
    # guess the uname_pattern until after the file has been read and
    # the os_name and os_version_id values have been set.
    if uname_pattern:
        osp.uname_pattern = uname_pattern
    else:
        # Attempt to guess a uname_pattern for operating systems
        # that have predictable UTS release patterns.
        osp.uname_pattern = _uname_heuristic(osp.os_name, osp.os_version_id)

    if not osp.uname_pattern:
        osp.delete_profile()
        raise ValueError(f"Could not determine uname pattern for '{osp.os_name}'")

    if not osp.optional_keys:
        osp.optional_keys = _default_optional_keys(osp)

    osp.write_profile()
    return osp


def create_profile(
    name: Optional[str],
    short_name: Optional[str],
    version: Optional[str],
    version_id: Optional[str],
    uname_pattern: str = "",
    kernel_pattern: Optional[str] = None,
    initramfs_pattern: Optional[str] = None,
    root_opts_lvm2: Optional[str] = None,
    root_opts_btrfs: Optional[str] = None,
    options: Optional[str] = None,
    optional_keys: Optional[str] = None,
    profile_data: Optional[Dict[str, str]] = None,
    profile_file: Optional[str] = None,
) -> OsProfile:
    """Create new operating system profile.

    Create the specified OsProfile in the configured profiles
    directory.

    OsProfile key values may be specified either by passing
    individual keyword arguments, or by passing a dictionary
    of OsProfile key name to value pairs as the ``profile_data``
    argument. If a key is present as both a keyword argument
    and in the ``profile_data`` dictionary, the argument will
    take precedence.

    An error is raised if a matching profile already exists.

    :param name: The name of the new OsProfile
    :param short_name: The short name of the new OsProfile
    :param version: The version string of the new OsProfile
    :param version_id: The version ID string of the new OsProfile
    :param uname_pattern: A uname pattern to match for this profile
    :param kernel_pattern: Pattern to generate kernel paths
    :param initramfs_pattern: Pattern to generate initramfs paths
    :param root_opts_lvm2: Template options for LVM2 entries
    :param root_opts_btrfs: Template options for BTRFS entries
    :param options: Template kernel command line options
    :param profile_data: Dictionary of profile key:value pairs
    :param profile_file: File to be used for profile

    :returns: an ``OsProfile`` object for the new profile
    :rtype: ``OsProfile``
    :raises: ``ValueError`` if either required values are missing or
             a duplicate profile exists, or ``OSError`` if an error
             occurs while writing the profile file.
    """
    profile_data = profile_data or {}

    def _have_key(pd, arg, key):
        val = arg if arg is not None else pd.get(key)
        return bool(val) and str(val).strip() != ""

    if not profile_file:
        if not _have_key(profile_data, name, BOOM_OS_NAME):
            raise ValueError("Profile name cannot be empty.")

        if not _have_key(profile_data, short_name, BOOM_OS_SHORT_NAME):
            raise ValueError("Profile short name cannot be empty.")

        if not _have_key(profile_data, version, BOOM_OS_VERSION):
            raise ValueError("Profile version cannot be empty.")

        if not _have_key(profile_data, version_id, BOOM_OS_VERSION_ID):
            raise ValueError("Profile version ID cannot be empty.")

        # Allow keyword arguments to override
        if name:
            profile_data[BOOM_OS_NAME] = name
        if short_name:
            profile_data[BOOM_OS_SHORT_NAME] = short_name
        if version:
            profile_data[BOOM_OS_VERSION] = version
        if version_id:
            profile_data[BOOM_OS_VERSION_ID] = version_id

        if uname_pattern:
            profile_data[BOOM_OS_UNAME_PATTERN] = uname_pattern
        elif BOOM_OS_UNAME_PATTERN not in profile_data:
            # Attempt to guess a uname_pattern for operating systems
            # that have predictable UTS release patterns.
            pattern = _uname_heuristic(
                profile_data[BOOM_OS_NAME], profile_data[BOOM_OS_VERSION_ID]
            )
            if pattern:
                profile_data[BOOM_OS_UNAME_PATTERN] = pattern
            else:
                raise ValueError(
                    f"Could not determine uname pattern for '{profile_data[BOOM_OS_NAME]}'"
                )

    if kernel_pattern:
        profile_data[BOOM_OS_KERNEL_PATTERN] = kernel_pattern
    if initramfs_pattern:
        profile_data[BOOM_OS_INITRAMFS_PATTERN] = initramfs_pattern
    if root_opts_lvm2:
        profile_data[BOOM_OS_ROOT_OPTS_LVM2] = root_opts_lvm2
    if root_opts_btrfs:
        profile_data[BOOM_OS_ROOT_OPTS_BTRFS] = root_opts_btrfs
    if options:
        profile_data[BOOM_OS_OPTIONS] = options
    if optional_keys:
        profile_data[BOOM_OS_OPTIONAL_KEYS] = optional_keys

    if profile_file:
        return _os_profile_from_file(
            profile_file, uname_pattern, profile_data=profile_data
        )

    osp = OsProfile(name, short_name, version, version_id, profile_data=profile_data)

    if not osp.optional_keys:
        osp.optional_keys = _default_optional_keys(osp)

    osp.write_profile()
    return osp


def delete_profiles(selection: Optional[Selection] = None) -> int:
    """Delete profiles matching selection criteria.

    Delete the specified OsProfile or profiles from the configured
    profile directory. If ``os_id`` is used, or if the criteria
    specified match exactly one profile, a single entry is removed.
    If ``os_id`` is not used, and more than one matching profile
    is present, all matching profiles will be removed.

    Selection criteria are expressed via a Selection object
    passed to the call using the ``selection`` parameter.

    On success the number of profiles removed is returned.

    :param selection: A Selection object giving selection
                      criteria for the operation.
    :returns: the number of entries removed.
    :rtype: ``int``
    """
    osps = find_profiles(selection=selection)

    if not osps:
        raise IndexError("No matching profiles found.")

    deleted = 0
    for osp in osps:
        osp.delete_profile()
        deleted += 1

    return deleted


def clone_profile(
    selection: Optional[Selection] = None,
    name: Optional[str] = None,
    short_name: Optional[str] = None,
    version: Optional[str] = None,
    version_id: Optional[str] = None,
    uname_pattern: Optional[str] = None,
    kernel_pattern: Optional[str] = None,
    initramfs_pattern: Optional[str] = None,
    root_opts_lvm2: Optional[str] = None,
    root_opts_btrfs: Optional[str] = None,
    options: Optional[str] = None,
) -> OsProfile:
    """Clone an existing operating system profile.

    Create the specified profile in the configured profile directory
    by cloning all un-set parameters from the profile selected by
    the ``selection`` argument.

    An error is raised if a matching profile already exists, or if
    the selection criteria match more than one profile.

    :param selection: criteria matching the profile to clone.
    :param name: the name of the new profile.
    :param short_name: the short name of the new profile.
    :param version: the version string for the new profile.
    :param version_id: the version ID string for the new profile.
    :param uname_pattern: a uname pattern to match this profile.
    :param kernel_pattern: a kernel pattern to match this profile.
    :param initramfs_pattern: a initramfs pattern to match this profile.
    :param root_opts_lvm2: LVM2 root options template.
    :param root_opts_btrfs: BTRFS root options template.
    :param options: Kernel options template.

    :returns: a new ``OsProfile`` object.
    :rtype: ``OsProfile``
    :raises: ``ValueError`` if either required values are missing or
             a duplicate profile exists, or ``OSError`` if an error
             occurs while writing the profile file.
    """
    if not selection:
        raise ValueError("clone requires selection criteria")
    if not selection.os_id:
        raise ValueError("clone requires os_id")

    all_args = (
        name,
        short_name,
        version,
        version_id,
        uname_pattern,
        kernel_pattern,
        initramfs_pattern,
        root_opts_lvm2,
        root_opts_btrfs,
        options,
    )

    if not any(all_args):
        raise ValueError(
            "clone requires one or more of:\nname, "
            "short_name, version, version_id, uname_pattern,"
            "kernel_pattern, initramfs_pattern, root_opts_lvm2, "
            "root_opts_btrfs, options"
        )

    osps = find_profiles(selection)
    if not osps:
        raise ValueError(f"No matching profile found: {selection.os_id}")

    if len(osps) > 1:
        raise ValueError("Clone criteria must match exactly one profile")

    osp = osps.pop()

    # Clone unset keys
    name = name or osp.os_name
    short_name = short_name or osp.os_short_name
    version = version or osp.os_version
    version_id = version_id or osp.os_version_id
    uname_pattern = uname_pattern or osp.uname_pattern
    kernel_pattern = kernel_pattern or osp.kernel_pattern
    initramfs_pattern = initramfs_pattern or osp.initramfs_pattern
    root_opts_lvm2 = root_opts_lvm2 or osp.root_opts_lvm2
    root_opts_btrfs = root_opts_btrfs or osp.root_opts_btrfs
    options = options or osp.options

    clone_osp = OsProfile(
        name,
        short_name,
        version,
        version_id,
        uname_pattern=uname_pattern,
        kernel_pattern=kernel_pattern,
        initramfs_pattern=initramfs_pattern,
        root_opts_lvm2=root_opts_lvm2,
        root_opts_btrfs=root_opts_btrfs,
        options=options,
    )

    clone_osp.write_profile()

    return clone_osp


def edit_profile(
    selection: Optional[Selection] = None,
    uname_pattern: Optional[str] = None,
    kernel_pattern: Optional[str] = None,
    initramfs_pattern: Optional[str] = None,
    root_opts_lvm2: Optional[str] = None,
    root_opts_btrfs: Optional[str] = None,
    options: Optional[str] = None,
    optional_keys: Optional[str] = None,
) -> OsProfile:
    """Edit an existing operating system profile.

    Modify an existing OsProfile by changing one or more of the
    profile values.

    The modified OsProfile is written to disk and returned on
    success.

    :param selection: A Selection specifying the boot_id to edit
    :param uname_pattern: The new uname pattern
    :param kernel_pattern: The new kernel pattern
    :param initramfs_pattern: The new initramfs pattern
    :param root_opts_lvm2: The new LVM2 root options
    :param root_opts_btrfs: The new BTRFS root options
    :param options: The new kernel options template
    :returns: The modified ``OsProfile``
    :rtype: ``OsProfile``
    """
    if not selection:
        raise ValueError("edit requires selection criteria")
    # Discard all selection criteria but os_id.
    selection = Selection(os_id=selection.os_id)

    osp = None
    osps = find_profiles(Selection(os_id=selection.os_id))
    if not osps:
        raise ValueError(f"No matching profile found: {selection.os_id}")
    if len(osps) > 1:
        raise ValueError(f"OS profile identifier '{selection.os_id}' is ambiguous")

    osp = osps.pop()
    osp.uname_pattern = uname_pattern or osp.uname_pattern
    osp.kernel_pattern = kernel_pattern or osp.kernel_pattern
    osp.initramfs_pattern = initramfs_pattern or osp.initramfs_pattern
    osp.root_opts_lvm2 = root_opts_lvm2 or osp.root_opts_lvm2
    osp.root_opts_btrfs = root_opts_btrfs or osp.root_opts_btrfs
    osp.options = options or osp.options
    osp.optional_keys = optional_keys or osp.optional_keys
    osp.write_profile()
    return osp


def list_profiles(selection: None = None) -> List[OsProfile]:
    """List operating system profiles matching selection criteria.

    Return a list of ``boom.osprofile.OsProfile`` objects matching
    the given criteria.

    Selection criteria may be expressed via a Selection object
    passed to the call using the ``selection`` parameter.

    :param selection: A Selection object giving selection
                      criteria for the operation.
    :returns: a list of ``OsProfile`` objects.
    :rtype: list
    """
    osps = find_profiles(selection=selection)

    return osps


# pylint: disable=unused-argument
def print_profiles(
    selection: Optional[Selection] = None,
    opts: Optional[ReportOpts] = None,
    output_fields: Optional[str] = None,
    sort_keys: Optional[str] = None,
    expand: bool = False,
) -> None:
    """Print operating system profiles matching selection criteria.

    Selection criteria may be expressed via a Selection object
    passed to the call using the ``selection`` parameter.

    :param selection: A Selection object giving selection
                      criteria for the operation
    :param output_fields: a comma-separated list of output fields
    :param opts: output formatting and control options
    :param sort_keys: a comma-separated list of sort keys
    :param expand: unused
    :rtype: ``None``
    """
    output_fields = _expand_fields(_default_profile_fields, output_fields)

    osps = find_profiles(selection=selection)
    selected = [ReportObj(None, osp, None, None) for osp in osps]

    report_fields = _profile_fields
    return _do_print_type(
        report_fields,
        selected,
        output_fields=output_fields,
        opts=opts,
        sort_keys=sort_keys,
        title="OsProfiles",
    )


def create_host(
    machine_id: Optional[str] = None,
    host_name: Optional[str] = None,
    os_id: Optional[str] = None,
    label: Optional[str] = None,
    kernel_pattern: Optional[str] = None,
    initramfs_pattern: Optional[str] = None,
    root_opts_lvm2: Optional[str] = None,
    root_opts_btrfs: Optional[str] = None,
    options: Optional[str] = None,
    add_opts: Optional[str] = None,
    del_opts: Optional[str] = None,
    host_data: Optional[Dict[str, str]] = None,
) -> HostProfile:
    """Create new host profile.

    Create the specified HostProfile in the configured profiles
    directory.

    HostProfile key values may be specified either by passing
    individual keyword arguments, or by passing a dictionary
    of HostProfile key name to value pairs as the ``host_data``
    argument. If a key is present as both a keyword argument
    and in the ``host_data`` dictionary, the argument will
    take precedence.

    An error is raised if a matching profile already exists.

    :param machine_id: The machine_id of the host
    :param host_name: The full name of the new HostProfile
    :param label: An optional host label
    :param os_id: The os_id for the new host
    :param kernel_pattern: Pattern to generate kernel paths
    :param initramfs_pattern: Pattern to generate initramfs paths
    :param root_opts_lvm2: Template options for LVM2 entries
    :param root_opts_btrfs: Template options for BTRFS entries
    :param options: Template kernel command line options
    :param add_opts: Additional boot options for this profile
    :param del_opts: Boot options to delete for this profile
    :param host_data: Dictionary of profile key:value pairs

    :returns: a ``HostProfile`` object for the new profile
    :rtype: ``HostProfile``
    :raises: ``ValueError`` if either required values are missing or
             a duplicate profile exists, or ``OSError`` if an error
             occurs while writing the profile file.
    """

    host_data = host_data or {}

    def _have_key(hd, arg, key):
        val = arg if arg is not None else hd.get(key)
        return bool(val) and str(val).strip() != ""

    if not _have_key(host_data, host_name, BOOM_HOST_NAME):
        raise ValueError("Host name cannot be empty.")

    if not _have_key(host_data, machine_id, BOOM_ENTRY_MACHINE_ID):
        raise ValueError("Host machine_id cannot be empty.")

    if not _have_key(host_data, os_id, BOOM_OS_ID):
        raise ValueError("Host OS ID cannot be empty.")

    label = label or ""

    # Resolve required fields from kwargs or host_data
    machine_id = machine_id or host_data.get(BOOM_ENTRY_MACHINE_ID, "").strip()
    host_name = host_name or host_data.get(BOOM_HOST_NAME, "").strip()
    os_id = os_id or host_data.get(BOOM_OS_ID, "").strip()

    hp = HostProfile(
        machine_id=machine_id,
        host_name=host_name,
        label=label,
        os_id=os_id,
        kernel_pattern=kernel_pattern,
        initramfs_pattern=initramfs_pattern,
        root_opts_lvm2=root_opts_lvm2,
        root_opts_btrfs=root_opts_btrfs,
        add_opts=add_opts,
        del_opts=del_opts,
        options=options,
        profile_data=host_data,
    )

    hp.write_profile()
    return hp


def delete_hosts(selection: Optional[Selection] = None) -> int:
    """Delete host profiles matching selection criteria.

    Delete the specified ``HostProfile`` or profiles from the
    configured profile directory. If ``os_id`` is used, or if the
    criteria specified match exactly one profile, a single entry is
    removed. If ``host_id`` is not used, and more than one matching
    profile is present, all matching profiles will be removed.

    Selection criteria are expressed via a Selection object
    passed to the call using the ``selection`` parameter.

    On success the number of profiles removed is returned.

    :param selection: A Selection object giving selection
                      criteria for the operation.
    :returns: the number of entries removed.
    :rtype: ``int``
    """
    hps = find_host_profiles(selection=selection)

    if not hps:
        raise IndexError("No matching host profiles found.")

    deleted = 0
    for hp in hps:
        hp.delete_profile()
        deleted += 1

    return deleted


def clone_host(
    selection: Optional[Selection] = None,
    machine_id: Optional[str] = None,
    host_name: Optional[str] = None,
    label: Optional[str] = None,
    os_id: Optional[str] = None,
    kernel_pattern: Optional[str] = None,
    initramfs_pattern: Optional[str] = None,
    root_opts_lvm2: Optional[str] = None,
    root_opts_btrfs: Optional[str] = None,
    add_opts: Optional[str] = None,
    del_opts: Optional[str] = None,
    options: Optional[str] = None,
) -> HostProfile:
    """Clone an existing host profile.

    Create the specified profile in the configured profile directory
    by cloning all un-set parameters from the profile selected by
    the ``selection`` argument.

    An error is raised if a matching profile already exists, or if
    the selection criteria match more than one profile.

    :param selection: criteria matching the profile to clone.
    :param machine_id: the machine_id of the new host profile.
    :param host_name: the hostname of the new host profile.
    :param label: an optional host label.
    :param os_id: the operating system identifier for the host.
    :param kernel_pattern: The kernel pattern for the host.
    :param initramfs_pattern: The initramfs pattern for the host.
    :param root_opts_lvm2: LVM2 root options template.
    :param root_opts_btrfs: BTRFS root options template.
    :param add_opts: Additional boot options for this profile.
    :param del_opts: Boot options to delete for this profile.
    :param options: Kernel options template.

    :returns: a new ``HostProfile`` object.
    :rtype: ``HostProfile``
    :raises: ``ValueError`` if either required values are missing or
             a duplicate profile exists, or ``OSError`` if an error
             occurs while writing the profile file.
    """
    if not selection:
        raise ValueError("clone requires selection criteria")
    if not selection.host_id:
        raise ValueError("clone requires host_id")

    all_args = (
        machine_id,
        label,
        host_name,
        os_id,
        kernel_pattern,
        initramfs_pattern,
        root_opts_lvm2,
        root_opts_btrfs,
        add_opts,
        del_opts,
        options,
    )

    if not any(all_args):
        raise ValueError(
            "clone requires one or more of:\n"
            "--machine-id, --label, --name, --os-id, "
            "--kernel-pattern, --initramfs_pattern, "
            "--root-opts-lvm2, --root_opts-btrfs, "
            "--add-opts, --del-opts, --options"
        )

    hps = find_host_profiles(selection)
    if not hps:
        raise ValueError(f"No matching host profile found: {selection.host_id}")

    if len(hps) > 1:
        raise ValueError("Clone criteria must match exactly one profile")

    hp = hps.pop()

    # Clone unset keys
    machine_id = machine_id or hp.machine_id
    host_name = host_name or hp.host_name
    label = label or ""
    os_id = os_id or hp.os_id
    initramfs_pattern = initramfs_pattern or hp.initramfs_pattern
    kernel_pattern = kernel_pattern or hp.kernel_pattern
    root_opts_lvm2 = root_opts_lvm2 or hp.root_opts_lvm2
    root_opts_btrfs = root_opts_btrfs or hp.root_opts_btrfs
    add_opts = add_opts or hp.add_opts
    del_opts = del_opts or hp.del_opts
    options = options or hp.options

    clone_hp = HostProfile(
        machine_id=machine_id,
        host_name=host_name,
        label=label,
        os_id=os_id,
        kernel_pattern=kernel_pattern,
        initramfs_pattern=initramfs_pattern,
        root_opts_lvm2=root_opts_lvm2,
        root_opts_btrfs=root_opts_btrfs,
        add_opts=add_opts,
        del_opts=del_opts,
        options=options,
    )

    clone_hp.write_profile()

    return clone_hp


def edit_host(
    selection: Optional[Selection] = None,
    machine_id: Optional[str] = None,
    os_id: Optional[str] = None,
    host_name: Optional[str] = None,
    label: Optional[str] = None,
    kernel_pattern: Optional[str] = None,
    initramfs_pattern: Optional[str] = None,
    root_opts_lvm2: Optional[str] = None,
    root_opts_btrfs: Optional[str] = None,
    add_opts: Optional[str] = None,
    del_opts: Optional[str] = None,
    options: Optional[str] = None,
) -> HostProfile:
    """Edit an existing host profile.

    Modify an existing HostProfile by changing one or more of the
    profile values.

    The modified HostProfile is written to disk and returned on
    success.

    :param selection: A Selection specifying the boot_id to edit
    :param machine_id: The machine id for the edited host profile
    :param os_id: The OS id for the edited host profile
    :param host_name: The host name for the edited host profile
    :param label: an optional host label
    :param kernel_pattern: The new kernel pattern
    :param initramfs_pattern: The new initramfs pattern
    :param root_opts_lvm2: The new LVM2 root options
    :param root_opts_btrfs: The new BTRFS root options
    :param add_opts: Additional boot options for this profile.
    :param del_opts: Boot options to delete for this profile.
    :param options: The new kernel options template

    :returns: The modified ``HostProfile``
    :rtype: ``HostProfile``
    """
    if not selection:
        raise ValueError("edit requires selection criteria")
    # Discard all selection criteria but host_id.
    selection = Selection(host_id=selection.host_id)

    hps = None
    hps = find_host_profiles(selection)
    if not hps:
        raise ValueError(f"No matching profile found: {selection.host_id}")
    if len(hps) > 1:
        raise ValueError(f"Host profile identifier '{selection.host_id}' is ambiguous")

    hp = hps.pop()
    hp.delete_profile()
    hp.machine_id = machine_id or hp.machine_id
    hp.host_name = host_name or hp.host_name
    hp.label = label or hp.label
    hp.os_id = os_id or hp.os_id
    hp.kernel_pattern = kernel_pattern or hp.kernel_pattern
    hp.initramfs_pattern = initramfs_pattern or hp.initramfs_pattern
    hp.root_opts_lvm2 = root_opts_lvm2 or hp.root_opts_lvm2
    hp.root_opts_btrfs = root_opts_btrfs or hp.root_opts_btrfs
    hp.add_opts = add_opts or hp.add_opts
    hp.del_opts = del_opts or hp.del_opts
    hp.options = options or hp.options
    hp.write_profile()
    return hp


def list_hosts(selection: None = None) -> List[HostProfile]:
    """List host profiles matching selection criteria.

    Return a list of ``boom.hostprofile.HostProfile`` objects
    matching the given criteria.

    Selection criteria may be expressed via a Selection object
    passed to the call using the ``selection`` parameter.

    :param selection: A Selection object giving selection
                      criteria for the operation.
    :returns: a list of ``HostProfile`` objects.
    :rtype: list
    """
    hps = find_host_profiles(selection=selection)

    return hps


# pylint: disable=unused-argument
def print_hosts(
    selection: Optional[Selection] = None,
    opts: Optional[ReportOpts] = None,
    output_fields: Optional[str] = None,
    sort_keys: Optional[str] = None,
    expand: bool = False,
) -> None:
    """Print host profiles matching selection criteria.

    Selection criteria may be expressed via a Selection object
    passed to the call using the ``selection`` parameter.

    :param selection: A Selection object giving selection
                      criteria for the operation
    :param output_fields: a comma-separated list of output fields
    :param opts: output formatting and control options
    :param sort_keys: a comma-separated list of sort keys
    :param expand: unused
    :rtype: ``None``
    """
    output_fields = _expand_fields(_default_host_fields, output_fields)

    hps = find_host_profiles(selection=selection)
    selected = [ReportObj(None, None, hp, None) for hp in hps]
    report_fields = _host_fields
    return _do_print_type(
        report_fields,
        selected,
        output_fields=output_fields,
        opts=opts,
        sort_keys=sort_keys,
        title="HostProfiles",
    )


# pylint: disable=unused-argument
def _print_cache(
    find_fn: Callable,
    selection: Optional[Selection] = None,
    opts: Optional[ReportOpts] = None,
    output_fields: Optional[str] = None,
    sort_keys: Optional[str] = None,
    expand: bool = False,
) -> None:
    """Print cache entries (with or without images) matching selection
    criteria.

    Selection criteria may be expressed via a ``Selection`` object
    passed to the call using the ``Selection`` parameter.

    :param selection: A Selection object giving selection
                      criteria for the operation
    :param output_fields: a comma-separated list of output fields
    :param opts: output formatting and control options
    :param sort_keys: a comma-separated list of sort keys
    :param expand: unused
    :rtype: ``None``
    """
    output_fields = _expand_fields(_default_cache_fields, output_fields)

    ces = find_fn(selection=selection)
    selected = [ReportObj(None, None, None, ce) for ce in ces]
    report_fields = _cache_fields
    return _do_print_type(
        report_fields,
        selected,
        output_fields=output_fields,
        opts=opts,
        sort_keys=sort_keys,
        title="Cache",
    )


# pylint: disable=unused-argument
def print_cache(
    selection: Optional[Selection] = None,
    opts: Optional[ReportOpts] = None,
    output_fields: Optional[str] = None,
    sort_keys: Optional[str] = None,
    expand: bool = False,
) -> None:
    """Print cache entries matching selection criteria.

    Selection criteria may be expressed via a ``Selection`` object
    passed to the call using the ``Selection`` parameter.

    :param selection: A Selection object giving selection
                      criteria for the operation
    :param output_fields: a comma-separated list of output fields
    :param opts: output formatting and control options
    :param sort_keys: a comma-separated list of sort keys
    :param expand: unused
    :rtype: ``None``
    """
    return _print_cache(
        find_cache_paths,
        selection=selection,
        opts=opts,
        output_fields=output_fields,
        sort_keys=sort_keys,
    )


# pylint: disable=unused-argument
def print_cache_images(
    selection: Optional[Selection] = None,
    opts: Optional[ReportOpts] = None,
    output_fields: Optional[str] = None,
    sort_keys: Optional[str] = None,
    expand: bool = False,
) -> None:
    """Print cache entries and images matching selection criteria.

    Selection criteria may be expressed via a ``Selection`` object
    passed to the call using the ``Selection`` parameter.

    :param selection: A Selection object giving selection
                      criteria for the operation
    :param output_fields: a comma-separated list of output fields
    :param opts: output formatting and control options
    :param sort_keys: a comma-separated list of sort keys
    :param expand: unused
    :rtype: ``None``
    """
    return _print_cache(
        find_cache_images,
        selection=selection,
        opts=opts,
        output_fields=output_fields,
        sort_keys=sort_keys,
    )


def show_legacy(selection=None, loader=BOOM_LOADER_GRUB1):
    """Print boot entries in legacy boot loader formats.

    :param selection: A Selection object giving selection criteria
                      for the operation
    :param loader: Which boot loader to use
    """
    (_, decorator, _) = find_legacy_loader(loader, None)
    bes = find_entries(selection=selection)
    [print(decorator(be)) for be in bes]


def create_config(boot_path: Optional[str] = None):
    """Create default boom configuration in `boot_path`."""
    bc = get_boom_config()

    if boot_path is not None:
        if not path_exists(boot_path):
            raise BoomConfigError(f"Specified boot_path not found: {boot_path}")
        if not isdir(boot_path):
            raise BoomConfigError(
                f"Specified boot_path is not a directory: {boot_path}"
            )
        bc.boot_path = boot_path
        bc.boom_path = join(boot_path, "boom")
        bc.cache_path = join(bc.boom_path, "cache")

        try:
            runtime_boot_path = (
                abspath(boot_path) if not isabs(boot_path) else boot_path
            )
            set_boot_path(runtime_boot_path)
        except ValueError as err:
            _log_error("Failed to set boot path to '%s': %s", runtime_boot_path, err)
            raise BoomConfigError("Failed to set boot_path") from err

    _log_info("Creating default configuration in %s", bc.boot_path)

    boom_conf = join(bc.boom_path, BOOM_CONFIG_FILE)
    if path_exists(boom_conf):
        _log_warn("Boom configuration already present in %s", bc.boom_path)

    try:
        makedirs(bc.boom_path, mode=0o755, exist_ok=True)
        for subdir in ["cache", "hosts", "profiles"]:
            makedirs(join(bc.boom_path, subdir), mode=0o700, exist_ok=True)
    except OSError as err:
        _log_error(
            "Failed to create boom directory layout in %s: %s", bc.boot_path, err
        )
        raise BoomConfigError("Failed to create boom directory layout") from err

    try:
        write_boom_config(config=bc, path=boom_conf)
    except OSError as err:
        _log_error("Failed to write boom configuration to %s: %s", boom_conf, err)
        raise BoomConfigError("Failed to write boom configuration") from err

    try:
        set_boom_path("boom")
    except ValueError as err:
        attempted = join(get_boot_path(), "boom")
        _log_error("Failed to set boom_path to '%s': %s", attempted, err)
        raise BoomConfigError("Failed to set boom_path") from err


#
# boom command line tool
#


def _apply_profile_overrides(boot_entry: BootEntry, cmd_args: Namespace):
    if cmd_args.linux:
        boot_entry.linux = cmd_args.linux

    if cmd_args.initrd:
        boot_entry.initrd = cmd_args.initrd


def _optional_key_to_arg(optional_key: str) -> Optional[str]:
    """Map a Boom optional key name constant to the boom command line
    argument it corresponds to.

    Returns the argument name in long option style, or None if no
    matching optional key exists.
    """
    _key_map = {
        BOOM_ENTRY_GRUB_USERS: "--grub-users",
        BOOM_ENTRY_GRUB_ARG: "--grub-arg",
        BOOM_ENTRY_GRUB_CLASS: "--grub-class",
        BOOM_ENTRY_GRUB_ID: "--grub-id",
    }
    return _key_map[optional_key] if optional_key in _key_map else None


def _apply_optional_keys(be: BootEntry, cmd_args: Namespace):
    """Set the optional key values defined by ``cmd_args`` in the
    ``BootEntry`` ``be``. This function assumes that the caller
    has already checked that the active ``OsProfile`` accepts these
    optional keys, or will handle exceptions raised by setting an
    invalid optional key.
    """
    if cmd_args.id:
        be.id = cmd_args.id.strip()
    if cmd_args.grub_arg:
        be.grub_arg = cmd_args.grub_arg.strip()
    if cmd_args.grub_class:
        be.grub_class = cmd_args.grub_class.strip()
    if cmd_args.grub_users:
        be.grub_users = cmd_args.grub_users.strip()


def _set_optional_key_defaults(
    profile: Union[OsProfile, HostProfile], cmd_args: Namespace
):
    """Apply default values for all optional keys supported by
    ``profile`` to command line arguments ``cmd_args``.
    """
    for opt_key in OPTIONAL_KEYS:
        bls_key = key_to_bls_name(opt_key)
        if bls_key not in profile.optional_keys:
            if getattr(cmd_args, bls_key) is not None:
                print(
                    f"Profile with os_id='{profile.disp_os_id}' "
                    f"does not support {_optional_key_to_arg(opt_key)}"
                )
                return 1
        else:
            if getattr(cmd_args, bls_key) is None:
                setattr(cmd_args, bls_key, optional_key_default(opt_key))
    return 0


def _lv_from_device_string(dev_path: str) -> Optional[str]:
    """Return an LVM2 vg/lv name from a /dev/mapper device path."""
    if dev_path.startswith("/dev/mapper/"):
        vg_lv_name = basename(dev_path)
        if "-" not in vg_lv_name:
            return None
        vg_lv_name = re.sub(r"([^-])-([^-])", r"\1/\2", vg_lv_name)
        vg_lv_name = re.sub(r"--", r"-", vg_lv_name)
        return vg_lv_name
    if dev_path.startswith("/dev"):
        try:
            (_, vg, lv) = dev_path.lstrip("/").split("/")
        except ValueError:
            return None
        return join(vg, lv)
    return None


def os_options_from_cmdline() -> Optional[str]:
    """Generate an os-options template from the running system."""
    options = ""
    vg_lv_name = None
    have_root = False
    with open("/proc/cmdline", "r", encoding="utf8") as proc_cmdline:
        cmdline = proc_cmdline.read().strip()
    for word in cmdline.split():
        if word.startswith("root="):
            have_root = True
            root_dev = word.split("=")[1]
            vg_lv_name = _lv_from_device_string(root_dev)
            options += "root=%{root_device} ro %{root_opts} "
        elif word.startswith("rd.lvm.lv="):
            if vg_lv_name:
                if vg_lv_name in word:
                    continue
                else:
                    options += word + " "
        elif word.startswith("rootflags="):
            continue
        elif word == "ro":
            continue
        elif word.startswith("BOOT_IMAGE"):
            continue
        elif word.startswith("stratis.rootfs.pool_uuid"):
            continue
        else:
            options += word + " "
    return options.strip() if have_root else None


def _create_cmd(
    cmd_args: Namespace,
    _select: Optional[Selection],
    _opts: Optional[ReportOpts],
    identifier: Optional[str],
) -> int:
    """Create entry command handler.

    Attempt to create a new boot entry using the arguments
    supplied in ``cmd_args`` and return the command status
    as an integer.

    :param cmd_args: Command line arguments for the command
    :param _select: Unused
    :param _opts: Unused
    :param identifier: Unused
    :returns: integer status code returned from ``main()``
    """
    if identifier is not None:
        print("entry create does not accept <identifier>")
        return 1

    if not cmd_args.version:
        version = get_uts_release()
        if not version:
            print("create requires --version")
            return 1
    else:
        version = cmd_args.version

    if not cmd_args.machine_id:
        # Use host machine-id by default
        machine_id = get_machine_id()
        if not machine_id:
            print("Could not determine machine_id")
            return 1
    else:
        machine_id = cmd_args.machine_id

    if not cmd_args.root_device:
        print("create requires --root-device")
        return 1
    root_device = cmd_args.root_device

    lvm_root_lv = cmd_args.root_lv if cmd_args.root_lv else None
    subvol = cmd_args.btrfs_subvolume
    (subvol_path, subvol_id) = parse_btrfs_subvol(subvol)

    no_dev = cmd_args.no_dev

    profile = _find_profile(cmd_args, version, machine_id, "create", optional=False)

    if not profile:
        return 1

    if _set_optional_key_defaults(profile, cmd_args):
        return 1

    if not cmd_args.title and not profile.title:
        print("create requires --title")
        return 1

    # Empty title will be filled out by profile
    title = cmd_args.title

    add_opts = cmd_args.add_opts
    del_opts = cmd_args.del_opts

    arch = cmd_args.architecture

    images = I_BACKUP if cmd_args.backup else I_NONE

    try:
        be = create_entry(
            title,
            version,
            machine_id,
            root_device,
            lvm_root_lv=lvm_root_lv,
            btrfs_subvol_path=subvol_path,
            btrfs_subvol_id=subvol_id,
            profile=profile,
            add_opts=add_opts,
            del_opts=del_opts,
            architecture=arch,
            write=False,
            expand=cmd_args.expand_variables,
            allow_no_dev=no_dev,
            images=images,
            update=cmd_args.update,
            no_fstab=cmd_args.no_fstab,
            mounts=cmd_args.mount,
            swaps=cmd_args.swap,
        )

    except BoomRootDeviceError as brde:
        print(brde)
        print("Creating an entry with no valid root device requires --no-dev")
        return 1
    except (ValueError, BoomConfigError, OSError) as err:
        print(err, file=sys.stderr)
        return 1

    _apply_profile_overrides(be, cmd_args)
    _apply_optional_keys(be, cmd_args)

    try:
        be.write_entry(expand=cmd_args.expand_variables)
        __write_legacy()
    except (BoomError, OSError, ValueError) as err:  # pragma: no cover
        if cmd_args.debug:
            raise
        print(err, file=sys.stderr)
        return 1

    print(f"Created entry with boot_id {be.disp_boot_id}:")
    print(_str_indent(str(be), 2))
    return 0


def _delete_cmd(
    cmd_args: Namespace,
    select: Optional[Selection],
    opts: Optional[ReportOpts],
    identifier: Optional[str],
) -> int:
    """Delete entry command handler.

    Attempt to delete boot entries matching the selection criteria
    given in ``select``.

    :param cmd_args: Command line arguments for the command
    :param select: Selection criteria for the entries to remove
    :param opts: Optional ReportOpts
    :param identifier: Optional identifier
    :returns: integer status code returned from ``main()``
    """
    # If a boot_id is given as a command line argument treat it as
    # a single boot entry to delete and ignore any other criteria.
    identifier = identifier or cmd_args.boot_id
    if identifier is not None:
        select = Selection(boot_id=identifier)

    if not select or select.is_null():
        print("delete requires selection criteria")
        return 1

    if cmd_args.options:
        fields = cmd_args.options
    else:
        fields = None
    try:
        if cmd_args.verbose:
            print_entries(
                selection=select,
                output_fields=fields,
                opts=opts,
                sort_keys=cmd_args.sort,
            )
        nr = delete_entries(select)
    except (ValueError, IndexError) as err:
        print(err, file=sys.stderr)
        return 1

    print(f"Deleted {nr} entr{'ies' if nr > 1 else 'y'}")
    return 0


def _clone_cmd(
    cmd_args: Namespace,
    select: Optional[Selection],
    _opts: Optional[ReportOpts],
    identifier: Optional[str],
) -> int:
    """Clone entry command handler.

    Attempt to create a new boot entry by cloning an existing
    entry. The ``boot_id`` of the supplied ``Selection`` object
    is used to select the entry to clone. Any set entry values
    supplied in ``cmd_args`` will be used to modify the newly
    cloned entry.

    :param cmd_args: Command line arguments for the command
    :param select: The ``boot_id`` to clone
    :param _opts: Unused
    :param identifier: Optional identifier
    :returns: integer status code returned from ``main()``
    """
    identifier = identifier or cmd_args.boot_id
    if identifier is not None:
        select = Selection(boot_id=identifier)

    if not select or select.is_null():
        print("clone requires selection criteria")
        return 1

    title = cmd_args.title
    root_device = cmd_args.root_device
    lvm_root_lv = cmd_args.root_lv
    subvol = cmd_args.btrfs_subvolume
    (subvol_path, subvol_id) = parse_btrfs_subvol(subvol)

    # Discard all selection criteria but boot_id.
    select = Selection(boot_id=select.boot_id)
    try:
        be = _find_one_entry(select)
    except ValueError as err:
        print(err, file=sys.stderr)
        return 1

    version = cmd_args.version or be.version
    machine_id = cmd_args.machine_id or be.machine_id

    profile = _find_profile(cmd_args, version, machine_id, "clone")

    add_opts = cmd_args.add_opts
    del_opts = cmd_args.del_opts

    arch = cmd_args.architecture

    images = I_BACKUP if cmd_args.backup else I_NONE

    try:
        be = clone_entry(
            select,
            title=title,
            version=version,
            machine_id=machine_id,
            root_device=root_device,
            lvm_root_lv=lvm_root_lv,
            btrfs_subvol_path=subvol_path,
            btrfs_subvol_id=subvol_id,
            profile=profile,
            add_opts=add_opts,
            del_opts=del_opts,
            architecture=arch,
            expand=cmd_args.expand_variables,
            allow_no_dev=cmd_args.no_dev,
            images=images,
            update=cmd_args.update,
            no_fstab=cmd_args.no_fstab,
            mounts=cmd_args.mount,
            swaps=cmd_args.swap,
        )

    except (ValueError, BoomConfigError, OSError) as err:
        print(err, file=sys.stderr)
        return 1

    # Command-line overrides take precedence over any overridden values
    # in the cloned BootEntry.
    _apply_profile_overrides(be, cmd_args)
    _apply_optional_keys(be, cmd_args)

    try:
        be.write_entry(expand=cmd_args.expand_variables)
        __write_legacy()
    except (BoomError, OSError, ValueError) as err:  # pragma: no cover
        if cmd_args.debug:
            raise
        print(err, file=sys.stderr)
        return 1

    print(
        f"Cloned entry with boot_id {select.boot_id} " f"as boot_id {be.disp_boot_id}:"
    )
    print(_str_indent(str(be), 2))

    return 0


def _show_cmd(
    cmd_args: Namespace,
    select: Optional[Selection],
    _opts: Optional[ReportOpts],
    identifier: Optional[str],
) -> int:
    """Show entry command handler.

    Show the boot entries that match the given selection criteria in
    BLS boot entry notation: one key per line, with keys and values
    separated by a single space character.

    :param cmd_args: Command line arguments for the command
    :param select: Selection criteria for the entries to show.
    :param _opts: Unused
    :param identifier: Optional identifier
    :returns: integer status code returned from ``main()``
    """
    identifier = identifier or cmd_args.boot_id
    if identifier is not None:
        select = Selection(boot_id=identifier)

    if identifier or cmd_args.all:
        if select:
            select.allow_null_profile = True
        else:
            select = Selection(allow_null=True)

    try:
        bes = find_entries(selection=select)
    except ValueError as err:
        print(err, file=sys.stderr)
        return 1
    first = True
    for be in bes:
        ws = "" if first else "\n"
        be_str = be.expanded() if cmd_args.expand_variables else str(be)
        be_str = _str_indent(be_str, 2)
        print(f"{ws}Boot Entry (boot_id={be.disp_boot_id})\n{be_str}")
        first = False
    return 0


def _generic_list_cmd(
    cmd_args: Namespace,
    select: Optional[Selection],
    opts: Optional[ReportOpts],
    print_fn: Callable,
) -> int:
    """Generic list command implementation.

    Implements a simple list command that applies selection criteria
    and calls a print_*() API function to display results.

    Callers should initialise identifier and select appropriately
    for the specific command arguments.

    :param cmd_args: the command arguments
    :param select: selection criteria
    :param opts: reporting options object
    :param print_fn: the API call to display results. The function
                     must accept the selection, output_fields,
                     opts, and sort_keys keyword arguments
    :returns: integer status code returned from ``main()``
    """
    if cmd_args.options:
        fields = cmd_args.options
    else:
        fields = None

    try:
        print_fn(
            selection=select,
            output_fields=fields,
            opts=opts,
            sort_keys=cmd_args.sort,
            expand=cmd_args.expand_variables,
        )
    except ValueError as err:
        print(err, file=sys.stderr)
        return 1
    return 0


def _list_cmd(
    cmd_args: Namespace,
    select: Optional[Selection],
    opts: Optional[ReportOpts],
    identifier: Optional[str],
) -> int:
    """List entry command handler.
    List the boot entries that match the given selection criteria as
    a tabular report, with one boot entry per row.

    :param cmd_args: Command line arguments for the command
    :param select: Selection criteria for the entries to list
    :param opts: Optional ReportOpts
    :param identifier: Optional identifier
    :returns: integer status code returned from ``main()``
    """
    identifier = identifier or cmd_args.boot_id
    if identifier is not None:
        select = Selection(boot_id=identifier)

    if cmd_args.all:
        if select:
            select.allow_null_profile = True
        else:
            select = Selection(allow_null=True)

    return _generic_list_cmd(cmd_args, select, opts, print_entries)


def _edit_cmd(
    cmd_args: Namespace,
    select: Optional[Selection],
    _opts: Optional[ReportOpts],
    identifier: Optional[str],
) -> int:
    """Edit entry command handler.

    Attempt to edit an existing boot entry. The ``boot_id`` of
    the supplied ``Selection`` object is used to select the entry
    to edit. Any set entry values supplied in ``cmd_args`` will be
    used to modify the edited entry.

    :param cmd_args: Command line arguments for the command
    :param select: The ``boot_id`` of the entry to edit
    :param _opts: Unused
    :param identifier: Optional identifier
    :returns: integer status code returned from ``main()``
    """
    identifier = identifier or cmd_args.boot_id
    if identifier is not None:
        select = Selection(boot_id=identifier)

    if not select or select.is_null():
        print("edit requires selection criteria")
        return 1

    title = cmd_args.title
    root_device = cmd_args.root_device
    lvm_root_lv = cmd_args.root_lv
    subvol = cmd_args.btrfs_subvolume
    (subvol_path, subvol_id) = parse_btrfs_subvol(subvol)

    # Discard all selection criteria but boot_id.
    select = Selection(boot_id=select.boot_id)
    try:
        be = _find_one_entry(select)
    except ValueError as err:
        print(err, file=sys.stderr)
        return 1

    version = cmd_args.version or be.version
    machine_id = cmd_args.machine_id or be.machine_id

    profile = _find_profile(cmd_args, version, machine_id, "edit")

    add_opts = cmd_args.add_opts
    del_opts = cmd_args.del_opts

    arch = cmd_args.architecture

    images = I_BACKUP if cmd_args.backup else I_NONE

    try:
        be = edit_entry(
            selection=select,
            title=title,
            version=version,
            machine_id=machine_id,
            root_device=root_device,
            lvm_root_lv=lvm_root_lv,
            btrfs_subvol_path=subvol_path,
            btrfs_subvol_id=subvol_id,
            profile=profile,
            add_opts=add_opts,
            del_opts=del_opts,
            architecture=arch,
            expand=cmd_args.expand_variables,
            allow_no_dev=cmd_args.no_dev,
            images=images,
            update=cmd_args.update,
            no_fstab=cmd_args.no_fstab,
            mounts=cmd_args.mount,
            swaps=cmd_args.swap,
        )
    except BoomRootDeviceError as brde:
        print(brde)
        print("Editing an entry with no valid root device requires --no-dev")
        return 1
    except (ValueError, BoomConfigError, OSError) as err:
        print(err, file=sys.stderr)
        return 1

    # Command-line overrides take precedence over any overridden values
    # in the edited BootEntry.
    _apply_profile_overrides(be, cmd_args)

    try:
        be.write_entry(expand=cmd_args.expand_variables)
        __write_legacy()
    except (BoomError, OSError, ValueError) as err:  # pragma: no cover
        if cmd_args.debug:
            raise
        print(err, file=sys.stderr)
        return 1

    print(f"Edited entry, boot_id now: {be.disp_boot_id}")
    print(_str_indent(str(be), 2))
    return 0


def _create_profile_cmd(
    cmd_args: Namespace,
    _select: Optional[Selection],
    _opts: Optional[ReportOpts],
    identifier: Optional[str],
) -> int:
    """Create profile command handler.
    Attempt to create a new OS profile using the arguments
    supplied in ``cmd_args`` and return the command status
    as an integer.

    :param cmd_args: Command line arguments for the command
    :param _select: Unused
    :param _opts: Unused
    :param identifier: Optional identifier
    :returns: integer status code returned from ``main()``
    """
    if identifier is not None:
        print("profile create does not accept <identifier>")
        return 1

    if cmd_args.options:
        print("Invalid argument for profile create: --options")
        return 1

    if cmd_args.os_release or cmd_args.from_host:
        name = None
        short_name = None
        version = None
        version_id = None
        release = cmd_args.os_release or "/etc/os-release"
    else:
        if not cmd_args.name:
            print("profile create requires --name")
            return 1
        name = cmd_args.name

        if not cmd_args.short_name:
            print("profile create requires --short-name")
            return 1
        short_name = cmd_args.short_name

        if not cmd_args.os_version:
            print("profile create requires --os-version")
            return 1
        version = cmd_args.os_version

        if not cmd_args.os_version_id:
            print("profile create requires --os-version-id")
            return 1
        version_id = cmd_args.os_version_id
        release = None

    if cmd_args.from_host and not cmd_args.os_options:
        options = os_options_from_cmdline()
    else:
        options = cmd_args.os_options

    try:
        osp = create_profile(
            name,
            short_name,
            version,
            version_id,
            uname_pattern=cmd_args.uname_pattern,
            kernel_pattern=cmd_args.kernel_pattern,
            initramfs_pattern=cmd_args.initramfs_pattern,
            root_opts_lvm2=cmd_args.lvm_opts,
            root_opts_btrfs=cmd_args.btrfs_opts,
            options=options,
            optional_keys=cmd_args.optional_keys,
            profile_file=release,
        )
    except ValueError as err:
        print(err, file=sys.stderr)
        return 1
    print(f"Created profile with os_id {osp.disp_os_id}:")
    print(_str_indent(str(osp), 2))
    return 0


def _delete_profile_cmd(
    cmd_args: Namespace,
    select: Optional[Selection],
    _opts: Optional[ReportOpts],
    identifier: Optional[str],
) -> int:
    """Delete profile command handler.

    Attempt to delete OS profiles matching the selection criteria
    given in ``select``.

    :param cmd_args: Command line arguments for the command
    :param select: Selection criteria for the profiles to remove
    :param _opts: Optional ReportOpts (unused)
    :param identifier: Optional identifier
    :returns: integer status code returned from ``main()``
    """
    # If an os_id is given as a command line argument treat it as
    # a single OsProfile to delete and ignore any other criteria.
    identifier = identifier or cmd_args.profile
    if identifier is not None:
        select = Selection(os_id=identifier)

    if not select or select.is_null():
        print("profile delete requires selection criteria")
        return 1

    if cmd_args.options:
        fields = cmd_args.options
    else:
        fields = None

    try:
        if cmd_args.verbose:
            print_profiles(select, output_fields=fields, sort_keys=cmd_args.sort)
        nr = delete_profiles(select)
    except (ValueError, IndexError) as err:
        print(err, file=sys.stderr)
        return 1
    print(f"Deleted {nr} profile{'s' if nr > 1 else ''}")
    return 0


def _clone_profile_cmd(
    cmd_args: Namespace,
    select: Optional[Selection],
    _opts: Optional[ReportOpts],
    identifier: Optional[str],
) -> int:
    """Clone profile command handler.

    Attempt to create a new OS profile by cloning an existing
    profile. The ``os_id`` of the supplied ``Selection`` object
    is used to select the profile to clone. Any set profile values
    supplied in ``cmd_args`` will be used to modify the newly
    cloned profile.

    :param cmd_args: Command line arguments for the command
    :param select: The ``os_id`` to clone
    :param _opts: Optional ReportOpts (unused)
    :param identifier: Optional identifier
    :returns: integer status code returned from ``main()``
    """
    name = cmd_args.name
    short_name = cmd_args.short_name
    version = cmd_args.os_version
    version_id = cmd_args.os_version_id
    uname_pattern = cmd_args.uname_pattern
    kernel_pattern = cmd_args.kernel_pattern
    initramfs_pattern = cmd_args.initramfs_pattern
    root_opts_lvm2 = cmd_args.lvm_opts
    root_opts_btrfs = cmd_args.btrfs_opts
    options = cmd_args.os_options

    identifier = identifier or cmd_args.profile
    if identifier is not None:
        select = Selection(os_id=identifier)

    if not select or select.is_null():
        print("profile clone requires selection criteria")
        return 1

    # Discard all selection criteria but os_id.
    select = Selection(os_id=select.os_id)

    try:
        osp = clone_profile(
            selection=select,
            name=name,
            short_name=short_name,
            version=version,
            version_id=version_id,
            uname_pattern=uname_pattern,
            kernel_pattern=kernel_pattern,
            initramfs_pattern=initramfs_pattern,
            root_opts_lvm2=root_opts_lvm2,
            root_opts_btrfs=root_opts_btrfs,
            options=options,
        )

    except ValueError as err:
        print(err, file=sys.stderr)
        return 1
    print(f"Cloned profile with os_id {select.os_id} as {osp.disp_os_id}:")
    print(_str_indent(str(osp), 2))
    return 0


def _generic_show_cmd(
    select: Optional[Selection], find_fn: Callable, fmt: str, get_data: Callable
) -> int:
    """Generic show command handler.

    Show the objects returned by calling `find_fn` with selection
    criteria `select`, using the format string `fmt`, and the data
    tuple returned by calling `get_data` for each object.

    :param select: Selection() object with search criteria.
    :param find_fn: A find_*() function accepting Selection.
    :param fmt: A Python format string.
    :param get_data: A function returning a tuple of data values
                     satisfying the format string `fmt`.
    :returns: integer status code returned from ``main()``
    """
    try:
        objs = find_fn(select)
    except ValueError as err:
        print(err, file=sys.stderr)
        return 1

    first = True
    for obj in objs:
        ws = "" if first else "\n"
        print(ws + fmt % get_data(obj))
        first = False
    return 0


def _show_profile_cmd(
    cmd_args: Namespace,
    select: Optional[Selection],
    _opts: Optional[ReportOpts],
    identifier: Optional[str],
) -> int:
    """Show profile command handler.

    Show the OS profiles that match the given selection criteria in
    human readable form. Each matching profile is printed as a
    multi-line record, with like attributes grouped together on a
    line.

    :param cmd_args: Command line arguments for the command
    :param select: Selection criteria for the profiles to show.
    :param _opts: Optional ReportOpts
    :param identifier: Optional identifier
    :returns: integer status code returned from ``main()``
    """
    identifier = identifier or cmd_args.profile
    if identifier is not None:
        select = Selection(os_id=identifier)

    def _profile_get_data(osp):
        return (osp.disp_os_id, _str_indent(str(osp), 2))

    fmt = "OS Profile (os_id=%s)\n%s"
    return _generic_show_cmd(select, find_profiles, fmt, _profile_get_data)


def _list_profile_cmd(
    cmd_args: Namespace,
    select: Optional[Selection],
    opts: Optional[ReportOpts],
    identifier: Optional[str],
) -> int:
    """List profile command handler.

    List the OS profiles that match the given selection criteria as
    a tabular report, with one profile per row.

    :param cmd_args: Command line arguments for the command
    :param select: Selection criteria for the profiles to list
    :param opts: Optional ReportOpts
    :param identifier: Optional identifier
    :returns: integer status code returned from ``main()``
    """
    identifier = identifier or cmd_args.profile
    if identifier is not None:
        select = Selection(os_id=identifier)

    return _generic_list_cmd(cmd_args, select, opts, print_profiles)


def _edit_profile_cmd(
    cmd_args: Namespace,
    select: Optional[Selection],
    _opts: Optional[ReportOpts],
    identifier: Optional[str],
) -> int:
    """Edit profile command handler.

    Attempt to edit an existing OS profile. The ``os_id`` of the
    supplied ``Selection`` object is used to select the profile to
    edit. Any set entry values supplied in ``cmd_args`` will be used
    to modify the edited profile.

    :param cmd_args: Command line arguments for the command
    :param select: The ``os_id`` of the profile to edit
    :param _opts: Unused
    :param identifier: Optional identifier
    :returns: integer status code returned from ``main()``
    """
    identifier = identifier or cmd_args.profile
    if identifier is not None:
        select = Selection(os_id=identifier)

    id_keys = (
        cmd_args.name,
        cmd_args.short_name,
        cmd_args.os_version,
        cmd_args.os_version_id,
    )

    if cmd_args.options:
        print("Invalid argument for profile edit: --options")
        return 1

    if any(id_keys):
        print(
            "Cannot edit name, short_name, version, or version_id:\n"
            "Use 'clone --profile OS_ID'."
        )
        return 1

    uname_pattern = cmd_args.uname_pattern
    kernel_pattern = cmd_args.kernel_pattern
    initramfs_pattern = cmd_args.initramfs_pattern
    root_opts_lvm2 = cmd_args.lvm_opts
    root_opts_btrfs = cmd_args.btrfs_opts
    options = cmd_args.os_options
    optional_keys = cmd_args.optional_keys

    try:
        osp = edit_profile(
            selection=select,
            uname_pattern=uname_pattern,
            kernel_pattern=kernel_pattern,
            initramfs_pattern=initramfs_pattern,
            root_opts_lvm2=root_opts_lvm2,
            root_opts_btrfs=root_opts_btrfs,
            options=options,
            optional_keys=optional_keys,
        )
    except ValueError as err:
        print(err, file=sys.stderr)
        return 1

    print("Edited profile:")
    print(_str_indent(str(osp), 2))
    return 0


def _create_host_cmd(
    cmd_args: Namespace,
    _select: Optional[Selection],
    _opts: Optional[ReportOpts],
    identifier: Optional[str],
) -> int:
    """Create host profile command handler.

    Attempt to create a new host profile using the arguments
    supplied in ``cmd_args`` and return the command status
    as an integer.

    :param cmd_args: Command line arguments for the command
    :param _select: Unused
    :param _opts: Unused
    :returns: integer status code returned from ``main()``
    """
    if identifier is not None:
        print("host profile create does not accept <identifier>")
        return 1

    host_name = cmd_args.host_name or platform.node()

    if not host_name:
        print("host profile create requires a valid host name to be set or --host-name")
        return 1

    if not cmd_args.machine_id:
        # Use host machine-id by default
        machine_id = get_machine_id()
        if not machine_id:
            print("Could not determine machine_id")
            return 1
    else:
        machine_id = cmd_args.machine_id

    if not cmd_args.profile:
        print("host profile create requires --profile")
        return 1
    os_id = cmd_args.profile

    try:
        hp = create_host(
            machine_id=machine_id,
            os_id=os_id,
            host_name=host_name,
            label=cmd_args.label,
            kernel_pattern=cmd_args.kernel_pattern,
            initramfs_pattern=cmd_args.initramfs_pattern,
            root_opts_lvm2=cmd_args.lvm_opts,
            root_opts_btrfs=cmd_args.btrfs_opts,
            add_opts=cmd_args.add_opts,
            del_opts=cmd_args.del_opts,
            options=cmd_args.os_options,
        )
    except ValueError as err:
        print(err, file=sys.stderr)
        return 1
    print(f"Created host profile with host_id {hp.disp_host_id}:")
    print(_str_indent(str(hp), 2))
    return 0


def _delete_host_cmd(
    cmd_args: Namespace,
    select: Optional[Selection],
    _opts: Optional[ReportOpts],
    identifier: Optional[str],
) -> int:
    """Delete host profile command handler.

    Attempt to delete host profiles matching the selection criteria
    given in ``select``.

    :param cmd_args: Command line arguments for the command
    :param select: Selection criteria for the host to remove
    :param _opts: Unused
    :param identifier: Optional identifier
    :returns: integer status code returned from ``main()``
    """
    # If a host_id is given as a command line argument treat it as
    # a single HostProfile to delete and ignore any other criteria.
    identifier = identifier or cmd_args.host_id
    if identifier:
        select = Selection(host_id=identifier)

    if not select or select.is_null():
        print("host profile delete requires selection criteria")
        return 1

    if cmd_args.options:
        fields = cmd_args.options
    else:
        fields = _default_host_fields

    try:
        if cmd_args.verbose:
            print_hosts(select, output_fields=fields, sort_keys=cmd_args.sort)
        nr = delete_hosts(select)
    except (ValueError, IndexError) as err:
        print(err, file=sys.stderr)
        return 1
    print(f"Deleted {nr} profile{'s' if nr > 1 else ''}")
    return 0


def _clone_host_cmd(
    cmd_args: Namespace,
    select: Optional[Selection],
    _opts: Optional[ReportOpts],
    identifier: Optional[str],
) -> int:
    """Clone host profile command handler.

    Attempt to create a new host profile by cloning an existing
    profile. The ``host_id`` of the supplied ``Selection`` object
    is used to select the profile to clone. Any set profile values
    supplied in ``cmd_args`` will be used to modify the newly
    cloned profile.

    :param cmd_args: Command line arguments for the command
    :param select: The ``host_id`` to clone
    :param _opts: Unused
    :param identifier: Optional identifier
    :returns: integer status code returned from ``main()``
    """
    host_name = cmd_args.host_name
    os_id = cmd_args.profile

    identifier = identifier or cmd_args.host_id
    if identifier is not None:
        select = Selection(host_id=identifier)

    if not select:
        print("host clone requires identifier or selection criteria")
        return 1

    # For clone allow the machine_id to be inherited from the original
    # HostProfile unless the user has given an explicit argument.
    machine_id = cmd_args.machine_id

    # Cloning to modify only the host label is permitted
    label = cmd_args.label

    # Discard all selection criteria but host_id.
    select = Selection(host_id=select.host_id)

    try:
        hp = clone_host(
            selection=select,
            machine_id=machine_id,
            label=label,
            os_id=os_id,
            host_name=host_name,
            kernel_pattern=cmd_args.kernel_pattern,
            initramfs_pattern=cmd_args.initramfs_pattern,
            root_opts_lvm2=cmd_args.lvm_opts,
            root_opts_btrfs=cmd_args.btrfs_opts,
            add_opts=cmd_args.add_opts,
            del_opts=cmd_args.del_opts,
            options=cmd_args.os_options,
        )
    except ValueError as err:
        print(err, file=sys.stderr)
        return 1
    print(f"Cloned profile with host_id {select.host_id} as {hp.disp_host_id}:")
    print(_str_indent(str(hp), 2))
    return 0


def _show_host_cmd(
    cmd_args: Namespace,
    select: Optional[Selection],
    _opts: Optional[ReportOpts],
    identifier: Optional[str],
) -> int:
    """Show host profile command handler.

    Show the host profiles that match the given selection criteria
    in human readable form. Each matching profile is printed as a
    multi-line record, with like attributes grouped together on a
    line.

    :param cmd_args: Command line arguments for the command
    :param select: Selection criteria for the hosts to show.
    :param _opts: Unused
    :param identifier: Optional identifier
    :returns: integer status code returned from ``main()``
    """
    identifier = identifier or cmd_args.host_id
    if identifier is not None:
        select = Selection(host_id=identifier)

    def _host_get_data(hp):
        return (hp.disp_host_id, _str_indent(str(hp), 2))

    fmt = "Host Profile (host_id=%s)\n%s"

    return _generic_show_cmd(select, find_host_profiles, fmt, _host_get_data)


def _list_host_cmd(
    cmd_args: Namespace,
    select: Optional[Selection],
    opts: Optional[ReportOpts],
    identifier: Optional[str],
) -> int:
    """List host profile command handler.

    List the host profiles that match the given selection criteria
    as a tabular report, with one profile per row.

    :param cmd_args: Command line arguments for the command
    :param select: Selection criteria for the hosts to list
    :param opts: Optional ReportOpts
    :param identifier: Optional identifier
    :returns: integer status code returned from ``main()``
    """
    identifier = identifier or cmd_args.host_id
    if identifier is not None:
        select = Selection(host_id=identifier)

    return _generic_list_cmd(cmd_args, select, opts, print_hosts)


def _edit_host_cmd(
    cmd_args: Namespace,
    select: Optional[Selection],
    _opts: Optional[ReportOpts],
    identifier: Optional[str],
) -> int:
    """Edit profile command handler.

    Attempt to edit an existing host profile. The ``host_id`` of the
    supplied ``Selection`` object is used to select the profile to
    edit. Any set entry values supplied in ``cmd_args`` will be used
    to modify the edited profile.

    :param cmd_args: Command line arguments for the command
    :param select: The ``host_id`` of the host to edit
    :param _opts: Unused
    :param identifier: Optional identifier
    :returns: integer status code returned from ``main()``
    """
    identifier = identifier or cmd_args.host_id
    if identifier is not None:
        select = Selection(host_id=identifier)

    if cmd_args.options:
        print(
            "Invalid argument for 'host edit': --options\n"
            "To modify profile options template use --os-options"
        )
        return 1

    machine_id = cmd_args.machine_id
    os_id = cmd_args.profile
    host_name = cmd_args.host_name
    kernel_pattern = cmd_args.kernel_pattern
    initramfs_pattern = cmd_args.initramfs_pattern
    root_opts_lvm2 = cmd_args.lvm_opts
    root_opts_btrfs = cmd_args.btrfs_opts
    add_opts = cmd_args.add_opts
    del_opts = cmd_args.del_opts
    options = cmd_args.os_options

    try:
        hp = edit_host(
            selection=select,
            machine_id=machine_id,
            os_id=os_id,
            host_name=host_name,
            kernel_pattern=kernel_pattern,
            initramfs_pattern=initramfs_pattern,
            root_opts_lvm2=root_opts_lvm2,
            root_opts_btrfs=root_opts_btrfs,
            add_opts=add_opts,
            del_opts=del_opts,
            options=options,
        )
    except ValueError as err:
        print(err, file=sys.stderr)
        return 1

    print("Edited profile:")
    print(_str_indent(str(hp), 2))
    return 0


def _show_cache_cmd(
    cmd_args: Namespace,
    select: Optional[Selection],
    _opts: Optional[ReportOpts],
    identifier: Optional[str],
) -> int:
    """Show cache command handler.

    Show the cache entries that match the given selection criteria
    in human readable form. Each matching entry is printed as a
    multi-line record.

    :param cmd_args: Command line arguments for the command
    :param select: Selection criteria for the cache entries to show.
    :param _opts: Unused
    :param identifier: Optional identifier
    :returns: integer status code returned from ``main()``
    """
    if identifier is not None:
        select = Selection(path=identifier)

    try:
        find_fn = find_cache_images if cmd_args.verbose else find_cache_paths
        ces = find_fn(selection=select)
    except ValueError as err:
        print(err, file=sys.stderr)
        return 1
    first = True
    for ce in ces:
        ws = "" if first else "\n"
        ce_str = str(ce)
        ce_str = _str_indent(ce_str, 2)
        print(f"{ws}Cache Entry (img_id={ce.disp_img_id})\n{ce_str}")
        first = False
    return 0


def _list_cache_cmd(
    cmd_args: Namespace,
    select: Optional[Selection],
    opts: Optional[ReportOpts],
    _identifier: Optional[str],
) -> int:
    """List cache command handler.

    List the cache entries that match the given selection criteria
    as a tabular report, with one entry per row.

    :param cmd_args: Command line arguments for the command
    :param select: Selection criteria for the cache entries to list
    :param opts: Optional ReportOpts
    :param identifier: Optional identifier
    :returns: integer status code returned from ``main()``
    """
    print_fn = print_cache if not cmd_args.verbose else print_cache_images
    return _generic_list_cmd(cmd_args, select, opts, print_fn)


def _write_legacy_cmd(
    _cmd_args: Namespace,
    select: Optional[Selection],
    _opts: Optional[ReportOpts],
    identifier: Optional[str],
) -> int:  # pragma: no cover
    """Write legacy command handler.

    Write entries in configured legacy bootloader format.

    :param cmd_args: Command line arguments for the command
    :param select: Selection criteria for the entries to write
    :param _opts: Unused
    :param _identifier: Unused
    :returns: integer status code returned from ``main()``
    """
    if identifier:
        print("write legacy does not accept a boot_id")
        return 1
    config = get_boom_config()
    try:
        clear_legacy_loader()
        write_legacy_loader(selection=select, loader=config.legacy_format)
    except (BoomLegacyFormatError, BoomError, OSError, ValueError) as err:
        print(err, file=sys.stderr)
        return 1
    return 0


def _clear_legacy_cmd(
    _cmd_args: Namespace,
    _select: Optional[Selection],
    _opts: Optional[ReportOpts],
    identifier: Optional[str],
) -> int:  # pragma: no cover
    """Clear legacy command handler.

    Remove all boom entries from the legacy bootloader configuration.

    :param cmd_args: Command line arguments for the command
    :param _select: Unused
    :param _opts: Unused
    :param _identifier: Unused
    :returns: integer status code returned from ``main()``
    """
    if identifier:
        print("clear legacy does not accept a boot_id")
        return 1

    try:
        clear_legacy_loader()
    except BoomLegacyFormatError as err:
        print(err, file=sys.stderr)
        return 1
    return 0


def _show_legacy_cmd(
    _cmd_args: Namespace,
    select: Optional[Selection],
    _opts: Optional[ReportOpts],
    _identifier: Optional[str],
) -> int:
    """
    Display legacy boot loader configuration.

    :param cmd_args: Command line arguments for the command
    :param select: Selection criteria for the entries to show
    :param _opts: Unused
    :param _identifier: Unused
    :returns: integer status code returned from ``main()``
    """
    config = get_boom_config()
    show_legacy(selection=select, loader=config.legacy_format)
    return 0


def _create_config_cmd(
    _cmd_args: Namespace,
    _select: Optional[Selection],
    _opts: Optional[ReportOpts],
    _identifier: Optional[str],
) -> int:
    """
    Create default boom configuration in /boot.

    :param _cmd_args: Unused
    :param _select: Unused
    :param _opts: Unused
    :param _identifier: Unused
    :returns: integer status code returned from ``main()``
    """
    try:
        create_config()
    except BoomConfigError as err:
        print(err, file=sys.stderr)
        return 1
    return 0


CREATE_CMD = "create"
DELETE_CMD = "delete"
CLONE_CMD = "clone"
CLEAR_CMD = "clear"
SHOW_CMD = "show"
LIST_CMD = "list"
EDIT_CMD = "edit"

WRITE_CMD = "write"

ENTRY_TYPE = "entry"
PROFILE_TYPE = "profile"
HOST_TYPE = "host"
CACHE_TYPE = "cache"
LEGACY_TYPE = "legacy"
CONFIG_TYPE = "config"

_boom_entry_commands = [
    (CREATE_CMD, _create_cmd),
    (DELETE_CMD, _delete_cmd),
    (CLONE_CMD, _clone_cmd),
    (SHOW_CMD, _show_cmd),
    (LIST_CMD, _list_cmd),
    (EDIT_CMD, _edit_cmd),
]

_boom_profile_commands = [
    (CREATE_CMD, _create_profile_cmd),
    (DELETE_CMD, _delete_profile_cmd),
    (CLONE_CMD, _clone_profile_cmd),
    (SHOW_CMD, _show_profile_cmd),
    (LIST_CMD, _list_profile_cmd),
    (EDIT_CMD, _edit_profile_cmd),
]

_boom_host_commands = [
    (CREATE_CMD, _create_host_cmd),
    (DELETE_CMD, _delete_host_cmd),
    (CLONE_CMD, _clone_host_cmd),
    (SHOW_CMD, _show_host_cmd),
    (LIST_CMD, _list_host_cmd),
    (EDIT_CMD, _edit_host_cmd),
]

_boom_cache_commands = [(SHOW_CMD, _show_cache_cmd), (LIST_CMD, _list_cache_cmd)]

_boom_legacy_commands = [
    (WRITE_CMD, _write_legacy_cmd),
    (CLEAR_CMD, _clear_legacy_cmd),
    (SHOW_CMD, _show_legacy_cmd),
]

_boom_config_commands = [
    (CREATE_CMD, _create_config_cmd),
]

_boom_command_types: List[Tuple[str, List[Tuple[str, Any]]]] = [
    (ENTRY_TYPE, _boom_entry_commands),
    (PROFILE_TYPE, _boom_profile_commands),
    (HOST_TYPE, _boom_host_commands),
    (CACHE_TYPE, _boom_cache_commands),
    (LEGACY_TYPE, _boom_legacy_commands),
    (CONFIG_TYPE, _boom_config_commands),
]


def _id_from_arg(cmd_args: Namespace, cmdtype: str, cmd: str) -> Optional[str]:
    if cmd == CREATE_CMD:
        if cmdtype == ENTRY_TYPE:
            return cmd_args.boot_id
        if cmdtype == PROFILE_TYPE:
            return cmd_args.profile
    else:
        if cmd_args.identifier:
            return cmd_args.identifier
        if cmdtype == ENTRY_TYPE:
            return cmd_args.boot_id
        if cmdtype == PROFILE_TYPE:
            return cmd_args.profile
    return None


def _match_cmd_type(cmdtype: str):
    matches = [t for t in _boom_command_types if t[0] == cmdtype]
    return matches[0] if len(matches) == 1 else None


def _match_command(cmd: str, cmds):
    for c in cmds:
        if cmd == c[0]:
            return c
    return None


def _report_opts_from_args(cmd_args: Namespace) -> ReportOpts:
    opts = ReportOpts()

    if not cmd_args:
        return opts

    if cmd_args.json:
        opts.json = True

    if cmd_args.rows:
        opts.columns_as_rows = True

    if cmd_args.separator:
        opts.separator = cmd_args.separator

    if cmd_args.name_prefixes:
        opts.field_name_prefix = "BOOM_"
        opts.unquoted = False
        opts.aligned = False

    if cmd_args.no_headings:
        opts.headings = False

    return opts


def get_uts_release() -> str:
    return uname()[2]


def setup_logging(cmd_args: Namespace):
    global _console_handler
    level = _default_log_level
    if cmd_args.verbose and cmd_args.verbose > 1:
        level = logging.DEBUG
    elif cmd_args.verbose and cmd_args.verbose > 0:
        level = logging.INFO

    # Configure the package-level logger
    boom_log = logging.getLogger("boom")
    formatter = logging.Formatter("%(levelname)s - %(message)s")
    boom_log.setLevel(level)
    if boom_log.hasHandlers():
        boom_log.handlers.clear()

    # Subsystem log filtering
    _boom_subsystem_filter = SubsystemFilter("boom")

    # Main console handler
    _console_handler = logging.StreamHandler()
    _console_handler.setLevel(level)
    _console_handler.setFormatter(formatter)
    _console_handler.addFilter(_boom_subsystem_filter)

    boom_log.addHandler(_console_handler)


def shutdown_logging():
    logging.shutdown()


def set_debug(debug_arg: Optional[str]):
    if not debug_arg:
        return

    mask_map = {
        "profile": BOOM_DEBUG_PROFILE,
        "entry": BOOM_DEBUG_ENTRY,
        "report": BOOM_DEBUG_REPORT,
        "command": BOOM_DEBUG_COMMAND,
        "all": BOOM_DEBUG_ALL,
    }

    mask = 0
    for name in debug_arg.split(","):
        if name not in mask_map:
            raise ValueError(f"Unknown debug mask: {name}")
        mask |= mask_map[name]
    set_debug_mask(mask)


def main(args: List[str]) -> int:
    parser = ArgumentParser(prog=basename(args[0]), description="Boom Boot Manager")
    # Derive help text from registered command types/commands
    type_choices_list = sorted(t[0] for t in _boom_command_types)
    type_choices = ", ".join(type_choices_list)
    cmd_help_by_type = "; ".join(
        f"{t}=[{', '.join(sorted(cmd for (cmd, _) in cmds))}]"
        for (t, cmds) in _boom_command_types
    )

    # Default type is boot entry.
    if len(args) > 1 and _match_command(args[1], _boom_entry_commands):
        args.insert(1, "entry")

    parser.add_argument(
        "type",
        metavar="TYPE",
        type=str,
        help=(
            f"The command type to run: {type_choices}. Defaults to 'entry' for known entry commands."
        ),
        action="store",
    )
    parser.add_argument(
        "command",
        metavar="COMMAND",
        type=str,
        action="store",
        help=f"The command to run; by TYPE: {cmd_help_by_type}",
    )
    parser.add_argument(
        "identifier",
        metavar="ID",
        type=str,
        action="store",
        help="An optional profile or boot identifier to operate on",
        nargs="?",
        default=None,
    )
    parser.add_argument(
        "-a",
        "--add-opts",
        "--addopts",
        metavar="OPTIONS",
        help="Additional kernel options to append",
        type=str,
    )
    parser.add_argument(
        "--all", action="store_true", help="Include entries with no valid profile"
    )
    parser.add_argument(
        "--architecture",
        metavar="ARCH",
        default=None,
        help="An optional BLS architecture string",
        type=str,
    )
    parser.add_argument(
        "--backup", action="store_true", help="Back up boot images used by entry"
    )
    parser.add_argument(
        "-b",
        "--boot-id",
        "--bootid",
        metavar="BOOT_ID",
        type=str,
        help="The BOOT_ID of a boom boot entry",
    )
    parser.add_argument(
        "--boot-dir",
        "--bootdir",
        metavar="PATH",
        type=str,
        help="The path to the /boot file system",
    )
    parser.add_argument(
        "-B",
        "--btrfs-subvolume",
        "--btrfssubvolume",
        metavar="SUBVOL",
        type=str,
        help="The path or ID of a BTRFS subvolume",
    )
    parser.add_argument(
        "--btrfs-opts",
        "--btrfsopts",
        metavar="OPTS",
        type=str,
        help="A template option string for BTRFS devices",
    )
    parser.add_argument(
        "-c",
        "--config",
        metavar="FILE",
        type=str,
        help="Path to a boom configuration file",
        default=None,
    )
    parser.add_argument(
        "-d",
        "--del-opts",
        "--delopts",
        metavar="OPTIONS",
        help="List of kernel options to be dropped",
        type=str,
    )
    parser.add_argument(
        "--debug",
        metavar="DEBUGOPTS",
        type=str,
        help="A list of debug options to enable",
    )
    parser.add_argument(
        "-e",
        "--efi",
        metavar="IMG",
        type=str,
        help="An executable EFI application image",
    )
    parser.add_argument(
        "-E",
        "--expand-variables",
        action="store_true",
        help="Expand bootloader environment variables",
    )
    parser.add_argument(
        "--grub-arg",
        metavar="ARGS",
        type=str,
        help="Pass additional arguments to the Grub2 loader",
    )
    parser.add_argument(
        "--grub-class",
        metavar="CLASS",
        type=str,
        help="Specify a Grub2 class for this entry",
    )
    parser.add_argument(
        "--grub-users",
        metavar="USERS",
        type=str,
        help="Grub user list for password protection",
    )
    parser.add_argument(
        "--grub-id",
        metavar="ID",
        type=str,
        dest="id",
        help="Grub menu identifier string",
    )
    parser.add_argument(
        "-H",
        "--from-host",
        "--fromhost",
        help="Take os-release values from the running host",
        action="store_true",
    )
    parser.add_argument(
        "-P",
        "--host-profile",
        metavar="PROFILE",
        type=str,
        help="A boom host profile identifier",
        dest="host_id",
    )
    parser.add_argument(
        "--host-name",
        metavar="HOSTNAME",
        type=str,
        help="The host name associated with a host profile",
    )
    parser.add_argument(
        "-i", "--initrd", metavar="IMG", type=str, help="A linux initrd image path"
    )
    parser.add_argument(
        "-k",
        "--kernel-pattern",
        "--kernelpattern",
        metavar="PATTERN",
        type=str,
        help="A pattern for generating kernel paths",
    )
    parser.add_argument("--label", metavar="LABEL", type=str, help="Host profile label")
    parser.add_argument(
        "-l", "--linux", metavar="IMG", type=str, help="A linux kernel image path"
    )
    parser.add_argument(
        "-L",
        "--root-lv",
        "--rootlv",
        metavar="LV",
        type=str,
        help="An LVM2 root logical volume",
    )
    parser.add_argument(
        "--lvm-opts",
        "--lvmopts",
        metavar="OPTS",
        type=str,
        help="A template option string for LVM2 devices",
    )
    parser.add_argument(
        "-m",
        "--machine-id",
        "--machineid",
        metavar="MACHINE_ID",
        type=str,
        help="The machine_id value to use",
    )
    parser.add_argument(
        "-M",
        "--mount",
        metavar="MOUNT",
        action="append",
        help="File system mount specification",
    )
    parser.add_argument(
        "-n", "--name", metavar="OSNAME", type=str, help="The name of a Boom OsProfile"
    )
    parser.add_argument(
        "--name-prefixes",
        "--nameprefixes",
        help="Add a prefix to report field names",
        action="store_true",
    )
    parser.add_argument(
        "--no-headings",
        "--noheadings",
        action="store_true",
        help="Suppress output of report headings",
    )
    parser.add_argument(
        "--no-dev",
        "--nodev",
        action="store_true",
        help="Disable checks for a valid root device",
    )
    parser.add_argument(
        "--no-fstab",
        "--nofstab",
        action="store_true",
        help="Ignore mounts and swap devices defined in fstab",
    )
    parser.add_argument(
        "--optional-keys",
        metavar="KEYS",
        type=str,
        help="Optional keys allowed by this operating system profile",
    )
    parser.add_argument(
        "-o",
        "--options",
        metavar="FIELDS",
        type=str,
        help="Specify which fields to display",
    )
    parser.add_argument(
        "--os-version",
        "--osversion",
        metavar="OSVERSION",
        help="A Boom OsProfile version",
        type=str,
    )
    parser.add_argument(
        "-O",
        "--sort",
        metavar="SORTFIELDS",
        type=str,
        help="Specify which fields to sort by",
    )
    parser.add_argument(
        "-I",
        "--os-version-id",
        "--osversionid",
        help="A Boom OsProfile version ID",
        metavar="OSVERSIONID",
        type=str,
    )
    parser.add_argument(
        "--os-options",
        "--osoptions",
        metavar="OPTIONS",
        help="A Boom OsProfile options template",
        type=str,
    )
    parser.add_argument(
        "--os-release",
        "--osrelease",
        metavar="OSRELEASE",
        help="Path to an os-release file",
        type=str,
    )
    parser.add_argument(
        "-p",
        "--profile",
        metavar="OS_ID",
        type=str,
        help="A boom operating system profile identifier",
    )
    parser.add_argument(
        "-r",
        "--root-device",
        "--rootdevice",
        metavar="ROOT",
        help="The root device for a boot entry",
        type=str,
    )
    parser.add_argument(
        "-R",
        "--initramfs-pattern",
        "--initramfspattern",
        type=str,
        help="A pattern for generating initramfs paths",
        metavar="PATTERN",
    )
    format_group = parser.add_mutually_exclusive_group()
    format_group.add_argument(
        "--json", action="store_true", help="Output report as JSON"
    )
    format_group.add_argument(
        "--rows", action="store_true", help="Output report columns as rows"
    )
    parser.add_argument(
        "--separator", metavar="SEP", type=str, help="Report field separator"
    )
    parser.add_argument(
        "-s",
        "--short-name",
        "--shortname",
        help="A Boom OsProfile short name",
        metavar="OSSHORTNAME",
        type=str,
    )
    parser.add_argument(
        "--swap",
        metavar="SWAP",
        action="append",
        help="Swap device specification",
    )
    parser.add_argument(
        "-t",
        "--title",
        metavar="TITLE",
        type=str,
        help="The title of a boom boot entry",
    )
    parser.add_argument(
        "-u",
        "--uname-pattern",
        "--unamepattern",
        help="A Boom OsProfile uname pattern",
        metavar="PATTERN",
        type=str,
    )
    parser.add_argument(
        "--update",
        action="store_true",
        help="Update cached images when creating backups",
    )
    parser.add_argument("-V", "--verbose", help="Enable verbose output", action="count")
    parser.add_argument(
        "-v",
        "--version",
        metavar="VERSION",
        type=str,
        help="The kernel version of a boom boot entry",
    )

    # Special case: allow -h/--help to work even if type/command are omitted.
    if any(a in ("-h", "--help") for a in args[1:]):
        parser.print_help()
        return 0
    if len(args) < 3:
        parser.error("too few arguments")

    type_arg = args[1] if len(args) > 1 else ""
    cmd_arg = args[2] if len(args) > 2 else ""

    cmd_type = _match_cmd_type(type_arg)
    if not cmd_type:
        parser.error(f"unknown command type: {type_arg}")

    command = _match_command(cmd_arg, cmd_type[1])
    if not command:
        parser.error(f"unknown command: {type_arg} {cmd_arg}")

    cmd_args = parser.parse_args(args=args[1:])

    try:
        set_debug(cmd_args.debug)
    except ValueError as err:
        print(err, file=sys.stderr)
        return 1
    setup_logging(cmd_args)

    boot_path = None
    if cmd_args.boot_dir or BOOM_BOOT_PATH_ENV in environ:
        boot_path = cmd_args.boot_dir or environ[BOOM_BOOT_PATH_ENV]
        if not isabs(boot_path):
            boot_path = join(getcwd(), boot_path)

    boot_path = boot_path or DEFAULT_BOOT_PATH

    if cmd_args.config:
        set_boom_config_path(cmd_args.config)

    if cmd_type[0] != CONFIG_TYPE:
        try:
            set_boot_path(boot_path)
        except ValueError as err:
            _log_error("Failed to set boot path to '%s': %s", boot_path, err)
            return 1
        try:
            set_boom_path(join(boot_path, "boom"))
        except ValueError as err:
            _log_error(
                "Failed to set boom_path to '%s': %s",
                join(get_boot_path(), "boom"),
                err,
            )
            print("Run 'boom config create' to generate default configuration")
            return 1

        if not path_exists(get_boom_path()):
            _log_error("Configuration directory '%s' not found.", get_boom_path())
            print("Run 'boom config create' to generate default configuration")
            return 1

        if not path_exists(get_boom_config_path()):
            _log_error("Configuration file '%s' not found.", get_boom_config_path())
            print("Run 'boom config create' to generate default configuration")
            return 1

        if not path_exists(boom_profiles_path()):
            _log_error(
                "OS profile configuration path '%s' not found.", boom_profiles_path()
            )
            return 1

        if not path_exists(boom_host_profiles_path()):
            _log_error(
                "Host profile configuration path '%s' not found.",
                boom_host_profiles_path(),
            )
            return 1

        if not path_exists(boom_entries_path()):
            _log_error(
                "Boot loader entries directory '%s' not found.", boom_entries_path()
            )
            return 1

        try:
            bc = load_boom_config()
        except (BoomConfigError, OSError, ValueError) as err:
            _log_error("Could not load boom configuration: %s", err)
            return 1
    else:
        try:
            set_boot_path(boot_path)
        except ValueError as err:
            _log_error("Failed to set boot path to '%s': %s", boot_path, err)
            return 1
        bc = get_boom_config()

    # legacy_enable = False has been the default since BoomConfig was introduced
    # in boom-0.9: very few installations are expected to remain where this
    # is enabled.
    if bc.legacy_enable or cmd_type[0] == LEGACY_TYPE:
        _log_warn(
            "Legacy boot loader integration is deprecated and will be removed in a future version."
        )

    if cmd_type[0] == CACHE_TYPE and not bc.cache_enable:
        _log_error("Boot image cache disabled (config.cache_enable=False)")
        return 1

    # Parse an LV name from root_lv and re-write the root_device if found
    if cmd_args.root_lv:
        try:
            root_lv = _canonicalize_lv_name(cmd_args.root_lv)
        except ValueError as err:
            print(err, file=sys.stderr)
            print(f"Invalid logical volume name: '{cmd_args.root_lv}'", file=sys.stderr)
            return 1
        root_device = DEV_PATTERN % root_lv
        if cmd_args.root_device and cmd_args.root_device != root_device:
            print(
                f"Options --root-lv {root_lv} and "
                f"--root-device {root_device} do not match."
            )
            return 1
        cmd_args.root_device = root_device
        cmd_args.root_lv = root_lv

    # Try parsing an LV name from root_device and rewrite root_lv if found
    elif cmd_args.root_device:
        try:
            root_lv = _canonicalize_lv_name(cmd_args.root_device)
            cmd_args.root_lv = root_lv
        except ValueError:
            # No valid VG name
            pass

    if cmd_args.backup and not bc.cache_enable:
        print("--backup specified but cache disabled (config.cache_enable=False)")
        return 1
    if cmd_args.backup:
        load_cache()

    select = Selection.from_cmd_args(cmd_args)
    opts = _report_opts_from_args(cmd_args)
    identifier = _id_from_arg(cmd_args, cmd_type[0], command[0])
    status = 1

    if cmd_args.debug:
        status = command[1](cmd_args, select, opts, identifier)
    else:
        try:
            status = command[1](cmd_args, select, opts, identifier)
        except (BoomError, OSError, ValueError) as err:
            _log_error("Command failed: %s", err)

    if bc.cache_enable and bc.cache_auto_clean:
        try:
            clean_cache()
        except (BoomError, OSError) as err:
            _log_error("Could not clean boot image cache: %s", err)

    shutdown_logging()
    return status


__all__ = [
    # BootEntry manipulation
    "create_entry",
    "delete_entries",
    "clone_entry",
    "edit_entry",
    "list_entries",
    "print_entries",
    # OsProfile manipulation
    "create_profile",
    "delete_profiles",
    "clone_profile",
    "edit_profile",
    "list_profiles",
    "print_profiles",
    "os_options_from_cmdline",
    # HostProfile manipulation
    "create_host",
    "delete_hosts",
    "clone_host",
    "edit_host",
    "list_hosts",
    "print_hosts",
    # Cache manipulation
    "print_cache",
    "print_cache_images",
    "I_NONE",
    "I_CACHE",
    "I_BACKUP",
    # Environment
    "get_machine_id",
    "get_uts_release",
]

# vim: set et ts=4 sw=4 :
