cmake_minimum_required(VERSION 3.21)
project(mew LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_POSITION_INDEPENDENT_CODE ON)

# Generate a compilation database for each build tree. Developer builds can
# copy theirs to the repository root without letting isolated package builds
# replace it with paths from a temporary environment.
set(CMAKE_EXPORT_COMPILE_COMMANDS ON CACHE INTERNAL "")
option(MEW_SYNC_COMPILE_COMMANDS
    "Copy compile_commands.json to the source root" OFF)
if(PROJECT_IS_TOP_LEVEL AND MEW_SYNC_COMPILE_COMMANDS)
    add_custom_target(
        sync_compile_commands ALL
        COMMAND ${CMAKE_COMMAND} -E copy_if_different
            ${CMAKE_BINARY_DIR}/compile_commands.json
            ${CMAKE_CURRENT_SOURCE_DIR}/compile_commands.json
        BYPRODUCTS ${CMAKE_CURRENT_SOURCE_DIR}/compile_commands.json
        COMMENT "Syncing compile_commands.json to source root"
        VERBATIM)
endif()

if(NOT CMAKE_BUILD_TYPE)
    set(CMAKE_BUILD_TYPE Release)
endif()

# AddressSanitizer, enabled with -DMEW_ASAN=ON or MEW_ASAN=1. Global flags also
# instrument Google Benchmark; _core repeats them for nanobind's stub generator.
option(MEW_ASAN "Build with AddressSanitizer" OFF)
if(NOT MEW_ASAN AND DEFINED ENV{MEW_ASAN} AND "$ENV{MEW_ASAN}")
    set(MEW_ASAN ON)
endif()
if(MEW_ASAN)
    add_compile_options(-fsanitize=address -fno-omit-frame-pointer)
    add_link_options(-fsanitize=address)
endif()

# ThreadSanitizer build, driven by -DMEW_TSAN=ON or MEW_TSAN=1 in the env.
# Mutually exclusive with ASAN — the two runtimes can't co-load.
option(MEW_TSAN "Build with ThreadSanitizer" OFF)
if(NOT MEW_TSAN AND DEFINED ENV{MEW_TSAN} AND "$ENV{MEW_TSAN}")
    set(MEW_TSAN ON)
endif()
if(MEW_TSAN AND MEW_ASAN)
    message(FATAL_ERROR "MEW_TSAN and MEW_ASAN cannot be enabled at the same time")
endif()
if(MEW_TSAN)
    add_compile_options(-fsanitize=thread -fno-omit-frame-pointer)
    add_link_options(-fsanitize=thread)
endif()

# --- Python + nanobind --------------------------------------------------------
# Optional, not required: a missing SABI module must degrade to a version-specific
# build rather than fail the configure, and this also works outside scikit-build-core,
# where SKBUILD_SABI_COMPONENT is undefined.
find_package(Python 3.11
    REQUIRED COMPONENTS Interpreter Development.Module
    OPTIONAL_COMPONENTS Development.SABIModule)

if(SKBUILD_SABI_COMPONENT AND NOT TARGET Python::SABIModule)
    message(FATAL_ERROR
        "scikit-build-core is building a stable-ABI wheel, but Python::SABIModule "
        "was not found. Install the Python SABI development files, or clear "
        "tool.scikit-build.wheel.py-api to build an untagged wheel.")
endif()

execute_process(
    COMMAND "${Python_EXECUTABLE}" -m nanobind --cmake_dir
    OUTPUT_STRIP_TRAILING_WHITESPACE
    OUTPUT_VARIABLE nanobind_ROOT
)
find_package(nanobind CONFIG REQUIRED)

# --- Google Benchmark ---------------------------------------------------------
include(FetchContent)

set(MEW_BENCHMARK_COMMIT
    "a8460680f0df91fd26205e0931708a26c3b4094d"
    CACHE STRING "Google Benchmark commit SHA to fetch")

set(BENCHMARK_ENABLE_TESTING OFF CACHE BOOL "" FORCE)
set(BENCHMARK_ENABLE_GTEST_TESTS OFF CACHE BOOL "" FORCE)
set(BENCHMARK_ENABLE_INSTALL OFF CACHE BOOL "" FORCE)
set(BENCHMARK_ENABLE_WERROR OFF CACHE BOOL "" FORCE)

# Apply these patches in order to the pinned Google Benchmark tree.
set(MEW_BENCHMARK_PATCHES
    "${CMAKE_CURRENT_SOURCE_DIR}/patches/0001-profilermanager-result.patch"
    "${CMAKE_CURRENT_SOURCE_DIR}/patches/0002-threaded-manager-passes.patch"
    "${CMAKE_CURRENT_SOURCE_DIR}/patches/0003-memory-iterations-flag.patch")

string(JOIN "|" _mew_patches_arg ${MEW_BENCHMARK_PATCHES})

# Needed by PATCH_COMMAND below, so it must resolve *before* the Declare (the
# built-commit probe further down reuses it).
find_package(Git QUIET)
if(NOT Git_FOUND)
    message(FATAL_ERROR
        "git is required to apply mew's Google Benchmark patches "
        "(see MEW_BENCHMARK_PATCHES).")
endif()

FetchContent_Declare(
    googlebenchmark
    GIT_REPOSITORY https://github.com/google/benchmark.git
    GIT_TAG ${MEW_BENCHMARK_COMMIT}
    # Keep the fetched tree LF on every platform. A Windows runner's global
    # `core.autocrlf=true` would otherwise check this clone out as CRLF while
    # `.gitattributes` holds the patches at LF, and the mismatch makes every
    # hunk fail with "patch does not apply".
    GIT_CONFIG core.autocrlf=false core.eol=lf
    # PATCH_COMMAND runs without a shell; the -P script carries the
    # already-applied guard and the failure message. The patch list is
    # |-joined: a ;-list would be split into separate arguments here.
    PATCH_COMMAND ${CMAKE_COMMAND}
        "-DGIT=${GIT_EXECUTABLE}"
        "-DSRC=<SOURCE_DIR>"
        "-DPATCHES=${_mew_patches_arg}"
        -P "${CMAKE_CURRENT_SOURCE_DIR}/cmake/apply_patches.cmake"
    # Without this, a reconfigure re-runs the git update step, which resets the
    # tree and drops the patch before PATCH_COMMAND gets a chance to re-run.
    UPDATE_DISCONNECTED TRUE
)
FetchContent_MakeAvailable(googlebenchmark)

# Report the checked-out upstream SHA rather than the requested pin. Fall back
# to the pin for vendored sdists; local patches are not reflected in this value.
set(MEW_BENCHMARK_BUILT_COMMIT "${MEW_BENCHMARK_COMMIT}")
if(Git_FOUND AND IS_DIRECTORY "${googlebenchmark_SOURCE_DIR}/.git")
    execute_process(
        COMMAND "${GIT_EXECUTABLE}" -C "${googlebenchmark_SOURCE_DIR}" rev-parse HEAD
        OUTPUT_VARIABLE MEW_BENCHMARK_BUILT_COMMIT
        OUTPUT_STRIP_TRAILING_WHITESPACE
        RESULT_VARIABLE _mew_rev_parse_status
        ERROR_QUIET)
    if(NOT _mew_rev_parse_status EQUAL 0)
        set(MEW_BENCHMARK_BUILT_COMMIT "${MEW_BENCHMARK_COMMIT}")
    endif()
endif()

message(STATUS "Google Benchmark requested: ${MEW_BENCHMARK_COMMIT}")
message(STATUS "Google Benchmark built:     ${MEW_BENCHMARK_BUILT_COMMIT}")

# --- _core extension ----------------------------------------------------------
set(_MEW_SOURCES
    src/_mew/module.cpp
    src/_mew/state.cpp
    src/_mew/registry.cpp
    src/_mew/reporter.cpp
    src/_mew/managers.cpp
    src/_mew/abort.cpp
)

# nanobind keeps whichever of STABLE_ABI / FREE_THREADED fits the interpreter:
# `cp312` abi3 on stock CPython, a `Py_MOD_GIL_NOT_USED` wheel on free-threaded
# builds (the limited API has no free-threaded variant). Inert on a GIL build.
nanobind_add_module(
    _core
    STABLE_ABI
    FREE_THREADED
    NB_STATIC
    ${_MEW_SOURCES}
)

# Scoped to _core so the fetched Google Benchmark keeps its own warning settings.
# Not -Werror: a newer compiler or nanobind release must not break the build.
if(MSVC)
    target_compile_options(_core PRIVATE /W4)
else()
    target_compile_options(_core PRIVATE -Wall -Wextra)
endif()

target_include_directories(_core PRIVATE src/_mew)
target_link_libraries(_core PRIVATE benchmark::benchmark)
target_compile_definitions(_core PRIVATE
    MEW_BENCHMARK_COMMIT="${MEW_BENCHMARK_BUILT_COMMIT}"
)

# Repeat the sanitizer flag on _core itself: nanobind's stubgen detector only
# scans per-target COMPILE_OPTIONS / LINK_OPTIONS. Without this it skips
# preloading libclang_rt.asan and stubgen aborts on `import _core`.
if(MEW_ASAN)
    target_compile_options(_core PRIVATE -fsanitize=address -fno-omit-frame-pointer)
    target_link_options(_core PRIVATE -fsanitize=address)
endif()
if(MEW_TSAN)
    target_compile_options(_core PRIVATE -fsanitize=thread -fno-omit-frame-pointer)
    target_link_options(_core PRIVATE -fsanitize=thread)
endif()

install(TARGETS _core LIBRARY DESTINATION mew)

# Generate into the build tree to avoid regenerating the stub on out-of-tree builds.
set(_MEW_CORE_STUB "${CMAKE_CURRENT_BINARY_DIR}/_core.pyi")
nanobind_add_stub(
    mew_core_stub
    MODULE _core
    OUTPUT ${_MEW_CORE_STUB}
    PATTERN_FILE ${CMAKE_CURRENT_SOURCE_DIR}/src/_mew/stubgen.pat
    PYTHON_PATH $<TARGET_FILE_DIR:_core>
    DEPENDS _core
)

# The checked-in, formatted stub is the distribution artifact. Stub generation
# above is for verification and explicit maintainer updates; consumer builds
# need neither ruff nor permission to modify mew's source checkout.
install(FILES ${CMAKE_CURRENT_SOURCE_DIR}/src/mew/_core.pyi DESTINATION mew)

# Apply Ruff fixes and formatting to the generated stub when Ruff is available.
execute_process(
    COMMAND ${Python_EXECUTABLE} -m ruff --version
    RESULT_VARIABLE _ruff_available
    OUTPUT_QUIET ERROR_QUIET
)
if(_ruff_available EQUAL 0)
    add_custom_command(
        TARGET mew_core_stub POST_BUILD
        COMMAND ${Python_EXECUTABLE} -m ruff check --fix --exit-zero --quiet
            ${_MEW_CORE_STUB}
        COMMAND ${Python_EXECUTABLE} -m ruff format --quiet
            ${_MEW_CORE_STUB}
        COMMENT "ruff fix + format _core.pyi"
        VERBATIM
    )
endif()

# Updating the repository copy is an explicit maintainer action.
# Normal builds only read the source tree.
add_custom_target(
    update_mew_core_stub
    COMMAND ${CMAKE_COMMAND} -E copy_if_different
        ${_MEW_CORE_STUB}
        ${CMAKE_CURRENT_SOURCE_DIR}/src/mew/_core.pyi
    DEPENDS mew_core_stub
    COMMENT "Updating checked-in src/mew/_core.pyi"
    VERBATIM)

# CI/release check: generation remains read-only, but a stale checked-in stub
# fails loudly. Run after installing the development dependencies so ruff is
# available and the generated file has its canonical formatting.
add_custom_target(
    check_mew_core_stub
    COMMAND ${CMAKE_COMMAND} -E compare_files
        ${_MEW_CORE_STUB}
        ${CMAKE_CURRENT_SOURCE_DIR}/src/mew/_core.pyi
    DEPENDS mew_core_stub
    COMMENT "Checking checked-in src/mew/_core.pyi"
    VERBATIM)
