# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#   http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Implementation of the inject_files deploy step."""

import base64
import contextlib
import os

from ironic_lib import disk_utils
from ironic_lib import utils as ironic_utils
from oslo_concurrency import processutils
from oslo_config import cfg
from oslo_log import log

from ironic_python_agent import errors
from ironic_python_agent import hardware
from ironic_python_agent import utils


CONF = cfg.CONF
LOG = log.getLogger(__name__)


ARGSINFO = {
    "files": {
        "description": (
            "Files to inject, a list of file structures with keys: 'path' "
            "(path to the file), 'partition' (partition specifier), "
            "'content' (base64 encoded string), 'mode' (new file mode) and "
            "'dirmode' (mode for the leaf directory, if created). "
            "Merged with the values from node.properties[inject_files]."
        ),
        "required": False,
    },
    "verify_ca": {
        "description": (
            "Whether to verify TLS certificates. Global agent options "
            "are used by default."
        ),
        "required": False,
    }
}


def inject_files(node, ports, files, verify_ca=True):
    """A deploy step to inject arbitrary files.

    :param node: A dictionary of the node object
    :param ports: A list of dictionaries containing information
                  of ports for the node
    :param files: See ARGSINFO.
    :param verify_ca: Whether to verify TLS certificate.
    :raises: InvalidCommandParamsError
    """
    files = _validate_files(
        node['properties'].get('inject_files') or [],
        files or [])
    if not files:
        LOG.info('No files to inject')
        return

    http_get = utils.StreamingClient(verify_ca)
    root_dev = hardware.dispatch_to_managers('get_os_install_device')

    for fl in files:
        _inject_one(node, ports, fl, root_dev, http_get)


def _inject_one(node, ports, fl, root_dev, http_get):
    """Inject one file.

    :param node: A dictionary of the node object
    :param ports: A list of dictionaries containing information
                  of ports for the node
    :param fl: File information.
    :param root_dev: Root device used for the current node.
    :param http_get: Context manager to get HTTP URLs.
    """
    with _find_and_mount_path(fl['path'], fl.get('partition'),
                              root_dev) as path:
        if fl.get('deleted'):
            ironic_utils.unlink_without_raise(path)
            return

        try:
            dirpath = os.path.dirname(path)
            try:
                os.makedirs(dirpath)
            except FileExistsError:
                pass
            else:
                # Use chmod here and below to avoid relying on umask
                if fl.get('dirmode'):
                    os.chmod(dirpath, fl['dirmode'])

            content = fl['content']
            with open(path, 'wb') as fp:
                if '://' in content:
                    # Allow node-specific URLs to be used in a deploy template
                    url = content.format(node=node, ports=ports)
                    with http_get(url) as resp:
                        for chunk in resp:
                            fp.write(chunk)
                else:
                    fp.write(base64.b64decode(content))

            if fl.get('mode'):
                os.chmod(path, fl['mode'])

            if fl.get('owner') is not None or fl.get('group') is not None:
                # -1 means do not change
                os.chown(path, fl.get('owner', -1), fl.get('group', -1))
        except Exception as exc:
            LOG.exception('Failed to process file %s', fl)
            raise errors.CommandExecutionError(
                'Failed to process file %s. %s: %s'
                % (fl, type(exc).__class__, exc))


@contextlib.contextmanager
def _find_and_mount_path(path, partition, root_dev):
    """Find the specified path on a device.

    Tries to find the suitable device for the file based on the ``path`` and
    ``partition``, mount the device and provides the actual full path.

    :param path: Path to the file to find.
    :param partition: Device to find the file on or None.
    :param root_dev: Root device from the hardware manager.
    :return: Context manager that yields the full path to the file.
    """
    path = os.path.normpath(path.strip('/'))  # to make path joining work
    if partition:
        try:
            part_num = int(partition)
        except ValueError:
            with ironic_utils.mounted(partition) as part_path:
                yield os.path.join(part_path, path)
        else:
            # TODO(dtantsur): switch to ironic-lib instead:
            # https://review.opendev.org/c/openstack/ironic-lib/+/774502
            part_template = '%s%s'
            if 'nvme' in root_dev:
                part_template = '%sp%s'
            part_dev = part_template % (root_dev, part_num)

            with ironic_utils.mounted(part_dev) as part_path:
                yield os.path.join(part_path, path)
    else:
        try:
            # This turns e.g. etc/sysctl.d/my.conf into etc + sysctl.d/my.conf
            detect_dir, rest_dir = path.split('/', 1)
        except ValueError:
            # Validation ensures that files in / have "partition" present,
            # checking here just in case.
            raise errors.InvalidCommandParamsError(
                "Invalid path %s, must be an absolute path to a file" % path)

        with find_partition_with_path(detect_dir, root_dev) as part_path:
            yield os.path.join(part_path, rest_dir)


@contextlib.contextmanager
def find_partition_with_path(path, device=None):
    """Find a partition with the given path.

    :param path: Expected path.
    :param device: Target device. If None, the root device is used.
    :returns: A context manager that will unmount and delete the temporary
        mount point on exit.
    """
    if device is None:
        device = hardware.dispatch_to_managers('get_os_install_device')
    partitions = disk_utils.list_partitions(device)
    # Make os.path.join work as expected
    lookup_path = path.lstrip('/')

    for part in partitions:
        if 'lvm' in part['flags']:
            LOG.debug('Skipping LVM partition %s', part)
            continue

        # TODO(dtantsur): switch to ironic-lib instead:
        # https://review.opendev.org/c/openstack/ironic-lib/+/774502
        part_template = '%s%s'
        if 'nvme' in device:
            part_template = '%sp%s'
        part_path = part_template % (device, part['number'])

        LOG.debug('Inspecting partition %s for path %s', part, path)
        try:
            with ironic_utils.mounted(part_path) as local_path:
                found_path = os.path.join(local_path, lookup_path)
                if not os.path.isdir(found_path):
                    continue

                LOG.info('Path %s has been found on partition %s', path, part)
                yield found_path
                return
        except processutils.ProcessExecutionError as exc:
            LOG.warning('Failure when inspecting partition %s: %s', part, exc)

    raise errors.DeviceNotFound("No partition found with path %s, scanned: %s"
                                % (path, partitions))


def _validate_files(from_properties, from_args):
    """Sanity check for files."""
    if not isinstance(from_properties, list):
        raise errors.InvalidCommandParamsError(
            "The `inject_files` node property must be a list, got %s"
            % type(from_properties).__name__)
    if not isinstance(from_args, list):
        raise errors.InvalidCommandParamsError(
            "The `files` argument must be a list, got %s"
            % type(from_args).__name__)

    files = from_properties + from_args
    failures = []

    for fl in files:
        unknown = set(fl) - {'path', 'partition', 'content', 'deleted', 'mode',
                             'dirmode', 'owner', 'group'}
        if unknown:
            failures.append('unexpected fields in %s: %s'
                            % (fl, ', '.join(unknown)))

        if not fl.get('path'):
            failures.append('expected a path in %s' % fl)
        elif os.path.dirname(fl['path']) == '/' and not fl.get('partition'):
            failures.append('%s in root directory requires "partition"' % fl)
        elif fl['path'].endswith('/'):
            failures.append('directories not supported for %s' % fl)

        if fl.get('content') and fl.get('deleted'):
            failures.append('content cannot be used with deleted in %s' % fl)

        for field in ('owner', 'group', 'mode', 'dirmode'):
            if field in fl and type(fl[field]) is not int:
                failures.append('%s must be a number in %s' % (field, fl))

    if failures:
        raise errors.InvalidCommandParamsError(
            "Validation of files failed: %s" % '; '.join(failures))

    return files
