option(OPYN_GENERATE_STUBS "Generate Python stubs (.pyi) for native bindings" ON)

# Sanity-check universal builds (not allowed)
if(APPLE AND CMAKE_OSX_ARCHITECTURES)
    list(LENGTH CMAKE_OSX_ARCHITECTURES _arch_count)
    if(_arch_count GREATER 1)
        message(FATAL_ERROR
            "Universal builds are not allowed, because it's simpler to package python libraries one arch per package"
            "CMAKE_OSX_ARCHITECTURES=${CMAKE_OSX_ARCHITECTURES} contains multiple entries."
        )
    endif()
endif()

# Copy the Python source code into build directory, so each build configuration
# (Debug, Release, etc.) has its own unique staging directory.
if(TRUE)
    set(OPYN_OPYNSIM_SOURCE_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/opynsim")
    set(OPYN_OPYNSIM_STAGING_DIR "${CMAKE_CURRENT_BINARY_DIR}/opynsim")
    set(OPYN_STAGING_OUTPUTS)

    # Setup custom build command that copies Python sources into the staging
    # directory whenever a source file changes.
    file(GLOB_RECURSE OPYN_OPYNSIM_PYTHON_SOURCES "${OPYN_OPYNSIM_SOURCE_ROOT}/*.py")
    foreach(_src ${OPYN_OPYNSIM_PYTHON_SOURCES})
        file(RELATIVE_PATH _rel ${OPYN_OPYNSIM_SOURCE_ROOT} ${_src})
        set(_dst ${OPYN_OPYNSIM_STAGING_DIR}/${_rel})
        get_filename_component(_dst_dir ${_dst} DIRECTORY)
        add_custom_command(
            OUTPUT ${_dst}
            COMMAND ${CMAKE_COMMAND} -E make_directory ${_dst_dir}
            COMMAND ${CMAKE_COMMAND} -E copy_if_different ${_src} ${_dst}
            DEPENDS ${_src}
            COMMENT "Staging ${_rel}"
            VERBATIM
        )
        list(APPEND OPYN_STAGING_OUTPUTS ${_dst})
    endforeach()
    add_custom_target(opynsim_python_source_staging ALL DEPENDS ${OPYN_STAGING_OUTPUTS})
endif()

# Setup native Python extension module (opynsim._core)
#
# This uses nanobind to build C++ code into a native Python extension module
# that's built into the staging directory, which means that the resulting
# staging directory is compatible with PYTHONPATH (handy for debugging/dev).
if(TRUE)
    # Use nanobind (docs: https://nanobind.readthedocs.io/en/latest/api_cmake.html)
    find_package(nanobind REQUIRED)

    set(OPYN_NANOBIND_ARGS
        NB_STATIC             # Compile the nanobind library as a static library
        NB_SUPPRESS_WARNINGS  # Mark nanobind/Python directories as SYSTEM and disable clang-tidying them
    )
    if(OPYN_STABLE_ABI)
        list(APPEND OPYN_NANOBIND_ARGS STABLE_ABI)
    endif()
    file(GLOB_RECURSE _core_sources "opynsim/_core/*.cpp")
    nanobind_add_module(_core ${OPYN_NANOBIND_ARGS} ${_core_sources})
    unset(_core_sources)
    target_include_directories(_core
        PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}  # so that `#include <opynsim/_core/...>` works
    )
    target_compile_features(_core PUBLIC cxx_std_23)
    set_target_properties(_core PROPERTIES
        CXX_STANDARD_REQUIRED ON
        CXX_EXTENSIONS        OFF
    )
    if(OPYN_USE_STRICT_COMPILER_FLAGS)
        include(${CMAKE_CURRENT_SOURCE_DIR}/../cmake/opynsim_strict_compiler_options.cmake)
        opyn_add_strict_compiler_options_to(_core)
    endif()
    target_link_libraries(_core PRIVATE opynsim)
    add_dependencies(_core opynsim_python_source_staging) # Dependent on the copied Python staging directory

    # Scrap all public symbols apart from `PyInit_*` / `_PyInit_*` (reduces symbol collisions).
    if (APPLE)
        target_link_options(_core PRIVATE "-Wl,-exported_symbols_list,${CMAKE_CURRENT_SOURCE_DIR}/exports.txt")
    elseif(UNIX AND NOT APPLE)  # i.e. Linux
        target_link_options(_core PRIVATE "-Wl,--version-script=${CMAKE_CURRENT_SOURCE_DIR}/exports.map")
    endif()

    # Make `_core` build to the staging directory
    set_target_properties(_core PROPERTIES
        LIBRARY_OUTPUT_DIRECTORY ${OPYN_OPYNSIM_STAGING_DIR}
        RUNTIME_OUTPUT_DIRECTORY ${OPYN_OPYNSIM_STAGING_DIR}
    )

    # Set `OPYN_PYTHON_INTERPRETER_TAG` and `OPYN_PYTHON_ABI_TAG` properties on
    # the `_core` target.
    #
    # Used during packaging to determine wheel naming conventions etc. When compiling
    # with a stable ABI, the ABI tag is always "abi3". Otherwise, the ABI tag is
    # tied to the python version that was used to compile the module.
    if(TRUE)
        # Remove dot between MAJOR.MINOR → "3.11" → "311"
        string(REGEX MATCH "^[0-9]+\\.[0-9]+" _py_mm "${Python_VERSION}")
        string(REPLACE "." "" _py_tag "${_py_mm}")
        if(OPYN_STABLE_ABI)
            set_target_properties(_core PROPERTIES
                OPYN_PYTHON_INTERPRETER_TAG "cp${_py_tag}"
                OPYN_PYTHON_ABI_TAG         "abi3"
            )
        else()
            set_target_properties(_core PROPERTIES
                OPYN_PYTHON_INTERPRETER_TAG "cp${_py_tag}"
                OPYN_PYTHON_ABI_TAG         "cp${_py_tag}"
            )
        endif()
        unset(_py_tag)
        unset(_py_mm)
    endif()

    # Build Python interface stubs (`.pyi`) for `_core` into the staging directory.
    #
    # This enables type checking etc. in downstream development environments
    # when using the build staging directory.
    # See:
    # - https://nanobind.readthedocs.io/en/latest/typing.html
    # - https://nanobind.readthedocs.io/en/latest/api_cmake.html#command:nanobind_add_stub
    if(OPYN_GENERATE_STUBS)
        nanobind_add_stub(
            _core_development_stubs
            MODULE _core
            PYTHON_PATH $<TARGET_FILE_DIR:_core>
            DEPENDS _core
            MARKER_FILE py.typed
            RECURSIVE
            OUTPUT_PATH ${OPYN_OPYNSIM_STAGING_DIR}
        )
    endif()
endif()

# Installation
#
# Installation is effectively copying the staging directory to the
# installation directory, but care must be taken to ensure debug
# symbols are appropriately stripped.
if(TRUE)
    set(OPYN_PYTHON_INSTALL_DIR "lib/python3/site-packages/opynsim")

    # Install staged sources and stubs
    install(
        DIRECTORY   "${OPYN_OPYNSIM_STAGING_DIR}/"
        DESTINATION "${OPYN_PYTHON_INSTALL_DIR}"
        COMPONENT opynsim-python
        FILES_MATCHING
            PATTERN "*.py"
            PATTERN "*.pyi"
            PATTERN "py.typed"
            PATTERN "__pycache__" EXCLUDE
            PATTERN "*.pyc"       EXCLUDE
    )

    # Install `_core` native extension module
    install(
        FILES $<TARGET_FILE:_core>
        DESTINATION "${OPYN_PYTHON_INSTALL_DIR}"
        COMPONENT opynsim-python
    )

    # If `CMAKE_INSTALL_DO_STRIP` is `TRUE` at install-time, strip
    # symbols from the `_core` native extension module.
    #
    # This is necessary because `CPACK_STRIP_FILES` doesn't automatically
    # apply to `_core` because it's a `MODULE` target type.
    if(CMAKE_STRIP)
        install(CODE "
            # This variable is automatically set to TRUE by CMake during 'cmake --install --strip' or CPack packaging
            if(CMAKE_INSTALL_DO_STRIP)
                # Feature-test the stripping tool (some use -x, some use --strip-unneeded)
                execute_process(
                    COMMAND \"${CMAKE_STRIP}\" --help
                    OUTPUT_VARIABLE STRIP_HELP_OUT
                    ERROR_VARIABLE STRIP_HELP_ERR
                )
                set(STRIP_INFO \"\${STRIP_HELP_OUT}\${STRIP_HELP_ERR}\")
                if(STRIP_INFO MATCHES \"--strip-unneeded\")
                    set(STRIP_FLAG \"--strip-unneeded\")  # GNU stripper
                else()
                    set(STRIP_FLAG \"-x\")                # Mach-O, LLVM/BSD stripper
                endif()
                message(STATUS \"Dynamically selected strip flag: \${STRIP_FLAG}\")

                set(INSTALLED_MODULE \"\${CMAKE_INSTALL_PREFIX}/${OPYN_PYTHON_INSTALL_DIR}/\$<TARGET_FILE_NAME:_core>\")
                message(NOTICE \"Stripping Python extension module: \${INSTALLED_MODULE}\")
                execute_process(
                    COMMAND \"${CMAKE_STRIP}\" \"\${STRIP_FLAG}\" \"\${INSTALLED_MODULE}\"
                    RESULT_VARIABLE STRIP_RESULT
                )
                if(NOT STRIP_RESULT EQUAL 0)
                    message(FATAL_ERROR \"Failed to strip extension module: \${INSTALLED_MODULE}\")
                endif()
            endif()
            "
            COMPONENT opynsim-python
        )
    endif()
endif()
