#    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.

import glob
import jsonschema
import logging
import microversion_parse
import os
import yaml

import os_resource_classes
import os_traits

from nova import exception as nova_exc
from nova.i18n import _

LOG = logging.getLogger(__name__)

# A dictionary with keys for all supported major versions with lists of
# corresponding minor versions as values.
SUPPORTED_SCHEMA_VERSIONS = {
    1: {0}
}

# Supported provider config file schema
SCHEMA_V1 = {
    # This definition uses JSON Schema Draft 7.
    # https://json-schema.org/draft-07/json-schema-release-notes.html
    'type': 'object',
    'properties': {
        # This property is used to track where the provider.yaml file
        # originated. It is reserved for internal use and should never be
        # set in a provider.yaml file supplied by an end user.
        '__source_file': {'not': {}},
        'meta': {
            'type': 'object',
            'properties': {
                # Version ($Major, $minor) of the schema must successfully
                # parse documents conforming to ($Major, 0..N).
                # Any breaking schema change (e.g. removing fields, adding
                # new required fields, imposing a stricter pattern on a value,
                # etc.) must bump $Major.
                'schema_version': {
                    'type': 'string',
                    'pattern': '^1.([0-9]|[1-9][0-9]+)$'
                }
            },
            'required': ['schema_version'],
            'additionalProperties': True
        },
        'providers': {
            'type': 'array',
            'items': {
                'type': 'object',
                'properties': {
                    'identification': {
                        '$ref': '#/$defs/providerIdentification'
                    },
                    'inventories': {
                        '$ref': '#/$defs/providerInventories'
                    },
                    'traits': {
                        '$ref': '#/$defs/providerTraits'
                    }
                },
                'required': ['identification'],
                'additionalProperties': True
            }
        }
    },
    'required': ['meta'],
    'additionalProperties': True,
    '$defs': {
        'providerIdentification': {
            # Identify a single provider to configure.
            # Exactly one identification method should be used. Currently
            # `uuid` or `name` are supported, but future versions may
            # support others. The uuid can be set to the sentinel value
            # `$COMPUTE_NODE` which will cause the consuming compute service to
            # apply the configuration to all compute node root providers
            # it manages that are not otherwise specified using a uuid or name.
            'type': 'object',
            'properties': {
                'uuid': {
                    'oneOf': [
                        {
                            # TODO(sean-k-mooney): replace this with type uuid
                            # when we can depend on a version of the jsonschema
                            # lib that implements draft 8 or later of the
                            # jsonschema spec.
                            'type': 'string',
                            'pattern':
                                '^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-'
                                '[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-'
                                '[0-9A-Fa-f]{12}$'
                        },
                        {
                            'type': 'string',
                            'const': '$COMPUTE_NODE'
                        }
                    ]
                },
                'name': {
                    'type': 'string',
                    'minLength': 1,
                    'maxLength': 200
                }
            },
            # This introduces the possibility of an unsupported key name being
            # used to get by schema validation, but is necessary to support
            # forward compatibility with new identification methods.
            # This should be checked after schema validation.
            'minProperties': 1,
            'maxProperties': 1,
            'additionalProperties': False
        },
        'providerInventories': {
            # Allows the admin to specify various adjectives to create and
            # manage providers' inventories. This list of adjectives can be
            # extended in the future as the schema evolves to meet new use
            # cases. As of v1.0, only one adjective, `additional`, is
            # supported.
            'type': 'object',
            'properties': {
                'additional': {
                    'type': 'array',
                    'items': {
                        'patternProperties': {
                            # Allows any key name matching the resource class
                            # pattern, check to prevent conflicts with virt
                            # driver owned resources classes will be done after
                            # schema validation.
                            '^[A-Z0-9_]{1,255}$': {
                                'type': 'object',
                                'properties': {
                                    # Any optional properties not populated
                                    # will be given a default value by
                                    # placement. If overriding a pre-existing
                                    # provider values will not be preserved
                                    # from the existing inventory.
                                    'total': {
                                        'type': 'integer'
                                    },
                                    'reserved': {
                                        'type': 'integer'
                                    },
                                    'min_unit': {
                                        'type': 'integer'
                                    },
                                    'max_unit': {
                                        'type': 'integer'
                                    },
                                    'step_size': {
                                        'type': 'integer'
                                    },
                                    'allocation_ratio': {
                                        'type': 'number'
                                    }
                                },
                                'required': ['total'],
                                # The defined properties reflect the current
                                # placement data model. While defining those
                                # in the schema and not allowing additional
                                # properties means we will need to bump the
                                # schema version if they change, that is likely
                                # to be part of a large change that may have
                                # other impacts anyway. The benefit of stricter
                                # validation of property names outweighs the
                                # (small) chance of having to bump the schema
                                # version as described above.
                                'additionalProperties': False
                            }
                        },
                        # This ensures only keys matching the pattern
                        # above are allowed.
                        'additionalProperties': False
                    }
                }
            },
            'additionalProperties': True
        },
        'providerTraits': {
            # Allows the admin to specify various adjectives to create and
            # manage providers' traits. This list of adjectives can be extended
            # in the future as the schema evolves to meet new use cases.
            # As of v1.0, only one adjective, `additional`, is supported.
            'type': 'object',
            'properties': {
                'additional': {
                    'type': 'array',
                    'items': {
                        # Allows any value matching the trait pattern here,
                        # additional validation will be done after schema
                        # validation.
                        'type': 'string',
                        'pattern': '^[A-Z0-9_]{1,255}$'
                    }
                }
            },
            'additionalProperties': True
        }
    }
}


def _load_yaml_file(path):
    """Loads and parses a provider.yaml config file into a dict.

    :param path: Path to the yaml file to load.
    :return: Dict representing the yaml file requested.
    :raise: ProviderConfigException if the path provided cannot be read
            or the file is not valid yaml.
    """
    try:
        with open(path) as open_file:
            try:
                return yaml.safe_load(open_file)
            except yaml.YAMLError as ex:
                message = _("Unable to load yaml file: %s ") % ex
                if hasattr(ex, 'problem_mark'):
                    pos = ex.problem_mark
                    message += _("File: %s ") % open_file.name
                    message += _("Error position: "
                                 "({line}:{column})").format(
                                     line=pos.line + 1, column=pos.column + 1)
                raise nova_exc.ProviderConfigException(error=message)
    except OSError:
        message = _("Unable to read yaml config file: %s") % path
        raise nova_exc.ProviderConfigException(error=message)


def _validate_provider_config(config, provider_config_path):
    """Accepts a schema-verified provider config in the form of a dict and
     performs additional checks for format and required keys.

    :param config: Dict containing a provider config file
    :param provider_config_path: Path to the provider config, used for logging
    :return: List of valid providers
    :raise nova.exception.ProviderConfigException: If provider id is missing,
        or a resource class or trait name is invalid.
    """
    def _validate_traits(provider):
        # Check that traits are custom
        additional_traits = set(provider.get("traits", {}).get(
            "additional", []))
        trait_conflicts = [trait for trait in additional_traits
                           if not os_traits.is_custom(trait)]
        if trait_conflicts:
            # sort for more predictable message for testing
            message = _(
                "Invalid traits, only custom traits are allowed: %s"
            ) % sorted(trait_conflicts)
            raise nova_exc.ProviderConfigException(error=message)
        return additional_traits

    def _validate_rc(provider):
        # Check that resource classes are custom
        additional_inventories = provider.get("inventories", {}).get(
            "additional", [])
        all_inventory_conflicts = []
        for inventory in additional_inventories:
            inventory_conflicts = [rc for rc in inventory
                                   if not os_resource_classes.is_custom(rc)]
            all_inventory_conflicts += inventory_conflicts

        if all_inventory_conflicts:
            # sort for more predictable message for testing
            message = _(
                "Invalid resource class, only custom resource classes "
                "are allowed: %s") % ', '.join(sorted(all_inventory_conflicts))
            raise nova_exc.ProviderConfigException(error=message)
        return additional_inventories

    # store valid providers
    valid_providers = []

    for provider in config.get("providers", []):
        # Check that the identification method is known since
        # the schema only requires that some property be present
        pid = provider["identification"]
        provider_id = pid.get("name") or pid.get("uuid")
        # Not checking the validity of provider_id since
        # the schema has already ensured that.

        additional_traits = _validate_traits(provider)
        additional_inventories = _validate_rc(provider)

        # filter out no-op providers so they will not be returned
        if not additional_traits and not additional_inventories:
            message = (
                "Provider %(provider_id)s defined in %(provider_config_path)s "
                "has no additional inventories or traits and will be ignored."
            ) % {
                "provider_id": provider_id,
                "provider_config_path": provider_config_path
            }
            LOG.warning(message)
        else:
            valid_providers.append(provider)

    return valid_providers


def _parse_provider_yaml(path):
    """Loads schema, parses a provider.yaml file and validates the content.

    :param path: File system path to the file to parse.
    :return: dict representing the contents of the file.
    :raise ProviderConfigException: If the specified file does
        not validate against the schema, the schema version is not supported,
        or if unable to read configuration or schema files.
    """
    yaml_file = _load_yaml_file(path)

    try:
        schema_version = microversion_parse.parse_version_string(
            yaml_file['meta']['schema_version'])
    except (KeyError, TypeError):
        message = _("Unable to detect schema version: %s") % yaml_file
        raise nova_exc.ProviderConfigException(error=message)

    if schema_version.major not in SUPPORTED_SCHEMA_VERSIONS:
        message = _(
            "Unsupported schema major version: %d") % schema_version.major
        raise nova_exc.ProviderConfigException(error=message)

    if schema_version.minor not in \
            SUPPORTED_SCHEMA_VERSIONS[schema_version.major]:
        # TODO(sean-k-mooney): We should try to provide a better
        # message that identifies which fields may be ignored
        # and the max minor version supported by this version of nova.
        message = (
            "Provider config file [%(path)s] is at schema version "
            "%(schema_version)s. Nova supports the major version, "
            "but not the minor. Some fields may be ignored."
            % {"path": path, "schema_version": schema_version})
        LOG.warning(message)

    try:
        jsonschema.validate(yaml_file, SCHEMA_V1)
    except jsonschema.exceptions.ValidationError as e:
        message = _(
            "The provider config file %(path)s did not pass validation "
            "for schema version %(schema_version)s: %(reason)s") % {
                "path": path, "schema_version": schema_version, "reason": e}
        raise nova_exc.ProviderConfigException(error=message)
    return yaml_file


def get_provider_configs(provider_config_dir):
    """Gathers files in the provided path and calls the parser for each file
    and merges them into a list while checking for a number of possible
    conflicts.

    :param provider_config_dir: Path to a directory containing provider config
        files to be loaded.
    :raise nova.exception.ProviderConfigException: If unable to read provider
        config directory or if one of a number of validation checks fail:
        - Unknown, unsupported, or missing schema major version.
        - Unknown, unsupported, or missing resource provider identification.
        - A specific resource provider is identified twice with the same
          method. If the same provider identified by *different* methods,
          such conflict will be detected in a later stage.
        - A resource class or trait name is invalid or not custom.
        - A general schema validation error occurs (required fields,
          types, etc).
    :return: A dict of dicts keyed by uuid_or_name with the parsed and
    validated contents of all files in the provided dir. Each value in the dict
    will include the source file name the value of the __source_file key.
    """
    provider_configs = {}

    provider_config_paths = glob.glob(
        os.path.join(provider_config_dir, "*.yaml"))
    provider_config_paths.sort()

    if not provider_config_paths:
        message = (
            "No provider configs found in %s. If files are present, "
            "ensure the Nova process has access."
        )
        LOG.info(message, provider_config_dir)

        # return an empty dict as no provider configs found
        return provider_configs

    for provider_config_path in provider_config_paths:
        provider_config = _parse_provider_yaml(provider_config_path)

        for provider in _validate_provider_config(
            provider_config, provider_config_path,
        ):
            provider['__source_file'] = os.path.basename(provider_config_path)
            pid = provider["identification"]
            uuid_or_name = pid.get("uuid") or pid.get("name")
            # raise exception if this provider was already processed
            if uuid_or_name in provider_configs:
                raise nova_exc.ProviderConfigException(
                    error=_(
                        "Provider %(provider_id)s has multiple definitions "
                        "in source file(s): %(source_files)s."
                    ) % {
                        "provider_id": uuid_or_name,
                        # sorted set for deduplication and consistent order
                        "source_files": sorted({
                            provider_configs[uuid_or_name]["__source_file"],
                            provider_config_path
                        })
                    }
                )
            provider_configs[uuid_or_name] = provider
    return provider_configs
