# -*- coding: utf-8 -*-
#
# Copyright 2013 Simone Campagna
#
# 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.
#

"""
Main 'zirkon' tool program.
"""

import argparse
import logging
import os
import sys

from .errors import FormatError
from .filetype import (
    get_fmts, get_config_classes,
    get_config_class_name,
    get_default_fmt, parse_filetype,
)
from .discover import discover, search_filetype

from .config import Config
from .program_config import ProgramConfig
from .schema import Schema
from .toolbox.loader import load_module
from .toolbox.tool_utils import (
    IoManager,
    die,
    tabulate_filetypes,
    trace_errors,
)
from .utils import (
    get_key, set_key, del_key,
    create_template_from_schema, sort_section,
)
from .validation import Validation
from .version import VERSION


__author__ = "Simone Campagna"
__copyright__ = 'Copyright (c) 2015 Simone Campagna'
__license__ = 'Apache License Version 2.0'
__all__ = [
    'main',
]


def list_files(params, *, config_dirs, schema_dirs):
    """Lists files.

       Parameters
       ----------
       params: dict
           common params
       config_dirs: tuple
           list of config directories
       schema_dirs: tuple
           list of schema directories
    """
    printer = params["printer"]
    header = params.get("header", True)
    paths = []
    for config_dir in config_dirs:
        paths.append((config_dir, (Config,)))
    for schema_dir in schema_dirs:
        paths.append((schema_dir, (Schema,)))
    filetypes = discover(*paths, standard_paths=True)
    for line in tabulate_filetypes(filetypes, header=header):
        printer(line)


def _copy_keys(source, target, keys):
    """Copy keys from source to target"""
    for key in keys:
        set_key(target, key, get_key(source, key), parents=True)


def _delete_keys(section, keys):
    """Delete keys from section"""
    for key in keys:
        del_key(section, key)


def _select_discard_keys(config, select_keys, discard_keys):
    """Select/discard keys"""
    if select_keys:
        new_config = config.__class__()
        _copy_keys(config, new_config, select_keys)
        return new_config
    elif discard_keys:
        new_config = config.copy()
        _delete_keys(new_config, discard_keys)
        return new_config
    else:
        return config


def read_config(params, *,
                defaults, evaluate, sort_keys,
                input_filetypes, schema_filetypes,
                output_filetype, validation_filetype, force,
                select_keys, discard_keys):
    # pylint: disable=too-many-locals
    """Reads a config file.

       Parameters
       ----------
       params: dict
           common parameters
       defaults: bool
           enable defaults
       evaluate: bool
           evaluate macros
       sort_keys: bool
           sort section keys
       input_filetypes: list
           list of input filetypes
       schema_filetypes: list
           list of schema filetypes
       output_filetype: FileType
           output filetype
       validation_filetype: FileType
           validation filetype
       force: bool
           force overwriting output files
       select_keys: list
           list of keys to be selected
       discard_keys: list
           list of keys to be discarded
    """
    printer = params["printer"]
    logger = params["logger"]
    io_manager = IoManager(printer=printer, logger=logger)

    with trace_errors(params["debug"]):
        schema = None
        if schema_filetypes:
            schema = Schema()
            for schema_filetype in schema_filetypes:
                io_manager.read_obj(schema, schema_filetype)

        default_output_fmt = input_filetypes[0].fmt
        config_class = input_filetypes[0].config_class
        config_args = {}
        if issubclass(config_class, Config):
            config_args['defaults'] = not defaults
        config = config_class(**config_args)
        input_filenames = []
        for input_filetype in input_filetypes:
            input_filenames.append(input_filetype.filepath)
            io_manager.read_obj(config, input_filetype)
        if schema is not None:
            validation = schema.validate(config)
            if validation_filetype is not None:
                io_manager.write_obj(validation, validation_filetype, overwrite=force)
            if validation and validation_filetype is None:
                logger.warning("validation failed for config %s:", ", ".join(input_filenames))
                io_manager.dump_obj(validation, print_function=logger.warning)
        if evaluate:
            config = config.__class__(config.as_dict(evaluate=True))
        config = _select_discard_keys(config, select_keys, discard_keys)
        if sort_keys:
            sort_section(config)
        if output_filetype is None:
            io_manager.dump_obj(config, fmt=default_output_fmt)
        else:
            io_manager.write_obj(config, output_filetype, overwrite=force)


def create_config(params, *,
                  defaults, evaluate, sort_keys,
                  schema_filetypes, output_filetype, force,
                  select_keys, discard_keys):
    """Creates a config file from a schema.

       Parameters
       ----------
       params: dict
           common parameters
       defaults: bool
           enable defaults
       evaluate: bool
           evaluate macros
       sort_keys: bool
           sort section keys
       schema_filetypes: list
           list of schema filetypes
       output_filetype: FileType
           output filetype
       force: bool
           force overwriting output files
       select_keys: list
           list of keys to be selected
       discard_keys: list
           list of keys to be discarded
    """
    printer = params["printer"]
    logger = params["logger"]
    default_output_fmt = params["default_fmt"]
    io_manager = IoManager(printer=printer, logger=logger)
    schema = Schema()
    for schema_filetype in schema_filetypes:
        io_manager.read_obj(schema, schema_filetype)
    config = Config(defaults=not defaults)
    create_template_from_schema(schema=schema, config=config)
    if evaluate:
        config = config.__class__(config.as_dict(evaluate=True))
    config = _select_discard_keys(config, select_keys, discard_keys)
    if sort_keys:
        sort_section(config)
    if output_filetype is None:
        io_manager.dump_obj(config, fmt=default_output_fmt)
    else:
        io_manager.write_obj(config, output_filetype, overwrite=force)


def program(params, *,
            module_names, pmode, args,
            defaults, evaluate, sort_keys,
            output_filetype, validation_filetype, force,
            select_keys, discard_keys):
    # pylint: disable=too-many-locals
    """Reads a config file.

       Parameters
       ----------
       params: dict
           common parameters
       pmode: str
           "config" or "schema"
       module_names: list
           list of configured module names
       args: list
           list of positional args
       defaults: bool
           enable defaults
       evaluate: bool
           evaluate macros
       sort_keys: bool
           sort section keys
       output_filetype: FileType
           output filetype
       validation_filetype: FileType
           validation filetype
       force: bool
           force overwriting output files
    """
    printer = params["printer"]
    logger = params["logger"]
    io_manager = IoManager(printer=printer, logger=logger)
    default_output_fmt = params["default_fmt"]

    with trace_errors(params["debug"]):
        modules = []
        for module_name in module_names:
            modules.append(load_module(module_name))
        program_config = ProgramConfig(*modules)
        if pmode == "config":
            program_info = program_config.parse_args(args, raise_on_error=False)
            config = program_info.config
            validation = program_info.validation
            if validation_filetype is not None:
                io_manager.write_obj(validation, validation_filetype, overwrite=force)
            if validation and validation_filetype is None:
                logger.warning("validation failed:")
                io_manager.dump_obj(validation, print_function=logger.warning)
            if validation:
                return
            config = Config(config.as_dict(defaults=defaults, evaluate=evaluate))
        elif pmode == "schema":
            config = program_config.schema  # pylint: disable=redefined-variable-type
        config = _select_discard_keys(config, select_keys, discard_keys)
        if sort_keys:
            sort_section(config)
        if output_filetype is None:
            io_manager.dump_obj(config, fmt=default_output_fmt)
        else:
            io_manager.write_obj(config, output_filetype, overwrite=force)


def _create_logger(stream, verbose_level):
    """Creates a logger.

       Parameters
       ----------
       stream: file
           the logger's stream
       verbose_level: int
           the initial verbose level

       Returns
       -------
       Logger
           the logger
    """
    logger = logging.getLogger("ZIRKON-LOG")
    if verbose_level == 0:
        log_level = logging.ERROR
    elif verbose_level == 1:
        log_level = logging.WARNING
    elif verbose_level == 2:
        log_level = logging.INFO
    elif verbose_level >= 3:
        log_level = logging.DEBUG
    log_handler = logging.StreamHandler(stream=stream)
    log_formatter = logging.Formatter("{levelname:8s} {message}", style="{")
    log_handler.setFormatter(log_formatter)
    logger.addHandler(log_handler)
    logger.setLevel(log_level)
    return logger


def _filetype(logger, filearg, config_class=None, fmt=None):
    """Converts a string from command line to filetype."""
    try:
        return parse_filetype(filename=filearg, config_class=config_class, fmt=fmt)
    except FormatError as err:
        die(logger, "invalid format {} (available formats: {})".format(err.args[0], "|".join(get_fmts())))


def _input_filetype(logger, filearg, config_class=None):
    """Converts a string from command line to an input filetype.

       Parameters
       ----------
       logger: Logger
           the logger
       filearg: str
           the command line string
       config_class: ConfigBase, optional
           the expected config class (defaults to None)

       Returns
       -------
       FileType
           the FileType object
    """
    if filearg is None:  # pragma: no cover
        return None
    filetype = _filetype(logger, filearg, config_class=config_class)
    found_filetypes = tuple(search_filetype(filetype))
    if len(found_filetypes) == 0:
        if not os.path.exists(filetype.filepath):
            die(logger, "invalid value {}: input file not found".format(
                filearg))
    elif len(found_filetypes) > 1:  # pragma: no cover
        logger.warning("{!r}: multiple matches: found {} matches:".format(filearg, len(found_filetypes)))
        for line in tabulate_filetypes(found_filetypes):
            logger.warning(" * {}".format(line))
        die(logger, "invalid value {!r}: multiple matches".format(
            filearg))
    undetected_attributes = []
    for attribute in 'config_class', 'fmt':
        if getattr(filetype, attribute) is None:
            undetected_attributes.append(attribute)
    if undetected_attributes:
        die(logger, "invalid value {}: cannot detect {}".format(
            filearg, ', '.join(undetected_attributes)))
    return filetype


def _input_schema_filetype(logger, filearg):
    """Converts a string from command line to an input schema filetype.

       Parameters
       ----------
       logger: Logger
           the logger
       filearg: str
           the command line string

       Returns
       -------
       FileType
           the FileType object
    """
    return _input_filetype(logger, filearg, config_class=Schema)


def _output_filetype(logger, filearg):
    """Converts a string from command line to an output filetype.

       Parameters
       ----------
       logger: Logger
           the logger
       filearg: str
           the command line string

       Returns
       -------
       FileType
           the FileType object
    """
    return _filetype(logger, filearg)


def _validation_filetype(logger, filearg):
    """Converts a string from command line to an output validation filetype.

       Parameters
       ----------
       logger: Logger
           the logger
       filearg: str
           the command line string

       Returns
       -------
       FileType
           the FileType object
    """
    return _filetype(logger, filearg, config_class=Validation)


def _validate_args(logger, args):
    """Validates args.

       Parameters
       ----------
       logger: Logger
           the logger
       filearg: str
           the command line string
    """
    input_filetypes = getattr(args, "input_filetypes", None)
    schema_filetypes = getattr(args, "schema_filetypes", None)
    output_filetype = getattr(args, "output_filetype", None)
    validation_filetype = getattr(args, "validation_filetype", None)
    ref_fmt = None
    ref_config_class = None
    if input_filetypes:
        ref_fmt = input_filetypes[0].fmt
        ref_config_class = input_filetypes[0].config_class
    elif schema_filetypes:
        ref_fmt = schema_filetypes[0].fmt

    if output_filetype is not None:
        replace_d = {}
        if output_filetype.fmt is None and ref_fmt is not None:
            replace_d['fmt'] = ref_fmt
        if ref_config_class is not None:
            if output_filetype.config_class is None:
                replace_d['config_class'] = ref_config_class
            elif output_filetype.config_class != ref_config_class:
                logger.warning("output filename {}: config_class mismatch: {} or {}?".format(
                    output_filetype.filepath,
                    ref_config_class.__name__,
                    output_filetype.config_class.__name__))
                replace_d['config_class'] = ref_config_class
        if replace_d:
            args.output_filetype = output_filetype._replace(**replace_d)

    if validation_filetype is not None:
        if validation_filetype.fmt is None and ref_fmt is not None:
            args.validation_filetype = validation_filetype._replace(fmt=ref_fmt)


def main_parse_args(log_stream=sys.stderr, out_stream=sys.stdout, argv=None):
    # pylint: disable=too-many-locals
    # pylint: disable=too-many-statements
    """Parses command line arguments.

       Parameters
       ----------
       log_stream: file, optional
           the log file (defaults to sys.stderr)
       out_stream: file, optional
           the output file (defaults to sys.stdout)
       argv: list, optional
           the command line arguments (defaults to None, meaning sys.args[1:])
    """
    if argv is None:  # pragma: no cover
        argv = sys.argv[1:]

    default_verbose_level = 1

    config_class_names = [get_config_class_name(config_class) for config_class in get_config_classes()]

    parser_args = dict(
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    common_parser = argparse.ArgumentParser(
        add_help=False,
        **parser_args)

    top_level_description = """\
Zirkon tool - read/write/validate config files.

The FILE values can be specified with the following syntax:

    filepath[:fmt[:config_class]]

where fmt can be any of the available formats:

    {fmts}

and config_class any of the available classes:

    {config_classes}

Environment variables
---------------------
* ZIRKON_CONFIG_PATH colon-separated list of directories for config files search
* ZIRKON_SCHEMA_PATH colon-separated list of directories for schema files search
""".format(fmts=', '.join(get_fmts()),
           config_classes=', '.join(config_class_names))

    top_level_parser = argparse.ArgumentParser(
        "zirkon",
        parents=(common_parser,),
        description=top_level_description,
        **parser_args)

    top_level_parser.add_argument(
        "--verbose", "-v",
        dest="verbose_level",
        action="count",
        default=default_verbose_level,
        help="increase verbosity")

    top_level_parser.add_argument(
        "--quiet", "-q",
        dest="verbose_level",
        action="store_const",
        const=0,
        default=default_verbose_level,
        help="quiet mode (only errors are shown)")

    top_level_parser.add_argument(
        "--debug",
        action="store_true",
        default=False,
        help="debug mode")

    top_level_parser.add_argument(
        "--version",
        action="version",
        version="%(prog)s {}".format(VERSION),
        help="show version")

    subparsers = top_level_parser.add_subparsers()

    list_parser = subparsers.add_parser(
        "list",
        parents=(common_parser,),
        description="""Lists all the available config and schema files.""",
        **parser_args)

    list_parser.set_defaults(
        function=list_files,
        function_args=("config_dirs", "schema_dirs"))

    read_parser = subparsers.add_parser(
        "read",
        parents=(common_parser,),
        description="""Reads a config file.""",
        **parser_args)

    read_parser.set_defaults(
        function=read_config,
        function_args=("defaults", "evaluate", "sort_keys",
                       "input_filetypes", "schema_filetypes",
                       "output_filetype", "validation_filetype",
                       "force", "select_keys", "discard_keys"))

    create_parser = subparsers.add_parser(
        "create",
        parents=(common_parser,),
        description="""Creates a config file from a schema.""",
        **parser_args)

    create_parser.set_defaults(
        function=create_config,
        function_args=("defaults", "evaluate", "sort_keys",
                       "schema_filetypes", "output_filetype", "force",
                       "select_keys", "discard_keys"))

    program_parser = subparsers.add_parser(
        "program",
        parents=(common_parser,),
        description="""Loads and show program configuration.""",
        **parser_args)

    program_parser.set_defaults(
        function=program,
        function_args=("module_names", "pmode", "args", "defaults", "evaluate",
                       "sort_keys", "output_filetype", "force", "validation_filetype",
                       "select_keys", "discard_keys"))

    default_pmode = "schema"
    program_parser.add_argument(
        "args",
        nargs="*",
        help="arguments to be passed to the program_config object")

    program_parser.add_argument(
        "--module", "-m",
        metavar="MOD",
        dest="module_names",
        default=[],
        action="append",
        required=True,
        type=str,
        help="load a configured module (can be repeated)")

    program_group = program_parser.add_mutually_exclusive_group()
    program_group.add_argument(
        "--config", "-c",
        dest="pmode",
        default=default_pmode,
        action="store_const",
        const="config",
        help="show program config")

    program_group.add_argument(
        "--schema", "-s",
        dest="pmode",
        default=default_pmode,
        action="store_const",
        const="schema",
        help="show program schema")

    for parser in (read_parser,):
        parser.add_argument(
            "--input", "-i",
            dest="input_filetypes",
            metavar="IC",
            default=[],
            required=True,
            type=str,
            action="append",
            help="input file")

    for parser in read_parser, create_parser, program_parser:
        parser.add_argument(
            "--sort-keys", "-S",
            dest="sort_keys",
            default=False,
            action="store_true",
            help="sort keys")

    schema_required = {}
    schema_required[read_parser] = False
    schema_required[create_parser] = True

    for parser in read_parser, create_parser, program_parser:
        parser.add_argument(
            "--defaults", "-d",
            action="store_true",
            default=False,
            help="show default values")

        parser.add_argument(
            "--evaluate", "-e",
            action="store_true",
            default=False,
            help="evaluate macros")

        parser.add_argument(
            "--output", "-o",
            dest="output_filetype",
            metavar="OC",
            default=None,
            type=str,
            help="output file")

        parser.add_argument(
            "--force", "-f",
            action="store_true",
            default=False,
            help="force overwriting existing output files")

        select_key_group = parser.add_mutually_exclusive_group()
        select_key_group.add_argument(
            "--select-key", "-k",
            dest="select_keys",
            metavar="KEY",
            action='append',
            default=[],
            help="select config key to be shown (can be repeated); for instance, '-k /sub/x -k sub.opts.value'")

        select_key_group.add_argument(
            "--discard-key", "-K",
            dest="discard_keys",
            metavar="KEY",
            action='append',
            default=[],
            help="discard config key to be shown (can be repeated); for instance, '-k /sub/x -k sub.opts.value'")

    for parser in read_parser, create_parser:
        parser.add_argument(
            "--schema", "-s",
            dest="schema_filetypes",
            metavar="FILE",
            default=[],
            required=schema_required[parser],
            type=str,
            action="append",
            help="schema input file")

    for parser in (read_parser, program_parser):
        parser.add_argument(
            "--validation", "-V",
            dest="validation_filetype",
            metavar="FILE",
            default=None,
            type=str,
            help="validation output file")

    for parser in (list_parser,):
        parser.add_argument(
            "--config-dir", "-cd",
            dest="config_dirs",
            metavar="CD",
            action="append",
            default=[],
            type=str,
            help="add config dir")

        parser.add_argument(
            "--schema-dir", "-sd",
            dest="schema_dirs",
            metavar="SD",
            action="append",
            default=[],
            type=str,
            help="add config dir")

    args = top_level_parser.parse_args(argv)

    logger = _create_logger(log_stream, args.verbose_level)
    printer = lambda x: print(x, file=out_stream, flush=True)  # flake8: noqa

    list_converter_d = {
        "input_filetypes": _input_filetype,
        "schema_filetypes": _input_schema_filetype,
    }
    for key, converter in list_converter_d.items():
        if hasattr(args, key):
            lst = [converter(logger=logger, filearg=value) for value in getattr(args, key)]
            setattr(args, key, lst)

    converter_d = {
        "output_filetype": _output_filetype,
        "validation_filetype": _validation_filetype,
    }

    for key, converter in converter_d.items():
        if hasattr(args, key):
            setattr(args, key, converter(logger=logger, filearg=getattr(args, key)))

    _validate_args(logger, args)

    for key, _ in converter_d.items():
        if hasattr(args, key):
            logger.debug("%20s: %s", key, getattr(args, key))
    return top_level_parser, args, logger, printer


def main(log_stream=sys.stderr, out_stream=sys.stdout, argv=None):
    """Runs the main program.

       Parameters
       ----------
       log_stream: file, optional
           the log file (defaults to sys.stderr)
       out_stream: file, optional
           the output file (defaults to sys.stdout)
       argv: list, optional
           the command line arguments (defaults to None, meaning sys.args[1:])
    """

    parser, args, logger, printer = main_parse_args(
        log_stream=log_stream, out_stream=out_stream, argv=argv)

    function = getattr(args, "function", None)
    if not function:
        parser.print_help()
        sys.exit(1)
    function_args = {}
    for function_arg in args.function_args:
        function_args[function_arg] = getattr(args, function_arg)

    params = {}
    params["printer"] = printer
    params["logger"] = logger
    params["default_fmt"] = get_default_fmt()
    for key in ("debug",):
        params[key] = getattr(args, key)

    return function(params=params, **function_args)


if __name__ == "__main__":
    main()
