import logging
from typing import Text, Dict, List, Optional, Any

from packaging import version
from packaging.version import LegacyVersion
from pykwalify.errors import SchemaError

from ruamel.yaml.constructor import DuplicateKeyError

import convo.shared
from convo.shared.exceptions import ConvoException
import convo.shared.utils.io
from convo.shared.constants import (
    DOCS_URL_TRAINING_DATA_NLU,
    PACKAGE_NAME,
    LATEST_TRAINING_DATA_FORMAT_VERSION,
    DOCS_BASE_URL,
    SCHEMA_EXTENSIONS_FILE,
    RESPONSES_SCHEMA_FILE,
)

logger = logging.getLogger(__name__)

KEY_TRAINING_DATA_FORMAT_VERSION = "version"


class YamlValidationException(ValueError, ConvoException):
    """Raised if a yaml file does not correspond to the expected schema."""

    def __init__(
        self,
        message: Text,
        validation_errors: Optional[List[SchemaError.SchemaErrorEntry]] = None,
        filename: Optional[Text] = None,
        content: Any = None,
    ) -> None:
        """Create The Error.

        Args:
            message: error message
            validation_errors: validation errors
            filename: name of the file which was validated
            content: yaml content loaded from the file (used for line information)
        """
        self.message = message
        self.filename = filename
        self.validation_errors = validation_errors
        self.content = content
        super(YamlValidationException, self).__init__()

    def __str__(self) -> Text:
        msg = ""
        if self.filename:
            msg += f"Failed to validate '{self.filename}'. "
        else:
            msg += "Failed to validate yaml. "
        msg += self.message
        if self.validation_errors:
            unique_errors = {}
            for error in self.validation_errors:
                line_number = self._line_number_for_path(self.content, error.path)

                if line_number and self.filename:
                    error_representation = f"  in {self.filename}:{line_number}:\n"
                elif line_number:
                    error_representation = f"  in Line {line_number}:\n"
                else:
                    error_representation = ""

                error_representation += f"      {error}"
                unique_errors[str(error)] = error_representation
            error_msg = "\n".join(unique_errors.values())
            msg += f":\n{error_msg}"
        return msg

    def _line_number_for_path(self, current: Any, path: Text) -> Optional[int]:
        """Get line number for a yaml path in the current content.

        Implemented using recursion: algorithm goes down the path navigating to the
        leaf in the YAML tree. Unfortunately, not all nodes returned from the
        ruamel yaml parser have line numbers attached (arrays have them, dicts have
        them), e.g. strings don't have attached line numbers.
        If we arrive at a node that has no line number attached, we'll return the
        line number of the parent - that is as close as it gets.

        Args:
            current: current content
            path: path to traverse within the content

        Returns:
            the line number of the path in the content.
        """
        if not current:
            return None

        this_line = current.lc.line + 1 if hasattr(current, "lc") else None

        if not path:
            return this_line

        if "/" in path:
            head, tail = path.split("/", 1)
        else:
            head, tail = path, ""

        if head:
            if isinstance(current, dict) and head in current:
                return self._line_number_for_path(current[head], tail) or this_line
            elif isinstance(current, list) and head.isdigit():
                return self._line_number_for_path(current[int(head)], tail) or this_line
            else:
                return this_line
        return self._line_number_for_path(current, tail) or this_line


def validate_yaml_schema(yaml_file_content: Text, schema_path: Text) -> None:
    """
    Validate yaml content.

    Args:
        yaml_file_content: the content of the yaml file to be validated
        schema_path: the schema of the yaml file
    """
    from pykwalify.core import Core
    from pykwalify.errors import SchemaError
    from ruamel.yaml import YAMLError
    import pkg_resources
    import logging

    log = logging.getLogger("pykwalify")
    log.setLevel(logging.CRITICAL)

    try:
        # we need "rt" since
        # it will add meta information to the parsed output. this meta information
        # will include e.g. at which line an object was parsed. this is very
        # helpful when we validate files later on and want to point the user to the
        # right line
        source_data = convo.shared.utils.io.read_yaml(
            yaml_file_content, reader_type=["safe", "rt"]
        )
    except YAMLError:
        raise YamlValidationException(
            "The provided yaml file is invalid. You can use "
            "http://www.yamllint.com/ to validate the yaml syntax "
            "of your file."
        )
    except DuplicateKeyError as e:
        raise YamlValidationException(
            "The provided yaml file contains a duplicated key: '{}'. You can use "
            "http://www.yamllint.com/ to validate the yaml syntax "
            "of your file.".format(str(e))
        )

    schema_file = pkg_resources.resource_filename(PACKAGE_NAME, schema_path)
    schema_utils_file = pkg_resources.resource_filename(
        PACKAGE_NAME, RESPONSES_SCHEMA_FILE
    )
    schema_extensions = pkg_resources.resource_filename(
        PACKAGE_NAME, SCHEMA_EXTENSIONS_FILE
    )

    c = Core(
        source_data=source_data,
        schema_files=[schema_file, schema_utils_file],
        extensions=[schema_extensions],
    )

    try:
        c.validate(raise_exception=True)
    except SchemaError:
        raise YamlValidationException(
            "Please make sure the file is correct and all "
            "mandatory parameters are specified. Here are the errors "
            "found during validation",
            c.errors,
            content=source_data,
        )


def validate_training_data(json_data: Dict[Text, Any], schema: Dict[Text, Any]) -> None:
    """Validate convo training data format to ensure proper training.

    Args:
        json_data: the data to validate
        schema: the schema

    Raises:
        ValidationError if validation fails.
    """
    from jsonschema import validate
    from jsonschema import ValidationError

    try:
        validate(json_data, schema)
    except ValidationError as e:
        e.message += (
            f". Failed to validate data, make sure your data "
            f"is valid. For more information about the format visit "
            f"{DOCS_URL_TRAINING_DATA_NLU}."
        )
        raise e


def validate_training_data_format_version(
    yaml_file_content: Dict[Text, Any], filename: Text
) -> bool:
    """Validates version on the training data content using `version` field
       and warns users if the file is not compatible with the current version of
       Convo Open Source.

    Args:
        yaml_file_content: Raw content of training data file as a dictionary.
        filename: Name of the validated file.

    Returns:
        `True` if the file can be processed by current version of Convo Open Source,
        `False` otherwise.
    """
    if not isinstance(yaml_file_content, dict):
        raise ValueError(f"Failed to validate {filename}.")

    version_value = yaml_file_content.get(KEY_TRAINING_DATA_FORMAT_VERSION)

    if not version_value:
        # not raising here since it's not critical
        logger.warning(
            f"Training data file {filename} doesn't have a "
            f"'{KEY_TRAINING_DATA_FORMAT_VERSION}' key. "
            f"Convo Open Source will read the file as a "
            f"version '{LATEST_TRAINING_DATA_FORMAT_VERSION}' file. "
            f"See {DOCS_BASE_URL}."
        )
        return True

    try:
        parsed_version = version.parse(version_value)
        if isinstance(parsed_version, LegacyVersion):
            raise TypeError

        if version.parse(LATEST_TRAINING_DATA_FORMAT_VERSION) >= parsed_version:
            return True

    except TypeError:
        convo.shared.utils.io.raise_warning(
            f"Training data file {filename} must specify "
            f"'{KEY_TRAINING_DATA_FORMAT_VERSION}' as string, for example:\n"
            f"{KEY_TRAINING_DATA_FORMAT_VERSION}: '{LATEST_TRAINING_DATA_FORMAT_VERSION}'\n"
            f"Convo Open Source will read the file as a "
            f"version '{LATEST_TRAINING_DATA_FORMAT_VERSION}' file.",
            docs=DOCS_BASE_URL,
        )
        return True

    convo.shared.utils.io.raise_warning(
        f"Training data file {filename} has a greater format version than "
        f"your Convo Open Source installation: "
        f"{version_value} > {LATEST_TRAINING_DATA_FORMAT_VERSION}. "
        f"Please consider updating to the latest version of Convo Open Source."
        f"This file will be skipped.",
        docs=DOCS_BASE_URL,
    )
    return False
