Модуль:Призёры

Материал из Наро-Фоминский Футбол
Перейти к навигации Перейти к поиску

Для документации этого модуля может быть создана страница Модуль:Призёры/doc

--[[
	Модуль:Призёры

	Из одного списка сезонов строит две таблицы:
	  * призёры по сезонам                     — вид = сезоны;
	  * сводная таблица медалей или кубков     — вид = зачёт (по умолчанию).

	Данные: каждый сезон — с новой строки, ячейки через «|»:
	  | Сезон | 1-е место | 2-е место | 3-е место | доп. столбцы…
	Если строк с данными нет, модуль ищет таблицу призёров в тексте статьи
	(параметр «источник», по умолчанию — текущая страница).

	Подробно — на странице «Шаблон:Призёры/doc».
]]

local p = {}

local CLUBS_MODULE = 'Module:Призёры/Клубы'

-- Столбцы для каждого типа турнира.
local PRESETS = {
	medals = {
		labels = { 'Золото', 'Серебро', 'Бронза' },          -- сводная таблица
		places = { '1-е место', '2-е место', '3-е место' },  -- таблица по сезонам
		colors = { 'gold', 'silver', '#CC9966' },
		total  = 'Всего медалей',
	},
	cup = {
		labels = { 'Победитель', 'Финалист' },
		places = { 'Победитель', 'Финалист' },
		colors = { 'gold', 'silver' },
		total  = 'Всего кубков',
	},
	supercup = {
		labels = { 'Победитель', 'Финалист' },
		places = { 'Победитель', 'Финалист' },
		colors = { 'gold', 'silver' },
		total  = 'Всего суперкубков',
	},
}

-- Добавляется к кубку при «малый кубок = да».
local SMALL_CUP = {
	labels = { 'Малый кубок', 'Финалист МК' },
	colors = { 'gold', 'silver' },
}

local TYPES = {
	['медали'] = 'medals', ['чемпионат'] = 'medals', ['первенство'] = 'medals',
	['кубок'] = 'cup', ['кубки'] = 'cup',
	['суперкубок'] = 'supercup', ['суперкубки'] = 'supercup',
}

-- Ключи записаны так, как их возвращает norm(): строчные, «ё» → «е».
local VIEWS = {
	['зачет'] = 'standings', ['итоги'] = 'standings', ['итог'] = 'standings',
	['сезоны'] = 'seasons', ['сезон'] = 'seasons', ['по сезонам'] = 'seasons', ['призеры'] = 'seasons',
}

local PARAMS = {
	'вид', 'тип', 'малый кубок', 'доп', 'названия', 'названия мест', 'цвета',
	'всего', 'столбец сезона', 'столбец клуба', 'сортировка', 'равные',
	'учтены', 'жирный', 'клубы', 'источник', 'номер таблицы',
}
local KNOWN = {}
for _, name in ipairs(PARAMS) do
	KNOWN[name] = true
end

-- Ячейка места с таким текстом — награда не вручалась или пока неизвестна.
local DASHES = { ['—'] = true, ['–'] = true, ['-'] = true, ['−'] = true, ['?'] = true }

-- Ссылки в эти пространства имён — не клубы (флаги, категории).
local SKIP_NS = {
	['файл'] = true, ['file'] = true, ['image'] = true, ['изображение'] = true,
	['категория'] = true, ['category'] = true, ['media'] = true, ['медиа'] = true,
}

------------------------------------------------------------------------
-- Строки
------------------------------------------------------------------------

local function trim(s)
	if s == nil then
		return ''
	end
	return mw.text.trim(s)
end

-- Для сравнения слов: без пробелов по краям, строчные, «ё» → «е».
local function norm(s)
	return (mw.ustring.lower(trim(s)):gsub('ё', 'е'))
end

local function isYes(s)
	local v = norm(s)
	return v == 'да' or v == 'yes' or v == '1' or v == 'true' or v == 'вкл'
end

local function isNo(s)
	local v = norm(s)
	return v == 'нет' or v == 'no' or v == '0' or v == 'false' or v == 'выкл'
end

-- «a; b; c» → { 'a', 'b', 'c' }; точку с запятой внутри   и т. п. не трогает.
local function splitList(s)
	local list = {}
	if not s then
		return list
	end
	s = s:gsub('&(#?%w+);', '&%1\1')
	for part in mw.text.gsplit(s, ';', true) do
		table.insert(list, trim((part:gsub('\1', ';'))))
	end
	return list
end

-- Все strip-маркеры строки (так в аргументы попадают <ref> и подобные теги).
local function markersOf(s)
	local found = {}
	for marker in s:gmatch('\127[^\127]*\127') do
		table.insert(found, marker)
	end
	return table.concat(found)
end

-- Текст без маркеров, вики-разметки выделения и HTML-тегов.
local function stripFormatting(s)
	s = mw.text.killMarkers(s or '')
	s = s:gsub("'''?", '')
	s = s:gsub('<[^>]*>', '')
	return trim(s)
end

-- Видимый текст: ссылки заменены их подписями.
local function plainText(s)
	s = mw.text.killMarkers(s or '')
	s = s:gsub('%[%[([^%]]-)%]%]', function (inner)
		local label = inner:match('^[^|]*|(.*)$')
		return label or inner
	end)
	s = s:gsub('%[%a+://[^%s%]]+%s+([^%]]+)%]', '%1')
	return stripFormatting(s)
end

-- Название страницы в каноническом виде: пробелы вместо «_», первая буква заглавная.
local function normTitle(t)
	t = t:gsub('_', ' ')
	t = t:gsub('#.*$', '')
	t = t:gsub('%s+', ' ')
	t = trim(t)
	t = t:gsub('^:%s*', '')
	if t == '' then
		return t
	end
	return mw.ustring.upper(mw.ustring.sub(t, 1, 1)) .. mw.ustring.sub(t, 2)
end

-- Первая ссылка на статью в тексте: цель и подпись.
local function firstLink(s)
	for inner in s:gmatch('%[%[(.-)%]%]') do
		local target, label = inner:match('^([^|]*)|(.*)$')
		target = target or inner
		local ns = target:match('^%s*:?%s*([^:]+):')
		if trim(target) ~= '' and not (ns and SKIP_NS[norm(ns)]) then
			if label == nil then
				label = (target:gsub('#.*$', ''):gsub('^%s*:', ''))
			elseif trim(label) == '' then
				label = (target:gsub('%s*%(.-%)%s*$', ''))   -- «трюк с вертикальной чертой»
			end
			return target, stripFormatting(label)
		end
	end
end

local function makeLink(target, label)
	if target == label then
		return '[[' .. target .. ']]'
	end
	return '[[' .. target .. '|' .. label .. ']]'
end

------------------------------------------------------------------------
-- Список клубов
------------------------------------------------------------------------

local function loadClubs(name, warnings)
	name = name or CLUBS_MODULE
	if not name:find(':', 1, true) then
		name = 'Module:' .. name
	end
	local ok, data = pcall(mw.loadData, name)
	if ok and type(data) == 'table' then
		return data
	end
	if not ok and not tostring(data):find('not found', 1, true) then
		table.insert(warnings, 'не удалось прочитать список клубов «' .. name .. '»: ' .. tostring(data))
	end
	return {}
end

------------------------------------------------------------------------
-- Аргументы
------------------------------------------------------------------------

-- Ячейки по строкам: строка заканчивается там, где ячейка заканчивается переводом строки.
local function splitRows(positional)
	local rows, current = {}, {}
	for _, raw in ipairs(positional) do
		local endsLine = raw:find('\n[ \t]*$') ~= nil
		table.insert(current, trim((raw:gsub('%s*\n%s*', ' '))))
		if endsLine then
			table.insert(rows, current)
			current = {}
		end
	end
	if #current > 0 then
		table.insert(rows, current)
	end
	local filled = {}
	for _, row in ipairs(rows) do
		for _, cell in ipairs(row) do
			if cell ~= '' then
				table.insert(filled, row)
				break
			end
		end
	end
	return filled
end

local function viewOf(s)
	if s == nil or trim(s) == '' then
		return nil
	end
	return VIEWS[norm(s)]
end

local function collectArgs(frame, forcedView, warnings)
	local fargs = frame.args
	local parent = frame:getParent()
	local pargs = parent and parent.args or {}

	-- Параметр из вызова на странице важнее того, что записан в шаблоне.
	local function get(name)
		local v = pargs[name]
		if v == nil or trim(v) == '' then
			v = fargs[name]
		end
		v = trim(v)
		return v ~= '' and v or nil
	end
	local settings = {}
	for _, name in ipairs(PARAMS) do
		settings[name] = get(name)
	end

	local fromTemplate = fargs[1] ~= nil
	local source = fromTemplate and fargs or pargs
	local positional, i = {}, 1
	while source[i] ~= nil do
		positional[i] = source[i]
		i = i + 1
	end
	-- «сезоны» / «зачёт» самой первой ячейкой — это вид таблицы, а не данные. Убираем его
	-- до разбивки на строки: если следом на той же строке идёт именованный параметр
	-- ({{Призёры|сезоны|доп=…), перевод строки достаётся параметру, а не этому слову.
	local leadingView = viewOf(positional[1])
	if leadingView then
		table.remove(positional, 1)
	end
	local rows = splitRows(positional)

	local wrongView = settings['вид']
	if not wrongView and fromTemplate and trim(pargs[1]) ~= '' then
		wrongView = trim(pargs[1])   -- {{Призёры/Чемпионат 7х7|…}}: безымянный параметр — только вид
	end
	if wrongView and not viewOf(wrongView) then
		table.insert(warnings, 'неизвестный вид «' .. wrongView .. '»: ожидается «зачёт» или «сезоны».')
	end
	local pageView = viewOf(pargs['вид'])
	if fromTemplate then
		pageView = pageView or viewOf(pargs[1])
	else
		pageView = pageView or leadingView
	end
	local templateView = viewOf(fargs['вид']) or (fromTemplate and leadingView or nil)
	settings.view = forcedView or pageView or templateView or 'standings'

	local function scan(args)
		for name in pairs(args) do
			if type(name) == 'string' and not KNOWN[name] then
				table.insert(warnings, 'неизвестный параметр «' .. name .. '». Если это ячейка, '
					.. 'в которой есть знак «=», запишите его как {{=}} или &amp;#61;.')
			end
		end
	end
	scan(fargs)
	if parent then
		scan(pargs)
	end
	return rows, settings
end

local function makeConfig(settings, warnings)
	local typeName = 'medals'
	if settings['тип'] then
		typeName = TYPES[norm(settings['тип'])]
		if not typeName then
			table.insert(warnings, 'неизвестный тип «' .. settings['тип']
				.. '»: ожидается «медали», «кубок» или «суперкубок».')
			typeName = 'medals'
		end
	end
	local preset = PRESETS[typeName]
	local cfg = { labels = {}, places = {}, colors = {} }
	for i = 1, #preset.labels do
		cfg.labels[i] = preset.labels[i]
		cfg.places[i] = preset.places[i]
		cfg.colors[i] = preset.colors[i]
	end
	if typeName ~= 'medals' and settings['малый кубок'] and isYes(settings['малый кубок']) then
		for i = 1, #SMALL_CUP.labels do
			table.insert(cfg.labels, SMALL_CUP.labels[i])
			table.insert(cfg.places, SMALL_CUP.labels[i])
			table.insert(cfg.colors, SMALL_CUP.colors[i])
		end
	end
	cfg.n = #cfg.labels

	local function override(list, param)
		for i, value in ipairs(splitList(settings[param])) do
			if i <= cfg.n and value ~= '' then
				list[i] = value
			end
		end
	end
	override(cfg.labels, 'названия')
	override(cfg.places, 'названия мест')
	override(cfg.colors, 'цвета')

	cfg.total = settings['всего'] or preset.total
	cfg.seasonHeader = settings['столбец сезона'] or 'Сезон'
	cfg.clubHeader = settings['столбец клуба'] or 'Клуб'
	cfg.extras = splitList(settings['доп'])
	cfg.extrasGiven = settings['доп'] ~= nil
	cfg.bold = not (settings['жирный'] and isNo(settings['жирный']))
	cfg.note = settings['учтены'] ~= nil and isYes(settings['учтены'])

	local order = norm(settings['сортировка'] or '')
	if order == '' or order:find('сумм') then
		cfg.olympic = false
	elseif order:find('олимп') or order:find('золот') or order:find('мест') or order:find('побед') then
		cfg.olympic = true
	else
		table.insert(warnings, 'неизвестная сортировка «' .. settings['сортировка']
			.. '»: ожидается «по сумме» или «олимпийская».')
		cfg.olympic = false
	end

	local equal = norm(settings['равные'] or '')
	cfg.alphabetical = equal:find('алфав') ~= nil
	if equal ~= '' and not cfg.alphabetical and not equal:find('поряд') then
		table.insert(warnings, 'неизвестное значение «равные = ' .. settings['равные']
			.. '»: ожидается «по алфавиту» или «по порядку».')
	end
	return cfg
end

------------------------------------------------------------------------
-- Таблица из текста статьи
------------------------------------------------------------------------

-- Делит строку по разделителям, не заглядывая внутрь [[…]] и {{…}}.
local function splitTop(s, seps)
	local parts, start, i, n = {}, 1, 1, #s
	local links, templates = 0, 0
	while i <= n do
		local two = s:sub(i, i + 1)
		if two == '[[' then
			links = links + 1
			i = i + 2
		elseif two == ']]' and links > 0 then
			links = links - 1
			i = i + 2
		elseif two == '{{' then
			templates = templates + 1
			i = i + 2
		elseif two == '}}' and templates > 0 then
			templates = templates - 1
			i = i + 2
		else
			local sep
			if links == 0 and templates == 0 then
				for _, candidate in ipairs(seps) do
					if s:sub(i, i + #candidate - 1) == candidate then
						sep = candidate
						break
					end
				end
			end
			if sep then
				table.insert(parts, s:sub(start, i - 1))
				i = i + #sep
				start = i
			else
				i = i + 1
			end
		end
	end
	table.insert(parts, s:sub(start))
	return parts
end

-- Вики-таблицы верхнего уровня: строки с сырыми ячейками.
local function parseTables(text)
	local tables, current, depth = {}, nil, 0
	for line in (text .. '\n'):gmatch('([^\n]*)\n') do
		local l = trim(line)
		if l:sub(1, 2) == '{|' then
			depth = depth + 1
			if depth == 1 then
				current = { rows = {}, newRow = true }
			end
		elseif depth > 0 and l:sub(1, 2) == '|}' then
			depth = depth - 1
			if depth == 0 then
				table.insert(tables, current)
				current = nil
			end
		elseif depth == 1 then
			local row = current.rows[#current.rows]
			local first = l:sub(1, 1)
			if l:sub(1, 2) == '|-' then
				current.newRow = true
			elseif l:sub(1, 2) == '|+' then
				-- подпись таблицы
			elseif first == '|' or first == '!' then
				if current.newRow or not row then
					row = { cells = {}, header = true }
					table.insert(current.rows, row)
					current.newRow = false
				end
				local isHeader = first == '!'
				for _, cell in ipairs(splitTop(l:sub(2), isHeader and { '!!', '||' } or { '||' })) do
					table.insert(row.cells, cell)
					if not isHeader then
						row.header = false
					end
				end
			elseif row and #row.cells > 0 then
				row.cells[#row.cells] = row.cells[#row.cells] .. '\n' .. l
			end
		end
	end
	return tables
end

local function attrNumber(attrs, name)
	return tonumber(attrs:lower():match(name .. '%s*=%s*["\']?%s*(%d+)'))
end

-- Раскладывает ячейки по столбцам с учётом rowspan и colspan.
local function tableGrid(tbl)
	local pending = {}
	for _, row in ipairs(tbl.rows) do
		local out, col = {}, 1
		local function skipSpanned()
			while pending[col] do
				local span = pending[col]
				out[col] = span.text
				span.left = span.left - 1
				if span.left == 0 then
					pending[col] = nil
				end
				col = col + 1
			end
		end
		for _, cellText in ipairs(row.cells) do
			skipSpanned()
			local parts = splitTop(cellText, { '|' })
			local attrs, content = '', cellText
			if #parts > 1 then
				attrs = parts[1]
				content = table.concat(parts, '|', 2)
			end
			content = trim(content)
			local rowspan = attrNumber(attrs, 'rowspan') or 1
			local colspan = attrNumber(attrs, 'colspan') or 1
			for k = 0, colspan - 1 do
				local text = content
				if colspan > 1 and not row.header then
					-- Объединённая по горизонтали ячейка («не проводился») — пояснение, а не клуб.
					text = (k == 0 and content ~= '') and ('(' .. content .. ')') or ''
				end
				out[col + k] = text
				if rowspan > 1 then
					pending[col + k] = { text = text, left = rowspan - 1 }
				end
			end
			col = col + colspan
		end
		skipSpanned()
		row.grid = out
		row.width = col - 1
	end
end

local function readSourceTable(frame, settings, cfg, warnings)
	local name = settings['источник']
	local title = name and mw.title.new(name) or mw.title.getCurrentTitle()
	if not title then
		return nil, nil, 'не удалось разобрать название страницы «' .. name .. '».'
	end
	local text = title:getContent()
	if not text then
		return nil, nil, 'страница «' .. title.prefixedText .. '» не найдена.'
	end
	text = text:gsub('<!%-%-.-%-%->', '')
	text = text:gsub('<[Rr][Ee][Ff][^>]-/>', '')
	text = text:gsub('<[Rr][Ee][Ff][%s>].-</[Rr][Ee][Ff]%s*>', '')

	local wanted = tonumber(settings['номер таблицы'] or '')
	local chosen
	for i, tbl in ipairs(parseTables(text)) do
		tableGrid(tbl)
		if wanted then
			if i == wanted then
				chosen = tbl
			end
		else
			for _, row in ipairs(tbl.rows) do
				local second = row.header and row.grid[2]
				if second then
					second = norm(stripFormatting(second))
					if second == norm(cfg.places[1]) or second == norm(cfg.labels[1]) then
						chosen = tbl
						break
					end
				end
			end
		end
		if chosen then
			break
		end
	end
	if not chosen then
		if wanted then
			return nil, nil, 'на странице «' .. title.prefixedText .. '» нет таблицы № ' .. wanted .. '.'
		end
		return nil, nil, 'на странице «' .. title.prefixedText .. '» не найдена таблица призёров '
			.. '(во втором столбце заголовка ожидается «' .. cfg.places[1] .. '»). '
			.. 'Укажите «номер таблицы» или впишите данные строками.'
	end

	local rows, header = {}, nil
	for _, row in ipairs(chosen.rows) do
		if row.header then
			header = row   -- при двухэтажной шапке нужен нижний этаж
		else
			local cells, filled = {}, false
			for c = 1, row.width do
				local value = row.grid[c] or ''
				if value:find('{{', 1, true) then
					value = frame:preprocess(value)
				end
				cells[c] = trim(value)
				filled = filled or cells[c] ~= ''
			end
			if filled then
				table.insert(rows, cells)
			end
		end
	end

	local extras = {}
	if header then
		-- Столбцы мест должны называться так, как ждёт «тип»: иначе, например, «Счёт»
		-- молча засчитался бы как «Малый кубок».
		local wrong = {}
		for j = 1, cfg.n do
			local h = stripFormatting(header.grid[1 + j] or '')
			if h ~= '' and norm(h) ~= norm(cfg.places[j]) and norm(h) ~= norm(cfg.labels[j]) then
				table.insert(wrong, (1 + j) .. '-й столбец называется «' .. h .. '», а ожидается «'
					.. cfg.places[j] .. '»')
			end
		end
		if #wrong > 0 then
			table.insert(warnings, 'в таблице на странице «' .. title.prefixedText .. '» '
				.. table.concat(wrong, '; ') .. '. Проверьте «тип» и «малый кубок» '
				.. 'или задайте «названия мест» как в шапке таблицы.')
		end
		for c = cfg.n + 2, header.width do
			table.insert(extras, header.grid[c] or '')
		end
	end
	return rows, extras
end

------------------------------------------------------------------------
-- Сезоны и клубы
------------------------------------------------------------------------

local function parseTeam(part, clubs)
	local clean = trim(mw.text.killMarkers(part))
	if clean == '' or DASHES[clean] then
		return nil
	end
	local team = { raw = part, markers = markersOf(part) }
	local target, label = firstLink(clean)
	if target then
		team.target = normTitle(target)
		team.label = label ~= '' and label or team.target
		team.linked = true
	else
		team.label = stripFormatting(clean)
		if team.label == '' or DASHES[team.label] then
			return nil
		end
		local known = clubs[team.label]
		if type(known) == 'string' and trim(known) ~= '' then
			team.target = normTitle(known)
		end
	end
	return team
end

local BR = '<[Bb][Rr]%s*/?%s*>'

local function parseCell(raw, clubs)
	local cell = { raw = raw, parts = {} }
	local inner = stripFormatting(raw):match('^%((.*)%)$')
	if inner then
		cell.note = trim(inner) .. markersOf(raw)
		return cell
	end
	local pos = 1
	while true do
		local a, b = raw:find(BR, pos)
		local piece = raw:sub(pos, a and a - 1 or nil)
		table.insert(cell.parts, { raw = piece, team = parseTeam(piece, clubs) })
		if not a then
			break
		end
		pos = b + 1
	end
	return cell
end

local function buildSeasons(rows, cfg, clubs)
	local seasons = {}
	for _, cells in ipairs(rows) do
		local season = { label = cells[1] or '', places = {}, extras = {} }
		for j = 1, cfg.n do
			season.places[j] = parseCell(cells[1 + j] or '', clubs)
		end
		for e = 1, #cfg.extras do
			season.extras[e] = cells[1 + cfg.n + e] or ''
		end
		table.insert(seasons, season)
	end
	return seasons
end

local function eachTeam(seasons, n, fn)
	for _, season in ipairs(seasons) do
		for j = 1, n do
			for _, part in ipairs(season.places[j].parts) do
				if part.team then
					fn(part.team, season, j)
				end
			end
		end
	end
end

-- Клуб, записанный текстом, получает ссылку, если в данных он уже встречался ссылкой
-- с той же подписью (и только с одной целью). Затем — ключи и вид ячеек.
local function resolveTeams(seasons, n)
	local byLabel = {}
	eachTeam(seasons, n, function (team)
		if team.linked then
			byLabel[team.label] = byLabel[team.label] or {}
			byLabel[team.label][team.target] = true
		end
	end)
	eachTeam(seasons, n, function (team)
		if not team.target and byLabel[team.label] then
			local only, count = nil, 0
			for target in pairs(byLabel[team.label]) do
				only = target
				count = count + 1
			end
			if count == 1 then
				team.target = only
			end
		end
		team.key = team.target or ('\1' .. team.label)
		if team.linked or not team.target then
			team.display = team.raw
		else
			local link = makeLink(team.target, team.label)
			local a, b = team.raw:find(team.label, 1, true)
			if a then
				team.display = team.raw:sub(1, a - 1) .. link .. team.raw:sub(b + 1)
			else
				team.display = link .. team.markers
			end
		end
	end)
end

local function tally(seasons, n)
	local byKey, list = {}, {}
	eachTeam(seasons, n, function (team, season, j)
		local s = byKey[team.key]
		if not s then
			s = { counts = {}, total = 0, first = #list + 1 }
			for c = 1, n do
				s.counts[c] = 0
			end
			byKey[team.key] = s
			table.insert(list, s)
		end
		s.counts[j] = s.counts[j] + 1
		s.total = s.total + 1
		s.label = team.label            -- подпись — из последнего упоминания
		s.target = team.target or s.target
		season.counted = true
	end)
	return list
end

local function rank(list, cfg)
	table.sort(list, function (a, b)
		if not cfg.olympic and a.total ~= b.total then
			return a.total > b.total
		end
		for c = 1, cfg.n do
			if a.counts[c] ~= b.counts[c] then
				return a.counts[c] > b.counts[c]
			end
		end
		if cfg.alphabetical then
			local x, y = norm(a.label), norm(b.label)
			if x ~= y then
				return x < y
			end
		end
		return a.first < b.first
	end)
	local place = 0
	for i, s in ipairs(list) do
		local same = i > 1
		if same then
			for c = 1, cfg.n do
				if list[i - 1].counts[c] ~= s.counts[c] then
					same = false
					break
				end
			end
		end
		if not same then
			place = place + 1
		end
		s.place = place
	end
end

local function seasonsNote(seasons)
	local first, last
	for _, season in ipairs(seasons) do
		if season.counted then
			local label = plainText(season.label)
			if label ~= '' then
				first = first or label
				last = label
			end
		end
	end
	if not first then
		return nil
	end
	local y1, y2 = tonumber(first:match('%d%d%d%d')), tonumber(last:match('%d%d%d%d'))
	if y1 and y2 and y1 > y2 then
		first, last = last, first
	end
	if first == last then
		return 'Учтён сезон ' .. first
	end
	return 'Учтены сезоны с ' .. first .. ' по ' .. last
end

------------------------------------------------------------------------
-- Вывод
------------------------------------------------------------------------

local function newTable()
	return mw.html.create('table')
		:addClass('wikitable')
		:css('text-align', 'center')
end

local function colorHeader(row, text, color)
	local th = row:tag('th'):wikitext(text)
	if color and color ~= '' then
		th:css('background-color', color)
	end
	return th
end

local function renderStandings(list, cfg)
	local root = newTable()
	local head = root:tag('tr')
	head:tag('th')
	head:tag('th'):css('width', '18em'):css('height', '2em'):wikitext(cfg.clubHeader)
	for j = 1, cfg.n do
		colorHeader(head, cfg.labels[j], cfg.colors[j])
	end
	head:tag('th'):css('width', '8em'):css('height', '2em'):wikitext(cfg.total)

	local function number(v)
		return cfg.bold and ("'''" .. v .. "'''") or tostring(v)
	end
	if #list == 0 then
		root:tag('tr'):tag('td'):attr('colspan', cfg.n + 3):wikitext('Призёров пока нет')
	end
	local i = 1
	while i <= #list do
		local j = i
		while j < #list and list[j + 1].place == list[i].place do
			j = j + 1
		end
		for k = i, j do
			local s = list[k]
			local tr = root:tag('tr')
			if k == i then
				local td = tr:tag('td'):wikitext(s.place)
				if j > i then
					td:attr('rowspan', j - i + 1)
				end
			end
			local name = s.target and makeLink(s.target, s.label) or s.label
			tr:tag('td'):css('text-align', 'left'):wikitext(name)
			for c = 1, cfg.n do
				tr:tag('td'):wikitext(number(s.counts[c]))
			end
			tr:tag('td'):wikitext(number(s.total))
		end
		i = j + 1
	end
	return tostring(root)
end

local function cellText(cell)
	if cell.note then
		return cell.note
	end
	local out = {}
	for _, part in ipairs(cell.parts) do
		table.insert(out, part.team and part.team.display or part.raw)
	end
	return table.concat(out, '<br>')
end

local function renderSeasons(seasons, cfg)
	local root = newTable()
	local head = root:tag('tr')
	head:tag('th'):wikitext(cfg.seasonHeader)
	for j = 1, cfg.n do
		colorHeader(head, cfg.places[j], cfg.colors[j])
	end
	for e = 1, #cfg.extras do
		head:tag('th'):wikitext(cfg.extras[e])
	end
	for _, season in ipairs(seasons) do
		local tr = root:tag('tr')
		tr:tag('td'):wikitext(season.label)
		local first = season.places[1]
		local merged = first and first.note ~= nil
		for j = 2, cfg.n do
			if season.places[j].raw ~= '' then
				merged = false
			end
		end
		if merged then
			tr:tag('td'):attr('colspan', cfg.n):wikitext(first.note)
		else
			for j = 1, cfg.n do
				tr:tag('td'):wikitext(cellText(season.places[j]))
			end
		end
		for e = 1, #cfg.extras do
			tr:tag('td'):wikitext(season.extras[e])
		end
	end
	return tostring(root)
end

local function messages(list)
	local out = {}
	for _, text in ipairs(list) do
		if mw.addWarning then
			mw.addWarning('Призёры: ' .. text)
		end
		table.insert(out, '<div class="error">Призёры: ' .. text .. '</div>')
	end
	return #out > 0 and (table.concat(out, '\n') .. '\n') or ''
end

------------------------------------------------------------------------
-- Точки входа
------------------------------------------------------------------------

local function main(frame, forcedView)
	local warnings = {}
	local rows, settings = collectArgs(frame, forcedView, warnings)
	local cfg = makeConfig(settings, warnings)
	local clubs = loadClubs(settings['клубы'], warnings)

	if #rows > 0 then
		for _, cells in ipairs(rows) do
			if #cells > 1 + cfg.n + #cfg.extras then
				table.insert(warnings, 'в сезоне «' .. plainText(cells[1]) .. '» лишние ячейки (всего '
					.. #cells .. ', а столбцов ' .. (1 + cfg.n + #cfg.extras) .. '). '
					.. 'Каждый сезон пишите с новой строки, дополнительные столбцы объявите в «доп».')
			end
		end
	else
		local extras, err
		rows, extras, err = readSourceTable(frame, settings, cfg, warnings)
		if err then
			return messages(warnings) .. '<strong class="error">Призёры: ' .. err .. '</strong>'
		end
		if not cfg.extrasGiven then
			cfg.extras = extras
		end
	end
	if #rows == 0 then
		return messages(warnings) .. '<strong class="error">Призёры: нет данных — '
			.. 'впишите сезоны (каждый с новой строки) или укажите «источник».</strong>'
	end

	local seasons = buildSeasons(rows, cfg, clubs)
	resolveTeams(seasons, cfg.n)
	local list = tally(seasons, cfg.n)

	-- Один клуб, записанный ссылками на разные статьи, попал бы в зачёт двумя строками.
	local byName = {}
	for _, s in ipairs(list) do
		local name = norm(s.label)
		local other = byName[name]
		if other then
			table.insert(warnings, 'в зачёте две строки «' .. s.label .. '»: ссылки ведут на «'
				.. (other.target or 'без ссылки') .. '» и «' .. (s.target or 'без ссылки')
				.. '». Если это один клуб, запишите его везде одинаково.')
		else
			byName[name] = s
		end
	end

	if settings.view == 'seasons' then
		return messages(warnings) .. renderSeasons(seasons, cfg)
	end
	rank(list, cfg)
	local out = renderStandings(list, cfg)
	if cfg.note then
		local note = seasonsNote(seasons)
		if note then
			out = out .. '\n<small>' .. note .. '</small>'
		end
	end
	return messages(warnings) .. out
end

function p.main(frame)
	return main(frame)
end

function p.seasons(frame)
	return main(frame, 'seasons')
end

function p.standings(frame)
	return main(frame, 'standings')
end

-- {{#invoke:Призёры|клуб|ДСК}} → [[ДСК (футбольный клуб, Наро-Фоминск)|ДСК]]
-- Второй параметр — другая подпись. Пригодится в доп. столбцах (бомбардир и т. п.).
function p.club(frame)
	local args = frame.args
	if args[1] == nil then
		local parent = frame:getParent()
		args = parent and parent.args or {}
	end
	local name = trim(args[1])
	local label = trim(args[2])
	if label == '' then
		label = name
	end
	local listName = trim(args['клубы'])
	local target = loadClubs(listName ~= '' and listName or nil, {})[name]
	if type(target) == 'string' and trim(target) ~= '' then
		return makeLink(normTitle(target), label)
	end
	return label
end

p['таблица'] = p.main
p['сезоны'] = p.seasons
p['зачёт'] = p.standings
p['зачет'] = p.standings
p['клуб'] = p.club

return p