// SPDX-FileCopyrightText: Copyright (c) 2018-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: LicenseRef-NvidiaProprietary
//
// NVIDIA CORPORATION, its affiliates and licensors retain all intellectual
// property and proprietary rights in and to this material, related
// documentation and any modifications thereto. Any use, reproduction,
// disclosure or distribution of this material and related documentation
// without an express license agreement from NVIDIA CORPORATION or
// its affiliates is strictly prohibited.
#pragma once

#include "TestDoctestWrapper.h"

#include <carb/cpp/Optional.h>
#include <carb/cpp/StringView.h>
#include <carb/cpp/ZStringView.h>
#include <carb/extras/Library.h>
#include <carb/thread/Util.h>

#include <cstdlib>
#include <string>
#include <vector>

namespace carb
{

/** Timeout in milliseconds to use when waiting for a child process to exit on its own. */
constexpr uint64_t kChildWaitExitTimeoutMs = 20000;

/** Timeout in milliseconds to use when waiting for a child process' standard streams to end. */
constexpr uint64_t kChildWaitStreamEndTimeoutMs = 20000;

/** Timeout in milliseconds to use when waiting for a child process to be killed. */
constexpr uint64_t kChildKillTimeoutMs = 20000;

inline unsigned clamped_hardware_concurrency() noexcept
{
    // returns 2-8 cpus
    return std::min(std::max(2u, thread::hardware_concurrency()), 8u);
}

/**
 * Test Session global settings. Set with test.unit[.exe] CLI, use "-h" for more info.
 */
struct TestGlobalSettings
{
    std::string carbAudioDebugChannels;
    std::string carbAudioForgeChannels;
    int32_t carbTestCount = 0;
    bool carbAudibleTests = false;
    bool carbExtraFuzz = false;
    bool carbAllowAssert = false;
    bool carbCrashReporter = true;
    bool carbForceZeroAudioDevices = false;
    bool carbForceBrokenAudioDevices = false;
    std::string carbForceAudioBackend;
    std::string pythonVersion;
    bool compatibilityModeEnabled = false;

    static TestGlobalSettings& get()
    {
        static TestGlobalSettings instance;
        return instance;
    }
};

enum class TestAssetDirectoryType
{
    eBuildRoot,
    eImageComparisonOutput
};


std::string getAssetDirectory(TestAssetDirectoryType assetType);

/** The default python to use when --python-version is not specified. */
constexpr char kDefaultPythonVersion[] = "3.10";

inline std::string getPythonVersion()
{
    if (carb::TestGlobalSettings::get().pythonVersion.empty())
    {
        return kDefaultPythonVersion;
    }
    return carb::TestGlobalSettings::get().pythonVersion;
}

/** Get the search path relative to the test.unit binary where carb.scripting-python is stored.
 *  @returns A static string with the search path relative to the test.unit
 *           binary where carb.scripting-python is stored
 */
const char* getScriptingPythonSearchPath();

/**
 * Returns the processor the calling thread is running on.
 *
 * This function returns a CpuMaskVector rather than a single value to handle system that have more than 64 cores. On
 * Windows, the current processor is relative to the Processor Group the CPU is in. Returning an CpuMaskVector allows
 * this function to represent that by setting the bit in the CpuMaskVector entry corresponding to the processor group.
 * For instance, if the current processor was the first processor in processor group 1, the return value would be:
 *
 * CpuMaskVector[0] = 0x0
 * CpuMaskVector[1] = 0x1
 *
 * @return An CpuMaskVector with a bit set representing the current processor
 *
 * @note This function is not supported on Mac OS.
 */
thread::CpuMaskVector getCurrentProcessor();

/** Get the framework configuration necessary to use carb.scripting-python.
 *  @returns The framework configuration necessary to use carb.scripting-python.
 */
const char* getScriptingPythonConfig();

/** Get the vector of plugins to load if you want to use carb.scripting-python.
 *  @returns The base list of plugins that are required to load carb.scripting-python.
 *           You may need to append extra plugins for additional functionality.
 *  @note If you aren't using FrameworkScoped, you will also need to add an additional
 *        search path when loading the framework with getScriptingPythonSearchPath().
 */
inline std::vector<const char*> getScriptingPythonPluginList()
{
    return std::vector<const char*>{
        "carb.dictionary.plugin",
        "carb.settings.plugin",
        "carb.dictionary.serializer-json.plugin",
        "carb.scripting-python.plugin",
    };
}

/** Try to retrieve the path to the ASAN library in use.
 * @returns The path to the ASAN library if one exists.
 * @remarks This is used for tests that may launch an executable that wasn't built with ASAN, such
 *          as python.
 * @note This is only implemented on Linux. This is used so subprocesses can be launched with
 *       LD_PRELOAD, which doesn't have a Windows equivalent.
 */
inline carb::cpp::optional<std::string> getAsanPath()
{
#if CARB_ASAN_ENABLED
    auto getAsanHandle = []() -> carb::extras::UniqueLibrary {
#    if CARB_PLATFORM_LINUX
        // Just check for every known ASAN option
        constexpr const char* options[] = {
            "libasan.so.4", // CentOS7 GCC 7
            "libasan.so.6", // Ubuntu 22 GCC 11
            "libclang_rt.asan.so", // Clang toolchain
        };
        for (size_t i = 0; i < CARB_COUNTOF(options); i++)
        {
            carb::extras::LibraryHandle asan = carb::extras::loadLibrary(options[i], carb::extras::fLibFlagLoadExisting);
            if (asan != nullptr)
            {
                return carb::extras::UniqueLibrary(asan);
            }
        }
        WARN_MESSAGE(false, "Failed to find libasan despite being built with ASAN");
#    endif
        return carb::extras::UniqueLibrary(carb::extras::kInvalidLibraryHandle);
    };

    // Note that we're assuming that ASAN is dynamically linked. We can statically link ASAN, but
    // then you can't run from a non-ASAN entry point like python.
    carb::extras::UniqueLibrary asan(getAsanHandle());
    if (asan != nullptr)
    {
        return carb::extras::getLibraryFilenameByHandle(asan.get());
    }
    else
    {
        return carb::cpp::nullopt;
    }
#endif
    return carb::cpp::nullopt;
}

#if CARB_PLATFORM_LINUX

/** Heuristic test for whether a given shared library's name looks like a runtime for one
 *  of the well-known compiler sanitizers.  Used by `buildChildLdPreload()` below.
 *
 *  Recognizes both the GCC `libsanitizer` family and the Clang `compiler-rt` naming.  The
 *  match is a substring scan on the basename for any of `asan` (covers `libasan`,
 *  `libhwasan`, `libclang_rt.asan`, `libclang_rt.hwasan`), `tsan`, `ubsan`, `msan`, and
 *  `lsan`.  This is intentionally lenient: false positives only cause a sanitizer
 *  ordering rule to be applied to a non-sanitizer entry, which still produces a valid
 *  `LD_PRELOAD` -- the only cost is that our shim ends up second instead of first.
 *
 *  @param[in] library  Library name or path.  Any leading directory components are
 *                      stripped before the substring check.
 *  @returns `true` if the basename looks like a sanitizer runtime, `false` otherwise.
 *
 *  @note Linux-only.  `LD_PRELOAD` does not exist on Windows / macOS, so this helper and
 *        its companion `buildChildLdPreload()` are not built on those platforms.
 */
inline bool looksLikeSanitizerLibrary(carb::cpp::string_view library) noexcept
{
    if (const size_t slash = library.rfind('/'); slash != carb::cpp::string_view::npos)
    {
        library.remove_prefix(slash + 1);
    }
    constexpr carb::cpp::string_view kMarkers[] = {
        "asan", // libasan, libhwasan, libclang_rt.asan, libclang_rt.hwasan
        "tsan", // libtsan, libclang_rt.tsan
        "ubsan", // libubsan, libclang_rt.ubsan
        "msan", // libmsan, libclang_rt.msan
        "lsan", // liblsan, libclang_rt.lsan (often part of asan but sometimes standalone)
    };
    for (const carb::cpp::string_view& marker : kMarkers)
    {
        if (library.find(marker) != carb::cpp::string_view::npos)
        {
            return true;
        }
    }
    return false;
}

/** Builds the value of `LD_PRELOAD` to pass to a child process so that @p extraLib is
 *  preloaded ahead of every other library the parent process had configured, with two
 *  important exceptions:
 *
 *   1. Any *leading* run of sanitizer runtimes in the parent's `LD_PRELOAD` is kept in
 *      first position(s) and @p extraLib is inserted immediately after them.
 *   2. If the parent is itself running under ASan (`CARB_ASAN_ENABLED`) but does *not*
 *      already have libasan in its `LD_PRELOAD` (because it was loaded via `DT_NEEDED`
 *      or static linking instead), the libasan currently loaded in the parent is
 *      detected via @c getAsanPath() and prepended to the result.  Without this step,
 *      a child binary that was built with the same toolchain settings would pull
 *      libasan in via `DT_NEEDED` only *after* the dynamic linker has already loaded
 *      @p extraLib via `LD_PRELOAD`, and ASan would abort with "ASan runtime does not
 *      come first in initial library list".
 *
 *  Examples (using `S` for a sanitizer library, `L` for a non-sanitizer one, and `A`
 *  for the libasan path returned by @c getAsanPath()):
 *
 *    CARB_ASAN_ENABLED   parent LD_PRELOAD       returned LD_PRELOAD
 *    ------------------  ----------------------  ----------------------
 *    0                   (unset / empty)         extra
 *    0                   L1:L2                   extra:L1:L2
 *    0                   S1:L1:L2                S1:extra:L1:L2
 *    1                   (unset / empty)         A:extra
 *    1                   L1                      A:extra:L1
 *    1                   S1:L1                   S1:extra:L1
 *    (S1 already present)
 *
 *  Once the leading sanitizer run ends, the remaining entries (whether they contain
 *  more sanitizers or not) are preserved unchanged after our entry.  This matches the
 *  usual expectation that `LD_PRELOAD` is consumed left-to-right by the dynamic linker.
 *
 *  @param[in] extraLib  Filesystem path (or library name) to splice into the parent's
 *                       `LD_PRELOAD`.  Must not contain a colon.
 *  @returns A newly-allocated string suitable for passing to `setenv("LD_PRELOAD", ...)`
 *           in the child's environment.
 */
inline std::string buildChildLdPreload(carb::cpp::string_view extraLib)
{
    // ----- Step 1: build the base result from the parent's `LD_PRELOAD` -----
    //
    // Walk the parent's `:`-separated entries, finding the run of *leading* sanitizer
    // libraries.  Our extraLib gets spliced in immediately after that run; anything
    // after the first non-sanitizer entry is preserved as-is.
    std::string out;
    if (const char* existing = std::getenv("LD_PRELOAD"); existing == nullptr || existing[0] == '\0')
    {
        out = std::string{ extraLib.data(), extraLib.size() };
    }
    else
    {
        // The `unsafe_length` overload is required to opt in to length-from-strlen
        // construction; `carb::cpp::basic_string_view` deletes the unguarded
        // `basic_string_view(const CharT*)` constructor as a safety measure.  `existing`
        // came from libc's environment block, which guarantees a NUL terminator.
        const carb::cpp::string_view existingSv{ carb::cpp::unsafe_length, existing };

        // After this loop:
        //   - `sanitizerLen` is the number of bytes in `existingSv` covered by the
        //     leading sanitizer run (no trailing separator);
        //   - `restStart` is the byte offset of the first non-sanitizer entry, or
        //     `carb::cpp::string_view::npos` if the entire existing string was sanitizers.
        size_t sanitizerLen = 0;
        size_t restStart = carb::cpp::string_view::npos;
        size_t pos = 0;
        while (pos < existingSv.size())
        {
            const size_t sep = existingSv.find(':', pos);
            const size_t entryEnd = (sep == carb::cpp::string_view::npos) ? existingSv.size() : sep;
            const carb::cpp::string_view entry = existingSv.substr(pos, entryEnd - pos);

            if (looksLikeSanitizerLibrary(entry))
            {
                sanitizerLen = entryEnd;
                if (sep == carb::cpp::string_view::npos)
                {
                    break; // exhausted the input
                }
                pos = sep + 1;
            }
            else
            {
                restStart = pos;
                break;
            }
        }

        if (sanitizerLen == 0)
        {
            // No leading sanitizer entries -- our library leads, the parent's list follows.
            out.reserve(extraLib.size() + 1 + existingSv.size());
            out.append(extraLib.data(), extraLib.size());
            out += ':';
            out.append(existingSv.data(), existingSv.size());
        }
        else
        {
            // <sanitizer-prefix> : <extra> [ : <rest> ]
            out.reserve(sanitizerLen + 1 + extraLib.size() + 1 + existingSv.size());
            out.append(existingSv.data(), sanitizerLen);
            out += ':';
            out.append(extraLib.data(), extraLib.size());
            if (restStart != carb::cpp::string_view::npos)
            {
                out += ':';
                out.append(existingSv.data() + restStart, existingSv.size() - restStart);
            }
        }
    }

    // ----- Step 2: inject libasan if it isn't already present -----
    //
    // ASan can be loaded into the parent process via any of `LD_PRELOAD`, `DT_NEEDED`
    // (parent linked with `-fsanitize=address [-shared-libasan]`), or static linking.
    // Only the first of those shows up in `std::getenv("LD_PRELOAD")` above; the
    // others leave the parent's preload list empty even though ASan is very much
    // active.  In the latter cases the child binary (built under the same toolchain)
    // will pull libasan in via its own `DT_NEEDED`, but the dynamic linker processes
    // `LD_PRELOAD` *first* -- which means our `extraLib` would load ahead of libasan,
    // and ASan would abort with "ASan runtime does not come first in initial library
    // list".
    //
    // `getAsanPath()` queries the running process for whichever libasan is actually
    // loaded, so it works for all three loading mechanisms.  We compare basenames to
    // detect the "already in LD_PRELOAD" case and avoid a duplicate prepend.
#    if CARB_ASAN_ENABLED
    if (auto asanPath = getAsanPath(); asanPath.has_value())
    {
        const std::string& asanFull = *asanPath;
        carb::cpp::string_view asanBasename{ asanFull };
        if (const size_t slash = asanBasename.rfind('/'); slash != carb::cpp::string_view::npos)
        {
            asanBasename.remove_prefix(slash + 1);
        }

        bool alreadyPresent = false;
        const carb::cpp::string_view sv{ out };
        size_t pos = 0;
        while (pos < sv.size())
        {
            const size_t sep = sv.find(':', pos);
            const size_t end = (sep == carb::cpp::string_view::npos) ? sv.size() : sep;
            carb::cpp::string_view entry = sv.substr(pos, end - pos);
            if (const size_t slash = entry.rfind('/'); slash != carb::cpp::string_view::npos)
            {
                entry.remove_prefix(slash + 1);
            }
            if (entry == asanBasename)
            {
                alreadyPresent = true;
                break;
            }
            if (sep == carb::cpp::string_view::npos)
            {
                break;
            }
            pos = sep + 1;
        }

        if (!alreadyPresent)
        {
            std::string prefixed;
            prefixed.reserve(asanFull.size() + 1 + out.size());
            prefixed.append(asanFull);
            prefixed += ':';
            prefixed.append(out);
            out = std::move(prefixed);
        }
    }
#    endif

    return out;
}

#endif // CARB_PLATFORM_LINUX

class ScopedDisableCrashreporter
{
    bool m_prev;

public:
    ScopedDisableCrashreporter() : m_prev(std::exchange(carb::TestGlobalSettings::get().carbCrashReporter, false))
    {
    }
    ~ScopedDisableCrashreporter()
    {
        carb::TestGlobalSettings::get().carbCrashReporter = m_prev;
    }
};

} // namespace carb
