# Copyright 2021-2022 NetCracker Technology Corporation
#
# 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 json
import pathlib
from collections.abc import Hashable
from textwrap import dedent
from typing import List, Dict, Callable, Union, cast

import jsonschema
from ordered_set import OrderedSet

from kubemarine.core import utils, log, errors
from kubemarine.core.cluster import KubernetesCluster, EnrichmentStage, enrichment


@enrichment(EnrichmentStage.LIGHT)
def verify_connections(cluster: KubernetesCluster) -> None:
    inventory = utils.convert_native_yaml(cluster.inventory)
    # Run restricted validation of sections that participate in the enrichment of connections.
    # This is primarily needed for `do`, and `migrate_kubemarine` procedures.
    _verify_inventory_by_schema(cluster, inventory, 'internal/connections')
    context = cluster.context
    args: dict = context['execution_arguments']
    procedure = context["initial_procedure"]
    if args.get('procedure_config') and procedure in ('add_node', 'remove_node'):
        # Run full validation for add_node/remove_node
        # It is currently not necessary to restrict validation to connections only as for inventory.
        procedure_inventory = utils.convert_native_yaml(cluster.procedure_inventory)
        _verify_inventory_by_schema(cluster, procedure_inventory, context["initial_procedure"])


@enrichment(EnrichmentStage.FULL)
def verify_inventory(cluster: KubernetesCluster) -> None:
    inventory = utils.convert_native_yaml(cluster.inventory)
    _verify_inventory_by_schema(cluster, inventory, 'cluster')
    context = cluster.context
    args: dict = context['execution_arguments']
    # Skip validation if procedure inventory is optional and not provided
    if args.get('procedure_config'):
        procedure_inventory = utils.convert_native_yaml(cluster.procedure_inventory)
        _verify_inventory_by_schema(cluster, procedure_inventory, context["initial_procedure"])


def _verify_inventory_by_schema(cluster: KubernetesCluster, inventory: dict, schema_name: str) -> None:
    for_procedure = ("" if schema_name == 'cluster'
                     else " for connections" if schema_name == 'internal/connections'
                     else f" for procedure '{schema_name}'")

    root_schema_resource = f'resources/schemas/{schema_name}.json'
    root_schema_path = utils.get_internal_resource_path(root_schema_resource)
    root_schema = pathlib.Path(root_schema_path)
    if not root_schema.exists():
        raise Exception(f"Failed to find schema to validate the inventory file{for_procedure}.")

    with utils.open_internal(root_schema_resource) as f:
        schema = json.load(f)

    validator_cls = jsonschema.validators.validator_for(schema)
    validator_cls.check_schema(schema)

    root_schema_uri = root_schema.as_uri()
    resolver = jsonschema.RefResolver(base_uri=root_schema_uri, referrer=schema)
    validator = validator_cls(schema, resolver=resolver)

    errs = list(validator.iter_errors(inventory))
    if not errs:
        return

    context = cluster.context
    logger = cluster.log
    logger.error(f"Inventory file{for_procedure} is failed to be validated against the schema.")

    errs = _resolve_errors(errs)
    for err in errs:
        logger.verbose("------------------------------------------")
        logger.verbose(_verbose_msg(validator, err))

    debug_filepath = log.get_dump_debug_filepath(context)
    if debug_filepath:
        detailed_msg = f"See detailed message in {debug_filepath}."
    else:
        detailed_msg = "Enable verbose logs to see details."

    version = utils.get_version()
    public_schema = f"https://raw.githubusercontent.com/Netcracker/KubeMarine/{version}/kubemarine/{root_schema_resource}"
    hint = f"Inventory file{for_procedure} has incorrect format. {detailed_msg}\n" \
           f"To validate the file manually, you can use JSON schema {root_schema_uri}\n" \
           f"or its public alternative {public_schema}\n"

    msg = _error_msg(validator, errs[0])
    raise errors.FailException(msg, hint=hint)


def _resolve_errors(errs: List[jsonschema.ValidationError]) -> List[jsonschema.ValidationError]:
    key = _extended_relevance()
    for error in errs:
        _unnest_errors(error)

    errs.sort(key=key, reverse=True)
    outer_sorted = []
    for error in errs:
        outer_sorted.extend(_descend_errors(error))

    return outer_sorted


def _unnest_errors(error: jsonschema.ValidationError) -> None:
    if not error.context:
        return

    context: List[jsonschema.ValidationError] = error.context
    errors_by_subschema: Dict[Union[str, int], List[jsonschema.ValidationError]] = {}
    for child in context:
        _unnest_errors(child)
        errors_by_subschema.setdefault(child.schema_path[0], []).append(child)

    # jsonschema might show not very friendly messages for anyOf / oneOf.
    # In case of failed type or enum validation in each subschema, we can unnest the error to the higher level.
    # Note that possible remaining subschema errors will be discarded,
    # but it is acceptable for our current schemas implementation.
    subschemas_errors = list(errors_by_subschema.values())
    _unnest_type_subschema_errors(error, subschemas_errors)
    _unnest_enum_subschema_errors(error, subschemas_errors)
    _unnest_required_subschema_errors(error, subschemas_errors)


def _descend_errors(error: jsonschema.ValidationError) -> List[jsonschema.ValidationError]:
    if not error.context:
        return [error]

    # Here can be anyOf or oneOf with all not valid subschemas.
    # The idea is taken from jsonschema.exceptions.best_match,
    # with improved heuristic algorithm of choosing of the best subschema match.
    # 1. group errors by subschema
    # 2. find error with the max relevance in each group
    # 3. choose group with the min relevance of the error found in the previous step
    # 4. take the group instead of the parent error, sort by relevance reverse
    # 5. recurse if necessary

    context: List[jsonschema.ValidationError] = error.context
    errors_by_subschema: Dict[Union[str, int], List[jsonschema.ValidationError]] = {}
    for child in context:
        errors_by_subschema.setdefault(child.schema_path[0], []).append(child)

    key = _extended_relevance()

    for errors in errors_by_subschema.values():
        errors.sort(key=key, reverse=True)

    smallest_subschema_errors = sorted(errors_by_subschema.values(), key=lambda errors: key(errors[0]))

    # We don't recurse if two the smallest errors have the same relevance (i.e. if min == max == all).
    # This place is a candidate of future developing of better heuristic.
    if len(smallest_subschema_errors) >= 2 and key(smallest_subschema_errors[0][0]) == key(smallest_subschema_errors[1][0]):
        return [error]

    unnested_errors = []
    for err in smallest_subschema_errors[0]:
        unnested_errors.extend(_descend_errors(err))

    return unnested_errors


def _unnest_type_subschema_errors(error: jsonschema.ValidationError,
                                  subschemas_errors: List[List[jsonschema.ValidationError]]) -> None:
    if not error.context:
        return

    expected_types = OrderedSet[str]()
    for errs in subschemas_errors:
        for child in errs:
            if _validated_by(child, "type") and len(child.relative_path) == 0 and list(child.schema_path)[1:] == ["type"]:
                value = child.validator_value
                expected_types.update([value] if isinstance(value, str) else value)
                break
        else:  # not found error with "type" validation failed for root instance.
            break
    else:  # not found subschema not containing the necessary error, i.e. all subschemas have necessary error
        reprs = ", ".join(repr(type) for type in expected_types)
        for child in error.context:
            child.parent = None
        error.context = []
        error.validator = "type"  # type: ignore[assignment]
        error.validator_value = list(expected_types)
        error.schema_path[-1] = "type"
        error.message = f"{error.instance!r} is not of type {reprs}"
        subschemas_errors.clear()


def _unnest_enum_subschema_errors(error: jsonschema.ValidationError,
                                  subschemas_errors: List[List[jsonschema.ValidationError]]) -> None:
    if not error.context:
        return

    expected_elems = OrderedSet[str]()
    for errs in subschemas_errors:
        for child in errs:
            if _validated_by(child, "enum") and len(child.relative_path) == 0 and list(child.schema_path)[1:] == ["enum"]:
                expected_elems.update(child.validator_value)
                break
        else:  # not found error with "enum" validation failed for root instance.
            break
    else:  # not found subschema not containing the necessary error, i.e. all subschemas have necessary error
        for child in error.context:
            child.parent = None
        error.context = []
        error.validator = "enum"  # type: ignore[assignment]
        error.validator_value = list(expected_elems)
        error.schema_path[-1] = "enum"
        error.message = f"{error.instance!r} is not one of {list(expected_elems)!r}"
        subschemas_errors.clear()


def _unnest_required_subschema_errors(error: jsonschema.ValidationError,
                                      subschemas_errors: List[List[jsonschema.ValidationError]]) -> None:
    if not error.context:
        return

    required_sets: List[List[str]] = []
    for errs in subschemas_errors:
        if len(errs) > 1:
            # Support only the case when all subschema failed with the only 'required' validation.
            # This is a common pattern if "at least one property from the set is required" requirement
            # is moved to the separate "allOf (anyOf (required prop1, required prop2)) subschema.
            break
        for child in errs:
            if (_validated_by(child, "required") and len(child.relative_path) == 0
                    and list(child.schema_path)[1:] == ["required"] and len(child.validator_value) > 0
                    and child.validator_value != ['<<']):
                if isinstance(child.validator_value[0], str):
                    required_sets.append(child.validator_value)
                else:
                    required_sets.extend(child.validator_value)
                break
        else:  # not found error with "required" validation failed for root instance.
            break
    # not found subschema not containing the only necessary error,
    # i.e. all subschemas have the only necessary error
    else:
        initial_validator = cast(str, error.validator)

        for child in error.context:
            child.parent = None

        error.context = []
        error.validator = "required"  # type: ignore[assignment]
        # This breaks original typing.
        # Access to the validator_value should always be guarded with extra isinstance() checks.
        error.validator_value = required_sets
        error.schema_path[-1] = "required"
        error.message = (f"{'One' if initial_validator == 'oneOf' else 'At least one'} "
                         f"of the following property sets is required: {', '.join(map(str, required_sets))}")
        subschemas_errors.clear()


def _extended_relevance() -> Callable[[jsonschema.ValidationError], tuple]:
    # The extended relevance function is intended to improve heuristic for oneOf|anyOf,
    # when it is necessary to choose the most suitable branch.

    def relevance(error: jsonschema.ValidationError) -> tuple:
        relevance_value: tuple = jsonschema.exceptions.relevance(error)  # type: ignore[assignment]
        if error.parent is None:
            return relevance_value

        relevance_value = _apply_property_names_heuristic(error, relevance_value)
        relevance_value = _apply_list_merging_strong_heuristic(error, relevance_value)
        relevance_value = _apply_required_and_optional_properties_heuristic(error, relevance_value)

        return relevance_value

    return relevance


def _apply_property_names_heuristic(error: jsonschema.ValidationError, relevance_value: tuple) -> tuple:
    # jsonschema has type matching heuristic but it works bad for "propertyNames".
    # "propertyNames" does not introduce new path element and effectively verifies the object holding the property.
    # But an attempt to match the type happens for the "propertyNames" subschema.
    # The following heuristic resolves the simplest case of oneOf|anyOf(something, schema with "propertyNames" section)

    if len(error.relative_path) == 0 and "propertyNames" in error.schema_path:
        # "propertyNames" is validated only if the instance is "object".
        # See jsonschema._validators.propertyNames. So type is always matched.
        type_matched = True
        mutate_relevance = list(relevance_value)
        mutate_relevance[3] = not type_matched
        return tuple(mutate_relevance)

    return relevance_value


def _apply_list_merging_strong_heuristic(error: jsonschema.ValidationError, relevance_value: tuple) -> tuple:
    # Other conditions being equal, the error for list merging strategy has greater relevance,
    # because if the user specified '<<', we consider that he/she intends to use this advanced feature.

    is_list_merging = len(error.relative_path) == 0 and isinstance(error.schema, dict) \
                      and "properties" in error.schema and error.schema["properties"].keys() == {"<<"}

    return _append_relevance_element(relevance_value, is_list_merging)


def _apply_required_and_optional_properties_heuristic(error: jsonschema.ValidationError,
                                                      relevance_value: tuple) -> tuple:
    # Resolve oneOf|anyOf(object, object) ambiguity.
    # Currently only 'registry' section has such schema.
    # The chosen priority is:
    # 1. If all required properties are present, then the error has lower relevance.
    # 2. Otherwise, calculate proportion of matched properties.

    # By default, consider all required properties are present, and no properties are matched
    coeff: list = [False, 0]
    if len(error.relative_path) != 0 or not isinstance(error.schema, dict) or not isinstance(error.instance, dict):
        return _append_relevance_element(relevance_value, tuple(coeff))

    coeff[0] = not all(p in error.instance for p in error.schema.get("required", []))

    if len(error.schema.get("properties", {})) > 0:
        inverse_weight = len(error.schema["properties"])
        matched = set(error.instance.keys()).intersection(set(error.schema["properties"].keys()))
        # negative coefficient because more matched properties - more suitable subschema - less relevant error
        coeff[1] = - len(matched) / inverse_weight

    return _append_relevance_element(relevance_value, tuple(coeff))


def _append_relevance_element(relevance_value: tuple, elem: Hashable) -> tuple:
    mutate_relevance = list(relevance_value)
    mutate_relevance.append(elem)
    return tuple(mutate_relevance)


def _error_msg(validator: jsonschema.Draft7Validator, error: jsonschema.ValidationError) -> str:
    return dedent(
        f"""\
        {_friendly_msg(validator, error)}
        On inventory{utils.pretty_path(error.absolute_path)}
        """.rstrip()
    )


def _verbose_msg(validator: jsonschema.Draft7Validator, error: jsonschema.ValidationError) -> str:
    schema_path = utils.pretty_path(list(error.absolute_schema_path)[:-1])
    return dedent(
        f"""\
        {_friendly_msg(validator, error)}
        
        Failed validating {error.validator!r} in {schema_path}
        On inventory{utils.pretty_path(error.absolute_path)}
        """.rstrip()
    )


def _friendly_msg(validator: jsonschema.Draft7Validator, error: jsonschema.ValidationError) -> str:
    # pylint: disable=too-many-return-statements

    if _validated_by(error, 'minProperties'):
        return f"Number of properties is less than the minimum of {error.validator_value!r}. " \
               f"Property names: {list(error.instance)!r}"

    if _validated_by(error, 'maxProperties'):
        return f"Number of properties is greater than the maximum of {error.validator_value!r}. " \
               f"Property names: {list(error.instance)!r}"

    if _validated_by(error, 'minItems'):
        return f"Number of items equal to {len(error.instance)} is less than the minimum of {error.validator_value!r}"

    if _validated_by(error, 'maxItems'):
        return f"Number of items equal to {len(error.instance)} is greater than the maximum of {error.validator_value!r}"

    if _validated_by(error, 'type'):
        value = error.validator_value
        expected_types = [value] if isinstance(value, str) else value
        reprs = ", ".join(repr(type) for type in expected_types)
        try:
            for type_ in validator.TYPE_CHECKER._type_checkers:  # pylint: disable=protected-access
                if validator.is_type(error.instance, type_):  # type: ignore[no-untyped-call]
                    return f"Actual instance type is {type_!r}. Expected: {reprs}."
        except (AttributeError, TypeError, jsonschema.exceptions.UnknownType):
            # In current 3rd-party version the error should not appear, but let's still fall back to default behaviour
            pass

    if (_validated_by(error, 'not') and isinstance(error.validator_value, dict)
            and set(error.validator_value) == {'enum'} and "propertyNames" in error.schema_path):
        return f"Property name {error.instance!r} is unexpected"

    if _validated_by(error, 'enum'):
        if "propertyNames" not in error.schema_path:
            return f"Value should be one of {error.validator_value!r}"
        else:
            return f"Property name {error.instance!r} is not one of {error.validator_value!r}"

    return error.message


def _validated_by(error: jsonschema.ValidationError, expected: str) -> bool:
    return error.validator == expected  # type: ignore[comparison-overlap]
