from __future__ import annotations

from typing import Any, Callable, cast

import click
from click.types import BoolParamType

from the1conf import ConfigVarDef
from the1conf.utils import Undefined, _undef


class StringOrUndefined(click.ParamType):
    name = "string"

    def convert(self, value: Any, param: Any, ctx: Any) -> Any:
        if value is _undef:
            return value
        return click.STRING.convert(value, param, ctx)


class BoolOrUndefined(BoolParamType):
    name = "boolean"

    def convert(self, value: Any, param: Any, ctx: Any) -> Any:
        if value is _undef:
            return value
        return super().convert(value, param, ctx)


def click_option(config_var: Any, **kwargs: Any) -> Callable[[Any], Any]:
    """Wrapper for click.option with the metadata from ConfigVarDef."""
    if not isinstance(config_var, ConfigVarDef):
        raise TypeError(f"click_option expects a ConfigVarDef, got {type(config_var)}")

    # setting the option names (--foo, -f) and flags for boolean variables based on the config variable.
    param_decls = []

    keys = config_var.cli_keys
    if keys is None or len(keys) == 0:
        raise ValueError(
            f"Config variable {config_var.fq_name} has no value_key, which means it won't be settable via click options. This is likely a misconfiguration of the variable, check that it is not defined with no_search flags."
        )
    is_bool = config_var.type_info is bool

    for key in keys:
        if len(key) == 1:
            param_decls.append(f"-{key}")
        else:
            if is_bool:
                param_decls.append(f"--{key}/--no-{key}")
            else:
                param_decls.append(f"--{key}")

    # Used as destination name in Click internals.
    # We build it from fq_name and replace namespace separators (.) with
    # the unicode character ː (U+02D0) so Click can infer a valid option name.
    if config_var.fq_name is None:
        raise ValueError(
            "Config variable fq_name is not set. Make sure to define click options on class attributes with a resolved ConfigVarDef."
        )
    param_name = config_var.fq_name.replace(".", "\u02d0")

    if is_bool:
        kwargs["is_flag"] = True

    # Add help.
    if "help" not in kwargs and config_var.help:
        kwargs["help"] = config_var.help

    # We overwrite any passed type to ensure that resolve_vars receives a string.
    # We use a custom type to handle the `_undef` default value without converting
    # it to string.
    if not is_bool:
        kwargs["type"] = StringOrUndefined()
    else:
        kwargs["type"] = BoolOrUndefined()

    # We set default to `_undef` to distinguish between "option not provided" (_undef)
    # and "option provided with no value" (which shouldn't happen with string) or None.
    kwargs["default"] = _undef

    # Add default in help message.
    if (
        "show_default" not in kwargs
        and config_var.default is not Undefined
        and not callable(config_var.default)
    ):
        # We must convert to string otherwise click interprets bool/int as flags (True/False)
        # which tell it "show the default" (which is None here), so it displays nothing.
        kwargs["show_default"] = str(config_var.default)

    def _get_the1conf_click_callback(
        var_fq_name: str, user_callback: Callable[[Any, Any, Any], Any]
    ) -> Callable[[Any, Any, Any], Any]:
        """Parameters (options and arguments) are forwarded to the command callbacks.
        One common way to prevent a parameter from being passed to the callback is set the click argument `expose_value` to False.
        This will hide the parameter entirely so that it is not passed to the callback and also not stored in the Click context params.

        This allows us to store the value in the Click context with the original the1conf key (with dots and '-') instead of the click option name
        (with dots and '-' replaced), which makes it easier to resolve the variable in resolve_vars.
        """
        var_fq_name = var_fq_name
        user_callback = user_callback

        def call_back(ctx: Any, param: Any, value: Any) -> Any:
            ctx.params[var_fq_name] = value
            if callable(user_callback):
                return user_callback(ctx, param, value)
            return value

        return call_back

    # we are going to use a callback and we don't want to override any user provided callback,
    # so we store the user callback to call it later in our own callback
    user_callback = cast(Callable[[Any, Any, Any], Any], kwargs.get("callback"))

    kwargs["callback"] = _get_the1conf_click_callback(config_var.fq_name, user_callback)
    # We set expose_value to False to prevent Click from storing the value in the context with the
    # option name (with dots replaced), which would be different from the original
    # the1conf key and would make it harder to resolve the variable in resolve_vars.
    kwargs["expose_value"] = False

    def _decorator(func: Any) -> Any:
        """this is a docorator function that takes as parameter the function being decorated.

        It is used on a click command function to intercept the click option added and verify that there
        are no duplicate option names.
        """
        existing_option_names: set[str] = set()
        # read the __click_params__ of click that contains the list of options and arguments already declared.
        # we keep on the click.Option parameter (not click.Argument or other things).
        for param in getattr(func, "__click_params__", []):
            if isinstance(param, click.Option):
                existing_option_names.update(param.opts)

        # expand boolean parameter  "--feature/--no-feature" in param_decls (get from the closure)
        # by splitting them in two.
        expanded_param_decls = {
            token for declaration in param_decls for token in declaration.split("/")
        }
        # test duplication. We check that none of the new option names (after expansion)
        # are already declared in the function.
        duplicate_option_names = sorted(
            existing_option_names.intersection(expanded_param_decls)
        )
        if duplicate_option_names:
            raise ValueError(
                "Duplicate click option declaration detected: "
                f"{', '.join(duplicate_option_names)}. "
                "Each CLI option key must be unique across config variables."
            )

        return click.option(*param_decls, param_name, **kwargs)(func)

    # Note: We do NOT pass 'envvar' nor 'default'.
    # The callback stores values with the original the1conf key in ctx.params.
    return _decorator
