import click
import kitipy
import os.path
import time
import yaml
import subprocess
import sys
from typing import Callable, Dict, List, Optional, TypeVar
from .exceptions import TaskError


def load_config_file(path: str) -> Dict:
    """Load the YAML config file at the given path. Note that this function
    doesn't normalize the config, this is handled by normalize_config().

    Args:
        path (str):
            The path to the config file to load. It could be either relative or
            absolute.
    
    Raises:
        click.BadParameter: When the given path does not exist.

    Returns:
        Dict: The loaded and parsed config file
    """

    if not os.path.exists(path):
        raise click.BadParameter('No file "%s" found.' % (path))

    with open(path, 'r') as f:
        config = yaml.safe_load(f)
        config['path'] = path

    return config


def normalize_config(config: Dict) -> Dict:
    """Normalize kitipy config

    It puts single stage/stack parameters into stacks/stages keys, for
    mono-stack and/or mono-stage use cases.
    It also takes care of creating stacks/stages keys if they're missing.
    And finally it puts stage/stack name keys into stage/stack values to easily
    access them later.

    For instance, a single-stage/single-stack config:

        stage:
          name: dev
          type: local

        stack:
          name: api
          type: compose
    
    Will be normalized into:

        stages:
          dev:
            name: dev
            type: local
        
        stacks:
          api:
            name: api
            type: compose

    Args:
        config (Dict): Config to normalize.
    
    Returns:
        Dict: The normalized config.
    """

    if 'stage' in config and 'stages' not in config:
        if 'name' not in config['stage']:
            raise RuntimeError('You have to set a name for your stage.')

        config['stages'] = {config['stage']['name']: config['stage']}
        del config['stage']
    if 'stages' not in config:
        config['stages'] = {
            'default': {
                'type': 'local',
            }
        }

    if 'stack' in config and 'stacks' not in config:
        if 'name' not in config['stack']:
            raise RuntimeError('You have to set a name for your stack.')

        config['stacks'] = {config['stack']['name']: config['stack']}
        del config['stack']
    if 'stacks' not in config:
        config['stacks'] = {}

    for k, v in config['stages'].items():
        v['name'] = k
    for k, v in config['stacks'].items():
        v['name'] = k

    return config


def set_up_file_transfer_listeners(dispatcher: kitipy.Dispatcher):
    """Set up listeners for file transfer progress bars.

    This is usually called by the RootCommand when it creates the event
    dispatcher. However, you might want to call it if you are writing your own
    RootCommand.

    Args:
        dispatcher (Dispatcher): The dispatcher against which the listeners
            will be registered.
    """
    progressbar = None

    def on_start(size: int, label: str):
        nonlocal progressbar
        if progressbar is not None:
            progressbar.close()
        progressbar = click.progressbar(length=size, label=label)

    def on_update(current: int, total: int):
        nonlocal progressbar
        if progressbar is not None:
            progressbar.update(current)

    def on_end():
        nonlocal progressbar
        if progressbar is not None:
            progressbar.close()
            progressbar = None

    dispatcher.on('file_transfer.start', on_start)
    dispatcher.on('file_transfer.update', on_update)
    dispatcher.on('filter_transfer.end', on_end)


def append_cmd_flags(cmd: str, **kwargs) -> str:
    """Build a command string by appending flags in \**kwargs.

    Args:
        cmd (str): The base command to prefix with the given flags.
        **kwargs:
            List of flags to append to the command with flag names as keys and
            their value as dict value. If the value is a boolean, no value is 
            appended to the flag. If the value is a tuple, the flag will be
            repeated with each tuple value. Otherwise, the value is appended to
            the flag.
            Short and long flags are supported: single-letter flags are
            considered as short flags (prefixed with a single dash) and other
            flags are considere as long flags (prefixed with a double dash).
    
    Returns:
        str: The given cmd with short_flags and long_flags appended.
    """

    for name, value in kwargs.items():
        name = name.replace('_', '-')
        sep = ' ' if len(name) == 1 else '='
        name = '-' + name if len(name) == 1 else '--' + name

        if value is None:
            continue
        if type(value) == tuple:
            for single_value in value:
                cmd = _append_cmd_flag(cmd, name, single_value, sep)
        else:
            cmd = _append_cmd_flag(cmd, name, value, sep)

    return cmd


def _append_cmd_flag(cmd: str, name: str, value, sep: str) -> str:
    if type(value) == bool:
        return cmd + " %s" % (name)

    return cmd + " %s%s%s" % (name, sep, value)


TesterResult = TypeVar('TesterResult', subprocess.CompletedProcess, bool, None)
"""TesterResult is the return type of TesterCallable. See TesterCallable for
more details.
"""
TesterCallable = Callable[[kitipy.Context], TesterResult]
"""TesterCallable are used by wait_for() function to regularly run some tests,
generally to ensure a stage is in a given state (e.g. all services are running,
the DB has been initialized, etc...).
The TesterCallable takes a kitipy.Context as argument, to help you to interact
with your stage/stack.
It has to return either a subprocess.CompletedProcess, a boolean or None. The
test is considered successful when it returns True or a CompletedProcess with a
returncode equal to 0.
"""


def wait_for(tester: TesterCallable,
             max_checks: int,
             interval: int = 1,
             label: Optional[str] = None):
    """This helper function will run a tester callback for max_checks times at
    most, with an interval between each check. If the callback didn't return
    true or a successful subprocess.CompletedProcess after max_checks retries,
    an exception is thrown.
    
    This helps implementing some higher-level wait functions, for instance to
    ensure containers are all running or to ensure a DB is initialized.

    Args:
        tester (TesterCallable):
            The callback to regularly run.
        max_checks (int):
            Number of times the tester functions should be called at most.
            After that, an exception is thrown if no check were successful.
        interval (int):
            Interval in seconds between two retry.
        label (Optional[str]):
            Label to display on the CLI every time the tester function is
            called. This is prefixed by "[X/max_checks]". It's also used for
            the exception message when wait_for fails. It's recommended to 
            write it in the form of: "Wait for <something>".
    
    Raises:
        click.ClickException: When max_checks is reached and no checks were successful.
    """
    kctx = kitipy.get_current_context()
    label = label if label is not None else 'Waiting...'
    for i in range(1, max_checks, interval):
        kctx.echo(message="[%d/%d] %s" % (i, max_checks, label))

        result = None
        succeeded = False

        try:
            result = tester(kctx)
        except subprocess.CalledProcessError as e:
            succeedded = False

        if isinstance(result, bool):
            succeeded = result
        if isinstance(result, subprocess.CompletedProcess):
            succeeded = result.returncode == 0

        if succeeded:
            return

        time.sleep(interval)

    kctx.fail("Failed to %s" % (label.lower()))


# @TODO: add string confirmation (like "enter environment name expected: prod)")
def confirm_and_apply(
        dry_run: Callable[[], Optional[subprocess.CompletedProcess]],
        confirm_msg: str,
        apply: Callable[[], None],
        show_dry_run: bool = True,
        ask_confirm: bool = True,
        should_apply: bool = True,
        confirm_default=True):
    exit_code = 0
    if show_dry_run:
        res = dry_run()
        exit_code = res.returncode if res else 0
        should_apply = exit_code != 0 if should_apply else False

    if not should_apply:
        if exit_code == 0:
            return
        raise TaskError("Dry run failed.", exit_code=exit_code)

    if ask_confirm:
        if not click.confirm(confirm_msg, default=confirm_default):
            sys.exit(exit_code)

    apply()


def invoke_tree(root: click.Group, subcommands: List[str]):
    click_ctx = click.get_current_context()
    click_ctx.args = subcommands[1:]
    click_ctx.protected_args = subcommands[:1]
    root.invoke(click_ctx)
