# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, # You can obtain one at http://mozilla.org/MPL/2.0/.

import argparse
import functools
import re
import subprocess
import sys
from itertools import chain
from pathlib import Path

import attr

from mach.decorators import Command, CommandArgument, SubCommand

COMPLETION_TEMPLATES_DIR = Path(__file__).resolve().parent / "completion_templates"


@attr.s
class CommandInfo:
    name = attr.ib(type=str)
    description = attr.ib(type=str)
    subcommands = attr.ib(type=list)
    options = attr.ib(type=dict)
    subcommand = attr.ib(type=str, default=None)


def render_template(shell, context):
    filename = f"{shell}.template"
    with open(COMPLETION_TEMPLATES_DIR / filename) as fh:
        template = fh.read()
    return template % context


@functools.cache
def command_handlers(command_context):
    """A dictionary of command handlers keyed by command name."""
    return command_context._mach_context.commands.command_handlers


@functools.cache
def commands(command_context):
    """A sorted list of all command names."""
    return sorted(command_handlers(command_context))


def _get_parser_options(parser):
    options = {}
    for action in parser._actions:
        # ignore positional args
        if not action.option_strings:
            continue

        # ignore suppressed args
        if action.help == argparse.SUPPRESS:
            continue

        options[tuple(action.option_strings)] = action.help or ""
    return options


@functools.cache
def global_options(command_context):
    """Return a dict of global options.

    Of the form `{("-o", "--option"): "description"}`.
    """
    for group in command_context._mach_context.global_parser._action_groups:
        if group.title == "Global Arguments":
            return _get_parser_options(group)


@functools.cache
def _get_handler_options(handler):
    """Return a dict of options for the given handler.

    Of the form `{("-o", "--option"): "description"}`.
    """
    options = {}
    for option_strings, val in handler.arguments:
        # ignore positional args
        if option_strings[0][0] != "-":
            continue

        options[tuple(option_strings)] = val.get("help", "")

    if handler._parser:
        options.update(_get_parser_options(handler.parser))

    return options


def _get_handler_info(handler):
    try:
        options = _get_handler_options(handler)
    except (Exception, SystemExit):
        # We don't want misbehaving commands to break tab completion,
        # ignore any exceptions.
        options = {}

    subcommands = []
    for sub in sorted(handler.subcommand_handlers):
        subcommands.append(_get_handler_info(handler.subcommand_handlers[sub]))

    return CommandInfo(
        name=handler.name,
        description=handler.description or "",
        options=options,
        subcommands=subcommands,
        subcommand=handler.subcommand,
    )


@functools.cache
def commands_info(command_context):
    """Return a list of CommandInfo objects for each command."""
    commands_info = []
    # Loop over self.commands() rather than self.command_handlers().items() for
    # alphabetical order.
    for c in commands(command_context):
        commands_info.append(_get_handler_info(command_handlers(command_context)[c]))
    return commands_info


@Command("mach-commands", category="misc", description="List all mach commands.")
def run_commands(command_context):
    print("\n".join(commands(command_context)))


@Command(
    "mach-debug-commands",
    category="misc",
    description="Show info about available mach commands.",
)
@CommandArgument(
    "match",
    metavar="MATCH",
    default=None,
    nargs="?",
    help="Only display commands containing given substring.",
)
def run_debug_commands(command_context, match=None):
    import inspect

    for command, handler in command_handlers(command_context).items():
        if match and match not in command:
            continue

        func = handler.func

        print(command)
        print("=" * len(command))
        print("")
        print("File: %s" % inspect.getsourcefile(func))
        print("Function: %s" % func.__name__)
        print("")


@Command(
    "mach-completion",
    category="misc",
    description="Prints a list of completion strings for the specified command.",
)
@CommandArgument(
    "args", default=None, nargs=argparse.REMAINDER, help="Command to complete."
)
def run_completion(command_context, args):
    if not args:
        print("\n".join(commands(command_context)))
        return

    is_help = "help" in args
    command = None
    for i, arg in enumerate(args):
        if arg in commands(command_context):
            command = arg
            args = args[i + 1 :]
            break

    # If no command is typed yet, just offer the commands.
    if not command:
        print("\n".join(commands(command_context)))
        return

    handler = command_handlers(command_context)[command]
    # If a subcommand was typed, update the handler.
    for arg in args:
        if arg in handler.subcommand_handlers:
            handler = handler.subcommand_handlers[arg]
            break

    targets = sorted(handler.subcommand_handlers.keys())
    if is_help:
        print("\n".join(targets))
        return

    targets.append("help")
    targets.extend(chain(*_get_handler_options(handler).keys()))
    print("\n".join(targets))


def _zsh_describe(value, description=None):
    value = '"' + value.replace(":", "\\:")
    if description:
        description = subprocess.list2cmdline([
            re.sub(r'(["\'#&;`|*?~<>^()\[\]{}$\\\x0A\xFF])', r"\\\1", description)
        ]).lstrip('"')

        if description.endswith('"') and not description.endswith(r"\""):
            description = description[:-1]

        value += f":{description}"

    value += '"'

    return value


@SubCommand(
    "mach-completion",
    "bash",
    description="Print mach completion script for bash shell",
)
@CommandArgument(
    "-f",
    "--file",
    dest="outfile",
    default=None,
    help="File path to save completion script.",
)
def completion_bash(command_context, outfile):
    # Note: Make sure to generate a Bash 3-compatible script, because macOS
    # ships with Bash 3 by default. One of the consequences is that Bash 4
    # features such as associative arrays ("declare -A") cannot be used.
    # For example, while completion_zsh uses the associative array syntax for
    # commands_subcommands, here we use case/esac instead.
    case_commands_subcommands = []
    case_options = []
    case_subcommands = []
    for i, cmd in enumerate(commands_info(command_context)):
        # Build case statement for options.
        options = []
        for opt_strs, description in cmd.options.items():
            for opt in opt_strs:
                options.append(_zsh_describe(opt, None).strip('"'))

        if options:
            case_options.append(
                "\n".join([
                    f"            ({cmd.name})",
                    '            opts="${{opts}} {}"'.format(" ".join(options)),
                    "            ;;",
                    "",
                ])
            )

        # Build case statement for subcommand options.
        for sub in cmd.subcommands:
            options = []
            for opt_strs, description in sub.options.items():
                for opt in opt_strs:
                    options.append(_zsh_describe(opt, None))

            if options:
                case_options.append(
                    "\n".join([
                        f'            ("{sub.name} {sub.subcommand}")',
                        '            opts="${{opts}} {}"'.format(" ".join(options)),
                        "            ;;",
                        "",
                    ])
                )

        # Build case statement for subcommands.
        subcommands = [_zsh_describe(s.subcommand, None) for s in cmd.subcommands]
        if subcommands:
            comsubs = " ".join([h.subcommand for h in cmd.subcommands])
            case_commands_subcommands.append(
                "\n".join([
                    f"            ({cmd.name})",
                    f'            comsubs=" {comsubs} "',
                    "            ;;",
                    "",
                ])
            )

            case_subcommands.append(
                "\n".join([
                    f"            ({cmd.name})",
                    '            subs="${{subs}} {}"'.format(" ".join(subcommands)),
                    "            ;;",
                    "",
                ])
            )

    globalopts = [
        opt for opt_strs in global_options(command_context) for opt in opt_strs
    ]
    context = {
        "case_commands_subcommands": "\n".join(sorted(case_commands_subcommands)),
        "case_options": "\n".join(case_options),
        "case_subcommands": "\n".join(case_subcommands),
        "commands": " ".join(commands(command_context)),
        "globalopts": " ".join(sorted(globalopts)),
    }

    outfile = open(outfile, "w") if outfile else sys.stdout
    print(render_template("bash", context), file=outfile)


@SubCommand(
    "mach-completion",
    "zsh",
    description="Print mach completion script for zsh shell",
)
@CommandArgument(
    "-f",
    "--file",
    dest="outfile",
    default=None,
    help="File path to save completion script.",
)
def completion_zsh(command_context, outfile):
    commands_descriptions = []
    commands_subcommands = []
    case_options = []
    case_subcommands = []
    for i, cmd in enumerate(commands_info(command_context)):
        commands_descriptions.append(_zsh_describe(cmd.name, cmd.description))

        # Build case statement for options.
        options = []
        for opt_strs, description in cmd.options.items():
            for opt in opt_strs:
                options.append(_zsh_describe(opt, description))

        if options:
            case_options.append(
                "\n".join([
                    f"            ({cmd.name})",
                    "            opts+=({})".format(" ".join(options)),
                    "            ;;",
                    "",
                ])
            )

        # Build case statement for subcommand options.
        for sub in cmd.subcommands:
            options = []
            for opt_strs, description in sub.options.items():
                for opt in opt_strs:
                    options.append(_zsh_describe(opt, description))

            if options:
                case_options.append(
                    "\n".join([
                        f"            ({sub.name} {sub.subcommand})",
                        "            opts+=({})".format(" ".join(options)),
                        "            ;;",
                        "",
                    ])
                )

        # Build case statement for subcommands.
        subcommands = [
            _zsh_describe(s.subcommand, s.description) for s in cmd.subcommands
        ]
        if subcommands:
            commands_subcommands.append(
                '[{}]=" {} "'.format(
                    cmd.name, " ".join([h.subcommand for h in cmd.subcommands])
                )
            )

            case_subcommands.append(
                "\n".join([
                    f"            ({cmd.name})",
                    "            subs+=({})".format(" ".join(subcommands)),
                    "            ;;",
                    "",
                ])
            )

    globalopts = []
    for opt_strings, description in global_options(command_context).items():
        for opt in opt_strings:
            globalopts.append(_zsh_describe(opt, description))

    context = {
        "case_options": "\n".join(case_options),
        "case_subcommands": "\n".join(case_subcommands),
        "commands": " ".join(sorted(commands_descriptions)),
        "commands_subcommands": " ".join(sorted(commands_subcommands)),
        "globalopts": " ".join(sorted(globalopts)),
    }

    outfile = open(outfile, "w") if outfile else sys.stdout
    print(render_template("zsh", context), file=outfile)


@SubCommand(
    "mach-completion",
    "fish",
    description="Print mach completion script for fish shell",
)
@CommandArgument(
    "-f",
    "--file",
    dest="outfile",
    default=None,
    help="File path to save completion script.",
)
def completion_fish(command_context, outfile):
    def _append_opt_strs(comp, opt_strs):
        for opt in opt_strs:
            if opt.startswith("--"):
                comp += f" -l {opt[2:]}"
            elif opt.startswith("-"):
                comp += f" -s {opt[1:]}"
        return comp

    globalopts = []
    for opt_strs, description in global_options(command_context).items():
        comp = "complete -c mach -n '__fish_mach_complete_no_command' -d '{}'".format(
            description.replace("'", "\\'")
        )
        comp = _append_opt_strs(comp, opt_strs)
        globalopts.append(comp)

    cmds = []
    cmds_opts = []
    for i, cmd in enumerate(commands_info(command_context)):
        cmds.append(
            "complete -c mach -f -n '__fish_mach_complete_no_command' "
            "-a {} -d '{}'".format(cmd.name, cmd.description.replace("'", "\\'"))
        )

        cmds_opts += [f"# {cmd.name}"]

        subcommands = " ".join([s.subcommand for s in cmd.subcommands])
        for opt_strs, description in cmd.options.items():
            comp = (
                "complete -c mach -A -n '__fish_mach_complete_command {} {}' "
                "-d '{}'".format(cmd.name, subcommands, description.replace("'", "\\'"))
            )
            comp = _append_opt_strs(comp, opt_strs)
            cmds_opts.append(comp)

        for sub in cmd.subcommands:
            for opt_strs, description in sub.options.items():
                comp = (
                    "complete -c mach -A -n '__fish_mach_complete_subcommand {} {}' "
                    "-d '{}'".format(
                        sub.name, sub.subcommand, description.replace("'", "\\'")
                    )
                )
                comp = _append_opt_strs(comp, opt_strs)
                cmds_opts.append(comp)

            description = sub.description or ""
            description = description.replace("'", "\\'")
            comp = (
                f"complete -c mach -A -n '__fish_mach_complete_command {cmd.name} {subcommands}' "
                f"-d '{description}' -a {sub.subcommand}"
            )
            cmds_opts.append(comp)

        if i < len(commands(command_context)) - 1:
            cmds_opts.append("")

    context = {
        "commands": " ".join(commands(command_context)),
        "command_completions": "\n".join(cmds),
        "command_option_completions": "\n".join(cmds_opts),
        "global_option_completions": "\n".join(globalopts),
    }

    outfile = open(outfile, "w") if outfile else sys.stdout
    print(render_template("fish", context), file=outfile)
