<?php
if ( ! defined( 'ABSPATH' ) ) exit;

/** Bump when schema output changes so archive JSON-LD cache misses without a manual Settings save. */
if ( ! defined( 'NROS_SCHEMA_BUILD' ) ) {
	define( 'NROS_SCHEMA_BUILD', '271' );
}

/**
 * Newsroom OS: God-Tier Entity & Graph Schema Engine (v1.7.5 - Editorial Intelligence and Stability Update)
 * Includes: Dedicated City Field, Home Page Organization Schema, Global Unaccent, Hybrid GeoCoordinates, BCP47 Locale
 */

if ( ! function_exists( 'nros_schema_credit_html_comment' ) ) {
	/**
	 * HTML comment printed above JSON-LD (similar to Rank Math attribution).
	 *
	 * @return string
	 */
	function nros_schema_credit_html_comment() {
		$url = (string) apply_filters( 'nros_schema_credit_url', 'https://newsos.io/' );

		return (string) apply_filters(
			'nros_schema_credit_html_comment',
			sprintf(
				'<!-- Newsroom OS — Semantic JSON-LD by Newsroom OS - %s -->',
				esc_url( $url )
			)
		);
	}
}

if ( ! function_exists( 'nros_schema_format_ld_json_script' ) ) {
	/**
	 * Wrap JSON-LD payload with plugin credit comment + script tag.
	 *
	 * @param string $json_payload Encoded JSON (no HTML).
	 * @return string
	 */
	function nros_schema_format_ld_json_script( $json_payload ) {
		return "\n" . nros_schema_credit_html_comment() . "\n<script type=\"application/ld+json\">\n" . $json_payload . "\n</script>\n";
	}
}

if ( ! function_exists( 'nros_schema_sanitize_json_string' ) ) {
	/**
	 * Plain-text values embedded in JSON-LD (articleBody, headlines, etc.).
	 *
	 * ASCII and curly double quotes break JSON when optimizers strip backslash escapes.
	 * Replace with apostrophe; normalize backslashes that confuse parsers.
	 *
	 * @param string $text Raw string.
	 * @return string
	 */
	function nros_schema_sanitize_json_string( $text ) {
		$text = (string) $text;
		if ( $text === '' ) {
			return '';
		}
		$text = html_entity_decode( $text, ENT_QUOTES | ENT_HTML5, 'UTF-8' );
		$text = str_replace(
			[
				"\u{201C}",
				"\u{201D}",
				"\u{201E}",
				"\u{201F}",
				'"',
			],
			"'",
			$text
		);
		$text = str_replace( '\\', '/', $text );
		return $text;
	}
}

if ( ! function_exists( 'nros_schema_plain_text_field' ) ) {
	/**
	 * Decode entities and strip markup for JSON-LD text fields (headline, description, etc.).
	 *
	 * @param string $text            Raw field value.
	 * @param bool   $strip_ellipsis  Remove trailing ellipsis artifacts from SEO plugins.
	 * @return string
	 */
	function nros_schema_plain_text_field( $text, $strip_ellipsis = true ) {
		$text = html_entity_decode( (string) $text, ENT_QUOTES | ENT_HTML5, 'UTF-8' );
		$text = wp_strip_all_tags( $text );
		if ( $strip_ellipsis ) {
			$text = trim( str_replace( [ '&hellip;', '...', '…' ], '', $text ) );
		} else {
			$text = trim( $text );
		}
		return nros_schema_sanitize_json_string( $text );
	}
}

if ( ! function_exists( 'nros_schema_register_image_sizes' ) ) {
	/**
	 * Register optional publisher logo size for Google News height guidance (600×60).
	 */
	function nros_schema_register_image_sizes() {
		add_image_size( 'nros-publisher-logo', 600, 60, false );
	}
}
add_action( 'after_setup_theme', 'nros_schema_register_image_sizes' );

if ( ! function_exists( 'nros_schema_resolve_publisher_logo' ) ) {
	/**
	 * Resolve publisher logo URL/dimensions for JSON-LD (Google News max height ~60px).
	 *
	 * @param string $logo_url Logo URL.
	 * @param int    $logo_w   Width from attachment.
	 * @param int    $logo_h   Height from attachment.
	 * @return array{0:string,1:int,2:int} [ url, width, height ]
	 */
	function nros_schema_resolve_publisher_logo( $logo_url, $logo_w, $logo_h ) {
		$max_height = max( 1, (int) apply_filters( 'nros_schema_publisher_logo_max_height', 60 ) );
		$logo_url   = (string) $logo_url;
		$logo_w     = max( 0, (int) $logo_w );
		$logo_h     = max( 0, (int) $logo_h );

		if ( $logo_url === '' ) {
			return [ '', 0, 0 ];
		}

		$attachment_id = function_exists( 'attachment_url_to_postid' ) ? (int) attachment_url_to_postid( $logo_url ) : 0;
		if ( $attachment_id > 0 && $logo_h > $max_height && function_exists( 'wp_get_attachment_image_src' ) ) {
			$size_name = (string) apply_filters( 'nros_schema_publisher_logo_size', 'nros-publisher-logo' );
			$variant   = wp_get_attachment_image_src( $attachment_id, $size_name );
			if ( is_array( $variant ) && ! empty( $variant[0] ) && ! empty( $variant[2] ) && (int) $variant[2] <= $max_height ) {
				return [ esc_url_raw( $variant[0] ), (int) $variant[1], (int) $variant[2] ];
			}
		}

		$resolved = [ $logo_url, $logo_w, $logo_h ];

		return (array) apply_filters( 'nros_schema_publisher_logo', $resolved, $attachment_id );
	}
}

if ( ! function_exists( 'nros_schema_sanitize_graph_strings' ) ) {
	/**
	 * Recursively sanitize string leaves before JSON encode.
	 *
	 * @param mixed $node Graph node or scalar.
	 * @return mixed
	 */
	function nros_schema_sanitize_graph_strings( $node ) {
		if ( is_array( $node ) ) {
			foreach ( $node as $key => $value ) {
				$node[ $key ] = nros_schema_sanitize_graph_strings( $value );
			}
			return $node;
		}
		if ( is_string( $node ) ) {
			return nros_schema_sanitize_json_string( $node );
		}
		return $node;
	}
}

if ( ! function_exists( 'nros_schema_encode_graph' ) ) {
	/**
	 * Encode @graph payload as JSON-LD string (quotes-safe for script output).
	 *
	 * @param array<int|string, mixed> $graph Schema nodes.
	 * @return string
	 */
	function nros_schema_encode_graph( array $graph ) {
		$graph   = nros_schema_sanitize_graph_strings( $graph );
		$payload = [
			'@context' => 'https://schema.org',
			'@graph'    => $graph,
		];
		$json = wp_json_encode( $payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE );
		return is_string( $json ) ? $json : '';
	}
}

if ( ! function_exists( 'nros_schema_build_publisher_trust_node' ) ) {
	/**
	 * Publisher Organization for singles/archives: same @id as home, E-E-A-T transparency without tax/legal bulk.
	 *
	 * @param array<string, mixed> $publisher Full Organization node from settings.
	 * @return array<string, mixed>
	 */
	function nros_schema_build_publisher_trust_node( array $publisher ) {
		$trust = [
			'@type' => $publisher['@type'] ?? 'NewsMediaOrganization',
			'@id'   => $publisher['@id'] ?? home_url() . '#organization',
			'name'  => $publisher['name'] ?? '',
			'url'   => $publisher['url'] ?? home_url(),
		];
		$copy_keys = [
			'description',
			'logo',
			'image',
			'sameAs',
			'email',
			'telephone',
			'address',
			'foundingDate',
			'areaServed',
			'location',
			'publishingPrinciples',
			'ownershipFundingInfo',
			'correctionsPolicy',
			'actionableFeedbackPolicy',
		];
		foreach ( $copy_keys as $key ) {
			if ( ! empty( $publisher[ $key ] ) ) {
				$trust[ $key ] = $publisher[ $key ];
			}
		}
		return (array) apply_filters( 'nros_schema_publisher_trust_node', $trust, $publisher );
	}
}

if ( ! function_exists( 'nros_schema_build_website_node' ) ) {
	/**
	 * WebSite node shared across templates (home parity fields on singles).
	 *
	 * @param string $site_name   Site display name.
	 * @param string $site_desc   Site tagline / description.
	 * @param string $schema_lang BCP47 locale.
	 * @return array<string, mixed>
	 */
	function nros_schema_build_website_node( $site_name, $site_desc, $schema_lang ) {
		$website = [
			'@type'     => 'WebSite',
			'@id'       => home_url() . '#website',
			'url'       => home_url(),
			'name'      => $site_name,
			'inLanguage' => $schema_lang,
			'publisher' => [ '@id' => home_url() . '#organization' ],
		];
		if ( trim( (string) $site_desc ) !== '' ) {
			$website['description'] = (string) $site_desc;
		}
		return (array) apply_filters( 'nros_schema_website_node', $website, $site_name, $site_desc, $schema_lang );
	}
}

if ( ! function_exists( 'nros_schema_build_single_webpage_node' ) ) {
	/**
	 * WebPage node for single posts (description + dates aligned with NewsArticle).
	 *
	 * @param int    $post_id       Post ID.
	 * @param string $meta_desc     Plain meta description.
	 * @param string $published_iso ISO-8601 published.
	 * @param string $modified_iso  ISO-8601 modified.
	 * @param string $schema_lang   BCP47 locale.
	 * @return array<string, mixed>
	 */
	function nros_schema_build_single_webpage_node( $post_id, $meta_desc, $published_iso, $modified_iso, $schema_lang ) {
		$post_id = (int) $post_id;
		$webpage = [
			'@type'        => 'WebPage',
			'@id'          => trailingslashit( get_permalink( $post_id ) ) . '#webpage',
			'url'          => get_permalink( $post_id ),
			'name'         => get_the_title( $post_id ),
			'inLanguage'   => $schema_lang,
			'isPartOf'     => [ '@id' => home_url() . '#website' ],
			'mainEntity'   => [ '@id' => trailingslashit( get_permalink( $post_id ) ) . '#article' ],
			'breadcrumb'   => [ '@id' => trailingslashit( get_permalink( $post_id ) ) . '#breadcrumb' ],
		];
		if ( trim( (string) $meta_desc ) !== '' ) {
			$webpage['description'] = (string) $meta_desc;
		}
		if ( trim( (string) $published_iso ) !== '' ) {
			$webpage['datePublished'] = (string) $published_iso;
		}
		if ( trim( (string) $modified_iso ) !== '' ) {
			$webpage['dateModified'] = (string) $modified_iso;
		}
		return (array) apply_filters( 'nros_schema_single_webpage_node', $webpage, $post_id, $meta_desc, $published_iso, $modified_iso, $schema_lang );
	}
}

if ( ! function_exists( 'nros_schema_register_seo_conflict_filters' ) ) {
	/**
	 * Defer Yoast/Rank Math JSON-LD suppression until plugins_loaded (avoids early get_option during CLI/import).
	 */
	function nros_schema_register_seo_conflict_filters() {
		if ( get_option( 'nros_schema_type', 'newsarticle' ) === 'none' ) {
			return;
		}
		if ( get_option( 'nros_schema_output_mode', 'hybrid' ) === 'strict' ) {
			add_filter( 'wpseo_json_ld_output', '__return_empty_array', 99 );
			add_filter( 'rank_math/json_ld', '__return_empty_array', 99 );
		}
	}
}
add_action( 'plugins_loaded', 'nros_schema_register_seo_conflict_filters', 5 );

// ==========================================
// 0.05. GOOGLE DISCOVER: max-image-preview:large SAFEGUARD
// ==========================================
if ( ! function_exists( 'nros_seo_plugin_manages_robots' ) ) {
	/**
	 * True when a dedicated SEO plugin is active and already owns robots / max-image-preview.
	 * When one is present we never touch robots — that plugin is the source of truth.
	 *
	 * @return bool
	 */
	function nros_seo_plugin_manages_robots() {
		$managed = defined( 'WPSEO_VERSION' )              // Yoast SEO
			|| defined( 'RANK_MATH_VERSION' )              // Rank Math
			|| defined( 'AIOSEO_VERSION' )                 // All in One SEO
			|| function_exists( 'aioseo' )
			|| defined( 'SEOPRESS_VERSION' )               // SEOPress
			|| defined( 'THE_SEO_FRAMEWORK_VERSION' )      // The SEO Framework
			|| defined( 'SLIM_SEO_VERSION' );              // Slim SEO
		return (bool) apply_filters( 'nros_seo_plugin_manages_robots', $managed );
	}
}

if ( ! function_exists( 'nros_schema_ensure_max_image_preview' ) ) {
	/**
	 * Guarantee `max-image-preview:large` for Google Discover — only when nothing else provides it.
	 *
	 * Discover needs this directive for large-image treatment; without it Discover traffic can
	 * collapse. WordPress core adds it by default (5.7+) and SEO plugins add their own, but themes,
	 * caching, or security layers sometimes strip it. On a Discover-first site running WITHOUT an
	 * SEO plugin, we re-assert it ONLY when: (a) no SEO plugin owns robots, (b) the page is not
	 * noindex, and (c) the value is not already set. This never creates a duplicate/conflicting tag
	 * because wp_robots is an associative array keyed by directive.
	 *
	 * @param array $robots wp_robots directives.
	 * @return array
	 */
	function nros_schema_ensure_max_image_preview( $robots ) {
		if ( ! is_array( $robots ) ) {
			return $robots;
		}
		if ( ! empty( $robots['noindex'] ) ) {
			return $robots; // Respect explicit noindex — never override indexing intent.
		}
		if ( nros_seo_plugin_manages_robots() ) {
			return $robots; // Another plugin already manages this — stay out of the way.
		}
		if ( empty( $robots['max-image-preview'] ) ) {
			$robots['max-image-preview'] = 'large';
		}
		return $robots;
	}
}
if ( (bool) apply_filters( 'nros_schema_enforce_max_image_preview', true ) ) {
	// Late priority so we only fill the gap after core / any wp_robots contributors have run.
	add_filter( 'wp_robots', 'nros_schema_ensure_max_image_preview', 99 );
}

// ==========================================
// 0.1. BULLETPROOF GLOBAL UNACCENT
// ==========================================
if (!function_exists('nros_global_unaccent')) {
    function nros_global_unaccent($string) {
        $string = mb_strtolower($string, 'UTF-8');
        if (class_exists('Normalizer')) {
            $string = Normalizer::normalize($string, Normalizer::FORM_D);
            $string = preg_replace('/\p{M}/u', '', $string);
        } else {
            $accents = [
                'ά' => 'α', 'έ' => 'ε', 'ή' => 'η', 'ί' => 'ι', 'ό' => 'ο', 'ύ' => 'υ', 'ώ' => 'ω',
                'ϊ' => 'ι', 'ϋ' => 'υ', 'ΐ' => 'ι', 'ΰ' => 'υ',
                'é' => 'e', 'è' => 'e', 'ê' => 'e', 'ë' => 'e',
                'á' => 'a', 'à' => 'a', 'ä' => 'a', 'â' => 'a',
                'ó' => 'o', 'ò' => 'o', 'ö' => 'o', 'ô' => 'o',
                'í' => 'i', 'ì' => 'i', 'ï' => 'i', 'î' => 'i',
                'ú' => 'u', 'ù' => 'u', 'ü' => 'u', 'û' => 'u',
                'ñ' => 'n', 'ç' => 'c'
            ];
            $string = strtr($string, $accents);
        }
        return $string;
    }
}

// ==========================================
// 0.15. SCHEMA DATES (LOCAL TZ FOR JSON-LD)
// ==========================================
if ( ! function_exists( 'nros_schema_post_datetime_iso' ) ) {
    /**
     * ISO 8601 in the site timezone (e.g. +03:00), matching wp-admin and typical SEO OG tags.
     *
     * @param int    $post_id Post ID.
     * @param string $field   'date' (published) or 'modified'.
     */
    function nros_schema_post_datetime_iso( $post_id, $field = 'date' ) {
        $post_id = (int) $post_id;
        if ( $post_id < 1 ) {
            return '';
        }
        $field = ( 'modified' === $field ) ? 'modified' : 'date';
        if ( function_exists( 'get_post_datetime' ) ) {
            $dt = get_post_datetime( $post_id, $field, 'local' );
            if ( $dt instanceof DateTimeImmutable ) {
                return $dt->format( 'c' );
            }
        }
        if ( 'modified' === $field ) {
            return (string) get_post_modified_time( 'c', false, $post_id );
        }
        return (string) get_post_time( 'c', false, $post_id );
    }
}

if ( ! function_exists( 'nros_schema_build_author_ref' ) ) {
    /**
     * Lightweight Person for feed/list NewsArticle refs (Rich Results expects name + url, not @id alone).
     *
     * @param int $author_id WP user ID.
     * @return array|null Person array or null.
     */
    function nros_schema_build_author_ref( $author_id ) {
        $author_id = (int) $author_id;
        if ( $author_id < 1 ) {
            return null;
        }
        $author = get_userdata( $author_id );
        if ( ! $author ) {
            return null;
        }
        $author_url = get_author_posts_url( $author_id );
        if ( ! $author_url ) {
            return null;
        }

        return [
            '@type' => 'Person',
            '@id'   => trailingslashit( $author_url ) . '#author',
            'name'  => nros_schema_plain_text_field( $author->display_name, false ),
            'url'   => esc_url_raw( $author_url ),
        ];
    }
}

if ( ! function_exists( 'nros_schema_build_item_list_element' ) ) {
    /**
     * Enriched ListItem for archive ItemList feeds (title + publish date for Discover).
     *
     * @param WP_Post|int $post     Post object or ID.
     * @param int         $position 1-based position in the list.
     * @return array|null ListItem array or null if post invalid.
     */
    function nros_schema_build_item_list_element( $post, $position ) {
        $post_id = is_object( $post ) ? (int) $post->ID : (int) $post;
        if ( $post_id < 1 ) {
            return null;
        }
        $permalink = get_permalink( $post_id );
        if ( ! $permalink ) {
            return null;
        }
        $title = wp_strip_all_tags( get_the_title( $post_id ) );
        $published = nros_schema_post_datetime_iso( $post_id, 'date' );

        $article_ref = [
            '@type'    => 'NewsArticle',
            '@id'      => trailingslashit( $permalink ) . '#article',
            'url'      => $permalink,
            'headline' => $title,
        ];
        if ( $published !== '' ) {
            $article_ref['datePublished'] = $published;
        }

        $author_id = (int) get_post_field( 'post_author', $post_id );
        $author_ref = nros_schema_build_author_ref( $author_id );
        if ( $author_ref ) {
            $article_ref['author'] = $author_ref;
        }

        if ( has_post_thumbnail( $post_id ) ) {
            $thumb_url = get_the_post_thumbnail_url( $post_id, 'large' );
            if ( ! $thumb_url ) {
                $thumb_url = get_the_post_thumbnail_url( $post_id, 'full' );
            }
            if ( $thumb_url ) {
                $article_ref['thumbnailUrl'] = esc_url_raw( $thumb_url );
                $article_ref['image']        = esc_url_raw( $thumb_url );
            }
        }

        return [
            '@type'    => 'ListItem',
            'position' => (int) $position,
            'name'     => $title,
            'item'     => $article_ref,
        ];
    }
}

if ( ! function_exists( 'nros_schema_normalize_modified_iso' ) ) {
    /**
     * Ensures dateModified is never earlier than datePublished.
     *
     * Scheduled newsroom workflows can produce a valid WP modified timestamp that is
     * earlier than the eventual publish timestamp; for Schema.org NewsArticle this
     * ordering is undesirable, so we clamp modified to published when needed.
     *
     * @param string $published_iso ISO 8601 publish datetime.
     * @param string $modified_iso  ISO 8601 modified datetime.
     * @return string Normalized modified datetime.
     */
    function nros_schema_normalize_modified_iso( $published_iso, $modified_iso ) {
        $published_iso = (string) $published_iso;
        $modified_iso  = (string) $modified_iso;
        if ( $published_iso === '' || $modified_iso === '' ) {
            return $modified_iso !== '' ? $modified_iso : $published_iso;
        }
        $published_ts = strtotime( $published_iso );
        $modified_ts  = strtotime( $modified_iso );
        if ( false === $published_ts || false === $modified_ts ) {
            return $modified_iso;
        }
        return ( $modified_ts < $published_ts ) ? $published_iso : $modified_iso;
    }
}

if ( ! function_exists( 'nros_schema_resolve_article_datetimes' ) ) {
	/**
	 * Resolve stable publish/modified ISO datetimes for schema with safe fallbacks.
	 *
	 * @param int $post_id Post ID.
	 * @return array{published:string,modified:string}
	 */
	function nros_schema_resolve_article_datetimes( $post_id ) {
		$post_id = (int) $post_id;
		$published_iso = nros_schema_post_datetime_iso( $post_id, 'date' );
		$modified_iso  = nros_schema_post_datetime_iso( $post_id, 'modified' );

		// Rare edge-cases: missing DB datetime values or malformed timezone conversions.
		if ( $published_iso === '' ) {
			$published_iso = (string) get_post_time( 'c', false, $post_id );
		}
		if ( $modified_iso === '' ) {
			$modified_iso = (string) get_post_modified_time( 'c', false, $post_id );
		}
		if ( $published_iso === '' ) {
			$published_iso = current_time( 'c' );
		}
		if ( $modified_iso === '' ) {
			$modified_iso = $published_iso;
		}

		if ( function_exists( 'nros_schema_normalize_modified_iso' ) ) {
			$modified_iso = nros_schema_normalize_modified_iso( $published_iso, $modified_iso );
		}
		return [
			'published' => (string) $published_iso,
			'modified'  => (string) $modified_iso,
		];
	}
}

// ==========================================
// 0.2. KNOWLEDGE GRAPH: THE ULTIMATE GLOBAL MAP
// ==========================================
if (!function_exists('nros_get_global_cities_graph')) {
    function nros_get_global_cities_graph() {
        return [
            'αθηνα' => ['lat' => 37.9838, 'lng' => 23.7275, 'wiki' => 'https://www.wikidata.org/wiki/Q1524'],
            'θεσσαλονικη' => ['lat' => 40.6401, 'lng' => 22.9444, 'wiki' => 'https://www.wikidata.org/wiki/Q17151'],
            'πατρα' => ['lat' => 38.2466, 'lng' => 21.7346, 'wiki' => 'https://www.wikidata.org/wiki/Q133123'],
            'ηρακλειο' => ['lat' => 35.3387, 'lng' => 25.1442, 'wiki' => 'https://www.wikidata.org/wiki/Q160544'],
            'λαρισα' => ['lat' => 39.6390, 'lng' => 22.4191, 'wiki' => 'https://www.wikidata.org/wiki/Q178836'],
            'βολος' => ['lat' => 39.3621, 'lng' => 22.9423, 'wiki' => 'https://www.wikidata.org/wiki/Q200050'],
            'ιωαννινα' => ['lat' => 39.6650, 'lng' => 20.8537, 'wiki' => 'https://www.wikidata.org/wiki/Q183199'],
            'τρικαλα' => ['lat' => 39.5557, 'lng' => 21.7679, 'wiki' => 'https://www.wikidata.org/wiki/Q200063'],
            'χαλκιδα' => ['lat' => 38.4636, 'lng' => 23.5955, 'wiki' => 'https://www.wikidata.org/wiki/Q200054'],
            'σερρες' => ['lat' => 41.0849, 'lng' => 23.5475, 'wiki' => 'https://www.wikidata.org/wiki/Q200049'],
            'αλεξανδρουπολη' => ['lat' => 40.8457, 'lng' => 25.8739, 'wiki' => 'https://www.wikidata.org/wiki/Q200042'],
            'ξανθη' => ['lat' => 41.1328, 'lng' => 24.8877, 'wiki' => 'https://www.wikidata.org/wiki/Q200044'],
            'κατερινη' => ['lat' => 40.2696, 'lng' => 22.5061, 'wiki' => 'https://www.wikidata.org/wiki/Q205051'],
            'καλαματα' => ['lat' => 37.0391, 'lng' => 22.1126, 'wiki' => 'https://www.wikidata.org/wiki/Q200040'],
            'καβαλα' => ['lat' => 40.9396, 'lng' => 24.4069, 'wiki' => 'https://www.wikidata.org/wiki/Q200046'],
            'χανια' => ['lat' => 35.5138, 'lng' => 24.0180, 'wiki' => 'https://www.wikidata.org/wiki/Q171092'],
            'λαμια' => ['lat' => 38.9000, 'lng' => 22.4333, 'wiki' => 'https://www.wikidata.org/wiki/Q200072'],
            'κομοτηνη' => ['lat' => 41.1192, 'lng' => 25.4054, 'wiki' => 'https://www.wikidata.org/wiki/Q200043'],
            'ροδος' => ['lat' => 36.4408, 'lng' => 28.2225, 'wiki' => 'https://www.wikidata.org/wiki/Q200048'],
            'αττικη' => ['lat' => 38.0000, 'lng' => 23.7000, 'wiki' => 'https://www.wikidata.org/wiki/Q12994'],
            'κρητη' => ['lat' => 35.2000, 'lng' => 24.8500, 'wiki' => 'https://www.wikidata.org/wiki/Q22201'],
            'πελοποννησος' => ['lat' => 37.5000, 'lng' => 22.3333, 'wiki' => 'https://www.wikidata.org/wiki/Q172774'],
            'λευκωσια' => ['lat' => 35.1855, 'lng' => 33.3822, 'wiki' => 'https://www.wikidata.org/wiki/Q3856'],
            'λεμεσος' => ['lat' => 34.6735, 'lng' => 33.0406, 'wiki' => 'https://www.wikidata.org/wiki/Q133315'],
            'london' => ['lat' => 51.5072, 'lng' => -0.1276, 'wiki' => 'https://www.wikidata.org/wiki/Q84'],
            'new york' => ['lat' => 40.7128, 'lng' => -74.0060, 'wiki' => 'https://www.wikidata.org/wiki/Q60'],
            'paris' => ['lat' => 48.8566, 'lng' => 2.3522, 'wiki' => 'https://www.wikidata.org/wiki/Q90'],
            'berlin' => ['lat' => 52.5200, 'lng' => 13.4050, 'wiki' => 'https://www.wikidata.org/wiki/Q64'],
            'monterrey' => ['lat' => 25.6866, 'lng' => -100.3161, 'wiki' => 'https://www.wikidata.org/wiki/Q69069'],
            'mexico city' => ['lat' => 19.4326, 'lng' => -99.1332, 'wiki' => 'https://www.wikidata.org/wiki/Q1489'],
            'jakarta' => ['lat' => -6.2088, 'lng' => 106.8456, 'wiki' => 'https://www.wikidata.org/wiki/Q3630'],
            'surabaya' => ['lat' => -7.2575, 'lng' => 112.7521, 'wiki' => 'https://www.wikidata.org/wiki/Q11462'],
            'baghdad' => ['lat' => 33.3152, 'lng' => 44.3661, 'wiki' => 'https://www.wikidata.org/wiki/Q1530'],
            'madrid' => ['lat' => 40.4168, 'lng' => -3.7038, 'wiki' => 'https://www.wikidata.org/wiki/Q2807'],
            'barcelona' => ['lat' => 41.3874, 'lng' => 2.1686, 'wiki' => 'https://www.wikidata.org/wiki/Q1492'],
            'amsterdam' => ['lat' => 52.3676, 'lng' => 4.9041, 'wiki' => 'https://www.wikidata.org/wiki/Q727'],
            'sydney' => ['lat' => -33.8688, 'lng' => 151.2093, 'wiki' => 'https://www.wikidata.org/wiki/Q3130'],
            'tokyo' => ['lat' => 35.6762, 'lng' => 139.6503, 'wiki' => 'https://www.wikidata.org/wiki/Q1490'],
            'dubai' => ['lat' => 25.2048, 'lng' => 55.2708, 'wiki' => 'https://www.wikidata.org/wiki/Q612'],
        ];
    }
}

if ( ! function_exists( 'nros_schema_build_place_node' ) ) {
	/**
	 * Build a schema.org Place node.
	 *
	 * @param string $name     Display name.
	 * @param string $wiki_url Optional sameAs.
	 * @param string $lat      Optional latitude.
	 * @param string $lng      Optional longitude.
	 * @return array<string, mixed>
	 */
	function nros_schema_build_place_node( $name, $wiki_url = '', $lat = '', $lng = '' ) {
		$node = [
			'@type' => 'Place',
			'name'  => $name,
		];
		if ( $wiki_url !== '' ) {
			$node['sameAs'] = esc_url_raw( $wiki_url );
		}
		if ( $lat !== '' && $lng !== '' ) {
			$node['geo'] = [
				'@type'     => 'GeoCoordinates',
				'latitude'  => (float) $lat,
				'longitude' => (float) $lng,
			];
		}

		return $node;
	}
}

if ( ! function_exists( 'nros_schema_resolve_tag_wikidata_url' ) ) {
	/**
	 * Resolve Wikidata sameAs for tag/term entities in JSON-LD.
	 *
	 * Strict gate first (newsai_term_wikidata_sameas_url), then Place city map,
	 * then a schema-only soft confidence fallback so mentions keep entity depth.
	 *
	 * @param int    $term_id   Tag term ID.
	 * @param string $tag_name  Tag label.
	 * @param string $tag_type  Schema @type (Place, Thing, …).
	 * @return string Wikidata URL or empty.
	 */
	function nros_schema_resolve_tag_wikidata_url( $term_id, $tag_name, $tag_type = 'Thing' ) {
		$term_id  = (int) $term_id;
		$tag_name = (string) $tag_name;
		$tag_type = (string) $tag_type;

		$wiki_url = function_exists( 'newsai_term_wikidata_sameas_url' )
			? newsai_term_wikidata_sameas_url( $term_id, $tag_name )
			: '';
		if ( $wiki_url !== '' ) {
			return $wiki_url;
		}

		if ( 'Place' === $tag_type ) {
			$city_map   = nros_get_global_cities_graph();
			$search_key = nros_global_unaccent( $tag_name );
			if ( isset( $city_map[ $search_key ]['wiki'] ) && $city_map[ $search_key ]['wiki'] !== '' ) {
				return (string) $city_map[ $search_key ]['wiki'];
			}
		}

		$stored = (string) get_term_meta( $term_id, 'nros_wikidata_url', true );
		if ( $stored !== '' && strpos( $stored, 'wikidata.org/wiki/Q' ) !== false ) {
			$source = (string) get_term_meta( $term_id, 'nros_wikidata_source', true );
			if ( $source === 'manual' ) {
				return esc_url_raw( $stored );
			}
			$confidence = (int) get_term_meta( $term_id, 'nros_wikidata_confidence', true );
			$soft_min   = (int) apply_filters( 'nros_schema_wikidata_soft_min_score', 50 );
			if ( $confidence >= $soft_min ) {
				return esc_url_raw( $stored );
			}
		}

		return (string) apply_filters( 'nros_schema_tag_wikidata_url', '', $term_id, $tag_name, $tag_type );
	}
}

if ( ! function_exists( 'nros_schema_build_term_entity_node' ) ) {
	/**
	 * Build a schema.org entity node for a taxonomy term (category, tag, etc.).
	 *
	 * @param WP_Term|object|null $term Term object.
	 * @return array<string, mixed>|null
	 */
	function nros_schema_build_term_entity_node( $term ) {
		if ( ! is_object( $term ) || empty( $term->term_id ) || empty( $term->name ) ) {
			return null;
		}

		$term_id = (int) $term->term_id;
		$name    = (string) $term->name;
		$slug    = isset( $term->slug ) ? (string) $term->slug : '';

		$wiki_url = nros_schema_resolve_tag_wikidata_url( $term_id, $name, $entity_type );
		$lat        = (string) get_term_meta( $term_id, 'nros_lat', true );
		$lng        = (string) get_term_meta( $term_id, 'nros_lng', true );
		$saved_type = (string) get_term_meta( $term_id, 'nros_entity_type', true );

		$entity_type = ( $saved_type !== '' && $saved_type !== 'auto' )
			? $saved_type
			: nros_detect_entity_type( $name, $slug );
		if ( 'Event' === $entity_type ) {
			$entity_type = 'Thing';
		}
		if ( 'Place' !== $entity_type && $lat !== '' && $lng !== '' ) {
			$entity_type = 'Place';
		}

		$term_link = get_term_link( $term );
		if ( is_wp_error( $term_link ) ) {
			return null;
		}

		$entity_node = [
			'@type' => $entity_type,
			'@id'   => trailingslashit( $term_link ) . '#entity',
			'name'  => $name,
			'url'   => $term_link,
		];
		if ( $wiki_url !== '' ) {
			$entity_node['sameAs'] = esc_url_raw( $wiki_url );
		}
		if ( 'Place' === $entity_type ) {
			if ( $lat !== '' && $lng !== '' ) {
				$entity_node['geo'] = [
					'@type'     => 'GeoCoordinates',
					'latitude'  => (float) $lat,
					'longitude' => (float) $lng,
				];
			} else {
				$city_map   = nros_get_global_cities_graph();
				$search_key = nros_global_unaccent( $name );
				if ( isset( $city_map[ $search_key ] ) ) {
					$entity_node['geo'] = [
						'@type'     => 'GeoCoordinates',
						'latitude'  => (float) $city_map[ $search_key ]['lat'],
						'longitude' => (float) $city_map[ $search_key ]['lng'],
					];
					if ( $wiki_url === '' && ! empty( $city_map[ $search_key ]['wiki'] ) ) {
						$entity_node['sameAs'] = $city_map[ $search_key ]['wiki'];
					}
				}
			}
		}

		$entity_node = array_filter( $entity_node );

		return (array) apply_filters( 'nros_schema_term_entity_node', $entity_node, $term );
	}
}

if ( ! function_exists( 'nros_schema_extract_article_body_from_html' ) ) {
	/**
	 * Core HTML → plain extract (optional shortcode strip).
	 *
	 * @param string $html                 Post HTML or block markup.
	 * @param bool   $strip_shortcodes_first Whether to run strip_shortcodes first.
	 * @return string
	 */
	function nros_schema_extract_article_body_from_html( $html, $strip_shortcodes_first = true ) {
		$html = (string) $html;
		if ( $html === '' ) {
			return '';
		}

		if ( $strip_shortcodes_first && function_exists( 'strip_shortcodes' ) ) {
			$html = strip_shortcodes( $html );
		}
		if ( function_exists( 'do_blocks' ) ) {
			$html = do_blocks( $html );
		}

		$html = preg_replace( '/<(script|style|noscript)[^>]*>.*?<\/\1>/is', '', $html );
		$html = preg_replace( '/<!--.*?-->/s', '', $html );

		$html = preg_replace( '/<h[1-6][^>]*>(.*?)<\/h[1-6]>/is', "\n\n$1\n\n", $html );
		$html = preg_replace( '/<\/(p|div|section|article|li|blockquote|figcaption|tr)>/i', "\n", $html );
		$html = preg_replace( '/<(br|hr)\s*\/?>/i', "\n", $html );

		return wp_strip_all_tags( $html );
	}
}

if ( ! function_exists( 'nros_schema_extract_article_body_source' ) ) {
	/**
	 * Extract article body text from post HTML while preserving basic structure.
	 *
	 * Strips shortcodes, keeps heading breaks and paragraph boundaries for NLP-friendly articleBody.
	 * Retries without strip_shortcodes when a site wraps the whole article in a registered shortcode
	 * (common on high-traffic news stacks) so we do not end up with wordCount 0 / empty articleBody.
	 *
	 * @param string $post_content Raw post_content.
	 * @return string
	 */
	function nros_schema_extract_article_body_source( $post_content ) {
		$html = (string) $post_content;
		if ( $html === '' ) {
			return '';
		}

		$with_strip = nros_schema_extract_article_body_from_html( $html, true );
		if ( trim( $with_strip ) !== '' ) {
			return (string) apply_filters( 'nros_schema_extract_article_body_source', $with_strip, $post_content );
		}

		$without_strip = nros_schema_extract_article_body_from_html( $html, false );
		return (string) apply_filters( 'nros_schema_extract_article_body_source', $without_strip, $post_content );
	}
}

if ( ! function_exists( 'nros_schema_sanitize_article_body_plain' ) ) {
	/**
	 * Normalize plain text for JSON-LD articleBody (Full & Limited modes).
	 *
	 * Strips tags, decodes entities, removes control chars, JSON-safe quotes only.
	 * No paragraph/line-break rewriting (stable across news stacks and page caches).
	 *
	 * @param string $text Plain post body (tags already stripped).
	 * @return string
	 */
	function nros_schema_sanitize_article_body_plain( $text ) {
		$text = (string) $text;
		if ( $text === '' ) {
			return '';
		}

		$text = html_entity_decode( $text, ENT_QUOTES | ENT_HTML5, 'UTF-8' );
		$text = wp_strip_all_tags( $text );
		$text = str_replace( [ "\r\n", "\r" ], "\n", $text );
		$text = preg_replace( '/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/u', '', $text );
		$text = trim( $text );
		$text = nros_schema_sanitize_json_string( $text );

		return (string) apply_filters( 'nros_schema_sanitize_article_body_plain', $text );
	}
}

if ( ! function_exists( 'nros_schema_normalize_article_plain' ) ) {
	/**
	 * Full sanitize with light fallback (shared by wordCount and articleBody).
	 *
	 * @param string $extracted Output of nros_schema_extract_article_body_source().
	 * @return string
	 */
	function nros_schema_normalize_article_plain( $extracted ) {
		$plain = nros_schema_sanitize_article_body_plain( $extracted );
		if ( $plain !== '' ) {
			return $plain;
		}
		return nros_schema_fallback_plain_content( $extracted );
	}
}

if ( ! function_exists( 'nros_schema_build_article_body' ) ) {
	/**
	 * Build articleBody for NewsArticle / LiveBlogPosting from settings.
	 *
	 * @param string $content          Extracted HTML/plain or pre-normalized plain text.
	 * @param string $body_mode        omit|full|limited.
	 * @param int    $body_max_words   Max words when limited.
	 * @param bool   $already_plain    When true, skip sanitize (use content_pack plain).
	 * @return string|null Null when omit; empty string when nothing left after sanitize.
	 */
	function nros_schema_build_article_body( $content, $body_mode, $body_max_words, $already_plain = false ) {
		$body_mode = sanitize_key( (string) $body_mode );
		if ( 'omit' === $body_mode ) {
			return null;
		}

		$plain = $already_plain
			? trim( (string) $content )
			: nros_schema_normalize_article_plain( $content );
		if ( $plain === '' ) {
			return '';
		}

		if ( 'limited' === $body_mode ) {
			$max = max( 30, min( 2000, (int) $body_max_words ) );
			$plain = wp_trim_words( $plain, $max, ' …' );
		}

		return $plain;
	}
}

if ( ! function_exists( 'nros_schema_resolve_post_content_for_schema' ) ) {
	/**
	 * Resolve post_content for schema with at most one DB read per post per request.
	 *
	 * Uses the in-memory WP_Post from the main query when post_content is populated; otherwise
	 * a single get_post_field( ..., 'raw' ) (object-cache friendly). Avoids wpdb round-trips and
	 * repeated reads during cache warm / singular builds.
	 *
	 * @param int           $post_id Post ID.
	 * @param WP_Post|null  $post    Optional post object (e.g. global $post).
	 * @return string
	 */
	function nros_schema_resolve_post_content_for_schema( $post_id, $post = null ) {
		static $resolved = [];

		$post_id = (int) $post_id;
		if ( $post_id < 1 ) {
			return '';
		}
		if ( array_key_exists( $post_id, $resolved ) ) {
			return $resolved[ $post_id ];
		}

		$content = '';
		if ( $post instanceof WP_Post && (int) $post->ID === $post_id && is_string( $post->post_content ) && $post->post_content !== '' ) {
			$content = $post->post_content;
		} elseif ( function_exists( 'get_post_field' ) ) {
			$from_db = get_post_field( 'post_content', $post_id, 'raw' );
			$content = is_string( $from_db ) ? $from_db : '';
		}

		$content = (string) apply_filters( 'nros_schema_resolve_post_content_for_schema', $content, $post_id, $post );
		$resolved[ $post_id ] = $content;

		return $content;
	}
}

if ( ! function_exists( 'nros_schema_get_post_raw_content' ) ) {
	/**
	 * @param int $post_id Post ID.
	 * @return string
	 */
	function nros_schema_get_post_raw_content( $post_id ) {
		return nros_schema_resolve_post_content_for_schema( (int) $post_id );
	}
}

if ( ! function_exists( 'nros_schema_try_rendered_content_fallback' ) ) {
	/**
	 * Expensive the_content pass — opt-in only (filter), never on cron/admin.
	 *
	 * @param int          $post_id Post ID.
	 * @param WP_Post|null $post    Post object when already in memory.
	 * @param string       $raw     Already-resolved raw content (skip when non-empty).
	 * @return string Rendered HTML or empty string.
	 */
	function nros_schema_try_rendered_content_fallback( $post_id, $post = null, $raw = '' ) {
		if ( $raw !== '' || is_admin() || wp_doing_cron() || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ) {
			return '';
		}

		$use = (bool) apply_filters( 'nros_schema_use_rendered_content_fallback', false, $post_id );
		if ( ! $use ) {
			return '';
		}

		if ( ! ( $post instanceof WP_Post ) || (int) $post->ID !== (int) $post_id ) {
			$post = get_post( $post_id );
		}
		if ( ! ( $post instanceof WP_Post ) ) {
			return '';
		}

		$raw_for_render = is_string( $post->post_content ) ? $post->post_content : '';
		if ( $raw_for_render === '' ) {
			return '';
		}

		$previous_post = isset( $GLOBALS['post'] ) ? $GLOBALS['post'] : null;
		$GLOBALS['post'] = $post;
		setup_postdata( $post );
		$rendered = apply_filters( 'the_content', $raw_for_render );
		wp_reset_postdata();
		if ( $previous_post instanceof WP_Post ) {
			$GLOBALS['post'] = $previous_post;
		}

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

if ( ! function_exists( 'nros_schema_count_plain_words' ) ) {
	/**
	 * @param string $text Plain text.
	 * @return int
	 */
	function nros_schema_count_plain_words( $text ) {
		$text = trim( (string) $text );
		if ( $text === '' ) {
			return 0;
		}
		$parts = preg_split( '/\s+/u', $text, -1, PREG_SPLIT_NO_EMPTY );
		return is_array( $parts ) ? count( $parts ) : 0;
	}
}

if ( ! function_exists( 'nros_schema_fallback_plain_content' ) ) {
	/**
	 * Lighter plain-text pass when full articleBody sanitize returns empty (wordCount / body fallback).
	 *
	 * @param string $extracted Output of nros_schema_extract_article_body_source().
	 * @return string
	 */
	function nros_schema_fallback_plain_content( $extracted ) {
		$text = html_entity_decode( wp_strip_all_tags( (string) $extracted ), ENT_QUOTES | ENT_HTML5, 'UTF-8' );
		if ( $text === '' ) {
			return '';
		}
		$text = str_replace( [ "\r\n", "\r" ], "\n", $text );
		$text = trim( preg_replace( '/\s+/u', ' ', $text ) );
		return nros_schema_sanitize_json_string( $text );
	}
}

if ( ! function_exists( 'nros_schema_prepare_post_content_pack' ) ) {
	/**
	 * Extract + sanitize post body for wordCount, articleBody, and tag scoring.
	 *
	 * @param int          $post_id Post ID.
	 * @param WP_Post|null $post    Optional in-memory post (avoids DB read on singular).
	 * @return array{extracted:string,plain:string}
	 */
	function nros_schema_prepare_post_content_pack( $post_id, $post = null ) {
		$raw       = nros_schema_resolve_post_content_for_schema( $post_id, $post );
		$extracted = nros_schema_extract_article_body_source( $raw );
		if ( trim( $extracted ) === '' ) {
			$rendered = nros_schema_try_rendered_content_fallback( $post_id, $post, '' );
			if ( $rendered !== '' ) {
				$extracted = nros_schema_extract_article_body_source( $rendered );
			}
		}
		$plain = nros_schema_normalize_article_plain( $extracted );
		if ( $plain === '' && $raw !== '' ) {
			$plain = nros_schema_normalize_article_plain( wp_strip_all_tags( strip_shortcodes( $raw ) ) );
			if ( $plain === '' ) {
				$plain = nros_schema_normalize_article_plain( wp_strip_all_tags( $raw ) );
			}
		}
		return [
			'extracted' => $extracted,
			'plain'     => $plain,
		];
	}
}

if ( ! function_exists( 'nros_schema_cached_ld_needs_content_rebuild' ) ) {
	/**
	 * Detect stale single-post cache: wordCount 0 or missing articleBody when settings expect body.
	 *
	 * @param string $cached_html Cached script HTML.
	 */
	function nros_schema_cached_ld_needs_content_rebuild( $cached_html ) {
		if ( ! is_string( $cached_html ) || $cached_html === '' ) {
			return false;
		}
		if ( (bool) preg_match( '/"wordCount"\s*:\s*0(?!\d)/', $cached_html ) ) {
			return true;
		}
		static $body_mode = null;
		if ( null === $body_mode ) {
			$body_mode = sanitize_key( (string) get_option( 'nros_schema_article_body', 'full' ) );
		}
		if ( 'omit' === $body_mode ) {
			return false;
		}
		return (bool) preg_match( '/"@type"\s*:\s*(\[)?\s*"(NewsArticle|LiveBlogPosting)"/', $cached_html )
			&& ! preg_match( '/"articleBody"\s*:/', $cached_html );
	}
}

if ( ! function_exists( 'nros_schema_cached_ld_needs_wordcount_rebuild' ) ) {
	/**
	 * @deprecated 2.5.2 Use nros_schema_cached_ld_needs_content_rebuild().
	 * @param string $cached_html Cached script HTML.
	 */
	function nros_schema_cached_ld_needs_wordcount_rebuild( $cached_html ) {
		return nros_schema_cached_ld_needs_content_rebuild( $cached_html );
	}
}

if ( ! function_exists( 'nros_schema_guess_place_from_lede' ) ) {
	/**
	 * Extract a dateline city from common news lede patterns (e.g. "Site.com, Monterrey - …").
	 *
	 * @param string $text Title, excerpt, or body start.
	 * @return string|null
	 */
	function nros_schema_guess_place_from_lede( $text ) {
		$text = trim( wp_strip_all_tags( (string) $text ) );
		if ( $text === '' ) {
			return null;
		}
		$head = mb_substr( $text, 0, 320 );
		if ( preg_match( '/,\s*([A-Za-zÀ-ÖØ-öø-ÿ][A-Za-zÀ-ÖØ-öø-ÿ\.\'\-\s]{1,48}?)\s*[-–—]/u', $head, $m ) ) {
			$city = trim( (string) $m[1] );
			if ( $city !== '' && mb_strlen( $city ) >= 3 ) {
				return $city;
			}
		}
		if ( preg_match( '/^([A-Za-zÀ-ÖØ-öø-ÿ][A-Za-zÀ-ÖØ-öø-ÿ\.\'\-\s]{1,48}?)\s*[-–—]/u', $head, $m ) ) {
			$city = trim( (string) $m[1] );
			if ( $city !== '' && mb_strlen( $city ) >= 3 && stripos( $city, '.' ) === false ) {
				return $city;
			}
		}

		return null;
	}
}

if ( ! function_exists( 'nros_schema_primary_city_article_fallback' ) ) {
	/**
	 * Whether Primary city may fill article contentLocation/dateline when no story place is detected.
	 * Default off — safer for sites that mix local and international coverage.
	 */
	function nros_schema_primary_city_article_fallback() {
		return get_option( 'nros_primary_city_article_fallback', 'no' ) === 'yes';
	}
}

if ( ! function_exists( 'nros_schema_resolve_content_location' ) ) {
	/**
	 * Article contentLocation: story geography only — never the site Target Geo default.
	 *
	 * Priority: Place tag → known city tag → lede dateline → (optional) Primary city setting.
	 *
	 * @param array|null $dynamic_place Highest-scored Place entity from tags.
	 * @param string     $primary_city    Site primary city option.
	 * @param array      $scored_tags     Tag entities with scores.
	 * @param string     $title           Post title.
	 * @param string     $meta_desc       Meta / excerpt.
	 * @param string     $content         Plain post body.
	 * @return array<string, mixed>|null
	 */
	function nros_schema_resolve_content_location( $dynamic_place, $primary_city, $scored_tags, $title, $meta_desc, $content ) {
		if ( is_array( $dynamic_place ) && ! empty( $dynamic_place['name'] ) ) {
			$node = [
				'@type' => 'Place',
				'name'  => (string) $dynamic_place['name'],
			];
			if ( ! empty( $dynamic_place['sameAs'] ) ) {
				$node['sameAs'] = $dynamic_place['sameAs'];
			}
			if ( ! empty( $dynamic_place['geo'] ) ) {
				$node['geo'] = $dynamic_place['geo'];
			}

			return $node;
		}

		$city_map = nros_get_global_cities_graph();
		$best     = null;
		foreach ( (array) $scored_tags as $t ) {
			if ( empty( $t['entity']['name'] ) ) {
				continue;
			}
			$key = nros_global_unaccent( (string) $t['entity']['name'] );
			if ( ! isset( $city_map[ $key ] ) ) {
				continue;
			}
			$score = (int) ( $t['score'] ?? 0 );
			if ( $best === null || $score > $best['score'] ) {
				$best = [
					'score' => $score,
					'name'  => (string) $t['entity']['name'],
					'data'  => $city_map[ $key ],
				];
			}
		}
		if ( $best !== null ) {
			return nros_schema_build_place_node(
				$best['name'],
				isset( $best['data']['wiki'] ) ? (string) $best['data']['wiki'] : '',
				(string) $best['data']['lat'],
				(string) $best['data']['lng']
			);
		}

		$lede_guess = nros_schema_guess_place_from_lede( $meta_desc );
		if ( $lede_guess === null ) {
			$lede_guess = nros_schema_guess_place_from_lede( $title );
		}
		if ( $lede_guess === null ) {
			$lede_guess = nros_schema_guess_place_from_lede( mb_substr( (string) $content, 0, 400 ) );
		}
		if ( $lede_guess !== null ) {
			$key = nros_global_unaccent( $lede_guess );
			if ( isset( $city_map[ $key ] ) ) {
				$data = $city_map[ $key ];

				return nros_schema_build_place_node(
					$lede_guess,
					isset( $data['wiki'] ) ? (string) $data['wiki'] : '',
					(string) $data['lat'],
					(string) $data['lng']
				);
			}

			return nros_schema_build_place_node( $lede_guess );
		}

		$primary_city = trim( (string) $primary_city );
		if ( $primary_city !== '' && nros_schema_primary_city_article_fallback() ) {
			$key = nros_global_unaccent( $primary_city );
			if ( isset( $city_map[ $key ] ) ) {
				$data = $city_map[ $key ];

				return nros_schema_build_place_node(
					$primary_city,
					isset( $data['wiki'] ) ? (string) $data['wiki'] : '',
					(string) $data['lat'],
					(string) $data['lng']
				);
			}

			return nros_schema_build_place_node( $primary_city );
		}

		return null;
	}
}

// ==========================================
// 0.3. SMART ENTITY TYPE DETECTOR (The AI Fallback)
// ==========================================
if (!function_exists('nros_detect_entity_type')) {
    function nros_detect_entity_type($name, $slug = '') {
        $name_clean = nros_global_unaccent($name);
        $slug_clean = nros_global_unaccent($slug);
        $combined = $name_clean . ' ' . $slug_clean;

        // Only genuinely *named* government programmes should become GovernmentService.
        // Generic common nouns (πρόγραμμα, voucher, επίδομα, σύνταξη, tax, fund…) are NOT
        // services — mislabeling them adds noise and weakens entity accuracy (2026 guidance:
        // uncertain → Thing). Editors can still set the type manually per tag, and the list
        // below is filterable for real named schemes.
        $gov_services = apply_filters( 'nros_schema_government_service_terms', [
            'market pass', 'fuel pass', 'power pass', 'freedom pass', 'youth pass',
            'market-pass', 'fuel-pass', 'power-pass', 'freedom-pass', 'youth-pass',
        ] );
        foreach ($gov_services as $word) { if (mb_strpos($combined, $word, 0, 'UTF-8') !== false) return 'GovernmentService'; }

        $organizations = ['οπεκα', 'υπουργειο', 'τραπεζα', 'bank', 'εταιρεια', 'company', 'organization', 'ministry', 'department', 'agency', 'council', 'commission', 'committee', 'bureau', 'board', 'union', 'association', 'fed', 'ecb', 'imf', 'un', 'who', 'nato', 'university', 'institute', 'hospital', 'police', 'αστυνομια', 'πυροσβεστικη', 'ministerie', 'ministere', 'ministerium', 'gemeente', 'prefecture', 'ayuntamiento', 'universiteit', 'universite', 'universitat'];
        foreach ($organizations as $org) { if (mb_strpos($combined, $org, 0, 'UTF-8') !== false) return 'Organization'; }

        $persons = ['μητσοτακης', 'κασσελακης', 'ανδρουλακης', 'πρωθυπουργος', 'υπουργος', 'προεδρος', 'biden', 'trump', 'harris', 'sunak', 'starmer', 'macron', 'scholz', 'putin', 'zelensky', 'president', 'minister', 'senator', 'governor', 'mayor', 'chancellor', 'premier', 'burgemeester', 'alcalde', 'kanselier', 'presidente', 'ministro', 'minister-president'];
        foreach ($persons as $person) { if (mb_strpos($combined, $person, 0, 'UTF-8') !== false) return 'Person'; }

        $places = ['ελλαδα', 'κυπρος', 'αθηνα', 'θεσσαλονικη', 'πατρα', 'greece', 'cyprus', 'usa', 'uk', 'europe', 'city', 'country', 'state', 'london', 'paris', 'amsterdam', 'rotterdam', 'brussel', 'bruxelles', 'berlin', 'madrid', 'barcelona', 'land', 'stad', 'ville', 'ciudad', 'gemeente'];
        foreach ($places as $place) { if (mb_strpos($combined, $place, 0, 'UTF-8') !== false) return 'Place'; }

        return 'Thing';
    }
}

// ==========================================
// 1. ΠΡΟΣΘΗΚΗ ΠΕΔΙΩΝ WIKIDATA, GEO & ΤΥΠΟΥ ΣΤΑ TAGS
// ==========================================
add_action( 'post_tag_edit_form_fields', 'nros_add_tag_entity_fields' );
add_action( 'post_tag_add_form_fields', 'nros_add_tag_entity_fields' );
if (!function_exists('nros_add_tag_entity_fields')) {
    function nros_add_tag_entity_fields( $term ) {
        $term_id = is_object($term) ? $term->term_id : 0;
        $wiki    = $term_id ? get_term_meta( $term_id, 'nros_wikidata_url', true ) : '';
        $lat     = $term_id ? get_term_meta( $term_id, 'nros_lat', true ) : '';
        $lng     = $term_id ? get_term_meta( $term_id, 'nros_lng', true ) : '';
        $type    = $term_id ? get_term_meta( $term_id, 'nros_entity_type', true ) : 'auto';
        ?>
        <div class="form-field">
            <label for="nros_entity_type">Τύπος Οντότητας (Entity Type)</label>
            <select name="nros_entity_type" id="nros_entity_type">
                <option value="auto" <?php selected($type, 'auto'); ?>>🤖 Αυτόματη Αναγνώριση (Προεπιλογή)</option>
                <option value="Person" <?php selected($type, 'Person'); ?>>👤 Πρόσωπο (Person)</option>
                <option value="Organization" <?php selected($type, 'Organization'); ?>>🏢 Οργανισμός / Εταιρεία</option>
                <option value="Place" <?php selected($type, 'Place'); ?>>📍 Τοποθεσία (Place)</option>
                <option value="Event" <?php selected($type, 'Event'); ?>>📅 Γεγονός (Event)</option>
                <option value="Thing" <?php selected($type, 'Thing'); ?>>📦 Αντικείμενο (Thing)</option>
            </select>
            <p class="description">Επιλέξτε χειροκίνητα τον τύπο για απόλυτη ακρίβεια στο Schema Markup.</p>
        </div>
        <div class="form-field">
            <label for="nros_wikidata_url">Wikidata URL (Newsroom OS Entity SEO)</label>
            <input name="nros_wikidata_url" id="nros_wikidata_url" type="url" value="<?php echo esc_attr( $wiki ); ?>" size="40" placeholder="π.χ. https://www.wikidata.org/wiki/Q94">
            <p class="description">Βοηθάει τη Google να κατανοήσει την "Οντότητα" (Knowledge Graph).</p>
        </div>
        <div class="form-field" style="display:flex; gap:15px; margin-top:15px;">
            <div style="flex:1;">
                <label for="nros_lat">Latitude (Γεωγρ. Πλάτος)</label>
                <input name="nros_lat" id="nros_lat" type="text" value="<?php echo esc_attr( $lat ); ?>" placeholder="π.χ. 37.9838">
            </div>
            <div style="flex:1;">
                <label for="nros_lng">Longitude (Γεωγρ. Μήκος)</label>
                <input name="nros_lng" id="nros_lng" type="text" value="<?php echo esc_attr( $lng ); ?>" placeholder="π.χ. 23.7275">
            </div>
        </div>
        <p class="description"><strong>Hybrid Geo-Location:</strong> Αν συμπληρώσετε συντεταγμένες εδώ, η ετικέτα θεωρείται αυτόματα Τοποθεσία (Place) και θα υπερισχύσει του παγκόσμιου χάρτη!</p>
        <?php
    }
}

add_action( 'edited_post_tag', 'nros_save_tag_entity_fields' );
add_action( 'create_post_tag', 'nros_save_tag_entity_fields' );
if (!function_exists('nros_save_tag_entity_fields')) {
    function nros_save_tag_entity_fields( $term_id ) {
        if ( isset( $_POST['nros_entity_type'] ) ) update_term_meta( $term_id, 'nros_entity_type', sanitize_text_field( $_POST['nros_entity_type'] ) );
        if ( isset( $_POST['nros_wikidata_url'] ) ) {
            $wiki = esc_url_raw( wp_unslash( $_POST['nros_wikidata_url'] ) );
            update_term_meta( $term_id, 'nros_wikidata_url', $wiki );
            if ( $wiki !== '' ) {
                $term = get_term( (int) $term_id, 'post_tag' );
                update_term_meta( $term_id, 'nros_wikidata_source', 'manual' );
                update_term_meta( $term_id, 'nros_wikidata_confidence', '100' );
                if ( $term && ! is_wp_error( $term ) ) {
                    update_term_meta( $term_id, 'nros_wikidata_label', sanitize_text_field( (string) $term->name ) );
                }
                delete_transient( 'newsai_wiki_reject_' . (int) $term_id );
            } else {
                delete_term_meta( $term_id, 'nros_wikidata_source' );
                delete_term_meta( $term_id, 'nros_wikidata_confidence' );
                delete_term_meta( $term_id, 'nros_wikidata_label' );
            }
        }
        if ( isset( $_POST['nros_lat'] ) ) update_term_meta( $term_id, 'nros_lat', sanitize_text_field( $_POST['nros_lat'] ) );
        if ( isset( $_POST['nros_lng'] ) ) update_term_meta( $term_id, 'nros_lng', sanitize_text_field( $_POST['nros_lng'] ) );
    }
}

// ==========================================
// 2. VideoObject Extractor
// ==========================================
if (!function_exists('nros_extract_video_schema')) {
    function nros_extract_video_schema( $content, $post ) {
        $video_url = '';
        if ( preg_match( '/https?:\/\/(?:www\.)?(?:youtube\.com\/watch\?v=[\w\-]+|youtu\.be\/[\w\-]+|vimeo\.com\/\d+)/i', $content, $m ) ) { $video_url = esc_url_raw( $m[0] ); } 
        elseif ( preg_match( '/<iframe[^>]+src=["\']([^"\']+)["\']/i', $content, $m ) ) { $video_url = esc_url_raw( $m[1] ); }
        if ( empty( $video_url ) ) return null;

        $thumbnail_url = has_post_thumbnail($post) ? get_the_post_thumbnail_url($post, 'full') : '';
        $is_youtube = preg_match( '/(?:youtube\.com\/(?:[^\/\n\s]+\/\S+\/|(?:v|e(?:mbed)?)\/|\S*?[?&]v=)|youtu\.be\/)([a-zA-Z0-9_-]{11})/', $video_url, $yt_match );

        if ( $is_youtube ) {
            $video_id      = $yt_match[1];
            $thumbnail_url = "https://img.youtube.com/vi/$video_id/hqdefault.jpg";
            $embed_url     = "https://www.youtube.com/embed/$video_id";
        } else { $embed_url = $video_url; }

        return [
            '@type'        => 'VideoObject',
            '@id'          => get_permalink($post) . '#video',
            'name'         => get_the_title( $post ) . ' (Video)',
            'description'  => wp_trim_words( wp_strip_all_tags( get_the_excerpt( $post ) ), 20 ) ?: 'Video presentation',
            'thumbnailUrl' => [ $thumbnail_url ],
            'uploadDate'   => nros_schema_post_datetime_iso( is_object( $post ) ? (int) $post->ID : (int) $post, 'date' ),
            'embedUrl'     => $embed_url,
            'contentUrl'   => $video_url,
        ];
    }
}

if (!function_exists('nros_build_liveblog_updates')) {
    function nros_build_liveblog_updates($post) {
        $content = (string) $post->post_content;
        if ($content === '') {
            return [];
        }

        preg_match_all('/<(h2|h3)[^>]*>(.*?)<\/\1>/is', $content, $heading_matches);
        $updates = [];
        $max_updates = 5;
        $post_id     = is_object( $post ) ? (int) $post->ID : (int) $post;
        $dt_pack     = function_exists( 'nros_schema_resolve_article_datetimes' )
            ? nros_schema_resolve_article_datetimes( $post_id )
            : [ 'published' => nros_schema_post_datetime_iso( $post_id, 'date' ), 'modified' => nros_schema_post_datetime_iso( $post_id, 'modified' ) ];
        $default_iso = (string) ( $dt_pack['modified'] ?? '' );
        if ( $default_iso === '' ) {
            $default_iso = (string) ( $dt_pack['published'] ?? '' );
        }
        $post_day    = get_post_time( 'Y-m-d', false, $post );
        $tz          = function_exists( 'wp_timezone' ) ? wp_timezone() : new DateTimeZone( 'UTC' );

        $to_iso_time = static function ( $text, $fallback_iso ) use ( $post_day, $tz ) {
            if ( preg_match( '/\b([01]?\d|2[0-3])[:.]([0-5]\d)\b/u', $text, $hm ) ) {
                $h  = str_pad( $hm[1], 2, '0', STR_PAD_LEFT );
                $m  = str_pad( $hm[2], 2, '0', STR_PAD_LEFT );
                $dt = date_create( $post_day . ' ' . $h . ':' . $m . ':00', $tz );
                return $dt ? $dt->format( 'c' ) : $fallback_iso;
            }
            if ( preg_match( '/\b(\d{4})-(\d{2})-(\d{2})[ T]([01]?\d|2[0-3]):([0-5]\d)\b/u', $text, $full ) ) {
                $h  = str_pad( $full[4], 2, '0', STR_PAD_LEFT );
                $m  = str_pad( $full[5], 2, '0', STR_PAD_LEFT );
                $dt = date_create( $full[1] . '-' . $full[2] . '-' . $full[3] . ' ' . $h . ':' . $m . ':00', $tz );
                return $dt ? $dt->format( 'c' ) : $fallback_iso;
            }
            return $fallback_iso;
        };

        if (!empty($heading_matches[2])) {
            foreach ($heading_matches[2] as $idx => $raw_heading) {
                if (count($updates) >= $max_updates) {
                    break;
                }
                $headline = trim(wp_strip_all_tags($raw_heading));
                if ($headline === '') {
                    continue;
                }
                $update_iso = $to_iso_time($headline, $default_iso);
                $updates[] = [
                    '@type' => 'BlogPosting',
                    '@id' => get_permalink($post) . '#live-update-' . ($idx + 1),
                    'headline' => mb_substr($headline, 0, 110),
                    'datePublished' => $update_iso,
                    'dateModified' => $update_iso,
                    'url' => get_permalink($post),
                ];
            }
        }

        if (empty($updates)) {
            $breaking_on = get_post_meta( $post_id, '_newsai_breaking_active', true ) === 'yes'
                || get_post_meta( $post_id, '_newsai_enable_liveblog', true ) === 'yes';
            $skip_fallback = $breaking_on
                || ( function_exists( 'nros_content_looks_like_readme' ) && nros_content_looks_like_readme( $content ) );
            if ( ! $skip_fallback ) {
                $fallback = wp_trim_words( wp_strip_all_tags( $content ), 24, '' );
                if ( $fallback !== '' ) {
                    $updates[] = [
                        '@type' => 'BlogPosting',
                        '@id' => get_permalink( $post ) . '#live-update-1',
                        'headline' => mb_substr( $fallback, 0, 110 ),
                        'datePublished' => $default_iso,
                        'dateModified' => $default_iso,
                        'url' => get_permalink( $post ),
                    ];
                }
            }
        }

        return $updates;
    }
}

if (!function_exists('nros_resolve_effective_schema_type')) {
    function nros_resolve_effective_schema_type($schema_setting, $liveblog_enabled, $liveblog_post_mode) {
        $base_type = ($schema_setting === 'article') ? 'Article' : 'NewsArticle';
        if ($schema_setting !== 'none' && $liveblog_enabled && $liveblog_post_mode) {
            return 'LiveBlogPosting';
        }
        return $base_type;
    }
}

if (!function_exists('nros_is_low_value_keyword')) {
    function nros_is_low_value_keyword($term) {
        $value = trim((string) $term);
        if ($value === '') {
            return true;
        }
        $normalized = nros_global_unaccent(mb_strtolower($value, 'UTF-8'));
        if (mb_strlen($normalized, 'UTF-8') <= 2) {
            return true;
        }
        // Generic editorial + administrative nouns that are not real entities. Keeping them
        // out of about/mentions/keywords avoids the "every tag becomes an entity" noise that
        // hurts entity accuracy (2026: precision over volume). Filterable for site-specifics.
        $deny = apply_filters( 'nros_schema_low_value_keywords', [
            'news', 'article', 'story', 'blog', 'hello', 'hi', 'update', 'newsroom',
            'ειδηση', 'ειδησεις', 'αρθρο', 'αρθρα', 'νεα', 'θεμα', 'θεματα',
            'πλατφορμα', 'προγραμμα', 'προγραμματα', 'γονεις', 'γονεας',
            'αιτηση', 'αιτησεις', 'δικαιουχος', 'δικαιουχοι', 'διαδικασια',
            'platform', 'program', 'programme', 'parents', 'parent',
            'application', 'applications', 'process', 'procedure', 'topic', 'topics',
        ] );
        return in_array($normalized, $deny, true);
    }
}

if (!function_exists('nros_attachment_image_src_dims')) {
    /**
     * Resolves attachment image URL plus width/height. WordPress sometimes returns 0×0 in the src tuple
     * while the URL is still valid — fill from attachment metadata when possible.
     *
     * @return array{url:string,w:int,h:int}|null
     */
    function nros_attachment_image_src_dims( $attachment_id, $size ) {
        $img = wp_get_attachment_image_src( $attachment_id, $size );
        if ( ! $img || empty( $img[0] ) ) {
            return null;
        }
        $w = isset( $img[1] ) ? (int) $img[1] : 0;
        $h = isset( $img[2] ) ? (int) $img[2] : 0;
        if ( $w < 1 || $h < 1 ) {
            $meta = wp_get_attachment_metadata( $attachment_id );
            if ( is_array( $meta ) ) {
                if ( 'full' === $size && ! empty( $meta['width'] ) && ! empty( $meta['height'] ) ) {
                    $w = (int) $meta['width'];
                    $h = (int) $meta['height'];
                } elseif ( ! empty( $meta['sizes'][ $size ]['width'] ) && ! empty( $meta['sizes'][ $size ]['height'] ) ) {
                    $w = (int) $meta['sizes'][ $size ]['width'];
                    $h = (int) $meta['sizes'][ $size ]['height'];
                }
            }
        }
        return [
            'url' => esc_url_raw( $img[0] ),
            'w'   => $w,
            'h'   => $h,
        ];
    }
}

if (!function_exists('nros_build_article_image_urls')) {
    /**
     * Featured + alternate sizes as Schema.org ImageObject nodes (width/height for Discover).
     * thumbnail_url: prefers **full / large / hero** sizes (max width among full, 2048, 1536, large, medium_large),
     * never the `thumbnail` crop, so NewsArticle.thumbnailUrl stays a lead-sized URL. Falls back to attachment file URL
     * then to widest candidate including medium/thumbnail only if nothing else exists.
     * image[]: aspect picks (1:1, 4:3, 16:9) use only variants whose **longer side ≥ 600px** when available, so 150×150
     * does not beat a wide 1536px derivative; size penalty vs. ratio is tuned toward wide lead images.
     *
     * @return array{images: list<array<string,mixed>>, thumbnail_url: string}
     */
    function nros_build_article_image_urls($post_id) {
        $empty = [ 'images' => [], 'thumbnail_url' => '' ];
        if (!has_post_thumbnail($post_id)) {
            return $empty;
        }
        $thumb_id = get_post_thumbnail_id($post_id);
        if (!$thumb_id) {
            return $empty;
        }

        $candidate_sizes = ['full', 'large', 'medium_large', 'medium', 'thumbnail', '1536x1536', '2048x2048'];
        $candidates = [];
        $seen = [];
        foreach ($candidate_sizes as $size) {
            $dims = nros_attachment_image_src_dims( $thumb_id, $size );
            if ( ! $dims ) {
                continue;
            }
            $url = $dims['url'];
            if (isset($seen[$url])) {
                continue;
            }
            $seen[ $url ] = true;
            $candidates[] = ['url' => $url, 'w' => $dims['w'], 'h' => $dims['h']];
        }
        if (empty($candidates)) {
            return $empty;
        }

        // Lead image URL for thumbnailUrl: hero sizes only (avoid 150×150 "thumbnail" winning).
        $thumbnail_url = '';
        $thumb_best_w  = 0;
        foreach ( [ 'full', '2048x2048', '1536x1536', 'large', 'medium_large' ] as $hero_size ) {
            $hero = nros_attachment_image_src_dims( $thumb_id, $hero_size );
            if ( ! $hero || $hero['url'] === '' ) {
                continue;
            }
            $w = max( 0, $hero['w'] );
            if ( $w > $thumb_best_w ) {
                $thumb_best_w  = $w;
                $thumbnail_url = $hero['url'];
            }
        }
        if ( $thumbnail_url === '' ) {
            foreach ( [ 'full', '2048x2048', '1536x1536', 'large', 'medium_large' ] as $hero_size ) {
                $hero = nros_attachment_image_src_dims( $thumb_id, $hero_size );
                if ( $hero && $hero['url'] !== '' ) {
                    $thumbnail_url = $hero['url'];
                    break;
                }
            }
        }
        if ( $thumbnail_url === '' ) {
            $orig = wp_get_attachment_url( $thumb_id );
            if ( $orig ) {
                $thumbnail_url = esc_url_raw( $orig );
            }
        }
        if ( $thumbnail_url === '' ) {
            $best_w_ge = 0;
            foreach ( $candidates as $c ) {
                if ( $c['w'] >= 1200 && $c['w'] > $best_w_ge ) {
                    $best_w_ge     = $c['w'];
                    $thumbnail_url = $c['url'];
                }
            }
        }
        if ( $thumbnail_url === '' ) {
            $best_w = 0;
            foreach ( $candidates as $c ) {
                if ( $c['w'] > $best_w ) {
                    $best_w        = $c['w'];
                    $thumbnail_url = $c['url'];
                }
            }
        }

        // Aspect-tuned `image[]` must not pick micro-crops (e.g. 150×150): they win ratio=1:1 with a naive score.
        $min_main_dim = 600;
        $aspect_pool    = array_values(
            array_filter(
                $candidates,
                static function ( $c ) use ( $min_main_dim ) {
                    return max( (int) $c['w'], (int) $c['h'] ) >= $min_main_dim;
                }
            )
        );
        if ( empty( $aspect_pool ) ) {
            $aspect_pool = $candidates;
        }

        $pick_best = static function ( $target_ratio ) use ( $aspect_pool ) {
            $best = null;
            $best_score = PHP_FLOAT_MAX;
            foreach ( $aspect_pool as $c ) {
                if ( $c['w'] <= 0 || $c['h'] <= 0 ) {
                    continue;
                }
                $ratio        = $c['w'] / $c['h'];
                $ratio_delta  = abs( $ratio - $target_ratio );
                $size_penalty = ( $c['w'] >= 1200 ) ? 0 : ( ( 1200 - min( $c['w'], 1200 ) ) / 1200 );
                $score        = $ratio_delta + ( $size_penalty * 1.15 );
                if ( $score < $best_score ) {
                    $best_score = $score;
                    $best       = $c['url'];
                }
            }
            return $best;
        };

        $ordered_urls = [];
        foreach ( [ 1 / 1, 4 / 3, 16 / 9 ] as $target ) {
            $picked = $pick_best( $target );
            if ( $picked && ! in_array( $picked, $ordered_urls, true ) ) {
                $ordered_urls[] = $picked;
            }
        }
        if ( empty( $ordered_urls ) && ! empty( $aspect_pool[0]['url'] ) ) {
            $ordered_urls[] = $aspect_pool[0]['url'];
        }

        $dims = [];
        foreach ( $candidates as $c ) {
            $dims[ $c['url'] ] = [ 'w' => $c['w'], 'h' => $c['h'] ];
        }
        $out = [];
        foreach ( $ordered_urls as $i => $u ) {
            $node = [ '@type' => 'ImageObject', 'url' => $u ];
            if ( isset( $dims[ $u ] ) && $dims[ $u ]['w'] > 0 && $dims[ $u ]['h'] > 0 ) {
                $node['width']  = $dims[ $u ]['w'];
                $node['height'] = $dims[ $u ]['h'];
            }
            if ( 0 === $i ) {
                $node['representativeOfPage'] = true;
            }
            $out[] = $node;
        }
        return [
            'images'         => $out,
            'thumbnail_url'  => $thumbnail_url,
        ];
    }
}

if (!function_exists('nros_schema_parse_speakable_selectors')) {
    /**
     * Comma-separated SpeakableSpecification cssSelector values; bare tokens like "entry-title" become ".entry-title".
     * Space-separated class names without dots (e.g. "entry-content clear") become one compound selector ".entry-content.clear".
     *
     * @return list<string>
     */
    function nros_schema_parse_speakable_selectors( $raw ) {
        $raw = trim( (string) $raw );
        if ( $raw === '' ) {
            return [];
        }
        $parts = array_map( 'trim', explode( ',', $raw ) );
        $out   = [];
        foreach ( $parts as $s ) {
            if ( $s === '' ) {
                continue;
            }
            if ( preg_match( '/[>+~]/', $s ) ) {
                $out[] = $s;
                continue;
            }
            if ( preg_match( '/\s/', $s ) ) {
                $toks = preg_split( '/\s+/', $s, -1, PREG_SPLIT_NO_EMPTY );
                $all  = true;
                foreach ( $toks as $t ) {
                    if ( ! preg_match( '/^[a-zA-Z0-9_-]+$/', $t ) ) {
                        $all = false;
                        break;
                    }
                }
                if ( $all && count( $toks ) > 1 ) {
                    $tag_like = [
                        'article' => true, 'div' => true, 'section' => true, 'main' => true, 'header' => true,
                        'footer' => true, 'aside' => true, 'nav' => true, 'body' => true, 'html' => true, 'span' => true,
                        'p' => true, 'h1' => true, 'h2' => true, 'h3' => true, 'h4' => true, 'h5' => true, 'h6' => true,
                        'ul' => true, 'ol' => true, 'li' => true, 'a' => true,
                    ];
                    $has_tag = false;
                    foreach ( $toks as $t ) {
                        if ( isset( $tag_like[ strtolower( $t ) ] ) ) {
                            $has_tag = true;
                            break;
                        }
                    }
                    if ( ! $has_tag ) {
                        $out[] = '.' . implode( '.', $toks );
                        continue;
                    }
                }
                $out[] = $s;
                continue;
            }
            $first = $s[0];
            if ( $first === '.' || $first === '#' || $first === '[' || $first === '*' ) {
                $out[] = $s;
                continue;
            }
            if ( $first === ':' && isset( $s[1] ) && $s[1] === ':' ) {
                $out[] = $s;
                continue;
            }
            if ( preg_match( '/^[a-zA-Z0-9_-]+$/', $s ) ) {
                $out[] = '.' . $s;
                continue;
            }
            $out[] = $s;
        }
        return array_values( array_unique( $out ) );
    }
}

if ( ! function_exists( 'nros_speakable_finalize_schema_selectors' ) ) {
    /**
     * When the concise lede snippet is enabled, optionally strips broad article-body selectors from Speakable
     * (they pull the full DOM text — the character cap applies only to `.nros-speakable-snippet`). Ensures the
     * snippet selector is present. Filter `nros_speakable_body_selector_substrings` customizes stripped fragments.
     *
     * @param list<string> $speakable_selectors Already parsed/trimmed selectors (after `nros_schema_speakable_css_selectors`).
     * @param string       $richness            Schema richness preset: minimal|balanced|full.
     * @param int          $post_id             Context post (0 for admin preview).
     * @param WP_Post|null $post
     * @return list<string>
     */
    function nros_speakable_finalize_schema_selectors( array $speakable_selectors, $richness, $post_id = 0, $post = null ) {
        $richness   = sanitize_key( (string) $richness );
        $snippet_on = ( get_option( 'nros_speakable_snippet_enabled', 'no' ) === 'yes' );
        $strip_body = ( get_option( 'nros_speakable_snippet_strip_body', 'yes' ) !== 'no' );
        if ( $snippet_on && 'minimal' !== $richness && $strip_body && ! empty( $speakable_selectors ) ) {
            $ban_subs = apply_filters(
                'nros_speakable_body_selector_substrings',
                [
                    'entry-content',
                    'post-content',
                    'article-content',
                    'wp-block-post-content',
                    'post__content',
                    'td-post-content',
                    'article__body',
                    'post-entry',
                    'entry__content',
                ],
                (int) $post_id,
                $post
            );
            $filtered = [];
            foreach ( $speakable_selectors as $sel ) {
                if ( ! is_string( $sel ) || $sel === '' ) {
                    continue;
                }
                $low  = strtolower( $sel );
                $drop = false;
                foreach ( $ban_subs as $frag ) {
                    $frag = strtolower( (string) $frag );
                    if ( $frag === '' ) {
                        continue;
                    }
                    if ( strpos( $low, $frag ) !== false ) {
                        $drop = true;
                        break;
                    }
                }
                if ( ! $drop ) {
                    $filtered[] = $sel;
                }
            }
            $speakable_selectors = array_values( array_unique( $filtered ) );
        }
        if ( $snippet_on && 'minimal' !== $richness ) {
            $has_snip = false;
            foreach ( $speakable_selectors as $sel ) {
                if ( ! is_string( $sel ) ) {
                    continue;
                }
                if ( strpos( $sel, 'nros-speakable-snippet' ) !== false ) {
                    $has_snip = true;
                    break;
                }
            }
            if ( ! $has_snip ) {
                array_unshift( $speakable_selectors, '.nros-speakable-snippet' );
                $speakable_selectors = array_values( array_unique( $speakable_selectors ) );
            }
        }
        return $speakable_selectors;
    }
}

// ==========================================
// 3. Dynamic Author Entity Generator
// ==========================================
if (!function_exists('nros_get_dynamic_author_entity')) {
    /**
     * @param int    $author_id WP user ID.
     * @param string $variant   full (default) | slim — slim keeps image, worksFor, knowsAbout, sameAs; bio/jobTitle/alumniOf on author archive only.
     */
    function nros_get_dynamic_author_entity( $author_id, $variant = 'full' ) {
        $variant = ( 'slim' === $variant ) ? 'slim' : 'full';
        $schema_version = get_option('nros_schema_global_v', '1');
        $cache_key = 'nros_schema_author_v158_' . $author_id . '_' . $schema_version . '_' . $variant;
        $cached_entity = get_transient( $cache_key );
        if ( false !== $cached_entity ) {
            return $cached_entity;
        }

        $author = get_userdata( $author_id );
        if ( ! $author ) return null;

        $avatar_url = set_url_scheme(get_avatar_url($author_id), 'https');

        $entity = [
            '@type'    => 'Person',
            '@id'      => trailingslashit(get_author_posts_url( $author_id )) . '#author',
            'name'     => $author->display_name,
            'url'      => get_author_posts_url( $author_id ),
            'image'    => [ '@type' => 'ImageObject', 'url' => $avatar_url, 'width' => 96, 'height' => 96 ],
        ];

        $bio = get_the_author_meta('description', $author_id);
        if (!empty($bio)) { $entity['description'] = wp_trim_words(wp_strip_all_tags($bio), 45); }

        $job_title = get_the_author_meta( 'nros_job_title', $author_id );
        if ($job_title) $entity['jobTitle'] = $job_title;

        $education = get_the_author_meta( 'nros_alumni_of', $author_id );
        if ( $education ) {
            if ( strpos( $education, '|' ) !== false ) {
                $al_parts = explode( '|', $education );
                $alumni   = [
                    '@type' => 'EducationalOrganization',
                    'name'  => trim( $al_parts[0] ),
                ];
                if ( isset( $al_parts[1] ) && trim( $al_parts[1] ) !== '' ) {
                    $alumni['sameAs'] = esc_url_raw( trim( $al_parts[1] ) );
                }
                $entity['alumniOf'] = $alumni;
            } else {
                $entity['alumniOf'] = [
                    '@type' => 'EducationalOrganization',
                    'name'  => trim( $education ),
                ];
            }
        }

        $sameAs = [];
        if ( $fb = get_the_author_meta( 'nros_social_facebook', $author_id ) ) $sameAs[] = esc_url_raw( $fb );
        if ( $li = get_the_author_meta( 'nros_social_linkedin', $author_id ) ) $sameAs[] = esc_url_raw( $li );
        if ( $tw = get_the_author_meta( 'nros_social_twitter', $author_id ) )  $sameAs[] = esc_url_raw( $tw );
        if ( $wb = $author->user_url ) $sameAs[] = esc_url_raw( $wb );
        if ( ! empty( $sameAs ) ) $entity['sameAs'] = array_values(array_unique($sameAs));

        $knows_array = [];
        $knows_raw = get_the_author_meta('nros_knows_about', $author_id);
        if ( ! empty( $knows_raw ) ) {
            $knows_raw = str_replace("\r", "", $knows_raw); 
            $knows_lines = array_filter(array_map('trim', explode("\n", $knows_raw)));
            foreach ($knows_lines as $line) {
                $k_parts = explode('|', $line);
                $topic_name = trim($k_parts[0]);
                $thing = [ '@type' => nros_detect_entity_type($topic_name), 'name' => $topic_name, 'url' => home_url('/tag/' . sanitize_title($topic_name)) ];
                if (isset($k_parts[1]) && !empty(trim($k_parts[1]))) { $thing['sameAs'] = esc_url_raw(trim($k_parts[1])); }
                $knows_array[] = $thing;
            }
        }
        if (!empty($knows_array)) { $entity['knowsAbout'] = $knows_array; }

        if ( 'slim' === $variant ) {
            $entity['worksFor'] = [ '@id' => home_url() . '#organization' ];
            unset(
                $entity['alumniOf'],
                $entity['description'],
                $entity['jobTitle']
            );
        }

        set_transient( $cache_key, $entity, 30 * DAY_IN_SECONDS );
        return $entity;
    }
}

// ==========================================
// 3.9 ABOUT PAGE (Settings page ID → AboutPage schema)
// ==========================================
if ( ! function_exists( 'newsai_get_about_page_id' ) ) {
	/**
	 * WordPress page ID for the About / Who we are page (0 = disabled).
	 *
	 * @return int
	 */
	function newsai_get_about_page_id() {
		return max( 0, (int) get_option( 'newsai_about_page_id', 0 ) );
	}
}

if ( ! function_exists( 'nros_schema_page_description' ) ) {
	/**
	 * Meta description for static pages (SEO plugins → excerpt → trimmed body).
	 *
	 * @param int $page_id Page ID.
	 * @return string
	 */
	function nros_schema_page_description( $page_id ) {
		$page_id = (int) $page_id;
		if ( $page_id < 1 ) {
			return '';
		}
		$meta = get_post_meta( $page_id, 'rank_math_description', true );
		if ( ! is_string( $meta ) || trim( $meta ) === '' ) {
			$meta = get_post_meta( $page_id, '_yoast_wpseo_metadesc', true );
		}
		if ( is_string( $meta ) && trim( $meta ) !== '' ) {
			return nros_schema_plain_text_field( $meta );
		}
		if ( has_excerpt( $page_id ) ) {
			return nros_schema_plain_text_field( get_the_excerpt( $page_id ) );
		}
		$content = get_post_field( 'post_content', $page_id );
		if ( ! is_string( $content ) || $content === '' ) {
			return '';
		}
		return nros_schema_plain_text_field( wp_trim_words( strip_shortcodes( $content ), 40, '' ) );
	}
}

// ==========================================
// 4. CACHE (post_meta + transient) & INVALIDATION
// ==========================================
if ( ! function_exists( 'nros_schema_get_versioned_option_keys' ) ) {
	/**
	 * Options that change JSON-LD output when modified (bump global version only if these change).
	 *
	 * @return string[]
	 */
	function nros_schema_get_versioned_option_keys() {
		return [
			'nros_site_name',
			'newsai_site_desc',
			'newsai_geo',
			'nros_primary_city',
			'nros_primary_city_article_fallback',
			'nros_schema_type',
			'nros_org_type',
			'nros_schema_output_mode',
			'nros_schema_richness',
			'nros_schema_article_body',
			'nros_schema_article_body_words',
			'newsai_liveblog_schema_enabled',
			'nros_org_logo_url',
			'nros_org_legal_name',
			'nros_org_alternate_name',
			'nros_org_email',
			'nros_org_phone',
			'nros_org_tax_id',
			'nros_org_vat_id',
			'nros_org_iso6523',
			'nros_org_naics',
			'nros_org_address_country',
			'nros_org_address_locality',
			'nros_org_address_region',
			'nros_org_address_postal_code',
			'nros_org_address_street',
			'nros_social_links',
			'nros_org_publishing_principles_url',
			'nros_org_corrections_policy_url',
			'nros_org_ownership_funding_url',
			'nros_org_actionable_feedback_url',
			'newsai_custom_feed_pages',
			'newsai_about_page_id',
			'newsai_speakable_selectors',
			'nros_speakable_snippet_enabled',
			'nros_speakable_snippet_max_chars',
			'nros_speakable_snippet_strip_body',
		];
	}
}

if ( ! function_exists( 'nros_schema_snapshot_versioned_options' ) ) {
	/**
	 * @return array<string, mixed>
	 */
	function nros_schema_snapshot_versioned_options() {
		$snap = [];
		foreach ( nros_schema_get_versioned_option_keys() as $key ) {
			$snap[ $key ] = get_option( $key );
		}
		return $snap;
	}
}

if ( ! function_exists( 'nros_schema_versioned_options_changed' ) ) {
	/**
	 * @param array<string, mixed> $before Snapshot from {@see nros_schema_snapshot_versioned_options()}.
	 */
	function nros_schema_versioned_options_changed( array $before ) {
		foreach ( $before as $key => $old ) {
			if ( (string) get_option( $key ) !== (string) $old ) {
				return true;
			}
		}
		return false;
	}
}

if ( ! function_exists( 'nros_schema_maybe_bump_global_version' ) ) {
	/**
	 * Invalidate cached JSON-LD only when schema-affecting options actually changed.
	 *
	 * @param array<string, mixed> $before Snapshot taken before settings save.
	 */
	function nros_schema_maybe_bump_global_version( array $before ) {
		if ( nros_schema_versioned_options_changed( $before ) ) {
			update_option( 'nros_schema_global_v', (string) time() );
			if ( function_exists( 'nros_schema_mark_stale_backlog_after_global_bump' ) ) {
				nros_schema_mark_stale_backlog_after_global_bump();
			}
		}
	}
}

if ( ! function_exists( 'nros_schema_post_meta_json_key' ) ) {
	function nros_schema_post_meta_json_key() {
		return '_nros_schema_ld_json';
	}
}

if ( ! function_exists( 'nros_schema_post_meta_version_key' ) ) {
	function nros_schema_post_meta_version_key() {
		return '_nros_schema_ld_v';
	}
}

if ( ! function_exists( 'nros_schema_get_global_version' ) ) {
	function nros_schema_get_global_version() {
		return (string) get_option( 'nros_schema_global_v', '1' );
	}
}

if ( ! function_exists( 'nros_schema_is_megasite_mode' ) ) {
	/**
	 * Megasite mode: on a global schema-version change, serve the existing (stale)
	 * per-post JSON-LD and migrate posts in the background instead of cold-rebuilding
	 * every article on its next pageview. Prevents CPU storms on high-traffic sites
	 * with thousands of posts when global schema settings/author/plugin versions change.
	 *
	 * @return bool
	 */
	function nros_schema_is_megasite_mode() {
		// Default ON: serve-stale + background warm is the safe behavior for every site
		// (small sites rebuild instantly on save anyway; large sites avoid CPU storms).
		$enabled = ( get_option( 'nros_schema_megasite_mode', 'yes' ) === 'yes' );
		return (bool) apply_filters( 'nros_schema_megasite_mode', $enabled );
	}
}

if ( ! function_exists( 'nros_schema_sanitize_hm' ) ) {
	/**
	 * Normalize HH:MM (site timezone wall clock).
	 *
	 * @param string $raw        User input.
	 * @param int    $default_h  Fallback hour.
	 * @param int    $default_m  Fallback minute.
	 * @return string
	 */
	function nros_schema_sanitize_hm( $raw, $default_h = 2, $default_m = 0 ) {
		$raw = trim( (string) $raw );
		if ( preg_match( '/^(\d{1,2}):(\d{2})$/', $raw, $m ) ) {
			$h  = max( 0, min( 23, (int) $m[1] ) );
			$mi = max( 0, min( 59, (int) $m[2] ) );
			return sprintf( '%02d:%02d', $h, $mi );
		}
		return sprintf( '%02d:%02d', max( 0, min( 23, (int) $default_h ) ), max( 0, min( 59, (int) $default_m ) ) );
	}
}

if ( ! function_exists( 'nros_schema_parse_hm' ) ) {
	/**
	 * @param string $hm         HH:MM.
	 * @param int    $default_h  Fallback hour.
	 * @param int    $default_m  Fallback minute.
	 * @return int[] [hour, minute]
	 */
	function nros_schema_parse_hm( $hm, $default_h = 2, $default_m = 0 ) {
		$hm = nros_schema_sanitize_hm( $hm, $default_h, $default_m );
		$parts = explode( ':', $hm );
		return [ (int) $parts[0], (int) $parts[1] ];
	}
}

if ( ! function_exists( 'nros_schema_hm_to_minutes' ) ) {
	/**
	 * @param string $hm HH:MM.
	 * @return int
	 */
	function nros_schema_hm_to_minutes( $hm ) {
		list( $h, $m ) = nros_schema_parse_hm( $hm );
		return ( $h * 60 ) + $m;
	}
}

if ( ! function_exists( 'nros_schema_nightly_warm_enabled' ) ) {
	/**
	 * @return bool
	 */
	function nros_schema_nightly_warm_enabled() {
		return get_option( 'nros_schema_nightly_warm', 'no' ) === 'yes';
	}
}

if ( ! function_exists( 'nros_schema_nightly_warm_window_active' ) ) {
	/**
	 * True when site-local time is inside the configured nightly warm window.
	 * Supports overnight spans (e.g. 22:00–06:00).
	 *
	 * @return bool
	 */
	function nros_schema_nightly_warm_window_active() {
		if ( ! nros_schema_nightly_warm_enabled() ) {
			return false;
		}
		$start_m = nros_schema_hm_to_minutes( (string) get_option( 'nros_schema_nightly_warm_start', '02:00' ) );
		$end_m   = nros_schema_hm_to_minutes( (string) get_option( 'nros_schema_nightly_warm_end', '06:00' ) );
		$now_m   = nros_schema_hm_to_minutes( wp_date( 'H:i' ) );

		if ( $start_m === $end_m ) {
			return false;
		}
		if ( $start_m < $end_m ) {
			return $now_m >= $start_m && $now_m < $end_m;
		}
		return $now_m >= $start_m || $now_m < $end_m;
	}
}

if ( ! function_exists( 'nros_schema_nightly_warm_next_timestamp' ) ) {
	/**
	 * Next occurrence of start or end wall-clock time (site timezone).
	 *
	 * @param string $which start|end.
	 * @return int Unix timestamp.
	 */
	function nros_schema_nightly_warm_next_timestamp( $which ) {
		$which   = ( $which === 'end' ) ? 'end' : 'start';
		$opt_key = ( $which === 'end' ) ? 'nros_schema_nightly_warm_end' : 'nros_schema_nightly_warm_start';
		$default = ( $which === 'end' ) ? '06:00' : '02:00';
		$def_h   = ( $which === 'end' ) ? 6 : 2;
		list( $h, $m ) = nros_schema_parse_hm( (string) get_option( $opt_key, $default ), $def_h, 0 );

		$tz     = wp_timezone();
		$now    = new DateTimeImmutable( 'now', $tz );
		$target = $now->setTime( $h, $m, 0 );
		if ( $target <= $now ) {
			$target = $target->modify( '+1 day' );
		}
		return $target->getTimestamp();
	}
}

if ( ! function_exists( 'nros_schema_sync_lite_warm_cron_state' ) ) {
	/**
	 * Start or stop legacy warm cron based on Lite + nightly window + master switches.
	 *
	 * @return void
	 */
	function nros_schema_sync_lite_warm_cron_state() {
		if ( get_option( 'nros_schema_type', 'newsarticle' ) === 'none' ) {
			wp_clear_scheduled_hook( 'nros_schema_legacy_warm_batch' );
			return;
		}
		if ( function_exists( 'nros_schema_background_warm_allowed' ) && nros_schema_background_warm_allowed() ) {
			if ( function_exists( 'nros_schema_schedule_legacy_warm_cron' ) ) {
				$aggressive = function_exists( 'nros_schema_nightly_warm_window_active' ) && nros_schema_nightly_warm_window_active();
				nros_schema_schedule_legacy_warm_cron( $aggressive );
			}
			return;
		}
		wp_clear_scheduled_hook( 'nros_schema_legacy_warm_batch' );
	}
}

if ( ! function_exists( 'nros_schema_schedule_nightly_warm_events' ) ) {
	/**
	 * Schedule daily start/end hooks + hourly safety pulse for nightly warm.
	 *
	 * @param bool $force_reschedule Clear and rebuild start/end single events.
	 * @return void
	 */
	function nros_schema_schedule_nightly_warm_events( $force_reschedule = false ) {
		if ( ! nros_schema_nightly_warm_enabled() ) {
			wp_clear_scheduled_hook( 'nros_schema_nightly_warm_start' );
			wp_clear_scheduled_hook( 'nros_schema_nightly_warm_end' );
			wp_clear_scheduled_hook( 'nros_schema_nightly_warm_pulse' );
			nros_schema_sync_lite_warm_cron_state();
			return;
		}

		$next_start = wp_next_scheduled( 'nros_schema_nightly_warm_start' );
		$next_end   = wp_next_scheduled( 'nros_schema_nightly_warm_end' );
		if ( ! $force_reschedule && $next_start && $next_end ) {
			if ( ! wp_next_scheduled( 'nros_schema_nightly_warm_pulse' ) ) {
				wp_schedule_event( time() + 5 * MINUTE_IN_SECONDS, 'hourly', 'nros_schema_nightly_warm_pulse' );
			}
			nros_schema_sync_lite_warm_cron_state();
			return;
		}

		wp_clear_scheduled_hook( 'nros_schema_nightly_warm_start' );
		wp_clear_scheduled_hook( 'nros_schema_nightly_warm_end' );
		wp_schedule_single_event( nros_schema_nightly_warm_next_timestamp( 'start' ), 'nros_schema_nightly_warm_start' );
		wp_schedule_single_event( nros_schema_nightly_warm_next_timestamp( 'end' ), 'nros_schema_nightly_warm_end' );

		if ( ! wp_next_scheduled( 'nros_schema_nightly_warm_pulse' ) ) {
			wp_schedule_event( time() + 5 * MINUTE_IN_SECONDS, 'hourly', 'nros_schema_nightly_warm_pulse' );
		}

		nros_schema_sync_lite_warm_cron_state();
	}
}

add_action( 'nros_schema_nightly_warm_start', 'nros_schema_on_nightly_warm_start' );
if ( ! function_exists( 'nros_schema_on_nightly_warm_start' ) ) {
	function nros_schema_on_nightly_warm_start() {
		nros_schema_sync_lite_warm_cron_state();
		nros_schema_schedule_nightly_warm_events();
	}
}

add_action( 'nros_schema_nightly_warm_end', 'nros_schema_on_nightly_warm_end' );
if ( ! function_exists( 'nros_schema_on_nightly_warm_end' ) ) {
	function nros_schema_on_nightly_warm_end() {
		nros_schema_sync_lite_warm_cron_state();
		nros_schema_schedule_nightly_warm_events();
	}
}

add_action( 'nros_schema_nightly_warm_pulse', 'nros_schema_on_nightly_warm_pulse' );
if ( ! function_exists( 'nros_schema_on_nightly_warm_pulse' ) ) {
	function nros_schema_on_nightly_warm_pulse() {
		if ( ! nros_schema_nightly_warm_enabled() ) {
			return;
		}
		nros_schema_sync_lite_warm_cron_state();
	}
}
add_action( 'admin_init', 'nros_schema_schedule_nightly_warm_events' );

if ( ! function_exists( 'nros_schema_background_warm_allowed' ) ) {
	/**
	 * Whether the recurring schema backfill cron may run.
	 *
	 * Lite mode keeps JSON-LD output but must NOT run the migration engine (15-min
	 * batches + backlog probes) — that is the main CPU spike on megasites when
	 * "only schema" is enabled. Publish/save still warms the edited post inline.
	 *
	 * Nightly warm window overrides Lite (and background_warm=no) for off-peak backfill.
	 *
	 * Option: nros_schema_background_warm = yes|no (default yes; Lite forces off unless filter).
	 *
	 * @return bool
	 */
	function nros_schema_background_warm_allowed() {
		if ( get_option( 'nros_schema_type', 'newsarticle' ) === 'none' ) {
			return false;
		}
		if ( function_exists( 'nros_schema_nightly_warm_window_active' ) && nros_schema_nightly_warm_window_active() ) {
			return (bool) apply_filters( 'nros_schema_background_warm_allowed', true );
		}
		if ( get_option( 'nros_schema_background_warm', 'yes' ) === 'no' ) {
			return (bool) apply_filters( 'nros_schema_background_warm_allowed', false );
		}
		// Lite Mode: schema stays, background warm stops (prevents 80–100% load).
		if ( function_exists( 'newsai_lite_mode_active' ) && newsai_lite_mode_active() ) {
			return (bool) apply_filters( 'nros_schema_warm_under_lite', false );
		}
		return (bool) apply_filters( 'nros_schema_background_warm_allowed', true );
	}
}

if ( ! function_exists( 'nros_schema_is_warm_context' ) ) {
	/**
	 * True while cron/shutdown is building schema (allows full cold builds off the frontend).
	 */
	function nros_schema_is_warm_context() {
		return ! empty( $GLOBALS['nros_schema_warming'] );
	}
}

if ( ! function_exists( 'nros_schema_set_warm_context' ) ) {
	/**
	 * @param bool $on Warm context flag.
	 */
	function nros_schema_set_warm_context( $on = true ) {
		$GLOBALS['nros_schema_warming'] = (bool) $on;
	}
}

if ( ! function_exists( 'nros_schema_should_defer_frontend_build' ) ) {
	/**
	 * Megasite guard: never run a full JSON-LD cold build during a normal pageview.
	 * Background cron + save_post warm migrate caches; visitors get minimal or stale markup.
	 */
	function nros_schema_should_defer_frontend_build() {
		if ( nros_schema_is_warm_context() || wp_doing_cron() || is_admin() ) {
			return false;
		}
		if ( ! function_exists( 'nros_schema_is_megasite_mode' ) || ! nros_schema_is_megasite_mode() ) {
			return false;
		}
		return (bool) apply_filters( 'nros_schema_defer_frontend_build', true );
	}
}

if ( ! function_exists( 'nros_schema_resolve_schema_lang' ) ) {
	/**
	 * BCP 47 locale for JSON-LD inLanguage fields.
	 */
	function nros_schema_resolve_schema_lang() {
		$wp_locale = get_locale();
		if ( ! is_string( $wp_locale ) || $wp_locale === '' ) {
			$wp_locale = 'en_US';
		}
		$schema_lang = str_replace( '_', '-', $wp_locale );
		if ( $schema_lang === 'el' ) {
			$schema_lang = 'el-GR';
		}
		return (string) apply_filters( 'nros_schema_lang', $schema_lang );
	}
}

if ( ! function_exists( 'nros_schema_build_slim_publisher_node' ) ) {
	/**
	 * Lightweight Organization node (no logo hunting) for deferred megasite output.
	 *
	 * @return array<string, mixed>
	 */
	function nros_schema_build_slim_publisher_node() {
		$org_type  = (string) get_option( 'nros_org_type', 'NewsMediaOrganization' );
		$site_name = (string) get_option( 'nros_site_name', (string) get_bloginfo( 'name' ) );
		if ( $site_name === '' ) {
			$site_name = (string) get_bloginfo( 'name' );
		}
		if ( $site_name === '' ) {
			$site_name = 'Site';
		}
		$publisher = [
			'@type' => $org_type,
			'@id'   => home_url() . '#organization',
			'name'  => $site_name,
			'url'   => home_url(),
		];
		$site_desc = (string) get_option( 'newsai_site_desc', (string) get_bloginfo( 'description' ) );
		if ( $site_desc !== '' ) {
			$publisher['description'] = $site_desc;
		}
		return (array) apply_filters( 'nros_schema_slim_publisher_node', $publisher );
	}
}

if ( ! function_exists( 'nros_schema_emit_minimal_post_ld' ) ) {
	/**
	 * Cheap NewsArticle JSON-LD for megasite cache misses (no tag/wiki/content-pack work).
	 *
	 * @param int $post_id Post ID.
	 * @return string Script HTML or empty.
	 */
	function nros_schema_emit_minimal_post_ld( $post_id ) {
		$post_id = (int) $post_id;
		if ( $post_id < 1 ) {
			return '';
		}
		$post = get_post( $post_id );
		if ( ! $post || $post->post_type !== 'post' || $post->post_status !== 'publish' ) {
			return '';
		}

		$schema_lang   = nros_schema_resolve_schema_lang();
		$schema_setting = get_option( 'nros_schema_type', 'newsarticle' );
		$article_type   = ( $schema_setting === 'article' ) ? 'Article' : 'NewsArticle';
		$permalink      = get_permalink( $post_id );
		if ( ! $permalink ) {
			return '';
		}

		$published_iso = function_exists( 'nros_schema_post_datetime_iso' )
			? nros_schema_post_datetime_iso( $post_id, 'date' )
			: '';
		$modified_iso = function_exists( 'nros_schema_post_datetime_iso' )
			? nros_schema_post_datetime_iso( $post_id, 'modified' )
			: '';

		$publisher = nros_schema_build_slim_publisher_node();
		$site_name = (string) ( $publisher['name'] ?? get_bloginfo( 'name' ) );
		$site_desc = (string) get_option( 'newsai_site_desc', (string) get_bloginfo( 'description' ) );
		$website   = function_exists( 'nros_schema_build_website_node' )
			? nros_schema_build_website_node( $site_name, $site_desc, $schema_lang )
			: [
				'@type'     => 'WebSite',
				'@id'       => home_url() . '#website',
				'url'       => home_url(),
				'name'      => $site_name,
				'inLanguage' => $schema_lang,
				'publisher' => [ '@id' => home_url() . '#organization' ],
			];

		$headline = function_exists( 'nros_schema_plain_text_field' )
			? nros_schema_plain_text_field( get_the_title( $post_id ) )
			: wp_strip_all_tags( get_the_title( $post_id ) );
		$headline = mb_substr( $headline, 0, 110 );

		$article = [
			'@type'            => $article_type,
			'@id'              => trailingslashit( $permalink ) . '#article',
			'url'              => $permalink,
			'headline'         => $headline,
			'datePublished'    => $published_iso,
			'dateModified'     => $modified_iso,
			'publisher'        => [ '@id' => home_url() . '#organization' ],
			'mainEntityOfPage' => [ '@id' => trailingslashit( $permalink ) . '#webpage' ],
			'inLanguage'       => $schema_lang,
			'isAccessibleForFree' => ( get_option( 'nros_paywall_active', 'no' ) === 'yes' ) ? false : true,
		];

		$author_id = (int) $post->post_author;
		if ( $author_id ) {
			$au = get_userdata( $author_id );
			if ( $au ) {
				$article['author'] = [
					'@type' => 'Person',
					'name'  => $au->display_name,
					'url'   => get_author_posts_url( $author_id ),
				];
			}
		}

		$webpage = [
			'@type'        => 'WebPage',
			'@id'          => trailingslashit( $permalink ) . '#webpage',
			'url'          => $permalink,
			'name'         => get_the_title( $post_id ),
			'inLanguage'   => $schema_lang,
			'isPartOf'     => [ '@id' => home_url() . '#website' ],
			'mainEntity'   => [ '@id' => trailingslashit( $permalink ) . '#article' ],
		];
		if ( $published_iso !== '' ) {
			$webpage['datePublished'] = $published_iso;
		}
		if ( $modified_iso !== '' ) {
			$webpage['dateModified'] = $modified_iso;
		}

		$home_label = ( strpos( $schema_lang, 'el' ) !== false ? 'Αρχική' : 'Home' );
		$breadcrumb = [
			'@type'           => 'BreadcrumbList',
			'@id'             => trailingslashit( $permalink ) . '#breadcrumb',
			'itemListElement' => [
				[
					'@type'    => 'ListItem',
					'position' => 1,
					'name'     => $home_label,
					'item'     => home_url(),
				],
				[
					'@type'    => 'ListItem',
					'position' => 2,
					'name'     => $headline,
					'item'     => $permalink,
				],
			],
		];

		$graph        = [ $publisher, $website, $webpage, $breadcrumb, $article ];
		$json_payload = nros_schema_encode_graph( $graph );
		$output       = nros_schema_format_ld_json_script( $json_payload );
		$output       = (string) apply_filters( 'nros_schema_ld_output', $output, $post_id );
		return $output;
	}
}

if ( ! function_exists( 'nros_schema_try_defer_archive_ld' ) ) {
	/**
	 * On archive cache miss in megasite mode: emit a slim graph (no ItemList loop) and skip cold build.
	 *
	 * @return bool True when deferred output was echoed.
	 */
	function nros_schema_try_defer_archive_ld() {
		if ( ! nros_schema_should_defer_frontend_build() ) {
			return false;
		}

		$cache_id   = '';
		$legacy_key = '';
		$page_url   = '';
		$page_title = '';
		$paged      = get_query_var( 'paged' ) ? (int) get_query_var( 'paged' ) : ( get_query_var( 'page' ) ? (int) get_query_var( 'page' ) : 1 );
		$paged      = max( 1, $paged );

		if ( is_front_page() || is_home() ) {
			$current_obj_id = is_home() ? (int) get_option( 'page_for_posts' ) : (int) get_queried_object_id();
			$cache_id       = nros_schema_home_cache_id( $paged, $current_obj_id );
			$legacy_key     = 'nros_schema_home_v156_' . nros_schema_get_global_version() . '_p' . $paged . '_id' . $current_obj_id;
			if ( is_home() ) {
				$page_url   = get_option( 'page_for_posts' ) ? get_permalink( (int) get_option( 'page_for_posts' ) ) : home_url( '/' );
				$page_title = get_option( 'page_for_posts' ) ? get_the_title( (int) get_option( 'page_for_posts' ) ) : (string) get_option( 'nros_site_name', get_bloginfo( 'name' ) );
			} else {
				$page_url   = home_url( '/' );
				$page_title = (string) get_option( 'nros_site_name', get_bloginfo( 'name' ) );
			}
		} elseif ( is_page() && function_exists( 'nros_schema_is_custom_feed_page' ) && nros_schema_is_custom_feed_page( (int) get_queried_object_id() ) ) {
			$current_obj_id = (int) get_queried_object_id();
			$cache_id       = nros_schema_home_cache_id( $paged, $current_obj_id );
			$legacy_key     = 'nros_schema_home_v156_' . nros_schema_get_global_version() . '_p' . $paged . '_id' . $current_obj_id;
			$page_url       = get_permalink( $current_obj_id );
			$page_title     = get_the_title( $current_obj_id );
		} elseif ( is_category() || is_tag() ) {
			$term = get_queried_object();
			if ( ! $term || ! isset( $term->term_id, $term->taxonomy ) ) {
				return false;
			}
			$term_link = get_term_link( $term );
			if ( is_wp_error( $term_link ) ) {
				return false;
			}
			$cache_id   = nros_schema_term_cache_id( (int) $term->term_id, (string) $term->taxonomy, $paged );
			$legacy_key = 'nros_schema_term_v156_' . (int) $term->term_id . '_' . nros_schema_get_global_version() . '_p' . $paged;
			$page_url   = $term_link;
			$page_title = (string) $term->name;
		} else {
			return false;
		}

		$schema_lang = nros_schema_resolve_schema_lang();
		$site_name   = (string) get_option( 'nros_site_name', (string) get_bloginfo( 'name' ) );
		if ( $site_name === '' ) {
			$site_name = (string) get_bloginfo( 'name' );
		}
		$site_desc = (string) get_option( 'newsai_site_desc', (string) get_bloginfo( 'description' ) );

		$publisher = nros_schema_build_slim_publisher_node();
		$website   = nros_schema_build_website_node( $site_name, $site_desc, $schema_lang );

		$collection = [
			'@type'      => 'CollectionPage',
			'@id'        => trailingslashit( $page_url ) . '#webpage',
			'url'        => $page_url,
			'name'       => $page_title . ( $paged > 1 ? ' - ' . ( strpos( $schema_lang, 'el' ) !== false ? 'Σελίδα ' : 'Page ' ) . $paged : '' ),
			'inLanguage' => $schema_lang,
			'isPartOf'   => [ '@id' => home_url() . '#website' ],
		];

		$home_label = ( strpos( $schema_lang, 'el' ) !== false ? 'Αρχική' : 'Home' );
		$breadcrumb = [
			'@type'           => 'BreadcrumbList',
			'@id'             => trailingslashit( $page_url ) . '#breadcrumb',
			'itemListElement' => [
				[
					'@type'    => 'ListItem',
					'position' => 1,
					'name'     => $home_label,
					'item'     => home_url(),
				],
				[
					'@type'    => 'ListItem',
					'position' => 2,
					'name'     => $page_title,
					'item'     => $page_url,
				],
			],
		];
		$collection['breadcrumb'] = [ '@id' => trailingslashit( $page_url ) . '#breadcrumb' ];

		$graph        = [ $publisher, $website, $collection, $breadcrumb ];
		$json_payload = nros_schema_encode_graph( $graph );
		$output       = nros_schema_format_ld_json_script( $json_payload );

		if ( function_exists( 'nros_schema_enqueue_archive_warm' ) ) {
			$warm_job = [
				'cache_id'   => $cache_id,
				'legacy_key' => $legacy_key,
				'paged'      => $paged,
			];
			if ( is_front_page() || is_home() || ( is_page() && function_exists( 'nros_schema_is_custom_feed_page' ) && nros_schema_is_custom_feed_page( (int) get_queried_object_id() ) ) ) {
				$warm_job['kind']    = 'home';
				$warm_job['obj_id']  = is_home() ? (int) get_option( 'page_for_posts' ) : (int) get_queried_object_id();
				$warm_job['is_blog'] = is_home() ? 1 : 0;
			} elseif ( is_category() || is_tag() ) {
				$term = get_queried_object();
				if ( $term && isset( $term->term_id, $term->taxonomy ) ) {
					$warm_job['kind']     = 'term';
					$warm_job['term_id']  = (int) $term->term_id;
					$warm_job['taxonomy'] = (string) $term->taxonomy;
				}
			}
			if ( isset( $warm_job['kind'] ) ) {
				nros_schema_enqueue_archive_warm( $warm_job );
			}
		}

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

if ( ! function_exists( 'nros_schema_on_schema_enabled' ) ) {
	/**
	 * Kick background warm when schema is turned on (none → active) so pageviews stay cheap.
	 */
	function nros_schema_on_schema_enabled() {
		if ( function_exists( 'nros_schema_schedule_legacy_warm_cron' ) ) {
			nros_schema_schedule_legacy_warm_cron();
		}
		if ( function_exists( 'nros_schema_schedule_warm_kick' ) ) {
			nros_schema_schedule_warm_kick();
		}
		if ( function_exists( 'nros_schema_enqueue_default_home_archives' ) ) {
			nros_schema_enqueue_default_home_archives();
		}
	}
}

if ( ! function_exists( 'nros_schema_schedule_warm_kick' ) ) {
	/**
	 * Defer batch warm to WP-Cron instead of running a heavy shutdown pass (Rank Math-style).
	 */
	function nros_schema_schedule_warm_kick() {
		if ( get_option( 'nros_schema_type', 'newsarticle' ) === 'none' ) {
			return;
		}
		if ( function_exists( 'nros_schema_background_warm_allowed' ) && ! nros_schema_background_warm_allowed() ) {
			return;
		}
		if ( wp_next_scheduled( 'nros_schema_legacy_warm_batch' ) ) {
			return;
		}
		$delay = (int) apply_filters( 'nros_schema_warm_kick_delay', MINUTE_IN_SECONDS );
		wp_schedule_single_event( time() + max( 60, $delay ), 'nros_schema_legacy_warm_batch' );
	}
}

if ( ! function_exists( 'nros_schema_post_transient_key' ) ) {
	function nros_schema_post_transient_key( $post_id ) {
		return 'nros_schema_post_v156_' . (int) $post_id . '_' . nros_schema_get_global_version();
	}
}

if ( ! function_exists( 'nros_schema_pack_cached_ld_payload' ) ) {
	/**
	 * Encode JSON-LD script HTML for post_meta (immune to WP slash/unslash on \\n).
	 *
	 * @param string $output Full script HTML.
	 * @return string
	 */
	function nros_schema_pack_cached_ld_payload( $output ) {
		return base64_encode( (string) $output );
	}
}

if ( ! function_exists( 'nros_schema_unpack_cached_ld_payload' ) ) {
	/**
	 * Decode post_meta JSON-LD cache; falls back to legacy plain HTML meta.
	 *
	 * @param string $stored Raw meta value.
	 * @return string
	 */
	function nros_schema_unpack_cached_ld_payload( $stored ) {
		$stored = is_string( $stored ) ? $stored : '';
		if ( $stored === '' ) {
			return '';
		}

		$decoded = base64_decode( $stored, true );
		if ( is_string( $decoded ) && $decoded !== '' && strpos( $decoded, 'application/ld+json' ) !== false ) {
			return $decoded;
		}

		// Legacy: plain HTML before base64 packing (single wp_unslash pairs WP meta slashes).
		return function_exists( 'wp_unslash' ) ? (string) wp_unslash( $stored ) : $stored;
	}
}

if ( ! function_exists( 'nros_schema_delete_post_cached_ld' ) ) {
	/**
	 * @param int $post_id Post ID.
	 */
	function nros_schema_delete_post_cached_ld( $post_id ) {
		$post_id = (int) $post_id;
		if ( $post_id < 1 ) {
			return;
		}
		delete_post_meta( $post_id, nros_schema_post_meta_json_key() );
		delete_post_meta( $post_id, nros_schema_post_meta_version_key() );
		delete_transient( nros_schema_post_transient_key( $post_id ) );
	}
}

if ( ! function_exists( 'nros_schema_store_post_cached_ld' ) ) {
	/**
	 * Persist single-post JSON-LD (Rank Math style: build once, read many).
	 *
	 * @param int    $post_id Post ID.
	 * @param string $output  Full script HTML.
	 */
	function nros_schema_store_post_cached_ld( $post_id, $output ) {
		$post_id = (int) $post_id;
		if ( $post_id < 1 || ! is_string( $output ) || $output === '' ) {
			return;
		}
		$version = nros_schema_get_global_version();
		update_post_meta( $post_id, nros_schema_post_meta_json_key(), nros_schema_pack_cached_ld_payload( $output ) );
		update_post_meta( $post_id, nros_schema_post_meta_version_key(), $version );
		set_transient( nros_schema_post_transient_key( $post_id ), $output, 30 * DAY_IN_SECONDS );
	}
}

if ( ! function_exists( 'nros_schema_get_post_cached_ld' ) ) {
	/**
	 * Fast path for singular posts: post_meta first, legacy transient migration second.
	 *
	 * @param int $post_id Post ID.
	 * @return string Cached HTML or empty string.
	 */
	function nros_schema_get_post_cached_ld( $post_id ) {
		$post_id = (int) $post_id;
		if ( $post_id < 1 ) {
			return '';
		}
		$version  = nros_schema_get_global_version();
		$stored_v = (string) get_post_meta( $post_id, nros_schema_post_meta_version_key(), true );
		$cached   = get_post_meta( $post_id, nros_schema_post_meta_json_key(), true );
		$cached   = is_string( $cached ) ? $cached : '';

		// Fresh hit: stored schema version matches the current global version.
		if ( $stored_v === $version && $cached !== '' ) {
			return nros_schema_unpack_cached_ld_payload( $cached );
		}

		// Megasite mode: serve the existing (stale) cache instead of cold-rebuilding on
		// the pageview. A global_v bump (settings/author/plugin change) no longer forces a
		// synchronous rebuild across thousands of posts; the warm cron migrates stale posts
		// in the background and save_post refreshes each edited post immediately. Stale schema
		// is still valid markup — only slightly behind the latest global settings.
		if ( $cached !== '' && function_exists( 'nros_schema_is_megasite_mode' ) && nros_schema_is_megasite_mode() ) {
			return nros_schema_unpack_cached_ld_payload( $cached );
		}

		$legacy = get_transient( nros_schema_post_transient_key( $post_id ) );
		if ( is_string( $legacy ) && $legacy !== '' ) {
			nros_schema_store_post_cached_ld( $post_id, $legacy );
			return $legacy;
		}
		return '';
	}
}

if ( ! function_exists( 'nros_schema_listings_version' ) ) {
	/**
	 * Bumps when published posts change so home/category ItemLists refresh without a settings save.
	 */
	function nros_schema_listings_version() {
		return (string) get_option( 'nros_schema_listings_v', '1' );
	}
}

if ( ! function_exists( 'nros_schema_bump_listings_version' ) ) {
	function nros_schema_bump_listings_version() {
		update_option( 'nros_schema_listings_v', (string) time(), false );
		if ( function_exists( 'nros_schema_enqueue_default_home_archives' ) ) {
			nros_schema_enqueue_default_home_archives();
		}
	}
}

if ( ! function_exists( 'nros_schema_is_custom_feed_page' ) ) {
	/**
	 * @param int $page_id Page ID.
	 */
	function nros_schema_is_custom_feed_page( $page_id = 0 ) {
		$page_id = (int) $page_id;
		if ( $page_id < 1 ) {
			return false;
		}
		$custom_feed_pages = (string) get_option( 'newsai_custom_feed_pages', '' );
		if ( $custom_feed_pages === '' ) {
			return false;
		}
		$ids = array_filter( array_map( 'trim', explode( ',', $custom_feed_pages ) ) );
		return in_array( (string) $page_id, $ids, true );
	}
}

if ( ! function_exists( 'nros_schema_archive_cache_id' ) ) {
	/**
	 * Stable cache id (includes global + listings version for invalidation).
	 *
	 * @param string               $kind home|term|author.
	 * @param array<string, mixed> $parts Context parts.
	 */
	function nros_schema_archive_cache_id( $kind, array $parts ) {
		$parts['kind']       = sanitize_key( (string) $kind );
		$parts['build']      = defined( 'NROS_SCHEMA_BUILD' ) ? (string) NROS_SCHEMA_BUILD : '1';
		$parts['plugin_v']   = defined( 'NEWSAI_VERSION' ) ? (string) NEWSAI_VERSION : '1';
		$parts['schema_v']   = nros_schema_get_global_version();
		$parts['listings_v'] = nros_schema_listings_version();
		ksort( $parts );
		return wp_json_encode( $parts );
	}
}

if ( ! function_exists( 'nros_schema_maybe_invalidate_on_plugin_upgrade' ) ) {
	/**
	 * After plugin version changes, bump schema versions so post + archive caches refresh.
	 */
	function nros_schema_maybe_invalidate_on_plugin_upgrade() {
		if ( ! defined( 'NEWSAI_VERSION' ) ) {
			return;
		}
		$current = (string) NEWSAI_VERSION;
		$stored  = (string) get_option( 'newsai_plugin_version', '' );
		if ( $stored === $current ) {
			return;
		}
		update_option( 'newsai_plugin_version', $current, false );
		if ( $stored === '' ) {
			return;
		}
		update_option( 'nros_schema_global_v', (string) time(), false );
		if ( function_exists( 'nros_schema_mark_stale_backlog_after_global_bump' ) ) {
			nros_schema_mark_stale_backlog_after_global_bump();
		}
		if ( function_exists( 'nros_schema_bump_listings_version' ) ) {
			nros_schema_bump_listings_version();
		}
	}
}
add_action( 'plugins_loaded', 'nros_schema_maybe_invalidate_on_plugin_upgrade', 5 );

if ( ! function_exists( 'nros_schema_home_cache_id' ) ) {
	function nros_schema_home_cache_id( $paged, $obj_id ) {
		return nros_schema_archive_cache_id(
			'home',
			[
				'paged'  => max( 1, (int) $paged ),
				'obj_id' => (int) $obj_id,
			]
		);
	}
}

if ( ! function_exists( 'nros_schema_term_cache_id' ) ) {
	function nros_schema_term_cache_id( $term_id, $taxonomy, $paged ) {
		return nros_schema_archive_cache_id(
			'term',
			[
				'term_id'  => (int) $term_id,
				'taxonomy' => sanitize_key( (string) $taxonomy ),
				'paged'    => max( 1, (int) $paged ),
			]
		);
	}
}

if ( ! function_exists( 'nros_schema_archive_option_name' ) ) {
	function nros_schema_archive_option_name( $cache_id ) {
		return 'nros_schema_arch_' . md5( (string) $cache_id );
	}
}

if ( ! function_exists( 'nros_schema_get_archive_cached_ld' ) ) {
	/**
	 * @param string $cache_id            From {@see nros_schema_archive_cache_id()}.
	 * @param string $legacy_transient_key Optional old transient key to migrate.
	 */
	function nros_schema_get_archive_cached_ld( $cache_id, $legacy_transient_key = '' ) {
		$cache_id = (string) $cache_id;
		if ( $cache_id === '' ) {
			return '';
		}
		$opt = get_option( nros_schema_archive_option_name( $cache_id ), '' );
		if ( is_string( $opt ) && $opt !== '' ) {
			return $opt;
		}
		if ( $legacy_transient_key !== '' ) {
			$legacy = get_transient( $legacy_transient_key );
			if ( is_string( $legacy ) && $legacy !== '' ) {
				nros_schema_store_archive_cached_ld( $cache_id, $legacy, $legacy_transient_key );
				return $legacy;
			}
		}
		return '';
	}
}

if ( ! function_exists( 'nros_schema_store_archive_cached_ld' ) ) {
	/**
	 * @param string $cache_id             Cache id.
	 * @param string $output               HTML script block.
	 * @param string $legacy_transient_key Optional transient to delete after migrate.
	 */
	function nros_schema_store_archive_cached_ld( $cache_id, $output, $legacy_transient_key = '' ) {
		$cache_id = (string) $cache_id;
		if ( $cache_id === '' || ! is_string( $output ) || $output === '' ) {
			return;
		}
		update_option( nros_schema_archive_option_name( $cache_id ), $output, false );
		if ( $legacy_transient_key !== '' ) {
			delete_transient( $legacy_transient_key );
		}
	}
}

if ( ! function_exists( 'nros_schema_get_archive_warm_queue' ) ) {
	/**
	 * @return array<string, array<string, mixed>>
	 */
	function nros_schema_get_archive_warm_queue() {
		$queue = get_option( 'nros_schema_archive_warm_queue', [] );
		return is_array( $queue ) ? $queue : [];
	}
}

if ( ! function_exists( 'nros_schema_enqueue_archive_warm' ) ) {
	/**
	 * Queue a home/term archive for background ItemList warm (megasite-safe).
	 *
	 * @param array<string, mixed> $job Must include cache_id.
	 */
	function nros_schema_enqueue_archive_warm( array $job ) {
		$cache_id = isset( $job['cache_id'] ) ? (string) $job['cache_id'] : '';
		if ( $cache_id === '' ) {
			return;
		}
		$queue = nros_schema_get_archive_warm_queue();
		if ( isset( $queue[ $cache_id ] ) ) {
			return;
		}
		$job['queued_at'] = time();
		$queue[ $cache_id ] = $job;
		if ( count( $queue ) > 40 ) {
			uasort(
				$queue,
				static function ( $a, $b ) {
					return (int) ( $a['queued_at'] ?? 0 ) <=> (int) ( $b['queued_at'] ?? 0 );
				}
			);
			$queue = array_slice( $queue, -40, null, true );
		}
		update_option( 'nros_schema_archive_warm_queue', $queue, false );
		if ( function_exists( 'nros_schema_schedule_legacy_warm_cron' ) ) {
			nros_schema_schedule_legacy_warm_cron( true );
		}
	}
}

if ( ! function_exists( 'nros_schema_enqueue_default_home_archives' ) ) {
	/**
	 * Prime home / blog-index archive caches (page 1).
	 */
	function nros_schema_enqueue_default_home_archives() {
		if ( get_option( 'nros_schema_type', 'newsarticle' ) === 'none' ) {
			return;
		}
		$schema_version = nros_schema_get_global_version();
		$paged          = 1;
		$obj_id         = (int) get_option( 'page_for_posts' );
		$cache_id       = nros_schema_home_cache_id( $paged, $obj_id );
		$legacy_key     = 'nros_schema_home_v156_' . $schema_version . '_p' . $paged . '_id' . $obj_id;
		nros_schema_enqueue_archive_warm(
			[
				'kind'       => 'home',
				'paged'      => $paged,
				'obj_id'     => $obj_id,
				'is_blog'    => 1,
				'cache_id'   => $cache_id,
				'legacy_key' => $legacy_key,
			]
		);

		$custom_feed_pages = (string) get_option( 'newsai_custom_feed_pages', '' );
		if ( $custom_feed_pages !== '' ) {
			foreach ( array_filter( array_map( 'trim', explode( ',', $custom_feed_pages ) ) ) as $page_id_raw ) {
				$page_id = (int) $page_id_raw;
				if ( $page_id < 1 ) {
					continue;
				}
				$feed_cache = nros_schema_home_cache_id( $paged, $page_id );
				$feed_legacy = 'nros_schema_home_v156_' . $schema_version . '_p' . $paged . '_id' . $page_id;
				nros_schema_enqueue_archive_warm(
					[
						'kind'       => 'home',
						'paged'      => $paged,
						'obj_id'     => $page_id,
						'is_blog'    => 0,
						'is_feed'    => 1,
						'cache_id'   => $feed_cache,
						'legacy_key' => $feed_legacy,
					]
				);
			}
		}
	}
}

if ( ! function_exists( 'nros_schema_build_home_archive_ld' ) ) {
	/**
	 * Build full home/blog-index JSON-LD with ItemList (cron-safe, no pageview cold build).
	 *
	 * @param int  $paged   Page number.
	 * @param int  $obj_id  page_for_posts ID or custom feed page ID (0 = front).
	 * @param bool $is_blog True when blog index (page_for_posts).
	 * @return string Script HTML or empty.
	 */
	function nros_schema_build_home_archive_ld( $paged, $obj_id, $is_blog = true ) {
		$paged  = max( 1, (int) $paged );
		$obj_id = (int) $obj_id;

		$schema_lang = nros_schema_resolve_schema_lang();
		$site_name   = (string) get_option( 'nros_site_name', (string) get_bloginfo( 'name' ) );
		if ( $site_name === '' ) {
			$site_name = (string) get_bloginfo( 'name' );
		}
		$site_desc = (string) get_option( 'newsai_site_desc', (string) get_bloginfo( 'description' ) );

		$publisher = nros_schema_build_slim_publisher_node();
		$website   = nros_schema_build_website_node( $site_name, $site_desc, $schema_lang );

		$latest_posts = get_posts(
			[
				'numberposts' => 15,
				'post_status' => 'publish',
				'post_type'   => 'post',
			]
		);

		$list_base_url = home_url( '/' );
		$page_url      = home_url( '/' );
		$page_title    = $site_name;

		if ( $is_blog && $obj_id > 0 ) {
			$page_url   = (string) get_permalink( $obj_id );
			$page_title = (string) get_the_title( $obj_id );
			$list_base_url = trailingslashit( $page_url );
		} elseif ( ! $is_blog && $obj_id > 0 ) {
			$page_url      = (string) get_permalink( $obj_id );
			$page_title    = (string) get_the_title( $obj_id );
			$list_base_url = trailingslashit( $page_url );
		}

		$item_list = [
			'@type'           => 'ItemList',
			'@id'             => $list_base_url . '#itemlist',
			'itemListElement' => [],
		];
		foreach ( $latest_posts as $index => $lp ) {
			$list_item = nros_schema_build_item_list_element( $lp, $index + 1 );
			if ( $list_item ) {
				$item_list['itemListElement'][] = $list_item;
			}
		}

		$graph = [ $publisher, $website, $item_list ];

		if ( $is_blog || $obj_id > 0 ) {
			$home_label = ( strpos( $schema_lang, 'el' ) !== false ? 'Αρχική' : 'Home' );
			$breadcrumb = [
				'@type'           => 'BreadcrumbList',
				'@id'             => trailingslashit( $page_url ) . '#breadcrumb',
				'itemListElement' => [
					[
						'@type'    => 'ListItem',
						'position' => 1,
						'name'     => $home_label,
						'item'     => home_url(),
					],
					[
						'@type'    => 'ListItem',
						'position' => 2,
						'name'     => $page_title,
						'item'     => $page_url,
					],
				],
			];
			$collection = [
				'@type'      => 'CollectionPage',
				'@id'        => trailingslashit( $page_url ) . '#webpage',
				'url'        => $page_url,
				'name'       => $page_title . ( $paged > 1 ? ' - ' . ( strpos( $schema_lang, 'el' ) !== false ? 'Σελίδα ' : 'Page ' ) . $paged : '' ),
				'inLanguage' => $schema_lang,
				'isPartOf'   => [ '@id' => home_url() . '#website' ],
				'mainEntity' => [ '@id' => $list_base_url . '#itemlist' ],
				'breadcrumb' => [ '@id' => trailingslashit( $page_url ) . '#breadcrumb' ],
			];
			$graph = [ $publisher, $website, $item_list, $collection, $breadcrumb ];
		}

		return nros_schema_format_ld_json_script( nros_schema_encode_graph( $graph ) );
	}
}

if ( ! function_exists( 'nros_schema_build_term_archive_ld' ) ) {
	/**
	 * @param int    $term_id  Term ID.
	 * @param string $taxonomy Taxonomy slug.
	 * @param int    $paged    Page number.
	 * @return string Script HTML or empty.
	 */
	function nros_schema_build_term_archive_ld( $term_id, $taxonomy, $paged = 1 ) {
		$term_id  = (int) $term_id;
		$paged    = max( 1, (int) $paged );
		$taxonomy = sanitize_key( (string) $taxonomy );
		if ( $term_id < 1 || $taxonomy === '' ) {
			return '';
		}
		$term = get_term( $term_id, $taxonomy );
		if ( ! $term || is_wp_error( $term ) ) {
			return '';
		}
		$term_link = get_term_link( $term );
		if ( is_wp_error( $term_link ) ) {
			return '';
		}

		$schema_lang = nros_schema_resolve_schema_lang();
		$site_name   = (string) get_option( 'nros_site_name', (string) get_bloginfo( 'name' ) );
		if ( $site_name === '' ) {
			$site_name = (string) get_bloginfo( 'name' );
		}

		$publisher = nros_schema_build_slim_publisher_node();
		$publisher_trust = function_exists( 'nros_schema_build_publisher_trust_node' )
			? nros_schema_build_publisher_trust_node( $publisher )
			: $publisher;

		$home_label = ( strpos( $schema_lang, 'el' ) !== false ? 'Αρχική' : 'Home' );
		$breadcrumb = [
			'@type'           => 'BreadcrumbList',
			'@id'             => trailingslashit( $term_link ) . '#breadcrumb',
			'itemListElement' => [
				[
					'@type'    => 'ListItem',
					'position' => 1,
					'name'     => $home_label,
					'item'     => home_url(),
				],
				[
					'@type'    => 'ListItem',
					'position' => 2,
					'name'     => (string) $term->name,
					'item'     => $term_link,
				],
			],
		];

		$collection = [
			'@type'      => 'CollectionPage',
			'@id'        => trailingslashit( $term_link ) . '#webpage',
			'url'        => $term_link,
			'name'       => (string) $term->name . ' - ' . $site_name . ( $paged > 1 ? ' - ' . ( strpos( $schema_lang, 'el' ) !== false ? 'Σελίδα ' : 'Page ' ) . $paged : '' ),
			'inLanguage' => $schema_lang,
			'isPartOf'   => [ '@id' => home_url() . '#website' ],
			'breadcrumb' => [ '@id' => trailingslashit( $term_link ) . '#breadcrumb' ],
		];
		if ( function_exists( 'nros_schema_build_term_entity_node' ) ) {
			$term_about = nros_schema_build_term_entity_node( $term );
			if ( is_array( $term_about ) && ! empty( $term_about['name'] ) ) {
				$collection['about'] = $term_about;
			}
		}

		$posts = get_posts(
			[
				'numberposts' => 15,
				'post_status' => 'publish',
				'post_type'   => 'post',
				'tax_query'   => [
					[
						'taxonomy' => $taxonomy,
						'field'    => 'term_id',
						'terms'    => [ $term_id ],
					],
				],
			]
		);

		$graph = [ $publisher_trust, $collection, $breadcrumb ];
		if ( ! empty( $posts ) ) {
			$item_list = [
				'@type'           => 'ItemList',
				'@id'             => trailingslashit( $term_link ) . '#itemlist',
				'itemListElement' => [],
			];
			$pos = 1;
			foreach ( $posts as $p ) {
				$list_item = nros_schema_build_item_list_element( $p, $pos );
				if ( $list_item ) {
					$item_list['itemListElement'][] = $list_item;
					$pos++;
				}
			}
			if ( ! empty( $item_list['itemListElement'] ) ) {
				$collection['mainEntity'] = $item_list;
			}
		}

		return nros_schema_format_ld_json_script( nros_schema_encode_graph( $graph ) );
	}
}

if ( ! function_exists( 'nros_schema_warm_single_archive_job' ) ) {
	/**
	 * @param array<string, mixed> $job Archive warm descriptor.
	 * @return bool
	 */
	function nros_schema_warm_single_archive_job( array $job ) {
		$cache_id   = isset( $job['cache_id'] ) ? (string) $job['cache_id'] : '';
		$legacy_key = isset( $job['legacy_key'] ) ? (string) $job['legacy_key'] : '';
		if ( $cache_id === '' ) {
			return false;
		}
		if ( nros_schema_get_archive_cached_ld( $cache_id, $legacy_key ) !== '' ) {
			return true;
		}

		$kind = isset( $job['kind'] ) ? sanitize_key( (string) $job['kind'] ) : '';
		$output = '';
		if ( $kind === 'home' ) {
			$output = nros_schema_build_home_archive_ld(
				(int) ( $job['paged'] ?? 1 ),
				(int) ( $job['obj_id'] ?? 0 ),
				! empty( $job['is_blog'] )
			);
		} elseif ( $kind === 'term' ) {
			$output = nros_schema_build_term_archive_ld(
				(int) ( $job['term_id'] ?? 0 ),
				(string) ( $job['taxonomy'] ?? '' ),
				(int) ( $job['paged'] ?? 1 )
			);
		}

		if ( $output === '' ) {
			return false;
		}
		nros_schema_store_archive_cached_ld( $cache_id, $output, $legacy_key );
		return true;
	}
}

if ( ! function_exists( 'nros_schema_warm_archives_batch' ) ) {
	/**
	 * Warm queued home/term archives (max 1 per cron tick — cheap ItemList build).
	 *
	 * @param int $limit Max archives per batch.
	 * @return int
	 */
	function nros_schema_warm_archives_batch( $limit = 1 ) {
		$limit = max( 1, min( 3, (int) $limit ) );
		$queue = nros_schema_get_archive_warm_queue();
		if ( empty( $queue ) ) {
			return 0;
		}
		uasort(
			$queue,
			static function ( $a, $b ) {
				return (int) ( $a['queued_at'] ?? 0 ) <=> (int) ( $b['queued_at'] ?? 0 );
			}
		);
		$warmed = 0;
		foreach ( array_keys( $queue ) as $cache_id ) {
			if ( $warmed >= $limit ) {
				break;
			}
			$job = $queue[ $cache_id ];
			if ( ! is_array( $job ) ) {
				unset( $queue[ $cache_id ] );
				continue;
			}
			if ( nros_schema_warm_single_archive_job( $job ) ) {
				$warmed++;
			}
			unset( $queue[ $cache_id ] );
		}
		update_option( 'nros_schema_archive_warm_queue', $queue, false );
		return $warmed;
	}
}

if ( ! function_exists( 'nros_schema_try_echo_archive_cache' ) ) {
	/**
	 * @param string $cache_id Cache id.
	 * @param string $legacy_transient_key Legacy transient key.
	 * @return bool True when cache hit was echoed.
	 */
	function nros_schema_try_echo_archive_cache( $cache_id, $legacy_transient_key = '' ) {
		$cached = nros_schema_get_archive_cached_ld( $cache_id, $legacy_transient_key );
		if ( $cached === '' ) {
			return false;
		}
		echo $cached; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
		return true;
	}
}

add_action( 'transition_post_status', 'nros_schema_maybe_bump_listings_version', 15, 3 );
if ( ! function_exists( 'nros_schema_maybe_bump_listings_version' ) ) {
	/**
	 * Refresh home/category ItemList caches when publish state changes (no settings save required).
	 *
	 * @param string  $new_status New status.
	 * @param string  $old_status Old status.
	 * @param WP_Post $post       Post.
	 */
	function nros_schema_maybe_bump_listings_version( $new_status, $old_status, $post ) {
		if ( ! $post instanceof WP_Post || $post->post_type !== 'post' ) {
			return;
		}
		if ( wp_is_post_revision( $post->ID ) ) {
			return;
		}
		$was_pub = ( $old_status === 'publish' );
		$now_pub = ( $new_status === 'publish' );
		if ( $was_pub !== $now_pub && function_exists( 'nros_schema_bump_listings_version' ) ) {
			nros_schema_bump_listings_version();
		}
	}
}

if ( ! function_exists( 'nros_schema_mark_stale_backlog_after_global_bump' ) ) {
	/**
	 * After global schema settings change: serve-stale stays on; cron migrates gradually.
	 */
	function nros_schema_mark_stale_backlog_after_global_bump() {
		$counts    = wp_count_posts( 'post' );
		$published = isset( $counts->publish ) ? (int) $counts->publish : 0;
		update_option(
			'nros_schema_warm_backlog_stats',
			[
				'stale'   => max( 0, $published ),
				'missing' => null,
				'updated' => time(),
			],
			false
		);
		delete_transient( 'nros_schema_stale_warm_cursor' );
		if ( function_exists( 'nros_schema_schedule_legacy_warm_cron' ) ) {
			nros_schema_schedule_legacy_warm_cron( true );
		}
	}
}

if ( ! function_exists( 'nros_schema_get_cron_warm_queue' ) ) {
	/**
	 * @return array<int, int> post_id => queued_at timestamp.
	 */
	function nros_schema_get_cron_warm_queue() {
		$queue = get_option( 'nros_schema_cron_warm_queue', [] );
		return is_array( $queue ) ? $queue : [];
	}
}

if ( ! function_exists( 'nros_schema_enqueue_cron_warm_post' ) ) {
	/**
	 * Queue a post for cron warm (frontend cache miss — never warm on shutdown).
	 *
	 * @param int $post_id Post ID.
	 */
	function nros_schema_enqueue_cron_warm_post( $post_id ) {
		$post_id = (int) $post_id;
		if ( $post_id < 1 ) {
			return;
		}
		// Lite / warm-off: do not grow queues or probe backlog on pageviews.
		if ( function_exists( 'nros_schema_background_warm_allowed' ) && ! nros_schema_background_warm_allowed() ) {
			return;
		}
		$queue            = nros_schema_get_cron_warm_queue();
		$queue[ $post_id ] = time();
		if ( count( $queue ) > 2000 ) {
			asort( $queue );
			$queue = array_slice( $queue, -2000, null, true );
		}
		update_option( 'nros_schema_cron_warm_queue', $queue, false );
		// Light kick only — never run backlog probe SQL from a frontend miss.
		if ( function_exists( 'nros_schema_schedule_warm_kick' ) ) {
			nros_schema_schedule_warm_kick();
		} elseif ( function_exists( 'nros_schema_schedule_legacy_warm_cron' ) && ! wp_next_scheduled( 'nros_schema_legacy_warm_batch' ) ) {
			nros_schema_schedule_legacy_warm_cron();
		}
	}
}

if ( ! function_exists( 'nros_schema_drain_cron_warm_queue' ) ) {
	/**
	 * @param int $limit Max posts.
	 * @return int[] Post IDs drained (for stats).
	 */
	function nros_schema_drain_cron_warm_queue( $limit ) {
		$limit = max( 0, (int) $limit );
		if ( $limit < 1 ) {
			return [];
		}
		$queue = nros_schema_get_cron_warm_queue();
		if ( empty( $queue ) ) {
			return [];
		}
		asort( $queue );
		$ids = array_map( 'intval', array_keys( $queue ) );
		$ids = array_slice( $ids, 0, $limit );
		foreach ( $ids as $id ) {
			unset( $queue[ $id ] );
		}
		update_option( 'nros_schema_cron_warm_queue', $queue, false );
		return $ids;
	}
}

if ( ! function_exists( 'nros_schema_find_posts_missing_cache' ) ) {
	/**
	 * Published posts that never received a JSON-LD post_meta entry (legacy backfill).
	 *
	 * @param int $limit Max IDs.
	 * @return int[]
	 */
	function nros_schema_find_posts_missing_cache( $limit = 20 ) {
		global $wpdb;
		$limit = max( 1, min( 50, (int) $limit ) );
		$json_key = nros_schema_post_meta_json_key();
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$ids = $wpdb->get_col(
			$wpdb->prepare(
				"SELECT p.ID FROM {$wpdb->posts} p
				WHERE p.post_type = 'post' AND p.post_status = 'publish'
				AND NOT EXISTS (
					SELECT 1 FROM {$wpdb->postmeta} pm
					WHERE pm.post_id = p.ID AND pm.meta_key = %s AND pm.meta_value != ''
				)
				ORDER BY p.ID DESC
				LIMIT %d",
				$json_key,
				$limit
			)
		);
		return array_map( 'intval', (array) $ids );
	}
}

if ( ! function_exists( 'nros_schema_find_posts_stale_cache' ) ) {
	/**
	 * Posts with stored JSON-LD but an outdated schema version (serve-stale until refreshed).
	 *
	 * @param int $limit  Max IDs.
	 * @param int $cursor Process IDs below this value (DESC walk).
	 * @return int[]
	 */
	function nros_schema_find_posts_stale_cache( $limit = 20, $cursor = 0 ) {
		global $wpdb;
		$limit   = max( 1, min( 50, (int) $limit ) );
		$cursor  = max( 0, (int) $cursor );
		$version = nros_schema_get_global_version();
		$json_key = nros_schema_post_meta_json_key();
		$ver_key  = nros_schema_post_meta_version_key();

		if ( $cursor > 0 ) {
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
			$ids = $wpdb->get_col(
				$wpdb->prepare(
					"SELECT p.ID FROM {$wpdb->posts} p
					INNER JOIN {$wpdb->postmeta} pm_json
						ON p.ID = pm_json.post_id AND pm_json.meta_key = %s AND pm_json.meta_value != ''
					LEFT JOIN {$wpdb->postmeta} pm_v
						ON p.ID = pm_v.post_id AND pm_v.meta_key = %s AND pm_v.meta_value = %s
					WHERE p.post_type = 'post' AND p.post_status = 'publish'
					AND pm_v.meta_id IS NULL AND p.ID < %d
					ORDER BY p.ID DESC
					LIMIT %d",
					$json_key,
					$ver_key,
					$version,
					$cursor,
					$limit
				)
			);
		} else {
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
			$ids = $wpdb->get_col(
				$wpdb->prepare(
					"SELECT p.ID FROM {$wpdb->posts} p
					INNER JOIN {$wpdb->postmeta} pm_json
						ON p.ID = pm_json.post_id AND pm_json.meta_key = %s AND pm_json.meta_value != ''
					LEFT JOIN {$wpdb->postmeta} pm_v
						ON p.ID = pm_v.post_id AND pm_v.meta_key = %s AND pm_v.meta_value = %s
					WHERE p.post_type = 'post' AND p.post_status = 'publish'
					AND pm_v.meta_id IS NULL
					ORDER BY p.ID DESC
					LIMIT %d",
					$json_key,
					$ver_key,
					$version,
					$limit
				)
			);
		}
		return array_map( 'intval', (array) $ids );
	}
}

if ( ! function_exists( 'nros_schema_find_posts_needing_warm' ) ) {
	/**
	 * @param int $limit Max IDs.
	 * @return int[]
	 */
	function nros_schema_find_posts_needing_warm( $limit = 20 ) {
		return nros_schema_find_posts_missing_cache( $limit );
	}
}

if ( ! function_exists( 'nros_schema_probe_warm_backlog_depth' ) ) {
	/**
	 * Cheap saturation probe: if we find a full batch, backlog is at least that deep.
	 *
	 * @return array{missing:int,stale:int,queued:int}
	 */
	function nros_schema_probe_warm_backlog_depth() {
		$probe   = 50;
		$missing = nros_schema_find_posts_missing_cache( $probe );
		$stale   = nros_schema_find_posts_stale_cache( $probe );
		$queued  = count( nros_schema_get_cron_warm_queue() );
		return [
			'missing' => count( $missing ),
			'stale'   => count( $stale ),
			'queued'  => $queued,
		];
	}
}

if ( ! function_exists( 'nros_schema_resolve_warm_batch_size' ) ) {
	/**
	 * Adaptive batch: larger when backlog is deep, smaller when caught up.
	 *
	 * @return int
	 */
	function nros_schema_resolve_warm_batch_size() {
		$probe   = nros_schema_probe_warm_backlog_depth();
		$missing = (int) $probe['missing'];
		$stale   = (int) $probe['stale'];
		$queued  = (int) $probe['queued'];

		if ( $missing >= 50 || $queued >= 200 ) {
			$size = 40;
		} elseif ( $missing >= 30 || $queued >= 80 ) {
			$size = 30;
		} elseif ( $missing >= 15 || $stale >= 50 ) {
			$size = 20;
		} elseif ( $missing > 0 || $stale > 0 || $queued > 0 ) {
			$size = 12;
		} else {
			$size = 5;
		}

		if ( ! function_exists( 'nros_schema_is_megasite_mode' ) || ! nros_schema_is_megasite_mode() ) {
			$size = min( $size, 20 );
		}

		$size = (int) apply_filters( 'nros_schema_warm_batch_size', $size );
		return max( 5, min( 80, $size ) );
	}
}

if ( ! function_exists( 'nros_schema_warm_posts_batch' ) ) {
	/**
	 * Build schema post_meta in priority order: queued traffic → missing → stale cursor.
	 *
	 * @param int|null $limit Posts per batch (null = adaptive).
	 * @return int Number warmed.
	 */
	function nros_schema_warm_posts_batch( $limit = null ) {
		if ( get_option( 'nros_schema_type', 'newsarticle' ) === 'none' ) {
			return 0;
		}
		if ( get_transient( 'nros_schema_warm_batch_lock' ) ) {
			return 0;
		}
		set_transient( 'nros_schema_warm_batch_lock', 1, 2 * MINUTE_IN_SECONDS );

		$batch  = ( null === $limit ) ? nros_schema_resolve_warm_batch_size() : max( 1, min( 80, (int) $limit ) );
		$warmed = 0;
		$slots  = $batch;

		$queued_ids = nros_schema_drain_cron_warm_queue( min( 10, $slots ) );
		foreach ( $queued_ids as $post_id ) {
			if ( function_exists( 'nros_schema_warm_single_post_cache' ) && nros_schema_warm_single_post_cache( $post_id ) ) {
				$warmed++;
			}
			$slots--;
			if ( $slots < 1 ) {
				break;
			}
		}

		if ( $slots > 0 ) {
			foreach ( nros_schema_find_posts_missing_cache( $slots ) as $post_id ) {
				if ( function_exists( 'nros_schema_warm_single_post_cache' ) && nros_schema_warm_single_post_cache( $post_id ) ) {
					$warmed++;
				}
				$slots--;
				if ( $slots < 1 ) {
					break;
				}
			}
		}

		if ( $slots > 0 ) {
			$cursor = (int) get_option( 'nros_schema_stale_warm_cursor', 0 );
			$stale_ids = nros_schema_find_posts_stale_cache( $slots, $cursor );
			if ( empty( $stale_ids ) && $cursor > 0 ) {
				$cursor    = 0;
				$stale_ids = nros_schema_find_posts_stale_cache( $slots, 0 );
			}
			foreach ( $stale_ids as $post_id ) {
				if ( function_exists( 'nros_schema_warm_single_post_cache' ) && nros_schema_warm_single_post_cache( $post_id ) ) {
					$warmed++;
				}
				$slots--;
				if ( $slots < 1 ) {
					break;
				}
			}
			if ( ! empty( $stale_ids ) ) {
				update_option( 'nros_schema_stale_warm_cursor', min( $stale_ids ), false );
			} elseif ( $cursor > 0 ) {
				delete_option( 'nros_schema_stale_warm_cursor' );
			}
		}

		delete_transient( 'nros_schema_warm_batch_lock' );
		update_option( 'nros_schema_warm_last_batch', time(), false );
		update_option( 'nros_schema_warm_last_count', $warmed, false );

		$idle = (int) get_option( 'nros_schema_warm_idle_runs', 0 );
		if ( $warmed < 1 ) {
			update_option( 'nros_schema_warm_idle_runs', $idle + 1, false );
		} else {
			update_option( 'nros_schema_warm_idle_runs', 0, false );
		}

		if ( function_exists( 'nros_schema_schedule_legacy_warm_cron' ) ) {
			nros_schema_schedule_legacy_warm_cron();
		}

		if ( function_exists( 'nros_schema_warm_archives_batch' ) ) {
			nros_schema_warm_archives_batch( 1 );
		}

		return $warmed;
	}
}

if ( ! function_exists( 'nros_schema_register_cron_schedules' ) ) {
	/**
	 * Register a 15-minute recurrence used by the megasite background schema-warm task.
	 *
	 * @param array<string, array> $schedules Existing cron schedules.
	 * @return array<string, array>
	 */
	function nros_schema_register_cron_schedules( $schedules ) {
		if ( ! is_array( $schedules ) ) {
			return $schedules;
		}
		if ( ! isset( $schedules['nros_fifteen_minutes'] ) ) {
			$schedules['nros_fifteen_minutes'] = [
				'interval' => 15 * MINUTE_IN_SECONDS,
				'display'  => __( 'Every 15 minutes (Newsroom schema warm)', 'newsroom-ai-assistant' ),
			];
		}
		return $schedules;
	}
}
add_filter( 'cron_schedules', 'nros_schema_register_cron_schedules' );

add_action( 'nros_schema_legacy_warm_batch', 'nros_schema_run_legacy_warm_cron' );
if ( ! function_exists( 'nros_schema_run_legacy_warm_cron' ) ) {
	function nros_schema_run_legacy_warm_cron() {
		if ( get_option( 'nros_schema_type', 'newsarticle' ) === 'none' ) {
			return;
		}
		if ( function_exists( 'nros_schema_background_warm_allowed' ) && ! nros_schema_background_warm_allowed() ) {
			wp_clear_scheduled_hook( 'nros_schema_legacy_warm_batch' );
			return;
		}
		nros_schema_set_warm_context( true );
		nros_schema_warm_posts_batch( null );
		nros_schema_set_warm_context( false );
	}
}

if ( ! function_exists( 'nros_schema_schedule_legacy_warm_cron' ) ) {
	/**
	 * @param bool $force_aggressive When true, always use the 15-minute schedule (settings bump / schema enabled).
	 */
	function nros_schema_schedule_legacy_warm_cron( $force_aggressive = false ) {
		if ( get_option( 'nros_schema_type', 'newsarticle' ) === 'none' ) {
			wp_clear_scheduled_hook( 'nros_schema_legacy_warm_batch' );
			return;
		}
		if ( function_exists( 'nros_schema_background_warm_allowed' ) && ! nros_schema_background_warm_allowed() ) {
			wp_clear_scheduled_hook( 'nros_schema_legacy_warm_batch' );
			delete_option( 'nros_schema_cron_warm_queue' );
			delete_option( 'nros_schema_archive_warm_queue' );
			return;
		}

		// Frontend / AJAX callers must not pay for backlog probe SQL on every miss.
		$may_probe = is_admin() || wp_doing_cron() || (bool) $force_aggressive;
		$idle_runs = (int) get_option( 'nros_schema_warm_idle_runs', 0 );
		if ( $may_probe && function_exists( 'nros_schema_probe_warm_backlog_depth' ) ) {
			$probe = nros_schema_probe_warm_backlog_depth();
		} else {
			$queued = function_exists( 'nros_schema_get_cron_warm_queue' ) ? count( nros_schema_get_cron_warm_queue() ) : 0;
			$arch_q = function_exists( 'nros_schema_get_archive_warm_queue' ) ? count( nros_schema_get_archive_warm_queue() ) : 0;
			$probe  = [ 'missing' => 0, 'stale' => 0, 'queued' => $queued + $arch_q ];
		}
		$has_work = ( (int) $probe['missing'] + (int) $probe['stale'] + (int) $probe['queued'] ) > 0;

		$megasite = ( function_exists( 'nros_schema_is_megasite_mode' ) && nros_schema_is_megasite_mode() );
		// Megasite without Lite: hourly by default (was 15 min — caused sustained CPU).
		// Aggressive 15-min only when force or explicit large backlog while probing.
		if ( $force_aggressive || ( $may_probe && $has_work && ( (int) $probe['missing'] + (int) $probe['stale'] ) >= 30 ) ) {
			$want = 'nros_fifteen_minutes';
		} elseif ( $has_work || $idle_runs < 3 ) {
			$want = 'hourly';
		} else {
			$want = $megasite ? 'twicedaily' : 'hourly';
		}

		$scheduled = wp_get_schedule( 'nros_schema_legacy_warm_batch' );
		if ( $scheduled === $want ) {
			return;
		}
		if ( $scheduled ) {
			wp_clear_scheduled_hook( 'nros_schema_legacy_warm_batch' );
		}
		$first_offset = (int) apply_filters(
			'nros_schema_warm_cron_first_offset',
			( $want === 'nros_fifteen_minutes' ) ? 5 * MINUTE_IN_SECONDS : 15 * MINUTE_IN_SECONDS
		);
		$first = time() + max( 60, $first_offset );
		wp_schedule_event( $first, $want, 'nros_schema_legacy_warm_batch' );
	}
}
add_action( 'admin_init', 'nros_schema_schedule_legacy_warm_cron' );

add_action('save_post', 'nros_clear_schema_cache_on_save');
if (!function_exists('nros_clear_schema_cache_on_save')) {
    function nros_clear_schema_cache_on_save($post_id) {
        if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) return;
        if ( function_exists( 'nros_schema_delete_post_cached_ld' ) ) {
            nros_schema_delete_post_cached_ld( $post_id );
        } else {
            $schema_version = get_option('nros_schema_global_v', '1');
            delete_transient('nros_schema_post_v156_' . $post_id . '_' . $schema_version);
        }
        delete_transient( 'nros_schema_about_v156_' . (int) $post_id . '_' . nros_schema_get_global_version() );
    }
}

add_action('personal_options_update', 'nros_clear_author_cache_on_save');
add_action('edit_user_profile_update', 'nros_clear_author_cache_on_save');
if (!function_exists('nros_clear_author_cache_on_save')) {
    function nros_clear_author_cache_on_save($user_id) {
        $user_id = (int) $user_id;
        if ( $user_id < 1 ) {
            return;
        }
        $schema_version = function_exists( 'nros_schema_get_global_version' )
            ? nros_schema_get_global_version()
            : (string) get_option( 'nros_schema_global_v', '1' );

        for ( $paged = 1; $paged <= 10; $paged++ ) {
            delete_transient( 'nros_schema_author_page_v156_' . $user_id . '_' . $schema_version . '_p' . $paged );
        }

        // Person entity graph (ProfilePage + article slim nodes) — was not cleared before, so
        // author profile edits could stay stale for up to 30 days inside a fresh page cache.
        foreach ( [ 'full', 'slim' ] as $variant ) {
            delete_transient( 'nros_schema_author_v158_' . $user_id . '_' . $schema_version . '_' . $variant );
        }

        if ( function_exists( 'nros_schema_queue_author_posts_invalidate' ) ) {
            nros_schema_queue_author_posts_invalidate( $user_id );
        }
    }
}

if ( ! function_exists( 'nros_schema_queue_author_posts_invalidate' ) ) {
	/**
	 * Drop cached JSON-LD for this author's recent posts; warm gradually (no CPU storm).
	 *
	 * @param int $author_id User ID.
	 */
	function nros_schema_queue_author_posts_invalidate( $author_id ) {
		$author_id = (int) $author_id;
		if ( $author_id < 1 ) {
			return;
		}
		global $nros_schema_author_invalidate_queue;
		if ( ! is_array( $nros_schema_author_invalidate_queue ) ) {
			$nros_schema_author_invalidate_queue = [];
		}
		$nros_schema_author_invalidate_queue[ $author_id ] = true;
		if ( ! has_action( 'shutdown', 'nros_schema_process_author_posts_invalidate' ) ) {
			add_action( 'shutdown', 'nros_schema_process_author_posts_invalidate', 2 );
		}
	}
}

if ( ! function_exists( 'nros_schema_process_author_posts_invalidate' ) ) {
	function nros_schema_process_author_posts_invalidate() {
		global $nros_schema_author_invalidate_queue;
		if ( empty( $nros_schema_author_invalidate_queue ) || ! is_array( $nros_schema_author_invalidate_queue ) ) {
			return;
		}
		$author_ids = array_keys( $nros_schema_author_invalidate_queue );
		$author_id  = (int) reset( $author_ids );
		if ( $author_id < 1 ) {
			$nros_schema_author_invalidate_queue = [];
			return;
		}
		$post_ids = get_posts(
			[
				'author'                 => $author_id,
				'post_type'              => 'post',
				'post_status'            => 'publish',
				'posts_per_page'         => 20,
				'fields'                 => 'ids',
				'no_found_rows'          => true,
				'update_post_meta_cache' => false,
				'orderby'                => 'modified',
				'order'                  => 'DESC',
			]
		);
		$warmed = 0;
		foreach ( (array) $post_ids as $post_id ) {
			$post_id = (int) $post_id;
			if ( $post_id < 1 ) {
				continue;
			}
			if ( function_exists( 'nros_schema_delete_post_cached_ld' ) ) {
				nros_schema_delete_post_cached_ld( $post_id );
			}
			if ( $warmed < 1 && function_exists( 'nros_schema_warm_single_post_cache' ) ) {
				nros_schema_set_warm_context( true );
				nros_schema_warm_single_post_cache( $post_id );
				nros_schema_set_warm_context( false );
				$warmed++;
			} elseif ( function_exists( 'nros_schema_queue_warm_post_cache' ) ) {
				nros_schema_queue_warm_post_cache( $post_id );
			}
		}
		$nros_schema_author_invalidate_queue = [];
	}
}

// ==========================================
// 5. 🚀 THE ENTITY ENGINE (AI-FIRST GRAPH & FEEDS)
// ==========================================
add_action('wp_head', 'nros_output_json_ld_schema', 99);
add_action('amp_post_template_head', 'nros_output_json_ld_schema', 99);
add_action('ampforwp_head', 'nros_output_json_ld_schema', 99);

if ( ! function_exists( 'nros_schema_get_founding_date' ) ) {
	/**
	 * Oldest published post date for Organization.foundingDate — cached to avoid a DB
	 * query on every frontend page view (expensive on large archives).
	 *
	 * @return string Y-m-d
	 */
	function nros_schema_get_founding_date() {
		$cached = get_option( 'nros_schema_founding_date', '' );
		if ( is_string( $cached ) && preg_match( '/^\d{4}-\d{2}-\d{2}$/', $cached ) ) {
			return $cached;
		}
		$first_posts = get_posts(
			[
				'numberposts'            => 1,
				'orderby'                => 'ID',
				'order'                  => 'ASC',
				'post_type'              => 'post',
				'post_status'            => 'publish',
				'fields'                 => 'ids',
				'no_found_rows'          => true,
				'update_post_meta_cache' => false,
				'update_post_term_cache' => false,
			]
		);
		$first_id = ! empty( $first_posts ) ? (int) $first_posts[0] : 0;
		$date = $first_id > 0 ? get_the_date( 'Y-m-d', $first_id ) : '2015-01-01';
		if ( ! is_string( $date ) || ! preg_match( '/^\d{4}-\d{2}-\d{2}$/', $date ) ) {
			$date = '2015-01-01';
		}
		update_option( 'nros_schema_founding_date', $date, false );
		return $date;
	}
}

if (!function_exists('nros_output_json_ld_schema')) {
    function nros_output_json_ld_schema() {
        $schema_setting = get_option('nros_schema_type', 'newsarticle');
        if ( $schema_setting === 'none' ) return;

        // Fast path: singular posts with post_meta cache (2 reads, no org rebuild).
        if ( is_singular( 'post' ) ) {
            global $post;
            $early_id = ( $post instanceof WP_Post ) ? (int) $post->ID : 0;
            if ( $early_id < 1 ) {
                $early_id = (int) get_queried_object_id();
            }
            if ( $early_id > 0 && function_exists( 'nros_schema_get_post_cached_ld' ) ) {
                $early_cached = nros_schema_get_post_cached_ld( $early_id );
                if ( $early_cached !== '' ) {
                    if ( function_exists( 'nros_schema_cached_ld_needs_content_rebuild' )
                        && nros_schema_cached_ld_needs_content_rebuild( $early_cached ) ) {
                        if ( function_exists( 'nros_schema_delete_post_cached_ld' ) ) {
                            nros_schema_delete_post_cached_ld( $early_id );
                        }
                    } else {
                        /**
                         * Filter JSON-LD script HTML immediately before echo.
                         * Cached post meta is stored *before* this filter — safe for dynamic data (e.g. comments).
                         *
                         * @param string $early_cached Full script HTML.
                         * @param int    $early_id       Post ID.
                         */
                        $early_cached = (string) apply_filters( 'nros_schema_ld_output', $early_cached, $early_id );
                        echo $early_cached; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
                        return;
                    }
                }
                // Megasite: cache miss — never cold-build on pageview (queue background warm + slim markup).
                if ( function_exists( 'nros_schema_should_defer_frontend_build' ) && nros_schema_should_defer_frontend_build() ) {
                    if ( function_exists( 'nros_schema_queue_warm_post_cache' ) ) {
                        nros_schema_queue_warm_post_cache( $early_id );
                    }
                    $minimal_ld = function_exists( 'nros_schema_emit_minimal_post_ld' )
                        ? nros_schema_emit_minimal_post_ld( $early_id )
                        : '';
                    if ( $minimal_ld !== '' ) {
                        echo $minimal_ld; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
                    }
                    return;
                }
            }
        }

        // Fast path: home / blog index / category / tag archive (option cache, no org rebuild).
        if ( is_front_page() || is_home() ) {
            $paged          = get_query_var( 'paged' ) ? (int) get_query_var( 'paged' ) : ( get_query_var( 'page' ) ? (int) get_query_var( 'page' ) : 1 );
            $current_obj_id = is_home() ? (int) get_option( 'page_for_posts' ) : (int) get_queried_object_id();
            $cache_id       = nros_schema_home_cache_id( $paged, $current_obj_id );
            $legacy_key     = 'nros_schema_home_v156_' . nros_schema_get_global_version() . '_p' . $paged . '_id' . $current_obj_id;
            if ( nros_schema_try_echo_archive_cache( $cache_id, $legacy_key ) ) {
                return;
            }
        } elseif ( is_page() && function_exists( 'nros_schema_is_custom_feed_page' ) && nros_schema_is_custom_feed_page( (int) get_queried_object_id() ) ) {
            $paged          = get_query_var( 'paged' ) ? (int) get_query_var( 'paged' ) : ( get_query_var( 'page' ) ? (int) get_query_var( 'page' ) : 1 );
            $current_obj_id = (int) get_queried_object_id();
            $cache_id       = nros_schema_home_cache_id( $paged, $current_obj_id );
            $legacy_key     = 'nros_schema_home_v156_' . nros_schema_get_global_version() . '_p' . $paged . '_id' . $current_obj_id;
            if ( nros_schema_try_echo_archive_cache( $cache_id, $legacy_key ) ) {
                return;
            }
        } elseif ( is_category() || is_tag() ) {
            $term = get_queried_object();
            if ( $term && isset( $term->term_id, $term->taxonomy ) ) {
                $paged    = get_query_var( 'paged' ) ? (int) get_query_var( 'paged' ) : 1;
                $cache_id = nros_schema_term_cache_id( (int) $term->term_id, (string) $term->taxonomy, $paged );
                $legacy_key = 'nros_schema_term_v156_' . (int) $term->term_id . '_' . nros_schema_get_global_version() . '_p' . $paged;
                if ( nros_schema_try_echo_archive_cache( $cache_id, $legacy_key ) ) {
                    return;
                }
            }
        }

        // Megasite: archive cache miss — slim CollectionPage only (no per-post ItemList loop on pageview).
        if ( function_exists( 'nros_schema_try_defer_archive_ld' ) && nros_schema_try_defer_archive_ld() ) {
            return;
        }

        $schema_version = get_option('nros_schema_global_v', '1');
        $org_type = (string) get_option('nros_org_type', 'NewsMediaOrganization');
        $site_name = (string) get_option('nros_site_name', (string) get_bloginfo('name'));
        $site_desc = (string) get_option('newsai_site_desc', (string) get_bloginfo('description'));
        if ( $site_name === '' ) {
            $site_name = (string) get_bloginfo( 'name' );
        }
        if ( $site_name === '' ) {
            $site_name = 'Site';
        }
        $social_links = (string) get_option('nros_social_links', '');
        $primary_city = get_option('nros_primary_city', ''); 
        $org_legal_name = trim((string)get_option('nros_org_legal_name', ''));
        $org_alt_name = trim((string)get_option('nros_org_alternate_name', ''));
        $org_email = trim((string)get_option('nros_org_email', ''));
        $org_phone = trim((string)get_option('nros_org_phone', ''));
        $org_tax_id = trim((string)get_option('nros_org_tax_id', ''));
        $org_vat_id = trim((string)get_option('nros_org_vat_id', ''));
        $org_iso6523 = trim((string)get_option('nros_org_iso6523', ''));
        $org_naics = trim((string)get_option('nros_org_naics', ''));
        $org_address_country = trim((string)get_option('nros_org_address_country', ''));
        $org_address_locality = trim((string)get_option('nros_org_address_locality', ''));
        $org_address_region = trim((string)get_option('nros_org_address_region', ''));
        $org_address_postal = trim((string)get_option('nros_org_address_postal_code', ''));
        $org_address_street = trim((string)get_option('nros_org_address_street', ''));
        
        $geo_opt = function_exists( 'newsai_get_target_geo' ) ? newsai_get_target_geo() : 'US';
        $geo_names = [
            'GR' => 'Greece', 'CY' => 'Cyprus', 'US' => 'United States', 'GB' => 'United Kingdom', 
            'DE' => 'Germany', 'FR' => 'France', 'IT' => 'Italy', 'ES' => 'Spain', 
            'NL' => 'Netherlands', 'BE' => 'Belgium', 'PT' => 'Portugal', 'RU' => 'Russia', 
            'IN' => 'India', 'ID' => 'Indonesia', 'AU' => 'Australia', 'CA' => 'Canada', 'IL' => 'Israel',
            'MX' => 'Mexico', 'BR' => 'Brazil', 'JP' => 'Japan', 'ZA' => 'South Africa',
            'CH' => 'Switzerland', 'SE' => 'Sweden', 'NO' => 'Norway', 'DK' => 'Denmark',
            'FI' => 'Finland', 'PL' => 'Poland', 'AT' => 'Austria', 'IE' => 'Ireland'
        ];
        $location_name = isset($geo_names[$geo_opt]) ? $geo_names[$geo_opt] : $geo_opt;
        
        $founding_date = nros_schema_get_founding_date();

        // 🚀 BCP 47 Dynamic Locale Fix — get_locale() / `locale` filter must be string for PHP 8.1+.
        $wp_locale = get_locale();
        if ( ! is_string( $wp_locale ) || $wp_locale === '' ) {
            $wp_locale = 'en_US';
        }
        $schema_lang = str_replace( '_', '-', $wp_locale );
        if ($schema_lang === 'el') { $schema_lang = 'el-GR'; }

        // 🚀 ENHANCED PUBLISHER (NewsMediaOrganization)
        $publisher = [
            '@type' => $org_type,
            '@id' => home_url() . '#organization',
            'name' => $site_name,
            'url' => home_url(),
            'description' => $site_desc,
            'foundingDate' => $founding_date,
            'areaServed' => [ [ '@type' => 'Country', 'name' => $location_name ] ]
        ];

        // Σύνδεση με την "Πόλη" αν έχει οριστεί (Local SEO Fix)
        if (!empty($primary_city)) {
            $publisher['areaServed'][] = [ '@type' => 'City', 'name' => $primary_city ];
            $publisher['location'] = [ '@type' => 'Place', 'name' => $primary_city ];
        }
        
        $logo_url = '';
        $logo_w   = 512;
        $logo_h   = 512;
        $manual_logo_url = trim( (string) get_option( 'nros_org_logo_url', '' ) );
        if ( $manual_logo_url !== '' ) {
            $logo_url = esc_url_raw( $manual_logo_url );
            $manual_logo_id = attachment_url_to_postid( $logo_url );
            if ( $manual_logo_id ) {
                $manual_dims = wp_get_attachment_image_src( $manual_logo_id, 'full' );
                if ( $manual_dims && ! empty( $manual_dims[1] ) && ! empty( $manual_dims[2] ) ) {
                    $logo_w = (int) $manual_dims[1];
                    $logo_h = (int) $manual_dims[2];
                }
            }
        }

        $custom_logo_id = get_theme_mod('custom_logo');
        if (empty($logo_url) && $custom_logo_id) {
            $img = wp_get_attachment_image_src($custom_logo_id, 'full');
            if ($img) { $logo_url = $img[0]; $logo_w = $img[1]; $logo_h = $img[2]; }
        }
        if (empty($logo_url)) {
            $rm_titles = get_option('rank_math_titles');
            if (!empty($rm_titles['knowledgegraph_logo'])) { $logo_url = $rm_titles['knowledgegraph_logo']; }
        }
        if (empty($logo_url)) {
            $yoast_titles = get_option('wpseo_titles');
            if (!empty($yoast_titles['company_logo'])) { $logo_url = $yoast_titles['company_logo']; }
        }
        if (empty($logo_url) && has_site_icon()) {
            $logo_url = get_site_icon_url(512); $logo_w = 512; $logo_h = 512;
        }

        if (!empty($logo_url)) {
            list( $logo_url, $logo_w, $logo_h ) = nros_schema_resolve_publisher_logo( $logo_url, $logo_w, $logo_h );
            $publisher['logo'] = [ '@type' => 'ImageObject', 'url' => esc_url_raw($logo_url), 'width' => (int)$logo_w, 'height' => (int)$logo_h ];
            // Optional field that helps quiet Organization rich-result warnings on some homepage scans.
            $publisher['image'] = [ '@type' => 'ImageObject', 'url' => esc_url_raw($logo_url), 'width' => (int)$logo_w, 'height' => (int)$logo_h ];
        }

        if (!empty($social_links)) { 
            $clean_links = str_replace("\r\n", "\n", $social_links); 
            $links_array = array_filter(array_map('trim', explode("\n", $clean_links)));
            $valid_links = [];
            foreach ($links_array as $l) { if (filter_var($l, FILTER_VALIDATE_URL)) { $valid_links[] = esc_url_raw($l); } }
            if (!empty($valid_links)) { $publisher['sameAs'] = array_values($valid_links); }
        }
        if ($org_legal_name !== '') { $publisher['legalName'] = $org_legal_name; }
        if ($org_alt_name !== '') { $publisher['alternateName'] = $org_alt_name; }
        if ($org_email !== '') { $publisher['email'] = sanitize_email($org_email); }
        if ($org_phone !== '') { $publisher['telephone'] = $org_phone; }
        if ($org_tax_id !== '') { $publisher['taxID'] = $org_tax_id; }
        if ($org_vat_id !== '' && strcasecmp( $org_vat_id, $org_tax_id ) !== 0) { $publisher['vatID'] = $org_vat_id; }
        if ($org_iso6523 !== '') { $publisher['iso6523Code'] = $org_iso6523; }
        if ($org_naics !== '') { $publisher['naics'] = $org_naics; }
        $org_address = ['@type' => 'PostalAddress'];
        if ($org_address_country !== '') { $org_address['addressCountry'] = strtoupper($org_address_country); }
        if ($org_address_locality !== '') { $org_address['addressLocality'] = $org_address_locality; }
        if ($org_address_region !== '') { $org_address['addressRegion'] = $org_address_region; }
        if ($org_address_postal !== '') { $org_address['postalCode'] = $org_address_postal; }
        if ($org_address_street !== '') { $org_address['streetAddress'] = $org_address_street; }
        if (count($org_address) > 1) { $publisher['address'] = $org_address; }

        $trust_urls = [
            'publishingPrinciples'    => trim( (string) get_option( 'nros_org_publishing_principles_url', '' ) ),
            'correctionsPolicy'       => trim( (string) get_option( 'nros_org_corrections_policy_url', '' ) ),
            'ownershipFundingInfo'    => trim( (string) get_option( 'nros_org_ownership_funding_url', '' ) ),
            'actionableFeedbackPolicy'=> trim( (string) get_option( 'nros_org_actionable_feedback_url', '' ) ),
        ];
        foreach ( $trust_urls as $schema_key => $raw_url ) {
            if ( $raw_url !== '' && filter_var( $raw_url, FILTER_VALIDATE_URL ) ) {
                $publisher[ $schema_key ] = esc_url_raw( $raw_url );
            }
        }

        // Trust publisher on singles/archives: same @id as home Organization, transparency fields for E-E-A-T
        // (no taxID/VAT/legal bulk repeated on every post).
        $publisher_trust = nros_schema_build_publisher_trust_node( $publisher );

        $graph = [];

        $custom_feed_pages = get_option('newsai_custom_feed_pages', '');
        $custom_feed_array = !empty($custom_feed_pages) ? array_map('trim', explode(',', $custom_feed_pages)) : [];
        $is_custom_feed = (is_page() && in_array((string)get_queried_object_id(), $custom_feed_array));

        // 🚀 ΣΕΝΑΡΙΟ 1: Αρχική Σελίδα (Full Authority Organization Graph)
        if ( is_front_page() || is_home() || $is_custom_feed ) {
            $paged = get_query_var('paged') ? get_query_var('paged') : (get_query_var('page') ? get_query_var('page') : 1);
            $current_obj_id = is_home() ? get_option('page_for_posts') : get_queried_object_id();
            $cache_id = nros_schema_home_cache_id( $paged, (int) $current_obj_id );
            $legacy_key = 'nros_schema_home_v156_' . $schema_version . '_p' . $paged . '_id' . $current_obj_id;
            
            $cached_schema = nros_schema_get_archive_cached_ld( $cache_id, $legacy_key );
            if ( $cached_schema !== '' ) { echo $cached_schema; return; } // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped

            $website = [
                '@type' => 'WebSite', '@id' => home_url() . '#website', 'url' => home_url(), 'name' => $site_name,
                'description' => $site_desc, 'inLanguage' => $schema_lang, 'publisher' => ['@id' => home_url() . '#organization'] 
            ];
            
            // Προσθήκη ItemList για Google Discover
            $latest_posts = get_posts(['numberposts' => 15, 'post_status' => 'publish']);
            $list_base_url = home_url( '/' );
            if ( is_home() || $is_custom_feed ) {
                $page_url   = is_home() ? ( get_option( 'page_for_posts' ) ? get_permalink( get_option( 'page_for_posts' ) ) : home_url( '/' ) ) : get_permalink();
                $page_title = is_home() ? ( get_option( 'page_for_posts' ) ? get_the_title( get_option( 'page_for_posts' ) ) : $site_name ) : get_the_title();
                $list_base_url = trailingslashit( $page_url );
            }
            $item_list = [ '@type' => 'ItemList', '@id' => $list_base_url . '#itemlist', 'itemListElement' => [] ];
            foreach ( $latest_posts as $index => $lp ) {
                $list_item = nros_schema_build_item_list_element( $lp, $index + 1 );
                if ( $list_item ) {
                    $item_list['itemListElement'][] = $list_item;
                }
            }

            if ( is_home() || $is_custom_feed ) {
                $home_label = ( strpos( $schema_lang, 'el' ) !== false ? 'Αρχική' : 'Home' );
                $breadcrumb = [
                    '@type'           => 'BreadcrumbList',
                    '@id'             => trailingslashit( $page_url ) . '#breadcrumb',
                    'itemListElement' => [
                        [
                            '@type'    => 'ListItem',
                            'position' => 1,
                            'name'     => $home_label,
                            'item'     => home_url(),
                        ],
                        [
                            '@type'    => 'ListItem',
                            'position' => 2,
                            'name'     => $page_title,
                            'item'     => $page_url,
                        ],
                    ],
                ];

                $collection = [
                    '@type' => 'CollectionPage', '@id' => trailingslashit($page_url) . '#webpage', 'url' => $page_url,
                    'name' => $page_title . ($paged > 1 ? ' - ' . (strpos($schema_lang, 'el') !== false ? 'Σελίδα ' : 'Page ') . $paged : ''),
                    'inLanguage' => $schema_lang, 'isPartOf' => ['@id' => home_url() . '#website'],
                    'mainEntity' => ['@id' => $list_base_url . '#itemlist'],
                    'breadcrumb' => [ '@id' => trailingslashit( $page_url ) . '#breadcrumb' ],
                ];
                $graph = [$publisher, $website, $item_list, $collection, $breadcrumb];
            } else {
                $graph = [$publisher, $website, $item_list];
            }

            $json_payload = nros_schema_encode_graph( $graph );
            $output = nros_schema_format_ld_json_script( $json_payload );
            nros_schema_store_archive_cached_ld( $cache_id, $output, $legacy_key );
            echo $output; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
            return;
        }

        if ( is_category() || is_tag() ) {
            $term = get_queried_object();
            if (!$term) return;
            $paged = get_query_var('paged') ? get_query_var('paged') : 1;
            $cache_id = nros_schema_term_cache_id( (int) $term->term_id, (string) $term->taxonomy, (int) $paged );
            $legacy_key = 'nros_schema_term_v156_' . $term->term_id . '_' . $schema_version . '_p' . $paged;
            $cached_schema = nros_schema_get_archive_cached_ld( $cache_id, $legacy_key );
            if ( $cached_schema !== '' ) { echo $cached_schema; return; } // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped

            $term_link = get_term_link( $term );
            if ( is_wp_error( $term_link ) ) {
                return;
            }
            $home_label = ( strpos( $schema_lang, 'el' ) !== false ? 'Αρχική' : 'Home' );
            $breadcrumb = [
                '@type'           => 'BreadcrumbList',
                '@id'             => trailingslashit( $term_link ) . '#breadcrumb',
                'itemListElement' => [
                    [
                        '@type'    => 'ListItem',
                        'position' => 1,
                        'name'     => $home_label,
                        'item'     => home_url(),
                    ],
                    [
                        '@type'    => 'ListItem',
                        'position' => 2,
                        'name'     => $term->name,
                        'item'     => $term_link,
                    ],
                ],
            ];

            $collection = [
                '@type' => 'CollectionPage', '@id' => trailingslashit( $term_link ) . '#webpage', 'url' => $term_link,
                'name' => $term->name . ' - ' . $site_name . ($paged > 1 ? ' - ' . (strpos($schema_lang, 'el') !== false ? 'Σελίδα ' : 'Page ') . $paged : ''),
                'inLanguage' => $schema_lang, 'isPartOf' => ['@id' => home_url() . '#website'],
                'breadcrumb' => [ '@id' => trailingslashit( $term_link ) . '#breadcrumb' ],
            ];
            $term_about = nros_schema_build_term_entity_node( $term );
            if ( is_array( $term_about ) && ! empty( $term_about['name'] ) ) {
                $collection['about'] = $term_about;
            }

            global $wp_query;
            if ( !empty($wp_query->posts) ) {
                $item_list = [ '@type' => 'ItemList', '@id' => trailingslashit( $term_link ) . '#itemlist', 'itemListElement' => [] ];
                $pos = 1;
                foreach ( $wp_query->posts as $p ) {
                    $list_item = nros_schema_build_item_list_element( $p, $pos );
                    if ( $list_item ) {
                        $item_list['itemListElement'][] = $list_item;
                        $pos++;
                    }
                }
                $collection['mainEntity'] = $item_list;
            }

            $graph[] = $publisher_trust; $graph[] = $collection; $graph[] = $breadcrumb;
            $json_payload = nros_schema_encode_graph( $graph );
            $output = nros_schema_format_ld_json_script( $json_payload );
            nros_schema_store_archive_cached_ld( $cache_id, $output, $legacy_key );
            echo $output; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
            return;
        }

        if ( is_author() ) {
            $author_id = get_queried_object_id();
            if (!$author_id) return;
            $paged = get_query_var('paged') ? get_query_var('paged') : 1;
            $cache_key = 'nros_schema_author_page_v156_' . $author_id . '_' . $schema_version . '_p' . $paged;
            $cached_schema = get_transient($cache_key);
            if ( false !== $cached_schema ) { echo $cached_schema; return; }
            
            $author_url = get_author_posts_url($author_id);
            $profile_page = [
                '@type' => 'ProfilePage', '@id' => trailingslashit($author_url) . '#webpage', 'url' => $author_url,
                'name' => get_the_author_meta('display_name', $author_id) . ' - ' . $site_name,
                'inLanguage' => $schema_lang, 'isPartOf' => ['@id' => home_url() . '#website'],
                'mainEntity' => ['@id' => trailingslashit($author_url) . '#author'] 
            ];

            global $wp_query;
            if ( !empty($wp_query->posts) ) {
                $item_list = [ '@type' => 'ItemList', '@id' => trailingslashit($author_url) . '#itemlist', 'itemListElement' => [] ];
                $pos = 1;
                foreach ( $wp_query->posts as $p ) {
                    $list_item = nros_schema_build_item_list_element( $p, $pos );
                    if ( $list_item ) {
                        $item_list['itemListElement'][] = $list_item;
                        $pos++;
                    }
                }
                $graph[] = $item_list;
            }

            $graph[] = $publisher_trust; $graph[] = $profile_page;
            if (function_exists('nros_get_dynamic_author_entity')) {
                $author_data = nros_get_dynamic_author_entity( $author_id, 'full' );
                if ($author_data) { $author_data['mainEntityOfPage'] = ['@id' => trailingslashit($author_url) . '#webpage']; $graph[] = $author_data; }
            }

            $json_payload = nros_schema_encode_graph( $graph );
            $output = nros_schema_format_ld_json_script( $json_payload );
            set_transient($cache_key, $output, HOUR_IN_SECONDS);
            echo $output; return;
        }

        $about_page_id = function_exists( 'newsai_get_about_page_id' ) ? newsai_get_about_page_id() : 0;
        if ( $about_page_id > 0 && is_page( $about_page_id ) ) {
            $cache_key     = 'nros_schema_about_v156_' . $about_page_id . '_' . $schema_version;
            $cached_schema = get_transient( $cache_key );
            if ( false !== $cached_schema ) {
                echo $cached_schema;
                return;
            }

            $page_url   = get_permalink( $about_page_id );
            $page_title = get_the_title( $about_page_id );
            $page_desc  = function_exists( 'nros_schema_page_description' ) ? nros_schema_page_description( $about_page_id ) : '';
            $home_label = ( strpos( $schema_lang, 'el' ) !== false ) ? 'Αρχική' : 'Home';

            $website = [
                '@type'     => 'WebSite',
                '@id'       => home_url() . '#website',
                'url'       => home_url(),
                'name'      => $site_name,
                'publisher' => [ '@id' => home_url() . '#organization' ],
            ];

            $breadcrumb = [
                '@type'           => 'BreadcrumbList',
                '@id'             => trailingslashit( $page_url ) . '#breadcrumb',
                'itemListElement' => [
                    [
                        '@type'    => 'ListItem',
                        'position' => 1,
                        'name'     => $home_label,
                        'item'     => home_url(),
                    ],
                    [
                        '@type'    => 'ListItem',
                        'position' => 2,
                        'name'     => $page_title,
                        'item'     => $page_url,
                    ],
                ],
            ];

            $about_webpage = [
                '@type'        => 'AboutPage',
                '@id'          => trailingslashit( $page_url ) . '#webpage',
                'url'          => $page_url,
                'name'         => $page_title,
                'inLanguage'   => $schema_lang,
                'isPartOf'     => [ '@id' => home_url() . '#website' ],
                'about'        => [ '@id' => home_url() . '#organization' ],
                'mainEntity'   => [ '@id' => home_url() . '#organization' ],
                'breadcrumb'   => [ '@id' => trailingslashit( $page_url ) . '#breadcrumb' ],
            ];
            if ( $page_desc !== '' ) {
                $about_webpage['description'] = $page_desc;
            }
            if ( has_post_thumbnail( $about_page_id ) ) {
                $thumb = wp_get_attachment_image_src( get_post_thumbnail_id( $about_page_id ), 'full' );
                if ( ! empty( $thumb[0] ) ) {
                    $about_webpage['primaryImageOfPage'] = [
                        '@type'  => 'ImageObject',
                        'url'    => esc_url_raw( $thumb[0] ),
                        'width'  => isset( $thumb[1] ) ? (int) $thumb[1] : null,
                        'height' => isset( $thumb[2] ) ? (int) $thumb[2] : null,
                    ];
                    $about_webpage['primaryImageOfPage'] = array_filter( $about_webpage['primaryImageOfPage'] );
                }
            }

            $graph         = [ $publisher, $website, $about_webpage, $breadcrumb ];
            $json_payload  = nros_schema_encode_graph( $graph );
            $output        = nros_schema_format_ld_json_script( $json_payload );
            set_transient( $cache_key, $output, 30 * DAY_IN_SECONDS );
            echo $output;
            return;
        }

        if ( is_singular( 'post' ) ) {
            global $post;
            $post_id = $post->ID;

            $richness = sanitize_key( (string) get_option( 'nros_schema_richness', 'balanced' ) );
            if ( ! in_array( $richness, [ 'minimal', 'balanced', 'full' ], true ) ) {
                $richness = 'full';
            }
            $body_mode = sanitize_key( (string) get_option( 'nros_schema_article_body', 'full' ) );
            if ( ! in_array( $body_mode, [ 'omit', 'full', 'limited' ], true ) ) {
                $body_mode = 'full';
            }
            $body_max_words = (int) get_option( 'nros_schema_article_body_words', 200 );
            $body_max_words = max( 30, min( 2000, $body_max_words ) );

            $liveblog_enabled = (get_option('newsai_liveblog_schema_enabled', 'yes') === 'yes');
            $liveblog_post_mode = (get_post_meta($post_id, '_newsai_enable_liveblog', true) === 'yes');
            $effective_type = nros_resolve_effective_schema_type($schema_setting, $liveblog_enabled, $liveblog_post_mode);
            $base_profile = ($schema_setting === 'article') ? 'article' : 'newsarticle';
            $article_section = 'Blog';
            $categories = get_the_category($post_id);
            if (!empty($categories)) { $article_section = $categories[0]->name; }

            $breadcrumb = [ '@type' => 'BreadcrumbList', '@id' => trailingslashit(get_permalink()) . '#breadcrumb', 'itemListElement' => [ [ '@type' => 'ListItem', 'position' => 1, 'name' => (strpos($schema_lang, 'el') !== false ? 'Αρχική' : 'Home'), 'item' => home_url() ] ] ];
            if (!empty($categories)) {
                $breadcrumb['itemListElement'][] = [ '@type' => 'ListItem', 'position' => 2, 'name' => $categories[0]->name, 'item' => get_category_link($categories[0]) ];
                $breadcrumb['itemListElement'][] = [ '@type' => 'ListItem', 'position' => 3, 'name' => get_the_title(), 'item' => get_permalink() ];
            }

            $content_pack  = nros_schema_prepare_post_content_pack( $post_id, $post );
            $clean_content = (string) $content_pack['plain'];
            $meta_desc_raw = get_post_meta($post_id, 'rank_math_description', true) ?: (get_post_meta($post_id, '_yoast_wpseo_metadesc', true) ?: (has_excerpt($post_id) ? get_the_excerpt($post_id) : wp_trim_words($clean_content, 40, '')));
            $meta_desc = nros_schema_plain_text_field( $meta_desc_raw );

            $tags = get_the_tags($post_id);
            $scored_tags = []; $keywords_array = []; $about = []; $mentions = [];
            $dynamic_place = null;

            if ( 'minimal' !== $richness && $tags ) {
                foreach ($tags as $tag) {
                    $score = 0;
                    $lat = get_term_meta($tag->term_id, 'nros_lat', true);
                    $lng = get_term_meta($tag->term_id, 'nros_lng', true);
                    $saved_type = get_term_meta($tag->term_id, 'nros_entity_type', true);

                    $tag_type = (!empty($saved_type) && $saved_type !== 'auto') ? $saved_type : (nros_detect_entity_type($tag->name, $tag->slug));
                    if ( 'Event' === $tag_type ) {
                        $tag_type = 'Thing';
                    }
                    if ($tag_type !== 'Place' && !empty($lat) && !empty($lng)) { $tag_type = 'Place'; }

                    $wiki_url = nros_schema_resolve_tag_wikidata_url( (int) $tag->term_id, (string) $tag->name, $tag_type );

                    if ($wiki_url) $score += 3;
                    if (mb_stripos(get_the_title(), $tag->name) !== false) $score += 2;
                    if (substr_count(mb_strtolower($clean_content), mb_strtolower($tag->name)) >= 3) $score += 1;

                    if (!nros_is_low_value_keyword($tag->name)) {
                        $keywords_array[] = $tag->name;
                    }
                    // Tag entities do not carry full Event fields (startDate/location/etc).
                    // Emitting @type Event from plain taxonomy tags can trigger rich-result
                    // warnings, so downgrade to Thing unless a dedicated Event schema exists.

                    $entity_node = [ '@type' => $tag_type, '@id' => trailingslashit(get_term_link($tag)) . '#entity', 'name' => $tag->name, 'url' => get_term_link($tag) ];
                    if ($wiki_url) $entity_node['sameAs'] = esc_url_raw($wiki_url);
                    if ($tag_type === 'Place') {
                        if (!empty($lat) && !empty($lng)) {
                            $entity_node['geo'] = [ '@type' => 'GeoCoordinates', 'latitude' => (float)$lat, 'longitude' => (float)$lng ];
                        } else {
                            $city_map = nros_get_global_cities_graph();
                            $search_key = nros_global_unaccent($tag->name);
                            if (isset($city_map[$search_key])) {
                                $entity_node['geo'] = [ '@type' => 'GeoCoordinates', 'latitude' => (float)$city_map[$search_key]['lat'], 'longitude' => (float)$city_map[$search_key]['lng'] ];
                            }
                        }
                    }
                    $scored_tags[] = [ 'entity' => $entity_node, 'score' => $score ];
                }
                usort($scored_tags, function($a, $b) { return $b['score'] <=> $a['score']; });
                foreach ($scored_tags as $t) {
                    $ent = array_filter($t['entity']);
                    if (!empty($ent['name']) && nros_is_low_value_keyword($ent['name'])) {
                        continue;
                    }
                    if ($ent['@type'] === 'Place' && !$dynamic_place) { $dynamic_place = $ent; }
                    if ($t['score'] >= 3 || count($about) < 1) { if (count($about) < 3) { $about[] = $ent; } else { $mentions[] = $ent; } } else { $mentions[] = $ent; }
                }
            }
            // Cap entity depth in ALL non-minimal modes. Google ignores excess about/mentions/
            // keywords and lean-but-accurate structured data is the 2026 best practice. Full mode
            // stays a little richer than Balanced, but never unbounded.
            if ( 'minimal' !== $richness ) {
                $caps = ( 'balanced' === $richness )
                    ? [ 'about' => 1, 'mentions' => 3, 'keywords' => 8 ]
                    : [ 'about' => 2, 'mentions' => 5, 'keywords' => 10 ];
                $caps = apply_filters( 'nros_schema_entity_caps', $caps, $richness );
                if ( count( $about ) > $caps['about'] ) {
                    $about = array_slice( $about, 0, $caps['about'] );
                }
                if ( count( $mentions ) > $caps['mentions'] ) {
                    $mentions = array_slice( $mentions, 0, $caps['mentions'] );
                }
                if ( count( $keywords_array ) > $caps['keywords'] ) {
                    $keywords_array = array_slice( $keywords_array, 0, $caps['keywords'] );
                }
            }

            $image_pack  = nros_build_article_image_urls($post_id);
            $image_objs  = $image_pack['images'];
            $thumb_lead  = isset( $image_pack['thumbnail_url'] ) ? (string) $image_pack['thumbnail_url'] : '';

            $content_location = nros_schema_resolve_content_location(
                $dynamic_place,
                $primary_city,
                $scored_tags,
                (string) get_the_title(),
                (string) $meta_desc,
                (string) $clean_content
            );

            $dt_pack       = function_exists( 'nros_schema_resolve_article_datetimes' )
                ? nros_schema_resolve_article_datetimes( $post_id )
                : [ 'published' => nros_schema_post_datetime_iso( $post_id, 'date' ), 'modified' => nros_schema_post_datetime_iso( $post_id, 'modified' ) ];
            $published_iso = (string) ( $dt_pack['published'] ?? '' );
            $modified_iso  = (string) ( $dt_pack['modified'] ?? '' );

            $website = nros_schema_build_website_node( $site_name, $site_desc, $schema_lang );
            $webpage = nros_schema_build_single_webpage_node( $post_id, $meta_desc, $published_iso, $modified_iso, $schema_lang );

            $article_type_value = ($effective_type === 'LiveBlogPosting') ? ['LiveBlogPosting', 'NewsArticle'] : $effective_type;
            $article = [
                '@type' => $article_type_value, '@id' => trailingslashit(get_permalink()) . '#article',
                'isPartOf' => [ ['@id' => trailingslashit(get_permalink()) . '#webpage'], ['@id' => home_url() . '#website'] ],
                'mainEntityOfPage' => ['@id' => trailingslashit(get_permalink()) . '#webpage'],
                'headline' => mb_substr( nros_schema_plain_text_field( get_the_title() ), 0, 110 ), 'description' => $meta_desc,
                'datePublished' => $published_iso,
                'dateModified'  => $modified_iso,
                'dateCreated'   => $published_iso,
                'publisher' => [ '@id' => home_url() . '#organization' ],
                'articleSection' => $article_section, 'wordCount' => nros_schema_count_plain_words( $clean_content ),
                'inLanguage' => $schema_lang,
                'isAccessibleForFree' => (get_option('nros_paywall_active', 'no') === 'yes') ? false : true
            ];
            if ( 'omit' !== $body_mode ) {
                $article_body = nros_schema_build_article_body( $clean_content, $body_mode, $body_max_words, true );
                if ( ( ! is_string( $article_body ) || $article_body === '' ) && trim( (string) $content_pack['extracted'] ) !== '' ) {
                    $article_body = nros_schema_build_article_body( (string) $content_pack['extracted'], $body_mode, $body_max_words, false );
                }
                if ( is_string( $article_body ) && $article_body !== '' ) {
                    $article['articleBody'] = $article_body;
                }
            }
            if (!empty($image_objs)) {
                $article['image'] = $image_objs;
                $article['thumbnailUrl'] = $thumb_lead !== ''
                    ? $thumb_lead
                    : ( isset( $image_objs[0]['url'] ) ? $image_objs[0]['url'] : '' );
            }
            if ( is_array( $content_location ) && ! empty( $content_location['name'] ) ) {
                if ( $base_profile === 'newsarticle' || $effective_type === 'LiveBlogPosting' || ! empty( $dynamic_place ) ) {
                    $article['contentLocation'] = $content_location;
                }
            }

            if ( 'minimal' !== $richness ) {
                if (!empty($about)) { $article['about'] = $about; }
                if (!empty($mentions)) { $article['mentions'] = $mentions; }
                if (!empty($keywords_array)) { $article['keywords'] = array_values(array_unique($keywords_array)); }
            }

            if ( ( $effective_type === 'NewsArticle' || $effective_type === 'LiveBlogPosting' ) && is_array( $content_location ) && ! empty( $content_location['name'] ) ) {
                $article['dateline'] = (string) $content_location['name'];
            }
            if ($effective_type === 'LiveBlogPosting') {
                $article['coverageStartTime'] = $published_iso;
                $article['coverageEndTime']   = $modified_iso;
                $live_updates = nros_build_liveblog_updates($post);
                if (!empty($live_updates)) {
                    $article['liveBlogUpdate'] = $live_updates;
                }
            }
            $speakable_selectors = function_exists( 'nros_schema_parse_speakable_selectors' )
                ? nros_schema_parse_speakable_selectors( (string) get_option( 'newsai_speakable_selectors', '' ) )
                : array_values(
                    array_filter(
                        array_map( 'trim', explode( ',', (string) get_option( 'newsai_speakable_selectors', '' ) ) )
                    )
                );
            $speakable_selectors = apply_filters( 'nros_schema_speakable_css_selectors', $speakable_selectors, $post_id, $post );
            if ( ! is_array( $speakable_selectors ) ) {
                $speakable_selectors = [];
            }
            $speakable_selectors = array_values(
                array_filter(
                    array_map(
                        static function ( $s ) {
                            return is_string( $s ) ? trim( $s ) : '';
                        },
                        $speakable_selectors
                    )
                )
            );
            $speakable_selectors = function_exists( 'nros_speakable_finalize_schema_selectors' )
                ? nros_speakable_finalize_schema_selectors( $speakable_selectors, $richness, $post_id, $post )
                : $speakable_selectors;
            // Speakable is omitted only for Minimal. Balanced + Full honor configured selectors.
            // Mirror on WebPage too: Google's primary example attaches speakable to WebPage; some tools only surface it there.
            if ( 'minimal' !== $richness && ! empty( $speakable_selectors ) ) {
                $speakable_node = [
                    '@type'       => 'SpeakableSpecification',
                    'cssSelector' => $speakable_selectors,
                ];
                $article['speakable'] = $speakable_node;
                $webpage['speakable'] = $speakable_node;
            }

            $author_id = (int) $post->post_author;
            if ( $author_id ) {
                if ( 'minimal' === $richness ) {
                    $au = get_userdata( $author_id );
                    if ( $au ) {
                        $article['author'] = [
                            '@type' => 'Person',
                            'name'  => $au->display_name,
                            'url'   => get_author_posts_url( $author_id ),
                        ];
                    }
                } elseif ( function_exists( 'nros_get_dynamic_author_entity' ) ) {
                    // Balanced & Full: pointer Person in the article graph; full E-E-A-T only on is_author() ProfilePage.
                    $author_data = nros_get_dynamic_author_entity( $author_id, 'slim' );
                    if ( $author_data ) {
                        $graph[] = $author_data;
                        $article['author'] = [ '@id' => trailingslashit( get_author_posts_url( $author_id ) ) . '#author' ];
                    }
                }
            }

            if ( 'minimal' !== $richness && function_exists('nros_extract_video_schema')) { $video = nros_extract_video_schema($post->post_content, $post); if ($video) { $graph[] = $video; $article['video'] = ['@id' => trailingslashit(get_permalink()) . '#video']; } }

            $claim_review = function_exists( 'nros_schema_build_claim_review_node' )
                ? nros_schema_build_claim_review_node( $post_id, trailingslashit( get_permalink() ) . '#article', home_url() . '#organization' )
                : null;
            if ( is_array( $claim_review ) && ! empty( $claim_review['@id'] ) ) {
                $graph[] = $claim_review;
            }

            $graph[] = $publisher_trust; $graph[] = $website; $graph[] = $webpage; $graph[] = $breadcrumb; $graph[] = $article;
            $json_payload = nros_schema_encode_graph( $graph );
            $output = nros_schema_format_ld_json_script( $json_payload );
            if ( function_exists( 'nros_schema_store_post_cached_ld' ) ) {
                nros_schema_store_post_cached_ld( $post_id, $output );
            } else {
                set_transient( 'nros_schema_post_v156_' . $post_id . '_' . $schema_version, $output, 30 * DAY_IN_SECONDS );
            }
            $output = (string) apply_filters( 'nros_schema_ld_output', $output, $post_id );
            echo $output; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
        }
    }
}

// ==========================================
// Speakable: optional concise visible snippet (DOM text for TTS)
// ==========================================
add_filter( 'the_content', 'nros_speakable_prepend_concise_snippet', 4 );
if ( ! function_exists( 'nros_speakable_build_snippet_text' ) ) {
    /**
     * Plain text for the speakable snippet (excerpt first, else trimmed body).
     *
     * @param int $post_id    Post ID.
     * @param int $max_chars Max characters (clamped 80–2000).
     */
    function nros_speakable_build_snippet_text( $post_id, $max_chars ) {
        $post_id = (int) $post_id;
        if ( $post_id <= 0 ) {
            return '';
        }
        $max_chars = max( 80, min( 2000, (int) $max_chars ) );
        $raw       = get_post_field( 'post_excerpt', $post_id );
        $raw       = is_string( $raw ) ? trim( wp_strip_all_tags( $raw, true ) ) : '';
        if ( $raw === '' ) {
            $raw = wp_strip_all_tags( (string) get_post_field( 'post_content', $post_id ), true );
            $raw = trim( wp_trim_words( $raw, 100, '' ) );
        }
        if ( $raw === '' ) {
            return '';
        }
        if ( function_exists( 'mb_strlen' ) && function_exists( 'mb_substr' ) ) {
            if ( mb_strlen( $raw, 'UTF-8' ) > $max_chars ) {
                $raw = mb_substr( $raw, 0, $max_chars, 'UTF-8' );
                $raw = preg_replace( '/\s+\S*$/u', '', $raw );
                $raw .= '…';
            }
        } else {
            if ( strlen( $raw ) > $max_chars ) {
                $raw = substr( $raw, 0, $max_chars );
                $raw = preg_replace( '/\s+\S*$/', '', $raw ) . '...';
            }
        }
        return (string) apply_filters( 'nros_speakable_snippet_text', $raw, $post_id, $max_chars );
    }
}

if ( ! function_exists( 'nros_speakable_prepend_concise_snippet' ) ) {
    /**
     * Prepends a short paragraph so Speakable cssSelector can target concise voice text (Google suggests ~20–30s / few sentences).
     */
    function nros_speakable_prepend_concise_snippet( $content ) {
        if ( is_admin() || wp_doing_ajax() || ( function_exists( 'newsai_is_http_rest_request' ) && newsai_is_http_rest_request() ) || ( function_exists( 'wp_is_json_request' ) && wp_is_json_request() ) ) {
            return $content;
        }
        if ( is_feed() || is_embed() ) {
            return $content;
        }
        if ( ! is_singular( 'post' ) || ! in_the_loop() || ! is_main_query() ) {
            return $content;
        }
        if ( function_exists( 'newsai_lite_mode_skips_frontend_injections' ) && newsai_lite_mode_skips_frontend_injections() ) {
            return $content;
        }
        if ( get_option( 'nros_speakable_snippet_enabled', 'no' ) !== 'yes' ) {
            return $content;
        }
        $rich = sanitize_key( (string) get_option( 'nros_schema_richness', 'balanced' ) );
        if ( 'minimal' === $rich ) {
            return $content;
        }
        $user_sel = trim( (string) get_option( 'newsai_speakable_selectors', '' ) );
        if ( $user_sel === '' && get_option( 'nros_speakable_snippet_enabled', 'no' ) !== 'yes' ) {
            return $content;
        }
        $post = get_post();
        if ( ! $post || $post->post_type !== 'post' || $post->post_status !== 'publish' ) {
            return $content;
        }
        static $done = [];
        if ( isset( $done[ $post->ID ] ) ) {
            return $content;
        }
        $max = (int) get_option( 'nros_speakable_snippet_max_chars', 400 );
        $max = max( 80, min( 2000, $max ) );
        $txt = nros_speakable_build_snippet_text( $post->ID, $max );
        if ( $txt === '' ) {
            return $content;
        }
        $done[ $post->ID ] = true;
        $p = '<p class="nros-speakable-snippet" data-newsai-speakable-snippet="1">' . esc_html( $txt ) . '</p>' . "\n";
        return $p . $content;
    }
}

// ==========================================
// Schema cache warm on publish (avoid cold frontend miss)
// ==========================================
if ( ! function_exists( 'nros_schema_queue_warm_post_cache' ) ) {
	/**
	 * Warm on save/publish in admin; queue cron work for frontend cache misses (no shutdown load).
	 *
	 * @param int $post_id Post ID.
	 * @return void
	 */
	function nros_schema_queue_warm_post_cache( $post_id ) {
		if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) {
			return;
		}
		$post_id = (int) $post_id;
		if ( $post_id < 1 || wp_is_post_revision( $post_id ) ) {
			return;
		}
		$post = get_post( $post_id );
		if ( ! $post || $post->post_type !== 'post' || $post->post_status !== 'publish' ) {
			return;
		}
		if ( get_option( 'nros_schema_type', 'newsarticle' ) === 'none' ) {
			return;
		}

		$can_warm_inline = is_admin() || wp_doing_cron() || nros_schema_is_warm_context();
		if ( $can_warm_inline && function_exists( 'nros_schema_warm_single_post_cache' ) ) {
			$lock_key = 'nros_schema_warm_post_' . $post_id;
			if ( ! get_transient( $lock_key ) ) {
				set_transient( $lock_key, 1, 30 );
				nros_schema_set_warm_context( true );
				nros_schema_warm_single_post_cache( $post_id );
				nros_schema_set_warm_context( false );
				delete_transient( $lock_key );
			}
			return;
		}

		if ( function_exists( 'nros_schema_enqueue_cron_warm_post' ) ) {
			nros_schema_enqueue_cron_warm_post( $post_id );
		}
	}
}
add_action( 'save_post', 'nros_schema_queue_warm_post_cache', 99, 1 );

if ( ! function_exists( 'nros_schema_queue_warm_on_publish' ) ) {
	/**
	 * @param string  $new_status New status.
	 * @param string  $old_status Old status.
	 * @param WP_Post $post       Post.
	 */
	function nros_schema_queue_warm_on_publish( $new_status, $old_status, $post ) {
		if ( $new_status !== 'publish' || $old_status === 'publish' ) {
			return;
		}
		if ( ! $post instanceof WP_Post || $post->post_type !== 'post' ) {
			return;
		}
		nros_schema_queue_warm_post_cache( (int) $post->ID );
	}
}
add_action( 'transition_post_status', 'nros_schema_queue_warm_on_publish', 20, 3 );

if ( ! function_exists( 'nros_schema_warm_single_post_cache' ) ) {
	/**
	 * Build and store the single-post schema transient without sending output.
	 *
	 * @param int $post_id Post ID.
	 * @return bool True when cache exists or was built.
	 */
	function nros_schema_warm_single_post_cache( $post_id ) {
		$post_id = (int) $post_id;
		if ( $post_id < 1 || ! function_exists( 'nros_output_json_ld_schema' ) ) {
			return false;
		}

		$stored_v  = (string) get_post_meta( $post_id, nros_schema_post_meta_version_key(), true );
		$current_v = nros_schema_get_global_version();
		$packed    = get_post_meta( $post_id, nros_schema_post_meta_json_key(), true );
		$packed    = is_string( $packed ) ? $packed : '';

		if ( $stored_v === $current_v && $packed !== '' ) {
			$cached = nros_schema_unpack_cached_ld_payload( $packed );
			if ( $cached !== '' ) {
				if ( ! function_exists( 'nros_schema_cached_ld_needs_content_rebuild' )
					|| ! nros_schema_cached_ld_needs_content_rebuild( $cached ) ) {
					return true;
				}
				if ( function_exists( 'nros_schema_delete_post_cached_ld' ) ) {
					nros_schema_delete_post_cached_ld( $post_id );
				}
			}
		}

		$post = get_post( $post_id );
		if ( ! $post || $post->post_type !== 'post' || $post->post_status !== 'publish' ) {
			return false;
		}

		global $wp_query, $post;
		$prev_query = $wp_query;
		$prev_post  = $post;

		$warm_query = new WP_Query(
			[
				'p'                      => $post_id,
				'post_type'              => 'post',
				'posts_per_page'         => 1,
				'no_found_rows'          => true,
				'update_post_meta_cache' => false,
				'update_post_term_cache' => true,
			]
		);
		if ( ! $warm_query->have_posts() ) {
			$wp_query = $prev_query;
			$post     = $prev_post;
			return false;
		}
		$wp_query = $warm_query;
		$warm_query->the_post();

		nros_schema_set_warm_context( true );
		ob_start();
		nros_output_json_ld_schema();
		ob_end_clean();
		nros_schema_set_warm_context( false );

		wp_reset_postdata();
		$wp_query = $prev_query;
		$post     = $prev_post;

		return function_exists( 'nros_schema_get_post_cached_ld' )
			? ( nros_schema_get_post_cached_ld( $post_id ) !== '' )
			: false;
	}
}

if ( ! function_exists( 'nros_schema_process_warm_queue' ) ) {
	/**
	 * Legacy shutdown queue — forwards to cron (no inline warm on visitor requests).
	 */
	function nros_schema_process_warm_queue() {
		global $nros_schema_warm_queue;
		if ( empty( $nros_schema_warm_queue ) || ! is_array( $nros_schema_warm_queue ) ) {
			return;
		}
		foreach ( array_keys( $nros_schema_warm_queue ) as $id ) {
			if ( function_exists( 'nros_schema_enqueue_cron_warm_post' ) ) {
				nros_schema_enqueue_cron_warm_post( (int) $id );
			}
		}
		$nros_schema_warm_queue = [];
	}
}