# coding=utf-8
#
# 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 https://mozilla.org/MPL/2.0/.

"""Check whether a testcase causes an interesting result in a shell.
"""

from copy import deepcopy
import io
from optparse import OptionParser  # pylint: disable=deprecated-module
import os
from pathlib import Path
import platform
from shlex import quote
import shutil
import subprocess
import sys
import zipfile

from FTB.ProgramConfiguration import ProgramConfiguration
import FTB.Signatures.CrashInfo as Crash_Info
import lithium.interestingness.timed_run as timedrun

from . import inspect_shell
from ..util import create_collector
from ..util import file_manipulation
from ..util import os_ops

# Levels of unhappiness.
# These are in order from "most expected to least expected" rather than "most ok to worst".
# Fuzzing will note the level, and pass it to Lithium.
# Lithium is allowed to go to a higher level.
JS_LEVELS = 6
JS_LEVEL_NAMES = [
    "fine",
    "jsfunfuzz did not finish",
    "jsfunfuzz decided to exit",
    "overall mismatch",
    "valgrind error",
    "new assert or crash",
]
assert len(JS_LEVEL_NAMES) == JS_LEVELS
(
    JS_FINE,
    JS_DID_NOT_FINISH,                  # correctness (only jsfunfuzzLevel)
    JS_DECIDED_TO_EXIT,                 # correctness (only jsfunfuzzLevel)
    JS_OVERALL_MISMATCH,                # correctness (only compare_jit)
    JS_VG_AMISS,                        # memory safety
    JS_NEW_ASSERT_OR_CRASH,             # memory safety or other issue that is definitely a bug
) = range(JS_LEVELS)


gOptions = ""  # pylint: disable=invalid-name
VALGRIND_ERROR_EXIT_CODE = 77


class ShellResult:  # pylint: disable=missing-docstring,too-many-instance-attributes,too-few-public-methods
    # options dict should include: timeout, knownPath, collector, valgrind, shellIsDeterministic
    def __init__(self, options, runthis, logPrefix, in_compare_jit, env=None):  # pylint: disable=too-complex
        # pylint: disable=too-many-arguments,too-many-branches,too-many-locals,too-many-statements

        # If Lithium uses this as an interestingness test, logPrefix is likely not a Path object, so make it one.
        logPrefix = Path(logPrefix)
        pathToBinary = runthis[0].expanduser().resolve()  # pylint: disable=invalid-name
        # This relies on the shell being a local one from compile_shell:
        # Ignore trailing ".exe" in Win, also abspath makes it work w/relative paths like "./js"
        # pylint: disable=invalid-name
        assert pathToBinary.with_suffix(".fuzzmanagerconf").is_file()
        pc = ProgramConfiguration.fromBinary(str(pathToBinary.parent / pathToBinary.stem))
        pc.addProgramArguments(runthis[1:-1])

        if options.valgrind:
            runthis = (
                inspect_shell.constructVgCmdList(errorCode=VALGRIND_ERROR_EXIT_CODE) +
                [f"--suppressions={filename}" for filename in "valgrind_suppressions.txt"] +
                runthis)

        timed_run_kw = {"env": (env or deepcopy(os.environ))}

        # Enable LSan which is enabled with non-ARM64 simulator ASan, only on Linux
        if platform.system() == "Linux" and inspect_shell.queryBuildConfiguration(options.jsengine, "asan"):
            env_asan_options = "detect_leaks=1,"
            env_lsan_options = "max_leaks=1,"
            if inspect_shell.queryBuildConfiguration(options.jsengine, "arm64-simulator"):
                env_asan_options = ""
                env_lsan_options = ""
            timed_run_kw["env"].update({"ASAN_OPTIONS": env_asan_options})
            timed_run_kw["env"].update({"LSAN_OPTIONS": env_lsan_options})
        elif not platform.system() == "Windows":
            timed_run_kw["preexec_fn"] = set_ulimit

        pc.addEnvironmentVariables(dict(timed_run_kw["env"]))

        lithium_logPrefix = str(logPrefix).encode("utf-8")
        if isinstance(lithium_logPrefix, b"".__class__):
            lithium_logPrefix = lithium_logPrefix.decode("utf-8", errors="replace")

        # logPrefix should be a string for timed_run in Lithium version 0.2.1 to work properly, apparently
        runinfo = timedrun.timed_run(
            [str(x) for x in runthis],  # Convert all Paths/bytes to strings for Lithium
            options.timeout,
            lithium_logPrefix,
            **timed_run_kw)

        lev = JS_FINE
        is_oom = False
        issues = []
        auxCrashData = []  # pylint: disable=invalid-name

        # FuzzManager expects a list of strings rather than an iterable, so bite the
        # bullet and "readlines" everything into memory.
        # Collector adds newlines later, see https://git.io/fjoMB
        out_log = (logPrefix.parent / f"{logPrefix.stem}-out").with_suffix(".txt")
        with io.open(str(out_log), "r", encoding="utf-8", errors="replace") as f:
            out = [line.rstrip() for line in f]
        err_log = (logPrefix.parent / f"{logPrefix.stem}-err").with_suffix(".txt")
        with io.open(str(err_log), "r", encoding="utf-8", errors="replace") as f:
            err = [line.rstrip() for line in f]

        for line in reversed(err):
            if "[unhandlable oom]" in line:
                print("Ignoring unhandlable oom...")
                is_oom = True
                break

        if is_oom:
            lev = JS_FINE
            crash_log = (logPrefix.parent / f"{logPrefix.stem}-crash").with_suffix(".txt")
            core_file = logPrefix.parent / f"{logPrefix.stem}-core"
            if crash_log.is_file():
                crash_log.unlink()
            if core_file.is_file():
                core_file.unlink()

            dbggr_cmd = os_ops.make_dbg_cmd(runthis[0], runinfo.pid)
            if dbggr_cmd:
                core_file = Path(dbggr_cmd[-1])
                if core_file.is_file():
                    core_file.unlink()
        elif options.valgrind and runinfo.return_code == VALGRIND_ERROR_EXIT_CODE:
            issues.append("valgrind reported an error")
            lev = max(lev, JS_VG_AMISS)
            valgrindErrorPrefix = f"=={runinfo.pid}=="
            for line in err:
                if valgrindErrorPrefix and line.startswith(valgrindErrorPrefix):
                    issues.append(line.rstrip())
        elif runinfo.sta == timedrun.CRASHED:
            if os_ops.grab_crash_log(runthis[0], runinfo.pid, logPrefix, True):
                crash_log = (logPrefix.parent / f"{logPrefix.stem}-crash").with_suffix(".txt")
                with io.open(str(crash_log), "r", encoding="utf-8", errors="replace") as f:
                    auxCrashData = [line.strip() for line in f.readlines()]
        elif file_manipulation.amiss(logPrefix):
            issues.append("malloc error")
            lev = max(lev, JS_NEW_ASSERT_OR_CRASH)
        elif runinfo.return_code == 0 and not in_compare_jit:
            # We might have(??) run jsfunfuzz directly, so check for special kinds of bugs
            for line in out:
                if line.startswith("Found a bug: ") and not ("NestTest" in line and oomed(err)):
                    lev = JS_DECIDED_TO_EXIT
                    issues.append(line.rstrip())
            if options.shellIsDeterministic and not understoodJsfunfuzzExit(out, err) and not oomed(err):
                issues.append("jsfunfuzz didn't finish")
                lev = JS_DID_NOT_FINISH

        # Copy non-crash issues to where FuzzManager's "AssertionHelper" can see it.
        if lev != JS_FINE:
            for issue in issues:
                err.append(f"[Non-crash bug] {issue}")

        activated = False  # Turn on when trying to report *reliable* testcases that do not have a coredump
        # On Linux, fall back to run testcase via gdb using --args if core file data is unavailable
        # Note that this second round of running uses a different fuzzSeed as the initial if default jsfunfuzz is run
        # We should separate this out, i.e. running jsfunfuzz within a debugger, only if core dumps cannot be generated
        if (activated and platform.system() == "Linux" and
                shutil.which("gdb") and not auxCrashData and not in_compare_jit):
            print("Note: No core file found on Linux - falling back to run via gdb")
            extracted_gdb_cmds = ["-ex", "run"]
            with io.open(str(Path(__file__).parent.parent / "util" / "gdb_cmds.txt"), "r",
                         encoding="utf-8", errors="replace") as f:
                for line in f:
                    if line.rstrip() and not line.startswith("#") and not line.startswith("echo"):
                        extracted_gdb_cmds.append("-ex")
                        extracted_gdb_cmds.append(f"{line.rstrip()}")
            no_main_log_gdb_log = subprocess.run(
                (["gdb", "-n", "-batch"] + extracted_gdb_cmds + ["--args"] +
                 [str(x) for x in runthis]),
                check=True,
                stderr=subprocess.PIPE,
                stdout=subprocess.PIPE,
            )
            auxCrashData = no_main_log_gdb_log.stdout

        # Finally, make a CrashInfo object and parse stack traces for asan/crash/assertion bugs
        crashInfo = Crash_Info.CrashInfo.fromRawCrashData(out, err, pc, auxCrashData=auxCrashData)

        create_collector.printCrashInfo(crashInfo)
        # We only care about crashes and assertion failures on shells with no symbols
        # Note that looking out for the Assertion failure message is highly SpiderMonkey-specific
        if not is_oom and (not isinstance(crashInfo, Crash_Info.NoCrashInfo) or
                           "Assertion failure: " in str(crashInfo.rawStderr) or
                           "Segmentation fault" in str(crashInfo.rawStderr) or
                           "Bus error" in str(crashInfo.rawStderr)):
            lev = max(lev, JS_NEW_ASSERT_OR_CRASH)

        try:
            match = options.collector.search(crashInfo)
            if match[0] is not None:
                create_collector.printMatchingSignature(match)
                if match[1].get("frequent"):
                    print("Ignoring frequent bucket")
                    lev = JS_FINE
        except UnicodeDecodeError:  # Sometimes FM throws due to unicode issues
            print("Note: FuzzManager is throwing a UnicodeDecodeError, signature matching skipped")
            match = False

        print(f"{logPrefix} | {summaryString(issues, lev, runinfo.elapsedtime)}")

        if lev != JS_FINE:
            summary_log = (logPrefix.parent / f"{logPrefix.stem}-summary").with_suffix(".txt")
            with io.open(str(summary_log), "w", encoding="utf-8", errors="replace") as f:
                f.writelines([f"Number: {logPrefix}\n",
                              f'Command: {" ".join(quote(str(x)) for x in runthis)}\n'] +
                             [f"Status: {i}\n" for i in issues])

        self.lev = lev
        self.out = out
        self.err = err
        self.issues = issues
        self.crashInfo = crashInfo  # pylint: disable=invalid-name
        self.match = match
        self.runinfo = runinfo
        self.return_code = runinfo.return_code


def understoodJsfunfuzzExit(out, err):  # pylint: disable=invalid-name,missing-docstring,missing-return-doc
    # pylint: disable=missing-return-type-doc
    for line in err:
        if "terminate called" in line or "quit called" in line:
            return True
        if "can't allocate region" in line:
            return True

    for line in out:
        # Note that "jsfunfuzz broke its own scripting environment: " is not currently generated in error-reporting.js
        if line.startswith("It's looking good!") or line.startswith("jsfunfuzz broke its own scripting environment: "):
            return True
        if line.startswith("Found a bug: "):
            return True
        if line.startswith("[fuzz-exec] calling "):  # Working wasm testcases show this in stdout
            return True

    return False


def hitMemoryLimit(err):  # pylint: disable=invalid-name,missing-param-doc,missing-return-doc,missing-return-type-doc
    # pylint: disable=missing-type-doc
    """Return True iff stderr text indicates that the shell hit a memory limit."""
    if "ReportOverRecursed called" in err:  # pylint: disable=no-else-return
        # --differential-testing
        return "ReportOverRecursed called"
    elif "ReportOutOfMemory called" in err:
        # --differential-testing
        return "ReportOutOfMemory called"
    elif "failed to allocate" in err:
        # ASan
        return "failed to allocate"
    elif "can't allocate region" in err:
        # malloc
        return "can't allocate region"

    return None


def oomed(err):  # pylint: disable=missing-docstring,missing-return-doc,missing-return-type-doc
    # spidermonkey shells running with --differential-testing will tell us on stderr if they run out of memory
    for line in err:
        if hitMemoryLimit(line):
            return True
    return False


def summaryString(issues, level, elapsedtime):  # pylint: disable=invalid-name,missing-docstring,missing-return-doc
    # pylint: disable=missing-return-type-doc
    amissDetails = "" if (not issues) else f" | {issues[:5]!r} "  # pylint: disable=invalid-name
    return f"{elapsedtime:5.1f}s | {level} | {JS_LEVEL_NAMES[level]}{amissDetails}"


def truncateFile(fn, maxSize):  # pylint: disable=invalid-name,missing-docstring
    if fn.is_file() and fn.stat().st_size > maxSize:
        with io.open(str(fn), "r+", encoding="utf-8", errors="replace") as f:
            f.truncate(maxSize)


def set_ulimit():
    """Sets appropriate resource limits for the JS shell when on POSIX."""
    try:
        import resource

        # log.debug("Limit address space to 4GB")
        # This has to be twice the 2GB figure in:
        # https://hg.mozilla.org/mozilla-central/annotate/7beb238a0ea0/js/src/jit/ProcessExecutableMemory.h#l26
        # i.e. https://hg.mozilla.org/mozilla-central/rev/7beb238a0ea0 (bug 1542292) changed from 1GB to 2GB, so our
        # number here in this file has to be changed from 2GB to 4GB or else the harness (m-err.txt) will show this:
        # JS_Init failed: js::jit::InitProcessExecutableMemory() failed
        # We cannot set a limit for RLIMIT_AS for ASan binaries
        giga_byte = 2**30
        resource.setrlimit(resource.RLIMIT_AS, (4 * giga_byte, 4 * giga_byte))

        # log.debug("Limit corefiles to 0.5 GB")
        half_giga_byte = int(giga_byte // 2)
        resource.setrlimit(resource.RLIMIT_CORE, (half_giga_byte, half_giga_byte))
    except ImportError:
        # log.debug("Skipping resource import as a non-POSIX platform was detected: %s", platform.system())
        return


def parseOptions(args):  # pylint: disable=invalid-name,missing-docstring,missing-return-doc,missing-return-type-doc
    parser = OptionParser()
    parser.disable_interspersed_args()
    parser.add_option("--valgrind",
                      action="store_true", dest="valgrind",
                      default=False,
                      help="use valgrind with a reasonable set of options")
    parser.add_option("--submit",
                      action="store_true", dest="submit",
                      default=False,
                      help="submit to fuzzmanager (if interesting)")
    parser.add_option("--minlevel",
                      type="int", dest="minimumInterestingLevel",
                      default=JS_FINE + 1,
                      help="minimum js/js_interesting level for lithium to consider the testcase interesting")
    parser.add_option("--timeout",
                      type="int", dest="timeout",
                      default=120,
                      help="timeout in seconds")
    options, args = parser.parse_args(args)
    if len(args) < 2:
        raise Exception("Not enough positional arguments")
    options.knownPath = args[0]
    options.jsengineWithArgs = [Path(args[1]).resolve()] + args[2:-1] + [Path(args[-1]).resolve()]
    options.jsengine = options.jsengineWithArgs[0]  # options.jsengine is needed as it is present in compare_jit
    assert options.jsengine.is_file()  # js shell
    assert options.jsengineWithArgs[-1].is_file()  # testcase
    options.collector = create_collector.make_collector()
    options.shellIsDeterministic = any([arg == "--differential-testing" for arg in options.jsengineWithArgs])

    return options


# loop uses parseOptions and ShellResult [with in_compare_jit = False]
# compare_jit uses ShellResult [with in_compare_jit = True]

# For use by Lithium and autobisectjs. (autobisectjs calls init multiple times because it changes the js engine name)
def init(args):  # pylint: disable=missing-docstring
    global gOptions  # pylint: disable=global-statement,invalid-name
    gOptions = parseOptions(args)


# FIXME: _args is unused here, we should check if it can be removed?  # pylint: disable=fixme
def interesting(_args, cwd_prefix):  # pylint: disable=missing-docstring,missing-return-doc
    # pylint: disable=missing-return-type-doc
    cwd_prefix = Path(cwd_prefix)  # Lithium uses this function and cwd_prefix from Lithium is not a Path
    options = gOptions
    # options, runthis, logPrefix, in_compare_jit
    res = ShellResult(options, options.jsengineWithArgs, cwd_prefix, False)
    out_log = (cwd_prefix.parent / f"{cwd_prefix.stem}-out").with_suffix(".txt")
    err_log = (cwd_prefix.parent / f"{cwd_prefix.stem}-err").with_suffix(".txt")
    truncateFile(out_log, 1000000)
    truncateFile(err_log, 1000000)
    return res.lev >= gOptions.minimumInterestingLevel


# For direct, manual use
def main():  # pylint: disable=missing-docstring
    options = parseOptions(sys.argv[1:])
    cwd_prefix = Path.cwd() / "m"
    res = ShellResult(options, options.jsengineWithArgs, cwd_prefix, False)  # pylint: disable=no-member
    print(res.lev)
    if options.submit:  # pylint: disable=no-member
        if res.lev >= options.minimumInterestingLevel:  # pylint: disable=no-member
            testcase_filename = Path(options.jsengineWithArgs[-1])  # pylint: disable=no-member
            wrapper_file = Path(options.jsengineWithArgs[-2])  # pylint: disable=no-member
            if testcase_filename.suffix == ".wasm" and wrapper_file.suffix == ".wrapper":
                # binaryen integration, we do not yet have pinpoint nor autobisectjs support, so temporarily quality 10
                assert testcase_filename.is_file()
                assert wrapper_file.is_file()
                result_zip = Path.cwd() / "webassembly_files.zip"
                with zipfile.ZipFile(result_zip, "w") as f:
                    f.write(wrapper_file, wrapper_file.name, compress_type=zipfile.ZIP_DEFLATED)
                    f.write(testcase_filename, testcase_filename.name, compress_type=zipfile.ZIP_DEFLATED)

                # Quality is 10, meta_data {}
                print(f"Submitting {result_zip}")
                # pylint: disable=no-member
                create_collector.submit_collector(options.collector, res.crashInfo, str(result_zip), 10, meta_data={})
                print(f"Submitted {result_zip}")
            else:
                print(f"Submitting {testcase_filename}")
                # pylint: disable=no-member
                create_collector.submit_collector(options.collector, res.crashInfo, str(testcase_filename), 0)
                print(f"Submitted {testcase_filename}")
        else:
            print("Not submitting (not interesting)")


if __name__ == "__main__":
    main()
