<?php

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

/**
 * Per-post meta helpers.
 *
 * Meta keys used by this build:
 *   _doig_enabled                   — '' (inherit) | '1' (force on) | '0' (force off)
 *   _doig_status                    — '' | 'pending' | 'ready' | 'failed' | 'quota_exceeded'
 *   _doig_cached_url                — Full URL to cached WebP file
 *   _doig_cached_at                 — Unix timestamp of last successful screenshot
 *   _doig_scheduled_at              — Unix timestamp when the cron job was last queued
 *   _doig_job_id                    — Proxy job ID; used to correlate callbacks and polls; cleared on completion
 *   _doig_error_code                — Machine-readable error token from proxy (e.g. 'licence_inactive', 'proxy_unavailable')
 *   _doig_error_message             — Human-readable error detail from proxy; cleared on successful capture
 *   _doig_source                    — '' (inherit) | 'screenshot' | 'featured' | 'featured_with_fallback' | 'custom' | 'off'   (2.4.0)
 *   _doig_custom_image_id           — Attachment ID for the per-post Custom Image when _doig_source = 'custom'                 (2.4.0)
 *   _doig_orphaned_at               — Unix timestamp when this cached screenshot was first noticed to be orphaned (its
 *                                     resolved Source mode no longer needs a capture). Empty/0 when the cache is current.
 *                                     The weekly orphan sweep cleans up at the 14-day mark; see DOIG_Cron::handle_orphan_sweep(). (2.4.0 M5)
 */
class DOIG_Post_Meta {

	/** Map of short key names to full meta key strings. */
	const KEYS = array(
		'enabled'               => '_doig_enabled',
		'status'                => '_doig_status',
		'cached_url'            => '_doig_cached_url',
		'cached_at'             => '_doig_cached_at',
		'scheduled_at'          => '_doig_scheduled_at',
		'job_id'                => '_doig_job_id',
		'error_code'            => '_doig_error_code',
		'error_message'         => '_doig_error_message',
		// 2.4.0: Image Source feature.
		'source'                => '_doig_source',
		'custom_image_id'       => '_doig_custom_image_id',
		// 2.4.0 M5: cache-flip orphan sweep state.
		'orphaned_at'           => '_doig_orphaned_at',
	);

	public static function init() {
		// Register meta for REST API access (required for Gutenberg sidebar)
		add_action( 'init',         array( __CLASS__, 'register_meta' ) );
		// Register REST route here (not in admin classes) so it's available
		// regardless of is_admin() — REST API requests bypass that check.
		add_action( 'rest_api_init', array( __CLASS__, 'register_rest_route' ) );
	}

	/**
	 * Registers post meta for the REST API so Gutenberg can read/write it.
	 */
	public static function register_meta() {
		// Register against every post type DOIG is eligible to override
		// on, not only those currently in scope for automation. The
		// Gutenberg sidebar appears on every eligible type (post + page
		// always; in Premium, every public viewable CPT) so the REST
		// meta must be registered there too or save calls fail. 2.3.0:
		// widened from supported_post_types() to eligible_post_types() to
		// match the sidebar enqueue gate.
		$post_types = class_exists( 'DOIG_Cron' )
			? DOIG_Cron::eligible_post_types()
			: array( 'post', 'page' );

		// Shared auth callback — any user who can edit posts can read/write these.
		// WordPress blocks write access to private meta (keys starting with _) via
		// the REST API unless auth_callback explicitly permits it. Without this,
		// Gutenberg's save request fails because it sends ALL registered meta back
		// to the server, not just the fields the user actually changed.
		$auth = function() {
			return current_user_can( 'edit_posts' );
		};

		foreach ( $post_types as $post_type ) {

			// -------- Free-tier meta only --------
			//
			// _doig_enabled - per-page tri-state on/off override of the global toggle.
			// _doig_status, _doig_cached_url, _doig_cached_at - capture state used by
			//                the Gutenberg sidebar to show current progress and last result.
			// _doig_job_id - server-internal correlation key, not exposed to REST.
			// _doig_error_code / _doig_error_message - last failure detail, exposed to REST
			//                so the sidebar can surface them to the user.
			//
			// Additional per-page meta keys are registered by separately
			// distributed builds via the doig_register_premium_meta action
			// below.

			register_post_meta( $post_type, '_doig_enabled', array(
				'show_in_rest'  => true,
				'single'        => true,
				'type'          => 'string',
				'default'       => '',
				'auth_callback' => $auth,
			) );

			register_post_meta( $post_type, '_doig_status', array(
				'show_in_rest'  => true,
				'single'        => true,
				'type'          => 'string',
				'default'       => '',
				'auth_callback' => $auth,
			) );

			register_post_meta( $post_type, '_doig_cached_url', array(
				'show_in_rest'  => true,
				'single'        => true,
				'type'          => 'string',
				'default'       => '',
				'auth_callback' => $auth,
			) );

			register_post_meta( $post_type, '_doig_cached_at', array(
				'show_in_rest'  => true,
				'single'        => true,
				'type'          => 'integer',
				'default'       => 0,
				'auth_callback' => $auth,
			) );

			// _doig_job_id - internal correlation key, not exposed to REST because the
			// Gutenberg sidebar never reads or displays it. Cleared at terminal states.
			register_post_meta( $post_type, '_doig_job_id', array(
				'show_in_rest'  => false,
				'single'        => true,
				'type'          => 'string',
				'default'       => '',
				'auth_callback' => $auth,
			) );

			// _doig_error_code / _doig_error_message are shown in the Gutenberg sidebar
			// so the user can see why a capture failed without opening the Logs tab.
			// Cleared on every successful capture so stale errors are never displayed.
			register_post_meta( $post_type, '_doig_error_code', array(
				'show_in_rest'  => true,
				'single'        => true,
				'type'          => 'string',
				'default'       => '',
				'auth_callback' => $auth,
			) );

			register_post_meta( $post_type, '_doig_error_message', array(
				'show_in_rest'  => true,
				'single'        => true,
				'type'          => 'string',
				'default'       => '',
				'auth_callback' => $auth,
			) );

			// 2.4.0: Image Source feature. Per-post Source mode override
			// + per-post Custom Image attachment ID. Both REST-exposed so
			// the Gutenberg sidebar's RadioControl + Media Library picker
			// can read and write them. Defaults to '' (inherit) and 0
			// (no upload) - same shape as the existing tri-state pattern.
			register_post_meta( $post_type, '_doig_source', array(
				'show_in_rest'  => true,
				'single'        => true,
				'type'          => 'string',
				'default'       => '',
				'auth_callback' => $auth,
			) );

			register_post_meta( $post_type, '_doig_custom_image_id', array(
				'show_in_rest'  => true,
				'single'        => true,
				'type'          => 'integer',
				'default'       => 0,
				'auth_callback' => $auth,
			) );

			// 2.4.0 M5: cache-flip orphan sweep tracker. Set to time() when
			// the weekly sweep first notices a cached screenshot whose
			// resolved Source mode no longer needs it. Stays empty when
			// the cache is current. Cleared automatically when the mode
			// flips back to needing a screenshot. The 14-day-old orphan
			// gets purged by the same sweep. Internal state only - not
			// exposed to REST because the sidebar has no use for it.
			register_post_meta( $post_type, '_doig_orphaned_at', array(
				'show_in_rest'  => false,
				'single'        => true,
				'type'          => 'integer',
				'default'       => 0,
				'auth_callback' => $auth,
			) );

			/**
			 * Action: allow separately distributed builds to register
			 * additional per-page meta keys. No-op when nothing is hooked.
			 *
			 * @param string   $post_type     The post type being registered.
			 * @param callable $auth_callback The shared "can edit posts" auth check.
			 */
			do_action( 'doig_register_premium_meta', $post_type, $auth );
		}
	}

	// -------------------------------------------------------------------------
	// Get / Set
	// -------------------------------------------------------------------------

	/**
	 * Gets a meta value by short key name.
	 *
	 * @param int    $post_id
	 * @param string $key  Short key (e.g. 'status', 'cached_url').
	 * @return mixed
	 */
	public static function get( $post_id, $key ) {
		// 2.7.0 M2: synthetic surface IDs route to the surface shim, so
		// every pipeline consumer of this interface gains surface support
		// without knowing surfaces exist.
		if ( DOIG_Surfaces::is_surface_id( $post_id ) ) {
			return DOIG_Surface_Meta::get( $post_id, $key );
		}
		$meta_key = self::KEYS[ $key ] ?? '_doig_' . $key;
		return get_post_meta( absint( $post_id ), $meta_key, true );
	}

	/**
	 * Sets a meta value by short key name.
	 *
	 * @param int    $post_id
	 * @param string $key
	 * @param mixed  $value
	 */
	public static function set( $post_id, $key, $value ) {
		if ( DOIG_Surfaces::is_surface_id( $post_id ) ) {
			DOIG_Surface_Meta::set( $post_id, $key, $value );
			return;
		}
		$meta_key = self::KEYS[ $key ] ?? '_doig_' . $key;
		update_post_meta( absint( $post_id ), $meta_key, $value );
	}

	/**
	 * Atomically claims an in-flight proxy job, returning true to exactly one
	 * caller across concurrent claim_job() invocations with the same job_id.
	 *
	 * The proxy delivers completed jobs via two paths that can race:
	 *   - Async callback POST to /doig/v1/callback
	 *   - Polling pass run by DOIG_Cron
	 * When both arrive within the same second, both used to read the
	 * _doig_job_id meta, both saw it set, and both proceeded to download
	 * the image and create a Media Library attachment - producing duplicate
	 * attachments for one screenshot.
	 *
	 * This method uses an atomic SQL UPDATE that includes the expected
	 * current value of the meta in its WHERE clause:
	 *
	 *     UPDATE wp_postmeta
	 *     SET    meta_value = ''
	 *     WHERE  post_id    = X
	 *       AND  meta_key   = '_doig_job_id'
	 *       AND  meta_value = '<job_id>'
	 *
	 * MariaDB serializes concurrent UPDATEs against the same row, so even if
	 * two PHP requests run the statement simultaneously, exactly one will
	 * find a matching row (rows_affected = 1) and the other will see zero
	 * (rows_affected = 0). The winner proceeds with the image download and
	 * attachment creation; the loser returns silently.
	 *
	 * Callers should invoke this at the top of their terminal-state handler
	 * (completed / failed / quota_exceeded) and skip the entire body when it
	 * returns false. Callers MUST NOT call DOIG_Post_Meta::set('job_id', '')
	 * after a successful claim - the claim itself already cleared it.
	 *
	 * @param int    $post_id
	 * @param string $job_id  The job_id the caller observed and wants to claim.
	 * @return bool           True if this caller won the race; false otherwise.
	 */
	public static function claim_job( $post_id, $job_id ) {
		$post_id = absint( $post_id );
		$job_id  = (string) $job_id;

		if ( $post_id <= 0 || '' === $job_id ) {
			return false;
		}

		// 2.7.0 M2: surface claims route to the shim. Its claim is
		// best-effort rather than SQL-atomic - see the docblock there for
		// why that is safe for surfaces when it was not for posts.
		if ( DOIG_Surfaces::is_surface_id( $post_id ) ) {
			return DOIG_Surface_Meta::claim_job( $post_id, $job_id );
		}

		global $wpdb;

		// Atomic compare-and-clear on _doig_job_id. WordPress's
		// update_post_meta($post_id, $key, $new, $prev) implementation does
		// a SELECT then an UPDATE - the two queries are not in a transaction
		// and the row can change between them. Under high-throughput
		// callback + polling race conditions that gap allows both paths to
		// "win" the claim and process the same screenshot twice. The direct
		// $wpdb->update with $job_id in the WHERE clause is atomic via
		// InnoDB row-level locking and is the only race-free way to do this
		// on top of postmeta. See SESSION_LOG entry for v2.0.32 (atomic
		// claim_job introduced) for the duplicate-attachment incident that
		// drove this design.
		//
		// The accompanying SlowDBQuery warnings about meta_key/meta_value
		// in the query are inherent to writing to postmeta and unavoidable
		// for this operation. NoCaching is intentional: this UPDATE bypasses
		// the WP object cache by design, then explicitly invalidates the
		// post's cached meta on line further below so subsequent reads see
		// the cleared value.
		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.SlowDBQuery.slow_db_query_meta_value,WordPress.DB.SlowDBQuery.slow_db_query_meta_key
		$rows = $wpdb->update(
			$wpdb->postmeta,
			array( 'meta_value' => '' ),
			array(
				'post_id'    => $post_id,
				'meta_key'   => '_doig_job_id',
				'meta_value' => $job_id,
			),
			array( '%s' ),
			array( '%d', '%s', '%s' )
		);
		// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.SlowDBQuery.slow_db_query_meta_value,WordPress.DB.SlowDBQuery.slow_db_query_meta_key

		if ( false === $rows || $rows < 1 ) {
			return false;
		}

		// Bust the WP object cache for this post's meta so subsequent
		// get_post_meta() calls within the same request see the cleared value.
		// Without this, the cached array still shows the old job_id and any
		// downstream code that reads the meta could re-trip the race.
		wp_cache_delete( $post_id, 'post_meta' );

		return true;
	}

	// -------------------------------------------------------------------------
	// REST API endpoint for "Regenerate Now" button
	// -------------------------------------------------------------------------

	/**
	 * Registers a custom REST route for the Gutenberg "Regenerate Now" button.
	 * Called from DOIG_Gutenberg::init() to keep REST registration near the sidebar.
	 */
	public static function register_rest_route() {
		register_rest_route( 'doig/v1', '/regenerate/(?P<id>[\d]+)', array(
			'methods'             => 'POST',
			'callback'            => array( __CLASS__, 'rest_regenerate' ),
			'permission_callback' => function( $request ) {
				return current_user_can( 'edit_post', (int) $request['id'] );
			},
			'args'                => array(
				'id' => array(
					'validate_callback' => function( $param ) {
						return is_numeric( $param );
					},
				),
			),
		) );

		// Clear the cached screenshot for one post (2.6.0). Distinct from
		// Regenerate Now: deletes the cached WebP and resets the capture
		// state WITHOUT submitting a fresh job, so no quota is consumed.
		// The daily refresh or the next content update re-captures
		// eventually; the customer is free to leave it empty until then.
		register_rest_route( 'doig/v1', '/clear-cache/(?P<id>[\d]+)', array(
			'methods'             => 'POST',
			'callback'            => array( __CLASS__, 'rest_clear_cache' ),
			'permission_callback' => function( $request ) {
				return current_user_can( 'edit_post', (int) $request['id'] );
			},
			'args'                => array(
				'id' => array(
					'validate_callback' => function( $param ) {
						return is_numeric( $param );
					},
				),
			),
		) );

		// Status endpoint for the Gutenberg sidebar to poll while a regen is
		// in flight. Triggers a direct poll against the screenshot server when
		// the post is still pending - the JS does not have to wait for the
		// callback or the next cron tick.
		register_rest_route( 'doig/v1', '/regenerate-status/(?P<id>[\d]+)', array(
			'methods'             => 'GET',
			'callback'            => array( __CLASS__, 'rest_regenerate_status' ),
			'permission_callback' => function( $request ) {
				return current_user_can( 'edit_post', (int) $request['id'] );
			},
			'args'                => array(
				'id' => array(
					'validate_callback' => function( $param ) {
						return is_numeric( $param );
					},
				),
			),
		) );
	}

	/**
	 * REST callback: clear the cached screenshot for one post (2.6.0).
	 *
	 * Deletes the cached WebP and resets the capture state via
	 * DOIG_Cache::purge() - status / cached_url / cached_at / scheduled_at /
	 * orphaned_at all reset. Deliberately does NOT submit a new capture and
	 * does not touch quota. The sidebar disables the button when no cache
	 * exists, but the guard here keeps the endpoint honest anyway.
	 *
	 * Response shape: { success: bool, message: string }.
	 *
	 * @param WP_REST_Request $request Request with the post id.
	 * @return WP_REST_Response
	 */
	public static function rest_clear_cache( $request ) {
		$post_id = (int) $request['id'];

		if ( ! DOIG_Cache::exists( $post_id ) && '' === (string) self::get( $post_id, 'cached_url' ) ) {
			return new WP_REST_Response( array(
				'success' => false,
				'message' => __( 'There is no cached screenshot for this post.', 'dynamic-og-image-generator' ),
			), 400 );
		}

		DOIG_Cache::purge( $post_id );
		DOIG_Logger::info( 'Cache cleared via block editor.', array( 'post_id' => $post_id ) );

		return new WP_REST_Response( array(
			'success' => true,
			'message' => __( 'Cached screenshot cleared. No new capture was queued and no quota was used.', 'dynamic-og-image-generator' ),
		), 200 );
	}

	/**
	 * REST callback: returns the current status of a regen job. If the post is
	 * still pending and a job_id is set, fires a single direct poll against
	 * the screenshot server before reading meta - so a successful poll updates
	 * meta in this same request and the JS sees the new state immediately.
	 *
	 * Response shape:
	 *   {
	 *     status:        'ready' | 'pending' | 'failed' | 'quota_exceeded' | '',
	 *     cached_url:    string|null,
	 *     cached_at:     int|null,
	 *     job_id:        string,
	 *     error_code:    string,
	 *     error_message: string
	 *   }
	 */
	public static function rest_regenerate_status( $request ) {
		$post_id = (int) $request['id'];

		$status = (string) self::get( $post_id, 'status' );
		$job_id = (string) self::get( $post_id, 'job_id' );

		// If still pending and we have a job_id, do a direct poll. This is
		// idempotent - the cron path uses the same call and is safe to
		// duplicate. Wrapped in try/catch so a transient blip never breaks
		// the polling endpoint.
		if ( 'pending' === $status && '' !== $job_id ) {
			try {
				$licence_key = DOIG_Screenshot::get_licence_key();
				if ( $licence_key ) {
					DOIG_Cron::poll_proxy_job( $post_id, $licence_key );
				}
			} catch ( \Throwable $e ) {
				DOIG_Logger::info(
					sprintf( 'Regenerate status poll failed: %s', $e->getMessage() ), array( 'post_id' => $post_id ) );
			}
		}

		// Re-read after the poll (poll_proxy_job may have updated these).
		$status        = (string) self::get( $post_id, 'status' );
		$cached_url    = (string) self::get( $post_id, 'cached_url' );
		$cached_at     = (int)    self::get( $post_id, 'cached_at' );
		$job_id        = (string) self::get( $post_id, 'job_id' );
		$error_code    = (string) self::get( $post_id, 'error_code' );
		$error_message = (string) self::get( $post_id, 'error_message' );

		return new WP_REST_Response( array(
			'status'        => $status,
			'cached_url'    => $cached_url ?: null,
			'cached_at'     => $cached_at  ?: null,
			'job_id'        => $job_id,
			'error_code'    => $error_code,
			'error_message' => $error_message,
		), 200 );
	}

	/**
	 * REST callback: submits an async screenshot job to the PlugUpp proxy.
	 *
	 * In v2.0, this no longer executes synchronously. It submits a job to the proxy
	 * and returns immediately with status 'pending'. The result arrives later via the
	 * doig/v1/callback endpoint, or via the cron polling pass if the callback is not
	 * received. The existing cached image (if any) continues to be served while the
	 * new job is in flight.
	 *
	 * @param WP_REST_Request $request
	 * @return WP_REST_Response
	 */
	public static function rest_regenerate( $request ) {
		$post_id = (int) $request['id'];

		DOIG_Logger::info( 'Regenerate requested via block editor.', array( 'post_id' => $post_id ) );

		// Guard: post must be published.
		if ( 'publish' !== get_post_status( $post_id ) ) {
			return new WP_REST_Response( array(
				'success' => false,
				'message' => __( 'Screenshots can only be generated for published posts.', 'dynamic-og-image-generator' ),
			), 400 );
		}

		// Source-mode quota gate (M4.2). Replaces the pre-2.4.0
		// is_enabled() check. Regenerate Now only makes sense when the
		// post's resolved Source mode actually emits a screenshot
		// (Screenshot mode always, FwF mode when no Featured Image is
		// set). Featured / Custom / Off / FwF-with-FI all read from
		// the Featured Image or Custom Image attachment - there is no
		// screenshot to regenerate, so the button shouldn't even be
		// rendered for those modes (the sidebar gates it client-side
		// from M3.2), but the endpoint still guards in case it is hit
		// directly. Per-page Source='screenshot' overrides the type
		// default the same way the pre-2.4.0 per-page enable override did.
		if ( ! DOIG_Source_Resolver::needs_screenshot( $post_id ) ) {
			return new WP_REST_Response( array(
				'success' => false,
				'message' => __( 'Regenerate is only available when the image source is set to Auto-screenshot. Change the Image Source in the sidebar to enable it.', 'dynamic-og-image-generator' ),
			), 400 );
		}

		// Cancel any pending scheduled doig_take_screenshot event for this
		// post. The post_updated hook (in DOIG_Cron) may have scheduled
		// one ~15s after a recent content edit; firing both that scheduled
		// event AND this manual regen produces two identical jobs that each
		// cost a screenshot credit. The manual click takes precedence -
		// the user wants the result now, not in 15s. (Fixed 2026-06-08
		// after a duplicate-submission was traced in the test logs.)
		DOIG_Cron::unschedule( $post_id );

		// Submit async job to the PlugUpp proxy.
		// The existing cached image continues to be served until the callback arrives.
		// submit_proxy_job() handles all meta updates internally (status, job_id, error meta).
		$url    = get_permalink( $post_id );
		$result = DOIG_Screenshot::submit_proxy_job( $url, $post_id, 1, 'regenerate_now' );

		if ( true === $result ) {
			// Job accepted by proxy — schedule a deferred polling pass at ~12 minutes so
			// the post is checked even if no bulk work triggers the queue processor first.
			// This is the single-post polling guarantee (mirrors handle_take_screenshot()).
			// Only schedule if no doig_process_queue event is already due within 12 minutes.
			$next_queue = wp_next_scheduled( 'doig_process_queue' );
			if ( ! $next_queue || $next_queue > ( time() + 720 ) ) {
				wp_schedule_single_event( time() + 720, 'doig_process_queue' );
			}

			DOIG_Logger::info( 'Regenerate Now — job submitted to screenshot server successfully.', array( 'post_id' => $post_id ) );

			return new WP_REST_Response( array(
				'success' => true,
				'status'  => 'pending',
				'message' => __( 'Screenshot requested. The image will update automatically when it is ready.', 'dynamic-og-image-generator' ),
			), 200 );
		}

		if ( is_wp_error( $result ) ) {
			$error_code = $result->get_error_code();

			if ( 'proxy_transient_error' === $error_code ) {
				// Proxy temporarily unreachable — add to the submission retry queue so
				// handle_process_queue() retries automatically (up to 3 attempts, 30s/2m/5m backoff).
				DOIG_Cron::add_submission_retry( $post_id, $result->get_error_message() );

				// Ensure a process_queue event is scheduled at the first retry time.
				$next_event    = wp_next_scheduled( 'doig_process_queue' );
				$next_retry_at = time() + 30;
				if ( ! $next_event || $next_event > $next_retry_at ) {
					wp_schedule_single_event( $next_retry_at, 'doig_process_queue' );
				}

				DOIG_Logger::info( 'Regenerate Now — screenshot server unavailable, job queued for automatic retry.', array( 'post_id' => $post_id ) );
				return new WP_REST_Response( array(
					'success' => true,
					'status'  => 'pending',
					'message' => __( 'Screenshot requested. The screenshot server is temporarily unavailable and will be retried automatically.', 'dynamic-og-image-generator' ),
				), 200 );
			}

			if ( 'quota_exceeded' === $error_code ) {
				return new WP_REST_Response( array(
					'success' => false,
					'status'  => 'quota_exceeded',
					'message' => __( 'Your monthly screenshot quota has been reached. Existing screenshots will continue to be served. New screenshots will resume automatically when your quota resets.', 'dynamic-og-image-generator' ),
				), 200 );
			}

			if ( 'licence_inactive' === $error_code ) {
				return new WP_REST_Response( array(
					'success' => false,
					'status'  => 'failed',
					'message' => __( 'Your PlugUpp licence is inactive. Screenshots have been paused. Please check your account at plugupp.com.', 'dynamic-og-image-generator' ),
				), 200 );
			}

			if ( 'http_site_url' === $error_code ) {
				return new WP_REST_Response( array(
					'success'    => false,
					'status'     => 'failed',
					'error_code' => 'http_site_url',
					'message'    => $result->get_error_message(),
				), 200 );
			}

			// Generic proxy rejection — surface the error message from the proxy if available.
			$error_message = $result->get_error_message();
			$message = ! empty( $error_message )
				? sprintf(
					/* translators: %s: error detail returned by the proxy */
					__( 'Screenshot failed: %s', 'dynamic-og-image-generator' ),
					esc_html( $error_message )
				)
				: __( 'Screenshot failed. Please check the Logs tab for more details.', 'dynamic-og-image-generator' );

			return new WP_REST_Response( array(
				'success'    => false,
				'status'     => 'failed',
				'error_code' => $error_code,
				'message'    => $message,
			), 200 );
		}

		// Should not be reached — submit_proxy_job() always returns true or WP_Error.
		return new WP_REST_Response( array(
			'success' => false,
			'message' => __( 'An unexpected error occurred. Please check the Logs tab for more details.', 'dynamic-og-image-generator' ),
		), 500 );
	}
}
