<?php
/**
 * Schema.org orchestrator (ADR-19).
 *
 * Three-mode JSON-LD output. Mode is decided once on `wp_loaded` and cached:
 *   1. AEO Optimizer active   → defer everything to AEO's module system
 *   2. Yoast SEO active       → register graph pieces via `wpseo_schema_graph_pieces`
 *   3. No SEO plugin          → output our own JSON-LD in `wp_head`
 *
 * The piece classes (`CarSchema`, `OfferSchema`, ...) only know how to build
 * JSON-LD arrays — they don't know which mode is active. The mode integrations
 * (StandaloneOutput, YoastIntegration, AeoOptimizerIntegration) call the
 * pieces at the right time.
 *
 * Vehicle @type mapping (per ADR-19):
 *   - car, truck       → Car
 *   - motorcycle       → Motorcycle
 *   - boat, rv, atv,
 *     commercial       → Vehicle
 *
 * @package ThemeREX\AutoDealer
 */

declare(strict_types=1);

namespace ThemeREX\AutoDealer\Integrations\Schema;

defined( 'ABSPATH' ) || exit;
use ThemeREX\AutoDealer\Integrations\AeoOptimizer\AeoOptimizerIntegration;
use ThemeREX\AutoDealer\Integrations\RankMath\RankMathIntegration;
use ThemeREX\AutoDealer\Integrations\Yoast\YoastIntegration;
use ThemeREX\AutoDealer\PostTypes\DealerPostType;
use ThemeREX\AutoDealer\PostTypes\VehiclePostType;

class SchemaGenerator {

	public const MODE_AEO        = 'aeo';
	public const MODE_YOAST      = 'yoast';
	public const MODE_RANKMATH   = 'rankmath';
	public const MODE_COMPAT     = 'compat';
	public const MODE_STANDALONE = 'standalone';

	private static ?string $mode = null;

	/**
	 * Wire the right output strategy. Called from Plugin::run().
	 */
	public function register(): void {
		// Run mode detection + binding on `wp_loaded` so other plugins
		// (Yoast, Rank Math, AEO Optimizer) have already declared themselves.
		add_action(
			'wp_loaded',
			function (): void {
				$mode = self::detectMode();
				switch ( $mode ) {
					case self::MODE_AEO:
						( new AeoOptimizerIntegration() )->register();
						break;
					case self::MODE_YOAST:
						( new YoastIntegration() )->register();
						break;
					case self::MODE_RANKMATH:
						( new RankMathIntegration() )->register();
						// Rank Math prints no frontend head block (and thus no
						// JSON-LD, so our rank_math/json_ld pieces never reach
						// the page) until its setup wizard has been completed.
						// Fall back to the standalone graph on requests where
						// Rank Math's head never fired, so vehicles aren't
						// schema-less on fresh RM installs (UAT round 2, R2-6).
						add_action(
							'wp_head',
							static function (): void {
								if ( ! did_action( 'rank_math/head' ) ) {
									self::outputStandaloneJsonLd();
								}
							},
							99
						);
						break;
					case self::MODE_COMPAT:
						// Unknown SEO plugin emits its own graph (Organization,
						// WebPage, BreadcrumbList). We emit our pieces only —
						// no BreadcrumbList — so we don't duplicate it.
					case self::MODE_STANDALONE:
					default:
						add_action( 'wp_head', array( self::class, 'outputStandaloneJsonLd' ), 99 );
						break;
				}
			},
			10
		);
	}

	/**
	 * Resolve which schema mode applies to the current request. Result is
	 * memoised. Filter `trx_ad_schema_mode` lets sites force a particular mode.
	 */
	public static function detectMode(): string {
		if ( null !== self::$mode ) {
			return self::$mode;
		}

		// AEO Optimizer beats Yoast — its module system understands
		// vehicle schema natively (per ADR-19).
		if ( defined( 'AEO_VERSION' ) || class_exists( 'AEO_Plugin' ) ) {
			$mode = self::MODE_AEO;
		} elseif ( defined( 'WPSEO_VERSION' ) || class_exists( 'WPSEO_Options' ) ) {
			$mode = self::MODE_YOAST;
		} elseif ( defined( 'RANK_MATH_VERSION' ) || class_exists( 'RankMath' ) ) {
			$mode = self::MODE_RANKMATH;
		} elseif ( self::isOtherSeoPluginActive() ) {
			// AIOSEO / SEOPress / The SEO Framework / etc. — they all emit
			// a BreadcrumbList but expose different (or no) extension hooks
			// for what we'd add. Skip our BreadcrumbList to avoid duplication.
			$mode = self::MODE_COMPAT;
		} else {
			$mode = self::MODE_STANDALONE;
		}

		/**
		 * Filter the resolved schema mode.
		 *
		 * @param string $mode One of `aeo`, `yoast`, `rankmath`, `compat`, `standalone`.
		 */
		$mode  = (string) apply_filters( 'trx_ad_schema_mode', $mode );
		$valid = array( self::MODE_AEO, self::MODE_YOAST, self::MODE_RANKMATH, self::MODE_COMPAT, self::MODE_STANDALONE );
		if ( ! in_array( $mode, $valid, true ) ) {
			$mode = self::MODE_STANDALONE;
		}

		return self::$mode = $mode;
	}

	/**
	 * Detect AIOSEO / SEOPress / TSF — SEO plugins we don't have a custom
	 * integration for, but whose presence triggers `compat` mode.
	 */
	private static function isOtherSeoPluginActive(): bool {
		// AIOSEO.
		if ( defined( 'AIOSEO_VERSION' ) || function_exists( 'aioseo' ) ) {
			return true; }
		// SEOPress.
		if ( defined( 'SEOPRESS_VERSION' ) || function_exists( 'seopress_get_service' ) ) {
			return true; }
		// The SEO Framework.
		if ( defined( 'THE_SEO_FRAMEWORK_VERSION' ) || function_exists( 'tsf' ) ) {
			return true; }
		return false;
	}

	/**
	 * Reset the cached mode (for tests).
	 */
	public static function resetMode(): void {
		self::$mode = null;
	}

	/**
	 * Build the full graph for the current request. Returns an array of
	 * `@graph` pieces (not wrapped). Used by standalone mode + verifier.
	 *
	 * @return array<int, array<string, mixed>>
	 */
	public static function buildGraph(): array {
		$pieces = array();
		// In compat mode the active SEO plugin emits its own BreadcrumbList;
		// skip ours to avoid duplication.
		$emit_breadcrumb = self::MODE_COMPAT !== self::detectMode();

		// Single vehicle: Car/Vehicle/Motorcycle + Offer + FAQPage + Breadcrumb.
		if ( is_singular( VehiclePostType::POST_TYPE ) ) {
			$post_id = (int) get_queried_object_id();
			$car     = ( new CarSchema() )->buildPiece( $post_id );
			$offer   = ( new OfferSchema() )->buildPiece( $post_id );
			$faq     = ( new FaqSchema() )->buildPiece( $post_id );
			$crumbs  = $emit_breadcrumb ? ( new BreadcrumbSchema() )->buildPiece() : array();

			if ( $car ) {
				$pieces[] = $car; }
			if ( $offer ) {
				$pieces[] = $offer; }
			if ( $faq ) {
				$pieces[] = $faq; }
			if ( $crumbs ) {
				$pieces[] = $crumbs; }
		}

		// Single dealer: AutoDealer + Breadcrumb.
		if ( is_singular( DealerPostType::POST_TYPE ) ) {
			$dealer_id = (int) get_queried_object_id();
			$dealer    = ( new AutoDealerSchema() )->buildPiece( $dealer_id );
			$crumbs    = $emit_breadcrumb ? ( new BreadcrumbSchema() )->buildPiece() : array();

			if ( $dealer ) {
				$pieces[] = $dealer; }
			if ( $crumbs ) {
				$pieces[] = $crumbs; }
		}

		// Vehicle archive / taxonomy archives: Breadcrumb only (skip in compat).
		if ( $emit_breadcrumb
			&& ( is_post_type_archive( VehiclePostType::POST_TYPE ) || self::isVehicleTaxonomy() ) ) {
			$crumbs = ( new BreadcrumbSchema() )->buildPiece();
			if ( $crumbs ) {
				$pieces[] = $crumbs; }
		}

		/**
		 * Filter the pieces array before output. Hook this to add or remove
		 * graph pieces (e.g. to suppress the offer on sold listings).
		 *
		 * @param array $pieces
		 */
		return (array) apply_filters( 'trx_ad_schema_graph_pieces', $pieces );
	}

	/**
	 * Echo a single `@graph` JSON-LD block on standalone-mode pages.
	 */
	public static function outputStandaloneJsonLd(): void {
		$pieces = self::buildGraph();
		if ( empty( $pieces ) ) {
			return;
		}

		$payload = array(
			'@context' => 'https://schema.org',
			'@graph'   => array_values( $pieces ),
		);

		// No JSON_UNESCAPED_SLASHES: escaped slashes make "</script>" inside
		// any value inert ("<\/script>"), so user-sourced strings can't break
		// out of the script context. wp_print_inline_script_tag() handles the
		// tag itself (wp.org 2026-07 review finding).
		wp_print_inline_script_tag(
			(string) wp_json_encode( $payload, JSON_UNESCAPED_UNICODE ),
			array( 'type' => 'application/ld+json' )
		);
	}

	/**
	 * Resolve the Schema.org `@type` for a vehicle based on its
	 * `vehicle_type` taxonomy term slug.
	 */
	public static function vehicleTypeFor( int $postId ): string {
		$terms = wp_get_post_terms( $postId, 'trx_ad_vehicle_type', array( 'fields' => 'slugs' ) );
		if ( is_wp_error( $terms ) || empty( $terms ) ) {
			return 'Car';
		}
		$slug = strtolower( (string) reset( $terms ) );
		switch ( $slug ) {
			case 'motorcycle':
				return 'Motorcycle';
			case 'truck':
			case 'car':
			case 'sedan':
			case 'suv':
			case 'coupe':
			case 'convertible':
				return 'Car';
			default:
				return 'Vehicle';
		}
	}

	private static function isVehicleTaxonomy(): bool {
		$taxes = array(
			'trx_ad_make',
			'trx_ad_model',
			'trx_ad_body_style',
			'trx_ad_fuel_type',
			'trx_ad_transmission',
			'trx_ad_drivetrain',
			'trx_ad_condition',
			'trx_ad_vehicle_type',
			'trx_ad_color',
			'trx_ad_feature',
		);
		foreach ( $taxes as $tax ) {
			if ( is_tax( $tax ) ) {
				return true;
			}
		}
		return false;
	}
}
