<?php
/**
 * BaseCloud Boost — Settings API
 *
 * @package BaseCloud\Boost\Admin
 */

declare( strict_types=1 );

namespace BaseCloud\Boost\Admin;

use BaseCloud\Boost\Core\Crypto;

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

/**
 * Handles sanitization and persistence of plugin settings.
 */
class Settings_API {

	/**
	 * Option keys holding secrets. These are encrypted at rest and masked in the UI.
	 *
	 * @var string[]
	 */
	public const SENSITIVE_KEYS = [
		'cloudflare_api_token',
		'bunnycdn_api_key',
		'webhook_secret',
		'lighthouse_webhook_secret',
		'lighthouse_psi_api_key',
		'fleet_ops_key',
	];

	/**
	 * Whether an option key holds a secret.
	 *
	 * @param string $key Option key.
	 * @return bool
	 */
	public static function is_sensitive( string $key ): bool {
		return in_array( $key, self::SENSITIVE_KEYS, true );
	}

	/**
	 * Save settings from a form POST submission.
	 *
	 * @param array $post_data Raw $_POST data.
	 * @return void
	 */
	public static function save_settings( array $post_data ): void {
		$defaults = self::get_defaults();

		// Load existing saved options so fields from other tabs are preserved.
		// Read them straight from the database: on persistent-object-cache hosts
		// the cached copy can be stale (see persist() below), and merging stale
		// other-tab values into this save would persist the staleness to the DB.
		wp_cache_delete( BCBOOST_OPTIONS_KEY, 'options' );
		wp_cache_delete( 'alloptions', 'options' );
		$existing = (array) get_option( BCBOOST_OPTIONS_KEY, [] );

		// Each tab form sends a hidden BCBOOST_tab_checkboxes field listing only
		// the checkbox names present on that tab. Unchecked boxes on other tabs
		// will NOT be in POST, so we must not treat their absence as "false".
		$tab_checkboxes = [];
		if ( ! empty( $post_data['BCBOOST_tab_checkboxes'] ) ) {
			$tab_checkboxes = array_map( 'trim', explode( ',', (string) $post_data['BCBOOST_tab_checkboxes'] ) );
		}

		$saved = [];

		foreach ( $defaults as $key => $default ) {
			if ( isset( $post_data[ $key ] ) ) {
				// Secret fields render blank (masked). A blank submission means
				// "keep the saved secret", never "erase it".
				if ( self::is_sensitive( $key ) && trim( (string) $post_data[ $key ] ) === '' ) {
					$saved[ $key ] = array_key_exists( $key, $existing ) ? $existing[ $key ] : $default;
					continue;
				}
				// Field was explicitly submitted — sanitize and save.
				$saved[ $key ] = self::sanitize_option( $key, $post_data[ $key ] );
			} elseif ( is_bool( $default ) && in_array( $key, $tab_checkboxes, true ) ) {
				// Checkbox was on this tab's form but absent from POST = unchecked.
				$saved[ $key ] = false;
			} else {
				// Field is from a different tab — keep whatever is already saved.
				$saved[ $key ] = array_key_exists( $key, $existing ) ? $existing[ $key ] : $default;
			}
		}

		self::persist( $saved );

		// Keep the automatic database-cleanup cron in sync with its setting.
		\BaseCloud\Boost\Database\DB_Optimizer::reschedule();
	}

	/**
	 * Persist the full options array — the ONLY correct way to write
	 * BCBOOST_OPTIONS_KEY. Every writer (save, preset, import, CLI, migration)
	 * must go through here.
	 *
	 * Why the cache hygiene: on hosts with a persistent object cache (Cloudways
	 * Redis), any concurrent request that updates an AUTOLOADED option while
	 * holding a stale copy of the `alloptions` blob writes that stale blob back
	 * to the cache (WordPress trac #31245). Boost's own preload chain makes this
	 * hot: every chain link reschedules its watchdog (`cron` is autoloaded) and
	 * every preloaded front-end render can trigger third-party autoloaded
	 * writes. A settings save landing mid-chain then persists to the DB but the
	 * object cache keeps serving the OLD value — which reads exactly like "the
	 * save didn't stick".
	 *
	 * Defence in depth:
	 * 1. autoload=no — removes bcboost_options from the alloptions blob
	 *    entirely, so no alloptions race can ever clobber it again. (Anonymous
	 *    cache hits never pay for this: advanced-cache.php reads the JSON
	 *    drop-in cache before WordPress loads. Dynamic requests pay one extra
	 *    primary-key lookup, memoised per request.)
	 * 2. Explicit cache deletes — heals installs where the option row is still
	 *    autoload=yes (WP only flips the autoload column when the value changes)
	 *    and evicts any already-stale alloptions blob, so the very next read
	 *    rebuilds from the database. NEVER wp_cache_flush() — one blob, not the
	 *    site's shared object cache.
	 *
	 * @param array $options Full options array to store.
	 * @return void
	 */
	public static function persist( array $options ): void {
		update_option( BCBOOST_OPTIONS_KEY, $options, false );

		wp_cache_delete( BCBOOST_OPTIONS_KEY, 'options' );
		wp_cache_delete( 'alloptions', 'options' );
		wp_cache_delete( 'notoptions', 'options' );

		// Write the options cache so advanced-cache.php can read them without a DB hit.
		self::write_options_cache( $options );
	}

	/**
	 * Write a JSON options cache file for the advanced-cache.php drop-in.
	 *
	 * @param array|null $options Options to write. If null, reads from DB.
	 * @return void
	 */
	public static function write_options_cache( ?array $options = null ): void {
		if ( null === $options ) {
			$options = (array) get_option( BCBOOST_OPTIONS_KEY, [] );
		}

		$cache_file = BCBOOST_CACHE_DIR . 'bcboost-options.json';

		if ( ! is_dir( BCBOOST_CACHE_DIR ) ) {
			wp_mkdir_p( BCBOOST_CACHE_DIR );
		}

		// Only persist the keys that advanced-cache.php actually needs.
		$drop_in_keys = [
			'cache_enabled',
			'cache_lifetime',
			'cache_mobile_separate',
			'cache_logged_in',
			'cache_query_strings',
			'cache_vary_by_country',
			'vary_cookies',
			'exclude_urls',
			'exclude_cookies',
			'cdn_enabled',
			'cdn_browser_ttl',
			'cdn_edge_ttl',
			'bunnycdn_enabled',
			'bunnycdn_pull_zone_id',
			'bunnycdn_hostname',
			// Note: bunnycdn_api_key is intentionally omitted — it is encrypted at
			// rest and is not used by the advanced-cache.php drop-in.
		];

		$cache_data = [];
		foreach ( $drop_in_keys as $key ) {
			if ( array_key_exists( $key, $options ) ) {
				$cache_data[ $key ] = $options[ $key ];
			}
		}

		// Atomic write: avoids LOCK_EX which fails on NFS-backed volumes (CloudWays).
		$tmp = $cache_file . '.tmp.' . getmypid();
		if ( file_put_contents( $tmp, wp_json_encode( $cache_data ) ) !== false ) {
			rename( $tmp, $cache_file );
		} elseif ( file_exists( $tmp ) ) {
			@unlink( $tmp );
		}
	}

	/**
	 * The recommended "Optimize for PageSpeed" profile.
	 *
	 * A high-impact set of optimisations that most sites benefit from out of the box.
	 * Includes the render-blocking-CSS levers (combine + async/lazy CSS) and the
	 * order-safe Delay JS engine — the biggest mobile FCP/TBT wins. Still omits the
	 * per-host / credential switches (critical CSS, self-hosted fonts, CDN, Varnish).
	 * Async CSS can briefly flash unstyled content on themes that don't inline their
	 * above-the-fold CSS, so test the homepage after applying. Credentials and other
	 * tabs' settings are never touched — only these keys are overridden.
	 *
	 * @return array<string, bool>
	 */
	public static function performance_preset(): array {
		return [
			'cache_enabled'        => true,
			'minify_html'          => true,
			'minify_css'           => true,
			'combine_css'          => true,
			'lazy_css'             => true,
			'minify_js'            => true,
			'combine_js'           => true,
			'defer_js'             => true,
			'delay_js'             => true,
			'lazy_load_images'     => true,
			'lazy_load_iframes'    => true,
			'lazy_load_videos'     => true,
			'optimize_images'      => true,
			'serve_webp'           => true,
			'gzip_compression'     => true,
			'browser_cache'        => true,
			'heartbeat_control'    => true,
			'disable_emojis'       => true,
			'disable_embeds'       => true,
			// Deliberately forced OFF by the preset: stripping the ?ver= busting
			// token freezes stale CSS/JS in visitors' browsers and has zero
			// PageSpeed benefit (query strings have not been scored for years).
			'remove_query_strings' => false,
			'resource_hints'       => true,
			'preload_links'        => true,
			'preload_on_purge'     => true,
		];
	}

	/**
	 * Apply the recommended PageSpeed profile, preserving every other saved value
	 * (credentials, CDN, schedules, exclusions, and so on).
	 *
	 * @return void
	 */
	public static function apply_performance_preset(): void {
		// Fresh-from-DB read — see persist() for why the cached copy can be stale.
		wp_cache_delete( BCBOOST_OPTIONS_KEY, 'options' );
		wp_cache_delete( 'alloptions', 'options' );
		$existing = (array) get_option( BCBOOST_OPTIONS_KEY, [] );

		// defaults < existing user values < preset overrides.
		$merged = array_merge( self::get_defaults(), $existing );
		foreach ( self::performance_preset() as $key => $value ) {
			$merged[ $key ] = (bool) $value;
		}

		self::persist( $merged );
		\BaseCloud\Boost\Database\DB_Optimizer::reschedule();
	}

	/**
	 * Get all default option values.
	 *
	 * @return array<string, mixed>
	 */
	public static function get_defaults(): array {
		$config_file = BCBOOST_DIR . 'bcboost-config.php';
		if ( file_exists( $config_file ) ) {
			return (array) require $config_file;
		}

		// Fallback defaults (should never be needed if bcboost-config.php exists).
		return [
			'cache_enabled'         => true,
			'cache_lifetime'        => 604800, // 7 days — event-driven purging handles freshness (see bcboost-config.php)
			'cache_mobile_separate' => false,
			'cache_logged_in'       => false,
			'cache_query_strings'   => false,
			'cache_vary_by_country' => false,
			'vary_cookies'          => '',
			'exclude_urls'          => '',
			'exclude_cookies'       => '',
			'minify_html'           => true,
			'minify_css'            => true,
			'combine_css'           => false,
			'minify_js'             => true,
			'minify_inline_js'      => false,
			'combine_js'            => false,
			'defer_js'              => true,
			'delay_js'              => true,
			'delay_js_excludes'     => '',
			'exclude_js'            => '',
			'exclude_css'           => '',
			'lazy_load_images'      => true,
			'lazy_load_iframes'     => true,
			'lazy_load_videos'      => true,
			'lazy_exclude'          => '',
			'lazy_skip_count'       => 2,
			'auto_sizes'            => true,
			'self_host_fonts'       => false,
			'serve_webp'            => true,
			'serve_avif'            => false,
			'serve_webm'            => true,
			'video_facade'          => false,
			'optimize_images'       => true,
			'preload_on_purge'      => true,
			'preload_interval'      => 'daily',
			'preload_links'         => true,
			'prefetch_dns'          => '',
			'preload_fonts'         => '',
			'preload_key_requests'  => '',
			'preload_lcp_image'     => true,
			'critical_css'          => '',
			'critical_css_auto'     => false,
			'critical_css_remote_endpoint' => '',
			'elementor_speed'            => false,
			'elementor_inline_post_css'  => true,
			'elementor_drop_unused_icons' => true,
			'elementor_settings_advisor' => true,
			'elementor_dom_warnings'     => true,
			'gzip_compression'      => true,
			'browser_cache'         => true,
			'heartbeat_control'     => true,
			'heartbeat_interval'    => 60,
			'disable_emojis'        => true,
			'disable_embeds'        => true,
			'disable_dashicons'     => false,
			'remove_query_strings'  => false,
			'limit_post_revisions'  => 5,
			'db_cleanup_schedule'   => 'off',
			'critical_css_enabled'  => false,
			'disable_xmlrpc'        => true,
			'resource_hints'        => true,
			'auto_preconnect'       => true,
			'admin_theme'           => 'dark',
			'admin_color_primary'   => '#4ade80',
			'admin_color_secondary' => '#8b5cf6',
			'admin_color_info'      => '#3b82f6',
			'admin_color_highlight' => '#22d3ee',
			'clean_head'            => true,
			'varnish_enabled'            => false,
			'varnish_servers'            => '',
			'varnish_purge_method'       => 'ban',
			'webhook_enabled'            => false,
			'webhook_url'                => '',
			'webhook_on_purge'           => false,
			'webhook_secret'             => '',
			'cdn_enabled'                => false,
			'cloudflare_zone_id'         => '',
			'cloudflare_api_token'       => '',
			'cdn_browser_ttl'            => 3600,
			'cdn_edge_ttl'               => 86400,
			'bunnycdn_enabled'           => false,
			'bunnycdn_api_key'           => '',
			'bunnycdn_pull_zone_id'      => '',
			'webhook_schedule'           => 'daily',
			'lighthouse_webhook_enabled' => false,
			'lighthouse_webhook_url'     => '',
			'lighthouse_webhook_secret'  => '',
			'lighthouse_psi_api_key'     => '',
			'lighthouse_default_url'     => '',
			'lighthouse_schedule'        => 'daily',
			'fleet_ops_enabled'          => false,
			'fleet_ops_strict'           => false,
			'fleet_ops_key'              => '',
		];
	}

	/**
	 * Sanitize a single option value.
	 *
	 * @param string $key   Option key.
	 * @param mixed  $value Raw value.
	 * @return mixed Sanitized value.
	 */
	public static function sanitize_option( string $key, $value ) {
		// Secrets are sanitised then encrypted at rest. Already-encrypted values
		// (e.g. re-importing an export) are stored verbatim, never double-encrypted.
		if ( self::is_sensitive( $key ) ) {
			$str = (string) $value;
			if ( $str === '' ) {
				return '';
			}
			if ( Crypto::is_encrypted( $str ) ) {
				return $str;
			}
			return Crypto::encrypt( sanitize_text_field( $str ) );
		}

		$booleans = [
			'cache_enabled', 'cache_mobile_separate', 'cache_logged_in',
			'cache_query_strings', 'cache_warm_on_purge', 'cache_vary_by_country',
			'minify_html', 'minify_css', 'combine_css',
			'minify_js', 'minify_inline_js', 'combine_js', 'defer_js', 'delay_js', 'delay_js_safe_mode', 'lazy_css',
			'lazy_load_images', 'lazy_load_iframes', 'lazy_load_videos', 'auto_sizes',
			'serve_webp', 'serve_avif', 'optimize_images', 'compress_images',
			'video_facade', 'serve_webm',
			'google_fonts_optimize', 'google_fonts_async', 'self_host_fonts',
			'preload_on_purge', 'preload_links', 'preload_lcp_image',
			'gzip_compression', 'browser_cache', 'stale_while_revalidate',
			'heartbeat_control', 'disable_emojis', 'disable_embeds',
			'disable_dashicons', 'remove_query_strings', 'critical_css_enabled', 'critical_css_auto',
			'elementor_speed', 'elementor_inline_post_css', 'elementor_drop_unused_icons',
			'elementor_settings_advisor', 'elementor_dom_warnings',
			'disable_xmlrpc', 'resource_hints', 'auto_preconnect', 'clean_head',
			'security_headers', 'hsts_preload',
			'webhook_enabled', 'webhook_on_purge',
			'varnish_enabled',
			'cdn_enabled', 'bunnycdn_enabled',
			'lighthouse_webhook_enabled',
			'fleet_ops_enabled', 'fleet_ops_strict',
		];

		$integers = [ 'cache_lifetime', 'heartbeat_interval', 'limit_post_revisions', 'hsts_max_age', 'cdn_browser_ttl', 'cdn_edge_ttl', 'compression_quality' ];

		if ( $key === 'varnish_purge_method' ) {
			$val = sanitize_key( (string) $value );
			return in_array( $val, [ 'ban', 'purge' ], true ) ? $val : 'ban';
		}

		if ( $key === 'varnish_mode' ) {
			$val = sanitize_key( (string) $value );
			return in_array( $val, [ 'self', 'cloudways' ], true ) ? $val : 'self';
		}

		if ( $key === 'db_cleanup_schedule' ) {
			$val = sanitize_key( (string) $value );
			return in_array( $val, [ 'off', 'daily', 'weekly' ], true ) ? $val : 'off';
		}

		if ( $key === 'admin_theme' ) {
			// normalize_theme() also migrates retired values ('goop', 'minimal') to 'dark'.
			return \BaseCloud\Boost\Admin\Admin_Controller::normalize_theme( sanitize_key( (string) $value ) );
		}

		if ( strpos( $key, 'admin_color_' ) === 0 ) {
			// Cosmetic admin accents. Invalid input falls back to the shipped
			// default so the UI can never end up with an unusable colour.
			$hex = sanitize_hex_color( (string) $value );
			if ( is_string( $hex ) && '' !== $hex ) {
				return $hex;
			}
			$defaults = self::get_defaults();
			return (string) ( $defaults[ $key ] ?? '#4ade80' );
		}

		if ( $key === 'lazy_skip_count' ) {
			// Leading images kept eager. Clamp 0–10 so a fat value can't keep the
			// whole page eager (which would defeat lazy-loading entirely).
			return min( 10, max( 0, (int) $value ) );
		}

		$texts = [
			'vary_cookies', 'varnish_servers', 'lazy_exclude',
			'exclude_urls', 'exclude_cookies', 'exclude_js', 'exclude_css',
			'delay_js_excludes', 'prefetch_dns', 'preload_fonts',
			'preload_key_requests', 'critical_css',
			'cloudflare_zone_id', 'cloudflare_api_token',
			'bunnycdn_api_key', 'bunnycdn_pull_zone_id', 'bunnycdn_hostname',
			'lighthouse_psi_api_key',
		];

		$urls = [];

		$strings = [
			'preload_interval',
			'webhook_secret',
			'lighthouse_webhook_secret',
		];

		$schedules = [ 'webhook_schedule', 'lighthouse_schedule' ];
		if ( in_array( $key, $schedules, true ) ) {
			$allowed = [ 'hourly', 'twicedaily', 'daily', 'weekly' ];
			$val     = sanitize_key( (string) $value );
			return in_array( $val, $allowed, true ) ? $val : 'daily';
		}

		if ( $key === 'speculation_rules' ) {
			$val = sanitize_key( (string) $value );
			return in_array( $val, [ 'off', 'prefetch', 'prerender' ], true ) ? $val : 'prefetch';
		}

		if ( $key === 'speculation_eagerness' ) {
			$val = sanitize_key( (string) $value );
			return in_array( $val, [ 'conservative', 'moderate', 'eager' ], true ) ? $val : 'moderate';
		}

		$webhook_urls = [ 'webhook_url', 'lighthouse_webhook_url', 'lighthouse_default_url', 'critical_css_remote_endpoint' ];
		if ( in_array( $key, $webhook_urls, true ) ) {
			return esc_url_raw( (string) $value );
		}

		if ( in_array( $key, $booleans, true ) ) {
			return (bool) $value;
		}

		if ( in_array( $key, $integers, true ) ) {
			return max( 0, (int) $value );
		}

		if ( $key === 'critical_css' ) {
			// CSS-safe: strip HTML tags but keep url-encoded sequences (%3C…) and CSS
			// punctuation. sanitize_textarea_field() deletes every %xx octet, which
			// silently corrupts url() values (e.g. inline SVG data URIs) in critical CSS.
			return wp_strip_all_tags( (string) $value );
		}

		if ( in_array( $key, $texts, true ) ) {
			return sanitize_textarea_field( (string) $value );
		}

		if ( in_array( $key, $urls, true ) ) {
			return esc_url_raw( (string) $value );
		}

		if ( in_array( $key, $strings, true ) ) {
			return sanitize_text_field( (string) $value );
		}

		return sanitize_text_field( (string) $value );
	}
}
