<?php

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

/**
 * Canonical rendered-page input contract shared by readiness and similarity.
 */
class Content_Miner_Rendered_Snapshot {
	const SCHEMA_VERSION             = 2;
	const PARSER_VERSION             = 3;
	const RENDER_EPOCH_OPTION        = 'content_miner_render_epoch';
	const FRESHNESS_SECONDS          = 604800;
	const MIN_RENDERED_WORDS         = 20;
	const SUBSTANTIAL_STORED_WORDS   = 100;
	const MIN_RENDERED_RATIO         = 0.15;

	/**
	 * Bind a snapshot to every local input that can knowingly change rendering.
	 */
	public static function source_fingerprint( $stored_content, $url, $status, $modified_at ) {
		$epoch = (int) get_option( self::RENDER_EPOCH_OPTION, 1 );
		return hash(
			'sha256',
			implode(
				'|',
				array(
					self::PARSER_VERSION,
					$epoch,
					self::content_hash( $stored_content ),
					(string) $url,
					(string) $status,
					(string) $modified_at,
				)
			)
		);
	}

	/** Return whether persisted snapshot metadata still describes this source. */
	public static function is_current( array $snapshot, $stored_content, $url, $status, $modified_at ) {
		return isset( $snapshot['snapshot_schema'], $snapshot['parser_version'], $snapshot['source_fingerprint'] )
			&& self::SCHEMA_VERSION === (int) $snapshot['snapshot_schema']
			&& self::PARSER_VERSION === (int) $snapshot['parser_version']
			&& hash_equals(
				(string) $snapshot['source_fingerprint'],
				self::source_fingerprint( $stored_content, $url, $status, $modified_at )
			);
	}

	/** A rendered snapshot may skip HTTP only while its source and TTL are fresh. */
	public static function is_fresh( array $snapshot, $stored_content, $url, $status, $modified_at, $now = null ) {
		if ( 'rendered' !== ( isset( $snapshot['input_source'] ) ? $snapshot['input_source'] : '' )
			|| ! self::is_current( $snapshot, $stored_content, $url, $status, $modified_at )
			|| empty( $snapshot['checked_at'] )
		) {
			return false;
		}
		$checked = strtotime( (string) $snapshot['checked_at'] );
		$now     = null === $now ? time() : (int) $now;
		return false !== $checked && $checked >= $now - self::FRESHNESS_SECONDS;
	}

	/**
	 * Parse one server-rendered response into the body and signals used by every
	 * downstream engine path.
	 *
	 * @return array<string,mixed>
	 */
	public static function extract( $html, $page_url = '' ) {
		$parse_started = microtime( true );
		if ( ! class_exists( 'DOMDocument' ) || ! class_exists( 'DOMXPath' ) ) {
			return array( 'ok' => false, 'reason_code' => 'dom_unavailable', 'parse_duration_ms' => 0 );
		}

		$document = new DOMDocument();
		$flags    = LIBXML_NONET | LIBXML_NOERROR | LIBXML_NOWARNING;
		$loaded   = $document->loadHTML( '<?xml encoding="UTF-8">' . (string) $html, $flags );
		if ( ! $loaded ) {
			return array( 'ok' => false, 'reason_code' => 'parse_failed', 'parse_duration_ms' => self::elapsed_ms( $parse_started ) );
		}

		$xpath       = new DOMXPath( $document );
		$schema_data = self::schema_data( $xpath );
		$metadata    = self::document_metadata( $xpath, $schema_data, $page_url );
		$body_nodes  = $xpath->query( '//body' );
		$body        = $body_nodes && $body_nodes->length ? $body_nodes->item( 0 ) : $document->documentElement;
		if ( ! $body ) {
			return array( 'ok' => false, 'reason_code' => 'parse_failed', 'parse_duration_ms' => self::elapsed_ms( $parse_started ) );
		}

		$root = self::content_root( $xpath, $body );
		self::remove_nodes( $xpath, '//*[self::script or self::style or self::noscript or self::template or @hidden or translate(@aria-hidden, "TRUE", "true") = "true"]' );
		$stripped = self::strip_furniture( $xpath );
		if ( ! $root->parentNode && $root !== $document->documentElement ) {
			return array( 'ok' => false, 'reason_code' => 'parse_failed', 'parse_duration_ms' => self::elapsed_ms( $parse_started ) );
		}

		$text      = self::node_text( $root );
		$questions = self::questions( $xpath, $root, $schema_data['faq_questions'] );
		$signals   = self::visible_signals( $xpath, $root, $page_url, $metadata, $questions );
		$words     = self::word_count( $text );
		$stripped_words = 0;
		foreach ( $stripped as $evidence ) {
			$stripped_words += (int) $evidence['words'];
		}

		return array(
			'ok'                  => true,
			'text'                => $text,
			'word_count'          => $words,
			'bytes'               => strlen( $text ),
			'content_hash'        => hash( 'sha256', $text ),
			'analysis_title'      => $metadata['title'],
			'published_at'        => $metadata['published_at'],
			'modified_at'         => $metadata['modified_at'],
			'signals'             => $signals,
			'stripped_elements'   => $stripped,
			'stripped_words'      => $stripped_words,
			'parse_duration_ms'   => self::elapsed_ms( $parse_started ),
		);
	}

	/** Pin the deliberately broad AEO heading predicate in fixtures. */
	public static function is_question_text( $text ) {
		$text = self::normalize_text( $text );
		return '' !== $text && (bool) preg_match( '/(?:\?|^(?:who|what|when|where|why|how|can|could|do|does|did|is|are|should|will)\b)/iu', $text );
	}

	/** Whether a very small render is suspicious rather than genuinely thin. */
	public static function near_empty_mismatch( $rendered_words, $stored_words ) {
		$rendered_words = max( 0, (int) $rendered_words );
		$stored_words   = max( 0, (int) $stored_words );
		return $stored_words >= self::SUBSTANTIAL_STORED_WORDS
			&& ( $rendered_words < self::MIN_RENDERED_WORDS || $rendered_words / max( 1, $stored_words ) < self::MIN_RENDERED_RATIO );
	}

	/** @return DOMNode */
	private static function content_root( $xpath, $body ) {
		$candidates = $xpath->query( '//main | //*[@role="main" and not(self::main)]' );
		$substantive = array();
		foreach ( $candidates ? iterator_to_array( $candidates ) : array() as $candidate ) {
			if ( self::word_count( self::node_text( $candidate ) ) >= self::MIN_RENDERED_WORDS ) {
				$substantive[] = $candidate;
			}
		}
		return 1 === count( $substantive ) ? $substantive[0] : $body;
	}

	/** Remove non-visible nodes without collecting content evidence. */
	private static function remove_nodes( $xpath, $query ) {
		$nodes = $xpath->query( $query );
		foreach ( $nodes ? iterator_to_array( $nodes ) : array() as $node ) {
			if ( $node->parentNode ) {
				$node->parentNode->removeChild( $node );
			}
		}
	}

	/** @return array<string,array{elements:int,words:int}> */
	private static function strip_furniture( $xpath ) {
		$matches = array();
		$queries = array(
			'nav'           => '//nav',
			'aside'         => '//aside',
			'navigation'    => '//*[@role="navigation" and not(self::nav)]',
			'complementary' => '//*[@role="complementary" and not(self::aside)]',
			'header'        => '//header',
			'footer'        => '//footer',
			'banner'        => '//*[@role="banner" and not(self::header)]',
			'contentinfo'   => '//*[@role="contentinfo" and not(self::footer)]',
		);
		foreach ( $queries as $label => $query ) {
			$nodes = $xpath->query( $query );
			foreach ( $nodes ? iterator_to_array( $nodes ) : array() as $node ) {
				if ( in_array( $label, array( 'header', 'footer', 'banner', 'contentinfo' ), true ) && self::inside_article_content( $node ) ) {
					continue;
				}
				$key = spl_object_hash( $node );
				if ( ! isset( $matches[ $key ] ) ) {
					$matches[ $key ] = array( 'element' => $label, 'node' => $node, 'depth' => self::node_depth( $node ) );
				}
			}
		}
		$matches = array_values( $matches );
		usort(
			$matches,
			function ( $left, $right ) {
				return $right['depth'] <=> $left['depth'];
			}
		);

		$stripped = array();
		foreach ( $matches as $match ) {
			$node = $match['node'];
			if ( ! $node->parentNode ) {
				continue;
			}
			$label = $match['element'];
			if ( ! isset( $stripped[ $label ] ) ) {
				$stripped[ $label ] = array( 'elements' => 0, 'words' => 0 );
			}
			++$stripped[ $label ]['elements'];
			$stripped[ $label ]['words'] += self::word_count( self::node_text( $node ) );
			$node->parentNode->removeChild( $node );
		}
		return $stripped;
	}

	private static function inside_article_content( $node ) {
		for ( $parent = $node->parentNode; $parent; $parent = $parent->parentNode ) {
			if ( XML_ELEMENT_NODE !== $parent->nodeType ) {
				continue;
			}
			$name = strtolower( $parent->nodeName );
			$role = $parent instanceof DOMElement ? strtolower( $parent->getAttribute( 'role' ) ) : '';
			if ( 'main' === $name || 'article' === $name || 'main' === $role ) {
				return true;
			}
		}
		return false;
	}

	/** @return array<string,mixed> */
	private static function schema_data( $xpath ) {
		$decoded = array();
		$scripts = $xpath->query( '//script[translate(@type, "ABCDEFGHIJKLMNOPQRSTUVWXYZ", "abcdefghijklmnopqrstuvwxyz") = "application/ld+json"]' );
		foreach ( $scripts ? iterator_to_array( $scripts ) : array() as $script ) {
			$value = json_decode( trim( (string) $script->textContent ), true );
			if ( is_array( $value ) ) {
				$decoded[] = $value;
			}
		}
		$nodes = array();
		foreach ( $decoded as $value ) {
			self::walk_schema( $value, $nodes );
		}

		$faq_questions = array();
		$author_name   = '';
		$author_bio    = '';
		$author_source = '';
		$published_at  = '';
		$modified_at   = '';
		$published_source = '';
		$modified_source  = '';
		foreach ( $nodes as $node ) {
			$types = self::schema_types( isset( $node['@type'] ) ? $node['@type'] : array() );
			if ( in_array( 'faqpage', $types, true ) && isset( $node['mainEntity'] ) ) {
				$entities = self::list_value( $node['mainEntity'] );
				foreach ( $entities as $entity ) {
					if ( ! is_array( $entity ) || ! in_array( 'question', self::schema_types( isset( $entity['@type'] ) ? $entity['@type'] : array() ), true ) ) {
						continue;
					}
					$text = isset( $entity['name'] ) ? $entity['name'] : ( isset( $entity['text'] ) ? $entity['text'] : '' );
					if ( is_scalar( $text ) && '' !== self::normalize_text( $text ) ) {
						$faq_questions[] = self::normalize_text( $text );
					}
				}
			}
			if ( self::page_bound_schema_type( $types ) && ! $published_at && isset( $node['datePublished'] ) && is_scalar( $node['datePublished'] ) ) {
				$published_at = (string) $node['datePublished'];
				$published_source = 'jsonld_datePublished';
			}
			if ( self::page_bound_schema_type( $types ) && ! $modified_at && isset( $node['dateModified'] ) && is_scalar( $node['dateModified'] ) ) {
				$modified_at = (string) $node['dateModified'];
				$modified_source = 'jsonld_dateModified';
			}
			if ( self::article_schema_type( $types ) && ! $author_name && isset( $node['author'] ) ) {
				$author = self::first_object( $node['author'] );
				if ( $author && isset( $author['name'] ) && is_scalar( $author['name'] ) ) {
					$author_name = self::normalize_text( $author['name'] );
					$author_bio  = isset( $author['description'] ) && is_scalar( $author['description'] ) ? self::normalize_text( $author['description'] ) : '';
					$author_source = 'jsonld_article_author';
				} elseif ( is_scalar( $node['author'] ) ) {
					$author_name = self::normalize_text( $node['author'] );
					$author_source = '' !== $author_name ? 'jsonld_article_author' : '';
				}
			}
		}
		return array(
			'present'       => ! empty( $decoded ),
			'faq_questions' => $faq_questions,
			'author_name'   => $author_name,
			'author_bio'    => $author_bio,
			'author_source' => $author_source,
			'published_at'  => $published_at,
			'modified_at'   => $modified_at,
			'published_source' => $published_source,
			'modified_source'  => $modified_source,
		);
	}

	private static function walk_schema( $value, array &$nodes ) {
		if ( ! is_array( $value ) ) {
			return;
		}
		if ( self::is_associative( $value ) ) {
			if ( isset( $value['@type'] ) ) {
				$nodes[] = $value;
			}
			foreach ( $value as $child ) {
				self::walk_schema( $child, $nodes );
			}
			return;
		}
		foreach ( $value as $child ) {
			self::walk_schema( $child, $nodes );
		}
	}

	/** @return array<string,mixed> */
	private static function document_metadata( $xpath, array $schema, $page_url ) {
		$title_nodes = $xpath->query( '//title' );
		$title       = $title_nodes && $title_nodes->length ? self::normalize_text( $title_nodes->item( 0 )->textContent ) : '';
		$description = self::meta_content( $xpath, array( 'description' ) );
		$robots      = strtolower( self::meta_content( $xpath, array( 'robots' ) ) );
		$article_author = self::meta_content( $xpath, array( 'article:author', 'og:article:author' ) );
		$meta_author = self::meta_content( $xpath, array( 'author' ) );
		$visible_author = self::visible_author( $xpath );
		$published   = self::meta_content( $xpath, array( 'article:published_time', 'datepublished' ) );
		$modified    = self::meta_content( $xpath, array( 'article:modified_time', 'datemodified' ) );
		$time_dates  = self::time_dates( $xpath );
		$visible_dates = self::visible_dates( $xpath );
		$canonical   = '';
		$links       = $xpath->query( '//link[@href]' );
		foreach ( $links ? iterator_to_array( $links ) : array() as $link ) {
			$rels = preg_split( '/\s+/', strtolower( $link->getAttribute( 'rel' ) ), -1, PREG_SPLIT_NO_EMPTY );
			if ( in_array( 'canonical', is_array( $rels ) ? $rels : array(), true ) ) {
				$canonical = self::resolve_url( $link->getAttribute( 'href' ), $page_url );
				break;
			}
		}
		$author_name = $schema['author_name'];
		$author_bio = $schema['author_bio'];
		$author_source = $schema['author_source'];
		if ( '' === $author_name && '' !== $article_author ) {
			$author_name = $article_author;
			$author_source = 'article_author_meta';
		}
		if ( '' === $author_name && '' !== $meta_author ) {
			$author_name = $meta_author;
			$author_source = 'meta_author';
		}
		if ( '' === $author_name && ! empty( $visible_author['name'] ) ) {
			$author_name = $visible_author['name'];
			$author_bio = $visible_author['bio'];
			$author_source = $visible_author['source'];
		}

		$published_at = $published ? $published : $schema['published_at'];
		$published_source = $published ? 'article_published_meta' : $schema['published_source'];
		if ( '' === $published_at && ! empty( $time_dates['published_at'] ) ) {
			$published_at = $time_dates['published_at'];
			$published_source = 'time_element';
		}
		if ( '' === $published_at && ! empty( $visible_dates['published_at'] ) ) {
			$published_at = $visible_dates['published_at'];
			$published_source = 'visible_published_text';
		}

		$modified_at = $modified ? $modified : $schema['modified_at'];
		$modified_source = $modified ? 'article_modified_meta' : $schema['modified_source'];
		if ( '' === $modified_at && ! empty( $time_dates['modified_at'] ) ) {
			$modified_at = $time_dates['modified_at'];
			$modified_source = 'time_element';
		}
		if ( '' === $modified_at && ! empty( $visible_dates['modified_at'] ) ) {
			$modified_at = $visible_dates['modified_at'];
			$modified_source = 'visible_' . $visible_dates['modified_label'] . '_text';
		}

		return array(
			'title'            => $title,
			'description'      => $description,
			'indexable'        => ! in_array( 'noindex', preg_split( '/[\s,]+/', $robots, -1, PREG_SPLIT_NO_EMPTY ), true ),
			'canonical'        => $canonical,
			'author_name'      => $author_name,
			'author_bio'       => $author_bio,
			'author_source'    => $author_source,
			'published_at'     => $published_at,
			'modified_at'      => $modified_at,
			'published_source' => $published_source,
			'modified_source'  => $modified_source,
			'schema_present'   => ! empty( $schema['present'] ),
		);
	}

	private static function meta_content( $xpath, array $names ) {
		$names = array_map( 'strtolower', $names );
		$nodes = $xpath->query( '//meta[@content]' );
		foreach ( $nodes ? iterator_to_array( $nodes ) : array() as $node ) {
			$key = strtolower( $node->getAttribute( 'name' ) );
			if ( '' === $key ) {
				$key = strtolower( $node->getAttribute( 'property' ) );
			}
			if ( in_array( $key, $names, true ) ) {
				return self::normalize_text( $node->getAttribute( 'content' ) );
			}
		}
		return '';
	}

	private static function article_schema_type( array $types ) {
		foreach ( $types as $type ) {
			if ( in_array( $type, array( 'article', 'blogposting', 'newsarticle', 'report', 'techarticle' ), true ) ) {
				return true;
			}
		}
		return false;
	}

	private static function page_bound_schema_type( array $types ) {
		foreach ( $types as $type ) {
			if ( in_array( $type, array( 'article', 'blogposting', 'newsarticle', 'report', 'techarticle', 'webpage' ), true ) ) {
				return true;
			}
		}
		return false;
	}

	/** @return array{name:string,bio:string,source:string} */
	private static function visible_author( $xpath ) {
		$rel_nodes = $xpath->query( '(//main | //article)//*[@rel and contains(concat(" ", translate(normalize-space(@rel), "ABCDEFGHIJKLMNOPQRSTUVWXYZ", "abcdefghijklmnopqrstuvwxyz"), " "), " author ")] | //header[ancestor::article or ancestor::main]//*[@rel and contains(concat(" ", translate(normalize-space(@rel), "ABCDEFGHIJKLMNOPQRSTUVWXYZ", "abcdefghijklmnopqrstuvwxyz"), " "), " author ")]' );
		foreach ( $rel_nodes ? iterator_to_array( $rel_nodes ) : array() as $node ) {
			$name = self::clean_author_name( self::node_text( $node ) );
			if ( '' !== $name ) {
				return array( 'name' => $name, 'bio' => '', 'source' => 'visible_rel_author' );
			}
		}

		$byline_nodes = $xpath->query( '(//main | //article | //header[ancestor::article or ancestor::main])//*[contains(translate(concat(" ", @class, " "), "ABCDEFGHIJKLMNOPQRSTUVWXYZ", "abcdefghijklmnopqrstuvwxyz"), " byline ") or contains(translate(concat(" ", @class, " "), "ABCDEFGHIJKLMNOPQRSTUVWXYZ", "abcdefghijklmnopqrstuvwxyz"), " author ") or contains(translate(concat(" ", @itemprop, " "), "ABCDEFGHIJKLMNOPQRSTUVWXYZ", "abcdefghijklmnopqrstuvwxyz"), " author ")]' );
		foreach ( $byline_nodes ? iterator_to_array( $byline_nodes ) : array() as $node ) {
			$name = self::clean_author_name( self::node_text( $node ) );
			if ( '' !== $name ) {
				return array( 'name' => $name, 'bio' => '', 'source' => 'visible_byline' );
			}
		}

		$sibling_nodes = $xpath->query( '//h1/following-sibling::*[position() <= 3] | //header[ancestor::article or ancestor::main]/*' );
		foreach ( $sibling_nodes ? iterator_to_array( $sibling_nodes ) : array() as $node ) {
			$text = self::normalize_text( self::node_text( $node ) );
			if ( ! preg_match( '/^by\s+/iu', $text ) ) {
				continue;
			}
			$name = self::clean_author_name( $text );
			if ( '' !== $name ) {
				return array( 'name' => $name, 'bio' => '', 'source' => 'visible_byline' );
			}
		}

		$near_heading = $xpath->query( '//h1/ancestor::*[self::header or self::article or self::main][1]' );
		foreach ( $near_heading ? iterator_to_array( $near_heading ) : array() as $node ) {
			$text = self::normalize_text( self::node_text( $node ) );
			if ( preg_match( '/(?:^|\s)by\s+([A-Z][A-Za-z\'\.\-]+(?:\s+[A-Z][A-Za-z\'\.\-]+){0,4})\b/u', $text, $matches ) ) {
				$name = self::clean_author_name( $matches[1] );
				if ( '' !== $name ) {
					return array( 'name' => $name, 'bio' => '', 'source' => 'visible_byline' );
				}
			}
		}

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

	/** @return array{published_at:string,modified_at:string} */
	private static function time_dates( $xpath ) {
		$dates = array( 'published_at' => '', 'modified_at' => '' );
		$nodes = $xpath->query( '//time' );
		foreach ( $nodes ? iterator_to_array( $nodes ) : array() as $node ) {
			$value = $node instanceof DOMElement && $node->hasAttribute( 'datetime' ) ? $node->getAttribute( 'datetime' ) : self::node_text( $node );
			$value = self::clean_date_text( $value );
			if ( '' === $value ) {
				continue;
			}
			$context = strtolower( self::normalize_text(
				( $node instanceof DOMElement ? $node->getAttribute( 'itemprop' ) . ' ' . $node->getAttribute( 'property' ) . ' ' . $node->getAttribute( 'class' ) : '' )
				. ' '
				. ( $node->parentNode ? self::node_text( $node->parentNode ) : '' )
			) );
			if ( '' === $dates['modified_at'] && preg_match( '/\b(?:modified|updated|reviewed)\b/u', $context ) ) {
				$dates['modified_at'] = $value;
				continue;
			}
			if ( '' === $dates['published_at'] ) {
				$dates['published_at'] = $value;
			}
		}
		return $dates;
	}

	/** @return array{published_at:string,modified_at:string,modified_label:string} */
	private static function visible_dates( $xpath ) {
		$dates = array( 'published_at' => '', 'modified_at' => '', 'modified_label' => '' );
		$nodes = $xpath->query( '//main | //article' );
		if ( ! $nodes || ! $nodes->length ) {
			$nodes = $xpath->query( '//body' );
		}
		foreach ( $nodes ? iterator_to_array( $nodes ) : array() as $node ) {
			$text = self::normalize_text( self::node_text( $node ) );
			if ( '' === $dates['modified_at'] && preg_match( '/\b(?:last\s+updated|updated|modified)\s*:?\s*([A-Z][a-z]+\s+\d{1,2},\s+\d{4}|\d{4}-\d{2}-\d{2})\b/iu', $text, $matches ) ) {
				$dates['modified_at'] = self::clean_date_text( $matches[1] );
				$dates['modified_label'] = 'last_updated';
			}
			if ( '' === $dates['modified_at'] && preg_match( '/\breviewed\s*:?\s*([A-Z][a-z]+\s+\d{1,2},\s+\d{4}|\d{4}-\d{2}-\d{2})\b/iu', $text, $matches ) ) {
				$dates['modified_at'] = self::clean_date_text( $matches[1] );
				$dates['modified_label'] = 'reviewed';
			}
			if ( '' === $dates['published_at'] && preg_match( '/\bpublished\s*:?\s*([A-Z][a-z]+\s+\d{1,2},\s+\d{4}|\d{4}-\d{2}-\d{2})\b/iu', $text, $matches ) ) {
				$dates['published_at'] = self::clean_date_text( $matches[1] );
			}
			if ( '' !== $dates['published_at'] && '' !== $dates['modified_at'] ) {
				break;
			}
		}
		return $dates;
	}

	private static function clean_author_name( $text ) {
		$text = self::normalize_text( $text );
		$text = preg_replace( '/^(?:by|written\s+by|posted\s+by|author)\s*:?\s+/iu', '', $text );
		$text = preg_replace( '/\s+(?:on|in)\s+[A-Z][a-z]+\s+\d{1,2},\s+\d{4}.*$/u', '', $text );
		$text = trim( (string) $text, " \t\n\r\0\x0B:-|" );
		if ( '' === $text || strlen( $text ) > 80 || preg_match( '/\d|@|https?:/i', $text ) ) {
			return '';
		}
		return $text;
	}

	private static function clean_date_text( $text ) {
		$text = trim( self::normalize_text( $text ), " \t\n\r\0\x0B:." );
		if ( preg_match( '/^([A-Z][a-z]+\s+\d{1,2},\s+\d{4}|\d{4}-\d{2}-\d{2}|[0-9T:\-\+Z]+)$/u', $text ) ) {
			return $text;
		}
		if ( preg_match( '/([A-Z][a-z]+\s+\d{1,2},\s+\d{4}|\d{4}-\d{2}-\d{2})/u', $text, $matches ) ) {
			return $matches[1];
		}
		return '';
	}

	/** @return array<string,mixed> */
	private static function visible_signals( $xpath, $root, $page_url, array $metadata, array $questions ) {
		$heading_counts = array_fill( 1, 6, 0 );
		$headings       = $xpath->query( './/h1 | .//h2 | .//h3 | .//h4 | .//h5 | .//h6', $root );
		foreach ( $headings ? iterator_to_array( $headings ) : array() as $heading ) {
			$level = (int) substr( strtolower( $heading->nodeName ), 1 );
			if ( isset( $heading_counts[ $level ] ) ) {
				++$heading_counts[ $level ];
			}
		}

		$internal_links = array();
		$external_links = array();
		$page_host      = strtolower( (string) wp_parse_url( (string) $page_url, PHP_URL_HOST ) );
		$links          = $xpath->query( './/a[@href]', $root );
		foreach ( $links ? iterator_to_array( $links ) : array() as $link ) {
			$url = self::resolve_url( $link->getAttribute( 'href' ), $page_url );
			if ( '' === $url ) {
				continue;
			}
			$host = strtolower( (string) wp_parse_url( $url, PHP_URL_HOST ) );
			if ( '' === $host || $host === $page_host ) {
				$internal_links[ $url ] = true;
			} else {
				$external_links[ $url ] = true;
			}
		}

		$image_count        = 0;
		$images_missing_alt = 0;
		$images             = $xpath->query( './/img', $root );
		foreach ( $images ? iterator_to_array( $images ) : array() as $image ) {
			++$image_count;
			if ( ! $image->hasAttribute( 'alt' ) || '' === trim( $image->getAttribute( 'alt' ) ) ) {
				++$images_missing_alt;
			}
		}

		return array(
			'seo_title'          => $metadata['title'],
			'meta_description'   => $metadata['description'],
			'effective_canonical'=> $metadata['canonical'],
			'indexable'          => $metadata['indexable'],
			'h1_count'           => $heading_counts[1],
			'h2_count'           => $heading_counts[2],
			'h3_count'           => $heading_counts[3],
			'question_headings'  => (int) $questions['source_counts']['headings'],
			'question_count'     => (int) $questions['count'],
			'question_sources'   => $questions['source_counts'],
			'question_text_hashes'=> $questions['hashes'],
			'internal_links'     => count( $internal_links ),
			'external_links'     => count( $external_links ),
			'image_count'        => $image_count,
			'images_missing_alt' => $images_missing_alt,
			'list_count'         => (int) $xpath->query( './/ul | .//ol', $root )->length,
			'table_count'        => (int) $xpath->query( './/table', $root )->length,
			'schema_present'     => $metadata['schema_present'],
			'author_name'        => $metadata['author_name'],
			'author_bio'         => $metadata['author_bio'],
			'author_source'      => $metadata['author_source'],
			'published_at'       => $metadata['published_at'],
			'modified_at'        => $metadata['modified_at'],
			'published_source'   => $metadata['published_source'],
			'modified_source'    => $metadata['modified_source'],
		);
	}

	/** @return array{count:int,source_counts:array<string,int>,hashes:array<int,string>} */
	private static function questions( $xpath, $root, array $faq_questions ) {
		$by_hash = array();
		$add = function ( $text, $source, $require_predicate = true ) use ( &$by_hash ) {
			$text = Content_Miner_Rendered_Snapshot::normalize_text( $text );
			if ( '' === $text || ( $require_predicate && ! Content_Miner_Rendered_Snapshot::is_question_text( $text ) ) ) {
				return;
			}
			$normalized = strtolower( preg_replace( '/[\s\?\!\.]+$/u', '', $text ) );
			$hash       = hash( 'sha256', $normalized );
			if ( ! isset( $by_hash[ $hash ] ) ) {
				$by_hash[ $hash ] = array();
			}
			$by_hash[ $hash ][ $source ] = true;
		};

		$headings = $xpath->query( './/h1 | .//h2 | .//h3 | .//h4 | .//h5 | .//h6', $root );
		foreach ( $headings ? iterator_to_array( $headings ) : array() as $heading ) {
			$add( self::node_text( $heading ), 'headings' );
		}
		$summaries = $xpath->query( './/details/summary', $root );
		foreach ( $summaries ? iterator_to_array( $summaries ) : array() as $summary ) {
			$add( self::node_text( $summary ), 'details' );
		}
		$accordions = $xpath->query( './/button[@aria-expanded and @aria-controls] | .//h1[@aria-expanded and @aria-controls] | .//h2[@aria-expanded and @aria-controls] | .//h3[@aria-expanded and @aria-controls] | .//h4[@aria-expanded and @aria-controls] | .//h5[@aria-expanded and @aria-controls] | .//h6[@aria-expanded and @aria-controls]', $root );
		foreach ( $accordions ? iterator_to_array( $accordions ) : array() as $accordion ) {
			$add( self::node_text( $accordion ), 'accordion' );
		}
		foreach ( $faq_questions as $question ) {
			$add( $question, 'faqpage_jsonld', false );
		}

		$source_counts = array( 'headings' => 0, 'faqpage_jsonld' => 0, 'details' => 0, 'accordion' => 0 );
		foreach ( $by_hash as $sources ) {
			foreach ( array_keys( $sources ) as $source ) {
				++$source_counts[ $source ];
			}
		}
		$hashes = array_keys( $by_hash );
		sort( $hashes, SORT_STRING );
		return array( 'count' => count( $by_hash ), 'source_counts' => $source_counts, 'hashes' => $hashes );
	}

	private static function resolve_url( $raw, $page_url ) {
		$raw = trim( html_entity_decode( (string) $raw, ENT_QUOTES | ENT_HTML5, 'UTF-8' ) );
		if ( '' === $raw || preg_match( '/^(?:#|mailto:|tel:|javascript:|data:)/i', $raw ) ) {
			return '';
		}
		if ( preg_match( '#^https?://#i', $raw ) ) {
			return $raw;
		}
		$scheme = (string) wp_parse_url( (string) $page_url, PHP_URL_SCHEME );
		$host   = (string) wp_parse_url( (string) $page_url, PHP_URL_HOST );
		$port   = wp_parse_url( (string) $page_url, PHP_URL_PORT );
		if ( '' === $scheme || '' === $host ) {
			return '';
		}
		$origin = $scheme . '://' . $host . ( $port ? ':' . (int) $port : '' );
		if ( 0 === strpos( $raw, '//' ) ) {
			return $scheme . ':' . $raw;
		}
		if ( 0 === strpos( $raw, '/' ) ) {
			return $origin . $raw;
		}
		$path = (string) wp_parse_url( (string) $page_url, PHP_URL_PATH );
		$base = preg_replace( '#/[^/]*$#', '/', $path ? $path : '/' );
		return $origin . $base . $raw;
	}

	private static function schema_types( $value ) {
		$values = is_array( $value ) ? $value : array( $value );
		return array_values( array_filter( array_map( function ( $type ) { return is_scalar( $type ) ? strtolower( (string) $type ) : ''; }, $values ) ) );
	}

	private static function list_value( $value ) {
		if ( ! is_array( $value ) ) {
			return array();
		}
		return self::is_associative( $value ) ? array( $value ) : $value;
	}

	private static function first_object( $value ) {
		$values = self::list_value( $value );
		return $values && is_array( $values[0] ) ? $values[0] : array();
	}

	private static function is_associative( array $value ) {
		if ( array() === $value ) {
			return false;
		}
		return array_keys( $value ) !== range( 0, count( $value ) - 1 );
	}

	private static function node_text( $node ) {
		$parts = array();
		foreach ( $node->childNodes as $child ) {
			if ( XML_TEXT_NODE === $child->nodeType || XML_CDATA_SECTION_NODE === $child->nodeType ) {
				$text = self::normalize_text( $child->nodeValue );
				if ( '' !== $text ) {
					$parts[] = $text;
				}
			} else {
				$text = self::node_text( $child );
				if ( '' !== $text ) {
					$parts[] = $text;
				}
			}
		}
		return self::normalize_text( implode( ' ', $parts ) );
	}

	private static function node_depth( $node ) {
		$depth = 0;
		while ( $node && $node->parentNode ) {
			++$depth;
			$node = $node->parentNode;
		}
		return $depth;
	}

	public static function normalize_text( $text ) {
		$text = html_entity_decode( (string) $text, ENT_QUOTES | ENT_HTML5, 'UTF-8' );
		$text = str_replace( "\xc2\xa0", ' ', $text );
		return trim( (string) preg_replace( '/\s+/u', ' ', $text ) );
	}

	public static function content_hash( $content ) {
		return hash( 'sha256', self::normalize_text( wp_strip_all_tags( (string) $content ) ) );
	}

	public static function word_count( $text ) {
		$text  = self::normalize_text( wp_strip_all_tags( (string) $text ) );
		$words = preg_split( '/\s+/u', $text, -1, PREG_SPLIT_NO_EMPTY );
		return is_array( $words ) ? count( $words ) : 0;
	}

	private static function elapsed_ms( $started ) {
		return (int) round( max( 0, microtime( true ) - (float) $started ) * 1000 );
	}
}
