<?php

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

/**
 * Injects og:image and twitter:image meta tags into the page <head>.
 *
 * Runs at wp_head priority 99 — after most SEO plugins.
 *
 * Two operating modes:
 *
 * 1. No supported SEO plugin active — outputs the full social meta block
 *    (og:image, og:image:width, og:image:height, og:image:type, twitter:image,
 *    twitter:card).
 *
 * 2. Supported SEO plugin active (Yoast, Rank Math, AIOSEO) — the SEO compat
 *    class already overrides og:image and twitter:image inside the SEO plugin's
 *    own output. This method only emits the supplementary tags that SEO plugins
 *    cannot reliably resolve for a cached screenshot file: og:image:width,
 *    og:image:height, og:image:type, and twitter:card. og:image and twitter:image
 *    are intentionally omitted to avoid duplicate tags.
 */
class DOIG_OG_Injector {

	public static function init() {
		add_action( 'wp_head', array( __CLASS__, 'inject' ), 99 );
	}

	/**
	 * Outputs og:image tags for the current singular post if the resolver
	 * decides DOIG should emit anything.
	 *
	 * 2.4.0 M4.1: the resolver is the single decision point. Replaces
	 * the pre-2.4.0 `is_enabled()` + cached-screenshot path. The resolver
	 * returns a payload {url, width, height, type} when DOIG should emit,
	 * or null when DOIG should stay out (Off mode, Featured with no FI,
	 * Custom with no upload, or Screenshot mode with no ready capture).
	 */
	public static function inject() {
		if ( is_singular() ) {
			self::inject_post();
			return;
		}

		// 2.7.0 M4: non-singular requests may be a covered surface
		// (homepage, post-type archive, taxonomy page, author archive).
		$surface_key = DOIG_Surfaces::current_surface_key();
		if ( '' !== $surface_key ) {
			self::inject_surface( $surface_key );
		}
	}

	/**
	 * The singular-post emission path (the pre-2.7.0 inject() body).
	 */
	private static function inject_post() {
		$post_id = get_the_ID();
		if ( ! $post_id ) {
			return;
		}

		// Skip WordPress-internal singular post types (wp_global_styles,
		// wp_block, wp_template, wp_navigation, etc.) - URL-addressable
		// but not real renderable pages. eligible_post_types() excludes
		// these via is_post_type_viewable. See 2.3.0 DECISIONS entry
		// "Two-tier post-type scope" for the eligible vs supported
		// distinction.
		if ( ! in_array( get_post_type( $post_id ), DOIG_Cron::eligible_post_types(), true ) ) {
			return;
		}

		// Resolver returns the emit payload or null. Single decision
		// point for "what does DOIG put on this page?".
		$payload = DOIG_Source_Resolver::resolve( $post_id );

		if ( ! $payload ) {
			// No payload - DOIG won't emit on this page. Three reasons:
			//
			//   a. Source = Off, Featured without FI, Custom without upload
			//      → customer's intent is "DOIG silent on this post".
			//      Do nothing.
			//
			//   b. Source = Screenshot (or FwF without FI) but no ready
			//      capture cached yet → DOIG WANTS to emit a screenshot
			//      but hasn't got one. Schedule a capture if status isn't
			//      already in a terminal state. May also fall back to the
			//      configured fallback image for the no-SEO-plugin branch.
			//
			// resolver.needs_screenshot() encodes the mode-aware decision
			// between these two cases.
			if ( DOIG_Source_Resolver::needs_screenshot( $post_id ) ) {
				$status          = DOIG_Post_Meta::get( $post_id, 'status' );
				$terminal_states = array( 'pending', 'failed', 'quota_exceeded' );
				if ( ! in_array( $status, $terminal_states, true ) ) {
					DOIG_Cron::schedule( $post_id, 0 );
				}

				// Fallback path - only relevant for the no-SEO-plugin
				// branch. SEO-plugin-active sites let the plugin's own
				// fallback chain (typically: featured image -> site
				// default -> nothing) handle this. See get_fallback_url()
				// below.
				if ( ! self::seo_plugin_handles_current_page() ) {
					$fallback_url = self::get_fallback_url( $post_id );
					if ( $fallback_url ) {
						// Fallback image's dimensions aren't tracked; omit
						// the width/height/type meta to avoid asserting
						// values we don't know. og:image alone is valid;
						// social-platform scrapers fetch the image and
						// determine size from the actual response.
						echo "\n<!-- PlugUpp Dynamic OG Image Generator (fallback) -->\n";
						echo '<meta property="og:image" content="' . esc_url( $fallback_url ) . '" />' . "\n";
						echo '<meta name="twitter:image" content="' . esc_url( $fallback_url ) . '" />' . "\n";
						echo '<meta name="twitter:card" content="summary_large_image" />' . "\n";
						echo "<!-- /PlugUpp Dynamic OG Image Generator (fallback) -->\n";
					}
				}
			}

			return;
		}

		// Have a payload. Stale-refresh check only applies to the
		// screenshot path - Featured / Custom images don't expire.
		// image/webp is the screenshot mode's MIME type; non-webp
		// payloads are FI / Custom attachments and don't need a
		// freshness check.
		$is_screenshot = ( 'image/webp' === $payload['type'] );
		if ( $is_screenshot && DOIG_Cache::exists( $post_id ) && ! DOIG_Cache::is_fresh( $post_id ) ) {
			DOIG_Cron::schedule( $post_id, 0 );
		}

		self::emit_tags( $payload );
	}

	/**
	 * Surface emission path (2.7.0 M4): homepage, post-type archives,
	 * taxonomy pages, author archives.
	 *
	 * Mirrors inject_post()'s shape: resolve a payload, lazily schedule a
	 * capture when Screenshot mode wants one and none is ready, refresh
	 * stale screenshots, emit. No fallback-image path - the fallback
	 * setting is a post-centric feature.
	 *
	 * @param string $surface_key From DOIG_Surfaces::current_surface_key().
	 */
	private static function inject_surface( $surface_key ) {
		// Mode first, BEFORE allocating an ID - a surface the customer
		// never enabled must not write options on the front end.
		$mode = DOIG_Source_Resolver::surface_mode_for_key( $surface_key );
		if ( DOIG_Source_Resolver::MODE_OFF === $mode ) {
			return;
		}

		$surface_id = DOIG_Surfaces::id_for_key( $surface_key );
		$payload    = DOIG_Source_Resolver::resolve_surface( $surface_id );

		if ( ! $payload ) {
			// Screenshot mode with no ready capture: schedule one, same
			// lazy first-capture behaviour posts get at render time.
			if ( DOIG_Source_Resolver::MODE_SCREENSHOT === $mode ) {
				$status          = DOIG_Post_Meta::get( $surface_id, 'status' );
				$terminal_states = array( 'pending', 'failed', 'quota_exceeded' );
				if ( ! in_array( $status, $terminal_states, true ) ) {
					DOIG_Cron::schedule( $surface_id, 0 );
				}
			}
			return;
		}

		// Stale-refresh, screenshot payloads only (Custom images don't expire).
		if ( 'image/webp' === $payload['type'] && DOIG_Cache::exists( $surface_id ) && ! DOIG_Cache::is_fresh( $surface_id ) ) {
			DOIG_Cron::schedule( $surface_id, 0 );
		}

		self::emit_tags( $payload );
	}

	/**
	 * Whether the active SEO plugin outputs social meta on the CURRENT
	 * page - which decides whether DOIG emits the full block or only the
	 * supplementary tags. Source-verified 2026-07-23 (M4 research):
	 *
	 * - Yoast: generate_open_graph_images() lives on the shared
	 *   Indexable_Presentation base (src/presentations/indexable-presentation.php)
	 *   used by ALL page types, and the generator applies both image
	 *   filters unconditionally -> handles every page type.
	 * - Rank Math: Image::set_images() has explicit branches for
	 *   is_front_page / is_home / is_post_type_archive / is_category /
	 *   is_tag / is_tax / is_author (includes/opengraph/class-image.php)
	 *   -> handles every surface type.
	 * - AIOSEO: Social\Output::isAllowed() permits ONLY front page, home,
	 *   singular, post-type archives and the Woo shop
	 *   (app/Common/Social/Output.php) -> on taxonomy terms and author
	 *   archives AIOSEO outputs NOTHING, so DOIG must emit the full
	 *   block there itself; the compat filters never fire.
	 *
	 * @return bool
	 */
	private static function seo_plugin_handles_current_page() {
		if ( defined( 'WPSEO_VERSION' ) || defined( 'RANK_MATH_VERSION' ) ) {
			return true;
		}
		if ( defined( 'AIOSEO_VERSION' ) ) {
			return is_front_page() || is_home() || is_singular() || is_post_type_archive();
		}
		return false;
	}

	/**
	 * The shared emission block for posts and surfaces.
	 *
	 * @param array $payload {url, width, height, type} from the resolver.
	 */
	private static function emit_tags( $payload ) {
		echo "\n<!-- PlugUpp Dynamic OG Image Generator -->\n";

		// og:image + twitter:image only when no SEO plugin handles this
		// page. When one does, DOIG_SEO_Compat hooks the plugin's own
		// image filters with the same payload URL, so emitting our own
		// og:image here would produce a duplicate. Note this is
		// per-PAGE, not per-install: AIOSEO handles singular pages but
		// not taxonomy/author pages (see seo_plugin_handles_current_page).
		if ( ! self::seo_plugin_handles_current_page() ) {
			echo '<meta property="og:image" content="' . esc_url( $payload['url'] ) . '" />' . "\n";
			echo '<meta name="twitter:image" content="' . esc_url( $payload['url'] ) . '" />' . "\n";
		}

		// Supplementary metadata. The og:image:* tags are inconsistently
		// emitted by SEO plugins (Yoast emits them only when its
		// internal cache has the attachment's metadata; Rank Math emits
		// via its image_meta() loop reading from an attachment array
		// that DOIG's URL-rewrite resets to {url: ...} - the 2.3.0
		// image_array filter fix mitigates but doesn't fully eliminate
		// the gap; AIOSEO is more consistent post-2.3.0). Always emit
		// from DOIG so the metadata IS present even when the SEO
		// plugin's own emission drops them. twitter:card: same story.
		echo '<meta property="og:image:width" content="' . esc_attr( $payload['width'] ) . '" />' . "\n";
		echo '<meta property="og:image:height" content="' . esc_attr( $payload['height'] ) . '" />' . "\n";
		echo '<meta property="og:image:type" content="' . esc_attr( $payload['type'] ) . '" />' . "\n";
		echo '<meta name="twitter:card" content="summary_large_image" />' . "\n";

		echo "<!-- /PlugUpp Dynamic OG Image Generator -->\n";
	}

	/**
	 * Returns the fallback image URL based on the configured fallback type.
	 *
	 * @param int $post_id
	 * @return string|false
	 */
	private static function get_fallback_url( $post_id ) {
		$type = DOIG_Settings::get( 'fallback_type', 'seo_plugin' );

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

		if ( 'media' === $type ) {
			$image_id = (int) DOIG_Settings::get( 'fallback_image_id', 0 );
			if ( $image_id ) {
				$src = wp_get_attachment_image_url( $image_id, 'full' );
				if ( $src ) {
					return $src;
				}
			}
		}

		// 'seo_plugin' — return false and let the SEO plugin handle it naturally.
		// (We do not override SEO plugin output when we have no screenshot ready.)
		return false;
	}

	/**
	 * Returns the resolved image URL for a post without outputting anything.
	 *
	 * 2.4.0 M4.1: back-compat shim around DOIG_Source_Resolver::resolve().
	 * Pre-2.4.0 this method was a direct read of `_doig_cached_url`
	 * gated on `is_enabled()`; the resolver supersedes both. Kept as a
	 * public method so any third-party or premium-build code that calls
	 * it still works, but new code (anywhere) should call the resolver
	 * directly to get the full payload (URL + width + height + type) -
	 * this shim returns only the URL.
	 *
	 * @param int $post_id
	 * @return string|false Resolved image URL, or false when DOIG would
	 *                      not emit on this post (Off mode, missing
	 *                      attachment, screenshot pending, etc.).
	 */
	public static function get_image_url_for_post( $post_id ) {
		$payload = DOIG_Source_Resolver::resolve( $post_id );
		return $payload ? $payload['url'] : false;
	}
}
