!===========================================
! Author: Elmo Tempel (elmo.tempel@ut.ee)
!===========================================
!
!> Read-in (Role-B) MAML support for `parquet_table`: parsing the `extra: remap:` block that
!! relabels a file's columns for reading, and building the descriptor slots from it.
!!
!! A read-in MAML describes the PHYSICAL file and travels with it, so everything it names is a
!! file column name. `remap:` is the one place the two vocabularies meet: its keys are the
!! table-facing (internal) names a program will use, its values the file columns they read.
!! Everything downstream then keeps the split the descriptor already has -- `%name` is the lookup
!! key, `%file_name` is what the reader is asked for -- which is why remapping needs no change to
!! classification, materialization or residency at all.
!!
!! Three rules this file implements, all of them easy to get subtly wrong:
!!
!! * **An internal name may equal a physical column name it does not refer to.** A file often
!!   carries columns a program does not want, and one of them happening to share a name with an
!!   internal name must not make that name unusable. The physical column is then simply
!!   unreachable -- a deliberate, silent shadow, not an error.
!! * **Two internal names may read the SAME physical column.** They become two ordinary,
!!   independent columns from that point on: each materializes on its own first touch (reading the
!!   physical column again) and each may be mutated without the other seeing it.
!! * **File order is preserved.** Slots are emitted by walking the physical columns, not the remap
!!   list, so a struct's leaves stay adjacent -- which the batch release policy in
!!   `materialize_marked` depends on -- and duplicate slots land next to each other, where one
!!   decode serves both.
!!
!! The parser is deliberately independent of the write-side `parquet_parse_col_map`, which is
!! validated against a base schema a generic table has no equivalent of and serves the opposite
!! direction. It scans lines already loaded from disk (via `parquet_load_qc_maml_file`), so the
!! shared reader's line-length cap and CRLF handling apply without this file re-reading anything.
submodule (parquet_tables) parquet_tables_maml
    implicit none
    !
contains
    !
    module procedure parse_read_maml_remap
        integer :: i, j, idx_extra, extra_end, idx_remap, count, width
        character(len=:), allocatable :: tline, key, cvalue
        !
        n = 0
        allocate(character(len=1) :: internal(0))
        allocate(character(len=1) :: physical(0))
        if (.not. allocated(schema%maml%lines)) return
        ! Two passes: one to count and size the deferred-length result, one to fill it. Cheaper
        ! than growing a deferred-length array entry by entry, and a remap: block is tiny.
        call locate_extra_block(schema%maml%lines, "remap:", idx_extra, extra_end, idx_remap)
        if (idx_remap == 0) return
        count = 0
        width = 1
        do i = idx_remap + 1, extra_end
            if (len_trim(schema%maml%lines(i)) == 0) cycle
            tline = trim(adjustl(schema%maml%lines(i)))
            if (tline(1:1) /= "-") exit
            call split_remap_entry(tline, key, cvalue)
            if (len(key) == 0 .or. len(cvalue) == 0) cycle
            count = count + 1
            width = max(width, len(key), len(cvalue))
        end do
        if (count == 0) return
        deallocate(internal)
        deallocate(physical)
        allocate(character(len=width) :: internal(count))
        allocate(character(len=width) :: physical(count))
        ! Blanked ELEMENT BY ELEMENT, never as `internal = ""`. A whole-array intrinsic assignment
        ! to a deferred-length allocatable array reallocates it to the RHS's length -- here zero --
        ! so the fill loop below would then write `width` bytes into a zero-length allocation: every
        ! name comes back blank AND the heap is corrupted. gfortran happens to keep the length and
        ! hides both; ifx follows the standard and shows them. An array ELEMENT is not itself an
        ! allocatable variable, so assigning to one only blank-pads, which is what is wanted.
        do i = 1, count
            internal(i) = ""
            physical(i) = ""
        end do
        do i = idx_remap + 1, extra_end
            if (len_trim(schema%maml%lines(i)) == 0) cycle
            tline = trim(adjustl(schema%maml%lines(i)))
            if (tline(1:1) /= "-") exit
            call split_remap_entry(tline, key, cvalue)
            if (len(key) == 0 .or. len(cvalue) == 0) cycle
            n = n + 1
            internal(n) = key
            physical(n) = cvalue
        end do
        ! A repeated internal name is ambiguous with no rule that could pick between the entries.
        ! Checked here rather than alongside the "does that column exist?" rule in validate_remap
        ! because it is a property of the MAML alone: catching it at parse time means it aborts
        ! before parquet_open_table has opened the file at all, with no reader (and so no live
        ! Arrow object) in scope when the process stops.
        do i = 2, n
            do j = 1, i - 1
                if (trim(internal(j)) /= trim(internal(i))) cycle
                error stop EP // "parquet_open_table: extra: remap: internal name '" // &
                    trim(internal(i)) // "' is declared more than once (maml '" // &
                    trim(maml_file) // "')"
            end do
        end do
    end procedure parse_read_maml_remap
    !
    !> .true. when `line` is exactly the MAML block header `key`, ignoring surrounding blanks and
    !! ASCII case — a MAML key is case-insensitive, so `Extra:` and `extra:` are the same section.
    !!
    !! A local twin of `parquet_core`'s `parquet_maml_key_matches`, which is private to that
    !! module's own subtree. This file already carries its own `split_key_value`/`unquote_trimmed`
    !! for the same reason (see the module doc above): the read-side parser is deliberately
    !! independent of the write-side one. What must not diverge is the RULE, and
    !! `check_maml_keys_case_insensitive` (`tools/check_source_conventions.py`) is what enforces
    !! that — it fails on any new literal `== "<key>:"` comparison in either subtree, which is the
    !! shape that once let a capitalized `Extra:` silently lose its whole block. See
    !! `feature_risks.md` Risk-91.
    !!
    !! Indentation is not considered here; a caller needing a top-level header keeps its own
    !! `line(1:1) /= " "` test. Values stay case-sensitive — a `remap:` column name is data.
    logical function maml_key_matches(line, key)
        character(len=*), intent(in) :: line !! raw MAML source line.
        character(len=*), intent(in) :: key  !! block-header name, lowercase, with its colon.
        character(len=len(line)) :: t
        character(len=1) :: c
        integer :: i, n
        !
        t = adjustl(line)
        n = len_trim(t)
        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
        maml_key_matches = .true.
    end function maml_key_matches
    !
    !> Finds `key` nested inside the MAML's own `extra:` section, reporting the block bounds the
    !! caller then walks. `idx_key` is 0 when there is no `extra:` section at all, or none with
    !! that key in it.
    !!
    !! `remap:`/`filter:`/`sort:` are only meaningful nested under `extra:` -- exactly as
    !! `col_map:` is on the write side -- because `extra:` is the one MAML section whose contents
    !! the core validator accepts unexamined. A stray top-level `remap:` is therefore correctly
    !! rejected by that validator as an unknown section, and never reaches here.
    subroutine locate_extra_block(lines, key, idx_extra, extra_end, idx_key)
        character(len=*), intent(in) :: lines(:) !! raw MAML source lines.
        character(len=*), intent(in) :: key      !! nested key to find, with its colon ("remap:").
        integer, intent(out) :: idx_extra        !! line index of "extra:", or 0.
        integer, intent(out) :: extra_end        !! last line belonging to the extra: block.
        integer, intent(out) :: idx_key          !! line index of `key`, or 0.
        integer :: i, nlines
        !
        nlines = size(lines)
        idx_extra = 0
        extra_end = nlines
        idx_key = 0
        do i = 1, nlines
            if (len(lines(i)) == 0) cycle
            if (lines(i)(1:1) /= " " .and. maml_key_matches(lines(i), "extra:")) then
                idx_extra = i
                exit
            end if
        end do
        if (idx_extra == 0) return
        ! The block runs until the next unindented (top-level) line.
        do i = idx_extra + 1, nlines
            if (len_trim(lines(i)) == 0) cycle
            if (lines(i)(1:1) /= " ") then
                extra_end = i - 1
                exit
            end if
        end do
        do i = idx_extra + 1, extra_end
            if (len_trim(lines(i)) == 0) cycle
            if (maml_key_matches(lines(i), key)) then
                idx_key = i
                exit
            end if
        end do
    end subroutine locate_extra_block
    !
    module procedure parse_read_maml_string_list
        integer :: i, idx_extra, extra_end, idx_key, count, width
        character(len=:), allocatable :: tline, item
        !
        n = 0
        allocate(character(len=1) :: items(0))
        if (.not. allocated(schema%maml%lines)) return
        call locate_extra_block(schema%maml%lines, key, idx_extra, extra_end, idx_key)
        if (idx_key == 0) return
        ! Two passes, as in parse_read_maml_remap: one to size the deferred-length result, one to
        ! fill it.
        count = 0
        width = 1
        do i = idx_key + 1, extra_end
            if (len_trim(schema%maml%lines(i)) == 0) cycle
            tline = trim(adjustl(schema%maml%lines(i)))
            if (tline(1:1) /= "-") exit
            call unquote_trimmed(tline(2:), item)
            if (len(item) == 0) cycle
            count = count + 1
            width = max(width, len(item))
        end do
        if (count == 0) return
        deallocate(items)
        allocate(character(len=width) :: items(count))
        ! Blanked element by element -- see parse_read_maml_remap's own comment for why never
        ! `items = ""`.
        do i = 1, count
            items(i) = ""
        end do
        do i = idx_key + 1, extra_end
            if (len_trim(schema%maml%lines(i)) == 0) cycle
            tline = trim(adjustl(schema%maml%lines(i)))
            if (tline(1:1) /= "-") exit
            call unquote_trimmed(tline(2:), item)
            if (len(item) == 0) cycle
            n = n + 1
            items(n) = item
        end do
    end procedure parse_read_maml_string_list
    !
    module procedure split_sort_nulls_token
        integer :: sep
        character(len=:), allocatable :: tail, lowered
        integer :: i
        !
        nulls_first = .false.
        key_text = trim(adjustl(entry))
        ! The token is the LAST blank-separated word, and only when it is one of the two spellings
        ! -- so an ordinary two-word key ("ra asc") and a bare column name are both left alone, and
        ! anything else is handed to parquet_sortkey%add to reject with its own message.
        sep = index(trim(key_text), " ", back=.true.)
        if (sep == 0) return
        tail = trim(adjustl(key_text(sep + 1:)))
        lowered = tail
        do i = 1, len(lowered)
            if (lowered(i:i) >= "A" .and. lowered(i:i) <= "Z") lowered(i:i) = achar(iachar(lowered(i:i)) + 32)
        end do
        if (lowered == "nulls_first") then
            nulls_first = .true.
        else if (lowered /= "nulls_last") then
            return
        end if
        key_text = trim(key_text(1:sep - 1))
    end procedure split_sort_nulls_token
    !
    module procedure compose_read_transform
        type(parquet_schema), allocatable :: read_maml
        type(parquet_read_qc), allocatable :: qc_local
        type(parquet_sortkey) :: sort_local
        character(len=:), allocatable :: maml_rules(:), maml_keys(:), key_text
        integer :: i, n_rules, n_keys
        logical :: nulls_first
        !
        n_remap = 0
        n_qc = 0
        n_rules = 0
        n_keys = 0
        n_units = 0
        allocate(character(len=1) :: unit_cols(0))
        allocate(character(len=1) :: unit_vals(0))
        allocate(character(len=1) :: internal(0))
        allocate(character(len=1) :: physical(0))
        allocate(character(len=1) :: maml_rules(0))
        allocate(character(len=1) :: maml_keys(0))
        if (present(maml_file)) then
            read_maml = parquet_load_qc_maml_file(trim(maml_file))
            deallocate(internal, physical)
            call parse_read_maml_remap(read_maml, internal, physical, n_remap, trim(maml_file))
            deallocate(unit_cols, unit_vals)
            call collect_maml_units(read_maml, unit_cols, unit_vals, n_units)
            deallocate(maml_rules, maml_keys)
            call parse_read_maml_string_list(read_maml, "filter:", maml_rules, n_rules)
            call parse_read_maml_string_list(read_maml, "sort:", maml_keys, n_keys)
            ! The code-facing half of this rule needs no check at all -- the slice specifics of
            ! parquet_open_table have no `sort` argument, so supplying one does not compile. A
            ! MAML's own list cannot be caught that way, so it is caught here, before the file is
            ! opened: a sort reorders rows across the whole file, which would leave the slice's
            ! [row_lo, row_hi] naming a different set of rows than the caller chose it to.
            if (sliced .and. n_keys > 0) then
                error stop EP // "parquet_open_table: sort is not allowed in the slice regime; " // &
                    "remove extra: sort: from this maml or open the whole file (maml '" // &
                    trim(maml_file) // "')"
            end if
        end if
        !
        ! Filter: the caller's rules, translated out of internal names, then the MAML's own (already
        ! file names) appended. AND is commutative, so the order is a documentation choice only.
        if (present(filter)) then
            out_filter = filter
            call out_filter%remap_column_names(internal(1:n_remap), physical(1:n_remap))
        end if
        do i = 1, n_rules
            call out_filter%add(trim(maml_rules(i)))
        end do
        !
        ! Sort: MAML keys FIRST, because the first key is the primary one -- this half genuinely is
        ! order-sensitive, unlike the filter above.
        do i = 1, n_keys
            call split_sort_nulls_token(maml_keys(i), key_text, nulls_first)
            call out_sort%add(key_text, nulls_first=nulls_first)
        end do
        if (present(sort)) then
            sort_local = sort
            call sort_local%remap_column_names(internal(1:n_remap), physical(1:n_remap))
            do i = 1, sort_local%n
                call out_sort%add(trim(sort_local%keys(i)), nulls_first=sort_local%nulls_first(i))
            end do
        end if
        !
        ! qc: translated the same way, then merged per column by parquet_compose_read_qc. Both of
        ! its sources are passed as ALLOCATABLE locals left unallocated when absent -- an
        ! unallocated allocatable actual makes an optional dummy absent (F2018 15.5.2.12), which is
        ! what lets one call cover all four present/absent combinations without branching.
        if (present(qc)) then
            qc_local = qc
            call qc_local%remap_column_names(internal(1:n_remap), physical(1:n_remap))
        end if
        call parquet_compose_read_qc(read_maml, qc_local, out_qc, n_qc)
    end procedure compose_read_transform
    !
    !> Collects every `fields:` entry of a read-in MAML that declares a `unit:`, as a
    !! file-name/unit pair list.
    !!
    !! Walks the raw MAML lines, exactly as `parse_read_maml_remap` above does, and for the same
    !! reason: `parquet_load_qc_maml_file` deliberately does not parse a read-in MAML into
    !! `%cinfo` (its validation rules are the write side's, and a file-describing MAML need not
    !! satisfy them), so `%get_num_fields` would answer 0 here. The grammar recognized is the one
    !! `fields:` already uses everywhere else -- a list of `- name: X` items, each followed by
    !! indented `key: value` lines until the next item.
    !!
    !! The names are the MAML's own, i.e. the FILE's column names -- a read-in MAML describes the
    !! physical file, so `extra: remap:` renames its columns for the table but does not rename them
    !! here. open_table_impl therefore matches these against each slot's `file_name`, never its
    !! internal name.
    !!
    !! Fields declaring no unit are skipped rather than stored as "", so an absent entry and an
    !! empty one stay distinguishable and a column keeps whatever unit it had.
    subroutine collect_maml_units(read_maml, cols, vals, n)
        type(parquet_schema), intent(in) :: read_maml              !! the loaded read-in MAML.
        character(len=:), allocatable, intent(out) :: cols(:)      !! file column names declaring a unit.
        character(len=:), allocatable, intent(out) :: vals(:)      !! the unit each declares.
        integer, intent(out) :: n                                  !! live entries.
        character(len=:), allocatable :: fname, u
        integer :: i, first, last, count, width, pass
        !
        n = 0
        allocate(character(len=1) :: cols(0))
        allocate(character(len=1) :: vals(0))
        if (.not. allocated(read_maml%maml%lines)) return
        call locate_fields_block(read_maml%maml%lines, first, last)
        if (first == 0) return
        ! Two passes, as everywhere else in this file: one to size the deferred-length result,
        ! one to fill it.
        count = 0
        width = 1
        do pass = 1, 2
            if (pass == 2) then
                if (count == 0) return
                deallocate(cols, vals)
                allocate(character(len=width) :: cols(count))
                allocate(character(len=width) :: vals(count))
                ! Element by element, never `cols = ""` -- see parse_read_maml_remap's own note on
                ! what a whole-array assignment to a deferred-length allocatable array does here.
                do i = 1, count
                    cols(i) = ""
                    vals(i) = ""
                end do
            end if
            n = 0
            fname = ""
            u = ""
            do i = first, last
                if (len_trim(read_maml%maml%lines(i)) == 0) cycle
                call scan_field_line(read_maml%maml%lines(i), fname, u, pass, cols, vals, n, width)
            end do
            call emit_field_unit(fname, u, pass, cols, vals, n, width)
            if (pass == 1) count = n
        end do
    end subroutine collect_maml_units
    !
    !> One line of a `fields:` block: starts a new entry, records its `unit:`, or is ignored.
    subroutine scan_field_line(line, fname, u, pass, cols, vals, n, width)
        character(len=*), intent(in) :: line                  !! the raw MAML line.
        character(len=:), allocatable, intent(inout) :: fname !! name of the entry being scanned.
        character(len=:), allocatable, intent(inout) :: u     !! its unit so far ("" if none).
        integer, intent(in) :: pass                           !! 1 = counting, 2 = filling.
        character(len=*), intent(inout) :: cols(:)            !! result names (pass 2).
        character(len=*), intent(inout) :: vals(:)            !! result units (pass 2).
        integer, intent(inout) :: n                           !! entries emitted so far.
        integer, intent(inout) :: width                       !! widest entry seen (pass 1).
        character(len=:), allocatable :: tline, key, cvalue
        !
        tline = trim(adjustl(line))
        if (tline(1:1) == "-") then
            ! A new list item closes the previous one, whatever it had.
            call emit_field_unit(fname, u, pass, cols, vals, n, width)
            u = ""
            fname = ""
            call split_key_value(tline(2:), key, cvalue)
            if (key == "name") fname = cvalue
            return
        end if
        call split_key_value(tline, key, cvalue)
        if (key == "unit") u = cvalue
    end subroutine scan_field_line
    !
    !> Records one completed `fields:` entry, if it named a column and declared a unit.
    subroutine emit_field_unit(fname, u, pass, cols, vals, n, width)
        character(len=*), intent(in) :: fname      !! the entry's column name ("" if none).
        character(len=*), intent(in) :: u          !! its unit ("" if none).
        integer, intent(in) :: pass                !! 1 = counting, 2 = filling.
        character(len=*), intent(inout) :: cols(:) !! result names (pass 2).
        character(len=*), intent(inout) :: vals(:) !! result units (pass 2).
        integer, intent(inout) :: n                !! entries emitted so far.
        integer, intent(inout) :: width            !! widest entry seen (pass 1).
        !
        if (len_trim(fname) == 0 .or. len_trim(u) == 0) return
        n = n + 1
        if (pass == 1) then
            width = max(width, len_trim(fname), len_trim(u))
        else
            cols(n) = trim(fname)
            vals(n) = trim(u)
        end if
    end subroutine emit_field_unit
    !
    !> Bounds of the MAML's top-level `fields:` block: the lines after it, up to the next
    !! unindented line. `first` is 0 when there is no such section.
    subroutine locate_fields_block(lines, first, last)
        character(len=*), intent(in) :: lines(:) !! raw MAML source lines.
        integer, intent(out) :: first            !! first line inside the block, or 0.
        integer, intent(out) :: last             !! last line inside it.
        integer :: i, nlines
        !
        nlines = size(lines)
        first = 0
        last = nlines
        do i = 1, nlines
            if (len_trim(lines(i)) == 0) cycle
            if (lines(i)(1:1) /= " " .and. maml_key_matches(lines(i), "fields:")) then
                first = i + 1
                exit
            end if
        end do
        if (first == 0) return
        do i = first, nlines
            if (len_trim(lines(i)) == 0) cycle
            if (lines(i)(1:1) /= " " .and. lines(i)(1:1) /= "-") then
                last = i - 1
                return
            end if
        end do
    end subroutine locate_fields_block
    !
    !> Splits a `key: value` line into its two trimmed, unquoted halves. Both come back empty for
    !! a line with no colon, which every caller skips.
    subroutine split_key_value(line, key, cvalue)
        character(len=*), intent(in) :: line                 !! the line, already trimmed.
        character(len=:), allocatable, intent(out) :: key    !! the key, lowercase-as-written.
        character(len=:), allocatable, intent(out) :: cvalue !! the value, unquoted.
        integer :: colon
        !
        key = ""
        cvalue = ""
        colon = index(line, ":")
        if (colon <= 1) return
        key = trim(adjustl(line(1:colon - 1)))
        call unquote_trimmed(line(colon + 1:), cvalue)
    end subroutine split_key_value
    !
    !> Splits one `- internal: physical` list item (leading dash already the first character) into
    !! its two unquoted halves. Both come back empty for a line that is not a well-formed entry,
    !! which the caller skips rather than aborting on -- the same tolerance the write-side
    !! `col_map:` parser applies to its own list.
    subroutine split_remap_entry(tline, key, cvalue)
        character(len=*), intent(in) :: tline                  !! trimmed list-item line, starting with "-".
        character(len=:), allocatable, intent(out) :: key      !! internal name, unquoted.
        character(len=:), allocatable, intent(out) :: cvalue   !! file column name, unquoted.
        character(len=:), allocatable :: body
        integer :: colon
        !
        key = ""
        cvalue = ""
        body = trim(adjustl(tline(2:)))
        if (len(body) == 0) return
        colon = index(body, ":")
        if (colon <= 1) return
        call unquote_trimmed(body(1:colon-1), key)
        call unquote_trimmed(body(colon+1:), cvalue)
    end subroutine split_remap_entry
    !
    !> Trims `raw` and strips one matching pair of surrounding single or double quotes, so a name
    !! that needs quoting in YAML (one containing a colon, say) is stored as the caller means it.
    !!
    !! A local equivalent of the core's own unquote helper rather than a call to it: that one is
    !! private to `parquet`, and making three string utilities public just to reach them would
    !! widen that module's API for no benefit (see this file's own module doc on staying
    !! independent of the write-side parser).
    subroutine unquote_trimmed(raw, res)
        character(len=*), intent(in) :: raw                 !! text to trim and unquote.
        character(len=:), allocatable, intent(out) :: res   !! trimmed, unquoted result.
        character(len=:), allocatable :: t
        !
        t = trim(adjustl(raw))
        if (len(t) >= 2) then
            if ((t(1:1) == '"' .and. t(len(t):len(t)) == '"') .or. &
                (t(1:1) == "'" .and. t(len(t):len(t)) == "'")) then
                res = t(2:len(t)-1)
                return
            end if
        end if
        res = t
    end subroutine unquote_trimmed
    !
    module procedure table_enumerate_columns
        integer :: i, j, nslots, claimed
        character(len=:), allocatable :: sfx
        !
        call validate_remap(names, internal, physical, n_remap, filename)
        !
        ! Slot count: every physical column contributes one slot unless its name is claimed as an
        ! internal name, plus one slot per remap entry. NOT size(names) any more -- two internal
        ! names may target one physical column, so slots can outnumber the file's own columns.
        nslots = n_remap
        do i = 1, size(names)
            if (remap_claims_internal(internal, n_remap, trim(names(i)))) cycle
            if (remap_targets_physical(physical, n_remap, trim(names(i)))) cycle
            nslots = nslots + 1
        end do
        allocate(cache%cols(nslots + COL_HEADROOM))
        cache%ncols = 0
        !
        ! Walk the PHYSICAL columns in file order, expanding each into whatever claims it. File
        ! order is what keeps a struct's leaves adjacent for the batch release policy, and what
        ! puts two internal names reading one column in neighbouring slots.
        do i = 1, size(names)
            claimed = 0
            do j = 1, n_remap
                if (trim(physical(j)) /= trim(names(i))) cycle
                claimed = claimed + 1
                call add_file_slot(cache, trim(internal(j)), trim(names(i)))
            end do
            if (claimed > 0) cycle
            ! Nothing remaps TO this column. It is still shadowed out if its own name was claimed
            ! as an internal name pointing somewhere else -- the deliberate silent shadow.
            if (remap_claims_internal(internal, n_remap, trim(names(i)))) cycle
            call add_file_slot(cache, trim(names(i)), trim(names(i)))
        end do
        !
        ! Every remap entry must have produced a slot; a target that does not exist was already
        ! rejected by validate_remap, so this is an assertion rather than a reachable error.
        if (cache%ncols /= nslots) then
            call table_context_suffix(cache, "", sfx) ! GCOVR_EXCL_LINE
            error stop EP // "parquet_open_table: internal error building the column list " // & ! GCOVR_EXCL_LINE
                "from extra: remap:" // sfx ! GCOVR_EXCL_LINE
        end if
    end procedure table_enumerate_columns
    !
    !> Appends one descriptor slot for a file-backed column: `name` is the lookup key, `file_name`
    !! the column actually read. The two differ only for a remapped column.
    subroutine add_file_slot(cache, name, file_name)
        type(parquet_table_cache), intent(inout) :: cache !! the column store gaining a slot.
        character(len=*), intent(in) :: name              !! internal/logical name.
        character(len=*), intent(in) :: file_name         !! physical name in the file.
        !
        cache%ncols = cache%ncols + 1
        cache%cols(cache%ncols)%name = name
        cache%cols(cache%ncols)%file_name = file_name
        cache%cols(cache%ncols)%file_source = .true.
        call cache_name_index_insert(cache, cache%ncols)
    end subroutine add_file_slot
    !
    !> Whether any remap entry declares `name` as an INTERNAL name (i.e. claims that name for
    !! itself, whatever it points at).
    logical function remap_claims_internal(internal, n_remap, name) result(res)
        character(len=*), intent(in) :: internal(:) !! remap: table-facing names.
        integer, intent(in) :: n_remap              !! live entries.
        character(len=*), intent(in) :: name        !! name to look for.
        integer :: j
        !
        res = .false.
        do j = 1, n_remap
            if (trim(internal(j)) == name) then
                res = .true.
                return
            end if
        end do
    end function remap_claims_internal
    !
    !> Whether any remap entry TARGETS the physical column `name`.
    logical function remap_targets_physical(physical, n_remap, name) result(res)
        character(len=*), intent(in) :: physical(:) !! remap: file columns targeted.
        integer, intent(in) :: n_remap              !! live entries.
        character(len=*), intent(in) :: name        !! name to look for.
        integer :: j
        !
        res = .false.
        do j = 1, n_remap
            if (trim(physical(j)) == name) then
                res = .true.
                return
            end if
        end do
    end function remap_targets_physical
    !
    !> The one remap rule that needs the file: every entry must target a column it actually has.
    !! Checked before any slot is built, so a bad MAML fails while the table is still obviously
    !! unusable.
    !!
    !! The other rule -- no repeated internal name -- is enforced by `parse_read_maml_remap`
    !! instead, since it needs no file to detect and is better raised before one is opened.
    !!
    !! Deliberately NOT a rule: two entries targeting the same file column. That is supported --
    !! the two internal names become independent columns over one physical source (see this file's
    !! module doc).
    subroutine validate_remap(names, internal, physical, n_remap, filename)
        character(len=*), intent(in) :: names(:)    !! the file's own column names.
        character(len=*), intent(in) :: internal(:) !! remap: table-facing names.
        character(len=*), intent(in) :: physical(:) !! remap: the file column each one reads.
        integer, intent(in) :: n_remap              !! live entries.
        character(len=*), intent(in) :: filename    !! source file, for the message.
        integer :: i, j
        logical :: found
        !
        do i = 1, n_remap
            found = .false.
            do j = 1, size(names)
                if (trim(names(j)) == trim(physical(i))) then
                    found = .true.
                    exit
                end if
            end do
            if (.not. found) then
                error stop EP // "parquet_open_table: extra: remap: column '" // trim(physical(i)) // &
                    "' declared for internal name '" // trim(internal(i)) // "' does not exist in " // &
                    "this file (file '" // trim(filename) // "')"
            end if
        end do
    end subroutine validate_remap
    !
end submodule parquet_tables_maml
