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 table/auto/sandbox

From Wikipedia, the free encyclopedia
-- Builds standings from match results for |auto_generate_standings=yes.
-- Reads match cells, calculates records, ranks teams, and writes the results
-- back for Module:Sports table. See the documentation for criteria.

require('strict')

local pa = {}

local yesno = require('Module:Yesno')

-- Fields in each statistics record.
local FIELDS = {'pld', 'w', 'd', 'l', 'gf', 'ga',
	'hpld', 'hw', 'hd', 'hl', 'hgf', 'hga',
	'apld', 'aw', 'ad', 'al', 'agf', 'aga'}

-- Score separators normalized to hyphens before matching.
local DASHES = {'–', '—', '−', '‒', '―', '‐', '‑'}
local DASH = '%-+'

-- Limit displayed errors for badly broken tables.
local MAX_ERRORS = 25

-- Limit recursive tiebreak processing.
local MAX_RECURSION = 25

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

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

local function add_error(errors, msg)
	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

--------------------------------------------------------------------------------
-- Match-cell parsing
--------------------------------------------------------------------------------

-- Remove common markup from a match cell before parsing its score.
local function clean_cell(raw)
	local s = tostring(raw or '')
	-- Strip reference markers.
	s = s:gsub('\127[^\127]*\127', '')
	-- Remove superscripts; retain other tag contents.
	s = s:gsub('<[Ss][Uu][Pp].->.-</[Ss][Uu][Pp]>', '')
	s = s:gsub('</?%w+[^<>]*>', '')
	-- Keep wikilink display text.
	s = s:gsub('%[%[[^%[%]|]*|([^%[%]|]*)%]%]', '%1')
	s = s:gsub('%[%[([^%[%]|]*)%]%]', '%1')
	-- Keep external-link display text.
	s = s:gsub('%[%S+%s+([^%[%]]*)%]', '%1')
	-- Normalize entities.
	s = s:gsub('&[Nn][Dd][Aa][Ss][Hh];', '-')
	s = s:gsub('&[Mm][Dd][Aa][Ss][Hh];', '-')
	s = s:gsub('&minus;', '-')
	s = s:gsub('&nbsp;', ' ')
	-- Normalize dash characters.
	for _, d in ipairs(DASHES) do
		s = s:gsub(d, '-')
	end
	return trim(s)
end

-- Returns scores and whether an unparseable value resembles a score.
local function parse_score(raw)
	local s = clean_cell(raw)
	if s == '' or s:lower() == 'null' then
		return nil, nil, false
	end
	local h, a = s:match('^([%d%.]+)%s*' .. DASH .. '%s*([%d%.]+)')
	h, a = tonumber(h or ''), tonumber(a or '')
	if h and a then
		return h, a, false
	end
	-- Flag score-like but unparseable values.
	local looks = s:match('[%d%.]+%s*' .. DASH .. '%s*[%d%.]+') ~= nil
	return nil, nil, looks
end

-- Split match parameter suffixes, 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

-- Return match parameters in a stable order.
local function match_keys(Args)
	local keys = {}
	for k, v in pairs(Args) do
		if type(k) == 'string' and v ~= nil then
			-- Check neutral matches first.
			local num, rest = k:match('^matchn(%d*)_(.+)$')
			local neutral = true
			if not rest then
				num, rest = k:match('^match(%d*)_(.+)$')
				neutral = false
			end
			if rest and not rest:match('_note$') then
				keys[#keys + 1] = {key = k, rest = rest, neutral = neutral,
					num = tonumber(num) or 1, numbered = (num ~= '')}
			end
		end
	end
	table.sort(keys, function(x, y) return x.key < y.key end)
	return keys
end

--------------------------------------------------------------------------------
-- Team list
--------------------------------------------------------------------------------

-- Convert a team name to plain text for sorting and link anchors.
-- Keep this in sync with clean_team_name in Module:Sports results.
local function replace_link(m)
	local pipe_pos = m:find('|')
	if pipe_pos then
		return m:sub(pipe_pos + 1, -3) -- text after the '|'
	else
		return m:sub(3, -3) -- text between the brackets
	end
end

local function plain_name(str, default)
	if str and str ~= '' then
		str = str:gsub('<sup.->.-</sup>', '')
		str = str:gsub('</?%w+[^>]*>', '')
		str = str:gsub('\127%\'"`UNIQ.-QINU`"%\'\127', '')
		str = str:gsub('%[%[[Ff]ile:[^%]]+%]%]', '')
		str = str:gsub('%[%[[Ii]mage:[^%]]+%]%]', '')
		str = str:gsub('%[%[.-%]%]', replace_link)
		str = str:gsub('%s*&nbsp;%s*', '')
		str = str:gsub("'''+", '')
		str = str:gsub("''", '')
		str = str:match('^%s*(.-)%s*$') -- trim
		return (str ~= '' and str) or default
	end
	return default
end

local function name_sorter(Args)
	local key = {}
	local function keyof(c)
		if key[c] == nil then
			key[c] = string.lower(plain_name(Args['name_' .. c], 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

-- Read outside opponents, which are excluded from the standings and matches.
function pa.external(Args)
	local ext = {}
	for _, c in ipairs(mw.text.split(trim(Args['external_teams'] or ''), '%s*[;,]%s*')) do
		if c ~= '' then ext[c] = true end
	end
	return ext
end

-- Build the roster from names and matches, excluding outside opponents.
-- The code set retains outsiders to split match names with underscores.
function pa.roster(Args)
	local codes, list, appears = {}, {}, {}
	local ext = pa.external(Args)
	for k, _ in pairs(Args) do
		if type(k) == 'string' then
			local c = k:match('^name_(.+)$') or k:match('^schedule_(.+)$')
			if c and c ~= '' then codes[c] = true end
		end
	end
	for _, mk in ipairs(match_keys(Args)) do
		local h, a = split_pair(mk.rest, codes)
		if h then
			codes[h] = true
			codes[a] = true
			-- Outside matches do not count as league fixtures.
			if not (ext[h] or ext[a]) then
				appears[h] = (appears[h] or 0) + 1
				if a ~= h then appears[a] = (appears[a] or 0) + 1 end
			end
		end
	end
	for c in pairs(codes) do
		if not ext[c] then list[#list + 1] = c end
	end
	table.sort(list, name_sorter(Args))
	return list, codes, appears
end

-- Warn about likely mistyped team codes without rejecting valid sparse input.
local function check_orphans(Args, teams, appears, errors)
	local n_named = 0
	local unnamed = {}
	for _, c in ipairs(teams) do
		if Args['name_' .. c] ~= nil then
			n_named = n_named + 1
		else
			unnamed[#unnamed + 1] = c
		end
	end

	-- Unnamed codes are suspicious only when other teams have names.
	if n_named > 0 and #unnamed > 0 then
		add_error(errors, 'unnamed teams = ' .. table.concat(unnamed, ', ')
			.. ' (found in a match parameter but given no |name_TTT=)')
	end

	-- Ignore idle teams when the schedule is largely unfilled.
	local total, idle = 0, {}
	for _, c in ipairs(teams) do
		total = total + (appears[c] or 0)
		if (appears[c] or 0) == 0 then idle[#idle + 1] = c end
	end
	if total > 0 and #idle > 0 and (#idle * 3) <= #teams then
		add_error(errors, 'teams with no matches = ' .. table.concat(idle, ', ')
			.. ' (no match parameter mentions them; check for a mistyped team code)')
	end
end

-- Base order: |default_order= first, then remaining codes alphabetically.
function pa.base_order(Args, teams, codes, errors)
	local order, seen = {}, {}
	local raw = Args['default_order']
	if raw and trim(raw) ~= '' then
		for _, tname in ipairs(mw.text.split(raw, '%s*[;,]%s*')) do
			tname = trim(tname)
			if tname ~= '' then
				if not codes[tname] then
					add_error(errors, 'the team "' .. tname .. '" given in |default_order= is not in the table')
				elseif seen[tname] then
					add_error(errors, 'the team "' .. tname .. '" is given more than once in |default_order=')
				else
					seen[tname] = true
					order[#order + 1] = tname
				end
			end
		end
	end
	-- teams is already sorted alphabetically.
	for _, c in ipairs(teams) do
		if not seen[c] then order[#order + 1] = c end
	end
	return order
end

--------------------------------------------------------------------------------
-- Statistics
--------------------------------------------------------------------------------

local function new_stats()
	local st = {}
	for _, f in ipairs(FIELDS) do st[f] = 0 end
	return st
end

-- Neutral matches affect overall records, not home/away records.
-- side is 'home', 'away', or 'neutral'.
local function add_result(st, gf, ga, side)
	st.pld = st.pld + 1
	st.gf = st.gf + gf
	st.ga = st.ga + ga
	local w, d, l = 0, 0, 0
	if gf > ga then
		w = 1
	elseif gf < ga then
		l = 1
	else
		d = 1
	end
	st.w, st.d, st.l = st.w + w, st.d + d, st.l + l
	if side == 'home' then
		st.hpld = st.hpld + 1
		st.hgf = st.hgf + gf
		st.hga = st.hga + ga
		st.hw, st.hd, st.hl = st.hw + w, st.hd + d, st.hl + l
	elseif side == 'away' then
		st.apld = st.apld + 1
		st.agf = st.agf + gf
		st.aga = st.aga + ga
		st.aw, st.ad, st.al = st.aw + w, st.ad + d, st.al + l
	end
end

-- Build played matches; the first code is the home team.
function pa.matches(Args, codes, errors)
	local out = {}
	local legs = tonumber(Args['legs']) or 1
	local fmt = string.lower(trim(Args['matches_format']))
	local schedule = (fmt == 'schedule' or fmt == 'series' or fmt == 'series_split')
	-- Neutral matches cannot appear in a results grid.
	local sm = string.lower(trim(Args['show_matches']))
	local shows_grid = (fmt == '' or fmt == 'grid')
		and sm ~= '' and sm ~= 'no' and sm ~= 'n' and sm ~= 'false' and sm ~= 'f'
		and sm ~= 'off' and sm ~= '0'
	local seen = {}
	local ext = pa.external(Args)
	for _, mk in ipairs(match_keys(Args)) do
		local h, a = split_pair(mk.rest, codes)
		local hs, as, broken = parse_score(Args[mk.key])

		-- Skip matches against listed outside opponents.
		local outside = h and (ext[h] or ext[a]) and true or false

		-- Count only matches shown by grid mode; schedules number freely.
		local displayed = true
		if not schedule and not mk.neutral then
			displayed = (legs == 1) and not mk.numbered or (mk.numbered and mk.num <= legs)
		end

		-- Neutral fixtures are unordered.
		local pair_id
		if mk.neutral and h then
			local x, y = h, a
			if y < x then x, y = y, x end
			pair_id = 'n' .. mk.num .. '|' .. x .. '|' .. y
		else
			pair_id = mk.num .. '|' .. tostring(h) .. '|' .. tostring(a)
		end

		if not h then
			add_error(errors, 'could not read two team codes from |' .. mk.key .. '=')
		elseif outside then
			-- Outside fixture.
		elseif h == a then
			add_error(errors, '|' .. mk.key .. '= is a match of a team against itself')
		elseif seen[pair_id] then
			-- Catch reversed duplicate neutral fixtures.
			add_error(errors, '|' .. mk.key .. '= repeats a match already given by |' .. seen[pair_id]
				.. '=' .. (mk.neutral and ' (a neutral match is the same match either way round)' or ''))
		elseif not displayed then
			add_error(errors, '|' .. mk.key .. '= is not shown in the results grid with |legs='
				.. legs .. ', so it has not been counted; use |'
				.. ((legs == 1) and ('match_' .. mk.rest) or ('match<1-' .. legs .. '>_' .. mk.rest)) .. '=')
		elseif broken then
			add_error(errors, 'could not read the score in |' .. mk.key .. '=' .. trim(Args[mk.key]))
		else
			if mk.neutral and shows_grid then
				add_error(errors, '|' .. mk.key .. '= is a neutral-venue match, which the results grid '
					.. 'cannot show; it has still been counted in the standings')
			end
			seen[pair_id] = mk.key
			if hs then
				out[#out + 1] = {home = h, away = a, hs = hs, as = as, neutral = mk.neutral}
			end
		end
	end
	return out
end

-- Build statistics for these teams only (used for head-to-head criteria).
local function build_stats(matches, teams)
	local st = {}
	for _, c in ipairs(teams) do st[c] = new_stats() end
	for _, m in ipairs(matches) do
		if st[m.home] and st[m.away] then
			add_result(st[m.home], m.hs, m.as, m.neutral and 'neutral' or 'home')
			add_result(st[m.away], m.as, m.hs, m.neutral and 'neutral' or 'away')
		end
	end
	return st
end

-- Aggregate opponents' full-season records, counting repeat meetings twice.
local function opp_stats(code, matches, full, adj)
	local agg = new_stats()
	local aadj = 0
	for _, m in ipairs(matches) do
		local opp
		if m.home == code then
			opp = m.away
		elseif m.away == code then
			opp = m.home
		end
		if opp and full[opp] then
			for _, f in ipairs(FIELDS) do
				agg[f] = agg[f] + full[opp][f]
			end
			aadj = aadj + (adj[opp] or 0)
		end
	end
	return agg, aadj
end

--------------------------------------------------------------------------------
-- Criteria
--------------------------------------------------------------------------------

-- Criterion identifiers; each also accepts a h2h_ or opp_ scope prefix, and
-- (independently) a h or a side prefix (e.g. hpts, agd, h2h_hpts, opp_agpm).
local METRICS = {
	pts = true, gd = true, gf = true, ga = true,
	w = true, d = true, l = true,
	gr = true, gp = true, ap = true, pp = true, gpm = true,
	wp = true, perc = true, ppg = true,
}

local SIDES = {h = true, a = true}

-- Split a criterion id into its scope (full/h2h/opp), side (''/h/a), and
-- underlying metric. A bare metric name is always preferred over reading its
-- first letter as a side prefix (so e.g. "ap" is the against-percentage
-- metric, not the away "p" metric, since "p" is not a metric on its own).
local function id_parts(id)
	local scope, rest = 'full', id
	if id:sub(1, 4) == 'h2h_' then
		scope, rest = 'h2h', id:sub(5)
	elseif id:sub(1, 4) == 'opp_' then
		scope, rest = 'opp', id:sub(5)
	end
	if METRICS[rest] then
		return scope, '', rest
	end
	local prefix, remainder = rest:sub(1, 1), rest:sub(2)
	if SIDES[prefix] and METRICS[remainder] then
		return scope, prefix, remainder
	end
	return scope, '', rest
end

local function valid_id(id)
	local _, _, base = id_parts(id)
	return METRICS[base] and true or false
end

-- Pick out the home, away, or overall view of a statistics record.
local function side_view(st, side)
	if side == 'h' then
		return {pld = st.hpld, w = st.hw, d = st.hd, l = st.hl, gf = st.hgf, ga = st.hga}
	elseif side == 'a' then
		return {pld = st.apld, w = st.aw, d = st.ad, l = st.al, gf = st.agf, ga = st.aga}
	end
	return st
end

-- Return a criterion value; undefined values sort last.
local function value_of(base, opts, st, adj)
	adj = adj or 0
	local pts = opts.winpoints * st.w + opts.drawpoints * st.d + opts.losspoints * st.l
		+ opts.goalpoints * st.gf + adj

	if base == 'pts' then return pts end
	if base == 'gd' then return st.gf - st.ga end
	if base == 'gf' then return st.gf end
	if base == 'ga' then return st.ga end
	if base == 'w' then return st.w end
	if base == 'd' then return st.d end
	if base == 'l' then return st.l end
	-- A clean sheet is infinite; 0–0 is undefined.
	if base == 'gr' or base == 'gp' then
		if st.ga ~= 0 then
			return (base == 'gr') and (st.gf / st.ga) or (100 * st.gf / st.ga)
		end
		return (st.gf > 0) and math.huge or nil
	end
	if base == 'ap' then
		if st.gf ~= 0 then return 100 * st.ga / st.gf end
		return (st.ga > 0) and math.huge or nil
	end
	if base == 'pp' then
		return ((st.gf + st.ga) > 0) and (100 * st.gf / (st.gf + st.ga)) or nil
	end
	if base == 'gpm' then return (st.pld > 0) and (st.gf / st.pld) or nil end
	if base == 'wp' then return (st.pld > 0) and (100 * st.w / st.pld) or nil end
	if base == 'perc' then
		return (st.pld > 0) and ((2 * st.w + st.d + adj) / (2 * st.pld)) or nil
	end
	if base == 'ppg' then return (st.pld > 0) and (pts / st.pld) or nil end
	return nil
end

-- Parse ranking criteria, recursive groups, ascending order, and final-only rules.
local function parse_criteria(str, errors)
	str = tostring(str or '')
	str = str:gsub('−', '-'):gsub('&minus;', '-')

	local items = {}
	local stack = {items}
	local cur = items
	local buf = ''
	local final, seen_period = false, false

	local function flush()
		local tok = trim(buf)
		buf = ''
		if tok == '' then return end
		local desc = true
		if tok:sub(1, 1) == '-' then
			desc = false
			tok = tok:sub(2)
		end
		local id = (trim(tok):lower():gsub('%s+', '_'))
		if id == '' then return end
		if not valid_id(id) then
			add_error(errors, 'unknown ranking criterion "' .. id ..
				'" in |ranking_criteria=')
			return
		end
		cur[#cur + 1] = {kind = 'crit', id = id, desc = desc, final_only = final}
	end

	for i = 1, #str do
		local ch = str:sub(i, i)
		if ch == '(' then
			flush()
			local g = {kind = 'group', items = {}, final_only = final}
			cur[#cur + 1] = g
			stack[#stack + 1] = g.items
			cur = g.items
		elseif ch == ')' then
			flush()
			if #stack > 1 then
				table.remove(stack)
				cur = stack[#stack]
			else
				add_error(errors, 'unmatched ")" in |ranking_criteria=')
			end
		elseif ch == ',' or ch == ';' then
			flush()
		elseif ch == '.' then
			flush()
			if #stack == 1 and not seen_period then
				seen_period = true
				final = true
			end
		else
			buf = buf .. ch
		end
	end
	flush()
	if #stack > 1 then
		add_error(errors, 'unmatched "(" in |ranking_criteria=')
	end

	-- Remove empty groups.
	local function prune(list)
		local out = {}
		for _, it in ipairs(list) do
			if it.kind == 'group' then
				it.items = prune(it.items)
				if #it.items > 0 then out[#out + 1] = it end
			else
				out[#out + 1] = it
			end
		end
		return out
	end
	return prune(items)
end

--------------------------------------------------------------------------------
-- Ranking
--------------------------------------------------------------------------------

-- Evaluate one criterion for a tied group.
local function evaluate(crit, bucket, ctx)
	local scope, side, base = id_parts(crit.id)
	local vals = {}
	if scope == 'h2h' then
		-- Rebuild the mini-table for the tied teams.
		local sub = build_stats(ctx.matches, bucket)
		for _, c in ipairs(bucket) do
			vals[c] = value_of(base, ctx.opts, side_view(sub[c], side), 0)
		end
	elseif scope == 'opp' then
		for _, c in ipairs(bucket) do
			local agg, aadj = opp_stats(c, ctx.matches, ctx.full, ctx.adj)
			-- A home/away split of the opponents' aggregate has no adjustment to prorate.
			vals[c] = value_of(base, ctx.opts, side_view(agg, side), (side == '') and aadj or 0)
		end
	else
		for _, c in ipairs(bucket) do
			-- Point deductions/additions are season-wide, not tied to venue, so
			-- they only apply to the overall (non-home/away-split) criteria.
			vals[c] = value_of(base, ctx.opts, side_view(ctx.full[c], side), (side == '') and ctx.adj[c] or 0)
		end
	end
	return vals
end

--------------------------------------------------------------------------------
-- Head-to-head completeness (|h2h_after_complete=)
--------------------------------------------------------------------------------

-- One match slot: present with a readable score, or explicitly 'null'.
local function slot_ok(Args, key)
	local raw = Args[key]
	if raw == nil then return false end
	local s = clean_cell(raw)
	if s == '' then return false end
	if s:lower() == 'null' then return true end
	local hs = parse_score(raw)
	return hs ~= nil
end

local function directional_key(h, a, n)
	if n == 1 then return 'match_' .. h .. '_' .. a end
	return 'match' .. n .. '_' .. h .. '_' .. a
end

local function neutral_key(x, y, n)
	if n == 1 then return 'matchn_' .. x .. '_' .. y end
	return 'matchn' .. n .. '_' .. x .. '_' .. y
end

-- Whether x and y have completed every leg against each other. Every leg is
-- expected in both directions (home and away); a leg explicitly marked
-- 'null' does not block completeness. Pairs with no directional fixtures at
-- all (an all-neutral competition) instead need one recorded neutral meeting
-- per leg, in either team's order.
local function h2h_pair_complete(Args, x, y, legs)
	local has_directional = false
	for n = 1, legs do
		if Args[directional_key(x, y, n)] ~= nil or Args[directional_key(y, x, n)] ~= nil then
			has_directional = true
			break
		end
	end
	if has_directional then
		for n = 1, legs do
			if not slot_ok(Args, directional_key(x, y, n)) or not slot_ok(Args, directional_key(y, x, n)) then
				return false
			end
		end
		return true
	end
	local nx, ny = x, y
	if ny < nx then nx, ny = ny, nx end
	for n = 1, legs do
		if not (slot_ok(Args, neutral_key(nx, ny, n)) or slot_ok(Args, neutral_key(ny, nx, n))) then
			return false
		end
	end
	return true
end

-- Whether every pair within a tied bucket has completed its head-to-head fixtures.
local function bucket_h2h_complete(Args, bucket, legs)
	for i = 1, #bucket do
		for j = i + 1, #bucket do
			if not h2h_pair_complete(Args, bucket[i], bucket[j], legs) then
				return false
			end
		end
	end
	return true
end

-- Split tied teams by a criterion, retaining base order for equal values.
local function split_by(bucket, vals, desc, rank)
	local sorted = {}
	for _, c in ipairs(bucket) do sorted[#sorted + 1] = c end
	table.sort(sorted, function(x, y)
		local vx, vy = vals[x], vals[y]
		if vx == nil and vy == nil then return rank[x] < rank[y] end
		if vx == nil then return false end
		if vy == nil then return true end
		if vx == vy then return rank[x] < rank[y] end
		if desc then return vx > vy end
		return vx < vy
	end)

	local out, cur, prev = {}, nil, nil
	for _, c in ipairs(sorted) do
		local v = vals[c]
		if cur == nil or v ~= prev then
			cur = {c}
			out[#out + 1] = cur
		else
			cur[#cur + 1] = c
		end
		prev = v
	end
	return out
end

local apply_items, apply_group

-- Reapply grouped criteria to smaller unresolved ties.
function apply_group(bucket, group, ctx, depth)
	depth = (depth or 0) + 1
	local res = apply_items({bucket}, group.items, ctx)
	if depth >= MAX_RECURSION then return res end

	local out = {}
	for _, b in ipairs(res) do
		if #b > 1 and #b < #bucket then
			for _, nb in ipairs(apply_group(b, group, ctx, depth)) do
				out[#out + 1] = nb
			end
		else
			out[#out + 1] = b
		end
	end
	return out
end

function apply_items(buckets, items, ctx)
	for _, item in ipairs(items) do
		if item.final_only and not ctx.complete then
			-- Final-only criteria require |update=complete.
			break
		end
		local nextb = {}
		for _, b in ipairs(buckets) do
			if #b <= 1 then
				nextb[#nextb + 1] = b
			else
				local res
				local scope = (item.kind == 'crit') and select(1, id_parts(item.id)) or nil
				if scope == 'h2h' and ctx.h2h_gate and not bucket_h2h_complete(ctx.Args, b, ctx.legs) then
					-- Not every tied team has played every other tied team yet;
					-- skip this head-to-head criterion for this group.
					res = {b}
				elseif item.kind == 'group' then
					res = apply_group(b, item, ctx)
				else
					res = split_by(b, evaluate(item, b, ctx), item.desc, ctx.rank)
				end
				for _, nb in ipairs(res) do nextb[#nextb + 1] = nb end
			end
		end
		buckets = nextb
	end
	return buckets
end

--------------------------------------------------------------------------------
-- Options and write-back
--------------------------------------------------------------------------------

local function read_opts(Args)
	local show_draw = yesno(Args['show_draw'] or 'yes')
	return {
		winpoints  = tonumber(Args['winpoints'])  or (show_draw and 3 or 2),
		drawpoints = tonumber(Args['drawpoints']) or (show_draw and 1 or 0),
		losspoints = tonumber(Args['losspoints']) or (show_draw and 0 or 1),
		goalpoints = Args['goalpoints'] and (tonumber(Args['goalpoints']) or 1) or 0,
	}
end

local function read_adjustments(Args, teams)
	local adj = {}
	for _, c in ipairs(teams) do
		adj[c] = (tonumber(Args['adjust_points_' .. c]) or 0)
			+ (tonumber(Args['startpoints_' .. c]) or 0)
	end
	return adj
end

-- Warn about manual parameters that automatic generation ignores or overwrites.
local function check_ignored(Args, teams, errors, write_ha)
	local overwritten, n_over, first = {}, 0, nil
	local rounds = {}
	local fields = {'win', 'pf', 'pa', 'matches'}
	if write_ha then
		for _, f in ipairs({'hwin', 'hdraw', 'hloss', 'hgf', 'hga', 'awin', 'adraw', 'aloss', 'agf', 'aga'}) do
			fields[#fields + 1] = f
		end
	else
		for _, f in ipairs({'draw', 'loss', 'gf', 'ga'}) do
			fields[#fields + 1] = f
		end
	end
	for _, c in ipairs(teams) do
		local hit = false
		for _, f in ipairs(fields) do
			if Args[f .. '_' .. c] ~= nil then
				overwritten[f] = true
				hit = true
			end
		end
		if hit then
			n_over = n_over + 1
			first = first or c
		end
		if tonumber(Args['rw_' .. c]) then rounds[#rounds + 1] = c end
	end

	if n_over > 0 then
		local names = {}
		for f in pairs(overwritten) do names[#names + 1] = '|' .. f .. '_TTT=' end
		table.sort(names)
		add_error(errors, 'values given in ' .. table.concat(names, ', ') .. ' are ignored when '
			.. '|auto_generate_standings= is set (found for ' .. first
			.. (n_over > 1 and (' and ' .. (n_over - 1) .. ' other team(s)') or '')
			.. '); they are calculated from the match results and can be removed')
	end
	if #rounds > 0 then
		add_error(errors, '|rw_TTT= is added to the points column by the style but is not used when ranking, '
			.. 'so the table may not be in points order (found for ' .. table.concat(rounds, ', ')
			.. '); rounds won are not supported by |auto_generate_standings=')
	end
end

-- write_ha writes HA-style split fields (hwin_TTT, awin_TTT, etc.) only.
-- WDLHA/WLHA do not use plain fields, which argcheck would flag as unknown.
local function write_stats(Args, teams, full, write_ha)
	for _, c in ipairs(teams) do
		local st = full[c]
		Args['win_' .. c] = tostring(st.w)
		if write_ha then
			Args['hwin_' .. c]  = tostring(st.hw)
			Args['hdraw_' .. c] = tostring(st.hd)
			Args['hloss_' .. c] = tostring(st.hl)
			Args['hgf_' .. c]   = tostring(st.hgf)
			Args['hga_' .. c]   = tostring(st.hga)
			Args['awin_' .. c]  = tostring(st.aw)
			Args['adraw_' .. c] = tostring(st.ad)
			Args['aloss_' .. c] = tostring(st.al)
			Args['agf_' .. c]   = tostring(st.agf)
			Args['aga_' .. c]   = tostring(st.aga)
		else
			Args['draw_' .. c] = tostring(st.d)
			Args['loss_' .. c] = tostring(st.l)
			Args['gf_' .. c]   = tostring(st.gf)
			Args['ga_' .. c]   = tostring(st.ga)
		end
	end
end

--------------------------------------------------------------------------------
-- Table rows
--------------------------------------------------------------------------------

-- Read manual ordering; |team_order= overrides |teamN= at any position both give.
local function read_pins(Args, top_pos, errors)
	local pins, order_full = {}, false
	local from_teamN = {}
	for k, v in pairs(Args) do
		if type(k) == 'string' and v and trim(v) ~= '' then
			local num = k:match('^team(%d+)$')
			if num then
				num = tonumber(num)
				pins[num] = trim(v)
				from_teamN[num] = pins[num]
			end
		end
	end
	local to = Args['team_order']
	if to and trim(to) ~= '' then
		order_full = true
		local offset = (tonumber(Args['team_order_start']) or top_pos) - 1
		local overridden = {}
		for k, tname in ipairs(mw.text.split(to, '%s*[;,]%s*')) do
			if tname ~= '' then
				local pos = k + offset
				if from_teamN[pos] and from_teamN[pos] ~= tname then
					overridden[#overridden + 1] = pos
				end
				pins[pos] = tname
			end
		end
		if errors and #overridden > 0 then
			table.sort(overridden)
			add_error(errors, '|team_order= and |team' .. table.concat(overridden, '=, |team') .. '= are both set; '
				.. '|team_order= takes precedence, so the |teamN= value(s) at position(s) '
				.. table.concat(overridden, ', ') .. ' have been ignored')
		end
	end
	return pins, order_full
end

local function clear_team_args(Args)
	local keys = {}
	for k, _ in pairs(Args) do
		if type(k) == 'string' and k:match('^team%d+$') then keys[#keys + 1] = k end
	end
	for _, k in ipairs(keys) do Args[k] = nil end
	Args['team_order'] = nil
	Args['team_order_start'] = nil
end

-- Place ranked teams, applying manual ordering unless disabled.
local function place(Args, buckets, top_pos, errors, ignore_manual)
	local auto, auto_index, shared, group_of = {}, {}, {}, {}
	local n, gid = 0, 0
	for _, b in ipairs(buckets) do
		gid = gid + 1
		local pos = top_pos + n
		for _, c in ipairs(b) do
			n = n + 1
			auto[n] = c
			auto_index[c] = top_pos + n - 1
			shared[c] = pos
			if #b > 1 then group_of[c] = gid end
		end
	end

	local pins, order_full = {}, false
	if not ignore_manual then
		pins, order_full = read_pins(Args, top_pos, errors)
	end

	-- A valid manual order must fill every vacated slot.
	local pinned_at, ok, count = {}, true, 0
	-- Keep messages in a stable order.
	local pin_slots = {}
	for pos in pairs(pins) do pin_slots[#pin_slots + 1] = pos end
	table.sort(pin_slots)
	for _, pos in ipairs(pin_slots) do
		local tname = pins[pos]
		count = count + 1
		if not auto_index[tname] then
			ok = false
			add_error(errors, 'the team "' .. tname .. '" given in the manual order is not in the table')
		elseif pinned_at[tname] then
			ok = false
			add_error(errors, 'the team "' .. tname .. '" is given more than once in the manual order')
		else
			pinned_at[tname] = pos
		end
		if pos < top_pos or pos > (top_pos + n - 1) then
			ok = false
			add_error(errors, 'position ' .. pos .. ' in the manual order is outside the table')
		end
	end
	if ok then
		for _, slot in ipairs(pin_slots) do
			local tname = pins[slot]
			local pos = pinned_at[tname]
			if tname and pins[auto_index[tname]] == nil then
				ok = false
				add_error(errors, 'the manual order is incomplete: "' .. tname .. '" has been moved from position '
					.. auto_index[tname] .. ' to position ' .. pos .. ', but nothing has been given for position '
					.. auto_index[tname] .. '. Every team displaced by a |teamN= must itself be given a position, '
					.. 'otherwise the order cannot be resolved. The order before the manual changes has been used instead')
			end
		end
	end

	local final, manual = {}, false
	if count > 0 and ok then
		manual = true
		for pos, tname in pairs(pins) do final[pos] = tname end
		local rest = {}
		for i = 1, n do
			if not pinned_at[auto[i]] then rest[#rest + 1] = auto[i] end
		end
		local j = 1
		for i = top_pos, top_pos + n - 1 do
			if not final[i] then
				final[i] = rest[j]
				j = j + 1
			end
		end
	else
		for i = 1, n do final[top_pos + i - 1] = auto[i] end
	end

	-- Manual ordering breaks tied-position groups it touches.
	local broken = {}
	if manual then
		if order_full then
			for g = 1, gid do broken[g] = true end
		else
			for tname, _ in pairs(pinned_at) do
				if group_of[tname] then broken[group_of[tname]] = true end
			end
		end
	end

	-- Number every row if tied positions are disabled.
	local show_tied = yesno(Args['show_tied_positions'] or 'yes')

	clear_team_args(Args)
	for i = top_pos, top_pos + n - 1 do
		local c = final[i]
		Args['team' .. i] = c
		local shown = i
		if show_tied and group_of[c] and not broken[group_of[c]] then shown = shared[c] end
		-- Preserve editor-set positions.
		if Args['pos_' .. c] == nil and shown ~= i then
			Args['pos_' .. c] = tostring(shown)
		end
	end
	-- Prevent stale |teamN= values from adding rows.
	if not tonumber(Args['lowest_pos']) then
		Args['lowest_pos'] = tostring(top_pos + n - 1)
	end
	return n
end

--------------------------------------------------------------------------------
-- Entry points
--------------------------------------------------------------------------------

-- Build standings and return any errors. write_ha additionally computes and
-- writes the home/away split fields a HA-style table needs to render.
function pa.generate(Args, top_pos, write_ha)
	local errors = {}
	top_pos = top_pos or 1

	local teams, codes, appears = pa.roster(Args)
	if #teams == 0 then
		add_error(errors, 'no teams were found: |auto_generate_standings= needs |name_TTT= or |match_TTT_SSS= parameters')
		return errors
	end
	check_orphans(Args, teams, appears, errors)

	local matches = pa.matches(Args, codes, errors)
	local opts = read_opts(Args)
	local adj = read_adjustments(Args, teams)
	local full = build_stats(matches, teams)

	check_ignored(Args, teams, errors, write_ha)
	write_stats(Args, teams, full, write_ha)

	local crit_str = Args['ranking_criteria']
	if not crit_str or trim(crit_str) == '' then crit_str = 'pts, gd, gf' end
	local items = parse_criteria(crit_str, errors)

	local start = {}
	local rank = {}
	for i, c in ipairs(pa.base_order(Args, teams, codes, errors)) do
		start[#start + 1] = c
		rank[c] = i
	end

	local legs = tonumber(Args['legs']) or 1
	local h2h_gate = yesno(Args['h2h_after_complete'] or 'no') and true or false
	if h2h_gate then
		local fmt = string.lower(trim(Args['matches_format']))
		if fmt == 'schedule' or fmt == 'series' or fmt == 'series_split' then
			add_error(errors, '|h2h_after_complete= has no effect with |matches_format=' .. fmt
				.. ', because that fixture list is not necessarily a full round-robin; the head-to-head '
				.. 'criteria have been applied immediately instead')
			h2h_gate = false
		end
	end

	local ctx = {
		matches = matches,
		full = full,
		adj = adj,
		opts = opts,
		rank = rank,
		complete = (trim(Args['update']):lower() == 'complete'),
		Args = Args,
		legs = legs,
		h2h_gate = h2h_gate,
	}
	local buckets = apply_items({start}, items, ctx)

	place(Args, buckets, top_pos, errors)
	return errors
end

-- Place teams in base order for |show_matches=only.
function pa.order_only(Args, top_pos, ignore_manual)
	local errors = {}
	top_pos = top_pos or 1
	local teams, codes, appears = pa.roster(Args)
	if #teams == 0 then
		add_error(errors, 'no teams were found: |auto_generate_standings= needs |name_TTT= or |match_TTT_SSS= parameters')
		return errors
	end
	check_orphans(Args, teams, appears, errors)
	local buckets = {}
	for _, c in ipairs(pa.base_order(Args, teams, codes, errors)) do
		buckets[#buckets + 1] = {c}
	end
	place(Args, buckets, top_pos, errors, ignore_manual)
	return errors
end

return pa