<?php

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

/**
 * Resolves the image source for a given post into an emit-ready payload.
 *
 * Introduced in 2.4.0 as the foundation of the "Image source" feature.
 * Pre-2.4.0 DOIG always emitted a screenshot when one was cached for the
 * post. From 2.4.0 the customer picks the source mode per-post (Gutenberg
 * sidebar) and per-content-type (Settings), with five terminal modes plus
 * an Inherit value that defers to the type-level default:
 *
 *   ''                       => inherit content-type default (per-post)
 *   'screenshot'             => the cached browser screenshot (1200x630 webp)
 *   'featured'               => the post's Featured Image attachment
 *   'featured_with_fallback' => featured image when present, screenshot otherwise
 *   'custom'                 => a per-post Custom Image uploaded via Media Library
 *   'off'                    => DOIG steps out of the og:image chain on this post
 *
 * The resolver is the single decision point for "what does DOIG emit?".
 * The og:image injector and the SEO-plugin compat layer (Yoast / Rank Math
 * / AIOSEO) both call into resolve() and emit whatever payload comes back -
 * URL, width, height, MIME type - or stay out of the way when null is
 * returned.
 *
 * Pure function: input post_id, output payload-or-null. No DB writes, no
 * AJAX, no side effects. Lets the resolver be unit-tested in isolation
 * and called from any context (admin, REST, frontend, cron) without
 * surprises.
 *
 * @package dynamic-og-image-generator
 */
class DOIG_Source_Resolver {

	// ---------------------------------------------------------------------
	// Mode constants
	// ---------------------------------------------------------------------

	/** Inherit the content-type default. Per-post meta only; type defaults must be terminal. */
	const MODE_INHERIT = '';

	/** Emit the cached browser screenshot (1200x630 webp). */
	const MODE_SCREENSHOT = 'screenshot';

	/** Emit the post's Featured Image attachment. Null payload when no FI is set. */
	const MODE_FEATURED = 'featured';

	/**
	 * Emit the Featured Image when present; fall back to the cached screenshot
	 * when no FI is set. The most useful default for mixed-content sites
	 * (some posts have hand-curated FIs; others don't).
	 */
	const MODE_FEATURED_WITH_FALLBACK = 'featured_with_fallback';

	/** Emit a per-post Custom Image uploaded via Media Library. Null payload when no upload. */
	const MODE_CUSTOM = 'custom';

	/** DOIG steps out of the og:image chain entirely on this post. */
	const MODE_OFF = 'off';

	/**
	 * Whitelist of terminal modes accepted by the resolver. Inherit ('')
	 * is NOT in this list - it gets resolved to a terminal mode by
	 * resolve_mode().
	 *
	 * @return string[]
	 */
	public static function terminal_modes() {
		return array(
			self::MODE_SCREENSHOT,
			self::MODE_FEATURED,
			self::MODE_FEATURED_WITH_FALLBACK,
			self::MODE_CUSTOM,
			self::MODE_OFF,
		);
	}

	/**
	 * Same as terminal_modes() with Inherit prepended. Used by sanitisers
	 * for per-post meta where Inherit IS a valid stored value.
	 *
	 * @return string[]
	 */
	public static function valid_per_post_modes() {
		return array_merge( array( self::MODE_INHERIT ), self::terminal_modes() );
	}

	/**
	 * Translated, customer-facing labels for the terminal modes. Used by
	 * the Settings page Source dropdowns and the Premium CPT row Source
	 * dropdowns. The Gutenberg sidebar defines its own labels in JS so
	 * the two surfaces don't have to round-trip strings via
	 * wp_localize_script.
	 *
	 * Inherit is NOT in this map - it's surfaced only on per-post controls
	 * (Gutenberg sidebar) where it gets a special label like
	 * "Inherit content-type default (currently: <resolved>)". Type-level
	 * dropdowns must pick a terminal mode.
	 *
	 * @return array<string,string> mode_constant => translated_label
	 */
	public static function mode_labels() {
		return array(
			self::MODE_SCREENSHOT             => __( 'Auto-screenshot', 'dynamic-og-image-generator' ),
			self::MODE_FEATURED               => __( 'Featured image', 'dynamic-og-image-generator' ),
			self::MODE_FEATURED_WITH_FALLBACK => __( 'Featured image (with screenshot when missing)', 'dynamic-og-image-generator' ),
			self::MODE_CUSTOM                 => __( 'Custom image', 'dynamic-og-image-generator' ),
			self::MODE_OFF                    => __( 'Off', 'dynamic-og-image-generator' ),
		);
	}

	// ---------------------------------------------------------------------
	// Public entry points
	// ---------------------------------------------------------------------

	/**
	 * Resolves the image payload for a given post. Returns either a payload
	 * array DOIG will emit, or null when DOIG should not emit anything for
	 * this post (Off mode, or a transient null while a screenshot is
	 * pending, or a missing Featured / Custom attachment in those modes).
	 *
	 * @param int $post_id
	 * @return array{url:string,width:int,height:int,type:string}|null
	 */
	public static function resolve( $post_id ) {
		$post_id = (int) $post_id;
		if ( $post_id <= 0 ) {
			return null;
		}

		$mode = self::resolve_mode( $post_id );
		return self::build_payload_for_mode( $mode, $post_id );
	}

	/**
	 * Resolves the source mode for this post by walking the inherit chain:
	 *   per-post `_doig_source` meta
	 *      -> per-content-type default (source_posts / source_pages / source_cpts)
	 *      -> 'screenshot' as the ultimate fallback if neither resolved.
	 *
	 * Always returns a terminal mode (never MODE_INHERIT). Exposed as
	 * public so the Gutenberg sidebar's "Currently inheriting (...)" label
	 * can read the resolved value for display.
	 *
	 * @param int $post_id
	 * @return string one of the terminal MODE_* constants
	 */
	public static function resolve_mode( $post_id ) {
		$per_post = (string) get_post_meta( $post_id, '_doig_source', true );
		if ( self::MODE_INHERIT !== $per_post && in_array( $per_post, self::terminal_modes(), true ) ) {
			return $per_post;
		}

		// 2.4.0 back-compat shim. Pre-2.4.0 customers had a separate
		// `_doig_enabled` per-post tri-state ('' / '1' / '0'). The Q2
		// Option C re-lock (2026-06-23) collapsed Enable into Source,
		// so the new UI writes only to `_doig_source`. But existing
		// posts may still carry a legacy `_doig_enabled = '0'`
		// (force off) override from a customer or third-party plugin
		// that wrote to it pre-collapse - we MUST honour that here or
		// posts that the customer affirmatively disabled would silently
		// re-emit under the new Source resolution.
		//
		// Legacy '0' (force off) -> return MODE_OFF.
		// Legacy '1' (force on)  -> fall through to type default; the
		//                           customer wanted DOIG active here,
		//                           and the type default (most likely
		//                           a non-Off mode) gives them that.
		// Legacy ''  (inherit)   -> fall through to type default.
		$legacy_enabled = (string) get_post_meta( $post_id, '_doig_enabled', true );
		if ( '0' === $legacy_enabled ) {
			return self::MODE_OFF;
		}

		return self::type_default( get_post_type( $post_id ) );
	}

	/**
	 * Per-content-type default. Posts/pages read from the doig_settings
	 * `source_posts` / `source_pages` keys; CPTs read from the Premium-only
	 * `source_cpts` array (defaults to 'screenshot' for any type not
	 * explicitly listed).
	 *
	 * @param string $post_type
	 * @return string one of the terminal MODE_* constants
	 */
	public static function type_default( $post_type ) {
		$post_type = (string) $post_type;

		if ( 'post' === $post_type ) {
			return self::sanitize_terminal_mode( (string) DOIG_Settings::get( 'source_posts', self::MODE_SCREENSHOT ) );
		}

		if ( 'page' === $post_type ) {
			return self::sanitize_terminal_mode( (string) DOIG_Settings::get( 'source_pages', self::MODE_SCREENSHOT ) );
		}

		// CPT - read from the Premium source_cpts array. Default 'off'
		// when the CPT isn't explicitly listed (preserves the pre-2.4.0
		// opt-in pattern: a CPT must be affirmatively given a non-Off
		// Source on the Settings page to be processed). Matches the
		// "Off when unset" default used by the Settings page CPT row
		// renderer in class-cpts__premium_only.php::render_settings_row().
		//
		// Pre-Q2-Option-C, this branch defaulted to MODE_SCREENSHOT.
		// That made sense when the Enable checkbox controlled scope
		// (an unticked CPT was "out of scope" before the cron loops
		// even reached the resolver). After the collapse, the resolver
		// IS the scope gate - returning MODE_SCREENSHOT here would
		// auto-process every public CPT on the site by default, which
		// is a quota-safety regression (a new WooCommerce install
		// would immediately queue screenshots for every product). Off
		// is the right default.
		$cpt_defaults = (array) DOIG_Settings::get( 'source_cpts', array() );
		if ( isset( $cpt_defaults[ $post_type ] ) ) {
			return self::sanitize_terminal_mode( (string) $cpt_defaults[ $post_type ] );
		}

		return self::MODE_OFF;
	}

	/**
	 * Does this post need a screenshot capture queued?
	 *
	 * True when the resolved Source mode is:
	 *   - 'screenshot'             (always wants one)
	 *   - 'featured_with_fallback' AND the post has no Featured Image
	 *                              (FwF uses the screenshot as fallback)
	 *
	 * False for Featured (post has FI or null payload either way),
	 * Custom (per-post upload or null), and Off.
	 *
	 * Used by the og injector's pending-state branch to decide whether
	 * to schedule a capture when no payload is available, and by the
	 * cron loops in M4.2 to gate the daily refresh sweep so Source
	 * modes that don't emit screenshots don't burn quota.
	 *
	 * @param int $post_id
	 * @return bool
	 */
	public static function needs_screenshot( $post_id ) {
		$mode = self::resolve_mode( $post_id );
		if ( self::MODE_SCREENSHOT === $mode ) {
			$needed = true;
		} elseif ( self::MODE_FEATURED_WITH_FALLBACK === $mode ) {
			// FwF only needs a screenshot when no Featured Image is set
			// on the post; with an FI, the FI emits and no capture is
			// needed.
			$needed = ! has_post_thumbnail( $post_id );
		} else {
			$needed = false;
		}

		// Developer escape hatch (2.7.1): veto capture per post from code,
		// e.g. skip programmatically-generated pages a bulk plugin created.
		// Runs at the single decision point, so every path respects it -
		// publish-time scheduling, the daily refresh sweep, Regenerate
		// All, retry drains, and the og injector's lazy scheduling.
		// Not applied to site-wide surfaces (synthetic IDs; they have
		// their own Settings toggles and no WP_Post to hand a callback).
		// The has_filter() guard keeps the hot sweep loops free of the
		// get_post() lookup when no callback is registered.
		if ( ! DOIG_Surfaces::is_surface_id( $post_id ) && has_filter( 'doig_should_capture' ) ) {
			/**
			 * Filters whether DOIG should capture a screenshot for a post.
			 *
			 * Returning false excludes the post from capture everywhere
			 * (no scheduling, no quota use); the first sweep that sees
			 * the veto also clears any existing capture state, same as
			 * switching the post's Source mode off. Forcing true for a
			 * post whose Source mode does not emit screenshots is
			 * unsupported - the capture would burn quota without being
			 * emitted.
			 *
			 * @since 2.7.1
			 *
			 * @param bool         $needed  Whether a capture is wanted.
			 * @param int          $post_id The post ID.
			 * @param WP_Post|null $post    The post object, null if gone.
			 */
			$needed = (bool) apply_filters( 'doig_should_capture', $needed, (int) $post_id, get_post( $post_id ) );
		}

		return $needed;
	}

	/**
	 * Sanitise a possibly-untrusted string against the terminal mode
	 * whitelist. Anything not on the whitelist coerces to 'screenshot' -
	 * the safe default that preserves pre-2.4.0 behaviour.
	 *
	 * @param string $mode
	 * @return string
	 */
	public static function sanitize_terminal_mode( $mode ) {
		$mode = (string) $mode;
		return in_array( $mode, self::terminal_modes(), true ) ? $mode : self::MODE_SCREENSHOT;
	}

	/**
	 * Sanitise a per-post source value. Same as sanitize_terminal_mode but
	 * Inherit ('') is also accepted (and is the natural default for new
	 * posts that haven't been overridden).
	 *
	 * @param string $mode
	 * @return string
	 */
	public static function sanitize_per_post_mode( $mode ) {
		$mode = (string) $mode;
		return in_array( $mode, self::valid_per_post_modes(), true ) ? $mode : self::MODE_INHERIT;
	}

	// ---------------------------------------------------------------------
	// Payload builders (one per terminal mode)
	// ---------------------------------------------------------------------

	/**
	 * Dispatch a resolved terminal mode to the appropriate payload builder.
	 *
	 * @param string $mode
	 * @param int    $post_id
	 * @return array|null
	 */
	private static function build_payload_for_mode( $mode, $post_id ) {
		switch ( $mode ) {
			case self::MODE_SCREENSHOT:
				return self::screenshot_payload( $post_id );

			case self::MODE_FEATURED:
				return self::featured_payload( $post_id );

			case self::MODE_FEATURED_WITH_FALLBACK:
				// Try featured image first. If no FI is set on the post,
				// fall back to the cached screenshot. This is the Q1-locked
				// default for new installs on Posts: mixed-content sites
				// get FIs where curated and screenshots where not, with no
				// quota burn on the FI'd posts.
				$payload = self::featured_payload( $post_id );
				if ( null !== $payload ) {
					return $payload;
				}
				return self::screenshot_payload( $post_id );

			case self::MODE_CUSTOM:
				return self::custom_payload( $post_id );

			case self::MODE_OFF:
				// DOIG steps out of the og:image chain entirely. The SEO
				// plugin (or core WP) handles og:image via its own
				// resolution path - we add nothing and we rewrite nothing.
				return null;
		}

		return null;
	}

	/**
	 * Cached browser-screenshot payload. Null when no screenshot is ready
	 * (post hasn't been captured yet, or capture is pending/failed).
	 *
	 * @param int $post_id
	 * @return array|null
	 */
	private static function screenshot_payload( $post_id ) {
		// 2.4.0 M4.1: read post meta directly. Previously delegated to
		// DOIG_OG_Injector::get_image_url_for_post() which created a
		// circular dependency once that helper itself was refactored
		// to call the resolver. The screenshot branch is straight
		// "is a cached webp ready and reachable?" which doesn't need
		// indirection.
		$status = (string) get_post_meta( $post_id, '_doig_status', true );
		if ( 'ready' !== $status ) {
			return null;
		}
		if ( ! DOIG_Cache::exists( $post_id ) ) {
			return null;
		}
		$url = (string) get_post_meta( $post_id, '_doig_cached_url', true );
		if ( '' === $url ) {
			return null;
		}
		return array(
			'url'    => $url,
			'width'  => (int) DOIG_Settings::OG_WIDTH,
			'height' => (int) DOIG_Settings::OG_HEIGHT,
			'type'   => 'image/webp',
		);
	}

	/**
	 * Featured Image payload. Pulls the post's Featured Image attachment
	 * (post_thumbnail) and reads dimensions + MIME type from the
	 * attachment metadata. Null when no FI is set on the post.
	 *
	 * @param int $post_id
	 * @return array|null
	 */
	private static function featured_payload( $post_id ) {
		$attachment_id = (int) get_post_thumbnail_id( $post_id );
		if ( $attachment_id <= 0 ) {
			return null;
		}
		return self::attachment_payload( $attachment_id );
	}

	/**
	 * Custom Image payload. Reads the per-post `_doig_custom_image_id`
	 * meta written by the Gutenberg sidebar's Media Library picker. Null
	 * when no upload is recorded (or the attachment has been deleted from
	 * the Media Library since the upload).
	 *
	 * @param int $post_id
	 * @return array|null
	 */
	private static function custom_payload( $post_id ) {
		$attachment_id = (int) get_post_meta( $post_id, '_doig_custom_image_id', true );
		if ( $attachment_id <= 0 ) {
			return null;
		}
		return self::attachment_payload( $attachment_id );
	}

	/**
	 * Common attachment-to-payload conversion. Returns null when the
	 * attachment is missing or has been deleted; otherwise emits a payload
	 * with the attachment's real dimensions and MIME type rather than the
	 * hard-coded screenshot ones.
	 *
	 * @param int $attachment_id
	 * @return array|null
	 */
	private static function attachment_payload( $attachment_id ) {
		$url = wp_get_attachment_url( $attachment_id );
		if ( ! $url ) {
			return null;
		}

		$meta   = wp_get_attachment_metadata( $attachment_id );
		$width  = is_array( $meta ) && isset( $meta['width'] )  ? (int) $meta['width']  : 0;
		$height = is_array( $meta ) && isset( $meta['height'] ) ? (int) $meta['height'] : 0;

		$mime = (string) get_post_mime_type( $attachment_id );
		if ( '' === $mime ) {
			// Last-resort default. wp_get_attachment_url() returned a URL
			// so the attachment exists; absent MIME would be unusual but
			// JPEG is the safest assumption for an arbitrary uploaded
			// image.
			$mime = 'image/jpeg';
		}

		return array(
			'url'    => $url,
			'width'  => $width,
			'height' => $height,
			'type'   => $mime,
		);
	}

	// -------------------------------------------------------------------------
	// 2.7.0 M1: site-wide surfaces (homepage / archives / taxonomies / authors)
	// -------------------------------------------------------------------------

	/**
	 * The modes a surface supports. Featured has no meaning on pages
	 * without a post, so surfaces get the three-mode subset (Q3 lock).
	 *
	 * @return string[]
	 */
	public static function surface_modes() {
		return array( self::MODE_SCREENSHOT, self::MODE_CUSTOM, self::MODE_OFF );
	}

	/**
	 * The settings group a capturable surface key belongs to.
	 *
	 * Per the v1 granularity lock, configuration lives at group level:
	 * every term page of a taxonomy shares the taxonomy's setting, and
	 * every author archive shares the authors setting.
	 *
	 * @param string $surface_key A DOIG_Surfaces capture key.
	 * @return string Settings group key ('' when unrecognised).
	 */
	public static function group_for_surface_key( $surface_key ) {
		$parts = explode( ':', $surface_key );

		switch ( $parts[0] ) {
			case 'home':
				return 'home';
			case 'archive':
				return isset( $parts[1] ) ? 'archive:' . $parts[1] : '';
			case 'tax':
				return isset( $parts[1] ) ? 'taxonomy:' . $parts[1] : '';
			case 'author':
				return 'authors';
		}
		return '';
	}

	/**
	 * Resolved mode for a surface: Screenshot / Custom / Off.
	 *
	 * Off wins in every ambiguous case - unknown surface, unrecognised
	 * key, and (except for the free homepage) non-premium builds, so a
	 * stale premium configuration after a downgrade can never burn
	 * quota. Storage is sparse: an absent group IS Off.
	 *
	 * @param int $surface_id Synthetic surface ID.
	 * @return string One of surface_modes().
	 */
	public static function surface_mode( $surface_id ) {
		return self::surface_mode_for_key( DOIG_Surfaces::key_for_id( $surface_id ) );
	}

	/**
	 * Resolved mode for a surface KEY - same semantics as surface_mode()
	 * but usable before a synthetic ID has been allocated (2.7.0 M4: the
	 * front-end emission path checks the mode first and only allocates an
	 * ID when the surface is actually enabled).
	 *
	 * @param string $surface_key
	 * @return string One of surface_modes().
	 */
	public static function surface_mode_for_key( $surface_key ) {
		$group = self::group_for_surface_key( $surface_key );
		if ( '' === $group ) {
			return self::MODE_OFF;
		}
		if ( 'home' !== $group && ! DOIG_License::is_premium() ) {
			return self::MODE_OFF;
		}

		$surfaces = (array) DOIG_Settings::get( 'surfaces', array() );
		return self::sanitize_surface_mode( $surfaces[ $group ] ?? self::MODE_OFF );
	}

	/**
	 * Emit payload for a surface, mirroring resolve()'s contract: the
	 * {url, width, height, type} array when DOIG should emit on this
	 * surface, or null when it should stay silent (Off, Custom with no
	 * upload, Screenshot with no ready capture).
	 *
	 * The screenshot branch reads through DOIG_Surface_Meta - the
	 * postmeta-direct read in screenshot_payload() would always miss,
	 * since surface state never touches postmeta.
	 *
	 * @param int $surface_id Synthetic surface ID.
	 * @return array|null
	 */
	public static function resolve_surface( $surface_id ) {
		$mode = self::surface_mode( $surface_id );

		if ( self::MODE_CUSTOM === $mode ) {
			$attachment_id = self::surface_custom_image_id( $surface_id );
			return $attachment_id ? self::attachment_payload( $attachment_id ) : null;
		}

		if ( self::MODE_SCREENSHOT !== $mode ) {
			return null;
		}

		if ( 'ready' !== (string) DOIG_Surface_Meta::get( $surface_id, 'status' ) ) {
			return null;
		}
		if ( ! DOIG_Cache::exists( $surface_id ) ) {
			return null;
		}
		$url = (string) DOIG_Surface_Meta::get( $surface_id, 'cached_url' );
		if ( '' === $url ) {
			return null;
		}

		return array(
			'url'    => $url,
			'width'  => (int) DOIG_Settings::OG_WIDTH,
			'height' => (int) DOIG_Settings::OG_HEIGHT,
			'type'   => 'image/webp',
		);
	}

	/**
	 * Does this surface need an actual capture?
	 *
	 * The surface counterpart of needs_screenshot() - the single
	 * decision point every quota-burning surface path must gate on.
	 *
	 * @param int $surface_id
	 * @return bool
	 */
	public static function surface_needs_screenshot( $surface_id ) {
		return self::MODE_SCREENSHOT === self::surface_mode( $surface_id );
	}

	/**
	 * The Custom-mode attachment ID for a surface (0 when none / not
	 * in Custom mode). One image per settings group in v1.
	 *
	 * @param int $surface_id
	 * @return int
	 */
	public static function surface_custom_image_id( $surface_id ) {
		if ( self::MODE_CUSTOM !== self::surface_mode( $surface_id ) ) {
			return 0;
		}
		$group  = self::group_for_surface_key( DOIG_Surfaces::key_for_id( $surface_id ) );
		$custom = (array) DOIG_Settings::get( 'surfaces_custom', array() );
		return absint( $custom[ $group ] ?? 0 );
	}

	/**
	 * Customer-facing labels for the surface modes (Settings UI).
	 *
	 * @return array<string, string> mode => label
	 */
	public static function surface_mode_labels() {
		return array(
			self::MODE_OFF        => __( 'Off', 'dynamic-og-image-generator' ),
			self::MODE_SCREENSHOT => __( 'Screenshot of the page', 'dynamic-og-image-generator' ),
			self::MODE_CUSTOM     => __( 'Custom image', 'dynamic-og-image-generator' ),
		);
	}

	/**
	 * Sanitise a possibly-untrusted surface mode. Unknowns coerce to
	 * Off - for surfaces the safe default is the consent-preserving one
	 * (no capture), deliberately the OPPOSITE of
	 * sanitize_terminal_mode()'s coercion to 'screenshot', which exists
	 * to preserve pre-2.4.0 per-post behaviour.
	 *
	 * @param string $mode
	 * @return string
	 */
	public static function sanitize_surface_mode( $mode ) {
		$mode = (string) $mode;
		return in_array( $mode, self::surface_modes(), true ) ? $mode : self::MODE_OFF;
	}
}
