<?php
/**
 * Livada SEO — Module Analysis
 *
 * Runs on-save SEO analysis for posts and pages, scores content against the
 * focus keyword, and surfaces the score as a coloured indicator in the WP
 * admin list tables.
 *
 * Saved meta keys:
 *   _livada_seo_score         (int 0–100)
 *   _livada_seo_score_details (serialized array of per-check results)
 *
 * @package Livada_SEO
 */

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

class Livada_SEO_Analysis {

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

	/**
	 * Registers all hooks for this module.
	 * Called once by Livada_SEO_Loader::init().
	 */
	public static function init(): void {
		// Run analysis whenever a post is saved.
		add_action( 'save_post', [ self::class, 'on_save_post' ], 10, 2 );

		// Re-run analysis when post meta that affects scoring changes outside
		// of save_post (e.g. featured image set via the WP REST API
		// `featured_media` field, Quick Edit thumbnail change, custom plugins
		// writing _thumbnail_id directly).
		add_action( 'updated_post_meta', [ self::class, 'on_meta_changed' ], 10, 4 );
		add_action( 'added_post_meta',   [ self::class, 'on_meta_changed' ], 10, 4 );
		add_action( 'deleted_post_meta', [ self::class, 'on_meta_changed' ], 10, 4 );

		// Re-run after any WP REST API post-create/update so featured_media,
		// title, slug, content all converge to a fresh score.
		add_action( 'rest_after_insert_post', [ self::class, 'on_rest_post_save' ], 10, 1 );
		add_action( 'rest_after_insert_page', [ self::class, 'on_rest_post_save' ], 10, 1 );

		// Admin list-table columns — posts.
		add_filter( 'manage_posts_columns',        [ self::class, 'add_seo_column' ] );
		add_action( 'manage_posts_custom_column',  [ self::class, 'render_seo_column' ], 10, 2 );

		// Admin list-table columns — pages.
		add_filter( 'manage_pages_columns',        [ self::class, 'add_seo_column' ] );
		add_action( 'manage_pages_custom_column',  [ self::class, 'render_seo_column' ], 10, 2 );
	}

	/**
	 * Re-runs analyze() when a meta key relevant to scoring changes.
	 * Filtered set: `_thumbnail_id` and the plugin's own SEO meta keys.
	 *
	 * @param int    $meta_id  Ignored.
	 * @param int    $post_id
	 * @param string $meta_key
	 * @param mixed  $meta_value Ignored.
	 */
	public static function on_meta_changed( $meta_id, $post_id, $meta_key, $meta_value ): void {
		// Avoid recursion: analyze() itself writes _livada_seo_score(_details)
		// and _livada_seo_readability_score, which would re-trigger this hook.
		static $watched = [
			'_thumbnail_id'                 => true,
			'_livada_seo_focus_keyword'     => true,
			'_livada_seo_title'             => true,
			'_livada_seo_description'       => true,
			'_livada_seo_canonical'         => true,
		];

		if ( empty( $watched[ $meta_key ] ) ) {
			return;
		}

		$post = get_post( (int) $post_id );
		if ( ! $post instanceof WP_Post ) {
			return;
		}
		$pt = get_post_type_object( $post->post_type );
		if ( ! $pt || ! $pt->public ) {
			return;
		}

		self::analyze( (int) $post_id );
	}

	/**
	 * Hooked on `rest_after_insert_post` / `rest_after_insert_page`.
	 *
	 * @param WP_Post $post
	 */
	public static function on_rest_post_save( $post ): void {
		if ( ! $post instanceof WP_Post ) {
			return;
		}
		self::analyze( (int) $post->ID );
	}

	// ── Save hook ─────────────────────────────────────────────────────────────

	/**
	 * Fired on `save_post`. Guards against autosaves, revisions, and non-public
	 * post types before delegating to analyze().
	 *
	 * @param int     $post_id
	 * @param WP_Post $post
	 */
	public static function on_save_post( int $post_id, WP_Post $post ): void {
		// Skip autosaves.
		if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) {
			return;
		}

		// Skip revisions.
		if ( wp_is_post_revision( $post_id ) ) {
			return;
		}

		// Skip non-public post types.
		$post_type_obj = get_post_type_object( $post->post_type );
		if ( ! $post_type_obj || ! $post_type_obj->public ) {
			return;
		}

		self::analyze( $post_id );
	}

	// ── Core analysis ─────────────────────────────────────────────────────────

	/**
	 * Runs all SEO checks for the given post and persists the score.
	 *
	 * @param int $post_id
	 */
	public static function analyze( int $post_id ): void {
		$post = get_post( $post_id );

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

		// Retrieve the focus keyword.
		$keyword = (string) get_post_meta( $post_id, '_livada_seo_focus_keyword', true );

		// No keyword → score 0, no details.
		if ( $keyword === '' ) {
			update_post_meta( $post_id, '_livada_seo_score', 0 );
			update_post_meta( $post_id, '_livada_seo_score_details', [] );
			return;
		}

		// Build the data set used by all checks.
		// IMPORTANT: For Elementor-built pages, post_content is empty/minimal — the
		// real content lives in _elementor_data meta. We call Elementor's frontend
		// renderer to get the actual rendered HTML, with fallback to standard content.
		$rendered_content = self::get_rendered_content( $post );

		$meta_title = (string) get_post_meta( $post_id, '_livada_seo_title', true );
		if ( $meta_title === '' ) {
			$meta_title = get_the_title( $post_id );
		}

		$description = (string) get_post_meta( $post_id, '_livada_seo_description', true );

		// Compute Flesch once and reuse for both the per-check entry and the
		// standalone readability meta exposed in the admin column.
		$flesch_score = self::flesch_reading_ease_score( $rendered_content );
		update_post_meta( $post_id, '_livada_seo_readability_score', $flesch_score );

		// Run every check.
		$checks = [
			self::check_keyword_in_title( $keyword, $meta_title ),
			self::check_keyword_in_description( $keyword, $description ),
			self::check_keyword_in_h1( $keyword, $rendered_content ),
			self::check_keyword_in_first_paragraph( $keyword, $rendered_content ),
			self::check_keyword_in_url( $keyword, $post ),
			self::check_title_length( $meta_title ),
			self::check_description_length( $description ),
			self::check_internal_links( $rendered_content ),
			self::check_external_links( $rendered_content ),
			self::check_images_alt( $rendered_content ),
			self::check_keyword_density( $keyword, $rendered_content ),
			self::check_content_length( $rendered_content ),
			self::check_headings_structure( $rendered_content ),
			self::check_paragraph_length( $rendered_content ),
			self::check_sentence_readability( $rendered_content ),
			self::flesch_check_from_score( $flesch_score ),
			self::check_has_featured_image( $post_id ),
		];

		// Tally score: green=2, orange=1, red=0.
		$total   = 0;
		$maximum = count( $checks ) * 2;

		foreach ( $checks as $result ) {
			if ( $result['status'] === 'green' ) {
				$total += 2;
			} elseif ( $result['status'] === 'orange' ) {
				$total += 1;
			}
			// red → 0.
		}

		$score = $maximum > 0 ? (int) round( ( $total / $maximum ) * 100 ) : 0;

		update_post_meta( $post_id, '_livada_seo_score',         $score );
		update_post_meta( $post_id, '_livada_seo_score_details', $checks );
	}

	// ── Content extraction ───────────────────────────────────────────────────

	/**
	 * Returns the fully rendered HTML content of a post for analysis purposes.
	 *
	 * Resolution order:
	 *   1. Elementor frontend renderer (when the post is built with Elementor)
	 *   2. Standard `apply_filters('the_content')` for Gutenberg / classic posts
	 *   3. Raw `post_content` as last-resort fallback
	 *
	 * Without this method, all Elementor-built pages would score very low because
	 * `post_content` is empty (the real content lives in the `_elementor_data`
	 * post meta as a JSON tree).
	 *
	 * @param WP_Post $post The post being analyzed.
	 * @return string Rendered HTML content (may include scripts/styles).
	 */
	private static function get_rendered_content( WP_Post $post ): string {
		$content = '';

		// 1. Try Elementor frontend rendering when the page is built with Elementor.
		if ( did_action( 'elementor/loaded' ) && class_exists( '\Elementor\Plugin' ) ) {
			try {
				$elementor = \Elementor\Plugin::$instance;
				if ( isset( $elementor->documents ) ) {
					$document = $elementor->documents->get( $post->ID );
					if ( $document && method_exists( $document, 'is_built_with_elementor' )
						&& $document->is_built_with_elementor() ) {
						// Get the full rendered HTML (without inline CSS to keep it light).
						$content = (string) $elementor->frontend->get_builder_content( $post->ID, false );
					}
				}
			} catch ( \Throwable $e ) {
				// Defensive: any Elementor failure falls through to the standard path.
				$content = '';
			}
		}

		// 2. Elementor render failed or returned empty (common during save_post when
		// the frontend renderer can't run safely) → walk the raw _elementor_data JSON
		// and extract text content from every widget setting. Covers html widgets,
		// text-editor, headings, buttons, image alt — anything stored as a string.
		if ( $content === '' || strlen( wp_strip_all_tags( $content ) ) < 50 ) {
			$elementor_text = self::extract_text_from_elementor_data( $post->ID );
			if ( $elementor_text !== '' ) {
				$content = $elementor_text;
			}
		}

		// 3. Fallback to standard post content rendering (Gutenberg, classic editor).
		if ( $content === '' ) {
			$content = (string) apply_filters( 'the_content', $post->post_content );
		}

		// 4. Absolute fallback to raw content.
		if ( $content === '' ) {
			$content = (string) $post->post_content;
		}

		return $content;
	}

	/**
	 * Walks the `_elementor_data` JSON tree for a post and concatenates every
	 * string value into a single HTML blob. Used as a fallback when the live
	 * Elementor renderer can't execute (e.g. during save_post on a non-frontend
	 * context, or when an external integration doesn't trigger render hooks).
	 *
	 * Recognises common widget setting keys: `editor`, `html`, `title`, `text`,
	 * `description`, `caption`, `button_text`, `link`, plus image `alt`. Any
	 * unrecognised string under 1024 chars is also captured so unusual widgets
	 * still feed the analysis.
	 *
	 * @param int $post_id
	 * @return string Concatenated HTML; empty when no Elementor data exists.
	 */
	private static function extract_text_from_elementor_data( int $post_id ): string {
		$raw = get_post_meta( $post_id, '_elementor_data', true );
		if ( ! is_string( $raw ) || $raw === '' ) {
			return '';
		}

		$tree = json_decode( $raw, true );
		// Some imports / migration plugins double-encode `_elementor_data`. If the
		// first decode produced a string, try once more before giving up.
		if ( is_string( $tree ) ) {
			$tree = json_decode( $tree, true );
		}
		if ( ! is_array( $tree ) ) {
			return '';
		}

		$buffer = [];
		self::walk_elementor_tree( $tree, $buffer );

		return implode( "\n\n", array_filter( $buffer, static fn( $s ) => $s !== '' ) );
	}

	/**
	 * Recursive helper for extract_text_from_elementor_data().
	 * Builds an HTML blob in $buffer (passed by reference).
	 */
	private static function walk_elementor_tree( array $node, array &$buffer ): void {
		// A node typically has: id, elType (section/column/widget/container),
		// widgetType, settings, elements (children).
		if ( isset( $node['settings'] ) && is_array( $node['settings'] ) ) {
			$widget = isset( $node['widgetType'] ) ? (string) $node['widgetType'] : '';
			$s      = $node['settings'];

			// HTML widget — emit raw HTML so the analysis can find <h1>, <p>, <a>, <img>.
			if ( $widget === 'html' && isset( $s['html'] ) && is_string( $s['html'] ) ) {
				$buffer[] = $s['html'];
			}
			// Text editor — already HTML.
			if ( isset( $s['editor'] ) && is_string( $s['editor'] ) ) {
				$buffer[] = $s['editor'];
			}
			// Heading widget — wrap in <h{size}> so check_keyword_in_h1 can match.
			if ( $widget === 'heading' && isset( $s['title'] ) && is_string( $s['title'] ) ) {
				$tag      = isset( $s['header_size'] ) && is_string( $s['header_size'] ) ? $s['header_size'] : 'h2';
				$tag      = preg_match( '/^h[1-6]$/', $tag ) ? $tag : 'h2';
				$buffer[] = '<' . $tag . '>' . $s['title'] . '</' . $tag . '>';
			}
			// Button widget — emit as link so external/internal link checks see it.
			if ( $widget === 'button' && isset( $s['text'] ) && is_string( $s['text'] ) ) {
				$href     = isset( $s['link']['url'] ) && is_string( $s['link']['url'] ) ? $s['link']['url'] : '#';
				$buffer[] = '<a href="' . esc_attr( $href ) . '">' . $s['text'] . '</a>';
			}
			// Image widget — emit <img> so alt-text and image checks see it.
			if ( $widget === 'image' && isset( $s['image']['url'] ) && is_string( $s['image']['url'] ) ) {
				$alt      = isset( $s['image']['alt'] ) && is_string( $s['image']['alt'] ) ? $s['image']['alt'] : '';
				$buffer[] = '<img src="' . esc_attr( $s['image']['url'] ) . '" alt="' . esc_attr( $alt ) . '">';
			}
			// Generic catch-all for anything textual we didn't model explicitly.
			foreach ( [ 'description', 'caption', 'content', 'subtitle' ] as $key ) {
				if ( isset( $s[ $key ] ) && is_string( $s[ $key ] ) && $s[ $key ] !== '' ) {
					$buffer[] = '<p>' . $s[ $key ] . '</p>';
				}
			}
		}

		// Recurse into child elements.
		if ( ! empty( $node['elements'] ) && is_array( $node['elements'] ) ) {
			foreach ( $node['elements'] as $child ) {
				if ( is_array( $child ) ) {
					self::walk_elementor_tree( $child, $buffer );
				}
			}
		}

		// Top-level array passed in — iterate it.
		if ( ! isset( $node['settings'] ) && ! isset( $node['elements'] ) ) {
			foreach ( $node as $child ) {
				if ( is_array( $child ) ) {
					self::walk_elementor_tree( $child, $buffer );
				}
			}
		}
	}

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

	/**
	 * Checks whether the focus keyword appears in the meta/post title.
	 *
	 * @param string $keyword
	 * @param string $title
	 * @return array{status:string,check:string,message:string}
	 */
	private static function check_keyword_in_title( string $keyword, string $title ): array {
		$found = self::keyword_matches( $title, $keyword );

		return [
			'status'  => $found ? 'green' : 'red',
			'check'   => 'keyword_in_title',
			'message' => $found
				? __( 'Focus keyword found in SEO title.', 'livada-seo' )
				: __( 'Focus keyword not found in SEO title.', 'livada-seo' ),
		];
	}

	/**
	 * Checks whether the focus keyword appears in the meta description.
	 *
	 * @param string $keyword
	 * @param string $description
	 * @return array{status:string,check:string,message:string}
	 */
	private static function check_keyword_in_description( string $keyword, string $description ): array {
		$found = $description !== '' && self::keyword_matches( $description, $keyword );

		return [
			'status'  => $found ? 'green' : 'red',
			'check'   => 'keyword_in_description',
			'message' => $found
				? __( 'Focus keyword found in meta description.', 'livada-seo' )
				: __( 'Focus keyword not found in meta description.', 'livada-seo' ),
		];
	}

	/**
	 * Checks whether the focus keyword appears inside the first <h1> tag.
	 *
	 * @param string $keyword
	 * @param string $content Rendered HTML content.
	 * @return array{status:string,check:string,message:string}
	 */
	private static function check_keyword_in_h1( string $keyword, string $content ): array {
		$found = false;

		if ( preg_match( '/<h1[^>]*>(.*?)<\/h1>/is', $content, $matches ) ) {
			$h1_text = wp_strip_all_tags( $matches[1] );
			$found   = self::keyword_matches( $h1_text, $keyword );
		}

		return [
			'status'  => $found ? 'green' : 'red',
			'check'   => 'keyword_in_h1',
			'message' => $found
				? __( 'Focus keyword found in H1 heading.', 'livada-seo' )
				: __( 'Focus keyword not found in H1 heading.', 'livada-seo' ),
		];
	}

	/**
	 * Checks whether the focus keyword appears in the first <p> tag.
	 *
	 * @param string $keyword
	 * @param string $content Rendered HTML content.
	 * @return array{status:string,check:string,message:string}
	 */
	private static function check_keyword_in_first_paragraph( string $keyword, string $content ): array {
		$found = false;

		if ( preg_match( '/<p[^>]*>(.*?)<\/p>/is', $content, $matches ) ) {
			$p_text = wp_strip_all_tags( $matches[1] );
			$found  = self::keyword_matches( $p_text, $keyword );
		}

		return [
			'status'  => $found ? 'green' : 'red',
			'check'   => 'keyword_in_first_paragraph',
			'message' => $found
				? __( 'Focus keyword found in the first paragraph.', 'livada-seo' )
				: __( 'Focus keyword not found in the first paragraph.', 'livada-seo' ),
		];
	}

	/**
	 * Checks whether the slugified focus keyword is present in the post slug.
	 *
	 * @param string  $keyword
	 * @param WP_Post $post
	 * @return array{status:string,check:string,message:string}
	 */
	private static function check_keyword_in_url( string $keyword, WP_Post $post ): array {
		$slug = $post->post_name;
		// Slug is already kebab-case ASCII; turn it into a space-separated haystack
		// so keyword_matches() can apply the same word-by-word logic it uses for
		// titles/descriptions. This handles "réparation PC Castellane" vs.
		// "reparation-maintenance-pc-castellane" — partial word presence wins.
		$slug_haystack = str_replace( '-', ' ', $slug );
		$found         = $slug !== '' && self::keyword_matches( $slug_haystack, $keyword );

		return [
			'status'  => $found ? 'green' : 'red',
			'check'   => 'keyword_in_url',
			'message' => $found
				? __( 'Focus keyword found in the URL slug.', 'livada-seo' )
				: __( 'Focus keyword not found in the URL slug.', 'livada-seo' ),
		];
	}

	/**
	 * Checks whether the meta/post title length is within the ideal range.
	 *
	 * Green  : 50–60 chars
	 * Orange : 40–50 or 60–70 chars
	 * Red    : <40 or >70 chars
	 *
	 * @param string $title
	 * @return array{status:string,check:string,message:string}
	 */
	private static function check_title_length( string $title ): array {
		$length = mb_strlen( $title );

		if ( $length >= 50 && $length <= 60 ) {
			$status  = 'green';
			$message = sprintf(
				/* translators: %d: character count */
				__( 'SEO title length is good (%d characters).', 'livada-seo' ),
				$length
			);
		} elseif ( ( $length >= 40 && $length < 50 ) || ( $length > 60 && $length <= 70 ) ) {
			$status  = 'orange';
			$message = sprintf(
				/* translators: %d: character count */
				__( 'SEO title length is acceptable but not ideal (%d characters). Aim for 50–60.', 'livada-seo' ),
				$length
			);
		} else {
			$status  = 'red';
			$message = sprintf(
				/* translators: %d: character count */
				__( 'SEO title length is outside the recommended range (%d characters). Aim for 50–60.', 'livada-seo' ),
				$length
			);
		}

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

	/**
	 * Checks whether the meta description length is within the ideal range.
	 *
	 * Green  : 120–160 chars
	 * Orange : 100–120 or 160–180 chars
	 * Red    : <100 or >180 chars
	 *
	 * @param string $description
	 * @return array{status:string,check:string,message:string}
	 */
	private static function check_description_length( string $description ): array {
		$length = mb_strlen( $description );

		if ( $length >= 120 && $length <= 160 ) {
			$status  = 'green';
			$message = sprintf(
				/* translators: %d: character count */
				__( 'Meta description length is good (%d characters).', 'livada-seo' ),
				$length
			);
		} elseif ( ( $length >= 100 && $length < 120 ) || ( $length > 160 && $length <= 180 ) ) {
			$status  = 'orange';
			$message = sprintf(
				/* translators: %d: character count */
				__( 'Meta description length is acceptable but not ideal (%d characters). Aim for 120–160.', 'livada-seo' ),
				$length
			);
		} else {
			$status  = 'red';
			$message = sprintf(
				/* translators: %d: character count */
				__( 'Meta description length is outside the recommended range (%d characters). Aim for 120–160.', 'livada-seo' ),
				$length
			);
		}

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

	/**
	 * Counts internal links (<a href> pointing to the same domain) in content.
	 *
	 * Green  : ≥2 internal links
	 * Orange : 1 internal link
	 * Red    : 0 internal links
	 *
	 * @param string $content Rendered HTML content.
	 * @return array{status:string,check:string,message:string}
	 */
	private static function check_internal_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 );

				// Relative URLs are always internal.
				if ( strpos( $href, '//' ) === false && strpos( $href, 'http' ) !== 0 ) {
					$count++;
					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( 'Found %d internal link.', 'Found %d internal links.', $count, 'livada-seo' ),
				$count
			);
		} elseif ( $count === 1 ) {
			$status  = 'orange';
			$message = __( 'Only 1 internal link found. Add at least one more.', 'livada-seo' );
		} else {
			$status  = 'red';
			$message = __( 'No internal links found. Add at least 2.', 'livada-seo' );
		}

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

	/**
	 * Counts external links (<a href> pointing to a different domain) in content.
	 *
	 * Green : ≥1 external link
	 * Red   : 0 external links
	 *
	 * @param string $content Rendered HTML content.
	 * @return array{status:string,check:string,message:string}
	 */
	private static function check_external_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 );

				// 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 >= 1 ) {
			$status  = 'green';
			$message = sprintf(
				/* translators: %d: link count */
				_n( 'Found %d external link.', 'Found %d external links.', $count, 'livada-seo' ),
				$count
			);
		} else {
			$status  = 'red';
			$message = __( 'No external links found. Add at least 1 outbound link.', 'livada-seo' );
		}

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

	/**
	 * Audits alt attributes on all <img> tags in the content.
	 *
	 * Green  : all images have a non-empty alt attribute
	 * Orange : some images are missing an alt attribute
	 * Red    : no images have an alt attribute (or no images at all treated as red)
	 *
	 * @param string $content Rendered HTML content.
	 * @return array{status:string,check:string,message:string}
	 */
	private static function check_images_alt( string $content ): array {
		$total    = 0;
		$with_alt = 0;

		if ( preg_match_all( '/<img\s[^>]+>/i', $content, $img_matches ) ) {
			foreach ( $img_matches[0] as $img_tag ) {
				$total++;

				// Check for a non-empty alt attribute.
				if ( preg_match( '/\balt=["\']([^"\']*)["\']/', $img_tag, $alt_match ) ) {
					if ( trim( $alt_match[1] ) !== '' ) {
						$with_alt++;
					}
				}
			}
		}

		// No images found → orange (informational). Many service / listing pages
		// legitimately have no inline images; penalising them as red distorts the
		// score. The featured-image check is a separate signal and stays red on
		// its own when missing.
		if ( $total === 0 ) {
			return [
				'status'  => 'orange',
				'check'   => 'images_alt',
				'message' => __( 'No images found in the content. Consider adding at least one descriptive image.', 'livada-seo' ),
			];
		}

		if ( $with_alt === $total ) {
			$status  = 'green';
			$message = __( 'All images have alt attributes.', 'livada-seo' );
		} elseif ( $with_alt > 0 ) {
			$status  = 'orange';
			$message = sprintf(
				/* translators: 1: images with alt, 2: total images */
				__( '%1$d of %2$d images have alt attributes. Add alt text to the remaining images.', 'livada-seo' ),
				$with_alt,
				$total
			);
		} else {
			$status  = 'red';
			$message = __( 'No images have alt attributes. Add descriptive alt text to all images.', 'livada-seo' );
		}

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

	/**
	 * Checks the focus keyword density in the plain-text content.
	 *
	 * Density = (keyword occurrences / total words) * 100
	 *
	 * Green  : 1–3 %
	 * Orange : 0.5–1 % or 3–4 %
	 * Red    : <0.5 % or >4 %
	 *
	 * @param string $keyword
	 * @param string $content Rendered HTML content.
	 * @return array{status:string,check:string,message:string}
	 */
	private static function check_keyword_density( string $keyword, string $content ): array {
		$plain_text = wp_strip_all_tags( $content );
		$plain_text = html_entity_decode( $plain_text, ENT_QUOTES | ENT_HTML5, 'UTF-8' );

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

		if ( $total === 0 ) {
			return [
				'status'  => 'red',
				'check'   => 'keyword_density',
				'message' => __( 'Content is empty; keyword density cannot be calculated.', 'livada-seo' ),
			];
		}

		// Count keyword occurrences against the accent-normalized form so
		// "réparation" in keyword still matches "Reparation" in content.
		$keyword_norm = self::normalize_for_matching( $keyword );
		$plain_norm   = self::normalize_for_matching( $plain_text );
		$occurrences  = $keyword_norm === '' ? 0 : substr_count( $plain_norm, $keyword_norm );

		// If the full keyword phrase isn't found contiguously, fall back to the
		// minimum count of any single keyword word — gives multi-word keywords
		// a meaningful density when words appear separately in the content.
		if ( $occurrences === 0 ) {
			$kw_words = self::keyword_words( $keyword );
			if ( ! empty( $kw_words ) ) {
				$min = PHP_INT_MAX;
				foreach ( $kw_words as $w ) {
					$c   = substr_count( $plain_norm, $w );
					$min = $c < $min ? $c : $min;
				}
				$occurrences = $min === PHP_INT_MAX ? 0 : $min;
			}
		}

		$density = ( $occurrences / $total ) * 100;

		if ( $density >= 1.0 && $density <= 3.0 ) {
			$status  = 'green';
			$message = sprintf(
				/* translators: %.1f: density percentage */
				__( 'Keyword density is good (%.1f%%).', 'livada-seo' ),
				$density
			);
		} elseif ( ( $density >= 0.5 && $density < 1.0 ) || ( $density > 3.0 && $density <= 4.0 ) ) {
			$status  = 'orange';
			$message = sprintf(
				/* translators: %.1f: density percentage */
				__( 'Keyword density is acceptable but not ideal (%.1f%%). Aim for 1–3%%.', 'livada-seo' ),
				$density
			);
		} else {
			$status  = 'red';
			$message = sprintf(
				/* translators: %.1f: density percentage */
				__( 'Keyword density is outside the recommended range (%.1f%%). Aim for 1–3%%.', 'livada-seo' ),
				$density
			);
		}

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

	/**
	 * Checks the word count of the plain-text content.
	 *
	 * Green  : >= 800 words
	 * Orange : 300–799 words
	 * Red    : < 300 words
	 *
	 * @param string $content Rendered HTML content.
	 * @return array{status:string,check:string,message:string}
	 */
	private static function check_content_length( 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 >= 800 ) {
			$status = 'green';
		} elseif ( $count >= 300 ) {
			$status = 'orange';
		} else {
			$status = 'red';
		}

		$message = sprintf(
			/* translators: %d: word count */
			__( 'Content has %d words (recommended: 800+).', 'livada-seo' ),
			$count
		);

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

	/**
	 * Checks the number of H2/H3 subheadings in the content.
	 *
	 * Green  : >= 3 subheadings
	 * Orange : 1–2 subheadings
	 * Red    : 0 subheadings
	 *
	 * @param string $content Rendered HTML content.
	 * @return array{status:string,check:string,message:string}
	 */
	private static function check_headings_structure( string $content ): array {
		$count = preg_match_all( '/<h[23][^>]*>/i', $content );
		$count = $count === false ? 0 : $count;

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

		$message = sprintf(
			/* translators: %d: subheading count */
			__( '%d subheadings found (recommended: 3+).', 'livada-seo' ),
			$count
		);

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

	/**
	 * Checks the average word count per paragraph (<p> tag) in the content.
	 *
	 * Green  : average < 150 words
	 * Orange : average 150–200 words
	 * Red    : average > 200 words
	 *
	 * When no paragraphs are found the check returns orange as a neutral result.
	 *
	 * @param string $content Rendered HTML content.
	 * @return array{status:string,check:string,message:string}
	 */
	private static function check_paragraph_length( string $content ): array {
		if ( ! preg_match_all( '/<p[^>]*>(.*?)<\/p>/is', $content, $matches ) ) {
			return [
				'status'  => 'orange',
				'check'   => 'paragraph_length',
				'message' => __( 'No paragraphs found to evaluate average length.', 'livada-seo' ),
			];
		}

		$total_words = 0;
		$para_count  = 0;

		foreach ( $matches[1] as $para_html ) {
			$text = wp_strip_all_tags( $para_html );
			$text = html_entity_decode( $text, ENT_QUOTES | ENT_HTML5, 'UTF-8' );
			$text = trim( $text );

			if ( $text === '' ) {
				continue;
			}

			$words        = preg_split( '/\s+/u', $text, -1, PREG_SPLIT_NO_EMPTY );
			$total_words += is_array( $words ) ? count( $words ) : 0;
			$para_count++;
		}

		if ( $para_count === 0 ) {
			return [
				'status'  => 'orange',
				'check'   => 'paragraph_length',
				'message' => __( 'No non-empty paragraphs found to evaluate average length.', 'livada-seo' ),
			];
		}

		$average = (int) round( $total_words / $para_count );

		if ( $average < 150 ) {
			$status = 'green';
		} elseif ( $average <= 200 ) {
			$status = 'orange';
		} else {
			$status = 'red';
		}

		$message = sprintf(
			/* translators: %d: average word count per paragraph */
			__( 'Average paragraph length: %d words.', 'livada-seo' ),
			$average
		);

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

	/**
	 * Checks the proportion of sentences longer than 25 words.
	 *
	 * Green  : < 20 % of sentences are too long
	 * Orange : 20–30 % of sentences are too long
	 * Red    : > 30 % of sentences are too long
	 *
	 * When no sentences are found the check returns orange as a neutral result.
	 *
	 * @param string $content Rendered HTML content.
	 * @return array{status:string,check:string,message:string}
	 */
	private static function check_sentence_readability( string $content ): array {
		$plain = wp_strip_all_tags( $content );
		$plain = html_entity_decode( $plain, ENT_QUOTES | ENT_HTML5, 'UTF-8' );
		$plain = trim( $plain );

		// Split on sentence-ending punctuation followed by whitespace or end of string.
		$sentences = preg_split( '/(?<=[.!?])\s+/u', $plain, -1, PREG_SPLIT_NO_EMPTY );

		if ( ! is_array( $sentences ) || count( $sentences ) === 0 ) {
			return [
				'status'  => 'orange',
				'check'   => 'sentence_readability',
				'message' => __( 'No sentences found to evaluate readability.', 'livada-seo' ),
			];
		}

		$total_sentences = count( $sentences );
		$long_sentences  = 0;

		foreach ( $sentences as $sentence ) {
			$words = preg_split( '/\s+/u', trim( $sentence ), -1, PREG_SPLIT_NO_EMPTY );
			if ( is_array( $words ) && count( $words ) > 25 ) {
				$long_sentences++;
			}
		}

		$percentage = (int) round( ( $long_sentences / $total_sentences ) * 100 );

		if ( $percentage < 20 ) {
			$status = 'green';
		} elseif ( $percentage <= 30 ) {
			$status = 'orange';
		} else {
			$status = 'red';
		}

		$message = sprintf(
			/* translators: %d: percentage of long sentences */
			__( '%d%% of sentences are longer than 25 words.', 'livada-seo' ),
			$percentage
		);

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

	/**
	 * Checks whether the post has a featured image (thumbnail) set.
	 *
	 * Green : has a featured image
	 * Red   : no featured image
	 *
	 * @param int $post_id
	 * @return array{status:string,check:string,message:string}
	 */
	private static function check_has_featured_image( int $post_id ): array {
		// Triple-check: WP helper, direct meta, and post-thumbnail-id getter.
		// Some integrations (REST featured_media write, AJAX builders) update
		// `_thumbnail_id` without invalidating the object cache that
		// `has_post_thumbnail()` reads from inside the same request.
		$has_image = has_post_thumbnail( $post_id )
			|| (int) get_post_meta( $post_id, '_thumbnail_id', true ) > 0
			|| (int) get_post_thumbnail_id( $post_id ) > 0;

		return [
			'status'  => $has_image ? 'green' : 'red',
			'check'   => 'has_featured_image',
			'message' => $has_image
				? __( 'Featured image is set.', 'livada-seo' )
				: __( 'No featured image set.', 'livada-seo' ),
		];
	}

	// ── Admin columns ─────────────────────────────────────────────────────────

	/**
	 * Appends a "SEO" column to the posts/pages list table.
	 *
	 * @param  array<string,string> $columns
	 * @return array<string,string>
	 */
	public static function add_seo_column( array $columns ): array {
		$columns['livada_seo_score']       = __( 'SEO', 'livada-seo' );
		$columns['livada_seo_readability'] = __( 'Read.', 'livada-seo' );
		$columns['livada_seo_cornerstone'] = '★';
		return $columns;
	}

	/**
	 * Renders the SEO score cell for each row in the posts/pages list table.
	 *
	 * Outputs a coloured dot followed by the numeric score, or an em-dash when
	 * no score has been calculated yet.
	 *
	 * @param string $column_name
	 * @param int    $post_id
	 */
	public static function render_seo_column( string $column_name, int $post_id ): void {
		if ( $column_name === 'livada_seo_score' ) {
			self::render_score_dot(
				get_post_meta( $post_id, '_livada_seo_score', true ),
				[ self::class, 'score_to_color' ],
				__( 'No SEO score', 'livada-seo' )
			);
			return;
		}

		if ( $column_name === 'livada_seo_readability' ) {
			self::render_score_dot(
				get_post_meta( $post_id, '_livada_seo_readability_score', true ),
				[ self::class, 'flesch_to_color' ],
				__( 'No readability score', 'livada-seo' )
			);
			return;
		}

		if ( $column_name === 'livada_seo_cornerstone' ) {
			$is_cs = (bool) get_post_meta( $post_id, '_livada_seo_cornerstone', true );
			if ( $is_cs ) {
				echo '<span title="' . esc_attr__( 'Cornerstone content', 'livada-seo' ) . '" style="color:#dba617;">★</span>';
			} else {
				echo '<span aria-hidden="true" style="color:#ddd;">☆</span>';
			}
			return;
		}
	}

	/**
	 * Outputs a coloured-dot + numeric badge for an admin list-table cell.
	 *
	 * @param mixed    $raw_score    Score from post meta (may be '' if absent).
	 * @param callable $color_fn     Callable returning a hex colour for the score.
	 * @param string   $missing_aria Aria-label to use when the score is absent.
	 */
	private static function render_score_dot( $raw_score, callable $color_fn, string $missing_aria ): void {
		if ( $raw_score === '' || $raw_score === false ) {
			echo '<span style="color:#aaa;" aria-label="' . esc_attr( $missing_aria ) . '">&mdash;</span>';
			return;
		}

		$score = (int) $raw_score;
		$color = (string) $color_fn( $score );

		// Validate hex colour before output to keep style attribute safe.
		if ( ! preg_match( '/^#[0-9a-f]{6}$/i', $color ) ) {
			$color = '#aaa';
		}

		printf(
			'<span style="display:inline-flex;align-items:center;gap:4px;">'
			. '<span style="width:10px;height:10px;border-radius:50%%;background:%s;display:inline-block;" aria-hidden="true"></span>'
			. '<span>%d</span>'
			. '</span>',
			esc_attr( $color ),
			$score
		);
	}

	/**
	 * Maps a Flesch reading-ease score to a hex colour.
	 * ≥60 green (plain language), 30–59 orange, <30 red.
	 */
	private static function flesch_to_color( int $score ): string {
		if ( $score >= 60 ) {
			return '#22c55e';
		}
		if ( $score >= 30 ) {
			return '#f59e0b';
		}
		return '#ef4444';
	}

	// ── Utilities ─────────────────────────────────────────────────────────────

	/**
	 * Returns a CSS colour value based on the score.
	 *
	 * Green  ( ≥67 ) : #22c55e
	 * Orange ( ≥34 ) : #f59e0b
	 * Red    ( <34  ) : #ef4444
	 *
	 * The thresholds mirror the scoring logic (green=2 pts, orange=1 pt, red=0).
	 * A score of 67+ means roughly two-thirds of checks are green.
	 *
	 * @param int $score 0–100
	 * @return string Hex colour string.
	 */
	private static function score_to_color( int $score ): string {
		if ( $score >= 67 ) {
			return '#22c55e'; // green
		}

		if ( $score >= 34 ) {
			return '#f59e0b'; // orange
		}

		return '#ef4444'; // red
	}

	/**
	 * Computes a Flesch Reading Ease score (0–100, higher = easier).
	 *
	 * Formula: 206.835 - 1.015 × (words / sentences) - 84.6 × (syllables / words)
	 * Reference: Flesch, R. (1948). A new readability yardstick.
	 *
	 * The score is clamped to [0, 100] so the value stays interpretable when
	 * very long sentences or very complex words push the raw output out of range.
	 *
	 * @param string $content Rendered HTML content.
	 * @return int Score 0–100. Returns 0 when content is empty.
	 */
	private static function flesch_reading_ease_score( string $content ): int {
		$plain = wp_strip_all_tags( $content );
		$plain = html_entity_decode( $plain, ENT_QUOTES | ENT_HTML5, 'UTF-8' );
		$plain = trim( $plain );

		if ( '' === $plain ) {
			return 0;
		}

		$words     = preg_split( '/\s+/u', $plain, -1, PREG_SPLIT_NO_EMPTY );
		$sentences = preg_split( '/(?<=[.!?])\s+/u', $plain, -1, PREG_SPLIT_NO_EMPTY );

		$word_count     = is_array( $words )     ? count( $words )     : 0;
		$sentence_count = is_array( $sentences ) ? count( $sentences ) : 0;

		if ( $word_count === 0 || $sentence_count === 0 ) {
			return 0;
		}

		$syllable_count = 0;
		foreach ( $words as $word ) {
			$syllable_count += self::estimate_syllables( $word );
		}

		if ( $syllable_count === 0 ) {
			return 0;
		}

		$score = 206.835
			- 1.015 * ( $word_count / $sentence_count )
			- 84.6  * ( $syllable_count / $word_count );

		return (int) round( max( 0, min( 100, $score ) ) );
	}

	/**
	 * Builds a check entry from a precomputed Flesch score.
	 *
	 * Bands: ≥60 green (plain language), 30–59 orange (fairly difficult),
	 * <30 red (very difficult).
	 *
	 * @param int $score 0–100 Flesch reading ease.
	 * @return array{status:string,check:string,message:string}
	 */
	private static function flesch_check_from_score( int $score ): array {
		if ( $score >= 60 ) {
			$status = 'green';
		} elseif ( $score >= 30 ) {
			$status = 'orange';
		} else {
			$status = 'red';
		}

		$message = sprintf(
			/* translators: %d: Flesch reading ease score */
			__( 'Flesch reading ease: %d / 100 (60+ recommended).', 'livada-seo' ),
			$score
		);

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

	/**
	 * Estimates the syllable count of a word using a portable heuristic that
	 * works reasonably well for both English and Romance-language content
	 * (French, Spanish, Italian, etc.).
	 *
	 * Algorithm:
	 *   1. Lowercase, strip non-letters.
	 *   2. Count vowel groups (a, e, i, o, u, y, plus accented vowels).
	 *   3. Subtract 1 for trailing silent "e" (English heuristic — harmless on French).
	 *   4. Floor at 1 syllable per non-empty word.
	 *
	 * Pure heuristic; ±15 % accuracy is acceptable for the Flesch formula's purposes.
	 *
	 * @param string $word
	 * @return int
	 */
	private static function estimate_syllables( string $word ): int {
		$word = mb_strtolower( $word, 'UTF-8' );
		// Keep letters (incl. accented) only.
		$word = preg_replace( '/[^\p{L}]/u', '', $word ) ?? '';

		if ( '' === $word ) {
			return 0;
		}

		// Count vowel groups (consecutive vowels = one syllable).
		$count = preg_match_all( '/[aeiouyàâäéèêëîïôöùûüÿœæáíóúñ]+/u', $word );

		// English silent "e" — trim one syllable when word ends in "e" (but not "le").
		if ( $count > 1 && substr( $word, -1 ) === 'e' && substr( $word, -2 ) !== 'le' ) {
			$count--;
		}

		return max( 1, (int) $count );
	}

	/**
	 * Converts a keyword string into a URL-slug-compatible form for URL matching.
	 *
	 * Lowercases, replaces spaces/special chars with hyphens, and strips anything
	 * that would never appear in a WP slug.
	 *
	 * @param string $keyword
	 * @return string
	 */
	private static function slugify( string $keyword ): string {
		$keyword = self::normalize_for_matching( $keyword );
		$keyword = preg_replace( '/[^a-z0-9\-]+/', '-', $keyword ) ?? $keyword;
		$keyword = trim( $keyword, '-' );
		return $keyword;
	}

	/**
	 * Normalises a string for keyword matching: lowercase + accent stripping
	 * (NFD decomposition then removal of combining marks). Falls back to a
	 * static replacement table when the intl extension isn't available.
	 *
	 * Examples:
	 *   "Réparation PC à Castellane" → "reparation pc a castellane"
	 *   "Naïve façade"                → "naive facade"
	 *
	 * @param string $str
	 * @return string
	 */
	private static function normalize_for_matching( string $str ): string {
		$str = mb_strtolower( $str, 'UTF-8' );

		// Preferred path: PHP intl Normalizer.
		if ( class_exists( '\Normalizer' ) ) {
			$nfd = \Normalizer::normalize( $str, \Normalizer::FORM_D );
			if ( is_string( $nfd ) ) {
				// Strip combining diacritical marks (U+0300–U+036F).
				$str = preg_replace( '/\p{Mn}+/u', '', $nfd ) ?? $str;
				return $str;
			}
		}

		// Fallback table for hosts without intl.
		$map = [
			'à' => 'a', 'á' => 'a', 'â' => 'a', 'ä' => 'a', 'ã' => 'a', 'å' => 'a', 'ā' => 'a',
			'ç' => 'c', 'ć' => 'c', 'č' => 'c',
			'è' => 'e', 'é' => 'e', 'ê' => 'e', 'ë' => 'e', 'ē' => 'e', 'ė' => 'e', 'ę' => 'e',
			'ì' => 'i', 'í' => 'i', 'î' => 'i', 'ï' => 'i', 'ī' => 'i',
			'ñ' => 'n', 'ń' => 'n',
			'ò' => 'o', 'ó' => 'o', 'ô' => 'o', 'ö' => 'o', 'õ' => 'o', 'ø' => 'o', 'ō' => 'o',
			'ù' => 'u', 'ú' => 'u', 'û' => 'u', 'ü' => 'u', 'ū' => 'u',
			'ý' => 'y', 'ÿ' => 'y',
			'ß' => 'ss', 'œ' => 'oe', 'æ' => 'ae',
		];
		return strtr( $str, $map );
	}

	/**
	 * Splits a keyword phrase into significant normalised words (3+ chars,
	 * minus a small French/English stopword list) for word-by-word matching.
	 *
	 * @param string $keyword
	 * @return string[]
	 */
	private static function keyword_words( string $keyword ): array {
		$norm  = self::normalize_for_matching( $keyword );
		$parts = preg_split( '/[^a-z0-9]+/u', $norm, -1, PREG_SPLIT_NO_EMPTY );
		if ( ! is_array( $parts ) ) {
			return [];
		}

		$stop = [
			'le', 'la', 'les', 'de', 'du', 'des', 'un', 'une', 'et', 'ou', 'a',
			'au', 'aux', 'en', 'sur', 'pour', 'par', 'avec', 'sans', 'dans',
			'the', 'and', 'for', 'with', 'from', 'into',
		];
		return array_values( array_filter( $parts, static function ( $w ) use ( $stop ) {
			return mb_strlen( $w ) >= 2 && ! in_array( $w, $stop, true );
		} ) );
	}

	/**
	 * Returns true when the keyword matches the haystack. Tries three strategies
	 * in order, accepting the first that succeeds:
	 *
	 *   1. Exact (accent-insensitive, case-insensitive) substring of the full keyword
	 *   2. All significant words present (any order, any distance)
	 *   3. ≥ 80 % of significant words present (handles small typos/missing terms)
	 *
	 * Used by every check_keyword_in_* check so "réparation PC Castellane"
	 * matches "Réparation et maintenance PC à Castellane".
	 *
	 * @param string $haystack
	 * @param string $keyword
	 * @return bool
	 */
	private static function keyword_matches( string $haystack, string $keyword ): bool {
		if ( $haystack === '' || $keyword === '' ) {
			return false;
		}

		$h_norm = self::normalize_for_matching( $haystack );
		$k_norm = self::normalize_for_matching( $keyword );

		// 1. Exact (accent-insensitive, case-insensitive) match wins immediately.
		if ( $k_norm !== '' && strpos( $h_norm, $k_norm ) !== false ) {
			return true;
		}

		// 2/3. Word-by-word coverage.
		$words = self::keyword_words( $keyword );
		if ( empty( $words ) ) {
			return false;
		}

		$present = 0;
		foreach ( $words as $w ) {
			// Use word-boundary regex so "pc" doesn't match "spcial".
			$pattern = '/\b' . preg_quote( $w, '/' ) . '\b/u';
			if ( preg_match( $pattern, $h_norm ) ) {
				$present++;
			}
		}

		$total = count( $words );
		// Single-word keyword must be present.
		if ( $total === 1 ) {
			return $present === 1;
		}
		// Multi-word: green only when all words are present.
		return $present === $total;
	}
}
