solver_c_api.f90 Source File


Source Code

!> @file solver_c_api.f90
!> @brief C ABI wrapper over the polling-friendly solver session API.
module solver_c_api
  use iso_c_binding, only: c_ptr, c_loc, c_f_pointer, c_null_ptr, c_associated, &
                           c_int, c_double, c_char, c_null_char
  use precision, only: wp
  use config_schema, only: config_schema_entry_t, cfg_kind_int, cfg_kind_real, &
                           cfg_kind_logical, cfg_kind_choice, cfg_kind_string, &
                           cfg_kind_real3, config_schema_count, get_config_schema_entry, &
                           config_schema_choice_count, get_config_schema_choice, &
                           config_default_integer, config_default_real, config_default_logical, &
                           config_default_string, config_default_real3
  use solver_session, only: solver_session_t, solver_progress_t, solver_status_ok, &
                            solver_status_invalid_argument, solver_status_invalid_state, &
                            solver_status_config_error, solver_status_io_error, &
                            solver_status_busy, solver_session_create, solver_session_destroy, &
                            solver_session_load_namelist, solver_session_initialize, &
                            solver_session_advance, solver_session_run_to_end, &
                            solver_session_get_progress, solver_session_get_point_count, &
                            solver_session_copy_solution, solver_session_write_result, &
                            solver_session_write_checkpoint, solver_session_last_error, &
                            solver_session_get_integer, solver_session_get_real, &
                            solver_session_get_logical, solver_session_get_string, &
                            solver_session_get_real3, solver_session_set_integer, &
                            solver_session_set_real, solver_session_set_logical, &
                            solver_session_set_string, solver_session_set_real3
  implicit none
  private

  integer, parameter :: handle_token = 1 !< Sentinel used to reject stale/foreign handles.

  !> C-interoperable progress record returned by `cfd_solver_get_progress`.
  type, bind(c) :: cfd_solver_progress_c_t
    integer(c_int) :: iteration = 0       !< Completed solver iterations.
    integer(c_int) :: n_point = 0         !< Grid-point count (`n_cell + 1`).
    integer(c_int) :: is_initialized = 0  !< `1` after runtime initialisation, otherwise `0`.
    integer(c_int) :: is_finished = 0     !< `1` once `time_stop` has been reached.
    real(c_double) :: sim_time = 0.0_c_double !< Current simulation time. [s]
    real(c_double) :: time_stop = 0.0_c_double !< Configured stop time. [s]
    real(c_double) :: dt = 0.0_c_double       !< Current or configured time step. [s]
    real(c_double) :: residual = 0.0_c_double !< Latest residual scalar from the runtime.
  end type cfd_solver_progress_c_t

  !> Opaque token wrapper used to validate ABI handles from foreign code.
  type, bind(c) :: cfd_solver_handle_t
    integer(c_int) :: token = 0 !< Sentinel copied into the exported opaque handle.
  end type cfd_solver_handle_t

  ! V1 supports a single active session per host process even though the API is
  ! already shaped around opaque handles to avoid a future ABI break.
  type(solver_session_t), pointer, save :: active_session => null()
  type(cfd_solver_handle_t), pointer, save :: active_handle => null()
  character(len=512), save :: api_last_error = '' !< Process-global error fallback before session resolution.

  public :: cfd_solver_progress_c_t
  public :: cfd_solver_create, cfd_solver_destroy, cfd_solver_load_namelist
  public :: cfd_solver_initialize, cfd_solver_advance, cfd_solver_run_to_end
  public :: cfd_solver_get_progress, cfd_solver_get_point_count
  public :: cfd_solver_copy_solution, cfd_solver_write_result
  public :: cfd_solver_write_checkpoint, cfd_solver_get_last_error
  public :: cfd_solver_get_int, cfd_solver_get_real, cfd_solver_get_bool
  public :: cfd_solver_get_string, cfd_solver_get_real3
  public :: cfd_solver_set_int, cfd_solver_set_real, cfd_solver_set_bool
  public :: cfd_solver_set_string, cfd_solver_set_real3
  public :: cfd_solver_schema_count, cfd_solver_schema_key, cfd_solver_schema_group
  public :: cfd_solver_schema_help, cfd_solver_schema_kind, cfd_solver_schema_bounds
  public :: cfd_solver_schema_choice_count, cfd_solver_schema_choice
  public :: cfd_solver_schema_default_int, cfd_solver_schema_default_real
  public :: cfd_solver_schema_default_bool, cfd_solver_schema_default_string
  public :: cfd_solver_schema_default_real3

contains

  !> Allocate the sole supported v1 session and return its opaque handle.
  integer(c_int) function cfd_solver_create(handle) bind(c, name='cfd_solver_create') result(status)
    type(c_ptr), intent(out) :: handle
    integer :: alloc_stat

    handle = c_null_ptr
    ! Keep the externally visible handle model stable now, even though the
    ! implementation still enforces a single active session in this first ABI.
    if (associated(active_session)) then
      api_last_error = 'cfd_solver: only one active session is supported in v1'
      status = solver_status_busy
      return
    end if

    allocate (active_session, stat=alloc_stat)
    if (alloc_stat /= 0) then
      api_last_error = 'cfd_solver: failed to allocate session'
      status = solver_status_io_error
      return
    end if
    call solver_session_create(active_session)
    allocate (active_handle, stat=alloc_stat)
    if (alloc_stat /= 0) then
      deallocate (active_session)
      nullify (active_session)
      api_last_error = 'cfd_solver: failed to allocate handle'
      status = solver_status_io_error
      return
    end if
    active_handle % token = handle_token
    handle = c_loc(active_handle)
    api_last_error = ''
    status = solver_status_ok
  end function cfd_solver_create

  !> Destroy the active session referenced by `handle`.
  integer(c_int) function cfd_solver_destroy(handle) bind(c, name='cfd_solver_destroy') result(status)
    type(c_ptr), value :: handle
    type(solver_session_t), pointer :: session

    status = resolve_session(handle, session)
    if (status /= solver_status_ok) return

    call solver_session_destroy(session)
    deallocate (active_session)
    deallocate (active_handle)
    nullify (active_session)
    nullify (active_handle)
    api_last_error = ''
    status = solver_status_ok
  end function cfd_solver_destroy

  !> Load a namelist file into the session configuration.
  integer(c_int) function cfd_solver_load_namelist(handle, filename) bind(c, name='cfd_solver_load_namelist') result(status)
    type(c_ptr), value :: handle
    character(kind=c_char), intent(in) :: filename(*)
    type(solver_session_t), pointer :: session
    character(len=512) :: path, err

    status = resolve_session(handle, session)
    if (status /= solver_status_ok) return
    path = c_string_to_fortran(filename)
    call solver_session_load_namelist(session, trim(path), status, err)
    api_last_error = trim(err)
  end function cfd_solver_load_namelist

  !> Validate config and allocate the runtime owned by `handle`.
  integer(c_int) function cfd_solver_initialize(handle) bind(c, name='cfd_solver_initialize') result(status)
    type(c_ptr), value :: handle
    type(solver_session_t), pointer :: session
    character(len=512) :: err

    status = resolve_session(handle, session)
    if (status /= solver_status_ok) return
    call solver_session_initialize(session, status, err)
    api_last_error = trim(err)
  end function cfd_solver_initialize

  !> Advance the solver by at most `max_steps` iterations.
  !!
  !! This is the canonical polling-first live-update path for GUI adapters.
  integer(c_int) function cfd_solver_advance(handle, max_steps, steps_taken, finished) &
    bind(c, name='cfd_solver_advance') result(status)
    type(c_ptr), value :: handle
    integer(c_int), value :: max_steps
    integer(c_int), intent(out) :: steps_taken
    integer(c_int), intent(out) :: finished
    type(solver_session_t), pointer :: session
    integer :: steps_done
    logical :: is_finished
    character(len=512) :: err

    steps_taken = 0_c_int
    finished = 0_c_int
    status = resolve_session(handle, session)
    if (status /= solver_status_ok) return

    call solver_session_advance(session, int(max_steps), steps_done, is_finished, status, err)
    steps_taken = int(steps_done, c_int)
    if (is_finished) finished = 1_c_int
    api_last_error = trim(err)
  end function cfd_solver_advance

  !> Run the session to completion without intermediate polling.
  integer(c_int) function cfd_solver_run_to_end(handle) bind(c, name='cfd_solver_run_to_end') result(status)
    type(c_ptr), value :: handle
    type(solver_session_t), pointer :: session
    character(len=512) :: err

    status = resolve_session(handle, session)
    if (status /= solver_status_ok) return
    call solver_session_run_to_end(session, status, err)
    api_last_error = trim(err)
  end function cfd_solver_run_to_end

  !> Copy out the current progress snapshot for a session.
  integer(c_int) function cfd_solver_get_progress(handle, progress) bind(c, name='cfd_solver_get_progress') result(status)
    type(c_ptr), value :: handle
    type(cfd_solver_progress_c_t), intent(out) :: progress
    type(solver_session_t), pointer :: session
    type(solver_progress_t) :: progress_f
    character(len=512) :: err

    progress = cfd_solver_progress_c_t()
    status = resolve_session(handle, session)
    if (status /= solver_status_ok) return

    call solver_session_get_progress(session, progress_f, status, err)
    if (status == solver_status_ok) then
      progress % iteration = int(progress_f % iteration, c_int)
      progress % n_point = int(progress_f % n_point, c_int)
      progress % is_initialized = merge(1_c_int, 0_c_int, progress_f % is_initialized)
      progress % is_finished = merge(1_c_int, 0_c_int, progress_f % is_finished)
      progress % sim_time = real(progress_f % sim_time, c_double)
      progress % time_stop = real(progress_f % time_stop, c_double)
      progress % dt = real(progress_f % dt, c_double)
      progress % residual = real(progress_f % residual, c_double)
    end if
    api_last_error = trim(err)
  end function cfd_solver_get_progress

  !> Return the grid-point count required by solution-copy buffers.
  integer(c_int) function cfd_solver_get_point_count(handle, n_point) bind(c, name='cfd_solver_get_point_count') result(status)
    type(c_ptr), value :: handle
    integer(c_int), intent(out) :: n_point
    type(solver_session_t), pointer :: session
    integer :: n_point_f
    character(len=512) :: err

    n_point = 0_c_int
    status = resolve_session(handle, session)
    if (status /= solver_status_ok) return
    call solver_session_get_point_count(session, n_point_f, status, err)
    if (status == solver_status_ok) n_point = int(n_point_f, c_int)
    api_last_error = trim(err)
  end function cfd_solver_get_point_count

  !> Copy the primitive solution into caller-owned C buffers.
  !!
  !! `n_point` must match the exact grid-point count reported by
  !! `cfd_solver_get_point_count`.
  integer(c_int) function cfd_solver_copy_solution(handle, x, rho, u, p, n_point) &
    bind(c, name='cfd_solver_copy_solution') result(status)
    type(c_ptr), value :: handle
    real(c_double), intent(out) :: x(*), rho(*), u(*), p(*)
    integer(c_int), value :: n_point
    type(solver_session_t), pointer :: session
    real(wp), allocatable :: x_f(:), rho_f(:), u_f(:), p_f(:)
    integer :: i, alloc_stat
    character(len=512) :: err

    status = resolve_session(handle, session)
    if (status /= solver_status_ok) return
    if (n_point < 0_c_int) then
      api_last_error = 'cfd_solver: n_point must be >= 0'
      status = solver_status_invalid_argument
      return
    end if

    allocate (x_f(int(n_point)), rho_f(int(n_point)), u_f(int(n_point)), p_f(int(n_point)), stat=alloc_stat)
    if (alloc_stat /= 0) then
      api_last_error = 'cfd_solver: failed to allocate solution copy buffers'
      status = solver_status_io_error
      return
    end if

    ! Bridge the assumed-size C buffers through explicit Fortran temporaries so
    ! the session layer can keep a simple bounds-checked assumed-shape API.
    call solver_session_copy_solution(session, x_f, rho_f, u_f, p_f, status, err)
    if (status == solver_status_ok) then
      do i = 1, int(n_point)
        x(i) = real(x_f(i), c_double)
        rho(i) = real(rho_f(i), c_double)
        u(i) = real(u_f(i), c_double)
        p(i) = real(p_f(i), c_double)
      end do
    end if
    api_last_error = trim(err)
  end function cfd_solver_copy_solution

  !> Write the current solution in the standard text result format.
  integer(c_int) function cfd_solver_write_result(handle, filename) bind(c, name='cfd_solver_write_result') result(status)
    type(c_ptr), value :: handle
    character(kind=c_char), intent(in) :: filename(*)
    type(solver_session_t), pointer :: session
    character(len=512) :: path, err

    status = resolve_session(handle, session)
    if (status /= solver_status_ok) return
    path = c_string_to_fortran(filename)
    call solver_session_write_result(session, trim(path), status, err)
    api_last_error = trim(err)
  end function cfd_solver_write_result

  !> Force an immediate checkpoint write for the active runtime.
  integer(c_int) function cfd_solver_write_checkpoint(handle, base) bind(c, name='cfd_solver_write_checkpoint') result(status)
    type(c_ptr), value :: handle
    character(kind=c_char), intent(in) :: base(*)
    type(solver_session_t), pointer :: session
    character(len=512) :: base_path, err

    status = resolve_session(handle, session)
    if (status /= solver_status_ok) return
    base_path = c_string_to_fortran(base)
    call solver_session_write_checkpoint(session, trim(base_path), status, err)
    api_last_error = trim(err)
  end function cfd_solver_write_checkpoint

  !> Copy the last surfaced error string into a C buffer.
  !!
  !! When `handle` cannot be resolved, the function falls back to the process-wide
  !! API error string so callers can still retrieve create/resolve failures.
  integer(c_int) function cfd_solver_get_last_error(handle, buffer, buffer_len) &
    bind(c, name='cfd_solver_get_last_error') result(status)
    type(c_ptr), value :: handle
    character(kind=c_char), intent(out) :: buffer(*)
    integer(c_int), value :: buffer_len
    type(solver_session_t), pointer :: session
    character(len=512) :: err

    if (buffer_len <= 0_c_int) then
      status = solver_status_invalid_argument
      return
    end if

    ! When the handle is NULL we cannot and should not call resolve_session(),
    ! because resolve_session() would overwrite api_last_error with a
    ! "null session handle" message, hiding any earlier error from, for example,
    ! a failed cfd_solver_create() call.  Return the preserved api_last_error
    ! directly instead.
    if (.not. c_associated(handle)) then
      call fortran_to_c_buffer(trim(api_last_error), buffer, int(buffer_len))
      status = solver_status_ok
      return
    end if

    if (resolve_session(handle, session) == solver_status_ok) then
      call solver_session_last_error(session, err)
    else
      err = trim(api_last_error)
    end if
    call fortran_to_c_buffer(trim(err), buffer, int(buffer_len))
    status = solver_status_ok
  end function cfd_solver_get_last_error

  !> Read an integer configuration field by canonical schema key.
  integer(c_int) function cfd_solver_get_int(handle, key, value) bind(c, name='cfd_solver_get_int') result(status)
    type(c_ptr), value :: handle
    character(kind=c_char), intent(in) :: key(*)
    integer(c_int), intent(out) :: value
    type(solver_session_t), pointer :: session
    integer :: value_f
    character(len=512) :: err

    value = 0_c_int
    status = resolve_session(handle, session)
    if (status /= solver_status_ok) return
    call solver_session_get_integer(session, trim(c_string_to_fortran(key)), value_f, status, err)
    if (status == solver_status_ok) value = int(value_f, c_int)
    api_last_error = trim(err)
  end function cfd_solver_get_int

  !> Read a scalar real configuration field by canonical schema key.
  integer(c_int) function cfd_solver_get_real(handle, key, value) bind(c, name='cfd_solver_get_real') result(status)
    type(c_ptr), value :: handle
    character(kind=c_char), intent(in) :: key(*)
    real(c_double), intent(out) :: value
    type(solver_session_t), pointer :: session
    real(wp) :: value_f
    character(len=512) :: err

    value = 0.0_c_double
    status = resolve_session(handle, session)
    if (status /= solver_status_ok) return
    call solver_session_get_real(session, trim(c_string_to_fortran(key)), value_f, status, err)
    if (status == solver_status_ok) value = real(value_f, c_double)
    api_last_error = trim(err)
  end function cfd_solver_get_real

  !> Read a logical configuration field by canonical schema key.
  integer(c_int) function cfd_solver_get_bool(handle, key, value) bind(c, name='cfd_solver_get_bool') result(status)
    type(c_ptr), value :: handle
    character(kind=c_char), intent(in) :: key(*)
    integer(c_int), intent(out) :: value
    type(solver_session_t), pointer :: session
    logical :: value_f
    character(len=512) :: err

    value = 0_c_int
    status = resolve_session(handle, session)
    if (status /= solver_status_ok) return
    call solver_session_get_logical(session, trim(c_string_to_fortran(key)), value_f, status, err)
    if (status == solver_status_ok) value = merge(1_c_int, 0_c_int, value_f)
    api_last_error = trim(err)
  end function cfd_solver_get_bool

  !> Read a string or choice-token configuration field by canonical schema key.
  integer(c_int) function cfd_solver_get_string(handle, key, buffer, buffer_len) &
    bind(c, name='cfd_solver_get_string') result(status)
    type(c_ptr), value :: handle
    character(kind=c_char), intent(in) :: key(*)
    character(kind=c_char), intent(out) :: buffer(*)
    integer(c_int), value :: buffer_len
    type(solver_session_t), pointer :: session
    character(len=512) :: value_f, err

    status = resolve_session(handle, session)
    if (status /= solver_status_ok) return
    if (buffer_len <= 0_c_int) then
      api_last_error = 'cfd_solver: buffer_len must be > 0'
      status = solver_status_invalid_argument
      return
    end if
    call solver_session_get_string(session, trim(c_string_to_fortran(key)), value_f, status, err)
    if (status == solver_status_ok) call fortran_to_c_buffer(trim(value_f), buffer, int(buffer_len))
    api_last_error = trim(err)
  end function cfd_solver_get_string

  !> Read a length-3 real configuration field by canonical schema key.
  integer(c_int) function cfd_solver_get_real3(handle, key, values, n_values) bind(c, name='cfd_solver_get_real3') result(status)
    type(c_ptr), value :: handle
    character(kind=c_char), intent(in) :: key(*)
    real(c_double), intent(out) :: values(*)
    integer(c_int), value :: n_values
    type(solver_session_t), pointer :: session
    real(wp) :: value_f(3)
    integer :: i
    character(len=512) :: err

    status = resolve_session(handle, session)
    if (status /= solver_status_ok) return
    if (n_values < 3_c_int) then
      api_last_error = 'cfd_solver: n_values must be at least 3 for real3 parameters'
      status = solver_status_invalid_argument
      return
    end if
    call solver_session_get_real3(session, trim(c_string_to_fortran(key)), value_f, status, err)
    if (status == solver_status_ok) then
      do i = 1, 3
        values(i) = real(value_f(i), c_double)
      end do
    end if
    api_last_error = trim(err)
  end function cfd_solver_get_real3

  !> Set an integer configuration field by canonical schema key.
  integer(c_int) function cfd_solver_set_int(handle, key, value) bind(c, name='cfd_solver_set_int') result(status)
    type(c_ptr), value :: handle
    character(kind=c_char), intent(in) :: key(*)
    integer(c_int), value :: value
    type(solver_session_t), pointer :: session
    character(len=512) :: err

    status = resolve_session(handle, session)
    if (status /= solver_status_ok) return
    call solver_session_set_integer(session, trim(c_string_to_fortran(key)), int(value), status, err)
    api_last_error = trim(err)
  end function cfd_solver_set_int

  !> Set a scalar real configuration field by canonical schema key.
  integer(c_int) function cfd_solver_set_real(handle, key, value) bind(c, name='cfd_solver_set_real') result(status)
    type(c_ptr), value :: handle
    character(kind=c_char), intent(in) :: key(*)
    real(c_double), value :: value
    type(solver_session_t), pointer :: session
    character(len=512) :: err

    status = resolve_session(handle, session)
    if (status /= solver_status_ok) return
    call solver_session_set_real(session, trim(c_string_to_fortran(key)), real(value, wp), status, err)
    api_last_error = trim(err)
  end function cfd_solver_set_real

  !> Set a logical configuration field by canonical schema key.
  integer(c_int) function cfd_solver_set_bool(handle, key, value) bind(c, name='cfd_solver_set_bool') result(status)
    type(c_ptr), value :: handle
    character(kind=c_char), intent(in) :: key(*)
    integer(c_int), value :: value
    type(solver_session_t), pointer :: session
    character(len=512) :: err

    status = resolve_session(handle, session)
    if (status /= solver_status_ok) return
    call solver_session_set_logical(session, trim(c_string_to_fortran(key)), value /= 0_c_int, status, err)
    api_last_error = trim(err)
  end function cfd_solver_set_bool

  !> Set a string or choice-token configuration field by canonical schema key.
  integer(c_int) function cfd_solver_set_string(handle, key, value) bind(c, name='cfd_solver_set_string') result(status)
    type(c_ptr), value :: handle
    character(kind=c_char), intent(in) :: key(*)
    character(kind=c_char), intent(in) :: value(*)
    type(solver_session_t), pointer :: session
    character(len=512) :: err

    status = resolve_session(handle, session)
    if (status /= solver_status_ok) return
    call solver_session_set_string(session, trim(c_string_to_fortran(key)), trim(c_string_to_fortran(value)), status, err)
    api_last_error = trim(err)
  end function cfd_solver_set_string

  !> Set a length-3 real configuration field by canonical schema key.
  integer(c_int) function cfd_solver_set_real3(handle, key, values, n_values) bind(c, name='cfd_solver_set_real3') result(status)
    type(c_ptr), value :: handle
    character(kind=c_char), intent(in) :: key(*)
    real(c_double), intent(in) :: values(*)
    integer(c_int), value :: n_values
    type(solver_session_t), pointer :: session
    real(wp) :: values_f(3)
    integer :: i
    character(len=512) :: err

    status = resolve_session(handle, session)
    if (status /= solver_status_ok) return
    if (n_values < 3_c_int) then
      api_last_error = 'cfd_solver: n_values must be at least 3 for real3 parameters'
      status = solver_status_invalid_argument
      return
    end if
    do i = 1, 3
      values_f(i) = real(values(i), wp)
    end do
    call solver_session_set_real3(session, trim(c_string_to_fortran(key)), values_f, status, err)
    api_last_error = trim(err)
  end function cfd_solver_set_real3

  !> Return the number of schema entries exported through the ABI.
  !!
  !! Schema indices are 1-based to match the native Fortran table ordering.
  integer(c_int) function cfd_solver_schema_count() bind(c, name='cfd_solver_schema_count') result(count)
    count = int(config_schema_count(), c_int)
  end function cfd_solver_schema_count

  !> Copy the canonical key for schema entry `index`.
  integer(c_int) function cfd_solver_schema_key(index, buffer, buffer_len) bind(c, name='cfd_solver_schema_key') result(status)
    integer(c_int), value :: index
    character(kind=c_char), intent(out) :: buffer(*)
    integer(c_int), value :: buffer_len
    type(config_schema_entry_t) :: entry
    logical :: found

    status = schema_entry_text(index, buffer, buffer_len, found, entry, 'key')
  end function cfd_solver_schema_key

  !> Copy the namelist group name for schema entry `index`.
  integer(c_int) function cfd_solver_schema_group(index, buffer, buffer_len) bind(c, name='cfd_solver_schema_group') result(status)
    integer(c_int), value :: index
    character(kind=c_char), intent(out) :: buffer(*)
    integer(c_int), value :: buffer_len
    type(config_schema_entry_t) :: entry
    logical :: found

    status = schema_entry_text(index, buffer, buffer_len, found, entry, 'group')
  end function cfd_solver_schema_group

  !> Copy the short help text for schema entry `index`.
  integer(c_int) function cfd_solver_schema_help(index, buffer, buffer_len) bind(c, name='cfd_solver_schema_help') result(status)
    integer(c_int), value :: index
    character(kind=c_char), intent(out) :: buffer(*)
    integer(c_int), value :: buffer_len
    type(config_schema_entry_t) :: entry
    logical :: found

    status = schema_entry_text(index, buffer, buffer_len, found, entry, 'help')
  end function cfd_solver_schema_help

  !> Return the `cfg_kind_*` tag for schema entry `index`.
  integer(c_int) function cfd_solver_schema_kind(index, value_kind) bind(c, name='cfd_solver_schema_kind') result(status)
    integer(c_int), value :: index
    integer(c_int), intent(out) :: value_kind
    type(config_schema_entry_t) :: entry
    logical :: found

    value_kind = 0_c_int
    call get_config_schema_entry(int(index), entry, found)
    if (.not. found) then
      api_last_error = 'cfd_solver: schema index out of range'
      status = solver_status_invalid_argument
      return
    end if
    value_kind = int(entry % value_kind, c_int)
    api_last_error = ''
    status = solver_status_ok
  end function cfd_solver_schema_kind

  !> Return optional numeric bounds for schema entry `index`.
  integer(c_int) function cfd_solver_schema_bounds(index, has_min, min_value, has_max, &
                                                   max_value) bind(c, name='cfd_solver_schema_bounds') result(status)
    integer(c_int), value :: index
    integer(c_int), intent(out) :: has_min
    real(c_double), intent(out) :: min_value
    integer(c_int), intent(out) :: has_max
    real(c_double), intent(out) :: max_value
    type(config_schema_entry_t) :: entry
    logical :: found

    has_min = 0_c_int
    has_max = 0_c_int
    min_value = 0.0_c_double
    max_value = 0.0_c_double
    call get_config_schema_entry(int(index), entry, found)
    if (.not. found) then
      api_last_error = 'cfd_solver: schema index out of range'
      status = solver_status_invalid_argument
      return
    end if
    has_min = merge(1_c_int, 0_c_int, entry % has_min)
    has_max = merge(1_c_int, 0_c_int, entry % has_max)
    min_value = real(entry % min_value, c_double)
    max_value = real(entry % max_value, c_double)
    api_last_error = ''
    status = solver_status_ok
  end function cfd_solver_schema_bounds

  !> Return the number of allowed string choices for schema entry `index`.
  integer(c_int) function cfd_solver_schema_choice_count(index, count) bind(c, name='cfd_solver_schema_choice_count') result(status)
    integer(c_int), value :: index
    integer(c_int), intent(out) :: count
    type(config_schema_entry_t) :: entry
    logical :: found

    count = 0_c_int
    call get_config_schema_entry(int(index), entry, found)
    if (.not. found) then
      api_last_error = 'cfd_solver: schema index out of range'
      status = solver_status_invalid_argument
      return
    end if
    count = int(config_schema_choice_count(int(index)), c_int)
    api_last_error = ''
    status = solver_status_ok
  end function cfd_solver_schema_choice_count

  !> Copy one allowed string token for a choice-valued schema entry.
  !!
  !! Both `index` and `choice_index` are 1-based.
  integer(c_int) function cfd_solver_schema_choice(index, choice_index, buffer, buffer_len) &
    bind(c, name='cfd_solver_schema_choice') result(status)
    integer(c_int), value :: index
    integer(c_int), value :: choice_index
    character(kind=c_char), intent(out) :: buffer(*)
    integer(c_int), value :: buffer_len
    character(len=64) :: choice_value
    logical :: found

    if (buffer_len <= 0_c_int) then
      api_last_error = 'cfd_solver: buffer_len must be > 0'
      status = solver_status_invalid_argument
      return
    end if
    call get_config_schema_choice(int(index), int(choice_index), choice_value, found)
    if (.not. found) then
      api_last_error = 'cfd_solver: schema choice index out of range'
      status = solver_status_invalid_argument
      return
    end if
    call fortran_to_c_buffer(trim(choice_value), buffer, int(buffer_len))
    api_last_error = ''
    status = solver_status_ok
  end function cfd_solver_schema_choice

  !> Copy the compiled-in default integer value for schema entry `index`.
  integer(c_int) function cfd_solver_schema_default_int(index, value) bind(c, name='cfd_solver_schema_default_int') result(status)
    integer(c_int), value :: index
    integer(c_int), intent(out) :: value
    character(len=32) :: key
    integer :: value_f
    logical :: found, ok
    type(config_schema_entry_t) :: entry
    character(len=256) :: err

    value = 0_c_int
    call get_config_schema_entry(int(index), entry, found)
    if (.not. found) then
      api_last_error = 'cfd_solver: schema index out of range'
      status = solver_status_invalid_argument
      return
    end if
    key = trim(entry % key)
    call config_default_integer(key, value_f, ok, err)
    if (.not. ok) then
      api_last_error = trim(err)
      status = solver_status_invalid_argument
      return
    end if
    value = int(value_f, c_int)
    api_last_error = ''
    status = solver_status_ok
  end function cfd_solver_schema_default_int

  !> Copy the compiled-in default scalar real value for schema entry `index`.
  integer(c_int) function cfd_solver_schema_default_real(index, value) bind(c, name='cfd_solver_schema_default_real') result(status)
    integer(c_int), value :: index
    real(c_double), intent(out) :: value
    character(len=32) :: key
    real(wp) :: value_f
    logical :: found, ok
    type(config_schema_entry_t) :: entry
    character(len=256) :: err

    value = 0.0_c_double
    call get_config_schema_entry(int(index), entry, found)
    if (.not. found) then
      api_last_error = 'cfd_solver: schema index out of range'
      status = solver_status_invalid_argument
      return
    end if
    key = trim(entry % key)
    call config_default_real(key, value_f, ok, err)
    if (.not. ok) then
      api_last_error = trim(err)
      status = solver_status_invalid_argument
      return
    end if
    value = real(value_f, c_double)
    api_last_error = ''
    status = solver_status_ok
  end function cfd_solver_schema_default_real

  !> Copy the compiled-in default logical value for schema entry `index`.
  integer(c_int) function cfd_solver_schema_default_bool(index, value) bind(c, name='cfd_solver_schema_default_bool') result(status)
    integer(c_int), value :: index
    integer(c_int), intent(out) :: value
    character(len=32) :: key
    logical :: value_f, found, ok
    type(config_schema_entry_t) :: entry
    character(len=256) :: err

    value = 0_c_int
    call get_config_schema_entry(int(index), entry, found)
    if (.not. found) then
      api_last_error = 'cfd_solver: schema index out of range'
      status = solver_status_invalid_argument
      return
    end if
    key = trim(entry % key)
    call config_default_logical(key, value_f, ok, err)
    if (.not. ok) then
      api_last_error = trim(err)
      status = solver_status_invalid_argument
      return
    end if
    value = merge(1_c_int, 0_c_int, value_f)
    api_last_error = ''
    status = solver_status_ok
  end function cfd_solver_schema_default_bool

  !> Copy the compiled-in default string or choice token for schema entry `index`.
  integer(c_int) function cfd_solver_schema_default_string(index, buffer, buffer_len) &
    bind(c, name='cfd_solver_schema_default_string') result(status)
    integer(c_int), value :: index
    character(kind=c_char), intent(out) :: buffer(*)
    integer(c_int), value :: buffer_len
    character(len=32) :: key
    character(len=512) :: value_f
    logical :: found, ok
    type(config_schema_entry_t) :: entry
    character(len=256) :: err

    if (buffer_len <= 0_c_int) then
      api_last_error = 'cfd_solver: buffer_len must be > 0'
      status = solver_status_invalid_argument
      return
    end if
    call get_config_schema_entry(int(index), entry, found)
    if (.not. found) then
      api_last_error = 'cfd_solver: schema index out of range'
      status = solver_status_invalid_argument
      return
    end if
    key = trim(entry % key)
    call config_default_string(key, value_f, ok, err)
    if (.not. ok) then
      api_last_error = trim(err)
      status = solver_status_invalid_argument
      return
    end if
    call fortran_to_c_buffer(trim(value_f), buffer, int(buffer_len))
    api_last_error = ''
    status = solver_status_ok
  end function cfd_solver_schema_default_string

  !> Copy the compiled-in default length-3 real vector for schema entry `index`.
  integer(c_int) function cfd_solver_schema_default_real3(index, values, n_values) &
    bind(c, name='cfd_solver_schema_default_real3') result(status)
    integer(c_int), value :: index
    real(c_double), intent(out) :: values(*)
    integer(c_int), value :: n_values
    character(len=32) :: key
    real(wp) :: value_f(3)
    integer :: i
    logical :: found, ok
    type(config_schema_entry_t) :: entry
    character(len=256) :: err

    if (n_values < 3_c_int) then
      api_last_error = 'cfd_solver: n_values must be at least 3 for real3 defaults'
      status = solver_status_invalid_argument
      return
    end if
    call get_config_schema_entry(int(index), entry, found)
    if (.not. found) then
      api_last_error = 'cfd_solver: schema index out of range'
      status = solver_status_invalid_argument
      return
    end if
    key = trim(entry % key)
    call config_default_real3(key, value_f, ok, err)
    if (.not. ok) then
      api_last_error = trim(err)
      status = solver_status_invalid_argument
      return
    end if
    do i = 1, 3
      values(i) = real(value_f(i), c_double)
    end do
    api_last_error = ''
    status = solver_status_ok
  end function cfd_solver_schema_default_real3

  !> Resolve an opaque C handle to the active Fortran session pointer.
  integer(c_int) function resolve_session(handle, session) result(status)
    type(c_ptr), value :: handle
    type(solver_session_t), pointer :: session
    type(cfd_solver_handle_t), pointer :: handle_ptr

    nullify (session)
    if (.not. c_associated(handle)) then
      api_last_error = 'cfd_solver: null session handle'
      status = solver_status_invalid_argument
      return
    end if
    if (.not. associated(active_session) .or. .not. associated(active_handle)) then
      api_last_error = 'cfd_solver: no active session'
      status = solver_status_invalid_state
      return
    end if

    call c_f_pointer(handle, handle_ptr)
    if (.not. associated(handle_ptr)) then
      api_last_error = 'cfd_solver: invalid session handle'
      status = solver_status_invalid_argument
      return
    end if
    if (handle_ptr % token /= handle_token) then
      api_last_error = 'cfd_solver: invalid session token'
      status = solver_status_invalid_argument
      return
    end if
    if (.not. associated(handle_ptr, active_handle)) then
      api_last_error = 'cfd_solver: stale session handle'
      status = solver_status_invalid_argument
      return
    end if

    session => active_session
    api_last_error = ''
    status = solver_status_ok
  end function resolve_session

  !> Shared helper for copying one text field from a schema entry into a C buffer.
  integer(c_int) function schema_entry_text(index, buffer, buffer_len, found, entry, field_name) result(status)
    integer(c_int), value :: index
    character(kind=c_char), intent(out) :: buffer(*)
    integer(c_int), value :: buffer_len
    logical, intent(out) :: found
    type(config_schema_entry_t), intent(out) :: entry
    character(len=*), intent(in) :: field_name

    found = .false.
    entry = config_schema_entry_t()
    if (buffer_len <= 0_c_int) then
      api_last_error = 'cfd_solver: buffer_len must be > 0'
      status = solver_status_invalid_argument
      return
    end if

    call get_config_schema_entry(int(index), entry, found)
    if (.not. found) then
      api_last_error = 'cfd_solver: schema index out of range'
      status = solver_status_invalid_argument
      return
    end if

    select case (trim(field_name))
    case ('key')
      call fortran_to_c_buffer(trim(entry % key), buffer, int(buffer_len))
    case ('group')
      call fortran_to_c_buffer(trim(entry % group), buffer, int(buffer_len))
    case ('help')
      call fortran_to_c_buffer(trim(entry % help), buffer, int(buffer_len))
    end select
    api_last_error = ''
    status = solver_status_ok
  end function schema_entry_text

  !> Convert a NUL-terminated C string into a fixed-length Fortran buffer.
  function c_string_to_fortran(cstr) result(text)
    character(kind=c_char), intent(in) :: cstr(*)
    character(len=512) :: text
    integer :: i

    text = ''
    do i = 1, len(text)
      if (cstr(i) == c_null_char) exit
      text(i:i) = achar(iachar(cstr(i)))
    end do
  end function c_string_to_fortran

  !> Copy trimmed Fortran text into a NUL-terminated C buffer.
  subroutine fortran_to_c_buffer(text, buffer, buffer_len)
    character(len=*), intent(in) :: text
    character(kind=c_char), intent(out) :: buffer(*)
    integer, intent(in) :: buffer_len
    integer :: i, ncopy

    if (buffer_len <= 0) return
    ncopy = min(len_trim(text), buffer_len - 1)
    do i = 1, ncopy
      buffer(i) = text(i:i)
    end do
    if (ncopy + 1 <= buffer_len) buffer(ncopy + 1) = c_null_char
    do i = ncopy + 2, buffer_len
      buffer(i) = c_null_char
    end do
  end subroutine fortran_to_c_buffer

end module solver_c_api