Jump to content

Module:Build bracket

Permanently protected module
From Wikipedia, the free encyclopedia
This is an old revision of this page, as edited by Pbrks (talk | contribs) at 01:39, 12 August 2025. The present address (URL) is a permanent link to this revision, which may differ significantly from the current revision.

local p = {}

local state = {
    entries       = {},
    pathCell      = {},
    crossCell     = {},
    skipPath      = {},
    shift         = {},
    hascross      = {},
    teamsPerMatch = {},
    rlegs         = {},
    maxlegs       = {},
    byes          = {},
    hide          = {},
    matchgroup    = {},
    headerindex   = {},
    masterindex    = 1,
}
local config = {
    autolegs     = false,
    nowrap       = true,
    autocol      = false,
    seeds        = true,
    forceseeds   = false,
    boldwinner   = false,
    boldwinner_mode     = 'off',   -- 'off' | 'high' | 'low'
	boldwinner_aggonly  = false,   -- true => only bold the 'agg' column
    aggregate           = false,    -- keeps layout conditionals working
	aggregate_mode      = 'off',    -- 'off' | 'manual' | 'sets' | 'score'
    paramstyle   = "indexed",
    colspacing   = 5,
    height       = 0,
    minc         = 1,
    c            = 1,   -- number of rounds
    r            = nil, -- rows (set later)
}

--========================
-- Localized globals for performance
--========================
local str_byte, str_char, str_find, str_format, str_gmatch, str_gsub, str_match =
    string.byte, string.char, string.find, string.format, string.gmatch, string.gsub, string.match
local t_insert, t_sort, t_concat = table.insert, table.sort, table.concat
local m_max, m_min, m_ceil = math.max, math.min, math.ceil
local tonumber, tostring = tonumber, tostring
local pairs, ipairs, type = pairs, ipairs, type

local mw_html_create = mw.html.create

--========================
-- Color configuration
--========================
local COLORS = {
    -- Cell backgrounds
    cell_bg_light    = 'var(--background-color-neutral-subtle,#f8f9fa)',
    cell_bg_dark     = 'var(--background-color-neutral,#eaecf0)',
    text_color       = 'var(--color-base,#202122)',
    path_line_color  = 'gray',
    cell_border      = 'var(--border-color-base,#a2a9b1)',
}

--========================
-- Core Helpers
--========================

--[[
Helper Index:
1. Basic checks: isempty, notempty, yes, no
2. Arg fetch: bargs
3. String: toChar, split, unboldParenthetical
4. Style: cellBorder, getWidth
]]

-- Basic truthy/empty checks
local function isempty(s) return s==nil or s=='' end
local function notempty(s) return s~=nil and s~='' end
local function yes(val) return val == 'y' or val == 'yes' end
local function no(val)  return val == 'n' or val == 'no' end

-- Argument fetcher
local function bargs(s) -- reads pargs, fargs
	return pargs[s] or fargs[s]
end

-- String helpers
local function toChar(num) -- pure
	return str_char(str_byte("a")+num-1)
end

local function unboldParenthetical(text) -- pure, heavy string ops
    if isempty(text) then return text end

    local STYLE_NORMAL = '<span style="font-weight:normal">%s</span>'
    local PLACEHOLDER_PREFIX = '__WIKILINK__'

    -- Step 1: Extract and temporarily replace wikilinks
    local counter = 0
    local placeholders = {}
    text = text:gsub('%[%[(.-)%]%]', function(link)
        counter = counter + 1
        local key = PLACEHOLDER_PREFIX .. counter .. '__'
        placeholders[key] = link
        return key
    end)

    -- Step 2: Wrap parenthetical (...) and bracketed [...] text in normal-weight span
    text = text:gsub("(%b())", function(match)
        return STYLE_NORMAL:format(match)
    end)
    text = text:gsub("(%b[])", function(match)
        return STYLE_NORMAL:format(match)
    end)

    -- Step 3: Restore wikilinks
    for key, link in pairs(placeholders) do
        text = text:gsub(key, '[[' .. link .. ']]')
    end

    return text
end

local function split(str,delim,tonum) -- pure
	result = {};
	local a = "[^"..t_concat(delim).."]+"
		for w in str:gmatch(a) do
			if tonum==true then
				t_insert(result, tonumber(w));
			else
				t_insert(result, w);
			end
		end
	return result;
end

-- Style & dimension helpers
local function cellBorder(b) -- pure
	return b[1]..'px '..b[2]..'px '..b[3]..'px '..b[4]..'px'
end

local function getWidth(ctype, default) -- reads pargs, fargs
	local result = bargs(ctype..'-width')
	if isempty(result) then return default end
	if tonumber(result)~=nil then return result..'px' end
	return result
end

local function parseArgs(frame, config)
    fargs, pargs = frame.args, frame:getParent().args

    config.r        = tonumber(fargs.rows) or ''
    config.c        = tonumber(fargs.rounds) or 1
    local maxc      = tonumber(pargs.maxrounds) or tonumber(pargs.maxround) or ''
    config.minc     = tonumber(pargs.minround) or 1
    if notempty(maxc) then config.c = maxc end

    config.autocol    = yes(fargs.autocol)
    config.colspacing = tonumber(fargs['col-spacing']) or 5
    config.height     = bargs('height') or 0

    local bw = (bargs('boldwinner') or ''):lower()
	config.boldwinner        = bw           -- keep for backward compat
	config.boldwinner_mode   = 'off'
	config.boldwinner_aggonly = false
	
	local function yesish(s) return s=='y' or s=='yes' or s=='true' or s=='1' end

	if bw == 'low' then
	    config.boldwinner_mode = 'low'
	elseif bw == 'high' or yesish(bw) then
	    config.boldwinner_mode = 'high'
	elseif bw == 'aggregate' or bw == 'agg' or bw == 'aggregate-high' or bw == 'agg-high' then
	    config.boldwinner_mode = 'high'
	    config.boldwinner_aggonly = true
	elseif bw == 'aggregate-low' or bw == 'agg-low' then
	    config.boldwinner_mode = 'low'
	    config.boldwinner_aggonly = true
	end
	
	-- optional explicit switch that works with any bw mode
	if yes(bargs('boldwinner-aggregate-only')) then
	    config.boldwinner_aggonly = true
	end

    config.forceseeds = yes(bargs('seeds'))
    config.seeds      = not no(bargs('seeds'))
    
    do
	    local aval = (bargs('aggregate') or ''):lower()
	    if aval == 'sets' or aval == 'legs' then
	        config.aggregate_mode = 'sets'
	    elseif aval == 'score' then
	        config.aggregate_mode = 'score'
	    elseif aval == 'y' or aval == 'yes' or aval == 'true' or aval == '1' then
	        config.aggregate_mode = 'manual'
	    else
	        config.aggregate_mode = 'off'
	    end
	    config.aggregate = (config.aggregate_mode ~= 'off')
	end
    
    config.autolegs   = yes(bargs('autolegs'))
    config.paramstyle = (bargs('paramstyle') == 'numbered') and 'numbered' or 'indexed'
    config.nowrap     = not no(pargs.nowrap)
end

--========================
-- Bracket State Checks
--========================

local function isBlankEntry(col,row,ctype) -- reads entries
	if isempty(state.entries[col][row]) then return true end
	if isempty(state.entries[col][row]['team']) and isempty(state.entries[col][row]['text']) then return true end
	return false
end

local function showSeeds(j,i) -- reads entries, teamsPerMatch, forceseeds
	local showseed=false
	if config.forceseeds or notempty(state.entries[j][i]['seed']) then
		showseed=true
	else
		for k=1,state.teamsPerMatch[j]-1 do
			if notempty(state.entries[j][i+2*k]) and state.entries[j][i]['group']==state.entries[j][i+2*k]['group'] and notempty(state.entries[j][i+2*k]['seed']) then
				showseed=true
			end
			if notempty(state.entries[j][i-2*k]) and state.entries[j][i]['group']==state.entries[j][i-2*k]['group'] and notempty(state.entries[j][i-2*k]['seed']) then
				showseed=true
			end
		end
	end
	return showseed
end

local function isRoundHidden(j,i,headerindex)
	if notempty(state.entries[j][i]['pheader']) then
		state.hide[j][state.entries[j][i]['headerindex']] = false
	end
	local row = i+1
	repeat
		if not isBlankEntry(j,row) then
			state.hide[j][state.entries[j][i]['headerindex']] = false
		end
		row = row+1
	until (state.entries[j][row]~=nil and state.entries[j][row]['ctype']=='header') or row>config.r
end

local function teamLegs(j,i) -- reads entries, rlegs, autolegs
	local legs = state.rlegs[j]
	if notempty(state.entries[j][i]['legs']) then
		legs = tonumber(state.entries[j][i]['legs'])
	end
	if config.autolegs then
		local l=1
		repeat l=l+1
		until isempty(state.entries[j][i]['score'][l])
		legs = l-1
	end
	return legs
end

-- Determine whether the "round" after header at (j,i) is empty (used for bye detection)
local function roundIsEmpty(j, i) -- reads entries, isBlankEntry
    -- start scanning rows after the header cell at i
    local row = i + 1
    repeat
        if not isBlankEntry(j, row) then
            return false
        end
        row = row + 1
    until (state.entries[j][row] ~= nil and state.entries[j][row]['ctype'] == 'header') or row > config.r
    return true
end

-- Default header text when none is provided
local function defaultHeaderText(j, headerindex)
    if headerindex == 1 then
        if j == config.c then
            return 'Final'
        elseif j == config.c - 1 then
            return 'Semifinals'
        elseif j == config.c - 2 then
            return 'Quarterfinals'
        else
            return 'Round ' .. j
        end
    else
        return 'Lower round ' .. j
    end
end

local function noPaths(j, i)
    -- how many path columns to check
    local cols = state.hascross[j] and 3 or 2

    -- path cells: any nonzero entry in [k][1][n] means there's a path
    local pcj = state.pathCell[j]
    local pci = pcj and pcj[i]
    if pci then
        for k = 1, cols do
            local ktab = pci[k]
            local dir1 = ktab and ktab[1]
            if dir1 then
                for n = 1, 4 do
                    local v = dir1[n]
                    if v and v ~= 0 then
                        return false
                    end
                end
            end
        end
    end

    -- cross cells: left/right flag of 1 means there's a cross path
    if state.hascross[j] then
        local ccj = state.crossCell[j]
        local cci = ccj and ccj[i]
        if cci then
            local left  = cci.left
            local right = cci.right
            if (left and left[1] == 1) or (right and right[1] == 1) then
                return false
            end
        end
    end

    return true
end

--========================
-- Rendering (HTML / Table Building)
--========================

local function Cell(tbl, j, i, opts)
    opts = opts or {}
    local cell = tbl:tag('td')

    -- classes first
    if opts.classes then
        for _, c in ipairs(opts.classes) do cell:addClass(c) end
    end

    if opts.colspan and opts.colspan ~= 1 then cell:attr('colspan', opts.colspan) end
    if opts.rowspan and opts.rowspan ~= 1 then cell:attr('rowspan', opts.rowspan) end

    if notempty(opts.border) then cell:css('border', opts.border) end
    if notempty(opts.borderWidth) then cell:css('border-width', cellBorder(opts.borderWidth)) end

    -- per-cell bg override (for RD*-shade)
    if notempty(opts.bg) then cell:css('background-color', opts.bg) end

    if notempty(opts.align) then cell:css('text-align', opts.align) end
    if opts.padding and opts.padding ~= '' then cell:css('padding', opts.padding) end
    if opts.weight == 'bold' then cell:css('font-weight', 'bold') end

    -- only set color when a caller asks for it
    if notempty(opts.color) then cell:css('color', opts.color) end

    if notempty(opts.text) then cell:wikitext(opts.text) end
    return cell
end


local function teamCell(tbl, k, j, i, l, colspan)
    local classes = { 'brk-td', 'brk-b', (k == 'seed') and 'brk-bgD' or 'brk-bgL' }
    if k == 'seed' or k == 'score' then classes[#classes+1] = 'brk-center' end

    local opts = {
        classes     = classes,        -- <-- gives border-style & color
        colspan     = colspan,
        rowspan     = 2,
        borderWidth = {0, 0, 1, 1},   -- same logic as before, you still mutate this
        -- bg only when overriding via RD*-shade; otherwise classes handle background
        text        = (l and tostring(state.entries[j][i][k][l])) or
                      unboldParenthetical(state.entries[j][i][k]),
        weight      = ((l == nil and state.entries[j][i].weight == 'bold')
                      or state.entries[j][i].score.weight[l] == 'bold') and 'bold' or nil,
    }

    -- Team cell specifics
    if k == 'team' and teamLegs(j, i) == 0 then
        opts.borderWidth[2] = 1
    end
    if state.entries[j][i].position == 'top' then
        opts.borderWidth[1] = 1
    end
    if l == teamLegs(j, i) or l == 'agg' or k == 'seed' then
        opts.borderWidth[2] = 1
    end

    -- Bold winner (unchanged logic)
    if (l == nil and state.entries[j][i].weight == 'bold')
        or state.entries[j][i].score.weight[l] == 'bold' then
        opts.weight = 'bold'
    end

    -- Text content
    if l == nil then
        opts.text = unboldParenthetical(state.entries[j][i][k])
    else
        opts.text = tostring(state.entries[j][i][k][l])
    end

    return Cell(tbl, j, i, opts)
end

-- Compute standard colspan for a single "entry" cell in column j
local function getEntryColspan(j) -- reads maxlegs, seeds, aggregate
    local colspan = state.maxlegs[j] + 2
    if not config.seeds then
        colspan = colspan - 1
    end
    if (config.aggregate and state.maxlegs[j] > 1) or state.maxlegs[j] == 0 then
        colspan = colspan + 1
    end
    return colspan
end

-- Handle cases where the entry is nil (absent) or explicitly a blank placeholder.
-- Returns true if this function produced the appropriate cell (so caller should return)
local function handleEmptyOrNilEntry(tbl, j, i)
    local entry_colspan = getEntryColspan(j)

    -- nil entry: produce a spanning blank cell if appropriate
    if state.entries[j][i] == nil then
	    -- If previous entry exists or this is the first row, create a rowspan covering following blank rows
	    if state.entries[j][i - 1] ~= nil or i == 1 then
	        local rowspan = 0
	        local row = i
	        repeat
	            rowspan = rowspan + 1
	            row = row + 1
	        until state.entries[j][row] ~= nil or row > config.r
	
	        -- produce an empty cell with the computed rowspan/colspan
	        local opts = {
	            rowspan = rowspan,
	            colspan = entry_colspan,
	            text    = nil
	        }
	        Cell(tbl, j, i, opts)
	        return true
	    else
	        -- do nothing (cell intentionally omitted)
	        return true
	    end
	end

    -- explicit 'blank' ctype: do nothing (no visible content)
    if state.entries[j][i]['ctype'] == 'blank' then
        return true
    end

    return false
end

-- Insert a header cell
local function insertHeader(tbl, j, i, entry)
    local entry_colspan = getEntryColspan(j)

    local function emptyCell()
        return Cell(tbl, j, i, { rowspan = 2, colspan = entry_colspan })
    end

    if state.byes[j][entry.headerindex] and roundIsEmpty(j, i) then return emptyCell() end
    if state.hide[j][entry.headerindex] then return emptyCell() end

    if isempty(entry.header) then
        entry.header = defaultHeaderText(j, entry.headerindex)
    end

    local classes = { 'brk-td', 'brk-b', 'brk-center' }
    local useCustomShade = entry.shade_is_rd and not isempty(entry.shade)
    if not useCustomShade then
        table.insert(classes, 'brk-bgD') -- default bg via class
    end

    return Cell(tbl, j, i, {
        rowspan     = 2,
        colspan     = entry_colspan,
        text        = entry.header,
        classes     = classes,
        borderWidth = {1,1,1,1},
        -- only for custom RD*-shade:
        bg          = useCustomShade and entry.shade or nil,
        color       = useCustomShade and '#202122'  or nil,
    })
end


-- Insert a team cell (seed, team, and scores)
local function insertTeam(tbl, j, i, entry)
    local entry_colspan = getEntryColspan(j)

    -- If this team belongs to a 'bye' header and is blank, render empty cell
    if (state.byes[j][entry.headerindex] and isBlankEntry(j, i)) or state.hide[j][entry.headerindex] then
        return Cell(tbl, j, i, {
            rowspan = 2,
            colspan = entry_colspan
        })
    end

    local legs = teamLegs(j, i)
    local team_colspan = state.maxlegs[j] - legs + 1
    if config.aggregate and legs == 1 and state.maxlegs[j] > 1 then
        team_colspan = team_colspan + 1
    end
    if state.maxlegs[j] == 0 then
        team_colspan = team_colspan + 1
    end

    -- Seed column (if enabled). Either render the seed cell or fold it into the team colspan.
    if config.seeds then
        if showSeeds(j, i) == true then
            teamCell(tbl, 'seed', j, i)
        else
            team_colspan = team_colspan + 1
        end
    end

    -- Team name cell (may span multiple score columns)
    teamCell(tbl, 'team', j, i, nil, team_colspan)

    -- Score cells (one per leg)
    for l = 1, legs do
        teamCell(tbl, 'score', j, i, l)
    end

    -- Aggregate score column (if configured)
    if config.aggregate and legs > 1 then
        teamCell(tbl, 'score', j, i, 'agg')
    end
end

-- Insert a text cell
local function insertText(tbl, j, i, entry)
    local entry_colspan = getEntryColspan(j)
    Cell(tbl, j, i, {
        rowspan = 2,
        colspan = entry_colspan,
        text    = entry.text,
    })
end

-- Insert a group cell (spans several columns)
local function insertGroup(tbl, j, i, entry)
    local span = state.entries[j][i].colspan
    local colspan = 0

    -- sum entry widths per column in the span
    for m = j, j + span - 1 do
        colspan = colspan + state.maxlegs[m] + 2
        if not config.seeds then colspan = colspan - 1 end
        if (config.aggregate and state.maxlegs[m] > 1) or state.maxlegs[m] == 0 then
            colspan = colspan + 1
        end
    end

    -- add path columns between each adjacent pair
    for m = j, j + span - 2 do
        colspan = colspan + (state.hascross[m] and 3 or 2)
    end

    return Cell(tbl, j, i, {
        rowspan = 2,
        colspan = colspan,
        text    = entry.group,
        align   = 'center'
    })
end

-- Insert a line cell (visual path markers)
local function insertLine(tbl, j, i, entry)
    local entry_colspan = getEntryColspan(j)
    local borderWidth   = {0, 0, 0, 0}
    borderWidth[3] = 2 * state.pathCell[j - 1][i + 1][3][1][3]

    local cell = Cell(tbl, j, i, {
        rowspan     = 2,
        colspan     = entry_colspan,
        text        = entry.text,
        borderWidth = borderWidth
    })
    -- add default line color via class (keeps output tiny)
    cell:addClass('brk-line')
    return cell
end

-- Insert a line2 cell (alternative path styling)
local function insertLine2(tbl, j, i, entry)
    local entry_colspan = getEntryColspan(j)
    local borderWidth   = {0, 0, 0, 0}
    borderWidth[1] = 2 * state.pathCell[j - 1][i][3][1][1]

    local cell = Cell(tbl, j, i, {
        rowspan     = 2,
        colspan     = entry_colspan,
        text        = entry.text,
        borderWidth = borderWidth
    })
    -- add default line color via class (keeps output tiny)
    cell:addClass('brk-line')
    return cell
end

local INSERTORS = {
    header = insertHeader,
    team   = insertTeam,
    text   = insertText,
    group  = insertGroup,
    line   = insertLine,
    line2  = insertLine2
}

local function insertEntry(tbl, j, i)
    if handleEmptyOrNilEntry(tbl, j, i) then return end
    local entry = state.entries[j][i]
    if not entry then return end
    local fn = INSERTORS[entry.ctype]
    if fn then
        return fn(tbl, j, i, entry)
    end
    return Cell(tbl, j, i, { rowspan = 2, colspan = getEntryColspan(j) })
end

-- Draw a single path cell in the bracket table
local function generatePathCell(tbl, j, i, k, bg, rowspan)
	local colData = state.pathCell[j][i][k]
	local color   = colData.color

	-- Skip center cell if there is no cross path
	if not state.hascross[j] and k == 2 then
		return
	end

	local cell = tbl:tag('td')

	-- Rowspan if this cell spans multiple rows
	if rowspan ~= 1 then
		cell:attr('rowspan', rowspan)
	end

	-- Background shading for middle column (cross path visuals)
	if notempty(bg) and k == 2 then
		cell:css('background', bg)
		    :css('transform', 'translate(-1px)')
	end

	-- Draw border if any segment has nonzero width
	local borders = colData[1]
	if borders[1] ~= 0 or borders[2] ~= 0 or borders[3] ~= 0 or borders[4] ~= 0 then
		cell:css('border', 'solid ' .. color)
		    :css('border-width',
				(2 * borders[1]) .. 'px ' ..
				(2 * borders[2]) .. 'px ' ..
				(2 * borders[3]) .. 'px ' ..
				(2 * borders[4]) .. 'px'
			)
	end

	return cell
end

-- Insert all path cells for a given position in the bracket
local function insertPath(tbl, j, i)
	if state.skipPath[j][i] then
		return
	end

	local colspan, rowspan = 2, 1
	local bg = ''
	local cross = { '', '' }

	-- Detect vertical merging (rowspan) for repeated paths
	if i < config.r then
		local function repeatedPath(a)
			if a > config.r - 1 or state.skipPath[j][a] then
				return false
			end
			for k = 1, 3 do
				for n = 1, 4 do
					if state.pathCell[j][i][k][1][n] ~= state.pathCell[j][a][k][1][n] then
						return false
					end
				end
			end
			return true
		end

		if repeatedPath(i) then
			local row = i
			repeat
				if row ~= i and repeatedPath(row) then
					state.skipPath[j][row] = true
				end
				rowspan = rowspan + 1
				row = row + 1
			until row > config.r or not repeatedPath(row)
			rowspan = rowspan - 1
		end
	end

	-- Skip if the previous row has cross path connections
	if i > 1 and (state.crossCell[j][i - 1].left[1] == 1 or state.crossCell[j][i - 1].right[1] == 1) then
		return
	end

	-- Handle cross paths
	if state.hascross[j] then
		colspan = 3

		if state.crossCell[j][i].left[1] == 1 or state.crossCell[j][i].right[1] == 1 then
			rowspan = 2

			if state.crossCell[j][i].left[1] == 1 then
				cross[1] = 'linear-gradient(to top right, transparent calc(50% - 1px),'
					.. state.crossCell[j][i].left[2] .. ' calc(50% - 1px),'
					.. state.crossCell[j][i].left[2] .. ' calc(50% + 1px), transparent calc(50% + 1px))'
			end

			if state.crossCell[j][i].right[1] == 1 then
				cross[2] = 'linear-gradient(to bottom right, transparent calc(50% - 1px),'
					.. state.crossCell[j][i].right[2] .. ' calc(50% - 1px),'
					.. state.crossCell[j][i].right[2] .. ' calc(50% + 1px), transparent calc(50% + 1px))'
			end
		end

		-- Combine left/right gradient layers if both exist
		if notempty(cross[1]) and notempty(cross[2]) then
			cross[1] = cross[1] .. ','
		end
		bg = cross[1] .. cross[2]
	end

	-- Generate three cells (left, middle, right) for this row
	for k = 1, 3 do
		generatePathCell(tbl, j, i, k, bg, rowspan)
	end
end

-- ===================
-- Cluster 4 – Data Population & Parameters
-- ===================

local function paramNames(cname, j, i, l)
    -- Helpers
    local function getArg(key)
        return bargs(key) or ''
    end
    local function getPArg(key)
        return pargs[key] or ''
    end
    local function tryBoth(base, name, idx, suffix)
        suffix = suffix or ''
        local val = getArg(base .. '-' .. name .. idx .. suffix)
        if isempty(val) then
            val = getArg(base .. '-' .. name .. str_format('%02d', idx) .. suffix)
        end
        return val
    end

    -- Round names
    local rname = {
        { 'RD' .. j, getArg('RD' .. j .. '-altname') or 'RD' .. j },
        { 'RD' .. j .. toChar(state.entries[j][i].headerindex),
          getArg('RD' .. j .. toChar(state.entries[j][i].headerindex) .. '-altname') 
              or 'RD' .. j .. toChar(state.entries[j][i].headerindex) }
    }

    -- Base name and indices
    local name  = { cname, getArg(cname .. '-altname') or cname }
    local index = { state.entries[j][i].index, state.entries[j][i].altindex }
    local result = {}

    if cname == 'header' then
        if state.entries[j][i].headerindex == 1 then
            for _, base in ipairs({ rname[1], rname[2] }) do
                for k = 2, 1, -1 do
                    result[#result+1] = getArg(base[k])
                end
            end
        else
            for k = 2, 1, -1 do
                result[#result+1] = getArg(rname[2][k])
            end
        end

    elseif cname == 'pheader' then
        if state.entries[j][i].headerindex == 1 then
            for _, base in ipairs({ rname[1], rname[2] }) do
                for k = 2, 1, -1 do
                    result[#result+1] = getPArg(base[k])
                end
            end
        else
            for k = 2, 1, -1 do
                result[#result+1] = getPArg(rname[2][k])
            end
        end

    elseif cname == 'score' then
        local bases = { rname[2][2], rname[2][1], rname[1][2], rname[1][1] }
        local idxs  = { index[2],    index[2],    index[1],    index[1] }
        for n = 1, 4 do
            if l == 1 then
                result[#result+1] = tryBoth(bases[n], name[1], idxs[n])
            end
            result[#result+1] = tryBoth(bases[n], name[1], idxs[n], '-' .. l)
        end

    elseif cname == 'shade' then
        for k = 2, 1, -1 do
            local base = (state.entries[j][i].headerindex == 1) and rname[1][k] or rname[2][k]
            result[#result+1] = getArg(base .. '-' .. name[1])
        end
        result[#result+1] = getArg('RD-shade')
        result[#result+1] = COLORS.cell_bg_dark

    elseif cname == 'text' then
        local bases = { rname[2][2], rname[2][1], rname[1][2], rname[1][1] }
        local idxs  = { index[2],    index[2],    index[1],    index[1] }
        local names = { name[2], name[1] }
        for ni = 1, 2 do
            for n = 1, 4 do
                result[#result+1] = tryBoth(bases[n], names[ni], idxs[n])
            end
        end

    else -- default case
        local bases = { rname[2][2], rname[2][1], rname[1][2], rname[1][1] }
        local idxs  = { index[2],    index[2],    index[1],    index[1] }
        for n = 1, 4 do
            result[#result+1] = tryBoth(bases[n], name[1], idxs[n])
        end
    end

    -- Return first non-empty
    for _, val in ipairs(result) do
        if notempty(val) then
            return val
        end
    end
    return ''
end

local function indexedParams(j)
	for i=1,config.r do
		if state.entries[j][i]~=nil then
			if state.entries[j][i]['ctype']=='team' then
				local legs = state.rlegs[j]
				if config.forceseeds then
					state.entries[j][i]['seed'] = bargs(masterindex) or ''
					masterindex = masterindex+1
				end
				state.entries[j][i]['team'] = bargs(tostring(masterindex)) or ''
				masterindex = masterindex+1
				state.entries[j][i]['legs'] = paramNames('legs',j,i)
				state.entries[j][i]['score'] = {}
				state.entries[j][i]['weight'] = 'normal'
				state.entries[j][i]['score']['weight'] = {}
				if notempty(state.entries[j][i]['legs']) then
					legs = tonumber(state.entries[j][i]['legs'])
				end
				for l=1,legs do
					state.entries[j][i]['score'][l] = bargs(tostring(masterindex)) or ''
					masterindex = masterindex+1
					state.entries[j][i]['score']['weight'][l] = 'normal'
				end
				if config.aggregate and legs > 1 then
				    state.entries[j][i].score.agg = bargs(masterindex) or ''
				    masterindex = masterindex + 1
				    state.entries[j][i].score.weight.agg = 'normal'
				end
			end
			if state.entries[j][i]['ctype']=='header' then
				state.entries[j][i]['header'] = paramNames('header',j,i)
				state.entries[j][i]['pheader'] = paramNames('pheader',j,i)
				state.entries[j][i]['shade'] = paramNames('shade',j,i)
			end
			if state.entries[j][i]['ctype']=='text' then
				state.entries[j][i]['text'] = bargs(tostring(masterindex)) or ''
				masterindex = masterindex+1
			end
			if state.entries[j][i]['ctype']=='group' then
				state.entries[j][i]['group'] = bargs(tostring(masterindex)) or ''
				masterindex = masterindex+1
			end
			if state.entries[j][i]['ctype'] == 'line' and state.entries[j][i]['hastext']==true then
				state.entries[j][i]['text'] = bargs(masterindex) or ''
				masterindex = masterindex+1
			end
		end
	end
end

local function assignTeamParams(j, i)
    local legs = state.rlegs[j]
    state.entries[j][i].seed  = paramNames('seed', j, i)
    state.entries[j][i].team  = paramNames('team', j, i)
    state.entries[j][i].legs  = paramNames('legs', j, i)
    state.entries[j][i].score = { weight = {} }
    state.entries[j][i].weight = 'normal'

    if notempty(state.entries[j][i].legs) then
        legs = tonumber(state.entries[j][i].legs)
    end

    if config.autolegs then
        local l = 1
        repeat
            state.entries[j][i].score[l] = paramNames('score', j, i, l)
            state.entries[j][i].score.weight[l] = 'normal'
            l = l + 1
        until isempty(paramNames('score', j, i, l))
        legs = l - 1
    else
        for l = 1, legs do
            state.entries[j][i].score[l] = paramNames('score', j, i, l)
            state.entries[j][i].score.weight[l] = 'normal'
        end
    end

    if config.aggregate and legs > 1 then
	    state.entries[j][i].score.agg = paramNames('score', j, i, 'agg')
	    state.entries[j][i].score.weight.agg = 'normal'
	end
end

local function assignHeaderParams(j, i)
    state.entries[j][i].header  = paramNames('header', j, i)
    state.entries[j][i].pheader = paramNames('pheader', j, i)
    state.entries[j][i].shade   = paramNames('shade', j, i)
    
    -- Mark if the shade came from any RD*-shade param
	local shadeParamsToCheck = {
	    'RD'..j..'-shade',
	    'RD'..j..toChar(state.entries[j][i].headerindex)..'-shade',
	    'RD-shade'
	}
	state.entries[j][i].shade_is_rd = false
	for _, pname in ipairs(shadeParamsToCheck) do
	    if notempty(bargs(pname)) and state.entries[j][i].shade == bargs(pname) then
	        state.entries[j][i].shade_is_rd = true
	        break
	    end
	end
end

local function assignTextParams(j, i)
    state.entries[j][i].text = paramNames('text', j, i)
end

local function assignGroupParams(j, i)
    state.entries[j][i].group = paramNames('group', j, i)
end

local function assignLineTextParams(j, i)
    state.entries[j][i].text = paramNames('text', j, i)
end

local function assignParams()
    masterindex = 1
    local maxcol   = 1
    local byerows  = 1
    local hiderows = 1

    local function updateMaxCol(j, i)
        if config.autocol and not isBlankEntry(j, i) then
            maxcol = m_max(maxcol, j)
        end
    end

    local function updateByerows(j, i)
        if state.entries[j][i] and not state.hide[j][state.entries[j][i].headerindex] then
            if not state.byes[j][state.entries[j][i].headerindex] or
               (state.byes[j][state.entries[j][i].headerindex] and not isBlankEntry(j, i)) then
                byerows = m_max(byerows, i)
            end
        end
    end

    for j = config.minc, config.c do
        -- Set legs for this column
        state.rlegs[j] = tonumber(bargs('RD'..j..'-legs')) or tonumber(bargs('legs')) or 1
        if notempty(bargs('RD'..j..'-legs')) or bargs('legs') then
            config.autolegs = false
        end

        if config.paramstyle == 'numbered' then
            indexedParams(j)
        else
            for i = 1, config.r do
                if state.entries[j][i] ~= nil then
                    local ctype = state.entries[j][i].ctype
                    if ctype == 'team' then
                        assignTeamParams(j, i)
                    elseif ctype == 'header' then
                        assignHeaderParams(j, i)
                    elseif ctype == 'text' then
                        assignTextParams(j, i)
                    elseif ctype == 'group' then
                        assignGroupParams(j, i)
                    elseif ctype == 'line' and state.entries[j][i].hastext == true then
                        assignLineTextParams(j, i)
                    end
                end
                updateMaxCol(j, i)
            end
        end

        -- Round hiding check
        for i = 1, config.r do
            if state.entries[j][i] and state.entries[j][i].ctype == 'header' then
                isRoundHidden(j, i)
            end
            updateByerows(j, i)
        end
    end

    -- Adjust row count if some rounds are byes or hidden
    for j = config.minc, config.c do
        for k = 1, state.headerindex[j] do
            if state.byes[j][k] or state.hide[j][k] then
                config.r = byerows + 1
            end
        end
    end

    -- Adjust column count automatically
    if config.autocol then
        config.c = maxcol
    end
end

local function getHide(j,headerindex)
	state.hide[j] = {}
	for k=1,state.headerindex[j] do
		if bargs('RD'..j..toChar(k)..'-hide')=='yes' or bargs('RD'..j..toChar(k)..'-hide')=='y' then
			state.hide[j][k]=true
		end
	end
end

local function getByes(j,headerindex)
	state.byes[j] = {}
	for k=1,state.headerindex[j] do
		if bargs('byes')=='yes' or bargs('byes')=='y' then
			state.byes[j][k]=true 
		elseif tonumber(bargs('byes')) then
			if j<=tonumber(bargs('byes')) then
				state.byes[j][k]=true
			end
		else 
			state.byes[j][k]=false
		end
		if bargs('RD'..j..'-byes')=='yes' or bargs('RD'..j..'-byes')=='y' then
			state.byes[j][k]=true
		elseif bargs('RD'..j..'-byes')=='no' or bargs('RD'..j..'-byes')=='n' then
			state.byes[j][k]=false
		end
		if bargs('RD'..j..toChar(k)..'-byes')=='yes' or bargs('RD'..j..toChar(k)..'-byes')=='y' then
			state.byes[j][k]=true
		elseif bargs('RD'..j..'-byes')=='no' or bargs('RD'..j..'-byes')=='n' then
			state.byes[j][k]=false
		end
	end
end

local function getAltIndices()
	local teamindex=1
	local textindex=1
	local groupindex=1
	for j=config.minc,config.c do
		state.headerindex[j]=0
		for i=1,config.r do
			if state.entries[j][i]==nil and i==1 then
				state.headerindex[j]=state.headerindex[j]+1
			end
			if state.entries[j][i]~=nil then
				if state.entries[j][i]['ctype'] == 'header' then
					state.entries[j][i]['altindex'] = state.headerindex[j]
					teamindex=1
					textindex=1
					state.headerindex[j]=state.headerindex[j]+1
				elseif state.entries[j][i]['ctype'] == 'team' then
					state.entries[j][i]['altindex'] = teamindex
					teamindex=teamindex+1
				elseif state.entries[j][i]['ctype'] == 'text' then
					state.entries[j][i]['altindex'] = textindex
					textindex=textindex+1
				elseif state.entries[j][i]['ctype'] == 'group' then
					state.entries[j][i]['altindex'] = groupindex
					groupindex=groupindex+1
				elseif state.entries[j][i]['ctype'] == 'line' and state.entries[j][i]['hastext']==true then
					state.entries[j][i]['altindex'] = textindex
					textindex=textindex+1
				end
				state.entries[j][i]['headerindex'] = state.headerindex[j]
			end
		end
		getByes(j,headerindex)
		getHide(j,headerindex)
	end
end

local function matchGroups() -- mutates matchgroup, entries
	for j=config.minc,config.c do
		state.matchgroup[j]={}
		for i=1,config.r do
			if state.entries[j][i]~= nil and state.entries[j][i]['ctype']=='team' then
				state.matchgroup[j][i]=m_ceil(state.entries[j][i]['index']/state.teamsPerMatch[j])
				state.entries[j][i]['group'] = m_ceil(state.entries[j][i]['index']/state.teamsPerMatch[j])
			end
		end
	end
end

local function computeAggregate()
    if config.aggregate_mode == 'off' or config.aggregate_mode == 'manual' then return end

    local modeLow = (config.boldwinner_mode == 'low')

    local function numlead(s)
        if not s or s == '' then return nil end
        return tonumber((s):match('^%d+'))  -- leading integer only
    end

    -- Build groups: round j -> groupId -> {team indices}
    local function buildGroupsForRound(j)
        local groups = {}
        local mg = state.matchgroup[j] or {}
        for i = 1, config.r do
            local e = state.entries[j][i]
            if e and e.ctype == 'team' then
                local gid = mg[i]
                if gid ~= nil then
                    local t = groups[gid]; if not t then t = {}; groups[gid] = t end
                    t[#t+1] = i
                end
            end
        end
        return groups
    end

    -- Pre-parse leg numbers for each team (per round)
    local function preparseLegs(j)
        local legNums = {}  -- i -> { [l] = number|nil }
        for i = 1, config.r do
            local e = state.entries[j][i]
            if e and e.ctype == 'team' then
                local L = teamLegs(j, i)
                if config.aggregate and L > 1 and (e.score and e.score.agg ~= nil) then
                    legNums[i] = {}
                    for l = 1, L do
                        legNums[i][l] = numlead(e.score[l])
                    end
                end
            end
        end
        return legNums
    end

    for j = config.minc, config.c do
        local groups   = buildGroupsForRound(j)
        local legNums  = preparseLegs(j)

        if config.aggregate_mode == 'score' then
            -- Sum per-leg scores
            for _, members in pairs(groups) do
                for _, i in ipairs(members) do
                    local e = state.entries[j][i]
                    if e and e.ctype == 'team' and config.aggregate and teamLegs(j, i) > 1 then
                        if isempty(e.score.agg) then
                            local sum = 0
                            local nums = legNums[i]
                            if nums then
                                for _, v in ipairs(nums) do if v then sum = sum + v end end
                                e.score.agg = tostring(sum)
                            end
                        end
                    end
                end
            end

        else -- 'sets'/'legs' → count leg wins using high/low rule; ties = no win; missing numeric → skip the leg
            for _, members in pairs(groups) do
                -- wins per team index in this group
                local wins = {}

                -- Common legs we can actually compare across this group
                local commonLegs = math.huge
                for _, i in ipairs(members) do
                    local nums = legNums[i]
                    local L = (nums and #nums) or 0
                    if L == 0 then commonLegs = 0; break end
                    if L < commonLegs then commonLegs = L end
                end

                for l = 1, commonLegs do
                    -- all teams must have a numeric value for this leg
                    local allNumeric = true
                    for _, i in ipairs(members) do
                        if not (legNums[i] and legNums[i][l] ~= nil) then
                            allNumeric = false; break
                        end
                    end
                    if allNumeric then
                        -- find best (high or low). ties => no win
                        local best, bestIndex, tie = nil, nil, false
                        for _, i in ipairs(members) do
                            local v = legNums[i][l]
                            if best == nil then
                                best, bestIndex, tie = v, i, false
                            else
                                if (modeLow and v < best) or (not modeLow and v > best) then
                                    best, bestIndex, tie = v, i, false
                                elseif v == best then
                                    tie = true
                                end
                            end
                        end
                        if not tie and bestIndex then
                            wins[bestIndex] = (wins[bestIndex] or 0) + 1
                        end
                    end
                end

                -- Write aggregates if still empty
                for _, i in ipairs(members) do
                    local e = state.entries[j][i]
                    if e and e.ctype == 'team' and config.aggregate and teamLegs(j, i) > 1 then
                        if isempty(e.score.agg) then
                            e.score.agg = tostring(wins[i] or 0)
                        end
                    end
                end
            end
        end
    end
end

local function boldWinner()
    local modeLow  = (config.boldwinner_mode == 'low')
    local aggOnly  = config.boldwinner_aggonly

    local function isWin(mine, theirs)
        if modeLow then return mine < theirs else return mine > theirs end
    end

    local function isAggWin(mine, theirs, l)
        if l == 'agg' and config.aggregate_mode == 'sets' then
            -- Sets/legs won: larger count wins regardless of low/high scoring sport
            return mine > theirs
        else
            return isWin(mine, theirs)
        end
    end

    local function boldScore(j, i, l)
        local e = state.entries[j][i]
        if not e or e.ctype ~= 'team' then return 'normal' end

        local raw = e.score[l] or ''
        local mine = tonumber((raw):match('^%d+'))
        if not mine then return 'normal' end

        local comps = {}
        for oppIndex, groupId in pairs(state.matchgroup[j]) do
            if groupId == state.matchgroup[j][i] and oppIndex ~= i then
                local theirraw = state.entries[j][oppIndex].score[l] or ''
                local theirs = tonumber((theirraw):match('^%d+'))
                if not theirs then return 'normal' end
                table.insert(comps, theirs)
            end
        end

        for _, v in ipairs(comps) do
            if not isAggWin(mine, v, l) then return 'normal' end
        end

        if l ~= 'agg' then
            e.wins = (e.wins or 0) + 1
        else
            e.aggwins = 1
        end
        return 'bold'
    end

    local function boldTeam(j, i, useAggregate)
        local e = state.entries[j][i]
        local winsKey = useAggregate and 'aggwins' or 'wins'
        local legs = teamLegs(j, i)

        if not useAggregate then
            if (e[winsKey] or 0) > legs / 2 then return 'bold' end
            local checkFn = config.autolegs and notempty or function(val) return not isempty(val) end
            for l = 1, legs do
                local sv = e.score[l]
                if not checkFn(sv) or str_find(sv or '', "nbsp") then
                    return 'normal'
                end
            end
        end

        for oppIndex, groupId in pairs(state.matchgroup[j]) do
            if groupId == state.matchgroup[j][i] and oppIndex ~= i then
                if (e[winsKey] or 0) <= tonumber(state.entries[j][oppIndex][winsKey] or 0) then
                    return 'normal'
                end
            end
        end
        return 'bold'
    end

    -- reset counters
    for j = config.minc, config.c do
        for i = 1, config.r do
            if state.entries[j][i] and state.entries[j][i].ctype == 'team' then
                state.entries[j][i].wins    = 0
                state.entries[j][i].aggwins = 0
            end
        end

        -- per-score bolding
        for i = 1, config.r do
            if state.entries[j][i] and state.entries[j][i].ctype == 'team' then
                local legs = teamLegs(j, i)

                -- legs (skip entirely if agg-only)
                if not aggOnly then
                    for l = 1, legs do
                        state.entries[j][i].score.weight[l] = boldScore(j, i, l)
                    end
                end

                -- aggregate column (if present)
                if config.aggregate and legs > 1 then
                    state.entries[j][i].score.weight.agg = boldScore(j, i, 'agg')
                end
            end
        end

        -- whole-team bolding (skip if agg-only so only agg cell bolds)
        if not aggOnly then
            for i = 1, config.r do
                if state.entries[j][i] and state.entries[j][i].ctype == 'team' then
                    local useAggregate = config.aggregate and teamLegs(j, i) > 1
                    state.entries[j][i].weight = boldTeam(j, i, useAggregate)
                end
            end
        end
    end
end

local function updateMaxLegs()
    for j = config.minc, config.c do
        state.maxlegs[j] = state.rlegs[j]
        for i = 1, config.r do
            if notempty(state.entries[j][i]) then
                if notempty(state.entries[j][i].legs) then
                    state.maxlegs[j] = m_max(state.rlegs[j], state.entries[j][i].legs)
                end
                if config.autolegs then
                    local l = 1
                    repeat l = l + 1
                    until isempty(state.entries[j][i].score) or isempty(state.entries[j][i].score[l])
                    state.maxlegs[j] = m_max(state.maxlegs[j], l - 1)
                end
            end
        end
    end
end

-- ========================
-- Path Parsing & Drawing
-- ========================

local function parsePaths(j)
	local result={}
	local str = fargs['col'..j..'-col'..(j+1)..'-paths'] or ''
		for val in str:gsub("%s+","")
					:gsub(",",", ")
					:gsub("%S+","\0%0\0")
					:gsub("%b()", function(s) return s:gsub("%z","") end)
					:gmatch("%z(.-)%z") do
			local array = split(val:gsub("%s+",""):gsub("%)",""):gsub("%(",""),{"-"})
			for k,_ in pairs(array) do
				array[k] = split(array[k],{","})
			end
			if notempty(array[2]) then
				for m=1,#array[2] do
					array[3] = {}
					array[2][m] = split(array[2][m],{":"})
					array[3][m] = array[2][m][2]
					array[2][m] = array[2][m][1]
				end
				for n=1,#array[1] do
					for m=1,#array[2] do
						t_insert(result,{tonumber(array[1][n]),tonumber(array[2][m]),['color']=array[3][m]})
					end
				end
			end
		end
	return result
end

local function isPathHidden(j,i,start,stop)
	local result=false
	if notempty(state.entries[j][start-1]) and (state.byes[j][state.entries[j][start-1]['headerindex']] and isBlankEntry(j,start-1) and isBlankEntry(j,start+1) or state.hide[j][state.entries[j][start-1]['headerindex']]) then
		if bargs('show-bye-paths')~='y' and bargs('show-bye-paths')~='yes' then
			result=true
		end
	end
	if notempty(state.entries[j+1][stop-1]) and (state.byes[j+1][state.entries[j+1][stop-1]['headerindex']] and isBlankEntry(j+1,stop-1) and isBlankEntry(j+1,stop+1) or state.hide[j+1][state.entries[j+1][stop-1]['headerindex']])then
		if bargs('show-bye-paths')~='y' and bargs('show-bye-paths')~='yes' then
			result=true
		end
	end
	if bargs('RD'..j..'-RD'..(j+1)..'-path')=='n' or bargs('RD'..j..'-RD'..(j+1)..'-path')=='no' or bargs('RD'..j..'-RD'..(j+1)..'-path')=='0' then
		if notempty(state.entries[j][start-1]) and state.entries[j][start-1]['headerindex']==1 then
			result=true
		end
	end
	return result
end


-- ===============
-- Path helpers 
-- ===============

-- Helper: write a segment to a path cell
local function setPathCell(j, row, colIndex, borderIndex, value, color)
	state.pathCell[j][row][colIndex][1][borderIndex] = value
	state.pathCell[j][row][colIndex].color = color
end

local function setMultipleCells(j, row, colIndexes, borderIndex, value, color)
	for _, colIndex in ipairs(colIndexes) do
		state.pathCell[j][row][colIndex][1][borderIndex] = value
		state.pathCell[j][row][colIndex].color = color
	end
end

-- ===== Path drawing =====
-- Handle straight path (start == stop)
local function handleStraightPath(j, start, color, straightpaths)
	t_insert(straightpaths, { start, color })
end
-- Handle downward paths (start < stop)
local function handleDownwardPath(j, start, stop, cross, color)
	if stop > config.r then return end
	setPathCell(j, start + 1, 1, 1, 1, color)

	if cross == 0 then
		if state.hascross[j] then
			setPathCell(j, start + 1, 2, 1, 1, color)
			for i = start + 1, stop do
				setPathCell(j, i, 2, 2, 1, color)
			end
		else
			for i = start + 1, stop do
				setPathCell(j, i, 1, 2, 1, color)
			end
		end
	else
		state.crossCell[j][cross].left = { 1, color }
		for i = start + 1, cross - 1 do
			setPathCell(j, i, 1, 2, 1, color)
		end
		for i = cross + 2, stop do
			setPathCell(j, i, 2, 2, 1, color)
		end
	end

	setPathCell(j, stop, 3, 3, 1, color)
end
-- Handle upward paths (start > stop)
local function handleUpwardPath(j, start, stop, cross, color)
	if start > config.r then return end
	setPathCell(j, stop + 1, 3, 1, 1, color)

	if cross == 0 then
		if state.hascross[j] then
			for i = stop + 1, start do
				setPathCell(j, i, 2, 2, 1, color)
			end
			setPathCell(j, start, 2, 3, 1, color)
		else
			for i = stop + 1, start do
				setPathCell(j, i, 1, 2, 1, color)
			end
		end
	else
		state.crossCell[j][cross].right = { 1, color }
		for i = stop + 1, cross - 1 do
			setPathCell(j, i, 2, 2, 1, color)
		end
		for i = cross + 2, start do
			setPathCell(j, i, 1, 2, 1, color)
		end
	end

	setPathCell(j, start, 1, 3, 1, color)
end

-- ===== Thickness adjustments =====
-- Thicken start==stop paths
local function thickenStraightPaths(j, straightpaths)
	for _, sp in ipairs(straightpaths) do
		local i, color = sp[1], sp[2]
		if i > config.r then break end

		if state.pathCell[j][i][1][1][3] == 0 then
			-- Set all three columns' bottom border to 1
			setMultipleCells(j, i, {1, 2, 3}, 3, 1, color)

			-- Set next row's top border to 0.5 if empty
			if state.pathCell[j][i+1][1][1][1] == 0 then
				setMultipleCells(j, i+1, {1, 2, 3}, 1, 0.5, color)
			end
		elseif state.pathCell[j][i+1][1][1][1] == 0 then
			-- Set next row's top border to 1
			setMultipleCells(j, i+1, {1, 3}, 1, 1, color)
			if state.hascross[j] then
				setMultipleCells(j, i+1, {2}, 1, 1, color)
			end
		end
	end
end
-- Adjust path thickness for outpaths (thicken/thin transitions)
local function adjustOutpaths(j, outpaths)
	for _, op in ipairs(outpaths) do
		local i = op[1]
		-- skip if this is the last row (i+1 would be out of range)
		if i < config.r then
			local top1, top2 = state.pathCell[j][i+1][1], state.pathCell[j][i+1][2]
			local bottom1, bottom2 = state.pathCell[j][i][1], state.pathCell[j][i][2]

			-- Thinning: strong bottom to empty top
			if bottom1[1][3] == 1 and top1[1][1] == 0 then
				top1[1][1] = 0.5 * bottom1[1][3]
				top2[1][1] = 0.5 * bottom2[1][3]
				top1.color = bottom1.color
				top2.color = bottom2.color

			-- Thickening: empty bottom to strong top
			elseif bottom1[1][3] == 0 and top1[1][1] == 1 then
				bottom1[1][3] = top1[1][1]
				bottom2[1][3] = top2[1][1]
				top1[1][1] = 0.5 * bottom1[1][3]
				top2[1][1] = 0.5 * bottom2[1][3]
				bottom1.color = top1.color
				bottom2.color = top2.color
			end
		end
	end
end
-- Thin double-in paths
local function thinDoubleInPaths(j, inpaths)
	for _, ip in ipairs(inpaths) do
		local i = ip[1]
		-- skip if this is the last row (i+1 would be out of range)
		if i < config.r then
			local curr3 = state.pathCell[j][i][3]
			local next3 = state.pathCell[j][i+1][3]

			if curr3[1][3] == 1 and next3[1][1] == 1 and curr3.color == next3.color then
				next3[1][1] = 0.5 * curr3[1][3]
			end
		end
	end
end

-- ===== Cross-column connections =====
-- Connect straight paths between adjacent columns
local function connectStraightPaths()
	for j = config.minc, config.c - 1 do
		for i = 1, config.r - 1 do
			local straightpath = false

			-- Check if the top entry in next column is blank or a bye
			local topEntry = state.entries[j+1][i-1]
			local isTopBlankOrBye =
				(topEntry == nil or (state.byes[j+1][topEntry.headerindex] and isBlankEntry(j+1, i-1)))

			if isTopBlankOrBye then
				local currPC, nextPC     = state.pathCell[j],     state.pathCell[j+1]
				local currEntry, nextEnt = state.entries[j],      state.entries[j+1]

				local cond1 = (currPC[i][3][1][3] ~= 0 and nextPC[i][1][1][3] ~= 0)
				local cond2 = (currPC[i+1][3][1][1] ~= 0 and nextPC[i+1][1][1][1] ~= 0)

				if cond1 or cond2 then
					if nextPC[i][1][1][3] == nextPC[i][3][1][3]
					and nextPC[i+1][1][1][1] == nextPC[i+1][3][1][1] then
						straightpath = true
					end

					-- Copy left path data from current col to next col
					nextPC[i][1][1][3]   = currPC[i][3][1][3]
					nextPC[i+1][1][1][1] = currPC[i+1][3][1][1]
					nextPC[i][2][1][3]   = currPC[i][3][1][3]
					nextPC[i+1][2][1][1] = currPC[i+1][3][1][1]

					-- Update entries to represent connecting lines
					nextEnt[i-1] = { ctype = 'line' }
					nextEnt[i]   = { ctype = 'blank' }
					if notempty(nextEnt[i+1]) then
						nextEnt[i+1].ctype = 'line2'
					else
						nextEnt[i+1] = { ctype = 'line2' }
					end
					nextEnt[i+2] = { ctype = 'blank' }

					-- If perfectly straight path, mirror left values to right
					if straightpath then
						nextPC[i][3][1][3]   = nextPC[i][1][1][3]
						nextPC[i+1][3][1][1] = nextPC[i+1][1][1][1]
					end
				end
			end
		end
	end
end

local function getPaths()
	local paths = {}

	-- Step 1: Determine which columns have cross paths
	for j = config.minc, config.c - 1 do
		state.hascross[j] = notempty(fargs['col' .. j .. '-col' .. (j + 1) .. '-cross'])
	end

	-- Step 2: Process each column
	for j = config.minc, config.c - 1 do
		paths[j] = parsePaths(j)

		-- Initialize per-column path data
		state.pathCell[j], state.crossCell[j], state.skipPath[j] = {}, {}, {}
		for i = 1, config.r do
			state.pathCell[j][i] = {}
			for k = 1, 3 do
				state.pathCell[j][i][k] = { {0, 0, 0, 0}, color = COLORS.path_line_color }
			end
			state.crossCell[j][i] = {
				left  = {0, COLORS.path_line_color},
				right = {0, COLORS.path_line_color}
			}
			state.skipPath[j][i] = false
		end

		-- Prepare cross location list
		local crossloc = split((fargs['col' .. j .. '-col' .. (j + 1) .. '-cross'] or '')
			:gsub("%s+", ""), {","}, true)
		local hasCrossLoc = notempty(crossloc[1])
		if state.shift[j] ~= 0 and hasCrossLoc then
			for n = 1, #crossloc do
				crossloc[n] = crossloc[n] + state.shift[j]
			end
		end

		-- Temporary holders for later thickening/thinning
		local straightpaths, outpaths, inpaths = {}, {}, {}

		-- Step 3: Process each path in the current column
		for _, path in ipairs(paths[j]) do
			local startRow = 2 * (path[1] + state.shift[j])     + (state.teamsPerMatch[j]     - 2)
			local stopRow  = 2 * (path[2] + state.shift[j + 1]) + (state.teamsPerMatch[j + 1] - 2)

			-- Build cross rows
			local crossRows = {0}
			if hasCrossLoc then
				for n = 1, #crossloc do
					crossRows[n] = 2 * crossloc[n] + (state.teamsPerMatch[j] - 2)
				end
			end

			-- Find applicable cross row
			local cross = 0
			for _, cr in ipairs(crossRows) do
				if (startRow < stopRow and cr < stopRow and cr > startRow)
				or (startRow > stopRow and cr > stopRow and cr < startRow) then
					cross = cr
				end
			end

			local color = path.color or COLORS.path_line_color
			t_insert(outpaths, { startRow, color })
			t_insert(inpaths, { stopRow,  color })

			if not isPathHidden(j, i, startRow, stopRow) then
				if startRow == stopRow then
					handleStraightPath(j, startRow, color, straightpaths)
				elseif startRow < stopRow then
					handleDownwardPath(j, startRow, stopRow, cross, color)
				else
					handleUpwardPath(j, startRow, stopRow, cross, color)
				end
			end
		end

		-- Step 4: Apply thickness adjustments
		thickenStraightPaths(j, straightpaths)
		adjustOutpaths(j, outpaths)
		thinDoubleInPaths(j, inpaths)
	end

	-- Step 5: Connect straight paths across columns
	connectStraightPaths()
end

local function getGroups()
	-- Helper: true if cell (or the next cell) is empty or blank text
	local function isBlankOrBlankText(j, i)
		if state.entries[j][i] == nil then
			if state.entries[j][i + 1] == nil then
				return true
			elseif state.entries[j][i + 1].ctype == 'text' and isBlankEntry(j, i + 1) then
				return true
			end
		elseif state.entries[j][i].ctype == 'text' and isBlankEntry(j, i) then
			return true
		end
		return false
	end

	for j = config.minc, config.c - 1 do
		-- Only handle standard 2-team matches (same as original)
		if state.teamsPerMatch[j] == 2 then
			local groupIndex = 0

			for i = 1, config.r - 1 do
				if state.pathCell[j][i][3][1][3] == 1 or state.pathCell[j][i + 1][3][1][1] == 1 then
					groupIndex = groupIndex + 1

					if isBlankOrBlankText(j, i) then
						local k = config.minc - 1
						repeat
							-- guard state.entries[j-k] existence before indexing
							if state.entries[j - k] and state.entries[j - k][i + 1]
							   and state.entries[j - k][i + 1].ctype == 'text'
							   and isBlankEntry(j - k, i + 1) then
								state.entries[j - k][i + 2] = nil
							end

							-- create blank placeholders as in original logic
							if state.entries[j - k] == nil then state.entries[j - k] = {} end
							state.entries[j - k][i]   = { ctype = 'blank' }
							state.entries[j - k][i+1] = { ctype = 'blank' }

							if k > 0 and noPaths(j - k, i) then
								state.skipPath[j - k][i]   = true
								state.skipPath[j - k][i+1] = true
							end

							k = k + 1
						until k > j - 1 or not isBlankOrBlankText(j - k, i) or not noPaths(j - k, i)

						k = k - 1

						if state.entries[j - k] == nil then state.entries[j - k] = {} end
						state.entries[j - k][i] = {
							ctype   = 'group',
							index   = groupIndex,
							colspan = k + 1
						}
						state.entries[j - k][i + 1] = { ctype = 'blank' }
						state.entries[j - k][i].group = bargs('RD' .. j .. '-group' .. groupIndex)
					end
				end
			end
		end
	end
end

local function getCells()
	local DEFAULT_TPM = 2
	local maxrow = 1
	local colentry = {}
	local hasNoHeaders = true

	-- Phase 1: Determine header presence and teamsPerMatch
	for j = config.minc, config.c do
		if notempty(fargs["col" .. j .. "-headers"]) then
			hasNoHeaders = false
		end
		state.teamsPerMatch[j] =
			tonumber(fargs["RD" .. j .. "-teams-per-match"]) or
			tonumber(fargs["col" .. j .. "-teams-per-match"]) or
			tonumber(fargs["teams-per-match"]) or
			DEFAULT_TPM
		state.maxtpm = m_max(state.maxtpm, state.teamsPerMatch[j])
	end

	-- Phase 2: Build colentry for each column
	for j = config.minc, config.c do
		state.entries[j] = {}
		state.shift[j] = tonumber(bargs("RD" .. j .. "-shift")) or tonumber(bargs("shift")) or 0

		colentry[j] = {
			split((fargs["col" .. j .. "-headers"] or ""):gsub("%s+", ""), {","}, true),
			split((fargs["col" .. j .. "-matches"] or ""):gsub("%s+", ""), {","}, true),
			split((fargs["col" .. j .. "-lines"]   or ""):gsub("%s+", ""), {","}, true),
			split((fargs["col" .. j .. "-text"]    or ""):gsub("%s+", ""), {","}, true),
		}

		if hasNoHeaders and fargs["noheaders"] ~= "y" and fargs["noheaders"] ~= "yes" then
			t_insert(colentry[j][1], 1)
		end
	end

	-- Ctype mapping for colentry positions
	local CTYPE_MAP = { "header", "team", "line", "text", "group" }

	-- Helper functions for each ctype
	local function populateTeam(j, rowIndex, n)
		if state.entries[j][rowIndex - 1] == nil and state.entries[j][rowIndex - 2] == nil then
			state.entries[j][rowIndex - 2] = { ctype = "text", index = n }
			state.entries[j][rowIndex - 1] = { ctype = "blank" }
		end
		state.entries[j][rowIndex] = { ctype = "team", index = state.teamsPerMatch[j] * n - (state.teamsPerMatch[j] - 1), position = "top" }
		state.entries[j][rowIndex + 1] = { ctype = "blank" }
		for m = 2, state.teamsPerMatch[j] do
			local idx = state.teamsPerMatch[j] * n - (state.teamsPerMatch[j] - m)
			state.entries[j][rowIndex + 2 * (m - 1)]     = { ctype = "team", index = idx }
			state.entries[j][rowIndex + 2 * (m - 1) + 1] = { ctype = "blank" }
		end
	end

	local function populateText(j, rowIndex, index)
		state.entries[j][rowIndex]     = { ctype = "text", index = index }
		state.entries[j][rowIndex + 1] = { ctype = "blank" }
	end

	local function populateLine(j, rowIndex)
		state.entries[j][rowIndex]     = { ctype = "line" }
		state.entries[j][rowIndex + 1] = { ctype = "blank" }
		state.entries[j][rowIndex + 2] = { ctype = "line2" }
		state.entries[j][rowIndex + 3] = { ctype = "blank" }
	end

	local function populateGroup(j, rowIndex, n)
		state.entries[j][rowIndex]     = { ctype = "group", index = n }
		state.entries[j][rowIndex + 1] = { ctype = "blank" }
	end

	local function populateDefault(j, rowIndex, n)
		state.entries[j][rowIndex]     = { ctype = "header", index = n, position = "top" }
		state.entries[j][rowIndex + 1] = { ctype = "blank" }
	end

	-- Phase 3: Populate entries for each column
	for j = config.minc, config.c do
		local textindex = 0
		for k, positions in ipairs(colentry[j]) do
			t_sort(positions)
			local ctype = CTYPE_MAP[k]

			for n = 1, #positions do
				if state.shift[j] ~= 0 and positions[n] > 1 then
					positions[n] = positions[n] + state.shift[j]
				end
				local rowIndex = 2 * positions[n] - 1
				maxrow = m_max(rowIndex + 2 * state.teamsPerMatch[j] - 1, maxrow)

				if ctype == "team" then
					populateTeam(j, rowIndex, n)
					textindex = n
				elseif ctype == "text" then
					populateText(j, rowIndex, textindex + n)
				elseif ctype == "line" then
					populateLine(j, rowIndex)
				elseif ctype == "group" then
					populateGroup(j, rowIndex, n)
				else
					populateDefault(j, rowIndex, n)
				end
			end
		end
	end

	if isempty(config.r) then
		config.r = maxrow
	end
end

--=== Build HTML output ===--
local function buildTable()
    local frame = mw.getCurrentFrame()
    local div = mw_html_create('div'):css('overflow', 'auto')

    -- Load TemplateStyles from the Template namespace
    div:wikitext(frame:extensionTag('templatestyles', '', {
        src = 'Module:Build bracket/styles.css'
    }))

    if config.height ~= 0 then div:css('height', config.height) end

    local tbl = mw_html_create('table'):addClass('brk')
    if config.nowrap then tbl:addClass('brk-nw') end

    -- Invisible header row for column widths
    tbl:tag('tr'):css('visibility', 'collapse')
    tbl:tag('td'):css('width', '1px')

    for j = config.minc, config.c do
        if config.seeds then tbl:tag('td'):css('width', getWidth('seed', '25px')) end
        tbl:tag('td'):css('width', getWidth('team', '150px'))

        local scoreWidth = getWidth('score', '25px')
        if state.maxlegs[j] == 0 then
            tbl:tag('td'):css('width', scoreWidth)
        else
            for l = 1, state.maxlegs[j] do
                tbl:tag('td'):css('width', scoreWidth)
            end
        end

        if config.aggregate and state.maxlegs[j] > 1 then
            tbl:tag('td'):css('width', getWidth('agg', scoreWidth))
        end

        if j ~= config.c then
            if state.hascross[j] then
                tbl:tag('td'):css('width', config.colspacing - 3 .. 'px'):css('padding-left', '4px')
                tbl:tag('td'):css('padding-left', '5px'):css('width', '5px')
                tbl:tag('td'):css('width', config.colspacing - 3 .. 'px'):css('padding-right', '2px')
            else
                tbl:tag('td'):css('width', config.colspacing - 3 .. 'px'):css('padding-left', '4px')
                tbl:tag('td'):css('width', config.colspacing - 3 .. 'px'):css('padding-right', '2px')
            end
        end
    end

    -- Table rows
    for i = 1, config.r do
        local row = tbl:tag('tr')
        row:tag('td'):css('height', '11px')
        for j = config.minc, config.c do
            insertEntry(row, j, i)
            if j ~= config.c then insertPath(row, j, i) end
        end
    end

    div:wikitext(tostring(tbl))
    return tostring(div)
end

--=== Main function ===--
function p.main(frame)
	frame:extensionTag('templatestyles', '', { src = 'Module:Build bracket/styles.css' })
	
    -- Reset state and config for each run
    state = {
        entries        = {},
        pathCell       = {},
        crossCell      = {},
        skipPath       = {},
        shift          = {},
        hascross       = {},
        teamsPerMatch  = {},
        rlegs          = {},
        maxlegs        = {},
        byes           = {},
        hide           = {},
        matchgroup     = {},
        headerindex    = {},
        masterindex    = 1,
        maxtpm         = 1,  -- used later
    }

    config = {
        autolegs     = false,
        nowrap       = true,
        autocol      = false,
        seeds        = true,
        forceseeds   = false,
        boldwinner   = false,
        boldwinner_mode     = 'off',   -- 'off' | 'high' | 'low'
		boldwinner_aggonly  = false,   -- true => only bold the 'agg' column
        aggregate           = false,    -- keeps layout conditionals working
		aggregate_mode      = 'off',    -- 'off' | 'manual' | 'sets' | 'score'
        paramstyle   = "indexed",
        colspacing   = 5,
        height       = 0,
        minc         = 1,
        c            = 1,   -- number of rounds
        r            = nil, -- rows (set later)
    }

     parseArgs(frame, config)

    -- === Data processing phases ===
    getCells(state, config)
    getAltIndices(state, config)
    assignParams(state, config)
    matchGroups(state, config)
    computeAggregate() 
    if config.boldwinner_mode ~= 'off' then
	    boldWinner(state, config)
	end
    getPaths(state, config)
    if config.minc == 1 then
        getGroups(state, config)
    end
    updateMaxLegs(state, config)

    -- === Output ===
    return buildTable(state, config)
end

return p