<?php

/**
 * CMS-neutral, evidence-based search and AI readiness analysis.
 */
class Content_Miner_Readiness_Analyzer {
	/** @var Content_Miner_Family_Classifier|null */
	private $classifier;

	/** @var array<string,string> */
	private $issue_labels = array(
		'not-indexable'          => 'Page is not currently indexable.',
		'title-length'           => 'Search title is missing, too short, or too long.',
		'missing-description'    => 'No search description was found in the rendered page metadata.',
		'description-length'     => 'The rendered search description is shorter than 70 characters or longer than 170 characters.',
		'weak-sectioning'        => 'The page has little visible section structure.',
		'no-internal-links'      => 'No internal contextual links were found in the rendered content.',
		'image-alt-gaps'         => 'One or more rendered-content images are missing alt text.',
		'stale-content'          => 'The page has not been updated in more than two years.',
		'thin-content'           => 'The page has too little substantive text for this assessment.',
		'no-question'            => 'No explicit question was found.',
		'no-direct-answer'       => 'The measured opening does not make a clear claim about the page subject.',
		'weak-intro-alignment'   => 'The opening does not clearly reinforce the subject promised by the title.',
		'no-answer-structure'    => 'No list or table was found to support an extractable answer.',
		'no-faq-depth'           => 'The page answers fewer than two explicit questions.',
		'no-author'              => 'No attributable author was found.',
		'no-author-context'      => 'The rendered page has no visible author context.',
		'no-external-sources'    => 'No external supporting source links were found.',
		'no-specific-evidence'   => 'No dates, percentages, currency, measurements, or other specific evidence were detected.',
		'missing-dates'          => 'No credible publication or modification date was found.',
		'no-supported-schema'    => 'No supported structured data was detected in the rendered page.',
		'route-transit-gap'      => 'Route content does not explain transit time or delivery timing.',
		'route-price-gap'        => 'Route content does not explain rates, quotes, costs, or pricing factors.',
		'route-logistics-gap'    => 'Route content does not address pickup, delivery, terminals, or carrier handling.',
	);

	/** @param Content_Miner_Family_Classifier|null $classifier Optional family classifier supplied by a CMS connector. */
	public function __construct( $classifier = null ) {
		$this->classifier = $classifier;
	}

	/**
	 * @param array<string,mixed> $item Normalized content record.
	 * @return array<string,mixed>
	 */
	public function analyze_item( array $item ) {
		$provided_signals = isset( $item['signals'] ) && is_array( $item['signals'] ) ? $item['signals'] : array();
		$signals    = array_merge( $this->signal_defaults(), $provided_signals );
		$raw_content = isset( $item['content'] ) ? (string) $item['content'] : '';
		$text       = $this->normalize_text( $raw_content );
		$title      = isset( $signals['seo_title'] ) && '' !== trim( $signals['seo_title'] ) ? $signals['seo_title'] : $item['title'];
		$question_count = array_key_exists( 'question_count', $provided_signals )
			? (int) $provided_signals['question_count']
			: (int) $signals['question_headings'];
		$word_count = $this->count_words( $text );
		$opening_source = $this->opening_source( $raw_content );
		$opening_text = $this->normalize_text( $opening_source['text'] );
		$intro      = implode( ' ', array_slice( preg_split( '/\s+/u', $opening_text, -1, PREG_SPLIT_NO_EMPTY ), 0, 120 ) );
		$direct_answer = $this->direct_answer_evidence( $title, $opening_source, $word_count );
		$family     = isset( $item['family'] ) ? $item['family'] : ( $this->classifier ? $this->classifier->classify( $item ) : 'other-page' );
		$author_required = $this->requires_author( $item, $family );
		$question_checks_apply = $this->question_checks_apply( $item, $family );
		$description = trim( (string) $signals['meta_description'] );
		$description_issue = '' === $description ? 'missing-description' : 'description-length';
		$checks     = array(
			'seo' => array(
				$this->check( ! empty( $signals['indexable'] ), 20, 'not-indexable', 'high' ),
				$this->check( $this->text_length( $title ) >= 20 && $this->text_length( $title ) <= 65, 14, 'title-length', 'medium' ),
				$this->check( $this->text_length( $description ) >= 70 && $this->text_length( $description ) <= 170, 14, $description_issue, 'medium' ),
				$this->check( $word_count >= 250, 14, 'thin-content', 'high' ),
				$this->check( (int) $signals['h2_count'] + (int) $signals['h3_count'] >= 2, 10, 'weak-sectioning', 'medium' ),
				$this->check( (int) $signals['internal_links'] >= 1, 12, 'no-internal-links', 'medium' ),
				$this->check( 0 === (int) $signals['images_missing_alt'], 8, 'image-alt-gaps', 'medium' ),
				$this->check( $this->age_days( $item['modified_at'] ) <= 730, 8, 'stale-content', 'medium' ),
			),
			'aeo' => array(
				$this->check( $word_count >= 250, 15, 'thin-content', 'high' ),
				$this->check( $question_count >= 1, 18, 'no-question', 'medium', $question_checks_apply ),
				$this->check( ! empty( $direct_answer['measured'] ) && ! empty( $direct_answer['passed'] ), 20, 'no-direct-answer', 'high', ! empty( $direct_answer['measured'] ) ),
				$this->check( $this->title_intro_alignment( $title, $intro ) >= 0.35, 17, 'weak-intro-alignment', 'medium' ),
				$this->check( (int) $signals['list_count'] + (int) $signals['table_count'] >= 1, 12, 'no-answer-structure', 'low' ),
				$this->check( $question_count >= 2, 10, 'no-faq-depth', 'low', $question_checks_apply ),
				$this->check( (int) $signals['h2_count'] + (int) $signals['h3_count'] >= 2, 8, 'weak-sectioning', 'medium' ),
			),
			'geo' => array(
				$this->check( ! $author_required || '' !== trim( $signals['author_name'] ), 12, 'no-author', 'medium' ),
				$this->check( (int) $signals['external_links'] >= 1, 20, 'no-external-sources', 'high' ),
				$this->check( $this->has_specific_evidence( $text ), 18, 'no-specific-evidence', 'medium' ),
				$this->check( ! empty( $item['published_at'] ) || ! empty( $item['modified_at'] ), 10, 'missing-dates', 'medium' ),
				$this->check( $this->title_intro_alignment( $title, $intro ) >= 0.35, 15, 'weak-intro-alignment', 'medium' ),
				$this->check( $word_count >= 400, 15, 'thin-content', 'high' ),
			),
			'aio' => array(
				$this->check( ! empty( $signals['indexable'] ), 20, 'not-indexable', 'high' ),
				$this->check( $word_count >= 300, 15, 'thin-content', 'high' ),
				$this->check( ! empty( $direct_answer['measured'] ) && ! empty( $direct_answer['passed'] ), 15, 'no-direct-answer', 'high', ! empty( $direct_answer['measured'] ) ),
				$this->check( (int) $signals['h2_count'] + (int) $signals['h3_count'] >= 2, 10, 'weak-sectioning', 'medium' ),
				$this->check( ! empty( $signals['schema_present'] ), 12, 'no-supported-schema', 'low' ),
				$this->check( (int) $signals['internal_links'] >= 1, 10, 'no-internal-links', 'medium' ),
				$this->check( (int) $signals['external_links'] >= 1 || $this->has_specific_evidence( $text ), 10, 'no-specific-evidence', 'medium' ),
				$this->check( $this->title_intro_alignment( $title, $intro ) >= 0.35, 8, 'weak-intro-alignment', 'medium' ),
			),
		);

		if ( 'nationwide-route' === $family ) {
			$checks['aeo'][] = $this->check( (bool) preg_match( '/\b(transit|delivery time|business days?|same[- ]day|next[- ]day)\b/i', $text ), 10, 'route-transit-gap', 'high' );
			$checks['geo'][] = $this->check( (bool) preg_match( '/\b(rate|rates|quote|cost|costs|pricing|price)\b/i', $text ), 10, 'route-price-gap', 'high' );
			$checks['aio'][] = $this->check( (bool) preg_match( '/\b(pickup|delivery|terminal|carrier|handling)\b/i', $text ), 10, 'route-logistics-gap', 'high' );
		}

		$scores = array();
		$issues = array();
		foreach ( $checks as $lens => $lens_checks ) {
			$total  = 0;
			$earned = 0;
			foreach ( $lens_checks as $check ) {
				if ( empty( $check['measured'] ) ) {
					continue;
				}
				$total += $check['weight'];
				if ( $check['passed'] ) {
					$earned += $check['weight'];
				} else {
					$issues[ $check['code'] ] = array(
						'code'     => $check['code'],
						'label'    => $this->issue_labels[ $check['code'] ],
						'lens'     => strtoupper( $lens ),
						'severity' => $check['severity'],
						'weight'   => $check['weight'],
					);
				}
			}
			$scores[ $lens ] = $total > 0 ? (int) round( ( $earned / $total ) * 100 ) : 0;
		}

		usort( $issues, array( $this, 'sort_issues' ) );
		$overall = (int) round( array_sum( $scores ) / count( $scores ) );

		return array(
			'scores'       => $scores,
			'overall'      => $overall,
			'status'       => $overall >= 80 ? 'strong' : ( $overall >= 60 ? 'improve' : 'weak' ),
			'issues'       => array_values( $issues ),
			'not_measured' => $this->not_measured( $provided_signals, $signals, $direct_answer, $question_checks_apply ),
			'evidence'     => array(
				'direct_answer_span'   => $direct_answer['span'],
				'direct_answer_passed' => $direct_answer['measured'] ? (bool) $direct_answer['passed'] : null,
				'direct_answer_reason' => $direct_answer['reason'],
			),
		);
	}

	/** @param array<string,mixed> $result Full result. @return array<string,mixed> */
	public function compact( array $result ) {
		return array(
			'scores'       => $result['scores'],
			'overall'      => $result['overall'],
			'status'       => $result['status'],
			'issues'       => array_column( $result['issues'], 'code' ),
			'not_measured' => isset( $result['not_measured'] ) && is_array( $result['not_measured'] ) ? $result['not_measured'] : array(),
			'evidence'     => isset( $result['evidence'] ) && is_array( $result['evidence'] ) ? $result['evidence'] : array(),
		);
	}

	/** @return array<string,string> */
	public function get_issue_labels() {
		return $this->issue_labels;
	}

	/** @return array<string,mixed> */
	private function signal_defaults() {
		return array(
			'seo_title'          => '',
			'meta_description'   => '',
			'indexable'          => true,
			'h2_count'           => 0,
			'h3_count'           => 0,
			'question_headings'  => 0,
			'question_count'     => 0,
			'internal_links'     => 0,
			'external_links'     => 0,
			'image_count'        => 0,
			'images_missing_alt' => 0,
			'list_count'         => 0,
			'table_count'        => 0,
			'schema_present'     => false,
			'author_name'        => '',
			'author_bio'         => '',
		);
	}

	/** @return array<string,string> */
	private function not_measured( array $provided_signals, array $signals, array $direct_answer, $question_checks_apply = true ) {
		$not_measured = array();
		if ( '' === trim( (string) $signals['author_name'] ) ) {
			$not_measured['author'] = 'not_measured';
		}
		if ( ! array_key_exists( 'author_bio', $provided_signals ) || '' === trim( (string) $provided_signals['author_bio'] ) ) {
			$not_measured['author_context'] = 'not_measured';
		}
		if ( empty( $direct_answer['measured'] ) ) {
			$not_measured['direct_answer'] = 'not_measured';
		}
		if ( ! $question_checks_apply ) {
			$not_measured['no-question'] = 'not applicable to this page type';
			$not_measured['no-faq-depth'] = 'not applicable to this page type';
		}
		return $not_measured;
	}

	/** @return bool */
	private function requires_author( array $item, $family ) {
		if ( isset( $item['post_type'] ) && 'post' === (string) $item['post_type'] ) {
			return true;
		}
		return in_array( (string) $family, array( 'editorial', 'help-content', 'shipping-guide' ), true );
	}

	/** Return whether explicit-question checks apply to this scored page. */
	private function question_checks_apply( array $item, $family ) {
		unset( $family );
		return ! $this->is_conversion_action_page( $item );
	}

	/** Conservative exact-match detection for contact, demo, quote, and estimate action pages. */
	private function is_conversion_action_page( array $item ) {
		if ( isset( $item['post_type'] ) && 'page' !== (string) $item['post_type'] ) {
			return false;
		}
		$path = isset( $item['url'] ) ? (string) wp_parse_url( $item['url'], PHP_URL_PATH ) : '';
		$segments = array_values( array_filter( explode( '/', trim( $path, '/' ) ) ) );
		$slug = $segments ? $this->normalize_slug( end( $segments ) ) : '';
		$title = isset( $item['title'] ) ? $this->normalize_slug( $item['title'] ) : '';
		$exact = array(
			'book-a-demo',
			'book-demo',
			'contact',
			'contact-us',
			'demo',
			'estimate',
			'free-estimate',
			'get-a-quote',
			'get-in-touch',
			'quote',
			'request-a-demo',
			'request-a-quote',
			'request-an-estimate',
			'request-demo',
			'request-estimate',
			'request-quote',
			'schedule-a-demo',
			'schedule-demo',
		);
		return in_array( $slug, $exact, true ) || in_array( $title, $exact, true );
	}

	/** @return string */
	private function normalize_slug( $value ) {
		$value = html_entity_decode( (string) $value, ENT_QUOTES | ENT_HTML5, 'UTF-8' );
		$value = function_exists( 'mb_strtolower' ) ? mb_strtolower( $value, 'UTF-8' ) : strtolower( $value );
		$value = preg_replace( '/[^\p{L}\p{N}]+/u', '-', $value );
		return trim( (string) $value, '-' );
	}

	/** @return array<string,mixed> */
	private function check( $passed, $weight, $code, $severity, $measured = true ) {
		return array( 'passed' => (bool) $passed, 'weight' => (int) $weight, 'code' => $code, 'severity' => $severity, 'measured' => (bool) $measured );
	}

	/** @param array<string,mixed> $left Left. @param array<string,mixed> $right Right. @return int */
	private function sort_issues( $left, $right ) {
		$rank = array( 'high' => 3, 'medium' => 2, 'low' => 1 );
		if ( $rank[ $left['severity'] ] === $rank[ $right['severity'] ] ) {
			return $right['weight'] <=> $left['weight'];
		}
		return $rank[ $right['severity'] ] <=> $rank[ $left['severity'] ];
	}

	/** @return bool */
	private function has_specific_evidence( $text ) {
		return (bool) preg_match( '/(?:\$\s?\d|\b\d+(?:[.,]\d+)?\s?(?:%|miles?|days?|hours?|minutes?|pounds?|lbs?|tons?|years?|20\d{2})\b)/i', $text );
	}

	/** @return array{text:string,isolated:bool,reason:string} */
	private function opening_source( $content ) {
		$content = (string) $content;
		if ( '' === trim( $content ) ) {
			return array( 'text' => '', 'isolated' => false, 'reason' => 'empty_content' );
		}
		if ( ! $this->looks_like_html( $content ) ) {
			return array( 'text' => $this->normalize_visible_text( $content ), 'isolated' => true, 'reason' => 'plain_text_content' );
		}

		if ( class_exists( 'DOMDocument' ) && class_exists( 'DOMXPath' ) ) {
			$previous = libxml_use_internal_errors( true );
			$document = new DOMDocument();
			$loaded = $document->loadHTML(
				'<?xml encoding="UTF-8"><div id="content-miner-readiness-root">' . $content . '</div>',
				LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD
			);
			libxml_clear_errors();
			libxml_use_internal_errors( $previous );
			if ( $loaded ) {
				$xpath = new DOMXPath( $document );
				$remove = $xpath->query( '//script | //style | //noscript | //template | //nav | //header | //footer | //aside | //*[@hidden or translate(@aria-hidden, "TRUE", "true") = "true" or @role="navigation" or @role="banner" or @role="contentinfo" or @role="complementary"]' );
				foreach ( $remove ? iterator_to_array( $remove ) : array() as $node ) {
					if ( $node->parentNode ) {
						$node->parentNode->removeChild( $node );
					}
				}
				$roots = $xpath->query( '//main | //article | //*[@role="main"]' );
				$root = $roots && $roots->length ? $roots->item( 0 ) : null;
				if ( ! $root ) {
					return array( 'text' => '', 'isolated' => false, 'reason' => 'no_main_content' );
				}
				return array( 'text' => $this->normalize_visible_text( $root->textContent ), 'isolated' => true, 'reason' => 'main_content' );
			}
		}

		return array( 'text' => '', 'isolated' => false, 'reason' => 'opening_parse_failed' );
	}

	/** @param array{text:string,isolated:bool,reason:string} $opening_source @return array{measured:bool,passed:bool,span:string,reason:string} */
	private function direct_answer_evidence( $title, array $opening_source, $word_count ) {
		$text = $this->measurable_opening_text( $title, $opening_source['text'] );
		$span = $this->opening_span( $text, $title );
		if ( empty( $opening_source['isolated'] ) ) {
			return array( 'measured' => false, 'passed' => false, 'span' => $span, 'reason' => $opening_source['reason'] );
		}
		if ( (int) $word_count < 20 || $this->count_words( $this->normalize_text( $text ) ) < 12 ) {
			return array( 'measured' => false, 'passed' => false, 'span' => $span, 'reason' => 'opening_too_thin' );
		}
		if ( '' === $span ) {
			return array( 'measured' => false, 'passed' => false, 'span' => '', 'reason' => 'no_opening_span' );
		}
		$subject_tokens = $this->subject_tokens( $title );
		if ( empty( $subject_tokens ) ) {
			return array( 'measured' => false, 'passed' => false, 'span' => $span, 'reason' => 'subject_not_available' );
		}

		$judged_span = $this->prose_judgment_span( $span, $title );
		if ( $this->sentence_references_subject( $judged_span, $subject_tokens ) && $this->opening_makes_subject_claim( $judged_span ) ) {
			return array( 'measured' => true, 'passed' => true, 'span' => $span, 'reason' => 'subject_claim_found' );
		}

		return array( 'measured' => true, 'passed' => false, 'span' => $span, 'reason' => 'no_subject_claim_in_opening' );
	}

	/** @return string */
	private function measurable_opening_text( $title, $text ) {
		$text = $this->normalize_visible_text( $text );
		if ( '' === $text ) {
			return '';
		}

		$text = preg_replace( '/^(?:back\s+to\s+blog|legal)\b\s*/iu', '', $text );
		$phrases = $this->title_phrases( $title );
		foreach ( $phrases as $phrase ) {
			$pattern = '/' . preg_quote( $phrase, '/' ) . '/iu';
			if ( preg_match( $pattern, $text, $match, PREG_OFFSET_CAPTURE ) ) {
				$offset = (int) $match[0][1];
				if ( $offset <= 180 ) {
					return $this->truncate_opening_furniture( substr( $text, $offset ) );
				}
			}
		}

		return $this->truncate_opening_furniture( $text );
	}

	/** @return string */
	private function truncate_opening_furniture( $text ) {
		$text = $this->normalize_visible_text( $text );
		$text = preg_replace( '/\s+By\s+[A-Z][^.]*(?:Founder|CEO|Author|Editor)\b.*$/u', '', $text );
		$text = preg_replace( '/\s+Key\s+Takeaways\b.*$/iu', '', (string) $text );
		return $this->normalize_visible_text( $text );
	}

	/** @return string */
	private function opening_span( $text, $title = '' ) {
		$sentences = $this->sentences( $text );
		$selected = array();
		foreach ( $sentences as $sentence ) {
			$count = $this->count_words( $this->normalize_text( $sentence ) );
			if ( 0 === $count ) {
				continue;
			}
			$selected[] = $sentence;
			if ( 1 === count( $selected ) && $this->is_title_only_sentence( $sentence, $title ) ) {
				continue;
			}
			if ( $this->is_opening_fragment( $sentence ) ) {
				continue;
			}
			$span = $this->normalize_visible_text( implode( ' ', $selected ) );
			$tokens = preg_split( '/\s+/u', $span, -1, PREG_SPLIT_NO_EMPTY );
			return $tokens && count( $tokens ) > 80 ? implode( ' ', array_slice( $tokens, 0, 80 ) ) : $span;
		}
		$tokens = preg_split( '/\s+/u', $this->normalize_visible_text( $text ), -1, PREG_SPLIT_NO_EMPTY );
		return $tokens ? implode( ' ', array_slice( $tokens, 0, 80 ) ) : '';
	}

	/** @return string */
	private function prose_judgment_span( $span, $title ) {
		$span = $this->normalize_visible_text( $span );
		if ( '' === $span ) {
			return '';
		}

		foreach ( $this->title_phrases( $title ) as $phrase ) {
			if ( 0 !== stripos( $span, $phrase ) ) {
				continue;
			}
			$remainder = $this->normalize_visible_text( substr( $span, strlen( $phrase ) ) );
			if ( '' === $remainder || $this->starts_as_subject_claim_continuation( $remainder ) ) {
				continue;
			}
			return $remainder;
		}

		return $span;
	}

	/** @return bool */
	private function starts_as_subject_claim_continuation( $text ) {
		return (bool) preg_match( '/^(?:is|are|was|were|means|refers\s+to|explains|handles|provides|enables|helps|uses|includes|covers|supports|connects|flags|detects|verifies|scores|[’\']s)\b/iu', $text );
	}

	/** @return bool */
	private function is_title_only_sentence( $sentence, $title ) {
		$sentence_tokens = $this->tokens( $sentence );
		$title_tokens = $this->tokens( $title );
		if ( empty( $sentence_tokens ) || empty( $title_tokens ) ) {
			return false;
		}
		$overlap = array_intersect( $sentence_tokens, $title_tokens );
		return count( $overlap ) >= max( 2, count( $sentence_tokens ) - 1 );
	}

	/** @return bool */
	private function is_opening_fragment( $sentence ) {
		$sentence = $this->normalize_visible_text( $sentence );
		$count = $this->count_words( $this->normalize_text( $sentence ) );
		if ( $count >= 10 ) {
			return false;
		}
		return ! $this->sentence_makes_claim( $sentence ) && ! $this->sentence_has_definitional_noun_phrase( $sentence );
	}

	/** @return string[] */
	private function sentences( $text ) {
		$text = $this->normalize_visible_text( $text );
		if ( '' === $text ) {
			return array();
		}
		$parts = preg_split( '/(?<=[.!?])(?:\s+|(?=[A-Z]))/u', $text, -1, PREG_SPLIT_NO_EMPTY );
		return array_values( array_map( array( $this, 'normalize_visible_text' ), is_array( $parts ) ? $parts : array( $text ) ) );
	}

	/** @return bool */
	private function is_declarative_sentence( $sentence ) {
		$sentence = $this->normalize_visible_text( $sentence );
		if ( ! preg_match( '/\.$/u', $sentence ) ) {
			return false;
		}
		if ( $this->count_words( $this->normalize_text( $sentence ) ) < 5 ) {
			return false;
		}
		return ! preg_match( '/^(?:welcome|explore|learn|browse|click|sign in|log in|schedule|key takeaways)\b/iu', $sentence );
	}

	/** @param string[] $subject_tokens */
	private function sentence_references_subject( $sentence, array $subject_tokens ) {
		$sentence_tokens = $this->tokens( $sentence );
		foreach ( $subject_tokens as $token ) {
			if ( in_array( $token, $sentence_tokens, true ) ) {
				return true;
			}
		}
		return false;
	}

	/** @return bool */
	private function sentence_makes_claim( $sentence ) {
		return (bool) preg_match( '/\b(?:is|are|was|were|means|refers\s+to|explains|handles|provides|enables|helps|uses|includes|covers|supports|gives|lets|makes|connects|automates|detects|verifies|flags|scores|evaluates|protects|reduces|improves|delivers|allows|brings|combines|turns|transforms|builds|built|creates|identifies|reviews|records|shows|announces|announced|launches|launched)\b/iu', $sentence );
	}

	/** @return bool */
	private function opening_makes_subject_claim( $span ) {
		$span = $this->normalize_visible_text( $span );
		if ( preg_match( '/^(?:welcome|explore|learn|browse|click|sign in|log in|schedule|key takeaways)\b/iu', $span ) ) {
			return false;
		}
		return $this->sentence_makes_claim( $span ) || $this->sentence_has_definitional_noun_phrase( $span );
	}

	/** @return bool */
	private function sentence_has_definitional_noun_phrase( $sentence ) {
		$sentence = $this->normalize_visible_text( $sentence );
		return (bool) preg_match( '/(?:\b[A-Z][A-Za-z0-9-]+(?:\s+[A-Z][A-Za-z0-9-]+)*[’\']s\s+\p{L}|\b(?:a|an|the|this|these|new|single|practical|plain-language|proprietary|purpose-built)\s+\p{L}[\p{L}\p{N}\s,\/-]{6,}\b(?:for|that|which|with|to|of|in|at|across|before)\b)/iu', $sentence );
	}

	/** @return string[] */
	private function subject_tokens( $title ) {
		$tokens = $this->tokens( $title );
		$generic = array( 'home', 'page', 'site', 'website', 'welcome', 'about', 'contact', 'blog', 'products', 'services', 'platform' );
		$subjects = array();
		foreach ( $tokens as $token ) {
			if ( strlen( $token ) < 3 || in_array( $token, $generic, true ) ) {
				continue;
			}
			$subjects[] = $token;
			if ( count( $subjects ) >= 6 ) {
				break;
			}
		}
		return $subjects;
	}

	/** @return string[] */
	private function title_phrases( $title ) {
		$title = $this->normalize_visible_text( $title );
		if ( '' === $title ) {
			return array();
		}
		$candidates = array( $title );
		$parts = preg_split( '/\s+[|–—-]\s+/u', $title, -1, PREG_SPLIT_NO_EMPTY );
		if ( is_array( $parts ) && ! empty( $parts ) ) {
			$candidates[] = $parts[0];
		}

		$phrases = array();
		foreach ( $candidates as $candidate ) {
			$candidate = trim( preg_replace( '/\s+/u', ' ', (string) $candidate ) );
			if ( '' === $candidate || $this->count_words( $candidate ) > 12 ) {
				continue;
			}
			$phrases[] = $candidate;
		}

		return array_values( array_unique( $phrases ) );
	}

	/** @return float */
	private function title_intro_alignment( $title, $intro ) {
		$title_tokens = $this->tokens( $title );
		$intro_tokens = $this->tokens( $intro );
		if ( empty( $title_tokens ) ) {
			return 0.0;
		}
		return count( array_intersect( $title_tokens, $intro_tokens ) ) / count( $title_tokens );
	}

	/** @return string */
	private function strip_structural_furniture( $content ) {
		$content = (string) $content;
		if ( ! $this->looks_like_html( $content ) ) {
			return $content;
		}

		if ( class_exists( 'DOMDocument' ) && class_exists( 'DOMXPath' ) ) {
			$previous = libxml_use_internal_errors( true );
			$document = new DOMDocument();
			$loaded = $document->loadHTML(
				'<?xml encoding="UTF-8"><div id="content-miner-readiness-root">' . $content . '</div>',
				LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD
			);
			libxml_clear_errors();
			libxml_use_internal_errors( $previous );
			if ( $loaded ) {
				$xpath = new DOMXPath( $document );
				$nodes = $xpath->query( '//nav | //header | //footer | //aside | //*[@role="navigation" or @role="banner" or @role="contentinfo" or @role="complementary"]' );
				$remove = $nodes ? iterator_to_array( $nodes ) : array();
				foreach ( $remove as $node ) {
					if ( $node->parentNode ) {
						$node->parentNode->removeChild( $node );
					}
				}
				$root = $document->getElementById( 'content-miner-readiness-root' );
				return $root ? $document->saveHTML( $root ) : $document->saveHTML();
			}
		}

		return preg_replace( '#<(nav|header|footer|aside)\b[^>]*>.*?</\1>#is', ' ', $content );
	}

	/** @return string[] */
	private function tokens( $text ) {
		$tokens = preg_split( '/\s+/u', $this->normalize_text( $text ), -1, PREG_SPLIT_NO_EMPTY );
		$stop   = array( 'a', 'an', 'and', 'for', 'in', 'of', 'on', 'the', 'to', 'with' );
		return array_values( array_unique( array_diff( $tokens, $stop ) ) );
	}

	/** @return string */
	private function normalize_text( $text ) {
		$text = function_exists( 'wp_strip_all_tags' ) ? wp_strip_all_tags( (string) $text ) : preg_replace( '/<[^>]*>/', '', (string) $text );
		$text = html_entity_decode( $text, ENT_QUOTES | ENT_HTML5, 'UTF-8' );
		$text = function_exists( 'mb_strtolower' ) ? mb_strtolower( $text, 'UTF-8' ) : strtolower( $text );
		$text = preg_replace( '/[^\p{L}\p{N}\s%$.-]+/u', ' ', $text );
		return trim( (string) preg_replace( '/\s+/u', ' ', $text ) );
	}

	/** @return bool */
	private function looks_like_html( $content ) {
		return (bool) preg_match( '/<[A-Za-z][^>]*>/u', (string) $content );
	}

	/** @return string */
	private function normalize_visible_text( $text ) {
		$text = function_exists( 'wp_strip_all_tags' ) ? wp_strip_all_tags( (string) $text ) : preg_replace( '/<[^>]*>/', '', (string) $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 ) );
	}

	/** @return int */
	private function count_words( $text ) {
		return '' === $text ? 0 : count( preg_split( '/\s+/u', $text, -1, PREG_SPLIT_NO_EMPTY ) );
	}

	/** @return int */
	private function text_length( $text ) {
		return function_exists( 'mb_strlen' ) ? mb_strlen( trim( (string) $text ), 'UTF-8' ) : strlen( trim( (string) $text ) );
	}

	/** @return int */
	private function age_days( $date ) {
		$timestamp = strtotime( (string) $date . ' UTC' );
		return $timestamp ? max( 0, (int) floor( ( time() - $timestamp ) / 86400 ) ) : 0;
	}
}
