Toggle menu
Toggle preferences menu
Toggle personal menu
Not logged in
Your IP address will be publicly visible if you make any edits.

Documentation for this module may be created at Module:Bracket/doc

-- ================================================================
-- Module:Bracket  v2.0
-- Supports: Single Elimination, Double Elimination, GSL, Round Robin, Swiss
-- Data: Cargo-driven (BracketMatch table) + inline template args
-- Match popup: embeds data-* attrs, JS handles modal
-- ================================================================

local p     = {}
local html  = mw.html
local cargo = mw.ext.cargo

-- ── Helpers ──────────────────────────────────────────────────────

local function esc(s)
    if not s then return "" end
    return s:gsub("\\","\\\\"):gsub("'","\\'")
end

local function clean(s)
    if not s then return nil end
    local c = s:gsub("[\r\n]",""):gsub("^%s*(.-)%s*$","%1")
    return c ~= "" and c or nil
end

-- Fetch team logo HTML (light/dark aware, matches your existing pattern)
local teamLogoCache = {}
local function getTeamLogo(teamName, size)
    if not teamName or teamName == "" then return nil end
    size = size or "20px"
    if teamLogoCache[teamName] then
        local t = teamLogoCache[teamName]
        if not t then return nil end
        local light = t.image or ""
        local dark  = (t.image_dark and t.image_dark ~= "") and t.image_dark or light
        if light == "" then return nil end
        return "[[File:"..light.."|"..size.."|link=|class=logo-lightmode bk-team-logo]]"
            .. "[[File:"..dark .."|"..size.."|link=|class=logo-darkmode bk-team-logo]]"
    end
    local rows = cargo.query("Teams","image,image_dark",
        {where="name='"..esc(teamName).."'", limit=1})
    local t = (rows and #rows > 0) and rows[1] or false
    teamLogoCache[teamName] = t
    if not t then return nil end
    local light = t.image or ""
    local dark  = (t.image_dark and t.image_dark ~= "") and t.image_dark or light
    if light == "" then return nil end
    return "[[File:"..light.."|"..size.."|link=|class=logo-lightmode bk-team-logo]]"
        .. "[[File:"..dark .."|"..size.."|link=|class=logo-darkmode bk-team-logo]]"
end

-- Fetch match data from Cargo BracketMatch table
-- Returns: { team1, score1, team2, score2, winner, bo, date, casters, vod }
local function fetchMatch(event, matchID)
    local rows = cargo.query("BracketMatch",
        "team1,score1,team2,score2,winner,bo,match_date,casters,vod,notes,short1,short2",
        { where = "event='"..esc(event).."' AND match_id='"..esc(matchID).."'",
          limit = 1 })
    if rows and #rows > 0 then return rows[1] end
    return {}
end

-- Merge cargo data with inline template args (inline wins if set)
local function mergeMatchData(cargoData, args, prefix)
    local d = {}
    d.team1   = clean(args[prefix.."team1"])   or clean(cargoData.team1)   or "TBD"
    d.team2   = clean(args[prefix.."team2"])   or clean(cargoData.team2)   or "TBD"
    -- short names: used for display in bracket card; falls back to full team name
    d.short1  = clean(args[prefix.."short1"])  or clean(cargoData.short1)  or d.team1
    d.short2  = clean(args[prefix.."short2"])  or clean(cargoData.short2)  or d.team2
    d.score1  = clean(args[prefix.."score1"])  or clean(cargoData.score1)  or ""
    d.score2  = clean(args[prefix.."score2"])  or clean(cargoData.score2)  or ""
    d.winner  = clean(args[prefix.."win"])     or clean(cargoData.winner)  or ""
    d.bo      = clean(args[prefix.."bo"])      or clean(cargoData.bo)      or ""
    d.date    = clean(args[prefix.."date"])    or clean(cargoData.match_date) or ""
    d.casters = clean(args[prefix.."casters"]) or clean(cargoData.casters) or ""
    d.vod     = clean(args[prefix.."vod"])     or clean(cargoData.vod)     or ""
    d.notes   = clean(args[prefix.."notes"])   or clean(cargoData.notes)   or ""
    return d
end

-- ── Match Card Renderer ───────────────────────────────────────────
-- Used by ALL bracket types. Renders one match box.
-- winner: '1' or '2' (or team name matching team1/team2)
-- matchID: used for popup data attribute
-- label: optional label above card (e.g. "Upper Final")

local function renderMatchCard(container, d, matchID, label, extraClass)
    local wrap = container:tag('div'):addClass('bk-match-wrap')

    -- Header row: match label (left) + BO badge (right) on same line
    local headerRow = wrap:tag('div'):addClass('bk-header-row')
    if label and label ~= "" then
        headerRow:tag('span'):addClass('bk-match-label-inline'):wikitext(label)
    end
    if d.bo and d.bo ~= "" then
        headerRow:tag('span'):addClass('bk-bo-badge'):wikitext('Bo'..d.bo)
    end

    local matchBox = wrap:tag('div'):addClass('bk-match')
    if extraClass and extraClass ~= "" then matchBox:addClass(extraClass) end

    -- Determine winner
    local win1 = (d.winner == "1" or d.winner == d.team1) and d.team1 ~= "TBD"
    local win2 = (d.winner == "2" or d.winner == d.team2) and d.team2 ~= "TBD"
    local isPlayed = (d.score1 ~= "" or d.score2 ~= "") or (d.winner ~= "")

    -- Data attrs for popup (JS reads these)
    matchBox:attr('data-match-id', matchID)
    matchBox:attr('data-team1',    d.team1)
    matchBox:attr('data-team2',    d.team2)
    matchBox:attr('data-score1',   d.score1)
    matchBox:attr('data-score2',   d.score2)
    matchBox:attr('data-bo',       d.bo)
    matchBox:attr('data-date',     d.date)
    matchBox:attr('data-casters',  d.casters)
    matchBox:attr('data-vod',      d.vod)
    matchBox:attr('data-notes',    d.notes)
    if isPlayed then matchBox:addClass('bk-match-played') end
    matchBox:addClass('bk-match-clickable') -- JS hook

    -- Team 1
    local row1 = matchBox:tag('div'):addClass('bk-team')
    if win1 then row1:addClass('bk-win') end
    if not win1 and win2 then row1:addClass('bk-lose') end
    local logo1 = getTeamLogo(d.team1, "18px")
    local nameCell1 = row1:tag('div'):addClass('bk-team-name')
    if logo1 then nameCell1:wikitext(logo1) end
    if d.team1 ~= "TBD" then
        -- link uses full team name, display uses short name
        nameCell1:tag('span'):wikitext("[["..d.team1.."|"..d.short1.."]]")
    else
        nameCell1:tag('span'):addClass('bk-tbd'):wikitext("TBD")
    end
    if win1 then row1:tag('div'):addClass('bk-win-icon'):wikitext('✓') end
    local scoreBox1 = row1:tag('div'):addClass('bk-score')
    if d.score1 ~= "" then scoreBox1:wikitext(d.score1) end

    -- Divider
    matchBox:tag('div'):addClass('bk-divider')

    -- Team 2
    local row2 = matchBox:tag('div'):addClass('bk-team')
    if win2 then row2:addClass('bk-win') end
    if not win2 and win1 then row2:addClass('bk-lose') end
    local logo2 = getTeamLogo(d.team2, "18px")
    local nameCell2 = row2:tag('div'):addClass('bk-team-name')
    if logo2 then nameCell2:wikitext(logo2) end
    if d.team2 ~= "TBD" then
        nameCell2:tag('span'):wikitext("[["..d.team2.."|"..d.short2.."]]")
    else
        nameCell2:tag('span'):addClass('bk-tbd'):wikitext("TBD")
    end
    if win2 then row2:tag('div'):addClass('bk-win-icon'):wikitext('✓') end
    local scoreBox2 = row2:tag('div'):addClass('bk-score')
    if d.score2 ~= "" then scoreBox2:wikitext(d.score2) end

    return matchBox
end

-- ════════════════════════════════════════════════════════════════
-- FORMAT 1: SINGLE ELIMINATION
-- ════════════════════════════════════════════════════════════════

local function renderSingleElim(args, event, teamCount)
    -- teamCount must be power of 2: 4, 8, 16, 32
    local rounds = math.floor(math.log(teamCount) / math.log(2))
    if 2^rounds ~= teamCount then
        return "<div class='bk-error'>Single elimination requires power-of-2 team count (4, 8, 16, 32). Got: "..teamCount.."</div>"
    end

    local roundNames = {
        [1] = args.r1name or "Round 1",
    }
    -- Auto-name rounds by position from the end
    local suffixNames = { "Grand Final", "Semifinal", "Quarterfinal", "Round of 16", "Round of 32" }
    for i = rounds, 1, -1 do
        local pos = rounds - i + 1  -- 1 = Grand Final
        roundNames[i] = args["r"..i.."name"] or suffixNames[pos] or ("Round "..i)
    end

    local scroll = html.create('div'):addClass('bk-scroll')
    local wrapper = scroll:tag('div'):addClass('bk-wrapper bk-single-elim')

    for r = 1, rounds do
        local col = wrapper:tag('div'):addClass('bk-col bk-col-'..r)
        col:tag('div'):addClass('bk-col-header'):wikitext(roundNames[r])

        local matchCount = teamCount / (2^r)
        for m = 1, matchCount do
            local matchID = "R"..r.."M"..m
            local cd = fetchMatch(event, matchID)
            local d  = mergeMatchData(cd, args, matchID)

            local spacer = col:tag('div'):addClass('bk-spacer')
            renderMatchCard(spacer, d, matchID, nil, "has-connector")
        end
    end

    return tostring(scroll)
end

-- ════════════════════════════════════════════════════════════════
-- FORMAT 2: DOUBLE ELIMINATION
-- Standard: Upper Bracket + Lower Bracket + Grand Final
-- ════════════════════════════════════════════════════════════════

local function renderDoubleElim(args, event, teamCount)
    -- Supports 4, 8, 16 teams
    local ubRounds  -- Upper bracket rounds before GF
    local lbRounds  -- Lower bracket rounds

    if teamCount == 4 then
        ubRounds = 2; lbRounds = 2
    elseif teamCount == 8 then
        ubRounds = 3; lbRounds = 4
    elseif teamCount == 16 then
        ubRounds = 4; lbRounds = 6
    else
        return "<div class='bk-error'>Double elimination supports 4, 8, or 16 teams. Got: "..teamCount.."</div>"
    end

    local scroll  = html.create('div'):addClass('bk-scroll')
    local wrapper = scroll:tag('div'):addClass('bk-wrapper bk-double-elim')

    -- ── Upper Bracket ──
    local ubSection = wrapper:tag('div'):addClass('bk-section')
    ubSection:tag('div'):addClass('bk-section-title'):wikitext('Upper Bracket')
    local ubRow = ubSection:tag('div'):addClass('bk-row')

    local ubRoundNames = {}
    local ubSuffix = {"Upper Final","Upper Semifinal","Upper Quarterfinal","Upper Round 1"}
    for r = ubRounds, 1, -1 do
        local pos = ubRounds - r + 1
        ubRoundNames[r] = args["ubr"..r.."name"] or ubSuffix[pos] or ("UB Round "..r)
    end

    for r = 1, ubRounds do
        local col = ubRow:tag('div'):addClass('bk-col bk-col-'..r)
        col:tag('div'):addClass('bk-col-header'):wikitext(ubRoundNames[r])
        local matchCount = teamCount / (2^r)
        for m = 1, matchCount do
            local matchID = "UBR"..r.."M"..m
            local cd = fetchMatch(event, matchID)
            local d  = mergeMatchData(cd, args, matchID)
            local sp = col:tag('div'):addClass('bk-spacer')
            renderMatchCard(sp, d, matchID, nil, r < ubRounds and "has-connector" or "")
        end
    end

    -- ── Lower Bracket ──
    local lbSection = wrapper:tag('div'):addClass('bk-section bk-lower-section')
    lbSection:tag('div'):addClass('bk-section-title bk-lower-title'):wikitext('Lower Bracket')
    local lbRow = lbSection:tag('div'):addClass('bk-row')

    local lbRoundNames = {}
    local lbSuffix = {"Lower Final","Lower Semifinal","Lower Round 4","Lower Round 3","Lower Round 2","Lower Round 1"}
    for r = lbRounds, 1, -1 do
        local pos = lbRounds - r + 1
        lbRoundNames[r] = args["lbr"..r.."name"] or lbSuffix[pos] or ("LB Round "..r)
    end

    for r = 1, lbRounds do
        local col = lbRow:tag('div'):addClass('bk-col bk-col-lb-'..r)
        col:tag('div'):addClass('bk-col-header bk-lb-header'):wikitext(lbRoundNames[r])
        -- LB match count: alternates between dropping & non-dropping rounds
        local matchCount
        if r == 1 then
            matchCount = teamCount / 4
        elseif r % 2 == 1 then
            matchCount = math.max(1, teamCount / (2^(math.ceil(r/2)+1)))
        else
            matchCount = math.max(1, teamCount / (2^(math.ceil(r/2)+1)))
        end
        matchCount = math.max(1, matchCount)

        for m = 1, matchCount do
            local matchID = "LBR"..r.."M"..m
            local cd = fetchMatch(event, matchID)
            local d  = mergeMatchData(cd, args, matchID)
            local sp = col:tag('div'):addClass('bk-spacer')
            renderMatchCard(sp, d, matchID, nil, r < lbRounds and "has-connector" or "")
        end
    end

    -- ── Grand Final ──
    local gfSection = wrapper:tag('div'):addClass('bk-section bk-gf-section')
    gfSection:tag('div'):addClass('bk-section-title bk-gf-title'):wikitext('Grand Final')
    local gfRow = gfSection:tag('div'):addClass('bk-row bk-gf-row')

    -- Game 1 (UB winner vs LB winner)
    local gf1 = fetchMatch(event, "GF1")
    local gf1d = mergeMatchData(gf1, args, "GF1")
    local gf1col = gfRow:tag('div'):addClass('bk-col bk-col-gf')
    gf1col:tag('div'):addClass('bk-col-header'):wikitext(args.gf1name or "Grand Final")
    local sp1 = gf1col:tag('div'):addClass('bk-spacer')
    renderMatchCard(sp1, gf1d, "GF1", nil, "")

    -- Optional bracket reset
    local gf2 = fetchMatch(event, "GF2")
    local gf2d = mergeMatchData(gf2, args, "GF2")
    if gf2d.team1 ~= "TBD" or gf2d.team2 ~= "TBD" or clean(args.GF2team1) or clean(args.GF2team2) then
        local gf2col = gfRow:tag('div'):addClass('bk-col bk-col-gf')
        gf2col:tag('div'):addClass('bk-col-header'):wikitext(args.gf2name or "Bracket Reset")
        local sp2 = gf2col:tag('div'):addClass('bk-spacer')
        renderMatchCard(sp2, gf2d, "GF2", nil, "")
    end

    return tostring(scroll)
end

-- ════════════════════════════════════════════════════════════════
-- FORMAT 3: GSL / 4-TEAM DOUBLE ELIM
-- Qualifier 1: #1 vs #2  → winner → Final, loser → Qualifier 2
-- Eliminator:  #3 vs #4  → winner → Qualifier 2, loser → OUT
-- Qualifier 2: loser(Q1) vs winner(Elim) → winner → Final, loser → OUT
-- Final:       winner(Q1) vs winner(Q2)  → WINNER
-- ════════════════════════════════════════════════════════════════

local function renderGSL(args, event)
    local scroll  = html.create('div'):addClass('bk-scroll')
    local wrapper = scroll:tag('div'):addClass('bk-wrapper bk-gsl')

    -- ── Column 1: Qualifier 1 + Eliminator (run simultaneously) ──
    local col1 = wrapper:tag('div'):addClass('bk-col bk-gsl-col1')
    col1:tag('div'):addClass('bk-col-header'):wikitext(args.q1name or 'Qualifier 1')

    local q1 = fetchMatch(event, "Q1")
    local q1d = mergeMatchData(q1, args, "Q1")
    local q1sp = col1:tag('div'):addClass('bk-spacer bk-gsl-top')
    renderMatchCard(q1sp, q1d, "Q1", nil, "has-connector")

    -- Gap between Q1 and Elim
    col1:tag('div'):addClass('bk-gsl-gap')
    col1:tag('div'):addClass('bk-col-header bk-col-header-2'):wikitext(args.elimname or 'Eliminator')

    local elim = fetchMatch(event, "ELIM")
    local elimd = mergeMatchData(elim, args, "ELIM")
    local elimsp = col1:tag('div'):addClass('bk-spacer bk-gsl-bot')
    renderMatchCard(elimsp, elimd, "ELIM", nil, "has-connector")

    -- ── Column 2: Qualifier 2 ──
    local col2 = wrapper:tag('div'):addClass('bk-col bk-gsl-col2')
    col2:tag('div'):addClass('bk-col-header'):wikitext(args.q2name or 'Qualifier 2')

    local q2 = fetchMatch(event, "Q2")
    local q2d = mergeMatchData(q2, args, "Q2")
    local q2sp = col2:tag('div'):addClass('bk-spacer bk-gsl-mid')
    renderMatchCard(q2sp, q2d, "Q2", nil, "has-connector")

    -- ── Column 3: Final ──
    local col3 = wrapper:tag('div'):addClass('bk-col bk-gsl-col3')
    col3:tag('div'):addClass('bk-col-header bk-gf-title'):wikitext(args.finalname or 'Grand Final')

    local final = fetchMatch(event, "FINAL")
    local finald = mergeMatchData(final, args, "FINAL")
    local finalsp = col3:tag('div'):addClass('bk-spacer bk-gsl-final')
    renderMatchCard(finalsp, finald, "FINAL", nil, "bk-final-match")

    -- ── Fate Labels (under the bracket) ──
    local legend = scroll:tag('div'):addClass('bk-gsl-legend')
    legend:tag('span'):addClass('bk-legend-item bk-legend-adv'):wikitext('✓ Advances to next round')
    legend:tag('span'):addClass('bk-legend-item bk-legend-elim'):wikitext('✗ Eliminated')

    return tostring(scroll)
end

-- ════════════════════════════════════════════════════════════════
-- FORMAT 4: ROUND ROBIN
-- ════════════════════════════════════════════════════════════════

local function renderRoundRobin(args, event, teamCount)
    -- Build team list
    local teams = {}
    for i = 1, teamCount do
        local t = clean(args["team"..i])
        if t then table.insert(teams, t) end
    end

    -- If fewer teams passed as args, try to figure out from BracketMatch
    if #teams == 0 then
        return "<div class='bk-error'>Round Robin: please pass team1=, team2=, ... up to team"..teamCount.."= arguments.</div>"
    end

    local n = #teams
    local scroll  = html.create('div'):addClass('bk-scroll')
    local wrapper = scroll:tag('div'):addClass('bk-wrapper bk-rr')

    -- ── Results Matrix Table ──
    local section = wrapper:tag('div'):addClass('bk-rr-section')
    section:tag('div'):addClass('bk-section-title'):wikitext('Match Results')

    local tbl = section:tag('table'):addClass('bk-rr-table')
    -- Header row
    local hdr = tbl:tag('tr')
    hdr:tag('th'):addClass('bk-rr-corner'):wikitext('')
    for _, t in ipairs(teams) do
        local logo = getTeamLogo(t, "16px")
        local th = hdr:tag('th'):addClass('bk-rr-th')
        if logo then th:wikitext(logo) end
        th:tag('span'):wikitext(t)
    end

    -- Data rows
    for i, t1 in ipairs(teams) do
        local tr = tbl:tag('tr')
        -- Row header
        local rowHdr = tr:tag('th'):addClass('bk-rr-row-hdr')
        local logo1 = getTeamLogo(t1, "16px")
        if logo1 then rowHdr:wikitext(logo1) end
        rowHdr:tag('span'):wikitext(t1)

        for j, t2 in ipairs(teams) do
            if i == j then
                tr:tag('td'):addClass('bk-rr-self'):wikitext('—')
            else
                -- Match ID: RR_i_j (always smaller index first for canonical ID)
                local matchID
                if i < j then
                    matchID = "RR"..i.."v"..j
                else
                    matchID = "RR"..j.."v"..i
                end
                local cd = fetchMatch(event, matchID)
                local d  = mergeMatchData(cd, args, matchID)

                local score
                if i < j then
                    score = d.score1
                else
                    score = d.score2
                end

                local isWin  = (i < j and d.winner == "1") or (i > j and d.winner == "2")
                    or (i < j and d.winner == t1) or (i > j and d.winner == t1)
                local isLose = (i < j and d.winner == "2") or (i > j and d.winner == "1")
                    or (i < j and d.winner == t2) or (i > j and d.winner == t2)

                local td = tr:tag('td'):addClass('bk-rr-cell bk-match-clickable')
                    :attr('data-match-id', matchID)
                    :attr('data-team1', d.team1)
                    :attr('data-team2', d.team2)
                    :attr('data-score1', d.score1)
                    :attr('data-score2', d.score2)
                    :attr('data-bo', d.bo)
                    :attr('data-date', d.date)
                    :attr('data-casters', d.casters)
                    :attr('data-vod', d.vod)

                if isWin  then td:addClass('bk-rr-win')
                elseif isLose then td:addClass('bk-rr-lose')
                end

                if score ~= "" then td:wikitext(score)
                elseif isWin then td:wikitext('W')
                elseif isLose then td:wikitext('L')
                else td:addClass('bk-rr-pending'):wikitext('·')
                end
            end
        end
    end

    -- ── Standings Table ──
    local standSection = wrapper:tag('div'):addClass('bk-rr-standings')
    standSection:tag('div'):addClass('bk-section-title'):wikitext('Standings')

    -- Calculate W/L/D from cargo data
    local wld = {}
    for _, t in ipairs(teams) do
        wld[t] = { w = 0, l = 0, d = 0, pts = 0 }
    end
    for i = 1, n-1 do
        for j = i+1, n do
            local matchID = "RR"..i.."v"..j
            local cd = fetchMatch(event, matchID)
            local d  = mergeMatchData(cd, args, matchID)
            if d.winner ~= "" then
                local winner = d.winner
                local t1n = teams[i]; local t2n = teams[j]
                local actualWinner
                if winner == "1" or winner == t1n then actualWinner = t1n
                elseif winner == "2" or winner == t2n then actualWinner = t2n
                end
                if actualWinner then
                    local loser = actualWinner == t1n and t2n or t1n
                    if wld[actualWinner] then wld[actualWinner].w = wld[actualWinner].w + 1; wld[actualWinner].pts = wld[actualWinner].pts + 3 end
                    if wld[loser] then wld[loser].l = wld[loser].l + 1 end
                end
            end
        end
    end

    -- Sort by points desc
    local sorted = {}
    for _, t in ipairs(teams) do table.insert(sorted, t) end
    table.sort(sorted, function(a,b)
        local wa = wld[a] or {pts=0}; local wb = wld[b] or {pts=0}
        if wa.pts ~= wb.pts then return wa.pts > wb.pts end
        return (wa.w or 0) > (wb.w or 0)
    end)

    local stbl = standSection:tag('table'):addClass('bk-rr-stand-table')
    local shdr = stbl:tag('tr')
    shdr:tag('th'):wikitext('#')
    shdr:tag('th'):css('text-align','left'):wikitext('Team')
    shdr:tag('th'):wikitext('W')
    shdr:tag('th'):wikitext('L')
    shdr:tag('th'):wikitext('Pts')

    for pos, t in ipairs(sorted) do
        local wl = wld[t] or {w=0,l=0,pts=0}
        local tr = stbl:tag('tr'):addClass('bk-rr-stand-row')
        tr:tag('td'):addClass('bk-rr-rank'):wikitext(tostring(pos))
        local nameTd = tr:tag('td'):addClass('bk-rr-stand-name')
        local logo = getTeamLogo(t, "18px")
        if logo then nameTd:wikitext(logo) end
        nameTd:tag('span'):wikitext("[["..t.."]]")
        tr:tag('td'):addClass('bk-rr-w'):wikitext(tostring(wl.w))
        tr:tag('td'):addClass('bk-rr-l'):wikitext(tostring(wl.l))
        tr:tag('td'):addClass('bk-rr-pts'):wikitext(tostring(wl.pts))
    end

    return tostring(scroll)
end

-- ════════════════════════════════════════════════════════════════
-- FORMAT 5: SWISS
-- ════════════════════════════════════════════════════════════════

local function renderSwiss(args, event, teamCount, swissRounds)
    swissRounds = swissRounds or tonumber(args.rounds) or 5

    local scroll  = html.create('div'):addClass('bk-scroll')
    local wrapper = scroll:tag('div'):addClass('bk-wrapper bk-swiss')

    for r = 1, swissRounds do
        local roundSection = wrapper:tag('div'):addClass('bk-swiss-round')
        roundSection:tag('div'):addClass('bk-section-title'):wikitext(
            args["r"..r.."name"] or ("Round "..r))

        local matchCount = math.floor(teamCount / 2)
        local matchRow = roundSection:tag('div'):addClass('bk-swiss-matches')

        for m = 1, matchCount do
            local matchID = "SR"..r.."M"..m
            local cd = fetchMatch(event, matchID)
            local d  = mergeMatchData(cd, args, matchID)
            renderMatchCard(matchRow, d, matchID, nil, "")
        end
    end

    return tostring(scroll)
end

-- ════════════════════════════════════════════════════════════════
-- MAIN ENTRY POINT
-- ════════════════════════════════════════════════════════════════

function p.main(frame)
    local args = (frame:getParent() and frame:getParent().args) or frame.args

    local format    = clean(args.format) or "single"
    local event     = clean(args.event)  or ""
    local teamCount = tonumber(args.teams) or 8

    -- Wrap everything in a root container with the modal placeholder
    local root = html.create('div'):addClass('bk-root')
    root:attr('data-event', event)

    -- Format selector tabs (if multiple formats on same page via JS tabs — optional)
    local bracketHTML

    format = format:lower()
    if format == "single" or format == "se" then
        bracketHTML = renderSingleElim(args, event, teamCount)
    elseif format == "double" or format == "de" then
        bracketHTML = renderDoubleElim(args, event, teamCount)
    elseif format == "gsl" or format == "4team" then
        bracketHTML = renderGSL(args, event)
    elseif format == "rr" or format == "roundrobin" then
        bracketHTML = renderRoundRobin(args, event, teamCount)
    elseif format == "swiss" then
        bracketHTML = renderSwiss(args, event, teamCount)
    else
        bracketHTML = "<div class='bk-error'>Unknown bracket format: <b>"..format.."</b>. "
            .."Valid: single, double, gsl, rr, swiss</div>"
    end

    root:wikitext(bracketHTML)

    -- Modal built with mw.html (avoids MediaWiki sanitizer stripping raw HTML strings)
    local overlay = root:tag('div')
        :addClass('bk-modal-overlay')
        :css('display','none')

    local modal = overlay:tag('div'):addClass('bk-modal')

    -- Use span instead of button (<button> is stripped by MediaWiki sanitizer)
    modal:tag('span')
        :addClass('bk-modal-close')
        :attr('title','Close')
        :attr('role','button')
        :attr('tabindex','0')
        :wikitext('&#10005;')

    local mheader = modal:tag('div'):addClass('bk-modal-header')
    local mteams  = mheader:tag('div'):addClass('bk-modal-teams')
    mteams:tag('span'):addClass('bk-modal-t1')
    mteams:tag('span'):addClass('bk-modal-vs'):wikitext('vs')
    mteams:tag('span'):addClass('bk-modal-t2')

    local mscore = mheader:tag('div'):addClass('bk-modal-score')
    mscore:tag('span'):addClass('bk-modal-s1')
    mscore:tag('span'):addClass('bk-modal-dash'):wikitext('&#8211;')
    mscore:tag('span'):addClass('bk-modal-s2')

    local mmeta = modal:tag('div'):addClass('bk-modal-meta')
    mmeta:tag('div'):addClass('bk-modal-row bk-modal-bo')
    mmeta:tag('div'):addClass('bk-modal-row bk-modal-date')
    mmeta:tag('div'):addClass('bk-modal-row bk-modal-casters')
    mmeta:tag('div'):addClass('bk-modal-row bk-modal-notes')
    mmeta:tag('div'):addClass('bk-modal-row bk-modal-vod')

    return tostring(root)
end

-- ── Convenience wrappers ──────────────────────────────────────────
function p.single(frame) 
    local args = (frame:getParent() and frame:getParent().args) or frame.args
    args.format = "single"
    return p.main(frame) 
end
function p.double(frame) 
    local args = (frame:getParent() and frame:getParent().args) or frame.args
    args.format = "double"
    return p.main(frame) 
end
function p.gsl(frame) 
    local args = (frame:getParent() and frame:getParent().args) or frame.args
    args.format = "gsl"
    return p.main(frame) 
end
function p.rr(frame) 
    local args = (frame:getParent() and frame:getParent().args) or frame.args
    args.format = "rr"
    return p.main(frame) 
end
function p.swiss(frame) 
    local args = (frame:getParent() and frame:getParent().args) or frame.args
    args.format = "swiss"
    return p.main(frame) 
end

return p