Module:Music chart

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 week 1–53 or combined weeks like 51+52.",
		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

local function renderTableCellContent(frame, text)
	if type(text) ~= "string" then return text end
	if frame and string.find(text, "{{", 1, true) then
		return frame:preprocess(text)
	end
	return text
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 == 91 or k == 93 or 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

--=============================================================================
-- 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 = {}

Helpers.alternatives = {
	single = {
		Slovakdigital = "Slovakdigital2",
		Slovakia = "Slovakia2",
	},
}

-- 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"},
	germany_timestamp = {"date"},
	czech_week_id = {"year", "week"},
	israel_week = {"year", "week"},
}

-- [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] Recommend switching to ...2 for 2016w43+
function Helpers.getAlternativeChart(chartType, chartKey, args)
	local altCharts = Helpers.alternatives[chartType]
	local altChart = altCharts and altCharts[chartKey]
	if not altChart then return nil end
	local year = tonumber(args.year) or 0
	local week = tonumber(string.match(args.week or "", "^%d+")) or 0
	return year * 100 + week >= 201643 and altChart or nil
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
		-- Convert year+week to "ID count" where weeks 51+52 share same ID
		local function toIdCount(y, w) return y * 51 + math.min(w, 51) end
		return tostring(czechMaxId + toIdCount(year, week) - toIdCount(lastYear, lastW))
	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-53, or special format like "51+52")
function DateUtils.validateWeek(week)
	if not week or week == "" then return true, nil end
	local singleWeek = tonumber(week)
	if singleWeek and mw.ustring.match(week, "^%d%d?$") and singleWeek >= 1 and singleWeek <= 53 then
		return true, nil
	end
	local week1, week2 = mw.ustring.match(week, "^(%d%d?)%+(%d%d?)$")
	week1, week2 = tonumber(week1), tonumber(week2)
	if week1 and week2 and week1 >= 1 and week1 <= 53 and week2 >= 1 and week2 <= 53 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

local function addOrderedParam(list, seen, param)
	if not param or param == "helper" or tonumber(param) or seen[param] then return end
	if list then table.insert(list, param) end
	seen[param] = true
end

local COMPOSITE_SECONDARY_FIELDS = {
	week = 100,
	month = 100,
}

local function getCompositeSecondaryParam(param)
	local secondaryParam = param and string.match(param, "^year%+(%a+)$")
	return COMPOSITE_SECONDARY_FIELDS[secondaryParam] and secondaryParam or nil
end

local function addExpandedWhenParam(list, seen, param)
	local secondaryParam = getCompositeSecondaryParam(param)
	if secondaryParam then
		addOrderedParam(list, seen, "year")
		addOrderedParam(list, seen, secondaryParam)
	else
		addOrderedParam(list, seen, param)
	end
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
		addOrderedParam(list, seen, resolveParam(placeholder))
	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

function Params.addWhenParamsOrdered(list, seen, when)
	if not when then return end
	for part in string.gmatch(when, "[^,|]+") do
		local param = string.match(mw.text.trim(part), "^!?([%w_%-%+]+)")
		addExpandedWhenParam(list, seen, param)
	end
end

function Params.addWhenParams(set, when)
	Params.addWhenParamsOrdered(nil, set, when)
end

-- Collect all params used by chart definition
function Params.collectFromChart(chart)
	local used = {}
	local chartTemplates = {
		[1] = chart.url,
		[2] = chart.url_title,
		[3] = chart.ref,
		[4] = chart.ref_note,
	}
	for i = 1, 4 do
		Params.addFromTemplate(used, chartTemplates[i])
	end
	if chart.multiple then
		for _, entry in ipairs(chart.multiple) do
			local entryTemplates = {
				[1] = entry.url,
				[2] = entry.url_title,
				[3] = entry.ref,
				[4] = entry.ref_note,
			}
			for i = 1, 4 do
				Params.addFromTemplate(used, entryTemplates[i])
			end
			Params.addWhenParams(used, entry.when)
		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
			local entryTemplates = {
				[1] = entry.url,
				[2] = entry.url_title,
			}
			for i = 1, 2 do
				Params.addFromTemplate(required, entryTemplates[i])
			end
		end
	else
		local templates = { [1] = url, [2] = title }
		for i = 1, 2 do
			Params.addFromTemplate(required, templates[i])
		end
	end
	local refTemplates = { [1] = ref, [2] = chart.ref_note }
	for i = 1, 2 do
		Params.addFromTemplate(required, refTemplates[i])
	end
	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 = {}
	local refTemplates = { [1] = ref, [2] = chart.ref_note }
	for i = 1, 2 do
		Params.addFromTemplate(refParams, refTemplates[i])
	end

	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 = {}
		local variantTemplates = {
			[1] = entry.url or chart.url,
			[2] = entry.url_title or chart.url_title,
			[3] = entry.ref_note,
		}
		for i = 1, 3 do
			Params.addFromTemplate(variantParams, variantTemplates[i])
		end
		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,
}

local function parseComparisonCondition(cond)
	cond = mw.text.trim(cond)
	for _, operator in ipairs({"<=", ">=", "<", ">"}) do
		local param, op, val = string.match(cond, "^([%w_%-%+]+)(" .. operator .. ")(.+)$")
		if param then return param, op, mw.text.trim(val) end
	end
end

local function normalizeDateComparisonValue(value, compareValue)
	local rawValue = tostring(value or "")
	local rawCompare = tostring(compareValue or "")

	if string.match(rawCompare, "^%d%d%d%d$") then
		return tonumber(string.sub(rawValue, 1, 4)) or tonumber(string.match(rawValue, "(%d%d%d%d)")) or 0,
			tonumber(rawCompare) or 0
	end

	local y, m, d = string.match(rawValue, "^(%d%d%d%d)%-(%d%d?)%-(%d%d?)$")
	if y then rawValue = string.format("%04d%02d%02d", tonumber(y), tonumber(m), tonumber(d)) end
	y, m, d = string.match(rawCompare, "^(%d%d%d%d)%-(%d%d?)%-(%d%d?)$")
	if y then rawCompare = string.format("%04d%02d%02d", tonumber(y), tonumber(m), tonumber(d)) end

	local rawDigits = string.gsub(rawValue, "%D", "")
	local compareDigits = string.gsub(rawCompare, "%D", "")
	if #rawDigits >= 8 then rawValue = string.sub(rawDigits, 1, 8) end
	if #compareDigits >= 8 then rawCompare = string.sub(compareDigits, 1, 8) end

	return tonumber(rawValue) or 0, tonumber(rawCompare) or 0
end

local function normalizeCompositeKeyComparisonValue(param, args, compareValue)
	local secondaryParam = getCompositeSecondaryParam(param)
	if not secondaryParam then return nil, nil, false end

	local scale = COMPOSITE_SECONDARY_FIELDS[secondaryParam]
	local yearRaw = Params.getValue(args, "year")
	local secondaryRaw = Params.getValue(args, secondaryParam)
	if not yearRaw or not secondaryRaw then return nil, nil, true end

	local year = tonumber(yearRaw) or 0
	local secondary = tonumber(string.match(secondaryRaw, "^%d+")) or 0
	local compareYear, compareSecondary = string.match(tostring(compareValue or ""), "^(%d%d%d%d)%s*%+%s*(%d+)$")
	if compareYear then
		return year * scale + secondary, (tonumber(compareYear) or 0) * scale + (tonumber(compareSecondary) or 0), true
	end

	local compareDigits = string.gsub(tostring(compareValue or ""), "%D", "")
	return year * scale + secondary, tonumber(compareDigits) or 0, true
end

local function normalizeComparisonValues(param, paramValue, compareValue, args)
	if param == "date" or param == "archivedate" or param == "archive-date" then
		local argVal, cmpVal = normalizeDateComparisonValue(paramValue, compareValue)
		return argVal, cmpVal, false
	end
	local compositeArgVal, compositeCmpVal, isComposite = normalizeCompositeKeyComparisonValue(param, args, compareValue)
	if isComposite then return compositeArgVal, compositeCmpVal, true end
	return tonumber(paramValue) or 0, tonumber(compareValue) or 0, false
end

local function checkCompositeComparison(condA, condB, args)
	local paramA, opA, valA = parseComparisonCondition(condA)
	local paramB, opB, valB = parseComparisonCondition(condB)
	if not paramA or not paramB or opA ~= opB then return nil end

	local secondaryParam, yearVal, secondaryVal
	if paramA == "year" and COMPOSITE_SECONDARY_FIELDS[paramB] then
		secondaryParam, yearVal, secondaryVal = paramB, valA, valB
	elseif paramB == "year" and COMPOSITE_SECONDARY_FIELDS[paramA] then
		secondaryParam, yearVal, secondaryVal = paramA, valB, valA
	else
		return nil
	end

	local scale = COMPOSITE_SECONDARY_FIELDS[secondaryParam]
	local year = tonumber(Params.getValue(args, "year")) or 0
	local secondary = tonumber(string.match(Params.getValue(args, secondaryParam) or "", "^%d+")) or 0
	local compareValue = (tonumber(yearVal) or 0) * scale + (tonumber(secondaryVal) or 0)
	local argValue = year * scale + secondary
	return COMPARISON_OPS[opA](argValue, compareValue)
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

	local param, op, val = parseComparisonCondition(cond)
	if param and op and val then
		local argVal, cmpVal, isComposite = normalizeComparisonValues(param, Params.getValue(args, param), val, args)
		if isComposite and argVal == nil then return false end
		return COMPARISON_OPS[op](argVal, cmpVal)
	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

	local function matchesAndConditions(expr)
		local parts = {}
		for param in string.gmatch(expr, "([^,]+)") do
			table.insert(parts, mw.text.trim(param))
		end

		local i = 1
		while i <= #parts do
			if i < #parts then
				local compositeMatch = checkCompositeComparison(parts[i], parts[i + 1], args)
				if compositeMatch ~= nil then
					if not compositeMatch then return false end
					i = i + 2
				else
					if not checkSingleCondition(parts[i], args) then return false end
					i = i + 1
				end
			else
				if not checkSingleCondition(parts[i], args) then return false end
				i = i + 1
			end
		end

		return true
	end

	for expr in string.gmatch(when, "([^|]+)") do
		if matchesAndConditions(expr) then return true end
	end

	return false
end

-- Select URL entry from chart.multiple based on args
-- Returns table with: url, url_title, ref, lang, provider, chart, entry, encode, helperValue, defunct
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,
		defunct = chart.defunct
	}

	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.defunct = chart.defunct or entry.defunct
		result.entry = entry
	end

	if chart.combine then
		local entries = {}
		local hasDefunctEntry = false
		for _, entry in ipairs(chart.multiple) do
			if EntrySelector.matchesCondition(entry.when, args, helperValue) then
				hasDefunctEntry = hasDefunctEntry or entry.defunct
				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
			result.defunct = chart.defunct or hasDefunctEntry
		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 = {}
local NBSP = string.char(194, 160)

-- 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)
	linkTitle = linkTitle:gsub("%[", "&#91;"):gsub("%]", "&#93;")
	return linkTitle ~= "" and ('"[' .. encodedUrl .. " " .. linkTitle .. ']"') or ("[" .. encodedUrl .. "]")
end

function Builder.appendLang(text, lang)
	local cleanText = mw.text.trim(text or "")
	local cleanLang = tostring(lang or ""):gsub("^%s+", "")
	local hasGlue = cleanLang:find("^&nbsp;")
		or cleanLang:find("^&#160;")
		or cleanLang:find("^&#[Xx][Aa]0;")
		or cleanLang:find("^" .. NBSP)
	if not hasGlue then cleanLang = mw.text.trim(cleanLang) end
	if cleanText == "" then return cleanLang end
	if cleanLang == "" then return cleanText end
	return cleanText .. (hasGlue and "" or " ") .. cleanLang
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 = Builder.appendLang(link, entryLang)
				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
	urlLang = Builder.appendLang(urlLang, lang)
	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(frame, args, chartName, warnings, errorInline, refTag, noteText, position, cats)
	local cell = args.rowheader == "true" and CONFIG.cell_header or CONFIG.cell_normal
	local renderedChartName = renderTableCellContent(frame, chartName)
	return string.format('%s%s%s%s%s%s\n|%s%s%s',
		cell, renderedChartName, 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, entry)
	if not Categories.shouldCategorize() then return "" end
	local t = getTypeName(chartType)
	local cats = {catLink(CONFIG.categories.usage, t, chartKey)}
	local isDefunct = chart.defunct or (entry and entry.defunct)

	if isDefunct 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 = type(url) == "string" and Params.extractFromTemplate(url) or {}
	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(frame, 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)
	if not data then
		return CONFIG.cell_normal .. errorSpan(chartKey or "?", 'Unknown chart type "' .. chartType .. '".')
	end
	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)
	local errorInline, errorInRef, extraCats = "", "", ""
	local refChart, refSel = chart, sel

	local altChart = args.year and args.week and Helpers.getAlternativeChart(chartType, chartKey, args)
	if altChart then
		return CONFIG.cell_normal .. errorSpan(chartKey, string.format(CONFIG.errors.use_new_chart, altChart))
	end

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

	-- 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(refChart, args, refSel.url, refSel.url_title, refSel.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, refChart, refSel.entry)
		if not valid then
			if checkParamInUrl(refChart, refSel.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(refChart, refSel.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(refSel.url, refSel.url_title, args, refChart, missing ~= nil, refSel.encode)
	local refText = missing and (refSel.ref or "") or Template.substitute(refSel.ref or "", args, refChart, nil)
	local refContent = Builder.refContent(refChart, args, urlResult, refSel.lang, refText, isYearEnd, refSel.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, sel.entry) .. extraCats
	local warnings = unusedWarning .. unknownWarning

	local output = Builder.outputRow(frame, 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(frame, 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
	local data = loadData(chartType)
	return data and data[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

local function collectShowChartParams(entry, chart)
	local paramList = {}
	local seen = {}

	Params.addWhenParamsOrdered(paramList, seen, entry.when)
	local templates = {
		[1] = entry.url or chart.url,
		[2] = entry.url_title or chart.url_title,
		[3] = chart.ref,
		[4] = chart.ref_note,
		[5] = entry.ref_note,
	}
	for i = 1, 5 do
		Params.addFromTemplateOrdered(paramList, seen, templates[i])
	end

	for _, param in ipairs(Helpers.getRequiredParams(chart.helper)) do
		addOrderedParam(paramList, seen, param)
	end

	return paramList
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 = collectShowChartParams(entry, chart)

	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

-- Build raw param list (without formatting) for template call generation
function ShowCharts.getRawParams(entry, chart)
	return collectShowChartParams(entry, chart)
end

-- Build template call string for copy-paste
function ShowCharts.buildTemplateCall(chartKey, entry, chart, typeName)
	local params = ShowCharts.getRawParams(entry, chart)
	local call = "{{" .. typeName .. " chart&#124;" .. chartKey .. "&#124;0"
	for _, p in ipairs(params) do
		call = call .. "&#124;" .. p .. "="
	end
	call = call .. "}}"
	return "<code>" .. call .. "</code>"
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
	link = Builder.appendLang(link, lang and mw.text.nowiki(lang) or nil)
	-- 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
			link = Builder.appendLang(link, lang and mw.text.nowiki(lang) or nil)
			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.groupMultipleEntries(entries, chartDefunct)
	local groups = {}
	local current = nil
	for i, entry in ipairs(entries or {}) do
		local rowDefunct = chartDefunct or entry.defunct or false
		if not current or current.defunct ~= rowDefunct then
			current = {start = i, count = 1, defunct = rowDefunct}
			table.insert(groups, current)
		else
			current.count = current.count + 1
		end
	end
	return groups
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.formatChartName(name, frame)
	return renderTableCellContent(frame, 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)
				local chartDisplay = ShowCharts.formatChartName(chart.chart or country, frame)
				local providerDisplay = renderTableCellContent(frame, chart.provider or "—")
				-- 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.showTemplate 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 .. chartDisplay
					row = row .. " || " .. cs .. providerDisplay
					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.showTemplate then
						local calls = {}
						for _, ent in ipairs(chart.multiple) do
							table.insert(calls, ShowCharts.buildTemplateCall(chartKey, ent, chart, opts.typeName))
						end
						row = row .. " || " .. cs .. table.concat(calls, "<br>")
					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 entryGroups = ShowCharts.groupMultipleEntries(chart.multiple, chart.defunct)
					local groupIndex = 1
					local currentGroup = entryGroups[groupIndex]
					for j, entry in ipairs(chart.multiple) do
						while currentGroup and j > (currentGroup.start + currentGroup.count - 1) do
							groupIndex = groupIndex + 1
							currentGroup = entryGroups[groupIndex]
						end
						local isGroupFirst = currentGroup and j == currentGroup.start
						local groupRowspan = currentGroup and currentGroup.count or 1
						local rowDefunct = currentGroup and currentGroup.defunct or (chart.defunct or entry.defunct)
						local entryStyle = rowDefunct and 'style="background:#ffebee;" ' or ""
						local entryCs = entryStyle ~= "" and (entryStyle .. "| ") or ""
						local entryNote = entry.doc_note or docNote
						local row = countryCell() .. entryCs .. ShowCharts.chartIdCell(chartKey) .. (rowDefunct and " ''(defunct)''" or "")
						local showWhen = entry["when"] and not ShowCharts.isRefOnlyOverride(entry, chart)
						if showWhen then row = row .. " → " .. entry["when"] end
						if isGroupFirst then
							if opts.showUses then row = row .. " || " .. ShowCharts.rowspanCell(ShowCharts.usageCountLink(chartKey, opts.typeName), groupRowspan, entryStyle) end
							row = row .. " || " .. ShowCharts.rowspanCell(chartDisplay, groupRowspan, entryStyle)
							row = row .. " || " .. ShowCharts.rowspanCell(providerDisplay, groupRowspan, entryStyle)
						end
						if opts.showParams then row = row .. " || " .. entryCs .. ShowCharts.buildParamsStr(entry, chart) end
						if opts.showCategories and isGroupFirst then
							row = row .. " || " .. ShowCharts.rowspanCell(ShowCharts.categoryLink(chart.number_one_category), groupRowspan, entryStyle)
						end
						if opts.showTemplate then row = row .. " || " .. entryCs .. ShowCharts.buildTemplateCall(chartKey, entry, chart, opts.typeName) end
						if opts.showRef then row = row .. " || " .. entryCs .. ShowCharts.buildSampleOutput(entry, chart, sampleArgs) end
						table.insert(rows, {content = row, note = entryNote, noteStyle = entryStyle})
						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 .. chartDisplay
					row = row .. " || " .. cs .. providerDisplay
					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.showTemplate then
						local entry = { url = chart.url, url_title = chart.url_title }
						row = row .. " || " .. cs .. ShowCharts.buildTemplateCall(chartKey, entry, chart, opts.typeName)
					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 = '! style="width:130px" | Group !! style="width:260px" | Chart ID'
	if opts.showUses then header = header .. ' !! style="width:40px" | Uses' end
	header = header .. ' !! style="width:160px" | Chart !! style="width:170px" | Provider'
	if opts.showParams then header = header .. ' !! style="width:110px" | Required params' end
	if opts.showCategories then header = header .. ' !! #1 Category' end
	if opts.showTemplate then header = header .. ' !! style="width:300px" | Template call' end
	if opts.showRef then header = header .. ' !! style="width:310px" | 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",
		showTemplate = frame.args.template == "yes" or frame.args.template == "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

Content Disclaimer

Informasi ini disarikan dari Wikipedia dan disajikan kembali untuk tujuan edukasi. Konten tersedia di bawah lisensi CC BY-SA 3.0. Kami tidak bertanggung jawab atas ketidakakuratan data yang bersumber dari kontribusi publik tersebut.

  1. The information displayed on this website is sourced in part or in whole from Wikipedia and has been adapted for the purpose of restating it. We strive to provide accurate and relevant information, however:
  2. There is no guarantee of absolute accuracy. Wikipedia is an open, collaborative project that can be edited by anyone, so information is subject to change.
  3. It is not intended to constitute professional advice. The content displayed is for informational and educational purposes only. For important decisions (e.g., medical, legal, or financial), please consult a professional.
  4. Content copyright. Wikipedia is licensed under the Creative Commons Attribution-ShareAlike License (CC BY-SA). This means that content may be reused with appropriate attribution and shared under a similar license.
  5. Responsible use. Any risk arising from the use of information from this website is entirely the responsibility of the user.