Jump to content

Module:Music chart

Permanently protected module
From Wikipedia, the free encyclopedia

local p = {}

-------------------------------------------------------------------------------
-- Module:Music Chart
-- Generates table rows for music chart positions with automatic references.
-- Chart data stored in JSON files, referenced by key (e.g., "Australia").
--
-- Called via templates:
--	{{Single chart|Australia|1|artist=...|song=...|...}}
--	{{Album chart|Australia|1|artist=...|album=...|...}}
--	{{Year-end single chart|Australia|1|artist=...|song=...|year=2024|...}}
--	{{Year-end album chart|Australia|1|artist=...|album=...|year=2024|...}}
--
-- Template code: {{#invoke:Music chart|main|type=single}}
-- Arguments passed automatically from template call.
-------------------------------------------------------------------------------

--=============================================================================
-- SECTION 1: CONFIGURATION
-- All module settings in one place. Edit here to customize behavior.
--=============================================================================

local CONFIG = {
	---------------------------------------------------------------------------
	-- TEXT OUTPUT
	---------------------------------------------------------------------------

	-- Month names for {dateMDY} placeholder
	months = {
		"January", "February", "March", "April", "May", "June",
		"July", "August", "September", "October", "November", "December"
	},

	-- Human-readable names for chart types (used in error messages and categories)
	type_names = {
		single = "Single",
		album = "Album",
		["year-end-single"] = "Year-end single",
		["year-end-album"] = "Year-end album",
	},

	---------------------------------------------------------------------------
	-- POSITION VALIDATION
	---------------------------------------------------------------------------

	-- Maximum allowed numeric position (1-200 typical for charts)
	max_position = 200,

	-- Accepted dash characters for "not charted" position
	-- en-dash (–) for enwiki, em-dash (—) for ruwiki, hyphen (-) as fallback
	-- For multiple: accepted_dashes = {"–", "—", "-"},
	accepted_dashes = {"–"},

	-- Error message templates
	errors = {
		prefix = 'ERROR in "%s": ',
		unknown_chart = 'Unknown chart "%s".',
		missing_params = "Missing parameters: %s.",
		invalid_date = "Invalid date format. Expected: %s.",
		invalid_year = "Invalid year format: %s. Expected 4 digits.",
		invalid_week = "Invalid week format: %s. Expected 1–2 digits (use 51+52 for combined weeks).",
		missing_chart = "Missing parameter: chart.",
		missing_position = "Missing parameter: position.",
		invalid_position = "Invalid position: %s. Expected number 1–%d or dash (–).",
		manual_missing_url_title = "Manual mode (M) requires url and title parameters.",
		url_validation = "Invalid URL. Required domain: %s.",
		use_new_chart = "For this date range, use %s instead.",
	},

	-- Warning templates (shown only in preview mode)
	warnings = {
		unused_params = 'WARNING: Unused parameters for "%s": %s.',		-- chartkey, params
		unknown_params = 'WARNING: Unknown parameters for "%s": %s.',	-- chartkey, params
		invalid_date_ref = "Date format should be %s.",
	},

	-- Reference text templates
	text = {
		retrieved = "Retrieved %s.",							-- %s = access-date
		archived = "Archived from [%s the original] on %s.",	-- %s = archive-url, archive-date
	},

	-- Category name templates
	-- %s placeholders filled with type name, chart key, param name
	categories = {
		usage = "%s chart usages for %s",	-- type, chartkey
		named_ref = "%s chart making named ref",
		manual_ref = "%s chart using manual ref mode",
		defunct = "%s chart used with defunct chart",
		unknown_chart = "%s chart used with unknown chart",
		missing_params = "%s chart used with missing parameters",
		unknown_params = "Pages using %s chart with unknown parameters",
		unused_params = "%s chart with unused parameters",
		manual_missing_url_title = "%s chart with manual mode missing url or title",
		without_artist = "%s chart called without artist",
		without_song = "%s chart called without song",
		without_album = "%s chart called without album", -- checks both album and dvd
		invalid_position = "%s chart with invalid position",
		track_param = "%s chart %s without %s parameter",	-- type, chartkey, param
		unsubstituted = "%s chart with unsubstituted parameters",
	},

	---------------------------------------------------------------------------
	-- CORE SETTINGS
	---------------------------------------------------------------------------

	-- Default chart type when not specified
	default_type = "single",

	-- Path to JSON data files
	-- %s is replaced with chart type: "single", "album", "year-end-single", "year-end-album"
	json_path = "Module:Music chart/%s.json",

	-- Category namespace prefix
	category_prefix = "Category:",

	-- Format for {dateMDY} placeholder
	-- %M = month name, %d = day number, %Y = 4-digit year
	-- Example: "%M %d, %Y" with date 2024-01-15 → "January 15, 2024"
	date_format_mdy = "%M %d, %Y",

	-- Prefixes for auto-generated reference names (refname)
	-- Result: prefix_chartkey_artist (e.g., "sc_Australia_Beyoncé")
	ref_prefixes = {
		single = "sc",	-- Single chart
		album = "ac",	-- Album chart
		["year-end-single"] = "ye",
		["year-end-album"] = "ye",
	},

	-- Where to display errors: "both" | "cell" | "ref"
	error_display = "both",

	-- Check for unknown parameters (not in CONFIG.params)
	check_unknown_params = true,

	-- Check for unused parameters (provided but not used by chart)
	check_unused_params = true,

	-- Optional wrapper for dates in references (access-date, publish-date, archive-date)
	-- nil = output dates as-is
	-- Use %s for date value (two %s if wrapper needs fallback)
	-- Examples:
	-- Optional template to reformat dates in references (access-date, archive-date).
	-- #time parses most date formats automatically (2025-01-15, January 15, 2025, 15.01.2025, etc.)
	-- #iferror returns original value if parsing fails (e.g., invalid or unusual format)
	-- Examples:
	--	"{{#iferror:{{#time:F j, Y|%s}}|%s}}" → "January 15, 2025" (MDY)
	--	"{{#iferror:{{#time:j F Y|%s}}|%s}}" → "15 January 2025" (DMY)
	--	nil → output date as-is without reformatting
	date_wrapper = nil,

	-- Date validation patterns (Lua patterns for each format)
	date_patterns = {
		["DD-MM-YYYY"] = "^%d%d%-%d%d%-%d%d%d%d$",
		["DD.MM.YYYY"] = "^%d%d%.%d%d%.%d%d%d%d$",
		["DD/MM/YYYY"] = "^%d%d/%d%d/%d%d%d%d$",
		["MM-DD-YYYY"] = "^%d%d%-%d%d%-%d%d%d%d$",
		["YYMMDD"] = "^%d%d%d%d%d%d$",
		["YYYY-MM-DD"] = "^%d%d%d%d%-%d%d%-%d%d$",
		["YYYYMMDD"] = "^%d%d%d%d%d%d%d%d$",
		["DD.MM.YYYY–DD.MM.YYYY"] = "^%d%d%.%d%d%.%d%d%d%d[–%-~]%d%d%.%d%d%.%d%d%d%d$",
		["YYYY.MM.DD–YYYY.MM.DD"] = "^%d%d%d%d%.%d%d%.%d%d[–%-~]%d%d%d%d%.%d%d%.%d%d$",
		["YYYYMMDD-YYYYMMDD"] = "^%d%d%d%d%d%d%d%d[%-~]%d%d%d%d%d%d%d%d$",
	},

	-- Parameter aliases (canonical → alternatives)
	param_aliases = {
		["access-date"] = {"accessdate"},
		["publish-date"] = {"publishdate"},
		["archive-url"] = {"archiveurl"},
		["archive-date"] = {"archivedate"},
	},

	-- Parameter groups for validation
	-- base: always allowed | content: chart-specific | manual: only with 3=M
	params = {
		base = {
			[1] = true, [2] = true, [3] = true,
			chart = true, type = true, position = true,
			refname = true, refgroup = true, rowheader = true,
			note = true,
			artist = true, song = true, album = true, dvd = true,
			["access-date"] = true, ["publish-date"] = true,
			["archive-url"] = true, ["archive-date"] = true,
		},
		content = {
			date = true, year = true, week = true,
			startdate = true, enddate = true,
			artistid = true, songid = true, chartid = true, id = true,
			page = true,
			url = true, title = true,
		},
		manual = { work = true, location = true, publisher = true, ["url-status"] = true },
	},

	---------------------------------------------------------------------------
	-- OUTPUT FORMATS
	---------------------------------------------------------------------------

	-- Cell prefixes
	cell_normal = "| ",
	cell_header = '!scope="row"| ',

	-- Position cell style
	position_style = 'style="text-align:center;"|',

	-- Note format
	note_format = "<br>''<small>%s</small>''",

	---------------------------------------------------------------------------
	-- SHOW CHARTS SETTINGS (affect only showCharts output)
	---------------------------------------------------------------------------

	-- Sort order for group (countries) and chart IDs: "abc" (alphabetical) or "keep" (JSON order)
	sort_order = "abc",

	-- Optional wrapper for Group column (country/region names)
	-- nil = output as-is
	-- Use %s for group name
	-- Examples:
	--	"{{Country|%s}}" → wraps in Country template
	--	"{{Flagicon|%s}} %s" → adds flag icon (two %s: for flag and name)
	group_wrapper = "{{Country|%s}}",
}

--=============================================================================
-- SECTION 2: UTILITY FUNCTIONS
--=============================================================================

-- Get type display name
local function getTypeName(chartType)
	return CONFIG.type_names[chartType] or chartType
end

-- Build category link
local function catLink(pattern, ...)
	return string.format("[[" .. CONFIG.category_prefix .. pattern .. "]]", ...)
end

-- Build category link with sort key
local function catLinkSort(pattern, sortKey, ...)
	local catName = string.format(pattern, ...)
	return string.format("[[%s%s|%s]]", CONFIG.category_prefix, catName, sortKey)
end

-- Check if arg has non-empty value
local function hasArg(args, key)
	return args[key] and args[key] ~= ""
end

-- Helper to set error messages based on CONFIG.error_display
local function setErrorDisplay(errMsg, currentInline, currentRef)
	local inline = currentInline or ""
	local inRef = currentRef or ""
	if CONFIG.error_display == "both" or CONFIG.error_display == "cell" then
		inline = inline .. errMsg
	end
	if CONFIG.error_display == "both" or CONFIG.error_display == "ref" then
		inRef = inRef .. errMsg
	end
	return inline, inRef
end

--=============================================================================
-- SECTION 3: URL ENCODERS
-- Encode parameter values for URLs. Selected via "encode" field in JSON.
-- Encoding applies only to text params: artist, song, album, dvd
--
-- Operations (in "encode" array):
--   normalize    - remove diacritics (é→e, ñ→n)
--   ansi         - Latin-1 encoding (é→%E9 instead of UTF-8 %C3%A9)
--   lower        - lowercase
--   clean-symbols - remove special chars, keep alphanumeric and dash
--   space-plus   - space → + (default if no encode specified), full URL encoding
--   space-dash   - space → -, encode only non-ASCII (preserves $, ', etc.)
--   space-url    - space → %20 (standard URL encoding)
--
-- Order in array doesn't matter - applied in fixed order:
-- normalize → lower → clean-symbols → space replacement → URL encoding
--
-- "encode" can be set at chart level or in multiple entries (entry overrides chart)
--=============================================================================

local Encoders = {}

-- Remove diacritics using Unicode normalization (NFD decomposition)
-- é → e + combining accent → remove combining → e
local function removeDiacritics(s)
	-- NFD decomposes: é → e + ́ (combining acute)
	local decomposed = mw.ustring.toNFD(s)
	-- Remove combining diacritical marks (U+0300–U+036F)
	return mw.ustring.gsub(decomposed, "[\204\128-\205\175]", "")
end

-- Parse encode config (array or nil) into flags
local function parseEncodeConfig(config)
	local flags = { ansi = false, normalize = false, lower = false, cleanSymbols = false, space = "+", spaceUrl = false }
	if not config then return flags end
	if type(config) == "string" then config = {config} end
	for _, op in ipairs(config) do
		if op == "ansi" then flags.ansi = true
		elseif op == "normalize" then flags.normalize = true
		elseif op == "lower" then flags.lower = true
		elseif op == "clean-symbols" then flags.cleanSymbols = true
		elseif op == "space-plus" then flags.space = "+"
		elseif op == "space-dash" then flags.space = "-"
		elseif op == "space-url" then flags.spaceUrl = true
		end
	end
	return flags
end

-- Main encode function
function Encoders.encode(s, config)
	if not s then return "" end
	local flags = parseEncodeConfig(config)

	-- 1. Remove diacritics (é→e, ñ→n)
	if flags.normalize then
		s = removeDiacritics(s)
	end

	-- 2. Lowercase
	if flags.lower then
		s = string.lower(s)
	end

	-- 3. Clean (remove special chars, keep alphanumeric and dash)
	if flags.cleanSymbols then
		s = string.gsub(s, " ", "-")
		s = string.gsub(s, "(%d)[^%w%-](%d)", "%1-%2")  -- 2.0 → 2-0
		s = string.gsub(s, "[^%w%-]", "")
		return s  -- clean mode doesn't need further encoding
	end

	-- 4. Handle & before space replacement
	s = string.gsub(s, "&", "%%26")

	-- 5. Space replacement + encoding
	if flags.spaceUrl then
		-- Standard URL encoding (space → %20)
		return mw.uri.encode(s, "PATH")
	elseif flags.ansi then
		-- Latin-1 (ISO-8859-1) encoding
		local r = ""
		for i = 1, mw.ustring.len(s) do
			local k = mw.ustring.codepoint(s, i, i)
			if k == 32 then
				r = r .. flags.space
			elseif k <= 32 or k > 126 then
				if k > 255 then
					-- UTF-8 multi-byte (Chinese, etc.)
					local char = mw.ustring.sub(s, i, i)
					for j = 1, #char do
						r = r .. string.format("%%%02X", char:byte(j))
					end
				else
					-- Latin-1 single byte
					r = r .. string.format("%%%02X", k)
				end
			else
				r = r .. mw.ustring.sub(s, i, i)
			end
		end
		return r
	elseif flags.space == "-" then
		-- space-dash: replace spaces, encode only non-ASCII (preserve $, ', etc.)
		s = string.gsub(s, " ", "-")
		local r = ""
		for i = 1, mw.ustring.len(s) do
			local k = mw.ustring.codepoint(s, i, i)
			if k > 127 then
				-- Non-ASCII: UTF-8 encode
				local char = mw.ustring.sub(s, i, i)
				for j = 1, #char do
					r = r .. string.format("%%%02X", char:byte(j))
				end
			else
				r = r .. mw.ustring.sub(s, i, i)
			end
		end
		return r
	else
		-- Default: space-plus with full URL encoding
		s = mw.uri.encode(s, "PATH")
		s = string.gsub(s, "%%20", "+")
		return s
	end
end

-- Get encoder function for a config
function Encoders.get(config)
	return function(s) return Encoders.encode(s, config) end
end

--=============================================================================
-- SECTION 4: HELPER FUNCTIONS
-- Special chart-specific logic called via "helper" field in JSON.
-- Result available as {helper} placeholder in URL/title templates.
--
-- To add new helper:
-- 1. Add function Helpers.your_name(args) returning string
-- 2. Add required params to Helpers.params["your_name"]
-- 3. Use in JSON: "helper": "your_name", in URL: "{helper}"
--=============================================================================

local Helpers = {}

-- Required params for each helper (won't be flagged as "unused")
Helpers.params = {
	south_africa_size = {"year", "week"},
	australia_issue = {"url"},
	bulgaria_date_range = {"url"},
	slovakia_period = {"year", "week"},
	germany_timestamp = {"date"},
	czech_week_id = {"year", "week"},
	israel_week = {"year", "week"},
}

-- Alternative charts to recommend when helper returns "use_new_chart"
Helpers.alternatives = {
	Slovakdigital = "Slovakdigital2",
	Slovakia = "Slovakia2",
}

-- [single: Southafrica2] South Africa chart size changed over time:
-- 2021-2022: 100 | 2023 w1-17: 100, w18+: 10 | 2024: 10
-- 2025 w1-12: 10, w13-37: 50, w38+: 20 | 2026+: 20
function Helpers.south_africa_size(args)
	local year, week = tonumber(args.year) or 0, tonumber(args.week) or 0
	if year < 2023 then return "100"
	elseif year == 2023 then return week < 18 and "100" or "10"
	elseif year == 2024 then return "10"
	elseif year == 2025 then
		if week < 13 then return "10"
		elseif week < 38 then return "50"
		else return "20" end
	else return "20" end
end

-- [single: Australiadance, Australiapandora, Australiaurban]
-- Extract issue number from pandora.nla.gov.au URL
-- Matches "Issue+123" or "issue%20456" → "123" or "456"
function Helpers.australia_issue(args)
	local url = args.url or ""
	return string.match(url, "[IiSsUuEe]+[+%%20]*(%d+)") or ""
end

-- [single: Bulgaria] Extract and format date range from bamp-bg.org URL
-- URL: ...01012024-07012024.html → "01.01.2024 – 07.01.2024"
function Helpers.bulgaria_date_range(args)
	local url = args.url or ""
	local d1, m1, y1, d2, m2, y2 = string.match(url, "(%d%d)(%d%d)(%d%d%d%d)%-(%d%d)(%d%d)(%d%d%d%d)%.html$")
	if d1 then return string.format("%s.%s.%s – %s.%s.%s", d1, m1, y1, d2, m2, y2) end
	d1, m1, y1, d2, m2, y2 = string.match(url, "(%d%d)(%d%d)(%d%d)%-(%d%d)(%d%d)(%d%d)%.html$")
	if d1 then return string.format("%s.%s.20%s – %s.%s.20%s", d1, m1, y1, d2, m2, y2) end
	d1, m1, d2, m2, y2 = string.match(url, "%-(%d%d)(%d%d)%-(%d%d)(%d%d)(%d%d%d%d)%.html$")
	if d1 then return string.format("%s.%s.%s – %s.%s.%s", d1, m1, y2, d2, m2, y2) end
	return ""
end

-- [single: Slovakdigital, Slovakia] URL structure changed after week 34 of 2016
-- Archives: hitparadask.ifpicr.cz has 2006w35–2016w34, hitparada.ifpicr.cz has 2016w43+
-- Gap: weeks 35-42 of 2016 have no archives
-- For 2016w43+: recommend using Slovakdigital2/Slovakia2 instead
-- Returns: "before2016w34", "after2016w34", or "use_new_chart"
function Helpers.slovakia_period(args)
	local year, week = tonumber(args.year) or 0, tonumber(args.week) or 0
	local yw = year * 100 + week
	if yw <= 201634 then return "before2016w34"
	elseif yw >= 201643 then return "use_new_chart"
	else return "after2016w34" end  -- 201635-201642: gap period, use after2016w34 URL
end

-- [album: GermanyComp] Convert DD.MM.YYYY to Unix timestamp (milliseconds)
-- Returns timestamp for Monday 12:00 UTC of that week
function Helpers.germany_timestamp(args)
	local date = args.date or ""
	local d, m, y = string.match(date, "^(%d%d)%.(%d%d)%.(%d%d%d%d)$")
	if not d then return "" end
	d, m, y = tonumber(d), tonumber(m), tonumber(y)

	local function isLeapYear(yr)
		return yr % 4 == 0 and (yr % 100 ~= 0 or yr % 400 == 0)
	end
	local daysInMonth = {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}
	local days = 0
	for yr = 1970, y - 1 do days = days + (isLeapYear(yr) and 366 or 365) end
	if isLeapYear(y) then daysInMonth[2] = 29 end
	for mo = 1, m - 1 do days = days + daysInMonth[mo] end
	days = days + d - 1
	local dow = (days + 3) % 7		-- 0=Mon, 6=Sun
	days = days - dow				-- go back to Monday
	return string.format("%.0f", (days * 86400 + 12 * 3600) * 1000)
end

-- Czech/Slovak week IDs cache
local czechWeekIds = nil
local czechMaxKey, czechMaxId = nil, nil

-- [single: Czech Republic, Czechdigital, Slovakia2, Slovakdigital2; album: Czech, Slovakia]
-- Returns weekId for ifpicr.cz URL parameter
-- Data from Module:Music chart/chartdata-czech.json, future weeks calculated from newest entry
-- Supports combined weeks like "51+52" - uses first week's ID
function Helpers.czech_week_id(args)
	-- Handle combined weeks like "51+52" - extract first number
	local year = tonumber(args.year) or 0
	local week = tonumber(string.match(args.week or "", "^%d+")) or 0
	if year == 0 or week == 0 then return "" end
	
	-- Load week IDs table on first use
	if not czechWeekIds then
		czechWeekIds = mw.loadJsonData("Module:Music chart/chartdata-czech.json")
		-- Find the newest week (highest key) and its ID
		czechMaxKey, czechMaxId = 0, 0
		for k, v in pairs(czechWeekIds) do
			-- Parse "YYYY-WW" format
			local y, w = string.match(k, "^(%d+)-(%d+)$")
			if y then
				local numKey = tonumber(y) * 100 + tonumber(w)
				if numKey > czechMaxKey then
					czechMaxKey = numKey
					czechMaxId = v
				end
			end
		end
	end
	
	-- Format key as YYYY-WW (string with dash to ensure it stays a string in JSON)
	local key = string.format("%d-%02d", year, week)
	
	-- Check if in table
	if czechWeekIds[key] then
		return tostring(czechWeekIds[key])
	end
	
	-- For future weeks: calculate from last known
	local currentYw = year * 100 + week
	
	if currentYw > czechMaxKey then
		local lastYear, lastW = math.floor(czechMaxKey / 100), czechMaxKey % 100
		-- Weeks difference, accounting for 51+52 sharing same ID each year
		local fullYears = year - lastYear - 1
		local weeksDiff = (52 - lastW) + (fullYears * 51) + week
		if week >= 52 then weeksDiff = weeksDiff - 1 end  -- current year's 51+52
		return tostring(czechMaxId + weeksDiff)
	end
	
	return ""
end

-- [single: Israel] Returns "WW DD-MM-YY DD-MM-YY" for Media Forest URL
-- Week 1 starts on Sunday closest to Jan 1 (prev Sunday unless prev year had 53 weeks and Jan 1 is Fri)
function Helpers.israel_week(args)
	local year, week = tonumber(args.year) or 0, tonumber(args.week) or 0
	if year == 0 or week == 0 then return "" end
	
	local function toDays(y, m, d)  -- days since Jan 1, 2000
		local days = (y - 2000) * 365 + math.floor((y - 1997) / 4) - math.floor((y - 1901) / 100) + math.floor((y - 1601) / 400)
		local mdays = {0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334}
		days = days + mdays[m] + d - 1
		if m > 2 and (y % 4 == 0 and (y % 100 ~= 0 or y % 400 == 0)) then days = days + 1 end
		return days
	end
	
	local function toDate(days)  -- days to DD-MM-YY
		local y, m, d = 2000, 1, days + 1
		while true do
			local ydays = (y % 4 == 0 and (y % 100 ~= 0 or y % 400 == 0)) and 366 or 365
			if d <= ydays then break end
			d, y = d - ydays, y + 1
		end
		local mdays = {31, (y % 4 == 0 and (y % 100 ~= 0 or y % 400 == 0)) and 29 or 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}
		for i = 1, 12 do if d <= mdays[i] then m = i; break end; d = d - mdays[i] end
		return string.format("%02d-%02d-%02d", d, m, y % 100)
	end
	
	local jan1 = toDays(year, 1, 1)
	local dow = (jan1 + 6) % 7  -- 0=Sun
	local prevDow = (toDays(year - 1, 1, 1) + 6) % 7
	local week1Sun = (dow == 0) and jan1 or ((prevDow == 4 and dow == 5) and (jan1 + 7 - dow) or (jan1 - dow))
	local sun = week1Sun + (week - 1) * 7
	return string.format("%02d%%20%s%%20%s", week, toDate(sun), toDate(sun + 6))
end

-- Call helper by name (safe wrapper)
function Helpers.call(name, args)
	return name and Helpers[name] and Helpers[name](args) or ""
end

-- Get required params for helper
function Helpers.getRequiredParams(helperName)
	return helperName and Helpers.params[helperName] or {}
end

--=============================================================================
-- SECTION 5: DATE UTILITIES
-- Parse and format dates. Computed placeholders: {dateDigits}, {dateMDY},
-- {dateDMY}, {dateYMD}, {dateYear}
--=============================================================================

local DateUtils = {}

function DateUtils.digitsOnly(s)
	return s and string.gsub(s, "[%-%.%/]", "") or ""
end

function DateUtils.formatMDY(y, m, d)
	local month = CONFIG.months[tonumber(m)] or m
	return CONFIG.date_format_mdy:gsub("%%M", month):gsub("%%d", tonumber(d)):gsub("%%Y", y)
end

function DateUtils.wrap(date)
	if not date or date == "" then return nil end
	if CONFIG.date_wrapper then return string.format(CONFIG.date_wrapper, date, date) end
	return date
end

-- Parse date string into y, m, d components
function DateUtils.parse(date, chart)
	if not date then return nil end
	local y, m, d

	-- YYYY-MM-DD
	y, m, d = string.match(date, "^(%d%d%d%d)%-(%d%d)%-(%d%d)$")
	if y then return y, m, d end

	-- YYYYMMDD
	y, m, d = string.match(date, "^(%d%d%d%d)(%d%d)(%d%d)$")
	if y then return y, m, d end

	-- YYMMDD
	local yy, mm, dd = string.match(date, "^(%d%d)(%d%d)(%d%d)$")
	if yy then return "20" .. yy, mm, dd end

	-- DD-MM-YYYY or DD.MM.YYYY or DD/MM/YYYY
	d, m, y = string.match(date, "^(%d%d)[%-%.%/](%d%d)[%-%.%/](%d%d%d%d)$")
	if d then
		local mid = tonumber(m)
		local isMMDD = (chart and chart.date_format and string.match(chart.date_format, "^MM")) or (mid and mid > 12)
		if isMMDD then return y, d, m end  -- swap d and m
		return y, m, d
	end

	return nil
end

-- Parse date string and compute all format variants
function DateUtils.compute(date, chart)
	if not date then return {} end
	local vals = { dateDigits = DateUtils.digitsOnly(date) }

	local y, m, d = DateUtils.parse(date, chart)
	if not y then return vals end

	vals.dateYear = y
	vals.dateMDY = DateUtils.formatMDY(y, m, d)           -- January 15, 2024
	vals.dateDMY = string.format("%s.%s.%s", d, m, y)     -- 15.01.2024
	vals.dateYMD = string.format("%s-%s-%s", y, m, d)     -- 2024-01-15
	vals.dateSlash = string.format("%d/%d/%s", tonumber(d), tonumber(m), y)  -- 15/1/2024

	return vals
end

-- Validate date against chart's expected format
function DateUtils.validate(date, chart, entry)
	local dateFormat = (entry and entry.date_format) or chart.date_format
	local dateFormatAlt = (entry and entry.date_format_alt) or chart.date_format_alt
	if not dateFormat or not date then return true, nil end
	local pat = CONFIG.date_patterns[dateFormat]
	local patAlt = dateFormatAlt and CONFIG.date_patterns[dateFormatAlt]
	if (pat and mw.ustring.match(date, pat)) or (patAlt and mw.ustring.match(date, patAlt)) then return true, nil end
	local formats = dateFormatAlt and (dateFormat .. " or " .. dateFormatAlt) or dateFormat
	return false, formats
end

-- Validate year format (must be exactly 4 digits)
function DateUtils.validateYear(year)
	if not year or year == "" then return true, nil end
	if mw.ustring.match(year, "^%d%d%d%d$") then return true, nil end
	return false, year
end

-- Validate week format (must be 1-2 digits, or special format like "51+52")
function DateUtils.validateWeek(week)
	if not week or week == "" then return true, nil end
	-- Allow 1-2 digits (1-52)
	if mw.ustring.match(week, "^%d%d?$") then return true, nil end
	-- Allow combined weeks like "51+52"
	if mw.ustring.match(week, "^%d%d?%+%d%d?$") then return true, nil end
	return false, week
end

--=============================================================================
-- SECTION 6: TEMPLATE SUBSTITUTION
-- Replace {placeholder} patterns with values. Handles URL encoding.
-- Encoding applies only to text params: artist, song, album, dvd
--=============================================================================

local Template = {}

-- Replace literal string (not pattern)
function Template.safeReplace(str, search, repl)
	local pos = string.find(str, search, 1, true)
	while pos do
		str = string.sub(str, 1, pos - 1) .. repl .. string.sub(str, pos + #search)
		pos = string.find(str, search, pos + #repl, true)
	end
	return str
end

-- Build reverse alias map: alias -> canonical
local aliasToCanonical = {}
for canonical, aliases in pairs(CONFIG.param_aliases) do
	for _, alias in ipairs(aliases) do aliasToCanonical[alias] = canonical end
end

-- Text parameters that need URL encoding
local textParams = { artist = true, song = true, album = true, dvd = true }

-- Substitute all {placeholders} in template
-- encodeConfig: array of operations from chart.encode or entry.encode
function Template.substitute(template, args, chart, encodeConfig)
	if not template then return "" end
	local result = template

	-- Computed date placeholders
	for k, v in pairs(DateUtils.compute(args.date, chart)) do
		result = Template.safeReplace(result, "{" .. k .. "}", v)
	end

	-- Helper placeholder
	if chart and chart.helper then
		result = Template.safeReplace(result, "{helper}", Helpers.call(chart.helper, args))
	end

	local dateParams = {archivedate = true, ["archive-date"] = true, accessdate = true, ["access-date"] = true}

	-- Substitute a single placeholder
	local function subst(name, rawValue)
		local placeholder = "{" .. name .. "}"
		if not string.find(result, placeholder, 1, true) then return end

		local encoded
		if name == "url" and string.match(rawValue, "^https?://") then
			encoded = rawValue  -- URL as-is
		elseif dateParams[name] then
			encoded = DateUtils.wrap(rawValue) or rawValue  -- Date with optional wrapper
		elseif textParams[name] and type(encodeConfig) == "table" then
			encoded = Encoders.encode(rawValue, encodeConfig)  -- Text params encoded only when table passed
		else
			encoded = rawValue  -- Everything else as-is
		end
		result = Template.safeReplace(result, placeholder, encoded)
	end

	-- Process all args
	for key, value in pairs(args) do
		if type(value) == "string" and value ~= "" then
			local k = tostring(key)
			subst(k, value)
			-- Substitute aliases
			if CONFIG.param_aliases[k] then
				for _, alias in ipairs(CONFIG.param_aliases[k]) do subst(alias, value) end
			elseif aliasToCanonical[k] then
				subst(aliasToCanonical[k], value)
			end
		end
	end
	return result
end


--=============================================================================
-- SECTION 7: PARAMETER UTILITIES
-- Extract params from templates, check if known/unused/missing.
--=============================================================================

local Params = {}

-- Computed placeholders map to source param
Params.computed = {
	dateDigits = "date", dateDMY = "date", dateYMD = "date", dateMDY = "date",
	dateSlash = "date", dateYear = "date", helper = true
}

-- Extract param name from placeholder (handles computed params)
local function resolveParam(placeholder)
	local source = Params.computed[placeholder]
	if source == true then return nil end  -- helper-computed, skip
	return source or placeholder
end

-- Add params from template string to list (preserving order) and seen set
function Params.addFromTemplateOrdered(list, seen, str)
	if not str then return end
	for placeholder in string.gmatch(str, "{([%w%-]+)}") do
		local param = resolveParam(placeholder)
		if param and not seen[param] then
			table.insert(list, param)
			seen[param] = true
		end
	end
end

-- Extract {placeholder} names from template string (returns set)
function Params.extractFromTemplate(str)
	local params = {}
	if not str then return params end
	for placeholder in string.gmatch(str, "{([%w%-]+)}") do
		local param = resolveParam(placeholder)
		if param then params[param] = true end
	end
	return params
end

function Params.isKnown(param)
	if CONFIG.params.base[param] or CONFIG.params.content[param] or CONFIG.params.manual[param] then
		return true
	end
	-- Check if param is a canonical name with aliases
	if CONFIG.param_aliases[param] then return true end
	-- Check if param is an alias
	for _, aliasList in pairs(CONFIG.param_aliases) do
		for _, alias in ipairs(aliasList) do
			if alias == param then return true end
		end
	end
	return false
end

function Params.getValue(args, param)
	if hasArg(args, param) then return args[param] end
	-- If param is canonical, check its aliases
	if CONFIG.param_aliases[param] then
		for _, alt in ipairs(CONFIG.param_aliases[param]) do
			if hasArg(args, alt) then return args[alt] end
		end
	end
	-- If param is an alias, check canonical name
	local canonical = aliasToCanonical[param]
	if canonical and hasArg(args, canonical) then return args[canonical] end
	return nil
end

function Params.hasValue(args, param)
	return Params.getValue(args, param) ~= nil
end

-- Add params from template string to a set
function Params.addFromTemplate(set, str)
	for k in pairs(Params.extractFromTemplate(str)) do set[k] = true end
end

-- Collect all params used by chart definition
function Params.collectFromChart(chart)
	local used = {}
	Params.addFromTemplate(used, chart.url)
	Params.addFromTemplate(used, chart.url_title)
	Params.addFromTemplate(used, chart.ref)
	Params.addFromTemplate(used, chart.ref_note)
	if chart.multiple then
		for _, entry in ipairs(chart.multiple) do
			Params.addFromTemplate(used, entry.url)
			Params.addFromTemplate(used, entry.url_title)
			Params.addFromTemplate(used, entry.ref)
			Params.addFromTemplate(used, entry.ref_note)
			if entry.when then
				for param in string.gmatch(entry.when, "!?([%w_%-]+)") do
					if param ~= "helper" and not tonumber(param) then used[param] = true end
				end
			end
		end
	end
	for _, param in ipairs(Helpers.getRequiredParams(chart.helper)) do used[param] = true end
	return used
end

-- Find unknown params (not in CONFIG)
function Params.checkUnknown(allKeys, args)
	local unknown = {}
	for param in pairs(allKeys) do if not Params.isKnown(param) then table.insert(unknown, tostring(param)) end end
	if args[3] and args[3] ~= "M" then table.insert(unknown, "3=" .. tostring(args[3])) end
	table.sort(unknown)
	return #unknown > 0 and table.concat(unknown, ", ") or nil
end

-- Find unused params (provided but not used by chart)
function Params.checkUnused(args, chart)
	local usedByChart = Params.collectFromChart(chart)
	local isManualRef = args[3] == "M"
	local unused = {}
	for param in pairs(args) do
		if CONFIG.params.content[param] and not usedByChart[param] then table.insert(unused, tostring(param)) end
		if CONFIG.params.manual[param] and not isManualRef then table.insert(unused, tostring(param)) end
	end
	table.sort(unused)
	return #unused > 0 and table.concat(unused, ", ") or nil
end

-- Validate position value
function Params.validatePosition(position)
	if not position or position == "" then return false, "empty" end
	for _, dash in ipairs(CONFIG.accepted_dashes) do
		if position == dash then return true end
	end
	local num = tonumber(position)
	if num and num >= 1 and num <= CONFIG.max_position and num == math.floor(num) then
		return true
	end
	return false, position
end

--=============================================================================
-- SECTION 8: MISSING PARAMS CHECKER
--=============================================================================

local MissingChecker = {}

function MissingChecker.formatParamSet(paramSet)
	local list = {}
	for param in pairs(paramSet) do table.insert(list, param) end
	table.sort(list)
	return table.concat(list, "+")
end

function MissingChecker.getMissing(paramSet, args)
	local missing = {}
	for param in pairs(paramSet) do if not Params.hasValue(args, param) then table.insert(missing, param) end end
	table.sort(missing)
	return missing
end

function MissingChecker.hasAll(paramSet, args)
	for param in pairs(paramSet) do if not Params.hasValue(args, param) then return false end end
	return true
end

function MissingChecker.collectRequired(url, title, ref, chart)
	local required = {}
	if type(url) == "table" then
		for _, entry in ipairs(url) do
			Params.addFromTemplate(required, entry.url)
			Params.addFromTemplate(required, entry.url_title)
		end
	else
		Params.addFromTemplate(required, url)
		Params.addFromTemplate(required, title)
	end
	Params.addFromTemplate(required, ref)
	Params.addFromTemplate(required, chart.ref_note)
	for _, param in ipairs(Helpers.getRequiredParams(chart.helper)) do required[param] = true end
	return required
end

-- Check if v1 is dominated by v2 (v2 is subset of v1 and v1 has more params)
local function isDominated(v1, v2)
	for k in pairs(v2) do
		if not v1[k] then return false end
	end
	for k in pairs(v1) do
		if not v2[k] then return true end
	end
	return false
end

-- Check missing for charts with multiple URL variants
function MissingChecker.checkMultiple(chart, args, ref)
	local refParams = {}
	Params.addFromTemplate(refParams, ref)
	Params.addFromTemplate(refParams, chart.ref_note)

	local helperReq = Helpers.getRequiredParams(chart.helper)

	-- Build list of param sets for each variant
	local variants = {}
	for _, entry in ipairs(chart.multiple) do
		local variantParams = {}
		Params.addFromTemplate(variantParams, entry.url or chart.url)
		Params.addFromTemplate(variantParams, entry.url_title or chart.url_title)
		Params.addFromTemplate(variantParams, entry.ref_note)
		for k in pairs(refParams) do variantParams[k] = true end
		for _, param in ipairs(helperReq) do variantParams[param] = true end
		table.insert(variants, variantParams)
	end

	-- Check if any variant is fully satisfied
	for _, variantParams in ipairs(variants) do
		if MissingChecker.hasAll(variantParams, args) then return nil, variantParams end
	end

	-- Extract unique URL-only param sets (excluding ref params)
	local uniqueVariants, seen = {}, {}
	for _, variantParams in ipairs(variants) do
		local urlOnly = {}
		for k in pairs(variantParams) do
			if not refParams[k] then urlOnly[k] = true end
		end
		local key = MissingChecker.formatParamSet(urlOnly)
		if key ~= "" and not seen[key] then
			seen[key] = true
			table.insert(uniqueVariants, urlOnly)
		end
	end

	-- Multiple unique variants: filter out dominated ones and show options
	if #uniqueVariants > 1 then
		local dominated = {}
		for i, v1 in ipairs(uniqueVariants) do
			for j, v2 in ipairs(uniqueVariants) do
				if i ~= j and isDominated(v1, v2) then
					dominated[i] = true
					break
				end
			end
		end
		local options = {}
		for i, v in ipairs(uniqueVariants) do
			if not dominated[i] then
				table.insert(options, MissingChecker.formatParamSet(v))
			end
		end
		if #options == 1 then return options[1], refParams end
		return table.concat(options, " or "), refParams
	end

	-- Single unique variant: show missing params
	if #uniqueVariants == 1 then
		local allRequired = {}
		for k in pairs(uniqueVariants[1]) do allRequired[k] = true end
		for k in pairs(refParams) do allRequired[k] = true end
		local missing = MissingChecker.getMissing(allRequired, args)
		return #missing > 0 and table.concat(missing, ", ") or nil, uniqueVariants[1]
	end

	-- No URL params, only ref params
	local missingRef = MissingChecker.getMissing(refParams, args)
	return #missingRef > 0 and table.concat(missingRef, ", ") or nil, refParams
end

function MissingChecker.check(chart, args, url, title, ref)
	if chart.multiple then return MissingChecker.checkMultiple(chart, args, ref) end
	local required = MissingChecker.collectRequired(url, title, ref, chart)
	local missing = MissingChecker.getMissing(required, args)
	return #missing > 0 and table.concat(missing, ", ") or nil, required
end

--=============================================================================
-- SECTION 9: ENTRY SELECTION
--=============================================================================

local EntrySelector = {}

-- Comparison operators lookup table
local COMPARISON_OPS = {
	["<="] = function(a, b) return a <= b end,
	[">="] = function(a, b) return a >= b end,
	["<"] = function(a, b) return a < b end,
	[">"] = function(a, b) return a > b end,
}

-- Check single condition
local function checkSingleCondition(cond, args)
	cond = mw.text.trim(cond)

	-- Negation check: !param
	if string.sub(cond, 1, 1) == "!" then
		local param = string.sub(cond, 2)
		return not Params.getValue(args, param)
	end

	-- Try comparison operators in order of pattern length
	local param, op, val
	for _, operator in ipairs({"<=", ">=", "<", ">"}) do
		param, op, val = string.match(cond, "^([%w_%-]+)(" .. operator .. ")(.+)$")
		if param then break end
	end

	if param and op and val then
		local argVal
		local paramValue = Params.getValue(args, param)
		if (param == "date" or param == "archivedate" or param == "archive-date") and paramValue then
			argVal = tonumber(string.sub(paramValue, 1, 4)) or tonumber(string.match(paramValue, "(%d%d%d%d)")) or 0
		else
			argVal = tonumber(paramValue) or 0
		end
		return COMPARISON_OPS[op](argVal, tonumber(val) or 0)
	end

	-- Equality check: param=value
	local key, eqVal = string.match(cond, "^([^=]+)=(.+)$")
	if key and eqVal then return Params.getValue(args, key) == eqVal end

	-- Existence check: param (non-empty)
	return Params.getValue(args, cond) ~= nil
end

-- Check if "when" condition matches args
function EntrySelector.matchesCondition(when, args, helperValue)
	if not when or when == "" then return true end
	local helperVal = string.match(when, "^helper=(.+)$")
	if helperVal then return helperValue == helperVal end

	if string.find(when, "|", 1, true) then
		for param in string.gmatch(when, "([^|]+)") do
			if checkSingleCondition(param, args) then return true end
		end
		return false
	end

	for param in string.gmatch(when, "([^,]+)") do
		if not checkSingleCondition(param, args) then return false end
	end
	return true
end

-- Select URL entry from chart.multiple based on args
-- Returns table with: url, url_title, ref, lang, provider, chart, entry, encode, helperValue
function EntrySelector.select(chart, args)
	-- Default result from chart base values
	local result = {
		url = chart.url,
		url_title = chart.url_title,
		ref = chart.ref,
		lang = chart.lang,
		provider = chart.provider,
		chart = chart.chart,
		encode = chart.encode,
		entry = nil,
		helperValue = nil
	}

	if not chart.multiple then return result end

	local helperValue = chart.helper and Helpers.call(chart.helper, args) or nil
	result.helperValue = helperValue

	-- Helper to apply entry overrides with fallback to chart defaults
	local function applyEntry(entry)
		result.url = entry.url or chart.url
		result.url_title = entry.url_title or chart.url_title
		result.ref = entry.ref or chart.ref
		result.lang = entry.lang or chart.lang
		result.provider = entry.provider or chart.provider
		result.chart = entry.chart or chart.chart
		result.encode = entry.encode or chart.encode
		result.entry = entry
	end

	if chart.combine then
		local entries = {}
		for _, entry in ipairs(chart.multiple) do
			if EntrySelector.matchesCondition(entry.when, args, helperValue) then
				table.insert(entries, {
					url = entry.url or chart.url,
					url_title = entry.url_title or chart.url_title,
					ref = entry.ref,
					ref_note = entry.ref_note,
					lang = entry.lang,
					encode = entry.encode or chart.encode
				})
			end
		end
		if #entries > 0 then
			result.url = entries
			result.url_title = nil
		end
	else
		for _, entry in ipairs(chart.multiple) do
			if EntrySelector.matchesCondition(entry.when, args, helperValue) then
				applyEntry(entry)
				break
			end
		end
	end

	return result
end


--=============================================================================
-- SECTION 10: OUTPUT BUILDERS
--=============================================================================

local Builder = {}

-- Build wikitext link from URL and title
-- encodeConfig: table of operations for text params (empty {} = default space-plus), nil = no encoding
function Builder.link(url, title, args, chart, encodeConfig)
	if not url or url == "" then return "" end
	if string.sub(url, 1, 1) == "[" then return '"' .. Template.substitute(url, args, chart, nil) .. '"' end
	local encodedUrl = Template.substitute(url, args, chart, encodeConfig or {})
	local linkTitle = Template.substitute(title or "", args, chart, nil)
	return linkTitle ~= "" and ('"[' .. encodedUrl .. " " .. linkTitle .. ']"') or ("[" .. encodedUrl .. "]")
end

-- Build chart display name with provider
function Builder.chartName(chartName, provider, chartKey)
	local name = chartName or chartKey
	return provider and provider ~= "" and (name .. " (" .. provider .. ")") or name
end

-- Build reference name
function Builder.refName(chartType, chartKey, args, chart, selectedEntry)
	if args.refname then return args.refname end
	local prefix = CONFIG.ref_prefixes[chartType] or "sc"
	local suffix = string.match(chartType, "^year%-end") and (args.year or "") or (args.artist or "")
	local defaultRefname = prefix .. "_" .. chartKey .. "_" .. suffix

	local format = (selectedEntry and selectedEntry.refname_format) or (chart and chart.refname_format)
	if not format then return defaultRefname end

	local refname = format
	for key, value in pairs(args) do
		if type(value) == "string" and value ~= "" then
			refname = refname:gsub("{" .. key .. "|[^}]*}", value):gsub("{" .. key .. "}", value)
		end
	end
	refname = refname:gsub("{[^}]+|([^}]*)}", "%1"):gsub("{[^}]+}", "")
	return #refname < 5 and defaultRefname or refname
end

-- Build reference content
function Builder.refContent(chart, args, urlResult, lang, refText, isYearEnd, selectedEntry)
	local accessDate = DateUtils.wrap(Params.getValue(args, "access-date"))
	local archiveDate = DateUtils.wrap(Params.getValue(args, "archive-date"))
	local pubDate = DateUtils.wrap(Params.getValue(args, "publish-date"))
	local archiveUrl = Params.getValue(args, "archive-url")
	local refNote = (selectedEntry and selectedEntry.ref_note) or chart.ref_note

	-- Combine mode: bullet list
	if type(urlResult) == "table" and chart.combine then
		local bullets, hasEntryRef = {}, false
		for _, entry in ipairs(urlResult) do
			local link = Builder.link(entry.url, entry.url_title, args, chart, entry.encode)
			if link ~= "" then
				local entryLang = entry.lang or lang
				local linkWithLang = entryLang and (link .. " " .. entryLang) or link
				local parts = {linkWithLang}
				if entry.ref then
					parts[#parts + 1] = entry.ref
					hasEntryRef = true
					if accessDate then parts[#parts + 1] = string.format(CONFIG.text.retrieved, accessDate) end
				end
				if entry.ref_note then parts[#parts + 1] = Template.substitute(entry.ref_note, args, chart, nil) end
				local line = table.concat(parts, ". ")
				if not line:match("%.$") then line = line .. "." end
				bullets[#bullets + 1] = "*" .. line
			end
		end
		local prefix = refNote and Template.substitute(refNote, args, chart, nil) or ""
		local result = prefix ~= "" and (prefix .. "\n" .. table.concat(bullets, "\n")) or table.concat(bullets, "\n")
		-- Add shared ref if no entry-level refs
		if not hasEntryRef and refText and refText ~= "" then
			local sharedRef = refText
			if accessDate then sharedRef = sharedRef .. ". " .. string.format(CONFIG.text.retrieved, accessDate) end
			if not sharedRef:match("%.$") then sharedRef = sharedRef .. "." end
			result = result .. "\n" .. sharedRef
		end
		return result
	end

	-- Standard format
	local parts = {}

	local urlLang = ""
	if type(urlResult) == "table" then
		for _, entry in ipairs(urlResult) do
			local link = Builder.link(entry.url, entry.url_title, args, chart)
			if link ~= "" then urlLang = urlLang .. (urlLang ~= "" and " " or "") .. link end
		end
	elseif urlResult and urlResult ~= "" then
		urlLang = urlResult
	end
	if lang then urlLang = urlLang .. " " .. lang end
	if urlLang ~= "" then table.insert(parts, urlLang) end

	if refText then table.insert(parts, refText) end
	if pubDate and isYearEnd then table.insert(parts, pubDate) end
	if archiveUrl and archiveDate and not (refText and string.find(refText, "Archived", 1, true)) then
		table.insert(parts, string.format(CONFIG.text.archived, archiveUrl, archiveDate))
	end
	if refNote then table.insert(parts, Template.substitute(refNote, args, chart, nil)) end

	local result = table.concat(parts, ". ")
	local skipDot = result:match("%.$") or result:match("{{cite Kent")
	if result ~= "" and not skipDot then result = result .. "." end
	if accessDate then result = result .. (result ~= "" and " " or "") .. string.format(CONFIG.text.retrieved, accessDate) end
	if chart.ref_suffix then result = result .. " " .. Template.substitute(chart.ref_suffix, args, chart, nil) end
	return result
end

-- Build URL result (with or without substitution based on missing params)
function Builder.urlResult(url, urlTitle, args, chart, hasMissing, encodeConfig)
	if type(url) == "table" then return url end
	if hasMissing then
		if not url or url == "" then return "" end
		if string.sub(url, 1, 1) == "[" then return '"' .. url .. '"' end
		return urlTitle and ('"[' .. url .. " " .. urlTitle .. ']"') or ("[" .. url .. "]")
	end
	return Builder.link(url, urlTitle, args, chart, encodeConfig)
end

-- Build note text
function Builder.noteText(args)
	return hasArg(args, "note") and string.format(CONFIG.note_format, args.note) or ""
end

-- Build ref tag
function Builder.refTag(frame, refContent, refname, refgroup)
	if not refContent or refContent == "" then return "" end
	local refAttrs = {name = refname}
	if refgroup and refgroup ~= "" then refAttrs.group = refgroup end
	local processed = string.find(refContent, "{{", 1, true) and frame:preprocess(refContent) or refContent
	return frame:extensionTag('ref', processed, refAttrs)
end

-- Build final output row
function Builder.outputRow(args, chartName, warnings, errorInline, refTag, noteText, position, cats)
	local cell = args.rowheader == "true" and CONFIG.cell_header or CONFIG.cell_normal
	return string.format('%s%s%s%s%s%s\n|%s%s%s',
		cell, chartName, warnings, errorInline, refTag, noteText,
		CONFIG.position_style, position, cats)
end

--=============================================================================
-- SECTION 11: CATEGORIES
--=============================================================================

local Categories = {}

function Categories.shouldCategorize()
	return mw.title.getCurrentTitle().namespace == 0
end

function Categories.build(chartType, chartKey, chart, args, position)
	if not Categories.shouldCategorize() then return "" end
	local t = getTypeName(chartType)
	local cats = {catLink(CONFIG.categories.usage, t, chartKey)}

	if chart.defunct then table.insert(cats, catLinkSort(CONFIG.categories.defunct, chartKey, t)) end
	if args[3] == "M" then table.insert(cats, catLinkSort(CONFIG.categories.manual_ref, chartKey, t)) end
	if not hasArg(args, "artist") then table.insert(cats, catLink(CONFIG.categories.without_artist, t)) end
	if string.match(chartType, "single") and not hasArg(args, "song") then
		table.insert(cats, catLink(CONFIG.categories.without_song, t))
	end
	if string.match(chartType, "album") and not hasArg(args, "album") and not hasArg(args, "dvd") then
		table.insert(cats, catLink(CONFIG.categories.without_album, t))
	end
	if hasArg(args, "refname") then table.insert(cats, catLink(CONFIG.categories.named_ref, t)) end

	-- Category conditions from chart definition
	if chart.category_conditions then
		local oldPosition = args.position
		args.position = position
		local helperValue = chart.helper and Helpers.call(chart.helper, args) or nil
		for _, cond in ipairs(chart.category_conditions) do
			if EntrySelector.matchesCondition(cond.when, args, helperValue) and cond.category then
				table.insert(cats, "[[" .. CONFIG.category_prefix .. cond.category .. "]]")
			end
		end
		args.position = oldPosition
	elseif chart.number_one_category and tonumber(position) == 1 then
		table.insert(cats, "[[" .. CONFIG.category_prefix .. chart.number_one_category .. "]]")
	end

	if chart.track_param and not hasArg(args, chart.track_param) then
		table.insert(cats, catLink(CONFIG.categories.track_param, t, chartKey, chart.track_param))
	end

	return table.concat(cats)
end

--=============================================================================
-- SECTION 12: ERROR HANDLING
--=============================================================================

local Errors = {}

local ERROR_SPAN = '<span style="color:red;">' .. CONFIG.errors.prefix .. '%s</span>'
local WARNING_SPAN = '<span style="color:orange;">%s</span>'

-- Format error span with chartKey and message
local function errorSpan(chartKey, msg)
	return string.format(ERROR_SPAN, chartKey, msg)
end

function Errors.make(chartType, chartKey, msg)
	local t = getTypeName(chartType)
	local cat = Categories.shouldCategorize() and catLink(CONFIG.categories.missing_params, t) or ""
	return errorSpan(chartKey, msg) .. cat
end

function Errors.inline(chartKey, param, extra)
	local msg
	if param == "invalid_date" then msg = string.format(CONFIG.errors.invalid_date, extra or "YYYY-MM-DD")
	elseif param == "invalid_year" then msg = string.format(CONFIG.errors.invalid_year, extra or "?")
	elseif param == "invalid_week" then msg = string.format(CONFIG.errors.invalid_week, extra or "?")
	elseif param == "unknown chart" then msg = string.format(CONFIG.errors.unknown_chart, chartKey)
	else msg = string.format(CONFIG.errors.missing_params, param) end
	return errorSpan(chartKey, msg)
end

function Errors.warning(text)
	return string.format(WARNING_SPAN, text)
end

function Errors.checkUnsubstituted(output)
	local found = {}
	for param in string.gmatch(output, "{([%w%-]+)}") do found[param] = true end
	local list = {}
	for ph in pairs(found) do table.insert(list, ph) end
	if #list > 0 then table.sort(list); return table.concat(list, ", ") end
	return nil
end

function Errors.isPreview()
	local frame = mw.getCurrentFrame()
	return frame and frame:preprocess("{{REVISIONID}}") == ""
end

--=============================================================================
-- SECTION 13: DATA LOADING
--=============================================================================

local dataCache = {}

local function loadData(chartType)
	if not CONFIG.type_names[chartType] then return nil end
	if dataCache[chartType] then return dataCache[chartType] end
	local grouped = mw.loadJsonData(string.format(CONFIG.json_path, chartType))
	local flat = {}
	for country, charts in pairs(grouped) do
		if string.sub(country, 1, 1) ~= "_" then
			for key, info in pairs(charts) do
				local copy = {}
				for k, v in pairs(info) do copy[k] = v end
				if not copy.chart then copy.chart = country end
				flat[key] = copy
			end
		end
	end
	dataCache[chartType] = flat
	return flat
end

--=============================================================================
-- SECTION 14: MAIN ENTRY POINT
--=============================================================================

local function parseArgs(frame)
	local args = {}
	local allKeys = CONFIG.check_unknown_params and {} or nil
	for k, v in pairs(frame.args) do
		if allKeys then allKeys[k] = true end
		if v and v ~= "" then args[k] = mw.text.trim(v) end
	end
	for k, v in pairs(frame:getParent().args) do
		if allKeys then allKeys[k] = true end
		if v and v ~= "" then args[k] = mw.text.trim(v) end
	end
	return args, allKeys
end

local function resolveChart(data, chartKey)
	local chart = data[chartKey]
	if not chart then return nil end
	return chart.alias_for and data[chart.alias_for] or chart
end

-- Check unknown params and return warning string and category string
local function checkUnknownParams(allKeys, args, chartKey, chartType, shouldCat)
	if not CONFIG.check_unknown_params then return "", "" end
	local unknownParams = Params.checkUnknown(allKeys, args)
	if not unknownParams then return "", "" end

	local warning = ""
	if Errors.isPreview() then
		warning = Errors.warning(string.format(CONFIG.warnings.unknown_params, chartKey, unknownParams))
	end

	local cat = ""
	local typeName = getTypeName(chartType)
	if shouldCat == nil then shouldCat = Categories.shouldCategorize() end
	if shouldCat then
		cat = catLinkSort(CONFIG.categories.unknown_params, chartKey .. ": " .. unknownParams, string.lower(typeName))
	end

	return warning, cat
end

-- Check if param is used in URL or helper (critical error) vs only in ref (warning)
local function checkParamInUrl(chart, url, paramName)
	local urlParams = Params.extractFromTemplate(url)
	if chart.multiple then
		for _, entry in ipairs(chart.multiple) do
			for k in pairs(Params.extractFromTemplate(entry.url)) do urlParams[k] = true end
		end
	end
	if urlParams[paramName] then return true end
	for _, param in ipairs(Helpers.getRequiredParams(chart.helper)) do
		if param == paramName then return true end
	end
	return false
end

-- Handle manual ref mode (3=M)
local function handleManualMode(frame, args, allKeys, chartType, chartKey, chart, typeName, shouldCat)
	if not hasArg(args, "url") or not hasArg(args, "title") then
		local errCat = shouldCat and catLinkSort(CONFIG.categories.manual_missing_url_title, chartKey, string.lower(typeName)) or ""
		return CONFIG.cell_normal .. errorSpan(chartKey, CONFIG.errors.manual_missing_url_title) .. errCat
	end

	-- Build cite news template using table for cleaner construction
	local citeParams = {
		"{{cite news",
		"|url=" .. args.url,
		"|title=" .. args.title,
	}
	local optionalParams = {
		{args.work, "|work="},
		{args.publisher, "|publisher="},
		{args.location, "|location="},
		{args.date, "|date="},
		{Params.getValue(args, "access-date"), "|access-date="},
		{Params.getValue(args, "archive-url"), "|archive-url="},
		{Params.getValue(args, "archive-date"), "|archive-date="},
		{args["url-status"], "|url-status="},
	}
	for _, param in ipairs(optionalParams) do
		if param[1] then table.insert(citeParams, param[2] .. param[1]) end
	end
	table.insert(citeParams, "}}")
	local cite = table.concat(citeParams)

	-- Manual refs are anonymous by default (like original template), unless refname explicitly provided
	local refname = args.refname  -- nil if not provided = anonymous ref
	local refTag = Builder.refTag(frame, frame:preprocess(cite), refname, args.refgroup)
	local chartName = Builder.chartName(chart.chart, chart.provider, chartKey)
	local position = args.position or args[2]
	local noteText = Builder.noteText(args)
	local unknownWarning, unknownCat = checkUnknownParams(allKeys, args, chartKey, chartType, shouldCat)
	local cats = Categories.build(chartType, chartKey, chart, args, position) .. unknownCat

	return Builder.outputRow(args, chartName, unknownWarning, "", refTag, noteText, position, cats)
end

function p.main(frame)
	local args, allKeys = parseArgs(frame)
	local chartType = args.type or CONFIG.default_type
	local chartKey = args.chart or args[1]
	local position = args.position or args[2]
	local typeName = getTypeName(chartType)
	local shouldCat = Categories.shouldCategorize()

	if not chartKey or chartKey == "" then
		return CONFIG.cell_normal .. Errors.make(chartType, "?", CONFIG.errors.missing_chart)
	end

	if not position or position == "" then
		return CONFIG.cell_normal .. Errors.make(chartType, chartKey, CONFIG.errors.missing_position)
	end

	local positionValid, positionErr = Params.validatePosition(position)
	if not positionValid then
		local errMsg = string.format(CONFIG.errors.invalid_position, positionErr, CONFIG.max_position)
		local cat = shouldCat and catLink(CONFIG.categories.invalid_position, typeName) or ""
		return CONFIG.cell_normal .. errorSpan(chartKey, errMsg) .. cat
	end

	local data = loadData(chartType)
	local chart = resolveChart(data, chartKey)

	if not chart then
		local cat = shouldCat and catLinkSort(CONFIG.categories.unknown_chart, chartKey, typeName) or ""
		return CONFIG.cell_normal .. errorSpan(chartKey, string.format(CONFIG.errors.unknown_chart, chartKey)) .. cat
	end

	if args[3] == "M" then
		return handleManualMode(frame, args, allKeys, chartType, chartKey, chart, typeName, shouldCat)
	end

	local isYearEnd = string.match(chartType, "^year%-end") ~= nil
	local sel = EntrySelector.select(chart, args)

	-- Check if helper recommends using a different chart
	if sel.helperValue == "use_new_chart" then
		local altChart = Helpers.alternatives[chartKey]
		local altText = altChart or (chartKey .. "2")
		local errMsg = string.format(CONFIG.errors.use_new_chart, altText)
		local cat = shouldCat and catLinkSort(CONFIG.categories.unknown_chart, chartKey, typeName) or ""
		return CONFIG.cell_normal .. errorSpan(chartKey, errMsg) .. cat
	end

	if sel.lang and string.find(sel.lang, "{{", 1, true) then
		sel.lang = frame:preprocess(sel.lang)
	end

	local errorInline, errorInRef, extraCats = "", "", ""

	-- URL validation
	if chart.url_validation and hasArg(args, "url") then
		if not string.find(args.url, chart.url_validation, 1, true) then
			local msg = string.format(CONFIG.errors.url_validation, chart.url_validation)
			errorInline, errorInRef = setErrorDisplay(errorSpan(chartKey, msg), errorInline, errorInRef)
		end
	end

	-- Missing params
	local missing = MissingChecker.check(chart, args, sel.url, sel.url_title, sel.ref)
	if missing and errorInline == "" then
		local errMsg = Errors.inline(chartKey, missing)
		errorInline, errorInRef = setErrorDisplay(errMsg, errorInline, errorInRef)
		if shouldCat then
			extraCats = extraCats .. catLinkSort(CONFIG.categories.missing_params, chartKey, typeName)
		end
	end

	-- Unused params
	local unusedWarning = ""
	if CONFIG.check_unused_params then
		local unusedParams = Params.checkUnused(args, chart)
		if unusedParams then
			if shouldCat then
				extraCats = extraCats .. catLinkSort(CONFIG.categories.unused_params, chartKey, typeName)
			end
			if Errors.isPreview() then
				unusedWarning = Errors.warning(string.format(CONFIG.warnings.unused_params, chartKey, unusedParams))
			end
		end
	end

	-- Unknown params
	local unknownWarning, unknownCat = checkUnknownParams(allKeys, args, chartKey, chartType, shouldCat)
	extraCats = extraCats .. unknownCat

	-- Date validation
	if args.date then
		local valid, formats = DateUtils.validate(args.date, chart, sel.entry)
		if not valid then
			if checkParamInUrl(chart, sel.url, "date") then
				local dateErr = Errors.inline(chartKey, "invalid_date", formats)
				errorInline, errorInRef = setErrorDisplay(dateErr, errorInline, errorInRef)
			elseif Errors.isPreview() then
				errorInline = errorInline .. Errors.warning(string.format(CONFIG.warnings.invalid_date_ref, formats))
			end
		end
	end

	-- Year/week format validation
	local dateParamChecks = {
		{param = "year", validator = DateUtils.validateYear, errorKey = "invalid_year"},
		{param = "week", validator = DateUtils.validateWeek, errorKey = "invalid_week"},
	}
	for _, check in ipairs(dateParamChecks) do
		if args[check.param] then
			local valid, badValue = check.validator(args[check.param])
			if not valid then
				if checkParamInUrl(chart, sel.url, check.param) then
					local err = Errors.inline(chartKey, check.errorKey, badValue)
					errorInline, errorInRef = setErrorDisplay(err, errorInline, errorInRef)
					if shouldCat then
						extraCats = extraCats .. catLinkSort(CONFIG.categories.unsubstituted, chartKey .. ": " .. check.param, typeName)
					end
				elseif Errors.isPreview() then
					errorInline = errorInline .. Errors.warning(string.format(CONFIG.errors[check.errorKey], badValue))
				end
			end
		end
	end

	local urlResult = Builder.urlResult(sel.url, sel.url_title, args, chart, missing ~= nil, sel.encode)
	local refText = missing and (sel.ref or "") or Template.substitute(sel.ref or "", args, chart, nil)
	local refContent = Builder.refContent(chart, args, urlResult, sel.lang, refText, isYearEnd, sel.entry)
	if errorInRef ~= "" then refContent = errorInRef .. " " .. refContent end

	local chartName = Builder.chartName(sel.chart, sel.provider, chartKey)
	local refname = Builder.refName(chartType, chartKey, args, chart, sel.entry)
	local refTag = Builder.refTag(frame, refContent, refname, args.refgroup)
	local noteText = Builder.noteText(args)
	local cats = Categories.build(chartType, chartKey, chart, args, position) .. extraCats
	local warnings = unusedWarning .. unknownWarning

	local output = Builder.outputRow(args, chartName, warnings, errorInline, refTag, noteText, position, cats)

	-- Check for unsubstituted placeholders
	local unsubstituted = Errors.checkUnsubstituted(output .. refContent)
	if unsubstituted then
		if errorInline == "" then
			output = Builder.outputRow(args, chartName, warnings, Errors.inline(chartKey, unsubstituted), refTag, noteText, position, cats)
		end
		if shouldCat then
			output = output .. catLinkSort(CONFIG.categories.unsubstituted, chartKey .. ": " .. unsubstituted, typeName)
		end
	end

	return output
end

--=============================================================================
-- SECTION 15: UTILITY FUNCTIONS
--=============================================================================

function p.chartExists(frame)
	local chartType = frame.args.type or CONFIG.default_type
	local chartKey = frame.args.chart or frame.args[1]
	if not chartKey then return "0" end
	return loadData(chartType)[chartKey] and "1" or "0"
end


--=============================================================================
-- SECTION 16: SHOW CHARTS TABLE GENERATOR
--=============================================================================

local ShowCharts = {}

function ShowCharts.countArray(arr)
	if not arr then return 0 end
	local count = 0
	for _ in ipairs(arr) do count = count + 1 end
	return count
end

function ShowCharts.chartIdCell(chartKey)
	return "<code>" .. chartKey .. "</code>"
end

function ShowCharts.usageCountLink(chartKey, typeName)
	local cat = string.format(CONFIG.categories.usage, typeName, chartKey)
	local count = mw.site.stats.pagesInCategory(cat, "pages") or 0
	return "[[:" .. CONFIG.category_prefix .. cat .. "|" .. count .. "]]"
end

function ShowCharts.buildParamsStr(entry, chart)
	local paramList = {}
	local seen = {}

	if entry.when then
		for part in string.gmatch(entry.when, "[^,|]+") do
			local param = string.match(mw.text.trim(part), "^!?([%w_%-]+)")
			if param and not seen[param] then
				table.insert(paramList, param)
				seen[param] = true
			end
		end
	end

	Params.addFromTemplateOrdered(paramList, seen, entry.url or chart.url)
	Params.addFromTemplateOrdered(paramList, seen, entry.url_title or chart.url_title)
	Params.addFromTemplateOrdered(paramList, seen, chart.ref)
	Params.addFromTemplateOrdered(paramList, seen, chart.ref_note)
	Params.addFromTemplateOrdered(paramList, seen, entry.ref_note)

	for _, param in ipairs(Helpers.getRequiredParams(chart.helper)) do
		if not seen[param] then
			table.insert(paramList, param)
			seen[param] = true
		end
	end

	local dateFormat = entry.date_format or chart.date_format
	if dateFormat then
		local displayFormat = dateFormat:gsub("–", "-"):gsub("~", "-")
		for i, param in ipairs(paramList) do
			if param == "date" then
				paramList[i] = "date <span style=\"font-size:85%\">[" .. displayFormat .. "]</span>"
				break
			end
		end
	end

	return #paramList > 0 and table.concat(paramList, ", ") or "—"
end

function ShowCharts.isRefOnlyOverride(entry, chart)
	return not (entry.url and entry.url ~= chart.url) and not (entry.url_title and entry.url_title ~= chart.url_title)
end

local function escapeForTable(str)
	if not str then return nil end
	local links = {}
	str = str:gsub("%[%[(.-)%]%]", function(l)
		links[#links + 1] = "[[" .. l .. "]]"
		return "\1" .. #links .. "\1"
	end)
	str = str:gsub("|", "&#124;")
	return str:gsub("\1(%d+)\1", function(n) return links[tonumber(n)] end)
end

function ShowCharts.buildCombineBullet(entry, chart, sampleArgs)
	local link = escapeForTable(Builder.link(entry.url or chart.url, entry.url_title or chart.url_title, sampleArgs, chart))
	local lang = entry.lang or chart.lang
	if lang then link = link .. " " .. mw.text.nowiki(lang) end
	-- Only add entry-level ref, not chart-level (chart.ref is added separately below bullets)
	if entry.ref then link = link .. ". " .. escapeForTable(entry.ref) end
	if entry.ref_note then link = link .. ". " .. escapeForTable(Template.substitute(entry.ref_note, sampleArgs, chart, nil)) end
	-- Add trailing dot
	if not link:match("%.$") then link = link .. "." end
	return link
end

function ShowCharts.buildSampleOutput(entry, chart, sampleArgs)
	local function sub(s)
		if not s then return nil end
		return escapeForTable(Template.substitute(s, sampleArgs, chart, nil))
	end

	local url = entry.url or chart.url
	local urlTitle = entry.url_title or chart.url_title
	local lang = entry.lang or chart.lang
	local ref = entry.ref or chart.ref
	local refNote = entry.ref_note or chart.ref_note
	local hasCondition = ShowCharts.isRefOnlyOverride(entry, chart) and entry.when

	local parts = {}

	if url then
		local link = escapeForTable(Builder.link(url, urlTitle, sampleArgs, chart))
		if link and link ~= "" then
			if lang then link = link .. " " .. mw.text.nowiki(lang) end
			parts[#parts + 1] = link
		end
	end

	if hasCondition then
		local overrides = {}
		if entry.ref_note then overrides[#overrides + 1] = sub(entry.ref_note) end
		if entry.ref and entry.ref ~= chart.ref then overrides[#overrides + 1] = "ref: " .. sub(entry.ref) end
		if entry.lang and entry.lang ~= chart.lang then overrides[#overrides + 1] = "lang: " .. mw.text.nowiki(entry.lang) end
		if #overrides > 0 then
			parts[#parts + 1] = "[" .. entry.when .. " → " .. table.concat(overrides, "; ") .. "]"
		end
	end

	if ref then parts[#parts + 1] = sub(ref) end

	local refNoteShownInCondition = hasCondition and entry.ref_note
	if refNote and not refNoteShownInCondition then
		parts[#parts + 1] = sub(refNote)
	end

	if #parts == 0 then return "''(no url)''" end
	local out = table.concat(parts, ". ")
	if not out:match("%.$") then out = out .. "." end
	if chart.ref_suffix then out = out .. " " .. sub(chart.ref_suffix) end
	return out
end

function ShowCharts.getSampleArgs(chart)
	local sampleArgs = {
		date = "2024-01-15", year = "2024", week = "3",
		chartid = "12345", songid = "67890", artistid = "11111",
		dvd = "Sample DVD", startdate = "01/01/2024", enddate = "07/01/2024",
		id = "123", page = "42", title = "Sample Title",
	}
	local dateFormats = {
		["YYYYMMDD"] = "20240115",
		["YYMMDD"] = "110115",
		["DD-MM-YYYY"] = "15-01-2024",
		["MM-DD-YYYY"] = "01-15-2024",
		["DD.MM.YYYY"] = "15.01.2024",
		["YYYYMMDD-YYYYMMDD"] = "20251219-20251225",
		["DD.MM.YYYY–DD.MM.YYYY"] = "19.12.2025–25.12.2025",
		["YYYY.MM.DD–YYYY.MM.DD"] = "2025.12.19–2025.12.25",
	}
	if chart.date_format and dateFormats[chart.date_format] then
		sampleArgs.date = dateFormats[chart.date_format]
	end
	return sampleArgs
end

function ShowCharts.rowspanCell(content, rowspan, style)
	return string.format('%srowspan="%d" | %s', style or "", rowspan, content or "")
end

function ShowCharts.categoryLink(catName)
	if not catName then return "—" end
	return "[[:" .. CONFIG.category_prefix .. catName .. "|" .. catName .. "]]"
end

function ShowCharts.formatGroup(name, frame)
	if string.find(name, "%[no wrap%]") then
		return (string.gsub(name, "%s*%[no wrap%]%s*", ""))
	end
	if CONFIG.group_wrapper then
		local wrapped = string.format(CONFIG.group_wrapper, name, name)
		if string.find(wrapped, "{{", 1, true) then
			return frame:preprocess(wrapped)
		end
		return wrapped
	end
	return name
end

function ShowCharts.isMvChart(chartKey)
	return string.sub(chartKey, -2) == "MV"
end

function ShowCharts.buildRows(grouped, countries, opts, frame, mvFilter)
	local rows = {}

	for _, country in ipairs(countries) do
		local keys = {}
		for k in pairs(grouped[country]) do
			if mvFilter == nil or ShowCharts.isMvChart(k) == mvFilter then table.insert(keys, k) end
		end
		if #keys > 0 then
			if opts.sortAlpha then table.sort(keys) end

			-- Count rows for this country (for rowspan)
			local countryRowCount = 0
			for _, k in ipairs(keys) do
				local c = grouped[country][k]
				if c.multiple and not c.alias_for and not c.combine then
					countryRowCount = countryRowCount + ShowCharts.countArray(c.multiple)
				else
					countryRowCount = countryRowCount + 1
				end
			end

			local countryRowsEmitted = 0
			for _, chartKey in ipairs(keys) do
				local chart = grouped[country][chartKey]
				local isFirstCountryRow = (countryRowsEmitted == 0)
				local sampleArgs = ShowCharts.getSampleArgs(chart)
				-- Base style for defunct charts (without pipe)
				local style = chart.defunct and 'style="background:#ffebee;" ' or ""
				local docNote = chart.doc_note or ""

				-- Helper to build row start with country cell
				-- First row of country: "| rowspan=N | Country || "
				-- Other rows: "| " (start new row cell)
				local function countryCell()
					if isFirstCountryRow then
						isFirstCountryRow = false
						return string.format('| rowspan="%d" | %s || ', countryRowCount, ShowCharts.formatGroup(country, frame))
					end
					return "| "
				end

				-- Cell style prefix: "style=... | " for defunct, empty for normal
				local cs = style ~= "" and (style .. "| ") or ""

				if chart.alias_for then
					local aliasStyle = 'style="background:#fffde7;" '
					local aliasCs = aliasStyle .. "| "
					local colspan = 2 + (opts.showParams and 1 or 0) + (opts.showCategories and 1 or 0) + (opts.showRef and 1 or 0)
					local aliasContent = string.format('colspan="%d" style="background:#fffde7; text-align:center; color:#666;" | \'\' → %s\'\'', colspan, chart.alias_for)
					local row = countryCell() .. aliasCs .. ShowCharts.chartIdCell(chartKey)
					if opts.showUses then row = row .. " || " .. aliasCs .. ShowCharts.usageCountLink(chartKey, opts.typeName) end
					row = row .. " || " .. aliasContent
					table.insert(rows, {content = row, note = docNote, noteStyle = aliasStyle})
					countryRowsEmitted = countryRowsEmitted + 1

				elseif chart.multiple and chart.combine then
					local row = countryCell() .. cs .. ShowCharts.chartIdCell(chartKey) .. (chart.defunct and " ''(defunct)''" or "")
					if opts.showUses then row = row .. " || " .. cs .. ShowCharts.usageCountLink(chartKey, opts.typeName) end
					row = row .. " || " .. cs .. (chart.chart or country)
					row = row .. " || " .. cs .. (chart.provider or "—")
					if opts.showParams then
						local allParams = {}
						for _, ent in ipairs(chart.multiple) do
							local paramStr = ShowCharts.buildParamsStr(ent, chart)
							if paramStr ~= "" then allParams[paramStr] = true end
						end
						local list = {}; for param in pairs(allParams) do table.insert(list, param) end
						row = row .. " || " .. cs .. table.concat(list, ", ")
					end
					if opts.showCategories then row = row .. " || " .. cs .. ShowCharts.categoryLink(chart.number_one_category) end
					if opts.showRef then
						local bullets = {}
						for _, entry in ipairs(chart.multiple) do table.insert(bullets, "• " .. ShowCharts.buildCombineBullet(entry, chart, sampleArgs)) end
						local prefix = chart.ref_note and escapeForTable(Template.substitute(chart.ref_note, sampleArgs, chart, nil)) or ""
						local content = prefix ~= "" and (prefix .. "<br>" .. table.concat(bullets, "<br>")) or table.concat(bullets, "<br>")
						if chart.ref then content = content .. "<br>" .. escapeForTable(Template.substitute(chart.ref, sampleArgs, chart, nil)) .. "." end
						row = row .. " || " .. cs .. content
					end
					table.insert(rows, {content = row, note = docNote, noteStyle = style})
					countryRowsEmitted = countryRowsEmitted + 1

				elseif chart.multiple then
					local entryCount = ShowCharts.countArray(chart.multiple)
					for j, entry in ipairs(chart.multiple) do
						local isFirst = (j == 1)
						local entryNote = entry.doc_note or docNote
						local row = countryCell() .. cs .. ShowCharts.chartIdCell(chartKey) .. (chart.defunct and " ''(defunct)''" or "")
						local showWhen = entry["when"] and not ShowCharts.isRefOnlyOverride(entry, chart)
						if showWhen then row = row .. " → " .. entry["when"] end
						if isFirst then
							if opts.showUses then row = row .. " || " .. ShowCharts.rowspanCell(ShowCharts.usageCountLink(chartKey, opts.typeName), entryCount, style) end
							row = row .. " || " .. ShowCharts.rowspanCell(chart.chart or country, entryCount, style)
							row = row .. " || " .. ShowCharts.rowspanCell(chart.provider or "—", entryCount, style)
						end
						if opts.showParams then row = row .. " || " .. cs .. ShowCharts.buildParamsStr(entry, chart) end
						if opts.showCategories and isFirst then
							row = row .. " || " .. ShowCharts.rowspanCell(ShowCharts.categoryLink(chart.number_one_category), entryCount, style)
						end
						if opts.showRef then row = row .. " || " .. cs .. ShowCharts.buildSampleOutput(entry, chart, sampleArgs) end
						table.insert(rows, {content = row, note = entryNote, noteStyle = style})
						countryRowsEmitted = countryRowsEmitted + 1
					end

				else
					local row = countryCell() .. cs .. ShowCharts.chartIdCell(chartKey) .. (chart.defunct and " ''(defunct)''" or "")
					if opts.showUses then row = row .. " || " .. cs .. ShowCharts.usageCountLink(chartKey, opts.typeName) end
					row = row .. " || " .. cs .. (chart.chart or country)
					row = row .. " || " .. cs .. (chart.provider or "—")
					if opts.showParams then
						local entry = { url = chart.url, url_title = chart.url_title }
						row = row .. " || " .. cs .. ShowCharts.buildParamsStr(entry, chart)
					end
					if opts.showCategories then row = row .. " || " .. cs .. ShowCharts.categoryLink(chart.number_one_category) end
					if opts.showRef then
						local entry = { url = chart.url, url_title = chart.url_title, lang = chart.lang }
						row = row .. " || " .. cs .. ShowCharts.buildSampleOutput(entry, chart, sampleArgs)
					end
					table.insert(rows, {content = row, note = docNote, noteStyle = style})
					countryRowsEmitted = countryRowsEmitted + 1
				end
			end
		end
	end

	-- Calculate note rowspans
	if opts.showNotes then
		local i = 1
		while i <= #rows do
			local note = rows[i].note
			if note ~= "" then
				local span = 1
				while i + span <= #rows and rows[i + span].note == note do span = span + 1 end
				rows[i].noteRowspan = span
				for j = 1, span - 1 do rows[i + j].noteRowspan = 0 end
				i = i + span
			else
				rows[i].noteRowspan = 1
				i = i + 1
			end
		end
	end

	return rows
end

function ShowCharts.generateTable(rows, caption, opts)
	local out = {}
	table.insert(out, '{| class="wikitable sortable"')
	table.insert(out, '|+ ' .. caption)
	table.insert(out, '|-')

	local header = '! Group !! Chart ID'
	if opts.showUses then header = header .. ' !! Uses' end
	header = header .. ' !! Chart !! Provider'
	if opts.showParams then header = header .. ' !! Required params' end
	if opts.showCategories then header = header .. ' !! #1 Category' end
	if opts.showRef then header = header .. ' !! Sample ref output' end
	if opts.showNotes then header = header .. ' !! Notes' end
	table.insert(out, header)

	for _, row in ipairs(rows) do
		table.insert(out, '|-')
		local line = row.content
		if opts.showNotes then
			local span = row.noteRowspan or 1
			if span > 1 then
				line = line .. string.format(' || rowspan="%d" %s| %s', span, row.noteStyle, row.note)
			elseif span == 1 then
				local noteCell = row.noteStyle ~= "" and (row.noteStyle .. "| ") or ""
				line = line .. " || " .. noteCell .. row.note
			end
		end
		table.insert(out, line)
	end

	table.insert(out, '|}')
	return table.concat(out, '\n')
end

function p.showCharts(frame)
	local chartType = frame.args.type or CONFIG.default_type
	local filterCountry = frame.args.country
	local splitDvd = frame.args.splitdvd == "yes" or frame.args.splitdvd == "1"

	local ok, grouped = pcall(mw.loadJsonData, string.format(CONFIG.json_path, chartType))
	if not ok then return '<span style="color:red;">ERROR: Cannot load JSON</span>' end

	local typeName = getTypeName(chartType)
	local opts = {
		showParams = frame.args.params ~= "no",
		showRef = frame.args.ref ~= "no",
		showUses = frame.args.uses ~= "no",
		showCategories = frame.args.number1 == "yes" or frame.args.number1 == "1",
		showNotes = frame.args.notes == "yes" or frame.args.notes == "1",
		sortAlpha = CONFIG.sort_order == "abc",
		typeName = typeName
	}

	local countries = {}
	for c in pairs(grouped) do
		if string.sub(c, 1, 1) ~= "_" and (not filterCountry or c == filterCountry) then table.insert(countries, c) end
	end
	if opts.sortAlpha then table.sort(countries) end

	local out = {}
	local jsonPage = string.format(CONFIG.json_path, chartType):gsub("%.json$", "")
	table.insert(out, string.format("'''Data:''' [[%s.json]] • '''Testcases:''' [[Template:%s chart/testcases]]", jsonPage, typeName))
	table.insert(out, "")

	if splitDvd then
		local mainRows = ShowCharts.buildRows(grouped, countries, opts, frame, false)
		local dvdRows = ShowCharts.buildRows(grouped, countries, opts, frame, true)
		if #mainRows > 0 then
			table.insert(out, ShowCharts.generateTable(mainRows, typeName .. " chart outputs", opts))
		end
		if #dvdRows > 0 then
			table.insert(out, "")
			table.insert(out, ShowCharts.generateTable(dvdRows, "Music DVD chart outputs", opts))
		end
	else
		local rows = ShowCharts.buildRows(grouped, countries, opts, frame, nil)
		table.insert(out, ShowCharts.generateTable(rows, typeName .. " chart outputs", opts))
	end

	return table.concat(out, '\n')
end

return p