!> @file config_2d.f90
!> @brief 2D Euler solver configuration: derived type, namelist loader, validation.
!!
!! Mirrors the 1D `config` module but for the coexisting `euler_2d` solver.
!! Carries grid, physics, time-control, scheme-selection, per-edge
!! boundary-condition, and 2D initial-condition fields. The time-control and
!! scheme fields drive the `euler_2d` solve (flux/reconstruction/time-integration
!! selection and stop criteria).
module config_2d
  use precision, only: wp
  use output_format_list, only: parse_format_list, max_formats
  use mesh_2d, only: build_mesh_2d_global, mesh_2d_t, MESH2D_MAX_DIM
  use string_utils, only: to_lower
  use logger, only: log_warn
  use path_util, only: resolve_case_path, case_base_dir
  use option_registry, only: flux_scheme_2d_names, recon_scheme_2d_names, &
                             time_scheme_2d_names, problem_type_2d_names, &
                             bc_2d_names, char_proj_mode_names, &
                             method_names, &
                             is_fds_flux_scheme, &
                             method_fdm, method_fvm, &
                             bc_periodic, problem_2d_sedov, &
                             problem_2d_toro_explosion, problem_2d_riemann2d_config3
  use config_engine, only: field_desc_t, field_meta, validate_fields, &
                           cfg_kind_int, cfg_kind_real, cfg_kind_logical, &
                           cfg_kind_choice, cfg_kind_string, choice_msg_bare
  use, intrinsic :: iso_fortran_env, only: iostat_end, int64
  implicit none
  private
  public :: config_2d_t, read_config_2d, validate_config_2d
  public :: build_field_table_2d

  !> Frozen ABI schema field count, identical order to
  !! `config_schema_2d.ensure_schema_2d` — append only.
  integer, parameter, public :: n_config_fields_2d = 38

  type :: config_2d_t
    ! --- grid ---
    integer :: nx = 100
    integer :: ny = 100
    real(wp) :: x_left = 0.0_wp
    real(wp) :: x_right = 1.0_wp
    real(wp) :: y_left = 0.0_wp
    real(wp) :: y_right = 1.0_wp
    character(len=32) :: grid_type = 'uniform'
    character(len=256) :: grid_file = ''
    logical :: grid_binary = .false.
    ! --- physics ---
    real(wp) :: gam = 1.4_wp
    ! --- time control ---
    real(wp) :: dt = 1.0e-4_wp
    real(wp) :: cfl = 0.5_wp
    real(wp) :: time_start = 0.0_wp
    real(wp) :: time_stop = 0.1_wp
    integer :: max_iter = 0
    ! --- schemes ---
    character(len=64) :: flux_scheme = 'hllc'
    character(len=64) :: recon_scheme = 'weno5'
    character(len=64) :: time_scheme = 'rk3'
    character(len=8) :: char_proj = 'auto'
    character(len=64) :: method = 'fvm'
    ! --- initial / boundary conditions ---
    character(len=64) :: problem_type = 'isentropic_vortex'
    character(len=32) :: bc_left = 'periodic'
    character(len=32) :: bc_right = 'periodic'
    character(len=32) :: bc_bottom = 'periodic'
    character(len=32) :: bc_top = 'periodic'
    ! --- free-stream reference (supersonic_inlet BC) ---
    real(wp) :: rho_inf = 1.0_wp
    real(wp) :: u_inf = 0.0_wp
    real(wp) :: v_inf = 0.0_wp
    real(wp) :: p_inf = 1.0_wp
    ! --- limiter switches ---
    logical :: use_positivity_limiter = .true.
    ! --- output ---
    character(len=256) :: output_file = 'result_2d.dat'
    character(len=64) :: output_format = 'dat'
    integer :: verbosity = 3
    integer :: print_freq = 50
    character(len=256) :: log_file = 'run.log'
    ! --- checkpoint / restart ---
    integer :: checkpoint_freq = 0                  !< write a checkpoint every N iters (0 = off)
    character(len=256) :: checkpoint_file = 'checkpoint'  !< base name; file = <base>_<iter>.bin
    character(len=256) :: restart_file = ''         !< restart from this file (empty = fresh start)
  end type config_2d_t

contains

  !> Read a namelist file into a config_2d_t and validate it.
  subroutine read_config_2d(filename, cfg, is_ok, message, case_dir)
    character(len=*), intent(in) :: filename
    type(config_2d_t), intent(out) :: cfg
    logical, intent(out) :: is_ok
    character(len=*), intent(out) :: message
    !> Optional case directory to anchor relative file paths against; when
    !! absent/empty the namelist's own directory is used (see path_util).
    character(len=*), intent(in), optional :: case_dir

    integer, parameter :: grid_unset = -huge(1)
    integer :: nx, ny, max_iter, n_cell
    real(wp) :: x_left, x_right, y_left, y_right, gam
    real(wp) :: dt, cfl, time_start, time_stop
    character(len=64) :: flux_scheme, recon_scheme, time_scheme, problem_type
    character(len=64) :: method
    character(len=8) :: char_proj
    character(len=32) :: bc_left, bc_right, bc_bottom, bc_top
    real(wp) :: rho_inf, u_inf, v_inf, p_inf
    character(len=256) :: output_file
    character(len=64) :: output_format
    integer :: verbosity, print_freq
    character(len=256) :: log_file
    logical :: use_positivity_limiter
    character(len=32) :: grid_type
    character(len=256) :: grid_file
    logical :: grid_binary
    integer :: checkpoint_freq
    character(len=256) :: checkpoint_file, restart_file
    integer :: u, ios
    type(config_2d_t) :: defaults

    namelist /grid/ n_cell, nx, ny, x_left, x_right, y_left, y_right, &
      grid_type, grid_file, grid_binary
    namelist /physics/ gam
    namelist /time_ctrl/ dt, cfl, time_start, time_stop, max_iter
    namelist /schemes/ flux_scheme, recon_scheme, time_scheme, char_proj, use_positivity_limiter, method
    namelist /initial_condition/ problem_type, bc_left, bc_right, bc_bottom, bc_top, &
      rho_inf, u_inf, v_inf, p_inf
    namelist /output/ output_file, output_format, verbosity, print_freq, log_file
    namelist /checkpoint/ checkpoint_freq, checkpoint_file, restart_file

    ! Seed namelist locals from the type defaults so omitted keys keep them.
    n_cell = grid_unset   ! sentinel: "not supplied"
    nx = grid_unset; ny = grid_unset   ! sentinel: "not supplied by caller"
    x_left = defaults % x_left; x_right = defaults % x_right
    y_left = defaults % y_left; y_right = defaults % y_right
    grid_type = defaults % grid_type
    grid_file = defaults % grid_file
    grid_binary = defaults % grid_binary
    gam = defaults % gam
    dt = defaults % dt; cfl = defaults % cfl
    time_start = defaults % time_start; time_stop = defaults % time_stop
    max_iter = defaults % max_iter
    flux_scheme = defaults % flux_scheme; recon_scheme = defaults % recon_scheme
    time_scheme = defaults % time_scheme; char_proj = defaults % char_proj
    use_positivity_limiter = defaults % use_positivity_limiter
    method = defaults % method
    problem_type = defaults % problem_type
    bc_left = defaults % bc_left; bc_right = defaults % bc_right
    bc_bottom = defaults % bc_bottom; bc_top = defaults % bc_top
    rho_inf = defaults % rho_inf; u_inf = defaults % u_inf
    v_inf = defaults % v_inf; p_inf = defaults % p_inf
    output_file = defaults % output_file
    output_format = defaults % output_format
    verbosity = defaults % verbosity
    print_freq = defaults % print_freq
    log_file = defaults % log_file
    checkpoint_freq = defaults % checkpoint_freq
    checkpoint_file = defaults % checkpoint_file
    restart_file = defaults % restart_file

    is_ok = .false.
    message = ''
    open (newunit=u, file=filename, status='old', action='read', iostat=ios)
    if (ios /= 0) then
      message = 'config_2d: cannot open "'//trim(filename)//'"'
      return
    end if

    ! Each group is optional; rewind between reads so order does not matter.
    rewind (u); read (u, nml=grid, iostat=ios)
    if (ios /= 0 .and. ios /= iostat_end) then
      message = 'config_2d: parse error in &grid namelist'
      close (u, iostat=ios)
      return
    end if
    ! n_cell cascade: n_cell is the isotropic resolution; an explicitly supplied
    ! nx/ny overrides it. A value supplied but invalid (<=0) is KEPT so that
    ! validate_config_2d rejects it (do not silently replace user input).
    if (nx == grid_unset) then
      if (n_cell /= grid_unset) then
        nx = n_cell
      else
        nx = defaults % nx
      end if
    end if
    if (ny == grid_unset) then
      if (n_cell /= grid_unset) then
        ny = n_cell
      else
        ny = defaults % ny
      end if
    end if
    rewind (u); read (u, nml=physics, iostat=ios)
    if (ios /= 0 .and. ios /= iostat_end) then
      message = 'config_2d: parse error in &physics namelist'
      close (u, iostat=ios)
      return
    end if
    rewind (u); read (u, nml=time_ctrl, iostat=ios)
    if (ios /= 0 .and. ios /= iostat_end) then
      message = 'config_2d: parse error in &time_ctrl namelist'
      close (u, iostat=ios)
      return
    end if
    rewind (u); read (u, nml=schemes, iostat=ios)
    if (ios /= 0 .and. ios /= iostat_end) then
      message = 'config_2d: parse error in &schemes namelist'
      close (u, iostat=ios)
      return
    end if
    rewind (u); read (u, nml=initial_condition, iostat=ios)
    if (ios /= 0 .and. ios /= iostat_end) then
      message = 'config_2d: parse error in &initial_condition namelist'
      close (u, iostat=ios)
      return
    end if
    rewind (u); read (u, nml=output, iostat=ios)
    if (ios /= 0 .and. ios /= iostat_end) then
      message = 'config_2d: parse error in &output namelist'
      close (u, iostat=ios)
      return
    end if
    rewind (u); read (u, nml=checkpoint, iostat=ios)
    if (ios /= 0 .and. ios /= iostat_end) then
      message = 'config_2d: parse error in &checkpoint namelist'
      close (u, iostat=ios)
      return
    end if
    close (u, iostat=ios)

    cfg % nx = nx; cfg % ny = ny
    cfg % x_left = x_left; cfg % x_right = x_right
    cfg % y_left = y_left; cfg % y_right = y_right
    cfg % grid_type = to_lower(grid_type)
    ! Resolve a relative grid_file against the case directory (the explicit
    ! case_dir override when given, else the namelist's own directory), so a
    ! case folder is movable and grids load regardless of the process cwd.
    ! Absolute paths are kept as-is; empty stays empty.
    cfg % grid_file = resolve_case_path(case_base_dir(filename, case_dir), grid_file)
    cfg % grid_binary = grid_binary
    cfg % gam = gam
    cfg % dt = dt; cfg % cfl = cfl
    cfg % time_start = time_start; cfg % time_stop = time_stop
    cfg % max_iter = max_iter
    cfg % flux_scheme = to_lower(flux_scheme)
    cfg % recon_scheme = to_lower(recon_scheme)
    cfg % time_scheme = to_lower(time_scheme)
    cfg % char_proj = to_lower(char_proj)
    cfg % method = to_lower(method)
    cfg % use_positivity_limiter = use_positivity_limiter
    cfg % problem_type = to_lower(problem_type)
    cfg % bc_left = to_lower(bc_left)
    cfg % bc_right = to_lower(bc_right)
    cfg % bc_bottom = to_lower(bc_bottom)
    cfg % bc_top = to_lower(bc_top)
    cfg % rho_inf = rho_inf; cfg % u_inf = u_inf
    cfg % v_inf = v_inf; cfg % p_inf = p_inf
    cfg % output_file = output_file
    cfg % output_format = to_lower(output_format)
    cfg % verbosity = verbosity
    cfg % print_freq = print_freq
    cfg % log_file = log_file
    cfg % checkpoint_freq = checkpoint_freq
    cfg % checkpoint_file = checkpoint_file
    cfg % restart_file = restart_file

    call validate_config_2d(cfg, is_ok, message)
  end subroutine read_config_2d

  !> Bind the 38-entry field-descriptor table (frozen `ensure_schema_2d`
  !! order) to `cfg`. No casefold flags: the 2D reader already stores
  !! lowercased selectors, and the engine's choice compare casefolds the
  !! candidate anyway, preserving the direct-call validate_config_2d
  !! mixed-case tolerance. Help texts are empty (the 2D schema never
  !! published help).
  function build_field_table_2d(cfg) result(fields)
    type(config_2d_t), intent(inout), target :: cfg
    type(field_desc_t) :: fields(n_config_fields_2d)

    ! --- grid: nx/ny/extent checks are conditional on grid_type, kept in
    ! validate_config_2d ---
    fields(1) = field_meta('nx', 'grid', cfg_kind_int, '')
    fields(1) % iptr => cfg % nx
    fields(2) = field_meta('ny', 'grid', cfg_kind_int, '')
    fields(2) % iptr => cfg % ny
    fields(3) = field_meta('x_left', 'grid', cfg_kind_real, '')
    fields(3) % rptr => cfg % x_left
    fields(4) = field_meta('x_right', 'grid', cfg_kind_real, '')
    fields(4) % rptr => cfg % x_right
    fields(5) = field_meta('y_left', 'grid', cfg_kind_real, '')
    fields(5) % rptr => cfg % y_left
    fields(6) = field_meta('y_right', 'grid', cfg_kind_real, '')
    fields(6) % rptr => cfg % y_right
    fields(7) = field_meta('grid_type', 'grid', cfg_kind_string, '')
    fields(7) % sptr => cfg % grid_type
    fields(8) = field_meta('grid_file', 'grid', cfg_kind_string, '')
    fields(8) % sptr => cfg % grid_file
    fields(9) = field_meta('grid_binary', 'grid', cfg_kind_logical, '')
    fields(9) % lptr => cfg % grid_binary

    fields(10) = field_meta('gam', 'physics', cfg_kind_real, '', &
                            has_min=.true., min_exclusive=.true., min_value=1.0_wp, &
                            msg_min='config_2d: gam must exceed 1')
    fields(10) % rptr => cfg % gam

    fields(11) = field_meta('dt', 'time_ctrl', cfg_kind_real, '', &
                            has_min=.true., min_exclusive=.true., min_value=0.0_wp, &
                            msg_min='config_2d: dt must be positive')
    fields(11) % rptr => cfg % dt
    fields(12) = field_meta('cfl', 'time_ctrl', cfg_kind_real, '', &
                            has_min=.true., min_value=0.0_wp, &
                            msg_min='config_2d: cfl must be non-negative')
    fields(12) % rptr => cfg % cfl
    fields(13) = field_meta('time_start', 'time_ctrl', cfg_kind_real, '')
    fields(13) % rptr => cfg % time_start
    fields(14) = field_meta('time_stop', 'time_ctrl', cfg_kind_real, '')
    fields(14) % rptr => cfg % time_stop
    ! max_iter has never been validated by validate_config_2d: metadata only.
    fields(15) = field_meta('max_iter', 'time_ctrl', cfg_kind_int, '')
    fields(15) % iptr => cfg % max_iter

    fields(16) = field_meta('flux_scheme', 'schemes', cfg_kind_choice, '', &
                            choices=flux_scheme_2d_names)
    fields(16) % sptr => cfg % flux_scheme
    fields(17) = field_meta('recon_scheme', 'schemes', cfg_kind_choice, '', &
                            choices=recon_scheme_2d_names)
    fields(17) % sptr => cfg % recon_scheme
    fields(18) = field_meta('time_scheme', 'schemes', cfg_kind_choice, '', &
                            choices=time_scheme_2d_names)
    fields(18) % sptr => cfg % time_scheme
    fields(19) = field_meta('char_proj', 'schemes', cfg_kind_choice, '', &
                            choices=char_proj_mode_names)
    fields(19) % sptr => cfg % char_proj
    fields(20) = field_meta('method', 'schemes', cfg_kind_choice, '', &
                            choices=method_names)
    fields(20) % sptr => cfg % method
    fields(21) = field_meta('use_positivity_limiter', 'schemes', cfg_kind_logical, '')
    fields(21) % lptr => cfg % use_positivity_limiter

    fields(22) = field_meta('problem_type', 'initial_condition', cfg_kind_choice, '', &
                            choices=problem_type_2d_names, choice_msg_style=choice_msg_bare)
    fields(22) % sptr => cfg % problem_type
    fields(23) = field_meta('bc_left', 'initial_condition', cfg_kind_choice, '', &
                            choices=bc_2d_names, &
                            msg_choice='config_2d: unknown boundary-condition selector')
    fields(23) % sptr => cfg % bc_left
    fields(24) = field_meta('bc_right', 'initial_condition', cfg_kind_choice, '', &
                            choices=bc_2d_names, &
                            msg_choice='config_2d: unknown boundary-condition selector')
    fields(24) % sptr => cfg % bc_right
    fields(25) = field_meta('bc_bottom', 'initial_condition', cfg_kind_choice, '', &
                            choices=bc_2d_names, &
                            msg_choice='config_2d: unknown boundary-condition selector')
    fields(25) % sptr => cfg % bc_bottom
    fields(26) = field_meta('bc_top', 'initial_condition', cfg_kind_choice, '', &
                            choices=bc_2d_names, &
                            msg_choice='config_2d: unknown boundary-condition selector')
    fields(26) % sptr => cfg % bc_top
    fields(27) = field_meta('rho_inf', 'initial_condition', cfg_kind_real, '', &
                            has_min=.true., min_exclusive=.true., min_value=0.0_wp, &
                            msg_min='config_2d: rho_inf and p_inf must be positive')
    fields(27) % rptr => cfg % rho_inf
    fields(28) = field_meta('u_inf', 'initial_condition', cfg_kind_real, '')
    fields(28) % rptr => cfg % u_inf
    fields(29) = field_meta('v_inf', 'initial_condition', cfg_kind_real, '')
    fields(29) % rptr => cfg % v_inf
    fields(30) = field_meta('p_inf', 'initial_condition', cfg_kind_real, '', &
                            has_min=.true., min_exclusive=.true., min_value=0.0_wp, &
                            msg_min='config_2d: rho_inf and p_inf must be positive')
    fields(30) % rptr => cfg % p_inf

    fields(31) = field_meta('output_file', 'output', cfg_kind_string, '')
    fields(31) % sptr => cfg % output_file
    ! output_format's multi-token parse stays a cross-field/conditional check.
    fields(32) = field_meta('output_format', 'output', cfg_kind_string, '')
    fields(32) % sptr => cfg % output_format
    fields(33) = field_meta('verbosity', 'output', cfg_kind_int, '', &
                            has_min=.true., min_value=0.0_wp, has_max=.true., max_value=4.0_wp, &
                            msg_min='config_2d: verbosity must be 0..4', &
                            msg_max='config_2d: verbosity must be 0..4')
    fields(33) % iptr => cfg % verbosity
    fields(34) = field_meta('print_freq', 'output', cfg_kind_int, '', &
                            has_min=.true., min_value=1.0_wp, &
                            msg_min='config_2d: print_freq must be >= 1')
    fields(34) % iptr => cfg % print_freq
    fields(35) = field_meta('log_file', 'output', cfg_kind_string, '')
    fields(35) % sptr => cfg % log_file

    fields(36) = field_meta('checkpoint_freq', 'checkpoint', cfg_kind_int, '', &
                            has_min=.true., min_value=0.0_wp, &
                            msg_min='config_2d: checkpoint_freq must be >= 0 (0 disables checkpointing)')
    fields(36) % iptr => cfg % checkpoint_freq
    fields(37) = field_meta('checkpoint_file', 'checkpoint', cfg_kind_string, '')
    fields(37) % sptr => cfg % checkpoint_file
    fields(38) = field_meta('restart_file', 'checkpoint', cfg_kind_string, '')
    fields(38) % sptr => cfg % restart_file
  end function build_field_table_2d

  !> Validate ranges and enumerated selectors.
  subroutine validate_config_2d(cfg, is_valid, message)
    type(config_2d_t), intent(in) :: cfg
    logical, intent(out) :: is_valid
    character(len=*), intent(out) :: message

    type(config_2d_t), target :: work
    type(field_desc_t) :: fields(n_config_fields_2d)
    logical :: ok

    is_valid = .false.
    message = ''
    work = cfg
    fields = build_field_table_2d(work)
    call validate_fields(fields, 'config_2d', ok, message)
    if (.not. ok) return

    ! --- Cross-field / conditional checks: KEPT VERBATIM, original order ---
    ! --- Method token + FDM/grid_type coarse check (before any grid-file probe) ---
    if (to_lower(cfg % method) == method_fdm .and. &
        trim(cfg % grid_type) /= 'uniform' .and. trim(cfg % grid_type) /= 'plot3d') then
      message = "config_2d: method='fdm' supports grid_type 'uniform' (Cartesian) or "// &
                "'plot3d' (curvilinear); grid_type='"//trim(cfg % grid_type)//"' not supported"
      return
    end if
    if (trim(cfg % grid_type) == 'plot3d') then
      if (len_trim(cfg % grid_file) == 0) then
        message = 'config_2d: grid_type=plot3d requires grid_file'
        return
      end if
      block
        type(mesh_2d_t) :: probe
        integer :: pnx, pny
        logical :: gok
        character(len=256) :: gmsg
        pnx = 0; pny = 0
        call build_mesh_2d_global(probe, 'plot3d', cfg % grid_file, cfg % grid_binary, &
                                  pnx, pny, 0.0_wp, 1.0_wp, 0.0_wp, 1.0_wp, gok, gmsg)
        if (.not. gok) then
          message = 'config_2d: '//trim(gmsg)
          return
        end if
        ! Curvilinear FDM treats the Plot3D POINTS as nodes and needs >= 3 nodes
        ! per axis for the 3-point one-sided boundary derivatives. The probe build
        ! reports cell counts (pnx=ni-1, pny=nj-1), so require pnx >= 2, pny >= 2.
        if (to_lower(cfg % method) == method_fdm .and. (pnx < 2 .or. pny < 2)) then
          message = 'config_2d: curvilinear FDM (method=fdm, grid_type=plot3d) requires '// &
                    'at least 3 grid points per axis'
          return
        end if
      end block
      ! Sedov / Toro / Config-3 build cell coordinates from x_left + (i-0.5)*dx;
      ! on a curvilinear grid dx == 0, collapsing the whole field to x_left (and
      ! dividing by zero in the Sedov hot-cell normaliser). Only freestream,
      ! uniform, and isentropic_vortex are curvilinear-aware.
      select case (trim(to_lower(cfg % problem_type)))
      case (problem_2d_sedov, problem_2d_toro_explosion, problem_2d_riemann2d_config3)
        message = "config_2d: problem_type '"//trim(cfg % problem_type)// &
                  "' needs uniform-grid cell coordinates and is not supported on "// &
                  "grid_type='plot3d' (use 'freestream', 'uniform', or 'isentropic_vortex')"
        return
      end select
    else if (trim(cfg % grid_type) == 'uniform') then
      if (cfg % nx <= 0 .or. cfg % ny <= 0) then
        message = 'config_2d: nx and ny must be positive'
        return
      end if
      if (cfg % nx > MESH2D_MAX_DIM .or. cfg % ny > MESH2D_MAX_DIM) then
        message = 'config_2d: nx and ny must be <= MESH2D_MAX_DIM'
        return
      end if
      if (int(cfg % nx, int64) * int(cfg % ny, int64) > int(MESH2D_MAX_DIM, int64)**2) then
        message = 'config_2d: nx*ny exceeds the maximum total cell count'
        return
      end if
      if (cfg % x_right <= cfg % x_left .or. cfg % y_right <= cfg % y_left) then
        message = 'config_2d: require x_right > x_left and y_right > y_left'
        return
      end if
    else
      message = "config_2d: unknown grid_type '"//trim(cfg % grid_type)// &
                "' (expected 'uniform' or 'plot3d')"
      return
    end if
    if (cfg % time_stop <= cfg % time_start) then
      message = 'config_2d: time_stop must exceed time_start'
      return
    end if
    if (to_lower(cfg % char_proj) /= 'auto') &
      call log_warn('config_2d: char_proj is ignored by the 2D solver')
    if (to_lower(cfg % method) == method_fvm .and. &
        .not. is_fds_flux_scheme(to_lower(cfg % flux_scheme))) then
      message = "config_2d: method='fvm' requires an FDS flux_scheme (hllc/rusanov); "// &
                "FVS schemes are FDM-only"
      return
    end if
    if ((to_lower(cfg % bc_left) == bc_periodic) .neqv. (to_lower(cfg % bc_right) == bc_periodic)) then
      message = 'config_2d: x-axis periodicity requires BOTH bc_left and bc_right = periodic'
      return
    end if
    if ((to_lower(cfg % bc_bottom) == bc_periodic) .neqv. (to_lower(cfg % bc_top) == bc_periodic)) then
      message = 'config_2d: y-axis periodicity requires BOTH bc_bottom and bc_top = periodic'
      return
    end if
    block
      character(len=8) :: fmt_tokens(max_formats)
      integer :: n_fmt
      logical :: fmt_ok
      character(len=256) :: fmt_msg
      call parse_format_list(cfg % output_format, &
                             [character(len=8) :: 'dat', 'vtk', 'tec', 'plt'], &
                             fmt_tokens, n_fmt, fmt_ok, fmt_msg)
      if (.not. fmt_ok) then
        message = 'config_2d: '//trim(fmt_msg)
        return
      end if
    end block
    is_valid = .true.
  end subroutine validate_config_2d

end module config_2d
