cmake_minimum_required(VERSION 3.28)
if (POLICY CMP0167)
    cmake_policy(SET CMP0167 NEW)
endif ()

project(ReverseMonteCarlo VERSION 1.0.2 LANGUAGES CXX)

option(ENABLE_LTO "Enable link-time optimization" ON)
include(CheckIPOSupported)
check_ipo_supported(RESULT ipo_supported OUTPUT ipo_error)

if (ENABLE_LTO AND ipo_supported)
    message(STATUS "LTO: enabled")
    set(CMAKE_INTERPROCEDURAL_OPTIMIZATION TRUE)
else ()
    message(STATUS "LTO: not supported: ${ipo_error}")
endif ()

set(CMAKE_CXX_STANDARD 23)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS ON)
# RMC uses no C++20 modules. With C++23 CMake would still scan every TU for
# them (-fdeps-format=p1689r5 -fmodules-ts), which costs a scan step per file
# and breaks under the manylinux image's ccache 3.x: it handles -MD/-MF itself,
# leaving GCC the scan flags alone ("to generate dependencies you must specify
# either -M or -MM"). seitz turns it off for the same reason.
set(CMAKE_CXX_SCAN_FOR_MODULES OFF)
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)

option(ENABLE_CCACHE "Use ccache to speed up rebuilds when available" ON)
if (ENABLE_CCACHE AND NOT CMAKE_CXX_COMPILER_LAUNCHER)
    find_program(CCACHE_PROGRAM ccache)
    if (CCACHE_PROGRAM)
        message(STATUS "ccache: enabled (${CCACHE_PROGRAM})")
        set(CMAKE_CXX_COMPILER_LAUNCHER "${CCACHE_PROGRAM}")
        set(CMAKE_C_COMPILER_LAUNCHER "${CCACHE_PROGRAM}")
    else ()
        message(STATUS "ccache: not found")
    endif ()
endif ()

option(RMC_BUILD_TESTS "Build Catch2 test suite" ON)
option(RMC_BUILD_PYTHON "Build the pybind11 extension module (rmc._core)" OFF)
option(ENABLE_NATIVE_ARCH "Optimise for the current machine" ON)
option(ENABLE_MKL "Use Intel MKL backend for Eigen" ON)

if (NOT BOOST_ROOT AND EXISTS "/opt/boost")
    set(BOOST_ROOT "/opt/boost" CACHE PATH "Boost root directory" FORCE)
endif ()
if (BOOST_ROOT)
    list(PREPEND CMAKE_PREFIX_PATH "${BOOST_ROOT}")
endif ()

set(RMC_KNOWN_COMPILER OFF)
if (CMAKE_CXX_COMPILER_ID STREQUAL "MSVC")
    set(RMC_KNOWN_COMPILER ON)
    set(RMC_COMPILER_MSVC ON)
elseif (CMAKE_CXX_COMPILER_ID STREQUAL "GNU")
    set(RMC_KNOWN_COMPILER ON)
    set(RMC_COMPILER_GCC ON)
elseif (CMAKE_CXX_COMPILER_ID STREQUAL "Clang")
    set(RMC_KNOWN_COMPILER ON)
    set(RMC_COMPILER_CLANG ON)
else ()
    message(STATUS "Compiler ${CMAKE_CXX_COMPILER_ID}: using portable C++23 defaults only")
endif ()

include(CheckCXXCompilerFlag)
if (RMC_COMPILER_MSVC)
    check_cxx_compiler_flag(/arch:AVX2 HAVE_AVX2)
    if (HAVE_AVX2)
        add_compile_options(/arch:AVX2)
        message(STATUS "AVX2: enabled (/arch:AVX2)")
    endif ()
else ()
    check_cxx_compiler_flag(-mavx2 HAVE_AVX2)
    if (HAVE_AVX2)
        if (ENABLE_NATIVE_ARCH)
            add_compile_options(-march=native)
            message(STATUS "AVX2: enabled (-march=native)")
        else ()
            add_compile_options(-march=x86-64-v3)
            message(STATUS "AVX2: enabled (-march=x86-64-v3)")
        endif ()
    else ()
        message(STATUS "AVX2: not supported by this compiler")
    endif ()
endif ()

if (RMC_COMPILER_CLANG)
    find_program(LLD_LINKER ld.lld lld)
    if (LLD_LINKER)
        add_link_options(-fuse-ld=lld)
        message(STATUS "LLD: enabled")
    else ()
        message(STATUS "LLD: not found")
    endif ()
endif ()

# Boost components pull in private .so dependencies (e.g. libboost_filesystem
# behind Boost::serialization) that we never link directly. The linker default
# DT_RUNPATH is not searched when the loader resolves a dependency's own indirect
# deps, so emit the older DT_RPATH instead — it is inherited transitively and
# lets the loader find Boost's private .so deps in ${BOOST_ROOT}/lib at runtime.
if (RMC_COMPILER_GCC OR RMC_COMPILER_CLANG)
    add_link_options(-Wl,--disable-new-dtags)
endif ()

# ── Sanitizer flags ──────────────────────────────────────────────────────────
# These MUST precede the first add_library/add_executable: add_compile_options /
# add_link_options only apply to targets created *after* the call, so placing
# them after the targets (as before) silently no-ops them.
option(ENABLE_SANITIZERS "Enable AddressSanitizer + UBSan on all targets" OFF)
if (ENABLE_SANITIZERS AND (RMC_COMPILER_GCC OR RMC_COMPILER_CLANG))
    add_compile_options(-fsanitize=address,undefined -fno-omit-frame-pointer -g)
    add_link_options(-fsanitize=address,undefined)
    message(STATUS "Sanitizers: ASan + UBSan enabled")
endif ()

include(FetchContent)

# The Python extension. Its find_package(Python) runs before anything else asks:
# the first call caches the interpreter, and seitz's configure step (and
# pybind11's) would otherwise pin whatever python is first on PATH -- conda's
# 3.10 on a machine that has one. The python preset points it at .venv.
if (RMC_BUILD_PYTHON)
    find_package(Python 3.11 REQUIRED COMPONENTS Interpreter Development.Module)
    # seitz as a static archive too, so _core absorbs it like RMC.
    set(SEITZ_STATIC_LIBRARY ON CACHE BOOL "" FORCE)
    # Everything linked into _core.so (RMC, mcsqs_core, a fetched spdlog) has
    # to be relocatable.
    set(CMAKE_POSITION_INDEPENDENT_CODE ON)
endif ()

# Eigen3 and Boost are declared before seitz, so its own FetchContent_Declare
# calls for them are ignored (first declaration wins) and it links these.
# Eigen3::Eigen is IMPORTED so seitz's install(EXPORT) accepts it.
FetchContent_Declare(Eigen3
        GIT_REPOSITORY https://gitlab.com/libeigen/eigen.git
        GIT_TAG 5.0.0
        GIT_SHALLOW TRUE
        SOURCE_SUBDIR _headers_only_no_cmake)
FetchContent_MakeAvailable(Eigen3)
add_library(Eigen3::Eigen INTERFACE IMPORTED GLOBAL)
set_target_properties(Eigen3::Eigen PROPERTIES INTERFACE_INCLUDE_DIRECTORIES "${eigen3_SOURCE_DIR}")
message(STATUS "Eigen: fetched 5.0.0 source at ${eigen3_SOURCE_DIR}")

# A b2-installed Boost ships CMake configs only for compiled libraries; the
# header-only ones seitz names get interface targets over Boost::headers.
FetchContent_Declare(Boost FIND_PACKAGE_ARGS 1.88 REQUIRED CONFIG COMPONENTS
        program_options serialization container graph nowide)
FetchContent_MakeAvailable(Boost)
foreach (lib leaf flyweight parser algorithm range)
    if (NOT TARGET Boost::${lib})
        add_library(Boost::${lib} INTERFACE IMPORTED GLOBAL)
        target_link_libraries(Boost::${lib} INTERFACE Boost::headers)
    endif ()
endforeach ()
# The system spdlog when there is one (locally, in CI); otherwise a static one,
# which is what the manylinux wheel container gets.
set(SPDLOG_BUILD_SHARED OFF CACHE BOOL "")
FetchContent_Declare(spdlog
        GIT_REPOSITORY https://github.com/gabime/spdlog.git
        GIT_TAG v1.15.3
        GIT_SHALLOW TRUE
        FIND_PACKAGE_ARGS 1.12)
FetchContent_MakeAvailable(spdlog)

add_library(eigen_backend INTERFACE)
if (ENABLE_MKL)
    # Eigen's LAPACKE backend supports ONLY the LP64 (32-bit int) MKL interface.
    # oneAPI defaults MKL::MKL to ILP64, which makes lapack_int 64-bit and breaks
    # Eigen's QR/permutation bindings (lapack_int* = int* in
    # ColPivHouseholderQR_LAPACKE.h). Force LP64 before find_package.
    set(MKL_INTERFACE lp64 CACHE STRING "MKL integer interface (Eigen needs lp64)")
    find_package(MKL CONFIG QUIET)
    if (MKL_FOUND)
        message(STATUS "MKL: found (oneAPI) — enabling Eigen MKL backend")
        target_compile_definitions(eigen_backend INTERFACE EIGEN_USE_MKL_ALL)
        target_link_libraries(eigen_backend INTERFACE MKL::MKL)
    else ()
        find_path(MKL_INCLUDE_DIR mkl.h PATHS /usr/include/mkl /usr/include PATH_SUFFIXES mkl)
        find_library(MKL_RT_LIB mkl_rt)
        if (MKL_INCLUDE_DIR AND MKL_RT_LIB)
            message(STATUS "MKL: found (legacy) — enabling Eigen MKL backend")
            if (NOT TARGET MKL::MKL)
                add_library(MKL::MKL IMPORTED INTERFACE)
                set_target_properties(MKL::MKL PROPERTIES
                        INTERFACE_INCLUDE_DIRECTORIES "${MKL_INCLUDE_DIR}"
                        INTERFACE_LINK_LIBRARIES "${MKL_RT_LIB}")
            endif ()
            target_compile_definitions(eigen_backend INTERFACE EIGEN_USE_MKL_ALL)
            target_link_libraries(eigen_backend INTERFACE MKL::MKL)
        else ()
            message(STATUS "MKL: not found — using default Eigen backend")
            target_compile_definitions(eigen_backend INTERFACE EIGEN_NO_MKL)
        endif ()
    endif ()
else ()
    message(STATUS "MKL: disabled by user")
    target_compile_definitions(eigen_backend INTERFACE EIGEN_NO_MKL)
endif ()

find_package(Threads REQUIRED)

# seitz (crystallography), linked against the Eigen3 and Boost declared above.
set(SEITZ_EXTERNAL_EIGEN_AND_BOOST ON)
set(SEITZ_BUILD_TESTS OFF)
set(SEITZ_BUILD_DEMO OFF)
FetchContent_Declare(Seitz
        GIT_REPOSITORY https://github.com/reach2sayan/Seitz.git
        GIT_TAG b2c96d194a469daec93b4160cdd47861f8fb8c91 # v0.1.4 + SEITZ_STATIC_LIBRARY
        SYSTEM)
FetchContent_MakeAvailable(Seitz)

# pybind11 3.x (py::native_enum), pinned like seitz pins it. FETCHCONTENT_SOURCE_
# DIR_PYBIND11 points it at a local clone for offline builds.
if (RMC_BUILD_PYTHON)
    set(PYBIND11_FINDPYTHON ON CACHE BOOL "" FORCE)
    set(PYBIND11_INSTALL OFF CACHE BOOL "" FORCE)
    set(PYBIND11_TEST OFF CACHE BOOL "" FORCE)
    FetchContent_Declare(pybind11
            GIT_REPOSITORY https://github.com/pybind/pybind11.git
            GIT_TAG v3.0.4
            GIT_SHALLOW TRUE
            SYSTEM)
    FetchContent_MakeAvailable(pybind11)
endif ()

# STATIC when the Python extension absorbs it: one module, one set of
# Boost.LEAF thread-local error slots, nothing to locate at import time.
if (RMC_BUILD_PYTHON)
    set(RMC_LIBRARY_TYPE STATIC)
else ()
    set(RMC_LIBRARY_TYPE SHARED)
endif ()
add_library(RMC ${RMC_LIBRARY_TYPE}
        src/core/Structure.cpp
        src/core/NeighborGrid.cpp
        src/core/Parallel.cpp
        src/constraints/PairConstraintBase.cpp
        src/constraints/ConstraintCollection.cpp
        src/constraints/CoordinationConstraint.cpp
        src/constraints/ClusterCorrelationConstraint.cpp
        src/constraints/AngularDistributionConstraint.cpp
        src/callbacks/Chi2CollectorCallback.cpp
        src/callbacks/PDBSnapshotCallback.cpp
        src/callbacks/HistogramCallback.cpp
        src/io/PdbReader.cpp
        src/io/DataReader.cpp
        src/io/LammpsReader.cpp
        src/io/VaspReader.cpp
        src/io/StructFormat.cpp
        src/io/Checkpoint.cpp
        src/analysis/RadialDistribution.cpp
        src/analysis/AngularDistribution.cpp
        src/core/RandomStructure.cpp
        src/Engine.cpp
        src/RMCRunner.cpp
)
target_include_directories(RMC PUBLIC
        $<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
        $<INSTALL_INTERFACE:include>
)
target_link_libraries(RMC PUBLIC
        Eigen3::Eigen
        eigen_backend
        Boost::headers
        spdlog::spdlog
        Boost::program_options
        Boost::serialization
        Boost::container
        Boost::nowide
        Threads::Threads
        seitz::seitz
)

option(RMC_USE_TBB "Use Intel TBB for parallel kernels (par_unseq) inside a shared task arena" ON)
if (RMC_USE_TBB)
    find_package(TBB REQUIRED)
    target_link_libraries(RMC PUBLIC TBB::tbb)
    target_compile_definitions(RMC PUBLIC RMC_USE_TBB)
    message(STATUS "TBB: enabled — parallel kernels run via par_unseq in a shared arena")
else ()
    message(STATUS "TBB: disabled (RMC_USE_TBB=OFF) — parallel kernels run serially")
endif ()

if (RMC_COMPILER_MSVC)
    target_compile_options(RMC PRIVATE /W4 /permissive- /Zc:__cplusplus /external:W0 /external:anglebrackets)
elseif (RMC_COMPILER_GCC OR RMC_COMPILER_CLANG)
    target_compile_options(RMC PRIVATE -Wall -Wextra -Wpedantic -Wfatal-errors)
endif ()
# RMCRunner.cpp (now in the library) uses BOOST_LEAF_AUTO, which expands to a GNU
# statement-expression; silence the resulting Clang diagnostic.
if (RMC_COMPILER_CLANG)
    target_compile_options(RMC PRIVATE -Wno-gnu-statement-expression-from-macro-expansion)
endif ()

add_executable(RMC_run src/main.cpp)
target_link_libraries(RMC_run PRIVATE RMC)

if (RMC_COMPILER_MSVC)
    target_compile_options(RMC_run PRIVATE /W4 /permissive-)
elseif (RMC_COMPILER_GCC OR RMC_COMPILER_CLANG)
    target_compile_options(RMC_run PRIVATE -Wall -Wextra -Wpedantic -Wfatal-errors)
endif ()

# Release tarballs are relocatable: bin/RMC_run finds lib/ relative to itself,
# and lib/ carries every non-glibc dependency (seitz, Boost, TBB, spdlog, fmt,
# and GCC 15's libstdc++, which stock distros do not ship yet). The RPATH is a
# DT_RPATH (--disable-new-dtags above), so it also covers indirect deps and wins
# over the system libstdc++.
# Not for the wheel, which installs COMPONENT python only.
if (CMAKE_SYSTEM_NAME STREQUAL "Linux" AND NOT RMC_BUILD_PYTHON)
    include(GNUInstallDirs)
    # seitz is listed explicitly: a target built in this tree (FetchContent) is
    # never collected as a runtime dependency unless it is itself installed.
    set_target_properties(RMC seitz PROPERTIES INSTALL_RPATH "$ORIGIN")
    set_target_properties(RMC_run PROPERTIES INSTALL_RPATH "$ORIGIN/../${CMAKE_INSTALL_LIBDIR}")
    install(TARGETS RMC RMC_run seitz COMPONENT runtime RUNTIME_DEPENDENCY_SET rmc_runtime)
    install(RUNTIME_DEPENDENCY_SET rmc_runtime COMPONENT runtime
            DESTINATION ${CMAKE_INSTALL_LIBDIR}
            PRE_EXCLUDE_REGEXES "^(ld-linux-x86-64|libc|libm|libdl|libpthread|librt)\\.so")
endif ()

option(RMC_BUILD_MCSQS "Build mcsqs_rmc SQS extension" ON)
if (RMC_BUILD_MCSQS)
    add_subdirectory(extensions/mcsqs)
endif ()

option(RMC_BUILD_EXAMPLES "Build the bundled C++ examples" ON)
if (RMC_BUILD_EXAMPLES)
    add_subdirectory(examples)
endif ()

include(CTest)
if (BUILD_TESTING AND RMC_BUILD_TESTS)
    find_package(Catch2 3 REQUIRED)
    add_executable(RMC_tests
            tests/test_boundary_conditions.cpp
            tests/test_generators.cpp
            tests/test_selectors.cpp
            tests/test_constraints.cpp
            tests/test_engine.cpp
            tests/test_examples.cpp
            tests/test_species_swap.cpp
            tests/test_sampler.cpp
            tests/test_io_readers.cpp
            tests/test_radial_distribution.cpp
            tests/test_angular_distribution.cpp
            tests/test_callbacks.cpp
            tests/bench_scaling.cpp
            tests/bench_thf.cpp
            tests/bench_adf.cpp
    )
    if (TARGET mcsqs_core) # the SQS lattice/cluster pipeline under test
        target_sources(RMC_tests PRIVATE tests/test_atat_pipeline.cpp)
        target_link_libraries(RMC_tests PRIVATE mcsqs_core)
    endif ()
    target_compile_definitions(RMC_tests PRIVATE
            THF_BENCHMARK_DATA_DIR="${CMAKE_SOURCE_DIR}/examples/benchmark/data")
    target_link_libraries(RMC_tests PRIVATE RMC Catch2::Catch2WithMain)
    if (RMC_COMPILER_MSVC)
        target_compile_options(RMC_tests PRIVATE
                /W4 /permissive- /Zc:__cplusplus /external:W0 /external:anglebrackets)
        set(CMAKE_WINDOWS_EXPORT_ALL_SYMBOLS ON)
        foreach (tgt Catch2 Catch2WithMain)
            if (TARGET ${tgt})
                # Skip alias/imported targets: target_compile_options can only set
                # INTERFACE properties on those, so only the real buildable targets
                # (the non-namespaced FetchContent ones) get the warning overrides.
                get_target_property(_tgt_aliased ${tgt} ALIASED_TARGET)
                get_target_property(_tgt_imported ${tgt} IMPORTED)
                if (NOT _tgt_aliased AND NOT _tgt_imported)
                    target_compile_options(${tgt} PRIVATE /W0)
                    set_target_properties(${tgt} PROPERTIES COMPILE_WARNING_AS_ERROR OFF)
                endif ()
            endif ()
        endforeach ()
    elseif (RMC_COMPILER_GCC OR RMC_COMPILER_CLANG)
        target_compile_options(RMC_tests PRIVATE -Wall -Wextra -Wpedantic -Wfatal-errors)
    endif ()
    include(Catch)
    # Catch2 exits 4 when every selected test SKIP()s (the corrdump oracle
    # without RMC_CORRDUMP); report that as skipped, not failed.
    catch_discover_tests(RMC_tests DISCOVERY_MODE POST_BUILD
            PROPERTIES SKIP_RETURN_CODE 4)
endif ()

# Last, after include(CTest), so the pytest suite registers as a ctest.
if (RMC_BUILD_PYTHON)
    if (NOT TARGET mcsqs_core)
        message(FATAL_ERROR "RMC_BUILD_PYTHON needs RMC_BUILD_MCSQS=ON (rmc.mcsqs binds mcsqs_core)")
    endif ()
    add_subdirectory(python)
endif ()
