# 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 logging
import os
import subprocess
import sys

import mozpack.path as mozpath
from mach.decorators import Command, CommandArgument
from mozfile import which

from mozbuild import build_commands


@Command(
    "ide",
    category="devenv",
    description="Generate a project and launch an IDE.",
    virtualenv_name="build",
)
@CommandArgument("ide", choices=["eclipse", "visualstudio", "vscode"])
@CommandArgument(
    "--no-interactive",
    default=False,
    action="store_true",
    help="Just generate the configuration",
)
@CommandArgument("args", nargs=argparse.REMAINDER)
def run(command_context, ide, no_interactive, args):
    interactive = not no_interactive

    if ide == "eclipse":
        backend = "CppEclipse"
    elif ide == "visualstudio":
        backend = "VisualStudio"
    elif ide == "vscode":
        backend = "Clangd"

    if ide == "eclipse" and not which("eclipse"):
        command_context.log(
            logging.ERROR,
            "ide",
            {},
            "Eclipse CDT 8.4 or later must be installed in your PATH.",
        )
        command_context.log(
            logging.ERROR,
            "ide",
            {},
            "Download: http://www.eclipse.org/cdt/downloads.php",
        )
        return 1

    if ide == "vscode":
        rc = build_commands.configure(command_context)

        if rc != 0:
            return rc

        # First install what we can through install manifests.
        rc = command_context._run_make(
            directory=command_context.topobjdir,
            target="pre-export",
            line_handler=None,
        )
        if rc != 0:
            return rc

        # Then build the rest of the build dependencies by running the full
        # export target, because we can't do anything better.
        for target in ("export", "pre-compile"):
            rc = command_context._run_make(
                directory=command_context.topobjdir,
                target=target,
                line_handler=None,
            )
            if rc != 0:
                return rc
    else:
        # Here we refresh the whole build. 'build export' is sufficient here and is
        # probably more correct but it's also nice having a single target to get a fully
        # built and indexed project (gives a easy target to use before go out to lunch).
        res = command_context._mach_context.commands.dispatch(
            "build", command_context._mach_context
        )
        if res != 0:
            return 1

    # Generate or refresh the IDE backend.
    python = command_context.virtualenv_manager.python_path
    config_status = os.path.join(command_context.topobjdir, "config.status")
    args = [python, config_status, "--backend=%s" % backend]
    res = command_context._run_command_in_objdir(
        args=args, pass_thru=True, ensure_exit_code=False
    )
    if res != 0:
        return 1

    if ide == "eclipse":
        eclipse_workspace_dir = get_eclipse_workspace_path(command_context)
        subprocess.check_call(["eclipse", "-data", eclipse_workspace_dir])
    elif ide == "visualstudio":
        visual_studio_workspace_dir = get_visualstudio_workspace_path(command_context)
        subprocess.call(["explorer.exe", visual_studio_workspace_dir])
    elif ide == "vscode":
        return setup_vscode(command_context, interactive)


def get_eclipse_workspace_path(command_context):
    from mozbuild.backend.cpp_eclipse import CppEclipseBackend

    return CppEclipseBackend.get_workspace_path(
        command_context.topsrcdir, command_context.topobjdir
    )


def get_visualstudio_workspace_path(command_context):
    return os.path.normpath(
        os.path.join(command_context.topobjdir, "msvc", "mozilla.sln")
    )


def setup_vscode(command_context, interactive):
    from mozbuild.backend.clangd import find_vscode_cmd

    # Check if platform has VSCode installed
    if interactive:
        vscode_cmd = find_vscode_cmd()
        if vscode_cmd is None:
            choice = prompt_bool(
                "VSCode cannot be found, and may not be installed. Proceed?"
            )
            if not choice:
                return 1

    vscode_settings = mozpath.join(
        command_context.topsrcdir, ".vscode", "settings.json"
    )

    new_settings = {}
    artifact_prefix = ""
    if command_context.config_environment.is_artifact_build:
        artifact_prefix = (
            "\nArtifact build configured: Skipping clang and rust setup. "
            "If you later switch to a full build, please re-run this command."
        )
    else:
        new_settings = setup_clangd_rust_in_vscode(command_context)

    # Add file associations.
    new_settings = {
        **new_settings,
        "files.associations": {
            "*.jsm": "javascript",
            "*.sjs": "javascript",
        },
        # Note, the top-level editor settings are left as default to allow the
        # user's defaults (if any) to take effect.
        "[javascript][javascriptreact][typescript][typescriptreact][json][html]": {
            "editor.defaultFormatter": "esbenp.prettier-vscode",
            "editor.formatOnSave": True,
        },
    }

    import difflib
    import json

    # Load the existing .vscode/settings.json file, to check if if needs to
    # be created or updated.
    try:
        with open(vscode_settings) as fh:
            old_settings_str = fh.read()
    except FileNotFoundError:
        print(
            "Configuration for {} will be created.{}".format(
                vscode_settings, artifact_prefix
            )
        )
        old_settings_str = None

    if old_settings_str is None:
        # No old settings exist
        with open(vscode_settings, "w") as fh:
            json.dump(new_settings, fh, indent=4)
    else:
        # Merge our new settings with the existing settings, and check if we
        # need to make changes. Only prompt & write out the updated config
        # file if settings actually changed.
        try:
            old_settings = json.loads(old_settings_str)
            prompt_prefix = ""
        except ValueError:
            old_settings = {}
            prompt_prefix = (
                "\n**WARNING**: Parsing of existing settings file failed. "
                "Existing settings will be lost!"
            )

        # If we've got an old section with the formatting configuration, remove it
        # so that we effectively "upgrade" the user to include json from the new
        # settings. The user is presented with the diffs so should spot any issues.
        if "[javascript][javascriptreact][typescript][typescriptreact]" in old_settings:
            old_settings.pop(
                "[javascript][javascriptreact][typescript][typescriptreact]"
            )
        if (
            "[javascript][javascriptreact][typescript][typescriptreact][json]"
            in old_settings
        ):
            old_settings.pop(
                "[javascript][javascriptreact][typescript][typescriptreact][json]"
            )

        settings = {**old_settings, **new_settings}

        if old_settings != settings:
            # Prompt the user with a diff of the changes we're going to make
            new_settings_str = json.dumps(settings, indent=4)
            if interactive:
                print(
                    "\nThe following modifications to {settings} will occur:\n{diff}".format(
                        settings=vscode_settings,
                        diff="".join(
                            difflib.unified_diff(
                                old_settings_str.splitlines(keepends=True),
                                new_settings_str.splitlines(keepends=True),
                                "a/.vscode/settings.json",
                                "b/.vscode/settings.json",
                                n=30,
                            )
                        ),
                    )
                )
                choice = prompt_bool(
                    "{}{}\nProceed with modifications to {}?".format(
                        artifact_prefix, prompt_prefix, vscode_settings
                    )
                )
                if not choice:
                    return 1

            with open(vscode_settings, "w") as fh:
                fh.write(new_settings_str)

    if not interactive:
        return 0

    # Open vscode with new configuration, or ask the user to do so if the
    # binary was not found.
    if vscode_cmd is None:
        print(
            "Please open VS Code manually and load directory: {}".format(
                command_context.topsrcdir
            )
        )
        return 0

    rc = subprocess.call(vscode_cmd + [command_context.topsrcdir])

    if rc != 0:
        command_context.log(
            logging.ERROR,
            "ide",
            {},
            "Unable to open VS Code. Please open VS Code manually and load "
            "directory: {}".format(command_context.topsrcdir),
        )
        return rc

    return 0


def setup_clangd_rust_in_vscode(command_context):
    clangd_cc_path = mozpath.join(command_context.topobjdir, "clangd")

    # Verify if the required files are present
    clang_tools_path = mozpath.join(
        command_context._mach_context.state_dir, "clang-tools"
    )
    clang_tidy_bin = mozpath.join(clang_tools_path, "clang-tidy", "bin")

    clangd_path = mozpath.join(
        clang_tidy_bin,
        "clangd" + command_context.config_environment.substs.get("BIN_SUFFIX", ""),
    )

    if not os.path.exists(clangd_path):
        command_context.log(
            logging.ERROR,
            "ide",
            {},
            "Unable to locate clangd in {}.".format(clang_tidy_bin),
        )
        rc = get_clang_tools(command_context, clang_tools_path)

        if rc != 0:
            return rc

    import multiprocessing

    from mozbuild.code_analysis.utils import ClangTidyConfig

    clang_tidy_cfg = ClangTidyConfig(command_context.topsrcdir)

    if sys.platform == "win32":
        cargo_check_command = [sys.executable, "mach"]
    else:
        cargo_check_command = ["./mach"]

    cargo_check_command += [
        "--log-no-times",
        "cargo",
        "check",
        "-j",
        str(multiprocessing.cpu_count() // 2),
        "--all-crates",
        "--message-format-json",
    ]

    clang_tidy = {}
    clang_tidy["Checks"] = ",".join(clang_tidy_cfg.checks)
    clang_tidy.update(clang_tidy_cfg.checks_config)

    # Write .clang-tidy yml
    import yaml

    with open(".clang-tidy", "w") as file:
        yaml.dump(clang_tidy, file)

    clangd_cfg = {
        "CompileFlags": {
            "CompilationDatabase": clangd_cc_path,
        }
    }

    with open(".clangd", "w") as file:
        yaml.dump(clangd_cfg, file)

    return {
        "clangd.path": clangd_path,
        "clangd.arguments": [
            "-j",
            str(multiprocessing.cpu_count() // 2),
            "--limit-results",
            "0",
            "--completion-style",
            "detailed",
            "--background-index",
            "--all-scopes-completion",
            "--log",
            "info",
            "--pch-storage",
            "disk",
            "--clang-tidy",
        ],
        "rust-analyzer.server.extraEnv": {
            # Point rust-analyzer at the real target directory used by our
            # build, so it can discover the files created when we run `./mach
            # cargo check`.
            "CARGO_TARGET_DIR": command_context.topobjdir,
        },
        "rust-analyzer.cargo.buildScripts.overrideCommand": cargo_check_command,
        "rust-analyzer.check.overrideCommand": cargo_check_command,
    }


def get_clang_tools(command_context, clang_tools_path):
    import shutil

    if os.path.isdir(clang_tools_path):
        shutil.rmtree(clang_tools_path)

    # Create base directory where we store clang binary
    os.mkdir(clang_tools_path)

    from mozbuild.artifact_commands import artifact_toolchain

    job, _ = command_context.platform

    if job is None:
        command_context.log(
            logging.ERROR,
            "ide",
            {},
            "The current platform isn't supported. "
            "Currently only the following platforms are "
            "supported: win32/win64, linux64 and macosx64.",
        )
        return 1

    job += "-clang-tidy"

    # We want to unpack data in the clang-tidy mozbuild folder
    currentWorkingDir = os.getcwd()
    os.chdir(clang_tools_path)
    rc = artifact_toolchain(
        command_context, verbose=False, from_build=[job], no_unpack=False, retry=0
    )
    # Change back the cwd
    os.chdir(currentWorkingDir)

    return rc


def prompt_bool(prompt, limit=5):
    """Prompts the user with prompt and requires a boolean value."""
    from distutils.util import strtobool

    for _ in range(limit):
        try:
            return strtobool(input(prompt + " [Y/N]\n"))
        except ValueError:
            print(
                "ERROR! Please enter a valid option! Please use any of the following:"
                " Y, N, True, False, 1, 0"
            )
    return False
