<?php

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

/**
 * REST endpoint for receiving async screenshot results from the PlugUpp proxy.
 *
 * The proxy POSTs a JSON callback to doig/v1/callback when a screenshot job
 * completes, fails, or is quota-blocked. Authentication is via HMAC-SHA256
 * signature — no WordPress user authentication is required or used.
 *
 * This file is loaded unconditionally (not just in admin) so the REST endpoint
 * is available to unauthenticated inbound requests from the proxy.
 *
 * Endpoint:  POST /wp-json/doig/v1/callback
 * Auth:      X-DOIG-Signature: sha256=<hmac_hex>
 * Body:      JSON — see handle_callback() for full schema.
 *
 * Return codes:
 *   200 — for any recognised status (completed, failed, quota_exceeded). Always
 *          200 so the proxy does not retry on a legitimate application error.
 *   400 — malformed payload or missing HMAC secret (proxy not yet initialised).
 *   403 — HMAC signature mismatch.
 */
class DOIG_Proxy_Callback {

	/** WordPress option that holds the shared HMAC secret issued by the proxy. */
	const HMAC_OPTION = 'doig_proxy_hmac_secret';

	/** Header name carrying the HMAC signature. */
	const SIG_HEADER = 'X-DOIG-Signature';

	public static function init() {
		add_action( 'rest_api_init', array( __CLASS__, 'register_routes' ) );

		// Background processor for "completed" callbacks. The REST handler
		// schedules a single event with this hook + the job args; WP-Cron
		// picks it up on the next spawn (~ seconds under any traffic) and
		// does the slow download + Media Library save off the request path.
		// The proxy gets its 200 in <100ms regardless of how busy the
		// plugin is. See handle_completed() for the trade-off rationale.
		add_action( 'doig_process_completed_callback', array( __CLASS__, 'process_completed_async' ), 10, 3 );
	}

	/**
	 * Registers the callback REST route.
	 */
	public static function register_routes() {
		register_rest_route( 'doig/v1', '/callback', array(
			'methods'             => 'POST',
			'callback'            => array( __CLASS__, 'handle_callback' ),
			// Authentication is via HMAC — no WordPress capability check.
			'permission_callback' => '__return_true',
		) );
	}

	/**
	 * Handles an inbound callback from the PlugUpp proxy.
	 *
	 * Expected JSON body:
	 * {
	 *   "job_id":       "abc123",
	 *   "post_id":      456,
	 *   "status":       "completed" | "failed" | "quota_exceeded",
	 *   "image_url":    "https://screenshots.plugupp.com/tmp/abc123.webp",  // when completed
	 *   "error_code":   "request_not_valid",                                // when failed
	 *   "error_message": "...",                                             // when failed
	 *   "retry_count":  2                                                   // proxy retries
	 * }
	 *
	 * @param WP_REST_Request $request
	 * @return WP_REST_Response
	 */
	public static function handle_callback( WP_REST_Request $request ) {

		// 1. Read raw request body (needed for HMAC verification before any parsing).
		$raw_body = $request->get_body();

		// 2. Retrieve HMAC secret. If not yet stored, the proxy has not successfully
		//    submitted a job — reject the callback rather than silently ignoring it.
		$hmac_secret = (string) get_option( self::HMAC_OPTION, '' );
		if ( '' === $hmac_secret ) {
			DOIG_Logger::info( 'Callback rejected — HMAC secret not yet initialised.' );
			return new WP_REST_Response(
				array( 'error' => 'hmac_secret_not_initialised' ),
				400
			);
		}

		// 3. Verify HMAC-SHA256 signature.
		$sig_header = $request->get_header( 'x_doig_signature' );
		// Header value format: "sha256=<hex>". Strip the "sha256=" prefix.
		$provided_hex  = ( 0 === strpos( (string) $sig_header, 'sha256=' ) )
			? substr( $sig_header, 7 )
			: '';
		$expected_hex  = hash_hmac( 'sha256', $raw_body, $hmac_secret );

		// hash_equals() prevents timing-attack leakage.
		if ( ! hash_equals( $expected_hex, $provided_hex ) ) {
			DOIG_Logger::info(
				sprintf(
					'Callback rejected — HMAC signature mismatch (provided: %s).',
					substr( $provided_hex, 0, 8 ) . '...'
				)
			);
			return new WP_REST_Response(
				array( 'error' => 'signature_mismatch' ),
				403
			);
		}

		// 4. Parse JSON body.
		$payload = json_decode( $raw_body, true );

		if ( ! is_array( $payload ) ) {
			DOIG_Logger::info( 'Callback rejected — non-JSON or empty body.' );
			return new WP_REST_Response(
				array( 'error' => 'invalid_payload' ),
				400
			);
		}

		$job_id      = isset( $payload['job_id'] )      ? (string) $payload['job_id']      : '';
		$post_id     = isset( $payload['post_id'] )     ? absint( $payload['post_id'] )     : 0;
		$status      = isset( $payload['status'] )      ? (string) $payload['status']       : '';
		$image_url   = isset( $payload['image_url'] )   ? (string) $payload['image_url']    : '';
		$error_code  = isset( $payload['error_code'] )  ? sanitize_key( (string) $payload['error_code'] ) : '';
		$error_msg   = isset( $payload['error_message'] ) ? (string) $payload['error_message'] : '';
		$retry_count = isset( $payload['retry_count'] ) ? (int) $payload['retry_count']     : 0;

		// Log the full payload at debug level on every callback receipt.
		DOIG_Logger::debug(
			sprintf(
				'Post %d — callback received: %s',
				$post_id,
				$raw_body ? substr( $raw_body, 0, 500 ) : '(empty)'
			)
		);

		// 5. Validate post_id and verify job_id matches the stored job for this post.
		if ( ! $post_id ) {
			DOIG_Logger::info( 'Callback ignored — missing or invalid post_id.' );
			return new WP_REST_Response( array( 'success' => true ), 200 );
		}

		// For terminal states (completed / failed / quota_exceeded), atomically
		// claim the job before dispatching. The claim is a compare-and-clear
		// SQL UPDATE that succeeds for exactly one caller across concurrent
		// claim_job() invocations - so if the polling pass (DOIG_Cron::poll_proxy_job)
		// is running at the same moment as this callback, only one path actually
		// processes the result and creates a Media Library attachment. The other
		// returns 200 silently. The previous "read stored, compare, dispatch"
		// pattern had a race window where both paths could see the same stored
		// job_id and both proceed.
		//
		// Non-terminal callbacks (status='processing', unrecognised statuses)
		// don't claim - they're either informational or unexpected, and we
		// want subsequent polls to still see job_id set in meta.
		$is_terminal = in_array( $status, array( 'completed', 'failed', 'quota_exceeded' ), true );

		if ( $is_terminal ) {
			if ( ! DOIG_Post_Meta::claim_job( $post_id, $job_id ) ) {
				// Either the job_id is genuinely stale (replayed/mis-routed
				// callback) or a concurrent poll won the race for the same
				// job. Either way, the right answer to the proxy is 200 -
				// the screenshot is being delivered by the path that did win,
				// and the proxy shouldn't retry the callback.
				DOIG_Logger::debug(
					sprintf(
						'Post %d — terminal callback for job %s skipped (stale or lost race to poll).',
						$post_id,
						$job_id
					)
				);
				return new WP_REST_Response( array( 'success' => true, 'duplicate' => true ), 200 );
			}
		} else {
			// Informational callbacks - still validate the job_id matches so
			// we ignore replays / mis-routed traffic.
			$stored_job_id = DOIG_Post_Meta::get( $post_id, 'job_id' );
			if ( $job_id !== $stored_job_id ) {
				DOIG_Logger::debug(
					sprintf(
						'Post %d — non-terminal callback job_id mismatch (received: %s, stored: %s). Ignoring.',
						$post_id,
						$job_id,
						$stored_job_id
					)
				);
				return new WP_REST_Response( array( 'success' => true ), 200 );
			}
		}

		// 6-8. Dispatch on status. Terminal handlers MUST NOT call
		// DOIG_Post_Meta::set('job_id', '') - claim_job above already cleared
		// it atomically. Re-clearing would be harmless but is redundant noise.

		if ( 'completed' === $status ) {
			return self::handle_completed( $post_id, $image_url, $retry_count );
		}

		if ( 'failed' === $status ) {
			return self::handle_failed( $post_id, $error_code, $error_msg, $retry_count );
		}

		if ( 'quota_exceeded' === $status ) {
			return self::handle_quota_exceeded( $post_id );
		}

		// Unrecognised status — acknowledge and log.
		DOIG_Logger::info(
			sprintf( 'Post %d — callback received unrecognised status: %s', $post_id, $status )
		);
		return new WP_REST_Response( array( 'success' => true ), 200 );
	}

	// -------------------------------------------------------------------------
	// Status handlers
	// -------------------------------------------------------------------------

	/**
	 * Handles a completed callback by deferring the heavy work to after the
	 * REST response is sent, then returning 200 immediately.
	 *
	 * Originally this method ran the full image download + Media Library save
	 * synchronously before returning. That worked fine for one-off
	 * regenerations but collapsed under bulk-regen load on the new fast
	 * daemon: the proxy could fire 30+ parallel callbacks per second, each
	 * one of which queued behind PHP-FPM workers doing 5-15s of download +
	 * attachment-creation work. The proxy's 10s callback timeout would
	 * elapse before the plugin responded, the proxy would log
	 * callback_failed_timeout warnings, and the polling fallback would have
	 * to clean up later. End-to-end screenshots were always delivered, but
	 * the proxy log filled with false-alarm warnings and the proxy daemon
	 * wasted time waiting for slow responses.
	 *
	 * Fix: register a shutdown function that will execute after WordPress
	 * has serialised and echoed the REST response. The shutdown function
	 * calls fastcgi_finish_request() to flush the response to the client
	 * and close the FPM connection, then does the heavy work in the same
	 * PHP process. The proxy gets its 200 in <100ms; the plugin continues
	 * for as long as the host's max_execution_time allows.
	 *
	 * Why fastcgi_finish_request and not wp_schedule_single_event: WP-Cron
	 * has a "spawn only when there is traffic" latency problem. On a quiet
	 * customer site, a Regenerate Now click could sit waiting for the next
	 * pageview to trigger the cron spawn - minutes of apparent silence
	 * before the screenshot appears in the Media Library. Sites with
	 * DISABLE_WP_CRON set wouldn't run it at all without an external cron.
	 * fastcgi_finish_request happens in the current request and is
	 * available on every PHP-FPM host (which is every modern WordPress
	 * managed host: Ploi, Kinsta, WP Engine, Cloudways, SiteGround). For
	 * the rare non-FPM environment we fall back to wp_schedule_single_event
	 * - slower but correct.
	 *
	 * The atomic claim_job() in dispatch() above guarantees no duplicate
	 * processing even if the polling-fallback fires between callback
	 * receipt and the deferred handler running, because claim has already
	 * cleared the job_id meta before this method returns.
	 *
	 * @param int    $post_id
	 * @param string $image_url  Temporary proxy download URL.
	 * @param int    $retry_count Number of proxy-side retries.
	 * @return WP_REST_Response
	 */
	private static function handle_completed( $post_id, $image_url, $retry_count ) {
		if ( empty( $image_url ) ) {
			DOIG_Logger::error(
				sprintf( 'Post %d — callback completed but no image_url.', $post_id )
			);
			// Return 200 - this is an application-level problem, not a transport error.
			return new WP_REST_Response( array( 'success' => true ), 200 );
		}

		if ( function_exists( 'fastcgi_finish_request' ) ) {
			// PHP-FPM path: run the work in the same PHP process, AFTER
			// WordPress has sent the REST response and we close the client
			// connection. No cron dependency, no extra HTTP roundtrip, no
			// latency on quiet sites.
			register_shutdown_function(
				array( __CLASS__, 'run_deferred_completed' ),
				(int) $post_id,
				(string) $image_url,
				(int) $retry_count
			);
		} else {
			// Non-FPM fallback (mod_php, CGI, some lesser-used hosting). Cron
			// is the only reasonable way to release the client connection
			// before doing the heavy work. Latency is the well-known
			// "WP-Cron waits for traffic" problem; less common but possible.
			$scheduled = wp_schedule_single_event(
				time(),
				'doig_process_completed_callback',
				array( (int) $post_id, (string) $image_url, (int) $retry_count )
			);
			if ( false === $scheduled ) {
				// Last-resort inline processing so the job is not dropped.
				DOIG_Logger::info(
					sprintf( 'Post %d — neither fastcgi_finish_request nor wp_schedule_single_event available; running inline.', $post_id )
				);
				self::process_completed_async( $post_id, $image_url, $retry_count );
			}
		}

		return new WP_REST_Response( array( 'success' => true ), 200 );
	}

	/**
	 * PHP-FPM deferred handler. Registered via register_shutdown_function in
	 * handle_completed() above. WordPress has finished routing the REST
	 * response and PHP is about to shut down; the output buffer still
	 * contains the JSON body waiting to be sent.
	 *
	 * Sequence:
	 *   1. Flush every output buffer level so the JSON response lands in
	 *      FPM's send queue.
	 *   2. Call fastcgi_finish_request() to flush FPM's send queue to the
	 *      client and close the connection. The client (proxy) now has its
	 *      200 response.
	 *   3. ignore_user_abort + set_time_limit so the rest of the work runs
	 *      to completion regardless of how long it takes.
	 *   4. Call the same process_completed_async() that the WP-Cron path
	 *      uses, keeping a single source of truth for the work itself.
	 *
	 * Errors during the deferred work cannot be surfaced back to the proxy
	 * (the connection is closed). They go to the plugin's own log; the
	 * proxy's polling-fallback path picks them up if the screenshot never
	 * lands - so a failure here is not silent end-to-end.
	 *
	 * @param int    $post_id
	 * @param string $image_url
	 * @param int    $retry_count
	 */
	public static function run_deferred_completed( $post_id, $image_url, $retry_count ) {
		// Drain every nested output buffer so the JSON response is fully in
		// FPM's send queue before we close the connection. WordPress and
		// some plugins may have wrapped the response in extra ob_start()
		// layers; flushing once would leave the response stuck.
		while ( ob_get_level() > 0 ) {
			@ob_end_flush();
		}
		@flush();

		// Close the client connection. After this returns, the proxy has its
		// 200; the PHP-FPM worker keeps running this script until it ends.
		fastcgi_finish_request();

		// The proxy is no longer waiting for us. Lift the script's time-limit
		// pressure and ignore an aborted client (already aborted by definition).
		//
		// set_time_limit is the documented way to extend the runtime budget
		// of the current request and is the right tool here - the image
		// download + Media Library save can take longer than the default
		// max_execution_time. The @-suppression is required because some
		// shared hosts disable set_time_limit via disable_functions; in that
		// case the call is a silent no-op and the host's existing time limit
		// applies, which is acceptable for the deferred work.
		ignore_user_abort( true );
		// phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged,WordPress.PHP.NoSilencedErrors.Discouraged -- see comment above
		@set_time_limit( 60 );

		self::process_completed_async( (int) $post_id, (string) $image_url, (int) $retry_count );
	}

	/**
	 * Shared worker for the completed-callback heavy work. Called by:
	 *   - run_deferred_completed() on PHP-FPM hosts (in the same request,
	 *     after fastcgi_finish_request has released the client),
	 *   - the doig_process_completed_callback action on non-FPM hosts (via
	 *     WP-Cron),
	 *   - the inline last-resort fallback if neither defer mechanism is
	 *     available.
	 *
	 * Has no time-budget pressure from the proxy in any of these paths, so
	 * the slow image download and Media Library work runs at whatever pace
	 * the host can manage.
	 *
	 * Public so the action callback can reach it. Not part of the documented
	 * extension surface - other code should not call this directly; it
	 * expects to run after dispatch()'s atomic claim_job has already
	 * cleared the job_id meta.
	 *
	 * @param int    $post_id
	 * @param string $image_url
	 * @param int    $retry_count
	 */
	public static function process_completed_async( $post_id, $image_url, $retry_count ) {
		$file_path = DOIG_Screenshot::save_proxy_image( $image_url, $post_id );

		if ( is_wp_error( $file_path ) ) {
			DOIG_Logger::error(
				sprintf(
					'Post %d — async callback: image download failed — %s',
					$post_id,
					$file_path->get_error_message()
				)
			);
			return;
		}

		$now        = time();
		$cached_url = DOIG_Cache::get_file_url( $post_id ) . '?v=' . $now;

		DOIG_Post_Meta::set( $post_id, 'status',        'ready' );
		DOIG_Post_Meta::set( $post_id, 'cached_url',    $cached_url );
		DOIG_Post_Meta::set( $post_id, 'cached_at',     $now );
		// Clear transient proxy job state. job_id was already cleared by
		// the atomic DOIG_Post_Meta::claim_job() call in dispatch().
		DOIG_Post_Meta::set( $post_id, 'error_code',    '' );
		DOIG_Post_Meta::set( $post_id, 'error_message', '' );

		DOIG_Logger::info(
			sprintf(
				'Post %d — screenshot ready (job completed after %d %s).',
				$post_id,
				$retry_count,
				1 === $retry_count ? 'retry' : 'retries'
			)
		);
	}

	/**
	 * Handles a failed callback: marks the post as failed with error details.
	 *
	 * @param int    $post_id
	 * @param string $error_code
	 * @param string $error_message
	 * @param int    $retry_count
	 * @return WP_REST_Response
	 */
	private static function handle_failed( $post_id, $error_code, $error_message, $retry_count ) {
		DOIG_Post_Meta::set( $post_id, 'status',        'failed' );
		DOIG_Post_Meta::set( $post_id, 'error_code',    $error_code );
		DOIG_Post_Meta::set( $post_id, 'error_message', $error_message );
		// job_id was already cleared by the atomic claim_job() call in dispatch().

		DOIG_Logger::error(
			sprintf(
				'Post %d — screenshot failed (%d %s): %s — %s',
				$post_id,
				$retry_count,
				1 === $retry_count ? 'retry' : 'retries',
				$error_code,
				$error_message
			)
		);

		return new WP_REST_Response( array( 'success' => true ), 200 );
	}

	/**
	 * Handles a quota_exceeded callback.
	 *
	 * @param int $post_id
	 * @return WP_REST_Response
	 */
	private static function handle_quota_exceeded( $post_id ) {
		DOIG_Post_Meta::set( $post_id, 'status',  'quota_exceeded' );
		// job_id was already cleared by the atomic claim_job() call in dispatch().

		DOIG_Logger::info(
			sprintf(
				'Post %d — monthly screenshot quota exceeded (callback). Will resume when quota resets.',
				$post_id
			)
		);

		return new WP_REST_Response( array( 'success' => true ), 200 );
	}
}
