# Copyright Red Hat
#
# boom/hostprofile.py - Boom host profiles
#
# This file is part of the boom project.
#
# SPDX-License-Identifier: Apache-2.0
"""The ``boom.hostprofile``  module defines the `HostProfile` class that
represents a host system profile. A `HostProfile` defines the identity
of a host and includes template values that override the corresponding
``OsProfile`` defaults for the respective host.

Functions are provided to read and write host system profiles from
an on-disk store, and to retrieve ``HostProfile`` instances using
various selection criteria.

The ``HostProfile`` class includes named properties for each profile
attribute ("profile key"). In addition, the class serves as a container
type, allowing attributes to be accessed via dictionary-style indexing.
This simplifies iteration over a profile's key / value pairs and allows
straightforward access to all members in scripts and the Python shell.

The keys used to access ``HostProfile`` members (and their corresponding
property names) are identical to those used by the ``OsProfile`` class.
"""
from hashlib import sha1
from os.path import join as path_join
from typing import Any, Callable, Dict, List, Optional
import logging
import string

from boom import (
    BOOM_SUBSYSTEM_PROFILE,
    FMT_KERNEL,
    FMT_INITRAMFS,
    FMT_ROOT_OPTS,
    MIN_ID_WIDTH,
    Selection,
    get_boom_path,
    min_id_width,
    load_profiles_for_class,
)
from boom.osprofile import (
    OS_KEY_NAMES,
    BOOM_OS_ID,
    BOOM_OS_NAME,
    BOOM_OS_SHORT_NAME,
    BOOM_OS_VERSION,
    BOOM_OS_VERSION_ID,
    BOOM_OS_UNAME_PATTERN,
    BOOM_OS_KERNEL_PATTERN,
    BOOM_OS_INITRAMFS_PATTERN,
    BOOM_OS_ROOT_OPTS_LVM2,
    BOOM_OS_ROOT_OPTS_BTRFS,
    BOOM_OS_OPTIONS,
    BOOM_OS_TITLE,
    BOOM_OS_OPTIONAL_KEYS,
    BoomProfile,
    OsProfile,
    find_profiles,
    key_from_key_name,
)

# 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_profile(msg, *args, **kwargs):
    """A wrapper for profile subsystem debug logs."""
    _log.debug(msg, *args, extra={"subsystem": BOOM_SUBSYSTEM_PROFILE}, **kwargs)


#: Global host profile list
_host_profiles = []
_host_profiles_by_id: Dict[str, Dict[str, "HostProfile"]] = {}
_host_profiles_by_host_id: Dict[str, "HostProfile"] = {}

#: Whether profiles have been read from disk
_host_profiles_loaded = False

#: Boom profiles directory name.
BOOM_HOST_PROFILES = "hosts"

#: File name format for Boom profiles.
BOOM_HOST_PROFILE_FORMAT = "%s-%s.host"

#: The file mode with which to create Boom profiles.
BOOM_HOST_PROFILE_MODE = 0o644

# Constants for Boom profile keys
#: Constant for the Boom host identifier profile key.
BOOM_HOST_ID = "BOOM_HOST_ID"
#: Constant for the Boom host name profile key.
BOOM_HOST_NAME = "BOOM_HOST_NAME"
#: Constant for the Boom host add options key.
BOOM_HOST_ADD_OPTS = "BOOM_HOST_ADD_OPTS"
#: Constant for the Boom host del options key.
BOOM_HOST_DEL_OPTS = "BOOM_HOST_DEL_OPTS"
#: Constant for the Boom host label key.
BOOM_HOST_LABEL = "BOOM_HOST_LABEL"

#: Constant for shared machine_id key
BOOM_ENTRY_MACHINE_ID = "BOOM_ENTRY_MACHINE_ID"

#: Ordered list of possible host profile keys, partitioned into
#: mandatory keys, optional host profile keys, keys mapping to
#: embedded ``OsProfile`` identity data, and ``OsProfile`` pattern
#: keys that may be overridden in the ``HostProfile``.
HOST_PROFILE_KEYS = [
    # HostProfile identifier
    BOOM_HOST_ID,
    # Machine hostname
    BOOM_HOST_NAME,
    # Binding to label, machine_id and OsProfile
    BOOM_ENTRY_MACHINE_ID,
    BOOM_OS_ID,
    # Optional host profile keys
    BOOM_HOST_LABEL,
    BOOM_HOST_ADD_OPTS,
    BOOM_HOST_DEL_OPTS,
    # Keys 7-10 OS identity keys mapped to the embedded OsProfile.
    BOOM_OS_NAME,
    BOOM_OS_SHORT_NAME,
    BOOM_OS_VERSION,
    BOOM_OS_VERSION_ID,
    # Keys 11-15 (OsProfile patterns) may be overridden in the host profile.
    BOOM_OS_UNAME_PATTERN,
    BOOM_OS_KERNEL_PATTERN,
    BOOM_OS_INITRAMFS_PATTERN,
    BOOM_OS_ROOT_OPTS_LVM2,
    BOOM_OS_ROOT_OPTS_BTRFS,
    BOOM_OS_OPTIONS,
]

#: A map of Boom host profile keys to human readable key names suitable
#: for use in formatted output. These key names are used to format a
#: ``HostProfile`` object as a human readable string.
HOST_KEY_NAMES = {
    # Keys unique to HostProfile
    BOOM_HOST_ID: "Host ID",
    BOOM_HOST_NAME: "Host name",
    BOOM_HOST_LABEL: "Host label",
    BOOM_HOST_ADD_OPTS: "Add options",
    BOOM_HOST_DEL_OPTS: "Del options",
    # Keys shared with BootEntry
    BOOM_ENTRY_MACHINE_ID: "Machine ID",
    # Keys incorporated from OsProfile
    BOOM_OS_ID: OS_KEY_NAMES[BOOM_OS_ID],
    BOOM_OS_NAME: OS_KEY_NAMES[BOOM_OS_NAME],
    BOOM_OS_SHORT_NAME: OS_KEY_NAMES[BOOM_OS_SHORT_NAME],
    BOOM_OS_VERSION: OS_KEY_NAMES[BOOM_OS_VERSION],
    BOOM_OS_VERSION_ID: OS_KEY_NAMES[BOOM_OS_VERSION_ID],
    BOOM_OS_UNAME_PATTERN: OS_KEY_NAMES[BOOM_OS_UNAME_PATTERN],
    BOOM_OS_KERNEL_PATTERN: OS_KEY_NAMES[BOOM_OS_KERNEL_PATTERN],
    BOOM_OS_INITRAMFS_PATTERN: OS_KEY_NAMES[BOOM_OS_INITRAMFS_PATTERN],
    BOOM_OS_ROOT_OPTS_LVM2: OS_KEY_NAMES[BOOM_OS_ROOT_OPTS_LVM2],
    BOOM_OS_ROOT_OPTS_BTRFS: OS_KEY_NAMES[BOOM_OS_ROOT_OPTS_BTRFS],
    BOOM_OS_OPTIONS: OS_KEY_NAMES[BOOM_OS_OPTIONS],
}

#: Boom host profile keys that must exist in a valid profile.
HOST_REQUIRED_KEYS = HOST_PROFILE_KEYS[0:4]

#: Boom optional host profile configuration keys.
HOST_OPTIONAL_KEYS = HOST_PROFILE_KEYS[4:]


def _host_exists(host_id) -> bool:
    """Test whether the specified ``host_id`` already exists.

    Used during ``HostProfile`` initialisation to test if the new
    ``host_id`` is already known (and to avoid passing through
    find_profiles(), which may trigger recursive profile loading).

    :param host_id: the host identifier to check for

    :returns: ``True`` if the identifier is known or ``False``
              otherwise.
    :rtype: bool
    """
    if not _host_profiles_by_host_id:
        return False
    if host_id in _host_profiles_by_host_id:
        return True
    return False


def boom_host_profiles_path() -> str:
    """Return the path to the boom host profiles directory.

    :returns: The boom host profiles path.
    :rtype: str
    """
    return path_join(get_boom_path(), BOOM_HOST_PROFILES)


def host_profiles_loaded() -> bool:
    """Test whether profiles have been loaded from disk.

    :rtype: bool
    :returns: ``True`` if profiles are loaded in memory or ``False``
              otherwise
    """
    return _host_profiles_loaded


def drop_host_profiles():
    """Drop all in-memory host profiles."""
    global _host_profiles, _host_profiles_by_id, _host_profiles_by_host_id
    global _host_profiles_loaded

    _host_profiles = []
    _host_profiles_by_id = {}
    _host_profiles_by_host_id = {}
    _host_profiles_loaded = False


def load_host_profiles():
    """Load HostProfile data from disk.

    Load the set of host profiles found at the path
    ``boom.hostprofile.boom_profiles_path()`` into the global host
    profile list.

    This function may be called to explicitly load, or reload the
    set of profiles on-disk. Profiles are also loaded implicitly
    if an API function or method that requires access to profiles
    is invoked (for example, ``boom.bootloader.load_entries()``.

    :returns: None
    """
    global _host_profiles_loaded
    drop_host_profiles()
    profiles_path = boom_host_profiles_path()
    load_profiles_for_class(HostProfile, "Host", profiles_path, "host")

    _host_profiles_loaded = True
    _log_debug("Loaded %d host profiles", len(_host_profiles))


def write_host_profiles(force=False):
    """Write all HostProfile data to disk.

    Write the current list of host profiles to the directory located
    at ``boom.osprofile.boom_profiles_path()``.

    :rtype: None
    """
    _log_debug("Writing host profiles to %s", boom_host_profiles_path())
    for hp in _host_profiles:
        try:
            hp.write_profile(force)
        except Exception as e:
            _log_warn(
                "Failed to write HostProfile(machine_id='%s'): %s",
                hp.disp_machine_id,
                e,
            )


def min_host_id_width() -> int:
    """Calculate the minimum unique width for host_id values.

    Calculate the minimum width to ensure uniqueness when displaying
    host_id values.

    :returns: the minimum host_id width.
    :rtype: int
    """
    return min_id_width(MIN_ID_WIDTH, _host_profiles, "host_id")


def min_machine_id_width() -> int:
    """Calculate the minimum unique width for host_id values.

    Calculate the minimum width to ensure uniqueness when displaying
    host_id values.

    :returns: the minimum host_id width.
    :rtype: int
    """
    return min_id_width(MIN_ID_WIDTH, _host_profiles, "machine_id")


def select_host_profile(s, hp: "HostProfile") -> bool:
    """Test the supplied host profile against selection criteria.

    Test the supplied ``HostProfile`` against the selection criteria
    in ``s`` and return ``True`` if it passes, or ``False``
    otherwise.

    :param s: The selection criteria
    :param hp: The ``HostProfile`` to test
    :rtype: bool
    :returns: True if ``hp`` passes selection or ``False`` otherwise.
    """
    if s.host_id and not hp.host_id.startswith(s.host_id):
        return False
    if s.machine_id and hp.machine_id != s.machine_id:
        return False
    if s.host_name and hp.host_name != s.host_name:
        return False
    if s.host_label and hp.label != s.host_label:
        return False
    if s.host_short_name and hp.short_name != s.host_short_name:
        return False
    if s.host_add_opts and hp.add_opts != s.host_add_opts:
        return False
    if s.host_del_opts and hp.del_opts != s.host_del_opts:
        return False
    if s.os_id and not hp.os_id.startswith(s.os_id):
        return False
    if s.os_name and hp.os_name != s.os_name:
        return False
    if s.os_short_name and hp.os_short_name != s.os_short_name:
        return False
    if s.os_version and hp.os_version != s.os_version:
        return False
    if s.os_version_id and hp.os_version_id != s.os_version_id:
        return False
    if s.os_uname_pattern and hp.uname_pattern != s.os_uname_pattern:
        return False
    if s.os_kernel_pattern and hp.kernel_pattern != s.os_kernel_pattern:
        return False
    if s.os_initramfs_pattern and hp.initramfs_pattern != s.os_initramfs_pattern:
        return False
    if s.os_options and hp.options != s.os_options:
        return False
    return True


def find_host_profiles(
    selection: Optional[Selection] = None, match_fn: Callable = select_host_profile
) -> List["HostProfile"]:
    """Find host profiles matching selection criteria.

    Return a list of ``HostProfile`` objects matching the specified
    criteria. Matching proceeds as the logical 'and' of all criteria.
    Criteria that are unset (``None``) are ignored.

    If the optional ``match_fn`` parameter is specified, the match
    criteria parameters are ignored and each ``HostProfile`` is
    tested in turn by calling ``match_fn``. If the matching function
    returns ``True`` the ``HostProfile`` will be included in the
    results.

    If no ``HostProfile`` matches the specified criteria the empty
    list is returned.

    Host profiles will be automatically loaded from disk if they are
    not already in memory.

    :param selection: A ``Selection`` object specifying the match
                      criteria for the operation.
    :param match_fn: An optional match function to test profiles.
    :returns: a list of ``HostProfile`` objects.
    :rtype: list
    """
    # Use null search criteria if unspecified
    selection = selection if selection else Selection()

    selection.check_valid_selection(host=True)

    if not host_profiles_loaded():
        load_host_profiles()

    matches = []

    _log_debug_profile("Finding host profiles for %s", repr(selection))
    for hp in _host_profiles:
        if match_fn(selection, hp):
            matches.append(hp)
    _log_debug_profile("Found %d host profiles", len(matches))
    matches.sort(key=lambda h: h.host_name)

    return matches


def get_host_profile_by_id(machine_id, label="") -> Optional["HostProfile"]:
    """Find a HostProfile by its machine_id.

    Return the HostProfile object corresponding to ``machine_id``,
    or ``None`` if it is not found.

    :rtype: HostProfile
    :returns: An HostProfile matching machine_id or None if no match
              was found.
    """
    if not host_profiles_loaded():
        load_host_profiles()
    if machine_id in _host_profiles_by_id:
        if label in _host_profiles_by_id[machine_id]:
            return _host_profiles_by_id[machine_id][label]
    return None


def match_host_profile(entry) -> Optional["HostProfile"]:
    """Attempt to match a BootEntry to a corresponding HostProfile.

    Attempt to find a loaded ``HostProfile`` object with the a
    ``machine_id`` that matches the supplied ``BootEntry``.
    Checking terminates on the first matching ``HostProfile``.

    :param entry: A ``BootEntry`` object with no attached
                  ``HostProfile``.
    :returns: The corresponding ``HostProfile`` for the supplied
              ``BootEntry`` or ``None`` if no match is found.
    :rtype: ``BootEntry`` or ``NoneType``.
    """
    if not host_profiles_loaded():
        load_host_profiles()

    _log_debug(
        "Attempting to match profile for BootEntry(title='%s', "
        "version='%s') with machine_id='%s'",
        entry.title,
        entry.version,
        entry.machine_id,
    )

    # Attempt to match by uname pattern
    for hp in _host_profiles:
        if hp.machine_id == entry.machine_id:
            _log_debug(
                "Matched BootEntry(version='%s', boot_id='%s') "
                "to HostProfile(name='%s', machine_id='%s')",
                entry.version,
                entry.disp_boot_id,
                hp.host_name,
                hp.machine_id,
            )
            return hp

    return None


class HostProfile(BoomProfile):
    """Class HostProfile implements Boom host system profiles.

    Objects of type HostProfile define a host identity, and optional
    fields or ``BootParams`` modifications to be applied to the
    specified host.

    Host profiles may modify any non-identity ``OsProfile`` key,
    either adding to or replacing the value defined by an embedded
    ``OsProfile`` instance.
    """

    _unwritten = False
    _comments = None

    _profile_keys = HOST_PROFILE_KEYS
    _required_keys = HOST_REQUIRED_KEYS
    _identity_key = BOOM_HOST_ID

    _osp = None

    def _key_data(self, key) -> Optional[str]:
        if self._profile_data and key in self._profile_data:
            return self._profile_data[key]
        if self.osp and self.osp._profile_data and key in self.osp._profile_data:
            return self.osp._profile_data[key]
        return None

    def _have_key(self, key) -> bool:
        """Test for presence of a Host or Os profile key."""
        if self._profile_data and key in self._profile_data:
            return True
        if self.osp and self.osp._profile_data and key in self.osp._profile_data:
            return True
        return False

    def __str__(self) -> str:
        """Format this HostProfile as a human readable string.

        Profile attributes are printed as "Name: value, " pairs,
        with like attributes grouped together onto lines.

        :returns: A human readable string representation of this
                  HostProfile.

        :rtype: string
        """
        breaks = [
            BOOM_HOST_ID,
            BOOM_HOST_NAME,
            BOOM_OS_ID,
            BOOM_ENTRY_MACHINE_ID,
            BOOM_HOST_LABEL,
            BOOM_OS_VERSION,
            BOOM_OS_UNAME_PATTERN,
            BOOM_HOST_ADD_OPTS,
            BOOM_HOST_DEL_OPTS,
            BOOM_OS_INITRAMFS_PATTERN,
            BOOM_OS_ROOT_OPTS_LVM2,
            BOOM_OS_ROOT_OPTS_BTRFS,
            BOOM_OS_OPTIONS,
        ]

        fields = [f for f in HOST_PROFILE_KEYS if self._have_key(f)]
        hp_str = ""
        tail = ""
        for f in fields:
            hp_str += f'{HOST_KEY_NAMES[f]}: "{self._key_data(f)}"'
            tail = ",\n" if f in breaks else ", "
            hp_str += tail
        hp_str = hp_str.rstrip(tail)
        return hp_str

    def __repr__(self) -> str:
        """Format this HostProfile as a machine readable string.

        Return a machine-readable representation of this ``HostProfile``
        object. The string is formatted as a call to the ``HostProfile``
        constructor, with values passed as a dictionary to the
        ``profile_data`` keyword argument.

        :returns: a string representation of this ``HostProfile``.
        :rtype: string
        """
        hp_str = "HostProfile(profile_data={"
        fields = [f for f in HOST_PROFILE_KEYS if self._have_key(f)]
        for f in fields:
            hp_str += f'{f}:"{self._key_data(f)}", '
        hp_str = hp_str.rstrip(", ")
        return hp_str + "})"

    def _generate_id(self):
        """Generate a new host identifier.

        Generate a new sha1 profile identifier for this profile,
        using the machine_id, and label and store it in  _profile_data.

        :returns: None
        """
        machine_id = self.machine_id or ""
        label = self.label or ""
        hashdata = machine_id + label

        digest = sha1(hashdata.encode("utf-8"), usedforsecurity=False).hexdigest()
        self._profile_data[BOOM_HOST_ID] = digest

    def __set_os_profile(self):
        """Set this ``HostProfile``'s ``osp`` member to the
        corresponding profile for the set ``os_id``.
        """
        os_id = self._profile_data[BOOM_OS_ID]
        osps = find_profiles(Selection(os_id=os_id))
        if not osps:
            raise ValueError(f"OsProfile not found: {os_id}")
        if len(osps) > 1:
            raise ValueError(f"OsProfile identifier '{os_id}' is ambiguous")

        self.osp = osps[0]

    def _append_profile(self):
        """Append a HostProfile to the global profile list"""
        if _host_exists(self.host_id):
            raise ValueError(f"Profile already exists (host_id={self.disp_host_id})")

        _host_profiles.append(self)
        machine_id = self.machine_id
        if machine_id not in _host_profiles_by_id:
            _host_profiles_by_id[machine_id] = {}
        _host_profiles_by_id[machine_id][self.label] = self
        _host_profiles_by_host_id[self.host_id] = self

    def _from_data(self, host_data, dirty=True):
        """Initialise a ``HostProfile`` from in-memory data.

        Initialise a new ``HostProfile`` object using the profile
        data in the `host_data` dictionary.

        This method should not be called directly: to build a new
        `Hostprofile`` object from in-memory data, use the class
        initialiser with the ``host_data`` argument.

        :returns: None
        """
        for key in HOST_REQUIRED_KEYS:
            if key == BOOM_HOST_ID:
                continue
            if key not in host_data or not host_data[key]:
                raise ValueError(f"Invalid profile data (missing {key})")

        self._profile_data = dict(host_data)

        if BOOM_HOST_ID not in self._profile_data:
            self._generate_id()

        self.__set_os_profile()

        if dirty:
            self._dirty()

        self._append_profile()

    def __init__(
        self,
        machine_id=None,
        host_name=None,
        label=None,
        os_id=None,
        kernel_pattern=None,
        initramfs_pattern=None,
        root_opts_lvm2=None,
        root_opts_btrfs=None,
        add_opts="",
        del_opts="",
        options=None,
        profile_file=None,
        profile_data=None,
    ):
        """Initialise a new ``HostProfile`` object.

        If neither ``profile_file`` nor ``profile_data`` is given,
        all of ``machine_id``, ``name``, and ``os_id`` must be given.

        These values form the host profile identity and are used to
        generate the profile unique identifier.

        :param host_name: The hostname of this system
        :param os_id: An OS identifier specifying the ``OsProfile``
                      to use with this host profile.
        :param profile_data: An optional dictionary mapping from
                             ``BOOM_*`` keys to profile values.
        :param profile_file: An optional path to a file from which
                             profile data should be loaded. The file
                             should be in Boom host profile format,
                             with ``BOOM_*`` key=value pairs.
        :returns: A new ``HostProfile`` object.
        :rtype: class HostProfile
        """
        self._profile_data = {}

        # Initialise BoomProfile base class
        super().__init__(HOST_PROFILE_KEYS, HOST_REQUIRED_KEYS, BOOM_HOST_ID)

        if profile_data and profile_file:
            raise ValueError(
                "Only one of 'profile_data' or 'profile_file' may be specified."
            )

        if profile_data:
            self._from_data(profile_data)
            return
        if profile_file:
            self._from_file(profile_file)
            return

        self._dirty()

        required_args = [machine_id, host_name, os_id]
        if any(not val for val in required_args):
            raise ValueError(
                "Invalid host profile arguments: machine_id, "
                "host_name, and os_id are mandatory."
            )

        osps = find_profiles(Selection(os_id=os_id))
        if not osps:
            raise ValueError(f"No matching profile found for os_id={os_id}")
        if len(osps) > 1:
            raise ValueError(f"OsProfile ID is ambiguous: {os_id}")
        os_id = osps[0].os_id

        self._profile_data[BOOM_ENTRY_MACHINE_ID] = machine_id
        self._profile_data[BOOM_HOST_NAME] = host_name
        self._profile_data[BOOM_OS_ID] = os_id
        self._profile_data[BOOM_HOST_LABEL] = label

        # Only set keys that have a value in the host profile data dict
        if kernel_pattern:
            self._profile_data[BOOM_OS_KERNEL_PATTERN] = kernel_pattern
        if initramfs_pattern:
            self._profile_data[BOOM_OS_INITRAMFS_PATTERN] = initramfs_pattern
        if root_opts_lvm2:
            self._profile_data[BOOM_OS_ROOT_OPTS_LVM2] = root_opts_lvm2
        if root_opts_btrfs:
            self._profile_data[BOOM_OS_ROOT_OPTS_BTRFS] = root_opts_btrfs
        if add_opts:
            self._profile_data[BOOM_HOST_ADD_OPTS] = add_opts
        if del_opts:
            self._profile_data[BOOM_HOST_DEL_OPTS] = del_opts
        if options:
            self._profile_data[BOOM_OS_OPTIONS] = options

        self.__set_os_profile()

        self._generate_id()
        _host_profiles.append(self)

    # We use properties for the HostProfile attributes: this is to
    # allow the values to be stored in a dictionary. Although
    # properties are quite verbose this reduces the code volume
    # and complexity needed to marshal and unmarshal the various
    # file formats used, as well as conversion to and from string
    # representations of HostProfile objects.

    # Keys obtained from os-release data form the profile's identity:
    # the corresponding attributes are read-only.

    # HostProfile properties:
    #
    #  Profile identity properties (ro):
    #   host_id
    #   disp_os_id
    #
    #  Profile identity properties (rw):
    #   host_name
    #   machine_id
    #   os_id
    #
    #  Properties mapped to OsProfile (ro)
    #   os_name
    #   os_short_name
    #   os_version
    #   os_version_id
    #   uname_pattern
    #
    # Properties overridden or mapped to OsProfile (rw)
    #   kernel_pattern
    #   initramfs_pattern
    #   root_opts_lvm2
    #   root_opts_btrfs
    #   options
    #
    # HostProfile specific properties (rw)
    #   label
    #   add_opts
    #   del_opts

    @property
    def disp_os_id(self) -> str:
        """The display os_id of this profile.

        Return the shortest prefix of this OsProfile's os_id that
        is unique within the current set of loaded profiles.

        :getter: return this OsProfile's os_id.
        :type: str
        """
        return self.osp.disp_os_id if self.osp else ""

    @property
    def host_id(self) -> str:
        if self._profile_data and BOOM_HOST_ID not in self._profile_data:
            self._generate_id()
        return self._profile_data[BOOM_HOST_ID] if self._profile_data else ""

    @property
    def disp_host_id(self) -> str:
        """The display host_id of this profile

        Return the shortest prefix of this HostProfile's os_id that
        is unique within the current set of loaded profiles.

        :getter: return this HostProfile's display host_id.
        :type: str
        """
        return self.host_id[: min_host_id_width()]

    @property
    def disp_machine_id(self) -> str:
        """The machine_id of this host profile.
        Return the shortest prefix of this HostProfile's os_id that
        is unique within the current set of loaded profiles.

        :getter: return this HostProfile's display host_id.
        :type: str
        """
        return self.machine_id[: min_machine_id_width()]

    @property
    def machine_id(self) -> str:
        """The machine_id of this host profile.
        Return the shortest prefix of this HostProfile's os_id that
        is unique within the current set of loaded profiles.

        :getter: return this ``HostProfile``'s display host_id.
        :setter: change this ``HostProfile``'s ``machine_id``. This
                 will change the ``host_id``.
        :type: str
        """
        if self._profile_data and BOOM_ENTRY_MACHINE_ID in self._profile_data:
            return self._profile_data[BOOM_ENTRY_MACHINE_ID]
        return ""

    @machine_id.setter
    def machine_id(self, value: str):
        """Set the machine_id of this host profile.

        :param value: The new value to set.
        :type value: str
        """
        if self._profile_data and BOOM_ENTRY_MACHINE_ID in self._profile_data:
            if value == self._profile_data[BOOM_ENTRY_MACHINE_ID]:
                return
        if self._profile_data:
            self._profile_data[BOOM_ENTRY_MACHINE_ID] = value
            self._dirty()
            self._generate_id()

    @property
    def os_id(self) -> str:
        """The ``os_id`` of this profile.

        :getter: returns the ``os_id`` as a string.
        :type: string
        """
        return self.osp.os_id if self.osp else ""

    @os_id.setter
    def os_id(self, value):
        """Set the os_id of this host profile.

        :param value: The new value to set.
        :type value: str
        """
        if value == self._profile_data[BOOM_OS_ID]:
            return
        self._profile_data[BOOM_OS_ID] = value
        self.__set_os_profile()
        self._dirty()
        self._generate_id()

    @property
    def osp(self) -> Optional[OsProfile]:
        """The ``OsProfile`` used by this ``HostProfile``.

        :getter: returns the ``OsProfile`` object used by this
                 ``HostProfile``.
        :setter: stores a new ``OsProfile`` for use by this
                 ``HostProfile`` and updates the stored ``os_id``
                 value in the host profile.
        """
        return self._osp

    @osp.setter
    def osp(self, osp):
        """Set the ``OsProfile`` used by this ``HostProfile``.

        :param value: The new value to set.
        :type value: OsProfile
        """
        if self._osp and osp.os_id == self._osp.os_id:
            return
        self._osp = osp
        self._profile_data[BOOM_OS_ID] = osp.os_id
        self._dirty()
        self._generate_id()

    @property
    def host_name(self) -> str:
        """The ``host_name`` of this profile.

        Normally set to the hostname of the system corresponding to
        this ``HostProfile``.

        :getter: returns the ``host_name`` as a string.
        :type: string
        """
        return self._profile_data[BOOM_HOST_NAME]

    @host_name.setter
    def host_name(self, value: str):
        """Set the `host_name` used by this host profile.

        :param value: The new value to set.
        :type value: str
        """
        if value == self._profile_data[BOOM_HOST_NAME]:
            return
        self._profile_data[BOOM_HOST_NAME] = value
        self._dirty()
        self._generate_id()

    @property
    def short_name(self):
        """The ``short_name`` of this profile.

        If ``HostProfile.host_name`` appears to contain a DNS-style name,
        return only the host portion.

        :getter: returns the ``short_name`` as a string.
        :type: string
        """
        host_name = self._profile_data[BOOM_HOST_NAME]
        return host_name.split(".")[0] if "." in host_name else host_name

    #
    #  Properties mapped to OsProfile
    #

    @property
    def os_name(self):
        """The ``os_name`` of this profile.

        :getter: returns the ``os_name`` as a string.
        :type: string
        """
        return self.osp.os_name

    @property
    def os_short_name(self):
        """The ``os_short_name`` of this profile.

        :getter: returns the ``os_short_name`` as a string.
        :type: string
        """
        return self.osp.os_short_name

    @property
    def os_version(self):
        """The ``os_version`` of this profile.

        :getter: returns the ``os_version`` as a string.
        :type: string
        """
        return self.osp.os_version

    @property
    def os_version_id(self):
        """The ``os_version_id`` of this profile.

        :getter: returns the ``os_version_id`` as a string.
        :type: string
        """
        return self.osp.os_version_id

    @property
    def uname_pattern(self):
        """The current ``uname_pattern`` setting of this profile.

        :getter: returns the ``uname_pattern`` as a string.
        :setter: stores a new ``uname_pattern`` setting.
        :type: string
        """
        if BOOM_OS_UNAME_PATTERN in self._profile_data:
            return self._profile_data[BOOM_OS_UNAME_PATTERN]
        return self.osp.uname_pattern

    @uname_pattern.setter
    def uname_pattern(self, value):
        """Set the `uname_pattern` used by this host profile.

        :param value: The new value to set.
        :type value: str
        """
        self._profile_data[BOOM_OS_UNAME_PATTERN] = value
        self._dirty()

    #
    # Properties overridden or mapped to OsProfile
    #

    @property
    def kernel_pattern(self):
        """The current ``kernel_pattern`` setting of this profile.

        :getter: returns the ``kernel_pattern`` as a string.
        :setter: stores a new ``kernel_pattern`` setting.
        :type: string
        """
        if BOOM_OS_KERNEL_PATTERN in self._profile_data:
            return self._profile_data[BOOM_OS_KERNEL_PATTERN]
        return self.osp.kernel_pattern

    @kernel_pattern.setter
    def kernel_pattern(self, value):
        """Set the `kernel_pattern` used by this host profile.

        :param value: The new value to set.
        :type value: str
        """
        kernel_key = key_from_key_name(FMT_KERNEL)
        if kernel_key in value:
            raise ValueError(f"HostProfile.kernel cannot contain {kernel_key}")
        self._profile_data[BOOM_OS_KERNEL_PATTERN] = value
        self._dirty()

    @property
    def initramfs_pattern(self):
        """The current ``initramfs_pattern`` setting of this profile.

        :getter: returns the ``initramfs_pattern`` as a string.
        :setter: store a new ``initramfs_pattern`` setting.
        :type: string
        """
        if BOOM_OS_INITRAMFS_PATTERN in self._profile_data:
            return self._profile_data[BOOM_OS_INITRAMFS_PATTERN]
        return self.osp.initramfs_pattern

    @initramfs_pattern.setter
    def initramfs_pattern(self, value):
        """Set the `initramfs_pattern` used by this host profile.

        :param value: The new value to set.
        :type value: str
        """
        initramfs_key = key_from_key_name(FMT_INITRAMFS)
        if initramfs_key in value:
            raise ValueError(f"HostProfile.initramfs cannot contain {initramfs_key}")
        self._profile_data[BOOM_OS_INITRAMFS_PATTERN] = value
        self._dirty()

    @property
    def root_opts_lvm2(self):
        """The current LVM2 root options setting of this profile.

        :getter: returns the ``root_opts_lvm2`` value as a string.
        :setter: store a new ``root_opts_lvm2`` value.
        :type: string
        """
        if BOOM_OS_ROOT_OPTS_LVM2 in self._profile_data:
            return self._profile_data[BOOM_OS_ROOT_OPTS_LVM2]

        return self.osp.root_opts_lvm2

    @root_opts_lvm2.setter
    def root_opts_lvm2(self, value):
        """Set the LVM2 root options used by this host profile.

        :param value: The new value to set.
        :type value: str
        """
        root_opts_key = key_from_key_name(FMT_ROOT_OPTS)
        if root_opts_key in value:
            raise ValueError(
                f"HostProfile.root_opts_lvm2 cannot contain {root_opts_key}"
            )
        self._profile_data[BOOM_OS_ROOT_OPTS_LVM2] = value
        self._dirty()

    @property
    def root_opts_btrfs(self):
        """The current BTRFS root options setting of this profile.

        :getter: returns the ``root_opts_btrfs`` value as a string.
        :setter: store a new ``root_opts_btrfs`` value.
        :type: string
        """
        if BOOM_OS_ROOT_OPTS_BTRFS in self._profile_data:
            return self._profile_data[BOOM_OS_ROOT_OPTS_BTRFS]
        return self.osp.root_opts_btrfs

    @root_opts_btrfs.setter
    def root_opts_btrfs(self, value):
        """Set the BTRFS root options used by this host profile.

        :param value: The new value to set.
        :type value: str
        """
        root_opts_key = key_from_key_name(FMT_ROOT_OPTS)
        if root_opts_key in value:
            raise ValueError(
                f"HostProfile.root_opts_btrfs cannot contain {root_opts_key}"
            )
        self._profile_data[BOOM_OS_ROOT_OPTS_BTRFS] = value
        self._dirty()

    @property
    def options(self):
        """The current kernel command line options setting for this
        profile.

        :getter: returns the ``options`` value as a string.
        :setter: store a new ``options`` value.
        :type: string
        """
        if BOOM_OS_OPTIONS in self._profile_data:
            return self._profile_data[BOOM_OS_OPTIONS]
        return self.osp.options

    @options.setter
    def options(self, value):
        """Set the options template used by this host profile.

        :param value: The new value to set.
        :type value: str
        """
        self._profile_data[BOOM_OS_OPTIONS] = value
        self._dirty()

    @property
    def title(self):
        """The current title template for this profile.

        :getter: returns the ``title`` value as a string.
        :setter: store a new ``title`` value.
        :type: string
        """
        if BOOM_OS_TITLE not in self._profile_data:
            return None
        return self._profile_data[BOOM_OS_TITLE]

    @title.setter
    def title(self, value):
        """Set the title used by this host profile.

        :param value: The new value to set.
        :type value: str
        """
        if not value:
            # It is valid to set an empty title in a HostProfile as long
            # as the OsProfile defines one.
            if not self.osp or not self.osp.title:
                raise ValueError("Entry title cannot be empty")
        self._profile_data[BOOM_OS_TITLE] = value
        self._dirty()

    @property
    def optional_keys(self):
        """Return the `optional_keys` value used by this host profile.

        :rtype: str
        """
        if BOOM_OS_OPTIONAL_KEYS in self._profile_data:
            return self._profile_data[BOOM_OS_OPTIONAL_KEYS]
        if not self.osp or not self.osp.optional_keys:
            return ""
        return self.osp.optional_keys

    @optional_keys.setter
    def optional_keys(self, value):
        """Set the `optional_keys` used by this host profile.

        :param value: The new value to set.
        :type value: str
        """
        if value is None:
            value = ""
        for opt_key in value.split():
            self._check_optional_key(opt_key)
        self._profile_data[BOOM_OS_OPTIONAL_KEYS] = value
        self._dirty()

    #
    # HostProfile specific properties
    #

    @property
    def add_opts(self):
        """Return additional options defined by this host profile.

        :rtype: str
        """
        if BOOM_HOST_ADD_OPTS in self._profile_data:
            return self._profile_data[BOOM_HOST_ADD_OPTS]
        return ""

    @add_opts.setter
    def add_opts(self, opts):
        """Set additional options used by this host profile.

        :param opts: The new value to set.
        :type opts: str
        """
        self._profile_data[BOOM_HOST_ADD_OPTS] = opts
        self._dirty()

    @property
    def del_opts(self):
        """Return options deleted this host profile.

        :rtype: str
        """
        if BOOM_HOST_DEL_OPTS in self._profile_data:
            return self._profile_data[BOOM_HOST_DEL_OPTS]
        return ""

    @del_opts.setter
    def del_opts(self, opts):
        """Set options deleted by this host profile.

        :param opts: The new value to set.
        :type opts: str
        """
        self._profile_data[BOOM_HOST_DEL_OPTS] = opts
        self._dirty()

    @property
    def label(self):
        """Return label for this host profile.

        :rtype: str
        """
        if BOOM_HOST_LABEL in self._profile_data:
            return self._profile_data[BOOM_HOST_LABEL]
        return ""

    @label.setter
    def label(self, value: Optional[str]):
        """Set label for this host profile.

        :param value: The new value to set.
        :type value: str
        """
        if value is None:
            value = ""

        valid_chars = string.ascii_letters + string.digits + "_- "

        if BOOM_HOST_LABEL in self._profile_data:
            if self._profile_data[BOOM_HOST_LABEL] == value:
                return

        for c in value:
            if c not in valid_chars:
                raise ValueError(f"Invalid host label character: '{c}'")

        self._profile_data[BOOM_HOST_LABEL] = value
        self._dirty()
        self._generate_id()

    def _profile_path(self):
        """Return the path to this profile's on-disk data.

        Return the full path to this HostProfile in the Boom profiles
        directory (or the location to which it will be written, if
        it has not yet been written).

        :rtype: str
        :returns: The absolute path for this HostProfile's file
        """
        if self.label:
            label = self.label
            if " " in label:
                label = label.replace(" ", "_")
            names = (self.short_name, label)
            name_fmt = "%s-%s"
        else:
            names = self.short_name
            name_fmt = "%s"
        profile_name = name_fmt % names
        profile_id = (self.host_id, profile_name)
        profile_path_name = BOOM_HOST_PROFILE_FORMAT % profile_id
        return path_join(boom_host_profiles_path(), profile_path_name)

    def write_profile(self, force=False):
        """Write out profile data to disk.

        Write out this ``HostProfile``'s data to a file in Boom
        format to the paths specified by the current configuration.

        Currently the ``machine_id`` and ``name`` keys are used to
        construct the file name.

        If the value of ``force`` is ``False`` and the ``HostProfile``
        is not currently marked as dirty (either new, or modified
        since the last load operation) the write will be skipped.

        :param force: Force this profile to be written to disk even
                      if the entry is unmodified.
        :raises: ``OSError`` if the temporary entry file cannot be
                 renamed, or if setting file permissions on the
                 new entry file fails.
        """
        path = boom_host_profiles_path()
        mode = BOOM_HOST_PROFILE_MODE
        self._write_profile(self.host_id, path, mode, force=force)

    def delete_profile(self):
        """Delete on-disk data for this profile.

        Remove the on-disk profile corresponding to this
        ``HostProfile`` object. This will permanently erase the
        current file (although the current data may be re-written at
        any time by calling ``write_profile()`` before the object is
        disposed of).

        :rtype: ``NoneType``
        :raises: ``OSError`` if an error occurs removing the file or
                 ``ValueError`` if the profile does not exist.
        """
        self._delete_profile(self.host_id)

        machine_id = self.machine_id
        host_id = self.host_id
        if _host_profiles and self in _host_profiles:
            _host_profiles.remove(self)
        if _host_profiles_by_id and machine_id in _host_profiles_by_id:
            if self.label in _host_profiles_by_id[machine_id]:
                del _host_profiles_by_id[machine_id][self.label]
            # Only remove machine_id dict if no labels remain
            if not _host_profiles_by_id[machine_id]:
                del _host_profiles_by_id[machine_id]
        if _host_profiles_by_host_id and host_id in _host_profiles_by_host_id:
            _host_profiles_by_host_id.pop(host_id)


__all__ = [
    # Host profiles
    "HostProfile",
    "drop_host_profiles",
    "load_host_profiles",
    "write_host_profiles",
    "host_profiles_loaded",
    "find_host_profiles",
    "select_host_profile",
    "get_host_profile_by_id",
    "match_host_profile",
    # Host profile keys
    "BOOM_HOST_ID",
    "BOOM_HOST_NAME",
    "BOOM_HOST_ADD_OPTS",
    "BOOM_HOST_DEL_OPTS",
    "BOOM_HOST_LABEL",
    "HOST_PROFILE_KEYS",
    "HOST_REQUIRED_KEYS",
    "HOST_OPTIONAL_KEYS",
    # Path configuration
    "boom_host_profiles_path",
]

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