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: a3ff1571af723564

Jump to content

Module:Sports table/auto

Permanently protected module
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', 'otw', 'd', 'otl', 'l', 'gf', 'ga',
	'hpld', 'hw', 'hotw', 'hd', 'hotl', 'hl', 'hgf', 'hga',
	'apld', 'aw', 'aotw', 'ad', 'aotl', 'al', 'agf', 'aga'}

-- |result_AAA_BBB= values that mark an overtime result; SO (shootout) is an alias.
local OT_RESULTS = {OT = true, SO = true}

-- 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'. ot marks an overtime result, counted as
-- an overtime win/loss instead of a win/loss; a drawn score stays a draw.
local function add_result(st, gf, ga, side, ot)
	st.pld = st.pld + 1
	st.gf = st.gf + gf
	st.ga = st.ga + ga
	local w, otw, d, otl, l = 0, 0, 0, 0, 0
	if gf > ga then
		if ot then otw = 1 else w = 1 end
	elseif gf < ga then
		if ot then otl = 1 else l = 1 end
	else
		d = 1
	end
	st.w, st.otw, st.d, st.otl, st.l = st.w + w, st.otw + otw, st.d + d, st.otl + otl, 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.hotw, st.hd, st.hotl, st.hl =
			st.hw + w, st.hotw + otw, st.hd + d, st.hotl + otl, 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.aotw, st.ad, st.aotl, st.al =
			st.aw + w, st.aotw + otw, st.ad + d, st.aotl + otl, st.al + l
	end
end

-- Build played matches; the first code is the home team. With read_ot,
-- |result_AAA_BBB=OT (or SO) marks the match as decided in overtime.
function pa.matches(Args, codes, errors, read_ot)
	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
				local ot = false
				if read_ot then
					-- match_h_a -> result_h_a, matchn2_h_a -> resultn2_h_a
					local rkey = mk.key:gsub('^match', 'result')
					ot = OT_RESULTS[trim(Args[rkey])] or false
				end
				out[#out + 1] = {home = h, away = a, hs = hs, as = as, neutral = mk.neutral, ot = ot}
			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', m.ot)
			add_result(st[m.away], m.as, m.hs, m.neutral and 'neutral' or 'away', m.ot)
		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, otw = true, d = true, otl = 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, otw = st.hotw, d = st.hd, otl = st.hotl, l = st.hl,
			gf = st.hgf, ga = st.hga}
	elseif side == 'a' then
		return {pld = st.apld, w = st.aw, otw = st.aotw, d = st.ad, otl = st.aotl, 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.otwinpoints * st.otw + opts.drawpoints * st.d
		+ opts.otlosspoints * st.otl + 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 == 'otw' then return st.otw end
	if base == 'd' then return st.d end
	if base == 'otl' then return st.otl 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.otw) / st.pld) or nil end
	if base == 'perc' then
		if opts.ot then
			-- Same as the OT styles' PCT: points over the most a team could earn.
			return (st.pld > 0 and opts.winpoints > 0) and (pts / (opts.winpoints * st.pld)) or nil
		end
		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, frozen)
	depth = (depth or 0) + 1
	local res = apply_items({bucket}, group.items, ctx, frozen)
	if depth >= MAX_RECURSION then return res end

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

function apply_items(buckets, items, ctx, frozen)
	frozen = frozen or {}
	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 or frozen[b] 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.
					res = {b}
					if ctx.h2h_freeze then
						-- Stop ranking this group altogether, not just this
						-- criterion: it stays tied until the mini-league completes.
						frozen[b] = true
					end
					-- Otherwise (the default), just skip this head-to-head
					-- criterion for this group and continue with the next one.
				elseif item.kind == 'group' then
					res = apply_group(b, item, ctx, nil, frozen)
				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
--------------------------------------------------------------------------------

-- write_ot uses the OT styles' points (win 3, overtime win 2, draw or overtime
-- loss 1, loss 0), which do not depend on |show_draw=.
local function read_opts(Args, write_ot)
	local goalpoints = Args['goalpoints'] and (tonumber(Args['goalpoints']) or 1) or 0
	if write_ot then
		return {
			ot = true,
			winpoints    = tonumber(Args['winpoints'])    or 3,
			otwinpoints  = tonumber(Args['OTwinpoints'])  or 2,
			drawpoints   = tonumber(Args['drawpoints'])   or 1,
			otlosspoints = tonumber(Args['OTlosspoints']) or 1,
			losspoints   = tonumber(Args['losspoints'])   or 0,
			goalpoints   = goalpoints,
		}
	end
	local show_draw = yesno(Args['show_draw'] or 'yes')
	return {
		winpoints  = tonumber(Args['winpoints'])  or (show_draw and 3 or 2),
		otwinpoints = 0,
		drawpoints = tonumber(Args['drawpoints']) or (show_draw and 1 or 0),
		otlosspoints = 0,
		losspoints = tonumber(Args['losspoints']) or (show_draw and 0 or 1),
		goalpoints = goalpoints,
	}
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, write_ot)
	local overwritten, n_over, first = {}, 0, nil
	local rounds = {}
	local fields = {'win', 'pf', 'pa', 'matches'}
	if write_ot then
		fields[#fields + 1] = 'OTwin'
		fields[#fields + 1] = 'OTloss'
	end
	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, write_ot)
	for _, c in ipairs(teams) do
		local st = full[c]
		Args['win_' .. c] = tostring(st.w)
		if write_ot then
			Args['OTwin_' .. c]  = tostring(st.otw)
			Args['OTloss_' .. c] = tostring(st.otl)
		end
		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, write_ot)
	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, write_ot)
	local opts = read_opts(Args, write_ot)
	local adj = read_adjustments(Args, teams)
	local full = build_stats(matches, teams)

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

	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
	-- |h2h_after_complete=freeze leaves teams tied after an incomplete
	-- head-to-head criterion; other positive values skip it and continue.
	local h2h_raw = trim(Args['h2h_after_complete'] or '')
	local h2h_freeze = (string.lower(h2h_raw) == 'freeze')
	local h2h_gate = h2h_freeze or (yesno(h2h_raw) 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
			h2h_freeze = 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,
		h2h_freeze = h2h_freeze,
	}
	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