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.

// request.cf · coarse context

A page that knows where it met you.

Only coarse request metadata is shown. This demo does not display or persist visitor IP addresses.

Country
US
Cloudflare location
CMH
Connection
HTTP/2
Language
Not provided

Ray ID: a3ff157fe8043564

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