from __future__ import annotations

import os
import typing as t
from glob import iglob
from typing import Generator

import click
from prompt_toolkit.completion import CompleteEvent, Completer, Completion
from prompt_toolkit.document import Document

from .utils import _resolve_context, split_arg_string

__all__ = ["ClickCompleter"]

IS_WINDOWS = os.name == "nt"


# Handle backwards compatibility between Click<=7.0 and >=8.0
try:
    import click.shell_completion

    HAS_CLICK_V8 = True
    AUTO_COMPLETION_PARAM = "shell_complete"
except (ImportError, ModuleNotFoundError):
    import click._bashcomplete  # type: ignore[import]

    HAS_CLICK_V8 = False
    AUTO_COMPLETION_PARAM = "autocompletion"


class ClickCompleter(Completer):
    __slots__ = ("cli", "ctx", "parsed_args", "parsed_ctx", "ctx_command")

    def __init__(
        self,
        cli: click.MultiCommand,
        ctx: click.Context,
        show_only_unused: bool = False,
        shortest_only: bool = False,
    ) -> None:
        self.cli = cli
        self.ctx = ctx
        self.parsed_args: list[str] = []
        self.parsed_ctx = ctx
        self.ctx_command = ctx.command
        self.show_only_unused = show_only_unused
        self.shortest_only = shortest_only

    def _get_completion_from_autocompletion_functions(
        self,
        param: click.Parameter,
        autocomplete_ctx: click.Context,
        args: list[str],
        incomplete: str,
    ) -> list[Completion]:
        param_choices: list[Completion] = []

        if HAS_CLICK_V8:
            autocompletions = param.shell_complete(autocomplete_ctx, incomplete)
        else:
            autocompletions = param.autocompletion(  # type: ignore[attr-defined]
                autocomplete_ctx, args, incomplete
            )

        for autocomplete in autocompletions:
            if isinstance(autocomplete, tuple):
                param_choices.append(
                    Completion(
                        str(autocomplete[0]),
                        -len(incomplete),
                        display_meta=autocomplete[1],
                    )
                )

            elif HAS_CLICK_V8 and isinstance(
                autocomplete, click.shell_completion.CompletionItem
            ):
                param_choices.append(Completion(autocomplete.value, -len(incomplete)))

            else:
                param_choices.append(Completion(str(autocomplete), -len(incomplete)))

        return param_choices

    def _get_completion_from_choices_click_le_7(
        self, param: click.Parameter, incomplete: str
    ) -> list[Completion]:
        param_type = t.cast(click.Choice, param.type)

        if not getattr(param.type, "case_sensitive", True):
            incomplete = incomplete.lower()
            return [
                Completion(
                    choice,
                    -len(incomplete),
                    display=repr(choice) if " " in choice else choice,
                )
                for choice in param_type.choices  # type: ignore[attr-defined]
                if choice.lower().startswith(incomplete)
            ]

        else:
            return [
                Completion(
                    choice,
                    -len(incomplete),
                    display=repr(choice) if " " in choice else choice,
                )
                for choice in param_type.choices  # type: ignore[attr-defined]
                if choice.startswith(incomplete)
            ]

    def _get_completion_for_Path_types(
        self, param: click.Parameter, args: list[str], incomplete: str
    ) -> list[Completion]:
        if "*" in incomplete:
            return []

        choices: list[Completion] = []
        _incomplete = os.path.expandvars(incomplete)
        search_pattern = _incomplete.strip("'\"\t\n\r\v ").replace("\\\\", "\\") + "*"
        quote = ""

        if " " in _incomplete:
            for i in incomplete:
                if i in ("'", '"'):
                    quote = i
                    break

        for path in iglob(search_pattern):
            if " " in path:
                if quote:
                    path = quote + path
                else:
                    if IS_WINDOWS:
                        path = repr(path).replace("\\\\", "\\")
            else:
                if IS_WINDOWS:
                    path = path.replace("\\", "\\\\")

            choices.append(
                Completion(
                    path,
                    -len(incomplete),
                    display=os.path.basename(path.strip("'\"")),
                )
            )

        return choices

    def _get_completion_for_Boolean_type(
        self, param: click.Parameter, incomplete: str
    ) -> list[Completion]:
        boolean_mapping: dict[str, tuple[str, ...]] = {
            "true": ("1", "true", "t", "yes", "y", "on"),
            "false": ("0", "false", "f", "no", "n", "off"),
        }

        return [
            Completion(k, -len(incomplete), display_meta="/".join(v))
            for k, v in boolean_mapping.items()
            if any(i.startswith(incomplete) for i in v)
        ]

    def _get_completion_from_params(
        self,
        autocomplete_ctx: click.Context,
        args: list[str],
        param: click.Parameter,
        incomplete: str,
    ) -> list[Completion]:
        choices: list[Completion] = []
        param_type = param.type

        # Flags (is_flag=True, incl. secondary opts like --foo/--no-foo) do not
        # consume a value, so we must not offer any value completions for them.
        # See https://github.com/click-contrib/click-repl/issues/116
        if getattr(param, "is_flag", False):
            return choices

        # shell_complete method for click.Choice was introduced in Click v8
        if not HAS_CLICK_V8 and isinstance(param_type, click.Choice):
            choices.extend(
                self._get_completion_from_choices_click_le_7(param, incomplete)
            )

        elif isinstance(param_type, click.types.BoolParamType):
            choices.extend(self._get_completion_for_Boolean_type(param, incomplete))

        elif isinstance(param_type, (click.Path, click.File)):
            choices.extend(self._get_completion_for_Path_types(param, args, incomplete))

        elif getattr(param, AUTO_COMPLETION_PARAM, None) is not None:
            choices.extend(
                self._get_completion_from_autocompletion_functions(
                    param,
                    autocomplete_ctx,
                    args,
                    incomplete,
                )
            )

        return choices

    def _get_completion_for_cmd_args(
        self,
        ctx_command: click.Command,
        incomplete: str,
        autocomplete_ctx: click.Context,
        args: list[str],
    ) -> list[Completion]:
        choices: list[Completion] = []
        param_called = False

        for param in ctx_command.params:
            if isinstance(param.type, click.types.UnprocessedParamType):
                return []

            elif getattr(param, "hidden", False):
                continue

            elif isinstance(param, click.Option):
                opts = param.opts + param.secondary_opts
                previous_args = args[: param.nargs * -1]
                current_args = args[param.nargs * -1 :]

                # Show only unused opts
                already_present = any([opt in previous_args for opt in opts])
                hide = self.show_only_unused and already_present and not param.multiple

                # Show only shortest opt
                if (
                    self.shortest_only
                    and not incomplete  # just typed a space
                    # not selecting a value for a longer version of this option
                    and (not args or args[-1] not in opts)
                ):
                    opts = [min(opts, key=len)]

                for option in opts:
                    # We want to make sure if this parameter was called
                    # If we are inside a parameter that was called, we want to show only
                    # relevant choices
                    if option in current_args:  # noqa: E203
                        param_called = True
                        break

                    elif option.startswith(incomplete) and not hide:
                        choices.append(
                            Completion(
                                option,
                                -len(incomplete),
                                display_meta=param.help or "",
                            )
                        )

                if param_called:
                    choices = self._get_completion_from_params(
                        autocomplete_ctx, args, param, incomplete
                    )
                    break

            elif isinstance(param, click.Argument):
                # Show only unused arguments. A variadic argument (nargs=-1)
                # always accepts more values, so it is never hidden.
                already_present = False
                get_source = getattr(autocomplete_ctx, "get_parameter_source", None)
                source = None
                if get_source is not None:
                    try:
                        source = get_source(param.name)
                    except Exception:
                        pass

                if source is not None:
                    already_present = getattr(source, "name", None) == "COMMANDLINE"
                else:
                    already_present = autocomplete_ctx.params.get(param.name) not in (
                        None,
                        (),
                        [],
                    )

                if self.show_only_unused and already_present and param.nargs != -1:
                    continue

                choices.extend(
                    self._get_completion_from_params(
                        autocomplete_ctx, args, param, incomplete
                    )
                )

        return choices

    def get_completions(
        self, document: Document, complete_event: CompleteEvent | None = None
    ) -> Generator[Completion, None, None]:
        # Code analogous to click._bashcomplete.do_complete

        args = split_arg_string(document.text_before_cursor, posix=False)

        choices: list[Completion] = []
        cursor_within_command = (
            document.text_before_cursor.rstrip() == document.text_before_cursor
        )

        if document.text_before_cursor.startswith(("!", ":")):
            return

        if args and cursor_within_command:
            # We've entered some text and no space, give completions for the
            # current word.
            incomplete = args.pop()
        else:
            # We've not entered anything, either at all or for the current
            # command, so give all relevant completions for this context.
            incomplete = ""

        if self.parsed_args != args:
            self.parsed_args = args
            try:
                self.parsed_ctx = _resolve_context(args, self.ctx)
            except Exception:
                return  # autocompletion for nonexistent cmd can throw here
            self.ctx_command = self.parsed_ctx.command

        if getattr(self.ctx_command, "hidden", False):
            return

        try:
            choices.extend(
                self._get_completion_for_cmd_args(
                    self.ctx_command, incomplete, self.parsed_ctx, args
                )
            )

            if isinstance(self.ctx_command, click.MultiCommand):
                incomplete_lower = incomplete.lower()

                for name in self.ctx_command.list_commands(self.parsed_ctx):
                    command = self.ctx_command.get_command(self.parsed_ctx, name)
                    if getattr(command, "hidden", False):
                        continue

                    elif name.lower().startswith(incomplete_lower):
                        choices.append(
                            Completion(
                                name,
                                -len(incomplete),
                                display_meta=getattr(command, "short_help", ""),
                            )
                        )

        except Exception as e:
            click.echo("{}: {}".format(type(e).__name__, str(e)))

        for item in choices:
            yield item
