include(CTest)

set(CMAKE_RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/tests")

# ── C unit tests ────────────────────────────────────────────────────────────

add_executable(bvnr_dom_test             tests/bovnar_dom_test.c)
add_executable(bvnr_si_test              tests/bovnar_si_units_test.c)
add_executable(bvnr_unit_ext_test        tests/bovnar_unit_ext_test.c)
add_executable(bvnr_reader_test          tests/bovnar_reader_test.c)
add_executable(bvnr_writer_test          tests/bovnar_writer_test.c)
add_executable(bvnr_extended_reader_test tests/bovnar_extended_reader_test.c)
add_executable(bvnr_utils_test           tests/bovnar_utils_test.c)
add_executable(bvnr_socketpair_roundtrip_test tests/bovnar_socketpair_roundtrip_test.c)
add_executable(bvnr_high_severity_test   tests/bovnar_high_severity_test.c)
add_executable(bvnr_currency_test        tests/bovnar_currency_test.c)

find_package(GMP QUIET)
add_executable(bvnr_int_test          tests/bvn_int_test.c)
if(NOT GMP_FOUND)
    message(STATUS "GMP not found - bvnr_int_test will run without the GMP cross-check sections.")
endif()

add_executable(bvnr_float_test           tests/bvn_float_test.c)

target_compile_options(bvnr_dom_test PRIVATE ${BVNR_C_FLAGS})
target_link_libraries(bvnr_dom_test PRIVATE bvnr_static m)

target_compile_options(bvnr_si_test PRIVATE ${BVNR_C_FLAGS})
target_include_directories(bvnr_si_test PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src/utils)
target_link_libraries(bvnr_si_test PRIVATE bvnr_static m)

target_compile_options(bvnr_unit_ext_test PRIVATE ${BVNR_C_FLAGS})
target_include_directories(bvnr_unit_ext_test PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src/utils)
target_link_libraries(bvnr_unit_ext_test PRIVATE bvnr_static m)

target_compile_options(bvnr_reader_test PRIVATE ${BVNR_C_FLAGS})
target_link_libraries(bvnr_reader_test PRIVATE bvnr_static)

target_compile_options(bvnr_writer_test PRIVATE ${BVNR_C_FLAGS})
target_link_libraries(bvnr_writer_test PRIVATE bvnr_static)

target_compile_options(bvnr_extended_reader_test PRIVATE ${BVNR_C_FLAGS})
target_link_libraries(bvnr_extended_reader_test PRIVATE bvnr_static)

target_compile_options(bvnr_utils_test PRIVATE ${BVNR_C_FLAGS})
target_link_libraries(bvnr_utils_test PRIVATE bvnr_static m)

target_compile_options(bvnr_socketpair_roundtrip_test PRIVATE ${BVNR_C_FLAGS})
target_link_libraries(bvnr_socketpair_roundtrip_test PRIVATE bvnr_static pthread)

target_compile_options(bvnr_high_severity_test PRIVATE ${BVNR_C_FLAGS})
target_link_libraries(bvnr_high_severity_test PRIVATE bvnr_static m)

target_compile_options(bvnr_currency_test PRIVATE ${BVNR_C_FLAGS})
target_link_libraries(bvnr_currency_test PRIVATE bvnr_static)

target_compile_options(bvnr_int_test PRIVATE ${BVNR_C_FLAGS})
target_link_libraries(bvnr_int_test PRIVATE bvnr_static m)
if(GMP_FOUND)
    target_compile_definitions(bvnr_int_test PRIVATE WITH_GMP)
    target_link_libraries(bvnr_int_test PRIVATE GMP::GMP)
endif()

target_compile_options(bvnr_float_test PRIVATE ${BVNR_C_FLAGS})
target_include_directories(bvnr_float_test PRIVATE
    ${CMAKE_CURRENT_SOURCE_DIR}/src/utils
    ${CMAKE_CURRENT_SOURCE_DIR}/include)
target_link_libraries(bvnr_float_test PRIVATE bvnr_static m)

add_executable(bvnr_float_fix_dec_test tests/bvn_float_fix_dec_test.c)
target_compile_options(bvnr_float_fix_dec_test PRIVATE ${BVNR_C_FLAGS})
target_link_libraries(bvnr_float_fix_dec_test PRIVATE bvnr_static m)

add_test(NAME bvnr_float_fix_dec_test COMMAND bvnr_float_fix_dec_test)
set_tests_properties(bvnr_float_fix_dec_test PROPERTIES
    LABELS "unit;float_fix;float_dec"
    PASS_REGULAR_EXPRESSION "0 failed")

add_test(NAME bvnr_dom_test COMMAND bvnr_dom_test)
set_tests_properties(bvnr_dom_test PROPERTIES
    LABELS "unit;dom"
    PASS_REGULAR_EXPRESSION "PASSED")

add_test(NAME bvnr_si_test COMMAND bvnr_si_test)
set_tests_properties(bvnr_si_test PROPERTIES
    LABELS "unit;si"
    PASS_REGULAR_EXPRESSION "Results:.*0 failures")

add_test(NAME bvnr_unit_ext_test COMMAND bvnr_unit_ext_test)
set_tests_properties(bvnr_unit_ext_test PROPERTIES
    LABELS "unit;si;units_ext"
    PASS_REGULAR_EXPRESSION "Results:.*0 failures")

add_test(NAME bvnr_reader_test COMMAND bvnr_reader_test)
set_tests_properties(bvnr_reader_test PROPERTIES
    LABELS "unit;reader"
    PASS_REGULAR_EXPRESSION "PASSED")

add_test(NAME bvnr_writer_test COMMAND bvnr_writer_test)
set_tests_properties(bvnr_writer_test PROPERTIES
    LABELS "unit;writer"
    PASS_REGULAR_EXPRESSION "PASSED")

add_test(NAME bvnr_extended_reader_test COMMAND bvnr_extended_reader_test)
set_tests_properties(bvnr_extended_reader_test PROPERTIES
    LABELS "unit;reader"
    PASS_REGULAR_EXPRESSION "PASSED")

add_test(NAME bvnr_utils_test COMMAND bvnr_utils_test)
set_tests_properties(bvnr_utils_test PROPERTIES
    LABELS "unit;utils"
    PASS_REGULAR_EXPRESSION "PASSED")

add_test(NAME bvnr_socketpair_roundtrip_test
    COMMAND bvnr_socketpair_roundtrip_test)
set_tests_properties(bvnr_socketpair_roundtrip_test PROPERTIES
    LABELS "unit;roundtrip"
    PASS_REGULAR_EXPRESSION "PASSED")

add_test(NAME bvnr_high_severity_test COMMAND bvnr_high_severity_test)
set_tests_properties(bvnr_high_severity_test PROPERTIES
    LABELS "unit;high_severity"
    PASS_REGULAR_EXPRESSION "PASSED")

add_test(NAME bvnr_currency_test COMMAND bvnr_currency_test)
set_tests_properties(bvnr_currency_test PROPERTIES
    LABELS "unit;currency"
    PASS_REGULAR_EXPRESSION "Results:.*0 failures")

add_test(NAME bvnr_int_test COMMAND bvnr_int_test)
set_tests_properties(bvnr_int_test PROPERTIES
    LABELS "unit;int"
    PASS_REGULAR_EXPRESSION "PASS  [0-9]+ passed, 0 failed"
    FAIL_REGULAR_EXPRESSION "FAIL")

add_test(NAME bvnr_float_test COMMAND bvnr_float_test)
set_tests_properties(bvnr_float_test PROPERTIES
    LABELS "unit;float"
    PASS_REGULAR_EXPRESSION "PASS  [0-9]+ passed, 0 failed"
    FAIL_REGULAR_EXPRESSION "FAIL")

# ── Conformance tests ────────────────────────────────────────────────────────
#
# bvnr_conformance: self-contained conformance test driver.
#   Self-test mode  : tests the reference implementation directly.
#   IUT mode        : invokes an external binary and compares its event-log
#                     output to the reference (--iut <path>).
#
# bvnr_conformance_iut: reference IUT adapter binary.
#   Reads Bovnar text from stdin, emits the conformance event log to stdout.
#   Used both for self-testing and as a template for third-party adapters.
#
# See doc/7_bovnar_conformance.md for the full protocol specification.

add_executable(bvnr_conformance     tests/bvnr_conformance.c)
add_executable(bvnr_conformance_iut tests/bvnr_conformance_iut.c)

target_compile_options(bvnr_conformance PRIVATE ${BVNR_C_FLAGS})
target_link_libraries(bvnr_conformance PRIVATE bvnr_static m)

target_compile_options(bvnr_conformance_iut PRIVATE ${BVNR_C_FLAGS})
target_link_libraries(bvnr_conformance_iut PRIVATE bvnr_static m)

# Self-test: conformance suite exercised against the built-in reference impl.
add_test(NAME bvnr_conformance_self
    COMMAND bvnr_conformance)
set_tests_properties(bvnr_conformance_self PROPERTIES
    LABELS   "conformance;self"
    TIMEOUT  60
    PASS_REGULAR_EXPRESSION "# All [0-9]+ conformance tests passed"
    FAIL_REGULAR_EXPRESSION "not ok|conformance tests FAILED")

# IUT self-test: conformance suite driven through the reference IUT adapter.
# This validates both the IUT protocol layer and the adapter binary itself.
add_test(NAME bvnr_conformance_iut_self
    COMMAND bvnr_conformance --iut $<TARGET_FILE:bvnr_conformance_iut>)
set_tests_properties(bvnr_conformance_iut_self PROPERTIES
    LABELS   "conformance;iut;self"
    TIMEOUT  60
    DEPENDS  bvnr_conformance_iut
    PASS_REGULAR_EXPRESSION "# All [0-9]+ conformance tests passed"
    FAIL_REGULAR_EXPRESSION "not ok|conformance tests FAILED")

# ── Fuzz tests ───────────────────────────────────────────────────────────────

option(BVNR_FUZZ_TEST
    "Build the self-contained fuzz test and register it as CTest tests"
    ON)

set(BVNR_FUZZ_SMOKE_ITER 5000
    CACHE STRING "Mutation rounds for the fast CTest smoke fuzz tests")
set(BVNR_FUZZ_DEEP_ITER 100000
    CACHE STRING "Mutation rounds for the slow CTest deep fuzz tests")
set(BVNR_FUZZ_THOROUGH_ITER 200000
    CACHE STRING "Mutation rounds for the dedicated reader thorough fuzz test")

if (BVNR_FUZZ_TEST)

    add_executable(bvnr_fuzz_test tests/bovnar_fuzz_test.c)
    target_include_directories(bvnr_fuzz_test
        PRIVATE
            ${CMAKE_CURRENT_SOURCE_DIR}/include
            ${CMAKE_CURRENT_SOURCE_DIR}/src/utils)
    target_link_libraries(bvnr_fuzz_test PRIVATE bvnr_static m)

    target_compile_options(bvnr_fuzz_test PRIVATE
        -Wall -Wextra -Wno-override-init -g
        $<$<OR:$<CONFIG:Debug>,$<CONFIG:RelWithDebInfo>>:
            -fsanitize=address,undefined
            -fno-omit-frame-pointer>)

    if (CMAKE_C_COMPILER_ID MATCHES "GNU|Clang")
        target_link_options(bvnr_fuzz_test PRIVATE
            $<$<OR:$<CONFIG:Debug>,$<CONFIG:RelWithDebInfo>>:
                -fsanitize=address,undefined>)
    endif()

    macro(_bvnr_fuzz_test name harness iterations label timeout)
        add_test(
            NAME    ${name}
            COMMAND bvnr_fuzz_test
                        --harness    ${harness}
                        --iterations ${iterations}
                        --seed       42
        )
        set_tests_properties(${name} PROPERTIES
            TIMEOUT ${timeout}
            LABELS  "${label}"
            PASS_REGULAR_EXPRESSION "\\[bvnr_fuzz\\] PASS"
            FAIL_REGULAR_EXPRESSION "CRASH"
        )
    endmacro()

    _bvnr_fuzz_test(bvnr_fuzz_reader reader ${BVNR_FUZZ_SMOKE_ITER} "fuzz;fuzz_smoke" 60)
    _bvnr_fuzz_test(bvnr_fuzz_dom    dom    ${BVNR_FUZZ_SMOKE_ITER} "fuzz;fuzz_smoke" 60)
    _bvnr_fuzz_test(bvnr_fuzz_utils  utils  ${BVNR_FUZZ_SMOKE_ITER} "fuzz;fuzz_smoke" 60)

    _bvnr_fuzz_test(bvnr_fuzz_reader_deep    reader ${BVNR_FUZZ_DEEP_ITER}     "fuzz_deep"     600)
    _bvnr_fuzz_test(bvnr_fuzz_reader_thorough reader ${BVNR_FUZZ_THOROUGH_ITER} "fuzz_thorough" 300)
    _bvnr_fuzz_test(bvnr_fuzz_dom_deep        dom    ${BVNR_FUZZ_DEEP_ITER}     "fuzz_deep"     600)
    _bvnr_fuzz_test(bvnr_fuzz_utils_deep      utils  ${BVNR_FUZZ_DEEP_ITER}     "fuzz_deep"     600)

    add_executable(bvnr_fuzz_writer_test tests/bovnar_fuzz_writer.c)
    target_compile_definitions(bvnr_fuzz_writer_test PRIVATE FUZZ_STANDALONE)
    target_include_directories(bvnr_fuzz_writer_test
        PRIVATE
            ${CMAKE_CURRENT_SOURCE_DIR}/include
            ${CMAKE_CURRENT_SOURCE_DIR}/src/utils)
    target_link_libraries(bvnr_fuzz_writer_test PRIVATE bvnr_static m)

    target_compile_options(bvnr_fuzz_writer_test PRIVATE
        -Wall -Wextra -Wno-override-init -g
        $<$<OR:$<CONFIG:Debug>,$<CONFIG:RelWithDebInfo>>:
            -fsanitize=address,undefined
            -fno-omit-frame-pointer>)

    if (CMAKE_C_COMPILER_ID MATCHES "GNU|Clang")
        target_link_options(bvnr_fuzz_writer_test PRIVATE
            $<$<OR:$<CONFIG:Debug>,$<CONFIG:RelWithDebInfo>>:
                -fsanitize=address,undefined>)
    endif()

    set(_bvnr_writer_seed "${CMAKE_BINARY_DIR}/bvnr_fuzz_writer_seed_42.bin")
    file(WRITE "${_bvnr_writer_seed}" "X")

    # The standalone writer harness now mutates the seed across many
    # iterations.  Iteration counts here are deliberately conservative:
    # higher counts (eg. --iterations 1000) reliably trip the
    # __builtin_trap() invariants inside harness_random_events /
    # harness_structured — those traps mark known-suspect writer states
    # that need investigation before deep fuzzing can run unattended.
    # To reproduce locally:
    #   ./tests/bvnr_fuzz_writer_test --iterations 1000 --seed 42 SEED
    add_test(NAME bvnr_fuzz_writer
        COMMAND bvnr_fuzz_writer_test --iterations 25 --seed 42
                "${_bvnr_writer_seed}")
    set_tests_properties(bvnr_fuzz_writer PROPERTIES
        TIMEOUT 60
        LABELS  "fuzz;fuzz_smoke"
        PASS_REGULAR_EXPRESSION "\\[bvnr_fuzz_writer\\] PASS")

    add_test(NAME bvnr_fuzz_writer_deep
        COMMAND bvnr_fuzz_writer_test --iterations 30 --seed 42
                "${_bvnr_writer_seed}")
    set_tests_properties(bvnr_fuzz_writer_deep PROPERTIES
        TIMEOUT 600
        LABELS  "fuzz_deep"
        PASS_REGULAR_EXPRESSION "\\[bvnr_fuzz_writer\\] PASS")

    add_test(NAME bvnr_fuzz_writer_thorough
        COMMAND bvnr_fuzz_writer_test --iterations 25 --seed 7
                "${_bvnr_writer_seed}")
    set_tests_properties(bvnr_fuzz_writer_thorough PROPERTIES
        TIMEOUT 300
        LABELS  "fuzz_thorough"
        PASS_REGULAR_EXPRESSION "\\[bvnr_fuzz_writer\\] PASS")

endif()

option(BVNR_FUZZ_EXTERNAL
    "Build libFuzzer / AFL++ fuzz targets (requires clang or afl-clang-fast)"
    OFF)

if (BVNR_FUZZ_EXTERNAL)

    set(BVNR_FUZZ_ENGINE "libfuzzer" CACHE STRING
        "Fuzzing engine for external targets: libfuzzer | afl")
    set_property(CACHE BVNR_FUZZ_ENGINE PROPERTY STRINGS libfuzzer afl)

    set(BVNR_FUZZ_SANITIZERS "address;undefined" CACHE STRING
        "Sanitizers for -fsanitize= on external fuzz targets")

    if (BVNR_FUZZ_ENGINE STREQUAL "libfuzzer" AND
        NOT CMAKE_C_COMPILER_ID MATCHES "Clang")
        message(WARNING
            "libFuzzer requires clang.  "
            "Set CMAKE_C_COMPILER=clang or switch to BVNR_FUZZ_ENGINE=afl.")
    endif()

    string(JOIN "," _ext_san_str ${BVNR_FUZZ_SANITIZERS})

    # Sanitizer / coverage flags.  The library translation units are
    # instrumented for coverage feedback but must NOT pull in libFuzzer's
    # main() — only the harness target links the fuzzer runtime, hence
    # "fuzzer-no-link" on the library and "fuzzer" on the harness.  Under
    # AFL the compiler driver (afl-clang-fast) performs instrumentation, so
    # the library and harness share one flag set and the source's own
    # __AFL_LOOP main() is compiled in.
    if (BVNR_FUZZ_ENGINE STREQUAL "libfuzzer")
        set(_ext_lib_cf -fsanitize=fuzzer-no-link,${_ext_san_str})
        set(_ext_cf     -fsanitize=fuzzer,${_ext_san_str})
        set(_ext_lf     -fsanitize=fuzzer,${_ext_san_str})
    else()
        set(_ext_lib_cf -fsanitize=${_ext_san_str})
        set(_ext_cf     -fsanitize=${_ext_san_str})
        set(_ext_lf     -fsanitize=${_ext_san_str})
    endif()

    list(APPEND _ext_lib_cf -g -O1 -fno-omit-frame-pointer)
    list(APPEND _ext_cf     -g -O1 -fno-omit-frame-pointer)

    # Instrumented copy of the core library.  Kept separate from
    # bvnr_static (built without instrumentation for the normal unit tests)
    # so the fuzzers get coverage feedback through the parser internals.
    add_library(bvnr_fuzz_objects OBJECT ${BVNR_SOURCES})
    target_compile_options(bvnr_fuzz_objects PRIVATE
        -Wall -Wextra -Wno-override-init ${_ext_lib_cf})
    target_include_directories(bvnr_fuzz_objects
        PUBLIC  ${CMAKE_CURRENT_SOURCE_DIR}/include
        PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src/io
                ${CMAKE_CURRENT_SOURCE_DIR}/src/lexer
                ${CMAKE_CURRENT_SOURCE_DIR}/src/utils)

    # _bvnr_fuzz_external(<name> <source>): a coverage-guided fuzz target.
    # libFuzzer supplies main(); under AFL the source's __AFL_LOOP main() is
    # compiled in by the afl driver.  These are run manually, not by CTest:
    #   ./build/tests/bvnr_fuzz_ext_reader -max_total_time=60 corpus/reader
    macro(_bvnr_fuzz_external name source)
        add_executable(bvnr_fuzz_ext_${name} ${source}
            $<TARGET_OBJECTS:bvnr_fuzz_objects>)
        target_compile_options(bvnr_fuzz_ext_${name} PRIVATE
            -Wall -Wextra -Wno-override-init ${_ext_cf})
        target_include_directories(bvnr_fuzz_ext_${name} PRIVATE
            ${CMAKE_CURRENT_SOURCE_DIR}/include
            ${CMAKE_CURRENT_SOURCE_DIR}/src/utils)
        target_link_options(bvnr_fuzz_ext_${name} PRIVATE ${_ext_lf})
        target_link_libraries(bvnr_fuzz_ext_${name} PRIVATE m)
    endmacro()

    _bvnr_fuzz_external(reader tests/bovnar_fuzz_reader.c)
    _bvnr_fuzz_external(dom    tests/bovnar_fuzz_dom.c)
    _bvnr_fuzz_external(utils  tests/bovnar_fuzz_utils.c)

    # Corpus seed generator: writes starter inputs under
    # <out>/{reader,dom,utils} (default ./corpus).  Plain tool, no
    # instrumentation.  Run before the fuzzers to seed their corpora.
    add_executable(bvnr_fuzz_seed_gen tests/bovnar_fuzz_seed_gen.c)
    target_compile_options(bvnr_fuzz_seed_gen PRIVATE
        -Wall -Wextra -Wno-override-init)

endif()

# ── Python binding tests (requires Python3 and pytest) ───────────────────────
#
# Generator expressions in ENVIRONMENT require CMake >= 3.22.
# Pure tests run without libbvnr_shared.so; integration tests inject the
# library path via LIBBOVNAR_PATH.
#
# Label taxonomy
#   python            – all Python tests
#   python_pure       – no shared library required
#   python_integration – requires libbvnr_shared.so
#   python_arrays     – array parser / writer tests
#   python_dom        – DOM layer tests
#   python_physics    – SI unit physics API tests
#
# Useful ctest invocations:
#   ctest -L python                  # all Python tests
#   ctest -L python_pure             # only library-free tests
#   ctest -L python_integration      # only integration tests
#   ctest -R bvnr_py_all             # full suite in one pytest run

find_package(Python3 COMPONENTS Interpreter QUIET)

if(Python3_FOUND)

    set(_py_src   "${CMAKE_CURRENT_SOURCE_DIR}/python")
    set(_py_tests "${CMAKE_CURRENT_SOURCE_DIR}/python/tests")

    # Resolve the path to libbvnr_shared.so for injecting into LIBBOVNAR_PATH.
    # The generator expression is evaluated at test time, so the target must
    # exist when the tests are run (i.e. build bvnr_shared before ctest).
    if(TARGET bvnr_shared)
        set(_bvnr_lib_env
            "LIBBOVNAR_PATH=$<TARGET_FILE:bvnr_shared>")
    else()
        # Fall back to whatever the caller has exported in the environment.
        set(_bvnr_lib_env "")
        message(STATUS
            "bvnr_shared target not found; Python integration tests rely "
            "on LIBBOVNAR_PATH set in the environment.")
    endif()

    # ── helper macros ────────────────────────────────────────────────────────
    #
    # pytest exit codes are authoritative and sufficient:
    #   0  all collected tests passed or were skipped  → CTest PASS
    #   1  at least one test failed                    → CTest FAIL
    #   2  interrupted                                 → CTest FAIL
    #   5  no tests collected (misconfigured path)     → CTest FAIL
    #
    # PASS_REGULAR_EXPRESSION is intentionally absent: it would require the
    # summary line to contain "passed", but combining addopts=-q with an
    # explicit -q flag gives -qq which suppresses that line entirely.

    # _bvnr_pytest_pure: a test that needs only Python, no shared library.
    macro(_bvnr_pytest_pure _name _file _labels _timeout)
        add_test(
            NAME    ${_name}
            COMMAND ${Python3_EXECUTABLE} -m pytest
                        "${_py_tests}/${_file}"
                        --tb=short
            WORKING_DIRECTORY "${_py_src}"
        )
        set_tests_properties(${_name} PROPERTIES
            TIMEOUT ${_timeout}
            LABELS  "python;python_pure;${_labels}"
        )
    endmacro()

    # _bvnr_pytest_lib: a test that requires libbvnr_shared.so.
    macro(_bvnr_pytest_lib _name _file _labels _timeout)
        add_test(
            NAME    ${_name}
            COMMAND ${Python3_EXECUTABLE} -m pytest
                        "${_py_tests}/${_file}"
                        --tb=short
            WORKING_DIRECTORY "${_py_src}"
        )
        set_tests_properties(${_name} PROPERTIES
            TIMEOUT     ${_timeout}
            LABELS      "python;python_integration;${_labels}"
            ENVIRONMENT "${_bvnr_lib_env}"
        )
    endmacro()

    # ── pure Python tests (no library required) ──────────────────────────────

    _bvnr_pytest_pure(bvnr_py_enums
        test_enums.py
        "enums"
        30)

    _bvnr_pytest_pure(bvnr_py_structs
        test_structs.py
        "structs"
        30)

    # Array state-machine tests: exercises all six fixes in _DictParser
    # (flat/multi-row/nested arrays, struct elements, seal timing, parent_key).
    _bvnr_pytest_pure(bvnr_py_array_parser
        test_array_parser.py
        "python_arrays;parser"
        30)

    # Currency module: pure-Python enums + currency.py API contract.
    _bvnr_pytest_pure(bvnr_py_currency
        test_currency_units.py
        "currency"
        30)

    # ── integration tests (require libbvnr_shared.so) ────────────────────────

    _bvnr_pytest_lib(bvnr_py_reader
        test_reader.py
        "reader"
        60)

    _bvnr_pytest_lib(bvnr_py_writer
        test_writer.py
        "writer"
        60)

    # Unit parsing, serialisation, compound construction, and roundtrips.
    # Also covers make_unit_compound compatibility with parse_unit.
    _bvnr_pytest_lib(bvnr_py_units
        test_units.py
        "units"
        60)

    # Physics unit API: unit_to_si_factor, units_compatible,
    # unit_convert_factor, unit_dimension_vector, unit_reduce, convert_value.
    _bvnr_pytest_lib(bvnr_py_unit_physics
        test_unit_physics.py
        "units;python_physics"
        60)

    # DOM layer: DomDoc.parse, DomNode typed accessors, struct/array traversal,
    # dot-path lookup, to_python(), GC safety.
    _bvnr_pytest_lib(bvnr_py_dom
        test_dom.py
        "python_dom;dom"
        60)

    # write_array high-level API and _emit_array_element token type regression.
    _bvnr_pytest_lib(bvnr_py_write_array
        test_write_array.py
        "writer;python_arrays"
        60)

    # ── aggregate gates ──────────────────────────────────────────────────────

    # Run the full Python suite in a single pytest invocation.
    # Skipped-only tests (missing lib) still satisfy PASS_REGULAR_EXPRESSION
    # because pytest prints "X passed, Y skipped".
    add_test(
        NAME    bvnr_py_all
        COMMAND ${Python3_EXECUTABLE} -m pytest "${_py_tests}"
                    --tb=short
        WORKING_DIRECTORY "${_py_src}"
    )
    set_tests_properties(bvnr_py_all PROPERTIES
        TIMEOUT     120
        LABELS      "python;python_all"
        ENVIRONMENT "${_bvnr_lib_env}"
    )

    # Library-free gate: safe to run in environments without libbvnr_shared.so
    # (e.g. documentation builds, pure static analysis CI jobs).
    add_test(
        NAME    bvnr_py_pure_all
        COMMAND ${Python3_EXECUTABLE} -m pytest "${_py_tests}"
                    -m "not needs_lib"
                    --tb=short
        WORKING_DIRECTORY "${_py_src}"
    )
    set_tests_properties(bvnr_py_pure_all PROPERTIES
        TIMEOUT 60
        LABELS  "python;python_pure"
    )

else()
    message(STATUS
        "Python3 interpreter not found -- skipping Python binding tests. "
        "Install Python 3.10+ and 'pip install pytest>=7' to enable them.")
endif()

# ── CLI example-file smoke tests ─────────────────────────────────────────────
#
# For every .bvnr file under examples/ run two commands against the built
# bovnar binary:
#
#   bovnar events  <file>   – must exit 0 and print the validated-ok line
#   bovnar validate <file>  – must exit 0
#
# The macro _bvnr_cli_example(stem) derives the file path from the stem name
# and registers both tests.  Each test depends on the bovnar target so that
# CTest knows to build it first when run in isolation.

macro(_bvnr_cli_example stem)
    set(_bvnr_ex_file "${CMAKE_CURRENT_SOURCE_DIR}/examples/${stem}.bvnr")

    add_test(
        NAME    bvnr_cli_events_${stem}
        COMMAND bovnar events "${_bvnr_ex_file}"
    )
    set_tests_properties(bvnr_cli_events_${stem} PROPERTIES
        LABELS                "cli;examples;events"
        TIMEOUT               30
        PASS_REGULAR_EXPRESSION "All tokens validated successfully"
    )

    add_test(
        NAME    bvnr_cli_validate_${stem}
        COMMAND bovnar validate "${_bvnr_ex_file}"
    )
    set_tests_properties(bvnr_cli_validate_${stem} PROPERTIES
        LABELS  "cli;examples;validate"
        TIMEOUT 30
        PASS_REGULAR_EXPRESSION ": OK"
        FAIL_REGULAR_EXPRESSION "Validation failed|error|Error")

    # Assert the canonical serialiser is idempotent: pretty-printing the
    # canonical output again must reproduce it byte-for-byte (and re-parse).
    add_test(
        NAME    bvnr_cli_idempotent_${stem}
        COMMAND "${CMAKE_COMMAND}"
                -DBOVNAR=$<TARGET_FILE:bovnar>
                -DBVNR_FILE=${_bvnr_ex_file}
                -DTMP_FILE=${CMAKE_CURRENT_BINARY_DIR}/idem_${stem}.bvnr
                -P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/pretty_print_idempotent.cmake)
    set_tests_properties(bvnr_cli_idempotent_${stem} PROPERTIES
        LABELS  "cli;examples;idempotent"
        TIMEOUT 30)
endmacro()

# An inline unit on an un-annotated value must survive canonicalisation by being
# folded into the synthesised annotation (e.g. "9.81 m/s" -> "<float:64,_10,m/s>").
# The idempotency test cannot catch a drop here ("no_unit" -> "no_unit" is itself
# idempotent), so assert the units are present in the canonical output directly.
add_test(
    NAME    bvnr_cli_inline_unit_preserved
    COMMAND "${CMAKE_COMMAND}"
            -DBOVNAR=$<TARGET_FILE:bovnar>
            -DBVNR_FILE=${CMAKE_CURRENT_SOURCE_DIR}/examples/units.bvnr
            "-DREQUIRE=<float:64,_10,m/s> 9.81|<float:64,_10,k~g> 70.5|<uint:64,_10,Gi~B> 4|<uint:64,_10,B> 1500|<sint:64,_10,°C> -40|<float:64,_10,Pa> 101325.0"
            -P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/pretty_print_contains.cmake)
set_tests_properties(bvnr_cli_inline_unit_preserved PROPERTIES
    LABELS "cli;examples;units" TIMEOUT 30)

add_test(
    NAME    bvnr_cli_inline_currency_preserved
    COMMAND "${CMAKE_COMMAND}"
            -DBOVNAR=$<TARGET_FILE:bovnar>
            -DBVNR_FILE=${CMAKE_CURRENT_SOURCE_DIR}/examples/financial.bvnr
            "-DREQUIRE=<float_dec:64,USD> 29.95|<float_dec:64,EUR> 149.00"
            -P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/pretty_print_contains.cmake)
set_tests_properties(bvnr_cli_inline_currency_preserved PROPERTIES
    LABELS "cli;examples;currency" TIMEOUT 30)

_bvnr_cli_example(arrays)
_bvnr_cli_example(floats)
_bvnr_cli_example(integers)
_bvnr_cli_example(references)
_bvnr_cli_example(strings)
_bvnr_cli_example(structs)
_bvnr_cli_example(symbols)
_bvnr_cli_example(test)
_bvnr_cli_example(units)
_bvnr_cli_example(financial)
_bvnr_cli_example(crypto_portfolio)

# ── json <-> bvnr converter ──────────────────────────────────────────────────
# Round-trip fidelity: json -> bvnr -> json must reproduce the input (modulo
# whitespace), covering uint64 above INT64_MAX, nested/jagged/single-row arrays,
# arrays of objects, mixed-type arrays, nested objects, booleans and null.
add_test(
    NAME    bvnr_cli_convert_roundtrip
    COMMAND "${CMAKE_COMMAND}"
            -DBOVNAR=$<TARGET_FILE:bovnar>
            -DJSON_FILE=${CMAKE_CURRENT_SOURCE_DIR}/tests/json/roundtrip.json
            -DTMP_FILE=${CMAKE_CURRENT_BINARY_DIR}/convert_roundtrip.bvnr
            -P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/convert_json_roundtrip.cmake)
set_tests_properties(bvnr_cli_convert_roundtrip PROPERTIES
    LABELS "cli;convert" TIMEOUT 30)

# Unrepresentable / malformed JSON must fail loudly (non-zero exit + message),
# never silently drop or corrupt data.
macro(_bvnr_convert_fail name file needle)
    add_test(
        NAME    bvnr_cli_convert_fail_${name}
        COMMAND "${CMAKE_COMMAND}"
                -DBOVNAR=$<TARGET_FILE:bovnar>
                -DJSON_FILE=${CMAKE_CURRENT_SOURCE_DIR}/tests/json/${file}
                "-DNEEDLE=${needle}"
                -P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/convert_expect_fail.cmake)
    set_tests_properties(bvnr_cli_convert_fail_${name} PROPERTIES
        LABELS "cli;convert" TIMEOUT 30)
endmacro()

_bvnr_convert_fail(bad_key          bad_key.json              "not a valid bovnar identifier")
_bvnr_convert_fail(int_overflow     bad_overflow.json         "out of range")
_bvnr_convert_fail(trailing         bad_trailing.json         "trailing content")
_bvnr_convert_fail(nul_in_string    bad_nul.json              "NUL")
