-- Copyright 2026 Open-Guji (https://github.com/open-guji)
--
-- Licensed under the Apache License, Version 2.0 (the "License");
-- you may not use this file except in compliance with the License.
-- You may obtain a copy of the License at
--
--     http://www.apache.org/licenses/LICENSE-2.0
--
-- Unless required by applicable law or agreed to in writing, software
-- distributed under the License is distributed on an "AS IS" BASIS,
-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-- See the License for the specific language governing permissions and
-- limitations under the License.
-- ============================================================================
-- core_textflow.lua - TextFlow balancing and segmentation logic
-- ============================================================================
-- File: core_textflow.lua
-- Layer: Core Layer
--
-- Module Purpose:
-- This module handles textflow (dual-column small notes) logic:
--   1. Balances textflow nodes across left/right sub-columns.
--   2. Handles continuous break logic based on remaining space.
--   3. Sets ATTR_JIAZHU_SUB attribute (1: right/first, 2: left/second).
--
-- Main Algorithm:
--   Given available height H, capacity C = H * 2.
--   If textflow length L <= C, balance: right = ceil(L/2), left = L - ceil(L/2).
--   If L > C, fill current column (right = H, left = H), overflow to next.
--
--
-- ============================================================================

local constants = package.loaded['core.luatex-cn-constants'] or
    require('core.luatex-cn-constants')
local D = constants.D
local style_registry = package.loaded['util.luatex-cn-style-registry'] or
    require('util.luatex-cn-style-registry')
local helpers = package.loaded['core.luatex-cn-layout-grid-helpers'] or
    require('core.luatex-cn-layout-grid-helpers')

local textflow = {}

--- Push textflow style to style stack
-- @param font_color (string|nil) Font color string (e.g., "red" or "1 0 0")
-- @param font_size (string|nil) Font size string (e.g., "14pt")
-- @param font (string|nil) Font family name
-- @param textflow_align (string|nil) TextFlow alignment (outward, inward, center, left, right)
-- @param auto_balance (boolean|nil) Whether to auto-balance last column (default true)
-- @param grid_height_sp (number|nil) Grid height in sp for capacity calculation
-- @return (number) Style ID
function textflow.push_style(font_color, font_size, font, textflow_align, auto_balance, grid_height_sp, style_extra)
    -- Start with style_extra (from \样式 forwarded keys), then overlay textflow-specific fields
    local extra = style_extra or {}
    if textflow_align and textflow_align ~= "" then
        extra.textflow_align = textflow_align
    end
    extra.auto_balance = (auto_balance ~= false)
    local gh_val = grid_height_sp and tonumber(grid_height_sp) or nil
    if gh_val then
        extra.grid_height = gh_val
        extra.textflow_grid_height = gh_val  -- mark as textflow-level (not inner \Style)
    end
    return style_registry.push_content_style(font_color, font_size, font, extra)
end

--- Pop textflow style from style stack
function textflow.pop_style()
    return style_registry.pop()
end

--- Calculate sub-column X offset for textflow
-- @param base_x (number) Base X coordinate (sp)
-- @param grid_width (number) Total cell width (sp)
-- @param w (number) Character width (sp)
-- @param sub_col (number) Sub-column number (1: right, 2: left)
-- @param align (string) Alignment (outward, inward, center, left, right)
-- @return (number) Physical X coordinate (sp)
function textflow.calculate_sub_column_x_offset(base_x, grid_width, w, sub_col, align)
    local sub_width = grid_width / 2
    local inner_padding = sub_width * 0.05 -- 5% internal padding

    align = align or "outward"

    local col_align
    if align == "inward" then
        col_align = (sub_col == 1) and "left" or "right"
    elseif align == "center" then
        col_align = "center"
    elseif align == "left" then
        col_align = "left"
    elseif align == "right" then
        col_align = "right"
    else -- outward (default)
        col_align = (sub_col == 1) and "right" or "left"
    end

    local sub_base_x = base_x
    if sub_col == 1 then sub_base_x = sub_base_x + sub_width end

    if col_align == "right" then
        return sub_base_x + (sub_width - w) - inner_padding
    elseif col_align == "left" then
        return sub_base_x + inner_padding
    else -- center
        return sub_base_x + (sub_width - w) / 2
    end
end

--- Collect consecutive textflow glyph nodes starting from a given node
-- @param start_node (direct node) Starting node (must have ATTR_JIAZHU == 1)
-- @return (table, direct node) Array of textflow glyph nodes, next non-textflow node
--- Collect consecutive textflow glyph nodes, stopping at end or force-column penalty.
-- Decorate marker nodes (ATTR_DECORATE_ID > 0, e.g., judou marks) are NOT collected
-- as content glyphs. Instead they are recorded in a separate table keyed by the
-- preceding content glyph so that place_textflow_segment can position them correctly.
-- @param start_node (node) Starting node
-- @param opts (table|nil) Options: { skip_leading_taitou = bool }
-- @return nodes (table) List of glyph nodes collected
-- @return next_node (node|nil) The next node after collected segment
-- @return hit_column_break (boolean) True if stopped due to PENALTY_FORCE_COLUMN
-- @return decorate_map (table) Map: content_node → {decorate_node, ...}
function textflow.collect_nodes(start_node, opts)
    local nodes = {}
    local decorate_map = {}
    local temp_t = start_node
    local hit_column_break = false
    local initial_mode = nil  -- Track mode of first glyph to detect segment boundaries
    local last_content_node = nil  -- Track last content glyph for decorate association

    while temp_t do
        -- Check if this node belongs to the textflow sequence.
        -- Decorate markers (e.g., judou marks created by judou.flatten) are
        -- zero-width glyph nodes that carry ATTR_DECORATE_ID. They must be
        -- skipped over (not collected as content) so they don't break the
        -- textflow sequence. Note: inside jiazhu, decorate markers may also
        -- carry ATTR_JIAZHU=1 (inherited from TeX context), so we must check
        -- for decorate BEFORE the jiazhu check.
        local tid = D.getid(temp_t)
        local is_decorate = false
        if tid == constants.GLYPH then
            local dec_id = D.get_attribute(temp_t, constants.ATTR_DECORATE_ID)
            if dec_id and dec_id > 0 then
                is_decorate = true
            end
        end
        local is_jiazhu = not is_decorate and D.get_attribute(temp_t, constants.ATTR_JIAZHU) == 1

        if not is_jiazhu and not is_decorate then
            break
        end

        -- Collect textbox nodes (HLIST/VLIST with textbox attributes) as
        -- inline blocks within textflow. They will be placed in a sub-column
        -- alongside regular glyphs. (#96)
        --
        -- Note: we set temp_t to the next sibling and goto continue_advance
        -- (NOT continue_collect) because the label below also calls getnext;
        -- jumping to continue_collect would double-advance and skip a node.
        if (tid == constants.HLIST or tid == constants.VLIST) then
            local tw = D.get_attribute(temp_t, constants.ATTR_TEXTBOX_WIDTH) or 0
            local th = D.get_attribute(temp_t, constants.ATTR_TEXTBOX_HEIGHT) or 0
            if tw > 0 and th > 0 then
                table.insert(nodes, temp_t)
                last_content_node = temp_t
                temp_t = D.getnext(temp_t)
                goto continue_advance
            end
        end

        if is_decorate then
            -- Associate decorate marker with the last content glyph
            if last_content_node then
                if not decorate_map[last_content_node] then
                    decorate_map[last_content_node] = {}
                end
                table.insert(decorate_map[last_content_node], temp_t)
            end
        elseif tid == constants.PENALTY then
            local p_val = D.getfield(temp_t, "penalty")
            if p_val == constants.PENALTY_FORCE_COLUMN
                or p_val == constants.PENALTY_DIGITAL_NEWLINE then
                -- Column break inside textflow: stop collecting, skip penalty
                temp_t = D.getnext(temp_t)
                hit_column_break = true
                break
            end
            if p_val == constants.PENALTY_TAITOU then
                -- In digital mode (skip_leading_taitou=true), a PENALTY_TAITOU at the
                -- start of a segment (e.g., \缩进[-1] at start of \右小列) should be
                -- skipped rather than triggering a column break. In normal (semantic)
                -- mode, PENALTY_TAITOU always triggers a segment break.
                local skip_leading = opts and opts.skip_leading_taitou
                if not skip_leading or #nodes > 0 then
                    temp_t = D.getnext(temp_t)
                    hit_column_break = true
                    break
                end
            end
        elseif tid == constants.GLYPH then
            -- Stop when JIAZHU_MODE changes (e.g., right-only → left-only in \双列).
            -- Each mode segment must be processed separately for correct sub-column placement.
            local mode = D.get_attribute(temp_t, constants.ATTR_JIAZHU_MODE) or 0
            if initial_mode == nil then
                initial_mode = mode
            elseif mode ~= initial_mode then
                break
            end
            table.insert(nodes, temp_t)
            last_content_node = temp_t
        end
        ::continue_collect::
        temp_t = D.getnext(temp_t)
        ::continue_advance::
    end

    return nodes, temp_t, hit_column_break, decorate_map
end

--- Helper: get height of node at index from node_heights table or default
local function get_node_h(node_heights, idx, default_gh)
    if node_heights then return node_heights[idx] or default_gh end
    return default_gh
end

--- Process textflow nodes into chunks with balanced distribution (sp-based).
-- All capacity parameters are in scaled points (sp), not row counts.
-- @param textflow_nodes (table) Consecutive textflow node list (direct nodes)
-- @param available_height_sp (number) Remaining height in current column (sp)
-- @param column_height_sp (number) Total column height per subsequent column (sp)
-- @param mode (number) Mode: 1=right only, 2=left only, 0=balanced
-- @param auto_balance (boolean) Whether to auto-balance last chunk (default true)
-- @param start_sub_col (number|nil) Starting sub-column (1=right, 2=left), nil means start fresh
-- @param start_row_offset (number|nil) Starting row offset when continuing (unused, kept for API compat)
-- @param first_sub_extra_sp (number|nil) Extra height for first sub-column only (sp, from forced indent)
-- @param global_gh (number) Global grid height in sp (for fallback and row conversion)
-- @param node_heights (table|nil) Per-node heights in sp, indexed by node position; nil = all use global_gh
-- @return (table) chunks: { {nodes_with_attr, height_used_sp, is_full_column, end_sub_col, end_height_used_sp}, ... }
function textflow.process_sequence(textflow_nodes, available_height_sp, column_height_sp, mode, auto_balance,
                                   start_sub_col, _start_row_offset, first_sub_extra_sp,
                                   global_gh, node_heights)
    local total_nodes = #textflow_nodes
    if total_nodes == 0 then return {}, nil, nil end

    -- Default auto_balance to true
    if auto_balance == nil then auto_balance = true end
    first_sub_extra_sp = first_sub_extra_sp or 0
    global_gh = global_gh or 655360  -- fallback 10pt

    -- Track sub-column continuation state
    local continue_on_left = (start_sub_col == 2)

    local chunks = {}
    local current_idx = 1
    local first_chunk = true

    -- Mode 1: Right only, Mode 2: Left only, Other: Balanced
    local is_single_column = (mode == 1 or mode == 2)

    while current_idx <= total_nodes do
        -- h_sp: available sub-column height for this chunk
        local h_sp = first_chunk and available_height_sp or column_height_sp
        local fse_sp = first_chunk and first_sub_extra_sp or 0
        local h_first_sub_sp = h_sp + fse_sp

        -- Fill sub-columns by accumulating node heights
        local right_nodes_info = {}  -- {node_idx, y_offset_sp}
        local left_nodes_info = {}
        local right_h = 0
        local left_h = 0
        local idx = current_idx
        local is_full = false

        if first_chunk and continue_on_left and not auto_balance then
            -- Continuing on left sub-column only (must check BEFORE is_single_column,
            -- because mode=2 is also single-column but needs the left continuation path)
            while idx <= total_nodes do
                local nh = get_node_h(node_heights, idx, global_gh)
                if left_h + nh > h_first_sub_sp and #left_nodes_info > 0 then
                    is_full = true
                    break
                end
                table.insert(left_nodes_info, {idx = idx, y_offset_sp = left_h})
                left_h = left_h + nh
                idx = idx + 1
            end

        elseif is_single_column then
            -- Single column: fill until height exceeded
            while idx <= total_nodes do
                local nh = get_node_h(node_heights, idx, global_gh)
                if right_h + nh > h_sp and #right_nodes_info > 0 then
                    is_full = true
                    break
                end
                table.insert(right_nodes_info, {idx = idx, y_offset_sp = right_h})
                right_h = right_h + nh
                idx = idx + 1
            end

        elseif auto_balance then
            -- Auto-balance: collect all that fit in both columns, then split
            local all_indices = {}
            local total_h = 0
            while idx <= total_nodes do
                local nh = get_node_h(node_heights, idx, global_gh)
                -- Two sub-columns: total capacity is 2 * h_sp (with extra for first sub)
                if total_h + nh > h_sp + h_first_sub_sp and #all_indices > 0 then
                    is_full = true
                    break
                end
                table.insert(all_indices, idx)
                total_h = total_h + nh
                idx = idx + 1
            end
            -- Split: right column gets ceil(count/2) nodes worth of height.
            -- For uniform heights this matches the old row-based ceil(N/2).
            -- For variable heights, we fill right first, switching to left once
            -- right has accumulated more than half the total.
            -- Each sub-column must not exceed its height limit (h_first_sub_sp / h_sp).
            local target_right_h = total_h / 2
            local accumulated = 0
            local filling_right = true
            for ki, ni in ipairs(all_indices) do
                local nh = get_node_h(node_heights, ni, global_gh)
                if filling_right then
                    -- Check per-column overflow: right must not exceed h_first_sub_sp
                    if accumulated + nh > h_first_sub_sp and #right_nodes_info > 0 then
                        filling_right = false
                    end
                end
                if filling_right then
                    table.insert(right_nodes_info, {idx = ni, y_offset_sp = accumulated})
                    accumulated = accumulated + nh
                    right_h = accumulated
                    -- Switch to left after right has reached or exceeded half
                    if accumulated >= target_right_h then
                        filling_right = false
                    end
                else
                    -- Check per-column overflow: left must not exceed h_sp
                    if left_h + nh > h_sp and #left_nodes_info > 0 then
                        -- Left column full, push remaining nodes back
                        idx = ni
                        is_full = true
                        break
                    end
                    table.insert(left_nodes_info, {idx = ni, y_offset_sp = left_h})
                    left_h = left_h + nh
                end
            end
            -- Re-compute left y_offsets from 0
            left_h = 0
            for _, info in ipairs(left_nodes_info) do
                info.y_offset_sp = left_h
                left_h = left_h + get_node_h(node_heights, info.idx, global_gh)
            end

        else
            -- No auto-balance: fill right first, then left
            while idx <= total_nodes do
                local nh = get_node_h(node_heights, idx, global_gh)
                if right_h + nh > h_first_sub_sp and #right_nodes_info > 0 then break end
                table.insert(right_nodes_info, {idx = idx, y_offset_sp = right_h})
                right_h = right_h + nh
                idx = idx + 1
            end
            while idx <= total_nodes do
                local nh = get_node_h(node_heights, idx, global_gh)
                if left_h + nh > h_sp and #left_nodes_info > 0 then
                    is_full = true
                    break
                end
                table.insert(left_nodes_info, {idx = idx, y_offset_sp = left_h})
                left_h = left_h + nh
                idx = idx + 1
            end
            -- Check if we consumed all remaining and still have room
            if idx > total_nodes then
                is_full = false
            end
        end

        -- Build chunk_nodes
        local chunk_nodes = {}

        -- Right sub-column nodes
        for _, info in ipairs(right_nodes_info) do
            local n = textflow_nodes[info.idx]
            local sub_col = 1
            D.set_attribute(n, constants.ATTR_JIAZHU_SUB, sub_col)
            table.insert(chunk_nodes, {
                node = n,
                sub_col = sub_col,
                relative_row = info.y_offset_sp,  -- now stores y_offset_sp
            })
        end

        -- Left sub-column nodes
        for _, info in ipairs(left_nodes_info) do
            local n = textflow_nodes[info.idx]
            local sub_col = (is_single_column and mode == 1) and 1 or 2
            D.set_attribute(n, constants.ATTR_JIAZHU_SUB, sub_col)
            table.insert(chunk_nodes, {
                node = n,
                sub_col = sub_col,
                relative_row = info.y_offset_sp,  -- now stores y_offset_sp
            })
        end

        -- height_used_sp: the max of right and left heights (determines column advancement)
        local height_used_sp = math.max(right_h, left_h)

        -- Track ending state for next textflow continuation
        local end_sub_col = nil
        local end_height_used_sp = nil
        if not auto_balance and not is_full then
            if #right_nodes_info > 0 and #left_nodes_info == 0 then
                end_sub_col = 1
                end_height_used_sp = right_h
            elseif #right_nodes_info == 0 and #left_nodes_info > 0 then
                end_sub_col = 2
                end_height_used_sp = left_h
            end
        end

        -- Compute rows_used for cur_row advancement.
        -- Special case: when not auto_balance and only right sub-column used,
        -- rows_used = 0 to allow next textflow to continue on left.
        local rows_used
        if not auto_balance and not is_full and #right_nodes_info > 0 and #left_nodes_info == 0 then
            rows_used = 0  -- Only right used, don't advance cur_row
        else
            rows_used = math.ceil(height_used_sp / global_gh)
        end

        table.insert(chunks, {
            nodes = chunk_nodes,
            height_used_sp = height_used_sp,
            is_full_column = is_full,
            end_sub_col = end_sub_col,
            end_height_used_sp = end_height_used_sp,
            rows_used = rows_used,
            end_row_used = end_height_used_sp and math.ceil(end_height_used_sp / global_gh) or nil,
        })
        current_idx = idx
        first_chunk = false
        continue_on_left = false
    end

    -- Return chunks and final state for potential continuation
    local final_chunk = chunks[#chunks]
    local final_sub_col = final_chunk and final_chunk.end_sub_col or nil
    local final_row_used = final_chunk and final_chunk.end_row_used or nil

    return chunks, final_sub_col, final_row_used
end

--- Place textflow nodes into layout map
-- @param ctx (table) Grid context
-- @param start_node (node) The starting textflow node
-- @param layout_map (table) The layout map to populate
-- @param params (table) Layout parameters { effective_limit, line_limit, base_indent, r_indent, block_id, first_indent, textflow_mode }
-- @param callbacks (table) Callbacks { flush, wrap, get_indent, debug }
-- @return (node) The next node to process
--- Place a single segment of textflow nodes into the layout map.
-- @param ctx (table) Grid context
-- @param nodes (table) List of glyph nodes
-- @param layout_map (table) The layout map to populate
-- @param params (table) Layout parameters
-- @param callbacks (table) Callbacks { flush, wrap, get_indent, debug }
-- @param decorate_map (table|nil) Map: content_node → {decorate_node, ...} for judou marks
local function place_textflow_segment(ctx, nodes, layout_map, params, callbacks, decorate_map)
    if #nodes == 0 then
        -- Even with no glyphs, if the previous segment left pending_sub_col=1
        -- and this is a left-only segment (mode=2), consume the pending state.
        -- This happens when \双列{\右小列{...}\左小列{}} has an empty left column.
        if ctx.textflow_pending_sub_col == 1 and params.textflow_mode == 2 then
            ctx.cur_row = ctx.cur_row + (ctx.textflow_pending_row_used or 0)
            ctx.cur_y_sp = ctx.cur_row * (params.grid_height or 655360)
            ctx.textflow_pending_sub_col = nil
            ctx.textflow_pending_row_used = nil
        end
        return
    end

    -- When forced indent is active (e.g., from \相对抬头), params.base_indent and
    -- params.first_indent may be polluted with the forced value (e.g., 1 instead of 2).
    -- We need the original paragraph indent for subsequent columns.
    -- Recover the original indent from the style stack.
    local orig_base_indent = params.base_indent
    local orig_first_indent = params.first_indent
    local node_indent_attr = D.get_attribute(nodes[1], constants.ATTR_INDENT)
    local is_forced, forced_indent_value = constants.is_any_command_indent(node_indent_attr)
    if is_forced then
        local sid = D.get_attribute(nodes[1], constants.ATTR_STYLE_REG_ID)
        if sid then
            local stack_indent = style_registry.get_indent(sid)
            if stack_indent and stack_indent > 0 then
                orig_base_indent = stack_indent
            end
            local stack_first_indent = style_registry.get_first_indent(sid)
            if stack_first_indent and stack_first_indent ~= -1 then
                orig_first_indent = stack_first_indent
            end
        end
    end

    -- Process textflow sequence into chunks (sp-based)
    local gh = params.grid_height or 655360

    -- Check if textflow has a segment-level grid_height override (from \夹注[grid-height=X])
    -- Read from style registry (textflow_grid_height key), not from per-node grid_height
    -- which may come from inner \样式[grid-height=Y]
    local textflow_gh = nil
    local first_sid = D.get_attribute(nodes[1], constants.ATTR_STYLE_REG_ID)
    if first_sid and first_sid > 0 then
        textflow_gh = style_registry.get_textflow_grid_height(first_sid)
    end

    local available_in_first = params.effective_limit - ctx.cur_row
    local capacity_per_subsequent = params.line_limit - orig_base_indent - params.r_indent

    -- Convert row-based values to sp for process_sequence
    -- When auto_column_wrap is disabled, use unlimited height so textflow
    -- never splits across columns (all nodes stay in one chunk).
    local available_height_sp, column_height_sp
    if ctx.auto_column_wrap == false then
        available_height_sp = 0x7FFFFFFF  -- max int: no overflow splitting
        column_height_sp = 0x7FFFFFFF
    elseif textflow_gh then
        -- When textflow has its own grid-height, use actual sp heights from ctx
        -- instead of row-count × textflow_gh (row counts are based on global gh).
        -- For both first and subsequent columns, use ctx.col_height_sp (band height)
        -- as the constraint, not capacity_per_subsequent * gh which uses the global
        -- line_limit and may be far larger than the actual band height.
        available_height_sp = ctx.col_height_sp - ctx.cur_y_sp
        column_height_sp = ctx.col_height_sp
    else
        available_height_sp = available_in_first * gh
        column_height_sp = capacity_per_subsequent * gh
    end

    -- Check if first node has forced indent (e.g., from \相对抬头)
    -- If so, the first sub-column starts from a lower indent, giving extra rows
    -- for that sub-column only (not the other sub-column or subsequent columns).
    local forced_indent_extra_sp = 0
    if is_forced and forced_indent_value < ctx.cur_row then
        forced_indent_extra_sp = (ctx.cur_row - forced_indent_value) * gh
    end
    -- Build node_heights table: per-node grid-height from style override
    -- For textbox (HLIST/VLIST) nodes, use ATTR_TEXTBOX_HEIGHT_SP as their height
    local node_heights = nil
    for i, n in ipairs(nodes) do
        local nid = D.getid(n)
        local tb_h_sp = nil
        if nid == constants.HLIST or nid == constants.VLIST then
            tb_h_sp = D.get_attribute(n, constants.ATTR_TEXTBOX_HEIGHT_SP)
        end
        if tb_h_sp and tb_h_sp > 0 then
            -- Use textbox outer row count × gh so it occupies the correct
            -- number of textflow rows (ATTR_TEXTBOX_HEIGHT_SP uses inner
            -- grid_height which is smaller than textflow's gh).
            local tb_rows = D.get_attribute(n, constants.ATTR_TEXTBOX_HEIGHT) or 1
            local effective_h = tb_rows * gh
            if not node_heights then
                node_heights = {}
                for j = 1, i - 1 do node_heights[j] = gh end
            end
            node_heights[i] = effective_h
        else
            local sid = D.get_attribute(n, constants.ATTR_STYLE_REG_ID)
            if sid and sid > 0 then
                local sgh = style_registry.get_grid_height(sid)
                -- sgh == 0 is a valid override: zero-advance overlay glyph
                -- (e.g. the compressed closing bracket of a jiao note that
                -- shares its cell with the following character)
                if sgh and sgh >= 0 and sgh ~= gh then
                    if not node_heights then
                        node_heights = {}
                        for j = 1, i - 1 do node_heights[j] = gh end
                    end
                    node_heights[i] = sgh
                elseif node_heights then
                    node_heights[i] = gh
                end
            elseif node_heights then
                node_heights[i] = gh
            end
        end
    end

    -- Get auto_balance from style (read from first node)
    local auto_balance = true
    local style_id = D.get_attribute(nodes[1], constants.ATTR_STYLE_REG_ID)
    local style = style_registry.get(style_id)
    if style and style.auto_balance == false then
        auto_balance = false
    end

    -- Get continuation state from ctx (if available)
    local start_sub_col = nil
    if not auto_balance and ctx.textflow_pending_sub_col == 1 then
        -- Previous textflow ended on right, continue on left
        start_sub_col = 2
    end

    -- Guard: if the current column has no room for even one textflow node,
    -- wrap to the next column first. Otherwise process_sequence force-places
    -- the first node (its "at least one node per chunk" anti-loop guard) and
    -- the glyph protrudes beyond the column bottom border.
    if ctx.auto_column_wrap ~= false and start_sub_col ~= 2 then
        local first_h = get_node_h(node_heights, 1, gh)
        if available_height_sp < first_h then
            callbacks.wrap()
            local wrap_indent = callbacks.get_indent(params.block_id, orig_base_indent, orig_first_indent)
            if ctx.cur_row < wrap_indent then
                ctx.cur_row = wrap_indent
                ctx.cur_y_sp = ctx.cur_row * (params.grid_height or 655360)
            end
            if textflow_gh then
                available_height_sp = ctx.col_height_sp - ctx.cur_y_sp
            else
                available_height_sp = (params.effective_limit - ctx.cur_row) * gh
            end
        end
    end

    local chunks, final_sub_col, final_row_used = textflow.process_sequence(
        nodes, available_height_sp, column_height_sp,
        params.textflow_mode, auto_balance, start_sub_col, nil,
        forced_indent_extra_sp, gh, node_heights)

    -- Determine the "first sub-col" where forced indent applies.
    -- Forced indent (from \相对抬头) only affects the first sub-column after the break.
    -- When the text flows to the other sub-column or next big column, revert to normal indent.
    local forced_first_sub_col = nil
    if is_forced then
        if start_sub_col == 2 then
            forced_first_sub_col = 2  -- Started on left sub-col
        else
            forced_first_sub_col = 1  -- Started on right sub-col (default)
        end
    end

    -- Textflow glyphs bypass col_buffer/flush, so the just_wrapped_column
    -- flag (issue #54) is never cleared by them. Clear it here: content HAS
    -- been placed in this column, otherwise the smart-break empty-column
    -- guard later discards the pending rows and the next paragraph
    -- overprints this column (big/small glyph overlap).
    if #chunks > 0 and #chunks[1].nodes > 0 then
        ctx.just_wrapped_column = false
    end

    -- Place chunks into layout_map
    for i, chunk in ipairs(chunks) do
        if i > 1 then
            callbacks.wrap()
            local chunk_indent = callbacks.get_indent(params.block_id, orig_base_indent, orig_first_indent)
            if ctx.cur_row < chunk_indent then
                ctx.cur_row = chunk_indent
                ctx.cur_y_sp = ctx.cur_row * (params.grid_height or 655360)
            end
        end
        for _, node_info in ipairs(chunk.nodes) do
            -- Note: ATTR_STYLE_REG_ID is already set by TeX layer

            -- Check if this node has forced indent (e.g., from \平抬 command)
            local ni_attr = D.get_attribute(node_info.node, constants.ATTR_INDENT)
            local ni_forced, ni_indent_val = constants.is_any_command_indent(ni_attr)

            -- Forced indent only applies in the first chunk AND the first sub-column.
            -- When text flows to the other sub-column or overflows to next big column,
            -- revert to normal indent.
            if ni_forced then
                if i > 1 or node_info.sub_col ~= forced_first_sub_col then
                    D.set_attribute(node_info.node, constants.ATTR_INDENT, 0)
                    ni_forced = false
                end
            end

            -- Resolve per-node cell height for entry.cell_height
            local nid = D.getid(node_info.node)
            local is_tb_block = (nid == constants.HLIST or nid == constants.VLIST)
                and (D.get_attribute(node_info.node, constants.ATTR_TEXTBOX_WIDTH) or 0) > 0
            local node_cell_h
            if is_tb_block then
                -- Textbox block: use outer row count × gh to match process_sequence allocation
                local tb_rows = D.get_attribute(node_info.node, constants.ATTR_TEXTBOX_HEIGHT) or 1
                node_cell_h = tb_rows * gh
            elseif node_info.sub_col then
                node_cell_h = gh  -- default: global grid_height
                local sid = D.get_attribute(node_info.node, constants.ATTR_STYLE_REG_ID)
                if sid and sid > 0 then
                    local style_ch = style_registry.get_grid_height(sid)
                    if style_ch and style_ch >= 0 then
                        node_cell_h = style_ch
                    end
                end
            else
                node_cell_h = helpers.resolve_cell_height(node_info.node, gh, nil, ctx.punct_config)
            end

            -- y_sp calculation: base position uses global grid_height,
            -- relative_row now stores y_offset_sp (cumulative offset from process_sequence)
            local base_y_sp
            if ni_forced then
                base_y_sp = ni_indent_val * gh
            else
                base_y_sp = ctx.cur_row * gh
            end
            local tf_y_sp = base_y_sp + node_info.relative_row  -- relative_row is y_offset_sp
            local tf_band_y_off = ctx.band_y_offsets_sp and ctx.band_y_offsets_sp[ctx.cur_band] or 0
            local entry = {
                page = ctx.cur_page,
                col = ctx.cur_col,
                band = ctx.cur_band,
                y_sp = tf_y_sp,
                band_y_offset_sp = tf_band_y_off,
                sub_col = node_info.sub_col,
                cell_height = node_cell_h,
                -- P2: absolute coordinates
                x = helpers.compute_x(ctx.cur_col, ctx.cur_page, ctx),
                y = helpers.compute_y(tf_y_sp, tf_band_y_off, ctx),
            }
            -- Textbox block: mark as block with width/height for render phase
            if is_tb_block then
                entry.is_block = true
                entry.width = 1
                entry.height = D.get_attribute(node_info.node, constants.ATTR_TEXTBOX_HEIGHT) or 1
            end
            if not node_info.sub_col then
                entry.cell_width = helpers.resolve_cell_width(node_info.node, nil)
            end
            helpers.apply_style_attrs(entry, node_info.node)

            -- Check for line mark attribute (专名号/书名号)
            local lm_id = D.get_attribute(node_info.node, constants.ATTR_LINE_MARK_ID)
            if lm_id and lm_id > 0 then
                entry.line_mark_id = lm_id
            end

            layout_map[node_info.node] = entry

            -- Track node for cell vertical-align (textflow bypasses col_buffer/flush_buffer)
            if ctx.cell_valign_nodes then
                ctx.cell_valign_nodes[#ctx.cell_valign_nodes + 1] = node_info.node
            end
            if ctx.pending_cell_valign_nodes then
                ctx.pending_cell_valign_nodes[#ctx.pending_cell_valign_nodes + 1] = node_info.node
            end

            -- Place associated decorate markers (e.g., judou marks) at the
            -- same position as their anchor glyph. They are zero-width overlays
            -- and do not occupy any grid space.
            if decorate_map then
                local dec_nodes = decorate_map[node_info.node]
                if dec_nodes then
                    for _, dec_node in ipairs(dec_nodes) do
                        local dec_y_sp = entry.y_sp + node_cell_h
                        local dec_entry = {
                            page = entry.page,
                            col = entry.col,
                            band = entry.band,
                            y_sp = dec_y_sp,
                            band_y_offset_sp = entry.band_y_offset_sp,
                            sub_col = entry.sub_col,
                            -- P2: absolute coordinates (inherit x from anchor, y shifted down)
                            x = entry.x,
                            y = helpers.compute_y(dec_y_sp, entry.band_y_offset_sp, ctx),
                        }
                        helpers.apply_style_attrs(dec_entry, dec_node)
                        layout_map[dec_node] = dec_entry
                    end
                end
            end
        end

        -- Advance cur_row appropriately
        if i == 1 and start_sub_col == 2 then
            -- Continuing on left: advance by max of pending (right) and current (left) rows
            local pending_rows = ctx.textflow_pending_row_used or 0
            ctx.cur_row = ctx.cur_row + math.max(pending_rows, chunk.rows_used)
        else
            ctx.cur_row = ctx.cur_row + chunk.rows_used
        end
        ctx.cur_y_sp = ctx.cur_row * gh
    end

    -- Update ctx with final sub-column state for next textflow
    if not auto_balance and final_sub_col == 1 then
        -- Ended on right sub-column, next textflow can continue on left
        ctx.textflow_pending_sub_col = 1
        ctx.textflow_pending_row_used = final_row_used
    else
        -- Clear pending state (used both sub-columns or auto-balanced)
        ctx.textflow_pending_sub_col = nil
        ctx.textflow_pending_row_used = nil
    end
end

--- Place textflow nodes into layout map, handling column breaks within textflow.
-- @param ctx (table) Grid context
-- @param start_node (node) The starting textflow node
-- @param layout_map (table) The layout map to populate
-- @param params (table) Layout parameters { effective_limit, line_limit, base_indent, r_indent, block_id, first_indent, textflow_mode }
-- @param callbacks (table) Callbacks { flush, wrap, get_indent, debug }
-- @return (node) The next node to process
function textflow.place_nodes(ctx, start_node, layout_map, params, callbacks)
    if callbacks.debug then
        callbacks.debug(string.format("  [layout] TEXTFLOW DETECTED: node=%s", tostring(start_node)))
    end
    callbacks.flush()
    -- Recover original paragraph indent from style stack.
    -- params.base_indent/first_indent may be polluted by forced indent (\相对抬头).
    local orig_base_indent = params.base_indent
    local orig_first_indent = params.first_indent
    if D.get_attribute(start_node, constants.ATTR_JIAZHU) == 1 then
        local first_glyph = start_node
        -- Find first glyph node for style lookup
        while first_glyph and D.getid(first_glyph) ~= constants.GLYPH do
            first_glyph = D.getnext(first_glyph)
        end
        if first_glyph then
            local ni = D.get_attribute(first_glyph, constants.ATTR_INDENT)
            local forced = constants.is_any_command_indent(ni)
            if forced then
                local sid = D.get_attribute(first_glyph, constants.ATTR_STYLE_REG_ID)
                if sid then
                    local si = style_registry.get_indent(sid)
                    if si and si > 0 then orig_base_indent = si end
                    local sfi = style_registry.get_first_indent(sid)
                    if sfi and sfi ~= -1 then orig_first_indent = sfi end
                end
            end
        end
    end

    local temp_t = start_node

    -- Loop: collect segments separated by force-column penalties
    local collect_opts = ctx.auto_column_wrap == false
        and { skip_leading_taitou = true } or nil
    while true do
        local nodes, next_t, hit_column_break, decorate_map = textflow.collect_nodes(temp_t, collect_opts)
        if callbacks.debug then
            callbacks.debug(string.format("  [layout] Collected %d textflow glyphs (column_break=%s)",
                #nodes, tostring(hit_column_break)))
        end

        -- Place this segment
        place_textflow_segment(ctx, nodes, layout_map, params, callbacks, decorate_map)

        if hit_column_break then
            -- Column break inside textflow: advance to next sub-column.
            -- Special case: PENALTY_TAITOU at the very start of textflow (before any
            -- glyphs collected) produces an empty segment. When the current column has
            -- no content yet (page_has_content=false or cur_row at indent baseline),
            -- skip the wrap to avoid wasting an empty column. The taitou info is
            -- already encoded in ATTR_INDENT of subsequent glyphs.
            if #nodes == 0 and not ctx.page_has_content then
                -- Apply paragraph indent since the skipped PENALTY_TAITOU won't
                -- trigger column change + indent application.
                local chunk_indent = callbacks.get_indent(params.block_id, orig_base_indent, orig_first_indent)
                if ctx.cur_row < chunk_indent then
                    ctx.cur_row = chunk_indent
                    ctx.cur_y_sp = ctx.cur_row * (params.grid_height or 655360)
                end
                temp_t = next_t
            -- Two cases:
            --   (a) Previous segment ended on RIGHT sub-col (pending_sub_col==1):
            --       → Next segment starts on LEFT sub-col of same big column.
            --       → Keep pending state so place_textflow_segment uses start_sub_col=2.
            --   (b) Previous segment ended on LEFT sub-col or both filled (pending_sub_col==nil or 2):
            --       → Next segment goes to the next big column's RIGHT sub-col.
            --       → Call callbacks.wrap() to advance to next big column.
            elseif ctx.textflow_pending_sub_col == 1 then
                -- Case (a): right → left in same big column
                temp_t = next_t
            else
                -- Case (b): left or both → next big column
                ctx.textflow_pending_sub_col = nil
                ctx.textflow_pending_row_used = nil
                callbacks.wrap()
                local chunk_indent = callbacks.get_indent(params.block_id, orig_base_indent, orig_first_indent)
                if ctx.cur_row < chunk_indent then
                    ctx.cur_row = chunk_indent
                    ctx.cur_y_sp = ctx.cur_row * (params.grid_height or 655360)
                end
                temp_t = next_t
            end
        else
            -- No more column breaks; done
            temp_t = next_t
            break
        end
    end

    return temp_t
end

-- Export internals for white-box unit testing
textflow._internal = {
    place_textflow_segment = place_textflow_segment,
    get_node_h = get_node_h,
}

-- Register module
package.loaded['core.luatex-cn-textflow'] = textflow

return textflow
