<?php

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

/**
 * Proxy job submission and image save helpers.
 *
 * In v2.0, screenshots are captured asynchronously by the PlugUpp proxy at
 * https://screenshots.plugupp.com. This class submits jobs to the proxy and
 * provides a helper for saving the result to the local cache.
 *
 * Capture params are assembled from global free settings in submit_proxy_job()
 * and then passed through the doig_capture_params filter so the premium file
 * (class-screenshot__premium_only.php) can inject per-page and premium-global
 * overrides. The free build fires the filter but has nothing hooked into it.
 *
 * Return values from submit_proxy_job():
 *   true                                — job submitted, status set to 'pending'
 *   WP_Error( 'proxy_transient_error' ) — proxy unreachable or 5xx; status left as 'pending'
 *   WP_Error( 'quota_exceeded' )        — 429; status set to 'quota_exceeded'
 *   WP_Error( 'licence_inactive' )      — 402; status set to 'failed'
 *   WP_Error( <proxy_error_code> )      — other 4xx; status set to 'failed'
 *
 * Callers are responsible for adding to the doig_submission_retry queue when
 * submit_proxy_job() returns WP_Error( 'proxy_transient_error' ).
 */
class DOIG_Screenshot {

	/**
	 * Capture triggers the proxy recognises (2.6.2) - MUST stay in
	 * lock-step with the proxy's Queue::CAPTURE_TRIGGERS, which pins this
	 * exact list in its test suite. Sent as the optional `trigger` payload
	 * field so the service can attribute failures to the code path that
	 * caused them (the question the 2026-07-20 incident could not answer).
	 *
	 * @var string[]
	 */
	const CAPTURE_TRIGGERS = array(
		'scheduled_refresh',
		'regenerate_all',
		'regenerate_now',
		'post_update',
		'retry',
	);

	/**
	 * Proxy base URL is sourced from DOIG_License::proxy_base_url(),
	 * which honours the DOIG_PROXY_URL wp-config constant. The
	 * historical PROXY_URL class constant has been removed - it could
	 * not be a constant once the override mechanism was introduced
	 * because constants cannot be computed at runtime. All callers
	 * call DOIG_License::proxy_base_url() directly.
	 */

	/**
	 * Submits an async screenshot job to the PlugUpp proxy.
	 *
	 * Handles all proxy response codes and updates post meta accordingly.
	 * Does not manage the doig_submission_retry queue — callers (DOIG_Cron and
	 * DOIG_Post_Meta::rest_regenerate) are responsible for that on transient failure.
	 *
	 * @param string $url     The URL to screenshot.
	 * @param int    $post_id The post ID.
	 * @param int    $attempt Attempt number (1-3) — used in log messages only. Default 1.
	 * @param string $trigger Which plugin code path initiated this capture
	 *                        (2.6.2) — one of CAPTURE_TRIGGERS, or '' when the
	 *                        caller has no provenance. Sent to the proxy as the
	 *                        optional `trigger` payload field for per-trigger
	 *                        error-rate analysis; never affects processing.
	 * @return true|WP_Error
	 */
	public static function submit_proxy_job( $url, $post_id, $attempt = 1, $trigger = '' ) {
		DOIG_Logger::debug(
			sprintf( 'submit_screenshot_job ENTRY (attempt %d) for post %d, url=%s', $attempt, $post_id, $url )
		);

		// Hard bail when the plugin is not connected to the screenshot
		// server. Without an opted-in Freemius site we have no licence_key
		// and no site_id, so the proxy would reject with 422 every time.
		// Set a friendly error_code the UI surfaces as "complete setup"
		// rather than spamming the proxy with doomed requests.
		if ( ! DOIG_License::is_connected() ) {
			DOIG_Post_Meta::set( $post_id, 'status',        'failed' );
			DOIG_Post_Meta::set( $post_id, 'error_code',    'awaiting_setup' );
			DOIG_Post_Meta::set( $post_id, 'error_message', __( 'Plugin setup is incomplete. Activate the plugin on the Settings page to start generating screenshots.', 'dynamic-og-image-generator' ) );
			DOIG_Logger::info(
				sprintf( 'Post %d - submission skipped, plugin not connected (awaiting Freemius activation).', $post_id )
			);
			return new WP_Error( 'awaiting_setup', __( 'Plugin setup is incomplete.', 'dynamic-og-image-generator' ) );
		}

		// Hard bail when the WordPress Site Address is not https://.
		//
		// The screenshot service requires https URLs (so it can safely render
		// the resulting OG image without mixed-content blocks, and to prevent
		// the service being used as a generic HTTP scraper). The proxy will
		// reject any http:// submission with HTTP 422, "url must use the
		// https scheme". Catching the condition at the WordPress end gives
		// the user a clear and actionable message - "fix Settings -> General"
		// rather than the raw scheme-rejection error.
		//
		// IMPORTANT: check get_option('home') rather than home_url() because
		// home_url() uses set_url_scheme + is_ssl() to adjust the scheme to
		// match the current request. In an admin/REST request loaded over
		// https, home_url() returns https:// even when the persistent DB
		// value is http://. The cron context has no HTTP request and gets
		// the raw scheme - which is what the proxy ultimately rejects.
		// Refusing on the DB value means cron, REST, and AJAX paths behave
		// consistently: everything is blocked until the root config is
		// fixed, rather than some operations succeeding (REST) and others
		// failing (cron) on the same install.
		$home_option = (string) get_option( 'home', '' );
		$home_scheme = wp_parse_url( $home_option, PHP_URL_SCHEME );
		if ( 'https' !== $home_scheme ) {
			$message = __( 'Your WordPress site URL is configured as http:// but the screenshot service requires https://. Update WordPress Settings -> General -> Site Address to use https://, then try again.', 'dynamic-og-image-generator' );
			DOIG_Post_Meta::set( $post_id, 'status',        'failed' );
			DOIG_Post_Meta::set( $post_id, 'error_code',    'http_site_url' );
			DOIG_Post_Meta::set( $post_id, 'error_message', $message );
			DOIG_Logger::error(
				sprintf( 'Post %d - submission skipped, get_option(home) returned %s (scheme: %s). Update WordPress Settings -> General URLs to https://.',
					$post_id,
					esc_url_raw( $home_option ),
					$home_scheme ? $home_scheme : '(none)'
				)
			);
			return new WP_Error( 'http_site_url', $message );
		}

		try {
			$licence_key = self::get_licence_key();
		} catch ( \Throwable $e ) {
			DOIG_Logger::error(
				sprintf( 'submit_screenshot_job FATAL during get_licence_key: %s in %s:%d',
					$e->getMessage(), $e->getFile(), $e->getLine() )
			);
			return new WP_Error( 'doig_internal', $e->getMessage() );
		}
		$callback_url = get_rest_url( null, 'doig/v1/callback' );

		DOIG_Logger::debug(
			sprintf( 'submit_screenshot_job:licence_key length=%d, callback_url=%s',
				strlen( (string) $licence_key ), $callback_url )
		);

		// Assemble capture params from saved settings. Values use native PHP
		// types (bool/int/string). The proxy translates these to whatever
		// format the screenshot provider expects.
		$capture_params = array(
			'viewport_width'                    => DOIG_Settings::VIEWPORT_WIDTH,
			'viewport_height'                   => (int) round( DOIG_Settings::VIEWPORT_WIDTH * DOIG_Settings::OG_HEIGHT / DOIG_Settings::OG_WIDTH ),
			'dark_mode'                         => (bool) DOIG_Settings::get( 'dark_mode',                    false ),
			'block_ads'                         => (bool) DOIG_Settings::get( 'block_ads',                    true  ),
			'block_cookie_banners'              => (bool) DOIG_Settings::get( 'block_cookie_banners',         true  ),
			'block_banners_by_heuristics'       => (bool) DOIG_Settings::get( 'block_banners_by_heuristics',  false ),
			'block_trackers'                    => (bool) DOIG_Settings::get( 'block_trackers',               true  ),
			'block_chats'                       => (bool) DOIG_Settings::get( 'block_chats',                  true  ),
			'wait_until'                        => (string) DOIG_Settings::get( 'wait_until', 'load' ),
			'scroll_into_view'                  => sanitize_text_field( (string) DOIG_Settings::get( 'scroll_into_view', '' ) ),
			'error_on_selector_not_found'       => (bool) DOIG_Settings::get( 'error_on_selector_not_found',       false ),
			'error_on_click_selector_not_found' => (bool) DOIG_Settings::get( 'error_on_click_selector_not_found', false ),
			'click_before_capture'              => sanitize_text_field( (string) DOIG_Settings::get( 'click_before_capture', '' ) ),
		);

		/**
		 * Filter the assembled capture params before submission. Separately
		 * distributed builds register additional capture parameters and
		 * per-page overrides through this hook.
		 *
		 * @param array $capture_params Default capture parameters.
		 * @param int   $post_id        The post being captured.
		 */
		$capture_params = (array) apply_filters( 'doig_capture_params', $capture_params, $post_id );

		// Translate plugin-internal storage names to the names the screenshot
		// service accepts. The storage keys keep the descriptive names
		// ("click_before_capture") for clarity; the wire format uses the
		// service-native short names. The proxy's allowed-params list
		// silently drops anything not in the wire vocabulary.
		if ( array_key_exists( 'click_before_capture', $capture_params ) ) {
			$capture_params['click'] = $capture_params['click_before_capture'];
			unset( $capture_params['click_before_capture'] );
		}

		// Freemius per-install ID. Used by the proxy to track per-site
		// startup quota across all activated installs under one licence.
		// Note: the Freemius SDK uses get_site() (no underscore prefix),
		// asymmetrically with _get_license() above.
		try {
			$fs_site = function_exists( 'doig_fs' ) ? doig_fs()->get_site() : null;
		} catch ( \Throwable $e ) {
			DOIG_Logger::error(
				sprintf( 'submit_screenshot_job FATAL during doig_fs()->get_site(): %s in %s:%d',
					$e->getMessage(), $e->getFile(), $e->getLine() )
			);
			return new WP_Error( 'doig_internal', $e->getMessage() );
		}
		$site_id = ( is_object( $fs_site ) && ! empty( $fs_site->id ) ) ? (int) $fs_site->id : 0;

		DOIG_Logger::debug(
			sprintf( 'submit_screenshot_job:site_id=%d, fs_site is %s',
				$site_id,
				is_object( $fs_site ) ? 'object' : ( is_null( $fs_site ) ? 'null' : gettype( $fs_site ) ) )
		);

		/**
		 * Filter the URL the screenshot service captures for this post
		 * (2.6.0). Separately distributed builds hook this to replace the
		 * permalink with a per-post custom capture URL - e.g. a portfolio
		 * entry whose visually meaningful page is the client site it
		 * describes, not the portfolio post itself. Must return an https
		 * URL; anything else falls back to the original.
		 *
		 * @param string $url     The URL about to be captured (the permalink).
		 * @param int    $post_id The post the capture belongs to.
		 */
		$filtered_url = (string) apply_filters( 'doig_capture_url', $url, $post_id );
		if ( '' !== $filtered_url && 0 === strpos( $filtered_url, 'https://' ) ) {
			$url = $filtered_url;
		}

		// Cross-domain marker: true when the capture URL's host differs from
		// the calling site's own host (derived from the callback URL). The
		// screenshot service allows cross-domain captures for paid licences
		// only, so it needs an explicit, honest signal rather than having to
		// infer intent. Hosts are compared case-insensitively with any
		// leading www. stripped, mirroring the service's own normalisation.
		$url_host      = strtolower( (string) wp_parse_url( $url, PHP_URL_HOST ) );
		$callback_host = strtolower( (string) wp_parse_url( $callback_url, PHP_URL_HOST ) );
		$url_host      = preg_replace( '/^www\./', '', $url_host );
		$callback_host = preg_replace( '/^www\./', '', $callback_host );
		$cross_domain  = ( '' !== $url_host && $url_host !== $callback_host );

		$payload = array(
			'licence_key'    => $licence_key,
			'site_id'        => $site_id,
			'post_id'        => $post_id,
			// Capture-trigger provenance (2.6.2). Omitted when unknown - the
			// proxy stores NULL for absent or unrecognised values and never
			// fails a job over it (SPEC-PROXY.md §POST /v1/job).
			'trigger'        => in_array( $trigger, self::CAPTURE_TRIGGERS, true ) ? $trigger : null,
			// Cache-bust timestamp prevents the proxy from receiving a cached page.
			'url'            => add_query_arg( '_doig_ts', time(), $url ),
			'callback_url'   => $callback_url,
			'capture_params' => $capture_params,
			'cross_domain'   => $cross_domain,
		);

		$request_body = wp_json_encode( $payload );

		// Log the outbound payload at DEBUG level so support can see exactly what
		// was sent to the screenshot server. licence_key is replaced with its length
		// only - it is effectively a secret and never appears in logs.
		$payload_for_log              = $payload;
		$payload_for_log['licence_key'] = sprintf( '<redacted, length=%d>', strlen( (string) $licence_key ) );
		DOIG_Logger::debug(
			sprintf(
				'submit_screenshot_job:BEFORE wp_remote_post — POST %s/v1/job, attempt %d, payload: %s',
				DOIG_License::proxy_base_url(),
				$attempt,
				wp_json_encode( $payload_for_log )
			)
		);

		try {
			$response = wp_remote_post( DOIG_License::proxy_base_url() . '/v1/job', array(
				'timeout'     => 15,
				'headers'     => array( 'Content-Type' => 'application/json' ),
				'body'        => $request_body,
				'data_format' => 'body',
			) );
		} catch ( \Throwable $e ) {
			DOIG_Logger::error(
				sprintf( 'submit_screenshot_job FATAL during wp_remote_post: %s in %s:%d',
					$e->getMessage(), $e->getFile(), $e->getLine() )
			);
			return new WP_Error( 'doig_internal', $e->getMessage() );
		}

		DOIG_Logger::debug(
			sprintf( 'submit_screenshot_job:AFTER wp_remote_post — returned %s',
				is_wp_error( $response )
					? 'WP_Error: ' . $response->get_error_code() . ' / ' . $response->get_error_message()
					: ( is_array( $response )
						? 'HTTP ' . wp_remote_retrieve_response_code( $response )
						: gettype( $response ) ) )
		);

		// --- WP_Error: connection failure, DNS error, timeout ---
		if ( is_wp_error( $response ) ) {
			$wp_error_code = $response->get_error_code();
			$wp_error_msg  = $response->get_error_message();
			DOIG_Logger::info(
				sprintf(
					'Post %d — screenshot server submission failed (WP_Error %s, attempt %d/3): %s. Will retry in %ds.',
					$post_id,
					$wp_error_code,
					$attempt,
					$wp_error_msg,
					self::retry_delay( $attempt )
				)
			);
			return new WP_Error( 'proxy_transient_error', $wp_error_msg );
		}

		$http_code = (int) wp_remote_retrieve_response_code( $response );
		$body_raw  = wp_remote_retrieve_body( $response );
		$body_json = json_decode( $body_raw, true );

		// --- 202 Accepted ---
		if ( 202 === $http_code ) {
			$job_id      = isset( $body_json['job_id'] )      ? (string) $body_json['job_id']      : '';
			$hmac_secret = isset( $body_json['hmac_secret'] ) ? (string) $body_json['hmac_secret'] : '';

			DOIG_Post_Meta::set( $post_id, 'job_id', $job_id );
			DOIG_Post_Meta::set( $post_id, 'status', 'pending' );

			// Store the HMAC secret returned in every 202. The proxy intentionally
			// returns the current secret on every accepted job (not only the first
			// one) so a licence reset or rotation on the proxy side can be picked
			// up without manual intervention. Overwriting the local copy on every
			// submit keeps the plugin self-healing.
			if ( $hmac_secret ) {
				update_option( 'doig_proxy_hmac_secret', $hmac_secret, false );
			}

			DOIG_Logger::info(
				sprintf( 'Post %d — screenshot job submitted (job_id: %s).', $post_id, $job_id )
			);

			return true;
		}

		// --- 5xx: transient proxy error ---
		if ( $http_code >= 500 ) {
			DOIG_Logger::info(
				sprintf(
					'Post %d — screenshot server submission failed (HTTP %d, attempt %d/3): %s. Will retry in %ds.',
					$post_id,
					$http_code,
					$attempt,
					$body_raw ? substr( $body_raw, 0, 200 ) : '(empty body)',
					self::retry_delay( $attempt )
				)
			);
			return new WP_Error( 'proxy_transient_error', sprintf( 'HTTP %d', $http_code ) );
		}

		// --- 429: quota exceeded ---
		if ( 429 === $http_code ) {
			DOIG_Post_Meta::set( $post_id, 'status', 'quota_exceeded' );
			DOIG_Logger::info(
				sprintf(
					'Post %d — monthly screenshot quota exceeded (HTTP 429). Will resume when quota resets.',
					$post_id
				)
			);
			return new WP_Error( 'quota_exceeded', __( 'Monthly screenshot quota exceeded.', 'dynamic-og-image-generator' ) );
		}

		// --- 402: licence inactive or expired ---
		if ( 402 === $http_code ) {
			// Prefer the proxy's customer-facing message field (audited copy)
			// over the raw JSON body - storing the raw body put a JSON blob
			// into the sidebar notice (2.6.2 error-handling audit).
			$licence_msg = isset( $body_json['message'] ) && is_string( $body_json['message'] )
				? $body_json['message']
				: ( $body_raw ? substr( $body_raw, 0, 500 ) : '' );
			DOIG_Post_Meta::set( $post_id, 'status',        'failed' );
			DOIG_Post_Meta::set( $post_id, 'error_code',    'licence_inactive' );
			DOIG_Post_Meta::set( $post_id, 'error_message', $licence_msg );
			DOIG_Logger::error(
				sprintf(
					'Post %d — screenshot server rejected job (HTTP 402 licence inactive): %s',
					$post_id,
					$body_raw ? substr( $body_raw, 0, 500 ) : '(empty body)'
				)
			);
			return new WP_Error( 'licence_inactive', __( 'PlugUpp licence is inactive or expired.', 'dynamic-og-image-generator' ) );
		}

		// --- Other 4xx: definitive rejection — proxy will not accept without user action ---
		// Extract error code from JSON body; fall back to a generic slug.
		$proxy_error_code = isset( $body_json['error'] )   ? sanitize_key( (string) $body_json['error'] ) : 'proxy_error';
		$proxy_error_msg  = isset( $body_json['message'] ) ? (string) $body_json['message']               : sprintf( 'HTTP %d', $http_code );

		DOIG_Post_Meta::set( $post_id, 'status',        'failed' );
		DOIG_Post_Meta::set( $post_id, 'error_code',    $proxy_error_code );
		DOIG_Post_Meta::set( $post_id, 'error_message', $proxy_error_msg );

		// Log the full raw body — it typically contains validation detail useful for debugging.
		DOIG_Logger::error(
			sprintf(
				'Post %d — screenshot server rejected job (HTTP %d): %s',
				$post_id,
				$http_code,
				$body_raw ? substr( $body_raw, 0, 500 ) : '(empty body)'
			)
		);

		return new WP_Error( $proxy_error_code, $proxy_error_msg );
	}

	/**
	 * Downloads a completed screenshot from the proxy and saves it to the local cache.
	 *
	 * Called by DOIG_Proxy_Callback (on callback receipt) and by DOIG_Cron (on polling
	 * pass when a job is found completed). Fires the doig_screenshot_saved action on
	 * success so class-media-library__premium_only.php can handle Media Library saving.
	 *
	 * @param string $image_url Temporary download URL from the proxy.
	 * @param int    $post_id
	 * @return string|WP_Error Absolute path to saved file, or WP_Error on failure.
	 */
	public static function save_proxy_image( $image_url, $post_id ) {
		$file_path = DOIG_Cache::get_file_path( $post_id );

		// Ensure the cache directory exists before streaming.
		// wp_remote_get() with stream=>true fopen()s the destination file before sending
		// the HTTP request — a missing directory causes WP_Error with no request ever sent.
		DOIG_Cache::create_directory();

		DOIG_Logger::debug(
			sprintf( 'Downloading screenshot from server — %s', $image_url ), array( 'post_id' => $post_id ) );

		$response = wp_remote_get( $image_url, array(
			'timeout'  => DOIG_Settings::API_TIMEOUT_OVERHEAD,
			'stream'   => true,
			'filename' => $file_path,
		) );

		if ( is_wp_error( $response ) ) {
			DOIG_Logger::error(
				'Image download failed — ' . $response->get_error_message(), array( 'post_id' => $post_id ) );
			return $response;
		}

		$code = (int) wp_remote_retrieve_response_code( $response );

		if ( 200 !== $code ) {
			$body = file_exists( $file_path ) ? file_get_contents( $file_path ) : '';
			wp_delete_file( $file_path );
			DOIG_Logger::error(
				sprintf(
					'Image download failed — HTTP %d: %s',
					$code,
					$body ? substr( $body, 0, 200 ) : '(empty body)'
				), array( 'post_id' => $post_id ) );
			return new WP_Error( 'doig_download_error', sprintf( 'HTTP %d', $code ) );
		}

		// Fire the saved action so class-media-library__premium_only.php can handle
		// Media Library saving. No-op in the free build — nothing hooks this action.
		do_action( 'doig_screenshot_saved', $file_path, $post_id );

		return $file_path;
	}

	// -------------------------------------------------------------------------
	// Private helpers
	// -------------------------------------------------------------------------

	/**
	 * Returns the Freemius licence key to include in proxy job submissions and polls.
	 *
	 * For premium installs: the Freemius licence secret key.
	 * For free installs (no active licence): the Freemius site secret key, which is
	 * a per-install unique identifier used by the proxy for free-tier quota tracking.
	 *
	 * Public so DOIG_Cron (polling pass) and DOIG_Proxy_Callback can share
	 * this logic without duplicating Freemius API calls.
	 *
	 * @return string
	 */
	public static function get_licence_key() {
		// Freemius WordPress SDK is inconsistent in its naming:
		//   - Licence accessor is _get_license() (underscore prefixed).
		//   - Site/install accessor is get_site() (no underscore).
		// Verified against vendor/freemius/includes/class-freemius.php:
		// _get_license() declared near line 12678, get_site() near 10820.
		$license = doig_fs()->_get_license();
		if ( is_object( $license ) && ! empty( $license->secret_key ) ) {
			return (string) $license->secret_key;
		}
		// Free tier: use the per-install Freemius site secret key as identifier.
		$site = doig_fs()->get_site();
		return ( is_object( $site ) && ! empty( $site->secret_key ) ) ? (string) $site->secret_key : '';
	}

	/**
	 * Returns the retry backoff delay in seconds for the given attempt number.
	 * Attempt 1 → 30s, Attempt 2 → 120s, Attempt 3 → 300s.
	 *
	 * @param int $attempt 1, 2, or 3.
	 * @return int
	 */
	private static function retry_delay( $attempt ) {
		$delays = array( 1 => 30, 2 => 120, 3 => 300 );
		return isset( $delays[ $attempt ] ) ? $delays[ $attempt ] : 30;
	}
}
