import os
from argparse import ArgumentParser
from dataclasses import is_dataclass
from enum import Enum
from functools import partial
from inspect import getfile, getfullargspec, isclass
from pathlib import Path
from typing import Any, Callable, Optional, Sequence, Type

from omegaconf import DictConfig

from .core import (from_dataclass, from_file, from_options, merge, traverse,
                   validate)
from .init import InitLater
from .utils import PrintUtils, clean_multiline


class CliFlags(Enum):
    CONFIG = 'config'
    PARSED = 'parsed'
    QUIET = 'quiet'
    INPUTS = 'inputs'
    OPTIONS = 'options'
    DEBUG = 'debug'


def make_flags(opt_name: CliFlags) -> Sequence[str]:
    """Simple helper to create a list of flags for an
    option based on its name"""
    return f'-{opt_name.value[0]}', f'--{opt_name.value}'


class cli:
    @classmethod
    def _check_signature(cls: Type['cli'], func: Callable):
        expected_args = getfullargspec(func).args
        if len(expected_args) == 0:
            msg = (f'Function `{func.__name__}` cannot be decorated '
                   f'by `config_to_program` because it does '
                   f'not accept any argument.')
            raise RuntimeError(msg)
        elif len(expected_args) > 1:
            msg = (f'Function `{func.__name__}` cannot be decorated by '
                   f' `config_to_program` because it expects '
                   f'{len(expected_args)} > 1; If you want to pass extra '
                   f'arguments to this function, use kwargs with default '
                   f'values.')
            raise RuntimeError(msg)

    @classmethod
    def _check_args(cls: Type['cli'], func: Callable, args: Sequence[Any]):
        if len(args):
            msg = (f'After decorating `{func.__name__}` with '
                   f'`config_to_program`, do not provide any additional '
                   f'arguments while invoking it; any additional parameter '
                   f'should be passed as a keyword argument.')
            raise RuntimeError(msg)

    @classmethod
    def _make_argument_parser(
        cls: Type['cli'],
        func: Callable,
        name: str
    ) -> ArgumentParser:
        """Sets up argument parser ahead of running the CLI. This includes
        creating a help message, and adding a series of flags."""

        # we find the path to the script we are decorating with the
        # cli so that we can display that to the user.
        current_dir = Path(os.getcwd())
        path_to_fn_file = Path(getfile(func))
        rel_fn_file_path = str(path_to_fn_file).replace(str(current_dir), '')

        # Program name and usage printed here.
        prog = f'Parser for configuration {name}'
        usage = clean_multiline(f'''
            python3 {rel_fn_file_path} '
            {{{"/".join(make_flags(CliFlags.CONFIG))} config_file.yaml}}
            {{{"/".join(make_flags(CliFlags.OPTIONS))}}}
            {{{"/".join(make_flags(CliFlags.INPUTS))}}}
            {{{"/".join(make_flags(CliFlags.DEBUG))}}}
            {{{"/".join(make_flags(CliFlags.PARSED))}}}
            {{{"/".join(make_flags(CliFlags.QUIET))}}}
            param1=value1, …, paramN=valueN'
        ''')
        ap = ArgumentParser(prog=prog, usage=usage)

        # add options
        msg = 'A path to a YAML file containing a configuration.'
        ap.add_argument(*make_flags(CliFlags.CONFIG),
                        default=None,
                        help=msg,
                        metavar='/path/to/config.yaml')

        msg = 'Print all default options and CLI flags.'
        ap.add_argument(*make_flags(CliFlags.OPTIONS),
                        action='store_true',
                        help=msg)

        msg = 'Print the input configuration.'
        ap.add_argument(*make_flags(CliFlags.INPUTS),
                        action='store_true',
                        help=msg)

        msg = 'Print the parsed configuration.'
        ap.add_argument(*make_flags(CliFlags.PARSED),
                        action='store_true',
                        help=msg)

        msg = 'Enter debug mode by setting global logging to DEBUG.'
        ap.add_argument(*make_flags(CliFlags.DEBUG),
                        action='store_true',
                        help=msg)

        msg = 'If provided, it does not print the configuration when running.'
        ap.add_argument(*make_flags(CliFlags.QUIET),
                        action='store_true',
                        help=msg)
        return ap

    @classmethod
    def _wrapped_main_method(
        cls: Type['cli'],
        func: Callable,
        name: str,
        config_node: DictConfig,
        print_fn: Optional[Callable] = None,
        strict_input: bool = False,
        *args: Any,
        **kwargs: Any
    ) -> Callable:

        if not isinstance(config_node, DictConfig):
            raise TypeError("Config node must be a DictConfig")

        # Making sure I can decorate this function
        cls._check_signature(func=func)
        cls._check_args(func=func, args=args)

        # Get argument parser and arguments
        ap = cls._make_argument_parser(func=func, name=name)
        opts, leftover_args = ap.parse_known_args()

        # set some default options for when no options are provided
        # printing_steps = PrintingSteps(opts)

        # setup debug
        if opts.debug:
            # relative import here not to mess things up
            from .logging import configure_logging
            configure_logging.debug()

        # Setup an utility to deal with printing
        pu = PrintUtils(print_fn=print_fn)

        # We don't run the main program if the user
        # has requested to print the any of the config.
        do_no_run = (opts.options or opts.inputs or opts.parsed)

        # Print default options if requested py the user
        if opts.options:
            params = traverse(config_node)

            cli_opts_repr = ('OPTS/CLI FLAG:', ) + tuple(
                f'{p.path} = ' + (str(p.value) if p.value != '' else "''")
                for p in params
            )
            pu.print(*cli_opts_repr, level_up=1)

        # load cli config and file config;
        # when merging, cli_config takes precedence over file_config
        cli_config = from_options(leftover_args)
        file_config = from_file(opts.config)
        input_config = merge(file_config, cli_config)

        # print both configs if requested
        if opts.inputs:
            pu.print('INPUT/CLI ARGS:', cli_config)
            pu.print('INPUT/CFG FILE:', file_config)

        if do_no_run and not opts.parsed:
            # if the user hasn't requested to print the parsed config
            # and we are not running the main program, we can exit here.
            return InitLater.no_op()

        # load configuration with node parsers
        parsed_config = merge(config_node, input_config, strict=strict_input)

        # check if all parameters are provided/resolved
        parsed_config = validate(parsed_config)

        # print it if requested
        if not(opts.quiet) or opts.parsed:
            pu.print('PARSE/ALL CFG:', parsed_config)   # type: ignore

        if do_no_run:
            # we are not running because the user has requested to print
            # either the options, inputs, or parsed config.
            return InitLater.no_op()
        else:
            # we execute the main method and pass the parsed config to it
            return func(parsed_config, **kwargs)

    def __new__(cls: Type['cli'],
                config_node_cls: Any,
                strict_input: bool = False,
                print_fn: Optional[Callable] = None) -> Callable:

        if not(isclass(config_node_cls) and is_dataclass(config_node_cls)):
            msg = '`config_node` must be be decorated as a dataclass'
            raise ValueError(msg)

        config_node = from_dataclass(config_node_cls)
        name = config_node_cls.__name__

        def wrapper(func: Callable) -> Callable:
            return partial(cls._wrapped_main_method,
                           func=func,
                           name=name,
                           strict_input=strict_input,
                           config_node=config_node,
                           print_fn=print_fn)
        return wrapper
