#!/usr/bin/env python

# 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 json
import os
import shutil
import sys
from collections import namedtuple
from subprocess import check_output

import mozinfo
import mozshellutil

__all__ = [
    "get_debugger_info",
    "get_default_debugger_name",
    "DebuggerSearch",
    "get_default_valgrind_args",
    "DebuggerInfo",
    "prepend_debugger_args",
]

"""
Map of debugging programs to information about them, like default arguments
and whether or not they are interactive.

To add support for a new debugger, simply add the relative entry in
_DEBUGGER_INFO and optionally update the _DEBUGGER_PRIORITIES.
"""
_DEBUGGER_INFO = {
    # gdb requires that you supply the '--args' flag in order to pass arguments
    # after the executable name to the executable.
    "gdb": {"interactive": True, "args": ["-q", "--args"]},
    "cgdb": {"interactive": True, "args": ["-q", "--args"]},
    "rust-gdb": {"interactive": True, "args": ["-q", "--args"]},
    "lldb": {"interactive": True, "args": ["--"], "requiresEscapedArgs": True},
    # Visual Studio Debugger Support.
    "devenv.exe": {"interactive": True, "args": ["-debugexe"]},
    # Visual C++ Express Debugger Support.
    "wdexpress.exe": {"interactive": True, "args": ["-debugexe"]},
    # Windows Development Kit super-debugger.
    "windbg.exe": {
        "interactive": True,
    },
}

# Maps each OS platform to the preferred debugger programs found in _DEBUGGER_INFO.
_DEBUGGER_PRIORITIES = {
    "win": ["devenv.exe", "wdexpress.exe"],
    "linux": ["gdb", "cgdb", "lldb"],
    "mac": ["lldb", "gdb"],
    "android": ["lldb"],
    "unknown": ["gdb"],
}


DebuggerInfo = namedtuple(
    "DebuggerInfo", ["path", "interactive", "args", "requiresEscapedArgs"]
)


def _windbg_installation_paths():
    programFilesSuffixes = ["", " (x86)"]
    programFiles = "C:/Program Files"
    # Try the most recent versions first.
    windowsKitsVersions = ["10", "8.1", "8"]

    for suffix in programFilesSuffixes:
        windowsKitsPrefix = os.path.join(programFiles + suffix, "Windows Kits")
        for version in windowsKitsVersions:
            yield os.path.join(
                windowsKitsPrefix, version, "Debuggers", "x64", "windbg.exe"
            )


def _vswhere_path():
    try:
        import buildconfig

        path = os.path.join(buildconfig.topsrcdir, "build", "win32", "vswhere.exe")
        if os.path.isfile(path):
            return path
    except ImportError:
        pass
    # Hope it's available on PATH!
    return "vswhere.exe"


def get_debugger_path(debugger):
    """
    Get the full path of the debugger.

    :param debugger: The name of the debugger.
    """

    if mozinfo.os == "mac" and debugger == "lldb":
        # On newer OSX versions System Integrity Protections prevents us from
        # setting certain env vars for a process such as DYLD_LIBRARY_PATH if
        # it's in a protected directory such as /usr/bin. This is the case for
        # lldb, so we try to find an instance under the Xcode install instead.

        # Attempt to use the xcrun util to find the path.
        try:
            path = check_output(
                ["xcrun", "--find", "lldb"], universal_newlines=True
            ).strip()
            if path:
                return path
        except Exception:
            # Just default to shutil.which instead.
            pass

    if mozinfo.os == "win" and debugger == "devenv.exe":
        # Attempt to use vswhere to find the path.
        try:
            encoding = "mbcs" if sys.platform == "win32" else "utf-8"
            vswhere = _vswhere_path()
            vsinfo = check_output([vswhere, "-format", "json", "-latest"])
            vsinfo = json.loads(vsinfo.decode(encoding, "replace"))
            return os.path.join(
                vsinfo[0]["installationPath"], "Common7", "IDE", "devenv.exe"
            )
        except Exception:
            # Just default to shutil.which instead.
            pass

    return shutil.which(debugger)


def get_debugger_info(debugger, debuggerArgs=None, debuggerInteractive=False):
    """
    Get the information about the requested debugger.

    Returns a dictionary containing the ``path`` of the debugger executable,
    if it will run in ``interactive`` mode, its arguments and whether it needs
    to escape arguments it passes to the debugged program (``requiresEscapedArgs``).
    If the debugger cannot be found in the system, returns ``None``.

    :param debugger: The name of the debugger.
    :param debuggerArgs: If specified, it's the arguments to pass to the debugger,
       as a string. This string will get split using shell-like syntax so that
       arguments can contain spaces, e.g. passing "-ex 'show args'" really means a
       first argument of "-ex" followed by a second argument of "show args". Any
       debugger-specific separator arguments are appended after these arguments.
    :param debuggerInteractive: If specified, forces the debugger to be interactive.
    """

    debuggerPath = None

    if debugger:
        # Append '.exe' to the debugger on Windows if it's not present,
        # so things like '--debugger=devenv' work.
        if os.name == "nt" and not debugger.lower().endswith(".exe"):
            debugger += ".exe"

        debuggerPath = get_debugger_path(debugger)

    if not debuggerPath:
        # windbg is not installed with the standard set of tools, and it's
        # entirely possible that the user hasn't added the install location to
        # PATH, so we have to be a little more clever than normal to locate it.
        # Just try to look for it in the standard installed location(s).
        if debugger == "windbg.exe":
            for candidate in _windbg_installation_paths():
                if os.path.exists(candidate):
                    debuggerPath = candidate
                    break
        elif os.path.exists(debugger):
            debuggerPath = debugger

    if not debuggerPath:
        print(f"Error: Could not find debugger {debugger!s}.")
        print("Is it installed? Is it in your PATH?")
        return None

    debuggerName = os.path.basename(debuggerPath).lower()

    def _get_debugger_info(type, default):
        if debuggerName in _DEBUGGER_INFO and type in _DEBUGGER_INFO[debuggerName]:
            return _DEBUGGER_INFO[debuggerName][type]
        return default

    # Define a namedtuple to access the debugger information from the outside world.
    debugger_arguments = []

    if debuggerArgs:
        try:
            # Append the provided debugger arguments at the start of the arguments list.
            debugger_arguments += mozshellutil.split(debuggerArgs)
        except mozshellutil.MetaCharacterException as e:
            print(
                "Error: The --debugger-args you passed require a real shell to parse them."
            )
            print(f"(We can't handle the {e.char!r} character.)")
            return None

    debugger_arguments += _get_debugger_info("args", [])

    # Override the default debugger interactive mode if needed.
    debugger_interactive = _get_debugger_info("interactive", False)
    if debuggerInteractive:
        debugger_interactive = debuggerInteractive

    d = DebuggerInfo(
        debuggerPath,
        debugger_interactive,
        debugger_arguments,
        _get_debugger_info("requiresEscapedArgs", False),
    )

    return d


# Defines the search policies to use in get_default_debugger_name.


class DebuggerSearch:
    OnlyFirst = 1
    KeepLooking = 2


def get_default_debugger_name(search=DebuggerSearch.OnlyFirst):
    """
    Get the debugger name for the default debugger on current platform.

    :param search: If specified, stops looking for the debugger if the
     default one is not found (``DebuggerSearch.OnlyFirst``) or keeps
     looking for other compatible debuggers (``DebuggerSearch.KeepLooking``).
    """

    mozinfo.find_and_update_from_json()
    os = mozinfo.info["os"]

    # Find out which debuggers are preferred for use on this platform.
    debuggerPriorities = _DEBUGGER_PRIORITIES[
        os if os in _DEBUGGER_PRIORITIES else "unknown"
    ]

    # Finally get the debugger information.
    for debuggerName in debuggerPriorities:
        debuggerPath = get_debugger_path(debuggerName)
        if debuggerPath:
            return debuggerName
        elif not search == DebuggerSearch.KeepLooking:
            return None

    return None


# Defines default values for Valgrind flags.
#
# --smc-check=all-non-file is required to deal with code generation and
#   patching by the various JITS.  Note that this is only necessary on
#   x86 and x86_64, but not on ARM.  This flag is only necessary for
#   Valgrind versions prior to 3.11.
#
# --vex-iropt-register-updates=allregs-at-mem-access is required so that
#   Valgrind generates correct register values whenever there is a
#   segfault that is caught and handled.  In particular OdinMonkey
#   requires this.  More recent Valgrinds (3.11 and later) provide
#   --px-default=allregs-at-mem-access and
#   --px-file-backed=unwindregs-at-mem-access
#   which provide a significantly cheaper alternative, by restricting the
#   precise exception behaviour to JIT generated code only.
#
# --trace-children=yes is required to get Valgrind to follow into
#   content and other child processes.  The resulting output can be
#   difficult to make sense of, and --child-silent-after-fork=yes
#   helps by causing Valgrind to be silent for the child in the period
#   after fork() but before its subsequent exec().
#
# --trace-children-skip lists processes that we are not interested
#   in tracing into.
#
# --leak-check=full requests full stack traces for all leaked blocks
#   detected at process exit.
#
# --show-possibly-lost=no requests blocks for which only an interior
#   pointer was found to be considered not leaked.
#
#
# TODO: pass in the user supplied args for V (--valgrind-args=) and
# use this to detect if a different tool has been selected.  If so
# adjust tool-specific args appropriately.
#
# TODO: pass in the path to the Valgrind to be used (--valgrind=), and
# check what flags it accepts.  Possible args that might be beneficial:
#
# --num-transtab-sectors=24   [reduces re-jitting overheads in long runs]
# --px-default=allregs-at-mem-access
# --px-file-backed=unwindregs-at-mem-access
#                             [these reduce PX overheads as described above]
#


def get_default_valgrind_args():
    return [
        "--fair-sched=yes",
        "--smc-check=all-non-file",
        "--vex-iropt-register-updates=allregs-at-mem-access",
        "--trace-children=yes",
        "--child-silent-after-fork=yes",
        (
            "--trace-children-skip="
            + "/usr/bin/hg,/bin/rm,*/bin/certutil,*/bin/pk12util,"
            + "*/bin/ssltunnel,*/bin/uname,*/bin/which,*/bin/ps,"
            + "*/bin/grep,*/bin/java,*/bin/lsb_release"
        ),
    ] + get_default_valgrind_tool_specific_args()


# The default tool is Memcheck.  Feeding these arguments to a different
# Valgrind tool will cause it to fail at startup, so don't do that!


def get_default_valgrind_tool_specific_args():
    return [
        "--partial-loads-ok=yes",
        "--leak-check=summary",
        "--show-possibly-lost=no",
        "--show-mismatched-frees=no",
    ]


def prepend_debugger_args(args, debugger, debugger_args):
    """
    Given an array with program arguments, prepend arguments to run it under a
    debugger.

    :param args: The executable and arguments used to run the process normally.
    :param debugger: The debugger to use, or empty to use the default debugger.
    :param debugger_args: Any additional parameters to pass to the debugger, as
     a string using shell-like syntax.
    """
    if not debugger:
        # No debugger name was provided. Look for the default ones on
        # current OS.
        debugger = get_default_debugger_name(DebuggerSearch.KeepLooking)

    if debugger:
        debuggerInfo = get_debugger_info(debugger, debugger_args)

    if not debugger or not debuggerInfo:
        print("Could not find a suitable debugger in your PATH.")
        return None

    return [debuggerInfo.path] + debuggerInfo.args + args
