<?php
/**
 * Shared hardening for long-running admin-ajax JSON responses.
 *
 * Sites with an external object cache (Redis / Memcached / LiteSpeed) store
 * WordPress transients only in memory — never the database. Flushing or
 * evicting those keys mid-job makes Backup and AI Alt Text look "broken"
 * while the same code works on hosts without object cache.
 *
 * Durable stepped-job state therefore uses non-autoloaded options (DB-backed).
 *
 * @package WP_PowerSuite\Core
 * @since 2.5.0
 */

namespace WP_PowerSuite\Core;

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

/**
 * @since 2.5.0
 */
final class LongAjaxResponse {

	/** @var list<string> */
	private const DEFAULT_ACTIONS = [
		'wpps_br_backup_step',
		'wpps_br_restore_tick',
		'wpps_br_restore_step',
		'wpps_ai_alt_bulk_start',
		'wpps_ai_alt_bulk_step',
		'wpps_ai_alt_bulk_status',
		'wpps_ai_alt_bulk_cancel',
		'wpps_ai_alt_bulk_retry_failed',
		'wpps_ai_alt_bulk_preview',
	];

	/**
	 * Disable zlib early for known long AJAX actions (plugins_loaded priority 1).
	 */
	public static function bootEarly(): void {
		if ( ! defined( 'DOING_AJAX' ) || ! DOING_AJAX ) {
			return;
		}

		$action = isset( $_REQUEST['action'] ) ? sanitize_key( wp_unslash( (string) $_REQUEST['action'] ) ) : '';
		if ( $action === '' || ! in_array( $action, self::actions(), true ) ) {
			return;
		}

		self::disableCompression();
	}

	/**
	 * Full response prep: compression off, no-cache headers, clean output buffers.
	 */
	public static function prepare(): void {
		self::disableCompression();
		self::sendNoCacheHeaders();
		self::discardBuffers();
	}

	/**
	 * @return list<string>
	 */
	public static function actions(): array {
		/**
		 * Filter long admin-ajax actions that need cache/compression hardening.
		 *
		 * @param list<string> $actions Action names.
		 */
		$actions = apply_filters( 'wp_powersuite_long_ajax_actions', self::DEFAULT_ACTIONS );

		return array_values(
			array_unique(
				array_filter(
					array_map( 'sanitize_key', is_array( $actions ) ? $actions : [] )
				)
			)
		);
	}

	public static function disableCompression(): void {
		if ( function_exists( 'ini_set' ) ) {
			// phpcs:ignore WordPress.PHP.IniSet.Risking -- Keeps admin-ajax JSON intact on compressed hosts.
			@ini_set( 'zlib.output_compression', '0' );
		}
		if ( function_exists( 'apache_setenv' ) ) {
			// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
			@apache_setenv( 'no-gzip', '1' );
		}
	}

	public static function sendNoCacheHeaders(): void {
		if ( ! defined( 'DONOTCACHEPAGE' ) ) {
			define( 'DONOTCACHEPAGE', true );
		}
		if ( ! defined( 'DONOTCACHEOBJECT' ) ) {
			define( 'DONOTCACHEOBJECT', true );
		}
		if ( ! defined( 'LSCACHE_NO_CACHE' ) ) {
			define( 'LSCACHE_NO_CACHE', true );
		}
		if ( ! defined( 'LITESPEED_NO_CACHE' ) ) {
			define( 'LITESPEED_NO_CACHE', true );
		}

		if ( function_exists( 'nocache_headers' ) ) {
			nocache_headers();
		}

		if ( function_exists( 'header' ) && ! headers_sent() ) {
			header( 'X-Accel-Buffering: no' );
			header( 'Content-Encoding: none' );
			header( 'Cache-Control: no-store, no-cache, must-revalidate, max-age=0' );
			header( 'Pragma: no-cache' );
			header( 'X-LiteSpeed-Cache-Control: no-cache' );
		}
	}

	public static function discardBuffers(): void {
		while ( ob_get_level() > 0 ) {
			// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.CodeAnalysis.AssignmentInCondition.FoundInWhileCondition
			if ( ! @ob_end_clean() ) {
				break;
			}
		}
	}

	/**
	 * Persist stepped-job state in a non-autoloaded option (DB-backed).
	 *
	 * Do not use transients for multi-request jobs when an external object cache
	 * is present — those never hit the database and are lost on eviction.
	 *
	 * @param mixed $value
	 */
	public static function setJobState( string $key, $value, int $expiration ): bool {
		$key = self::normalizeKey( $key );
		if ( $key === '' ) {
			return false;
		}

		$option  = self::optionName( $key );
		$payload = [
			'expires' => time() + max( 60, $expiration ),
			'data'    => $value,
		];

		// Bust before read/write so external object caches cannot serve a stale miss/hit.
		self::bustOptionCache( $option );

		$existing = get_option( $option, null );
		if ( null === $existing || false === $existing ) {
			$added = add_option( $option, $payload, '', 'no' );
			if ( ! $added ) {
				update_option( $option, $payload, false );
			}
		} else {
			update_option( $option, $payload, false );
		}

		self::bustOptionCache( $option );

		// Remove any legacy transient copy so object-cache hosts cannot revive stale state.
		delete_transient( $key );
		self::bustTransientCache( $key );

		return true;
	}

	/**
	 * @return mixed|false False when missing or expired.
	 */
	public static function getJobState( string $key ) {
		$key = self::normalizeKey( $key );
		if ( $key === '' ) {
			return false;
		}

		$option = self::optionName( $key );
		self::bustOptionCache( $option );

		$payload = get_option( $option, false );
		if ( is_array( $payload ) && array_key_exists( 'data', $payload ) ) {
			$expires = (int) ( $payload['expires'] ?? 0 );
			if ( $expires > 0 && $expires < time() ) {
				self::deleteJobState( $key );
				return false;
			}

			return $payload['data'];
		}

		// Backward compatible: migrate a surviving transient once.
		$legacy = get_transient( $key );
		if ( false !== $legacy && null !== $legacy ) {
			self::setJobState( $key, $legacy, DAY_IN_SECONDS );
			return $legacy;
		}

		return false;
	}

	public static function deleteJobState( string $key ): bool {
		$key = self::normalizeKey( $key );
		if ( $key === '' ) {
			return false;
		}

		$option = self::optionName( $key );
		delete_option( $option );
		self::bustOptionCache( $option );
		delete_transient( $key );
		self::bustTransientCache( $key );

		return true;
	}

	/**
	 * @deprecated 2.5.1 Use setJobState(). Kept as alias for callers already deployed.
	 * @param mixed $value
	 */
	public static function setTransient( string $key, $value, int $expiration ): bool {
		return self::setJobState( $key, $value, $expiration );
	}

	/**
	 * @deprecated 2.5.1 Use getJobState().
	 * @return mixed|false
	 */
	public static function getTransient( string $key ) {
		return self::getJobState( $key );
	}

	/**
	 * @deprecated 2.5.1 Use deleteJobState().
	 */
	public static function deleteTransient( string $key ): bool {
		return self::deleteJobState( $key );
	}

	/**
	 * @deprecated 2.5.1
	 */
	public static function flushTransientCache( string $key ): void {
		self::bustTransientCache( $key );
	}

	private static function normalizeKey( string $key ): string {
		return preg_replace( '/[^a-zA-Z0-9_\-:]/', '', $key ) ?? '';
	}

	private static function optionName( string $key ): string {
		// Keep under the 191-char options index limit on older MySQL.
		$name = 'wpps_job_' . $key;
		if ( strlen( $name ) <= 180 ) {
			return $name;
		}

		return 'wpps_job_' . md5( $key );
	}

	private static function bustOptionCache( string $option ): void {
		wp_cache_delete( $option, 'options' );
		wp_cache_delete( 'notoptions', 'options' );
		wp_cache_delete( 'alloptions', 'options' );
	}

	private static function bustTransientCache( string $key ): void {
		if ( $key === '' ) {
			return;
		}

		wp_cache_delete( $key, 'transient' );
		wp_cache_delete( '_transient_' . $key, 'options' );
		wp_cache_delete( '_transient_timeout_' . $key, 'options' );
	}
}
