Edge Rewrite
// HTMLRewriter · presentation

This page was redesigned at the edge.

Cloudflare fetched the original article and streamed it through HTMLRewriter to apply an entirely new visual system without rebuilding the source page.

Jump to content

Module:Sports results/schedule

From Wikipedia, the free encyclopedia
-- Schedule-format results for non-round-robin competitions.
-- Called by Module:Sports results; each fixture appears from both teams' views.

require('strict')

local pp = {}

local yesno = require('Module:Yesno')

local MAX_ERRORS = 25

--------------------------------------------------------------------------------
-- Helpers
--------------------------------------------------------------------------------

local function trim(s)
	return mw.text.trim(tostring(s or ''))
end

local function add_error(errors, msg)
	for _, e in ipairs(errors) do
		if e == msg then return end
	end
	if #errors < MAX_ERRORS then
		errors[#errors + 1] = msg
	elseif #errors == MAX_ERRORS then
		errors[#errors + 1] = 'further problems were found but are not listed'
	end
end

local function split_list(s)
	local out = {}
	for _, v in ipairs(mw.text.split(trim(s), '%s*[;,]%s*')) do
		if v ~= '' then out[#out + 1] = v end
	end
	return out
end

-- Build a match or result parameter prefix.
local function param_name(base, neutral, num)
	return base .. (neutral and 'n' or '') .. ((num > 1) and tostring(num) or '') .. '_'
end

--------------------------------------------------------------------------------
-- Colours
--------------------------------------------------------------------------------

-- Existing cancelled colour; postponed avoids the neutral-venue orange.
local CANCELLED_HEX, POSTPONED_HEX = '#DDA0DD', '#B3B7EE'
local CANCELLED_NAME, POSTPONED_NAME = 'Plum', 'Periwinkle'

-- Standard display values for unplayed matches.
local STATES = {
	canc = {text = '<small>Canc.</small>', hex = CANCELLED_HEX,
		name = CANCELLED_NAME, meaning = 'cancelled'},
	ppd = {text = '<small>Ppd.</small>', hex = POSTPONED_HEX,
		name = POSTPONED_NAME, meaning = 'postponed'},
}

local function match_state(raw)
	if raw == nil then return nil end
	local s = trim(raw)
	if s == '' then return nil end
	-- Unwrap a simple small-text wrapper.
	local inner = s:match('^<%s*[Ss][Mm][Aa][Ll][Ll]%s*>(.-)<%s*/%s*[Ss][Mm][Aa][Ll][Ll]%s*>$')
	if not inner then
		local attrs, text = s:match('^<%s*[Ss][Pp][Aa][Nn]%s([^<>]*)>(.-)<%s*/%s*[Ss][Pp][Aa][Nn]%s*>$')
		if attrs and attrs:lower():find('font%-size', 1, true) then inner = text end
	end
	local word = trim(inner or s):lower()
	word = (word:gsub('%.$', ''))
	return STATES[word] and word or nil
end

--------------------------------------------------------------------------------
-- Reading |schedule_TTT=
--------------------------------------------------------------------------------

-- Parse OPP:h/a/n entries, numbered repeats, and null byes.
local function parse_entry(raw, team, errors, all_neutral)
	local s = trim(raw)
	if s == '' then return nil end

	-- Parse byes, optional spans, and optional text.
	local nullq = s:match('^[Nn][Uu][Ll][Ll]$') and '' or s:match('^[Nn][Uu][Ll][Ll]:(.*)$')
	if nullq then
		local span, text
		local a, b = nullq:match('^(%d+):(.*)$')
		if a then
			span, text = tonumber(a), b
		elseif nullq:match('^%d+$') then
			span = tonumber(nullq)
		elseif nullq ~= '' then
			text = nullq
		end
		if span and span < 1 then
			add_error(errors, '"' .. s .. '" in |schedule_' .. team
				.. '= spans fewer than one column')
			span = 1
		end
		return {null = true, span = span or 1, text = text}
	end

	local code, qual = s:match('^(.-):(.*)$')
	local venue, num
	if code and code ~= '' then
		venue, num = qual:match('^([hanHAN])(%d*)$')
		if not venue and all_neutral then
			-- Treat OPP:2 as the second neutral meeting.
			num = qual:match('^(%d*)$')
			if num then venue = 'n' end
		end
	elseif all_neutral then
		-- Bare codes are allowed for all-neutral schedules.
		code, num = s:match('^(.-)(%d*)$')
		if code and code ~= '' then venue = 'n' end
	end
	if not venue then
		add_error(errors, 'could not read "' .. s .. '" in |schedule_' .. team
			.. '=; entries look like OPP:h, OPP:a or OPP:n, numbered for a repeat meeting '
			.. '(OPP:h2), or null for a bye'
			.. (all_neutral and '. With |all_neutral= a bare OPP or OPP:2 also works' or ''))
		return nil
	end
	return {opponent = code, venue = venue:lower(), num = tonumber(num), raw = s}
end

-- Read a team's schedule and assign missing meeting numbers.
local function read_schedule(Args, team, errors, all_neutral)
	local raw = Args['schedule_' .. team]
	if raw == nil then return {} end

	local entries, used, seen = {}, {}, {}
	local bad = false
	for _, item in ipairs(split_list(raw)) do
		local e = parse_entry(item, team, errors, all_neutral)
		if e then
			entries[#entries + 1] = e
		else
			bad = true
		end
	end
	entries.incomplete = bad
	-- Reserve explicit numbers first.
	for _, e in ipairs(entries) do
		if not e.null and e.num then
			local key = e.opponent .. '|' .. e.venue .. '|' .. e.num
			if used[key] then
				add_error(errors, '|schedule_' .. team .. '= gives meeting ' .. e.num .. ' against '
					.. e.opponent .. ' more than once')
			end
			used[key] = true
		end
	end
	-- Unnumbered repeats must identify their meeting number.
	for _, e in ipairs(entries) do
		if not e.null and not e.num then
			local base = e.opponent .. '|' .. e.venue
			if seen[base] or used[base .. '|1'] then
				add_error(errors, '|schedule_' .. team .. '= gives "' .. e.raw
					.. '" more than once; number the repeat meeting, for example '
					.. e.opponent .. ':' .. e.venue .. '2')
			else
				seen[base] = 1
				used[base .. '|1'] = true
				e.num = 1
			end
		end
	end
	-- Discard unresolved entries.
	local out = {}
	for _, e in ipairs(entries) do
		if e.null or e.num then out[#out + 1] = e end
	end
	out.incomplete = entries.incomplete
	return out
end

-- Return row width, including multi-column byes.
local function row_width(entries)
	local w = 0
	for _, e in ipairs(entries or {}) do
		w = w + (e.span or 1)
	end
	return w
end

-- Split HOME_AWAY, preferring known codes with underscores.
local function split_pair(rest, codes)
	local cands = {}
	local i = rest:find('_', 1, true)
	while i do
		local h, a = rest:sub(1, i - 1), rest:sub(i + 1)
		if h ~= '' and a ~= '' then cands[#cands + 1] = {h, a} end
		i = rest:find('_', i + 1, true)
	end
	if #cands == 0 then return nil end
	for _, c in ipairs(cands) do
		if codes[c[1]] and codes[c[2]] then return c[1], c[2] end
	end
	for _, c in ipairs(cands) do
		if codes[c[1]] then return c[1], c[2] end
	end
	for _, c in ipairs(cands) do
		if codes[c[2]] then return c[1], c[2] end
	end
	return cands[#cands][1], cands[#cands][2]
end

--------------------------------------------------------------------------------
-- Reading |roundN_matches=
--------------------------------------------------------------------------------

-- Parse home-first round fixtures, optional numbers, and neutral venues.
local function parse_round_entry(raw, n, codes, errors, all_neutral)
	local s = trim(raw)
	if s == '' then return nil end

	local pair, qual = s:match('^(.-):(.*)$')
	if not pair then pair, qual = s, '' end
	local neutral, num = qual:match('^([nN]?)(%d*)$')
	if not neutral then
		add_error(errors, 'could not read the qualifier in "' .. s .. '" in |round' .. n
			.. '_matches=; it is a meeting number, n for a neutral venue, or both (n2)')
		return nil
	end

	local h, a = split_pair(pair, codes)
	if not h then
		add_error(errors, 'could not read two team codes from "' .. s .. '" in |round' .. n
			.. '_matches=; entries look like HOME_AWAY')
		return nil
	end
	if h == a then
		add_error(errors, '"' .. s .. '" in |round' .. n .. '_matches= is a team against itself')
		return nil
	end
	return {home = h, away = a, neutral = all_neutral or (neutral ~= ''),
		num = tonumber(num), raw = s}
end

-- Convert round lists to per-team schedules, adding byes as needed.
-- Outside opponents play fixtures but receive no row or bye.
local function read_rounds(Args, codes, named, errors, all_neutral, external, series_mode)
	external = external or {}
	local nums = {}
	for k in pairs(Args) do
		if type(k) == 'string' then
			local n = k:match('^round(%d+)_matches$')
			if n then nums[#nums + 1] = tonumber(n) end
		end
	end
	if #nums == 0 then return nil end
	table.sort(nums)

	-- Missing rounds may be blank or mistyped.
	for i = 1, nums[#nums] do
		if nums[i] ~= i then
			add_error(errors, 'the rounds run from 1 to ' .. nums[#nums]
				.. ' but |round' .. i .. '_matches= is missing')
			break
		end
	end

	local schedules, seen, by_round = {}, {}, {}
	for _, n in ipairs(nums) do
		local this_round = {}
		local fixtures = {}
		for _, item in ipairs(split_list(Args['round' .. n .. '_matches'])) do
			local e = parse_round_entry(item, n, codes, errors, all_neutral)
			if e then
				-- Without names, round lists define the team codes.
				local ok = true
				if named then
					for _, c in ipairs({e.home, e.away}) do
						if not codes[c] then
							ok = false
							add_error(errors, 'the team code "' .. c .. '" in |round' .. n
								.. '_matches= is not one of the teams')
						end
					end
				end
				-- Outside opponents may appear in multiple fixtures per round.
				local clash = (not external[e.home] and this_round[e.home] and e.home)
					or (not external[e.away] and this_round[e.away] and e.away)
				if ok and clash then
					ok = false
					add_error(errors, '|round' .. n .. '_matches= has '
						.. clash .. ' playing more than once')
				end
				if ok and external[e.home] and external[e.away] and not series_mode then
					-- Team layout cannot show a fixture between two outsiders.
					add_error(errors, '"' .. e.raw .. '" in |round' .. n
						.. '_matches= is between two teams in |external_teams=, so neither has a '
						.. 'row to show it in; it appears nowhere in the schedule')
				end
				local id
				if ok then
					local x, y = e.home, e.away
					if e.neutral and y < x then x, y = y, x end
					id = (e.neutral and 'n' or '') .. tostring(e.num or 1) .. '|' .. x .. '|' .. y
					if seen[id] then
						ok = false
						add_error(errors, '"' .. e.raw .. '" in |round' .. n
							.. '_matches= repeats a fixture already given in |round' .. seen[id]
							.. '_matches=; number the repeat meeting, for example ' .. e.home .. '_'
							.. e.away .. ':' .. (e.neutral and 'n2' or '2'))
					end
				end
				if ok then
					seen[id] = n
					this_round[e.home] = true
					this_round[e.away] = true
					fixtures[#fixtures + 1] = e
				end
			end
		end
		-- Give each listed team a fixture or bye.
		for c in pairs(codes) do
			if not external[c] then schedules[c] = schedules[c] or {} end
		end
		for _, e in ipairs(fixtures) do
			if not external[e.home] then
				schedules[e.home][n] = {opponent = e.away, num = e.num,
					venue = e.neutral and 'n' or 'h'}
			end
			if not external[e.away] then
				schedules[e.away][n] = {opponent = e.home, num = e.num,
					venue = e.neutral and 'n' or 'a'}
			end
		end
		for c in pairs(codes) do
			if not external[c] then schedules[c][n] = schedules[c][n] or {null = true} end
		end
		by_round[n] = fixtures
	end

	-- Fill missing meeting numbers in round order.
	local counts = {}
	for _, n in ipairs(nums) do
		for c in pairs(codes) do
			local e = schedules[c] and schedules[c][n]
			if e and not e.null and not e.num then
				local key = c .. '|' .. e.opponent .. '|' .. e.venue
				counts[key] = (counts[key] or 0) + 1
				e.num = counts[key]
			end
		end
	end
	return schedules, nums[#nums], by_round
end

--------------------------------------------------------------------------------
-- Match lookup
--------------------------------------------------------------------------------

-- Look up matches; neutral fixtures accept either team order.
local function lookup(Args, base, team, opponent, venue, num, errors, all_neutral)
	if venue == 'n' then
		local k1 = param_name(base, true, num) .. team .. '_' .. opponent
		local k2 = param_name(base, true, num) .. opponent .. '_' .. team
		local alt1 = (num == 1) and (base .. 'n1_' .. team .. '_' .. opponent) or nil
		local alt2 = (num == 1) and (base .. 'n1_' .. opponent .. '_' .. team) or nil
		-- Plain keys also apply when every match is neutral.
		local p1, p2
		if all_neutral then
			p1 = param_name(base, false, num) .. team .. '_' .. opponent
			p2 = param_name(base, false, num) .. opponent .. '_' .. team
			if num == 1 then
				alt1 = alt1 or (base .. '1_' .. team .. '_' .. opponent)
				alt2 = alt2 or (base .. '1_' .. opponent .. '_' .. team)
			end
		end
		local v1 = Args[k1] or (alt1 and Args[alt1]) or (p1 and Args[p1])
		local v2 = Args[k2] or (alt2 and Args[alt2]) or (p2 and Args[p2])
		if all_neutral and errors then
			local found = {}
			for _, kk in ipairs({k1, k2, p1, p2, alt1, alt2}) do
				if kk and Args[kk] ~= nil then found[#found + 1] = kk end
			end
			if #found > 1 then
				table.sort(found)
				local x, y = team, opponent
				if y < x then x, y = y, x end
				add_error(errors, 'the match between ' .. x .. ' and ' .. y
					.. ' is given more than once: |' .. table.concat(found, '=, |')
					.. '=; with every match at a neutral venue these are all the same match')
			end
		end
		if v1 ~= nil and v2 ~= nil and errors then
			-- Report each key clash once.
			local a, b = k1, k2
			if b < a then a, b = b, a end
			add_error(errors, 'both |' .. a .. '= and |' .. b
				.. '= are given; a neutral match is the same match either way round, so only one may be used')
		end
		if Args[k1] ~= nil then return Args[k1], false, k1 end
		if alt1 and Args[alt1] ~= nil then return Args[alt1], false, alt1 end
		if p1 and Args[p1] ~= nil then return Args[p1], false, p1 end
		if Args[k2] ~= nil then return Args[k2], true, k2 end
		if alt2 and Args[alt2] ~= nil then return Args[alt2], true, alt2 end
		if p2 and Args[p2] ~= nil then return Args[p2], true, p2 end
		return nil, false, k1
	end

	local home = (venue == 'h') and team or opponent
	local away = (venue == 'h') and opponent or team
	local key = param_name(base, false, num) .. home .. '_' .. away
	local val = Args[key]
	if num == 1 then
		local alt = base .. '1_' .. home .. '_' .. away
		if val ~= nil and Args[alt] ~= nil and errors then
			add_error(errors, 'both |' .. key .. '= and |' .. alt
				.. '= are given; for the schedule format they are the same meeting')
		end
		if val == nil then val = Args[alt] end
	end
	-- reversed means the score is opponent-first.
	return val, (venue == 'a'), key
end

--------------------------------------------------------------------------------
-- Score handling
--------------------------------------------------------------------------------

-- Reverse every score pair for the opposite team, preserving markup targets,
-- URLs, and strip markers.

-- Parser markers are delimited by DEL (\127).
local MARKER = '\127'

-- Apply f only to safely rewritable text.
local function map_score_text(s, f)
	local out, i, n = {}, 1, #s
	while i <= n do
		-- Find the next protected construct.
		local nxt, kind = nil, nil
		local p = s:find(MARKER, i, true)
		if p then nxt, kind = p, 'marker' end
		p = s:find('[[', i, true)
		if p and (not nxt or p < nxt) then nxt, kind = p, 'link' end
		p = s:find('%[%a[%w%+%.%-]*://', i)
		if p and (not nxt or p < nxt) then nxt, kind = p, 'url' end

		if not nxt then
			out[#out + 1] = f(s:sub(i))
			break
		end
		if nxt > i then out[#out + 1] = f(s:sub(i, nxt - 1)) end

		if kind == 'marker' then
			-- Markers begin and end with DEL.
			local e = s:find(MARKER, nxt + 1, true)
			if not e then
				out[#out + 1] = s:sub(nxt)
				break
			end
			out[#out + 1] = s:sub(nxt, e)
			i = e + 1
		elseif kind == 'link' then
			-- Find the matching brackets, allowing nesting.
			local depth, j, close = 0, nxt, nil
			while j <= n and not close do
				if s:sub(j, j + 1) == '[[' then
					depth = depth + 1
					j = j + 2
				elseif s:sub(j, j + 1) == ']]' then
					depth = depth - 1
					j = j + 2
					if depth == 0 then close = j - 1 end
				else
					j = j + 1
				end
			end
			if not close then
				out[#out + 1] = s:sub(nxt)
				break
			end
			local body = s:sub(nxt + 2, close - 2)
			local pipe = body:find('|', 1, true)
			if pipe then
				-- Rewrite only the displayed link text.
				out[#out + 1] = '[[' .. body:sub(1, pipe)
					.. map_score_text(body:sub(pipe + 1), f) .. ']]'
			else
				out[#out + 1] = s:sub(nxt, close)
			end
			i = close + 1
		else
			-- Preserve the URL; rewrite its label.
			local close = s:find(']', nxt, true)
			if not close then
				out[#out + 1] = s:sub(nxt)
				break
			end
			local body = s:sub(nxt + 1, close - 1)
			local sp = body:find('%s')
			if sp then
				out[#out + 1] = '[' .. body:sub(1, sp)
					.. map_score_text(body:sub(sp + 1), f) .. ']'
			else
				out[#out + 1] = s:sub(nxt, close)
			end
			i = close + 1
		end
	end
	return table.concat(out)
end

-- Normalize score dashes to en dashes.
local function normalise_dashes(t)
	t = mw.ustring.gsub(t, '(%d)%s*&[Nn][Dd][Aa][Ss][Hh];%s*(%d)', '%1–%2')
	t = mw.ustring.gsub(t, '(%d)%s*&[Mm][Dd][Aa][Ss][Hh];%s*(%d)', '%1–%2')
	t = mw.ustring.gsub(t, '(%d)%s*&#821[12];%s*(%d)', '%1–%2')
	t = mw.ustring.gsub(t, '(%d)%s*[%-–—−]%s*(%d)', '%1–%2')
	return t
end

local function swap_dashes(t)
	return (mw.ustring.gsub(t, '(%d[%d%.]*)–(%d[%d%.]*)', '%2–%1'))
end

-- Tags make a score unsafe to reorder; refs are already strip markers.
local function has_markup(s)
	return s:find('<%s*/?%s*%a') ~= nil
end

-- Return normal and reversed scores, plus whether markup blocked reversal.
local function score_forms(raw, util)
	local s = util.format_score(raw)
	if has_markup(s) then
		local flippable = mw.ustring.find(s, '%d%s*[%-–—−]%s*%d') ~= nil
		return s, s, flippable
	end
	local plain = map_score_text(s, normalise_dashes)
	local turned = map_score_text(plain, swap_dashes)
	return plain, turned, false
end

-- Invert team-specific result modifiers for the opposite team.
local INVERT = {W = 'L', L = 'W', OTW = 'OTL', OTL = 'OTW', PKW = 'PKL', PKL = 'PKW'}
local function invert_result(r)
	return INVERT[r] or r
end

--------------------------------------------------------------------------------
-- Round headings
--------------------------------------------------------------------------------

-- $1 inserts the round number; otherwise the value is a prefix.
local function round_text(pattern, n)
	if not pattern or pattern == '' then return nil end
	if pattern:find('$1', 1, true) then
		return (pattern:gsub('%$1', tostring(n)))
	end
	return pattern .. tostring(n)
end

-- Common round-heading presets; explicit values override them.
local ROUND_TYPES = {
	matchday  = {'MD$1', 'Matchday $1'},
	matchweek = {'MW$1', 'Matchweek $1'},
	gameweek  = {'GW$1', 'Game week $1'},
	round     = {'R$1',  'Round $1'},
	week      = {'W$1',  'Week $1'},
	game      = {'G$1',  'Game $1'},
}

-- Fallback for round labels and a bare |round_span_header=yes.
local DEFAULT_ROUND_NAME = 'Match $1'

-- Return a round pattern's singular form, e.g. 'Matchday $1' -> 'Matchday'.
local function singular_round_name(pattern)
	return (pattern:gsub('%s*%$1%s*$', ''))
end

-- Whether |round_span_header= is enable vs. being unset, a negative value, or custom header text.
local function round_span_is_default(Args)
	local raw = Args['round_span_header']
	if raw == nil or trim(raw) == '' then return false end
	return yesno(raw) == true
end

-- Build a round heading, defaulting to its number.
local function round_header(frame, Args, n)
	local override = Args['round' .. n .. '_header']
	if override then return override end

	local preset = ROUND_TYPES[string.lower(trim(Args['round_type']))]
	if preset and round_span_is_default(Args) then
		return tostring(n)
	end
	local abbr = round_text(Args['round_abbreviation'] or (preset and preset[1]), n)
	local name = round_text(Args['round_name'] or (preset and preset[2]), n)
	if abbr and name then
		return frame:expandTemplate{title = 'abbr', args = {abbr, name}}
	end
	return abbr or name or tostring(n)
end

-- Return the |round_span_header= text above the round headings.
-- A positive value uses the singular |round_type= name (or "Match"); custom
-- text is used as given, while negative or unset values omit the row.
local function round_span_header(Args)
	local raw = Args['round_span_header']
	if raw == nil or trim(raw) == '' then return nil end
	if round_span_is_default(Args) then
		local preset = ROUND_TYPES[string.lower(trim(Args['round_type']))]
		return singular_round_name((preset and preset[2]) or DEFAULT_ROUND_NAME)
	end
	if yesno(raw) == false then return nil end
	-- Not recognised as a yes/no value, so it is custom header text.
	return raw
end

-- Check whether a second row of round dates is needed.
local function any_round_dates(Args)
	for k in pairs(Args) do
		if type(k) == 'string' and k:match('^round%d+_date$') and trim(Args[k]) ~= '' then
			return true
		end
	end
	return false
end

--------------------------------------------------------------------------------
-- Team list and blocks
--------------------------------------------------------------------------------

-- Sort team codes by display name, then code.
local function name_sorter(Args, util)
	local key = {}
	local function keyof(c)
		if key[c] == nil then
			local n = Args['name_' .. c]
			key[c] = string.lower(n and util.clean_team_name(n, c) or c)
		end
		return key[c]
	end
	return function(x, y)
		local kx, ky = keyof(x), keyof(y)
		if kx == ky then return x < y end
		return kx < ky
	end
end

local function team_list(Args, util, top_pos, bottom_pos, extra, external)
	local roster, all = {}, {}
	external = external or {}
	for k in pairs(Args) do
		if type(k) == 'string' then
			local c = k:match('^schedule_(.+)$') or k:match('^name_(.+)$')
			-- Outside opponents have no schedule row.
			if c and c ~= '' and not roster[c] and not external[c] then
				roster[c] = true
				all[#all + 1] = c
			end
		end
	end
	for _, c in ipairs(extra or {}) do
		if not roster[c] and not external[c] then
			roster[c] = true
			all[#all + 1] = c
		end
	end
	table.sort(all, name_sorter(Args, util))

	local ordered, seen = {}, {}
	local function push(c)
		if c and roster[c] and not seen[c] then
			seen[c] = true
			ordered[#ordered + 1] = c
		end
	end

	if trim(Args['default_order']) ~= '' then
		for _, c in ipairs(split_list(Args['default_order'])) do push(c) end
	elseif trim(Args['team_order']) ~= '' then
		for _, c in ipairs(split_list(Args['team_order'])) do push(c) end
	else
		local i = top_pos
		while Args['team' .. i] ~= nil and (bottom_pos < top_pos or (i - top_pos + 1) <= (bottom_pos - top_pos + 1)) do
			push(trim(Args['team' .. i]))
			i = i + 1
		end
	end
	for _, c in ipairs(all) do push(c) end
	return ordered, roster
end

-- Unassigned teams form a final untitled block.
local function using_blocks_early(Args)
	for k in pairs(Args) do
		if type(k) == 'string' and k:match('^block%d+$') then return true end
	end
	return false
end

local function build_blocks(Args, teams, errors, external)
	external = external or {}
	local nums = {}
	for k in pairs(Args) do
		if type(k) == 'string' then
			local n = k:match('^block(%d+)$')
			if n then nums[#nums + 1] = tonumber(n) end
		end
	end
	if #nums == 0 then
		return {{title = Args['title'], teams = teams}}, false
	end
	table.sort(nums)

	local known = {}
	for _, c in ipairs(teams) do known[c] = true end

	local blocks, placed = {}, {}
	for _, n in ipairs(nums) do
		local members = {}
		for _, c in ipairs(split_list(Args['block' .. n])) do
			if external[c] then
				add_error(errors, 'the team "' .. c .. '" in |block' .. n
					.. '= is in |external_teams=, so it has no row to put in a block')
			elseif not known[c] then
				add_error(errors, 'the team "' .. c .. '" in |block' .. n .. '= has no |schedule_' .. c .. '=')
			elseif placed[c] then
				add_error(errors, 'the team "' .. c .. '" is listed in more than one block')
			else
				placed[c] = true
				members[#members + 1] = c
			end
		end
		blocks[#blocks + 1] = {title = Args['block' .. n .. '_title'], teams = members}
	end

	local leftover = {}
	for _, c in ipairs(teams) do
		if not placed[c] then leftover[#leftover + 1] = c end
	end
	if #leftover > 0 then
		add_error(errors, 'no block was given for ' .. table.concat(leftover, ', ')
			.. '; they have been put in a final untitled block')
		blocks[#blocks + 1] = {title = nil, teams = leftover}
	end
	if Args['title'] then
		add_error(errors, '|title= is ignored when blocks are used; give each block its own |blockN_title=')
	end
	return blocks, true
end

--------------------------------------------------------------------------------
-- Schedule consistency
--------------------------------------------------------------------------------

-- Warn about empty schedules only when another team has fixtures.
local function check_idle(teams, schedules, errors)
	local any, idle = false, {}
	for _, team in ipairs(teams) do
		if #(schedules[team] or {}) > 0 then any = true else idle[#idle + 1] = team end
	end
	if any and #idle > 0 then
		table.sort(idle)
		add_error(errors, 'no fixtures were given for ' .. table.concat(idle, ', ')
			.. '; they appear as empty rows')
	end
end

local function check_pairs(schedules, roster, errors, external)
	external = external or {}
	local names = {}
	for team in pairs(schedules) do names[#names + 1] = team end
	table.sort(names)

	local count = {}
	for _, team in ipairs(names) do
		local entries = schedules[team]
		for _, e in ipairs(entries) do
			if not e.null then
				local key = team .. '|' .. e.opponent .. '|' .. e.venue
				count[key] = (count[key] or 0) + 1
			end
		end
	end

	local reported = {}
	for _, team in ipairs(names) do
		local entries = schedules[team]
		for _, e in ipairs(entries) do
			-- Skip incomplete lists to avoid false mismatches.
			if entries.incomplete or (schedules[e.opponent] or {}).incomplete then
				-- Skip.
			elseif external[e.opponent] then
				-- Outside opponents have no list to compare.
			elseif not e.null and roster[e.opponent] then
				local mirror_venue = (e.venue == 'h' and 'a') or (e.venue == 'a' and 'h') or 'n'
				local a = team .. '|' .. e.opponent .. '|' .. e.venue
				local b = e.opponent .. '|' .. team .. '|' .. mirror_venue
				local pair = (a < b) and (a .. '#' .. b) or (b .. '#' .. a)
				if not reported[pair] and (count[a] or 0) ~= (count[b] or 0) then
					reported[pair] = true
					add_error(errors, 'the fixture between ' .. team .. ' and ' .. e.opponent
						.. ' appears ' .. (count[a] or 0) .. ' time(s) in |schedule_' .. team .. '= but '
						.. (count[b] or 0) .. ' time(s) in |schedule_' .. e.opponent .. '=')
				end
			elseif not e.null then
				add_error(errors, '|schedule_' .. team .. '= names ' .. e.opponent
					.. ', which has no |schedule_' .. e.opponent
					.. '=; if it is an opponent from outside this table, add it to |external_teams=')
			end
		end
	end
end

--------------------------------------------------------------------------------
-- Building a block table
--------------------------------------------------------------------------------

local function build_table(frame, Args, util, opts, block, schedules, notes, errors)
	local root = mw.html.create('table')
		:addClass('wikitable sports-res plainrowheaders')
		:css('text-align', 'center')
	if opts.font_size then root:css('font-size', opts.font_size) end
	if opts.center then root:addClass('center-table') end
	-- Keep rows unwrapped when requested.
	if opts.table_nowrap then root:css('white-space', 'nowrap') end
	if block.title then root:tag('caption'):wikitext(block.title) end

	-- Optional legend row above the headers.
	if opts.header_legend and opts.legend then
		local lr = root:tag('tr')
		lr:tag('th'):attr('colspan', tostring(opts.max_rounds + 1)):wikitext(opts.legend)
	end

	-- Header.
	local h1 = root:tag('tr')
	local team_cell = h1:tag('th'):attr('scope', 'col'):wikitext(opts.team_header)
	-- Explicit width aligns team columns across blocks.
	if opts.team_width then team_cell:css('width', opts.team_width) end
	-- Span all active header rows.
	local head_rows = 1 + (opts.span_header and 1 or 0) + (opts.round_dates and 1 or 0)
	if head_rows > 1 then team_cell:attr('rowspan', tostring(head_rows)) end

	local label_row = h1
	if opts.span_header then
		h1:tag('th'):attr('colspan', tostring(opts.max_rounds)):wikitext(opts.span_header)
		label_row = root:tag('tr')
	end
	for n = 1, opts.max_rounds do
		local c = label_row:tag('th'):attr('scope', 'col')
			:wikitext(round_header(frame, Args, n))
		if opts.col_width then c:css('width', opts.col_width) end
	end
	if opts.round_dates then
		local h3 = root:tag('tr')
		for n = 1, opts.max_rounds do
			h3:tag('th'):attr('scope', 'col')
				:wikitext(Args['round' .. n .. '_date'] or '')
		end
	end

	for _, team in ipairs(block.teams) do
		local bold = (opts.showteam == team)
		local name = Args['name_' .. team] or team
		local mode = opts.codes
		if mode == 'inline' or mode == 'inline_bold' or mode == 'break' or mode == 'break_bold' then
			local code = Args['short_' .. team] or team
			-- Bold only the code, not its brackets.
			if (mode == 'inline_bold' or mode == 'break_bold') and not bold then
				code = "'''" .. code .. "'''"
			end
			local sep = (mode == 'break' or mode == 'break_bold') and '<br>' or ' '
			name = name .. sep .. '<small>(' .. code .. ')</small>'
		end
		if notes.team[team] then name = name .. notes.team[team] end
		if bold then name = "'''" .. name .. "'''" end

		-- Skip the empty score row for byes-only schedules.
		local entries_pre = schedules[team] or {}
		local has_scores = false
		for _, e in ipairs(entries_pre) do
			if not e.null then has_scores = true break end
		end
		local span2 = has_scores and '2' or '1'

		local opp_row = root:tag('tr')
		local th = opp_row:tag('th'):attr('scope', 'row'):attr('rowspan', span2):wikitext(name)
		if opts.team_align then th:css('text-align', opts.team_align) end
		if opts.team_nowrap then th:css('white-space', 'nowrap') end
		-- Apply row dividers to cells for browser compatibility.
		if opts.row_divider then th:cssText(opts.row_divider) end
		local score_row = has_scores and root:tag('tr') or nil

		local entries = schedules[team] or {}
		for _, e in ipairs(entries) do
			if e.null then
				local cell = opp_row:tag('td'):attr('rowspan', span2)
				if (e.span or 1) > 1 then cell:attr('colspan', tostring(e.span)) end
				if opts.row_divider then cell:cssText(opts.row_divider) end
				-- Bye text may override |null_text=.
				local txt = e.text or opts.null_text
				if opts.solid_grey then
					cell:css('background', '#BBB')
					if e.text or opts.null_text_set then cell:wikitext(txt) end
				else
					cell:wikitext(txt)
				end
			else
				-- Opponent.
				local short
				local code = Args['short_' .. e.opponent] or e.opponent
				local oname = Args['name_' .. e.opponent]
				if opts.opp_codes == 'unlinked' then
					short = code
					if opts.short_style ~= 'noflag' and oname then
						local icon = util.split_icon(oname)
						if icon ~= '' then short = icon .. short end
					end
				elseif opts.opp_codes == 'abbr' then
					-- Use the full name in the abbreviation tooltip.
					local shown = oname and util.clean_team_name(oname, e.opponent) or e.opponent
					short = frame:expandTemplate{title = 'abbr', args = {code, shown}}
					if opts.short_style ~= 'noflag' and oname then
						local icon = util.split_icon(oname)
						if icon ~= '' then short = icon .. short end
					end
				else
					short = util.get_short_name(Args['short_' .. e.opponent], e.opponent,
						oname, opts.short_style)
				end
				local venue_txt, at = '', ''
				local ocell = opp_row:tag('td')
				if opts.row_divider then ocell:cssText(opts.row_divider) end
				if opts.venue_display == 'color' then
					if e.venue == 'h' then
						ocell:css('background', '#DDD')
					elseif e.venue == 'n' then
						ocell:css('background', '#FEDCBA')
					end
				elseif opts.venue_display == 'at' then
					-- Prefix away games with @.
					if e.venue == 'a' then at = '@&nbsp;' end
				elseif opts.venue_display == 'abbr' then
					venue_txt = ' <small>(' .. e.venue:upper() .. ')</small>'
				end
				local cell_txt = at .. short .. venue_txt
				ocell:wikitext(bold and ("'''" .. cell_txt .. "'''") or cell_txt)

				-- Score.
				local raw, reversed, key = lookup(Args, 'match', team, e.opponent, e.venue, e.num, nil, opts.all_neutral)
				local scell = score_row:tag('td')
				local state = match_state(raw)
				if state then
					-- Unplayed matches use their state label.
					local st = STATES[state]
					scell:css('background', st.hex)
					local shown = st.text
					local mn = notes.match[key]
					if mn then
						if mn.used then
							shown = shown .. mn.ref
						else
							mn.used = true
							shown = shown .. mn.def
						end
					end
					scell:wikitext(bold and ("'''" .. shown .. "'''") or shown)
				elseif raw and raw ~= '' and raw ~= 'null' then
					local plain, turned, blocked = score_forms(raw, util)
					-- Evaluate the result from this team's view.
					local own = reversed and turned or plain
					local shown = own
					if opts.home_first and e.venue ~= 'n' then
						-- Scores are stored home first.
						shown = plain
					end
					if reversed and blocked then
						add_error(errors, '|' .. key .. '= contains markup, so its score was not '
							.. 'turned round for the other team\'s row; give the value without tags, '
							.. 'or write the two meetings out separately')
					end

					local extra = select(1, lookup(Args, 'result', team, e.opponent, e.venue, e.num, nil, opts.all_neutral)) or ''
					if reversed then extra = invert_result(extra) end

					if opts.style == 'FBR' then
						-- Shootouts colour by the advancing team.
						if extra == 'PKW' then
							scell:cssText(util.get_score_background('1–0', 'level3', 'blue'))
						elseif extra == 'PKL' then
							scell:cssText(util.get_score_background('0–1', 'level3', 'blue'))
						else
							local lvl = (extra == 'OT' and 'level2') or (extra == 'PK' and 'level3') or ''
							scell:cssText(util.get_score_background(own, lvl, 'blue'))
						end
					elseif opts.style == 'BSR' then
						if extra == 'OTL' or extra == 'PKL' then
							scell:cssText(util.get_score_background('0–1', 'level3', 'blue'))
						elseif extra == 'OTW' or extra == 'PKW' then
							scell:cssText(util.get_score_background('1–0', 'level3', 'blue'))
						elseif extra == 'L' then
							scell:cssText(util.get_score_background('0–1', '', 'blue'))
						elseif extra == 'W' then
							scell:cssText(util.get_score_background('1–0', '', 'blue'))
						else
							scell:cssText(util.get_score_background(own, extra == 'OT' and 'level3' or '', 'blue'))
						end
					elseif opts.colour then
						local lvl = (extra == 'OT' and 'level2') or (extra == 'PK' and 'level3') or ''
						if extra == 'W' then
							scell:cssText(util.get_score_background('1–0', '', 'green'))
						elseif extra == 'L' then
							scell:cssText(util.get_score_background('0–1', '', 'green'))
						elseif extra == 'OTW' or extra == 'PKW' then
							scell:cssText(util.get_score_background('1–0', 'level3', 'green'))
						elseif extra == 'OTL' or extra == 'PKL' then
							scell:cssText(util.get_score_background('0–1', 'level3', 'green'))
						else
							scell:cssText(util.get_score_background(own, lvl, 'green'))
						end
					end

					if opts.generate_links then
						local first, second
						if e.venue == 'n' then
							-- Use parameter order for neutral-match anchors.
							first = reversed and e.opponent or team
							second = reversed and team or e.opponent
						else
							first = (e.venue == 'h') and team or e.opponent
							second = (e.venue == 'h') and e.opponent or team
						end
						shown = util.generate_match_link(
							util.clean_team_name(Args['name_' .. first], first),
							util.clean_team_name(Args['name_' .. second], second),
							shown)
					end
					local mn = notes.match[key]
					if mn then
						-- Define each match footnote once.
						if mn.used then
							shown = shown .. mn.ref
						else
							mn.used = true
							shown = shown .. mn.def
						end
					end
					scell:wikitext(bold and ("'''" .. shown .. "'''") or shown)
				else
					scell:wikitext('')
				end
			end
		end

		-- Pad shorter rows to a consistent width.
		local pad = opts.max_rounds - row_width(entries)
		if pad > 0 then
			local padcell = opp_row:tag('td'):attr('rowspan', span2)
			if pad > 1 then padcell:attr('colspan', tostring(pad)) end
			if opts.row_divider then padcell:cssText(opts.row_divider) end
			padcell:wikitext('')
		end
	end

	-- Add the outside-opponent key below a single table.
	if opts.external_key then
		root:tag('tr'):tag('td')
			:attr('colspan', tostring(opts.max_rounds + 1))
			:wikitext(opts.external_key)
	end

	return tostring(root)
end

-- Wrap content in one horizontal scrollbar.
local function scroller(inner)
	return '<div style="overflow:hidden"><div class="noresize overflowbugx" style="overflow:auto">'
		.. inner .. '</div></div>'
end

--------------------------------------------------------------------------------
-- Round output via Module:Sports series
--------------------------------------------------------------------------------

-- Return the long form of a round heading.
local function round_label(frame, Args, n)
	local override = Args['round' .. n .. '_header']
	if override then return override end
	local preset = ROUND_TYPES[string.lower(trim(Args['round_type']))]
	local long = round_text(Args['round_name'] or (preset and preset[2]), n)
	if long then return long end
	local short = round_text(Args['round_abbreviation'] or (preset and preset[1]), n)
	if short then return short end
	return round_text(DEFAULT_ROUND_NAME, n)
end

-- Strip bold/italic markup; Module:Sports series handles highlighting.
local function series_name(Args, code, home, util)
	local n = Args['name_' .. code] or code
	n = n:gsub("'''+", ''):gsub("''", '')
	if home then
		-- Home names place the flag after the name.
		local icon, rest = util.split_icon(n)
		if icon ~= '' then
			icon = icon:gsub('%s*&nbsp;%s*$', ''):gsub('%s+$', '')
			n = rest .. '&nbsp;' .. icon
		end
	end
	return n
end

-- Map only compatible parameters to Module:Sports series.
local SERIES_PASS = {'generate_links', 'link_headings', 'baselink', 'id', 'font_size', 'center_table',
	'solid_cell', 'collapsed', 'small_text', 'bold_winner', 'color_winner',
	'away_goals', 'note_group'}
local SERIES_MAP = {
	series_matches_style = 'matches_style',
	series_team1 = 'team1',
	series_team2 = 'team2',
	series_aggregate = 'aggregate',
	table_nowrap = 'nowrap',
	teamwidth = 'team_width',
	match_col_width = 'score_width',
}

local function series_args(Args)
	local out = {}
	for _, k in ipairs(SERIES_PASS) do
		if Args[k] ~= nil then out[k] = Args[k] end
	end
	for from, to in pairs(SERIES_MAP) do
		if Args[from] ~= nil then out[to] = Args[from] end
	end
	-- Series mode fixes one leg per row and embeds flags in names.
	out.legs = 'n'
	out.flag = 'n'
	-- This module builds the shared footer.
	out.note_list = 'n'
	out.legend = 'n'
	return out
end

-- Build one series table for the given rounds.
local function build_series(frame, Args, util, opts, rounds, by_round, notes, heading, errors)
	local p_series = require('Module:Sports series')
	local args = series_args(Args)
	if opts.any_neutral then
		-- Neutral fixtures use generic headings.
		args.h_a = nil
	else
		args.h_a = args.team1 and args.team2 and nil or 'y'
	end
	if heading then args.caption = heading end
	if opts.split_columns then args.split_columns = opts.split_columns end

	local i, row = 1, 0
	for _, n in ipairs(rounds) do
		local first = true
		for _, e in ipairs(by_round[n] or {}) do
			row = row + 1
			if first and opts.headings then
				args['heading' .. row] = round_label(frame, Args, n)
				first = false
			end
			args[i] = series_name(Args, e.home, true, util)
			local raw = select(1, lookup(Args, 'match', e.home, e.away,
				e.neutral and 'n' or 'h', e.num or 1, nil, opts.all_neutral))
			args[i + 1] = (raw and raw ~= '' and raw ~= 'null') and util.format_score(raw) or ''
			args[i + 2] = series_name(Args, e.away, false, util)
			local _, _, key = lookup(Args, 'match', e.home, e.away,
				e.neutral and 'n' or 'h', e.num or 1, nil, opts.all_neutral)
			if notes.match[key] then args['note_agg_' .. row] = Args[key .. '_note'] end
			i = i + 3
		end
	end
	if row == 0 then return '' end
	return p_series._main(frame, args)
end

--------------------------------------------------------------------------------
-- Shared legend
--------------------------------------------------------------------------------

-- Valid |display_codes= values.
local CODE_MODES = {inline = true, inline_bold = true, ['break'] = true,
	break_bold = true, legend = true, legend_unlinked = true}

local function code_mode(v)
	v = string.lower(trim(v))
	if v == '' then return nil end
	if CODE_MODES[v] then return v end
	if yesno(v) == false or v == 'none' then return nil end
	return nil, v            -- unrecognised; the caller reports it
end

local function has_neutral(schedules)
	for _, entries in pairs(schedules) do
		for _, e in ipairs(entries) do
			if not e.null and e.venue == 'n' then return true end
		end
	end
	return false
end


-- Result-cell colours matching get_score_background.
local WIN_HEX = {blue = '#BBF3FF', green = '#BBF3BB'}
local DRAW_HEX, LOSS_HEX = '#FFFFBB', '#FFBBBB'
local HOME_HEX, NEUTRAL_HEX = '#DDD', '#FEDCBA'

-- Build a standard legend entry.
local function legend_entry(opts, abbrev, meaning)
	if opts.abbrev_after then
		return meaning .. opts.divider .. abbrev
	end
	return abbrev .. opts.divider .. meaning
end

local function colour_entry(frame, opts, hex, name, meaning)
	if opts.color_boxes then
		-- Colour-box labels are capitalized.
		return frame:expandTemplate{title = 'color box',
			args = {hex, meaning:sub(1, 1):upper() .. meaning:sub(2)}}
	end
	return name .. opts.divider .. meaning
end

-- Build team-key entries for legends and outside opponents.
local function team_key_entries(Args, util, opts, codes)
	local out = {}
	for i, team in ipairs(codes) do
		local name = Args['name_' .. team] or team
		if opts.codes == 'legend_unlinked' then
			name = util.clean_team_name(name, team)
		elseif opts.short_style == 'noflag' then
			local _, rest = util.split_icon(name)
			name = rest
		end
		out[i] = legend_entry(opts, Args['short_' .. team] or team, name)
	end
	return out
end

-- Gather legend content for header and footer rendering.
local function legend_data(frame, Args, util, opts, teams, schedules)
	local d = {venue = {}, colours = {}, teams = {}, venue_is_colour = false,
		venue_is_sentence = false}
	local word = opts.tie and 'game' or 'match'
	local neutral = has_neutral(schedules)

	if opts.venue_display == 'color' then
		d.venue_is_colour = true
		d.venue[#d.venue + 1] = colour_entry(frame, opts, HOME_HEX, 'Grey', 'home ' .. word)
		d.venue[#d.venue + 1] = colour_entry(frame, opts,
			'var(--background-color-neutral-subtle, #f8f9fa)',
			'standard background', 'away ' .. word)
		if neutral then
			d.venue[#d.venue + 1] = colour_entry(frame, opts, NEUTRAL_HEX, 'orange',
				'neutral ' .. word)
		end
	elseif opts.venue_display == 'at' then
		d.venue_is_sentence = true
		d.venue[#d.venue + 1] = 'Opponents shown after @ were played away'
	elseif opts.venue_display == 'abbr' then
		d.venue[#d.venue + 1] = legend_entry(opts, 'H', 'home')
		d.venue[#d.venue + 1] = legend_entry(opts, 'A', 'away')
		if neutral then d.venue[#d.venue + 1] = legend_entry(opts, 'N', 'neutral') end
	end

	if opts.coloured then
		local blue = (opts.style == 'FBR' or opts.style == 'BSR')
		local win = colour_entry(frame, opts, WIN_HEX[blue and 'blue' or 'green'],
			blue and 'Blue' or 'Green', 'win')
		local draw = colour_entry(frame, opts, DRAW_HEX, 'Yellow', opts.tie and 'tie' or 'draw')
		local loss = colour_entry(frame, opts, LOSS_HEX, 'Red', 'loss')
		d.colours[#d.colours + 1] = win
		if opts.loss_first then
			d.colours[#d.colours + 1] = loss
			d.colours[#d.colours + 1] = draw
		else
			d.colours[#d.colours + 1] = draw
			d.colours[#d.colours + 1] = loss
		end
	end

	-- List cancelled and postponed states last.
	for _, key in ipairs({'canc', 'ppd'}) do
		if opts.states_used and opts.states_used[key] then
			local st = STATES[key]
			d.colours[#d.colours + 1] = colour_entry(frame, opts, st.hex, st.name,
				st.meaning .. ' ' .. word)
		end
	end

	if opts.codes == 'legend' or opts.codes == 'legend_unlinked' then
		d.teams = team_key_entries(Args, util, opts, teams)
	end
	return d
end

-- Render the footer legend in its established format.
local function legend_footer(frame, Args, util, opts, teams, schedules)
	local d = legend_data(frame, Args, util, opts, teams, schedules)
	local parts = {}
	if #d.venue > 0 then
		local sep = ', '
		if d.venue_is_colour then sep = '; ' elseif d.venue_is_sentence then sep = ' ' end
		parts[#parts + 1] = table.concat(d.venue, sep)
	end
	if #d.colours > 0 then
		parts[#parts + 1] = table.concat(d.colours, '; ')
	end
	local out = {}
	if #parts > 0 then
		out[#out + 1] = '<br />Legend: ' .. table.concat(parts, '. ') .. '.'
	end
	if #d.teams > 0 then
		out[#out + 1] = '<br />Team key: ' .. table.concat(d.teams, '; ') .. '.'
	end
	return table.concat(out)
end

-- Render multi-item header legends as inline hlist entries.
local function inline_list(frame, items)
	if #items == 1 then return items[1] end
	local args = {class = 'inline'}
	for i, v in ipairs(items) do
		args[i] = v
	end
	return frame:expandTemplate{title = 'hlist', args = args}
end

-- Render the key for outside opponents.
local function external_key(frame, Args, util, opts, codes)
	if #codes == 0 then return nil end
	local entries = team_key_entries(Args, util, opts, codes)
	local per = tonumber(Args['legend_teams_per_line']) or 4
	if per < 1 then per = 1 end
	local lines = {}
	for i = 1, #entries, per do
		local chunk = {}
		for j = i, math.min(i + per - 1, #entries) do chunk[#chunk + 1] = entries[j] end
		lines[#lines + 1] = inline_list(frame, chunk)
	end
	local label = Args['external_teams_label']
	if label == nil then label = 'Other teams' end
	label = trim(label)
	if label == '' then return table.concat(lines, '<br>') end
	if #lines == 1 then return label .. ': ' .. lines[1] end
	return label .. ':<br>' .. table.concat(lines, '<br>')
end

-- Render venue, team-key, and colour entries in the header.
local function legend_header(frame, Args, util, opts, teams, schedules)
	local d = legend_data(frame, Args, util, opts, teams, schedules)
	local lines, kinds = {}, {}
	if #d.venue > 0 and not d.venue_is_colour then
		lines[#lines + 1] = inline_list(frame, d.venue)
		kinds[#kinds + 1] = 'venue'
	end
	if #d.teams > 0 then
		-- The row already says "Legend:".
		local per = tonumber(Args['legend_teams_per_line']) or 4
		if per < 1 then per = 1 end
		for i = 1, #d.teams, per do
			local chunk = {}
			for j = i, math.min(i + per - 1, #d.teams) do chunk[#chunk + 1] = d.teams[j] end
			lines[#lines + 1] = inline_list(frame, chunk)
			kinds[#kinds + 1] = 'teams'
		end
	end
	local cols = {}
	if d.venue_is_colour then
		for _, v in ipairs(d.venue) do cols[#cols + 1] = v end
	end
	for _, v in ipairs(d.colours) do cols[#cols + 1] = v end
	if #cols > 0 then
		lines[#lines + 1] = inline_list(frame, cols)
		kinds[#kinds + 1] = 'colours'
	end

	if #lines == 0 then return nil end
	if #lines == 1 and (kinds[1] == 'venue' or kinds[1] == 'colours') then
		return 'Legend: ' .. lines[1]
	end
	return 'Legend:<br>' .. table.concat(lines, '<br>')
end

--------------------------------------------------------------------------------
-- Footer
--------------------------------------------------------------------------------

-- Series footer: one FBR legend and the note list.
local function build_series_footer(frame, Args, opts, notes_exist)
	local f = {}
	if opts.series_fbr then
		f[#f + 1] = 'Legend: Blue = home team win; Yellow = draw; Red = away team win.'
	end
	if notes_exist then
		f[#f + 1] = (#f > 0 and '<br>' or '') .. 'Notes:'
	end
	if #f == 0 then return '' end

	-- Load these styles with the notes container.
	local templatestyles = frame:extensionTag{name = 'templatestyles',
		args = {src = 'Module:Sports results/styles.css'}}
	local out = templatestyles .. '<div class="sports-results-notes">' .. table.concat(f) .. '</div>'
	if notes_exist then
		out = out .. frame:expandTemplate{title = 'notelist',
			args = {group = Args['note_group'] or 'lower-alpha'}}
	end
	return out
end

-- Build one footer for all blocks.
local function build_footer(frame, Args, util, opts, teams, schedules, notes_exist)
	local f = {}
	local word = yesno(Args['use_tie'] or 'no') and 'game' or 'match'
	local update = Args['update'] or ''
	local source = Args['source'] or frame:expandTemplate{title = 'citation needed',
		args = {reason = 'No source parameter defined', date = os.date('%B %Y')}}

	if string.lower(update) == 'complete' then
		-- No score direction needed.
	elseif update == '' then
		f[#f + 1] = 'Updated to ' .. word .. '(es) played on unknown.'
	elseif string.lower(update) == 'future' then
		f[#f + 1] = 'First ' .. word .. '(es) will be played: ' .. (Args['start_date'] or 'unknown') .. '.'
	else
		f[#f + 1] = 'Updated to ' .. word .. '(es) played on ' .. update .. '.'
	end
	f[#f + 1] = ' Source: ' .. source

	-- Explain score direction.
	local coloured = opts.coloured
	local show_venue = (opts.venue_display ~= 'none')
	if opts.home_first then
		local txt = '<br />Scores are listed with the home team\'s score first'
		if has_neutral(schedules) then
			txt = txt .. '; neutral ' .. word .. 'es are listed from the perspective of the team in the "'
				.. (Args['team_header'] or 'Team') .. '" column'
		end
		f[#f + 1] = txt .. '.'
	else
		f[#f + 1] = '<br />Scores' .. (coloured and ' and legend colours' or '')
			.. ' are listed from the perspective of the team in the "'
			.. (Args['team_header'] or 'Team') .. '" column.'
	end

	-- Add the legend unless it is in the header.
	if not opts.header_legend then
		f[#f + 1] = legend_footer(frame, Args, util, opts, teams, schedules)
	end

	-- Block layouts place the outside-opponent key in the footer.
	if opts.footer_external then
		f[#f + 1] = '<br />' .. opts.footer_external
	end

	if Args['a_note'] then
		f[#f + 1] = '<br />For upcoming ' .. word .. 'es, an "a" indicates there is an article about the rivalry between the two participants.'
	end
	if Args['ot_note'] then
		f[#f + 1] = '<br />' .. (yesno(Args['use_tie'] or 'no') and 'Games' or 'Matches')
			.. ' with lighter background shading were decided after overtime.'
	end
	if Args['pk_note'] then
		f[#f + 1] = '<br />' .. (yesno(Args['use_tie'] or 'no') and 'Games' or 'Matches')
			.. ' with lighter background shading were decided by a penalty shoot-out.'
	end

	local templatestyles = frame:extensionTag{name = 'templatestyles',
		args = {src = 'Module:Sports results/styles.css'}}
	if notes_exist then
		f[#f + 1] = '<br>Notes:'
		return templatestyles .. '<div class="sports-results-notes">' .. table.concat(f) .. '</div>'
			.. frame:expandTemplate{title = 'notelist', args = {group = 'lower-alpha'}}
	end
	return templatestyles .. '<div class="sports-results-notes">' .. table.concat(f) .. '</div>'
end

--------------------------------------------------------------------------------
-- Entry point
--------------------------------------------------------------------------------

function pp._main(frame, Args, util, standalone)
	local errors = {}

	local format = string.lower(trim(Args['matches_format']))
	local series_mode = (format == 'series' or format == 'series_split')
	local series_split = (format == 'series_split')

	local top_pos = tonumber(Args['highest_pos']) or tonumber(Args['highest_row']) or 1
	local bottom_pos = tonumber(Args['lowest_pos']) or tonumber(Args['lowest_row']) or 0

	if Args['legs'] then
		add_error(errors, '|legs= has no meaning in the schedule format; repeat meetings are numbered instead')
	end
	do
		local _, bad = code_mode(Args['display_codes'])
		if bad then
			add_error(errors, '|display_codes=' .. bad .. ' was not recognised; it is one of inline, '
				.. 'inline_bold, break, break_bold, legend, legend_unlinked, or a negative value for none')
		end
	end
	local opp = string.lower(trim(Args['opponent_inline_codes']))
	if opp ~= '' and opp ~= 'linked' and opp ~= 'unlinked' and opp ~= 'abbr' then
		add_error(errors, '|opponent_inline_codes=' .. opp
			.. ' was not recognised; it is linked, unlinked or abbr')
	end
	if yesno(Args['header_legend'] or 'no') and using_blocks_early(Args) then
		add_error(errors, '|header_legend= does nothing when the teams are split into blocks, '
			.. 'because each block is a separate table; the legend stays in the footer')
	end
	if trim(Args['round_type']) ~= '' and not ROUND_TYPES[string.lower(trim(Args['round_type']))] then
		local names = {}
		for k in pairs(ROUND_TYPES) do names[#names + 1] = k end
		table.sort(names)
		add_error(errors, '|round_type=' .. trim(Args['round_type']) .. ' was not recognised; it is one of '
			.. table.concat(names, ', ') .. ', or set |round_abbreviation= and |round_name= directly')
	end

	-- |schedule_TTT= takes precedence over round lists.
	local has_schedule, has_rounds = false, false
	local round_params = {}
	for k in pairs(Args) do
		if type(k) == 'string' then
			if k:match('^schedule_.+$') then has_schedule = true end
			local n = k:match('^round(%d+)_matches$')
			if n then
				has_rounds = true
				round_params[#round_params + 1] = 'round' .. n .. '_matches'
			end
		end
	end
	table.sort(round_params)
	if has_schedule and has_rounds then
		add_error(errors, '|schedule_TTT= and |' .. table.concat(round_params, '=, |')
			.. '= are both given; the round lists have been ignored')
	end

	-- Round output requires |roundN_matches= lists.
	if series_mode and not has_rounds then
		return '<div style="color:red">Error: |matches_format=' .. format
			.. ' needs |roundN_matches=; it cannot be built from |schedule_TTT=</div>'
	end

	-- |all_neutral= permits bare codes and unordered match keys.
	local all_neutral = yesno(Args['all_neutral'] or 'no') and true or false

	-- Outside opponents have no schedule row but may have names and shorts.
	local external, external_list = {}, {}
	for _, c in ipairs(split_list(Args['external_teams'] or '')) do
		if not external[c] then
			external[c] = true
			external_list[#external_list + 1] = c
		end
	end
	for _, c in ipairs(external_list) do
		if Args['schedule_' .. c] ~= nil then
			add_error(errors, c .. ' is in |external_teams= but also has |schedule_' .. c
				.. '=; a team is either a row of the table or an outside opponent, not both')
		end
	end

	-- Warn when row-order parameters include outside opponents.
	for _, p in ipairs({'default_order', 'team_order'}) do
		for _, c in ipairs(split_list(Args[p] or '')) do
			if external[c] then
				add_error(errors, '|' .. p .. '= names ' .. c
					.. ', which is in |external_teams= and has no row; it has been passed over')
			end
		end
	end
	do
		local i = top_pos
		while Args['team' .. i] ~= nil do
			if external[trim(Args['team' .. i])] then
				add_error(errors, '|team' .. i .. '=' .. trim(Args['team' .. i])
					.. ' is in |external_teams= and has no row; it has been passed over')
			end
			i = i + 1
		end
	end

	local teams, roster, schedules, max_rounds, by_round

	if has_schedule or not has_rounds then
		teams, roster = team_list(Args, util, top_pos, bottom_pos, nil, external)
		schedules, max_rounds = {}, 0
		for _, team in ipairs(teams) do
			schedules[team] = read_schedule(Args, team, errors, all_neutral)
			max_rounds = math.max(max_rounds, row_width(schedules[team]))
		end
		check_idle(teams, schedules, errors)
		check_pairs(schedules, roster, errors, external)
	else
		-- Names validate round-list codes; outsiders remain valid for splitting.
		local named, codes = false, {}
		for k in pairs(Args) do
			if type(k) == 'string' then
				local c = k:match('^name_(.+)$')
				if c and c ~= '' then
					if not external[c] then named = true end
					codes[c] = true
				end
			end
		end
		for _, c in ipairs(external_list) do codes[c] = true end
		if not named then
			-- With no names, infer codes from round lists.
			for k, v in pairs(Args) do
				if type(k) == 'string' and k:match('^round%d+_matches$') then
					for _, item in ipairs(split_list(v)) do
						local pair = item:match('^(.-):') or item
						local i = pair:find('_', 1, true)
						while i do
							codes[trim(pair:sub(1, i - 1))] = true
							codes[trim(pair:sub(i + 1))] = true
							i = pair:find('_', i + 1, true)
						end
					end
				end
			end
		end
		local built
		built, max_rounds, by_round = read_rounds(Args, codes, named, errors, all_neutral, external, series_mode)
		local order = {}
		for c in pairs(codes) do order[#order + 1] = c end
		teams, roster = team_list(Args, util, top_pos, bottom_pos, order, external)
		schedules = built or {}
		for _, team in ipairs(teams) do
			schedules[team] = schedules[team] or {}
		end
	end

	if #teams == 0 then
		add_error(errors, 'no teams were found: the schedule format needs |schedule_TTT= '
			.. 'or |roundN_matches= parameters')
	end

	-- Notes are shared by both match appearances.
	local notes_exist = false
	local notes = {team = {}, match = {}}
	local rand = tostring(math.random(1000, 9999))
	for _, team in ipairs(teams) do
		local tn = util.team_note(Args, team, standalone)
		if tn then
			notes_exist = true
			notes.team[team] = frame:expandTemplate{title = 'efn',
				args = {group = 'lower-alpha', tn}}
		end
	end
	for _, team in ipairs(teams) do
		for _, e in ipairs(schedules[team] or {}) do
			if not e.null then
				local _, _, key = lookup(Args, 'match', team, e.opponent, e.venue, e.num, errors, all_neutral)
				local note = Args[key .. '_note']
				if note and not notes.match[key] then
					notes_exist = true
					local id = '"sched_' .. key .. rand .. '"'
					notes.match[key] = {
						def = frame:expandTemplate{title = 'efn',
							args = {group = 'lower-alpha', name = id, note}},
						ref = frame:extensionTag{name = 'ref',
							args = {group = 'lower-alpha', name = id}},
						used = false,
					}
				end
			end
		end
	end

	--------------------------------------------------------------------------
	-- Used legend entries
	--------------------------------------------------------------------------

	-- List only states and outside opponents actually used.
	local states_used, external_seen = {}, {}
	for _, team in ipairs(teams) do
		for _, e in ipairs(schedules[team] or {}) do
			if not e.null then
				if external[e.opponent] then external_seen[e.opponent] = true end
				local raw = select(1, lookup(Args, 'match', team, e.opponent, e.venue,
					e.num, nil, all_neutral))
				local st = match_state(raw)
				if st then states_used[st] = true end
			end
		end
	end

	local external_used = {}
	for _, c in ipairs(external_list) do
		if external_seen[c] then external_used[#external_used + 1] = c end
	end
	-- Order outsiders by |default_order=, display text, then code.
	do
		local order, placed_t = {}, {}
		local seen_ext = {}
		for _, c in ipairs(external_used) do seen_ext[c] = true end
		for _, c in ipairs(split_list(Args['default_order'] or '')) do
			if seen_ext[c] and not placed_t[c] then
				placed_t[c] = true
				order[#order + 1] = c
			end
		end
		local rest = {}
		for _, c in ipairs(external_used) do
			if not placed_t[c] then rest[#rest + 1] = c end
		end
		table.sort(rest, name_sorter(Args, util))
		for _, c in ipairs(rest) do order[#order + 1] = c end
		external_used = order
	end

	local style = Args['matches_style'] or ''
	local opts = {
		max_rounds = max_rounds,
		team_header = Args['team_header'] or 'Team',
		team_align = Args['team_align'],
		team_nowrap = yesno(Args['team_nowrap'] or 'no') and true or false,
		col_width = Args['match_col_width'],
		font_size = Args['font_size'] and (Args['font_size']:match('^%d+$')
			and (Args['font_size'] .. '%') or Args['font_size']) or nil,
		center = yesno(Args['center_table'] or 'no') and true or false,
		showteam = Args['showteam'],
		codes = code_mode(Args['display_codes']),
		opp_codes = string.lower(trim(Args['opponent_inline_codes'])),
		short_style = Args['short_style'],
		tie = yesno(Args['use_tie'] or 'no') and true or false,
		loss_first = (Args['loss_before_draw'] or Args['loss_before_tie']) and true or false,
		divider = (string.lower(trim(Args['legend_divider'])) == 'dash') and ' – ' or ' = ',
		abbrev_after = yesno(Args['abbreviation_after'] or 'no') and true or false,
		color_boxes = yesno(Args['color_boxes'] or 'no') and true or false,
		all_neutral = all_neutral,
		home_first = yesno(Args['home_first'] or 'no') and true or false,
		solid_grey = Args['solid_cell'] and (Args['solid_cell']:lower() == 'grey'
			or Args['solid_cell']:lower() == 'gray' or yesno(Args['solid_cell'])) or false,
		style = (style == 'FBR' or style == 'BSR') and style or nil,
		colour = (style == 'FBR_green') and true or false,
		generate_links = yesno(Args['generate_links'] or 'no') and true or false,
		span_header = round_span_header(Args),
		round_dates = any_round_dates(Args),
		null_text = Args['null_text'] or '—',
		null_text_set = (Args['null_text'] ~= nil),
		states_used = states_used,
		table_nowrap = yesno(Args['table_nowrap'] or 'no') and true or false,
		row_divider = yesno(Args['row_dividers'] or 'no') and 'border-top:2px solid gray;' or nil,
		team_width = Args['teamwidth'] and (Args['teamwidth']:match('^%d+$')
			and (Args['teamwidth'] .. 'px') or Args['teamwidth']) or nil,
	}

	-- Neutral fixtures override incompatible venue displays.
	local vd = string.lower(trim(Args['venue_display']))
	if vd == '' then vd = 'abbr' end
	if vd ~= 'abbr' and vd ~= 'color' and vd ~= 'colour' and vd ~= 'at' then
		add_error(errors, '|venue_display=' .. vd .. ' was not recognised; it is abbr, color or at')
		vd = 'abbr'
	end
	if vd == 'colour' then vd = 'color' end
	local every_neutral = all_neutral
	if not every_neutral then
		local any, all = false, true
		for _, team in ipairs(teams) do
			for _, e in ipairs(schedules[team] or {}) do
				if not e.null then
					any = true
					if e.venue ~= 'n' then all = false end
				end
			end
		end
		every_neutral = any and all
	end
	if every_neutral then
		vd = 'none'
	elseif vd == 'at' and has_neutral(schedules) then
		vd = 'abbr'
	end
	opts.venue_display = vd
	opts.color_venue = (vd == 'color')

	if Args['team_header_note'] then
		notes_exist = true
		opts.team_header = opts.team_header .. frame:expandTemplate{title = 'efn',
			args = {group = 'lower-alpha', Args['team_header_note']}}
	end

	-- Track match values that no fixture displays.
	local referenced = {}
	local function mark(team, opponent, venue, num)
		local _, _, key = lookup(Args, 'match', team, opponent, venue, num, nil, opts.all_neutral)
		local forms = {key}
		forms[#forms + 1] = key:gsub('^match(n?)_', 'match%%11_')
		forms[#forms + 1] = key:gsub('^match(n?)1_', 'match%%1_')
		if venue == 'n' then
			-- Include both orders and plain all-neutral keys.
			local n = (opts.all_neutral and {'', 'n'} or {'n'})
			for _, nn in ipairs(n) do
				local numtxt = (num or 1) > 1 and tostring(num) or ''
				forms[#forms + 1] = 'match' .. nn .. numtxt .. '_' .. team .. '_' .. opponent
				forms[#forms + 1] = 'match' .. nn .. numtxt .. '_' .. opponent .. '_' .. team
				if (num or 1) == 1 then
					forms[#forms + 1] = 'match' .. nn .. '1_' .. team .. '_' .. opponent
					forms[#forms + 1] = 'match' .. nn .. '1_' .. opponent .. '_' .. team
				end
			end
		end
		for _, f in ipairs(forms) do referenced[f] = true end
	end
	for _, team in ipairs(teams) do
		for _, e in ipairs(schedules[team] or {}) do
			if not e.null then mark(team, e.opponent, e.venue, e.num) end
		end
	end
	-- Round fixtures may display even when neither side has a row.
	for _, fixtures in pairs(by_round or {}) do
		for _, e in ipairs(fixtures) do
			mark(e.home, e.away, e.neutral and 'n' or 'h', e.num or 1)
		end
	end
	-- Sort orphan warnings for stable output.
	local orphans = {}
	for k in pairs(Args) do
		if type(k) == 'string' and k:match('^matchn?%d*_.+_.+$') and not k:match('_note$')
			and not referenced[k] then
			orphans[#orphans + 1] = k
		end
	end
	table.sort(orphans)
	for _, k in ipairs(orphans) do
		add_error(errors, '|' .. k .. '= is not shown anywhere; no team\'s fixtures refer to it')
	end

	if series_mode then
		local style = Args['series_matches_style'] or ''
		-- Headings are generated for every round; |series_split= also renders
		-- each round as a separate side-by-side table.
		local sopts = {
			headings = true,
			any_neutral = has_neutral(schedules),
			series_fbr = (style == 'FBR'),
			split_columns = series_split and (tonumber(Args['series_columns']) or 2) or nil,
		}
		local all = {}
		for n = 1, (max_rounds or 0) do all[#all + 1] = n end
		local body = build_series(frame, Args, util, sopts, all, by_round, notes, nil, errors)

		local out = scroller(body)
		out = out .. build_series_footer(frame, Args, sopts, notes_exist)
		if #errors > 0 and frame:preprocess('{{REVISIONID}}') == '' then
			for _, e in ipairs(errors) do
				out = out .. '<div style="color:red">Error: ' .. e .. '</div>'
			end
		end
		return out
	end

	local blocks, using_blocks = build_blocks(Args, teams, errors, external)

	-- Header legends are used only for a single table.
	opts.coloured = (opts.style ~= nil) or opts.colour
	if yesno(Args['header_legend'] or 'no') and not using_blocks then
		opts.header_legend = true
		opts.legend = legend_header(frame, Args, util, opts, teams, schedules)
	end

	-- Put the outside-opponent key in the footer for block layouts.
	local ext_key = external_key(frame, Args, util, opts, external_used)
	if using_blocks then
		opts.footer_external = ext_key
	else
		opts.external_key = ext_key
	end

	local parts = {}
	for _, block in ipairs(blocks) do
		parts[#parts + 1] = build_table(frame, Args, util, opts, block, schedules, notes, errors)
	end

	local body
	if using_blocks and #parts > 1 then
		local cols = tonumber(Args['block_columns']) or 2
		if cols < 1 then cols = 1 end
		-- Equal-width tracks expand as needed; the wrapper handles overflow.
		local wrap = mw.html.create('div')
			:css('display', 'grid')
			:css('grid-template-columns', 'repeat(' .. cols .. ', minmax(max-content, 1fr))')
			:css('gap', '1em')
			:css('align-items', 'start')
		for _, part in ipairs(parts) do
			wrap:tag('div'):wikitext(part)
		end
		body = scroller(tostring(wrap))
	else
		local wrapped = {}
		for i, part in ipairs(parts) do
			wrapped[i] = scroller(part)
		end
		body = table.concat(wrapped, '\n')
	end

	body = body .. build_footer(frame, Args, util, opts, teams, schedules, notes_exist)

	-- Rewrite match-report anchors for the transcluded template.
	local baselink = Args['baselink'] or frame:getParent():getTitle()
	if mw.title.getCurrentTitle().fullText == baselink then baselink = '' end
	if baselink ~= '' and body:match('%[%[#[^%[%]]*%|') then
		body = mw.ustring.gsub(body, '(%[%[)(#[^%[%]]*%|)', '%1' .. baselink .. '%2')
	end

	if #errors > 0 and frame:preprocess('{{REVISIONID}}') == '' then
		for _, e in ipairs(errors) do
			body = body .. '<div style="color:red">Error: ' .. e .. '</div>'
		end
	end
	return body
end

return pp