!===========================================
! Author: Elmo Tempel (elmo.tempel@ut.ee)
!===========================================
!> MAML-format-specific validation: the declarative schema of every top-level
!> MAML section and its allowed sub-keys plus the validator and its private
!> helpers that check a raw MAML's section/sub-key names against it (presence
!> only, not values); full-schema structural validation
!> (parquet_validate_maml); cross-checking a user MAML against a base schema
!> (parquet_validate_user_maml); loading MAML/qc-maml files from disk; and
!> qc-maml field parsing/validation for read-time quality control. Merges
!> what used to be parquet_metadata_sections.f90 and parquet_metadata_validate.f90.
submodule (parquet_core:parquet_metadata) parquet_metadata_maml
    use ieee_arithmetic, only: ieee_is_nan
    use iso_fortran_env, only: iostat_eor, iostat_end
    implicit none

    ! Schema of allowed top-level MAML sections and, for sections whose list
    ! items are maps (e.g. "fields:", "keyarray:"), the allowed sub-keys within
    ! each item -- checked (presence only, not content) by parquet_validate_maml.
    !
    ! To allow a new top-level section, add an entry here. To allow a new
    ! sub-key within an existing map-list section's items, add a name to that
    ! entry's subkeys(:) (unused slots must stay ""). Section/sub-key names are
    ! matched case-insensitively. Sections with an empty subkeys(:) are either
    ! plain scalars (survey:, table:, ...) or plain string lists (comments:,
    ! keywords:, ...): their list items (if any) are opaque strings, not maps,
    ! so no sub-keys are validated for them.
    !
    ! "extra:" is the sole exception: opaque = .true. means its entire
    ! internal structure (arbitrarily nested) is accepted unvalidated.
    !
    ! Sub-keys are only checked one level into a list item (e.g. fields:'s
    ! "qc:"); anything nested deeper than that (e.g. qc:'s own min/max/miss)
    ! is not descended into or validated, the same as extra:.
    integer, parameter :: maml_max_subkeys = 9 !! Declared subkeys(:) capacity per maml_section_schema entry.

    !> One allowed top-level MAML section: its name, whether its contents are
    !> opaque/unvalidated (extra:), and (for map-list sections) its allowed
    !> item sub-keys.
    type :: maml_section_schema
        character(len=32) :: name = "" !! Section name (matched case-insensitively).
        logical :: opaque = .false. !! .true. if this section's contents are accepted unvalidated (extra: only).
        character(len=32) :: subkeys(maml_max_subkeys) = "" !! Allowed item sub-keys; "" for unused slots.
    end type maml_section_schema

    ! Schema for sub-keys allowed one level deeper than allowed_maml_sections,
    ! i.e. inside a specific sub-key of a map-list item -- currently just
    ! fields:'s "qc:" sub-block (min/max/miss). Add an entry here for any
    ! other sub-key that itself has structured children needing validation;
    ! anything not listed here is left unvalidated at that depth (see
    ! parquet_validate_maml_sections).
    integer, parameter :: maml_max_nested_subkeys = 4 !! Declared subkeys(:) capacity per maml_nested_schema entry.

    !> One allowed nested sub-key block, one level deeper than a
    !> maml_section_schema item's own direct sub-keys (e.g. fields:'s "qc:").
    type :: maml_nested_schema
        character(len=32) :: parent_section = "" !! Enclosing top-level section name.
        character(len=32) :: parent_subkey = "" !! The item sub-key whose own children this schema describes.
        character(len=32) :: subkeys(maml_max_nested_subkeys) = "" !! Allowed nested sub-keys; "" for unused slots.
    end type maml_nested_schema

    !> Currently just fields:'s "qc:" sub-block (min:/max:/miss:).
    type(maml_nested_schema), parameter :: allowed_maml_nested_sections(1) = [ &
        maml_nested_schema("fields", "qc", [character(len=32) :: "min", "max", "miss", ""]) &
        ]

    !> The full set of allowed top-level MAML sections; see each entry's
    !> inline comments above for the rationale behind opaque/subkeys choices.
    type(maml_section_schema), parameter :: allowed_maml_sections(17) = [ &
        maml_section_schema("survey",      .false., [character(len=32) :: "", "", "", "", "", "", "", "", ""]), &
        maml_section_schema("dataset",     .false., [character(len=32) :: "", "", "", "", "", "", "", "", ""]), &
        maml_section_schema("table",       .false., [character(len=32) :: "", "", "", "", "", "", "", "", ""]), &
        maml_section_schema("version",     .false., [character(len=32) :: "", "", "", "", "", "", "", "", ""]), &
        maml_section_schema("date",        .false., [character(len=32) :: "", "", "", "", "", "", "", "", ""]), &
        maml_section_schema("author",      .false., [character(len=32) :: "", "", "", "", "", "", "", "", ""]), &
        maml_section_schema("coauthors",   .false., [character(len=32) :: "", "", "", "", "", "", "", "", ""]), &
        maml_section_schema("dois",        .false., [character(len=32) :: "doi", "type", "", "", "", "", "", "", ""]), &
        maml_section_schema("depends",     .false., &
            [character(len=32) :: "survey", "dataset", "table", "version", "", "", "", "", ""]), &
        maml_section_schema("description", .false., [character(len=32) :: "", "", "", "", "", "", "", "", ""]), &
        maml_section_schema("comments",    .false., [character(len=32) :: "", "", "", "", "", "", "", "", ""]), &
        maml_section_schema("license",     .false., [character(len=32) :: "", "", "", "", "", "", "", "", ""]), &
        maml_section_schema("keywords",    .false., [character(len=32) :: "", "", "", "", "", "", "", "", ""]), &
        maml_section_schema("maml_version", .false., [character(len=32) :: "", "", "", "", "", "", "", "", ""]), &
        maml_section_schema("keyarray",    .false., &
            [character(len=32) :: "key", "value", "comment", "", "", "", "", "", ""]), &
        maml_section_schema("extra",       .true.,  [character(len=32) :: "", "", "", "", "", "", "", "", ""]), &
        ! col_map: is deliberately NOT a top-level section: it is only valid
        ! nested inside extra: (see parquet_parse_col_map), which is already
        ! opaque/unvalidated here. A stray top-level "col_map:" is therefore
        ! correctly flagged as an unknown top-level section.
        ! "source" is read only by tools/generate_user_table_code.py, which turns a MAML into a
        ! generated parquet_table extension: it says whether a predefined column comes from the
        ! file ("file", the default) or is filled in by the program ("computed"). It is declared
        ! here so that such a MAML still passes parquet_validate_maml and stays usable as an
        ! ordinary write schema; nothing on the Fortran side reads it.
        maml_section_schema("fields",      .false., &
            [character(len=32) :: "name", "unit", "info", "ucd", "data_type", "array_size", "col_size", "qc", &
            "source"]) &
        ]

contains

    !> Checks that every top-level section in `lines`, every sub-key found
    !> one level inside a map-list section's items (e.g. "name:"/"data_type:"/
    !> ... inside a fields: entry), and every sub-key one level deeper still
    !> where allowed_maml_nested_sections declares one (currently just
    !> fields:'s "qc:" sub-block), is declared in the schema. Presence only:
    !> values are not inspected. Anything nested deeper than that, or
    !> anywhere inside "extra:", is left unvalidated.
    !> Appends one "; "-terminated message per unrecognized name to `errors`.
    module subroutine parquet_validate_maml_sections(lines, errors)
        character(len=*), intent(in) :: lines(:) !! raw MAML source lines to check.
        character(len=:), allocatable, intent(inout) :: errors !! accumulated error messages; appended to, not reset.
        character(len=:), allocatable :: tline, item_tline, key, cvalue
        character(len=32) :: current_subkey
        integer :: i, n, indent, item_indent, section_idx, nested_idx
        logical :: have_item_indent, section_opaque

        n = size(lines)
        section_idx = 0
        section_opaque = .false.
        have_item_indent = .false.
        item_indent = 0
        current_subkey = ""

        do i = 1, n
            tline = trim(adjustl(lines(i)))
            if (len_trim(tline) == 0) cycle
            if (tline(1:1) == "#") cycle

            indent = parquet_line_indent(lines(i))

            if (indent == 0 .and. tline(1:1) /= "-") then
                ! A new top-level section.
                call parquet_split_key_value(tline, key, cvalue)
                if (len_trim(key) == 0) cycle
                section_idx = parquet_find_maml_section(key)
                if (section_idx == 0) then
                    errors = errors // "unknown top-level section '" // trim(key) // "'; "
                    section_opaque = .true.
                else
                    section_opaque = allowed_maml_sections(section_idx)%opaque
                end if
                have_item_indent = .false.
                current_subkey = ""
                cycle
            end if

            if (section_opaque .or. section_idx == 0) cycle

            if (indent == 0 .and. tline(1:1) == "-") then
                ! Start of a new list item; the dash line may itself carry
                ! the item's first sub-key, e.g. "- name: id0".
                have_item_indent = .false.
                current_subkey = ""
                if (parquet_section_has_subkeys(section_idx)) then
                    item_tline = trim(adjustl(tline(2:)))
                    if (len_trim(item_tline) > 0) then
                        call parquet_split_key_value(item_tline, key, cvalue)
                        if (len_trim(key) > 0) then
                            call parquet_check_maml_subkey(section_idx, key, errors)
                            current_subkey = key
                        end if
                    end if
                end if
                cycle
            end if

            ! Indented continuation line.
            if (.not. have_item_indent) then
                item_indent = indent
                have_item_indent = .true.
            end if

            if (indent > item_indent) then
                ! Nested one level deeper than the item's direct sub-keys
                ! (e.g. fields:'s "qc:" children): only validated where
                ! allowed_maml_nested_sections declares a schema for the
                ! enclosing sub-key; anything else is left unvalidated here.
                nested_idx = parquet_find_maml_nested_section(allowed_maml_sections(section_idx)%name, current_subkey)
                if (nested_idx == 0) cycle
                if (index(tline, ":") == 0) cycle
                call parquet_split_key_value(tline, key, cvalue)
                if (len_trim(key) > 0) call parquet_check_maml_nested_subkey(nested_idx, key, errors)
                cycle
            end if

            if (.not. parquet_section_has_subkeys(section_idx)) cycle
            if (index(tline, ":") == 0) cycle

            call parquet_split_key_value(tline, key, cvalue)
            if (len_trim(key) > 0) then
                call parquet_check_maml_subkey(section_idx, key, errors)
                current_subkey = key
            end if
        end do
    end subroutine parquet_validate_maml_sections

    !> Number of leading blank characters in `line` (0 for a top-level line);
    !> `len(line)` for an all-blank line.
    function parquet_line_indent(line) result(indent)
        character(len=*), intent(in) :: line !! raw MAML source line.
        integer :: indent !! number of leading blanks.
        integer :: k

        do k = 1, len(line)
            if (line(k:k) /= " ") then
                indent = k - 1
                return
            end if
        end do
        indent = len(line) ! GCOVR_EXCL_LINE
    end function parquet_line_indent

    module procedure parquet_maml_key_matches
        character(len=len(line)) :: t
        character(len=1) :: c
        integer :: i, n

        t = adjustl(line)
        n = len_trim(t)
        parquet_maml_key_matches = .false.
        if (n /= len_trim(key)) return
        do i = 1, n
            c = t(i:i)
            if (c >= "A" .and. c <= "Z") c = achar(iachar(c) + 32)
            if (c /= key(i:i)) return
        end do
        parquet_maml_key_matches = .true.
    end procedure parquet_maml_key_matches

    !> 1-based index of `key` in allowed_maml_sections (case-insensitive), or 0 if unknown.
    function parquet_find_maml_section(key) result(idx)
        character(len=*), intent(in) :: key !! top-level section name to look up.
        integer :: idx !! index into allowed_maml_sections, or 0.
        integer :: k
        character(len=:), allocatable :: tlo1, tlo2 !! scratch (to_lower).

        idx = 0
        do k = 1, size(allowed_maml_sections)
            call parquet_to_lower(allowed_maml_sections(k)%name, tlo1)
            call parquet_to_lower(key, tlo2)
            if (trim(tlo1) == trim(tlo2)) then
                idx = k
                return
            end if
        end do
    end function parquet_find_maml_section

    !> True if the section at `section_idx` is a map-list section (has at
    !> least one declared sub-key), i.e. its items are validated as maps
    !> rather than treated as opaque scalars/strings.
    function parquet_section_has_subkeys(section_idx) result(has_subkeys)
        integer, intent(in) :: section_idx !! index into allowed_maml_sections.
        logical :: has_subkeys !! .true. if this section has at least one declared sub-key.

        has_subkeys = any(len_trim(allowed_maml_sections(section_idx)%subkeys) > 0)
    end function parquet_section_has_subkeys

    !> 1-based index of the (parent_section, parent_subkey) pair in
    !> allowed_maml_nested_sections (case-insensitive), or 0 if this
    !> sub-key has no declared nested schema.
    function parquet_find_maml_nested_section(parent_section, parent_subkey) result(idx)
        character(len=*), intent(in) :: parent_section !! enclosing top-level section name.
        character(len=*), intent(in) :: parent_subkey !! item sub-key whose nested schema is being looked up.
        integer :: idx !! index into allowed_maml_nested_sections, or 0.
        integer :: k
        character(len=:), allocatable :: tlo1, tlo2, tlo3, tlo4 !! scratch (to_lower).

        idx = 0
        if (len_trim(parent_subkey) == 0) return ! GCOVR_EXCL_LINE
        do k = 1, size(allowed_maml_nested_sections)
            call parquet_to_lower(allowed_maml_nested_sections(k)%parent_section, tlo1)
            call parquet_to_lower(parent_section, tlo2)
            call parquet_to_lower(allowed_maml_nested_sections(k)%parent_subkey, tlo3)
            call parquet_to_lower(parent_subkey, tlo4)
            if (trim(tlo1) == &
                trim(tlo2) .and. &
                trim(tlo3) == &
                trim(tlo4)) then
                idx = k
                return
            end if
        end do
    end function parquet_find_maml_nested_section

    !> Appends an "unknown sub-key" message to `errors` if `key` is not among
    !> allowed_maml_nested_sections(nested_idx)'s declared subkeys(:).
    subroutine parquet_check_maml_nested_subkey(nested_idx, key, errors)
        character(len=:), allocatable :: tlo1, tlo2 !! scratch (to_lower).
        integer, intent(in) :: nested_idx !! index into allowed_maml_nested_sections.
        character(len=*), intent(in) :: key !! nested sub-key name found in the MAML.
        character(len=:), allocatable, intent(inout) :: errors !! accumulated error messages; appended to, not reset.
        logical :: ok
        integer :: k

        ok = .false.
        do k = 1, maml_max_nested_subkeys
            if (len_trim(allowed_maml_nested_sections(nested_idx)%subkeys(k)) == 0) cycle
            call parquet_to_lower(allowed_maml_nested_sections(nested_idx)%subkeys(k), tlo1)
            call parquet_to_lower(key, tlo2)
            if (trim(tlo1) == &
                trim(tlo2)) then
                ok = .true.
                exit
            end if
        end do
        if (.not. ok) then
            errors = errors // "unknown sub-key '" // trim(key) // "' in '" // &
                trim(allowed_maml_nested_sections(nested_idx)%parent_subkey) // ":' block (inside section '" // &
                trim(allowed_maml_nested_sections(nested_idx)%parent_section) // "'); "
        end if
    end subroutine parquet_check_maml_nested_subkey

    !> Appends an "unknown sub-key" message to `errors` if `key` is not among
    !> allowed_maml_sections(section_idx)'s declared subkeys(:).
    subroutine parquet_check_maml_subkey(section_idx, key, errors)
        character(len=:), allocatable :: tlo1, tlo2 !! scratch (to_lower).
        integer, intent(in) :: section_idx !! index into allowed_maml_sections.
        character(len=*), intent(in) :: key !! sub-key name found in the MAML.
        character(len=:), allocatable, intent(inout) :: errors !! accumulated error messages; appended to, not reset.
        logical :: ok
        integer :: k

        ok = .false.
        do k = 1, maml_max_subkeys
            if (len_trim(allowed_maml_sections(section_idx)%subkeys(k)) == 0) cycle
            call parquet_to_lower(allowed_maml_sections(section_idx)%subkeys(k), tlo1)
            call parquet_to_lower(key, tlo2)
            if (trim(tlo1) == &
                trim(tlo2)) then
                ok = .true.
                exit
            end if
        end do
        if (.not. ok) then
            errors = errors // "unknown sub-key '" // trim(key) // "' in section '" // &
                trim(allowed_maml_sections(section_idx)%name) // "'; "
        end if
    end subroutine parquet_check_maml_subkey

    module procedure parquet_validate_user_maml
        type(parquet_column_info) :: base_cinfo, user_cinfo
        type(parquet_table_metadata) :: base_metadata, user_metadata
        character(len=:), allocatable :: bad_names, map_errors
        integer :: i, j
        logical :: found

        call parquet_validate_maml(base_maml)
        call parquet_validate_maml(user_maml)

        call parquet_parse_maml_lines(base_maml%lines, base_cinfo, base_metadata)
        call parquet_parse_maml_lines(user_maml%lines, user_cinfo, user_metadata)

        ! col_map: renames (col_internal -> col_user) are already applied to
        ! user_cinfo%col(:)%name by parquet_parse_maml_lines above -- every
        ! check below that compares names against base_cinfo therefore
        ! already operates on resolved internal names, with no changes
        ! needed. What's checked here, specific to col_map itself: every
        ! mapped internal name actually exists in the base schema, and the
        ! map has no internal-name duplicates or output-name collisions.
        user_maml%col_map = parquet_parse_col_map(user_maml%lines)
        map_errors = ""
        do i = 1, size(user_maml%col_map)
            found = .false.
            if (allocated(base_cinfo%col)) then
                do j = 1, size(base_cinfo%col)
                    if (trim(base_cinfo%col(j)%name) == trim(user_maml%col_map(i)%internal_name)) then
                        found = .true.
                        exit
                    end if
                end do
            end if
            if (.not. found) then
                map_errors = map_errors // "col_map: internal column '" // &
                    trim(user_maml%col_map(i)%internal_name) // "' not present in base MAML; "
            end if

            do j = 1, i - 1
                if (trim(user_maml%col_map(j)%internal_name) == trim(user_maml%col_map(i)%internal_name)) then
                    map_errors = map_errors // "col_map: duplicate internal column '" // &
                        trim(user_maml%col_map(i)%internal_name) // "'; "
                    exit
                end if
            end do

            do j = 1, i - 1
                if (trim(user_maml%col_map(j)%output_name) == trim(user_maml%col_map(i)%output_name)) then
                    map_errors = map_errors // "col_map: output name '" // &
                        trim(user_maml%col_map(i)%output_name) // "' used for more than one internal column; "
                    exit
                end if
            end do

            ! The renamed column must actually be declared in fields: under
            ! its output_name -- parquet_parse_maml_lines only renames a
            ! field it finds already declared as `output_name`; if none
            ! exists, the rename silently has nothing to apply to.
            found = .false.
            if (allocated(user_cinfo%col)) then
                do j = 1, size(user_cinfo%col)
                    if (trim(user_cinfo%col(j)%output_name) == trim(user_maml%col_map(i)%output_name)) then
                        found = .true.
                        exit
                    end if
                end do
            end if
            if (.not. found) then
                map_errors = map_errors // "col_map: renamed column '" // &
                    trim(user_maml%col_map(i)%output_name) // "' is not declared in fields:; "
            end if

            ! A remapped internal column must not also appear directly
            ! (un-renamed) in fields: -- that's ambiguous: was it meant to be
            ! renamed, or used as-is? (A field whose declared name matches
            ! internal_name but never got renamed keeps name == output_name
            ! == internal_name, since only a field declared under
            ! output_name is renamed.)
            if (allocated(user_cinfo%col)) then
                do j = 1, size(user_cinfo%col)
                    ! gcov attribution artifact: this condition is evaluated for every col_map(i)/col(j) pair
                    ! regardless of whether it's ever true, so gcov always marks it "hit".
                    if (trim(user_cinfo%col(j)%name) == trim(user_maml%col_map(i)%internal_name) .and. &
                        trim(user_cinfo%col(j)%output_name) == trim(user_maml%col_map(i)%internal_name)) then ! GCOVR_EXCL_START
                        map_errors = map_errors // "col_map: internal column '" // &
                            trim(user_maml%col_map(i)%internal_name) // &
                            "' is remapped but also appears directly (un-renamed) in fields:; "
                        exit
                    end if ! GCOVR_EXCL_STOP
                end do
            end if

            ! The chosen output_name must not coincide with a *different*
            ! existing (base) column's own name: if it did, that other base
            ! column -- whether or not this user MAML mentions it -- would
            ! collide with the renamed one the moment it's ever activated
            ! (e.g. via set_column_available), since both would then share the same
            ! output_name in the written schema.
            if (allocated(base_cinfo%col)) then
                do j = 1, size(base_cinfo%col)
                    if (trim(base_cinfo%col(j)%name) == trim(user_maml%col_map(i)%output_name) .and. &
                        trim(base_cinfo%col(j)%name) /= trim(user_maml%col_map(i)%internal_name)) then
                        map_errors = map_errors // "col_map: output name '" // &
                            trim(user_maml%col_map(i)%output_name) // &
                            "' coincides with the existing base column of that name; "
                        exit
                    end if
                end do
            end if
        end do

        ! Guards against a renamed column's output_name silently colliding
        ! with another, unrelated field's own declared name (or with another
        ! renamed column's output_name): every field ending up in the
        ! schema must have a distinct output_name, since that's what
        ! actually gets registered/written to the parquet file.
        if (allocated(user_cinfo%col)) then
            do i = 1, size(user_cinfo%col)
                do j = 1, i - 1
                    ! gcov attribution artifact: this condition is evaluated for every (i, j) pair regardless
                    ! of whether it's ever true, so gcov always marks it "hit".
                    if (trim(user_cinfo%col(j)%output_name) == trim(user_cinfo%col(i)%output_name)) then ! GCOVR_EXCL_START
                        map_errors = map_errors // "duplicate output name '" // &
                            trim(user_cinfo%col(i)%output_name) // "' used by more than one field in fields:; "
                        exit
                    end if ! GCOVR_EXCL_STOP
                end do
            end do
        end if

        if (len_trim(map_errors) > 0) then
            error stop "parquet_validate_user_maml: " // trim(map_errors)
        end if

        bad_names = ""
        if (allocated(user_cinfo%col)) then
            do i = 1, size(user_cinfo%col)
                found = .false.
                if (allocated(base_cinfo%col)) then
                    do j = 1, size(base_cinfo%col)
                        if (trim(base_cinfo%col(j)%name) == trim(user_cinfo%col(i)%name)) then
                            found = .true.
                            exit
                        end if
                    end do
                end if
                if (.not. found) then
                    if (len_trim(bad_names) > 0) bad_names = bad_names // ", "
                    bad_names = bad_names // trim(user_cinfo%col(i)%name)
                end if
            end do
        end if

        if (len_trim(bad_names) > 0) then
            error stop "parquet_validate_user_maml: columns not present in base MAML: " // trim(bad_names)
        end if

        if (allocated(user_maml%missing_columns)) deallocate(user_maml%missing_columns)
        user_maml%user_maml = .false.

        if (allocated(base_cinfo%col)) then
            do i = 1, size(base_cinfo%col)
                found = .false.
                if (allocated(user_cinfo%col)) then
                    do j = 1, size(user_cinfo%col)
                        if (trim(user_cinfo%col(j)%name) == trim(base_cinfo%col(i)%name)) then
                            found = .true.
                            exit
                        end if
                    end do
                end if
                if (.not. found) then
                    user_maml%user_maml = .true.
                    call parquet_append_missing_column(user_maml, base_cinfo%col(i))
                end if
            end do
        end if
    end procedure parquet_validate_user_maml

    !> Appends `col` (a base-schema column absent from `maml`) to
    !> maml%missing_columns, so parquet_merge_missing_columns can later
    !> restore it as a disabled/deactivated column.
    subroutine parquet_append_missing_column(maml, col)
        type(parquet_maml_file), intent(inout) :: maml !! user MAML gaining one missing-column entry.
        type(parquet_column_type), intent(in) :: col !! base-schema column that maml doesn't declare.
        type(parquet_maml_missing_column), allocatable :: tmp(:)
        integer :: n

        ! See g_maml_mutex in parquet_wrapper.cpp.
        call parquet_maml_lock()
        if (.not. allocated(maml%missing_columns)) then
            allocate(maml%missing_columns(1))
            n = 1
        else
            allocate(tmp(size(maml%missing_columns, kind=int64) + 1))
            tmp(1:size(maml%missing_columns)) = maml%missing_columns
            call move_alloc(tmp, maml%missing_columns)
            n = size(maml%missing_columns)
        end if

        maml%missing_columns(n)%name = col%name
        maml%missing_columns(n)%unit = col%unit
        maml%missing_columns(n)%info = col%info
        maml%missing_columns(n)%ucd = col%ucd
        maml%missing_columns(n)%data_type = col%data_type
        maml%missing_columns(n)%time_unit = col%time_unit
        maml%missing_columns(n)%is_utc = col%is_utc
        maml%missing_columns(n)%array_size = col%array_size
        maml%missing_columns(n)%col_size = col%col_size
        call parquet_maml_unlock()
    end subroutine parquet_append_missing_column

    module procedure parquet_validate_maml_internal
        type(parquet_column_info) :: cinfo
        type(parquet_table_metadata) :: metadata
        character(len=:), allocatable :: errors
        character(len=:), allocatable :: cur_name
        character(len=:), allocatable :: protected_names(:)
        character(len=32) :: idx_buf
        integer :: i, j
        logical :: has_table, found
        real(real64) :: qc_bound_value

        call parquet_parse_maml_lines(maml%lines, cinfo, metadata)

        errors = ""

        ! cinfo%col is always allocated here (parquet_parse_maml_lines never leaves it
        ! unallocated -- it allocates an explicit zero-size array when there are no fields).
        if (size(cinfo%col) == 0) then
            errors = errors // "no fields defined; "
        else
            do i = 1, size(cinfo%col)
                cur_name = trim(cinfo%col(i)%name)

                if (len_trim(cur_name) == 0) then ! GCOVR_EXCL_START -- gcov attribution artifact
                    write(idx_buf, '(I0)') i
                    errors = errors // "field #" // trim(idx_buf) // " has an empty name; "
                    cycle
                end if ! GCOVR_EXCL_STOP

                call parquet_validate_field_rules(cinfo%col(i), errors)

                do j = 1, i - 1
                    if (trim(cinfo%col(j)%name) == cur_name) then
                        errors = errors // "duplicate field name '" // cur_name // "'; "
                        exit
                    end if
                end do

            end do
        end if

        has_table = .false.
        if (allocated(metadata%items)) then
            do i = 1, size(metadata%items)
                if (trim(metadata%items(i)%key) == "table") then
                    has_table = len_trim(metadata%items(i)%value) > 0
                    exit
                end if
            end do
        end if
        if (.not. has_table) errors = errors // "missing required non-empty metadata: table; "

        ! extra: protected_cols: may only name columns declared under this
        ! same MAML's own fields: (matched by output_name -- see
        ! parquet_parse_maml_lines); anything else is a typo/dangling reference.
        ! Relayed through parquet_parse_protected_cols_relay (parquet_metadata.f90) rather than
        ! called directly -- see that wrapper's own comment for the gfortran 15.2.0 ICE it avoids.
        call parquet_parse_protected_cols_relay(maml%lines, protected_names)
        if (allocated(cinfo%col)) then
            do i = 1, size(protected_names)
                found = .false.
                do j = 1, size(cinfo%col)
                    if (trim(protected_names(i)) == trim(cinfo%col(j)%output_name)) then
                        found = .true.
                        exit
                    end if
                end do
                if (.not. found) then
                    errors = errors // "protected_cols: unknown column '" // trim(protected_names(i)) // "'; "
                end if
            end do
        end if

        call parquet_validate_maml_sections(maml%lines, errors)

        if (len_trim(errors) > 0) then
            error stop "parquet_validate_maml: " // trim(errors)
        end if
    end procedure parquet_validate_maml_internal

    module procedure parquet_load_maml_file
        character(len=maml_max_line_len), allocatable :: lines(:)
        integer :: nlines, i, max_len

        ! gcov attribution artifact: this call is the first executable statement of this
        ! abbreviated module procedure body, right after its declarations. Confirmed exercised
        ! (test_load_maml_file in test/test_maml.f90; tools/coverage.sh shows this file at 100%
        ! locally) -- only GitLab CI's toolchain misattributes this one entry line, consistently
        ! across separate runs, same as the module-procedure-header shape documented in
        ! CLAUDE.md's "Fortran gcov attribution artifacts".
        call parquet_read_maml_source_lines(filename, "parquet_load_maml_file", lines, nlines) ! GCOVR_EXCL_LINE

        maml%name = trim(filename)

        max_len = 1
        do i = 1, nlines
            max_len = max(max_len, len_trim(lines(i)))
        end do

        allocate(character(len=max_len) :: maml%lines(nlines))
        do i = 1, nlines
            maml%lines(i) = lines(i)(1:max_len)
        end do

        call parquet_validate_maml(maml)
    end procedure parquet_load_maml_file

    !> Loads maml (a filename) from disk and validates it (parquet_load_maml_file
    !> already validates internally, but this keeps that requirement explicit
    !> and self-contained here rather than depending on that side effect).
    module procedure parquet_validate_maml_file
        type(parquet_maml_file) :: loaded_maml

        loaded_maml = parquet_load_maml_file(maml)
        call parquet_validate_maml_internal(loaded_maml)
    end procedure parquet_validate_maml_file

    !> Reads `filename` line-by-line into a freshly allocated `lines(:)`/`nlines`, shared by
    !! parquet_load_maml_file/parquet_load_qc_maml_file below. Strips a trailing CRLF `char(13)`
    !! from every line (a `.maml` file authored/edited on Windows retains one after a formatted
    !! read on a Unix build, which would otherwise survive into every parsed key/value and produce
    !! a misleading "invalid data_type"-style error naming what looks like a perfectly correct
    !! value); `error stop`s naming the offending line number if any single line exceeds
    !! maml_max_line_len characters, rather than silently truncating it with no diagnostic (a
    !! `read(unit,'(A)')` into a fixed-length variable is defined to discard the remainder of a
    !! longer record and still report `iostat == 0`) -- see CLAUDE.md's MAML parser robustness
    !! notes. `context` is the caller's own name, used as the error-message prefix, matching the
    !! "cannot open file" error below.
    subroutine parquet_read_maml_source_lines(filename, context, lines, nlines)
        character(len=*), intent(in) :: filename !! path to the .maml file to read.
        character(len=*), intent(in) :: context !! caller's own name, used as the error-message prefix.
        character(len=maml_max_line_len), allocatable, intent(out) :: lines(:) !! one element per source line.
        integer, intent(out) :: nlines !! number of lines read.
        character(len=maml_max_line_len) :: line
        integer :: unit, ios, reclen
        character(len=32) :: idx_buf, len_buf

        nlines = 0

        ! Concurrent non-advancing (advance='no', size=) reads on separate units have been
        ! observed to spuriously report ios == 0 (i.e. a false "line exceeds maml_max_line_len"
        ! abort on a line nowhere near that long) under heavy multi-threaded contention with
        ! ifx's I/O runtime -- e.g. 100+ OpenMP threads from test-drive's own concurrent
        ! "examples" suite each parsing the same static fixture in the schemas dir at once. Each
        ! thread opens its own unit, so this is not a shared-fixture race in this library's own
        ! logic; serializing the whole open/read-loop/close sequence works around the runtime
        ! race regardless of its exact cause. (Written "schemas dir" below, not "schemas/*.maml",
        ! since a literal "/*" anywhere in this file opens a C block comment under cpp -- see
        ! CLAUDE.md's "Compiler & language gotchas".)
        !$omp critical (parquet_read_maml_source_lines_critical)
        open(newunit=unit, file=trim(filename), status="old", action="read", iostat=ios)
        if (ios /= 0) error stop trim(context) // ": cannot open file: " // trim(filename)

        do
            ! Non-advancing read + size= is the standard idiom to detect a record longer than the
            ! buffer: ios == 0 (rather than iostat_eor) after the read means the buffer filled
            ! before the record ended, i.e. more of this line remains unread.
            read(unit, '(A)', advance='no', size=reclen, iostat=ios) line
            if (ios == iostat_end) exit
            if (ios /= 0 .and. ios /= iostat_eor) exit
            nlines = nlines + 1
            if (ios == 0) then
                write(idx_buf, '(I0)') nlines
                write(len_buf, '(I0)') maml_max_line_len
                error stop trim(context) // ": line " // trim(idx_buf) // " exceeds " // &
                    trim(len_buf) // " characters: " // trim(filename)
            end if
            if (reclen > 0) then
                if (line(reclen:reclen) == char(13)) reclen = reclen - 1
            end if
            call parquet_append_line(lines, nlines, line(1:reclen))
        end do

        close(unit)
        !$omp end critical (parquet_read_maml_source_lines_critical)
    end subroutine parquet_read_maml_source_lines

    module procedure parquet_load_qc_maml_file
        character(len=maml_max_line_len), allocatable :: lines(:)
        integer :: nlines, i, max_len

        ! gcov attribution artifact: same shape/cause as parquet_load_maml_file's call to this
        ! same helper above -- confirmed exercised (test_load_qc_maml_file in test/test_maml.f90;
        ! 100% locally) but consistently misattributed by GitLab CI's toolchain across separate
        ! runs. See CLAUDE.md's "Fortran gcov attribution artifacts".
        call parquet_read_maml_source_lines(filename, "parquet_load_qc_maml_file", lines, nlines) ! GCOVR_EXCL_LINE

        schema%maml%name = trim(filename)

        max_len = 1
        do i = 1, nlines
            max_len = max(max_len, len_trim(lines(i)))
        end do

        allocate(character(len=max_len) :: schema%maml%lines(nlines))
        do i = 1, nlines
            schema%maml%lines(i) = lines(i)(1:max_len)
        end do
        ! Deliberately no parquet_validate_maml call here -- a qc-maml has its
        ! own, lighter validation (parquet_parse_qc_maml), run later once
        ! parquet_open_reader actually uses it.
    end procedure parquet_load_qc_maml_file

    !> Grows `rules(:)` by one empty entry and increments `n` -- same
    !> grow-by-one-element pattern as parquet_append_line/parquet_filter_add
    !> elsewhere in this codebase; qc-maml field counts are always small, so
    !> no capacity-doubling scheme is warranted.
    subroutine parquet_qc_append_empty_rule(rules, n)
        type(parquet_qc_rule), allocatable, intent(inout) :: rules(:) !! rule array being grown.
        integer, intent(inout) :: n !! number of rules in use; incremented by 1.
        type(parquet_qc_rule), allocatable :: tmp(:)

        n = n + 1
        if (.not. allocated(rules)) then
            allocate(rules(1))
            return
        end if
        if (size(rules) < n) then
            allocate(tmp(n))
            tmp(1:n-1) = rules
            call move_alloc(tmp, rules)
        end if
    end subroutine parquet_qc_append_empty_rule

    module procedure parquet_parse_qc_maml
        character(len=maml_max_line_len) :: line
        character(len=:), allocatable :: tline, key, cvalue, raw, errors, miss_lower
        character(len=:), allocatable :: qc_maml_suffix
        logical :: in_fields, have_current, in_qc
        integer :: i, j, n
        character(len=32) :: idx_buf
        type(parquet_qc_rule), allocatable :: tmp(:)
        character(len=:), allocatable :: tlo1, tlo3, tlo4 !! scratch (to_lower).
        character(len=:), allocatable :: tuq2, tuq5 !! scratch (unquote).

        ! "" if maml%name was never set (e.g. a qc-maml built in memory via
        ! add_col_qc); otherwise " (maml: X)", appended to every error stop
        ! below so it names which qc-maml file failed validation.
        qc_maml_suffix = ""
        if (allocated(maml%name)) then
            if (len_trim(maml%name) > 0) qc_maml_suffix = " (maml: " // trim(maml%name) // ")"
        end if

        ! Reuses the same top-level-section/sub-key name schema every other
        ! MAML validation path checks against (allowed_maml_sections/
        ! allowed_maml_nested_sections) -- so a typo'd section name or an
        ! unrecognized fields:/qc: sub-key is still caught here, exactly as
        ! it would be for a schema-authoring maml. Everything else
        ! parquet_validate_maml_internal additionally requires (table:, at
        ! least one field, valid data_type, ...) is deliberately NOT applied
        ! to a qc-maml -- see parquet_qc_rule's own doc comment.
        errors = ""
        call parquet_validate_maml_sections(maml%lines, errors)
        if (len_trim(errors) > 0) then
            error stop "parquet_open_reader: invalid qc maml: " // trim(errors) // qc_maml_suffix
        end if

        in_fields = .false.
        have_current = .false.
        in_qc = .false.
        n = 0

        do i = 1, size(maml%lines)
            line = maml%lines(i)
            tline = trim(adjustl(line))
            if (len_trim(tline) == 0) cycle
            if (tline(1:1) == "#") cycle

            if (.not. in_fields) then
                if (parquet_maml_key_matches(tline, "fields:")) in_fields = .true.
                cycle
            end if

            ! A new top-level section (unindented, has a ":", not a dash
            ! item) ends the fields: block, same as parquet_parse_maml_lines.
            if (index(tline, "- ") /= 1 .and. index(tline, ":") > 0 .and. line(1:1) /= " ") exit

            if (index(tline, "-") == 1 .and. line(1:1) /= " ") then
                call parquet_qc_append_empty_rule(tmp, n)
                have_current = .true.
                in_qc = .false.
                tline = trim(adjustl(tline(2:)))
                if (len_trim(tline) == 0) cycle
            end if

            if (.not. have_current) cycle

            if (in_qc) then
                call parquet_split_key_value(tline, key, cvalue)
                call parquet_to_lower(key, tlo1)
                select case (tlo1)
                case ("min")
                    call parquet_set_qc_bound(tmp(n)%has_min, tmp(n)%min_op, raw, cvalue, ">=")
                    tmp(n)%min_text = raw
                    cycle
                case ("max")
                    call parquet_set_qc_bound(tmp(n)%has_max, tmp(n)%max_op, raw, cvalue, "<=")
                    tmp(n)%max_text = raw
                    cycle
                case ("miss")
                    call parquet_unquote(cvalue, tuq2)
                    call parquet_to_lower(tuq2, tlo3)
                    miss_lower = trim(tlo3)
                    if (len_trim(miss_lower) == 0) then
                        tmp(n)%null_values_allowed = .false.
                    else if (trim(miss_lower) == "null" .or. trim(miss_lower) == "na") then
                        tmp(n)%null_values_allowed = .true.
                    else
                        error stop "parquet_open_reader: invalid qc maml: qc: miss: value '" // trim(miss_lower) // &
                            "' for field '" // trim(tmp(n)%name) // &
                            "' is not recognized (expected Null/NA or empty)" // qc_maml_suffix
                    end if
                    cycle
                case default
                    in_qc = .false.
                end select
            end if

            call parquet_split_key_value(tline, key, cvalue)
            if (len_trim(key) == 0) cycle

            call parquet_to_lower(key, tlo4)
            select case (tlo4)
            case ("name")
                call parquet_unquote(cvalue, tuq5)
                tmp(n)%name = tuq5
            case ("qc")
                in_qc = .true.
                tmp(n)%has_qc_block = .true.
            end select
        end do

        do i = 1, n
            if (len_trim(tmp(i)%name) == 0) then
                write(idx_buf, '(I0)') i
                error stop "parquet_open_reader: invalid qc maml: field #" // trim(idx_buf) // &
                    " is missing required 'name'" // qc_maml_suffix
            end if
            do j = 1, i - 1
                if (trim(tmp(j)%name) == trim(tmp(i)%name)) then
                    error stop "parquet_open_reader: invalid qc maml: duplicate field name '" // trim(tmp(i)%name) // &
                        "'" // qc_maml_suffix
                end if
            end do
            ! qc: min: must be a lower bound (>= or >), qc: max: an upper
            ! bound (<= or <); the reversed direction is a nonsensical bound.
            ! Unlike the write side this can't (and needn't) consult a
            ! data_type -- a qc-maml has none -- so it applies to every field.
            if (tmp(i)%has_min .and. tmp(i)%min_op(1:1) == "<") then
                error stop "parquet_open_reader: invalid qc maml: qc: min: for field '" // trim(tmp(i)%name) // &
                    "' uses a '" // trim(tmp(i)%min_op) // "' operator; min: accepts only >= or > (use max: for an upper bound)" &
                    // qc_maml_suffix
            end if
            if (tmp(i)%has_max .and. tmp(i)%max_op(1:1) == ">") then
                error stop "parquet_open_reader: invalid qc maml: qc: max: for field '" // trim(tmp(i)%name) // &
                    "' uses a '" // trim(tmp(i)%max_op) // "' operator; max: accepts only <= or < (use min: for a lower bound)" &
                    // qc_maml_suffix
            end if
        end do

        ! Fields with just a name: and no qc: block at all get no rule --
        ! same as a field never mentioned in this maml (see parquet_qc_rule's
        ! has_qc_block doc comment).
        allocate(rules(0))
        do i = 1, n
            if (tmp(i)%has_qc_block) rules = [rules, tmp(i)]
        end do
    end procedure parquet_parse_qc_maml

    module procedure parquet_set_qc_bound
        character(len=:), allocatable :: text
        character(len=:), allocatable :: tuq1 !! scratch (unquote).

        call parquet_unquote(cvalue, tuq1)
        text = trim(adjustl(tuq1))
        if (index(text, ">=") == 1) then
            op = ">="
            text = trim(adjustl(text(3:)))
        else if (index(text, "<=") == 1) then
            op = "<="
            text = trim(adjustl(text(3:)))
        else if (index(text, ">") == 1) then
            op = "> "
            text = trim(adjustl(text(2:)))
        else if (index(text, "<") == 1) then
            op = "< "
            text = trim(adjustl(text(2:)))
        else
            op = default_op
        end if
        raw = text
        has_flag = .true.
    end procedure parquet_set_qc_bound

    module procedure parquet_qc_bound_as_int64_text
        integer :: k, first, ios
        logical :: ok
        character(len=:), allocatable :: text

        value = 0_int64
        parquet_qc_bound_as_int64_text = .false.
        text = trim(adjustl(raw))
        if (len(text) == 0) return

        ! Shape first, by hand: an optional sign then nothing but digits, to the end. A bare
        ! list-directed read cannot do this -- it accepts "5 6" as 5, and "1.5" as 1.5 -- so the
        ! read below only ever runs on text already known to be a plain integer, and its iostat is
        ! then reporting one thing only: that the value does not fit in int64.
        first = 1
        if (text(1:1) == "+" .or. text(1:1) == "-") first = 2
        ok = len(text) >= first
        do k = first, len(text)
            if (text(k:k) < "0" .or. text(k:k) > "9") then
                ok = .false.
                exit
            end if
        end do
        if (.not. ok) return

        read(text, *, iostat=ios) value
        if (ios /= 0) then
            value = 0_int64
            return
        end if
        parquet_qc_bound_as_int64_text = .true.
    end procedure parquet_qc_bound_as_int64_text

    module procedure parquet_qc_numeric_bound
        integer :: ios
        real(real64) :: rounded
        integer(int64) :: exact

        value = 0.0_real64
        parquet_qc_numeric_bound = .false.

        ! An integer-typed bound is judged as an int64 whenever the text IS a plain integer, so a
        ! bound past 2**53 -- huge(int64) included -- validates exactly rather than being tested
        ! against a rounded copy of itself. `value` is still handed back as real64 for the callers
        ! that compare in real64; the int64 write path re-parses the text for its own comparison
        ! (see qc_numeric_i64), so no precision is lost where it matters.
        select case (trim(data_type))
        case ("int32", "int64")
            if (parquet_qc_bound_as_int64_text(raw, exact)) then
                if (trim(data_type) == "int32") then
                    if (exact < -int(huge(0_int32), int64) - 1_int64 .or. exact > int(huge(0_int32), int64)) return
                end if
                value = real(exact, kind=real64)
                parquet_qc_numeric_bound = .true.
                return
            end if
        end select

        read(raw, *, iostat=ios) value
        if (ios /= 0) return
        if (ieee_is_nan(value)) return
        if (.not. (abs(value) <= huge(1.0_real64))) return ! Inf (or a magnitude beyond real64's finite range)

        select case (trim(data_type))
        case ("int32")
            rounded = anint(value)
            if (value /= rounded) return
            if (rounded < -real(huge(0_int32), real64) - 1.0_real64 .or. rounded > real(huge(0_int32), real64)) return
        case ("int64")
            rounded = anint(value)
            if (value /= rounded) return
            if (rounded < -real(huge(0_int64), real64) .or. rounded >= real(huge(0_int64), real64)) return
        end select

        parquet_qc_numeric_bound = .true.
    end procedure parquet_qc_numeric_bound

    !> Renames the column each parquet_read_qc entry declares. Only the entry's FIRST field is
    !> touched -- everything from the first comma onwards is carried across byte for byte -- so
    !> this needs no knowledge of the bound grammar beyond where the column name ends, and a bound
    !> value that happens to spell a column name cannot be hit. See parquet_read_qc's own doc
    !> comment for what this is for; it is the qc sibling of parquet_filter%remap_column_names.
    module procedure parquet_read_qc_remap_column_names
        character(len=:), allocatable :: name, rest, text, tmp(:)
        integer :: i, k, comma

        if (size(from) /= size(to)) error stop "parquet_read_qc%remap_column_names: from and to " // &
            "must have the same size"
        if (size(from) == 0 .or. this%n == 0) return

        do i = 1, this%n
            comma = index(this%entries(i), ",")
            if (comma > 0) then
                name = trim(adjustl(this%entries(i)(1:comma - 1)))
                rest = trim(this%entries(i)(comma:))
            else
                name = trim(adjustl(this%entries(i)))
                rest = ""
            end if
            ! An entry with no column name at all is left alone, so the real error comes from
            ! parquet_compose_read_qc's own %add_col_qc call, which says exactly what is wrong.
            if (len(name) == 0) cycle
            do k = 1, size(from)
                if (name /= trim(from(k))) cycle
                text = trim(to(k)) // rest
                if (len(text) > read_qc_max_entry_len) then
                    ! Raised here rather than left to %add_col_qc, whose message would name a
                    ! limit the caller never exceeded: their own entry fitted.
                    error stop "parquet_read_qc%remap_column_names: entry exceeds the maximum " // &
                        "supported length after remapping its column name: " // text(1:100) // "..."
                end if
                if (len(text) <= len(this%entries)) then
                    this%entries(i) = text
                else
                    ! Every entry shares one length (the %add convention), so a longer rewritten
                    ! entry re-lengthens the whole array.
                    allocate(character(len=len(text)) :: tmp(this%n))
                    tmp(1:this%n) = this%entries(1:this%n)
                    tmp(i) = text
                    call move_alloc(tmp, this%entries)
                end if
                exit
            end do
        end do
    end procedure parquet_read_qc_remap_column_names

    !> Merges a MAML-declared and a code-declared read-time QC into one schema. See the interface
    !> in parquet_core.f90 for the override rule and why `composed` carries only qc-bearing entries.
    !>
    !> The MAML side is copied as raw SOURCE LINES rather than re-emitted from the parsed
    !> parquet_qc_rule array: a round trip through that representation would have to reconstruct
    !> the compact "col, min, max, miss" form from min_op/min_text/null_values_allowed, which is a
    !> third representation of the same thing and one more place for the three to drift.
    !>
    !> `parquet_parse_qc_maml` already drops any field with no `qc:` key (see its own tail), so the
    !> rules it returns ARE exactly the columns the MAML claims under Q9's `has_qc_block` rule --
    !> there is no separate test to keep in step with it.
    module procedure parquet_get_qc_columns
        type(parquet_qc_rule), allocatable :: rules(:)
        integer :: i, n, w

        allocate(character(len=1) :: names(0))
        if (.not. allocated(schema%maml%lines)) return
        call parquet_parse_qc_maml(schema%maml, rules)
        ! Two passes, as everywhere a deferred-length array is built here: one to size it, one to
        ! fill it. A rule with no qc: block declares nothing and is skipped -- that distinction is
        ! the whole reason this reports rules rather than fields.
        n = 0
        w = 1
        do i = 1, size(rules)
            if (.not. rules(i)%has_qc_block) cycle
            n = n + 1
            w = max(w, len_trim(rules(i)%name))
        end do
        if (n == 0) return
        deallocate(names)
        allocate(character(len=w) :: names(n))
        n = 0
        do i = 1, size(rules)
            if (.not. rules(i)%has_qc_block) cycle
            n = n + 1
            names(n) = trim(rules(i)%name)
        end do
    end procedure parquet_get_qc_columns

    module procedure parquet_compose_read_qc
        type(parquet_qc_rule), allocatable :: rules(:)
        character(len=:), allocatable :: lines(:), name
        integer :: i, j
        logical :: claimed

        ncolumns = 0
        allocate(rules(0))
        if (present(schema)) then
            if (allocated(schema%maml%lines)) then
                ! Validates the MAML as a side effect, which is wanted: a bad qc-maml should fail
                ! here rather than at parquet_open_reader, where the composed object is all that is
                ! left to name in the message.
                call parquet_parse_qc_maml(schema%maml, rules)
                call parquet_copy_qc_field_lines(schema%maml%lines, lines)
                if (allocated(lines)) composed%maml%lines = lines
            end if
            if (allocated(schema%maml%name)) composed%maml%name = schema%maml%name
        end if

        if (present(qc)) then
            do i = 1, qc%n
                call parquet_read_qc_entry_column(qc%entries(i), name)
                claimed = .false.
                do j = 1, size(rules)
                    if (trim(rules(j)%name) /= name) cycle
                    claimed = .true.
                    exit
                end do
                if (claimed) cycle
                ! %add_col_qc is what parses and validates the compact string -- this procedure
                ! deliberately never does, so there is exactly one implementation of that grammar.
                call composed%maml%add_col_qc(trim(qc%entries(i)))
            end do
        end if

        ! Counted by re-parsing rather than by tallying the loop above, so the answer is exactly
        ! "what a reader would enforce": an entry naming a column but declaring no bound at all
        ! ("mass" on its own) is a legal no-op that adds a field with no qc: block, and must not
        ! count towards it.
        if (allocated(composed%maml%lines)) then
            call parquet_parse_qc_maml(composed%maml, rules)
            ncolumns = size(rules)
        end if
    end procedure parquet_compose_read_qc

    !> The column name a compact "col, min, max, miss" entry declares: everything before its first
    !> comma, trimmed. "" for an entry with no name, which the caller leaves for %add_col_qc to
    !> reject with its own message.
    subroutine parquet_read_qc_entry_column(entry, name)
        character(len=*), intent(in) :: entry            !! one parquet_read_qc entry.
        character(len=:), allocatable, intent(out) :: name !! the column it declares, or "".
        integer :: comma

        comma = index(entry, ",")
        if (comma > 0) then
            name = trim(adjustl(entry(1:comma - 1)))
        else
            name = trim(adjustl(entry))
        end if
    end subroutine parquet_read_qc_entry_column

    !> Copies the `fields:` entries that carry a `qc:` key out of a MAML's source lines, header
    !> included, dropping everything else -- other top-level sections, and any field entry with no
    !> `qc:` key. `lines` is left unallocated when nothing qualifies.
    !>
    !> Entry boundaries are detected exactly as parquet_parse_qc_maml detects them (an UNINDENTED
    !> dash starts a field; an unindented non-dash key ends the block), deliberately: if the two
    !> ever disagree about where an entry begins, the composed schema would silently claim a
    !> different set of columns than the reader enforces.
    subroutine parquet_copy_qc_field_lines(src, lines)
        character(len=*), intent(in) :: src(:)                !! the MAML's raw source lines.
        character(len=:), allocatable, intent(out) :: lines(:) !! the qc-bearing fields: block, or unallocated.
        integer, allocatable :: lo(:), hi(:)
        integer :: i, j, k, nentry, nout, width

        call parquet_locate_qc_field_entries(src, lo, hi, nentry)
        if (nentry == 0) return

        width = len("fields:")
        nout = 1
        do k = 1, nentry
            do j = lo(k), hi(k)
                nout = nout + 1
                width = max(width, len_trim(src(j)))
            end do
        end do

        allocate(character(len=width) :: lines(nout))
        ! Blanked element by element, never as `lines = ""` -- see CLAUDE.md: a whole-array
        ! assignment to a deferred-length allocatable array reallocates it to length zero.
        do i = 1, nout
            lines(i) = ""
        end do
        lines(1) = "fields:"
        nout = 1
        do k = 1, nentry
            do j = lo(k), hi(k)
                nout = nout + 1
                lines(nout) = src(j)
            end do
        end do
    end subroutine parquet_copy_qc_field_lines

    !> Line ranges of the `fields:` entries that carry a `qc:` key, one (lo, hi) pair per entry.
    !> `nentry` is 0 when the MAML has no fields: block, or none of its entries declares qc.
    !>
    !> Entry boundaries are detected exactly as parquet_parse_qc_maml detects them (an UNINDENTED
    !> dash starts a field; an unindented non-dash key ends the block), deliberately: if the two
    !> ever disagreed about where an entry begins, the composed schema would silently claim a
    !> different set of columns than the reader enforces.
    subroutine parquet_locate_qc_field_entries(src, lo, hi, nentry)
        character(len=*), intent(in) :: src(:)          !! the MAML's raw source lines.
        integer, allocatable, intent(out) :: lo(:)      !! first line of each qualifying entry.
        integer, allocatable, intent(out) :: hi(:)      !! last line of each qualifying entry.
        integer, intent(out) :: nentry                  !! qualifying entries found.
        character(len=:), allocatable :: tline, key, cvalue, klow
        integer :: i, j, start, stop_at
        logical :: in_fields, has_qc

        allocate(lo(max(size(src, kind=int64), 1_int64)), hi(max(size(src, kind=int64), 1_int64)))
        nentry = 0
        in_fields = .false.
        i = 0
        do
            i = i + 1
            if (i > size(src)) exit
            tline = trim(adjustl(src(i)))
            if (len(tline) == 0) cycle
            if (tline(1:1) == "#") cycle
            if (.not. in_fields) then
                if (parquet_maml_key_matches(tline, "fields:")) in_fields = .true.
                cycle
            end if
            ! A new unindented top-level key ends the fields: block.
            if (index(tline, "- ") /= 1 .and. index(tline, ":") > 0 .and. src(i)(1:1) /= " ") exit
            if (index(tline, "-") /= 1 .or. src(i)(1:1) == " ") cycle
            ! src(i) starts a field entry; walk its indented sub-lines to find where it ends.
            start = i
            stop_at = size(src)
            do
                if (i + 1 > size(src)) exit
                tline = trim(adjustl(src(i + 1)))
                if (len(tline) > 0 .and. src(i + 1)(1:1) /= " ") then
                    stop_at = i
                    exit
                end if
                i = i + 1
            end do
            has_qc = .false.
            do j = start, stop_at
                tline = trim(adjustl(src(j)))
                if (len(tline) == 0) cycle
                if (j == start) tline = trim(adjustl(tline(2:)))
                call parquet_split_key_value(tline, key, cvalue)
                if (len_trim(key) == 0) cycle
                call parquet_to_lower(key, klow)
                if (trim(klow) == "qc") then
                    has_qc = .true.
                    exit
                end if
            end do
            if (.not. has_qc) cycle
            nentry = nentry + 1
            lo(nentry) = start
            hi(nentry) = stop_at
        end do
    end subroutine parquet_locate_qc_field_entries

end submodule parquet_metadata_maml
