<?php
/**
 * Livada SEO — Module Schema JSON-LD
 *
 * Outputs a single <script type="application/ld+json"> block containing a
 * Schema.org @graph with:
 *   - WebSite      (always)
 *   - LocalBusiness (front page only)
 *   - Service nodes (pages with _livada_seo_service_schema meta = true)
 *   - Article      (singular posts, post type 'post')
 *   - FAQPage      (singular posts/pages with _livada_seo_faq_schema meta = true)
 *
 * BreadcrumbList is intentionally excluded — it is generated inline by the
 * breadcrumbs module.
 *
 * @package Livada_SEO
 */

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

class Livada_SEO_Schema {

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

	/**
	 * Registers the wp_head hook.
	 * Called once by the Loader on `plugins_loaded`.
	 */
	public static function init(): void {
		add_action( 'wp_head', [ self::class, 'output' ], 3 );
	}

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

	/**
	 * Builds the JSON-LD @graph that would be emitted on the front-end of the
	 * given post, without rendering anything. Used by the REST preview endpoint
	 * and the metabox live preview panel.
	 *
	 * Wraps a temporary `setup_postdata` call so `is_singular()`, `get_the_ID()`
	 * and friends behave as if we were rendering that post. The wrapping is
	 * carefully nested so admin contexts aren't polluted.
	 *
	 * @param int $post_id
	 * @return array<string,mixed>
	 */
	public static function build_preview_graph( int $post_id ): array {
		$post = get_post( $post_id );
		if ( ! $post ) {
			return [
				'@context' => 'https://schema.org',
				'@graph'   => [],
			];
		}

		// Save current globals so we can restore.
		global $post, $wp_query;
		$saved_post  = $post;
		$saved_query = $wp_query;

		// Fake a "is_singular( post_type )" context. setup_postdata() handles
		// the global $post properties; we then build a synthetic WP_Query so
		// is_singular() / is_single() / is_page() return true.
		$post = get_post( $post_id );
		setup_postdata( $post );

		$wp_query              = new WP_Query();
		$wp_query->is_singular = true;
		$wp_query->is_single   = ( 'post' === $post->post_type );
		$wp_query->is_page     = ( 'page' === $post->post_type );
		$wp_query->queried_object    = $post;
		$wp_query->queried_object_id = $post->ID;
		$wp_query->post              = $post;
		$wp_query->posts             = [ $post ];

		$graph = [];

		$graph[] = self::build_website();

		foreach ( self::build_local_businesses() as $node ) {
			$graph[] = $node;
		}

		$service_nodes = self::build_service_nodes();
		foreach ( $service_nodes as $node ) {
			$graph[] = $node;
		}

		if ( 'post' === $post->post_type ) {
			$article = self::build_article();
			if ( $article ) {
				$graph[] = $article;
			}
		}

		$faq = self::build_faq();
		if ( $faq ) {
			$graph[] = $faq;
		}

		// Person (E-A-T) — same logic as the front-end output().
		if ( 'post' === $post->post_type ) {
			$person = self::build_author_person();
			if ( $person ) {
				$graph[] = $person;
			}
		}

		$content_type = (string) get_post_meta( $post->ID, '_livada_seo_schema_type', true );
		switch ( $content_type ) {
			case 'HowTo':
				$node = self::build_howto();
				if ( $node ) { $graph[] = $node; }
				break;
			case 'Product':
				$node = self::build_product();
				if ( $node ) { $graph[] = $node; }
				break;
			case 'Event':
				$node = self::build_event();
				if ( $node ) { $graph[] = $node; }
				break;
			case 'Recipe':
				$node = self::build_recipe();
				if ( $node ) { $graph[] = $node; }
				break;
			case 'Review':
				$node = self::build_review();
				if ( $node ) { $graph[] = $node; }
				break;
			case 'Offer':
				$node = self::build_offer();
				if ( $node ) { $graph[] = $node; }
				break;
		}

		// Restore globals.
		$post     = $saved_post;
		$wp_query = $saved_query;
		wp_reset_postdata();

		return [
			'@context' => 'https://schema.org',
			'@graph'   => $graph,
		];
	}

	// ── Output ────────────────────────────────────────────────────────────────

	/**
	 * Builds the @graph array and echoes the <script> block.
	 * Hooked to wp_head at priority 3.
	 */
	public static function output(): void {
		$graph = [];

		// Step 1 — WebSite node (always present).
		$graph[] = self::build_website();

		// Step 2 — LocalBusiness node(s). Multi-location support: when the
		// `livada_seo_locations` option holds more than one entry, every
		// location is emitted as its own node. Single-location deployments
		// keep using the legacy `schema_*` settings (auto-wrapped as location 0).
		foreach ( self::build_local_businesses() as $node ) {
			$graph[] = $node;
		}

		// Step 3 — Service nodes (pages with the post meta flag).
		if ( is_singular() ) {
			$service_nodes = self::build_service_nodes();
			if ( ! empty( $service_nodes ) ) {
				foreach ( $service_nodes as $node ) {
					$graph[] = $node;
				}
			}
		}

		// Step 4 — Article node (singular blog posts only).
		if ( is_singular( 'post' ) ) {
			$article = self::build_article();
			if ( $article ) {
				$graph[] = $article;
			}
		}

		// Step 5 — FAQPage node (singular posts/pages with the post meta flag).
		if ( is_singular() ) {
			$faq = self::build_faq();
			if ( $faq ) {
				$graph[] = $faq;
			}
		}

		// Step 5b — Person node on singular posts (so the Article author has a
		// resolvable @id) and on author archives (ProfilePage scenario).
		if ( is_singular( 'post' ) || is_author() ) {
			$person = self::build_author_person();
			if ( $person ) {
				$graph[] = $person;
			}
		}

		// Step 6 — Optional content-type schemas selected per-post via the
		// `_livada_seo_schema_type` meta. Empty/unknown values are ignored so
		// the default WebSite + LocalBusiness output stays untouched.
		if ( is_singular() ) {
			$content_type = (string) get_post_meta( get_the_ID(), '_livada_seo_schema_type', true );

			switch ( $content_type ) {
				case 'HowTo':
					$node = self::build_howto();
					if ( $node ) { $graph[] = $node; }
					break;
				case 'Product':
					$node = self::build_product();
					if ( $node ) { $graph[] = $node; }
					break;
				case 'Event':
					$node = self::build_event();
					if ( $node ) { $graph[] = $node; }
					break;
				case 'Recipe':
					$node = self::build_recipe();
					if ( $node ) { $graph[] = $node; }
					break;
				case 'Review':
					$node = self::build_review();
					if ( $node ) { $graph[] = $node; }
					break;
				case 'Offer':
					$node = self::build_offer();
					if ( $node ) { $graph[] = $node; }
					break;
			}
		}

		if ( empty( $graph ) ) {
			return;
		}

		$schema = [
			'@context' => 'https://schema.org',
			'@graph'   => $graph,
		];

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

	// ── Node builders ─────────────────────────────────────────────────────────

	/**
	 * Builds the WebSite node with a SearchAction potentialAction.
	 *
	 * @return array<string,mixed>
	 */
	private static function build_website(): array {
		$site_url  = home_url( '/' );
		$site_name = Livada_SEO_Loader::get_setting( 'site_name', '' );

		if ( '' === (string) $site_name ) {
			$site_name = get_bloginfo( 'name' );
		}

		$node = [
			'@type'           => 'WebSite',
			'@id'             => $site_url . '#website',
			'url'             => $site_url,
			'name'            => (string) $site_name,
			'potentialAction' => [
				'@type'       => 'SearchAction',
				'target'      => [
					'@type'       => 'EntryPoint',
					'urlTemplate' => $site_url . '?s={search_term_string}',
				],
				'query-input' => 'required name=search_term_string',
			],
		];

		return $node;
	}

	// ── Multi-location infrastructure ─────────────────────────────────────────

	/**
	 * Returns the list of business locations to emit as LocalBusiness nodes.
	 *
	 * Order of precedence:
	 *   1. The `livada_seo_locations` option, when it holds at least one entry
	 *      with a non-empty `name`.
	 *   2. A synthetic single-location array built from the legacy `schema_*`
	 *      settings (backward compatibility for sites configured before the
	 *      multi-location feature shipped).
	 *
	 * Each location is a flat associative array. See `legacy_settings_to_location()`
	 * for the canonical key list.
	 *
	 * @return array<int,array<string,mixed>>
	 */
	public static function get_locations(): array {
		$saved = get_option( 'livada_seo_locations', [] );

		if ( is_array( $saved ) && ! empty( $saved ) ) {
			$out = [];
			foreach ( $saved as $loc ) {
				if ( ! is_array( $loc ) ) {
					continue;
				}
				if ( '' === trim( (string) ( $loc['name'] ?? '' ) ) ) {
					continue;
				}
				$out[] = $loc;
			}

			if ( ! empty( $out ) ) {
				return array_values( $out );
			}
		}

		// Fallback: synthesise a single location from legacy schema_* settings.
		$legacy = self::legacy_settings_to_location();
		return null === $legacy ? [] : [ $legacy ];
	}

	/**
	 * Builds a location-shaped array from the legacy `schema_*` plugin settings.
	 * Returns null when the minimum required data (name) is missing.
	 */
	private static function legacy_settings_to_location(): ?array {
		$name = (string) Livada_SEO_Loader::get_setting( 'schema_name', '' );
		if ( '' === $name ) {
			return null;
		}

		return [
			'name'             => $name,
			'type'             => (string) Livada_SEO_Loader::get_setting( 'schema_type', 'LocalBusiness' ),
			'address'          => (string) Livada_SEO_Loader::get_setting( 'schema_address', '' ),
			'city'             => (string) Livada_SEO_Loader::get_setting( 'schema_city', '' ),
			'postal'           => (string) Livada_SEO_Loader::get_setting( 'schema_postal', '' ),
			'country'          => (string) Livada_SEO_Loader::get_setting( 'schema_country', 'FR' ),
			'phone'            => (string) Livada_SEO_Loader::get_setting( 'schema_phone', '' ),
			'email'            => (string) Livada_SEO_Loader::get_setting( 'schema_email', '' ),
			'url'              => (string) Livada_SEO_Loader::get_setting( 'schema_url', home_url( '/' ) ),
			'logo'             => (string) Livada_SEO_Loader::get_setting( 'schema_logo', '' ),
			'geo_lat'          => (string) Livada_SEO_Loader::get_setting( 'schema_geo_lat', '' ),
			'geo_lng'          => (string) Livada_SEO_Loader::get_setting( 'schema_geo_lng', '' ),
			'price_range'      => (string) Livada_SEO_Loader::get_setting( 'schema_price_range', '' ),
			'opening_hours'    => (array)  Livada_SEO_Loader::get_setting( 'schema_opening_hours', [] ),
			'same_as'          => (array)  Livada_SEO_Loader::get_setting( 'schema_same_as', [] ),
			'services'         => (array)  Livada_SEO_Loader::get_setting( 'schema_services', [] ),
			'star_rating'      => (string) Livada_SEO_Loader::get_setting( 'schema_star_rating', '' ),
			'number_of_rooms'  => (string) Livada_SEO_Loader::get_setting( 'schema_number_of_rooms', '' ),
			'pets_allowed'     => (string) Livada_SEO_Loader::get_setting( 'schema_pets_allowed', '' ),
			'checkin_time'     => (string) Livada_SEO_Loader::get_setting( 'schema_checkin_time', '' ),
			'checkout_time'    => (string) Livada_SEO_Loader::get_setting( 'schema_checkout_time', '' ),
			'amenities'        => (array)  Livada_SEO_Loader::get_setting( 'schema_amenities', [] ),
			'rating_value'     => (string) Livada_SEO_Loader::get_setting( 'schema_rating_value', '' ),
			'review_count'     => (string) Livada_SEO_Loader::get_setting( 'schema_review_count', '' ),
		];
	}

	/**
	 * Returns the JSON-LD `@id` URL for a given location index.
	 * Index 0 keeps the legacy `#localbusiness` anchor for backward compat with
	 * existing graph references; subsequent indexes use `#localbusiness-N` (1-based).
	 */
	public static function location_anchor( int $index ): string {
		if ( $index <= 0 ) {
			return home_url( '/#localbusiness' );
		}
		return home_url( '/#localbusiness-' . ( $index + 1 ) );
	}

	/**
	 * Resolves the location anchor URL to use for the current singular post,
	 * based on the `_livada_seo_location_id` post meta. Falls back to the
	 * default location (index 0) when the post has no override.
	 */
	public static function post_location_anchor( int $post_id ): string {
		$raw = get_post_meta( $post_id, '_livada_seo_location_id', true );
		if ( '' === $raw || false === $raw ) {
			return self::location_anchor( 0 );
		}
		return self::location_anchor( max( 0, (int) $raw ) );
	}

	/**
	 * Builds every LocalBusiness node configured for the site.
	 *
	 * @return array<int,array<string,mixed>>
	 */
	private static function build_local_businesses(): array {
		$locations = self::get_locations();
		$nodes     = [];

		foreach ( $locations as $i => $loc ) {
			$node = self::build_location_node( $loc, (int) $i );
			if ( null !== $node ) {
				$nodes[] = $node;
			}
		}

		return $nodes;
	}

	/**
	 * Builds a single LocalBusiness JSON-LD node from a location array.
	 *
	 * @param  array<string,mixed> $loc
	 * @param  int                 $index Zero-based location index.
	 * @return array<string,mixed>|null
	 */
	private static function build_location_node( array $loc, int $index ): ?array {
		$name = trim( (string) ( $loc['name'] ?? '' ) );
		if ( '' === $name ) {
			return null;
		}

		$schema_type  = (string) ( $loc['type']        ?? 'LocalBusiness' );
		$address_line = (string) ( $loc['address']     ?? '' );
		$city         = (string) ( $loc['city']        ?? '' );
		$postal       = (string) ( $loc['postal']      ?? '' );
		$country      = (string) ( $loc['country']     ?? 'FR' );
		$phone        = (string) ( $loc['phone']       ?? '' );
		$email        = (string) ( $loc['email']       ?? '' );
		$url          = (string) ( $loc['url']         ?? home_url( '/' ) );
		$logo         = (string) ( $loc['logo']        ?? '' );
		$geo_lat      = (string) ( $loc['geo_lat']     ?? '' );
		$geo_lng      = (string) ( $loc['geo_lng']     ?? '' );
		$price_range  = (string) ( $loc['price_range'] ?? '' );
		$opening_h    = (array)  ( $loc['opening_hours'] ?? [] );
		$same_as      = (array)  ( $loc['same_as']       ?? [] );

		$node = [
			'@type' => $schema_type,
			'@id'   => self::location_anchor( $index ),
			'name'  => $name,
		];

		// PostalAddress.
		$address = [];
		if ( '' !== (string) $address_line ) {
			$address['streetAddress'] = (string) $address_line;
		}
		if ( '' !== (string) $city ) {
			$address['addressLocality'] = (string) $city;
		}
		if ( '' !== (string) $postal ) {
			$address['postalCode'] = (string) $postal;
		}
		if ( '' !== (string) $country ) {
			$address['addressCountry'] = (string) $country;
		}
		if ( ! empty( $address ) ) {
			$address['@type'] = 'PostalAddress';
			$node['address']  = $address;
		}

		// Scalar contact / identity fields.
		if ( '' !== (string) $phone ) {
			$node['telephone'] = (string) $phone;
		}
		if ( '' !== (string) $email ) {
			$node['email'] = (string) $email;
		}
		if ( '' !== (string) $url ) {
			$node['url'] = (string) $url;
		}

		// Logo.
		if ( '' !== (string) $logo ) {
			$node['logo'] = [
				'@type' => 'ImageObject',
				'url'   => (string) $logo,
			];
		}

		// GeoCoordinates.
		if ( '' !== (string) $geo_lat && '' !== (string) $geo_lng ) {
			$node['geo'] = [
				'@type'     => 'GeoCoordinates',
				'latitude'  => (float) $geo_lat,
				'longitude' => (float) $geo_lng,
			];
		}

		// Price range.
		if ( '' !== (string) $price_range ) {
			$node['priceRange'] = (string) $price_range;
		}

		// Opening hours — supports both string format ("Mo-Fr 08:00-19:00")
		// and structured format ([{ day, open, close }]).
		if ( is_array( $opening_h ) && ! empty( $opening_h ) ) {
			$first = reset( $opening_h );

			if ( is_string( $first ) ) {
				// Simple string format → openingHours property.
				$node['openingHours'] = array_values( array_filter( $opening_h, 'strlen' ) );
			} else {
				// Structured format → openingHoursSpecification.
				$hours_spec = [];
				foreach ( $opening_h as $entry ) {
					if ( empty( $entry['day'] ) || empty( $entry['open'] ) || empty( $entry['close'] ) ) {
						continue;
					}
					$hours_spec[] = [
						'@type'     => 'OpeningHoursSpecification',
						'dayOfWeek' => 'https://schema.org/' . $entry['day'],
						'opens'     => $entry['open'],
						'closes'    => $entry['close'],
					];
				}
				if ( ! empty( $hours_spec ) ) {
					$node['openingHoursSpecification'] = $hours_spec;
				}
			}
		}

		// sameAs — automatically augmented with the Wikidata entity URL on the
		// primary location when an `aeo_wikidata_id` Q-id is declared. This
		// anchors the business in the structured knowledge graph that
		// AI engines consult, at zero external-call cost.
		if ( ! is_array( $same_as ) ) {
			$same_as = [];
		}
		if ( 0 === $index ) {
			$wikidata_id = trim( (string) Livada_SEO_Loader::get_setting( 'aeo_wikidata_id', '' ) );
			if ( '' !== $wikidata_id && preg_match( '/^Q\d+$/', $wikidata_id ) ) {
				$wikidata_url = 'https://www.wikidata.org/entity/' . $wikidata_id;
				if ( ! in_array( $wikidata_url, $same_as, true ) ) {
					$same_as[] = $wikidata_url;
				}
			}
		}
		$filtered_same_as = array_values( array_filter( array_map( 'strval', $same_as ), 'strlen' ) );
		if ( ! empty( $filtered_same_as ) ) {
			$node['sameAs'] = $filtered_same_as;
		}

		// areaServed — city name used as plain text when available.
		if ( '' !== (string) $city ) {
			$node['areaServed'] = (string) $city;
		}

		// ── Tourism / lodging-specific fields ─────────────────────────────────
		// Active for any LocalBusiness subtype but most useful when schema_type
		// is set to LodgingBusiness, Campground, Hotel, BedAndBreakfast, Resort.
		self::add_lodging_fields( $node, $loc );

		// Services (hasOfferCatalog) — per-location array, supports both plain strings and structured objects.
		$services = (array) ( $loc['services'] ?? [] );
		if ( ! empty( $services ) ) {
			$offers = [];
			foreach ( $services as $service ) {
				$service_name = is_string( $service ) ? $service : ( $service['name'] ?? '' );

				if ( '' === (string) $service_name ) {
					continue;
				}

				$offer = [
					'@type'       => 'Offer',
					'itemOffered' => [
						'@type' => 'Service',
						'name'  => (string) $service_name,
					],
				];

				if ( is_array( $service ) && ! empty( $service['description'] ) ) {
					$offer['itemOffered']['description'] = (string) $service['description'];
				}

				$offers[] = $offer;
			}
			if ( ! empty( $offers ) ) {
				$node['hasOfferCatalog'] = [
					'@type'           => 'OfferCatalog',
					'name'            => 'Services',
					'itemListElement' => $offers,
				];
			}
		}

		return $node;
	}

	/**
	 * Builds Service nodes for the current singular page.
	 *
	 * Returns an empty array when the post meta flag is absent/false, or when
	 * no services have been configured in settings.
	 *
	 * @return array<int,array<string,mixed>>
	 */
	private static function build_service_nodes(): array {
		$post_id = get_the_ID();

		if ( ! $post_id ) {
			return [];
		}

		// Only add Service schema when the per-page meta flag is truthy.
		// get_post_meta returns '' when absent, '1'/'true'/true when set.
		$flag = get_post_meta( $post_id, '_livada_seo_service_schema', true );
		if ( ! filter_var( $flag, FILTER_VALIDATE_BOOLEAN ) ) {
			return [];
		}

		// Pull services from the post's linked location (or the default one).
		$loc_id   = (int) get_post_meta( $post_id, '_livada_seo_location_id', true );
		$loc_id   = max( 0, $loc_id );
		$locs     = self::get_locations();
		$loc      = $locs[ $loc_id ] ?? ( $locs[0] ?? null );
		$services = is_array( $loc ) ? (array) ( $loc['services'] ?? [] ) : [];

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

		$nodes = [];
		$lb_id = self::post_location_anchor( $post_id );

		foreach ( $services as $index => $service ) {
			// Support both string format ("Service Name") and object format ({name, description}).
			$name        = '';
			$description = '';

			if ( is_string( $service ) ) {
				$name = trim( $service );
			} elseif ( is_array( $service ) ) {
				$name        = trim( $service['name'] ?? '' );
				$description = trim( $service['description'] ?? '' );
			}

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

			$node = [
				'@type'    => 'Service',
				'@id'      => home_url( '/#service-' . ( $index + 1 ) ),
				'name'     => $name,
				'provider' => [ '@id' => $lb_id ],
			];

			if ( '' !== $description ) {
				$node['description'] = $description;
			}

			$nodes[] = $node;
		}

		return $nodes;
	}

	/**
	 * Builds an Article node for the current singular blog post.
	 *
	 * Uses the custom meta description when available, falling back to the
	 * post excerpt. Featured image, word count, and first category are
	 * included when present. No extra SQL — all data comes from standard
	 * WordPress functions and already-loaded post meta.
	 *
	 * @return array<string,mixed>|null
	 */
	private static function build_article(): ?array {
		$post_id = get_the_ID();

		if ( ! $post_id ) {
			return null;
		}

		$permalink = get_permalink( $post_id );

		// Description: prefer the custom meta description, fall back to excerpt.
		$meta_desc = (string) get_post_meta( $post_id, '_livada_seo_description', true );
		if ( '' === $meta_desc ) {
			$meta_desc = (string) get_the_excerpt( $post_id );
		}

		$author_id = (int) get_post_field( 'post_author', $post_id );

		$node = [
			'@type'            => 'Article',
			'@id'              => $permalink . '#article',
			'headline'         => (string) get_the_title( $post_id ),
			'datePublished'    => (string) get_the_date( 'c', $post_id ),
			'dateModified'     => (string) get_the_modified_date( 'c', $post_id ),
			'author'           => [
				// Reference the Person node by @id when we'll be emitting one in
				// the same @graph (singular post → yes). Falls back to a flat
				// inline name on listing/archive contexts where no Person node
				// is emitted.
				'@id'   => self::author_anchor( $author_id ),
			],
			'publisher'        => [ '@id' => self::post_location_anchor( $post_id ) ],
			'mainEntityOfPage' => [ '@id' => $permalink ],
		];

		if ( '' !== $meta_desc ) {
			$node['description'] = $meta_desc;
		}

		// Featured image.
		if ( has_post_thumbnail( $post_id ) ) {
			$image_url = get_the_post_thumbnail_url( $post_id, 'full' );
			if ( $image_url ) {
				$node['image'] = (string) $image_url;
			}
		}

		// Word count — strip tags from raw content, no extra DB query.
		$raw_content = (string) get_post_field( 'post_content', $post_id );
		if ( '' !== $raw_content ) {
			$node['wordCount'] = (int) str_word_count( wp_strip_all_tags( $raw_content ) );
		}

		// First category name.
		$categories = get_the_category( $post_id );
		if ( ! empty( $categories ) && isset( $categories[0]->name ) ) {
			$node['articleSection'] = (string) $categories[0]->name;
		}

		return $node;
	}

	/**
	 * Builds a FAQPage node for the current singular post or page.
	 *
	 * Fires only when the per-post meta flag `_livada_seo_faq_schema` is truthy.
	 * Content is retrieved via `apply_filters( 'the_content', $post->post_content )`
	 * so Elementor-rendered HTML is available for parsing.
	 *
	 * Patterns detected (in priority order):
	 *   1. Elementor Toggle widget  — <div class="elementor-toggle-item">
	 *   2. Elementor Accordion widget — <div class="elementor-accordion-item">
	 *   3. Generic heading + paragraph — <h2>/<h3> immediately followed by <p>
	 *
	 * Returns null when the flag is absent/false or no Q&A pairs are found.
	 *
	 * @return array<string,mixed>|null
	 */
	private static function build_faq(): ?array {
		$post_id = get_the_ID();

		if ( ! $post_id ) {
			return null;
		}

		$flag = get_post_meta( $post_id, '_livada_seo_faq_schema', true );
		if ( ! filter_var( $flag, FILTER_VALIDATE_BOOLEAN ) ) {
			return null;
		}

		$post = get_post( $post_id );
		if ( ! $post ) {
			return null;
		}

		// Run the content through WordPress filters so Elementor shortcodes/
		// dynamic content are expanded into real HTML. This is the same call
		// WordPress makes when rendering the_content(), so no extra HTTP/SQL.
		$content = apply_filters( 'the_content', $post->post_content );

		if ( '' === (string) $content ) {
			return null;
		}

		$questions = [];

		// Suppress libxml errors for malformed HTML fragments.
		$use_errors = libxml_use_internal_errors( true );

		$doc = new DOMDocument( '1.0', 'UTF-8' );
		// Wrap in a root element with UTF-8 meta so DOMDocument handles encoding.
		$doc->loadHTML( '<?xml encoding="UTF-8"><div id="livada-faq-root">' . $content . '</div>' );
		libxml_clear_errors();
		libxml_use_internal_errors( $use_errors );

		$xpath = new DOMXPath( $doc );

		// ── Pattern 1: Elementor Toggle widget ─────────────────────────────
		// Structure: .elementor-toggle-item > .elementor-tab-title + .elementor-tab-content
		$toggle_items = $xpath->query( '//*[contains(@class,"elementor-toggle-item")]' );
		if ( $toggle_items && $toggle_items->length > 0 ) {
			foreach ( $toggle_items as $item ) {
				$title_nodes   = $xpath->query( './/*[contains(@class,"elementor-tab-title")]', $item );
				$content_nodes = $xpath->query( './/*[contains(@class,"elementor-tab-content")]', $item );

				$q = self::get_node_text( $title_nodes );
				$a = self::get_node_text( $content_nodes );

				if ( '' !== $q && '' !== $a ) {
					$questions[] = self::faq_question( $q, $a );
				}
			}
		}

		// ── Pattern 2: Elementor Accordion widget ──────────────────────────
		// Structure: .elementor-accordion-item > .elementor-tab-title + .elementor-tab-content
		if ( empty( $questions ) ) {
			$accordion_items = $xpath->query( '//*[contains(@class,"elementor-accordion-item")]' );
			if ( $accordion_items && $accordion_items->length > 0 ) {
				foreach ( $accordion_items as $item ) {
					$title_nodes   = $xpath->query( './/*[contains(@class,"elementor-tab-title")]', $item );
					$content_nodes = $xpath->query( './/*[contains(@class,"elementor-tab-content")]', $item );

					$q = self::get_node_text( $title_nodes );
					$a = self::get_node_text( $content_nodes );

					if ( '' !== $q && '' !== $a ) {
						$questions[] = self::faq_question( $q, $a );
					}
				}
			}
		}

		// ── Pattern 3: Generic <h2>/<h3> followed by <p> ───────────────────
		// Scans the flat node list inside the root div for heading → paragraph pairs.
		if ( empty( $questions ) ) {
			$root = $xpath->query( '//div[@id="livada-faq-root"]' )->item( 0 );
			if ( $root ) {
				$headings = $xpath->query( './/h2|.//h3', $root );
				if ( $headings ) {
					foreach ( $headings as $heading ) {
						$q = trim( $heading->textContent );
						if ( '' === $q ) {
							continue;
						}

						// Walk siblings to find the very next element node.
						$sibling = $heading->nextSibling;
						while ( $sibling && XML_ELEMENT_NODE !== $sibling->nodeType ) {
							$sibling = $sibling->nextSibling;
						}

						if ( $sibling && 'p' === strtolower( $sibling->nodeName ) ) {
							$a = trim( $sibling->textContent );
							if ( '' !== $a ) {
								$questions[] = self::faq_question( $q, $a );
							}
						}
					}
				}
			}
		}

		// ── Pattern 4: <strong>Question ?</strong> + answer ────────────────
		// Tourism pages frequently hand-write FAQs as a bold question inside a
		// <p>, with the answer either in the same <p> (after the bold) or in the
		// next <p>. This is the most common author-written format and no other
		// SEO plugin reads it — Livada SEO turns it into FAQPage schema for free.
		if ( empty( $questions ) ) {
			$strongs = $xpath->query( '//strong|//b' );
			if ( $strongs ) {
				foreach ( $strongs as $strong ) {
					$q = trim( $strong->textContent );

					// Must look like a real question.
					if ( '' === $q || false === strpos( $q, '?' ) ) {
						continue;
					}

					$anchor = $strong->parentNode;
					if ( ! $anchor ) {
						continue;
					}

					$a = '';

					// Case A — answer in the same element, after the question:
					//   <p><strong>Q ?</strong> answer…</p>
					$anchor_text = trim( $anchor->textContent );
					if ( $anchor_text !== $q && 0 === strpos( $anchor_text, $q ) ) {
						$a = trim( substr( $anchor_text, strlen( $q ) ) );
					}

					// Case B — answer in the next element sibling:
					//   <p><strong>Q ?</strong></p><p>answer…</p>
					if ( '' === $a ) {
						$sibling = $anchor->nextSibling;
						while ( $sibling && XML_ELEMENT_NODE !== $sibling->nodeType ) {
							$sibling = $sibling->nextSibling;
						}
						if ( $sibling ) {
							// Don't grab the next question as an answer.
							$sib_strong = $xpath->query( './/strong|.//b', $sibling );
							$sib_q      = ( $sib_strong && $sib_strong->length )
								? trim( $sib_strong->item( 0 )->textContent )
								: '';
							if ( '' === $sib_q || false === strpos( $sib_q, '?' ) ) {
								$a = trim( $sibling->textContent );
							}
						}
					}

					if ( '' !== $a ) {
						$questions[] = self::faq_question( $q, $a );
					}
				}
			}
		}

		if ( empty( $questions ) ) {
			return null;
		}

		return [
			'@type'       => 'FAQPage',
			'@id'         => get_permalink( $post_id ) . '#faqpage',
			'mainEntity'  => $questions,
		];
	}

	// ── Lodging / tourism enrichment ──────────────────────────────────────────

	/**
	 * Adds lodging/tourism-specific properties to a LocalBusiness node when
	 * the corresponding location fields are filled in.
	 *
	 * Properties added (only when non-empty):
	 *   - starRating          (Schema StarRating, e.g. "4")
	 *   - numberOfRooms       (integer; pitches + chalets for a campground)
	 *   - petsAllowed         (boolean)
	 *   - checkinTime         (ISO 8601 time, e.g. "15:00")
	 *   - checkoutTime        (ISO 8601 time, e.g. "11:00")
	 *   - amenityFeature      (array of LocationFeatureSpecification)
	 *   - aggregateRating     (AggregateRating with ratingValue + reviewCount)
	 *
	 * @param array<string,mixed> $node Reference to the node being built.
	 * @param array<string,mixed> $loc  Location array providing the values.
	 */
	private static function add_lodging_fields( array &$node, array $loc ): void {
		$star          = (string) ( $loc['star_rating']     ?? '' );
		$rooms         = (string) ( $loc['number_of_rooms'] ?? '' );
		$pets          = $loc['pets_allowed'] ?? null;
		$checkin       = (string) ( $loc['checkin_time']    ?? '' );
		$checkout      = (string) ( $loc['checkout_time']   ?? '' );
		$amenities     = (array)  ( $loc['amenities']       ?? [] );
		$rating_value  = (string) ( $loc['rating_value']    ?? '' );
		$review_count  = (string) ( $loc['review_count']    ?? '' );

		if ( '' !== $star && is_numeric( $star ) ) {
			$node['starRating'] = [
				'@type'       => 'Rating',
				'ratingValue' => (float) $star,
			];
		}

		if ( '' !== $rooms && is_numeric( $rooms ) ) {
			$node['numberOfRooms'] = (int) $rooms;
		}

		if ( null !== $pets && '' !== (string) $pets ) {
			$node['petsAllowed'] = (bool) filter_var( $pets, FILTER_VALIDATE_BOOLEAN );
		}

		if ( '' !== $checkin ) {
			$node['checkinTime']  = $checkin;
		}
		if ( '' !== $checkout ) {
			$node['checkoutTime'] = $checkout;
		}

		// Amenities: list of strings or list of {name, value} pairs.
		if ( is_array( $amenities ) && ! empty( $amenities ) ) {
			$features = [];
			foreach ( $amenities as $entry ) {
				$name  = '';
				$value = true;

				if ( is_string( $entry ) ) {
					$name = trim( $entry );
				} elseif ( is_array( $entry ) ) {
					$name  = trim( (string) ( $entry['name']  ?? '' ) );
					$value = isset( $entry['value'] )
						? (bool) filter_var( $entry['value'], FILTER_VALIDATE_BOOLEAN )
						: true;
				}

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

				$features[] = [
					'@type' => 'LocationFeatureSpecification',
					'name'  => $name,
					'value' => $value,
				];
			}
			if ( ! empty( $features ) ) {
				$node['amenityFeature'] = $features;
			}
		}

		// Aggregate rating: emitted only when both value and count are present.
		if ( '' !== $rating_value && '' !== $review_count
			&& is_numeric( $rating_value ) && (int) $review_count > 0 ) {
			$node['aggregateRating'] = [
				'@type'       => 'AggregateRating',
				'ratingValue' => (float) $rating_value,
				'reviewCount' => (int) $review_count,
				'bestRating'  => 5,
				'worstRating' => 1,
			];
		}
	}

	// ── Author Person (E-A-T) ────────────────────────────────────────────────

	/**
	 * Returns the JSON-LD `@id` URL for a given user (post author).
	 * Author archives at `/author/{slug}/` are stable across permalink changes
	 * so they make a reliable canonical anchor for the Person entity.
	 */
	public static function author_anchor( int $user_id ): string {
		if ( $user_id <= 0 ) {
			return home_url( '/#person' );
		}
		$url = get_author_posts_url( $user_id );
		return ( '' !== $url ? (string) $url : home_url( '/' ) ) . '#person';
	}

	/**
	 * Builds a Person node for the current author context. Reads the standard
	 * WP user fields (display_name, description, user_url) plus optional
	 * SEO-specific meta (`livada_seo_job_title`, `livada_seo_same_as`).
	 *
	 * Returns null when the user has no public-facing data worth emitting.
	 */
	private static function build_author_person(): ?array {
		// Resolve the author user ID from context.
		$user_id = 0;

		if ( is_singular( 'post' ) ) {
			$post_id = (int) get_the_ID();
			$user_id = (int) get_post_field( 'post_author', $post_id );
		} elseif ( is_author() ) {
			$queried = get_queried_object();
			if ( $queried instanceof WP_User ) {
				$user_id = (int) $queried->ID;
			}
		}

		if ( $user_id <= 0 ) {
			return null;
		}

		$user = get_userdata( $user_id );
		if ( ! $user ) {
			return null;
		}

		$name = (string) ( $user->display_name ?: $user->user_login );
		if ( '' === $name ) {
			return null;
		}

		$node = [
			'@type' => 'Person',
			'@id'   => self::author_anchor( $user_id ),
			'name'  => $name,
			'url'   => (string) get_author_posts_url( $user_id ),
		];

		// Bio (long description) and external website.
		$bio = (string) get_user_meta( $user_id, 'description', true );
		if ( '' !== $bio ) {
			$node['description'] = $bio;
		}

		$user_url = (string) ( $user->user_url ?? '' );
		if ( '' !== $user_url ) {
			$node['url'] = $user_url; // Override author archive with personal site if set.
		}

		// jobTitle / role — opt-in via custom user meta.
		$job_title = (string) get_user_meta( $user_id, 'livada_seo_job_title', true );
		if ( '' !== $job_title ) {
			$node['jobTitle'] = $job_title;
		}

		// Avatar.
		$avatar_url = get_avatar_url( $user_id, [ 'size' => 256 ] );
		if ( $avatar_url ) {
			$node['image'] = [
				'@type' => 'ImageObject',
				'url'   => (string) $avatar_url,
			];
		}

		// sameAs from custom user meta — array, JSON, or newline-separated string.
		$same_as_raw = get_user_meta( $user_id, 'livada_seo_same_as', true );
		$same_as     = [];

		if ( is_array( $same_as_raw ) ) {
			$same_as = $same_as_raw;
		} elseif ( is_string( $same_as_raw ) && '' !== $same_as_raw ) {
			$decoded = json_decode( $same_as_raw, true );
			if ( is_array( $decoded ) ) {
				$same_as = $decoded;
			} else {
				$same_as = preg_split( '/[\r\n,]+/', $same_as_raw ) ?: [];
			}
		}

		// Fall back to the standard WP profile social fields (post-WP-4.0 these
		// are removed by default but plugins like Yoast / Edit Author Slug
		// re-add them — read defensively).
		foreach ( [ 'twitter', 'facebook', 'linkedin', 'instagram', 'youtube', 'github' ] as $key ) {
			$val = (string) get_user_meta( $user_id, $key, true );
			if ( '' !== $val ) {
				$same_as[] = $val;
			}
		}

		$same_as = array_values( array_filter( array_map(
			static function ( $u ) { return esc_url_raw( trim( (string) $u ) ); },
			$same_as
		) ) );

		if ( ! empty( $same_as ) ) {
			$node['sameAs'] = array_values( array_unique( $same_as ) );
		}

		// Affiliate the author with the primary business location.
		$node['worksFor'] = [ '@id' => self::location_anchor( 0 ) ];

		return $node;
	}

	// ── Optional content-type builders ────────────────────────────────────────

	/**
	 * Builds a HowTo node. Steps are derived from the post's <ol>/<ul> first
	 * list, or from `_livada_seo_howto_steps` post meta if present (array of
	 * { name, text } entries).
	 *
	 * @return array<string,mixed>|null
	 */
	private static function build_howto(): ?array {
		$post_id = get_the_ID();
		if ( ! $post_id ) {
			return null;
		}

		$steps_meta = get_post_meta( $post_id, '_livada_seo_howto_steps', true );
		$steps      = [];

		if ( is_array( $steps_meta ) && ! empty( $steps_meta ) ) {
			foreach ( $steps_meta as $i => $row ) {
				$name = is_array( $row ) ? trim( (string) ( $row['name'] ?? '' ) ) : '';
				$text = is_array( $row ) ? trim( (string) ( $row['text'] ?? '' ) ) : '';

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

				$steps[] = [
					'@type'    => 'HowToStep',
					'position' => $i + 1,
					'name'     => $name !== '' ? $name : sprintf( __( 'Step %d', 'livada-seo' ), $i + 1 ),
					'text'     => $text,
				];
			}
		} else {
			// Auto-extract from the first <ol> in the rendered content.
			$content = apply_filters( 'the_content', get_post_field( 'post_content', $post_id ) );
			if ( preg_match( '/<ol[^>]*>(.*?)<\/ol>/is', $content, $m ) ) {
				if ( preg_match_all( '/<li[^>]*>(.*?)<\/li>/is', $m[1], $li ) ) {
					foreach ( $li[1] as $i => $item ) {
						$text = trim( wp_strip_all_tags( $item ) );
						if ( '' === $text ) {
							continue;
						}
						$steps[] = [
							'@type'    => 'HowToStep',
							'position' => $i + 1,
							'name'     => sprintf( __( 'Step %d', 'livada-seo' ), $i + 1 ),
							'text'     => $text,
						];
					}
				}
			}
		}

		if ( empty( $steps ) ) {
			return null;
		}

		$node = [
			'@type' => 'HowTo',
			'@id'   => get_permalink( $post_id ) . '#howto',
			'name'  => (string) get_the_title( $post_id ),
			'step'  => $steps,
		];

		$desc = (string) get_post_meta( $post_id, '_livada_seo_description', true );
		if ( '' === $desc ) {
			$desc = (string) get_the_excerpt( $post_id );
		}
		if ( '' !== $desc ) {
			$node['description'] = $desc;
		}

		if ( has_post_thumbnail( $post_id ) ) {
			$img = get_the_post_thumbnail_url( $post_id, 'full' );
			if ( $img ) {
				$node['image'] = (string) $img;
			}
		}

		return $node;
	}

	/**
	 * Builds a Product node from `_livada_seo_product_*` post meta.
	 *
	 * @return array<string,mixed>|null
	 */
	private static function build_product(): ?array {
		$post_id = get_the_ID();
		if ( ! $post_id ) {
			return null;
		}

		$name = (string) get_the_title( $post_id );
		if ( '' === $name ) {
			return null;
		}

		$node = [
			'@type' => 'Product',
			'@id'   => get_permalink( $post_id ) . '#product',
			'name'  => $name,
		];

		$desc = (string) get_post_meta( $post_id, '_livada_seo_description', true );
		if ( '' === $desc ) {
			$desc = (string) get_the_excerpt( $post_id );
		}
		if ( '' !== $desc ) {
			$node['description'] = $desc;
		}

		if ( has_post_thumbnail( $post_id ) ) {
			$img = get_the_post_thumbnail_url( $post_id, 'full' );
			if ( $img ) {
				$node['image'] = (string) $img;
			}
		}

		// Per-post overrides via meta.
		$brand = (string) get_post_meta( $post_id, '_livada_seo_product_brand', true );
		if ( '' !== $brand ) {
			$node['brand'] = [ '@type' => 'Brand', 'name' => $brand ];
		}

		$sku = (string) get_post_meta( $post_id, '_livada_seo_product_sku', true );
		if ( '' !== $sku ) {
			$node['sku'] = $sku;
		}

		$price    = (string) get_post_meta( $post_id, '_livada_seo_product_price',    true );
		$currency = (string) get_post_meta( $post_id, '_livada_seo_product_currency', true );
		if ( '' !== $price ) {
			$node['offers'] = [
				'@type'         => 'Offer',
				'price'         => (float) $price,
				'priceCurrency' => '' !== $currency ? strtoupper( $currency ) : 'EUR',
				'availability'  => 'https://schema.org/InStock',
				'url'           => get_permalink( $post_id ),
			];
		}

		$rating  = (string) get_post_meta( $post_id, '_livada_seo_product_rating',       true );
		$reviews = (string) get_post_meta( $post_id, '_livada_seo_product_review_count', true );
		if ( '' !== $rating && '' !== $reviews && (int) $reviews > 0 ) {
			$node['aggregateRating'] = [
				'@type'       => 'AggregateRating',
				'ratingValue' => (float) $rating,
				'reviewCount' => (int) $reviews,
			];
		}

		return $node;
	}

	/**
	 * Builds an Event node from `_livada_seo_event_*` post meta.
	 *
	 * @return array<string,mixed>|null
	 */
	private static function build_event(): ?array {
		$post_id = get_the_ID();
		if ( ! $post_id ) {
			return null;
		}

		$start = (string) get_post_meta( $post_id, '_livada_seo_event_start', true );
		if ( '' === $start ) {
			// No start date → can't emit a valid Event.
			return null;
		}

		$node = [
			'@type'     => 'Event',
			'@id'       => get_permalink( $post_id ) . '#event',
			'name'      => (string) get_the_title( $post_id ),
			'startDate' => $start,
		];

		$end = (string) get_post_meta( $post_id, '_livada_seo_event_end', true );
		if ( '' !== $end ) {
			$node['endDate'] = $end;
		}

		$venue   = (string) get_post_meta( $post_id, '_livada_seo_event_venue',   true );
		$address = (string) get_post_meta( $post_id, '_livada_seo_event_address', true );
		if ( '' !== $venue || '' !== $address ) {
			$node['location'] = [
				'@type' => 'Place',
				'name'  => '' !== $venue ? $venue : (string) get_bloginfo( 'name' ),
			];
			if ( '' !== $address ) {
				$node['location']['address'] = $address;
			}
		}

		$desc = (string) get_post_meta( $post_id, '_livada_seo_description', true );
		if ( '' !== $desc ) {
			$node['description'] = $desc;
		}

		if ( has_post_thumbnail( $post_id ) ) {
			$img = get_the_post_thumbnail_url( $post_id, 'full' );
			if ( $img ) {
				$node['image'] = (string) $img;
			}
		}

		return $node;
	}

	/**
	 * Builds a Recipe node from `_livada_seo_recipe_*` post meta.
	 *
	 * @return array<string,mixed>|null
	 */
	private static function build_recipe(): ?array {
		$post_id = get_the_ID();
		if ( ! $post_id ) {
			return null;
		}

		$node = [
			'@type' => 'Recipe',
			'@id'   => get_permalink( $post_id ) . '#recipe',
			'name'  => (string) get_the_title( $post_id ),
		];

		$desc = (string) get_post_meta( $post_id, '_livada_seo_description', true );
		if ( '' === $desc ) {
			$desc = (string) get_the_excerpt( $post_id );
		}
		if ( '' !== $desc ) {
			$node['description'] = $desc;
		}

		if ( has_post_thumbnail( $post_id ) ) {
			$img = get_the_post_thumbnail_url( $post_id, 'full' );
			if ( $img ) {
				$node['image'] = (string) $img;
			}
		}

		// Author defaults to the post author.
		$author_id = (int) get_post_field( 'post_author', $post_id );
		if ( $author_id > 0 ) {
			$node['author'] = [
				'@type' => 'Person',
				'name'  => (string) get_the_author_meta( 'display_name', $author_id ),
			];
		}

		$prep   = (string) get_post_meta( $post_id, '_livada_seo_recipe_prep_time',  true );
		$cook   = (string) get_post_meta( $post_id, '_livada_seo_recipe_cook_time',  true );
		$yield  = (string) get_post_meta( $post_id, '_livada_seo_recipe_yield',      true );
		$ingred = get_post_meta( $post_id, '_livada_seo_recipe_ingredients', true );
		$instr  = get_post_meta( $post_id, '_livada_seo_recipe_instructions', true );

		if ( '' !== $prep )  { $node['prepTime']      = $prep;  }   // ISO 8601 duration, e.g. PT15M
		if ( '' !== $cook )  { $node['cookTime']      = $cook;  }
		if ( '' !== $yield ) { $node['recipeYield']   = $yield; }

		if ( is_array( $ingred ) && ! empty( $ingred ) ) {
			$node['recipeIngredient'] = array_values( array_filter( array_map( 'strval', $ingred ), 'strlen' ) );
		}

		if ( is_array( $instr ) && ! empty( $instr ) ) {
			$steps = [];
			foreach ( $instr as $i => $line ) {
				$line = trim( (string) $line );
				if ( '' === $line ) {
					continue;
				}
				$steps[] = [
					'@type' => 'HowToStep',
					'text'  => $line,
				];
			}
			if ( ! empty( $steps ) ) {
				$node['recipeInstructions'] = $steps;
			}
		}

		return $node;
	}

	/**
	 * Builds a Review node. The reviewed item type defaults to "Thing"; the
	 * post can override it via `_livada_seo_review_item_type` (e.g. Restaurant,
	 * Product, LocalBusiness, Movie).
	 *
	 * @return array<string,mixed>|null
	 */
	private static function build_review(): ?array {
		$post_id = get_the_ID();
		if ( ! $post_id ) {
			return null;
		}

		$item_name = (string) get_post_meta( $post_id, '_livada_seo_review_item_name', true );
		$rating    = (string) get_post_meta( $post_id, '_livada_seo_review_rating',    true );

		if ( '' === $item_name || '' === $rating ) {
			return null;
		}

		$item_type = (string) get_post_meta( $post_id, '_livada_seo_review_item_type', true );
		if ( '' === $item_type ) {
			$item_type = 'Thing';
		}

		$best  = (string) get_post_meta( $post_id, '_livada_seo_review_best',  true );
		$worst = (string) get_post_meta( $post_id, '_livada_seo_review_worst', true );

		$node = [
			'@type'        => 'Review',
			'@id'          => get_permalink( $post_id ) . '#review',
			'itemReviewed' => [
				'@type' => $item_type,
				'name'  => $item_name,
			],
			'reviewRating' => [
				'@type'       => 'Rating',
				'ratingValue' => (float) $rating,
				'bestRating'  => '' !== $best  ? (float) $best  : 5,
				'worstRating' => '' !== $worst ? (float) $worst : 1,
			],
			'author'       => [
				'@type' => 'Person',
				'name'  => (string) get_the_author_meta( 'display_name', (int) get_post_field( 'post_author', $post_id ) ),
			],
			'datePublished' => (string) get_the_date( 'c', $post_id ),
		];

		$body = (string) get_post_meta( $post_id, '_livada_seo_description', true );
		if ( '' !== $body ) {
			$node['reviewBody'] = $body;
		}

		return $node;
	}

	/**
	 * Builds an Offer node for a seasonal package or accommodation listing.
	 *
	 * Required: `_livada_seo_offer_price` (numeric) and `_livada_seo_offer_currency`.
	 * Optional: valid_from / valid_through dates (ISO 8601), availability,
	 * itemOffered name (defaults to post title).
	 *
	 * Tied to the LocalBusiness via `seller` reference so Google can connect
	 * the offer back to the business entity for rich-result eligibility.
	 *
	 * @return array<string,mixed>|null
	 */
	private static function build_offer(): ?array {
		$post_id = get_the_ID();
		if ( ! $post_id ) {
			return null;
		}

		$price    = (string) get_post_meta( $post_id, '_livada_seo_offer_price',    true );
		$currency = (string) get_post_meta( $post_id, '_livada_seo_offer_currency', true );

		if ( '' === $price || ! is_numeric( $price ) ) {
			return null;
		}

		$node = [
			'@type'         => 'Offer',
			'@id'           => get_permalink( $post_id ) . '#offer',
			'name'          => (string) get_the_title( $post_id ),
			'price'         => (float) $price,
			'priceCurrency' => '' !== $currency ? strtoupper( $currency ) : 'EUR',
			'url'           => (string) get_permalink( $post_id ),
			'seller'        => [ '@id' => self::post_location_anchor( $post_id ) ],
		];

		$valid_from    = (string) get_post_meta( $post_id, '_livada_seo_offer_valid_from',    true );
		$valid_through = (string) get_post_meta( $post_id, '_livada_seo_offer_valid_through', true );

		if ( '' !== $valid_from )    { $node['validFrom']    = $valid_from;    }
		if ( '' !== $valid_through ) { $node['validThrough'] = $valid_through; }

		// Availability: mapped to a Schema.org URL value.
		$availability_map = [
			'in_stock'      => 'https://schema.org/InStock',
			'out_of_stock'  => 'https://schema.org/OutOfStock',
			'limited'       => 'https://schema.org/LimitedAvailability',
			'preorder'      => 'https://schema.org/PreOrder',
			'sold_out'      => 'https://schema.org/SoldOut',
		];
		$availability = (string) get_post_meta( $post_id, '_livada_seo_offer_availability', true );
		if ( '' === $availability ) {
			$availability = 'in_stock';
		}
		if ( isset( $availability_map[ $availability ] ) ) {
			$node['availability'] = $availability_map[ $availability ];
		}

		$desc = (string) get_post_meta( $post_id, '_livada_seo_description', true );
		if ( '' === $desc ) {
			$desc = (string) get_the_excerpt( $post_id );
		}
		if ( '' !== $desc ) {
			$node['description'] = $desc;
		}

		if ( has_post_thumbnail( $post_id ) ) {
			$img = get_the_post_thumbnail_url( $post_id, 'full' );
			if ( $img ) {
				$node['image'] = (string) $img;
			}
		}

		return $node;
	}

	// ── Private helpers ───────────────────────────────────────────────────────

	/**
	 * Returns the trimmed text content of the first node in a DOMNodeList,
	 * or an empty string when the list is empty or null.
	 *
	 * @param \DOMNodeList|false|null $node_list
	 * @return string
	 */
	private static function get_node_text( $node_list ): string {
		if ( ! $node_list || 0 === $node_list->length ) {
			return '';
		}
		return trim( $node_list->item( 0 )->textContent );
	}

	/**
	 * Returns a Question entity array for the FAQPage mainEntity list.
	 *
	 * @param string $question
	 * @param string $answer
	 * @return array<string,mixed>
	 */
	private static function faq_question( string $question, string $answer ): array {
		return [
			'@type'          => 'Question',
			'name'           => $question,
			'acceptedAnswer' => [
				'@type' => 'Answer',
				'text'  => $answer,
			],
		];
	}
}
