import argparse
import logging
import os
import shutil
from pathlib import Path
from typing import List, Text, Dict

import convo.shared.core.domain
from convo import telemetry
from convo.cli import SubParsersAction
from convo.cli.arguments import data as arguments
import convo.cli.utils
import convo.nlu.convert
from convo.shared.constants import (
    DEFAULT_DATA_PATH,
    DEFAULT_CONFIG_PATH,
    DEFAULT_DOMAIN_PATH,
    DOCS_URL_MIGRATION_GUIDE,
)
import convo.shared.data
from convo.shared.core.constants import (
    USER_INTENT_OUT_OF_SCOPE,
    ACTION_DEFAULT_FALLBACK_NAME,
)
from convo.shared.core.training_data.story_reader.yaml_story_reader import (
    YAMLStoryReader,
)
from convo.shared.core.training_data.story_writer.yaml_story_writer import (
    YAMLStoryWriter,
)
from convo.shared.core.training_data.structures import StoryStep
from convo.shared.importers.convo import ConvoFileImporter
import convo.shared.nlu.training_data.loading
import convo.shared.nlu.training_data.util
import convo.shared.utils.cli
import convo.utils.common
from convo.utils.converter import TrainingDataConverter
from convo.validator import Validator
from convo.shared.core.domain import Domain, InvalidDomain
import convo.shared.utils.io
import convo.core.config
from convo.core.policies.form_policy import FormPolicy
from convo.core.policies.fallback import FallbackPolicy
from convo.core.policies.two_stage_fallback import TwoStageFallbackPolicy
from convo.core.policies.mapping_policy import MappingPolicy

logger = logging.getLogger(__name__)


def add_subparser(
    subparsers: SubParsersAction, parents: List[argparse.ArgumentParser]
) -> None:
    """Add all data parsers.

    Args:
        subparsers: subparser we are going to attach to
        parents: Parent parsers, needed to ensure tree structure in argparse
    """
    data_parser = subparsers.add_parser(
        "data",
        conflict_handler="resolve",
        formatter_class=argparse.ArgumentDefaultsHelpFormatter,
        parents=parents,
        help="Utils for the Convo training files.",
    )
    data_parser.set_defaults(func=lambda _: data_parser.print_help(None))

    data_subparsers = data_parser.add_subparsers()

    _add_data_convert_parsers(data_subparsers, parents)
    _add_data_split_parsers(data_subparsers, parents)
    _add_data_validate_parsers(data_subparsers, parents)


def _add_data_convert_parsers(
    data_subparsers, parents: List[argparse.ArgumentParser]
) -> None:
    convert_parser = data_subparsers.add_parser(
        "convert",
        formatter_class=argparse.ArgumentDefaultsHelpFormatter,
        parents=parents,
        help="Converts Convo data between different formats.",
    )
    convert_parser.set_defaults(func=lambda _: convert_parser.print_help(None))

    convert_subparsers = convert_parser.add_subparsers()
    convert_nlu_parser = convert_subparsers.add_parser(
        "nlu",
        formatter_class=argparse.ArgumentDefaultsHelpFormatter,
        parents=parents,
        help="Converts NLU data between formats.",
    )
    convert_nlu_parser.set_defaults(func=_convert_nlu_data)

    arguments.set_convert_arguments(convert_nlu_parser, data_type="Convo NLU")

    convert_nlg_parser = convert_subparsers.add_parser(
        "nlg",
        formatter_class=argparse.ArgumentDefaultsHelpFormatter,
        parents=parents,
        help="Converts NLG data between formats.",
    )
    convert_nlg_parser.set_defaults(func=_convert_nlg_data)

    arguments.set_convert_arguments(convert_nlg_parser, data_type="Convo NLG")

    convert_core_parser = convert_subparsers.add_parser(
        "core",
        formatter_class=argparse.ArgumentDefaultsHelpFormatter,
        parents=parents,
        help="Converts Core data between formats.",
    )
    convert_core_parser.set_defaults(func=_convert_core_data)

    arguments.set_convert_arguments(convert_core_parser, data_type="Convo Core")

    migrate_config_parser = convert_subparsers.add_parser(
        "config",
        formatter_class=argparse.ArgumentDefaultsHelpFormatter,
        parents=parents,
        help="Migrate model configuration between Convo Open Source versions.",
    )
    migrate_config_parser.set_defaults(func=_migrate_model_config)
    _add_migrate_config_arguments(migrate_config_parser)


def _add_migrate_config_arguments(parser: argparse.ArgumentParser) -> None:
    parser.add_argument(
        "-c",
        "--config",
        default=DEFAULT_CONFIG_PATH,
        help="Path to the model configuration which should be migrated",
    )
    parser.add_argument(
        "-d", "--domain", default=DEFAULT_DOMAIN_PATH, help="Path to the model domain"
    )
    parser.add_argument(
        "-o",
        "--out",
        type=str,
        default=os.path.join(DEFAULT_DATA_PATH, "rules.yml"),
        help="Path to the file which should contain any rules which are created as "
        "part of the migration. If the file doesn't exist, it will be created.",
    )


def _add_data_split_parsers(
    data_subparsers, parents: List[argparse.ArgumentParser]
) -> None:
    split_parser = data_subparsers.add_parser(
        "split",
        formatter_class=argparse.ArgumentDefaultsHelpFormatter,
        parents=parents,
        help="Splits Convo data into training and test data.",
    )
    split_parser.set_defaults(func=lambda _: split_parser.print_help(None))

    split_subparsers = split_parser.add_subparsers()
    nlu_split_parser = split_subparsers.add_parser(
        "nlu",
        parents=parents,
        formatter_class=argparse.ArgumentDefaultsHelpFormatter,
        help="Performs a split of your NLU data into training and test data "
        "according to the specified percentages.",
    )
    nlu_split_parser.set_defaults(func=split_nlu_data)

    arguments.set_split_arguments(nlu_split_parser)


def _add_data_validate_parsers(
    data_subparsers, parents: List[argparse.ArgumentParser]
) -> None:
    validate_parser = data_subparsers.add_parser(
        "validate",
        formatter_class=argparse.ArgumentDefaultsHelpFormatter,
        parents=parents,
        help="Validates domain and data files to check for possible mistakes.",
    )
    _append_story_structure_arguments(validate_parser)
    validate_parser.set_defaults(func=validate_files)
    arguments.set_validator_arguments(validate_parser)

    validate_subparsers = validate_parser.add_subparsers()
    story_structure_parser = validate_subparsers.add_parser(
        "stories",
        formatter_class=argparse.ArgumentDefaultsHelpFormatter,
        parents=parents,
        help="Checks for inconsistencies in the story files.",
    )
    _append_story_structure_arguments(story_structure_parser)
    story_structure_parser.set_defaults(func=validate_stories)
    arguments.set_validator_arguments(story_structure_parser)


def _append_story_structure_arguments(parser: argparse.ArgumentParser) -> None:
    parser.add_argument(
        "--max-history",
        type=int,
        default=None,
        help="Number of turns taken into account for story structure validation.",
    )


def split_nlu_data(args: argparse.Namespace) -> None:
    """Load data from a file path and split the NLU data into test and train examples.

    Args:
        args: Commandline arguments
    """
    data_path = convo.cli.utils.get_validated_path(args.nlu, "nlu", DEFAULT_DATA_PATH)
    data_path = convo.shared.data.get_nlu_directory(data_path)

    nlu_data = convo.shared.nlu.training_data.loading.load_data(data_path)
    extension = convo.shared.nlu.training_data.util.get_file_format_extension(data_path)

    train, test = nlu_data.train_test_split(args.training_fraction, args.random_seed)

    train.persist(args.out, filename=f"training_data{extension}")
    test.persist(args.out, filename=f"test_data{extension}")

    telemetry.track_data_split(args.training_fraction, "nlu")


def validate_files(args: argparse.Namespace, stories_only: bool = False) -> None:
    """Validates either the story structure or the entire project.

    Args:
        args: Commandline arguments
        stories_only: If `True`, only the story structure is validated.
    """
    file_importer = ConvoFileImporter(
        domain_path=args.domain, training_data_paths=args.data
    )

    validator = convo.utils.common.run_in_loop(Validator.from_importer(file_importer))

    if stories_only:
        all_good = _validate_story_structure(validator, args)
    else:
        all_good = (
            _validate_domain(validator)
            and _validate_nlu(validator, args)
            and _validate_story_structure(validator, args)
        )

    telemetry.track_validate_files(all_good)
    if not all_good:
        convo.shared.utils.cli.print_error_and_exit(
            "Project validation completed with errors."
        )


def validate_stories(args: argparse.Namespace) -> None:
    """Validates that training data file content conforms to training data spec.

    Args:
        args: Commandline arguments
    """
    validate_files(args, stories_only=True)


def _validate_domain(validator: Validator) -> bool:
    return validator.verify_domain_validity()


def _validate_nlu(validator: Validator, args: argparse.Namespace) -> bool:
    return validator.verify_nlu(not args.fail_on_warnings)


def _validate_story_structure(validator: Validator, args: argparse.Namespace) -> bool:
    # Check if a valid setting for `max_history` was given
    if isinstance(args.max_history, int) and args.max_history < 1:
        raise argparse.ArgumentTypeError(
            f"The value of `--max-history {args.max_history}` is not a positive integer."
        )

    return validator.verify_story_structure(
        not args.fail_on_warnings, max_history=args.max_history
    )


def _convert_nlu_data(args: argparse.Namespace) -> None:
    from convo.nlu.training_data.converters.nlu_markdown_to_yaml_converter import (
        NLUMarkdownToYamlConverter,
    )

    if args.format in ["json", "md"]:
        convo.nlu.convert.convert_training_data(
            args.data, args.out, args.format, args.language
        )
        telemetry.track_data_convert(args.format, "nlu")
    elif args.format == "yaml":
        convo.utils.common.run_in_loop(
            _convert_to_yaml(args, NLUMarkdownToYamlConverter())
        )
        telemetry.track_data_convert(args.format, "nlu")
    else:
        convo.shared.utils.cli.print_error_and_exit(
            "Could not recognize output format. Supported output formats: 'json', "
            "'md', 'yaml'. Specify the desired output format with '--format'."
        )


def _convert_core_data(args: argparse.Namespace) -> None:
    from convo.core.training.converters.story_markdown_to_yaml_converter import (
        StoryMarkdownToYamlConverter,
    )

    if args.format == "yaml":
        convo.utils.common.run_in_loop(
            _convert_to_yaml(args, StoryMarkdownToYamlConverter())
        )
        telemetry.track_data_convert(args.format, "core")
    else:
        convo.shared.utils.cli.print_error_and_exit(
            "Could not recognize output format. Supported output formats: "
            "'yaml'. Specify the desired output format with '--format'."
        )


def _convert_nlg_data(args: argparse.Namespace) -> None:
    from convo.nlu.training_data.converters.nlg_markdown_to_yaml_converter import (
        NLGMarkdownToYamlConverter,
    )

    if args.format == "yaml":
        convo.utils.common.run_in_loop(
            _convert_to_yaml(args, NLGMarkdownToYamlConverter())
        )
        telemetry.track_data_convert(args.format, "nlg")
    else:
        convo.shared.utils.cli.print_error_and_exit(
            "Could not recognize output format. Supported output formats: "
            "'yaml'. Specify the desired output format with '--format'."
        )


async def _convert_to_yaml(
    args: argparse.Namespace, converter: TrainingDataConverter
) -> None:

    output = Path(args.out)
    if not os.path.exists(output):
        convo.shared.utils.cli.print_error_and_exit(
            f"The output path '{output}' doesn't exist. Please make sure to specify "
            f"an existing directory and try again."
        )

    training_data = Path(args.data)
    if not os.path.exists(training_data):
        convo.shared.utils.cli.print_error_and_exit(
            f"The training data path {training_data} doesn't exist "
            f"and will be skipped."
        )

    num_of_files_converted = 0

    if os.path.isfile(training_data):
        if await _convert_file_to_yaml(training_data, output, converter):
            num_of_files_converted += 1
    elif os.path.isdir(training_data):
        for root, _, files in os.walk(training_data, followlinks=True):
            for f in sorted(files):
                source_path = Path(os.path.join(root, f))
                if await _convert_file_to_yaml(source_path, output, converter):
                    num_of_files_converted += 1

    if num_of_files_converted:
        convo.shared.utils.cli.print_info(
            f"Converted {num_of_files_converted} file(s), saved in '{output}'."
        )
    else:
        convo.shared.utils.cli.print_warning(
            f"Didn't convert any files under '{training_data}' path. "
            "Did you specify the correct file/directory?"
        )


async def _convert_file_to_yaml(
    source_file: Path, target_dir: Path, converter: TrainingDataConverter
) -> bool:
    """Converts a single training data file to `YAML` format.

    Args:
        source_file: Training data file to be converted.
        target_dir: Target directory for the converted file.
        converter: Converter to be used.

    Returns:
        `True` if file was converted, `False` otherwise.
    """
    if not convo.shared.data.is_valid_filetype(source_file):
        return False

    if converter.filter(source_file):
        await converter.convert_and_write(source_file, target_dir)
        return True

    convo.shared.utils.cli.print_warning(f"Skipped file: '{source_file}'.")

    return False


def _migrate_model_config(args: argparse.Namespace) -> None:
    """Migrates old "rule-like" policies to the new `RulePolicy`.

    Updates the config, domain, and generates the required rules.

    Args:
        args: The commandline args with the required paths.
    """
    configuration_file = Path(args.config)
    model_configuration = _get_configuration(configuration_file)

    domain_file = Path(args.domain)
    domain = _get_domain(domain_file)

    rule_output_file = _get_rules_path(args.out)

    (
        model_configuration,
        domain,
        new_rules,
    ) = convo.core.config.migrate_mapping_policy_to_rules(model_configuration, domain)

    model_configuration, fallback_rule = convo.core.config.migrate_fallback_policies(
        model_configuration
    )

    if new_rules:
        _backup(domain_file)
        domain.persist_clean(domain_file)

    if fallback_rule:
        new_rules.append(fallback_rule)

    if new_rules:
        _backup(configuration_file)
        convo.shared.utils.io.write_yaml(model_configuration, configuration_file)
        _dump_rules(rule_output_file, new_rules)

    telemetry.track_data_convert("yaml", "config")

    _print_success_message(new_rules, rule_output_file)


def _get_configuration(path: Path) -> Dict:
    config = {}
    try:
        config = convo.shared.utils.io.read_config_file(path)
    except Exception:
        convo.shared.utils.cli.print_error_and_exit(
            f"'{path}' is not a path to a valid model configuration. "
            f"Please provide a valid path."
        )

    policy_names = [p.get("name") for p in config.get("policies", [])]

    _assert_config_needs_migration(policy_names)
    _assert_nlu_pipeline_given(config, policy_names)
    _assert_two_stage_fallback_policy_is_migratable(config)
    _assert_only_one_fallback_policy_present(policy_names)

    if FormPolicy.__name__ in policy_names:
        _warn_about_manual_forms_migration()

    return config


def _assert_config_needs_migration(policies: List[Text]) -> None:
    migratable_policies = {
        MappingPolicy.__name__,
        FallbackPolicy.__name__,
        TwoStageFallbackPolicy.__name__,
    }

    if not migratable_policies.intersection((set(policies))):
        convo.shared.utils.cli.print_error_and_exit(
            f"No policies were found which need migration. This command can migrate "
            f"'{MappingPolicy.__name__}', '{FallbackPolicy.__name__}' and "
            f"'{TwoStageFallbackPolicy.__name__}'."
        )


def _warn_about_manual_forms_migration() -> None:
    convo.shared.utils.cli.print_warning(
        f"Your model configuration contains the '{FormPolicy.__name__}'. "
        f"Note that this command does not migrate the '{FormPolicy.__name__}' and "
        f"you have to migrate the '{FormPolicy.__name__}' manually. "
        f"Please see the migration guide for further details: "
        f"{DOCS_URL_MIGRATION_GUIDE}"
    )


def _assert_nlu_pipeline_given(config: Dict, policy_names: List[Text]) -> None:
    if not config.get("pipeline") and any(
        policy in policy_names
        for policy in [FallbackPolicy.__name__, TwoStageFallbackPolicy.__name__]
    ):
        convo.shared.utils.cli.print_error_and_exit(
            "The model configuration has to include an NLU pipeline. This is required "
            "in order to migrate the fallback policies."
        )


def _assert_two_stage_fallback_policy_is_migratable(config: Dict) -> None:
    two_stage_fallback_config = next(
        (
            policy_config
            for policy_config in config.get("policies", [])
            if policy_config.get("name") == TwoStageFallbackPolicy.__name__
        ),
        None,
    )
    if not two_stage_fallback_config:
        return

    if (
        two_stage_fallback_config.get(
            "deny_suggestion_intent_name", USER_INTENT_OUT_OF_SCOPE
        )
        != USER_INTENT_OUT_OF_SCOPE
    ):
        convo.shared.utils.cli.print_error_and_exit(
            f"The TwoStageFallback in Convo Open Source 2.0 has to use the intent "
            f"'{USER_INTENT_OUT_OF_SCOPE}' to recognize when users deny suggestions. "
            f"Please change the parameter 'deny_suggestion_intent_name' to "
            f"'{USER_INTENT_OUT_OF_SCOPE}' before migrating the model configuration. "
        )

    if (
        two_stage_fallback_config.get(
            "fallback_nlu_action_name", ACTION_DEFAULT_FALLBACK_NAME
        )
        != ACTION_DEFAULT_FALLBACK_NAME
    ):
        convo.shared.utils.cli.print_error_and_exit(
            f"The Two-Stage Fallback in Convo Open Source 2.0 has to use the action "
            f"'{ACTION_DEFAULT_FALLBACK_NAME}' for cases when the user denies the "
            f"suggestion multiple times. "
            f"Please change the parameter 'fallback_nlu_action_name' to "
            f"'{ACTION_DEFAULT_FALLBACK_NAME}' before migrating the model "
            f"configuration. "
        )


def _assert_only_one_fallback_policy_present(policies: List[Text]) -> None:
    if (
        FallbackPolicy.__name__ in policies
        and TwoStageFallbackPolicy.__name__ in policies
    ):
        convo.shared.utils.cli.print_error_and_exit(
            "Your policy configuration contains two configured policies for handling "
            "fallbacks. Please decide on one."
        )


def _get_domain(path: Path) -> Domain:
    try:
        return Domain.from_path(path)
    except InvalidDomain:
        convo.shared.utils.cli.print_error_and_exit(
            f"'{path}' is not a path to a valid domain file. "
            f"Please provide a valid domain."
        )


def _get_rules_path(path: Text) -> Path:
    rules_file = Path(path)

    if rules_file.is_dir():
        convo.shared.utils.cli.print_error_and_exit(
            f"'{rules_file}' needs to be the path to a file."
        )

    if not rules_file.is_file():
        convo.shared.utils.cli.print_info(
            f"Output file '{rules_file}' did not exist and will be created."
        )
        convo.shared.utils.io.create_directory_for_file(rules_file)

    return rules_file


def _dump_rules(path: Path, new_rules: List[StoryStep]) -> None:
    existing_rules = []
    if path.exists():
        rules_reader = YAMLStoryReader()
        existing_rules = rules_reader.read_from_file(path)
        _backup(path)

    if existing_rules:
        convo.shared.utils.cli.print_info(
            f"Found existing rules in the output file '{path}'. The new rules will "
            f"be appended to the existing rules."
        )

    rules_writer = YAMLStoryWriter()
    rules_writer.dump(path, existing_rules + new_rules)


def _backup(path: Path) -> None:
    backup_file = path.parent / f"{path.name}.bak"
    shutil.copy(path, backup_file)


def _print_success_message(new_rules: List[StoryStep], output_file: Path) -> None:
    if len(new_rules) > 1:
        suffix = "rule"
        verb = "was"
    else:
        suffix = "rules"
        verb = "were"

    convo.shared.utils.cli.print_success(
        f"Finished migrating your policy configuration 🎉.\n"
        f"The migration generated {len(new_rules)} {suffix} which {verb} added to "
        f"'{output_file}'."
    )
