<?php
/**
 * Livada SEO — Module Sitemap XML
 *
 * Generates and serves XML sitemaps:
 *   sitemap.xml         → sitemap index listing all sub-sitemaps
 *   sitemap-post.xml    → posts sub-sitemap
 *   sitemap-page.xml    → pages sub-sitemap
 *   sitemap-{tax}.xml   → taxonomy sub-sitemap
 *
 * Rewrite rules intercept the requests before WordPress loads a theme
 * template; output is sent directly and execution halted.
 *
 * Each sub-sitemap is cached in a transient for 12 hours.  Relevant
 * transients are invalidated on save_post, delete_post, created_term,
 * and delete_term.
 *
 * @package Livada_SEO
 */

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

class Livada_SEO_Sitemap {

	// ── Constants ─────────────────────────────────────────────────────────────

	/** Transient TTL in seconds (12 hours). */
	const CACHE_TTL = 43200;

	/** Shorter TTL for the news sub-sitemap — Google News expects ≤ 2-day-old content. */
	const NEWS_CACHE_TTL = HOUR_IN_SECONDS;

	/** Window (in seconds) of posts surfaced in the news sub-sitemap (48 hours). */
	const NEWS_WINDOW = 172800;

	/** Transient key prefix. */
	const CACHE_PREFIX = 'livada_seo_sitemap_';

	/** Query var used to route sitemap requests. */
	const QUERY_VAR = 'livada_sitemap';

	/**
	 * Post-type slugs that are public for technical reasons but hold templates,
	 * partials or layout fragments — never real content. They are silently
	 * stripped from the sitemap index even if the user enabled them by mistake.
	 *
	 * Site owners can append more slugs via the `livada_seo_sitemap_template_cpts`
	 * filter, or remove an entry by returning a filtered array.
	 */
	const TEMPLATE_CPTS = [
		'elementor_library',     // Elementor templates / popups / theme parts
		'e-landing-page',        // Elementor landing pages (pre-publish)
		'jet-engine',            // JetEngine listings/posts (Crocoblock)
		'jkit-header',           // ElementsKit / JetEngine header templates
		'jkit-footer',           // ElementsKit / JetEngine footer templates
		'jet-smart-filters',     // JetSmartFilters templates
		'jet-theme-core',        // JetThemeCore templates
		'oceanwp_library',       // OceanWP templates
		'astra-advanced-hook',   // Astra hook content
		'cartflows_step',        // CartFlows funnel steps
		'cartflows_flow',        // CartFlows funnels
		'fl-builder-template',   // Beaver Builder templates
		'wpbakery_layout',       // WPBakery saved templates
		'tcb_lightbox',          // Thrive Architect lightboxes
		'brizy-template',        // Brizy templates
	];

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

	/**
	 * Registers all hooks for this module.
	 * Called once by Livada_SEO_Loader::init().
	 */
	public static function init(): void {
		// Rewrite rules — must fire on `init` before WP processes the request.
		add_action( 'init', [ __CLASS__, 'add_rewrite_rules' ] );

		// Register the custom query var so WP doesn't strip it.
		add_filter( 'query_vars', [ __CLASS__, 'register_query_var' ] );

		// Intercept matched sitemap requests before any template is loaded.
		add_action( 'template_redirect', [ __CLASS__, 'handle_request' ] );

		// CRITICAL : Block WordPress canonical redirect on sitemap URLs.
		// Without this, /sitemap.xml gets 301-redirected to /sitemap.xml/
		// which Google rejects as a sitemap submission error.
		add_filter( 'redirect_canonical', [ __CLASS__, 'block_sitemap_canonical_redirect' ], 10, 2 );

		// Auto-flush rewrite rules when plugin version changes (fix sitemap 404 on first install).
		add_action( 'wp_loaded', [ __CLASS__, 'maybe_flush_rewrites' ] );

		// Cache invalidation hooks.
		add_action( 'save_post',     [ __CLASS__, 'on_save_post'    ], 10, 2 );
		add_action( 'delete_post',   [ __CLASS__, 'on_delete_post'  ], 10, 1 );
		add_action( 'created_term',  [ __CLASS__, 'on_term_change'  ], 10, 3 );
		add_action( 'delete_term',   [ __CLASS__, 'on_term_change'  ], 10, 3 );

		// Invalidate when sitemap_exclude meta changes via REST/AJAX/CLI without
		// triggering a full save_post. The transient is otherwise stale until the
		// post is edited again.
		add_action( 'updated_post_meta', [ __CLASS__, 'on_seo_meta_change' ], 10, 4 );
		add_action( 'added_post_meta',   [ __CLASS__, 'on_seo_meta_change' ], 10, 4 );
		add_action( 'deleted_post_meta', [ __CLASS__, 'on_seo_meta_change' ], 10, 4 );
	}

	/**
	 * Invalidates the sitemap transient when a post meta key that affects
	 * sitemap inclusion changes outside the regular save_post flow.
	 *
	 * @param int    $meta_id    Meta ID (unused).
	 * @param int    $object_id  Post ID.
	 * @param string $meta_key   Meta key that changed.
	 * @param mixed  $meta_value New value (unused).
	 */
	public static function on_seo_meta_change( $meta_id, $object_id, $meta_key, $meta_value ): void {
		if ( ! in_array( $meta_key, [ '_livada_seo_sitemap_exclude', '_livada_seo_noindex' ], true ) ) {
			return;
		}

		$post_type  = get_post_type( (int) $object_id );
		$post_types = self::get_post_types();

		if ( $post_type && in_array( $post_type, $post_types, true ) ) {
			delete_transient( self::CACHE_PREFIX . $post_type );
		}
	}

	/**
	 * Blocks WordPress canonical redirect on sitemap URLs to prevent 301
	 * redirects on /sitemap.xml -> /sitemap.xml/ that Google Search Console
	 * rejects as an error.
	 *
	 * @param  string $redirect_url  The URL WordPress wants to redirect to.
	 * @param  string $requested_url The original requested URL.
	 * @return string|false False to disable the redirect, $redirect_url otherwise.
	 */
	public static function block_sitemap_canonical_redirect( $redirect_url, $requested_url ) {
		if ( preg_match( '#/sitemap[a-z0-9_-]*\.xml/?$#i', (string) $requested_url ) ) {
			return false;
		}
		return $redirect_url;
	}

	/**
	 * Auto-flush rewrite rules when plugin version changes.
	 * Tracks last flushed version in `livada_seo_sitemap_rewrites_version` option
	 * to avoid re-flushing on every request.
	 */
	public static function maybe_flush_rewrites(): void {
		$stored = (string) get_option( 'livada_seo_sitemap_rewrites_version', '' );

		if ( $stored !== LIVADA_SEO_VERSION ) {
			self::add_rewrite_rules();
			flush_rewrite_rules( false );
			update_option( 'livada_seo_sitemap_rewrites_version', LIVADA_SEO_VERSION, false );
		}
	}

	// ── Rewrite rules ─────────────────────────────────────────────────────────

	/**
	 * Adds custom rewrite rules for the sitemap index and sub-sitemaps.
	 *
	 * Both rules are added at the 'top' position so they take priority over
	 * any WordPress or theme rules.
	 */
	public static function add_rewrite_rules(): void {
		// sitemap.xml → index
		add_rewrite_rule(
			'sitemap\.xml$',
			'index.php?' . self::QUERY_VAR . '=index',
			'top'
		);

		// sitemap-{type}.xml → specific sub-sitemap (allows hyphens in slugs).
		add_rewrite_rule(
			'sitemap-([a-z0-9_-]+)\.xml$',
			'index.php?' . self::QUERY_VAR . '=$matches[1]',
			'top'
		);
	}

	/**
	 * Adds the livada_sitemap query var to WordPress's allowed list.
	 *
	 * @param  string[] $vars Existing query vars.
	 * @return string[]
	 */
	public static function register_query_var( array $vars ): array {
		$vars[] = self::QUERY_VAR;
		return $vars;
	}

	// ── Request handler ───────────────────────────────────────────────────────

	/**
	 * Intercepts sitemap requests on `template_redirect`.
	 *
	 * When the livada_sitemap query var is set, sends the appropriate XML
	 * response and exits — no theme template is loaded.
	 */
	public static function handle_request(): void {
		$type = get_query_var( self::QUERY_VAR, '' );

		if ( $type === '' ) {
			return;
		}

		// Disable object cache buffering / page caching plugins where possible.
		if ( ! defined( 'DONOTCACHEPAGE' ) ) {
			define( 'DONOTCACHEPAGE', true );
		}

		header( 'Content-Type: application/xml; charset=UTF-8' );
		header( 'X-Robots-Tag: noindex, follow' );

		if ( $type === 'index' ) {
			self::render_index();
		} elseif ( $type === 'news' ) {
			self::render_news_sitemap();
		} else {
			self::render_sitemap( $type );
		}

		exit;
	}

	// ── Sitemap index ─────────────────────────────────────────────────────────

	/**
	 * Outputs the sitemap index XML listing every enabled sub-sitemap.
	 *
	 * Sub-sitemaps are derived from:
	 *   settings `sitemap_post_types`  → sitemap-{post_type}.xml
	 *   settings `sitemap_taxonomies`  → sitemap-{taxonomy}.xml
	 *
	 * Each <sitemap> entry includes a <lastmod> derived from the most recent
	 * post/term modification date in that sub-sitemap's dataset.
	 */
	public static function render_index(): void {
		$post_types = self::get_post_types();
		$taxonomies = self::get_taxonomies();

		echo '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
		echo '<?xml-stylesheet type="text/xsl" href="' . esc_url( LIVADA_SEO_URL . 'assets/sitemap.xsl' ) . '"?>' . "\n";
		echo '<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' . "\n";

		// ── Post-type sub-sitemaps ────────────────────────────────────────────

		foreach ( $post_types as $post_type ) {
			if ( ! post_type_exists( $post_type ) ) {
				continue;
			}

			$lastmod = self::get_last_modified_post( $post_type );
			$url     = home_url( '/sitemap-' . $post_type . '.xml' );

			echo "\t<sitemap>\n";
			echo "\t\t<loc>" . esc_url( $url ) . "</loc>\n";

			if ( $lastmod ) {
				echo "\t\t<lastmod>" . esc_xml( $lastmod ) . "</lastmod>\n";
			}

			echo "\t</sitemap>\n";
		}

		// ── Taxonomy sub-sitemaps ─────────────────────────────────────────────

		foreach ( $taxonomies as $taxonomy ) {
			if ( ! taxonomy_exists( $taxonomy ) ) {
				continue;
			}

			$lastmod = self::get_last_modified_term( $taxonomy );
			$url     = home_url( '/sitemap-' . $taxonomy . '.xml' );

			echo "\t<sitemap>\n";
			echo "\t\t<loc>" . esc_url( $url ) . "</loc>\n";

			if ( $lastmod ) {
				echo "\t\t<lastmod>" . esc_xml( $lastmod ) . "</lastmod>\n";
			}

			echo "\t</sitemap>\n";
		}

		// ── News sub-sitemap entry ────────────────────────────────────────────
		// Listed only when the news module is enabled AND at least one post
		// matches the 48-hour window — a stale news sitemap with no items is
		// rejected by Google.
		if ( self::news_has_recent_items() ) {
			$news_url = home_url( '/sitemap-news.xml' );
			echo "\t<sitemap>\n";
			echo "\t\t<loc>" . esc_url( $news_url ) . "</loc>\n";
			echo "\t\t<lastmod>" . esc_xml( gmdate( 'Y-m-d\TH:i:sP' ) ) . "</lastmod>\n";
			echo "\t</sitemap>\n";
		}

		echo '</sitemapindex>';
	}

	// ── News sub-sitemap ──────────────────────────────────────────────────────

	/**
	 * Outputs the Google News compliant sub-sitemap. Posts are limited to the
	 * last 48 hours and to the post types listed in `sitemap_news_post_types`
	 * (defaults to `post`). The sub-sitemap is cached separately with a short
	 * 1-hour TTL.
	 */
	public static function render_news_sitemap(): void {
		$cache_key = self::CACHE_PREFIX . 'news';
		$cached    = get_transient( $cache_key );

		if ( false !== $cached ) {
			// Plugin-generated XML — already escaped at the field level inside
			// build_news_sitemap(). Output verbatim.
			echo $cached; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
			return;
		}

		ob_start();
		self::build_news_sitemap();
		$output = (string) ob_get_clean();

		set_transient( $cache_key, $output, self::NEWS_CACHE_TTL );

		echo $output; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
	}

	/**
	 * Builds the news sub-sitemap XML (echoed). Always outputs a valid empty
	 * <urlset> when no posts match — Google prefers an empty doc to a 404.
	 */
	private static function build_news_sitemap(): void {
		$post_types = self::get_news_post_types();
		$pub_name   = self::get_news_publication_name();
		$language   = self::get_news_language();

		self::xml_header();
		echo '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"'
			. ' xmlns:news="http://www.google.com/schemas/sitemap-news/0.9"'
			. '>' . "\n";

		if ( ! empty( $post_types ) ) {
			$query = new WP_Query( [
				'post_type'              => $post_types,
				'post_status'            => 'publish',
				'posts_per_page'         => 1000,
				'orderby'                => 'date',
				'order'                  => 'DESC',
				'no_found_rows'          => true,
				'update_post_term_cache' => false,
				'update_post_meta_cache' => true,
				'date_query'             => [
					[
						'after'     => gmdate( 'Y-m-d H:i:s', time() - self::NEWS_WINDOW ),
						'column'    => 'post_date_gmt',
						'inclusive' => true,
					],
				],
				'meta_query'             => [
					'relation' => 'OR',
					[ 'key' => '_livada_seo_noindex',          'compare' => 'NOT EXISTS' ],
					[ 'key' => '_livada_seo_noindex',          'value' => '1', 'compare' => '!=' ],
				],
			] );

			if ( $query->have_posts() ) {
				foreach ( $query->posts as $post ) {
					$permalink = get_permalink( $post );
					if ( ! $permalink ) {
						continue;
					}

					echo "\t<url>\n";
					echo "\t\t<loc>" . esc_url( $permalink ) . "</loc>\n";
					echo "\t\t<news:news>\n";
					echo "\t\t\t<news:publication>\n";
					echo "\t\t\t\t<news:name>" . esc_xml( $pub_name ) . "</news:name>\n";
					echo "\t\t\t\t<news:language>" . esc_xml( $language ) . "</news:language>\n";
					echo "\t\t\t</news:publication>\n";
					echo "\t\t\t<news:publication_date>" . esc_xml( mysql2date( 'c', $post->post_date_gmt, false ) ) . "</news:publication_date>\n";
					echo "\t\t\t<news:title>" . esc_xml( get_the_title( $post ) ) . "</news:title>\n";
					echo "\t\t</news:news>\n";
					echo "\t</url>\n";
				}
			}

			wp_reset_postdata();
		}

		echo '</urlset>';
	}

	/**
	 * Lightweight check: are there any posts in the last 48h that would land
	 * in the news sitemap? Used by the index to decide whether to advertise
	 * sitemap-news.xml at all.
	 */
	private static function news_has_recent_items(): bool {
		// Skip when the news feature isn't actually configured.
		$post_types = self::get_news_post_types();
		if ( empty( $post_types ) ) {
			return false;
		}

		global $wpdb;
		$placeholders = implode( ',', array_fill( 0, count( $post_types ), '%s' ) );
		$cutoff       = gmdate( 'Y-m-d H:i:s', time() - self::NEWS_WINDOW );

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery
		$count = (int) $wpdb->get_var(
			$wpdb->prepare(
				// phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
				"SELECT COUNT(*) FROM {$wpdb->posts}
				 WHERE post_status = 'publish'
				   AND post_type IN ({$placeholders})
				   AND post_date_gmt >= %s",
				...array_merge( $post_types, [ $cutoff ] )
			)
		);

		return $count > 0;
	}

	/**
	 * @return string[]
	 */
	private static function get_news_post_types(): array {
		$types = Livada_SEO_Loader::get_setting( 'sitemap_news_post_types', [ 'post' ] );
		if ( ! is_array( $types ) ) {
			return [];
		}

		// Strip template CPTs and unknown types.
		$deny       = self::get_template_cpts();
		$registered = get_post_types( [ 'public' => true ] );

		return array_values( array_intersect( array_diff( $types, $deny ), $registered ) );
	}

	private static function get_news_publication_name(): string {
		$name = (string) Livada_SEO_Loader::get_setting( 'sitemap_news_publication_name', '' );
		if ( '' === $name ) {
			$name = (string) get_bloginfo( 'name' );
		}
		return $name;
	}

	private static function get_news_language(): string {
		$lang = (string) Livada_SEO_Loader::get_setting( 'sitemap_news_language', '' );
		if ( '' === $lang ) {
			// e.g. fr_FR → fr
			$wp_lang = get_locale();
			$lang    = strtolower( substr( $wp_lang, 0, 2 ) ?: 'en' );
		}
		return $lang;
	}

	// ── Sub-sitemap ───────────────────────────────────────────────────────────

	/**
	 * Outputs a sub-sitemap for a given post type or taxonomy slug.
	 *
	 * Results are served from a 12-hour transient when available.
	 * Noindex posts and posts/terms with `_livada_seo_sitemap_exclude` are
	 * omitted.  The number of entries is capped by `sitemap_max_urls`.
	 *
	 * @param string $type Post-type slug or taxonomy slug.
	 */
	public static function render_sitemap( string $type ): void {
		$type = sanitize_key( $type );

		// ── Try transient cache ───────────────────────────────────────────────

		$cache_key = self::CACHE_PREFIX . $type;
		$cached    = get_transient( $cache_key );

		if ( false !== $cached ) {
			echo $cached; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
			return;
		}

		// ── Determine whether this is a post type or taxonomy ─────────────────

		$post_types = self::get_post_types();
		$taxonomies = self::get_taxonomies();

		ob_start();

		if ( in_array( $type, $post_types, true ) && post_type_exists( $type ) ) {
			self::output_post_sitemap( $type );
		} elseif ( in_array( $type, $taxonomies, true ) && taxonomy_exists( $type ) ) {
			self::output_taxonomy_sitemap( $type );
		} else {
			// Unknown type — output an empty urlset rather than a 404 body.
			self::xml_header();
			echo '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' . "\n";
			echo '</urlset>';
		}

		$output = ob_get_clean();

		set_transient( $cache_key, $output, self::CACHE_TTL );

		echo $output; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
	}

	// ── Post-type sitemap output ──────────────────────────────────────────────

	/**
	 * Queries and outputs a `<urlset>` for the given post type.
	 *
	 * One SQL query per call (via WP_Query).  Posts excluded by meta flag or
	 * noindex are filtered at the query level using a meta_query.
	 * Image and video tags are included inline per <url> entry.
	 *
	 * @param string $post_type Registered post-type slug.
	 */
	private static function output_post_sitemap( string $post_type ): void {
		$max_urls = self::get_max_urls();

		$args = [
			'post_type'              => $post_type,
			'post_status'            => 'publish',
			'posts_per_page'         => $max_urls,
			'orderby'                => 'modified',
			'order'                  => 'DESC',
			'no_found_rows'          => true,
			'update_post_term_cache' => false,
			'update_post_meta_cache' => true, // needed for featured image lookup
			// Exclude posts explicitly flagged or marked noindex.
			'meta_query'             => [
				'relation' => 'AND',
				[
					'relation' => 'OR',
					[
						'key'     => '_livada_seo_sitemap_exclude',
						'compare' => 'NOT EXISTS',
					],
					[
						'key'     => '_livada_seo_sitemap_exclude',
						'value'   => '1',
						'compare' => '!=',
					],
				],
				[
					'relation' => 'OR',
					[
						'key'     => '_livada_seo_noindex',
						'compare' => 'NOT EXISTS',
					],
					[
						'key'     => '_livada_seo_noindex',
						'value'   => '1',
						'compare' => '!=',
					],
				],
			],
		];

		$query = new WP_Query( $args );

		self::xml_header();

		echo '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"'
			. ' xmlns:image="http://www.google.com/schemas/sitemap-image/1.1"'
			. ' xmlns:video="http://www.google.com/schemas/sitemap-video/1.1"'
			. '>' . "\n";

		if ( $query->have_posts() ) {
			foreach ( $query->posts as $post ) {
				$permalink = get_permalink( $post );

				if ( ! $permalink ) {
					continue;
				}

				$lastmod = mysql2date( 'Y-m-d\TH:i:sP', $post->post_modified_gmt, false );

				echo "\t<url>\n";
				echo "\t\t<loc>" . esc_url( $permalink ) . "</loc>\n";

				if ( $lastmod ) {
					echo "\t\t<lastmod>" . esc_xml( $lastmod ) . "</lastmod>\n";
				}

				// Image tags.
				$images = self::extract_images( $post );

				foreach ( $images as $image ) {
					echo "\t\t<image:image>\n";
					echo "\t\t\t<image:loc>" . esc_url( $image['url'] ) . "</image:loc>\n";

					if ( ! empty( $image['title'] ) ) {
						echo "\t\t\t<image:title>" . esc_xml( $image['title'] ) . "</image:title>\n";
					}

					echo "\t\t</image:image>\n";
				}

				// Video tags.
				$videos = self::extract_videos( $post );

				foreach ( $videos as $video ) {
					echo "\t\t<video:video>\n";
					echo "\t\t\t<video:thumbnail_loc>" . esc_url( $video['thumbnail'] ) . "</video:thumbnail_loc>\n";
					echo "\t\t\t<video:title>" . esc_xml( $video['title'] ) . "</video:title>\n";
					echo "\t\t\t<video:description>" . esc_xml( $video['description'] ) . "</video:description>\n";
					echo "\t\t\t<video:content_loc>" . esc_url( $video['content_loc'] ) . "</video:content_loc>\n";
					echo "\t\t</video:video>\n";
				}

				echo "\t</url>\n";
			}
		}

		echo '</urlset>';

		wp_reset_postdata();
	}

	// ── Image extraction ──────────────────────────────────────────────────────

	/**
	 * Extracts all images from a post's content and featured image.
	 *
	 * Returns an array of associative arrays with keys:
	 *   url   (string) — absolute image URL
	 *   title (string) — alt text or attachment title, may be empty
	 *
	 * Capped at 1000 images (Google limit).
	 *
	 * @param  WP_Post $post Post object.
	 * @return array<int, array{url: string, title: string}>
	 */
	private static function extract_images( WP_Post $post ): array {
		$images  = [];
		$seen    = [];
		$content = $post->post_content;

		// Featured image first.
		$thumbnail_id = (int) get_post_thumbnail_id( $post->ID );

		if ( $thumbnail_id > 0 ) {
			$src = wp_get_attachment_image_url( $thumbnail_id, 'full' );

			if ( $src && ! isset( $seen[ $src ] ) ) {
				$seen[ $src ] = true;
				$alt          = get_post_meta( $thumbnail_id, '_wp_attachment_image_alt', true );
				$title        = $alt ?: get_the_title( $thumbnail_id );
				$images[]     = [ 'url' => $src, 'title' => (string) $title ];
			}
		}

		// Images from post content.
		if ( preg_match_all( '/<img[^>]+src=["\']([^"\']+)["\']/', $content, $matches ) ) {
			foreach ( $matches[1] as $i => $src ) {
				$src = esc_url_raw( $src );

				if ( ! $src || isset( $seen[ $src ] ) ) {
					continue;
				}

				$seen[ $src ] = true;

				// Try to derive alt text from the same <img> tag.
				$tag   = $matches[0][ $i ];
				$title = '';

				if ( preg_match( '/alt=["\']([^"\']*)["\']/', $tag, $alt_m ) ) {
					$title = $alt_m[1];
				}

				$images[] = [ 'url' => $src, 'title' => $title ];

				if ( count( $images ) >= 1000 ) {
					break;
				}
			}
		}

		return $images;
	}

	// ── Video extraction ──────────────────────────────────────────────────────

	/**
	 * Extracts YouTube and Vimeo video embeds from a post's content.
	 *
	 * Returns an array of associative arrays with keys:
	 *   thumbnail   (string) — thumbnail URL
	 *   title       (string) — post title
	 *   description (string) — post excerpt (may be empty)
	 *   content_loc (string) — canonical video URL
	 *
	 * @param  WP_Post $post Post object.
	 * @return array<int, array{thumbnail: string, title: string, description: string, content_loc: string}>
	 */
	private static function extract_videos( WP_Post $post ): array {
		$videos  = [];
		$seen    = [];
		$content = $post->post_content;

		$title       = get_the_title( $post );
		$description = has_excerpt( $post ) ? get_the_excerpt( $post ) : '';
		// Strip any HTML that may be in the excerpt.
		$description = wp_strip_all_tags( $description );

		// YouTube patterns: watch URL, short URL, embed URL.
		$yt_pattern = '/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([a-zA-Z0-9_-]+)/';

		if ( preg_match_all( $yt_pattern, $content, $matches ) ) {
			foreach ( $matches[1] as $video_id ) {
				if ( isset( $seen[ $video_id ] ) ) {
					continue;
				}

				$seen[ $video_id ] = true;

				$videos[] = [
					'thumbnail'   => 'https://img.youtube.com/vi/' . $video_id . '/0.jpg',
					'title'       => $title,
					'description' => $description,
					'content_loc' => 'https://www.youtube.com/watch?v=' . $video_id,
				];
			}
		}

		// Vimeo pattern.
		if ( preg_match_all( '/vimeo\.com\/(\d+)/', $content, $matches ) ) {
			foreach ( $matches[1] as $video_id ) {
				if ( isset( $seen[ 'vimeo_' . $video_id ] ) ) {
					continue;
				}

				$seen[ 'vimeo_' . $video_id ] = true;

				// Vimeo does not expose a public thumbnail URL without an API call;
				// use the oEmbed thumbnail endpoint pattern as a best-effort fallback.
				$thumbnail = 'https://vumbnail.com/' . $video_id . '.jpg';

				$videos[] = [
					'thumbnail'   => $thumbnail,
					'title'       => $title,
					'description' => $description,
					'content_loc' => 'https://vimeo.com/' . $video_id,
				];
			}
		}

		return $videos;
	}

	// ── Taxonomy sitemap output ───────────────────────────────────────────────

	/**
	 * Queries and outputs a `<urlset>` for the given taxonomy.
	 *
	 * Terms with `_livada_seo_sitemap_exclude` term meta are skipped.
	 * One get_terms() call; no additional queries per term.
	 *
	 * @param string $taxonomy Registered taxonomy slug.
	 */
	private static function output_taxonomy_sitemap( string $taxonomy ): void {
		$max_urls = self::get_max_urls();

		$terms = get_terms(
			[
				'taxonomy'   => $taxonomy,
				'hide_empty' => true,
				'number'     => $max_urls,
				'orderby'    => 'count',
				'order'      => 'DESC',
			]
		);

		self::xml_header();

		echo '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' . "\n";

		if ( ! is_wp_error( $terms ) && ! empty( $terms ) ) {
			foreach ( $terms as $term ) {
				// Skip terms explicitly excluded via term meta.
				$exclude = get_term_meta( $term->term_id, '_livada_seo_sitemap_exclude', true );

				if ( $exclude === '1' || $exclude === true ) {
					continue;
				}

				$term_link = get_term_link( $term );

				if ( is_wp_error( $term_link ) ) {
					continue;
				}

				echo "\t<url>\n";
				echo "\t\t<loc>" . esc_url( $term_link ) . "</loc>\n";
				echo "\t</url>\n";
			}
		}

		echo '</urlset>';
	}

	// ── Cache invalidation ────────────────────────────────────────────────────

	/**
	 * Clears the transient for the relevant post type when a post is saved.
	 *
	 * Skips auto-drafts and revisions to avoid unnecessary cache churn.
	 *
	 * @param int     $post_id Post ID.
	 * @param WP_Post $post    Post object.
	 */
	public static function on_save_post( int $post_id, WP_Post $post ): void {
		if ( in_array( $post->post_status, [ 'auto-draft', 'inherit' ], true ) ) {
			return;
		}

		if ( wp_is_post_revision( $post_id ) ) {
			return;
		}

		$post_types = self::get_post_types();

		if ( in_array( $post->post_type, $post_types, true ) ) {
			delete_transient( self::CACHE_PREFIX . $post->post_type );
		}
	}

	/**
	 * Clears the transient for the relevant post type when a post is deleted.
	 *
	 * @param int $post_id Post ID.
	 */
	public static function on_delete_post( int $post_id ): void {
		$post_type  = get_post_type( $post_id );
		$post_types = self::get_post_types();

		if ( $post_type && in_array( $post_type, $post_types, true ) ) {
			delete_transient( self::CACHE_PREFIX . $post_type );
		}
	}

	/**
	 * Clears the transient for the relevant taxonomy on term create/delete.
	 *
	 * Callback matches the signature of both `created_term` and `delete_term`.
	 *
	 * @param int    $term_id  Term ID.
	 * @param int    $tt_id    Term-taxonomy ID.
	 * @param string $taxonomy Taxonomy slug.
	 */
	public static function on_term_change( int $term_id, int $tt_id, string $taxonomy ): void {
		$taxonomies = self::get_taxonomies();

		if ( in_array( $taxonomy, $taxonomies, true ) ) {
			delete_transient( self::CACHE_PREFIX . $taxonomy );
		}
	}

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

	/**
	 * Outputs the XML declaration and stylesheet processing instruction.
	 *
	 * Kept as a helper to avoid repetition between post-type and taxonomy
	 * output methods.
	 */
	private static function xml_header(): void {
		echo '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
	}

	/**
	 * Returns the configured post types for the sitemap, with template-only
	 * CPTs (Elementor, JetEngine, ElementsKit, etc.) removed.
	 *
	 * @return string[]
	 */
	private static function get_post_types(): array {
		$types = Livada_SEO_Loader::get_setting( 'sitemap_post_types', [ 'post', 'page' ] );
		if ( ! is_array( $types ) ) {
			$types = [ 'post', 'page' ];
		}

		/**
		 * Filter the list of template-only post types stripped from the sitemap.
		 * Return [] to disable the deny-list entirely.
		 *
		 * @param string[] $template_cpts Default deny-list.
		 */
		$deny = (array) apply_filters( 'livada_seo_sitemap_template_cpts', self::TEMPLATE_CPTS );

		return array_values( array_diff( $types, $deny ) );
	}

	/**
	 * Returns the deny-list of template-only post types currently in effect.
	 * Public so the WP-CLI `sitemap diagnose` command can render it.
	 *
	 * @return string[]
	 */
	public static function get_template_cpts(): array {
		return (array) apply_filters( 'livada_seo_sitemap_template_cpts', self::TEMPLATE_CPTS );
	}

	/**
	 * Returns the configured taxonomies for the sitemap.
	 *
	 * @return string[]
	 */
	private static function get_taxonomies(): array {
		$taxes = Livada_SEO_Loader::get_setting( 'sitemap_taxonomies', [ 'category' ] );
		return is_array( $taxes ) ? $taxes : [ 'category' ];
	}

	/**
	 * Returns the configured maximum number of URLs per sub-sitemap.
	 *
	 * @return int
	 */
	private static function get_max_urls(): int {
		return (int) Livada_SEO_Loader::get_setting( 'sitemap_max_urls', 1000 );
	}

	/**
	 * Returns the most recently modified post date for the given post type,
	 * formatted as W3C datetime (Y-m-d\TH:i:sP).
	 *
	 * Uses a direct lightweight query rather than WP_Query to avoid overhead.
	 *
	 * @param  string $post_type Post-type slug.
	 * @return string            W3C date string, or empty string on failure.
	 */
	private static function get_last_modified_post( string $post_type ): string {
		global $wpdb;

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery
		$date = $wpdb->get_var(
			$wpdb->prepare(
				"SELECT post_modified_gmt
				 FROM {$wpdb->posts}
				 WHERE post_status = 'publish'
				   AND post_type   = %s
				 ORDER BY post_modified_gmt DESC
				 LIMIT 1",
				$post_type
			)
		);

		if ( ! $date || $date === '0000-00-00 00:00:00' ) {
			return '';
		}

		return mysql2date( 'Y-m-d\TH:i:sP', $date, false );
	}

	/**
	 * Returns a last-modified date for the given taxonomy based on the most
	 * recently published post assigned to any term in that taxonomy.
	 *
	 * @param  string $taxonomy Taxonomy slug.
	 * @return string           W3C date string, or empty string on failure.
	 */
	private static function get_last_modified_term( string $taxonomy ): string {
		global $wpdb;

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery
		$date = $wpdb->get_var(
			$wpdb->prepare(
				"SELECT p.post_modified_gmt
				 FROM {$wpdb->posts} p
				 INNER JOIN {$wpdb->term_relationships} tr ON tr.object_id = p.ID
				 INNER JOIN {$wpdb->term_taxonomy}     tt ON tt.term_taxonomy_id = tr.term_taxonomy_id
				 WHERE p.post_status = 'publish'
				   AND tt.taxonomy   = %s
				 ORDER BY p.post_modified_gmt DESC
				 LIMIT 1",
				$taxonomy
			)
		);

		if ( ! $date || $date === '0000-00-00 00:00:00' ) {
			return '';
		}

		return mysql2date( 'Y-m-d\TH:i:sP', $date, false );
	}
}
