<?php
/**
 * Livada SEO — Module Site Audit
 *
 * Crawls the site via pure DB introspection (no outbound HTTP) and grades
 * every published post + page across 8 per-post checks and 5 site-wide checks.
 * Results are cached in a transient with a 6-hour TTL so the audit page is
 * cheap to open repeatedly.
 *
 * Per-post checks:
 *   1. SEO title (presence + length 50–60)
 *   2. Meta description (presence + length 120–160)
 *   3. Focus keyword set
 *   4. Stored SEO score >= 50
 *   5. Stored Flesch reading-ease >= 30
 *   6. Featured image set
 *   7. At least one internal link in content
 *   8. Word count >= 300
 *
 * Site-wide checks:
 *   1. Sitemap rewrite rules current
 *   2. robots.txt contains a Sitemap: directive
 *   3. LocalBusiness schema has the minimum required fields
 *   4. No conflicting SEO plugin active
 *   5. No template CPT (jkit-*, elementor_library, …) in sitemap_post_types
 *
 * @package Livada_SEO
 */

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

class Livada_SEO_Site_Audit {

	/** Transient key holding the cached audit payload. */
	const CACHE_KEY = 'livada_seo_audit_results';

	/** Transient TTL in seconds (6 hours). */
	const CACHE_TTL = 21600;

	/** Daily WP-Cron event name. */
	const CRON_HOOK = 'livada_seo_cron_audit';

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

	public static function init(): void {
		// Schedule the daily cron the first time the module loads.
		add_action( 'init', [ self::class, 'maybe_schedule_cron' ] );

		// Cron callback runs the audit and refreshes the cache.
		add_action( self::CRON_HOOK, [ self::class, 'cron_run' ] );

		// AJAX: manual re-run from the admin page.
		add_action( 'wp_ajax_livada_seo_run_audit', [ self::class, 'ajax_run' ] );
	}

	/**
	 * Schedules the daily audit cron if it isn't already scheduled.
	 * Hooked on `init` so we don't fight with WP-Cron disabled environments.
	 */
	public static function maybe_schedule_cron(): void {
		if ( ! wp_next_scheduled( self::CRON_HOOK ) ) {
			wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', self::CRON_HOOK );
		}
	}

	/**
	 * WP-Cron callback. Runs the audit and discards the return value — the
	 * results are persisted in the transient cache for the admin page to read.
	 */
	public static function cron_run(): void {
		self::run_audit( true );
	}

	// ── Public API ────────────────────────────────────────────────────────────

	/**
	 * Returns the cached audit results, running the audit when the cache is
	 * empty. Pass true to `$force` to ignore the cache and re-run.
	 *
	 * @param bool $force
	 * @return array<string,mixed>
	 */
	public static function get_results( bool $force = false ): array {
		if ( ! $force ) {
			$cached = get_transient( self::CACHE_KEY );
			if ( is_array( $cached ) ) {
				return $cached;
			}
		}

		return self::run_audit( true );
	}

	/**
	 * Removes the cached results. Call this from save_post / settings updates
	 * if you want the next audit page open to show fresh data.
	 */
	public static function clear_cache(): void {
		delete_transient( self::CACHE_KEY );
	}

	/**
	 * Runs the full audit and (optionally) caches the results.
	 *
	 * @param  bool $cache When true, the result is written to the transient cache.
	 * @return array<string,mixed>
	 */
	public static function run_audit( bool $cache = true ): array {
		$started = microtime( true );

		$per_post  = self::run_per_post_checks();
		$site_wide = self::run_site_wide_checks();

		$summary = self::build_summary( $per_post, $site_wide );

		$payload = [
			'generated_at' => current_time( 'mysql' ),
			'duration_ms'  => (int) round( ( microtime( true ) - $started ) * 1000 ),
			'plugin_ver'   => defined( 'LIVADA_SEO_VERSION' ) ? LIVADA_SEO_VERSION : '0.0.0',
			'summary'      => $summary,
			'site_wide'    => $site_wide,
			'per_post'     => $per_post,
		];

		if ( $cache ) {
			set_transient( self::CACHE_KEY, $payload, self::CACHE_TTL );
		}

		if ( class_exists( 'Livada_SEO_Activity_Log' ) ) {
			Livada_SEO_Activity_Log::log(
				'audit_run',
				sprintf(
					'Audit: %d posts, %d issues',
					(int) ( $payload['summary']['total_posts']  ?? 0 ),
					(int) ( $payload['summary']['total_issues'] ?? 0 )
				),
				$payload['summary'] ?? null
			);
		}

		return $payload;
	}

	// ── Per-post audit ────────────────────────────────────────────────────────

	/**
	 * Crawls every published post in the configured sitemap post types and
	 * runs the 8 per-post checks.
	 *
	 * Single WP_Query, no per-row sub-queries — every check reads from the
	 * post object or already-loaded post meta.
	 *
	 * @return array<int,array<string,mixed>>
	 */
	private static function run_per_post_checks(): array {
		$post_types = self::get_audit_post_types();

		if ( empty( $post_types ) ) {
			return [];
		}

		$query = new WP_Query( [
			'post_type'              => $post_types,
			'post_status'            => 'publish',
			'posts_per_page'         => 500,
			'orderby'                => 'modified',
			'order'                  => 'DESC',
			'no_found_rows'          => true,
			'update_post_term_cache' => false,
			'update_post_meta_cache' => true,
		] );

		$results = [];

		foreach ( $query->posts as $post ) {
			$results[] = self::audit_post( $post );
		}

		wp_reset_postdata();

		return $results;
	}

	/**
	 * Runs every per-post check against a single WP_Post. Returns a structured
	 * row with the post identity, the global status, and per-check details.
	 *
	 * @param WP_Post $post
	 * @return array<string,mixed>
	 */
	private static function audit_post( WP_Post $post ): array {
		$post_id = (int) $post->ID;

		$title       = (string) get_post_meta( $post_id, '_livada_seo_title',         true );
		$desc        = (string) get_post_meta( $post_id, '_livada_seo_description',   true );
		$keyword     = (string) get_post_meta( $post_id, '_livada_seo_focus_keyword', true );
		$seo_score   = get_post_meta( $post_id, '_livada_seo_score',                  true );
		$flesch      = get_post_meta( $post_id, '_livada_seo_readability_score',     true );

		$checks   = [];
		$checks[] = self::check_seo_title( $title );
		$checks[] = self::check_meta_description( $desc );
		$checks[] = self::check_focus_keyword( $keyword );
		$checks[] = self::check_seo_score( $seo_score );
		$checks[] = self::check_flesch( $flesch );
		$checks[] = self::check_featured_image( $post_id );
		$checks[] = self::check_internal_links( $post );
		$checks[] = self::check_broken_internal_links( $post );
		$checks[] = self::check_word_count( $post );

		return [
			'post_id'  => $post_id,
			'title'    => get_the_title( $post ),
			'edit_url' => (string) get_edit_post_link( $post_id, 'raw' ),
			'view_url' => (string) get_permalink( $post_id ),
			'status'   => self::worst_status( $checks ),
			'checks'   => $checks,
		];
	}

	// ── Per-post check implementations ────────────────────────────────────────

	private static function check_seo_title( string $title ): array {
		$len = mb_strlen( $title );

		if ( '' === $title ) {
			return self::check( 'seo_title', 'red', __( 'No SEO title set.', 'livada-seo' ) );
		}
		if ( $len >= 50 && $len <= 60 ) {
			return self::check( 'seo_title', 'green', sprintf( __( 'SEO title length is ideal (%d).', 'livada-seo' ), $len ) );
		}
		if ( $len >= 40 && $len <= 70 ) {
			return self::check( 'seo_title', 'orange', sprintf( __( 'SEO title length is acceptable (%d). Aim for 50–60.', 'livada-seo' ), $len ) );
		}
		return self::check( 'seo_title', 'red', sprintf( __( 'SEO title length is out of range (%d). Aim for 50–60.', 'livada-seo' ), $len ) );
	}

	private static function check_meta_description( string $desc ): array {
		$len = mb_strlen( $desc );

		if ( '' === $desc ) {
			return self::check( 'meta_description', 'red', __( 'No meta description set.', 'livada-seo' ) );
		}
		if ( $len >= 120 && $len <= 160 ) {
			return self::check( 'meta_description', 'green', sprintf( __( 'Description length is ideal (%d).', 'livada-seo' ), $len ) );
		}
		if ( $len >= 100 && $len <= 180 ) {
			return self::check( 'meta_description', 'orange', sprintf( __( 'Description length is acceptable (%d). Aim for 120–160.', 'livada-seo' ), $len ) );
		}
		return self::check( 'meta_description', 'red', sprintf( __( 'Description length is out of range (%d). Aim for 120–160.', 'livada-seo' ), $len ) );
	}

	private static function check_focus_keyword( string $keyword ): array {
		if ( '' === $keyword ) {
			return self::check( 'focus_keyword', 'orange', __( 'No focus keyword set.', 'livada-seo' ) );
		}
		return self::check( 'focus_keyword', 'green', sprintf( __( 'Focus keyword: %s', 'livada-seo' ), $keyword ) );
	}

	private static function check_seo_score( $raw ): array {
		if ( '' === $raw || false === $raw ) {
			return self::check( 'seo_score', 'orange', __( 'SEO analysis has not run yet.', 'livada-seo' ) );
		}
		$score = (int) $raw;
		if ( $score >= 67 ) {
			return self::check( 'seo_score', 'green', sprintf( __( 'SEO score: %d / 100.', 'livada-seo' ), $score ) );
		}
		if ( $score >= 50 ) {
			return self::check( 'seo_score', 'orange', sprintf( __( 'SEO score: %d / 100. Aim for 67+.', 'livada-seo' ), $score ) );
		}
		return self::check( 'seo_score', 'red', sprintf( __( 'SEO score: %d / 100.', 'livada-seo' ), $score ) );
	}

	private static function check_flesch( $raw ): array {
		if ( '' === $raw || false === $raw ) {
			return self::check( 'flesch', 'orange', __( 'Readability has not been computed yet.', 'livada-seo' ) );
		}
		$score = (int) $raw;
		if ( $score >= 60 ) {
			return self::check( 'flesch', 'green', sprintf( __( 'Readability: %d (plain language).', 'livada-seo' ), $score ) );
		}
		if ( $score >= 30 ) {
			return self::check( 'flesch', 'orange', sprintf( __( 'Readability: %d (fairly difficult).', 'livada-seo' ), $score ) );
		}
		return self::check( 'flesch', 'red', sprintf( __( 'Readability: %d (very difficult).', 'livada-seo' ), $score ) );
	}

	private static function check_featured_image( int $post_id ): array {
		if ( has_post_thumbnail( $post_id ) ) {
			return self::check( 'featured_image', 'green', __( 'Featured image is set.', 'livada-seo' ) );
		}
		return self::check( 'featured_image', 'red', __( 'No featured image.', 'livada-seo' ) );
	}

	private static function check_internal_links( WP_Post $post ): array {
		$content = (string) $post->post_content;

		if ( '' === $content ) {
			return self::check( 'internal_links', 'orange', __( 'No content to analyse for internal links.', 'livada-seo' ) );
		}

		$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 ( '' === $href ) {
					continue;
				}

				// 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 >= 1 ) {
			return self::check( 'internal_links', 'green', sprintf( __( 'Found %d internal link(s).', 'livada-seo' ), $count ) );
		}
		return self::check( 'internal_links', 'red', __( 'No internal links found.', 'livada-seo' ) );
	}

	/**
	 * Scans every internal `<a href>` in the post content and checks the
	 * target resolves to a published post. Pure DB introspection via
	 * `url_to_postid()` — no outbound HTTP, no perf hit.
	 *
	 * Status:
	 *   green  — every internal link resolves to a published post (or no internal links at all)
	 *   orange — 1 broken link
	 *   red    — 2+ broken links
	 */
	private static function check_broken_internal_links( WP_Post $post ): array {
		$content = (string) $post->post_content;

		if ( '' === $content ) {
			return self::check( 'broken_internal_links', 'green', __( 'No content to scan.', 'livada-seo' ) );
		}

		$site_host = (string) wp_parse_url( home_url(), PHP_URL_HOST );
		$broken    = [];
		$scanned   = 0;

		if ( preg_match_all( '/<a\s[^>]*href=["\']([^"\']+)["\'][^>]*>/i', $content, $matches ) ) {
			foreach ( $matches[1] as $href ) {
				$href = trim( $href );
				if ( '' === $href || strpos( $href, '#' ) === 0 || strpos( $href, 'mailto:' ) === 0 || strpos( $href, 'tel:' ) === 0 ) {
					continue;
				}

				// Filter to internal links only.
				$is_internal = false;
				if ( strpos( $href, '//' ) === false && strpos( $href, 'http' ) !== 0 ) {
					$is_internal = true; // relative URL
				} else {
					$link_host = (string) wp_parse_url( $href, PHP_URL_HOST );
					if ( '' !== $link_host && $link_host === $site_host ) {
						$is_internal = true;
					}
				}

				if ( ! $is_internal ) {
					continue;
				}

				$scanned++;

				// Strip query/fragment and resolve to a post ID. url_to_postid
				// returns 0 when no post matches — that's our broken signal.
				$check_url = strtok( $href, '#?' );
				$post_match = url_to_postid( $check_url );

				if ( 0 === (int) $post_match ) {
					// url_to_postid can fail on category/tag/term URLs; check
					// whether the path exists at all by sniffing for a term.
					$path = (string) wp_parse_url( $check_url, PHP_URL_PATH );
					if ( '' !== $path && '/' !== $path && ! self::path_resolves_to_term( $path ) ) {
						$broken[] = $check_url;
					}
					continue;
				}

				// Make sure the resolved post is published (not trashed/draft).
				$status = get_post_status( $post_match );
				if ( 'publish' !== $status ) {
					$broken[] = $check_url;
				}
			}
		}

		if ( 0 === $scanned ) {
			return self::check( 'broken_internal_links', 'green', __( 'No internal links to verify.', 'livada-seo' ) );
		}

		$count = count( $broken );

		if ( 0 === $count ) {
			return self::check(
				'broken_internal_links',
				'green',
				sprintf(
					/* translators: %d: number of internal links scanned */
					_n( '%d internal link verified.', '%d internal links verified.', $scanned, 'livada-seo' ),
					$scanned
				)
			);
		}

		// Truncate the broken-link list in the message to the first 3 to keep
		// the audit table readable; the full list is in the post anyway.
		$preview = array_slice( $broken, 0, 3 );
		$message = sprintf(
			/* translators: 1: number of broken links, 2: total scanned, 3: comma-separated examples */
			__( '%1$d / %2$d internal link(s) broken: %3$s', 'livada-seo' ),
			$count,
			$scanned,
			implode( ', ', $preview ) . ( $count > 3 ? '…' : '' )
		);

		return self::check( 'broken_internal_links', $count >= 2 ? 'red' : 'orange', $message );
	}

	/**
	 * Returns true when the given URL path resolves to an existing public
	 * taxonomy term (category, tag, custom taxonomy). Used to avoid flagging
	 * legitimate category/tag links as broken when `url_to_postid()` returns 0.
	 */
	private static function path_resolves_to_term( string $path ): bool {
		// Cheap heuristic: try every public taxonomy until one matches the slug.
		$slug = trim( $path, '/' );
		$last = basename( $slug );

		if ( '' === $last ) {
			return false;
		}

		$taxes = get_taxonomies( [ 'public' => true ] );
		foreach ( $taxes as $tax ) {
			$term = get_term_by( 'slug', $last, $tax );
			if ( $term ) {
				return true;
			}
		}
		return false;
	}

	private static function check_word_count( WP_Post $post ): array {
		$plain = wp_strip_all_tags( (string) $post->post_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 ) {
			return self::check( 'word_count', 'green', sprintf( __( '%d words.', 'livada-seo' ), $count ) );
		}
		if ( $count >= 300 ) {
			return self::check( 'word_count', 'orange', sprintf( __( '%d words. Aim for 800+.', 'livada-seo' ), $count ) );
		}
		return self::check( 'word_count', 'red', sprintf( __( '%d words. Below the 300 minimum.', 'livada-seo' ), $count ) );
	}

	// ── Site-wide checks ──────────────────────────────────────────────────────

	/**
	 * @return array<int,array<string,mixed>>
	 */
	private static function run_site_wide_checks(): array {
		return [
			self::check_sitemap_health(),
			self::check_robots_directive(),
			self::check_localbusiness_minimum(),
			self::check_seo_plugin_conflicts(),
			self::check_template_cpt_in_sitemap(),
			self::check_redirect_chains(),
		];
	}

	private static function check_sitemap_health(): array {
		$stored  = (string) get_option( 'livada_seo_sitemap_rewrites_version', '' );
		$current = defined( 'LIVADA_SEO_VERSION' ) ? LIVADA_SEO_VERSION : '';

		if ( '' === $stored ) {
			return self::check( 'sitemap_health', 'orange', __( 'Sitemap rewrite rules have not been registered yet. Visit any admin page to trigger the bootstrap.', 'livada-seo' ) );
		}

		if ( $stored !== $current ) {
			return self::check( 'sitemap_health', 'orange', sprintf( __( 'Sitemap rewrite rules are stale (stored %s, plugin %s). Re-save permalinks.', 'livada-seo' ), $stored, $current ) );
		}

		return self::check( 'sitemap_health', 'green', __( 'Sitemap rewrite rules are current.', 'livada-seo' ) );
	}

	private static function check_robots_directive(): array {
		$robots = (string) Livada_SEO_Loader::get_setting( 'robots_txt', '' );

		if ( '' === $robots ) {
			return self::check( 'robots_directive', 'red', __( 'robots.txt setting is empty.', 'livada-seo' ) );
		}
		if ( false === stripos( $robots, 'sitemap:' ) ) {
			return self::check( 'robots_directive', 'red', __( 'robots.txt does not advertise the sitemap. Add a Sitemap: directive.', 'livada-seo' ) );
		}
		return self::check( 'robots_directive', 'green', __( 'robots.txt advertises the sitemap.', 'livada-seo' ) );
	}

	private static function check_localbusiness_minimum(): array {
		$name    = (string) Livada_SEO_Loader::get_setting( 'schema_name', '' );
		$address = (string) Livada_SEO_Loader::get_setting( 'schema_address', '' );
		$city    = (string) Livada_SEO_Loader::get_setting( 'schema_city', '' );

		$missing = [];
		if ( '' === $name )    { $missing[] = 'name'; }
		if ( '' === $address ) { $missing[] = 'address'; }
		if ( '' === $city )    { $missing[] = 'city'; }

		if ( ! empty( $missing ) ) {
			return self::check(
				'localbusiness_minimum',
				'red',
				sprintf(
					/* translators: %s: comma-separated list of missing fields */
					__( 'LocalBusiness schema is missing: %s.', 'livada-seo' ),
					implode( ', ', $missing )
				)
			);
		}

		return self::check( 'localbusiness_minimum', 'green', __( 'LocalBusiness schema has the minimum required fields.', 'livada-seo' ) );
	}

	private static function check_seo_plugin_conflicts(): array {
		if ( ! function_exists( 'is_plugin_active' ) ) {
			require_once ABSPATH . 'wp-admin/includes/plugin.php';
		}

		$conflicts = [];
		if ( is_plugin_active( 'wp-seopress/seopress.php' ) ) {
			$conflicts[] = 'SEOPress';
		}
		if ( is_plugin_active( 'wordpress-seo/wp-seo.php' ) ) {
			$conflicts[] = 'Yoast SEO';
		}
		if ( is_plugin_active( 'wordpress-seo-premium/wp-seo-premium.php' ) ) {
			$conflicts[] = 'Yoast SEO Premium';
		}
		if ( is_plugin_active( 'seo-by-rank-math/rank-math.php' ) ) {
			$conflicts[] = 'Rank Math SEO';
		}
		if ( is_plugin_active( 'all-in-one-seo-pack/all_in_one_seo_pack.php' ) ) {
			$conflicts[] = 'All in One SEO';
		}

		if ( ! empty( $conflicts ) ) {
			return self::check(
				'seo_plugin_conflicts',
				'red',
				sprintf(
					/* translators: %s: comma-separated plugin names */
					__( 'Conflicting SEO plugin(s) active: %s.', 'livada-seo' ),
					implode( ', ', $conflicts )
				)
			);
		}

		return self::check( 'seo_plugin_conflicts', 'green', __( 'No conflicting SEO plugin detected.', 'livada-seo' ) );
	}

	/**
	 * Walks the redirects table and flags two anti-patterns:
	 *   - Loops:  /a → /b → /a (or longer cycle)
	 *   - Chains: /a → /b → /c (an internal target is itself a redirect source)
	 *
	 * Both hurt crawl budget and Core Web Vitals because each hop adds a
	 * round-trip. Pure DB introspection — no outbound HTTP.
	 */
	private static function check_redirect_chains(): array {
		if ( ! class_exists( 'Livada_SEO_Redirects' ) ) {
			return self::check( 'redirect_chains', 'green', __( 'Redirects module not active.', 'livada-seo' ) );
		}

		global $wpdb;
		$table = $wpdb->prefix . 'livada_seo_redirects';

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery
		$rows = $wpdb->get_results( "SELECT id, source_url, target_url FROM {$table}", ARRAY_A );
		if ( empty( $rows ) ) {
			return self::check( 'redirect_chains', 'green', __( 'No redirects configured.', 'livada-seo' ) );
		}

		// Build a quick map source → target keyed by lowercase source.
		$sources = [];
		foreach ( $rows as $r ) {
			$sources[ strtolower( (string) $r['source_url'] ) ] = (string) $r['target_url'];
		}

		$site_host = (string) wp_parse_url( home_url(), PHP_URL_HOST );

		$loops  = [];
		$chains = [];

		foreach ( $rows as $r ) {
			$source = (string) $r['source_url'];
			$target = (string) $r['target_url'];

			// Convert absolute internal URLs to paths for the lookup.
			$path = self::redirect_path_part( $target, $site_host );
			if ( '' === $path ) {
				continue; // External target — can't have a chain on our side.
			}

			// Walk the chain at most 5 hops.
			$visited = [ strtolower( $source ) ];
			$cur     = strtolower( $path );

			for ( $i = 0; $i < 5; $i++ ) {
				if ( ! isset( $sources[ $cur ] ) ) {
					break; // End of chain — fine.
				}
				if ( in_array( $cur, $visited, true ) ) {
					$loops[] = $source . ' → … → ' . $cur;
					break;
				}
				$visited[] = $cur;

				if ( $i === 0 ) {
					// One hop already counts as a chain (target is itself a redirect).
					$chains[] = $source . ' → ' . $cur;
				}

				$next      = $sources[ $cur ];
				$next_path = self::redirect_path_part( $next, $site_host );
				if ( '' === $next_path ) {
					break;
				}
				$cur = strtolower( $next_path );
			}
		}

		$loops  = array_values( array_unique( $loops ) );
		$chains = array_values( array_unique( $chains ) );

		if ( ! empty( $loops ) ) {
			return self::check(
				'redirect_chains',
				'red',
				sprintf(
					/* translators: 1: count, 2: example list */
					__( '%1$d redirect loop(s) detected: %2$s', 'livada-seo' ),
					count( $loops ),
					implode( ', ', array_slice( $loops, 0, 3 ) ) . ( count( $loops ) > 3 ? '…' : '' )
				)
			);
		}

		if ( ! empty( $chains ) ) {
			return self::check(
				'redirect_chains',
				'orange',
				sprintf(
					/* translators: 1: count, 2: example list */
					__( '%1$d redirect chain(s) detected (target is itself a redirect): %2$s', 'livada-seo' ),
					count( $chains ),
					implode( ', ', array_slice( $chains, 0, 3 ) ) . ( count( $chains ) > 3 ? '…' : '' )
				)
			);
		}

		return self::check(
			'redirect_chains',
			'green',
			sprintf(
				/* translators: %d: redirect count scanned */
				_n( '%d redirect verified — no chains or loops.', '%d redirects verified — no chains or loops.', count( $rows ), 'livada-seo' ),
				count( $rows )
			)
		);
	}

	/**
	 * Returns the path part of a redirect target if it's internal, or '' if
	 * the target is external.
	 */
	private static function redirect_path_part( string $url, string $site_host ): string {
		$url = trim( $url );
		if ( '' === $url ) {
			return '';
		}

		// Relative URL → path is the URL itself.
		if ( strpos( $url, '//' ) === false && strpos( $url, 'http' ) !== 0 ) {
			return '/' . ltrim( $url, '/' );
		}

		$host = (string) wp_parse_url( $url, PHP_URL_HOST );
		if ( $host !== $site_host ) {
			return '';
		}

		$path = (string) wp_parse_url( $url, PHP_URL_PATH );
		return '' === $path ? '/' : $path;
	}

	private static function check_template_cpt_in_sitemap(): array {
		if ( ! class_exists( 'Livada_SEO_Sitemap' ) ) {
			return self::check( 'template_cpt_in_sitemap', 'orange', __( 'Sitemap module not active.', 'livada-seo' ) );
		}

		$configured = Livada_SEO_Loader::get_setting( 'sitemap_post_types', [ 'post', 'page' ] );
		$configured = is_array( $configured ) ? $configured : [];
		$deny       = Livada_SEO_Sitemap::get_template_cpts();
		$leaks      = array_values( array_intersect( $configured, $deny ) );

		if ( ! empty( $leaks ) ) {
			return self::check(
				'template_cpt_in_sitemap',
				'orange',
				sprintf(
					/* translators: %s: comma-separated post type slugs */
					__( 'Template CPTs are listed in sitemap_post_types but are auto-stripped: %s. Remove them from settings to clean up.', 'livada-seo' ),
					implode( ', ', $leaks )
				)
			);
		}

		return self::check( 'template_cpt_in_sitemap', 'green', __( 'No template CPT leak in the sitemap configuration.', 'livada-seo' ) );
	}

	// ── Helpers ───────────────────────────────────────────────────────────────

	/**
	 * Builds the summary section of the audit payload (counts + average score).
	 *
	 * @param array<int,array<string,mixed>> $per_post
	 * @param array<int,array<string,mixed>> $site_wide
	 * @return array<string,mixed>
	 */
	private static function build_summary( array $per_post, array $site_wide ): array {
		$totals = [ 'green' => 0, 'orange' => 0, 'red' => 0 ];

		foreach ( $per_post as $row ) {
			$status = isset( $row['status'] ) ? (string) $row['status'] : 'orange';
			if ( ! isset( $totals[ $status ] ) ) {
				$status = 'orange';
			}
			$totals[ $status ]++;
		}

		$site_totals = [ 'green' => 0, 'orange' => 0, 'red' => 0 ];
		foreach ( $site_wide as $check ) {
			$status = isset( $check['status'] ) ? (string) $check['status'] : 'orange';
			if ( ! isset( $site_totals[ $status ] ) ) {
				$status = 'orange';
			}
			$site_totals[ $status ]++;
		}

		$total_posts = count( $per_post );
		$issues      = $totals['red'] + $totals['orange'] + $site_totals['red'] + $site_totals['orange'];

		return [
			'total_posts'       => $total_posts,
			'posts_green'       => $totals['green'],
			'posts_orange'      => $totals['orange'],
			'posts_red'         => $totals['red'],
			'site_green'        => $site_totals['green'],
			'site_orange'       => $site_totals['orange'],
			'site_red'          => $site_totals['red'],
			'total_issues'      => (int) $issues,
		];
	}

	/**
	 * Returns the worst (red > orange > green) status across a list of checks.
	 *
	 * @param array<int,array<string,mixed>> $checks
	 * @return string
	 */
	private static function worst_status( array $checks ): string {
		$has_red    = false;
		$has_orange = false;

		foreach ( $checks as $c ) {
			$s = isset( $c['status'] ) ? (string) $c['status'] : 'orange';
			if ( 'red' === $s ) {
				$has_red = true;
				break;
			}
			if ( 'orange' === $s ) {
				$has_orange = true;
			}
		}

		if ( $has_red ) {
			return 'red';
		}
		if ( $has_orange ) {
			return 'orange';
		}
		return 'green';
	}

	/**
	 * Single-line factory for a check result row.
	 */
	private static function check( string $key, string $status, string $message ): array {
		$allowed = [ 'green', 'orange', 'red' ];
		if ( ! in_array( $status, $allowed, true ) ) {
			$status = 'orange';
		}

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

	/**
	 * Post types covered by the per-post audit. Defaults to the configured
	 * sitemap post types, minus the deny-list, intersected with public types
	 * registered on the site.
	 *
	 * @return string[]
	 */
	private static function get_audit_post_types(): array {
		$configured = (array) Livada_SEO_Loader::get_setting( 'sitemap_post_types', [ 'post', 'page' ] );

		$deny = class_exists( 'Livada_SEO_Sitemap' )
			? Livada_SEO_Sitemap::get_template_cpts()
			: [];

		$candidate = array_values( array_diff( $configured, $deny ) );

		// Keep only post types actually registered on this site.
		$registered = get_post_types( [ 'public' => true ] );

		return array_values( array_intersect( $candidate, $registered ) );
	}

	// ── AJAX entry point ──────────────────────────────────────────────────────

	public static function ajax_run(): void {
		check_ajax_referer( 'livada_seo_admin_nonce', 'nonce' );

		if ( ! current_user_can( 'manage_options' ) ) {
			wp_send_json_error( [ 'message' => __( 'Insufficient permissions.', 'livada-seo' ) ], 403 );
		}

		$results = self::run_audit( true );
		wp_send_json_success( $results );
	}
}
