--- @module 'Clickable button'
--- Creates clickable Codex button.
---
--- Outputs wikitext to render the [button component](https://doc.wikimedia.org/codex/latest/components/demos/button.html)
--- from the [Codex design system for Wikimedia](https://doc.wikimedia.org/codex/latest).
--- Options to:
--- - include an icon or create an icon-only button
--- - target a URL or a wikilink
--- - set the weight, size and state
--- - create a dummy or disabled button
--- - add custom CSS classes and inline styles
--- - include ARIA attributes for accessibility.
---
--- Dummy buttons are disabled by default. Includes helper functions for URL parsing and cleaning,
--- and adding tracking categories. Intended for use in templates and other modules.
--- Supports legacy parameters. To add icons, see CSS file links in `C`.
---
--- @diagnostic disable: duplicate-doc-field
--- @class args: frame Template arguments.
--- @field label? string Visible text label.
--- @field link? string Target wikilink.
--- @field url? string Target external URL.
--- @field icon? string Name of the icon to display as found in CSS file after the class's icon prefix, i.e. `search` for `cdx-demo-css-icon--search`.
--- @field weight? string Visual weight of the button.
--- @field size? string Size of the button.
--- @field action? string Action type of the button.
--- @field disabled? boolean|string Whether the button is disabled/greyed out. `true` if: `link` = `'no'` or `false`, or `disabled` = `'1'` or `true`.
--- @field nocat? boolean|string If `true`, suppresses tracking categories. Additional category, if defined, will still be added.
--- @field category? string An additional category to add.
--- @field aria-label? string The ARIA label for accessibility DOM.
--- @field class? string Custom CSS classes for the button. Do _not_ nest in "".
--- @field style? string Custom inline CSS styles. Do _not_ nest in "".
--- @field arialabel? string (alias for aria-label)
--- @field aria_label? string (alias for aria-label)
--- @field text string (alias for label)
--- @field ['1']? string Positional argument 1 (alias for link, can be label too if label is not defined).
--- @field ['2']? string Positional argument 2 (alias for label).
--- @field color? string Legacy color parameter.
--- @field private categories? string|boolean Categories to add.
--- @field private ariaDisabled? boolean Internal flag indicating if the button should be functionally disabled to ARIA.
--- @field private oldClassMatched string|boolean Internal flag for outdated classes if used.
--- @field private isUrl boolean Whether the target is a URL.
--- @field private errorText string|nil Internal string used both as error indicator, and error message text.
--- @field private tblClasses table Classes for the button span tag.
--- @field private pageTitleObject mw.title Title object of the current page.
--- @field private linkTitleObject mw.title Title object of the target wikilink.
--- @field private frame frame The current frame.
--- @field private rawArgs table Arguments passed to the module before parsing.
--- @field private parsedArgs table Parsed arguments.
--- @field private iconSpan mw.html Icon span element for the button.

require( 'strict' )
local M = {}

-- If your wiki uses non-ASCII/UTF-8 characters in any input text, then replace use of "string.lower" with "mw.ustring.lower". NOTE: "mw.ustring.lower" may be _much_ slower but respects Unicode codepoints rather than just bytes.
local _lower = string.lower
local getArgs = require( 'Module:Arguments' ).getArgs
local checkForUnknowns = require( 'Module:Check for unknown parameters' )._check
local _gsub = mw.ustring.gsub
local _mw_lower = mw.ustring.lower -- Still loaded, as instances where Unicode support is required use it.
local _tonumber = tonumber
local _format = mw.ustring.format
local _type = type
local _table_insert = table.insert

--- Requires @wikimedia/codex (check [[Special:Version]]).
--- @todo Check not in User/Draft namespaces.
--- @todo Is checkForUnknowns checking validity of input?
--- @todo Check if being subst'd via {{subst:#invoke:}} by checking mw.isSubsting() then output template call not the subst, i.e. unsubst.
--- @todo Check verbose output with mw.dumpObject( type.object ).
--- @todo Check knownArgs.

local C = { --- 'Constants'
	lowercaseArgs = { --- Arguments whose inputs are case-insensitive, and are converted to lowercase.
		[ 'action' ] = true,
		[ 'color' ] = true,
		[ 'weight' ] = true,
		[ 'size' ] = true,
		[ 'icon' ] = true,
	},
	knownArgs = { --- Valid argument keys.
		'class',
		'color',
		'weight',
		'size',
		'icon',
		'link',
		'action',
		'url',
		'disabled',
		'label',
		'aria-label',
		'nocat',
		'text',
		'1',
		'2',
		'url',
		'errorText',
		'arialabel',
		'aria_label',
		checkpositional = 'y', --- Other options for unknown parameters check.
		ignoreblank = 'y',
		unknown = '[[Category:Pages using Module:Clickable button with unknown parameters|_VALUE_]]',
		preview = '<span class="error" style="font-size:inherit;"><strong>Preview warning:</strong>' ..
			'Using undocumented parameter(s): "_VALUE_".</span>',
	},
	wrapperTemplates = { --- Wrapper templates that only require reading from `parentFrame()`. Positional arguments using template parameters (e.g., `{{{var|}}}`) are ignored, as `currentFrame()` is not used. Improves performance by avoiding argument checks in both frames.
		'Template:Clickable button', 'Template:Clickable button/sandbox',
		'Template:Cdx-button', 'Template:Cdx-button/sandbox',
	},
	trackingCategories = { --- Tracking category pagenames with namespace.
		dummyButton = 'Category:Pages using clickable dummy button',
		disabledButton = 'Category:Pages using disabled button',
		externalLinks = 'Category:Pages using clickable button with external links',
		outdatedClasses = 'Category:Pages using clickable button with outdated classes',
		errors = 'Category:Errors reported by Module:Clickable button',
		unknownParams = 'Category:Pages using Module:Clickable button with unknown parameters',
	},
	unknownArgsPreviewText = '<span class="error"><strong>Preview warning:</strong>' .. --- Preview warning text for unknown arguments.
		' Using undocumented parameter(s): "_VALUE_".</span>',
	noAriaLabelWarningText = '<span class="error"  style="font-size:inherit;">' .. --- No ARIA-label preview warning text.
		'<strong>Preview warning:</strong> A button without a visible label needs an [' ..
		'[WAI-ARIA|ARIA]] label, please define it using "aria-label".</span>',
	labelLengthWarningText = '<span class="error"  style="font-size:inherit;">' .. --- "Visible label is too long" preview warning text.
		'<strong>Preview warning:</strong> A button label should ideally be shorter th' ..
		'an 38 characters, see [[en:Template:Clickable button/doc#Button label length|' ..
		'documentation]].</span>',
	noArgsWarningText = '<span class="error" style="font-size:inherit;">' .. --- No arguments preview warning text.
		'<strong>Preview warning:</strong> No parameters were passed to clickable button.</span>',
	baseCSS = 'Template:Clickable button/styles.css', --- Base CSS file for button styles.
	iconsCSS = 'Template:Clickable button/icons.css', --- CSS file for button icons.
	buttonDefaults = { --- Default values for button options
		weight = 'normal',
		size = 'medium',
		action = 'default',
	},
	cssClasses = { -- CSS class prefixes for button.
		base = 'cdx-button',
		disabled = 'cdx-button--fake-button--disabled',
		wordWrap = 'cdx-button--word-wrap',
		enabled = 'cdx-button--fake-button--enabled',
		iconOnly = 'cdx-button--icon-only',
		shortLabel = 'cdx-button--short-label',
		icon = 'cdx-button__icon',
		iconPrefix = 'cdx-demo-css-icon--',
		sizePrefix = 'cdx-button--size-',
		weightPrefix = 'cdx-button--weight-',
		samePage = 'cdx-button--same-page',
		actionPrefix = 'cdx-button--action-',
		fakeButton = 'cdx-button--fake-button',
	},
	labelLimits = { maxLength = 38, minLength = 3 }, --- Label length limits.
	excludedNamespaces = { 'User', 'Draft' }, --- Namespace exclusions for tracking categories.
	legacyClassSets = {
		progressive = { --- Aliases for CSS class: `.progressive`.
			[ 'blue' ] = true,
			[ 'green' ] = true,
			[ 'ui-button-green' ] = true,
			[ 'ui-button-blue' ] = true,
			[ 'mw-ui-constructive' ] = true,
			[ 'mw-ui-progressive' ] = true,
			[ 'progressive' ] = true,
		},
		destructive = { --- Aliases for CSS class: `.destructive`.
			[ 'red' ] = true,
			[ 'ui-button-red' ] = true,
			[ 'mw-ui-destructive' ] = true,
			[ 'destructive' ] = true,
		},
	},
	booleanMap = {
		-- Explicit true values
		[ 'yes' ] = true,
		[ 'y' ] = true,
		[ 'true' ] = true,
		[ 't' ] = true,
		[ 'on' ] = true,
		[ '1' ] = true,
		[ 'enable' ] = true,
		[ 'enabled' ] = true,
		-- Explicit false values
		[ 'no' ] = false,
		[ 'n' ] = false,
		[ 'false' ] = false,
		[ 'f' ] = false,
		[ 'off' ] = false,
		[ '0' ] = false,
		[ 'disable' ] = false,
		[ 'disabled' ] = false,
	},
	defaultResponse = nil,
}

--- Allows for consistent treatment of boolean-like wikitext input.
---
--- Uses lookup table for efficiency, unlike [[Module:Yesno]] which uses chained if-elseif statements.
--- - Returns `nil` if input is `nil`.
--- - Checks for boolean type and returns as-is.
--- - For strings, looks up a normalized (lowercased) value in a lookup table (`C.booleanMap`).
--- - If not found, attempts to convert to a number: returns `true` for `1`, `false` for `0`.
--- - If still unrecognized, returns `defaultResponse` (or a constant fallback; default: `nil`).
--- @param value any Value to evaluate as truthy or falsy.
--- @param defaultResponse? any Value to return if input is unrecognized, i.e. neither truthy/falsy. Defaults to nil.
--- @return any valueBoolean Boolean true if truthy, or false if falsy, or nil if nil. defaultResponse or nil if input is unrecognized.
function M.yesno( value, defaultResponse )
	if value == nil then
		return nil
	end

	local valueType = _type( value )

	if valueType == 'boolean' then
		return value
	elseif valueType == 'string' then
		local lookupResult = C.booleanMap[ _lower( value ) ] -- Unicode doesn't matter here.
		if lookupResult ~= nil then
			return lookupResult
		end -- Not found in lookup table. Fallback to numeric check.
	end

	-- Numeric check works for both numbers and numeric strings.
	-- Numeric 1 is truthy, and 0 is falsy.
	local number = _tonumber( value ) or nil
	if number == 1 then
		return true
	elseif number == 0 then
		return false
	end -- Not 1 or 0, fallback to defaultResponse.

	if not defaultResponse then
		defaultResponse = C.defaultResponse
	end

	return defaultResponse
end

--- Parse a wikilink and return its component parts.
---
--- @class linkData, table
--- @field pageName string? The pagename part, with namespace if present
--- @field sectionHeading string? The section heading after `#`
--- @field displayText string? Display text after pipe `|`
--- @field isSectionLink boolean Whether wikilink is a section-only link in current page, i.e. `[[#Section]]`.
--- @param wikilinkText string|nil Wikitext to parse.
--- @return linkData|nil wikilink Components of wikilink, or nil if invalid.
local function parseWikilink( wikilinkText )
	-- @class wikilink: table<string, any>
	-- @field pageName string The pagename with namespace, if present
	-- @field sectionHeading string The section heading
	-- @field displayText string Display text, as given or as generated
	-- @field isSectionLinkOnly boolean Whether wikilink is a section-only link in current page, i.e. `[[#Section]]`
	-- @param wikilinkText string|nil Wikitext to parse.
	-- @return table<string, string>|nil wikilink Components of wikilink, or nil if invalid.

	if not wikilinkText or wikilinkText == '' then
		return nil
	end

	-- Remove outer square brackets if present: `[[:Help:Foo#Bar|Flog]]` → `Help:Foo#Bar|Flog`
	wikilinkText = _gsub( wikilinkText, '^%[%[', '' )
	wikilinkText = _gsub( wikilinkText, '%]%]$', '' )

	-- Remove initial colon if present
	wikilinkText = wikilinkText and string.match( wikilinkText, '^:?(.*)' ) -- Remove initial colon if present.

	-- Split on pipe `|` to separate link from display text
	local link, displayText = wikilinkText, wikilinkText and string.match( wikilinkText, '^(.-)|(.*)$' )
	wikilinkText = link or wikilinkText

	-- Split link on hash/pound sign `#` to separate page from section
	local pageName, sectionHeading = wikilinkText, wikilinkText and string.match( wikilinkText, '^(.-)#(.*)$' )
	local isSectionLink = false
	if not pageName and sectionHeading then
		isSectionLink = true -- It is a section link to current page, i.e. `[[#Bar]]`.
		pageName = nil
		-- pageName = FORMAT('#%s', sectionHeading)
	elseif not pageName and not sectionHeading then
		isSectionLink = false
		pageName = wikilinkText
	elseif pageName and not sectionHeading then
		isSectionLink = false
		sectionHeading = nil
		pageName = wikilinkText
	end

	if not displayText and sectionHeading and pageName then
		displayText = _format( '%s § %s', pageName, sectionHeading )
	elseif not displayText and sectionHeading and not pageName then
		displayText = _format( '§ %s', sectionHeading )
	elseif not displayText and not sectionHeading and pageName then
		displayText = pageName
	end

	return {
		pageName = pageName,
		sectionHeading = sectionHeading,
		displayText = displayText,
		isSectionLink = isSectionLink,
	}
end

--- Safely creates a [mw.uri object](lua://mw.uri) from a string, returning `nil` if invalid.
--- See [mw.uri in Lua reference manual](https://www.mediawiki.org/wiki/Extension:Scribunto/Lua_reference_manual#mw.uri).
---
--- @param s string The URL to check.
--- @return mw.uri|nil uri The URI of the given URL.
local function safeUri( s )
	local success, uri = pcall( function ( s )
		return mw.uri.new( s )
	end )
	if success then
		return uri
	else
		return nil
	end
end

--- Attempts to extract and normalize a URL from a string.
---
--- @param extract string String from which the URL must be obtained.
--- @return string|nil url The raw URL.
local function extractUrl( extract )
	local url = extract
	url = _gsub( url, '^([Hh]?[Tt]?[Tt]?[Pp]?[Ss]?:/*)(.+)', 'https://%2' )
	local uri = safeUri( url );
	if uri and uri.host then
		return url
	end
	return nil
end

--- Cleans and encodes a URL, generates a display label (domain-only if no label provided),
--- and adds word break opportunities for better display.
---
--- @param url string The URL
--- @param text? string|nil The display text for the URL if one must not be generated
--- @return string|nil url The URL, returns nil if URL invalid
--- @return string|nil text The display text for the URL
local function _url( url, text )
	-- @TODO cache some of these values.
	url = mw.text.trim( url or '' )
	text = mw.text.trim( text or '' )

	if url == '' or not url then
		return nil, text
	end

	-- If the URL contains any unencoded spaces, encode them,
	-- because MediaWiki will otherwise interpret a space as the end of the URL.
	url = _gsub( url, '%s', function ( s )
		return mw.uri.encode( s, 'PATH' )
	end )

	-- If there is an empty query string or fragment ID,
	-- remove it as it will cause mw.uri.new to throw an error
	url = _gsub( url, '#$', '' )
	url = _gsub( url, '%?$', '' )
	-- If it's an http(s) URL without the double slash, fix it.
	url = _gsub( url, '^[Hh][Tt][Tt][Pp]([Ss]?):(/?)([^/])', 'http%1://%3' )

	local uri = safeUri( url )

	-- Handle URLs without a protocol or who are protocol-relative.
	-- e.g., www.example.com/foo or www.example.com:8080/foo, and //www.example.com/foo
	if uri
	  and (not uri.protocol
		  or (uri.protocol and not uri.host))
	  and url:sub( 1, 2 ) ~= '//' then
		url = 'http://' .. url
		uri = safeUri( url )
	end

	if text == '' or not text then
		if uri then
			-- Generate clean domain-only text (e.g., "en.wikipedia.org")
			local host = _lower( uri.host or '' )

			-- Remove www. prefix for cleaner display
			host = _gsub( host, '^www%.', '' )

			-- For URLs like "http://en.wikipedia.org/wiki/Article_Name"
			-- Only want			 en.wikipedia.org
			text = host

			-- Add port if present and not standard
			if uri.port and uri.port ~= 80 and uri.port ~= 443 then
				text = text .. ':' .. uri.port
			end

			-- Add word break opportunities for better display. Add `<wbr>` before `_/.-#` sequences. This entry _must_ be the first. `<wbr/>` has a `/` in it, you know.
			text = _gsub( text, '(/+)', '<wbr/>%1' )
			text = _gsub( text, '(%.+)', '<wbr/>%1' )
			-- _Disabled_ for now.
			---- text = gsub(text,"(%-+)","<wbr/>%1")
			text = _gsub( text, '(%#+)', '<wbr/>%1' )
			text = _gsub( text, '(_+)', '<wbr/>%1' )
		else
			-- URL is badly-formed, so just display whatever was given.
			text = url
		end
	end

	return url, text
end

--- Strips HTML/wikilink markup, ensures protocol, and returns a cleaned URL and display label.
--- Cleans and normalises a URL string.
---
--- @param url string Raw URL.
--- @param text string Optional display text.
--- @return string|nil cleanUrl Cleaned URL for linking.
--- @return string|nil displayText Display label for the URL.
function M.url( url, text )
	local localUrl = url
	localUrl = localUrl or extractUrl( localUrl ) or extractUrl( text ) or ''

	-- Strip out HTML tags and wikilink brackets
	localUrl = _gsub( localUrl, '<[^>]*>', '' ) or ''
	localUrl = _gsub( localUrl, '[%[%]]', '' ) or ''

	-- Handle common URL prefixes and ensure proper protocol
	localUrl = _gsub( localUrl, '^[Ww][Ww][Ww]%.', 'http://www.' ) or ''

	-- Process the URL and generate label
	local cleanUrl, displayText = _url( localUrl, text )

	-- Enhanced label generation for URLs - domain-only format
	if cleanUrl and not text then
		local uri = safeUri( cleanUrl )
		if uri and uri.host then
			-- Generate clean domain label (e.g., "en.wikipedia.org")
			displayText = _lower( uri.host )
			-- Remove 'www.' prefix for cleaner display
			displayText = _gsub( displayText, '^www%.', '' )
		end
	end

	return cleanUrl, displayText
end

--- Generate tracking categories.
--- Checks for unknown parameter use and validates input arguments.
---
--- @param oldClassMatched string|nil Whether the parser matched any legacy classes in input.
--- @param rawArgs table Raw arguments passed to the module.
--- @return string categories Category wikitext.
local function renderTrackingCategories( category, class, nocat, link, url, disabled, oldClassMatched, rawArgs )
	local categories = ''
	class = _type( class ) == 'string' and _lower( class ) or ''

	--- Don't add categories if `nocat==true` or `category` is falsy,
	--- but still add any custom category passed in.
	if category and category ~= '' and M.yesno( category ) ~= false then
		-- Extract category name if in wikilink format like [[:Category:Foo Bar|Display]]
		local parsed = parseWikilink( category )
		if parsed and parsed.pageName then
			categories = _format( '[[%s]]', parsed.pageName )
		end
	end
	if M.yesno( nocat ) == true then
		return categories
	elseif M.yesno( category ) == false then -- Legacy `category=no`.
		return categories
	end

	--- Add categories for outdated classes, dummy buttons, disabled buttons,
	--- and external links.
	do
		--- Dummy button is:
		--- - Clickable (i.e. not disabled visually)
		--- - No target link and no URL
		--- - Gives feedback it'll do something, but does nothing.
		--- All matches to if-statements below should all have `ariaDisabled == true`,
		--- and therefore `aria-disabled = true`.
		if (not link or (M.yesno( link ) == false)) -- Checks for falsy or `link == 'no'`
		  and not url and not disabled then
			categories = _format( '%s [[%s]]', categories, C.trackingCategories.dummyButton )
		end
		--- Disabled button is:
		---- Greyed out (`data.disabled == true`).
		---- Also disabled to accessibility API (`aria-disabled = true`).
		if disabled then
			categories = _format( '%s [[%s]]', categories, C.trackingCategories.disabledButton )
		end

		if class and oldClassMatched then
			categories = _format( '%s [[%s]]', categories, C.trackingCategories.outdatedClasses )
		end
		if url then
			categories = _format( '%s [[%s]]', categories, C.trackingCategories.externalLinks )
		end
	end

	--- Check for unknown parameters and add appropriate categories
	local unknownParamCategories = checkForUnknowns( C.knownArgs, rawArgs ) or ''
	categories = categories .. unknownParamCategories

	return categories
end

---
--- Renders the wikitext span tags for the button.
---
--- @class mw.html: table MediaWiki DOM document content model based on HTML and RDFa.
--- @param data args table Arguments table.
--- @param iconSpan mw.html|nil Icon span element for the button.
--- @param isUrl boolean Whether target is URL
--- @param ariaDisabled boolean Whether button is disabled for ARIA API.
--- @param categories string Categories for the button.
--- @param errorText string|nil Internal string used both as error indicator, and error message text.
--- @param tblClasses table
--- @return string link Wikitext span tags for the button.
local function renderLink( data, iconSpan, isUrl, ariaDisabled, categories, errorText, tblClasses )
	--- @type mw.html: Span tag that creates the button.
	local displaySpan = mw.html.create( 'span' )
	--- @type string|nil Custom CSS style attributes for parent span node (not including plainlinks span tag if URL used).
	local styleAttributes = _type( data.style ) == 'string' and data.style or nil

	--- @future Additional ARIA attributes for button. If implement 'fake' button for use in collapsible/accordion component, don't forget to declare:
	--- displaySpan:attr('aria-haspopup', 'true') --- displaySpan:attr('aria-expanded', 'false')

	--- Classes, ARIA `role` and `aria-label`, and `style` attributes for button span tag.
	for _, aClass in ipairs( tblClasses or {} ) do
		displaySpan:addClass( aClass )
	end

	displaySpan:attr( 'role', 'button' )
	if data.aria_label then
		displaySpan:attr( 'aria-label', data.aria_label )
	end
	if styleAttributes then
		displaySpan:attr( 'style', styleAttributes )
	end

	if iconSpan ~= '' then
		displaySpan:node( iconSpan )
	end
	if data.label then
		displaySpan:wikitext( data.label )
	end

	--- @type string Wikilink that wraps around button wikitext.
	local link
	if data.disabled or ariaDisabled then
		-- `aria-disabled` attribute for no-link/dummy buttons.
		-- `aria-disabled` attribute for disabled buttons.
		displaySpan:attr( 'aria-disabled', 'true' )
		link = _format( '%s %s', tostring( displaySpan ), categories or '' )
	else
		displaySpan:attr( 'aria-disabled', 'false' )

		if isUrl then
			link = _format( '<span class="plainlinks">[%s %s]</span> %s', data.url, tostring( displaySpan ),
				categories or '' )
		elseif isUrl == false and data.link then
			link = _format( '[[:%s|%s]] %s', data.link, tostring( displaySpan ), categories or '' )
		else -- `isUrl` should be `nil` to get here, or data.link is nil.
			-- Dummy/disabled button.
			link = _format( '%s %s', tostring( displaySpan ), categories or '' )
		end
	end

	if errorText then
		--- Generate error message when viewed in preview mode of an edit.
		--[[ 		--- If previewing an edit displays first argument, otherwise second.
		--- @class ifPreview
		--- @field main function
		--- @type ifPreview
		local ifPreview = require('Module:If preview') ]]
		if M.yesno( data.nocat ) ~= true then -- Don't add category if `nocat=true`
			link = _format( '%s [[%s]]', link, C.trackingCategories.errors )
		end -- Add error message to the link if viewing in preview mode.
		mw.addWarning( errorText )
	end

	return link
end

--- Parses arguments from old template parameters. For backward compatibility.
--- Subfunction of parseParameters() for efficiency.
--- @param color? string `color` argument.
--- @param class? string `class` argument.
--- @param action? 'progressive'|'destructive'|'default'|string `action` argument.
--- @return string class String with class that did not match, likely custom class(es).
--- @return string action Returns action resolved.
--- @return string|nil matched Value of matched class if any of the arguments matched.
local function checkColorAndClass( color, class, action )
	local actionValue = (_type( action ) == 'string' and action) or ''
	color = (_type( color ) == 'string' and color) or ''
	class = (_type( class ) == 'string' and _lower( class )) or ''

	if color == '' and class == '' then
		return '', actionValue, nil
	end

	-- Resolve action, check against set constants.
	for actionName, set in pairs( C.legacyClassSets ) do
		if set[ color ] and not C.legacyClassSets[ actionName ][ actionValue ] then
			return class, actionName, actionValue -- Found `color`.
		end
		if set[ class ] and not C.legacyClassSets[ actionName ][ actionValue ] then
			return '', actionName, actionValue -- Found `class`.
		end
		if set[ actionValue ] then
			return class, actionName, actionValue -- Found `action`.
		end
	end

	-- No match.
	return class, '', nil
end

--- Constructs the attributes for the wikitext/HTML elements.
--- @param parsedArgs args Parsed arguments.
--- @param ariaDisabled boolean Whether button is disabled for ARIA API.
--- @return args data Data, such as attributes, ready to be assembled.
--- @return mw.html|nil iconSpan
--- @return boolean isUrl
--- @return boolean ariaDisabled
--- @return string|nil oldClassMatched
--- @return string|nil errorText Internal string used as both an indicator of an error, and error message text.
--- @return table tblClasses
--- @return mw.title pageTitleObject
local function makeLinkData( parsedArgs, ariaDisabled )
	local data = {}
	local tblClasses = { C.cssClasses.base, C.cssClasses.fakeButton }
	local iconSpan = nil
	local isUrl = false
	--- @type string|nil
	local errorText = nil
	local isSamePage = false
	local pageTitleObject = mw.title.getCurrentTitle()

	--- @todo do i need string check -- type(parsedArgs.icon) == 'string'
	data.icon = parsedArgs.icon or nil
	data.disabled = parsedArgs.disabled

	-- Decide link vs. URL vs. none
	-- URL has priority over link if both provided.
	if parsedArgs.url then
		isUrl = true
		local generatedLabel
		-- Process URL with enhanced cleaning and label generation
		data.url, generatedLabel = M.url( parsedArgs.url, parsedArgs.label )
		-- Use provided label or fall back to derived label.
		data.label = parsedArgs.label or generatedLabel
	elseif parsedArgs.link then
		isUrl = false
		data.link = parsedArgs.link
		data.label = parsedArgs.label
		-- Same-page detection
		local linkTitleObject = mw.title.new( data.link )
		if linkTitleObject and pageTitleObject then
			isSamePage = (linkTitleObject.fullText == pageTitleObject.fullText)
		end
	elseif not parsedArgs.url and not parsedArgs.link then
		data.label = parsedArgs.label -- Dummy button as has no link or URL.
	end

	local class, action, oldClassMatched = checkColorAndClass( parsedArgs.color, parsedArgs.class, parsedArgs.action )
	local weight = _type( parsedArgs.weight ) == 'string' and parsedArgs.weight or C.buttonDefaults.weight
	local size = _type( parsedArgs.size ) == 'string' and parsedArgs.size or C.buttonDefaults.size
	_table_insert( tblClasses, C.cssClasses.actionPrefix .. action )
	_table_insert( tblClasses, C.cssClasses.weightPrefix .. weight )
	_table_insert( tblClasses, C.cssClasses.sizePrefix .. size )
	if (class and class ~= '') then
		_table_insert( tblClasses, class ) -- Custom class.
		data.class = class
	end

	if data.disabled then
		_table_insert( tblClasses, C.cssClasses.disabled )
	else
		_table_insert( tblClasses, C.cssClasses.enabled )
	end
	mw.log( 'Debug classes: ' .. table.concat( tblClasses, ' ' ) )
	mw.log( 'Debug action: ' .. (action or 'nil') )
	mw.log( 'Debug label: ' .. (data.label or 'nil') )

	-- Cannot check length earlier as value changes above.
	local labelLength = (_type( data.label ) == 'string' and mw.ustring.len( data.label )) or 0

	if data.label and labelLength > C.labelLimits.maxLength then
		_table_insert( tblClasses, C.cssClasses.wordWrap )
	end

	--- @TODO Check if current page is the target link, if so, make button darker.
	--- @TODO Must still actually use this in the CSS file.
	if isSamePage then
		_table_insert( tblClasses, C.cssClasses.samePage )
	end

	if data.icon then -- Store until end of module for icons CSS output logic.
		iconSpan = mw.html.create( 'span' )
		iconSpan:addClass( C.cssClasses.icon )
		iconSpan:addClass( _format( '%s%s', C.cssClasses.iconPrefix, data.icon ) )
		iconSpan:attr( 'aria-hidden', 'true' )

		if not data.label then
			-- Icon-only button, add extra class for styling.
			_table_insert( tblClasses, C.cssClasses.iconOnly )
		end
	end

	-- Label length checks.
	if data.label then
		if labelLength > C.labelLimits.maxLength then
			errorText = C.labelLengthWarningText
		elseif labelLength < C.labelLimits.minLength then
			_table_insert( tblClasses, C.cssClasses.shortLabel )
		end
	end

	local hasNoLabel = not data.label and not parsedArgs.aria_label

	-- Error if no aria-label and no visible label, for any non-disabled button
	-- (whether it has a link/URL or is a dummy button)
	if hasNoLabel and not parsedArgs.disabled then
		errorText = errorText and _format( '%s %s', errorText, C.noAriaLabelWarningText ) or
			C.noAriaLabelWarningText
	end

	data.aria_label = parsedArgs.aria_label

	return data, iconSpan, isUrl, ariaDisabled, oldClassMatched, errorText, tblClasses, pageTitleObject
end

--- Parses the module's arguments for backward compatibility.
--- 	Validates module arguments and returns parsed arguments.
--- With deprecated parameters from old templates and modules.
--- @param rawArgs args table Module arguments.
--- @return args parsedArgs Parsed arguments.
--- @return boolean ariaDisabled Whether button is disabled for ARIA API.
local function parseParameters( rawArgs )
	--- It's weird that we may make a link a label, but if we truly
	--- only got positional argument `1`, then that would mean it's
	--- intentional to make both the link and label the same.
	--- `label` value priority: `label` > `text` > `2` > `1`
	rawArgs.label = rawArgs.label or rawArgs.text or rawArgs[ 2 ] or rawArgs[ 1 ] or nil
	rawArgs.disabled = M.yesno( rawArgs.disabled ) or (M.yesno( rawArgs.link ) == false) or false
	rawArgs.link = rawArgs.link or rawArgs[ 1 ] or nil
	rawArgs[ 1 ] = nil -- Remove positional rawArgs after assigning
	rawArgs[ 2 ] = nil

	if rawArgs.disabled then
		-- If disabled, must not generate link. Usually doesn't but in case.
		rawArgs.link = nil
		rawArgs.url = nil
	end

	local parsedLink = rawArgs.link and parseWikilink( rawArgs.link )

	--- @TODO double check next five lines of code
	-- If had no label, give autogenerated label.
	rawArgs.label = rawArgs.label or (parsedLink and parsedLink.displayText)
	-- Try assign newly cleaned link. Fallback if needed.
	rawArgs.link = (parsedLink and parsedLink.pageName) or rawArgs.link

	if rawArgs.link == '' then
		rawArgs.link = nil -- Invalid wikilink, remove it.
	end

	if rawArgs.link and parsedLink then
		-- Fallback to displayText if there was any in wikilink, or `Foo § Bar` or just pagename.
		rawArgs.label = rawArgs.label or parsedLink.displayText or nil
		rawArgs.link = parsedLink.pageName or rawArgs.link or nil

		if rawArgs.link == '' then -- If no link leftover, remove it.
			rawArgs.link = nil
		end

		if parsedLink.pageName and parsedLink.sectionHeading then
			rawArgs.link = _format( '%s#%s', parsedLink.pageName, parsedLink.sectionHeading )
		elseif parsedLink.isSectionLink and parsedLink.sectionHeading then
			rawArgs.link = _format( '#%s', parsedLink.sectionHeading )
		elseif parsedLink.pageName then
			rawArgs.link = parsedLink.pageName
		else
			rawArgs.link = nil
		end
	end

	--- `aria-disabled = true` if no link whatsoever, always. Make dummy button. But for accessibility,
	--- ARIA must know it won't do anything.
	local ariaDisabled = false
	if rawArgs.disabled or (not rawArgs.link and not rawArgs.url) then
		ariaDisabled = true
	end
	--- @TODO _OPTION_ to forcefully disable dummy buttons by setting:
	---- rawArgs.disabled = true

	if rawArgs.label then
		--- @TODO refactor: decide if we want to allow [[ or ]] in label, and if so, how to handle it.
		--[=[ -- Plain search if [[ or ]] present, to wrap <nowiki> tags.
		if string.find(rawArgs.label, '[[', 1, true) or string.find(rawArgs.label, ']]', 1, true) then
			rawArgs.label = GSUB(rawArgs.label, '%[%[', '')
			rawArgs.label = GSUB(rawArgs.label, '%]%]', '')
			if rawArgs.label == '' then -- If no label leftover, remove it.
				rawArgs.label = nil
			end
		end ]=]
		rawArgs.label = mw.text.nowiki( rawArgs.label )
	else
		rawArgs.label = nil
	end

	rawArgs.nocat = M.yesno( rawArgs.nocat )

	-- Normalize ARIA label keys
	rawArgs.aria_label = rawArgs.aria_label or rawArgs[ 'aria-label' ] or rawArgs.arialabel
	rawArgs[ 'aria-label' ] = nil
	rawArgs.arialabel = nil

	return rawArgs, ariaDisabled
end

--- Interface for other Lua modules.
--- Function can be called by other Lua modules to generate wikitext.
--- Note: Does not render CSS files or pre-process arguments like `M.main()`.
---
--- @param rawArgs args Module's arguments.
--- @return string data Wikitext that renders button, without CSS files.
function M._main( rawArgs )
	local parsedArgs, ariaDisabled = parseParameters( rawArgs )

	local data, iconSpan, isUrl, oldClassMatched, errorText, tblClasses, pageTitleObject
	data, iconSpan, isUrl, ariaDisabled, oldClassMatched, errorText, tblClasses, pageTitleObject = makeLinkData(
		parsedArgs, ariaDisabled )

	local isExcludedNamespace = false
	for _, namespace in ipairs( C.excludedNamespaces ) do -- Don't add tracking categories in excluded namespaces.
		if pageTitleObject.nsText == namespace then
			isExcludedNamespace = true
			parsedArgs.nocat = true -- Redundant, but whatever.
			break
		end
	end

	local categories --- @type string
	if not isExcludedNamespace then
		categories = renderTrackingCategories( parsedArgs.category, data.class, parsedArgs.nocat,
			data.link, data.url, data.disabled, oldClassMatched, rawArgs )
	end

	return renderLink( data, iconSpan, isUrl, ariaDisabled, categories, errorText, tblClasses )
end

--- Module entry point. Interface for templates and modules.
---
--- Pre-processes arguments, inserts CSS files, and renders the button.
--- @usage Called via the `{{#invoke: Clickable button | main }}` parser function.
--- @param frame args Frame object passed by the MediaWiki parser.
--- @return string|nil wikitextOutput Wikitext for insertion on a wiki page.
function M.main( frame )
	local rawArgs = getArgs( frame, {
		wrappers = C.wrapperTemplates,
		valueFunc = function ( key, value ) -- Custom formatting function for arguments.
			value = mw.text.trim( value ) -- Remove whitespace.
			if not value or value == '' then -- Remove blank arguments.
				return nil
			end
			if C.lowercaseArgs[ key ] then -- Convert to lowercase.
				return _mw_lower( value )
			else
				return value
			end
		end,
	} )

	-- Return empty string, and preview warning if no arguments supplied.
	do
		local hasInput = false
		for _, v in pairs( rawArgs ) do
			if v and v ~= '' then
				hasInput = true
				break
			end
		end

		if not hasInput then
			mw.addWarning( C.noArgsWarningText )
			return ''
		end
	end

	local output = M._main( rawArgs )

	local outputCSS = frame:extensionTag( 'templatestyles', '', { -- Insert CSS files into the output.
		src = C.baseCSS, -- Duplicates are de-duplicated by Parsoid.
	} )

	if rawArgs.icon then
		outputCSS = _format( '%s%s', outputCSS, frame:extensionTag( 'templatestyles', '', {
			src = C.iconsCSS,
		} ) )
	end

	return _format( '%s%s', outputCSS, output )
end

return M