cmake_minimum_required(VERSION 3.19)

# ##############################################################################
# Import CMake modules
# ##############################################################################
include(CheckFunctionExists)
include(CheckCXXSourceCompiles)
include(CheckCXXSourceRuns)

# ##############################################################################
# Check if the source directory is different from the build directory This is to
# prevent in-source builds.
# ##############################################################################
if("${CMAKE_SOURCE_DIR}" STREQUAL "${CMAKE_CURRENT_BINARY_DIR}")
  message(FATAL_ERROR "The build directory must be different from the \
        root directory of this software.")
endif()

# ##############################################################################
# Configure additional paths for CMake to search for custom modules
# ##############################################################################
set(CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/cmake"
                      "${CMAKE_MODULE_PATH}")

# ##############################################################################
# Project settings
# ##############################################################################
project(pyinterp LANGUAGES CXX)

# ##############################################################################
# CMake policies
# ##############################################################################
if(POLICY CMP0048)
  cmake_policy(SET CMP0048 NEW)
endif()

if(POLICY CMP0063)
  cmake_policy(SET CMP0063 NEW)
endif()

if(POLICY CMP0074)
  cmake_policy(SET CMP0074 NEW)
endif()

if(POLICY CMP0077)
  cmake_policy(SET CMP0077 NEW)
endif()

if(POLICY_CMP0144)
  cmake_policy(SET CMP0144 NEW)
endif()

if(POLICY CMP0167)
  cmake_policy(SET CMP0167 NEW)
endif()

# ##############################################################################
# Configure compiler flags and options
# ##############################################################################

# Define the build type Asan to enable address sanitizer if the compiler matches
# the requirements (GCC or Clang)
if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang")
  set(SANITIZE "address,undefined")
  if(UNIX AND NOT APPLE)
    set(SANITIZE "${SANITIZE},leak")
  endif()

  set(CMAKE_C_FLAGS_ASAN
      "${CMAKE_C_FLAGS_RELWITHDEBINFO} -fsanitize=${SANITIZE} \
      -fno-omit-frame-pointer -fno-common"
      CACHE STRING "" FORCE)
  set(CMAKE_CXX_FLAGS_ASAN
      "${CMAKE_CXX_FLAGS_RELWITHDEBINFO} -fsanitize=${SANITIZE} \
      -fno-omit-frame-pointer -fno-common"
      CACHE STRING "" FORCE)
  set(CMAKE_EXE_LINKER_FLAGS_ASAN
      "${CMAKE_EXE_LINKER_FLAGS_RELWITHDEBINFO} -fsanitize=${SANITIZE}"
      CACHE STRING "" FORCE)
  set(CMAKE_SHARED_LINKER_FLAGS_ASAN
      "${CMAKE_SHARED_LINKER_FLAGS_RELWITHDEBINFO} -fsanitize=${SANITIZE}"
      CACHE STRING "" FORCE)
endif()

# By default, build type is set to release, with debugging information.
if(NOT CMAKE_BUILD_TYPE)
  set(CMAKE_BUILD_TYPE
      "RelWithDebInfo"
      CACHE STRING "Choose the type of build" FORCE)
endif()

set_property(
  CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS "Debug" "Release" "MinSizeRel"
                                  "RelWithDebInfo" "ASan")

message(STATUS "Build type: ${CMAKE_BUILD_TYPE}")

if(ENV{CONDA_BUILD})
  # Workaround for macOS SDK compatibility: Disables availability checks for the
  # "to_chars" function, which requires macOS 13.4+. This allows compilation in
  # conda-forge CI environments with older SDKs. Reference:
  # https://conda-forge.org/docs/maintainer/knowledge_base, Newer C++ features
  # with old SDK.
  add_definitions(-D_LIBCPP_DISABLE_AVAILABILITY)
endif()

# The library must be built using C++23 compiler.
set(CMAKE_CXX_STANDARD 23)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_MACOSX_RPATH 1)

include(CheckCXXCompilerFlag)
if(NOT WIN32)
  check_cxx_compiler_flag("-std=c++23" HAS_CPP23_FLAG)
else()
  check_cxx_compiler_flag("/std:c++latest" HAS_CPP23_FLAG)
endif()
if(NOT HAS_CPP23_FLAG)
  message(FATAL_ERROR "Unsupported compiler -- requires C++23 support!")
endif()

# Check if the C++ compiler and linker flags are set correctly.
macro(CHECK_CXX_COMPILER_AND_linker_flags result cxx_flags linker_flags)
  set(CMAKE_REQUIRED_FLAGS ${cxx_flags})
  set(CMAKE_REQUIRED_LIBRARIES ${linker_flags})
  set(CMAKE_REQUIRED_QUIET FALSE)
  if(CMAKE_CROSSCOMPILING OR DEFINED ENV{CONDA_BUILD_CROSS_COMPILATION})
    check_cxx_source_compiles("int main(int argc, char **argv) { return 0; }"
                              ${result})
  else()
    check_cxx_source_runs("int main(int argc, char **argv) { return 0; }"
                          ${result})
  endif()
  set(CMAKE_REQUIRED_FLAGS "")
  set(CMAKE_REQUIRED_LIBRARIES "")
  unset(result)
endmacro()

# Always use libc++ on Clang
if(CMAKE_CXX_COMPILER_ID MATCHES "Clang")
  check_cxx_compiler_and_linker_flags(HAS_LIBCPP "-stdlib=libc++"
                                      "-stdlib=libc++")
  if(HAS_LIBCPP)
    string(APPEND CMAKE_CXX_FLAGS " -stdlib=libc++")
    string(APPEND CMAKE_EXE_linker_flags " -stdlib=libc++")
    string(APPEND CMAKE_SHARED_linker_flags " -stdlib=libc++")
    check_cxx_compiler_and_linker_flags(HAS_LIBCPPABI "-stdlib=libc++"
                                        "-stdlib=libc++ -lc++abi")
    if(HAS_LIBCPPABI)
      string(APPEND CMAKE_EXE_linker_flags " -lc++abi")
      string(APPEND CMAKE_SHARED_linker_flags " -lc++abi")
    endif()
  endif()
  check_cxx_compiler_and_linker_flags(HAS_SIZED_DEALLOCATION
                                      "-fsized-deallocation" "")
  if(HAS_SIZED_DEALLOCATION)
    string(APPEND CMAKE_CXX_FLAGS " -fsized-deallocation")
  endif()
endif()

if(NOT WIN32)
  if(NOT CMAKE_CXX_FLAGS MATCHES "-Wall$")
    string(APPEND CMAKE_CXX_FLAGS " -Wall")
  endif()
  if(NOT CMAKE_CXX_COMPILER MATCHES "icpc$" AND NOT CMAKE_CXX_FLAGS MATCHES
                                                "-Wpedantic$")
    string(APPEND CMAKE_CXX_FLAGS " -Wpedantic")
  endif()

  # Suppress warnings on ARM64 NEON code
  if(CMAKE_SYSTEM_PROCESSOR MATCHES "aarch64|ARM64")
    # Suppress psabi warning due to Boost Geometry ABI changes
    string(APPEND CMAKE_CXX_FLAGS " -Wno-psabi")
    # Suppress class-memaccess warning in Eigen NEON packet math
    string(APPEND CMAKE_CXX_FLAGS " -Wno-class-memaccess")
  endif()
endif()

if(MSVC)
  add_compile_options(/MP)
  # Disable warnings about using deprecated std::equal_to<>::result_type
  add_definitions(-D_SILENCE_CXX17_ADAPTOR_TYPEDEFS_DEPRECATION_WARNING)
  # Disable auto-linking and use cmake's dependency handling
  add_definitions(-DBOOST_ALL_NO_LIB)
endif()

check_function_exists(pow POW_FUNCTION_EXISTS)
if(NOT POW_FUNCTION_EXISTS)
  unset(POW_FUNCTION_EXISTS CACHE)
  list(APPEND CMAKE_REQUIRED_LIBRARIES m)
  check_function_exists(pow POW_FUNCTION_EXISTS)
  if(POW_FUNCTION_EXISTS)
    set(MATH_LIBRARY
        m
        CACHE STRING "" FORCE)
  else()
    message(FATAL_ERROR "Failed making the pow() function available")
  endif()
endif()

# Check if floating-point types fulfill the requirements of IEC 559 (IEEE 754)
# standard
macro(CHECK_FLOATING_POINT_IS_IEC559)
  message(STATUS "Performing Test HAVE_IEC559")
  check_cxx_source_compiles(
    "#include <limits>
int main() {
  static_assert(std::numeric_limits<double>::is_iec559,
                \"double must satisfy IEC 559\");
  return 0;
}"
    HAVE_IEC559)
  if(HAVE_IEC559)
    message(STATUS "Performing Test HAVE_IEC559 - Success")
    add_definitions(-DHAVE_IEC559)
  else()
    message(STATUS "Performing Test HAVE_IEC559 - Failed")
  endif()
  unset(HAVE_IEC559)
endmacro()

check_floating_point_is_iec559()

# ##############################################################################
# Configure the build options
# ##############################################################################
option(ENABLE_COVERAGE "Enable coverage reporting" OFF)
option(ENABLE_CLANG_TIDY "Enable clang-tidy" OFF)

# ##############################################################################
# FFT implementation selection
# ##############################################################################
set(FFT_IMPLEMENTATION
    "pocketfft"
    CACHE STRING "Choose the FFT implementation: mkl, pocketfft")
set(_choices "mkl" "pocketfft")
set_property(CACHE FFT_IMPLEMENTATION PROPERTY STRINGS _choices)

if(NOT ${FFT_IMPLEMENTATION} IN_LIST _choices)
  message(FATAL_ERROR "Invalid FFT implementation. Choose between mkl "
                      "and pocketfft not ${FFT_IMPLEMENTATION}.")
endif()

# ##############################################################################
# Code Coverage Configuration
# ##############################################################################
add_library(cpp_coverage INTERFACE)

if(ENABLE_COVERAGE)
  if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang")
    set(CMAKE_MODULE_PATH
        "${CMAKE_CURRENT_SOURCE_DIR}/third_party/CMake-codecov/cmake"
        "${CMAKE_MODULE_PATH}")
    find_package(codecov)
    target_compile_options(cpp_coverage INTERFACE -O0 -g --coverage)
    if(CMAKE_VERSION VERSION_GREATER_EQUAL 3.13)
      target_link_options(cpp_coverage INTERFACE --coverage)
    else()
      target_link_libraries(cpp_coverage INTERFACE --coverage)
    endif()
  else()
    message(
      FATAL_ERROR
        "Code coverage is only supported with GCC and Clang compilers.")
  endif()
endif()

# ##############################################################################
# Configure the project dependencies
# ##############################################################################

# Threads
if(NOT WIN32)
  set(CMAKE_THREAD_PREFER_PTHREAD TRUE)
  find_package(Threads REQUIRED)
endif()

# Python
if(CMAKE_CROSSCOMPILING OR DEFINED ENV{CONDA_BUILD_CROSS_COMPILATION})
  if(NOT DEFINED PYTHON_ROOT_DIR AND DEFINED ENV{PREFIX})
    set(PYTHON_ROOT_DIR "$ENV{PREFIX}")
  endif()
  set(Python_FIND_STRATEGY LOCATION) # cmake-lint: disable=C0103

  # Cross-compiling: resolve target Python development artifacts without
  # coupling them to the host interpreter architecture.
  find_package(
    Python
    COMPONENTS Development.Module
    OPTIONAL_COMPONENTS Development.SABIModule
    QUIET)

  if(NOT TARGET Python::Module)
    find_path(
      Python_INCLUDE_DIRS
      NAMES Python.h
      HINTS "${PYTHON_ROOT_DIR}/include" "$ENV{PREFIX}/include")
    if(NOT Python_INCLUDE_DIRS)
      message(FATAL_ERROR "Could not locate target Python headers (Python.h) "
                          "for cross-compilation.")
    endif()
    add_library(Python::Module INTERFACE IMPORTED)
    # cmake-format: off
    set_target_properties(
      Python::Module
      PROPERTIES
        INTERFACE_INCLUDE_DIRECTORIES
        "${Python_INCLUDE_DIRS}"
    )
    # cmake-format: on
  endif()

  if(DEFINED PYINTERP_HOST_PYTHON_EXECUTABLE
     AND NOT "${PYINTERP_HOST_PYTHON_EXECUTABLE}" STREQUAL "")
    set(Python_EXECUTABLE # cmake-lint: disable=C0103
        "${PYINTERP_HOST_PYTHON_EXECUTABLE}"
        CACHE FILEPATH "Host Python executable for nanobind tools" FORCE)
  endif()

  if(NOT DEFINED Python_VERSION AND DEFINED Python_EXECUTABLE)
    execute_process(
      COMMAND "${Python_EXECUTABLE}" -c "import sys; vinfo = sys.version_info;"
              "print(f'{vinfo[0]}.{vinfo[1]}.{vinfo[2]}')"
      OUTPUT_VARIABLE Python_VERSION
      OUTPUT_STRIP_TRAILING_WHITESPACE)
  endif()

  # nanobind requires Python::Interpreter for helper commands (stubgen, etc.).
  # Use the host interpreter passed from setup.py.
  if(NOT TARGET Python::Interpreter)
    add_executable(Python::Interpreter IMPORTED)
    # cmake-format: off
    set_target_properties(Python::Interpreter PROPERTIES
                          IMPORTED_LOCATION "${Python_EXECUTABLE}")
    # cmake-format: on
  endif()
else()
  find_package(
    Python
    COMPONENTS Interpreter Development.Module
    OPTIONAL_COMPONENTS Development.SABIModule
    REQUIRED)
endif()
include_directories(SYSTEM PRIVATE ${Python_INCLUDE_DIRS})

# BLAS
#
# If the environment variable MKLROOT is defined, we will try to use MKL as a
# dynamic library first. If it fails, we will try to use MKL lp64 model with
# sequential code. If MKL is not found, we will try to use OpenBLAS or Apple
# Accelerate framework. If none of them is found, we will use the generic
# implementation of BLAS provided by Eigen.
if(DEFINED ENV{MKLROOT})
  # First try to use MKL as a single dynamic library (conda-forge)
  set(BLA_VENDOR Intel10_64_dyn)
  find_package(BLAS)
  if(NOT BLAS_FOUND)
    # Otherwise try to use MKL lp64 model with sequential code
    set(BLA_VENDOR Intel10_64lp_seq)
    find_package(BLAS)
  endif()
endif()

if(BLAS_FOUND)
  # MKL
  if(DEFINED ENV{MKLROOT})
    find_path(
      MKL_INCLUDE_DIR
      NAMES mkl.h
      HINTS $ENV{MKLROOT}/include)
    if(MKL_INCLUDE_DIR)
      add_definitions(-DEIGEN_USE_MKL_ALL)
      add_definitions(-DMKL_LP64)
      include_directories(SYSTEM PRIVATE ${MKL_INCLUDE_DIR})
    endif()
  endif()
else()
  set(BLA_VENDOR_LIST "Apple" "OpenBLAS" "Generic")
  foreach(item IN LISTS BLA_VENDOR_LIST)
    set(BLA_VENDOR ${item})
    find_package(BLAS)
    if(BLAS_FOUND)
      break()
    endif()
  endforeach()
  if(BLAS_FOUND)
    add_definitions(-DEIGEN_USE_BLAS)
  else()
    message(
      WARNING "No BLAS library found. Eigen uses its own BLAS implementation.")
    set(BLAS_LIBRARIES "")
    if(NOT TARGET BLAS::BLAS)
      add_library(BLAS::BLAS INTERFACE IMPORTED)
      set_target_properties(BLAS::BLAS PROPERTIES INTERFACE_LINK_LIBRARIES "")
    endif()
  endif()
endif()

# Eigen3
include_directories(SYSTEM PRIVATE
                    ${CMAKE_CURRENT_SOURCE_DIR}/third_party/eigen)

# GoogleTest
include(CTest)
find_package(GTest)

# ##############################################################################
# Parse FFT implementation configuration
# ##############################################################################
# MKL-FFT
if(FFT_IMPLEMENTATION STREQUAL "mkl")
  if(BLAS_FOUND AND BLA_VENDOR MATCHES "Intel")
    find_path(
      MKL_INCLUDE_DIR
      NAMES mkl_dfti.h
      HINTS $ENV{MKLROOT}/include $ENV{CONDA_PREFIX}/include)
    if(MKL_INCLUDE_DIR)
      add_definitions(-DUSE_MKL_DTFI)
      set(DUSE_MKL_DTFI ON)
      set(FFT_LIBRARIES BLAS::BLAS)
    else()
      message(
        FATAL_ERROR "MKL not found. Cannot use MKL as the FFT implementation.")
    endif()
  else()
    message(
      FATAL_ERROR "MKL not found. Cannot use MKL as the FFT implementation.")
  endif()
endif()

# MKL does not provide a Discrete Cosine Transform (DCT). Always use pocketfft
# for DCTs even when MKL is chosen as the FFT backend.
include_directories(SYSTEM PRIVATE
                    ${CMAKE_CURRENT_SOURCE_DIR}/third_party/pocketfft)
if(FFT_IMPLEMENTATION STREQUAL "pocketfft")
  set(FFT_LIBRARIES)
endif()

# When building Python bindings with a static library, we need to set symbol
# visibility to hidden to prevent symbol conflicts in the Python module. This
# avoids collisions between symbols from different libraries when the Python
# extension is loaded.
set(CMAKE_VISIBILITY_INLINES_HIDDEN ON)
set(CMAKE_CXX_VISIBILITY_PRESET hidden)
set(CMAKE_POSITION_INDEPENDENT_CODE ON)

# ##############################################################################
# Enable clang-tidy if requested (must be before add_subdirectory)
# ##############################################################################
if(ENABLE_CLANG_TIDY)
  find_program(
    CLANG_TIDY_EXE
    NAMES "clang-tidy"
    DOC "/usr/bin/clang-tidy")
  if(NOT CLANG_TIDY_EXE)
    message(
      FATAL_ERROR
        "clang-tidy not found. Please set CLANG_TIDY_EXE to clang-tidy "
        "executable.")
  endif()
  string(
    CONCAT
      CLANG_TIDY_CMD
      "clang-tidy;-checks=-*,boost-*,concurrency-*,modernize-*,performance-*,"
      "clang-analyzer-*,portability-*,-portability-simd-intrinsics,google-*,"
      ";-fix")
  set(CMAKE_CXX_CLANG_TIDY "${CLANG_TIDY_CMD}")

  unset(THIRD_PARTY)
  unset(CLANG_TIDY_EXE CACHE)
  unset(CLANG_TIDY_CMD CACHE)
endif()

# ##############################################################################
# Add subdirectories
# ##############################################################################
add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/third_party/nanobind)
include_directories(SYSTEM
                    ${CMAKE_CURRENT_SOURCE_DIR}/third_party/nanobind/include)

# ##############################################################################
# External dependencies
# ##############################################################################

# Helper function to resolve the Boost include directory if find_package fails.
# It looks for boost/version.hpp in the Boost include directories and in the
# CMAKE_PREFIX_PATH.
function(resolve_boost_include_dir out_var)
  set(boost_search_hints)
  if(Boost_INCLUDE_DIRS)
    list(APPEND boost_search_hints "${Boost_INCLUDE_DIRS}")
  endif()
  foreach(prefix IN LISTS CMAKE_PREFIX_PATH)
    list(APPEND boost_search_hints "${prefix}/include" "${prefix}")
  endforeach()

  find_path(
    _BOOST_INCLUDE_DIR
    NAMES boost/version.hpp
    HINTS ${boost_search_hints})

  set(${out_var}
      "${_BOOST_INCLUDE_DIR}"
      PARENT_SCOPE)
endfunction()

# Helper function to check the Boost version by reading the boost/version.hpp
# file
function(check_boost_version boost_include_dir min_version)
  if(NOT EXISTS "${boost_include_dir}/boost/version.hpp")
    message(FATAL_ERROR "Boost version.hpp not found at "
                        "${boost_include_dir}/boost/version.hpp")
  endif()

  file(READ "${boost_include_dir}/boost/version.hpp" BOOST_VERSION_CONTENT)
  string(REGEX MATCH "BOOST_LIB_VERSION \"([0-9_]+)\"" _
               "${BOOST_VERSION_CONTENT}")
  set(boost_version_string "${CMAKE_MATCH_1}")
  message(STATUS "Boost version: ${boost_version_string}")

  # Parse version: e.g., "1_90_0" -> 1.90.0
  string(REPLACE "_" "." BOOST_VERSION_FORMATTED "${boost_version_string}")
  if(BOOST_VERSION_FORMATTED VERSION_LESS "${min_version}")
    message(FATAL_ERROR "Boost version must be at least ${min_version}, "
                        "found ${BOOST_VERSION_FORMATTED}")
  endif()
endfunction()

# Search for Boost using find_package. If it fails, try to resolve the include
# directory manually and check the version.
find_package(Boost 1.90 QUIET)

if(NOT Boost_FOUND)
  resolve_boost_include_dir(Boost_INCLUDE_DIRS)

  if(NOT Boost_INCLUDE_DIRS)
    message(
      FATAL_ERROR
        "Boost not found with find_package, and boost/version.hpp was not "
        "found in Boost_INCLUDE_DIRS/CMAKE_PREFIX_PATH.")
  endif()

  message(STATUS "Boost headers: ${Boost_INCLUDE_DIRS}")
  check_boost_version("${Boost_INCLUDE_DIRS}" "1.90.0")
endif()

include_directories(SYSTEM PRIVATE ${Boost_INCLUDE_DIRS})

add_subdirectory(cxx)

# ##############################################################################
# Disable clang-tidy for third-party targets (after add_subdirectory)
# ##############################################################################
if(ENABLE_CLANG_TIDY)
  set_target_properties(nanobind-static PROPERTIES CXX_CLANG_TIDY "")
endif()

# ##############################################################################
# Set up coverage
# ##############################################################################
if(ENABLE_COVERAGE)
  list(APPEND LCOV_REMOVE_PATTERNS "\"${PROJECT_SOURCE_DIR}/cxx/tests/*\"")
  list(APPEND LCOV_REMOVE_PATTERNS "\"${PROJECT_SOURCE_DIR}/third_party/*\"")
  coverage_evaluate()
endif()

# ##############################################################################
# Print configuration summary
# ##############################################################################

# Helper function to format boolean option
function(format_enabled value_ result_var)
  if(${value_})
    set(${result_var}
        "ON"
        PARENT_SCOPE)
  else()
    set(${result_var}
        "OFF"
        PARENT_SCOPE)
  endif()
endfunction()

format_enabled(ENABLE_COVERAGE CODE_COVERAGE_LINE)
format_enabled(ENABLE_CLANG_TIDY CLANG_TIDY_LINE)

set(CONFIG_LINES
    "==================[ PyInterp Configuration Summary ]=================="
    "Platform:               ${CMAKE_SYSTEM_NAME} ${CMAKE_SYSTEM_PROCESSOR}"
    "Build type:             ${CMAKE_BUILD_TYPE}"
    "CMake Version:          ${CMAKE_VERSION}"
    "C++ flags:              ${CMAKE_CXX_FLAGS}"
    "C++ Standard Required:  ${CMAKE_CXX_STANDARD_REQUIRED}"
    "C++ Standard:           ${CMAKE_CXX_STANDARD}"
    "Linker flags:           ${CMAKE_EXE_LINKER_FLAGS}"
    "Compiler ID:            ${CMAKE_CXX_COMPILER_ID}"
    "Compiler Version:       ${CMAKE_CXX_COMPILER_VERSION}"
    "Boost include dir:      ${Boost_INCLUDE_DIRS}"
    "FFT Implementation:     ${FFT_IMPLEMENTATION}"
    "BLAS:                   ${BLA_VENDOR}"
    "Code Coverage:          ${CODE_COVERAGE_LINE}"
    "Clang-Tidy:             ${CLANG_TIDY_LINE}"
    "Python:                 ${Python_VERSION}"
    "======================================================================")

foreach(line ${CONFIG_LINES})
  message(STATUS "${line}")
endforeach()

unset(CODE_COVERAGE_LINE)
unset(CLANG_TIDY_LINE)
unset(CONFIG_LINES)
