cmake_minimum_required(VERSION 3.18...3.31)
project(mdapy LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)

option(MDAPY_WERROR "Treat compiler warnings as errors" OFF)
option(MDAPY_INDEX64 "64-bit atom indices, for more than 2^31 - 1 atoms" OFF)
option(MDAPY_METAL "Pictures traced on the GPU with Metal (macOS only)" ON)
option(MDAPY_VULKAN "Pictures traced on the GPU with Vulkan (Linux only)" ON)
option(MDAPY_D3D12 "Pictures traced on the GPU with Direct3D 12 (Windows only)" ON)
set(MDAPY_SANITIZE "" CACHE STRING
  "Sanitizer build: 'address' (AddressSanitizer + UndefinedBehaviorSanitizer) or 'thread' (ThreadSanitizer)")

if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)
  set(CMAKE_BUILD_TYPE Release CACHE STRING "Build type" FORCE)
endif()

# SKBUILD_SABI_COMPONENT is set by scikit-build-core when building an abi3 wheel.
find_package(Python 3.11 REQUIRED
  COMPONENTS Interpreter Development.Module ${SKBUILD_SABI_COMPONENT})
find_package(nanobind CONFIG REQUIRED)
find_package(Threads REQUIRED)

# A single extension module, mdapy._core, holds all compiled code. Each
# functional area registers its own submodule from src/cpp/bindings/.
file(GLOB_RECURSE MDAPY_CPP_SOURCES CONFIGURE_DEPENDS
  "${CMAKE_CURRENT_SOURCE_DIR}/src/cpp/*.cpp")

# NB_SUPPRESS_WARNINGS marks nanobind's headers as system headers, so the strict
# warning flags below (and -Werror) apply to mdapy's own code only. NOMINSIZE
# keeps the optimization of the build type: nanobind would otherwise compile
# the whole module, kernels included, for size with -Os.
nanobind_add_module(_core STABLE_ABI NB_STATIC NB_SUPPRESS_WARNINGS NOMINSIZE
  ${MDAPY_CPP_SOURCES})
target_include_directories(_core PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/src/cpp")
target_link_libraries(_core PRIVATE Threads::Threads)
if(MDAPY_INDEX64)
  target_compile_definitions(_core PRIVATE MDAPY_INDEX64)
endif()
# The rays of pictures on the GPU: Objective-C++ for Metal, whose shader is
# compiled when first used, so the build needs no Metal compiler.
if(APPLE AND MDAPY_METAL)
  enable_language(OBJCXX)
  target_sources(_core PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/src/cpp/visualization/render_metal.mm")
  set_source_files_properties("${CMAKE_CURRENT_SOURCE_DIR}/src/cpp/visualization/render_metal.mm"
    PROPERTIES COMPILE_OPTIONS "-fobjc-arc")
  target_compile_definitions(_core PRIVATE MDAPY_METAL)
  target_link_libraries(_core PRIVATE "-framework Metal" "-framework Foundation")
endif()
# On Linux with Vulkan, whose loader is opened when first used, so the build
# needs its headers only and mdapy runs without it. The shader is the SPIR-V
# of render_vulkan.comp, held in the sources: the build needs no compiler.
if(CMAKE_SYSTEM_NAME STREQUAL "Linux" AND MDAPY_VULKAN)
  find_path(MDAPY_VULKAN_INCLUDE_DIR vulkan/vulkan_core.h)
  if(MDAPY_VULKAN_INCLUDE_DIR)
    # kSpirv, and kSpirvRayQuery for the ray tracing units
    set(spirv_arrays "")
    foreach(pair "render_vulkan:kSpirv" "render_vulkan_rt:kSpirvRayQuery")
      string(REPLACE ":" ";" pair "${pair}")
      list(GET pair 0 spirv_name)
      list(GET pair 1 spirv_array)
      set(spirv "${CMAKE_CURRENT_SOURCE_DIR}/src/cpp/visualization/${spirv_name}.spv")
      set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${spirv}")
      file(READ "${spirv}" spirv_bytes HEX)
      string(REGEX REPLACE "([0-9a-f][0-9a-f])" "0x\\1," spirv_bytes "${spirv_bytes}")
      string(APPEND spirv_arrays "const unsigned char ${spirv_array}[] = {${spirv_bytes}};\n")
    endforeach()
    file(CONFIGURE OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/generated/visualization/render_vulkan_spirv.h"
      CONTENT "// render_vulkan.spv and render_vulkan_rt.spv, written by CMake\n#pragma once\n${spirv_arrays}"
      @ONLY)
    target_include_directories(_core PRIVATE "${CMAKE_CURRENT_BINARY_DIR}/generated")
    target_include_directories(_core SYSTEM PRIVATE "${MDAPY_VULKAN_INCLUDE_DIR}")
    target_compile_definitions(_core PRIVATE MDAPY_VULKAN)
    target_link_libraries(_core PRIVATE ${CMAKE_DL_LIBS})
  else()
    message(STATUS "No Vulkan headers: pictures are traced on the processor only")
  endif()
endif()
# On Windows with Direct3D 12, whose libraries are opened when first used, so the
# build needs the headers of the Windows SDK only and mdapy runs without them. The
# shader is the DXIL of render_d3d12.hlsl, held in the sources: the build needs
# no compiler.
if(WIN32 AND MDAPY_D3D12)
  # kDxil, and kDxilRayQuery for the ray tracing units
  set(dxil_arrays "")
  foreach(pair "render_d3d12:kDxil" "render_d3d12_rt:kDxilRayQuery")
    string(REPLACE ":" ";" pair "${pair}")
    list(GET pair 0 dxil_name)
    list(GET pair 1 dxil_array)
    set(dxil "${CMAKE_CURRENT_SOURCE_DIR}/src/cpp/visualization/${dxil_name}.dxil")
    set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${dxil}")
    file(READ "${dxil}" dxil_bytes HEX)
    string(REGEX REPLACE "([0-9a-f][0-9a-f])" "0x\\1," dxil_bytes "${dxil_bytes}")
    string(APPEND dxil_arrays "const unsigned char ${dxil_array}[] = {${dxil_bytes}};\n")
  endforeach()
  file(CONFIGURE OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/generated/visualization/render_d3d12_dxil.h"
    CONTENT "// render_d3d12.dxil and render_d3d12_rt.dxil, written by CMake\n#pragma once\n${dxil_arrays}"
    @ONLY)
  target_include_directories(_core PRIVATE "${CMAKE_CURRENT_BINARY_DIR}/generated")
  target_compile_definitions(_core PRIVATE MDAPY_D3D12)
endif()

if(MSVC)
  target_compile_options(_core PRIVATE /W4 /permissive- /EHsc)
  if(MDAPY_WERROR)
    target_compile_options(_core PRIVATE /WX)
  endif()
else()
  # No fused multiply-add contraction: results are the same bits with every
  # compiler and processor.
  target_compile_options(_core PRIVATE -Wall -Wextra -Wpedantic -Wshadow -ffp-contract=off)
  if(MDAPY_WERROR)
    target_compile_options(_core PRIVATE -Werror)
  endif()
  if(MDAPY_SANITIZE STREQUAL "address")
    target_compile_options(_core PRIVATE
      -fsanitize=address,undefined -fno-omit-frame-pointer -fno-sanitize-recover=undefined)
    target_link_options(_core PRIVATE -fsanitize=address,undefined)
  elseif(MDAPY_SANITIZE STREQUAL "thread")
    target_compile_options(_core PRIVATE -fsanitize=thread -fno-omit-frame-pointer)
    target_link_options(_core PRIVATE -fsanitize=thread)
  elseif(NOT MDAPY_SANITIZE STREQUAL "")
    message(FATAL_ERROR "MDAPY_SANITIZE must be empty, 'address' or 'thread'")
  endif()
endif()

install(TARGETS _core LIBRARY DESTINATION mdapy)
