跳转到内容

模組:Build bracket/sandbox

本页使用了标题或全文手工转换
维基百科,自由的百科全书
local p = {}

local state = {}
local config = {}

--========================
-- 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
	local 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) -- reads entries
	local colEntries = state.entries[col]
    if not colEntries then return true end
    local e = colEntries[row]
    if not e then return true end
    return isempty(e.team) and isempty(e.text)
end

local function showSeeds(j, i) -- reads entries, teamsPerMatch, forceseeds
    local row = state.entries[j]
    if not row then return false end

    local e = row[i]
    if not e or e.ctype ~= 'team' then return false end

    -- Force show, or if this team already has a seed
    if config.forceseeds or notempty(e.seed) then
        return true
    end

    local group = e.group
    local tpm   = state.teamsPerMatch[j] or 2
    local step  = 2  -- layout uses every 2 rows for teams in a match

    local function neighborHasSeed(idx)
        local n = row[idx]
        return n and n.ctype == 'team' and n.group == group and notempty(n.seed)
    end

    for k = 1, tpm - 1 do
        local plus  = i + step * k
        local minus = i - step * k

        if plus  <= config.r and neighborHasSeed(plus)  then return true end
        if minus >= 1        and neighborHasSeed(minus) then return true end
    end

    return false
end

local function isRoundHidden(j, i, headerindex) -- mutates state.hide
    local col = state.entries[j]
    if not col then return end

    local e = col[i]
    if not e then return end

    local hidx = headerindex or e.headerindex
    if not state.hide[j] then state.hide[j] = {} end

    -- If there is a parent header, this header should be shown
    if notempty(e.pheader) then
        state.hide[j][hidx] = false
        return
    end

    -- Scan forward until next header (or end of round).
    local row = i + 1
    while row <= config.r do
        local r = col[row]
        if r and r.ctype == 'header' then
            break
        end
        if not isBlankEntry(j, row) then
            state.hide[j][hidx] = false
            break
        end
        row = row + 1
    end
end

local function teamLegs(j, i) -- reads entries, rlegs, autolegs
    local col = state.entries[j]
    if not col then return state.rlegs[j] or 1 end

    local e = col[i]
    if not e or e.ctype ~= 'team' then
        return state.rlegs[j] or 1
    end

    -- start with round default
    local legs = state.rlegs[j] or 1

    -- named override (if present)
    if notempty(e.legs) then
        legs = tonumber(e.legs) or legs
    end

    -- helper: treat nil/'' and values containing 'nbsp' as blank
    local function isScoreBlank(v)
        if isempty(v) then return true end
        return type(v) == 'string' and v:find('nbsp', 1, true) ~= nil
    end

    -- autolegs: count contiguous non-blank leg entries starting at 1
    if config.autolegs and e.score then
        local l = 1
        while not isScoreBlank(e.score[l]) do
            l = l + 1
        end
        local inferred = l - 1
        -- if nothing filled yet, keep prior legs; otherwise use inferred
        if inferred > 0 then
            legs = inferred
        end
    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
    local col = state.entries[j]
    if not col then return true end

    local row = i + 1
    while row <= config.r do
        local e = col[row]
        if e and e.ctype == 'header' then
            break
        end
        if not isBlankEntry(j, row) then
            return false
        end
        row = row + 1
    end
    return true
end

-- Default header text when none is provided
local function defaultHeaderText(j, headerindex)
    -- Non-primary headers
    if headerindex ~= 1 then
        return 'Lower round ' .. tostring(j)
    end

    -- Distance from the final column
    local c = tonumber(config.c) or j
    local rem = c - j
    if rem == 0 then
        return '決賽'
    elseif rem == 1 then
        return '-{zh-hans:半决赛; zh-hant:準決賽;}-'
    elseif rem == 2 then
        return '-{zh-hans:四分之一决赛; zh-hant:半準決賽;}-'
    else
        return '第' .. tostring(j) '-{zh-hans:轮; zh-hant:輪; zh-hk:圈;}-'
    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)

    -- Border mask: {top, right, bottom, left}
    local borderWidth = {0, 0, 0, 0}

    -- If a caller precomputed a mask, use it
    if entry.borderWidth then
        borderWidth = entry.borderWidth
    else
        -- Otherwise derive from pathCell + the caller's intent:
        --   entry.border = 'top' | 'bottom' | 'both' (default: 'bottom')
        local wantTop    = entry.border == 'top' or entry.border == 'both'
        local wantBottom = (entry.border == nil) or entry.border == 'bottom' or entry.border == 'both'

        -- bottom border uses (j-1, i+1)
        if wantBottom and state.pathCell[j - 1] and state.pathCell[j - 1][i + 1] then
            borderWidth[3] = 2 * (state.pathCell[j - 1][i + 1][3][1][3] or 0)
        end

        -- top border uses (j-1, i)
        if wantTop and state.pathCell[j - 1] and state.pathCell[j - 1][i] then
            borderWidth[1] = 2 * (state.pathCell[j - 1][i][3][1][1] or 0)
        end
    end

    local cell = Cell(tbl, j, i, {
        rowspan     = 2,
        colspan     = entry_colspan,
        text        = entry.text,
        borderWidth = borderWidth,
    })
    cell:addClass('brk-line')
    return cell
end

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

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)
    local row = state.entries[j]
    if not row then return end

    local function nextArg()
        local v = bargs(tostring(masterindex)) or ''
        masterindex = masterindex + 1
        return v
    end

    for i = 1, config.r do
        local e = row[i]
        if e then
            local ct = e.ctype

            if ct == 'team' then
                local legs = state.rlegs[j]

                if config.forceseeds then
                    e.seed = nextArg()
                end

                e.team   = nextArg()
                e.legs   = paramNames('legs', j, i)
                e.score  = { weight = {} }
                e.weight = 'normal'

                if notempty(e.legs) then
                    legs = tonumber(e.legs) or legs
                end

                for l = 1, legs do
                    e.score[l] = nextArg()
                    e.score.weight[l] = 'normal'
                end

                if config.aggregate and legs > 1 then
                    e.score.agg = nextArg()
                    e.score.weight.agg = 'normal'
                end

            elseif ct == 'header' then
                e.header  = paramNames('header',  j, i)
                e.pheader = paramNames('pheader', j, i)
                e.shade   = paramNames('shade',   j, i)

            elseif ct == 'text' then
                e.text = nextArg()

            elseif ct == 'group' then
                e.group = nextArg()

            elseif ct == 'line' and e.hastext == true then
                e.text = nextArg()
            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()
    for j = config.minc, config.c do
        state.headerindex[j] = 0

        -- per-round counters
        local teamindex  = 1
        local textindex  = 1
        local groupindex = 1

        local row = state.entries[j]

        -- if the very first cell is nil, bump headerindex once
        if row and row[1] == nil then
            state.headerindex[j] = state.headerindex[j] + 1
        end

        -- walk rows in the round
        for i = 1, config.r do
            local e = row and row[i] or nil
            if e then
                local ct = e.ctype  -- dot access instead of ['ctype']

                if ct == 'header' then
                    e.altindex = state.headerindex[j]
                    teamindex  = 1
                    textindex  = 1
                    state.headerindex[j] = state.headerindex[j] + 1

                elseif ct == 'team' then
                    e.altindex = teamindex
                    teamindex  = teamindex + 1

                elseif ct == 'text' or (ct == 'line' and e.hastext == true) then
                    -- treat 'line' with text like a text entry (matches your original)
                    e.altindex = textindex
                    textindex  = textindex + 1

                elseif ct == 'group' then
                    e.altindex = groupindex
                    groupindex = groupindex + 1
                end

                e.headerindex = state.headerindex[j]
            end
        end

        getByes(j,  state.headerindex[j])
        getHide(j,  state.headerindex[j])
    end
end

local function matchGroups() -- mutates state.matchgroup and entry.group
    for j = config.minc, config.c do
        state.matchgroup[j] = {}
        local mgj = state.matchgroup[j]

        local tpm = tonumber(state.teamsPerMatch[j]) or 2
        if tpm < 1 then tpm = 2 end

        local col = state.entries[j]
        if col then
            for i = 1, config.r do
                local e = col[i]
                if e and e.ctype == 'team' then
                    local idx = tonumber(e.index) or tonumber(e.altindex) or i
                    local g = m_ceil(idx / tpm)
                    mgj[i] = g
                    e.group = g
                end
            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
            array[3] = array[3] or {}  -- init once
            for m=1,#array[2] do
                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, start, stop)
    -- normalize "show-bye-paths"
    local function truthy(s)
        s = (s or ''):lower()
        return s == 'y' or s == 'yes' or s == '1' or s == 'true'
    end
    local showBye = truthy(bargs('show-bye-paths'))

    -- check one side (source or destination)
    local function sideHidden(col, headerRow, neighborRow)
        local colEntries = state.entries[col]
        local e = colEntries and colEntries[headerRow]
        if not e then return false end

        local hidx  = e.headerindex
        local byes  = state.byes[col]  and state.byes[col][hidx]
        local hid   = state.hide[col]  and state.hide[col][hidx]

        -- hide if it's a bye round with both adjacent slots blank (unless show-bye-paths)
        if byes and isBlankEntry(col, headerRow) and isBlankEntry(col, neighborRow) then
            if not showBye then return true end
        end

        -- hide if that header is explicitly hidden
        if hid then return true end

        return false
    end

    -- source side (round j)
    if sideHidden(j,     start - 1, start + 1) then return true end
    -- destination side (round j+1)
    if sideHidden(j + 1, stop  - 1, stop  + 1) then return true end

    -- explicit RDj-RDj+1 path suppression (only affects primary headerindex==1)
    local rdflag = (bargs('RD' .. j .. '-RD' .. (j + 1) .. '-path') or ''):lower()
    if rdflag == 'n' or rdflag == 'no' or rdflag == '0' then
        local srcHdr = state.entries[j] and state.entries[j][start - 1]
        if srcHdr and srcHdr.headerindex == 1 then
            return true
        end
    end

    return false
end


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

-- ensure nested tables exist and return the [1] border array and the cell table
local function ensurePathSlot(j, row, colIndex)
    state.pathCell[j]                   = state.pathCell[j]                   or {}
    state.pathCell[j][row]              = state.pathCell[j][row]              or {}
    state.pathCell[j][row][colIndex]    = state.pathCell[j][row][colIndex]    or {}
    local cell = state.pathCell[j][row][colIndex]
    cell[1] = cell[1] or { [1]=0, [2]=0, [3]=0, [4]=0 }
    return cell[1], cell
end

-- Helper: write a segment to a path cell
local function setPathCell(j, row, colIndex, borderIndex, value, color)
    local borders, cell = ensurePathSlot(j, row, colIndex)
    if borderIndex >= 1 and borderIndex <= 4 then
        borders[borderIndex] = value
    end
    cell.color = color
end

-- Safe read of a border; returns 0 if absent
local function getBorder(j, row, k, edge)
    state.pathCell[j]                = state.pathCell[j]                or {}
    state.pathCell[j][row]           = state.pathCell[j][row]           or {}
    state.pathCell[j][row][k]        = state.pathCell[j][row][k]        or { {0,0,0,0}, color = COLORS.path_line_color }
    local borders = state.pathCell[j][row][k][1]
    return borders[edge] or 0
end

-- Safe fetch of a cell color (falls back to default)
local function getCellColor(j, row, k)
    local col = state.pathCell[j]
    local cell = col and col[row] and col[row][k]
    return (cell and cell.color) or COLORS.path_line_color
end


local function setMultipleCells(j, row, colIndexes, borderIndex, value, color)
    for _, colIndex in ipairs(colIndexes) do
        setPathCell(j, row, colIndex, borderIndex, value, 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] and state.entries[j+1][i-1]
            local isTopBlankOrBye =
                (topEntry == nil or (state.byes[j+1][topEntry and topEntry.headerindex] and isBlankEntry(j+1, i-1)))

            if isTopBlankOrBye then
                -- Make sure the big containers exist
                state.pathCell[j]   = state.pathCell[j]   or {}
                state.pathCell[j+1] = state.pathCell[j+1] or {}
                state.entries[j+1]  = state.entries[j+1]  or {}

                local currEnt = state.entries[j]
                local nextEnt = state.entries[j+1]

                if state.pathCell[j] and state.pathCell[j+1] and currEnt and nextEnt then
                    -- Read with guards
                    local curr_i3_b = getBorder(j,   i,   3, 3) -- current col, row i, right cell, bottom edge
                    local next_i1_b = getBorder(j+1, i,   1, 3) -- next col, row i, left cell,   bottom edge
                    local curr_ip1_t= getBorder(j,   i+1, 3, 1) -- current col, row i+1, right, top edge
                    local next_ip1_t= getBorder(j+1, i+1, 1, 1) -- next col, row i+1, left,  top edge

                    local cond1 = (curr_i3_b ~= 0 and next_i1_b ~= 0)
                    local cond2 = (curr_ip1_t ~= 0 and next_ip1_t ~= 0)

                    if cond1 or cond2 then
                        -- Detect "perfectly straight" (mirror left/right equal)
                        local next_i3_b = getBorder(j+1, i,   3, 3)
                        local next_ip1_1= getBorder(j+1, i+1, 3, 1)
                        if next_i1_b == next_i3_b and next_ip1_t == next_ip1_1 then
                            straightpath = true
                        end

                        -- Colors to propagate
                        local color_i   = getCellColor(j,   i,   3)
                        local color_ip1 = getCellColor(j,   i+1, 3)

                        -- Copy left path data from current col to next col (write via safe setter)
                        setPathCell(j+1, i,   1, 3, curr_i3_b, color_i)
                        setPathCell(j+1, i+1, 1, 1, curr_ip1_t, color_ip1)
                        setPathCell(j+1, i,   2, 3, curr_i3_b, color_i)
                        setPathCell(j+1, i+1, 2, 1, curr_ip1_t, color_ip1)

                        -- Update entries to represent connecting lines
                        nextEnt[i-1] = { ctype = 'line', border = 'bottom' }
                        nextEnt[i]   = { ctype = 'blank' }
                        if nextEnt[i+1] ~= nil then
                            nextEnt[i+1].ctype  = 'line'
                            nextEnt[i+1].border = 'top'
                        else
                            nextEnt[i+1] = { ctype = 'line', border = 'top' }
                        end
                        nextEnt[i+2] = { ctype = 'blank' }

                        -- If perfectly straight path, mirror left values to right
                        if straightpath then
                            setPathCell(j+1, i,   3, 3, getBorder(j+1, i,   1, 3), getCellColor(j+1, i,   1))
                            setPathCell(j+1, i+1, 3, 1, getBorder(j+1, i+1, 1, 1), getCellColor(j+1, i+1, 1))
                        end
                    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, 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
	
    -- Ensure a blank path/cross/skip column for the terminal round (j = config.c)
    state.pathCell[config.c]  = state.pathCell[config.c]  or {}
    state.crossCell[config.c] = state.crossCell[config.c] or {}
    state.skipPath[config.c]  = state.skipPath[config.c]  or {}

    for i = 1, config.r do
        state.pathCell[config.c][i] = state.pathCell[config.c][i] or {}
        for k = 1, 3 do
            state.pathCell[config.c][i][k] =
                state.pathCell[config.c][i][k] or { {0, 0, 0, 0}, color = COLORS.path_line_color }
        end
        state.crossCell[config.c][i] = state.crossCell[config.c][i] or {
            left  = {0, COLORS.path_line_color},
            right = {0, COLORS.path_line_color}
        }
        if state.skipPath[config.c][i] == nil then
            state.skipPath[config.c][i] = false
        end
    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)
	    -- first segment draws its bottom edge
	    state.entries[j][rowIndex]     = { ctype = "line", border = "bottom" }
	    state.entries[j][rowIndex + 1] = { ctype = "blank" }
	
	    -- second segment draws its top edge
	    state.entries[j][rowIndex + 2] = { ctype = "line", border = "top" }
	    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

--========================
-- Deprecations (simple)
--========================

-- Only add categories in mainspace and when nocat is not set.
local function trackingAllowed()
    if yes(bargs('nocat')) then return false end
    local title = mw.title.getCurrentTitle()
    return title and title.namespace == 0  -- articles only
end

-- Scan current args for deprecated usage based on config.deprecations
local function checkDeprecations()
    state.deprecated_hits = {}
    local rules = config.deprecations or {}
    if next(rules) == nil then return end

    -- 1) Specific param with specific deprecated values, e.g. paramstyle = indexed
    if rules.paramstyle then
        local raw = bargs('paramstyle')
        if raw and rules.paramstyle[(raw or ''):lower()] then
            t_insert(state.deprecated_hits, { name='paramstyle', value=raw })
        end
    end

    -- 2) Any param that ends with "-byes" and is set to a deprecated "yes"/"y"
    if rules['*-byes'] then
        for name, value in pairs(fargs or {}) do
            if type(name) == 'string' and name:sub(-5) == '-byes' then
                local v = (tostring(value or '')):lower()
                if rules['*-byes'][v] then
                    t_insert(state.deprecated_hits, { name = name, value = value })
                end
            end
        end
    end
end

-- Emit unique categories (in preview is OK; we only limit to mainspace and nocat)
local function emitDeprecationCats()
    if not trackingAllowed() then return '' end
    if not state.deprecated_hits or #state.deprecated_hits == 0 then return '' end
    local seen, out = {}, {}
    local cat = config.deprecation_category or 'Bracket pages using deprecated parameters'
    -- single bucket (simple)
    if not seen[cat] then
        out[#out+1] = '[[Category:' .. cat .. ']]'
        seen[cat] = true
    end
    return table.concat(out)
end

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

    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)
        deprecation_category = 'Pages using Build Bracket with deprecated parameters',
    	deprecations = {
        paramstyle = { numbered = true },
        -- example: any ...-byes set to yes/y
        ---- ['*-byes'] = { yes = true, y = true },
    },
    }

     parseArgs(frame, config)
     checkDeprecations()

    -- === 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 ===
    local out = buildTable(state, config)
    return tostring(out) .. emitDeprecationCats()
end

return p