<?php
/**
 * Livada SEO — Module AEO (AI Engine Optimization)
 *
 * Optimizes content for discovery, understanding, and recommendation by AI
 * systems: ChatGPT, Gemini, Perplexity, Claude, Copilot, etc.
 *
 * Features:
 *   1. AI-friendly meta tags in wp_head (priority 5)
 *   2. Knowledge Graph / Organization entity JSON-LD (front page)
 *   3. AI content signals via the_content filter (priority 30)
 *   4. /llms.txt and /llms-full.txt virtual endpoints
 *   5. AI discoverability HTTP headers via send_headers
 *   6. AEO analysis — analyze_aeo( $post_id )
 *
 * Settings keys (stored in livada_seo_settings):
 *   aeo_enabled           bool   Master switch
 *   aeo_max_snippet       bool   Output max-snippet robots directive
 *   aeo_llms_txt          bool   Serve /llms.txt
 *   aeo_knowledge_graph   bool   Output Organization entity JSON-LD
 *   aeo_org_description   string Organization description
 *   aeo_org_founding_date string e.g. '2005'
 *   aeo_org_founder       string Founder name
 *   aeo_org_knows_about   array  Expertise topics
 *   aeo_content_signals   bool   Add AI-friendly content markers
 *
 * @package Livada_SEO
 */

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

class Livada_SEO_AEO {

	// ── Bootstrap ─────────────────────────────────────────────────────────────

	/**
	 * Registers all hooks for this module.
	 * Called once by Livada_SEO_Loader::init().
	 */
	public static function init(): void {
		if ( ! Livada_SEO_Loader::get_setting( 'aeo_enabled', true ) ) {
			return;
		}

		// 1. AI-friendly meta tags.
		add_action( 'wp_head', [ self::class, 'output_meta_tags' ], 5 );

		// 2. Knowledge Graph JSON-LD (front page).
		add_action( 'wp_head', [ self::class, 'output_knowledge_graph' ], 5 );

		// 3. AI content signals via the_content.
		if ( Livada_SEO_Loader::get_setting( 'aeo_content_signals', true ) ) {
			add_filter( 'the_content', [ self::class, 'add_content_signals' ], 30 );
		}

		// 4. /llms.txt and /llms-full.txt endpoints.
		if ( Livada_SEO_Loader::get_setting( 'aeo_llms_txt', true ) ) {
			add_action( 'init', [ self::class, 'register_llms_rewrite_rules' ] );
			add_filter( 'query_vars', [ self::class, 'add_llms_query_vars' ] );
			add_action( 'template_redirect', [ self::class, 'serve_llms_txt' ], 2 );
			// Auto-flush rewrite rules when plugin version changes (fix for 404 on first install).
			add_action( 'wp_loaded', [ self::class, 'maybe_flush_rewrites' ] );
		}

		// 5. AI discoverability HTTP headers.
		add_action( 'send_headers', [ self::class, 'send_ai_headers' ] );
	}

	// ── 1. AI-Friendly Meta Tags ──────────────────────────────────────────────

	/**
	 * Outputs AI-friendly meta tags into <head>.
	 * Hooked on wp_head at priority 5.
	 */
	public static function output_meta_tags(): void {
		// max-snippet directive — on all pages (if enabled).
		if ( Livada_SEO_Loader::get_setting( 'aeo_max_snippet', true ) ) {
			echo '<meta name="robots" content="max-snippet:-1, max-image-preview:large, max-video-preview:-1" />' . "\n";
		}

		// Singular posts/pages only for the contextual tags.
		if ( ! is_singular() ) {
			return;
		}

		$post_id = get_the_ID();

		if ( ! $post_id ) {
			return;
		}

		echo '<!-- AI Engine Optimization -->' . "\n";

		// Summary — first 200 chars of meta description or excerpt.
		$summary = self::get_summary( $post_id );
		if ( '' !== $summary ) {
			printf(
				'<meta name="summary" content="%s" />' . "\n",
				esc_attr( $summary )
			);
		}

		// Topic — focus keyword or first category name.
		$topic = self::get_topic( $post_id );
		if ( '' !== $topic ) {
			printf(
				'<meta name="topic" content="%s" />' . "\n",
				esc_attr( $topic )
			);
		}

		// Subject — post title.
		$title = get_the_title( $post_id );
		if ( '' !== $title ) {
			printf(
				'<meta name="subject" content="%s" />' . "\n",
				esc_attr( $title )
			);
		}

		// Classification — first category name.
		$category = self::get_first_category( $post_id );
		if ( '' !== $category ) {
			printf(
				'<meta name="classification" content="%s" />' . "\n",
				esc_attr( $category )
			);
		}

		// Static AI-friendly declarations.
		echo '<meta name="target" content="all" />' . "\n";
		echo '<meta name="audience" content="all" />' . "\n";
		echo '<meta name="coverage" content="Worldwide" />' . "\n";
		echo '<meta name="distribution" content="Global" />' . "\n";
		echo '<meta name="rating" content="General" />' . "\n";
		echo '<meta name="revisit-after" content="7 days" />' . "\n";
	}

	// ── 2. Knowledge Graph / Organization Entity ──────────────────────────────

	/**
	 * Outputs the Organization JSON-LD entity on the front page.
	 * Hooked on wp_head at priority 5.
	 */
	public static function output_knowledge_graph(): void {
		if ( ! Livada_SEO_Loader::get_setting( 'aeo_knowledge_graph', true ) ) {
			return;
		}

		if ( ! is_front_page() ) {
			return;
		}

		$name = (string) Livada_SEO_Loader::get_setting( 'schema_name', '' );
		if ( '' === $name ) {
			return;
		}

		$url         = (string) Livada_SEO_Loader::get_setting( 'schema_url', home_url( '/' ) );
		$description = (string) Livada_SEO_Loader::get_setting( 'aeo_org_description', '' );
		$founding    = (string) Livada_SEO_Loader::get_setting( 'aeo_org_founding_date', '' );
		$founder     = (string) Livada_SEO_Loader::get_setting( 'aeo_org_founder', '' );
		$knows_about = Livada_SEO_Loader::get_setting( 'aeo_org_knows_about', [] );
		$city        = (string) Livada_SEO_Loader::get_setting( 'schema_city', '' );
		$same_as     = Livada_SEO_Loader::get_setting( 'schema_same_as', [] );

		// Map the configured Schema.org Business Type onto the Knowledge Graph
		// node so an Hôtel doesn't identify as a generic Organization to AI
		// engines. Anything outside this allow-list falls back to Organization.
		$schema_type = (string) Livada_SEO_Loader::get_setting( 'schema_type', 'Organization' );
		$allowed     = [
			'Hotel',
			'Campground',
			'BedAndBreakfast',
			'LodgingBusiness',
			'Resort',
			'Hostel',
			'Motel',
			'VacationRental',
			'Restaurant',
			'LocalBusiness',
			'Store',
			'TouristAttraction',
			'MedicalBusiness',
			'HealthAndBeautyBusiness',
			'SportsActivityLocation',
			'GovernmentOrganization',
			'NGO',
			'Organization',
		];
		$type   = in_array( $schema_type, $allowed, true ) ? $schema_type : 'Organization';
		$id_tag = '#' . strtolower( $type );

		$node = [
			'@context' => 'https://schema.org',
			'@type'    => $type,
			'@id'      => home_url( '/' . $id_tag ),
			'name'     => $name,
			'url'      => $url !== '' ? $url : home_url( '/' ),
		];

		if ( '' !== $description ) {
			$node['description'] = $description;
		}

		if ( '' !== $founding ) {
			$node['foundingDate'] = $founding;
		}

		if ( '' !== $founder ) {
			$node['founder'] = [
				'@type' => 'Person',
				'name'  => $founder,
			];
		}

		if ( is_array( $knows_about ) && ! empty( $knows_about ) ) {
			$node['knowsAbout'] = array_values( array_filter( $knows_about, 'strlen' ) );
		}

		if ( '' !== $city ) {
			$node['areaServed'] = [
				'@type' => 'Place',
				'name'  => $city,
			];
		}

		if ( is_array( $same_as ) ) {
			$filtered = array_values( array_filter( $same_as, 'strlen' ) );
			if ( ! empty( $filtered ) ) {
				$node['sameAs'] = $filtered;
			}
		}

		echo '<script type="application/ld+json">' . "\n"
			. json_encode( $node, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE )
			. "\n</script>\n";
	}

	// ── 3. AI Content Signals ─────────────────────────────────────────────────

	/**
	 * Adds AI-friendly signals to post content:
	 *  - data-ai-summary="true" on the first <p> tag
	 *  - itemscope/itemtype Answer wrapper around content following a question H2/H3
	 *
	 * Hooked on the_content at priority 30.
	 *
	 * @param  string $content Filtered post content HTML.
	 * @return string Modified content.
	 */
	public static function add_content_signals( string $content ): string {
		if ( ! is_singular() ) {
			return $content;
		}

		if ( '' === trim( $content ) ) {
			return $content;
		}

		// Mark the first paragraph as the key takeaway for AI parsers.
		$content = preg_replace(
			'/<p([^>]*)>/i',
			'<p$1 data-ai-summary="true">',
			$content,
			1  // Only the first match.
		);

		// Wrap content following a question-format H2/H3 with an Answer schema.
		$content = self::wrap_question_answers( $content );

		return $content;
	}

	/**
	 * Finds H2/H3 headings ending with '?' and wraps the immediately following
	 * sibling content block in a Schema.org Answer itemscope wrapper.
	 *
	 * Uses a straightforward regex-based approach to avoid DOMDocument overhead
	 * on the_content (which may run on every page load with Elementor).
	 *
	 * @param  string $content HTML content string.
	 * @return string Modified content.
	 */
	private static function wrap_question_answers( string $content ): string {
		// Match an H2 or H3 whose visible text ends with a question mark.
		// The heading tag is immediately followed (in the HTML string) by any
		// block element (p, ul, ol, div, table, blockquote).
		$pattern = '/(<h[23][^>]*>(?:[^<]|<(?!\/h[23]))*\?<\/h[23]>)\s*(<(?:p|ul|ol|div|table|blockquote)[^>]*>(?:[^<]|<(?!\/))*(?:<[^>]+>[^<]*<\/[^>]+>[^<]*)*<\/(?:p|ul|ol|div|table|blockquote)>)/i';

		$content = preg_replace_callback(
			$pattern,
			static function ( array $matches ): string {
				return $matches[1] . "\n"
					. '<div itemscope itemtype="https://schema.org/Answer">'
					. "\n" . $matches[2] . "\n"
					. '</div>';
			},
			$content
		) ?? $content;

		return $content;
	}

	// ── 4. /llms.txt Endpoint ─────────────────────────────────────────────────

	/**
	 * Registers rewrite rules for /llms.txt and /llms-full.txt.
	 * Hooked on WP init action.
	 */
	public static function register_llms_rewrite_rules(): void {
		add_rewrite_rule( '^llms\.txt$', 'index.php?livada_seo_llms=1', 'top' );
		add_rewrite_rule( '^llms-full\.txt$', 'index.php?livada_seo_llms=full', 'top' );
	}

	/**
	 * Auto-flush rewrite rules when the plugin version changes.
	 * Stored in option `livada_seo_rewrites_version` to avoid re-flushing every request.
	 *
	 * Fixes BUG 7 : llms.txt returns 404 because rewrite rules were registered
	 * but never flushed after the rule was added.
	 */
	public static function maybe_flush_rewrites(): void {
		$stored = (string) get_option( 'livada_seo_rewrites_version', '' );

		if ( $stored !== LIVADA_SEO_VERSION ) {
			// Re-register the rules first (in case init hook didn't run yet).
			self::register_llms_rewrite_rules();
			flush_rewrite_rules( false );
			update_option( 'livada_seo_rewrites_version', LIVADA_SEO_VERSION, false );
		}
	}

	/**
	 * Registers the livada_seo_llms query var.
	 *
	 * @param  array<int,string> $vars
	 * @return array<int,string>
	 */
	public static function add_llms_query_vars( array $vars ): array {
		$vars[] = 'livada_seo_llms';
		return $vars;
	}

	/**
	 * Intercepts requests for /llms.txt and /llms-full.txt and serves them
	 * as plain text.  Hooked on template_redirect at priority 2.
	 */
	public static function serve_llms_txt(): void {
		// Check rewrite rule query var first.
		$llms_var = get_query_var( 'livada_seo_llms', '' );

		if ( '' !== $llms_var ) {
			$full_version = 'full' === $llms_var;
			status_header( 200 );
			header( 'Content-Type: text/plain; charset=utf-8' );
			header( 'X-Robots-Tag: noindex' );
			echo self::build_llms_content( $full_version );
			exit;
		}

		// Fallback: check REQUEST_URI directly.
		$request_uri  = isset( $_SERVER['REQUEST_URI'] ) ? (string) $_SERVER['REQUEST_URI'] : '';
		$request_path = (string) wp_parse_url( $request_uri, PHP_URL_PATH );
		$request_path = rtrim( $request_path, '/' );

		$home_path = rtrim( (string) wp_parse_url( home_url( '/' ), PHP_URL_PATH ), '/' );

		$relative = '' !== $home_path ? str_replace( $home_path, '', $request_path ) : $request_path;

		if ( '/llms.txt' !== $relative && '/llms-full.txt' !== $relative ) {
			return;
		}

		$full_version = '/llms-full.txt' === $relative;

		status_header( 200 );
		header( 'Content-Type: text/plain; charset=utf-8' );
		header( 'X-Robots-Tag: noindex' );

		echo self::build_llms_content( $full_version );

		exit;
	}

	/**
	 * Builds the /llms.txt plain-text content.
	 *
	 * @param  bool $full Include full post list when true.
	 * @return string
	 */
	private static function build_llms_content( bool $full = false ): string {
		// site_name may be saved as an empty string, in which case the get_setting
		// default never kicks in (?? only fires on null). Cascade to schema_name
		// then the WP blog name so the "# Heading" line is never empty.
		$site_name = (string) Livada_SEO_Loader::get_setting( 'site_name', '' );
		if ( '' === trim( $site_name ) ) {
			$site_name = (string) Livada_SEO_Loader::get_setting( 'schema_name', '' );
		}
		if ( '' === trim( $site_name ) ) {
			$site_name = (string) get_bloginfo( 'name' );
		}

		$description = (string) Livada_SEO_Loader::get_setting( 'aeo_org_description', '' );
		if ( '' === trim( $description ) ) {
			$description = (string) get_bloginfo( 'description' );
		}
		$schema_name = (string) Livada_SEO_Loader::get_setting( 'schema_name', $site_name );
		$schema_type = (string) Livada_SEO_Loader::get_setting( 'schema_type', 'LocalBusiness' );
		$schema_city = (string) Livada_SEO_Loader::get_setting( 'schema_city', '' );
		$schema_country  = (string) Livada_SEO_Loader::get_setting( 'schema_country', '' );
		$schema_url      = (string) Livada_SEO_Loader::get_setting( 'schema_url', home_url( '/' ) );
		$schema_email    = (string) Livada_SEO_Loader::get_setting( 'schema_email', '' );
		$schema_phone    = (string) Livada_SEO_Loader::get_setting( 'schema_phone', '' );
		$schema_address  = (string) Livada_SEO_Loader::get_setting( 'schema_address', '' );
		$schema_postal   = (string) Livada_SEO_Loader::get_setting( 'schema_postal', '' );

		$services = Livada_SEO_Loader::get_setting( 'schema_services', [] );
		$services_list = '';
		if ( is_array( $services ) && ! empty( $services ) ) {
			$service_names = [];
			foreach ( $services as $service ) {
				$name = is_string( $service ) ? $service : ( $service['name'] ?? '' );
				if ( '' !== (string) $name ) {
					$service_names[] = (string) $name;
				}
			}
			$services_list = implode( ', ', $service_names );
		}

		// Build the "Content topics" list for the ## Content section.
		// Priority order:
		//   1. AEO knowsAbout (curated areas of expertise) — preferred for AI
		//   2. WordPress categories — fallback when knowsAbout is empty
		// A hospitality site's WP categories are often noise ("Non classé",
		// blog topics, etc.) while knowsAbout is hand-curated and focused.
		$knows_about     = Livada_SEO_Loader::get_setting( 'aeo_org_knows_about', [] );
		$category_names  = [];

		if ( is_array( $knows_about ) ) {
			foreach ( $knows_about as $topic ) {
				$topic = trim( (string) $topic );
				if ( '' !== $topic ) {
					$category_names[] = $topic;
				}
			}
		}

		if ( empty( $category_names ) ) {
			$categories = get_categories( [ 'hide_empty' => true ] );
			if ( is_array( $categories ) ) {
				foreach ( $categories as $cat ) {
					if ( $cat instanceof WP_Term ) {
						$category_names[] = $cat->name;
					}
				}
			}
		}

		$categories_list = implode( ', ', $category_names );

		$lines = [];

		$lines[] = '# ' . $site_name;

		if ( '' !== $description ) {
			$lines[] = '> ' . $description;
		}

		$lines[] = '';
		$lines[] = '## About';

		$about = $schema_name;
		if ( '' !== $schema_type ) {
			$about .= ' is a ' . $schema_type;
		}
		if ( '' !== $schema_city ) {
			$about .= ' based in ' . $schema_city;
			if ( '' !== $schema_country ) {
				$about .= ', ' . $schema_country;
			}
		}
		$about .= '.';
		$lines[] = $about;

		if ( '' !== $services_list ) {
			$lines[] = 'Services: ' . $services_list;
		}

		$lines[] = '';
		$lines[] = '## Contact';

		if ( '' !== $schema_url ) {
			$lines[] = '- Website: ' . $schema_url;
		}
		if ( '' !== $schema_email ) {
			$lines[] = '- Email: ' . $schema_email;
		}
		if ( '' !== $schema_phone ) {
			$lines[] = '- Phone: ' . $schema_phone;
		}

		$location_parts = array_filter( [
			$schema_address,
			$schema_city . ( '' !== $schema_postal ? ' ' . $schema_postal : '' ),
		], 'strlen' );
		if ( ! empty( $location_parts ) ) {
			$lines[] = '- Location: ' . implode( ', ', $location_parts );
		}

		$lines[] = '';
		$lines[] = '## Content';

		if ( '' !== $categories_list ) {
			$lines[] = 'This site publishes content about: ' . $categories_list;
		}

		// Full version: append pages (menu_order ASC, max 50) then recent posts.
		// Pages come first because for service / hospitality sites the static
		// pages (Rooms, Restaurant, Rates, Contact) carry the high-value info
		// AI engines should crawl, whereas Posts are usually news / blog.
		if ( $full ) {
			$pages = get_posts( [
				'post_type'      => 'page',
				'post_status'    => 'publish',
				'numberposts'    => 50,
				'orderby'        => [ 'menu_order' => 'ASC', 'title' => 'ASC' ],
				'no_found_rows'  => true,
				'fields'         => 'ids',
			] );

			if ( ! empty( $pages ) ) {
				$lines[] = '';
				$lines[] = '## Pages';

				foreach ( $pages as $page_id ) {
					$page_id    = (int) $page_id;
					$page_title = get_the_title( $page_id );
					$page_url   = get_permalink( $page_id );
					if ( $page_title && $page_url ) {
						$lines[] = '- [' . $page_title . '](' . $page_url . ')';
					}
				}
			}

			$posts = get_posts( [
				'post_type'      => 'post',
				'post_status'    => 'publish',
				'numberposts'    => 100,
				'orderby'        => 'date',
				'order'          => 'DESC',
				'no_found_rows'  => true,
				'fields'         => 'ids',
			] );

			if ( ! empty( $posts ) ) {
				$lines[] = '';
				$lines[] = '## Posts';

				foreach ( $posts as $post_id ) {
					$post_id    = (int) $post_id;
					$post_title = get_the_title( $post_id );
					$post_url   = get_permalink( $post_id );
					if ( $post_title && $post_url ) {
						$lines[] = '- [' . $post_title . '](' . $post_url . ')';
					}
				}
			}
		}

		return implode( "\n", $lines ) . "\n";
	}

	// ── 5. AI Discoverability Headers ─────────────────────────────────────────

	/**
	 * Sends the X-Robots-Tag header for AI snippet control.
	 * Hooked on send_headers.
	 */
	public static function send_ai_headers(): void {
		if ( is_admin() ) {
			return;
		}

		header( 'X-Robots-Tag: max-snippet:-1, max-image-preview:large', false );
	}

	// ── 6. AEO Analysis ───────────────────────────────────────────────────────

	/**
	 * Runs AEO-specific checks on a post and returns an array of results.
	 *
	 * Each element: [ 'check' => string, 'status' => 'green|orange|red', 'message' => string ]
	 *
	 * Checks:
	 *   direct_answer      First paragraph < 50 words, no question mark
	 *   question_headings  >= 2 H2/H3 ending with ?
	 *   data_citations     >= 3 numbers/percentages/statistics
	 *   source_links       >= 2 external authoritative links
	 *   content_structure  Has H2/H3, lists, short paragraphs
	 *   entity_mentions    Mentions named entities (people, places, brands)
	 *   freshness          Modified date age
	 *   concise_summary    Meta description exists and < 160 chars
	 *
	 * @param  int $post_id
	 * @return array<int,array{check:string,status:string,message:string}>
	 */
	public static function analyze_aeo( int $post_id ): array {
		$post = get_post( $post_id );

		if ( ! $post instanceof WP_Post ) {
			return [];
		}

		$content     = apply_filters( 'the_content', $post->post_content );
		$plain       = wp_strip_all_tags( $content );
		$description = (string) get_post_meta( $post_id, '_livada_seo_description', true );

		return [
			self::check_direct_answer( $content ),
			self::check_question_headings( $content ),
			self::check_data_citations( $plain ),
			self::check_source_links( $content ),
			self::check_content_structure( $content, $plain ),
			self::check_entity_mentions( $plain ),
			self::check_freshness( $post ),
			self::check_concise_summary( $description ),
		];
	}

	// ── Individual AEO checks ─────────────────────────────────────────────────

	/**
	 * Checks if the content opens with a short direct-answer paragraph
	 * (< 50 words, does not end with a question mark).
	 *
	 * @param  string $content HTML content.
	 * @return array{check:string,status:string,message:string}
	 */
	private static function check_direct_answer( string $content ): array {
		$status  = 'red';
		$message = __( 'Content does not open with a short direct-answer paragraph (< 50 words).', 'livada-seo' );

		if ( preg_match( '/<p[^>]*>(.*?)<\/p>/is', $content, $matches ) ) {
			$first_p = trim( wp_strip_all_tags( $matches[1] ) );

			if ( '' !== $first_p ) {
				$words       = preg_split( '/\s+/u', $first_p, -1, PREG_SPLIT_NO_EMPTY );
				$word_count  = is_array( $words ) ? count( $words ) : 0;
				$ends_in_q   = '?' === substr( rtrim( $first_p ), -1 );

				if ( $word_count < 50 && ! $ends_in_q && $word_count > 0 ) {
					$status  = 'green';
					$message = sprintf(
						/* translators: %d: word count */
						__( 'Content opens with a concise direct-answer paragraph (%d words).', 'livada-seo' ),
						$word_count
					);
				} elseif ( $word_count >= 50 ) {
					$message = sprintf(
						/* translators: %d: word count */
						__( 'Opening paragraph is too long for a direct answer (%d words). Aim for < 50.', 'livada-seo' ),
						$word_count
					);
				}
			}
		}

		return [ 'check' => 'direct_answer', 'status' => $status, 'message' => $message ];
	}

	/**
	 * Checks if the content has >= 2 question-format H2/H3 headings (ending with ?).
	 *
	 * @param  string $content HTML content.
	 * @return array{check:string,status:string,message:string}
	 */
	private static function check_question_headings( string $content ): array {
		$count = 0;

		if ( preg_match_all( '/<h[23][^>]*>(.*?)<\/h[23]>/is', $content, $matches ) ) {
			foreach ( $matches[1] as $heading ) {
				$text = trim( wp_strip_all_tags( $heading ) );
				if ( '' !== $text && '?' === substr( $text, -1 ) ) {
					$count++;
				}
			}
		}

		if ( $count >= 2 ) {
			$status  = 'green';
			$message = sprintf(
				/* translators: %d: heading count */
				__( '%d question-format headings found — AI engines can surface these as Q&A.', 'livada-seo' ),
				$count
			);
		} elseif ( 1 === $count ) {
			$status  = 'orange';
			$message = __( '1 question-format heading found. Add at least one more for better AI Q&A coverage.', 'livada-seo' );
		} else {
			$status  = 'red';
			$message = __( 'No question-format headings (H2/H3 ending with ?) found.', 'livada-seo' );
		}

		return [ 'check' => 'question_headings', 'status' => $status, 'message' => $message ];
	}

	/**
	 * Checks whether the content contains at least 3 data points
	 * (numbers, percentages, year-format dates, statistics).
	 *
	 * @param  string $plain Plain text content (no HTML).
	 * @return array{check:string,status:string,message:string}
	 */
	private static function check_data_citations( string $plain ): array {
		// Match standalone numbers ≥ 2 digits, percentages, currency amounts.
		$count = 0;

		// Percentages: e.g. 42%, 3.5 %
		$count += (int) preg_match_all( '/\d+(?:[.,]\d+)?\s*%/', $plain );

		// Large numbers (≥ 4 digits, with optional thousand separators).
		$count += (int) preg_match_all( '/\b\d{4,}\b/', $plain );

		// Standalone 2-3 digit numeric data followed by a unit keyword.
		$count += (int) preg_match_all( '/\b\d{2,3}\s+(?:ans?|jours?|heures?|minutes?|euros?|€|\$|fois|fois|km|m²|m2)\b/ui', $plain );

		if ( $count >= 3 ) {
			$status  = 'green';
			$message = sprintf(
				/* translators: %d: data point count */
				__( '%d data points (numbers, percentages, statistics) detected — strong signal for AI citations.', 'livada-seo' ),
				$count
			);
		} elseif ( $count > 0 ) {
			$status  = 'orange';
			$message = sprintf(
				/* translators: %d: data point count */
				__( '%d data point(s) found. Add more numbers, percentages, or statistics (aim for 3+).', 'livada-seo' ),
				$count
			);
		} else {
			$status  = 'red';
			$message = __( 'No data points found. Add numbers, percentages, or statistics to increase AI citability.', 'livada-seo' );
		}

		return [ 'check' => 'data_citations', 'status' => $status, 'message' => $message ];
	}

	/**
	 * Checks whether the content contains >= 2 external links to authoritative sources.
	 * "External" = href pointing to a different domain than the site.
	 *
	 * @param  string $content HTML content.
	 * @return array{check:string,status:string,message:string}
	 */
	private static function check_source_links( string $content ): array {
		$site_host = (string) wp_parse_url( home_url(), PHP_URL_HOST );
		$count     = 0;

		if ( preg_match_all( '/<a\s[^>]*href=["\']([^"\']+)["\'][^>]*>/i', $content, $matches ) ) {
			foreach ( $matches[1] as $href ) {
				$href = trim( $href );

				if ( strpos( $href, 'http' ) !== 0 ) {
					continue;
				}

				$link_host = (string) wp_parse_url( $href, PHP_URL_HOST );

				if ( '' !== $link_host && $link_host !== $site_host ) {
					$count++;
				}
			}
		}

		if ( $count >= 2 ) {
			$status  = 'green';
			$message = sprintf(
				/* translators: %d: link count */
				_n( '%d external source link found.', '%d external source links found.', $count, 'livada-seo' ),
				$count
			);
		} elseif ( 1 === $count ) {
			$status  = 'orange';
			$message = __( '1 external source link found. Add at least one more authoritative external link.', 'livada-seo' );
		} else {
			$status  = 'red';
			$message = __( 'No external source links found. Link to authoritative sources to improve AI trust signals.', 'livada-seo' );
		}

		return [ 'check' => 'source_links', 'status' => $status, 'message' => $message ];
	}

	/**
	 * Checks whether the content is well-structured:
	 * has H2/H3 headings, at least one list (ul/ol), and mostly short paragraphs.
	 *
	 * @param  string $content HTML content.
	 * @param  string $plain   Plain-text content.
	 * @return array{check:string,status:string,message:string}
	 */
	private static function check_content_structure( string $content, string $plain ): array {
		$has_headings = (bool) preg_match( '/<h[23][^>]*>/i', $content );
		$has_lists    = (bool) preg_match( '/<(?:ul|ol)[^>]*>/i', $content );

		// Check average paragraph length.
		$short_paras  = true;
		if ( preg_match_all( '/<p[^>]*>(.*?)<\/p>/is', $content, $para_matches ) ) {
			$long_count  = 0;
			$total_paras = count( $para_matches[1] );

			foreach ( $para_matches[1] as $para_html ) {
				$text  = trim( wp_strip_all_tags( $para_html ) );
				$words = preg_split( '/\s+/u', $text, -1, PREG_SPLIT_NO_EMPTY );
				if ( is_array( $words ) && count( $words ) > 100 ) {
					$long_count++;
				}
			}

			if ( $total_paras > 0 && ( $long_count / $total_paras ) > 0.5 ) {
				$short_paras = false;
			}
		}

		$score = (int) $has_headings + (int) $has_lists + (int) $short_paras;

		if ( $score >= 3 ) {
			$status  = 'green';
			$message = __( 'Content is well-structured: headings, lists, and short paragraphs detected.', 'livada-seo' );
		} elseif ( $score >= 2 ) {
			$status  = 'orange';
			$issues  = [];
			if ( ! $has_headings ) {
				$issues[] = __( 'no H2/H3 headings', 'livada-seo' );
			}
			if ( ! $has_lists ) {
				$issues[] = __( 'no lists', 'livada-seo' );
			}
			if ( ! $short_paras ) {
				$issues[] = __( 'paragraphs are too long', 'livada-seo' );
			}
			$message = sprintf(
				/* translators: %s: comma-separated list of issues */
				__( 'Structure partially good, but: %s.', 'livada-seo' ),
				implode( ', ', $issues )
			);
		} else {
			$status  = 'red';
			$message = __( 'Content structure is poor. Add H2/H3 headings, bullet lists, and keep paragraphs short.', 'livada-seo' );
		}

		return [ 'check' => 'content_structure', 'status' => $status, 'message' => $message ];
	}

	/**
	 * Checks whether the plain-text content mentions specific named entities:
	 * capitalized sequences of 2+ words (proper nouns — people, places, brands).
	 *
	 * Heuristic: look for sequences of 2–4 title-case words.
	 *
	 * @param  string $plain Plain text content.
	 * @return array{check:string,status:string,message:string}
	 */
	private static function check_entity_mentions( string $plain ): array {
		// Match 2–4 consecutive Title-Case (or ALL CAPS) words not at sentence start.
		// We look for patterns in the middle of text (not the very first word).
		$count = (int) preg_match_all(
			'/(?<=[.!?\n]\s{1,4}|\s)([A-ZÀÁÂÄÆÃÅÈÉÊËÎÏÔŒÙÚÛÜ][a-zàáâäæãåèéêëîïôœùúûü]+(?:\s[A-ZÀÁÂÄÆÃÅÈÉÊËÎÏÔŒÙÚÛÜ][a-zàáâäæãåèéêëîïôœùúûü]+){1,3})/u',
			$plain
		);

		if ( $count >= 3 ) {
			$status  = 'green';
			$message = sprintf(
				/* translators: %d: entity count */
				__( '%d named entity mentions detected — helps AI systems identify key subjects.', 'livada-seo' ),
				$count
			);
		} elseif ( $count > 0 ) {
			$status  = 'orange';
			$message = sprintf(
				/* translators: %d: entity count */
				__( '%d named entity mention(s) found. Add more specific people, places, or brand names.', 'livada-seo' ),
				$count
			);
		} else {
			$status  = 'red';
			$message = __( 'No named entities detected. Mention specific people, places, or brands to improve AI comprehension.', 'livada-seo' );
		}

		return [ 'check' => 'entity_mentions', 'status' => $status, 'message' => $message ];
	}

	/**
	 * Checks how recently the post was last modified.
	 *
	 * Green  : modified within last 90 days
	 * Orange : 90–180 days ago
	 * Red    : more than 180 days ago
	 *
	 * @param  WP_Post $post
	 * @return array{check:string,status:string,message:string}
	 */
	private static function check_freshness( WP_Post $post ): array {
		$modified_ts = (int) strtotime( $post->post_modified_gmt );
		$now         = time();
		$days_old    = (int) floor( ( $now - $modified_ts ) / DAY_IN_SECONDS );

		if ( $days_old <= 90 ) {
			$status  = 'green';
			$message = sprintf(
				/* translators: %d: days since last modification */
				__( 'Content is fresh — last modified %d day(s) ago.', 'livada-seo' ),
				$days_old
			);
		} elseif ( $days_old <= 180 ) {
			$status  = 'orange';
			$message = sprintf(
				/* translators: %d: days since last modification */
				__( 'Content is aging — last modified %d day(s) ago. Consider refreshing.', 'livada-seo' ),
				$days_old
			);
		} else {
			$status  = 'red';
			$message = sprintf(
				/* translators: %d: days since last modification */
				__( 'Content is stale — last modified %d day(s) ago. Update to improve AI freshness signals.', 'livada-seo' ),
				$days_old
			);
		}

		return [ 'check' => 'freshness', 'status' => $status, 'message' => $message ];
	}

	/**
	 * Checks whether a meta description exists and is under 160 characters.
	 *
	 * @param  string $description Meta description string.
	 * @return array{check:string,status:string,message:string}
	 */
	private static function check_concise_summary( string $description ): array {
		$length = mb_strlen( trim( $description ) );

		if ( $length > 0 && $length <= 160 ) {
			$status  = 'green';
			$message = sprintf(
				/* translators: %d: character count */
				__( 'Meta description is concise (%d characters) — ideal for AI snippet extraction.', 'livada-seo' ),
				$length
			);
		} elseif ( $length > 160 ) {
			$status  = 'orange';
			$message = sprintf(
				/* translators: %d: character count */
				__( 'Meta description is too long (%d characters). Keep it under 160 for optimal AI snippet use.', 'livada-seo' ),
				$length
			);
		} else {
			$status  = 'red';
			$message = __( 'No meta description set. Add one under 160 characters to help AI systems summarise your content.', 'livada-seo' );
		}

		return [ 'check' => 'concise_summary', 'status' => $status, 'message' => $message ];
	}

	// ── Private helpers ───────────────────────────────────────────────────────

	/**
	 * Returns the first 200 characters of the meta description, falling back
	 * to the post excerpt.
	 *
	 * @param  int $post_id
	 * @return string
	 */
	private static function get_summary( int $post_id ): string {
		$desc = (string) get_post_meta( $post_id, '_livada_seo_description', true );

		if ( '' === $desc ) {
			$desc = (string) get_the_excerpt( $post_id );
		}

		$desc = wp_strip_all_tags( $desc );

		return mb_substr( $desc, 0, 200 );
	}

	/**
	 * Returns the focus keyword, falling back to the first assigned category name.
	 *
	 * @param  int $post_id
	 * @return string
	 */
	private static function get_topic( int $post_id ): string {
		$keyword = (string) get_post_meta( $post_id, '_livada_seo_focus_keyword', true );

		if ( '' !== $keyword ) {
			return $keyword;
		}

		return self::get_first_category( $post_id );
	}

	/**
	 * Returns the first category name for the given post, or empty string.
	 *
	 * @param  int $post_id
	 * @return string
	 */
	private static function get_first_category( int $post_id ): string {
		$categories = get_the_category( $post_id );

		if ( ! empty( $categories ) && $categories[0] instanceof WP_Term ) {
			return (string) $categories[0]->name;
		}

		return '';
	}
}
