-- 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.
-- ============================================================================
-- luatex-cn-core-render-border.lua - 边框渲染模块
-- ============================================================================
-- 文件名: luatex-cn-core-render-border.lua
-- 层级: 第三阶段 - 渲染层 (Stage 3: Render Layer)
--
-- 【模块功能 / Module Purpose】
-- 本模块负责边框和装饰边框的渲染：
--   1. draw_column_borders: 绘制普通列的边框（跳过版心列）
--   2. draw_outer_border: 绘制整个内容区域的外围边框
--   3. render_borders: 高层协调函数，处理所有边框渲染
--   4. 装饰边框形状（rect/octagon/circle）的渲染
--
-- ============================================================================

-- Load dependencies
local utils = package.loaded['util.luatex-cn-utils'] or
    require('util.luatex-cn-utils')
local constants = package.loaded['core.luatex-cn-constants'] or
    require('core.luatex-cn-constants')
local drawing = package.loaded['util.luatex-cn-drawing'] or
    require('util.luatex-cn-drawing')
local page_mod = package.loaded['core.luatex-cn-core-page'] or
    require('core.luatex-cn-core-page')
local text_position = package.loaded['core.luatex-cn-render-position'] or
    require('core.luatex-cn-render-position')

-- ============================================================================
-- Column Border Drawing
-- ============================================================================

--- 绘制列边框（仅限普通列，不含版心列）
-- 版心列应由 banxin.draw_banxin_column 单独绘制
-- @param p_head (node) 节点列表头部（直接引用）
-- @param params (table) 参数表:
--   - total_cols: 要绘制的总列数
--   - grid_width: 每列的宽度 (sp)
--   - content_dim_h: 列边框高度 (sp), 即 line_limit * grid_height + padding
--   - border_thickness: 边框厚度 (sp)
--   - shift_x: 水平偏移 (sp)
--   - outer_shift: 外边框偏移 (sp)
--   - border_rgb_str: 归一化的 RGB 颜色字符串
--   - banxin_cols: 可选，要跳过的列索引集合（版心列）
-- @return (node) 更新后的头部
local function draw_column_borders(p_head, params)
    local sp_to_bp = utils.sp_to_bp
    local total_cols = params.total_cols
    local grid_width = params.grid_width
    local border_thickness = params.border_thickness
    local shift_x = params.shift_x
    local outer_shift = params.outer_shift
    local border_rgb_str = params.border_rgb_str
    local banxin_cols = params.banxin_cols or {} -- Set of column indices to skip
    local col_min_y_sp = params.col_min_y_sp or {} -- Per-column min y_sp for taitou raised border
    local col_geom = params.col_geom or {
        grid_width = grid_width,
        banxin_width = params.banxin_width or 0,
        interval = params.interval or 0,
    }
    local content_dim_h = params.content_dim_h  -- Pre-computed: line_limit * grid_height + padding

    local b_thickness_bp = border_thickness * sp_to_bp
    local half_thickness = math.floor(border_thickness / 2)

    -- Variable-width column borders: when col_widths is set,
    -- each column has its own width in sp.
    local col_widths = _G.content and _G.content.col_widths
    if col_widths and #col_widths > 0 then
        for i = 1, #col_widths do
            local logical_col = i - 1
            local rtl_col = total_cols - 1 - logical_col
            local tx_bp = (text_position.get_column_x_var(rtl_col, col_widths, total_cols) + half_thickness + shift_x) * sp_to_bp
            local ty_bp = -(half_thickness + outer_shift) * sp_to_bp
            local tw_bp = col_widths[i] * sp_to_bp
            local th_bp = -content_dim_h * sp_to_bp

            local literal = utils.create_border_literal(b_thickness_bp, border_rgb_str, tx_bp, ty_bp, tw_bp, th_bp)
            p_head = utils.insert_pdf_literal(p_head, literal)
        end
        return p_head
    end

    -- Per-band column border control: when band_column_borders is set,
    -- draw column borders from full height, excluding only the bands with column_border=false.
    -- Gaps between bands and padding areas retain column borders.
    local band_column_borders = params.band_column_borders
    local n_bands = params.n_bands
    local band_heights_sp = params.band_heights_sp
    local band_y_offsets_sp = params.band_y_offsets_sp

    -- Per-cell column border support: cell_column_borders[band][cell_idx] overrides band default
    local cell_column_borders = params.cell_column_borders
    local col_groups = params.col_groups

    if band_column_borders and n_bands and n_bands > 1 then
        -- Offset to convert band y-coordinates (content area origin) to column border
        -- y-coordinates (inner border origin). Band dividers are drawn at
        -- outer_shift + border_thickness + band_y, while column border
        -- segments start at half_thickness + outer_shift + seg_y.
        -- column_padding only affects text Y-offset, not band/border positions.
        local band_y_to_seg = (border_thickness - half_thickness)

        -- Determine table column range so columns outside the table
        -- are drawn at full height (not affected by band_column_borders).
        local table_start_col = params.table_start_col
        local actual_band_cols = params.actual_band_cols

        -- Build per-column-per-band border map when cell_column_borders are present
        -- col_border_overrides[band][local_col] = true/false (nil = use band default)
        local col_border_overrides = nil
        if cell_column_borders and col_groups then
            col_border_overrides = {}
            for band = 0, n_bands - 1 do
                local cell_borders = cell_column_borders[band]
                local cells = col_groups[band]
                if cell_borders and cells then
                    col_border_overrides[band] = {}
                    local col_offset = 0
                    for cell_idx = 1, #cells do
                        local cw = cells[cell_idx] or 1
                        if cw == 0 then cw = actual_band_cols - col_offset end
                        local cb = cell_borders[cell_idx]
                        if cb ~= nil then
                            for c = 0, cw - 1 do
                                col_border_overrides[band][col_offset + c] = cb
                            end
                        end
                        col_offset = col_offset + cw
                    end
                end
            end
        end

        -- Build band-level skips for horizontal edge restoration
        -- (cell-level overrides don't affect the overall frame edges)
        local skips = {}
        for band = 0, n_bands - 1 do
            if band_column_borders[band] == false then
                local by = (band_y_offsets_sp[band] or 0) + band_y_to_seg
                local bh = band_heights_sp[band] or 0
                skips[#skips + 1] = { by, by + bh }
            end
        end

        -- Build per-column draw segments
        for col = 0, total_cols - 1 do
            if not banxin_cols[col] then
                local rtl_col = total_cols - 1 - col
                local tx_bp = (text_position.get_column_x(rtl_col, col_geom) + half_thickness + shift_x) * sp_to_bp
                local tw_bp = text_position.get_column_width(col, col_geom) * sp_to_bp

                -- Check if this column is inside the table range
                local in_table = table_start_col and actual_band_cols
                    and col >= table_start_col and col < table_start_col + actual_band_cols

                if in_table then
                    -- For this column, determine per-band draw/skip using both
                    -- band_column_borders and cell-level overrides
                    local local_col = col - table_start_col
                    local skips = {}
                    for band = 0, n_bands - 1 do
                        -- Cell override > band default
                        local draw = band_column_borders[band]
                        if col_border_overrides and col_border_overrides[band]
                            and col_border_overrides[band][local_col] ~= nil then
                            draw = col_border_overrides[band][local_col]
                        end
                        if draw == false then
                            local by = (band_y_offsets_sp[band] or 0) + band_y_to_seg
                            local bh = band_heights_sp[band] or 0
                            skips[#skips + 1] = { by, by + bh }
                        end
                    end

                    -- Build segments from skips
                    local segments = {}
                    local cursor = 0
                    for _, skip in ipairs(skips) do
                        if cursor < skip[1] then
                            segments[#segments + 1] = { cursor, skip[1] }
                        end
                        cursor = skip[2]
                    end
                    if cursor < content_dim_h then
                        segments[#segments + 1] = { cursor, content_dim_h }
                    end

                    -- Draw segments
                    for _, seg in ipairs(segments) do
                        local ty_bp = -(half_thickness + outer_shift + seg[1]) * sp_to_bp
                        local th_bp = -(seg[2] - seg[1]) * sp_to_bp

                        local literal = utils.create_border_literal(b_thickness_bp, border_rgb_str, tx_bp, ty_bp, tw_bp, th_bp)
                        p_head = utils.insert_pdf_literal(p_head, literal)
                    end
                else
                    -- Non-table column: draw full-height column border
                    local ty_bp = -(half_thickness + outer_shift) * sp_to_bp
                    local th_bp = -content_dim_h * sp_to_bp
                    local literal = utils.create_border_literal(b_thickness_bp, border_rgb_str, tx_bp, ty_bp, tw_bp, th_bp)
                    p_head = utils.insert_pdf_literal(p_head, literal)
                end

                -- Taitou raised border: extend topmost segment upward for negative y_sp columns
                local min_y = col_min_y_sp[col]
                if min_y and min_y < 0 then
                    local raised_sp = -min_y
                    local ty_bp = -(half_thickness + outer_shift) * sp_to_bp + raised_sp * sp_to_bp
                    local th_bp = -raised_sp * sp_to_bp
                    local literal = utils.create_border_literal(b_thickness_bp, border_rgb_str, tx_bp, ty_bp, tw_bp, th_bp)
                    p_head = utils.insert_pdf_literal(p_head, literal)
                end
            end
        end

        -- Restore vertical edges at table boundaries where column border
        -- rectangles are absent (all bands skipped). The boundary between
        -- table columns (column_border=false) and non-table columns (with borders)
        -- or page edges needs vertical lines so the frame remains closed.
        -- Check if ALL bands have column_border=false (no cell overrides apply here
        -- since boundary lines are about the overall table frame)
        local all_bands_skipped = true
        for band = 0, n_bands - 1 do
            if band_column_borders[band] ~= false then
                all_bands_skipped = false
                break
            end
        end
        if all_bands_skipped and table_start_col and actual_band_cols then
            local table_end_col = table_start_col + actual_band_cols  -- exclusive
            local ty_bp = -(half_thickness + outer_shift) * sp_to_bp
            local th_sp = content_dim_h

            -- Left edge of table region (high col = leftmost in RTL layout)
            -- Draw if table extends to page left boundary (col == total_cols - 1)
            -- or if the column to the left is non-table or banxin
            local left_col = table_end_col - 1  -- highest col in table
            if left_col >= 0 then
                local need_left = (left_col == total_cols - 1) or
                    (left_col + 1 < total_cols and (not (left_col + 1 >= table_start_col and left_col + 1 < table_end_col) or banxin_cols[left_col + 1]))
                if need_left then
                    local rtl_col = total_cols - 1 - left_col
                    local lx = text_position.get_column_x(rtl_col, col_geom)
                    local line_x_bp = (lx + half_thickness + shift_x) * sp_to_bp
                    local literal = string.format("q %.2f w %s RG %.4f %.4f m %.4f %.4f l S Q",
                        b_thickness_bp, border_rgb_str,
                        line_x_bp, ty_bp, line_x_bp, ty_bp - th_sp * sp_to_bp)
                    p_head = utils.insert_pdf_literal(p_head, literal)
                end
            end

            -- Right edge of table region (low col = rightmost in RTL layout)
            -- Draw if table extends to page right boundary (col == 0)
            -- or if the column to the right is non-table or banxin
            local right_col = table_start_col  -- lowest col in table
            if right_col >= 0 then
                local need_right = (right_col == 0) or
                    (right_col - 1 >= 0 and (not (right_col - 1 >= table_start_col and right_col - 1 < table_end_col) or banxin_cols[right_col - 1]))
                if need_right then
                    local rtl_col = total_cols - 1 - right_col
                    local rx = text_position.get_column_x(rtl_col, col_geom)
                    local rw = text_position.get_column_width(right_col, col_geom)
                    local line_x_bp = (rx + rw + half_thickness + shift_x) * sp_to_bp
                    local literal = string.format("q %.2f w %s RG %.4f %.4f m %.4f %.4f l S Q",
                        b_thickness_bp, border_rgb_str,
                        line_x_bp, ty_bp, line_x_bp, ty_bp - th_sp * sp_to_bp)
                    p_head = utils.insert_pdf_literal(p_head, literal)
                end
            end
        end

        -- Restore horizontal edges at content area boundaries where column border
        -- rectangles are absent (skipped bands). Use full-width horizontal lines
        -- (same style as band dividers) so they are visually consistent.
        -- Build horizontal line segments that skip banxin columns.
        local h_segments = {}
        local interval = col_geom.interval or 0
        local banxin_width_val = col_geom.banxin_width or 0
        if interval > 0 and banxin_width_val > 0 and total_cols > 0 then
            local seg_start = nil
            for rtl_col = 0, total_cols - 1 do
                local col = total_cols - 1 - rtl_col
                local is_banxin = (col % (interval + 1)) == interval
                local col_x = text_position.get_column_x(rtl_col, col_geom)
                local col_w = text_position.get_column_width(col, col_geom)
                if is_banxin then
                    if seg_start ~= nil then
                        h_segments[#h_segments + 1] = { seg_start, col_x }
                        seg_start = nil
                    end
                else
                    if seg_start == nil then seg_start = col_x end
                end
                if rtl_col == total_cols - 1 and seg_start ~= nil then
                    h_segments[#h_segments + 1] = { seg_start, col_x + col_w }
                end
            end
        else
            local iw = content_dim_h and params.content_dim_w
            if not iw then
                -- Approximate: total_cols * grid_width
                iw = total_cols * (col_geom.grid_width or 0)
            end
            h_segments[1] = { 0, iw }
        end

        -- Draw full-width horizontal lines at boundaries of skipped bands
        local need_top_edge = (#skips > 0 and skips[1][1] == 0)
        local need_bottom_edge = (#skips > 0 and skips[#skips][2] >= content_dim_h)

        if need_top_edge then
            local ey = -(half_thickness + outer_shift) * sp_to_bp
            for _, seg in ipairs(h_segments) do
                local left_x_bp = (half_thickness + shift_x + seg[1]) * sp_to_bp
                local right_x_bp = (half_thickness + shift_x + seg[2]) * sp_to_bp
                local literal = string.format("q %.2f w %s RG %.4f %.4f m %.4f %.4f l S Q",
                    b_thickness_bp, border_rgb_str,
                    left_x_bp, ey, right_x_bp, ey)
                p_head = utils.insert_pdf_literal(p_head, literal)
            end
        end

        if need_bottom_edge then
            local ey = -(half_thickness + outer_shift + content_dim_h) * sp_to_bp
            for _, seg in ipairs(h_segments) do
                local left_x_bp = (half_thickness + shift_x + seg[1]) * sp_to_bp
                local right_x_bp = (half_thickness + shift_x + seg[2]) * sp_to_bp
                local literal = string.format("q %.2f w %s RG %.4f %.4f m %.4f %.4f l S Q",
                    b_thickness_bp, border_rgb_str,
                    left_x_bp, ey, right_x_bp, ey)
                p_head = utils.insert_pdf_literal(p_head, literal)
            end
        end

        return p_head
    end

    for col = 0, total_cols - 1 do
        -- Skip banxin columns (they are drawn separately by banxin module)
        if not banxin_cols[col] then
            local rtl_col = total_cols - 1 - col
            local tx_bp = (text_position.get_column_x(rtl_col, col_geom) + half_thickness + shift_x) * sp_to_bp
            local ty_bp = -(half_thickness + outer_shift) * sp_to_bp
            local tw_bp = text_position.get_column_width(col, col_geom) * sp_to_bp
            local th_bp = -content_dim_h * sp_to_bp

            -- Taitou raised border: extend column upward for negative y_sp columns
            local min_y = col_min_y_sp[col]
            if min_y and min_y < 0 then
                local raised_sp = -min_y
                ty_bp = ty_bp + raised_sp * sp_to_bp
                th_bp = th_bp - raised_sp * sp_to_bp
            end

            -- Draw column border
            local literal = utils.create_border_literal(b_thickness_bp, border_rgb_str, tx_bp, ty_bp, tw_bp, th_bp)
            p_head = utils.insert_pdf_literal(p_head, literal)
        end
    end

    return p_head
end

-- ============================================================================
-- Content Frame Drawing (正文区域内边框)
-- ============================================================================

--- 绘制正文区域的内边框（整体矩形框，不含列间分隔线）
-- 当 column_border=false 但 border=true 时调用，保留正文区域的外框
-- @param p_head (node) 节点列表头部（直接引用）
-- @param params (table) 参数表:
--   - total_cols: 总列数
--   - grid_width: 每列的宽度 (sp)
--   - col_geom: 列几何参数
--   - content_dim_h: 列边框高度 (sp)
--   - border_thickness: 边框厚度 (sp)
--   - shift_x: 水平偏移 (sp)
--   - outer_shift: 外边框偏移 (sp)
--   - border_rgb_str: RGB 颜色字符串
--   - banxin_cols: 要跳过的列索引集合（版心列）
-- @return (node) 更新后的头部
local function draw_content_frame(p_head, params)
    local sp_to_bp = utils.sp_to_bp
    local total_cols = params.total_cols
    local border_thickness = params.border_thickness
    local shift_x = params.shift_x
    local outer_shift = params.outer_shift
    local border_rgb_str = params.border_rgb_str
    local banxin_cols = params.banxin_cols or {}
    local col_geom = params.col_geom or {
        grid_width = params.grid_width or 0,
        banxin_width = params.banxin_width or 0,
        interval = params.interval or 0,
    }
    local content_dim_h = params.content_dim_h

    local b_thickness_bp = border_thickness * sp_to_bp
    local half_thickness = math.floor(border_thickness / 2)

    -- only_cols: if set, only include these columns (for partial silk suppression)
    local only_cols = params.only_cols

    -- Build contiguous segments, splitting at banxin columns
    -- Each segment is a range of adjacent non-banxin columns
    local segments = {}
    local seg_start_rtl = nil
    local seg_end_rtl = nil
    for rtl_col = 0, total_cols - 1 do
        local col = total_cols - 1 - rtl_col
        local skip = banxin_cols[col]
        if only_cols then
            skip = skip or (not only_cols[col])
        end
        if skip then
            -- End current segment
            if seg_start_rtl ~= nil then
                segments[#segments + 1] = { seg_start_rtl, seg_end_rtl }
                seg_start_rtl = nil
                seg_end_rtl = nil
            end
        else
            if seg_start_rtl == nil then
                seg_start_rtl = rtl_col
            end
            seg_end_rtl = rtl_col
        end
    end
    if seg_start_rtl ~= nil then
        segments[#segments + 1] = { seg_start_rtl, seg_end_rtl }
    end

    -- Draw one rectangle for each contiguous segment
    for _, seg in ipairs(segments) do
        local start_rtl = seg[1]
        local end_rtl = seg[2]
        -- X position: leftmost column's X
        local x_start = text_position.get_column_x(start_rtl, col_geom)
        -- Width: from leftmost to rightmost column's right edge
        local end_col = total_cols - 1 - end_rtl
        local x_end = text_position.get_column_x(end_rtl, col_geom)
        local w_end = text_position.get_column_width(end_col, col_geom)
        local total_width = (x_end + w_end) - x_start

        local tx_bp = (x_start + half_thickness + shift_x) * sp_to_bp
        local ty_bp = -(half_thickness + outer_shift) * sp_to_bp
        local tw_bp = total_width * sp_to_bp
        local th_bp = -content_dim_h * sp_to_bp

        local literal = utils.create_border_literal(b_thickness_bp, border_rgb_str, tx_bp, ty_bp, tw_bp, th_bp)
        p_head = utils.insert_pdf_literal(p_head, literal)
    end

    return p_head
end

-- ============================================================================
-- Band Border Drawing (分栏边框)
-- ============================================================================

--- 绘制分栏边框（栏间水平分隔线，在版心列处断开）
-- @param p_head (node) 节点列表头部（直接引用）
-- @param params (table) 参数表:
--   - n_bands: 栏数
--   - band_heights_sp: band_heights_sp[band_idx] = height_sp
--   - band_y_offsets_sp: band_y_offsets_sp[band_idx] = y_offset_sp
--   - inner_width: 内容区域宽度 (sp)
--   - border_thickness: 边框厚度 (sp)
--   - border_rgb_str: RGB 颜色字符串
--   - shift_x: 水平偏移 (sp)
--   - outer_shift: 外边框偏移 (sp)
--   - band_gap_sp: 栏间距 (sp)
--   - col_geom: 列几何参数 { grid_width, banxin_width, interval }
--   - total_cols: 总列数
-- @return (node) 更新后的头部
local function draw_band_borders(p_head, params)
    local sp_to_bp = utils.sp_to_bp
    local n_bands = params.n_bands
    local band_heights_sp = params.band_heights_sp
    local band_y_offsets_sp = params.band_y_offsets_sp
    local inner_width = params.inner_width
    local border_thickness = params.border_thickness
    local border_rgb_str = params.border_rgb_str
    local shift_x = params.shift_x or 0
    local outer_shift = params.outer_shift or 0

    local b_thickness_bp = border_thickness * sp_to_bp
    local half_thickness = math.floor(border_thickness / 2)

    local col_geom = params.col_geom or {}
    local interval = col_geom.interval or 0
    local banxin_width = col_geom.banxin_width or 0
    local grid_width = col_geom.grid_width or 0
    local total_cols = params.total_cols or 0

    -- Optional column range for inline table borders
    local range_start = params.col_range_start or 0
    local range_end = params.col_range_end or (total_cols - 1)

    -- Build list of horizontal line segments, skipping banxin columns.
    -- Segments are pairs of {left_x_sp, right_x_sp} in page coordinates.
    local segments = {}
    if interval > 0 and banxin_width > 0 and total_cols > 0 then
        -- There are banxin columns: split the line at each banxin column
        local seg_start = nil
        for rtl_col = range_start, range_end do
            local col = total_cols - 1 - rtl_col
            local is_banxin = (col % (interval + 1)) == interval
            local col_x = text_position.get_column_x(rtl_col, col_geom)
            local col_w = text_position.get_column_width(col, col_geom)
            if is_banxin then
                -- End current segment before this banxin column
                if seg_start ~= nil then
                    segments[#segments + 1] = { seg_start, col_x }
                    seg_start = nil
                end
            else
                if seg_start == nil then
                    seg_start = col_x
                end
            end
            -- If last column in range, close segment
            if rtl_col == range_end and seg_start ~= nil then
                segments[#segments + 1] = { seg_start, col_x + col_w }
            end
        end
    else
        -- No banxin: single segment across full width
        segments[1] = { 0, inner_width - border_thickness }
    end

    -- Draw horizontal divider lines between bands (not top/bottom/left/right outer edges).
    -- The outer frame is drawn by draw_column_borders + draw_outer_border.
    -- column_padding only affects text Y-offset, not band divider positions.
    for band = 0, n_bands - 2 do
        local band_y = band_y_offsets_sp[band] or 0
        local band_h = band_heights_sp[band] or 0
        local divider_y = band_y + band_h
        -- Content starts at outer_shift + border_thickness from page box origin
        local horz_y_bp = -(outer_shift + border_thickness + divider_y) * sp_to_bp

        for _, seg in ipairs(segments) do
            local left_x_bp = (half_thickness + shift_x + seg[1]) * sp_to_bp
            local right_x_bp = (half_thickness + shift_x + seg[2]) * sp_to_bp
            local line = string.format(
                "q %.2f w %s RG %.4f %.4f m %.4f %.4f l S Q",
                b_thickness_bp, border_rgb_str,
                left_x_bp, horz_y_bp, right_x_bp, horz_y_bp)
            p_head = utils.insert_pdf_literal(p_head, line)
        end
    end

    return p_head
end

-- ============================================================================
-- Outer Border Drawing
-- ============================================================================

--- 在整个内容区域外围绘制外边框
-- 当有抬头列（负行号）时，绘制阶梯状外边框以包裹突出文字
-- @param p_head (node) 节点列表头部（直接引用）
-- @param params (table) 参数表:
--   - inner_width: 内部内容宽度 (sp)
--   - inner_height: 内部内容高度 (sp)
--   - outer_border_thickness: 外边框厚度 (sp)
--   - outer_border_sep: 内外边框间距 (sp)
--   - border_rgb_str: 归一化的 RGB 颜色字符串
--   - col_min_y_sp: (optional) 每列最小行号表
--   - total_cols: (optional) 总列数
--   - grid_width: (optional) 格子宽度 (sp)
--   - grid_height: (optional) 格子高度 (sp)
--   - half_thickness: (optional) 列边框半厚度 (sp)
--   - shift_x: (optional) 水平偏移 (sp)
-- @return (node) 更新后的头部
local function draw_outer_border(p_head, params)
    local sp_to_bp = utils.sp_to_bp
    local inner_width = params.inner_width
    local inner_height = params.inner_height
    local ob_thickness_val = params.outer_border_thickness
    local ob_sep_val = params.outer_border_sep
    local border_rgb_str = params.border_rgb_str
    local col_min_y_sp = params.col_min_y_sp or {}

    local ob_thickness_bp = ob_thickness_val * sp_to_bp

    local tx_bp = (ob_thickness_bp / 2)
    local ty_bp = -(ob_thickness_bp / 2)
    local tw_bp = (inner_width + ob_sep_val * 2 + ob_thickness_val) * sp_to_bp
    local th_bp = -(inner_height + ob_sep_val * 2 + ob_thickness_val) * sp_to_bp

    -- Check if any taitou columns exist
    local has_taitou = false
    for _, v in pairs(col_min_y_sp) do
        if v and v < 0 then has_taitou = true; break end
    end

    if not has_taitou then
        -- Simple rectangle (no taitou)
        local literal = utils.create_border_literal(ob_thickness_bp, border_rgb_str, tx_bp, ty_bp, tw_bp, th_bp)
        p_head = utils.insert_pdf_literal(p_head, literal)
        return p_head
    end

    -- Stepped outer border path for taitou columns
    local total_cols = params.total_cols
    local grid_width = params.grid_width
    local grid_height = params.grid_height
    local half_thickness = params.half_thickness or 0
    local shift_x = params.shift_x or 0
    local gh_bp = grid_height * sp_to_bp

    -- Bottom and right edges of outer border
    local by_bp = ty_bp + th_bp  -- bottom Y
    local rx_bp = tx_bp + tw_bp  -- right X
    local ob_sep_bp = ob_sep_val * sp_to_bp

    -- Per-column top Y (visual left to right = rtl_col 0 to n-1)
    local col_tops = {}
    for rtl_col = 0, total_cols - 1 do
        local col = total_cols - 1 - rtl_col
        local min_y = col_min_y_sp[col]
        if min_y and min_y < 0 then
            col_tops[rtl_col] = ty_bp + (-min_y) * sp_to_bp
        else
            col_tops[rtl_col] = ty_bp
        end
    end

    -- Column boundary X positions (supports mixed column widths)
    local col_geom = params.col_geom or {
        grid_width = grid_width,
        banxin_width = params.banxin_width or 0,
        interval = params.interval or 0,
    }
    local function cb(b)
        return (text_position.get_column_x(b, col_geom) + half_thickness + shift_x) * sp_to_bp
    end

    -- Construct path: counter-clockwise from bottom-left
    local parts = {}
    -- Start at bottom-left
    parts[#parts + 1] = string.format("%.4f %.4f m", tx_bp, by_bp)
    -- Left edge up to leftmost column's top
    parts[#parts + 1] = string.format("%.4f %.4f l", tx_bp, col_tops[0])

    -- Step offset: ob_sep measured from inner edges of both borders (compensate stroke centering)
    local half_inner_bp = half_thickness * sp_to_bp
    local step_offset = ob_sep_bp + ob_thickness_bp / 2 + half_inner_bp

    -- Top edge with steps (left to right)
    for b = 1, total_cols do
        local rtl_col = b - 1  -- column to the left of boundary b
        local cur_top = col_tops[rtl_col]

        if b < total_cols then
            local next_top = col_tops[b]
            local has_step = math.abs(next_top - cur_top) > 0.001

            if has_step then
                -- Shift step vertical to maintain ob_sep gap from inner column border edge
                local step_x
                if cur_top > next_top then
                    -- Step DOWN (left taller): shift RIGHT past inner boundary
                    step_x = cb(b) + step_offset
                else
                    -- Step UP (right taller): shift LEFT before inner boundary
                    step_x = cb(b) - step_offset
                end
                -- Horizontal to step position, then vertical step
                parts[#parts + 1] = string.format("%.4f %.4f l", step_x, cur_top)
                parts[#parts + 1] = string.format("%.4f %.4f l", step_x, next_top)
            else
                -- No step: horizontal to column boundary
                parts[#parts + 1] = string.format("%.4f %.4f l", cb(b), cur_top)
            end
        else
            -- Last column: horizontal to right edge
            parts[#parts + 1] = string.format("%.4f %.4f l", rx_bp, cur_top)
        end
    end

    -- Right edge down to bottom-right
    parts[#parts + 1] = string.format("%.4f %.4f l", rx_bp, by_bp)
    -- Close and stroke
    parts[#parts + 1] = "h S"

    local path_str = table.concat(parts, " ")
    local literal = string.format("q %.2f w %s RG %s Q", ob_thickness_bp, border_rgb_str, path_str)
    p_head = utils.insert_pdf_literal(p_head, literal)

    return p_head
end

-- ============================================================================
-- High-level Border Rendering
-- ============================================================================

--- 渲染所有边框（列边框、外边框、装饰边框、背景）
-- @param p_head (node) 节点列表头部
-- @param params (table) 参数表:
--   -- Grid and dimensions
--   - p_total_cols: 页面总列数
--   - actual_cols: 实际内容列数
--   - actual_height_sp: 实际内容高度 (sp)
--   - grid_width: 每列宽度 (sp)
--   - grid_height: 每行高度 (sp)
--   - content_height_sp: 内容区高度 (sp), 从三层架构获取
--   -- Border params
--   - border_thickness: 边框厚度 (sp)
--   - c_padding_top: 顶部内边距 (sp)
--   - c_padding_bottom: 底部内边距 (sp)
--   - shift_x: 水平偏移 (sp)
--   - outer_shift: 外边框偏移 (sp)
--   - b_rgb_str: 边框颜色字符串
--   - ob_thickness_val: 外边框厚度 (sp)
--   - ob_sep_val: 外边框间距 (sp)
--   -- Flags
--   - draw_border: 是否绘制列边框
--   - draw_outer_border: 是否绘制外边框
--   - is_textbox: 是否为文本框模式
--   - reserved_cols: 要跳过的列索引集合
--   -- Visual params
--   - border_shape: 装饰边框形状 ("none", "rect", "octagon", "circle")
--   - border_color_str: 装饰边框颜色
--   - border_width: 装饰边框宽度 (sp)
--   - border_margin: 装饰边框外边距 (sp)
--   - background_rgb_str: 背景颜色字符串
--   - text_rgb_str: 文字颜色字符串
-- @return (node) 更新后的头部
local function render_borders(p_head, params)
    local p_total_cols = params.p_total_cols
    local actual_cols = params.actual_cols
    local grid_width = params.grid_width
    local grid_height = params.grid_height
    local border_thickness = params.border_thickness
    local c_padding_top = params.c_padding_top
    local c_padding_bottom = params.c_padding_bottom
    local is_textbox = params.is_textbox

    local banxin_width = params.banxin_width or 0
    local interval = params.interval or 0
    local col_geom = params.col_geom or {
        grid_width = grid_width,
        banxin_width = banxin_width,
        interval = interval,
    }

    -- Calculate content dimensions (shared logic in content module)
    local content_mod = package.loaded['core.luatex-cn-core-content'] or
        require('core.luatex-cn-core-content')
    local content_dim_w, content_dim_h, inner_width, inner_height = content_mod.calculate_content_dimensions({
        is_textbox = is_textbox,
        actual_cols = actual_cols,
        actual_height_sp = params.actual_height_sp,
        grid_width = grid_width,
        grid_height = grid_height,
        content_height_sp = params.content_height_sp,
        c_padding_top = c_padding_top,
        c_padding_bottom = c_padding_bottom,
        p_total_cols = p_total_cols,
        border_thickness = border_thickness,
        banxin_width = banxin_width,
        interval = interval,
    })

    -- Granular border control: column_border and band_border override draw_border
    local draw_col_border = params.draw_column_border
    if draw_col_border == nil then draw_col_border = params.draw_border end
    local draw_bnd_border = params.draw_band_border
    if draw_bnd_border == nil then draw_bnd_border = params.draw_border end

    -- 1. Draw column borders (inner borders between columns)
    -- Table-level column_border applies to all bands as default;
    -- per-band column_border (via \栏格式) overrides the table default.
    --
    -- Multi-table per page: page_table_bands is an array of per-table band info.
    -- For backwards compatibility, accept a single object and wrap it.
    local ptbs = params.page_table_bands
    if ptbs and ptbs.n_bands and not ptbs[1] then
        ptbs = { ptbs }
    end
    ptbs = ptbs or {}

    -- Pre-compute which tables need segmented column borders (band-aware draw),
    -- and which page columns they own. Tables without band_column_borders or
    -- column_border falsy are drawn full-height in the non-table pass.
    local segmented_tables = {}
    local table_owned_cols = {}
    for _, ptb in ipairs(ptbs) do
        if ptb.n_bands and ptb.n_bands > 1 then
            local effective_borders = {}
            if ptb.column_border ~= nil then
                for band = 0, ptb.n_bands - 1 do
                    effective_borders[band] = ptb.column_border
                end
            end
            if ptb.band_column_borders then
                for band_idx, val in pairs(ptb.band_column_borders) do
                    effective_borders[band_idx] = val
                end
            end
            if next(effective_borders) then
                segmented_tables[#segmented_tables + 1] = {
                    ptb = ptb,
                    effective_borders = effective_borders,
                }
                local start_col = ptb.table_start_col or 0
                local end_col = start_col + (ptb.actual_band_cols or 0) - 1
                for col = start_col, end_col do
                    table_owned_cols[col] = true
                end
            end
        end
    end

    if draw_col_border and p_total_cols > 0 then
        -- Base skip set: banxin + no_silk regions are always skipped
        local base_skip = {}
        for k, v in pairs(params.reserved_cols or {}) do base_skip[k] = v end
        local no_silk_cols = params.no_silk_cols
        if no_silk_cols then
            for k, v in pairs(no_silk_cols) do base_skip[k] = v end
        end

        -- Step A: full-height column borders for all non-table columns.
        -- Skip columns owned by segmented tables (drawn in Step B).
        local non_table_skip = {}
        for k, v in pairs(base_skip) do non_table_skip[k] = v end
        for k in pairs(table_owned_cols) do non_table_skip[k] = true end
        p_head = draw_column_borders(p_head, {
            total_cols = p_total_cols,
            grid_width = grid_width,
            col_geom = col_geom,
            content_dim_h = content_dim_h,
            border_thickness = border_thickness,
            shift_x = params.shift_x,
            outer_shift = params.outer_shift,
            border_rgb_str = params.b_rgb_str,
            banxin_cols = non_table_skip,
            col_min_y_sp = params.col_min_y_sp,
            c_padding_top = params.c_padding_top,
        })

        -- Step B: per-table segmented column borders restricted to that
        -- table's column range (skip everything outside).
        for _, t in ipairs(segmented_tables) do
            local ptb = t.ptb
            local start_col = ptb.table_start_col or 0
            local end_col = start_col + (ptb.actual_band_cols or 0) - 1
            local table_skip = {}
            for k, v in pairs(base_skip) do table_skip[k] = v end
            for col = 0, p_total_cols - 1 do
                if col < start_col or col > end_col then
                    table_skip[col] = true
                end
            end
            p_head = draw_column_borders(p_head, {
                total_cols = p_total_cols,
                grid_width = grid_width,
                col_geom = col_geom,
                content_dim_h = content_dim_h,
                border_thickness = border_thickness,
                shift_x = params.shift_x,
                outer_shift = params.outer_shift,
                border_rgb_str = params.b_rgb_str,
                banxin_cols = table_skip,
                col_min_y_sp = params.col_min_y_sp,
                c_padding_top = params.c_padding_top,
                band_column_borders = t.effective_borders,
                n_bands = ptb.n_bands,
                band_heights_sp = ptb.band_heights_sp,
                band_y_offsets_sp = ptb.band_y_offsets_sp,
                table_start_col = ptb.table_start_col,
                actual_band_cols = ptb.actual_band_cols,
                cell_column_borders = ptb.cell_column_borders,
                col_groups = ptb.col_groups,
            })
        end

        -- Draw content frame for the no-silk region
        if no_silk_cols then
            p_head = draw_content_frame(p_head, {
                total_cols = p_total_cols,
                grid_width = grid_width,
                col_geom = col_geom,
                content_dim_h = content_dim_h,
                border_thickness = border_thickness,
                shift_x = params.shift_x,
                outer_shift = params.outer_shift,
                border_rgb_str = params.b_rgb_str,
                banxin_cols = params.reserved_cols,
                only_cols = no_silk_cols,
            })
        end
    elseif not draw_col_border and params.draw_border and p_total_cols > 0 then
        -- column_border=false but border=true: draw content frame (内边框) without 丝栏
        p_head = draw_content_frame(p_head, {
            total_cols = p_total_cols,
            grid_width = grid_width,
            col_geom = col_geom,
            content_dim_h = content_dim_h,
            border_thickness = border_thickness,
            shift_x = params.shift_x,
            outer_shift = params.outer_shift,
            border_rgb_str = params.b_rgb_str,
            banxin_cols = params.reserved_cols,
        })
    end

    -- 1b. Band divider lines (horizontal lines between bands)
    if draw_bnd_border and params.n_bands and params.n_bands > 1 then
        p_head = draw_band_borders(p_head, {
            n_bands = params.n_bands,
            band_heights_sp = params.band_heights_sp,
            band_y_offsets_sp = params.band_y_offsets_sp,
            inner_width = inner_width,
            border_thickness = border_thickness,
            border_rgb_str = params.b_rgb_str,
            shift_x = params.shift_x,
            outer_shift = params.outer_shift,
            c_padding_top = params.c_padding_top,
            band_gap_sp = params.band_gap_sp,
            col_geom = col_geom,
            total_cols = p_total_cols,
        })
    end

    -- 1c. Inline table band divider lines (per-page table band info)
    -- Iterate each table on the page; table-level band_border overrides
    -- content-level draw_bnd_border per table.
    for _, ptb in ipairs(ptbs) do
        local table_band_border = draw_bnd_border
        if ptb.band_border ~= nil then table_band_border = ptb.band_border end
        if table_band_border and ptb.n_bands and ptb.n_bands > 1 then
            -- table_start_col is a logical column index (0 = rightmost).
            -- draw_band_borders uses RTL column indices (0 = leftmost).
            -- Convert logical range to RTL range.
            local table_start_logical = ptb.table_start_col or 0
            local table_cols = ptb.actual_band_cols or p_total_cols

            -- Find end logical column by stepping through content columns,
            -- skipping banxin (reserved) columns in between.
            local end_logical = table_start_logical
            local placed = 0
            local iv = col_geom.interval or 0
            while placed < table_cols do
                local is_bx = (iv > 0) and ((end_logical % (iv + 1)) == iv)
                if not is_bx then
                    placed = placed + 1
                end
                if placed < table_cols then
                    end_logical = end_logical + 1
                end
            end

            -- Convert logical columns to RTL columns
            local range_start_rtl = p_total_cols - 1 - end_logical
            local range_end_rtl = p_total_cols - 1 - table_start_logical
            if range_start_rtl < 0 then range_start_rtl = 0 end
            if range_end_rtl >= p_total_cols then range_end_rtl = p_total_cols - 1 end

            p_head = draw_band_borders(p_head, {
                n_bands = ptb.n_bands,
                band_heights_sp = ptb.band_heights_sp,
                band_y_offsets_sp = ptb.band_y_offsets_sp,
                inner_width = inner_width,
                border_thickness = border_thickness,
                border_rgb_str = params.b_rgb_str,
                shift_x = params.shift_x,
                outer_shift = params.outer_shift,
                c_padding_top = params.c_padding_top,
                band_gap_sp = ptb.band_gap_sp,
                col_geom = col_geom,
                total_cols = p_total_cols,
                col_range_start = range_start_rtl,
                col_range_end = range_end_rtl,
            })
        end
    end

    -- 2. Draw outer border
    if params.draw_outer_border_flag and p_total_cols > 0 then
        p_head = draw_outer_border(p_head, {
            inner_width = inner_width,
            inner_height = inner_height,
            outer_border_thickness = params.ob_thickness_val,
            outer_border_sep = params.ob_sep_val,
            border_rgb_str = params.b_rgb_str,
            -- Taitou stepped border params
            col_min_y_sp = params.col_min_y_sp,
            total_cols = p_total_cols,
            grid_width = grid_width,
            grid_height = grid_height,
            col_geom = col_geom,
            half_thickness = math.floor(border_thickness / 2),
            shift_x = params.shift_x,
        })
    end

    -- 3. Draw background (shaped or rectangular)
    local border_shape = params.border_shape
    local shape_width = actual_cols * grid_width
    local shape_height = params.actual_height_sp

    if border_shape == "octagon" and params.background_rgb_str then
        -- Octagon-shaped background
        local border_m = params.border_margin or 0
        p_head = drawing.draw_octagon_fill(p_head, {
            x = -border_m,
            y = border_m,
            width = shape_width + 2 * border_m,
            height = shape_height + 2 * border_m,
            color_str = params.background_rgb_str,
        })
    elseif border_shape == "circle" and params.background_rgb_str then
        -- Circle-shaped background
        local border_m = params.border_margin or 0
        p_head = drawing.draw_circle_fill(p_head, {
            cx = shape_width / 2,
            cy = -shape_height / 2,
            radius = math.max(shape_width, shape_height) / 2 + border_m,
            color_str = params.background_rgb_str,
        })
    else
        -- Rectangular background (default)
        p_head = page_mod.draw_background(p_head, {
            bg_rgb_str = params.background_rgb_str,
            inner_width = inner_width,
            inner_height = inner_height,
            outer_shift = params.outer_shift,
            is_textbox = is_textbox,
        })
    end

    -- 4. Draw border frame decoration
    -- border_offset: extra outward expansion for frame only (not background fill),
    -- used when inverted background needs visible gap between fill and stroke.
    if border_shape and border_shape ~= "none" then
        local border_color = params.border_color_str or params.b_rgb_str or "0 0 0"
        local border_w = params.border_width or (65536 * 0.4)
        local border_m = params.border_margin or 0
        local border_m_x = params.border_margin_x or border_m
        local border_m_y = params.border_margin_y or border_m
        local b_off = params.border_offset or 0
        local frame_m_x = border_m_x + b_off
        local frame_m_y = border_m_y + b_off

        if border_shape == "rect" then
            p_head = drawing.draw_rect_frame(p_head, {
                x = -frame_m_x,
                y = frame_m_y,
                width = shape_width + 2 * frame_m_x,
                height = shape_height + 2 * frame_m_y,
                line_width = border_w,
                color_str = border_color,
            })
        elseif border_shape == "octagon" then
            p_head = drawing.draw_octagon_frame(p_head, {
                x = -(border_m + b_off),
                y = border_m + b_off,
                width = shape_width + 2 * (border_m + b_off),
                height = shape_height + 2 * (border_m + b_off),
                line_width = border_w,
                color_str = border_color,
            })
        elseif border_shape == "circle" then
            p_head = drawing.draw_circle_frame(p_head, {
                cx = shape_width / 2,
                cy = -shape_height / 2,
                radius = math.max(shape_width, shape_height) / 2 + border_m + b_off,
                line_width = border_w,
                color_str = border_color,
            })
        end
    end

    -- 5. Textbox outer border (drawn around the decorative shape frame)
    -- This is separate from body text outer border (section 2) to avoid coordinate conflicts
    if params.textbox_outer_border and border_shape and border_shape ~= "none" then
        local border_w = params.border_width or (65536 * 0.4)
        local border_m = params.border_margin or 0
        local ob_t = params.textbox_ob_thickness or (65536 * 1)
        local ob_s = params.textbox_ob_sep or (65536 * 2)
        local border_color = params.border_color_str or params.b_rgb_str or "0 0 0"

        -- Outer border wraps the decorative shape with ob_sep gap
        -- Gap measured from outer edge of inner stroke to inner edge of outer stroke
        local gap = border_w / 2 + ob_s + ob_t / 2
        local outer_x = -(border_m + gap)
        local outer_y = border_m + gap
        local outer_w = shape_width + 2 * (border_m + gap)
        local outer_h = shape_height + 2 * (border_m + gap)

        -- 5a. Fill gap between inner and outer border with background color
        -- so lower layers don't show through the gap
        if params.background_rgb_str then
            p_head = drawing.draw_rect_fill(p_head, {
                x = outer_x + ob_t / 2,
                y = outer_y - ob_t / 2,
                width = outer_w - ob_t,
                height = outer_h - ob_t,
                color_str = params.background_rgb_str,
            })
        end

        -- 5b. Outer border stroke
        p_head = drawing.draw_rect_frame(p_head, {
            x = outer_x,
            y = outer_y,
            width = outer_w,
            height = outer_h,
            line_width = ob_t,
            color_str = border_color,
        })
    end

    return p_head
end

-- ============================================================================
-- Module Exports
-- ============================================================================

local render_border = {
    draw_column_borders = draw_column_borders,
    draw_content_frame = draw_content_frame,
    draw_band_borders = draw_band_borders,
    draw_outer_border = draw_outer_border,
    render_borders = render_borders,
}

-- Register module
package.loaded['core.luatex-cn-core-render-border'] = render_border

return render_border
