Jump to content

Module:Build bracket/sandbox

From Wikipedia, the free encyclopedia
This is an old revision of this page, as edited by Pbrks (talk | contribs) at 19:19, 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 = {}
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 'Final'
    elseif rem == 1 then
        return 'Semifinals'
    elseif rem == 2 then
        return 'Quarterfinals'
    else
        return 'Round ' .. tostring(j)
    end
end

local function noPaths(j, i)
	-- Forward declarations for path helpers defined later
	local getBorder
	local cols = state.hascross[j] and 3 or 2
	for k = 1, cols do
    	if getBorder(j, i, k, 'top') ~= 0
    	or getBorder(j, i, k, 'right') ~= 0
    	or getBorder(j, i, k, 'bottom') ~= 0
    	or getBorder(j, i, k, 'left') ~= 0 then
			return false
		end
	end

	if state.hascross[j] then
    	local cc = state.crossCell[j] and state.crossCell[j][i]
    	if cc and ((cc.left and cc.left[1] == 1) or (cc.right and cc.right[1] == 1)) then
    	return false
    	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

local function makePathCell()
  return {
    borders = { top = 0, right = 0, bottom = 0, left = 0 },
    color   = COLORS.path_line_color
  }
end

-- Draw a single path cell in the bracket table
local function generatePathCell(tbl, j, i, k, bg, rowspan)
  -- Skip center cell if there is no cross path
  if not state.hascross[j] and k == 2 then return end

  local cellData = ensurePathCell(j, i, k)

  local td = tbl:tag('td')
  if rowspan ~= 1 then td:attr('rowspan', rowspan) end

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

  local b = cellData.borders
  if b.top ~= 0 or b.right ~= 0 or b.bottom ~= 0 or b.left ~= 0 then
    td:css('border', 'solid ' .. cellData.color)
      :css('border-width',
        (2*b.top) .. 'px ' ..
        (2*b.right) .. 'px ' ..
        (2*b.bottom) .. 'px ' ..
        (2*b.left) .. 'px')
  end
  return td
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
-- ========================

-- Edge map (supports both names and 1..4 indices)
local EDGE_MAP = { top=1, right=2, bottom=3, left=4 }
local EDGE_REV = { [1]='top', [2]='right', [3]='bottom', [4]='left' }

local function makePathCell()
	return {
    	borders = { top=0, right=0, bottom=0, left=0 },
    	color   = COLORS.path_line_color
	}
end

-- Ensure and return the PathCell at (j,row,k)
local function ensurePathCell(j, row, k)
	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 makePathCell()
	return state.pathCell[j][row][k]
end

-- Set one edge (edge can be 'top'/'right'/'bottom'/'left' or 1..4)
local function setBorder(j, row, k, edge, val, color)
	local cell = ensurePathCell(j, row, k)
	local e = EDGE_REV[edge] or edge -- allow numeric or named
	cell.borders[e] = val
	if color then cell.color = color end
end

-- Read one edge (returns 0 if missing)
getBorder = function(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

local function getCellColor(j, row, k)
	local cell = state.pathCell[j] and state.pathCell[j][row] and state.pathCell[j][row][k]
	return (cell and cell.color) or COLORS.path_line_color
end

-- Convenience for writing many k’s at once
local function setMany(j, row, ks, edge, val, color)
	for _, k in ipairs(ks) do setBorder(j, row, k, edge, val, color) end
end

-- Back‑compat adapters so old calls don’t crash while you migrate:
--   setPathCell(j,row,k,edgeIndex,val,color)
--   setMultipleCells(j,row,{k...},edgeIndex,val,color)
local function edgeIndexToName(n) return EDGE_REV[n] end
function setPathCell(j, row, k, edgeIndex, val, color)
	setBorder(j, row, k, edgeIndexToName(edgeIndex), val, color)
end
function setMultipleCells(j, row, ks, edgeIndex, val, color)
	setMany(j, row, ks, edgeIndexToName(edgeIndex), val, color)
end

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