<?php
/**
 * Livada SEO — Module GEO (Generative Engine Optimization)
 *
 * Optimises content to be cited by AI search engines (ChatGPT, Gemini,
 * Perplexity, Claude) through:
 *   - Citation meta tags (Google Scholar-style) for proper attribution
 *   - Speakable schema (separate <script> block) for voice / AI extraction
 *   - Author authority signals (meta author + rel=author link)
 *   - Content freshness signal (article:modified_time)
 *   - Lightweight semantic content markers via the_content filter
 *
 * Settings keys (all under `livada_seo_settings`):
 *   geo_enabled          bool    Master switch (default true)
 *   geo_author_expertise string  Plain-text expertise blurb
 *   geo_author_url       string  Author profile URL
 *   geo_speakable        bool    Emit Speakable schema (default true)
 *   geo_citation_meta    bool    Emit citation_* meta tags (default true)
 *
 * Frontend cost: zero extra SQL queries; < 500 bytes of meta output.
 *
 * @package Livada_SEO
 */

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

class Livada_SEO_GEO {

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

	/**
	 * Registers all hooks for this module.
	 * Called once by Livada_SEO_Loader::init().
	 */
	public static function init(): void {
		if ( ! self::is_enabled() ) {
			return;
		}

		// Priority 4 — runs after Schema (priority 3), before most other head output.
		add_action( 'wp_head', [ self::class, 'output_head_tags' ], 4 );

		// Content markers — late priority so other filters have already run.
		add_filter( 'the_content', [ self::class, 'add_content_markers' ], 25 );
	}

	// ── Head output ───────────────────────────────────────────────────────────

	/**
	 * Outputs all GEO head tags and the optional Speakable schema block.
	 * Hooked to wp_head at priority 4.
	 */
	public static function output_head_tags(): void {
		// Only meaningful on singular posts / pages.
		if ( ! is_singular() ) {
			return;
		}

		$post_id = get_the_ID();
		if ( ! $post_id ) {
			return;
		}

		if ( (bool) Livada_SEO_Loader::get_setting( 'geo_citation_meta', true ) ) {
			self::output_citation_meta( $post_id );
		}

		self::output_author_signals( $post_id );
		self::output_freshness_signal( $post_id );

		if ( (bool) Livada_SEO_Loader::get_setting( 'geo_speakable', true ) ) {
			self::output_speakable_schema( $post_id );
		}
	}

	// ── Citation meta tags ────────────────────────────────────────────────────

	/**
	 * Outputs Google Scholar-style citation_* meta tags.
	 * These help AI engines attribute the content to its correct source.
	 *
	 * @param int $post_id
	 */
	private static function output_citation_meta( int $post_id ): void {
		$title     = get_the_title( $post_id );
		$author_id = (int) get_post_field( 'post_author', $post_id );
		$author    = get_the_author_meta( 'display_name', $author_id );
		$date      = get_the_date( 'c', $post_id );
		$publisher = (string) Livada_SEO_Loader::get_setting( 'site_name', get_bloginfo( 'name' ) );
		if ( '' === $publisher ) {
			$publisher = get_bloginfo( 'name' );
		}

		if ( '' !== $title ) {
			printf(
				'<meta name="citation_title" content="%s" />' . "\n",
				esc_attr( $title )
			);
		}

		if ( '' !== $author ) {
			printf(
				'<meta name="citation_author" content="%s" />' . "\n",
				esc_attr( $author )
			);
		}

		if ( '' !== $date ) {
			printf(
				'<meta name="citation_date" content="%s" />' . "\n",
				esc_attr( $date )
			);
		}

		if ( '' !== $publisher ) {
			printf(
				'<meta name="citation_publisher" content="%s" />' . "\n",
				esc_attr( $publisher )
			);
		}

		// Language is site-level; fr is the Livada target locale.
		$language = get_bloginfo( 'language' );
		if ( '' === $language ) {
			$language = 'fr';
		}
		// Normalise to two-char code (e.g. 'fr-FR' → 'fr').
		$language = strtolower( substr( $language, 0, 2 ) );

		printf(
			'<meta name="citation_language" content="%s" />' . "\n",
			esc_attr( $language )
		);
	}

	// ── Author authority signals ───────────────────────────────────────────────

	/**
	 * Outputs author authority meta / link tags when the relevant settings
	 * are configured.
	 *
	 * @param int $post_id
	 */
	private static function output_author_signals( int $post_id ): void {
		$expertise  = (string) Livada_SEO_Loader::get_setting( 'geo_author_expertise', '' );
		$author_url = (string) Livada_SEO_Loader::get_setting( 'geo_author_url', '' );

		if ( '' !== $expertise ) {
			printf(
				'<meta name="author" content="%s" />' . "\n",
				esc_attr( $expertise )
			);
		}

		if ( '' !== $author_url ) {
			printf(
				'<link rel="author" href="%s" />' . "\n",
				esc_url( $author_url )
			);
		}
	}

	// ── Content freshness signal ───────────────────────────────────────────────

	/**
	 * Outputs the article:modified_time meta tag.
	 * Fresh content is preferred by AI engines when answering time-sensitive queries.
	 *
	 * @param int $post_id
	 */
	private static function output_freshness_signal( int $post_id ): void {
		$modified = get_the_modified_date( 'c', $post_id );

		if ( '' !== (string) $modified ) {
			printf(
				'<meta name="article:modified_time" content="%s" />' . "\n",
				esc_attr( (string) $modified )
			);
		}
	}

	// ── Speakable schema ──────────────────────────────────────────────────────

	/**
	 * Outputs a standalone <script type="application/ld+json"> containing a
	 * Speakable specification. Kept separate from the main Schema @graph so it
	 * can be disabled independently.
	 *
	 * @param int $post_id
	 */
	private static function output_speakable_schema( int $post_id ): void {
		$permalink = get_permalink( $post_id );
		if ( ! $permalink ) {
			return;
		}

		$schema = [
			'@context' => 'https://schema.org',
			'@type'    => 'WebPage',
			'speakable' => [
				'@type'       => 'SpeakableSpecification',
				'cssSelector' => [ '.entry-title', '.entry-content p:first-of-type' ],
			],
			'url'      => $permalink,
		];

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

	// ── Content markers ───────────────────────────────────────────────────────

	/**
	 * Adds lightweight semantic hints to the post content so AI parsers can
	 * identify the key summary paragraph and content type.
	 *
	 * - Wraps the first <p> in a version with `class="entry-summary"` and
	 *   `data-nosnippet="false"` to signal it as the lead summary.
	 * - The replacement is a single regex substitution; no DOM parsing overhead.
	 *
	 * Hooked to `the_content` at priority 25.
	 *
	 * @param  string $content Post content HTML.
	 * @return string          Modified content HTML.
	 */
	public static function add_content_markers( string $content ): string {
		if ( ! is_singular() || '' === $content ) {
			return $content;
		}

		// Wrap only the very first <p> tag (no class already set to entry-summary).
		// We do a single pass and return immediately after the first replacement.
		$count   = 0;
		$content = preg_replace_callback(
			'/<p(\s[^>]*)?>/',
			static function ( array $matches ) use ( &$count ): string {
				if ( $count > 0 ) {
					return $matches[0];
				}
				$count++;

				$existing_attrs = isset( $matches[1] ) ? $matches[1] : '';

				// Avoid double-adding the class if somehow already present.
				if ( strpos( $existing_attrs, 'entry-summary' ) !== false ) {
					return $matches[0];
				}

				// Inject class and data attribute; preserve any existing attributes.
				if ( '' !== $existing_attrs ) {
					// Check for existing class attribute to merge rather than duplicate.
					if ( preg_match( '/\bclass=["\']([^"\']*)["\']/', $existing_attrs, $class_match ) ) {
						$new_class  = trim( $class_match[1] . ' entry-summary' );
						$new_attrs  = str_replace( $class_match[0], 'class="' . esc_attr( $new_class ) . '"', $existing_attrs );
						$new_attrs .= ' data-nosnippet="false"';
					} else {
						$new_attrs = $existing_attrs . ' class="entry-summary" data-nosnippet="false"';
					}
				} else {
					$new_attrs = ' class="entry-summary" data-nosnippet="false"';
				}

				return '<p' . $new_attrs . '>';
			},
			$content
		);

		return is_string( $content ) ? $content : '';
	}

	// ── GEO Analysis ─────────────────────────────────────────────────────────

	/**
	 * Runs a set of GEO-specific content checks for the given post.
	 *
	 * Returns an array of check results, each shaped as:
	 *   [
	 *     'check'   => string  — machine key
	 *     'status'  => string  — 'green' | 'orange' | 'red'
	 *     'message' => string  — human-readable description
	 *   ]
	 *
	 * Intentionally adds no extra SQL queries: all data comes from standard
	 * WordPress functions and already-loaded post meta.
	 *
	 * @param  int   $post_id
	 * @return array<int,array{check:string,status:string,message:string}>
	 */
	public static function analyze_geo( int $post_id ): array {
		$post = get_post( $post_id );

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

		// Render the content once; used by multiple checks below.
		$content = apply_filters( 'the_content', $post->post_content );

		return [
			self::check_author_bio( $post ),
			self::check_dates( $post ),
			self::check_heading_structure( $content ),
			self::check_faq_content( $content ),
			self::check_statistics( $content ),
			self::check_citations( $content ),
			self::check_content_word_count( $content ),
			self::check_featured_image_alt( $post_id ),
		];
	}

	// ── GEO check helpers ─────────────────────────────────────────────────────

	/**
	 * Checks whether the post author has a WordPress bio (description) set.
	 *
	 * @param  WP_Post $post
	 * @return array{check:string,status:string,message:string}
	 */
	private static function check_author_bio( WP_Post $post ): array {
		$author_id  = (int) $post->post_author;
		$bio        = (string) get_the_author_meta( 'description', $author_id );
		$has_bio    = '' !== trim( $bio );

		return [
			'check'   => 'author_bio',
			'status'  => $has_bio ? 'green' : 'orange',
			'message' => $has_bio
				? __( 'Author bio is set — good for E-E-A-T signals.', 'livada-seo' )
				: __( 'Author bio is empty. Add a bio to strengthen authority signals for AI engines.', 'livada-seo' ),
		];
	}

	/**
	 * Checks whether the post has both a publish date and a modified date.
	 *
	 * @param  WP_Post $post
	 * @return array{check:string,status:string,message:string}
	 */
	private static function check_dates( WP_Post $post ): array {
		$published = (string) get_the_date( 'c', $post );
		$modified  = (string) get_the_modified_date( 'c', $post );

		$has_pub = '' !== $published;
		$has_mod = '' !== $modified;

		if ( $has_pub && $has_mod ) {
			return [
				'check'   => 'dates',
				'status'  => 'green',
				'message' => __( 'Published date and modified date are both present.', 'livada-seo' ),
			];
		}

		if ( $has_pub ) {
			return [
				'check'   => 'dates',
				'status'  => 'orange',
				'message' => __( 'Published date is set but modified date is missing.', 'livada-seo' ),
			];
		}

		return [
			'check'   => 'dates',
			'status'  => 'red',
			'message' => __( 'No published date found. Date signals are critical for AI freshness ranking.', 'livada-seo' ),
		];
	}

	/**
	 * Checks whether the content has clear H2/H3 heading structure.
	 *
	 * Green  : 3+ H2 or H3 headings
	 * Orange : 1–2 headings
	 * Red    : 0 headings
	 *
	 * @param  string $content Rendered HTML.
	 * @return array{check:string,status:string,message:string}
	 */
	private static function check_heading_structure( string $content ): array {
		$count = preg_match_all( '/<h[23][^>]*>/i', $content );
		$count = ( false === $count ) ? 0 : (int) $count;

		if ( $count >= 3 ) {
			$status  = 'green';
			$message = sprintf(
				/* translators: %d: number of subheadings */
				__( '%d H2/H3 headings found — clear structure helps AI parse and cite sections.', 'livada-seo' ),
				$count
			);
		} elseif ( $count >= 1 ) {
			$status  = 'orange';
			$message = sprintf(
				/* translators: %d: number of subheadings */
				__( 'Only %d H2/H3 heading(s) found. Add more to improve AI-readable structure.', 'livada-seo' ),
				$count
			);
		} else {
			$status  = 'red';
			$message = __( 'No H2/H3 headings found. Structure your content with clear subheadings.', 'livada-seo' );
		}

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

	/**
	 * Checks whether the content contains FAQ-style questions (headings ending
	 * with a question mark).
	 *
	 * Green  : 2+ question headings
	 * Orange : 1 question heading
	 * Red    : none
	 *
	 * @param  string $content Rendered HTML.
	 * @return array{check:string,status:string,message:string}
	 */
	private static function check_faq_content( string $content ): array {
		$count = preg_match_all( '/<h[2-4][^>]*>[^<]*\?[^<]*<\/h[2-4]>/i', $content );
		$count = ( false === $count ) ? 0 : (int) $count;

		if ( $count >= 2 ) {
			$status  = 'green';
			$message = sprintf(
				/* translators: %d: number of question headings */
				__( '%d question headings found — FAQ content is highly citable by AI engines.', 'livada-seo' ),
				$count
			);
		} elseif ( $count === 1 ) {
			$status  = 'orange';
			$message = __( '1 question heading found. Add more Q&A pairs to improve AI citability.', 'livada-seo' );
		} else {
			$status  = 'red';
			$message = __( 'No question headings found. Consider adding FAQ sections that AI can directly cite.', 'livada-seo' );
		}

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

	/**
	 * Checks whether the content contains statistics or numbers with a % sign.
	 * AI engines prefer content that cites specific data points.
	 *
	 * Green  : 3+ statistic-like expressions
	 * Orange : 1–2
	 * Red    : 0
	 *
	 * @param  string $content Rendered HTML.
	 * @return array{check:string,status:string,message:string}
	 */
	private static function check_statistics( string $content ): array {
		$plain = wp_strip_all_tags( $content );
		$count = preg_match_all( '/\d+[\s\xA0]*%/', $plain );
		$count = ( false === $count ) ? 0 : (int) $count;

		if ( $count >= 3 ) {
			$status  = 'green';
			$message = sprintf(
				/* translators: %d: number of statistics found */
				__( '%d statistics/percentages found — data-rich content is preferred by AI engines.', 'livada-seo' ),
				$count
			);
		} elseif ( $count >= 1 ) {
			$status  = 'orange';
			$message = sprintf(
				/* translators: %d: number of statistics found */
				__( '%d statistic(s) found. Add more data points to increase AI citability.', 'livada-seo' ),
				$count
			);
		} else {
			$status  = 'red';
			$message = __( 'No statistics found. Add percentages, numbers, or data to support your claims.', 'livada-seo' );
		}

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

	/**
	 * Checks whether the content contains links to external authoritative domains.
	 * Citations boost credibility for AI attribution.
	 *
	 * Green  : 2+ external links
	 * Orange : 1 external link
	 * Red    : 0 external links
	 *
	 * @param  string $content Rendered HTML.
	 * @return array{check:string,status:string,message:string}
	 */
	private static function check_citations( 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 );

				// Only absolute URLs can be external.
				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: number of external links */
				__( '%d external citations/sources found — outbound links strengthen AI trust signals.', 'livada-seo' ),
				$count
			);
		} elseif ( $count === 1 ) {
			$status  = 'orange';
			$message = __( '1 external citation found. Add more authoritative sources to strengthen credibility.', 'livada-seo' );
		} else {
			$status  = 'red';
			$message = __( 'No external citations found. Link to authoritative sources to back your claims.', 'livada-seo' );
		}

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

	/**
	 * Checks whether the plain-text content exceeds 1000 words.
	 * Longer, comprehensive content is more likely to be cited by AI engines.
	 *
	 * Green  : >= 1000 words
	 * Orange : 500–999 words
	 * Red    : < 500 words
	 *
	 * @param  string $content Rendered HTML.
	 * @return array{check:string,status:string,message:string}
	 */
	private static function check_content_word_count( string $content ): array {
		$plain = wp_strip_all_tags( $content );
		$plain = html_entity_decode( $plain, ENT_QUOTES | ENT_HTML5, 'UTF-8' );

		$words = preg_split( '/\s+/u', trim( $plain ), -1, PREG_SPLIT_NO_EMPTY );
		$count = is_array( $words ) ? count( $words ) : 0;

		if ( $count >= 1000 ) {
			$status = 'green';
		} elseif ( $count >= 500 ) {
			$status = 'orange';
		} else {
			$status = 'red';
		}

		$message = sprintf(
			/* translators: %d: word count */
			__( 'Content has %d words (GEO recommendation: 1000+ for AI citability).', 'livada-seo' ),
			$count
		);

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

	/**
	 * Checks whether the post has a featured image with a non-empty alt attribute.
	 *
	 * Green  : featured image exists and has alt text
	 * Orange : featured image exists but alt text is empty
	 * Red    : no featured image
	 *
	 * @param  int $post_id
	 * @return array{check:string,status:string,message:string}
	 */
	private static function check_featured_image_alt( int $post_id ): array {
		if ( ! has_post_thumbnail( $post_id ) ) {
			return [
				'check'   => 'featured_image_alt',
				'status'  => 'red',
				'message' => __( 'No featured image set. Images with alt text support AI content understanding.', 'livada-seo' ),
			];
		}

		$thumbnail_id = (int) get_post_thumbnail_id( $post_id );
		$alt          = (string) get_post_meta( $thumbnail_id, '_wp_attachment_image_alt', true );

		if ( '' !== trim( $alt ) ) {
			return [
				'check'   => 'featured_image_alt',
				'status'  => 'green',
				'message' => __( 'Featured image is set and has alt text.', 'livada-seo' ),
			];
		}

		return [
			'check'   => 'featured_image_alt',
			'status'  => 'orange',
			'message' => __( 'Featured image is set but missing alt text. Add a descriptive alt attribute.', 'livada-seo' ),
		];
	}

	// ── Private utilities ─────────────────────────────────────────────────────

	/**
	 * Returns true when the GEO module is enabled in settings.
	 *
	 * @return bool
	 */
	private static function is_enabled(): bool {
		return (bool) Livada_SEO_Loader::get_setting( 'geo_enabled', true );
	}
}
