cmake_minimum_required(VERSION 3.24)
project(leo_dynamics VERSION 0.1.0 LANGUAGES CXX)
set(CMAKE_CXX_EXTENSIONS OFF)
option(BUILD_TESTING "Build analytical tests" OFF)
option(LEO_BUILD_INTEGRATION "Build Boost.Odeint integration adapter" ON)
option(LEO_BUILD_EXAMPLES "Build propagation example" OFF)
option(LEO_ENABLE_CSPICE "Build explicit CSPICE Sun ephemeris provider" OFF)
option(LEO_BUILD_ANALYSIS "Build trajectory postprocessing and CSV export" ON)
option(LEO_TEST_PLOTTING "Run Python Matplotlib smoke tests (requires BUILD_TESTING)" OFF)
option(LEO_ENABLE_NRLMSISE00 "Build direct NRLMSISE-00 C reference adapter" OFF)
option(LEO_ENABLE_JB2008 "Build direct external official JB2008 Fortran adapter" OFF)
option(LEO_BUILD_PYTHON_BINDINGS "Build the private pybind11 extension for a local development import" OFF)
option(LEO_PROVISION_DEPENDENCIES "Build against the pinned, hash-verified Eigen and Boost archives instead of host packages" OFF)
if(SKBUILD)
  # Packaging is always the approved public component, even in the full private
  # checkout. Config-setting overrides must not compile/link private providers.
  if(LEO_ENABLE_CSPICE OR LEO_ENABLE_NRLMSISE00 OR LEO_ENABLE_JB2008
      OR LEO_BUILD_EXAMPLES OR BUILD_TESTING OR LEO_TEST_PLOTTING)
    message(FATAL_ERROR "Public package builds refuse the deferred providers, examples and test/research material")
  endif()
  if(NOT LEO_PROVISION_DEPENDENCIES)
    message(FATAL_ERROR "Public package builds require pinned dependency provisioning")
  endif()
endif()
set(LEO_PYTHON_BINDING_DIR "${CMAKE_BINARY_DIR}/python/leo_dynamics"
    CACHE PATH "Directory receiving the _core extension and the staged Python facade")
if(LEO_BUILD_PYTHON_BINDINGS)
  # The extension links the static engine targets, which therefore need PIC.
  set(CMAKE_POSITION_INDEPENDENT_CODE ON)
endif()
if(LEO_BUILD_EXAMPLES AND NOT LEO_BUILD_INTEGRATION)
  message(FATAL_ERROR "LEO_BUILD_EXAMPLES requires LEO_BUILD_INTEGRATION")
endif()
if(LEO_PROVISION_DEPENDENCIES)
  # Pinned, hash-verified archives: no host Eigen or Boost tree is consulted.
  include(cmake/provision.cmake)
else()
  find_package(Eigen3 3.4 REQUIRED NO_MODULE)
endif()
# Record resolved header dependencies for experiment provenance, not as user defaults.
get_target_property(_leo_eigen_includes Eigen3::Eigen INTERFACE_INCLUDE_DIRECTORIES)
set(LEO_EIGEN3_INCLUDE_DIRS "${_leo_eigen_includes}" CACHE INTERNAL "Resolved Eigen include directories")
set(LEO_EIGEN3_VERSION "${Eigen3_VERSION}" CACHE INTERNAL "Resolved Eigen version")
add_library(leo_kernel STATIC
  src/kernel/attitude.cpp src/kernel/state.cpp src/kernel/spacecraft.cpp
  src/kernel/gravity.cpp src/kernel/dynamics.cpp src/kernel/orbital.cpp
  src/kernel/radiation.cpp src/kernel/aerodynamics.cpp)
add_library(leo::kernel ALIAS leo_kernel)
target_compile_features(leo_kernel PUBLIC cxx_std_20)
target_include_directories(leo_kernel PUBLIC include)
target_link_libraries(leo_kernel PUBLIC Eigen3::Eigen)
if(LEO_ENABLE_CSPICE)
  # The CSPICE adapter is not part of the public source distribution. Fail
  # with a clear message instead of an unreadable CMake error when a caller
  # asks for it on a tree that does not carry it.
  if(NOT EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/src/environment/spice.cpp")
    message(FATAL_ERROR
      "LEO_ENABLE_CSPICE requires src/environment/spice.cpp, which is not part "
      "of this source distribution. Build the full repository to enable it.")
  endif()
  find_package(Threads REQUIRED)
  find_path(CSPICE_INCLUDE_DIR SpiceUsr.h HINTS "${CSPICE_ROOT}/include" REQUIRED)
  find_library(CSPICE_LIBRARY NAMES cspice cspice.a libcspice.a HINTS "${CSPICE_ROOT}/lib" REQUIRED)
  add_library(leo_environment STATIC src/environment/spice.cpp)
  add_library(leo::environment ALIAS leo_environment)
  target_link_libraries(leo_environment PUBLIC leo::kernel PRIVATE "${CSPICE_LIBRARY}" Threads::Threads)
  target_include_directories(leo_environment PRIVATE "${CSPICE_INCLUDE_DIR}")
endif()
if(LEO_ENABLE_NRLMSISE00 OR LEO_ENABLE_JB2008)
  # The empirical atmosphere providers are deferred: they are not part of the
  # public source distribution, and their build files are absent there.
  if(LEO_ENABLE_NRLMSISE00 AND NOT EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/cmake/nrlmsise00.cmake")
    message(FATAL_ERROR
      "LEO_ENABLE_NRLMSISE00 requires cmake/nrlmsise00.cmake, which is not "
      "part of this source distribution. Build the full repository to enable it.")
  endif()
  if(LEO_ENABLE_JB2008 AND NOT EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/cmake/jb2008.cmake")
    message(FATAL_ERROR
      "LEO_ENABLE_JB2008 requires cmake/jb2008.cmake, which is not part of "
      "this source distribution. Build the full repository to enable it.")
  endif()
  add_library(leo_atmosphere STATIC)
  add_library(leo::atmosphere ALIAS leo_atmosphere)
  target_compile_features(leo_atmosphere PUBLIC cxx_std_20)
  target_link_libraries(leo_atmosphere PUBLIC leo::kernel)
  if(LEO_ENABLE_NRLMSISE00)
    include(cmake/nrlmsise00.cmake)
  endif()
  if(LEO_ENABLE_JB2008)
    include(cmake/jb2008.cmake)
  endif()
  if(LEO_ENABLE_CSPICE)
    target_sources(leo_atmosphere PRIVATE
      src/atmosphere/coordinates.cpp src/atmosphere/weather.cpp src/atmosphere/provider.cpp)
    target_link_libraries(leo_atmosphere PUBLIC leo::environment)
  endif()
endif()
if(LEO_BUILD_INTEGRATION)
  if(LEO_BOOST_PROVISIONED)
    # cmake/provision.cmake already defined Boost::headers from the pinned tree.
  else()
  find_package(Boost 1.74 QUIET CONFIG)
  if(NOT Boost_FOUND)
    # Upstream header-only trees need module discovery, including on CMake >=3.30.
    cmake_policy(PUSH)
    if(POLICY CMP0167)
      cmake_policy(SET CMP0167 OLD)
    endif()
    find_package(Boost 1.74 REQUIRED)
    cmake_policy(POP)
  endif()
  endif() # not LEO_BOOST_PROVISIONED
  find_package(Threads REQUIRED) # MT-001 batch.hpp uses std::thread only.
  add_library(leo_integration STATIC src/integration/error_control.cpp)
  add_library(leo::integration ALIAS leo_integration)
  target_link_libraries(leo_integration PUBLIC leo::kernel Boost::headers Threads::Threads)
  get_target_property(_leo_boost_includes Boost::headers INTERFACE_INCLUDE_DIRECTORIES)
  set(LEO_BOOST_INCLUDE_DIRS "${_leo_boost_includes}" CACHE INTERNAL "Resolved Boost include directories")
  set(LEO_BOOST_VERSION "${Boost_VERSION}" CACHE INTERNAL "Resolved Boost version")
endif()
if(LEO_BUILD_ANALYSIS)
  add_library(leo_analysis STATIC
    src/analysis/orbital.cpp src/analysis/loads.cpp src/analysis/export.cpp)
  add_library(leo::analysis ALIAS leo_analysis)
  target_link_libraries(leo_analysis PUBLIC leo::kernel)
endif()
if(LEO_BUILD_PYTHON_BINDINGS)
  if(NOT LEO_BUILD_INTEGRATION OR NOT LEO_BUILD_ANALYSIS)
    message(FATAL_ERROR "LEO_BUILD_PYTHON_BINDINGS requires LEO_BUILD_INTEGRATION and LEO_BUILD_ANALYSIS")
  endif()
  # Explicit discovery of both development dependencies; the kernel itself keeps no
  # Python dependency and the default C++ build never reaches this block.
  find_package(Python3 3.9 REQUIRED COMPONENTS Interpreter Development.Module)
  # pybind11 derives the extension suffix from the interpreter it is told about;
  # a backend that passes -DPython3_EXECUTABLE only would otherwise be ignored.
  if(NOT DEFINED Python_EXECUTABLE)
    set(Python_EXECUTABLE "${Python3_EXECUTABLE}" CACHE FILEPATH "Interpreter pybind11 builds for")
  endif()
  find_package(pybind11 CONFIG QUIET)
  if(NOT pybind11_FOUND)
    # pybind11 ships its CMake package inside its own environment; ask the selected
    # interpreter so a virtual environment is used without extra configuration.
    execute_process(COMMAND "${Python3_EXECUTABLE}" -m pybind11 --cmakedir
                    OUTPUT_VARIABLE _leo_pybind11_dir OUTPUT_STRIP_TRAILING_WHITESPACE
                    RESULT_VARIABLE _leo_pybind11_result ERROR_QUIET)
    if(NOT _leo_pybind11_result EQUAL 0)
      message(FATAL_ERROR "pybind11 not found: pass -Dpybind11_DIR=<prefix>/share/cmake/pybind11")
    endif()
    find_package(pybind11 CONFIG REQUIRED PATHS "${_leo_pybind11_dir}" NO_DEFAULT_PATH)
  endif()
  pybind11_add_module(_core MODULE
    bindings/module.cpp bindings/configuration.cpp bindings/integration.cpp bindings/analysis.cpp)
  target_include_directories(_core PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/bindings")
  target_link_libraries(_core PRIVATE leo::integration leo::analysis)
  if(TARGET leo_environment) # Optional CSPICE provider; never required by v0.1.
    target_link_libraries(_core PRIVATE leo::environment)
  endif()
  set_target_properties(_core PROPERTIES
    LIBRARY_OUTPUT_DIRECTORY "${LEO_PYTHON_BINDING_DIR}"
    RUNTIME_OUTPUT_DIRECTORY "${LEO_PYTHON_BINDING_DIR}"
    CXX_VISIBILITY_PRESET hidden VISIBILITY_INLINES_HIDDEN ON)
  # Stage the facade and its type information beside the extension, so that
  # PYTHONPATH=<build>/python makes "import leo_dynamics" resolve this package.
  file(MAKE_DIRECTORY "${LEO_PYTHON_BINDING_DIR}")
  foreach(_leo_python_file IN ITEMS __init__.py __init__.pyi py.typed)
    # configure_file tracks input changes: facade-only edits are staged even
    # when no C++ source needs rebuilding.
    configure_file("${CMAKE_CURRENT_SOURCE_DIR}/python/leo_dynamics/${_leo_python_file}"
                   "${LEO_PYTHON_BINDING_DIR}/${_leo_python_file}" COPYONLY)
  endforeach()
  if(SKBUILD)
    # The wheel backend assembles the distribution from this install step: the
    # Python files come from the package directory, the extension from here.
    install(TARGETS _core DESTINATION "${SKBUILD_PLATLIB_DIR}/leo_dynamics")
  endif()
  message(STATUS "leo_dynamics: development import with PYTHONPATH=${LEO_PYTHON_BINDING_DIR}/..")
endif()
if(LEO_BUILD_EXAMPLES)
  # The examples are not part of the public source distribution.
  if(NOT EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/examples/propagate_leo.cpp")
    message(FATAL_ERROR
      "LEO_BUILD_EXAMPLES requires examples/, which is not part of this "
      "source distribution. Build the full repository to enable it.")
  endif()
  add_executable(propagate_leo examples/propagate_leo.cpp)
  target_link_libraries(propagate_leo PRIVATE leo::integration)
  if(LEO_BUILD_ANALYSIS)
    add_executable(analyze_orbit examples/analyze_orbit.cpp)
    target_link_libraries(analyze_orbit PRIVATE leo::integration leo::analysis)
    if(LEO_ENABLE_CSPICE)
      target_compile_definitions(analyze_orbit PRIVATE LEO_EXAMPLE_CSPICE)
      target_link_libraries(analyze_orbit PRIVATE leo::environment)
    endif()
  endif()
  if(LEO_ENABLE_CSPICE)
    add_executable(propagate_environment examples/propagate_environment.cpp)
    target_link_libraries(propagate_environment PRIVATE leo::integration leo::environment)
  endif()
endif()
if(BUILD_TESTING)
  # The test suites are not part of the public source distribution.
  if(NOT EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/tests/frames_tests.cpp")
    message(FATAL_ERROR
      "BUILD_TESTING requires tests/, which is not part of this source "
      "distribution. Build the full repository to run the test suites.")
  endif()
  enable_testing()
  find_package(Catch2 3 REQUIRED)
  add_executable(leo_kernel_tests
    tests/frames_tests.cpp tests/attitude_tests.cpp tests/gravity_tests.cpp tests/orbital_tests.cpp
    tests/environment_tests.cpp tests/interpolated_tests.cpp tests/radiation_tests.cpp
    tests/numerical_radiation_tests.cpp tests/aerodynamics_tests.cpp tests/numerical_environment_tests.cpp
    tests/numerical_dynamics_tests.cpp)
  target_link_libraries(leo_kernel_tests PRIVATE leo::kernel Catch2::Catch2WithMain)
  include(Catch)
  catch_discover_tests(leo_kernel_tests)
  if(LEO_ENABLE_NRLMSISE00)
    include(cmake/nrlmsise00-tests.cmake)
  endif()
  if(LEO_ENABLE_JB2008)
    add_executable(leo_jb2008_tests tests/jb2008_tests.cpp)
    target_link_libraries(leo_jb2008_tests PRIVATE leo::atmosphere Catch2::Catch2WithMain)
    catch_discover_tests(leo_jb2008_tests)
  endif()
  if((LEO_ENABLE_NRLMSISE00 OR LEO_ENABLE_JB2008) AND LEO_ENABLE_CSPICE)
    add_executable(leo_atmosphere_input_tests tests/atmosphere_input_tests.cpp)
    target_link_libraries(leo_atmosphere_input_tests PRIVATE leo::atmosphere Catch2::Catch2WithMain)
    catch_discover_tests(leo_atmosphere_input_tests)
  endif()
  if(LEO_ENABLE_CSPICE)
    add_executable(leo_spice_tests tests/spice_tests.cpp)
    target_include_directories(leo_spice_tests PRIVATE "${CSPICE_INCLUDE_DIR}")
    target_link_libraries(leo_spice_tests PRIVATE leo::environment "${CSPICE_LIBRARY}" Catch2::Catch2WithMain)
    catch_discover_tests(leo_spice_tests)
  endif()
  if(LEO_BUILD_INTEGRATION)
    add_executable(leo_integration_tests
      tests/integration_tests.cpp tests/two_body_tests.cpp tests/j2_tests.cpp
      tests/environment_integration_tests.cpp tests/numerical_integration_tests.cpp tests/batch_tests.cpp)
    target_link_libraries(leo_integration_tests PRIVATE leo::integration Catch2::Catch2WithMain)
    catch_discover_tests(leo_integration_tests)
  endif()
  if(LEO_BUILD_ANALYSIS AND LEO_BUILD_INTEGRATION)
    add_executable(leo_analysis_tests
      tests/analysis_orbital_tests.cpp tests/analysis_loads_tests.cpp tests/analysis_export_tests.cpp)
    target_link_libraries(leo_analysis_tests PRIVATE leo::analysis leo::integration Catch2::Catch2WithMain)
    catch_discover_tests(leo_analysis_tests)
  endif()
  find_package(Python3 QUIET COMPONENTS Interpreter)
  if(Python3_Interpreter_FOUND)
    add_test(NAME audit_metric_identities COMMAND "${Python3_EXECUTABLE}" "${CMAKE_CURRENT_SOURCE_DIR}/tests/sensitivity_metrics_tests.py")
    add_test(NAME process_batch_contracts COMMAND "${Python3_EXECUTABLE}" "${CMAKE_CURRENT_SOURCE_DIR}/tests/process_batch_tests.py")
  endif()
  if(LEO_TEST_PLOTTING)
    find_package(Python3 REQUIRED COMPONENTS Interpreter)
    add_test(NAME plotting_smoke COMMAND "${Python3_EXECUTABLE}" "${CMAKE_CURRENT_SOURCE_DIR}/tests/plotting_smoke.py")
  endif()
endif()
