<?php

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

/**
 * Manages plugin settings stored in wp_options.
 */
class DOIG_Settings {

	// -------------------------------------------------------------------------
	// Constants — single source of truth for all magic numbers in this class
	// -------------------------------------------------------------------------

	/**
	 * Browser capture viewport (px). Used as the dimensions of the headless
	 * browser the screenshot service spins up. Output is always resized to
	 * the OG_WIDTH x OG_HEIGHT standard regardless of capture viewport, so
	 * this dimension trades off rendering fidelity against capture cost.
	 *
	 * VIEWPORT_HEIGHT is documentary only. Code that needs the value
	 * derives it from VIEWPORT_WIDTH * OG_HEIGHT / OG_WIDTH so it stays
	 * proportional to the output if OG_WIDTH or OG_HEIGHT ever change.
	 */
	const VIEWPORT_WIDTH  = 1440;
	const VIEWPORT_HEIGHT = 756; // reference only — see note above

	/**
	 * OG image output dimensions (px).
	 * The proxy resizes every capture to these. The 40:21 ratio is the
	 * Open Graph standard used by Facebook, X, LinkedIn, etc.
	 */
	const OG_WIDTH  = 1200;
	const OG_HEIGHT = 630;

	/**
	 * HTTP timeout for proxy job submission requests (seconds).
	 *
	 * Used as the wp_remote_post() timeout when submitting a job to the proxy.
	 * The proxy accepts the job and responds immediately — the actual screenshot
	 * is delivered asynchronously via callback or polling.
	 */
	const API_TIMEOUT_OVERHEAD = 45;

	/**
	 * Field bounds for sanitisation.
	 */
	const MIN_IMAGE_QUALITY       = 1;
	const MAX_IMAGE_QUALITY       = 100;
	const MIN_REFRESH_DAYS        = 1;

	/**
	 * Default values for settings that appear as bare numbers in defaults().
	 */
	const DEFAULT_IMAGE_QUALITY     = 80;   // WebP compression — good quality/size balance
	const DEFAULT_REFRESH_INTERVAL  = 30;   // days — aligns with the monthly billing cycle

	/** @var array Cached settings array */
	private static $settings = null;

	/**
	 * Hook into WordPress — nothing needed at init for settings,
	 * but kept for consistency.
	 */
	public static function init() {
		// Intentionally empty — settings are loaded on first get() call.
	}

	/**
	 * Returns the default settings array.
	 *
	 * @return array
	 */
	public static function defaults() {
		return array(
			// Global behaviour.
			//
			// 2.4.0 (Q2 Option C re-lock 2026-06-23): Source is the
			// single customer-facing axis per content type. enabled_X
			// is DERIVED from Source ('any non-Off Source = enabled')
			// and kept in storage as back-compat for readers (cron
			// loops, sidebar gates, bulk-action handlers) that still
			// expect the boolean. The defaults below must agree with
			// the Source defaults so fresh installs don't reach a
			// state where the UI and cron disagree.
			//
			// 2.4.0 (Q1 amendment 2026-06-23): all sitewide Source
			// defaults are OFF on a fresh install. The earlier Q1
			// recommendation (Posts default to featured-with-fallback,
			// Pages default to auto-screenshot) was set aside when
			// Steve flagged that it silently overrode the long-standing
			// "no quota burn without explicit consent" principle that
			// has governed the plugin since 2.2.0 (preserved in the
			// pre-2.4.0 defaults() comment: "Both default FALSE on a
			// fresh install so we never burn a customer's quota before
			// they have consciously opted in").
			//
			// New customers' first interaction with DOIG must be opt-in
			// on the Settings page: they pick a Source for each content
			// type, save, and DOIG starts working. Sites without
			// curated Featured Images would otherwise burn screenshots
			// on every Post they publish before the customer realised
			// what was happening.
			//
			// Existing 2.2.x customers' state is preserved via the
			// migration in get_all() below. Their saved enabled_X bool
			// maps to source_X correctly (true -> screenshot, false ->
			// off) so neither the source dropdown nor the cron behaviour
			// changes on upgrade.
			'enabled_posts'               => false, // derived: source_posts = 'off'
			'enabled_pages'               => false, // derived: source_pages = 'off'
			'regenerate_on_update'        => false,
			'refresh_enabled'             => true,
			'refresh_interval'            => self::DEFAULT_REFRESH_INTERVAL,

			// Source defaults (Q1 amended 2026-06-23): both default to
			// Off for fresh installs - quota safety wins over the
			// "smart defaults" pitch. The customer affirmatively picks
			// a Source mode per content type. CPTs also default to Off
			// (handled in class-cpts__premium_only.php's source_cpts =
			// empty array default; the resolver returns MODE_OFF for
			// any CPT not in the array).
			//
			// IMPORTANT: keep these in lock-step with the enabled_X
			// derivation above. If a future change makes either source
			// default non-Off, flip the corresponding enabled_X to
			// true so the read-time view stays internally consistent.
			'source_posts'                => DOIG_Source_Resolver::MODE_OFF,
			'source_pages'                => DOIG_Source_Resolver::MODE_OFF,

			// 2.7.0 M1: site-wide surfaces (homepage / archives /
			// taxonomy pages / author archives). Sparse map of
			// settings-group key -> mode; an ABSENT group means Off, so
			// the no-quota-burn-without-consent principle extends to
			// surfaces automatically - including taxonomies and post
			// types registered AFTER the customer last saved Settings.
			// surfaces_custom maps group key -> attachment ID for the
			// Custom mode (one image per group in v1, per the
			// per-taxonomy granularity lock).
			'surfaces'                    => array(),
			'surfaces_custom'             => array(),

			// Screenshot dimensions. Output is always resized to OG_WIDTH x OG_HEIGHT.
			'viewport_width'              => self::VIEWPORT_WIDTH,
			'viewport_height'             => (int) round( self::VIEWPORT_WIDTH * self::OG_HEIGHT / self::OG_WIDTH ),
			'image_quality'               => self::DEFAULT_IMAGE_QUALITY,

			// Blocking options
			'block_ads'                   => true,
			'block_cookie_banners'        => true,
			'block_banners_by_heuristics' => false, // Off by default — can incorrectly hide decorative sections
			'block_trackers'              => true,
			'block_chats'                 => true,  // Block live chat widgets (Intercom, Drift, Zendesk, etc.)

			// Rendering behaviour
			'dark_mode'                   => false, // Hint the browser to render in dark mode
			'wait_until'                  => 'load', // When the page is considered ready: load | domcontentloaded | networkidle0 | networkidle2

			// CSS selector to scroll into view before capture.
			'scroll_into_view'            => '',

			// Error flags — when false (default), screenshot proceeds even if the selector is absent.
			// Only sent to the API when the corresponding selector param is present.
			'error_on_selector_not_found'       => false,
			'error_on_click_selector_not_found' => false,

			// CSS selector to click before capture.
			'click_before_capture'        => '',

			// Fallback
			'fallback_type'               => 'seo_plugin', // 'seo_plugin' | 'media' | 'none'
			'fallback_image_id'           => 0,

			// Logging
			'log_level'                   => 'info', // 'off' | 'error' | 'warning' | 'info' | 'debug'
		);
	}

	/**
	 * Returns all settings, merging saved values over defaults.
	 *
	 * @return array
	 */
	public static function get_all() {
		if ( null === self::$settings ) {
			$saved = get_option( DOIG_OPTION_KEY, array() );

			// 2.2.0 migration: the legacy single `enabled` boolean was split
			// into per-type `enabled_posts` / `enabled_pages`. If a saved
			// row has the legacy key but neither new key, mirror the legacy
			// value into both new keys so the install keeps behaving as
			// before. Persist back so subsequent reads skip this branch -
			// the `enabled` key itself is left in place during the
			// transition window (a future release can prune it).
			if (
				is_array( $saved )
				&& array_key_exists( 'enabled', $saved )
				&& ! array_key_exists( 'enabled_posts', $saved )
				&& ! array_key_exists( 'enabled_pages', $saved )
			) {
				$legacy                  = (bool) $saved['enabled'];
				$saved['enabled_posts']  = $legacy;
				$saved['enabled_pages']  = $legacy;
				update_option( DOIG_OPTION_KEY, $saved );
			}

			// 2.4.0 migration: preserve existing customers' opt-in/opt-out
			// state under the new Source-only single-axis design (Q2
			// Option C, re-locked 2026-06-23). An existing 2.2.x or
			// 2.3.x install has `enabled_posts` / `enabled_pages` as
			// booleans in its saved option but NO `source_posts` /
			// `source_pages` yet - that's the upgrade marker.
			//
			// Map enabled bool -> source mode:
			//   enabled_X = true  -> source_X = 'screenshot'  (preserves
			//                        the pre-2.4.0 "emit a screenshot" behaviour)
			//   enabled_X = false -> source_X = 'off'         (preserves
			//                        the pre-2.4.0 "do not auto-process" behaviour)
			//
			// Then persist so subsequent reads skip this branch. Fresh
			// 2.4.0+ installs don't hit this branch (no `enabled_posts`
			// in their initial empty option) and pick up the new
			// featured-with-fallback / screenshot defaults from
			// defaults() above. The `enabled_posts` / `enabled_pages`
			// keys stay in storage for back-compat with readers (cron
			// loops, sidebar gates) that still expect the bool;
			// sanitise() keeps them in sync on every save by deriving
			// from the source value.
			if (
				is_array( $saved )
				&& array_key_exists( 'enabled_posts', $saved )
				&& ! array_key_exists( 'source_posts', $saved )
			) {
				$saved['source_posts'] = ! empty( $saved['enabled_posts'] )
					? DOIG_Source_Resolver::MODE_SCREENSHOT
					: DOIG_Source_Resolver::MODE_OFF;
				$saved['source_pages'] = ! empty( $saved['enabled_pages'] )
					? DOIG_Source_Resolver::MODE_SCREENSHOT
					: DOIG_Source_Resolver::MODE_OFF;
				update_option( DOIG_OPTION_KEY, $saved );
			}

			self::$settings = wp_parse_args( $saved, self::defaults() );

			// Normalise viewport at read time. Enforced here so the value the
			// rest of the plugin reads is always consistent with the option
			// shape we ship, regardless of what may have been written to the
			// row by some other code path.
			self::$settings['viewport_width']  = self::VIEWPORT_WIDTH;
			self::$settings['viewport_height'] = (int) round( self::VIEWPORT_WIDTH * self::OG_HEIGHT / self::OG_WIDTH );

			/**
			 * Filter the complete settings array at read time. Separately
			 * distributed builds register additional read-time behaviour
			 * through this hook.
			 *
			 * @param array $settings Full settings array with defaults merged.
			 */
			self::$settings = (array) apply_filters( 'doig_settings_get_all', self::$settings );
		}
		return self::$settings;
	}

	/**
	 * Returns a single setting value.
	 *
	 * @param string $key
	 * @param mixed  $default
	 * @return mixed
	 */
	public static function get( $key, $default = null ) {
		$all = self::get_all();
		if ( array_key_exists( $key, $all ) ) {
			return $all[ $key ];
		}
		return $default;
	}

	/**
	 * Returns the per-post-type "auto-enable" default.
	 *
	 * - 'post'        -> the `enabled_posts` setting (Free).
	 * - 'page'        -> the `enabled_pages` setting (Free).
	 * - any other     -> in scope iff Premium's `enabled_cpts` (injected via
	 *                    the `doig_supported_post_types` filter) includes it.
	 *                    For Premium CPTs, presence in `enabled_cpts` means
	 *                    BOTH "in scope" AND "auto-enable" (one checkbox does
	 *                    both - this is the SSAA pattern). If the type isn't
	 *                    in scope at all, the default is false.
	 *
	 * Used by `DOIG_Source_Resolver::type_default()` to resolve the type-level
	 * fallback when a post's per-post `_doig_source` is Inherit, and by the
	 * Gutenberg sidebar localizer to drive the "Inherit" subtext. (Pre-2.4.0
	 * `DOIG_Post_Meta::is_enabled()` was the consumer; removed in 2.4.1.)
	 *
	 * @param string $post_type Post type slug.
	 * @return bool
	 */
	public static function is_enabled_for_post_type( $post_type ) {
		$post_type = (string) $post_type;
		if ( 'post' === $post_type ) {
			return (bool) self::get( 'enabled_posts', false );
		}
		if ( 'page' === $post_type ) {
			return (bool) self::get( 'enabled_pages', false );
		}
		// Premium CPTs: supported_post_types() returns post + page + any
		// CPTs Premium has joined via the filter. Presence implies opt-in.
		if ( ! class_exists( 'DOIG_Cron' ) ) {
			return false;
		}
		return in_array( $post_type, DOIG_Cron::supported_post_types(), true );
	}

	/**
	 * Saves sanitised settings to the database.
	 *
	 * @param array $raw Raw input (e.g. from $_POST).
	 * @return bool
	 */
	public static function save( array $raw ) {
		$clean          = self::sanitise( $raw );
		self::$settings = null; // Bust settings cache. (No logger cache to bust:
		// the shared-logging component reads the level via its getter per write.)
		return update_option( DOIG_OPTION_KEY, $clean );
	}

	/**
	 * Sanitises raw settings input.
	 *
	 * @param array $raw
	 * @return array
	 */
	public static function sanitise( array $raw ) {
		$defaults = self::defaults();
		// Fetch the currently-saved option once up-front so fields that are NOT
		// present in $raw (e.g. log_level, which the shared-logging component
		// saves independently from the Logs tab and is therefore absent from the
		// Settings tab form POST) can fall back to their saved value rather than
		// the hardcoded default.
		$saved = (array) get_option( DOIG_OPTION_KEY, array() );

		// 2.4.0: per-content-type Image Source. Sanitised against the
		// terminal-mode whitelist; anything not on the whitelist coerces
		// to 'screenshot' (safe pre-2.4.0-equivalent default). The
		// fallback for an absent key is the saved value, NOT the
		// default - the Settings form may not submit these fields
		// (e.g. a Logs-tab-only save) and we don't want such saves to
		// silently reset the customer's chosen source mode.
		$source_posts_clean = DOIG_Source_Resolver::sanitize_terminal_mode(
			$raw['source_posts'] ?? ( $saved['source_posts'] ?? $defaults['source_posts'] )
		);
		$source_pages_clean = DOIG_Source_Resolver::sanitize_terminal_mode(
			$raw['source_pages'] ?? ( $saved['source_pages'] ?? $defaults['source_pages'] )
		);

		// 2.7.0 M1: site-wide surface modes. Same absent-key rule as the
		// source fields - a form POST without the surfaces fields (Logs
		// tab save) must not reset the customer's configuration.
		// Validation: group keys must exist in the CURRENT
		// settings_groups() enumeration (stale keys for since-removed
		// taxonomies are dropped on save), modes must be surface modes
		// (unknowns coerce to Off - for surfaces the safe default is
		// consent-preserving Off, the OPPOSITE of the posts coercion to
		// 'screenshot' which preserves pre-2.4.0 behaviour), and in the
		// free build only the homepage row is accepted (Q1 tier lock) -
		// a stale premium config after a downgrade must not keep
		// burning quota.
		$surfaces_raw = $raw['surfaces'] ?? ( $saved['surfaces'] ?? $defaults['surfaces'] );
		$surfaces_custom_raw = $raw['surfaces_custom'] ?? ( $saved['surfaces_custom'] ?? $defaults['surfaces_custom'] );

		$valid_groups = array();
		foreach ( DOIG_Surfaces::settings_groups() as $group ) {
			$valid_groups[ $group['key'] ] = $group['premium'];
		}

		$surfaces_clean        = array();
		$surfaces_custom_clean = array();
		foreach ( (array) $surfaces_raw as $group_key => $mode ) {
			if ( ! isset( $valid_groups[ $group_key ] ) ) {
				continue;
			}
			if ( $valid_groups[ $group_key ] && ! DOIG_License::is_premium() ) {
				continue;
			}
			$mode = DOIG_Source_Resolver::sanitize_surface_mode( $mode );
			if ( DOIG_Source_Resolver::MODE_OFF === $mode ) {
				continue; // Sparse storage: absent = Off.
			}
			$surfaces_clean[ $group_key ] = $mode;

			if ( DOIG_Source_Resolver::MODE_CUSTOM === $mode ) {
				$attachment_id = absint( $surfaces_custom_raw[ $group_key ] ?? 0 );
				if ( $attachment_id ) {
					$surfaces_custom_clean[ $group_key ] = $attachment_id;
				}
			}
		}

		$clean = array(
			// 2.4.0: enabled_posts / enabled_pages are now DERIVED from
			// the Source mode on every save - they're no longer
			// customer-facing controls (Q2 Option C re-lock 2026-06-23).
			// Kept in storage so readers that haven't been migrated yet
			// (cron loops, sidebar enqueue gates, bulk-action handlers)
			// continue to work. Derivation rule: any non-Off Source mode
			// counts as "enabled" for the type.
			'enabled_posts'               => DOIG_Source_Resolver::MODE_OFF !== $source_posts_clean,
			'enabled_pages'               => DOIG_Source_Resolver::MODE_OFF !== $source_pages_clean,

			'source_posts'                => $source_posts_clean,
			'source_pages'                => $source_pages_clean,

			// 2.7.0 M1: site-wide surfaces (sparse: Off entries are
			// dropped, absent = Off).
			'surfaces'                    => $surfaces_clean,
			'surfaces_custom'             => $surfaces_custom_clean,

			'regenerate_on_update'        => ! empty( $raw['regenerate_on_update'] ),
			'refresh_enabled'             => ! empty( $raw['refresh_enabled'] ),
			'refresh_interval'            => max( self::MIN_REFRESH_DAYS, intval( $raw['refresh_interval'] ?? $defaults['refresh_interval'] ) ),
			'viewport_width'              => self::VIEWPORT_WIDTH,
			'viewport_height'             => (int) round( self::VIEWPORT_WIDTH * self::OG_HEIGHT / self::OG_WIDTH ),
			'image_quality'               => min( self::MAX_IMAGE_QUALITY, max( self::MIN_IMAGE_QUALITY, intval( $raw['image_quality'] ?? $defaults['image_quality'] ) ) ),
			// Blocking options
			'block_ads'                   => ! empty( $raw['block_ads'] ),
			'block_cookie_banners'        => ! empty( $raw['block_cookie_banners'] ),
			'block_banners_by_heuristics' => ! empty( $raw['block_banners_by_heuristics'] ),
			'block_trackers'              => ! empty( $raw['block_trackers'] ),
			'block_chats'                 => ! empty( $raw['block_chats'] ),
			// Rendering behaviour
			'dark_mode'                   => ! empty( $raw['dark_mode'] ),
			'wait_until'                  => in_array( $raw['wait_until'] ?? '', array( 'load', 'domcontentloaded', 'networkidle0', 'networkidle2' ), true )
			                                ? $raw['wait_until']
			                                : $defaults['wait_until'],
			// Scroll into view
			'scroll_into_view'            => sanitize_text_field( $raw['scroll_into_view'] ?? '' ),
			// Error flags — sent to API only when the corresponding selector is present.
			'error_on_selector_not_found'       => ! empty( $raw['error_on_selector_not_found'] ),
			'error_on_click_selector_not_found' => ! empty( $raw['error_on_click_selector_not_found'] ),
			// Click before capture
			'click_before_capture'        => sanitize_text_field( $raw['click_before_capture'] ?? '' ),
			// Fallback
			'fallback_type'               => in_array( $raw['fallback_type'] ?? '', array( 'seo_plugin', 'media', 'none' ), true )
										     ? $raw['fallback_type']
										     : $defaults['fallback_type'],
			'fallback_image_id'           => absint( $raw['fallback_image_id'] ?? 0 ),

			// Logging — falls back to the saved value (not the default) so that a
			// Settings tab save doesn't overwrite a log level set on the Logs tab
			// (saved independently there by the shared-logging component).
			'log_level'                   => in_array( $raw['log_level'] ?? '', array( 'off', 'error', 'warning', 'info', 'debug' ), true )
										     ? $raw['log_level']
										     : ( $saved['log_level'] ?? $defaults['log_level'] ),
		);

		/**
		 * Filter sanitised settings before they are stored. Separately
		 * distributed builds register additional field sanitisation through
		 * this hook.
		 *
		 * @param array $clean   Sanitised settings.
		 * @param array $raw     Raw input (e.g. from $_POST).
		 * @param array $saved   Currently-stored settings.
		 */
		$clean = (array) apply_filters( 'doig_sanitise_settings', $clean, $raw, $saved );

		return $clean;
	}
}
