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

import os
import importlib

import gi
gi.require_version("BlockDev", "3.0")

from gi.repository import BlockDev as blockdev

from .. import udev
from ..util import get_sysfs_path_by_name
from ..util import run_program
from ..util import ObjectID
from ..storage_log import log_method_call
from ..errors import DeviceFormatError, FormatCreateError, FormatDestroyError, FormatSetupError
from ..i18n import N_
from ..size import Size, ROUND_DOWN, unit_str
from ..threads import SynchronizedMeta
from ..flags import flags

from ..errors import FSError, FSResizeError, LUKSError, FormatResizeError

from ..tasks import fsinfo
from ..tasks import fsresize
from ..tasks import fssize
from ..tasks import fsck
from ..tasks import fsminsize

import logging
log = logging.getLogger("blivet")

device_formats = {}


def register_device_format(fmt_class):
    if not issubclass(fmt_class, DeviceFormat):
        raise ValueError("arg1 must be a subclass of DeviceFormat")

    device_formats[fmt_class._type] = fmt_class
    log.debug("registered device format class %s as %s", fmt_class.__name__,
              fmt_class._type)


default_fstypes = ("ext4", "xfs", "ext3", "ext2")


def get_default_filesystem_type():
    for fstype in default_fstypes:
        try:
            supported = get_format(fstype).supported
        except AttributeError:
            supported = False

        if supported:
            return fstype

    raise DeviceFormatError("None of %s is supported by your kernel" % ",".join(default_fstypes))


def get_format(fmt_type, *args, **kwargs):
    """ Return an instance of the appropriate DeviceFormat class.

        :param fmt_type: The name of the formatting type
        :type fmt_type: str.
        :return: the format instance
        :rtype: :class:`DeviceFormat`
        :raises: ValueError

        .. note::

            Any additional arguments will be passed on to the constructor for
            the format class. See the various :class:`DeviceFormat` subclasses
            for an exhaustive list of the arguments that can be passed.
    """
    fmt_class = get_device_format_class(fmt_type)
    if not fmt_class:
        fmt_class = DeviceFormat
    fmt = fmt_class(*args, **kwargs)

    # this allows us to store the given type for formats we implement as
    # DeviceFormat.
    if fmt_type and fmt.type is None:
        # unknown type, but we can set the name of the format
        # this should add/set an instance attribute
        fmt._name = fmt_type

    log.debug("get_format('%s') returning %s instance with object id %d",
              fmt_type, fmt.__class__.__name__, fmt.id)
    return fmt


def get_device_format_class(fmt_type):
    """ Return an appropriate format class.

        :param fmt_type: The name of the format type.
        :type fmt_type: str.
        :returns: The chosen DeviceFormat class
        :rtype: class.

        Returns None if no class is found for fmt_type.
    """
    fmt = device_formats.get(fmt_type)
    if not fmt:
        for fmt_class in device_formats.values():
            if fmt_type and fmt_type == fmt_class._name:
                fmt = fmt_class
                break
            elif fmt_type in fmt_class._udev_types:
                fmt = fmt_class
                break

    return fmt


class DeviceFormat(ObjectID, metaclass=SynchronizedMeta):

    """ Generic device format.

        This represents the absence of recognized formatting. That could mean a
        device is uninitialized, has had zeros written to it, or contains some
        valid formatting that this module does not support.
    """
    _type = None
    _name = N_("Unknown")
    _udev_types = []
    parted_flag = None
    parted_system = None
    _formattable = False                # can be formatted
    _supported = False                  # is supported
    _linux_native = False                # for clearpart
    _packages = []                      # required packages
    _resizable = False                  # can be resized
    _max_size = Size(0)                  # maximum size
    _min_size = Size(0)                  # minimum size
    _dump = False
    _check = False
    _hidden = False                     # hide devices with this formatting?
    _ks_mountpoint = None
    _protected = False

    _resize_class = fsresize.UnimplementedFSResize
    _size_info_class = fssize.UnimplementedFSSize
    _info_class = fsinfo.UnimplementedFSInfo
    _minsize_class = fsminsize.UnimplementedFSMinSize

    def __init__(self, **kwargs):
        """
            :keyword device: The path to the device node.
            :type device: str
            :keyword uuid: the formatting's UUID.
            :type uuid: str
            :keyword exists: Whether the formatting exists. (default: False)
            :raises: ValueError

            .. note::

                The 'device' kwarg is required for existing formats. For non-
                existent formats, it is only necessary that the :attr:`device`
                attribute be set before the :meth:`create` method runs. Note
                that you can specify the device at the last moment by specifying
                it via the 'device' kwarg to the :meth:`create` method.
        """
        ObjectID.__init__(self)
        self._label = None
        self._options = None
        self._device = None

        self.device = kwargs.get("device")
        self.uuid = kwargs.get("uuid")
        self.exists = kwargs.get("exists", False)
        self.options = kwargs.get("options")
        self._create_options = kwargs.get("create_options")

        # Create task objects
        self._info = self._info_class(self)
        self._resize = self._resize_class(self)
        # These two may depend on info class, so create them after
        self._minsize = self._minsize_class(self)
        self._size_info = self._size_info_class(self)

        # format size does not necessarily equal device size
        self._size = kwargs.get("size", Size(0))
        self._target_size = self._size
        self._min_instance_size = Size(0)    # min size of this DeviceFormat instance

        # Resize operations are limited to error-free formats whose current
        # size is known.
        self._resizable = False

    def __repr__(self):
        s = ("%(classname)s instance (%(id)s) object id %(object_id)d--\n"
             "  type = %(type)s  name = %(name)s  status = %(status)s\n"
             "  device = %(device)s  uuid = %(uuid)s  exists = %(exists)s\n"
             "  options = %(options)s\n"
             "  create_options = %(create_options)s  supported = %(supported)s"
             "  formattable = %(format)s  resizable = %(resize)s\n" %
             {"classname": self.__class__.__name__, "id": "%#x" % id(self),
              "object_id": self.id,
              "type": self.type, "name": self.name, "status": self.status,
              "device": self.device, "uuid": self.uuid, "exists": self.exists,
              "options": self.options, "supported": self.supported,
              "format": self.formattable, "resize": self.resizable,
              "create_options": self.create_options})
        return s

    @property
    def _existence_str(self):
        return "existing" if self.exists else "non-existent"

    @property
    def desc(self):
        return str(self.type)

    def __str__(self):
        return "%s %s" % (self._existence_str, self.desc)

    @property
    def dict(self):
        d = {"type": self.type, "name": self.name, "device": self.device,
             "uuid": self.uuid, "exists": self.exists,
             "options": self.options, "supported": self.supported,
             "resizable": self.resizable, "create_options": self.create_options}

        return d

    def labeling(self):
        """Returns False by default since most formats are non-labeling."""
        return False

    def relabels(self):
        """Returns False by default since most formats are non-labeling."""
        return False

    def label_format_ok(self, label):
        """Checks whether the format of the label is OK for whatever
           application is used by blivet to write a label for this format.
           If there is no application that blivet uses to write a label,
           then no format is acceptable, so must return False.

           :param str label: The label to be checked

           :rtype: bool
           :return: True if the format of the label is OK, otherwise False
        """
        # pylint: disable=unused-argument
        return self.labeling()

    def _set_label(self, label):
        """Sets the label for this format.

           :param label: the label for this format
           :type label: str or None

           None means no label specified, or in other words, accept the default
           label that the filesystem app may set. Once the device exists the
           label should not be None, as the device must then have some label
           even if just the empty label.

           "" means the empty label, i.e., no label.

           Some filesystems, even though they do not have a
           labeling application may be already labeled, so we allow to set
           the label of a filesystem even if a labeling application does not
           exist. This can happen with the install media, for example, where
           the filesystem on the CD has a label, but there is no labeling
           application for the Iso9660FS format.

           If a labeling application does exist, the label is not
           required to have the correct format for that application.
           The allowable format for the label may be more permissive than
           the format allowed by the labeling application.

           This method is not intended to be overridden.
        """
        self._label = label

    def _get_label(self):
        """The label for this filesystem.

           :return: the label for this device
           :rtype: str

           This method is not intended to be overridden.
        """
        return self._label

    def _set_options(self, options):
        self._options = options

    def _get_options(self):
        return self._options

    options = property(
        lambda s: s._get_options(),
        lambda s, v: s._set_options(v),
        doc="fstab entry option string"
    )

    def _set_create_options(self, options):
        self._create_options = options

    def _get_create_options(self):
        return self._create_options

    create_options = property(
        lambda s: s._get_create_options(),
        lambda s, v: s._set_create_options(v),
        doc="options to be used when running mkfs"
    )

    def _device_check(self, devspec):
        """ Verifies that device spec has a proper format.

            :param devspec: the device spec
            :type devspec: str or NoneType
            :rtype: str or NoneType
            :returns: an explanatory message if devspec fails check, else None
        """
        if devspec and not devspec.startswith("/"):
            return "device must be a fully qualified path"
        return None

    def _set_device(self, devspec):
        error_msg = self._device_check(devspec)
        if error_msg:
            raise ValueError(error_msg)
        self._device = devspec

    def _get_device(self):
        return self._device

    device = property(lambda f: f._get_device(),
                      lambda f, d: f._set_device(d),
                      doc="Full path the device this format occupies")

    @property
    def name(self):
        return self._name or self.type

    @property
    def type(self):
        return self._type

    def _set_target_size(self, newsize):
        """ Set the target size for this filesystem.

            :param :class:`~.size.Size` newsize: the newsize
        """
        if not isinstance(newsize, Size):
            raise ValueError("new size must be of type Size")

        if not self.exists:
            raise DeviceFormatError("format has not been created")

        if not self.resizable:
            raise DeviceFormatError("format is not resizable")

        if newsize < self.min_size:
            raise ValueError("requested size %s must be at least minimum size %s" % (newsize, self.min_size))

        if self.max_size and newsize >= self.max_size:
            raise ValueError("requested size %s must be less than maximum size %s" % (newsize, self.max_size))

        self._target_size = newsize

    def _get_target_size(self):
        """ Get this filesystem's target size. """
        return self._target_size

    target_size = property(_get_target_size, _set_target_size,
                           doc="Target size for this filesystem")

    def _get_size(self):
        """ Get this filesystem's size. """
        return self.target_size if self.resizable else self._size

    size = property(_get_size, doc="This filesystem's size, accounting "
                    "for pending changes")

    @property
    def min_size(self):
        # If self._min_instance_size is not 0, then it should be no less than
        # self._min_size, by definition, and since a non-zero value indicates
        # that it was obtained, it is the preferred value.
        # If self._min_instance_size is less than self._min_size,
        # but not 0, then there must be some mistake, so better to use
        # self._min_size.
        return max(self._min_instance_size, self._min_size)

    def update_size_info(self):
        """ Update this format's current and minimum size (for resize). """

    def do_resize(self):
        """ Resize this filesystem based on this instance's target_size attr.

            :raises: FSResizeError, FormatResizeError
        """
        if not self.exists:
            raise FormatResizeError("format does not exist", self.device)

        if not self.resizable:
            raise FormatResizeError("format not resizable", self.device)

        # skip if sizes are equal unless grow to fill on lvmpv is requested
        if (self.target_size == self.current_size and
                (self.type != "lvmpv" or not self.grow_to_fill)):  # pylint: disable=no-member
            return

        if not self._resize.available:
            return

        # tmpfs mounts don't need an existing device node
        if not self.device == "tmpfs" and not os.path.exists(self.device):
            raise FormatResizeError("device does not exist", self.device)

        self._pre_resize()

        self.update_size_info()

        # Check again if resizable is True, as update_size_info() can change that
        if not self.resizable:
            raise FormatResizeError("format not resizable", self.device)

        if self.target_size < self.min_size:
            self.target_size = self.min_size
            log.info("Minimum size changed, setting target_size on %s to %s",
                     self.device, self.target_size)

        # Bump target size to nearest whole number of the resize tool's units.
        # We always round down because the fs has to fit on whatever device
        # contains it. To round up would risk quietly setting a target size too
        # large for the device to hold.
        rounded = self.target_size.round_to_nearest(self._resize.unit,
                                                    rounding=ROUND_DOWN)

        # 1. target size was between the min size and max size values prior to
        #    rounding (see _set_target_size)
        # 2. we've just rounded the target size down (or not at all)
        # 3. the minimum size is already either rounded (see _get_min_size) or is
        #    equal to the current size (see update_size_info)
        # 5. the minimum size is less than or equal to the current size (see
        #    _get_min_size)
        #
        # This, I think, is sufficient to guarantee that the rounded target size
        # is greater than or equal to the minimum size.

        # It is possible that rounding down a target size greater than the
        # current size would move it below the current size, thus changing the
        # direction of the resize. That means the target size was less than one
        # unit larger than the current size, and we should do nothing and return
        # early.
        if self.target_size > self.current_size and rounded < self.current_size:
            log.info("rounding target size down to next %s obviated resize of "
                     "filesystem on %s", unit_str(self._resize.unit), self.device)
            return
        else:
            self.target_size = rounded

        try:
            self._resize.do_task()
        except FSError as e:
            raise FSResizeError(e, self.device)
        except LUKSError as e:
            raise FormatResizeError(e, self.device)

        self._post_resize()

    def _pre_resize(self):
        """ Do whatever needs to be done before the format is resized """

    def _post_resize(self):
        # XXX must be a smarter way to do this
        self._size = self.target_size

    @property
    def current_size(self):
        """ The filesystem's current actual size. """
        return self._size if self.exists else Size(0)

    def create(self, **kwargs):
        """ Write the formatting to the specified block device.

            :keyword device: path to device node
            :type device: str.
            :raises: FormatCreateError
            :returns: None.

            .. :note::

                If a device node path is passed to this method it will overwrite
                any previously set value of this instance's "device" attribute.
        """
        log_method_call(self, device=self.device,
                        type=self.type, status=self.status)
        self._pre_create(**kwargs)
        self._create(**kwargs)
        self._post_create(**kwargs)

    def _pre_create(self, **kwargs):
        """ Perform checks and setup prior to creating the format. """
        # allow late specification of device path
        device = kwargs.get("device")
        if device:
            self.device = device

        if not os.path.exists(self.device):
            raise FormatCreateError("invalid device specification %s" % self.device)

        if self.exists:
            raise DeviceFormatError("format already exists")

        if self.status:
            raise DeviceFormatError("device exists and is active")

    # pylint: disable=unused-argument
    def _create(self, **kwargs):
        """ Type-specific create method. """

    # pylint: disable=unused-argument
    def _post_create(self, **kwargs):
        self.exists = True

    def destroy(self, **kwargs):
        """ Remove the formatting from the associated block device.

            :raises: FormatDestroyError
            :returns: None.
        """
        log_method_call(self, device=self.device,
                        type=self.type, status=self.status)
        self._pre_destroy(**kwargs)
        self._destroy(**kwargs)
        self._post_destroy(**kwargs)

    # pylint: disable=unused-argument
    def _pre_destroy(self, **kwargs):
        if not self.exists:
            raise DeviceFormatError("format has not been created")

        if self.status:
            raise DeviceFormatError("device is active")

        if not os.access(self.device, os.W_OK):
            raise DeviceFormatError("device path does not exist or is not writable")

    def _destroy(self, **kwargs):
        try:
            blockdev.fs.clean(self.device, force=True)
        except blockdev.FSError as e:
            raise FormatDestroyError("error wiping old signatures from %s: %s" % (self.device, str(e)))

    def _post_destroy(self, **kwargs):
        udev.settle()
        self.exists = False

    @property
    def destroyable(self):
        """ Do we have the facilities to destroy a format of this type. """
        # assumes wipefs is always available
        return True

    def setup(self, **kwargs):
        """ Activate the formatting.

            :keyword device: device node path
            :type device: str.
            :raises: FormatSetupError.
            :returns: None.

            .. :note::

                If a device node path is passed to this method it will overwrite
                any previously set value of this instance's "device" attribute.
        """
        log_method_call(self, device=self.device,
                        type=self.type, status=self.status)
        if not self._pre_setup(**kwargs):
            return

        self._setup(**kwargs)
        self._post_setup(**kwargs)

    @property
    def controllable(self):
        """ Are external utilities available to allow this format to be both
            setup and teared down.

            :returns: True if this format can be set up, otherwise False
            :rtype: bool
        """
        return True

    def _pre_setup(self, **kwargs):
        """ Return True if setup should proceed. """
        if not self.exists:
            raise FormatSetupError("format has not been created")

        # allow late specification of device path
        device = kwargs.get("device")
        if device:
            self.device = device

        if not self.device or not os.path.exists(self.device):
            raise FormatSetupError("invalid device specification")

        return not self.status

    # pylint: disable=unused-argument
    def _setup(self, **kwargs):
        pass

    # pylint: disable=unused-argument
    def _post_setup(self, **kwargs):
        pass

    def teardown(self, **kwargs):
        """ Deactivate the formatting. """
        log_method_call(self, device=self.device,
                        type=self.type, status=self.status)
        if not self._pre_teardown(**kwargs):
            return

        self._teardown(**kwargs)
        self._post_teardown(**kwargs)

    def _pre_teardown(self, **kwargs):
        """ Return True if teardown should proceed. """
        if not self.exists:
            raise DeviceFormatError("format has not been created")

        return self.status

    def _teardown(self, **kwargs):
        pass

    def _post_teardown(self, **kwargs):
        pass

    @property
    def status(self):
        return (self.exists and
                self.__class__ is not DeviceFormat and
                isinstance(self.device, str) and
                self.device and
                os.path.exists(self.device))

    @property
    def formattable(self):
        """ Can we create formats of this type? """
        return self._formattable

    @property
    def supported(self):
        """ Is this format a supported type?

            Are the necessary external applications required by the
            functionality that this format provides actually provided by
            the environment in which blivet is running?
        """
        return self._supported

    @property
    def packages(self):
        """ Packages required to manage formats of this type. """
        return self._packages

    @property
    def protected(self):
        """ Is this format protected? """
        return self._protected

    @property
    def resizable(self):
        """ Can formats of this type be resized? """
        return self._resizable and self.exists

    @property
    def linux_native(self):
        """ Is this format type native to linux? """
        return self._linux_native

    @property
    def mountable(self):
        """ Is this something we can mount? """
        return False

    @property
    def dump(self):
        """ Whether or not this format will be dumped by dump(8). """
        return self._dump

    @property
    def check(self):
        """ Whether or not this format is checked on boot. """
        return self._check

    @property
    def max_size(self):
        """ Maximum size for this format type. """
        return self._max_size

    @property
    def hidden(self):
        """ Whether devices with this formatting should be hidden in UIs. """
        return self._hidden

    @property
    def ks_mountpoint(self):
        return (self._ks_mountpoint or self.type or "")

    def populate_ksdata(self, data):
        data.format = not self.exists
        data.fstype = self.type
        data.mountpoint = self.ks_mountpoint

    def drop_cache(self):
        """ Drop cached information stored for this format """


register_device_format(DeviceFormat)

# import the format modules (which register their device formats)
from . import biosboot, disklabel, dmraid, fslib, fs, luks, lvmpv, mdraid, multipath, prepboot, swap, stratis
