-- 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_sidenote.lua - 侧批（Side Annotation）处理模块
-- ============================================================================

local constants = package.loaded['core.luatex-cn-constants'] or
    require('core.luatex-cn-constants')
local utils = package.loaded['util.luatex-cn-utils'] or
    require('util.luatex-cn-utils')
local debug = package.loaded['debug.luatex-cn-debug'] or
    require('debug.luatex-cn-debug')
local style_registry = package.loaded['util.luatex-cn-style-registry'] or
    require('util.luatex-cn-style-registry')
local judou = package.loaded['guji.luatex-cn-guji-judou'] or
    require('guji.luatex-cn-guji-judou')
local punct_mod = package.loaded['core.luatex-cn-core-punct'] or
    require('core.luatex-cn-core-punct')
local setting_stack = package.loaded['util.luatex-cn-setting-stack'] or
    require('util.luatex-cn-setting-stack')
local text_position = package.loaded['core.luatex-cn-render-position'] or
    require('core.luatex-cn-render-position')
local helpers = package.loaded['core.luatex-cn-layout-grid-helpers'] or
    require('core.luatex-cn-layout-grid-helpers')
local D = node.direct

local dbg = debug.get_debugger('sidenote')

-- Safely resolve dimension value that may be a table (em unit) or number
-- Forward-declared here so render() can use it (defined before Internal Helpers section)
local function safe_resolve(val, font_size_sp)
    if type(val) == "table" and val.unit == "em" then
        return math.floor((val.value or 0) * (font_size_sp or 655360) + 0.5)
    end
    return tonumber(val) or 0
end

local sidenote = {}

-- Registry to hold sidenote content
sidenote.registry = {}
sidenote.registry_counter = 0

-- ============================================================================
-- Plugin Standard API
-- ============================================================================

--- Initialize Sidenote Plugin
-- @param params (table) Parameters from TeX
-- @param engine_ctx (table) Shared engine context
-- @return (table|nil) Plugin context or nil if disabled
function sidenote.initialize(params, engine_ctx)
    -- Sidenotes are currently always active if the module is loaded,
    -- but we could add a 'sidenote_on' parameter check here later.
    return {
        map = {} -- This will store the results of calculate_sidenote_positions
    }
end

--- Plugin integration for layout stage
function sidenote.layout(list, layout_map, engine_ctx, context)
    if not context then return end

    local sidenote_map = sidenote.calculate_sidenote_positions(layout_map, {
        list = list,
        page_columns = engine_ctx.page_columns,
        line_limit = engine_ctx.line_limit,
        n_column = engine_ctx.n_column,
        banxin_on = engine_ctx.banxin_on,
        grid_height = engine_ctx.g_height
    })

    context.map = sidenote_map
end

--- Plugin integration for render stage
function sidenote.render(head, layout_map, params, context, engine_ctx, page_idx, p_total_cols)
    if not (context and context.map) then return head end

    local sidenote_for_page = {}
    for sid, sn_list in pairs(context.map) do
        for _, node_info in ipairs(sn_list) do
            if node_info.page == page_idx then
                table.insert(sidenote_for_page, node_info)
            end
        end
    end

    if #sidenote_for_page == 0 then return head end

    dbg.log(string.format("Rendering %d nodes on page %d", #sidenote_for_page, page_idx))

    local d_head = D.todirect(head)

    local render_page = package.loaded['core.luatex-cn-core-render-page'] or
        require('core.luatex-cn-core-render-page')

    local sidenote_x_offset = engine_ctx.g_width * 0.9

    -- Build sidenote sublist first (using original reverse iteration to maintain correct order)
    local sn_head = nil
    local sidenote_color = nil  -- Track color from first sidenote node's attribute
    local linemark_entries = {}  -- Collect linemark entries for batch rendering

    for i = #sidenote_for_page, 1, -1 do
        local item = sidenote_for_page[i]
        local curr = item.node
        D.setnext(curr, nil)

        -- Extract font_color from node's ATTR_STYLE_REG_ID (Phase 2: Style registry)
        if not sidenote_color then
            local style_id = D.get_attribute(curr, constants.ATTR_STYLE_REG_ID)
            if style_id then
                sidenote_color = style_registry.get_font_color(style_id)
            end
        end

        if not sn_head then
            sn_head = curr
        else
            sn_head = D.insert_before(sn_head, sn_head, curr)
        end

        local meta_xshift = safe_resolve(item.metadata and item.metadata.xshift, engine_ctx.g_height)
        local pos = {
            col = item.col,
            y_sp = item.y_sp,
            sidenote_offset = sidenote_x_offset,
            xshift = meta_xshift,
        }

        local id = D.getid(curr)
        if id == constants.GLYPH then
            local d = D.getfield(curr, "depth") or 0
            local h = D.getfield(curr, "height") or 0
            local w = D.getfield(curr, "width") or 0

            local rtl_col = p_total_cols - 1 - pos.col
            local boundary_x = text_position.get_column_x(rtl_col + 1, engine_ctx.col_geom)
                + engine_ctx.half_thickness + engine_ctx.shift_x
            local final_x = boundary_x - (w / 2) + (pos.xshift or 0)

            local char_total_height = h + d
            local cell_h = item.cell_height or engine_ctx.g_height

            local final_y = -pos.y_sp - (cell_h + char_total_height) / 2 + d -
                engine_ctx.shift_y

            D.setfield(curr, "xoffset", final_x)
            D.setfield(curr, "yoffset", final_y)

            -- Collect linemark entries (专名号/书名号)
            local lm_id = D.get_attribute(curr, constants.ATTR_LINE_MARK_ID)
            if lm_id and lm_id > 0 then
                linemark_entries[#linemark_entries + 1] =
                    helpers.create_linemark_entry({
                        group_id = lm_id,
                        col = item.col,
                        y_sp = pos.y_sp,
                        x_center_sp = boundary_x,
                        font_size = cell_h,
                    })
            end

            local k = D.new(constants.KERN)
            D.setfield(k, "kern", -w)
            D.insert_after(sn_head, curr, k)
        elseif id == constants.HLIST or id == constants.VLIST then
            sn_head = render_page._internal.handle_block_node(curr, sn_head, pos, engine_ctx)
        elseif id == constants.WHATSIT then
            -- Preserve whatsit nodes (including pdf_colorstack for color preservation)
            -- These nodes don't need position adjustment, just need to be kept in the list
            -- This is crucial for maintaining color across page boundaries
        else
            if id == constants.GLUE then
                D.setfield(curr, "width", 0)
                D.setfield(curr, "stretch", 0)
                D.setfield(curr, "shrink", 0)
            end
        end

        sn_head = render_page._internal.handle_debug_drawing(curr, sn_head, pos, engine_ctx)
    end

    -- Render linemark lines for sidenote characters (专名号/书名号)
    if #linemark_entries > 0 and sn_head then
        local linemark_mod = package.loaded['decorate.luatex-cn-linemark'] or
            require('decorate.luatex-cn-linemark')
        local lm_ctx = {
            grid_width = engine_ctx.g_width,
            grid_height = engine_ctx.g_height,
            p_total_cols = p_total_cols,
            shift_x = engine_ctx.shift_x,
            shift_y = engine_ctx.shift_y,
            half_thickness = engine_ctx.half_thickness,
            banxin_width = engine_ctx.banxin_width or 0,
            interval = engine_ctx.n_column or 0,
        }
        sn_head = linemark_mod.render_line_marks(sn_head, linemark_entries, lm_ctx)
    end

    -- Build background fill rectangles per cell (issue #93).
    -- When a sidenote has background_color set, fill the cell behind each glyph
    -- so the column borders (界行) under the sidenote are visually masked.
    -- Special value "page" / "inherit" / 继承 / 页面 inherits the page background
    -- color, which makes the sidenote blend into the page.
    if sn_head then
        local bg_literals = sidenote._build_bg_literals(sidenote_for_page,
            engine_ctx, p_total_cols)
        for i = #bg_literals, 1, -1 do
            sn_head = D.insert_before(sn_head, sn_head, bg_literals[i])
        end
    end

    -- Append sidenote sublist to end of main list (so sidenotes render on top of borders)
    if sn_head then
        -- ALWAYS wrap sidenote content with q/Q to prevent color leakage
        -- This is critical when sidenotes cross page boundaries, as embedded
        -- pdf_colorstack nodes (from TeX \color commands) may be split across pages
        local q_push = utils.create_pdf_literal("q")
        sn_head = D.insert_before(sn_head, sn_head, q_push)

        -- Set the sidenote color from style_registry. This is important for
        -- cross-page sidenotes where the second page's content may not have
        -- the colorstack push (which is on the first page).
        -- Also serves as a color reset to prevent leakage from unbalanced pops.
        local last_inserted = q_push
        if sidenote_color and sidenote_color ~= "" then
            local rgb_str = utils.normalize_rgb(sidenote_color)
            if rgb_str then
                local color_cmd = string.format("%s rg %s RG", rgb_str, rgb_str)
                local color_literal = utils.create_pdf_literal(color_cmd)
                D.insert_after(sn_head, q_push, color_literal)
                last_inserted = color_literal
            end
        else
            -- Fallback: reset to black if no color specified
            local color_reset = utils.create_pdf_literal("0 0 0 rg 0 0 0 RG")
            D.insert_after(sn_head, q_push, color_reset)
            last_inserted = color_reset
        end

        -- Find the tail and insert Q at the end
        local sn_tail = sn_head
        while D.getnext(sn_tail) do
            sn_tail = D.getnext(sn_tail)
        end
        local q_pop = utils.create_pdf_literal("Q")
        D.insert_after(sn_head, sn_tail, q_pop)

        if not d_head then
            d_head = sn_head
        else
            local d_tail = d_head
            while D.getnext(d_tail) do
                d_tail = D.getnext(d_tail)
            end
            D.setnext(d_tail, sn_head)
        end
    end

    return D.tonode(d_head)
end

-- ============================================================================
-- Internal Helpers
-- ============================================================================

local function serialize(t)
    if type(t) ~= "table" then return tostring(t) end
    local s = "{"
    for k, v in pairs(t) do
        s = s .. tostring(k) .. "=" .. tostring(v) .. ","
    end
    s = s .. "}"
    return s
end

local function create_gap_tracker()
    local gap_filled = {}
    return {
        get = function(p, c)
            if not gap_filled[p] then gap_filled[p] = {} end
            return gap_filled[p][c] or -1
        end,
        set = function(p, c, r)
            if not gap_filled[p] then gap_filled[p] = {} end
            gap_filled[p][c] = r
        end
    }
end

local function is_reserved_column(col, banxin_on, interval)
    if not banxin_on then return false end
    if interval <= 0 then return false end
    local hooks = package.loaded['core.luatex-cn-hooks'] or
        require('core.luatex-cn-hooks')
    return hooks.is_reserved_column(col, interval)
end

local function skip_to_valid_column(p, c, p_cols, banxin_on, interval)
    -- Only skip reserved columns (banxin), not the column right after banxin
    -- Sidenotes are offset to the right of the main text column, so they won't
    -- overlap with the banxin on the left
    while is_reserved_column(c, banxin_on, interval) or (c >= p_cols) do
        if c >= p_cols then
            c = 0
            p = p + 1
        else
            c = c + 1
        end
    end
    return p, c
end

local function extract_registry_content(registry_item)
    local content = nil
    local metadata = {}

    if type(registry_item) == "table" and registry_item.head then
        content = registry_item.head
        metadata = registry_item.metadata or {}
    else
        content = registry_item
    end
    return content, metadata
end

local function calculate_start_position(anchor_y_sp, metadata, main_grid_height)
    local yshift_grid = safe_resolve(metadata.yshift, main_grid_height) / main_grid_height
    local padding_top_grid = safe_resolve(metadata.padding_top, main_grid_height) / main_grid_height
    local anchor_row = anchor_y_sp / main_grid_height
    return math.max(anchor_row, padding_top_grid) + yshift_grid
end

-- Advance to the next valid (non-reserved) column on the current/next page,
-- aligning the row with the target column's body-text top boundary (taitou).
-- Only negative col_min_row values (taitou) affect alignment; positive
-- indent is ignored. Bumps past existing filled rows when present.
local function wrap_to_next_column(curr_p, curr_c, config)
    local next_p, next_c = curr_p, curr_c + 1
    next_p, next_c = skip_to_valid_column(next_p, next_c, config.p_cols, config.banxin_on, config.interval)

    local next_r = 0
    if config.col_min_row and config.col_min_row[next_p] then
        next_r = math.min(0, config.col_min_row[next_p][next_c] or 0)
    end

    local filled = config.tracker.get(next_p, next_c)
    if next_r <= filled and filled ~= -1 then
        next_r = filled + 0.1
    end

    return next_p, next_c, next_r
end

local function calculate_next_node_pos(curr_p, curr_c, curr_r, node_id, config)
    local next_p, next_c, next_r = curr_p, curr_c, curr_r

    -- Determine if this node consumes a row
    -- Note: GLUE nodes (spaces) should NOT consume rows in vertical typesetting
    if node_id == constants.GLYPH or node_id == constants.HLIST or
        node_id == constants.VLIST or node_id == constants.RULE then
        next_r = curr_r + config.step
    end

    if next_r + config.padding_bottom_grid >= config.line_limit then
        next_p, next_c, next_r = wrap_to_next_column(curr_p, curr_c, config)
    end

    return next_p, next_c, next_r
end

-- Scan layout_map to find the minimum row for each column on each page.
-- This captures taitou (negative row) information so wrapped sidenote content
-- can align with the body text's top boundary in the target column.
-- Returns: col_min_row[page][col] = min_row (can be negative)
local function build_col_min_row(layout_map, grid_height)
    local col_min_y = {}
    for _, pos in pairs(layout_map) do
        -- Only consider entries with valid page/col/y_sp, excluding placeholders
        if pos.page and pos.col and pos.y_sp and pos.mode ~= "placeholder" then
            local p, c = pos.page, pos.col
            if not col_min_y[p] then col_min_y[p] = {} end
            local cur = col_min_y[p][c]
            if not cur or pos.y_sp < cur then
                col_min_y[p][c] = pos.y_sp
            end
        end
    end
    -- Convert SP to row units
    local col_min_row = {}
    for p, cols in pairs(col_min_y) do
        col_min_row[p] = {}
        for c, y in pairs(cols) do
            col_min_row[p][c] = math.floor(y / grid_height)
        end
    end
    return col_min_row
end

local function place_individual_sidenote(sid, registry_item, last_node_pos, params, tracker)
    local content, metadata = extract_registry_content(registry_item)
    if not (content and sid and last_node_pos) then return nil end

    local p_cols = params.page_columns or 10
    local line_limit = params.line_limit or 20
    local main_grid_height = params.grid_height or (65536 * 20)
    local effective_gh = (metadata.grid_height and metadata.grid_height > 0)
        and metadata.grid_height or main_grid_height
    local step = 1
    if metadata.grid_height and metadata.grid_height > 0 then
        step = metadata.grid_height / main_grid_height
    end

    local config = {
        p_cols = p_cols,
        line_limit = line_limit,
        banxin_on = params.banxin_on,
        interval = params.n_column or 0,
        padding_top_grid = safe_resolve(metadata.padding_top, main_grid_height) / main_grid_height,
        padding_bottom_grid = safe_resolve(metadata.padding_bottom, main_grid_height) / main_grid_height,
        step = step,
        tracker = tracker,
        base_indent = last_node_pos.indent or 0,
        col_min_row = params.col_min_row,
    }

    local curr_p, curr_c = last_node_pos.page, last_node_pos.col
    local base_indent = last_node_pos.indent or 0
    local anchor_y_sp = last_node_pos.y_sp or 0
    dbg.log(string.format("Placing sid=%d at p=%d, c=%d, anchor_y_sp=%.0f, indent=%d",
        sid, curr_p, curr_c, anchor_y_sp, base_indent))

    curr_p, curr_c = skip_to_valid_column(curr_p, curr_c, p_cols, config.banxin_on, config.interval)

    local curr_r = calculate_start_position(anchor_y_sp, metadata, main_grid_height)
    local filled_r = tracker.get(curr_p, curr_c)
    -- Push down if the current row is at or below existing content. Skip when
    -- filled_r is the default (-1), so taitou (negative curr_r) is preserved.
    if curr_r <= filled_r and filled_r ~= -1 then
        curr_r = filled_r + 0.1
    end

    -- Reverse flow: a negative curr_r (from yshift) starts the sidenote in a
    -- prior column. Walk backwards skipping reserved (banxin) columns. At the
    -- page 0 / column 0 boundary, clamp to the first valid column at row 0.
    while curr_r < 0 do
        curr_c = curr_c - 1
        if curr_c < 0 then
            if curr_p > 0 then
                curr_p = curr_p - 1
                curr_c = p_cols - 1
            else
                curr_r = 0
                curr_p, curr_c = skip_to_valid_column(0, 0, p_cols, config.banxin_on, config.interval)
                break
            end
        end
        if not is_reserved_column(curr_c, config.banxin_on, config.interval) then
            curr_r = config.line_limit + curr_r
        end
    end

    -- Anchor sits at the bottom of the column (e.g. last character): push the
    -- first sidenote glyph into the next column so it doesn't ride the edge.
    if curr_r + config.padding_bottom_grid >= config.line_limit then
        curr_p, curr_c, curr_r = wrap_to_next_column(curr_p, curr_c, config)
    end

    local placed_nodes = {}
    local current_content_node = D.todirect(content)

    while current_content_node do
        table.insert(placed_nodes, {
            node = current_content_node,
            page = curr_p,
            col = curr_c,
            y_sp = curr_r * main_grid_height,
            cell_height = effective_gh,
            metadata = metadata
        })

        tracker.set(curr_p, curr_c, curr_r)

        local nid = D.getid(current_content_node)
        curr_p, curr_c, curr_r = calculate_next_node_pos(curr_p, curr_c, curr_r, nid, config)
        current_content_node = D.getnext(current_content_node)
    end

    return placed_nodes
end

local function find_sidenote_anchors(head, layout_map, on_sidenote_found)
    local t = head
    local last_node_pos = nil
    while t do
        local id = D.getid(t)
        if id == constants.WHATSIT then
            local uid = D.getfield(t, "user_id")
            -- dbg.log("Found whatsit, uid=" .. tostring(uid))
            if uid == constants.SIDENOTE_USER_ID then
                local sid = D.getfield(t, "value")
                local indent = D.get_attribute(t, constants.ATTR_INDENT) or 0
                local pos = layout_map[t]
                if pos then
                    local anchor_pos = {
                        page = pos.page,
                        col = pos.col,
                        y_sp = pos.y_sp or 0,
                        indent = indent
                    }
                    on_sidenote_found(sid, anchor_pos)
                else
                    -- Fallback to last node if whatsit not in layout map
                    on_sidenote_found(sid, last_node_pos)
                end
            end
        else
            if layout_map[t] then
                last_node_pos = layout_map[t]
            end
        end
        t = D.getnext(t)
    end
end

--- Resolve sidenote background color value to RGB string.
-- Returns nil for empty/none values. Special tokens "page", "inherit",
-- "继承", "页面", "頁面" inherit from the page background color (white when
-- no page background is configured, since that's what the page actually shows).
local function resolve_bg_color(bg_color)
    if not bg_color then return nil end
    local s = tostring(bg_color)
    if s == "" or s == "none" then return nil end
    local low_s = s:lower():gsub("^%s*(.-)%s*$", "%1")
    if low_s == "page" or low_s == "inherit"
        or low_s == "继承" or low_s == "页面" or low_s == "頁面" then
        local tex_bg = utils.get_tex_tl("l__luatexcn_page_background_color_tl")
        return utils.normalize_rgb(tex_bg) or "1.0000 1.0000 1.0000"
    end
    return utils.normalize_rgb(s)
end

--- Build PDF fill-rectangle literal nodes behind sidenote glyphs (issue #93).
-- Each placed sidenote node that consumes a row gets a small rectangle of
-- the same height as its cell, centered on the column boundary. The
-- rectangle masks any column borders (界行) underneath the sidenote text.
function sidenote._build_bg_literals(sidenote_for_page, engine_ctx, p_total_cols)
    local literals = {}
    if not sidenote_for_page or #sidenote_for_page == 0 then return literals end

    local sp_to_bp = utils.sp_to_bp
    for _, item in ipairs(sidenote_for_page) do
        local id = D.getid(item.node)
        local consumes_row = (id == constants.GLYPH or id == constants.HLIST
            or id == constants.VLIST or id == constants.RULE)
        if consumes_row then
            local bg_rgb = resolve_bg_color(
                item.metadata and item.metadata.background_color)
            if bg_rgb then
                local cell_h = item.cell_height or engine_ctx.g_height
                local font_size_sp = (item.metadata
                    and tonumber(item.metadata.font_size)) or cell_h
                local rtl_col = p_total_cols - 1 - item.col
                local boundary_x = text_position.get_column_x(rtl_col + 1,
                    engine_ctx.col_geom)
                    + (engine_ctx.half_thickness or 0)
                    + (engine_ctx.shift_x or 0)
                local meta_xshift = safe_resolve(
                    item.metadata and item.metadata.xshift,
                    engine_ctx.g_height)
                local center_x = boundary_x + meta_xshift
                local x_left = center_x - font_size_sp / 2
                local y_top = -item.y_sp - (engine_ctx.shift_y or 0)
                local literal_str = utils.create_fill_rect_literal(bg_rgb,
                    x_left * sp_to_bp,
                    y_top * sp_to_bp,
                    font_size_sp * sp_to_bp,
                    -cell_h * sp_to_bp)
                literals[#literals + 1] = utils.create_pdf_literal(literal_str)
            end
        end
    end
    return literals
end

-- Expose internal functions for unit testing
sidenote._internal = {
    serialize = serialize,
    create_gap_tracker = create_gap_tracker,
    is_reserved_column = is_reserved_column,
    skip_to_valid_column = skip_to_valid_column,
    extract_registry_content = extract_registry_content,
    calculate_start_position = calculate_start_position,
    calculate_next_node_pos = calculate_next_node_pos,
    wrap_to_next_column = wrap_to_next_column,
    build_col_min_row = build_col_min_row,
    place_individual_sidenote = place_individual_sidenote,
    find_sidenote_anchors = find_sidenote_anchors
}

-- ============================================================================
-- Public API
-- ============================================================================

--- Register a sidenote from a TeX box
function sidenote.register_sidenote(box_num, metadata)
    local box = tex.box[box_num]
    if not box then
        dbg.log("register_sidenote: box is nil!")
        return
    end

    sidenote.registry_counter = sidenote.registry_counter + 1
    local id = sidenote.registry_counter

    local content_head = node.copy_list(box.list)

    -- Build setting overrides from metadata (passed from TeX via \Sidenote[punct-mode=...])
    local setting_overrides = {}
    if metadata and metadata.punct_mode and metadata.punct_mode ~= "" then
        setting_overrides.punct_mode = metadata.punct_mode
    end
    if metadata and metadata.punct_style and metadata.punct_style ~= "" then
        setting_overrides.punct_style = metadata.punct_style
    end

    -- Push component-level settings (inherits global if no override)
    setting_stack.push(setting_overrides)
    local settings = setting_stack.current()

    -- Apply punctuation processing based on setting stack
    local effective_mode = settings.punct_mode or "normal"
    if effective_mode == "normal" then
        -- Normal mode: apply punct.flatten for vertical form replacement
        -- (e.g. 《》→︽︾, 「」→﹁﹂, ""→﹁﹂)
        local punct_ctx = {
            style   = (_G.punct and _G.punct.style) or "mainland",
            squeeze = not (_G.punct and _G.punct.squeeze == false),
            kinsoku = not (_G.punct and _G.punct.kinsoku == false),
        }
        content_head = punct_mod.flatten(content_head, {}, punct_ctx)
    else
        local judou_ctx = {
            mode = effective_mode,
            punct_mode = effective_mode,
            pos = (_G.judou and _G.judou.pos) or "right-bottom",
            size = (_G.judou and _G.judou.size) or "1em",
            color = (_G.judou and _G.judou.color) or "red",
        }
        content_head = judou.flatten(content_head, {}, judou_ctx)
    end

    -- Pop setting stack (restore previous settings)
    setting_stack.pop()

    -- Register style and set attribute on all nodes (Phase 2: Style registry)
    local font_color_str = metadata and metadata.font_color
    local font_size_str = metadata and metadata.font_size

    -- Build style table with all available attributes
    local style = {}
    if font_color_str and font_color_str ~= "" then
        style.font_color = font_color_str
    end
    if font_size_str and font_size_str ~= "" then
        style.font_size = constants.to_dimen(font_size_str)
    end

    -- Register style and set attribute if any style attributes are present
    if next(style) then  -- Check if style table is not empty
        local style_reg_id = style_registry.register(style)
        -- Traverse the node list and set ATTR_STYLE_REG_ID on all nodes
        for n in node.traverse(content_head) do
            node.set_attribute(n, constants.ATTR_STYLE_REG_ID, style_reg_id)
        end
    end

    sidenote.registry[id] = {
        head = content_head,
        metadata = metadata or {}
    }

    dbg.log(string.format("Registered sidenote ID=%d, metadata=%s", id, serialize(metadata or {})))

    local n = node.new("whatsit", "user_defined")
    n.user_id = constants.SIDENOTE_USER_ID
    n.type = 100
    n.value = id
    node.write(n)
end

--- Calculate positions for sidenotes based on main layout
function sidenote.calculate_sidenote_positions(layout_map, params)
    local sidenote_map = {}
    local list = params.list
    if not list then return {} end

    -- Build column-min-row map from the main text layout to support
    -- taitou-aware wrapping: wrapped sidenote content aligns with body text's
    -- top boundary in the target column.
    local grid_height = params.grid_height or (65536 * 20)
    params.col_min_row = build_col_min_row(layout_map, grid_height)

    local tracker = create_gap_tracker()
    local t = D.todirect(list)

    find_sidenote_anchors(t, layout_map, function(sid, last_node_pos)
        local registry_item = sidenote.registry[sid]
        local placed_nodes = place_individual_sidenote(sid, registry_item, last_node_pos, params, tracker)
        if placed_nodes then
            sidenote_map[sid] = placed_nodes
            dbg.log(string.format("Placed sidenote sid=%d with %d nodes", sid, #placed_nodes))
        end
    end)

    return sidenote_map
end

--- Clear the sidenote registry to free node memory
function sidenote.clear_registry()
    sidenote.registry = {}
    sidenote.registry_counter = 0
end

package.loaded['core.luatex-cn-core-sidenote'] = sidenote
return sidenote
