<?php
/**
 * Lightweight, on-demand accessibility checks for admin use.
 *
 * @package AccessibilityWidget
 */

namespace AccessibilityWidget\Includes;

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

final class AccessibilityAudit {

	/**
	 * Number of distinct heuristic checks runChecks() executes on a parsed page.
	 */
	private const CHECK_COUNT = 12;

	/**
	 * Issue types that mean no checks actually ran (parse/transport failures).
	 *
	 * @var list<string>
	 */
	private const META_FAILURE_TYPES = [ 'empty_content', 'dom_unavailable', 'parse_error' ];

	/**
	 * @return array{issues: list<array<string, string>>, score: int, checks_run: int, summary: string}
	 */
	public static function buildReport( string $html ): array {
		$issues = self::runChecks( $html );
		$score  = self::computeScore( $issues );

		$checks_run = self::CHECK_COUNT;
		if ( count( $issues ) === 1 && in_array( $issues[0]['type'] ?? '', self::META_FAILURE_TYPES, true ) ) {
			$checks_run = 0;
		}

		return [
			'issues'      => $issues,
			'score'       => $score,
			'checks_run'  => $checks_run,
			'summary'     => self::buildSummary( $score, $issues ),
		];
	}

	/**
	 * @return list<array<string, string>>
	 */
	public static function runChecks( string $html ): array {
		$html = trim( $html );
		if ( $html === '' ) {
			return [
				[
					'type'       => 'empty_content',
					'severity'   => 'warning',
					'message'    => __( 'No HTML content was returned for this URL.', 'wp-powersuite' ),
					'suggestion' => __( 'Verify the URL is public and returns a normal page response.', 'wp-powersuite' ),
				],
			];
		}

		if ( ! class_exists( \DOMDocument::class ) ) {
			return [
				[
					'type'       => 'dom_unavailable',
					'severity'   => 'info',
					'message'    => __( 'DOMDocument is not available on this server.', 'wp-powersuite' ),
					'suggestion' => __( 'Install or enable the PHP DOM extension to run HTML checks.', 'wp-powersuite' ),
				],
			];
		}

		$issues = [];

		$previous = libxml_use_internal_errors( true );
		$doc      = new \DOMDocument();
		$loaded   = $doc->loadHTML(
			'<?xml encoding="utf-8" ?>' . $html,
			LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD
		);
		libxml_clear_errors();
		libxml_use_internal_errors( $previous );

		if ( ! $loaded ) {
			return [
				[
					'type'       => 'parse_error',
					'severity'   => 'warning',
					'message'    => __( 'Could not parse the page HTML.', 'wp-powersuite' ),
					'suggestion' => __( 'Try another URL or check that the page returns valid HTML.', 'wp-powersuite' ),
				],
			];
		}

		$xpath = new \DOMXPath( $doc );

		self::checkImagesMissingAlt( $xpath, $issues );
		self::checkEmptyLinksAndButtons( $xpath, $issues );
		self::checkHeadingStructure( $xpath, $issues );
		self::checkFormLabels( $xpath, $issues );
		self::checkVagueLinks( $xpath, $issues );
		self::checkImageButtons( $xpath, $issues );
		self::checkBasicContrast( $xpath, $issues );
		self::checkDocumentLanguage( $xpath, $issues );
		self::checkPageTitle( $xpath, $issues );
		self::checkIframeTitles( $xpath, $issues );
		self::checkDuplicateIds( $xpath, $issues );
		self::checkPositiveTabindex( $xpath, $issues );

		if ( $issues === [] ) {
			self::addIssue(
				$issues,
				'no_issues',
				'info',
				__( 'No common issues were detected in this quick scan.', 'wp-powersuite' ),
				__( 'This is not a full WCAG audit. Manual testing with assistive technology is still recommended.', 'wp-powersuite' ),
				''
			);
		}

		return $issues;
	}

	/**
	 * @param list<array<string, string>> $issues
	 */
	private static function addIssue( array &$issues, string $type, string $severity, string $message, string $suggestion, string $wcag ): void {
		$issues[] = [
			'type'       => $type,
			'severity'   => $severity,
			'message'    => $message,
			'suggestion' => $suggestion,
			'wcag'       => $wcag,
		];
	}

	/**
	 * @param list<array<string, string>> $issues
	 */
	private static function computeScore( array $issues ): int {
		$score = 100;
		foreach ( $issues as $issue ) {
			if ( ( $issue['type'] ?? '' ) === 'no_issues' ) {
				continue;
			}
			$severity = $issue['severity'] ?? 'info';
			$score   -= $severity === 'warning' ? 8 : 3;
		}
		return max( 0, min( 100, $score ) );
	}

	/**
	 * @param list<array<string, string>> $issues
	 */
	private static function buildSummary( int $score, array $issues ): string {
		$issue_count = 0;
		foreach ( $issues as $issue ) {
			if ( ( $issue['type'] ?? '' ) !== 'no_issues' ) {
				++$issue_count;
			}
		}

		if ( $issue_count === 0 ) {
			return __( 'Advisory score: 100/100. No common issues detected in this lightweight scan.', 'wp-powersuite' );
		}

		return sprintf(
			/* translators: 1: advisory score, 2: number of findings */
			__( 'Advisory WCAG-oriented score: %1$d/100 based on %2$d finding(s). This is not legal or full WCAG certification.', 'wp-powersuite' ),
			$score,
			$issue_count
		);
	}

	/**
	 * @param list<array{type: string, severity: string, message: string, suggestion: string}> $issues
	 */
	private static function checkImagesMissingAlt( \DOMXPath $xpath, array &$issues ): void {
		$images = $xpath->query( '//img[not(@alt)]' );
		if ( ! $images instanceof \DOMNodeList ) {
			return;
		}

		$count = $images->length;
		if ( $count > 0 ) {
			self::addIssue(
				$issues,
				'missing_alt',
				'warning',
				sprintf(
					/* translators: %d: number of images */
					_n( '%d image is missing an alt attribute.', '%d images are missing alt attributes.', $count, 'wp-powersuite' ),
					$count
				),
				__( 'Add descriptive alt text for informative images, or alt="" for decorative images.', 'wp-powersuite' ),
				'WCAG 1.1.1'
			);
		}
	}

	/**
	 * @param list<array{type: string, severity: string, message: string, suggestion: string}> $issues
	 */
	private static function checkEmptyLinksAndButtons( \DOMXPath $xpath, array &$issues ): void {
		$empty_links = 0;
		$links       = $xpath->query( '//a' );
		if ( $links instanceof \DOMNodeList ) {
			foreach ( $links as $link ) {
				if ( ! $link instanceof \DOMElement ) {
					continue;
				}
				if ( self::elementHasAccessibleName( $link, $xpath ) ) {
					continue;
				}
				++$empty_links;
			}
		}

		$empty_buttons = 0;
		$buttons       = $xpath->query( '//button' );
		if ( $buttons instanceof \DOMNodeList ) {
			foreach ( $buttons as $button ) {
				if ( ! $button instanceof \DOMElement ) {
					continue;
				}
				if ( self::elementHasAccessibleName( $button, $xpath ) ) {
					continue;
				}
				++$empty_buttons;
			}
		}

		if ( $empty_links > 0 ) {
			self::addIssue(
				$issues,
				'empty_links',
				'warning',
				sprintf(
					/* translators: %d: number of links */
					_n( '%d link appears to have no accessible name.', '%d links appear to have no accessible name.', $empty_links, 'wp-powersuite' ),
					$empty_links
				),
				__( 'Add visible link text or an aria-label that describes the destination.', 'wp-powersuite' ),
				'WCAG 2.4.4, 4.1.2'
			);
		}

		if ( $empty_buttons > 0 ) {
			self::addIssue(
				$issues,
				'empty_buttons',
				'warning',
				sprintf(
					/* translators: %d: number of buttons */
					_n( '%d button appears to have no accessible name.', '%d buttons appear to have no accessible name.', $empty_buttons, 'wp-powersuite' ),
					$empty_buttons
				),
				__( 'Add visible button text or an aria-label.', 'wp-powersuite' ),
				'WCAG 4.1.2'
			);
		}
	}

	/**
	 * @param list<array{type: string, severity: string, message: string, suggestion: string}> $issues
	 */
	private static function checkHeadingStructure( \DOMXPath $xpath, array &$issues ): void {
		$headings = $xpath->query( '//h1|//h2|//h3|//h4|//h5|//h6' );
		if ( ! $headings instanceof \DOMNodeList || $headings->length === 0 ) {
			self::addIssue(
				$issues,
				'missing_headings',
				'warning',
				__( 'No headings were found on this page.', 'wp-powersuite' ),
				__( 'Use a single H1 for the main page topic and logical H2–H6 subheadings.', 'wp-powersuite' ),
				'WCAG 1.3.1, 2.4.6'
			);
			return;
		}

		$h1_count = $xpath->query( '//h1' );
		$h1_total = $h1_count instanceof \DOMNodeList ? $h1_count->length : 0;

		if ( $h1_total === 0 ) {
			self::addIssue(
				$issues,
				'missing_h1',
				'warning',
				__( 'No H1 heading was found.', 'wp-powersuite' ),
				__( 'Add one H1 that describes the main purpose of the page.', 'wp-powersuite' ),
				'WCAG 1.3.1, 2.4.6'
			);
		} elseif ( $h1_total > 1 ) {
			self::addIssue(
				$issues,
				'multiple_h1',
				'warning',
				sprintf(
					/* translators: %d: number of H1 elements */
					__( '%d H1 headings were found.', 'wp-powersuite' ),
					$h1_total
				),
				__( 'Use a single H1 per page when possible, then nest H2–H6 below it.', 'wp-powersuite' ),
				'WCAG 1.3.1'
			);
		}

		$prev_level = 0;
		foreach ( $headings as $heading ) {
			if ( ! $heading instanceof \DOMElement ) {
				continue;
			}
			$level = (int) substr( strtolower( $heading->tagName ), 1 );
			if ( $prev_level > 0 && $level > $prev_level + 1 ) {
				self::addIssue(
					$issues,
					'skipped_heading_level',
					'warning',
					sprintf(
						/* translators: 1: previous heading level, 2: current heading level */
						__( 'A heading level was skipped (H%1$d followed by H%2$d).', 'wp-powersuite' ),
						$prev_level,
						$level
					),
					__( 'Increase heading levels one step at a time (for example, H2 then H3).', 'wp-powersuite' ),
					'WCAG 1.3.1'
				);
				break;
			}
			$prev_level = $level;
		}
	}

	/**
	 * @param list<array{type: string, severity: string, message: string, suggestion: string}> $issues
	 */
	private static function checkFormLabels( \DOMXPath $xpath, array &$issues ): void {
		$fields = $xpath->query( '//input[not(@type="hidden") and not(@type="submit") and not(@type="button") and not(@type="reset")]|//textarea|//select' );
		if ( ! $fields instanceof \DOMNodeList ) {
			return;
		}

		$unlabeled = 0;
		foreach ( $fields as $field ) {
			if ( ! $field instanceof \DOMElement ) {
				continue;
			}
			if ( self::formFieldHasLabel( $field, $xpath ) ) {
				continue;
			}
			++$unlabeled;
		}

		if ( $unlabeled > 0 ) {
			self::addIssue(
				$issues,
				'unlabeled_fields',
				'warning',
				sprintf(
					/* translators: %d: number of fields */
					_n( '%d form field may be missing a label.', '%d form fields may be missing labels.', $unlabeled, 'wp-powersuite' ),
					$unlabeled
				),
				__( 'Associate each field with a <label> or aria-label / aria-labelledby.', 'wp-powersuite' ),
				'WCAG 1.3.1, 3.3.2, 4.1.2'
			);
		}
	}

	/**
	 * @param list<array{type: string, severity: string, message: string, suggestion: string}> $issues
	 */
	private static function checkVagueLinks( \DOMXPath $xpath, array &$issues ): void {
		$vague_patterns = [
			'click here',
			'read more',
			'learn more',
			'here',
			'more',
		];

		$links = $xpath->query( '//a' );
		if ( ! $links instanceof \DOMNodeList ) {
			return;
		}

		$vague_count = 0;
		foreach ( $links as $link ) {
			if ( ! $link instanceof \DOMElement ) {
				continue;
			}
			$text = strtolower( trim( preg_replace( '/\s+/', ' ', wp_strip_all_tags( $link->textContent ?? '' ) ) ?? '' ) );
			if ( $text === '' ) {
				continue;
			}
			foreach ( $vague_patterns as $pattern ) {
				if ( $text === $pattern ) {
					++$vague_count;
					break;
				}
			}
		}

		if ( $vague_count > 0 ) {
			self::addIssue(
				$issues,
				'vague_links',
				'info',
				sprintf(
					/* translators: %d: number of links */
					_n( '%d link uses vague text such as “click here”.', '%d links use vague text such as “click here”.', $vague_count, 'wp-powersuite' ),
					$vague_count
				),
				__( 'Use link text that describes the destination or action out of context.', 'wp-powersuite' ),
				'WCAG 2.4.4'
			);
		}
	}

	/**
	 * @param list<array{type: string, severity: string, message: string, suggestion: string}> $issues
	 */
	private static function checkImageButtons( \DOMXPath $xpath, array &$issues ): void {
		$inputs = $xpath->query( '//input[@type="image"]' );
		if ( ! $inputs instanceof \DOMNodeList || $inputs->length === 0 ) {
			return;
		}

		$unlabeled = 0;
		foreach ( $inputs as $input ) {
			if ( ! $input instanceof \DOMElement ) {
				continue;
			}
			$alt   = trim( $input->getAttribute( 'alt' ) );
			$label = trim( $input->getAttribute( 'aria-label' ) );
			if ( $alt === '' && $label === '' ) {
				++$unlabeled;
			}
		}

		if ( $unlabeled > 0 ) {
			self::addIssue(
				$issues,
				'image_buttons',
				'warning',
				sprintf(
					/* translators: %d: number of image inputs */
					_n( '%d image button is missing alt or aria-label.', '%d image buttons are missing alt or aria-label.', $unlabeled, 'wp-powersuite' ),
					$unlabeled
				),
				__( 'Provide alt text or aria-label for image submit buttons.', 'wp-powersuite' ),
				'WCAG 1.1.1, 4.1.2'
			);
		}
	}

	/**
	 * @param list<array{type: string, severity: string, message: string, suggestion: string}> $issues
	 */
	private static function checkBasicContrast( \DOMXPath $xpath, array &$issues ): void {
		$nodes = $xpath->query( '//*[@style]' );
		if ( ! $nodes instanceof \DOMNodeList ) {
			return;
		}

		$low_contrast = 0;
		foreach ( $nodes as $node ) {
			if ( ! $node instanceof \DOMElement ) {
				continue;
			}
			$style = strtolower( $node->getAttribute( 'style' ) );
			if ( ! str_contains( $style, 'color' ) || ! str_contains( $style, 'background' ) ) {
				continue;
			}
			$fg = self::extractCssColor( $style, 'color' );
			$bg = self::extractCssColor( $style, 'background-color' ) ?? self::extractCssColor( $style, 'background' );
			if ( $fg === null || $bg === null ) {
				continue;
			}
			$ratio = self::contrastRatio( $fg, $bg );
			if ( $ratio !== null && $ratio < 4.5 ) {
				++$low_contrast;
			}
		}

		if ( $low_contrast > 0 ) {
			self::addIssue(
				$issues,
				'low_contrast_inline',
				'info',
				sprintf(
					/* translators: %d: number of elements */
					_n( '%d inline-styled element may have low text contrast.', '%d inline-styled elements may have low text contrast.', $low_contrast, 'wp-powersuite' ),
					$low_contrast
				),
				__( 'Aim for at least 4.5:1 contrast for normal text. This check only inspects inline styles, not theme CSS.', 'wp-powersuite' ),
				'WCAG 1.4.3'
			);
		}
	}

	/**
	 * @param list<array<string, string>> $issues
	 */
	private static function checkDocumentLanguage( \DOMXPath $xpath, array &$issues ): void {
		$html = $xpath->query( '/html' );
		if ( ! $html instanceof \DOMNodeList || $html->length === 0 ) {
			return;
		}
		$node = $html->item( 0 );
		if ( ! $node instanceof \DOMElement ) {
			return;
		}
		if ( trim( $node->getAttribute( 'lang' ) ) === '' ) {
			self::addIssue(
				$issues,
				'missing_lang',
				'warning',
				__( 'The page is missing a lang attribute on the html element.', 'wp-powersuite' ),
				__( 'Add lang="en" (or the correct language code) to the html element.', 'wp-powersuite' ),
				'WCAG 3.1.1'
			);
		}
	}

	/**
	 * @param list<array<string, string>> $issues
	 */
	private static function checkPageTitle( \DOMXPath $xpath, array &$issues ): void {
		$titles = $xpath->query( '//title' );
		$text   = '';
		if ( $titles instanceof \DOMNodeList && $titles->length > 0 ) {
			$text = trim( wp_strip_all_tags( $titles->item( 0 )->textContent ?? '' ) );
		}
		if ( $text === '' ) {
			self::addIssue(
				$issues,
				'missing_title',
				'warning',
				__( 'The page appears to be missing a descriptive title element.', 'wp-powersuite' ),
				__( 'Add a unique, descriptive <title> for each page.', 'wp-powersuite' ),
				'WCAG 2.4.2'
			);
		}
	}

	/**
	 * @param list<array<string, string>> $issues
	 */
	private static function checkIframeTitles( \DOMXPath $xpath, array &$issues ): void {
		$iframes = $xpath->query( '//iframe' );
		if ( ! $iframes instanceof \DOMNodeList ) {
			return;
		}
		$missing = 0;
		foreach ( $iframes as $iframe ) {
			if ( ! $iframe instanceof \DOMElement ) {
				continue;
			}
			if ( trim( $iframe->getAttribute( 'title' ) ) === '' && trim( $iframe->getAttribute( 'aria-label' ) ) === '' ) {
				++$missing;
			}
		}
		if ( $missing > 0 ) {
			self::addIssue(
				$issues,
				'iframe_missing_title',
				'warning',
				sprintf(
					/* translators: %d: number of iframes */
					_n( '%d iframe is missing a title or aria-label.', '%d iframes are missing a title or aria-label.', $missing, 'wp-powersuite' ),
					$missing
				),
				__( 'Add a descriptive title attribute to embedded frames.', 'wp-powersuite' ),
				'WCAG 4.1.2'
			);
		}
	}

	/**
	 * @param list<array<string, string>> $issues
	 */
	private static function checkDuplicateIds( \DOMXPath $xpath, array &$issues ): void {
		$nodes = $xpath->query( '//*[@id]' );
		if ( ! $nodes instanceof \DOMNodeList ) {
			return;
		}
		$seen = [];
		$dups = 0;
		foreach ( $nodes as $node ) {
			if ( ! $node instanceof \DOMElement ) {
				continue;
			}
			$id = trim( $node->getAttribute( 'id' ) );
			if ( $id === '' ) {
				continue;
			}
			if ( isset( $seen[ $id ] ) ) {
				++$dups;
			} else {
				$seen[ $id ] = true;
			}
		}
		if ( $dups > 0 ) {
			self::addIssue(
				$issues,
				'duplicate_ids',
				'warning',
				sprintf(
					/* translators: %d: number of duplicate IDs */
					_n( '%d duplicate id value was found.', '%d duplicate id values were found.', $dups, 'wp-powersuite' ),
					$dups
				),
				__( 'Ensure every id attribute value is unique within the page.', 'wp-powersuite' ),
				'WCAG 4.1.1'
			);
		}
	}

	/**
	 * @param list<array<string, string>> $issues
	 */
	private static function checkPositiveTabindex( \DOMXPath $xpath, array &$issues ): void {
		$nodes = $xpath->query( '//*[@tabindex]' );
		if ( ! $nodes instanceof \DOMNodeList ) {
			return;
		}
		$count = 0;
		foreach ( $nodes as $node ) {
			if ( ! $node instanceof \DOMElement ) {
				continue;
			}
			$tabindex = (int) $node->getAttribute( 'tabindex' );
			if ( $tabindex > 0 ) {
				++$count;
			}
		}
		if ( $count > 0 ) {
			self::addIssue(
				$issues,
				'positive_tabindex',
				'info',
				sprintf(
					/* translators: %d: number of elements */
					_n( '%d element uses a positive tabindex.', '%d elements use a positive tabindex.', $count, 'wp-powersuite' ),
					$count
				),
				__( 'Avoid positive tabindex values; use natural DOM order and visible focus styles instead.', 'wp-powersuite' ),
				'WCAG 2.4.3'
			);
		}
	}

	private static function elementHasAccessibleName( \DOMElement $element, \DOMXPath $xpath ): bool {
		$text = trim( preg_replace( '/\s+/', ' ', wp_strip_all_tags( $element->textContent ?? '' ) ) ?? '' );
		if ( $text !== '' ) {
			return true;
		}
		if ( trim( $element->getAttribute( 'aria-label' ) ) !== '' ) {
			return true;
		}
		$labelledby = trim( $element->getAttribute( 'aria-labelledby' ) );
		if ( $labelledby !== '' ) {
			$ref = $xpath->query( '//*[@id="' . self::escapeXPathLiteral( $labelledby ) . '"]' );
			if ( $ref instanceof \DOMNodeList && $ref->length > 0 ) {
				$ref_node = $ref->item( 0 );
				if ( $ref_node instanceof \DOMElement ) {
					$ref_text = trim( wp_strip_all_tags( $ref_node->textContent ?? '' ) );
					if ( $ref_text !== '' ) {
						return true;
					}
				}
			}
		}
		$title = trim( $element->getAttribute( 'title' ) );
		return $title !== '';
	}

	private static function formFieldHasLabel( \DOMElement $field, \DOMXPath $xpath ): bool {
		if ( trim( $field->getAttribute( 'aria-label' ) ) !== '' ) {
			return true;
		}
		$labelledby = trim( $field->getAttribute( 'aria-labelledby' ) );
		if ( $labelledby !== '' ) {
			return true;
		}
		$id = trim( $field->getAttribute( 'id' ) );
		if ( $id !== '' ) {
			$labels = $xpath->query( '//label[@for="' . self::escapeXPathLiteral( $id ) . '"]' );
			if ( $labels instanceof \DOMNodeList && $labels->length > 0 ) {
				return true;
			}
		}
		$parent = $field->parentNode;
		if ( $parent instanceof \DOMElement && strtolower( $parent->tagName ) === 'label' ) {
			return trim( wp_strip_all_tags( $parent->textContent ?? '' ) ) !== '';
		}
		return false;
	}

	private static function escapeXPathLiteral( string $value ): string {
		if ( ! str_contains( $value, '"' ) ) {
			return $value;
		}
		if ( ! str_contains( $value, "'" ) ) {
			return "'" . $value . "'";
		}
		$parts = explode( '"', $value );
		$built = 'concat("' . implode( '", \'"\', "', $parts ) . '")';
		return $built;
	}

	/**
	 * @return array{r: float, g: float, b: float}|null
	 */
	private static function parseHexColor( string $color ): ?array {
		$color = trim( $color );
		if ( preg_match( '/^#([a-f0-9]{3})$/i', $color, $m ) ) {
			$hex = $m[1];
			return [
				'r' => hexdec( str_repeat( $hex[0], 2 ) ) / 255,
				'g' => hexdec( str_repeat( $hex[1], 2 ) ) / 255,
				'b' => hexdec( str_repeat( $hex[2], 2 ) ) / 255,
			];
		}
		if ( preg_match( '/^#([a-f0-9]{6})$/i', $color, $m ) ) {
			$hex = $m[1];
			return [
				'r' => hexdec( substr( $hex, 0, 2 ) ) / 255,
				'g' => hexdec( substr( $hex, 2, 2 ) ) / 255,
				'b' => hexdec( substr( $hex, 4, 2 ) ) / 255,
			];
		}
		return null;
	}

	private static function extractCssColor( string $style, string $property ): ?string {
		$pattern = '/' . preg_quote( $property, '/' ) . '\s*:\s*([^;]+)/i';
		if ( ! preg_match( $pattern, $style, $m ) ) {
			return null;
		}
		$value = trim( $m[1] );
		if ( preg_match( '/#([a-f0-9]{3}|[a-f0-9]{6})\b/i', $value, $hex ) ) {
			return '#' . $hex[1];
		}
		return null;
	}

	private static function relativeLuminance( array $rgb ): float {
		$channels = [];
		foreach ( [ 'r', 'g', 'b' ] as $channel ) {
			$c = $rgb[ $channel ];
			$channels[] = $c <= 0.03928 ? $c / 12.92 : pow( ( $c + 0.055 ) / 1.055, 2.4 );
		}
		return 0.2126 * $channels[0] + 0.7152 * $channels[1] + 0.0722 * $channels[2];
	}

	private static function contrastRatio( string $fg, string $bg ): ?float {
		$fg_rgb = self::parseHexColor( $fg );
		$bg_rgb = self::parseHexColor( $bg );
		if ( $fg_rgb === null || $bg_rgb === null ) {
			return null;
		}
		$l1 = self::relativeLuminance( $fg_rgb );
		$l2 = self::relativeLuminance( $bg_rgb );
		$lighter = max( $l1, $l2 );
		$darker  = min( $l1, $l2 );
		return ( $lighter + 0.05 ) / ( $darker + 0.05 );
	}
}
