Documentationcréerpurger
-- Module:Palmares (universal, CR-format, language-agnostic)
-- Reads participant-in (P1344) on the cyclist item; renders a palmares list.
-- Links: local sitelink without "(...)" -> [[link|text]]; otherwise plain label (NO interwiki).
-- All display text comes from Wikidata in the local wiki language.
-- Call: {{#invoke:Palmares|list|Q123|road}}   (2nd arg = sport/discipline group; default road)

local p = {}

-- ===== reuse Module:Cycling race/data (stage types, competition classes, UCI circuits) =====
local RD = (function() local ok, d = pcall(mw.loadData, 'Module:Cycling race/data'); return (ok and d) or {} end)()
local STAGE_TYPES = RD.stages or {
	Q20646667=true, Q20646670=true, Q20646668=true, Q20680270=true,
	Q20679712=true, Q18131152=true, Q2266066=true, Q2348250=true, Q485321=true,
}
-- season-long standings (world rankings, World Tours, continental tours, ProSeries, World Cups,
-- Super Prestige Pernod, Challenge Desgrange-Colombo, …) all live in Module:Cycling race/data
-- table UCIYearToQ. None are individual races and all are shown via the separate {{Рейтинги}}
-- template, so every yearly Q in UCIYearToQ is excluded from the palmares — plus the general
-- (umbrella) items in case a result links to those directly rather than to a yearly edition.
-- (National championships sit in separate data tables and are NOT affected.)
local STANDING_Q = {}
do
	local y2q = RD.UCIYearToQ or {}
	for _, byYear in pairs(y2q) do
		if type(byYear) == 'table' then for _, q in pairs(byYear) do STANDING_Q[q] = true end end
	end
	for _, q in ipairs({                                   -- general/umbrella ranking & tour items
		'Q21857512',   -- UCI World Ranking
		'Q635366',     -- UCI World Tour
		'Q1194340', 'Q1063423', 'Q1063430', 'Q268357', 'Q1039648', -- Europe/Asia/America/Africa/Oceania Tour
	}) do STANDING_Q[q] = true end
end
local TTT_STAGE, PROLOGUE = 'Q2348250', 'Q485321'

-- competition classes that mark a CHAMPIONSHIP (class_dic value 0) + Olympics
local CHAMP_CLASS = {['Q22231119']=true,['Q22231118']=true,['Q106636612']=true,
                     ['Q116982664']=true,['Q124788734']=true,['Q18536594']=true} -- CN,CC,CM,CR,JC,JO

-- ===== sport / discipline taxonomy: Q (P641 sport or P2416 discipline) -> set of groups =====
-- A Q may belong to SEVERAL groups (call reg more than once for it); a later reg no longer
-- overwrites earlier ones. This is what lets a para road-discipline Q live in both 'para' and
-- 'pararoad' at the same time (see the para block below).
local SPORT_CAT = {}
local function reg(group, list)
	for _, q in ipairs(list) do
		local s = SPORT_CAT[q]; if not s then s = {}; SPORT_CAT[q] = s end
		s[group] = true
	end
end
reg('road',  {'Q3609','Q51541990','Q2266066','Q2348250','Q111087626','Q1498024','Q6394001','Q1318941'})
reg('track', {'Q221635','Q2657229','Q131918835','Q13635579','Q779934','Q1631168','Q2597988',
              'Q1307101','Q774176','Q1820070','Q1817141','Q2596614','Q1808956','Q1567867','Q352749','Q27437584'})
reg('mtb',   {'Q520611','Q1031445','Q106434943','Q106185304','Q1360806','Q3003748','Q106376162',
              'Q114173014','Q140438872','Q140438878','Q140439233','Q672066','Q860062','Q2274707'})
reg('snow',  {'Q119718439','Q140464246','Q140464248'})
reg('bmx',   {'Q215184','Q16240947','Q2192643','Q456455','Q119009890'})
reg('indoor',{'Q476503','Q1792605','Q8099087'})
reg('para',  {'Q2261949','Q140439727','Q140439729','Q122374912','Q140439725','Q140439726','Q140439728',
              'Q140439730','Q140439732','Q140439733','Q140439734','Q140439735','Q140439736'})
reg('pararoad',  {'Q140439727','Q140439729','Q122374912'})
reg('paratrack',  {'Q140439725','Q140439726','Q140439728',
              'Q140439730','Q140439732','Q140439733','Q140439734','Q140439735','Q140439736'})
reg('trial', {'Q1763915','Q11953082'})
reg('deaf',  {'Q140356846','Q140362507','Q140362508'})
reg('cross', {'Q335638'})
reg('gravel',{'Q111721834'})
reg('kiber', {'Q111244873'})
reg('fondo', {'Q25513762'})
reg('pump',  {'Q109522163'})

-- ===== champion jerseys (adapted from Template:Майка) =====
-- special jerseys: world / world-ITT / European / continental / olympic / esports / deaf
local JERSEY_SPECIAL = {
	world    = 'Jersey rainbow.svg',
	worldITT = 'Jersey rainbow chrono.svg',
	europe   = 'UEC Champion Jersey.svg',
	panam    = 'PanAmericanJersey.png',
	oceania  = 'OceaniaChampionJersey.png',
	asia     = 'Asian Championship Jersey.svg',
	africa   = 'African championship jersey 2019.svg',
	olympic  = 'Gold medal olympic.svg',
	esports  = 'UCI Cycling Esports World Championship rainbow jersey.svg',
	deaf     = 'Rainbow Jersey Deaflympics.svg',
}

-- national-champion jerseys keyed DIRECTLY by the country's Wikidata Q-id.
-- Used for dependent territories / sporting nations whose P17 or IOC code (P984)
-- is unreliable or gets bot-changed to the parent state (Guam->USA, Taiwan->China, …).
-- Resolved before the P984/IOC lookup, so it wins even if the country item lacks a clean P984.
local JERSEY_NC_Q = {
-- Oceania
    ['Q408']  = 'MaillotAustralia.PNG',   -- Australia
    ['Q712']  = 'MaillotFiji.png',   -- Fiji
    ['Q16635']  = 'MaillotGuam.png',   -- Guam
    ['Q664']  = 'MaillotNuevaZelanda.PNG',   -- New Zealand
-- Africa
    ['Q262']  = 'MaillotAlgeria.png',   -- Algeria
    ['Q916']  = 'MaillotAngola.png',   -- Angola
    ['Q967']  = 'MaillotBurundi.png',   -- Burundi
    ['Q962']  = 'MaillotBenin.png',   -- Benin
    ['Q963']  = 'MaillotBotswana.png',   -- Botswana
    ['Q965']  = 'MaillotBurkinaFaso.png',   -- Burkina Faso
    ['Q929']  = 'MaillotCAR.png',   -- Central African Republic
    ['Q971']  = 'MaillotCongo.png',   -- Republic of the Congo
    ['Q1008']  = 'MaillotCotedIvoire.png',   -- Cote d'Ivoire
    ['Q1009']  = 'MaillotCameroon.png',   -- Cameroon
    ['Q974']  = 'MaillotDRCongo.png',   -- DR Congo
    ['Q977']  = 'MaillotDjibouti.png',   -- Djibouti
    ['Q79']  = 'MaillotEgypt.png',   -- Egypt
    ['Q986']  = 'MaillotEritrea.png',   -- Eritrea
    ['Q115']  = 'Maillot ethiopia.png',   -- Ethiopia
    ['Q1000']  = 'MaillotGabon.png',   -- Gabon
    ['Q117']  = 'MaillotGhana.png',   -- Ghana
    ['Q1006']  = 'MaillotGuinea.png',   -- Guinea
    ['Q1016']  = 'MaillotLibya(2011).png',   -- Libya
    ['Q1014']  = 'MaillotLiberia.png',   -- Liberia
    ['Q1013']  = 'MaillotLesotho.png',   -- Lesotho
    ['Q1019']  = 'MaillotMadagascar.png',   -- Madagascar
    ['Q1028']  = 'MaillotMorocco.png',   -- Morocco
    ['Q1020']  = 'MaillotMalawi.png',   -- Malawi
    ['Q912']  = 'MaillotMali.png',   -- Mali
    ['Q1029']  = 'MaillotMozambique.png',   -- Mozambique
    ['Q1027']  = 'MaillotMauritius.png',   -- Mauritius
    ['Q1030']  = 'MaillotNamibia.PNG',   -- Namibia
    ['Q1033']  = 'MaillotNigeria.png',   -- Nigeria
    ['Q258']  = 'MaillotSudáfrica.PNG',   -- South Africa
    ['Q1037']  = 'MaillotRwanda.png',   -- Rwanda
    ['Q1041']  = 'MaillotSenegal.png',   -- Senegal
    ['Q1042']  = 'MaillotSeychelles.png',   -- Seychelles
    ['Q1044']  = 'MaillotSierraLeone.png',   -- Sierra Leone
    ['Q1039']  = 'MaillotSaoTomePrincipe.png',   -- São Tomé and Príncipe
    ['Q1050']  = 'MaillotSwaziland.png',   -- Eswatini
    ['Q924']  = 'MaillotTanzania.png',   -- Tanzania
    ['Q945']  = 'MaillotTogo.png',   -- Togo
    ['Q948']  = 'MaillotTunisia.png',   -- Tunisia
    ['Q1036']  = 'MaillotUganda.png',   -- Uganda
    ['Q953']  = 'MaillotZambia.png',   -- Zambia
    ['Q954']  = 'MaillotZimbabwe.png',   -- Zimbabwe
-- America
    ['Q25227']  = 'Maillot Antilles.png',   -- Netherlands Antilles
    ['Q781']  = 'MaillotAntiguaBarbuda.png',   -- Antigua and Barbuda
    ['Q414']  = 'MaillotArgentina.png',   -- Argentina
    ['Q21203']  = 'MaillotAruba.png',   -- Aruba
    ['Q778']  = 'MaillotBahamas.png',   -- Bahamas
    ['Q244']  = 'MaillotBarbados.png',   -- Barbados
    ['Q23635']  = 'MaillotBermuda.png',   -- Bermuda
    ['Q242']  = 'MaillotBelize.png',   -- Belize
    ['Q750']  = 'Maillot Bolivia.png',   -- Bolivia
    ['Q155']  = 'MaillotBrasil.PNG',   -- Brazil
    ['Q16']  = 'MaillotCanadá.PNG',   -- Canada
    ['Q5785']  = 'MaillotCaymanIslands.png',   -- Cayman Islands
    ['Q298']  = 'MaillotChile.PNG',   -- Chile
    ['Q739']  = 'MaillotColombia.PNG',   -- Colombia
    ['Q800']  = 'MaillotCostaRica.png',   -- Costa Rica
    ['Q241']  = 'MaillotCuba.png',   -- Cuba
    ['Q25279']  = 'MaillotCuracao.png',   -- Curaçao
    ['Q786']  = 'MaillotDominicanRepublic.png',   -- Dominican Republic
    ['Q736']  = 'MaillotEcuador.png',   -- Ecuador
    ['Q792']  = 'MaillotSalvador.png',   -- El Salvador
    ['Q769']  = 'MaillotGrenada.png',   -- Grenada
    ['Q774']  = 'MaillotGuatemala.png',   -- Guatemala
    ['Q734']  = 'MaillotGuyana.png',   -- Guyana
    ['Q783']  = 'MaillotHonduras.png',   -- Honduras
    ['Q11703']  = 'MaillotUSAVI.png',   -- United States Virgin Islands
    ['Q25305']  = 'MaillotBritishVI.png',   -- British Virgin Islands
    ['Q766']  = 'MaillotJamaica.png',   -- Jamaica
    ['Q760']  = 'MaillotSaintLucia.png',   -- Saint Lucia
    ['Q96']  = 'MaillotMexicano.PNG',   -- Mexico
    ['Q811']  = 'MaillotNicaragua.png',   -- Nicaragua
    ['Q804']  = 'MaillotPanama.png',   -- Panama
    ['Q733']  = 'MaillotParaguay.png',   -- Paraguay
    ['Q419']  = 'MaillotPeru.png',   -- Peru
    ['Q1183']  = 'MaillotPuertoRico.png',   -- Puerto Rico
    ['Q763']  = 'MaillotSaintKittsNevis.png',   -- Saint Kitts and Nevis
    ['Q730']  = 'MaillotSuriname.png',   -- Suriname
    ['Q754']  = 'MaillotTrinidadTobago.png',   -- Trinidad and Tobago
    ['Q77']  = 'MaillotUruguay.png',   -- Uruguay
    ['Q30']  = 'MaillotUSA.PNG',   -- United States of America
    ['Q717']  = 'MaillotVenezuela.png',   -- Venezuela
    ['Q757']  = 'MaillotSaintVincentGrenadines.png',   -- Saint Vincent and the Grenadines
    ['Q26273']  = 'MaillotSintMaarten.png',   -- Sint Maarten
-- Asia
    ['Q398']  = 'MaillotBahrain.png',   -- Bahrain
    ['Q921']  = 'MaillotBrunei.png',   -- Brunei
    ['Q424']  = 'MaillotCambodia.png',   -- Cambodia
    ['Q148']  = 'MaillotChina.png',   -- China
    ['Q8646']  = 'MaillotHongKong.png',   -- Hong Kong
    ['Q252']  = 'MaillotIndonesia.png',   -- Indonesia
    ['Q668']  = 'MaillotIndia.png',   -- India
    ['Q794']  = 'MaillotIran.png',   -- Iran
    ['Q796']  = 'MaillotIraq.png',   -- Iraq
    ['Q17']  = 'MaillotJapón.PNG',   -- Japan
    ['Q232']  = 'MaillotKazajistán.PNG',   -- Kazakhstan
    ['Q813']  = 'MaillotKyrgyzstan.png',   -- Kyrgyzstan
    ['Q884']  = 'MaillotSouthKorea.png',   -- South-Korea
    ['Q851']  = 'MaillotSaudiArabia.png',   -- Saudi Arabia
    ['Q817']  = 'MaillotKuwait.png',   -- Kuwait
    ['Q822']  = 'MaillotLebanon.png',   -- Lebanon
    ['Q14773']  = 'MaillotMacau.png',   -- Macau
    ['Q833']  = 'MaillotMalaysia.png',   -- Malaysia
    ['Q711']  = 'MaillotMongolia.png',   -- Mongolia
    ['Q842']  = 'MaillotOman.png',   -- Oman
    ['Q843']  = 'MaillotPakistan.png',   -- Pakistan
    ['Q928']  = 'MaillotPhilippines.png',   -- Philippines
    ['Q423']  = 'MaillotNorthKorea.png',   -- North Korea
    ['Q846']  = 'MaillotQatar.png',   -- Qatar
    ['Q334']  = 'MaillotSingapore.png',   -- Singapore
    ['Q854']  = 'MaillotSriLanka.png',   -- Sri Lanka
    ['Q858']  = 'MaillotSyria.png',   -- Syria
    ['Q869']  = 'MaillotThailand.png',   -- Thailand
    ['Q216923']  = 'MaillotTaipei.png',   -- Taiwan
    ['Q878']  = 'MaillotUAE.png',   -- United Arab Emirates
    ['Q265']  = 'MaillotUzbekistán.PNG',   -- Uzbekistan
    ['Q881']  = 'MaillotVietnam.png',   -- Vietnam
 -- Europa
    ['Q222']  = 'MaillotAlbania.png',   -- Albania
    ['Q228']  = 'MaillotAndorra.png',   -- Andorra
    ['Q40']  = 'MaillotAustria.PNG',   -- Austria
    ['Q227']  = 'MaillotAzerbaijan.png',   -- Azerbaijan
    ['Q31']  = 'MaillotBélgica.PNG',   -- Belgium
    ['Q225']  = 'MaillotBosniaHerzegovina.png',   -- Bosnia and Herzegovina
    ['Q184']  = 'MaillotBielorrusia.PNG',   -- Belarus
    ['Q219']  = 'MaillotBulgaria.png',   -- Bulgaria
    ['Q224']  = 'MaillotCroacia.PNG',   -- Croatia
    ['Q229']  = 'MaillotCyprus.png',   -- Cyprus
    ['Q213']  = 'MaillotRepúblicaCheca.PNG',   -- Czech Republic
    ['Q35']  = 'MaillotDinamarca.PNG',   -- Denmark
    ['Q29']  = 'MaillotEspaña.PNG',   -- Spain
    ['Q191']  = 'MaillotEstonia.PNG',   -- Estonia
    ['Q33']  = 'MaillotFinlandia.PNG',   -- Finland
    ['Q142']  = 'MaillotFrancia.png',   -- France
    ['Q145']  = 'MaillotReinoUnido.PNG',   -- Great Britain
    ['Q16957']  = 'MaillotGDR.png',   -- GDR
    ['Q230']  = 'MaillotGeorgia.png',   -- Georgia
    ['Q183']  = 'MaillotAlemania.PNG',   -- Germany
    ['Q41']  = 'Maillot Greece.png',   -- Greece
    ['Q28']  = 'MaillotHungary.png',   -- Hungary
    ['Q4628']  = 'MaillotFaroe.png',   -- Faroe Islands
    ['Q27']  = 'MaillotIrlanda.PNG',   -- Irish
    ['Q189']  = 'MaillotIceland.png',   -- Iceland
    ['Q801']  = 'MaillotIsrael.PNG',   -- Israel
    ['Q38']  = 'MaillotItalia.PNG',   -- Italy
    ['Q1246']  = 'MaillotKosovo.png',   -- Kosovo
    ['Q211']  = 'MaillotLetonia.PNG',   -- Latvia
    ['Q347']  = 'MaillotLiechtenstein.png',   -- Liechtenstein
    ['Q37']  = 'MaillotLituania.PNG',   -- Lithuania
    ['Q32']  = 'MaillotLuxemburgo.PNG',   -- Luxembourg
    ['Q217']  = 'MaillotMoldavia.PNG',   -- Moldova
    ['Q221']  = 'MaillotMacedonia.png',   -- North Macedonia
    ['Q233']  = 'MaillotMalta.png',   -- Malta
    ['Q236']  = 'MaillotMontenegro.png',   -- Montenegro
    ['Q235']  = 'MaillotMonaco.png',   -- Monaco
    ['Q55']  = 'MaillotHolanda.PNG',   -- The Netherlands
    ['Q20']  = 'MaillotNoruega.PNG',   -- Norway
    ['Q36']  = 'MaillotPolonia.PNG',   -- Poland
    ['Q45']  = 'MaillotPortugal.PNG',   -- Portugal
    ['Q218']  = 'MaillotRomanian.png',   -- Romania
    ['Q159']  = 'MaillotRusia.PNG',   -- Russia
    ['Q37024']  = 'MaillotSerbiaMontenegro.png',   -- Serbia and Montenegro
    ['Q215']  = 'MaillotEslovenia.PNG',   -- Slovenia
    ['Q238']  = 'MaillotSanMarino.png',   -- San Marino
    ['Q403']  = 'MaillotSerbia.png',   -- Serbia
    ['Q39']  = 'MaillotSuiza.PNG',   -- Switzerland
    ['Q214']  = 'MaillotSlovakia.png',   -- Slovakia
    ['Q34']  = 'MaillotSuecia.PNG',   -- Sweden
    ['Q33946']  = 'MaillotCSSR.png',   -- Czechoslovakia
    ['Q43']  = 'Maillot Turkey.png',   -- Turkey
    ['Q212']  = 'MaillotUcrania.PNG',   -- Ukraine
    ['Q15180']  = 'MaillotURSS.png',   -- USSR
    ['Q36704']  = 'MaillotYugoslavia.png',   -- Yugoslavia
}
local function jerseyByQ(code) local f = JERSEY_SPECIAL[code] or JERSEY_NC_Q[code]; return f and ('[[File:' .. f .. '|20px|link=]]') or nil end

-- ===== medals (as file links; work on every wiki) =====
local MEDAL = {
	[1] = '[[File:Gold medal icon.svg|16px|link=]]',
	[2] = '[[File:Silver medal icon.svg|16px|link=]]',
	[3] = '[[File:Bronze medal icon.svg|16px|link=]]',
}
local G_MALE = false                           -- rider gender (P21) for gendered ordinals (ru etc.)
-- language-aware ordinal for a place number
local function ord(n)
	n = tonumber(n) or n
	local lang = (mw.language and mw.language.getContentLanguage and mw.language.getContentLanguage():getCode()) or 'en'
	if lang == 'ru' or lang == 'uk' or lang == 'be' then
		return tostring(n) .. (G_MALE and '-й' or '-я')
	elseif lang == 'en' then
		local m10, m100 = n % 10, n % 100
		local suf = 'th'
		if m100 < 11 or m100 > 13 then
			if m10 == 1 then suf = 'st' elseif m10 == 2 then suf = 'nd' elseif m10 == 3 then suf = 'rd' end
		end
		return tostring(n) .. suf
	elseif lang == 'fr' then
		return tostring(n) .. ((n == 1) and 'er' or 'e')
	elseif lang == 'it' or lang == 'es' or lang == 'pt' then
		return tostring(n) .. 'º'
	elseif lang == 'de' or lang == 'nl' then
		return tostring(n) .. '.'
	end
	return tostring(n) .. '.'                   -- fallback
end

-- ===== Wikidata helpers =====
local function riderId(frame)
	local a = frame.args[1]
	if a and a ~= '' then return mw.text.trim(a) end
	return mw.wikibase.getEntityIdForCurrentPage()
end
local function valueId(st)
	local dv = st.mainsnak and st.mainsnak.datavalue
	return dv and dv.value.id or nil
end
local function rankOf(st)
	local q = st.qualifiers and st.qualifiers.P1352
	if not q or not q[1] or not q[1].datavalue then return nil end
	return tonumber(q[1].datavalue.value.amount)
end
local function winsOf(st)
	local out = {}
	local q = st.qualifiers and st.qualifiers.P2501
	if q then for _, sn in ipairs(q) do if sn.datavalue then out[#out+1] = sn.datavalue.value.id end end end
	return out
end
local function stYear(st)
	local q = st.qualifiers and st.qualifiers.P585
	if q and q[1] and q[1].datavalue then return q[1].datavalue.value.time:match('(%d%d%d%d)') end
	return nil
end
-- ===== memoization: cache Wikidata reads for the whole page render (keyed by entity id) =====
-- Reads for the same item/property are done once; false is the "cached negative" sentinel.
local wb = mw.wikibase
local _best, _all, _label, _llang, _slink = {}, {}, {}, {}, {}
local function wbBest(id, prop)
	if not id then return {} end
	local k = id .. '|' .. prop; local v = _best[k]
	if v == nil then v = wb.getBestStatements(id, prop) or false; _best[k] = v end
	return v or {}
end
local function wbAll(id, prop)
	if not id then return {} end
	local k = id .. '|' .. prop; local v = _all[k]
	if v == nil then v = wb.getAllStatements(id, prop) or false; _all[k] = v end
	return v or {}
end
local function wbLabel(id)
	if not id then return nil end
	local v = _label[id]
	if v == nil then v = wb.getLabel(id) or false; _label[id] = v end
	return v or nil
end
local function wbSitelink(id)
	if not id then return nil end
	local v = _slink[id]
	if v == nil then v = wb.getSitelink(id) or false; _slink[id] = v end
	return v or nil
end
local function wbLabelLang(id)
	if not id then return nil, nil end
	local v = _llang[id]
	if v == nil then
		if wb.getLabelWithLang then local l, lang = wb.getLabelWithLang(id); v = { l or false, lang or false }
		else v = { (wb.getLabel(id)) or false, false } end
		_llang[id] = v
	end
	return v[1] or nil, v[2] or nil
end
local function firstItem(qid, prop)
	local s = qid and wbBest(qid, prop)
	if s and s[1] and s[1].mainsnak.datavalue then return s[1].mainsnak.datavalue.value.id end
	return nil
end
local function allItems(qid, prop)
	local out = {}
	for _, st in ipairs((qid and wbBest(qid, prop)) or {}) do
		if st.mainsnak.datavalue then out[#out+1] = st.mainsnak.datavalue.value.id end
	end
	return out
end
local function ordinalP1545(qid)
	local s = wbBest(qid, 'P1545')
	if s and s[1] and s[1].mainsnak.datavalue then return tonumber(s[1].mainsnak.datavalue.value) end
	return nil
end
local _year, _date = {}, {}
local function yearOf(qid, depth)
	depth = depth or 0
	if not qid or depth > 3 then return nil end
	if depth == 0 then local c = _year[qid]; if c ~= nil then return c or nil end end
	local r
	local s = wbBest(qid, 'P585')
	if s and s[1] and s[1].mainsnak.datavalue then r = s[1].mainsnak.datavalue.value.time:match('(%d%d%d%d)') end
	if not r then local lab = wbLabel(qid); if lab then r = lab:match('([12]%d%d%d)') end end
	if not r then local ed = firstItem(qid, 'P361'); if ed then r = yearOf(ed, depth + 1) end end
	if depth == 0 then _year[qid] = r or false end
	return r
end
local function dateStr(qid)
	if not qid then return nil end
	local c = _date[qid]; if c ~= nil then return c or nil end
	local r
	local s = wbBest(qid, 'P585')
	if s and s[1] and s[1].mainsnak.datavalue then
		r = (s[1].mainsnak.datavalue.value.time:gsub('^%+', ''):sub(1, 10))
	end
	_date[qid] = r or false
	return r
end

-- subclasses (P279) of the item and of its P31 type
local _sub = {}
local function subclassSet(vid)
	if not vid then return {} end
	local c = _sub[vid]; if c then return c end
	local set, t = {}, firstItem(vid, 'P31')
	for _, q in ipairs(allItems(vid, 'P279')) do set[q] = true end
	if t then for _, q in ipairs(allItems(t, 'P279')) do set[q] = true end end
	_sub[vid] = set
	return set
end
local function isChamp(vid)
	for q in pairs(subclassSet(vid)) do if CHAMP_CLASS[q] then return true end end
	return false
end
local function isNational(vid)                 -- national championship (CN class) — no medals
	return subclassSet(vid)['Q22231119'] == true
end
-- country Q of a (national) championship, resolved by priority:
--   P1532 (country for sport) -> P2541 (operating area) -> P17 (country),
-- checked on the championship item first, then on its P31 type.
-- The sport-specific properties come first on purpose: for dependent territories
-- (Guam, Taiwan, Hong Kong, Macao, Puerto Rico, …) P17 is often bot-changed to the
-- parent state, whereas the sporting nation stays correct in P1532/P2541.
local COUNTRY_PROPS = {'P1532', 'P2541', 'P17'}
local function countryQ(vid)
	for _, prop in ipairs(COUNTRY_PROPS) do
		local c = firstItem(vid, prop); if c then return c end
	end
	local t = firstItem(vid, 'P31')
	if t then
		for _, prop in ipairs(COUNTRY_PROPS) do
			local c = firstItem(t, prop); if c then return c end
		end
	end
	return nil
end

-- champion jersey for a 1st place (language-agnostic, via competition class + discipline + sport)
local function champJersey(vid, sport)
	local sub = subclassSet(vid)
	if sub['Q22231119'] then                                   -- national -> country jersey (by Q)
		local c = countryQ(vid)                                -- P1532 -> P2541 -> P17
		return c and jerseyByQ(c) or nil
	end
	if sub['Q18536594'] then return jerseyByQ('olympic') end  -- olympic
	if sub['Q106636612'] then                                  -- world
		if sport == 'kiber' then return jerseyByQ('esports') end
		if sport == 'deaf'  then return jerseyByQ('deaf')    end
		for _, q in ipairs(allItems(vid, 'P2416')) do if q == 'Q2266066' then return jerseyByQ('worldITT') end end
		return jerseyByQ('world')
	end
	if sub['Q22231118'] then                                   -- continental -> detect continent by label keyword
		local lab = ((wbLabel(vid) or '') .. ' ' ..
			(function() local t = firstItem(vid, 'P31'); return t and wbLabel(t) or '' end)())
		lab = mw.ustring.lower(lab)
		if mw.ustring.find(lab, 'europ') or mw.ustring.find(lab, 'европ') then return jerseyByQ('europe') end
		if mw.ustring.find(lab, 'pan.?americ') or mw.ustring.find(lab, 'панамерик') or mw.ustring.find(lab, 'america') then return jerseyByQ('panam') end
		if mw.ustring.find(lab, 'ocean') or mw.ustring.find(lab, 'océan') or mw.ustring.find(lab, 'океан') then return jerseyByQ('oceania') end
		if mw.ustring.find(lab, 'asia') or mw.ustring.find(lab, 'ази') then return jerseyByQ('asia') end
		if mw.ustring.find(lab, 'afric') or mw.ustring.find(lab, 'африк') then return jerseyByQ('africa') end
		return nil
	end
	return nil                                                 -- unknown -> fallback (medal)
end
local function isStanding(vid)
	-- only the standing item itself (yearly rankings/tours + general umbrellas) is excluded.
	-- Do NOT infer from P361: a real race is "part of" (P361) the World Tour calendar too.
	return (vid and STANDING_Q[vid]) and true or false
end

-- all discipline groups a value belongs to (P641 sport, P2416 discipline); a value may be in
-- several groups at once (e.g. a para road event -> {para, pararoad}); stages inherit from the edition
local _sport = {}
local function sportGroups(vid, depth)
	depth = depth or 0
	if not vid or depth > 3 then return nil end
	if depth == 0 then local c = _sport[vid]; if c ~= nil then return c or nil end end
	local set
	local function add(q)
		local s = SPORT_CAT[q]
		if s then set = set or {}; for g in pairs(s) do set[g] = true end end
	end
	for _, q in ipairs(allItems(vid, 'P2416')) do add(q) end
	for _, q in ipairs(allItems(vid, 'P641'))  do add(q) end
	local p31 = firstItem(vid, 'P31')
	if p31 then                                       -- look at the type (P31) too
		for _, q in ipairs(allItems(p31, 'P2416')) do add(q) end
		for _, q in ipairs(allItems(p31, 'P641'))  do add(q) end
	end
	local result = set
	if not result and p31 and STAGE_TYPES[p31] then   -- stage -> inherit from edition
		local ed = firstItem(vid, 'P361')
		if ed then result = sportGroups(ed, depth + 1) end
	end
	if depth == 0 then _sport[vid] = result or false end
	return result                                     -- nil = unknown
end
-- does the value belong to the requested group? (unknown -> treated as road)
local function inGroup(vid, want)
	local set = sportGroups(vid)
	if set then return set[want] == true end
	return want == 'road'
end

-- ===== CR-format link: local sitelink -> [[link|text]]; else plain label. Language-aware. =====
local CONTENT_LANG = (mw.language and mw.language.getContentLanguage and mw.language.getContentLanguage():getCode()) or 'en'
-- localized fixed strings (general classification, stage word, prologue); English fallback
local MSG = {
	ru = {gc='Генеральная классификация', gcplace=' в генеральной классификации', stage='этап', stages='этапы', ["and"]='и', prologue='Пролог'},
	uk = {gc='Генеральна класифікація', gcplace=' в генеральній класифікації', stage='етап', stages='етапи', ["and"]='і', prologue='Пролог'},
	en = {gc='General classification', gcplace=' in the general classification', stage='stage', stages='stages', ["and"]='and', prologue='Prologue'},
	fr = {gc='Classement général', gcplace=' du classement général', stage='étape', stages='étapes', ["and"]='et', prologue='Prologue'},
	it = {gc='Classifica generale', gcplace=' della classifica generale', stage='tappa', stages='tappe', ["and"]='e', prologue='Prologo'},
	es = {gc='Clasificación general', gcplace=' de la clasificación general', stage='etapa', stages='etapas', ["and"]='y', prologue='Prólogo'},
}
local L = MSG[CONTENT_LANG] or MSG.en
-- stage ordinal agrees with the noun "stage" (ru: masculine, regardless of rider gender)
local function stageOrd(n)
	if CONTENT_LANG == 'ru' or CONTENT_LANG == 'uk' or CONTENT_LANG == 'be' then return tostring(n) .. '-й' end
	if CONTENT_LANG == 'en' then return ord(n) end
	if CONTENT_LANG == 'fr' then return tostring(n) .. ((n == 1) and 'er' or 'e') end
	return tostring(n) .. '.'
end

local GENERIC_SERIES = {['Q1318941']=true, ['Q1456943']=true, ['Q15091278']=true}  -- generic race classes (not a series)

local function clean(s)                          -- drop "(...)" and 4-digit year
	if not s or s == '' then return nil end
	s = mw.ustring.gsub(s, '%s*%b()', '')
	s = mw.ustring.gsub(s, '%s*[12]%d%d%d', '')
	s = mw.ustring.gsub(s, '%s+', ' ')
	return mw.text.trim(s)
end
local function labelLang(qid)                    -- label + its language (cached)
	return wbLabelLang(qid)
end
local _page = {}
local function pageExists(title)
	if not title or title == '' then return false end
	local c = _page[title]; if c ~= nil then return c end
	local ok, t = pcall(function() return mw.title.new(title) end)
	local r = (ok and t and t.exists) or false
	_page[title] = r
	return r
end
-- 4-digit year from a time-valued qualifier of a statement
local function qualYear(st, prop)
	local a = st.qualifiers and st.qualifiers[prop]
	local t = a and a[1] and a[1].datavalue and a[1].datavalue.value and a[1].datavalue.value.time
	return t and tonumber(mw.ustring.match(t, '%+?(%d%d%d%d)')) or nil
end
-- name property P2561 «имя» (monolingual), YEAR-AWARE + LANGUAGE-FIRST: a race name can change over
-- the years, each value qualified by year (P585 point in time, or P580..P582 range). Prefer the value
-- in the wiki's content language, choosing the one valid for `year` (largest applicable start year).
-- A value in another language is returned only when allowOther is true (used as a last resort).
local function nameProp(qid, year, allowOther)
	year = tonumber(year) or 9999
	local cFit, cFrom, aFit, aFrom   -- year-fitting picks (content / any language) + their start year
	local cGen, aGen                 -- unqualified (general) picks
	for _, st in ipairs(wbAll(qid, 'P2561') or {}) do
		if st.rank ~= 'deprecated' then
			local dv = st.mainsnak and st.mainsnak.datavalue
			local v = dv and dv.value
			if v and v.text then
				local isC = (v.language == CONTENT_LANG)
				local from = qualYear(st, 'P585') or qualYear(st, 'P580')
				local to   = qualYear(st, 'P582')
				if from == nil and to == nil then                                   -- no year -> general name
					if isC then cGen = cGen or v.text else aGen = aGen or v.text end
				elseif (from == nil or from <= year) and (to == nil or year <= to) then  -- valid for this year
					local f = from or -1
					if isC then if cFrom == nil or f >= cFrom then cFit, cFrom = v.text, f end
					else            if aFrom == nil or f >= aFrom then aFit, aFrom = v.text, f end end
				end
			end
		end
	end
	local content = cFit or cGen                       -- content-language always wins
	if content then return content end
	if allowOther then return aFit or aGen end         -- other language only as a last resort
	return nil
end
-- content-language display name, preferring the series (P31) for race editions
local function dispName(qid, year)
	local series = firstItem(qid, 'P31')
	if series and GENERIC_SERIES[series] then series = nil end
	local function contentName(q)
		if not q then return nil end
		local nm = nameProp(q, year); if nm then return clean(nm) end     -- P2561 «имя» (year-aware)
		local sl = wbSitelink(q); if sl then return clean(sl) end
		local lab, lang = labelLang(q)
		if lab and (lang == nil or lang == CONTENT_LANG) then return clean(lab) end
		return nil
	end
	return contentName(series) or contentName(qid) or clean(wbLabel(qid)) or qid
end
-- championship display name: use the explicit «имя» (P2561) verbatim if set; otherwise assemble
-- the v2 short form «Чемпионат X — дисциплина [(категория)]» from the Russian label.
-- championship short form «Чемпионат X — дисциплина [(категория)]» from the Russian label —
-- used only as a FALLBACK when the item has no explicit «имя» (P2561), which is resolved first at the call site.
local function champName(vid, year)
	local lab = wbLabel(vid) or ''
	if not (mw.ustring.find(lab, 'Чемпионат') or mw.ustring.find(lab, 'играх')) then
		local t = firstItem(vid, 'P31'); local tl = (t and wbLabel(t)) or ''
		if mw.ustring.find(tl, 'Чемпионат') or mw.ustring.find(tl, 'играх') then lab = tl else return nil end
	end
	lab = mw.ustring.gsub(lab, '%s+[%-—–]%s+', ' — ')
	local base, tail = mw.ustring.match(lab, '^(.-) — (.+)$')
	if not base then return nil end
	base = mw.text.trim(mw.ustring.gsub(base, '%s*%d%d%d%d%s*$', ''))
	local disp
	local X = mw.ustring.match(base, '^Чемпионат%s+(.-)%s+по ')
	if X then disp = 'Чемпионат ' .. X
	elseif mw.ustring.find(base, 'Олимпийских играх') then disp = 'Летние Олимпийские игры'
	elseif mw.ustring.find(base, 'Европейских играх') then disp = 'Европейские игры'
	else return nil end
	local discText = mw.text.trim(mw.ustring.match(tail, '^([^%(]+)') or '')
	local paren = mw.ustring.match(tail, '%(([^%)]*)%)') or ''
	local male = mw.ustring.find(paren, 'мужчин') ~= nil
	local cat
	if mw.ustring.find(paren, 'U%-?19') or mw.ustring.find(paren, 'юниор') then cat = male and 'юниоры' or 'юниорки'
	elseif mw.ustring.find(paren, 'U%-?23') then cat = 'U23' end
	return disp .. ' — ' .. discText .. (cat and (' (' .. cat .. ')') or '')
end
-- championship target sitelink (port of the championship branch of CR getMainRaceLink):
-- for a championship the article is reached through the "part of" (P361) / "instance of" (P31)
-- chain, NOT through P31-as-series (P31 is the competition-class marker, e.g. Q22231119).
--   item sitelink -> P361 -> P31 -> (P361|P31 of that) -> (P361|P31 of that)
local function champTarget(qid)
	local sl = wbSitelink(qid)
	if sl then return sl end
	local t = firstItem(qid, 'P361')
	if t then sl = wbSitelink(t); if sl then return sl end end
	local t2 = firstItem(qid, 'P31')
	if not t2 then return nil end
	sl = wbSitelink(t2); if sl then return sl end
	local t3 = firstItem(t2, 'P361') or firstItem(t2, 'P31')
	if not t3 then return nil end
	sl = wbSitelink(t3); if sl then return sl end
	local t4 = firstItem(t3, 'P361') or firstItem(t3, 'P31')
	if t4 then return wbSitelink(t4) end
	return nil
end

-- CR-format link: local sitelink (item -> series, or championship P361/P31 chain) or
-- existing article by name; else plain text. Pass isChamp=true for championships.
local function wplink(qid, dispOverride, isChampArg, year)
	local disp = dispOverride or dispName(qid, year)
	if isChampArg then
		local tgt = champTarget(qid)                          -- championship: P361/P31 cascade
		if tgt then return (tgt == disp) and ('[[' .. tgt .. ']]') or ('[[' .. tgt .. '|' .. disp .. ']]') end
		if disp and pageExists(disp) then return '[[' .. disp .. ']]' end
		return disp
	end
	local sl = wbSitelink(qid)                    -- 1) article of the item (edition/champ)
	if sl then return (sl == disp) and ('[[' .. sl .. ']]') or ('[[' .. sl .. '|' .. disp .. ']]') end
	local series = firstItem(qid, 'P31')
	if series and not GENERIC_SERIES[series] then
		local ssl = wbSitelink(series)           -- 2) article of the series
		if ssl then return (ssl == disp) and ('[[' .. ssl .. ']]') or ('[[' .. ssl .. '|' .. disp .. ']]') end
	end
	if disp and pageExists(disp) then return '[[' .. disp .. ']]' end   -- 3) article whose title = display name
	return disp                                               -- 4) plain text (no interwiki)
end

-- ===== main =====
p['list'] = function(frame)
	local rid = riderId(frame)
	if not rid then return '' end
	G_MALE = (firstItem(rid, 'P21') == 'Q6581097')            -- gender for ordinals
	local want = mw.text.trim(frame.args[2] or 'road')
	local sts = wbAll(rid, 'P1344')
	if not sts or #sts == 0 then return '' end

	local editions, champs = {}, {}
	local function ensureEdition(edQ, y)
		if editions[edQ] then
			if y and not editions[edQ].year then editions[edQ].year = y end
			return editions[edQ]
		end
		local e = { year = y or yearOf(edQ), gc=nil, sec={}, stages={}, prologue=false, d0=nil }
		editions[edQ] = e
		return e
	end

	for _, st in ipairs(sts) do
		local vid = valueId(st)
		if vid and not isStanding(vid) and inGroup(vid, want) then
			local p31 = firstItem(vid, 'P31')
			local rank = rankOf(st)
			if p31 and STAGE_TYPES[p31] then                          -- stage win
				local edQ = firstItem(vid, 'P361')
				if edQ then
					local e = ensureEdition(edQ, stYear(st))
					e.stages[#e.stages+1] = { q=vid, num=ordinalP1545(vid) or 0, ttt=(p31==TTT_STAGE), prologue=(p31==PROLOGUE) }
					local sd = dateStr(vid); if sd and (not e.d0 or sd < e.d0) then e.d0 = sd end
				end
			elseif isChamp(vid) then                                  -- championship
				if not (rank and rank >= 11) then                     -- hide placings 11th and worse
					local cy = stYear(st) or yearOf(vid) or ''
					champs[#champs+1] = { q=vid, pos=rank or 0, year=cy, nat=isNational(vid),
					                      d0 = dateStr(vid) or ((cy ~= '' and cy or '0000') .. '-01-01') }
				end
			else                                                      -- race edition
				local e = ensureEdition(vid, stYear(st))
				if rank and rank <= 10 then e.gc = rank end           -- GC placing only within top 10 (wins/stages/sec still show)
				for _, dq in ipairs(winsOf(st)) do e.sec[#e.sec+1] = dq end
			end
		end
	end

	-- build lines grouped by year, with A/B groups (races first, championships last)
	local byYear = {}
	local function push(year, group, key, text)
		year = year or '????'
		byYear[year] = byYear[year] or {}
		table.insert(byYear[year], { group=group, key=key, text=text })
	end

	-- combined stage-win line: "5th and 9th stages" (localized), prologue first
	local function stageLine(e)
		table.sort(e.stages, function(a, b) return (a.prologue and 0 or a.num) < (b.prologue and 0 or b.num) end)
		local nums, hasPro = {}, false
		for _, s in ipairs(e.stages) do
			if s.prologue then hasPro = true
			else nums[#nums+1] = stageOrd(s.num) .. (s.ttt and ' (TTT)' or '') end
		end
		local parts = {}
		if hasPro then parts[#parts+1] = L.prologue end
		for _, n in ipairs(nums) do parts[#parts+1] = n end
		local body
		if #parts == 1 then body = parts[1]
		else body = table.concat(parts, ', '):gsub(', ([^,]*)$', ' ' .. L['and'] .. ' %1') end
		local word = (#nums == 1 and not hasPro) and L.stage or ((#nums >= 1) and L.stages or '')
		return body .. (word ~= '' and (' ' .. word) or '')
	end

	for edQ, e in pairs(editions) do
		local d0 = dateStr(edQ) or e.d0 or ((e.year or '9999') .. '-01-01')
		local link = wplink(edQ, nil, false, e.year or yearOf(edQ))
		local ach = {}
		if e.gc then
			ach[#ach+1] = (e.gc == 1)
				and { head = L.gc, solo = link }                                  -- GC win -> just the race
				or  { head = ord(e.gc) .. L.gcplace, solo = ord(e.gc) .. ' — ' .. link }
		end
		for _, dq in ipairs(e.sec) do
			local nm = wbLabel(dq) or dq
			ach[#ach+1] = { head = nm, solo = nm .. ' — ' .. link }               -- secondary-classification win
		end
		if #e.stages > 0 then
			local sl = stageLine(e)
			ach[#ach+1] = { head = sl, solo = sl .. ' — ' .. link }               -- stage wins (one line)
		end

		local text
		if #ach == 0 then text = nil
		elseif #ach == 1 then text = ': ' .. ach[1].solo                          -- one achievement -> one line
		else
			local lines = { ': ' .. link }                                        -- header + sub-items
			for _, a in ipairs(ach) do lines[#lines+1] = ':: ' .. a.head end
			text = table.concat(lines, '\n')
		end
		local key = e.gc and { e.gc, (e.gc == 1) and 0 or 1, d0 } or { 50, 1, d0 }
		if text then push(e.year, e.gc and 'A' or 'B', key, text) end
	end

	for _, c in ipairs(champs) do
		local nat = isNational(c.q)
		local prefix
		if c.pos == 1 then
			prefix = champJersey(c.q, want) or (nat and '' or MEDAL[1])           -- win: jersey; else gold medal
		elseif nat then
			prefix = ord(c.pos) .. ' —'                                            -- national: no medals
		else
			prefix = MEDAL[c.pos] or (ord(c.pos) .. ' —')                          -- silver/bronze or ordinal
		end
		local sep = (prefix ~= '') and (prefix .. ' ') or ''
		local cname = nameProp(c.q, c.year)                                   -- «имя» (P2561) on the championship item
		if not cname then                                                     -- else «имя» on the series root (P31) — like normal races
			local series = firstItem(c.q, 'P31')
			if series then cname = nameProp(series, c.year) end
		end
		cname = (cname and mw.text.trim(cname)) or champName(c.q, c.year)     -- else assembled short form (fallback)
		if cname then cname = mw.ustring.gsub(cname, ' %- ', ' — ') end        -- «имя» uses a hyphen; display uses an em-dash
		push(c.year, 'C', { c.pos, c.d0 }, ': ' .. sep .. wplink(c.q, cname, true, c.year))   -- group C -> after all races
	end

	-- output: per year, races (A then B) then championships (C)
	local years = {}
	for y in pairs(byYear) do years[#years+1] = y end
	table.sort(years)
	local function cmp(a, b)
		for i = 1, math.max(#a.key, #b.key) do
			local x, y = a.key[i] or 0, b.key[i] or 0
			if x ~= y then return x < y end
		end
		return false
	end
	local out = {}
	for _, y in ipairs(years) do
		out[#out+1] = '; ' .. y
		local groups = { A = {}, B = {}, C = {} }
		for _, e in ipairs(byYear[y]) do table.insert(groups[e.group], e) end
		for _, g in ipairs({ 'A', 'B', 'C' }) do
			table.sort(groups[g], cmp)
			for _, e in ipairs(groups[g]) do out[#out+1] = e.text end
		end
		out[#out+1] = ''
	end
	return frame:preprocess(table.concat(out, '\n'))
end

return p