<?php
/**
 * Analyzer: extracts the metadata that actually shipped in the rendered
 * HTML and detects issues. The source of truth is always the final
 * rendered output - never plugin settings or database metadata.
 *
 * Every issue carries: code, area (search|social), severity
 * (critical|warning|notice), message, evidence (raw HTML snippets).
 *
 * @package search-social-appearance-audit
 */

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

class SSAA_Analyzer {

	/** Timeout for the og:image reachability check. */
	const IMAGE_CHECK_TIMEOUT = 5;

	/** OG properties that must appear at most once. og:image is excluded - multiple og:image values are valid (platforms offer a choice). */
	const SINGULAR_OG_PROPS = array( 'og:title', 'og:description', 'og:url', 'og:type', 'og:site_name' );

	/**
	 * Search-area codes that become MOOT on a noindexed page (1.4.0).
	 *
	 * The rule: a page carrying noindex can never appear in a search result, so
	 * a finding about how it would PRESENT there is not a defect - it is
	 * describing something that will never be rendered. Those findings are
	 * demoted to notice (they stay visible and counted; notices never demote an
	 * OK row) rather than dropped, so nothing is ever silently hidden.
	 *
	 * The list is an explicit opt-IN, and that direction is deliberate: a new
	 * check that nobody classifies stays LOUD. Under-reporting is the failure
	 * mode this plugin cannot afford, so silence must always be a decision
	 * somebody made, never a default.
	 *
	 * Deliberately NOT here:
	 * - noindex / canonical_noindex_conflict - findings ABOUT the noindex.
	 * - missing_title / multiple_titles - the title is the document's identity
	 *   (browser tab, bookmarks, and the fallback platforms use when og:title
	 *   is absent), so it is never purely a search concern.
	 * - unparseable_html - a scan-integrity defect, not an appearance one.
	 *
	 * The canonical codes ARE here for a second reason: on a noindexed page
	 * `canonical_noindex_conflict` already fires when a canonical is present,
	 * so leaving "missing canonical" at full severity made SSAA flag the page
	 * both for having one and for not having one.
	 */
	const NOINDEX_MOOT_CODES = array(
		'missing_meta_description',
		'multiple_meta_descriptions',
		'meta_description_too_long',
		'meta_description_too_short',
		'missing_canonical',
		'canonical_not_absolute',
		'canonical_mismatch',
		'duplicate_canonicals',
		'max_image_preview_none',
	);

	public static function init() {
		// No hooks needed.
	}

	/**
	 * Analyse rendered HTML.
	 *
	 * @param string $html    Full rendered HTML.
	 * @param array  $context { @type int $post_id @type string $permalink }
	 * @return array { @type array $meta Extracted values. @type array $issues Issue list. }
	 */
	public static function analyze( $html, array $context = array() ) {
		$extracted = self::extract( $html );
		$issues    = self::detect_issues( $extracted, $context );
		$author    = self::detect_exposed_author( $extracted );

		return array(
			'sources'       => self::detect_signatures( $html ),
			'author_source' => $author['source'],
			'meta'          => array(
				'exposed_author'   => $author['name'],
				'seo_title'        => self::first_value( $extracted['titles'] ),
				'meta_description' => self::first_value( $extracted['descriptions'] ),
				'canonical_url'    => self::first_value( $extracted['canonicals'] ),
				'robots'           => self::first_value( $extracted['robots'] ),
				'og_title'         => self::first_value( $extracted['og']['og:title'] ?? array() ),
				'og_description'   => self::first_value( $extracted['og']['og:description'] ?? array() ),
				'og_image_url'     => self::first_value( $extracted['og']['og:image'] ?? array() ),
				'twitter_card'     => self::first_value( $extracted['twitter']['twitter:card'] ?? array() ),
			),
			'issues' => $issues,
		);
	}

	// -------------------------------------------------------------------------
	// Extraction
	// -------------------------------------------------------------------------

	/**
	 * Pull every relevant head tag out of the HTML. Each entry is
	 * array( 'value' => string, 'evidence' => outer HTML ) so duplicates
	 * are countable and quotable.
	 *
	 * @param string $html Rendered HTML.
	 * @return array
	 */
	private static function extract( $html ) {
		$out = array(
			'titles'       => array(),
			'descriptions' => array(),
			'canonicals'   => array(),
			'robots'       => array(),
			'og'           => array(),
			'twitter'      => array(),
			'meta_author'  => array(),
			'json_ld'      => array(),
			'parse_failed' => false,
		);

		if ( '' === trim( (string) $html ) ) {
			$out['parse_failed'] = true;
			return $out;
		}

		$dom      = new DOMDocument();
		$previous = libxml_use_internal_errors( true );
		// The xml declaration pins the parser to UTF-8 without the deprecated
		// mb_convert_encoding HTML-ENTITIES round-trip.
		$loaded = $dom->loadHTML( '<?xml encoding="UTF-8">' . $html );
		libxml_clear_errors();
		libxml_use_internal_errors( $previous );

		if ( ! $loaded ) {
			$out['parse_failed'] = true;
			return $out;
		}

		$xpath = new DOMXPath( $dom );
		$heads = $dom->getElementsByTagName( 'head' );
		// Malformed pages without a <head> fall back to a whole-document
		// sweep - what shipped is still what shipped.
		$scope = ( $heads->length > 0 ) ? $heads->item( 0 ) : $dom->documentElement;

		if ( ! $scope ) {
			$out['parse_failed'] = true;
			return $out;
		}

		// <title> - scoped to direct children of head so <svg><title> in the
		// body can never produce a false "multiple titles".
		foreach ( $xpath->query( './title', $scope ) as $node ) {
			$out['titles'][] = array(
				'value'    => trim( $node->textContent ),
				'evidence' => $dom->saveHTML( $node ),
			);
		}

		// <meta> tags.
		foreach ( $xpath->query( './/meta', $scope ) as $node ) {
			$name     = strtolower( trim( $node->getAttribute( 'name' ) ) );
			$property = strtolower( trim( $node->getAttribute( 'property' ) ) );
			$content  = trim( $node->getAttribute( 'content' ) );
			$entry    = array(
				'value'    => $content,
				'evidence' => $dom->saveHTML( $node ),
			);

			if ( 'description' === $name ) {
				$out['descriptions'][] = $entry;
			} elseif ( 'robots' === $name ) {
				$out['robots'][] = $entry;
			} elseif ( 'author' === $name ) {
				$out['meta_author'][] = $entry;
			} elseif ( 0 === strpos( $property, 'article:' ) ) {
				$out['og'][ $property ][] = $entry;
			} elseif ( 0 === strpos( $property, 'og:' ) ) {
				$out['og'][ $property ][] = $entry;
			} elseif ( 0 === strpos( $name, 'twitter:' ) ) {
				$out['twitter'][ $name ][] = $entry;
			} elseif ( 0 === strpos( $property, 'twitter:' ) ) {
				// Some plugins emit twitter:* as property= instead of name=.
				$out['twitter'][ $property ][] = $entry;
			}
		}

		// <link rel="canonical">.
		foreach ( $xpath->query( './/link', $scope ) as $node ) {
			if ( 'canonical' === strtolower( trim( $node->getAttribute( 'rel' ) ) ) ) {
				$out['canonicals'][] = array(
					'value'    => trim( $node->getAttribute( 'href' ) ),
					'evidence' => $dom->saveHTML( $node ),
				);
			}
		}

		// JSON-LD blocks (whole document - some plugins place them in the
		// footer). Decoded payloads only; invalid JSON is skipped.
		foreach ( $xpath->query( '//script[@type="application/ld+json"]' ) as $node ) {
			$decoded = json_decode( trim( $node->textContent ), true );
			if ( is_array( $decoded ) ) {
				$out['json_ld'][] = $decoded;
			}
		}

		return $out;
	}

	/**
	 * The author name the rendered page publicly exposes, if any.
	 * Sources in priority order: meta name=author, JSON-LD schema
	 * author, Yoast-style twitter:label1/data1 ("Written by"),
	 * article:author (often a profile URL - last resort).
	 *
	 * @param array $x Extraction result.
	 * @return array { name: string, source: string }
	 */
	private static function detect_exposed_author( array $x ) {
		$meta = self::first_value( $x['meta_author'] );
		if ( '' !== $meta ) {
			return array(
				'name'   => $meta,
				'source' => 'meta_author',
			);
		}

		foreach ( $x['json_ld'] as $payload ) {
			$name = self::json_ld_author_name( $payload );
			if ( '' !== $name ) {
				return array(
					'name'   => $name,
					'source' => 'schema',
				);
			}
		}

		$label1 = strtolower( self::first_value( $x['twitter']['twitter:label1'] ?? array() ) );
		$data1  = self::first_value( $x['twitter']['twitter:data1'] ?? array() );
		if ( '' !== $data1 && false !== strpos( $label1, 'written by' ) ) {
			return array(
				'name'   => $data1,
				'source' => 'twitter_labels',
			);
		}

		$article_author = self::first_value( $x['og']['article:author'] ?? array() );
		if ( '' !== $article_author ) {
			return array(
				'name'   => $article_author,
				'source' => 'article_author',
			);
		}

		return array(
			'name'   => '',
			'source' => '',
		);
	}

	/**
	 * Pull an author name out of a decoded JSON-LD payload. Handles the
	 * common shapes: a direct author object/array with a name, and the
	 * Yoast/Rank Math @graph style where author is an @id reference to a
	 * Person node elsewhere in the graph.
	 *
	 * @param array $payload Decoded JSON-LD.
	 * @return string
	 */
	private static function json_ld_author_name( array $payload ) {
		$nodes = isset( $payload['@graph'] ) && is_array( $payload['@graph'] ) ? $payload['@graph'] : array( $payload );

		// Index Person nodes by @id for reference resolution.
		$persons = array();
		foreach ( $nodes as $node ) {
			if ( ! is_array( $node ) ) {
				continue;
			}
			$types = isset( $node['@type'] ) ? (array) $node['@type'] : array();
			if ( in_array( 'Person', $types, true ) && isset( $node['name'] ) && is_string( $node['name'] ) ) {
				$persons[ isset( $node['@id'] ) ? (string) $node['@id'] : '' ] = $node['name'];
			}
		}

		foreach ( $nodes as $node ) {
			if ( ! is_array( $node ) || ! isset( $node['author'] ) ) {
				continue;
			}
			$authors = $node['author'];
			// Single author object or a list of them - inspect the first.
			if ( isset( $authors['name'] ) || isset( $authors['@id'] ) ) {
				$authors = array( $authors );
			}
			if ( ! is_array( $authors ) ) {
				continue;
			}
			foreach ( $authors as $author ) {
				if ( ! is_array( $author ) ) {
					continue;
				}
				if ( isset( $author['name'] ) && is_string( $author['name'] ) && '' !== $author['name'] ) {
					return $author['name'];
				}
				if ( isset( $author['@id'] ) && isset( $persons[ (string) $author['@id'] ] ) ) {
					return $persons[ (string) $author['@id'] ];
				}
			}
		}

		return '';
	}

	// -------------------------------------------------------------------------
	// Issue rules
	// -------------------------------------------------------------------------

	/**
	 * Run the issue rule set over the extracted tags.
	 *
	 * @param array $x       Extraction result.
	 * @param array $context post_id / permalink.
	 * @return array[]
	 */
	private static function detect_issues( array $x, array $context ) {
		$issues = array();

		if ( $x['parse_failed'] ) {
			$issues[] = self::issue( 'unparseable_html', 'search', 'critical', __( 'The rendered page could not be parsed as HTML.', 'search-social-appearance-audit' ) );
			return $issues;
		}

		// --- Search area -----------------------------------------------------

		if ( 0 === count( $x['titles'] ) ) {
			$issues[] = self::issue( 'missing_title', 'search', 'critical', __( 'No title tag was found in the rendered page.', 'search-social-appearance-audit' ) );
		} elseif ( count( $x['titles'] ) > 1 ) {
			$issues[] = self::issue( 'multiple_titles', 'search', 'warning', __( 'More than one title tag is present. Search engines may pick either.', 'search-social-appearance-audit' ), $x['titles'] );
		}

		if ( 0 === count( $x['descriptions'] ) ) {
			$issues[] = self::issue( 'missing_meta_description', 'search', 'warning', __( 'No meta description was found. Search engines will compose their own snippet.', 'search-social-appearance-audit' ) );
		} elseif ( count( $x['descriptions'] ) > 1 ) {
			$severity = self::values_differ( $x['descriptions'] ) ? 'critical' : 'warning';
			$issues[] = self::issue( 'multiple_meta_descriptions', 'search', $severity, __( 'More than one meta description is present.', 'search-social-appearance-audit' ), $x['descriptions'] );
		} else {
			// Exactly one meta description: check its length against the
			// configured Search-tab preview length (where Google truncates).
			$desc     = (string) self::first_value( $x['descriptions'] );
			$desc_len = function_exists( 'mb_strlen' ) ? mb_strlen( $desc ) : strlen( $desc );
			$max_len  = (int) SSAA_Settings::get( 'search_desc_length', 160 );
			if ( $max_len > 0 && $desc_len > $max_len ) {
				$issues[] = self::issue( 'meta_description_too_long', 'search', 'notice', sprintf(
					/* translators: 1: actual length, 2: configured preview length */
					__( 'The meta description is %1$d characters - longer than the %2$d-character preview length, so search engines will likely truncate it.', 'search-social-appearance-audit' ),
					$desc_len,
					$max_len
				), $x['descriptions'] );
			} elseif ( $desc_len > 0 && $desc_len < 50 ) {
				$issues[] = self::issue( 'meta_description_too_short', 'search', 'notice', sprintf(
					/* translators: %d: actual length */
					__( 'The meta description is only %d characters. Very short descriptions make weak search snippets.', 'search-social-appearance-audit' ),
					$desc_len
				), $x['descriptions'] );
			}
		}

		$robots     = strtolower( self::first_value( $x['robots'] ) );
		$is_noindex = ( false !== strpos( $robots, 'noindex' ) );

		if ( count( $x['canonicals'] ) > 1 ) {
			$severity = self::values_differ( $x['canonicals'] ) ? 'critical' : 'warning';
			$issues[] = self::issue( 'duplicate_canonicals', 'search', $severity, __( 'More than one canonical URL is declared. Conflicting canonicals confuse search engines.', 'search-social-appearance-audit' ), $x['canonicals'] );
		}

		$issues = array_merge( $issues, self::check_canonical( $x, $context, $is_noindex ) );

		if ( $is_noindex ) {
			$issues[] = self::issue( 'noindex', 'search', 'notice', __( 'This page asks search engines not to index it (noindex). It is also excluded from AI answer surfaces that ride the search index, including Google AI Overviews and Bing Copilot. Intentional for some pages - verify it is intended here.', 'search-social-appearance-audit' ), $x['robots'] );
		}

		if ( false !== strpos( $robots, 'max-image-preview:none' ) ) {
			$issues[] = self::issue( 'max_image_preview_none', 'search', 'notice', __( 'This page sets max-image-preview:none, so Google will not show a thumbnail image for it in search results. Intentional for some pages - verify it is intended here.', 'search-social-appearance-audit' ), $x['robots'] );
		}

		// --- Social area -----------------------------------------------------

		$og_image = $x['og']['og:image'] ?? array();

		if ( 0 === count( $og_image ) ) {
			$issues[] = self::issue( 'missing_og_image', 'social', 'warning', __( 'No og:image is defined. Social platforms will guess an image or show none.', 'search-social-appearance-audit' ) );
		} else {
			if ( count( $og_image ) > 1 ) {
				$issues[] = self::issue( 'multiple_og_images', 'social', 'notice', __( 'Multiple og:image tags are present. This is valid (platforms offer a choice) but worth verifying.', 'search-social-appearance-audit' ), $og_image );
			}
			$issues = array_merge( $issues, self::check_og_image( self::first_value( $og_image ), $og_image, $context ) );
		}

		if ( empty( $x['og']['og:title'] ) ) {
			$issues[] = self::issue( 'missing_og_title', 'social', 'notice', __( 'No og:title is defined. Platforms will fall back to the page title.', 'search-social-appearance-audit' ) );
		}
		if ( empty( $x['og']['og:description'] ) ) {
			$issues[] = self::issue( 'missing_og_description', 'social', 'notice', __( 'No og:description is defined.', 'search-social-appearance-audit' ) );
		}

		foreach ( self::SINGULAR_OG_PROPS as $prop ) {
			if ( isset( $x['og'][ $prop ] ) && count( $x['og'][ $prop ] ) > 1 ) {
				$severity = self::values_differ( $x['og'][ $prop ] ) ? 'critical' : 'warning';
				$issues[] = self::issue(
					'duplicate_og_tags',
					'social',
					$severity,
					sprintf(
						/* translators: %s: og property name, e.g. og:title */
						__( 'The %s tag appears more than once.', 'search-social-appearance-audit' ),
						$prop
					),
					$x['og'][ $prop ]
				);
			}
		}

		if ( empty( $x['twitter']['twitter:card'] ) ) {
			$issues[] = self::issue( 'missing_twitter_card', 'social', 'notice', __( 'No twitter:card is defined. X/Twitter will fall back to Open Graph tags or a plain link.', 'search-social-appearance-audit' ) );
		}

		/**
		 * Extension point: add-on files extend or adjust detected issues.
		 *
		 * @param array $issues  Detected issues.
		 * @param array $x       Extraction result.
		 * @param array $context post_id / permalink.
		 */
		$issues = (array) apply_filters( 'ssaa_detected_issues', $issues, $x, $context );

		// Applied AFTER the seam so premium's search-area findings obey the same
		// rule: a snippet-length warning is just as moot on a page that has no
		// snippet. Social findings are untouched by design - noindex speaks to
		// search engines, and a noindexed page shared in a group chat still
		// renders an Open Graph card. AI findings are untouched too: direct
		// crawlers fetch pages independently of the search index.
		return self::demote_moot_search_issues( $issues, $is_noindex );
	}

	/**
	 * Demote search findings that cannot matter on a noindexed page, and say
	 * why in the finding itself so a reader never has to wonder where the
	 * severity went.
	 *
	 * @param array[] $issues     Assembled issues.
	 * @param bool    $is_noindex Whether the page carries noindex.
	 * @return array[]
	 */
	private static function demote_moot_search_issues( array $issues, $is_noindex ) {
		if ( ! $is_noindex ) {
			return $issues;
		}

		/**
		 * Search-area issue codes that are moot on a noindexed page.
		 *
		 * @param string[] $codes Issue codes.
		 */
		$moot = (array) apply_filters( 'ssaa_noindex_moot_codes', self::NOINDEX_MOOT_CODES );

		foreach ( $issues as &$issue ) {
			if ( ! isset( $issue['area'], $issue['code'] ) || 'search' !== $issue['area'] ) {
				continue;
			}
			if ( ! in_array( $issue['code'], $moot, true ) || 'notice' === ( $issue['severity'] ?? '' ) ) {
				continue;
			}

			$issue['severity'] = 'notice';
			$issue['message']  = trim( (string) ( $issue['message'] ?? '' ) ) . ' ' . __( 'This page is not indexable (noindex), so it cannot appear in a search result and this has no effect on its search appearance.', 'search-social-appearance-audit' );
		}
		unset( $issue );

		return $issues;
	}

	/**
	 * Canonical value checks (presence, absoluteness, self-reference,
	 * noindex conflict). Duplicates are handled by the caller.
	 *
	 * @param array $x          Extraction result.
	 * @param array $context    post_id / permalink.
	 * @param bool  $is_noindex Whether the page is noindexed.
	 * @return array[]
	 */
	private static function check_canonical( array $x, array $context, $is_noindex ) {
		$issues = array();

		if ( 0 === count( $x['canonicals'] ) ) {
			$issues[] = self::issue( 'missing_canonical', 'search', 'notice', __( 'No canonical URL is declared. Search engines will choose one themselves - usually fine, but explicit is safer.', 'search-social-appearance-audit' ) );
			return $issues;
		}

		$canonical = self::first_value( $x['canonicals'] );

		if ( $is_noindex ) {
			$issues[] = self::issue( 'canonical_noindex_conflict', 'search', 'warning', __( 'The page declares a canonical URL AND asks not to be indexed (noindex). These are contradictory signals - Google advises against combining them.', 'search-social-appearance-audit' ), $x['canonicals'] );
		}

		if ( ! preg_match( '#^https?://#i', $canonical ) ) {
			$issues[] = self::issue( 'canonical_not_absolute', 'search', 'notice', __( 'The canonical URL is not absolute. Relative canonicals are valid but error-prone, and Google recommends absolute URLs.', 'search-social-appearance-audit' ), $x['canonicals'] );
			return $issues; // Mismatch comparison is meaningless for relative values.
		}

		$permalink = isset( $context['permalink'] ) ? (string) $context['permalink'] : '';
		if ( '' !== $permalink && ! self::urls_equivalent( $canonical, $permalink ) ) {
			$issues[] = self::issue(
				'canonical_mismatch',
				'search',
				'warning',
				sprintf(
					/* translators: 1: canonical URL, 2: page URL */
					__( 'The canonical URL (%1$s) points somewhere other than this page (%2$s). Deliberate for syndicated content - otherwise this hands the page\'s search ranking to the other URL. Common causes: http vs https, www vs non-www, or a misconfigured plugin.', 'search-social-appearance-audit' ),
					$canonical,
					$permalink
				),
				$x['canonicals']
			);
		}

		return $issues;
	}

	/**
	 * Whether two absolute URLs are equivalent after normalisation
	 * (case-insensitive scheme/host, trailing slash, default ports).
	 *
	 * @param string $a URL.
	 * @param string $b URL.
	 * @return bool
	 */
	private static function urls_equivalent( $a, $b ) {
		$normalise = function ( $url ) {
			$parts = wp_parse_url( $url );
			if ( empty( $parts['host'] ) ) {
				return $url;
			}
			$scheme = isset( $parts['scheme'] ) ? strtolower( $parts['scheme'] ) : 'http';
			$host   = strtolower( $parts['host'] );
			$port   = isset( $parts['port'] ) && ! in_array( (int) $parts['port'], array( 80, 443 ), true ) ? ':' . $parts['port'] : '';
			$path   = isset( $parts['path'] ) ? untrailingslashit( $parts['path'] ) : '';
			$query  = isset( $parts['query'] ) ? '?' . $parts['query'] : '';
			return $scheme . '://' . $host . $port . $path . $query;
		};

		return $normalise( $a ) === $normalise( $b );
	}

	/**
	 * og:image reachability and validity checks.
	 *
	 * @param string $url      First og:image URL.
	 * @param array  $entries  All og:image entries (evidence).
	 * @param array  $context  post_id / permalink.
	 * @return array[]
	 */
	private static function check_og_image( $url, array $entries, array $context ) {
		$issues = array();

		if ( ! preg_match( '#^https?://#i', $url ) ) {
			$issues[] = self::issue( 'og_image_not_absolute', 'social', 'warning', __( 'The og:image URL is not an absolute http(s) URL. Social platforms require absolute URLs.', 'search-social-appearance-audit' ), $entries );
			return $issues;
		}

		/** Disable the og:image HTTP reachability check per-site. */
		if ( ! apply_filters( 'ssaa_check_og_image', true, $url, $context ) ) {
			return $issues;
		}

		$args = array(
			'timeout'     => self::IMAGE_CHECK_TIMEOUT,
			'redirection' => 3,
			'user-agent'  => 'SearchSocialAppearanceAudit/' . SSAA_VERSION . '; ' . home_url( '/' ),
		);

		$response = wp_remote_head( $url, $args );
		$status   = is_wp_error( $response ) ? 0 : (int) wp_remote_retrieve_response_code( $response );

		// Some servers reject HEAD; retry those (and transport errors) as GET.
		if ( is_wp_error( $response ) || in_array( $status, array( 405, 501 ), true ) ) {
			$response = wp_remote_get( $url, $args );
			$status   = is_wp_error( $response ) ? 0 : (int) wp_remote_retrieve_response_code( $response );
		}

		if ( is_wp_error( $response ) || $status >= 400 || 0 === $status ) {
			$issues[] = self::issue(
				'broken_og_image',
				'social',
				'critical',
				sprintf(
					/* translators: %d: HTTP status code (0 = unreachable) */
					__( 'The og:image URL could not be loaded (HTTP %d). Shares will show no image.', 'search-social-appearance-audit' ),
					$status
				),
				$entries
			);
			return $issues;
		}

		$type = strtolower( (string) wp_remote_retrieve_header( $response, 'content-type' ) );
		if ( '' !== $type && 0 !== strpos( $type, 'image/' ) ) {
			$issues[] = self::issue( 'og_image_not_image', 'social', 'warning', __( 'The og:image URL responds, but not with an image content type.', 'search-social-appearance-audit' ), $entries );
		}

		return $issues;
	}

	/**
	 * Detect which known SEO plugins left their signature in the rendered
	 * page. Informational only - feeds the "possible sources" section of the
	 * issue details modal and must never be presented as definitive.
	 *
	 * @param string $html Rendered HTML.
	 * @return string[] Human-readable plugin names.
	 */
	private static function detect_signatures( $html ) {
		$signatures = array(
			'Yoast SEO'         => 'optimized with the Yoast SEO',
			'Rank Math'         => 'Rank Math',
			'All in One SEO'    => 'All in One SEO',
			'SEOPress'          => 'SEOPress',
			'The SEO Framework' => 'The SEO Framework',
			'Slim SEO'          => 'Slim SEO',
		);

		$found = array();
		foreach ( $signatures as $label => $needle ) {
			if ( false !== stripos( $html, $needle ) ) {
				$found[] = $label;
			}
		}

		return $found;
	}

	// -------------------------------------------------------------------------
	// Helpers
	// -------------------------------------------------------------------------

	/**
	 * Short human label for an issue code - the display name wherever a code
	 * is offered as a choice (the premium Issue filter dropdown; any future
	 * surface). Codes themselves stay the stable machine identifiers (stored,
	 * filterable, shown in the modal as <code>). Unknown codes (extensions via
	 * ssaa_detected_issues) fall back to a prettified form of the code.
	 *
	 * @param string $code Stable issue code.
	 * @return string
	 */
	public static function code_label( $code ) {
		$labels = array(
			// Search area.
			'missing_title'               => __( 'Missing title', 'search-social-appearance-audit' ),
			'multiple_titles'             => __( 'Multiple title tags', 'search-social-appearance-audit' ),
			'missing_meta_description'    => __( 'Missing meta description', 'search-social-appearance-audit' ),
			'multiple_meta_descriptions'  => __( 'Multiple meta descriptions', 'search-social-appearance-audit' ),
			'meta_description_too_long'   => __( 'Meta description too long', 'search-social-appearance-audit' ),
			'meta_description_too_short'  => __( 'Meta description too short', 'search-social-appearance-audit' ),
			'missing_canonical'           => __( 'Missing canonical URL', 'search-social-appearance-audit' ),
			'canonical_not_absolute'      => __( 'Canonical URL not absolute', 'search-social-appearance-audit' ),
			'duplicate_canonicals'        => __( 'Multiple canonical URLs', 'search-social-appearance-audit' ),
			'canonical_mismatch'          => __( 'Canonical points to a different URL', 'search-social-appearance-audit' ),
			'canonical_noindex_conflict'  => __( 'Canonical combined with noindex', 'search-social-appearance-audit' ),
			'noindex'                     => __( 'Excluded from search (noindex)', 'search-social-appearance-audit' ),
			'search_engines_discouraged'  => __( 'Whole site hidden from search', 'search-social-appearance-audit' ),
			'max_image_preview_none'      => __( 'No search image preview', 'search-social-appearance-audit' ),
			'unparseable_html'            => __( 'Page HTML could not be parsed', 'search-social-appearance-audit' ),
			// Social area.
			'missing_og_title'            => __( 'Missing OG title', 'search-social-appearance-audit' ),
			'missing_og_description'      => __( 'Missing OG description', 'search-social-appearance-audit' ),
			'missing_og_image'            => __( 'Missing OG image', 'search-social-appearance-audit' ),
			'multiple_og_images'          => __( 'Multiple OG images', 'search-social-appearance-audit' ),
			'og_image_not_absolute'       => __( 'OG image URL not absolute', 'search-social-appearance-audit' ),
			'og_image_not_image'          => __( 'OG image is not an image', 'search-social-appearance-audit' ),
			'missing_twitter_card'        => __( 'Missing Twitter Card', 'search-social-appearance-audit' ),
			// Premium-detected codes (labels here are display copy only - the
			// detection lives in __premium_only files; naming a feature in
			// copy is the compliant pattern).
			'flagged_author'              => __( 'Flagged author', 'search-social-appearance-audit' ),
			'weak_og_image'               => __( 'Weak social image', 'search-social-appearance-audit' ),
			// AI readiness (area 'ai', 1.2.0; premium-detected, labels free).
			'ai_search_crawler_blocked'   => __( 'AI search crawler blocked', 'search-social-appearance-audit' ),
			'ai_training_crawler_blocked' => __( 'AI training opt-out detected', 'search-social-appearance-audit' ),
			'ai_blanket_block'            => __( 'Blanket rule blocks AI crawlers', 'search-social-appearance-audit' ),
			'cdn_may_block_ai'            => __( 'CDN may block AI crawlers', 'search-social-appearance-audit' ),
			'ai_robots_rules_ineffective' => __( 'robots.txt rules likely ineffective (404)', 'search-social-appearance-audit' ),
			'favicon_missing'             => __( 'Missing favicon', 'search-social-appearance-audit' ),
			'favicon_unreachable'         => __( 'Favicon URL does not resolve', 'search-social-appearance-audit' ),
			'ai_snippet_blocked'          => __( 'Snippets blocked for AI answers', 'search-social-appearance-audit' ),
			'ai_snippet_limited'          => __( 'Snippet length limits AI answers', 'search-social-appearance-audit' ),
			'ai_copilot_opted_out'        => __( 'Opted out of Copilot answers', 'search-social-appearance-audit' ),
			'ai_copilot_limited'          => __( 'Copilot limited to snippet', 'search-social-appearance-audit' ),
			'ai_content_not_server_side'  => __( 'Content may be invisible to AI crawlers', 'search-social-appearance-audit' ),
		);

		/**
		 * Extension point: add-on files label their own issue codes.
		 *
		 * @param array $labels code => label.
		 */
		$labels = (array) apply_filters( 'ssaa_issue_code_labels', $labels );

		if ( isset( $labels[ $code ] ) ) {
			return $labels[ $code ];
		}

		// Unknown code: prettify ('some_new_code' -> 'Some new code').
		return ucfirst( str_replace( '_', ' ', (string) $code ) );
	}

	/**
	 * Build one issue record.
	 *
	 * @param string $code     Stable issue code.
	 * @param string $area     search|social.
	 * @param string $severity critical|warning|notice.
	 * @param string $message  Human explanation.
	 * @param array  $entries  Extraction entries to quote as evidence.
	 * @return array
	 */
	private static function issue( $code, $area, $severity, $message, array $entries = array() ) {
		return array(
			'code'     => $code,
			'area'     => $area,
			'severity' => $severity,
			'message'  => $message,
			'evidence' => array_values( array_filter( wp_list_pluck( $entries, 'evidence' ) ) ),
		);
	}

	/**
	 * First entry's value, '' when none.
	 *
	 * @param array $entries Extraction entries.
	 * @return string
	 */
	private static function first_value( array $entries ) {
		return isset( $entries[0]['value'] ) ? (string) $entries[0]['value'] : '';
	}

	/**
	 * Whether entries carry more than one distinct non-empty value.
	 *
	 * @param array $entries Extraction entries.
	 * @return bool
	 */
	private static function values_differ( array $entries ) {
		$values = array_unique( array_filter( array_map( 'strval', wp_list_pluck( $entries, 'value' ) ) ) );
		return count( $values ) > 1;
	}
}
