<?php
/**
 * Settings storage.
 *
 * @package SubmissionInbox
 */

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

/**
 * Reads and writes plugin settings.
 */
class SUBIN_SettingsRepository {
	const OPTION_KEY = 'subin_settings';
	const LOCK_OPTION_KEY = 'subin_settings_write_lock';
	const LOCK_TTL_SECONDS = 10;
	const LOCK_WAIT_SECONDS = 3;
	const LOCK_RETRY_MICROSECONDS = 50000;
	const DEFAULT_MAIL_LOG_RETENTION_DAYS = 90;

	/**
	 * Get default settings.
	 *
	 * @return array<string,mixed>
	 */
	public static function defaults() {
		return array(
			'enable_smtp'              => false,
			'from_email'               => '',
			'from_name'                => '',
			'smtp_host'                => '',
			'smtp_port'                => 587,
			'smtp_encryption'          => 'tls',
			'smtp_auth'                => true,
			'smtp_username'            => '',
			'smtp_password'            => '',
			'store_leads'              => true,
			'retention_days'           => 90,
			'mail_log_retention_days'  => self::DEFAULT_MAIL_LOG_RETENTION_DAYS,
			'new_lead_hours'           => 24,
			'delete_data_on_uninstall' => false,
			'store_ip'                 => false,
			'store_user_agent'         => false,
			'enable_cf7_integration'   => true,
			'enable_wpforms_integration' => true,
			'enable_fluent_forms_integration' => true,
			'enable_forminator_integration' => true,
			'enable_ninja_forms_integration' => true,
			'recaptcha_enabled'        => false,
			'recaptcha_site_key'       => '',
			'recaptcha_secret_key'     => '',
			'recaptcha_score_threshold' => 0.5,
			'mask_sensitive_fields'        => true,
			'notification_recipient_email' => is_email( get_option( 'admin_email' ) ) ? get_option( 'admin_email' ) : '',
			'notification_from_lead_email' => false,
			'cloud_sync_enabled'           => false,
			'cloud_sync_data_mode'         => 'lead_summary',
			'cloud_sync_endpoint'          => 'https://inboxmend.com/api/v1/plugin',
			'cloud_sync_site_key'          => '',
			'cloud_sync_license_key'       => '',
			'cloud_sync_site_token'        => '',
			'cloud_sync_transport'         => 'polling',
			'cloud_sync_polling_enabled'   => false,
			'cloud_sync_allowed_mode'      => '',
			'cloud_sync_full_data_enabled' => false,
			'cloud_sync_full_data_consent' => false,
			'cloud_sync_connection_status' => 'disconnected',
			'cloud_sync_site_id'           => 0,
			'cloud_sync_license_status'    => '',
			'cloud_sync_license_site_limit' => 0,
			'cloud_sync_heartbeat_interval' => 300,
			'cloud_sync_last_heartbeat_at' => '',
			'cloud_sync_last_sync_at'      => '',
			'cloud_sync_last_sync_error'   => '',
		);
	}

	/**
	 * Add default option if missing.
	 *
	 * @return void
	 */
	public static function ensure_defaults() {
		if ( false === get_option( self::OPTION_KEY, false ) ) {
			add_option( self::OPTION_KEY, self::defaults(), '', false );
		}
	}

	/**
	 * Get all settings merged with defaults.
	 *
	 * @return array<string,mixed>
	 */
	public function all() {
		$settings = get_option( self::OPTION_KEY, array() );

		if ( ! is_array( $settings ) ) {
			$settings = array();
		}

		return array_merge( self::defaults(), $settings );
	}

	/**
	 * Atomically merge a partial settings update.
	 *
	 * @param array<string,mixed> $changes Changed settings only.
	 * @return true|\WP_Error
	 */
	public function patch( array $changes ) {
		return $this->atomic_update(
			static function ( array $settings ) use ( $changes ) {
				return array_replace( $settings, $changes );
			}
		);
	}

	/**
	 * Backward-compatible alias for patch().
	 *
	 * @param array<string,mixed> $changes Changed settings only.
	 * @return true|\WP_Error
	 */
	public function update( array $changes ) {
		return $this->patch( $changes );
	}

	/**
	 * Run an atomic read-modify-write operation.
	 *
	 * The callback must be quick and must not perform network requests. A thrown
	 * exception is allowed to bubble up; the write lock is still released.
	 *
	 * @param callable(array<string,mixed>):array<string,mixed>|\WP_Error $updater Settings updater.
	 * @return true|\WP_Error
	 */
	public function atomic_update( callable $updater ) {
		$lock = $this->acquire_write_lock();
		if ( is_wp_error( $lock ) ) {
			return $lock;
		}

		try {
			$settings = $this->fresh_settings();
			$updated  = call_user_func( $updater, $settings );

			if ( is_wp_error( $updated ) ) {
				return $updated;
			}

			if ( ! is_array( $updated ) ) {
				return new WP_Error(
					'subin_settings_invalid_update',
					__( 'The settings update returned invalid data.', 'submission-inbox' )
				);
			}

			if ( $updated === $settings ) {
				return true;
			}

			$this->clear_settings_cache();
			if ( ! update_option( self::OPTION_KEY, $updated, false ) ) {
				if ( $this->fresh_settings() === $updated ) {
					return true;
				}

				return new WP_Error(
					'subin_settings_update_failed',
					__( 'Settings could not be saved. Please try again.', 'submission-inbox' )
				);
			}

			return true;
		} finally {
			$this->release_write_lock( $lock );
		}
	}

	/**
	 * Update SMTP settings.
	 *
	 * @param array<string,mixed> $data Submitted data.
	 * @return true|\WP_Error
	 */
	public function update_smtp_settings( array $data ) {
		$current = $this->all();
		$raw_from_email = trim( (string) wp_unslash( $data['from_email'] ?? '' ) );
		$from_email     = sanitize_email( $raw_from_email );
		$raw_default_recipient = trim( (string) wp_unslash( $data['notification_recipient_email'] ?? '' ) );
		$default_recipient     = sanitize_email( $raw_default_recipient );

		if ( '' !== $raw_from_email && ! is_email( $raw_from_email ) ) {
			return new WP_Error(
				'subin_invalid_from_email',
				__( 'From Email must be a valid email address.', 'submission-inbox' )
			);
		}

		if ( '' === $from_email && ! is_email( get_option( 'admin_email' ) ) ) {
			return new WP_Error(
				'subin_invalid_from_email',
				__( 'From Email must be a valid email address.', 'submission-inbox' )
			);
		}

		if ( '' !== $raw_default_recipient && ! is_email( $raw_default_recipient ) ) {
			return new WP_Error(
				'subin_invalid_default_notification_recipient',
				__( 'Default admin notification recipient must be a valid email address.', 'submission-inbox' )
			);
		}

		if ( '' === $default_recipient ) {
			$admin_email = sanitize_email( (string) get_option( 'admin_email' ) );
			$default_recipient = is_email( $admin_email ) ? $admin_email : '';
		}

		$changes = array(
			'enable_smtp'                  => $this->sanitize_bool( $data['enable_smtp'] ?? false ),
			'from_email'                   => $from_email,
			'from_name'                    => sanitize_text_field( wp_unslash( $data['from_name'] ?? '' ) ),
			'notification_recipient_email' => $default_recipient,
			'smtp_host'                    => sanitize_text_field( wp_unslash( $data['smtp_host'] ?? '' ) ),
			'smtp_port'                    => max( 1, min( 65535, absint( $data['smtp_port'] ?? 587 ) ) ),
			'smtp_encryption'              => $this->sanitize_encryption( $data['smtp_encryption'] ?? 'tls' ),
			'smtp_auth'                    => $this->sanitize_bool( $data['smtp_auth'] ?? false ),
			'smtp_username'                => sanitize_text_field( wp_unslash( $data['smtp_username'] ?? '' ) ),
		);

		$effective_password = (string) ( $current['smtp_password'] ?? '' );
		$clear_password     = $this->sanitize_bool( $data['smtp_clear_password'] ?? false );
		if ( $clear_password ) {
			$changes['smtp_password'] = '';
			$effective_password        = '';
		}

		if ( array_key_exists( 'smtp_password', $data ) ) {
			$raw_password = $data['smtp_password'];
			if ( is_scalar( $raw_password ) && '' !== (string) wp_unslash( $raw_password ) ) {
				$password = self::normalize_opaque_secret( $raw_password );
				if ( is_wp_error( $password ) ) {
					return $password;
				}
				$changes['smtp_password'] = $password;
				$effective_password        = $password;
			}
		}

		if ( ! empty( $changes['enable_smtp'] ) && '' === trim( (string) $changes['smtp_host'] ) ) {
			return new WP_Error(
				'subin_smtp_host_required',
				__( 'Enter an SMTP Host before enabling custom SMTP.', 'submission-inbox' )
			);
		}

		if ( ! empty( $changes['enable_smtp'] ) && ! empty( $changes['smtp_auth'] ) && ( '' === trim( (string) $changes['smtp_username'] ) || '' === $effective_password ) ) {
			return new WP_Error(
				'subin_smtp_credentials_required',
				__( 'Enter both an SMTP username and password before enabling authenticated SMTP.', 'submission-inbox' )
			);
		}

		return $this->patch( $changes );
	}

	/**
	 * Validate an opaque credential without rewriting valid secret characters.
	 *
	 * WordPress text sanitizers are intentionally not used: SMTP passwords and
	 * API secrets may legitimately contain markup-like or percent-encoded text.
	 * Control bytes and unbounded payloads are rejected instead.
	 *
	 * @param mixed $value Raw submitted value.
	 * @param int   $max_length Maximum accepted byte length.
	 * @return string|\WP_Error
	 */
	public static function normalize_opaque_secret( $value, $max_length = 4096 ) {
		if ( ! is_scalar( $value ) ) {
			return new WP_Error( 'subin_secret_invalid', __( 'The credential has an invalid format.', 'submission-inbox' ) );
		}

		$value      = (string) wp_unslash( $value );
		$max_length = max( 1, min( 16384, absint( $max_length ) ) );
		if ( strlen( $value ) > $max_length ) {
			return new WP_Error( 'subin_secret_too_long', __( 'The credential is longer than the supported limit.', 'submission-inbox' ) );
		}
		if ( preg_match( '/[\x00-\x1F\x7F]/', $value ) ) {
			return new WP_Error( 'subin_secret_control_character', __( 'The credential contains an unsupported control character.', 'submission-inbox' ) );
		}

		return $value;
	}

	/**
	 * Update privacy and retention settings.
	 *
	 * @param array<string,mixed> $data Submitted data.
	 * @return true|\WP_Error
	 */
	public function update_privacy_settings( array $data ) {
		$changes = array(
			'store_leads'              => $this->sanitize_bool( $data['store_leads'] ?? false ),
			'retention_days'           => $this->sanitize_retention_days( $data['retention_days'] ?? 90 ),
			'new_lead_hours'           => $this->sanitize_new_lead_hours( $data['new_lead_hours'] ?? 24 ),
			'store_ip'                 => $this->sanitize_bool( $data['store_ip'] ?? false ),
			'store_user_agent'         => $this->sanitize_bool( $data['store_user_agent'] ?? false ),
			'mask_sensitive_fields'    => $this->sanitize_bool( $data['mask_sensitive_fields'] ?? false ),
			'delete_data_on_uninstall' => $this->sanitize_bool( $data['delete_data_on_uninstall'] ?? false ),
		);

		// Legacy callers that do not know about Email Log retention must not
		// overwrite an administrator's separately configured policy.
		if ( array_key_exists( 'mail_log_retention_days', $data ) ) {
			$changes['mail_log_retention_days'] = self::sanitize_mail_log_retention_days( $data['mail_log_retention_days'] );
		}

		return $this->patch( $changes );
	}

	/**
	 * Supported Email Log retention choices.
	 *
	 * Zero means that events are kept until they are explicitly deleted or the
	 * plugin data is removed. The list is shared by storage validation and UI so
	 * a submitted value can never silently select an unadvertised policy.
	 *
	 * @return array<int,int>
	 */
	public static function mail_log_retention_presets() {
		return array( 7, 30, 60, 90, 180, 365, 0 );
	}

	/**
	 * Sanitize the configured Email Log retention period.
	 *
	 * @param mixed $value Raw value.
	 * @return int
	 */
	public static function sanitize_mail_log_retention_days( $value ) {
		if ( ! is_scalar( $value ) ) {
			return self::DEFAULT_MAIL_LOG_RETENTION_DAYS;
		}

		$value = trim( (string) $value );
		if ( '' === $value || ! ctype_digit( $value ) ) {
			return self::DEFAULT_MAIL_LOG_RETENTION_DAYS;
		}

		$days = (int) $value;

		return in_array( $days, self::mail_log_retention_presets(), true ) ? $days : self::DEFAULT_MAIL_LOG_RETENTION_DAYS;
	}

	/**
	 * Resolve the effective Email Log retention period from a settings snapshot.
	 *
	 * Numeric custom values that predate the settings UI remain supported. New
	 * form submissions are restricted to the advertised presets, while the
	 * existing filter remains the final authority for programmatic policies.
	 * Every effective value is bounded to ten years; zero means Forever.
	 *
	 * @param array<string,mixed> $settings Settings snapshot.
	 * @return int
	 */
	public static function resolve_mail_log_retention_days( array $settings ) {
		$raw        = $settings['mail_log_retention_days'] ?? self::DEFAULT_MAIL_LOG_RETENTION_DAYS;
		$normalized = is_scalar( $raw ) ? trim( (string) $raw ) : '';
		$configured = '' !== $normalized && ctype_digit( $normalized )
			? max( 0, min( 3650, (int) $normalized ) )
			: self::DEFAULT_MAIL_LOG_RETENTION_DAYS;

		return max( 0, min( 3650, absint( apply_filters( 'subin_mail_log_retention_days', $configured ) ) ) );
	}

	/**
	 * Get the effective, read-only Email Log retention policy.
	 *
	 * @return int Number of days, or zero for Forever.
	 */
	public function mail_log_retention_days() {
		return self::resolve_mail_log_retention_days( $this->all() );
	}

	/**
	 * Read settings directly from storage while holding the write lock.
	 *
	 * @return array<string,mixed>
	 */
	private function fresh_settings() {
		global $wpdb;

		$value = $wpdb->get_var(
			$wpdb->prepare(
				"SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1",
				self::OPTION_KEY
			)
		);
		$settings = null === $value ? array() : maybe_unserialize( $value );

		if ( ! is_array( $settings ) ) {
			$settings = array();
		}

		return array_merge( self::defaults(), $settings );
	}

	/**
	 * Clear caches that can contain the settings option.
	 *
	 * @return void
	 */
	private function clear_settings_cache() {
		wp_cache_delete( self::OPTION_KEY, 'options' );

		$alloptions = wp_cache_get( 'alloptions', 'options' );
		if ( is_array( $alloptions ) && array_key_exists( self::OPTION_KEY, $alloptions ) ) {
			wp_cache_delete( 'alloptions', 'options' );
		}

		$notoptions = wp_cache_get( 'notoptions', 'options' );
		if ( is_array( $notoptions ) && isset( $notoptions[ self::OPTION_KEY ] ) ) {
			wp_cache_delete( 'notoptions', 'options' );
		}
	}

	/**
	 * Acquire the short-lived settings write lock.
	 *
	 * @return array{token:string,expires_at:int}|\WP_Error
	 */
	private function acquire_write_lock() {
		$token    = wp_generate_uuid4();
		$deadline = microtime( true ) + self::LOCK_WAIT_SECONDS;

		do {
			wp_cache_delete( self::LOCK_OPTION_KEY, 'options' );

			$lock = array(
				'token'      => $token,
				'expires_at' => time() + self::LOCK_TTL_SECONDS,
			);

			if ( add_option( self::LOCK_OPTION_KEY, $lock, '', false ) ) {
				return $lock;
			}

			$current_lock = $this->read_write_lock();
			if ( $this->is_stale_lock( $current_lock ) && $this->delete_write_lock_if_matches( $current_lock ) ) {
				continue;
			}

			if ( microtime( true ) >= $deadline ) {
				return new WP_Error(
					'subin_settings_locked',
					__( 'Settings are being updated by another request. Please try again.', 'submission-inbox' )
				);
			}

			usleep( self::LOCK_RETRY_MICROSECONDS + wp_rand( 0, 25000 ) );
		} while ( true );
	}

	/**
	 * Release a lock only if it is still owned by this request.
	 *
	 * @param array{token:string,expires_at:int} $lock Lock value.
	 * @return void
	 */
	private function release_write_lock( array $lock ) {
		$this->delete_write_lock_if_matches( $lock );
	}

	/**
	 * Read the raw lock value directly from the database.
	 *
	 * @return mixed
	 */
	private function read_write_lock() {
		global $wpdb;

		$value = $wpdb->get_var(
			$wpdb->prepare(
				"SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1",
				self::LOCK_OPTION_KEY
			)
		);

		if ( null === $value ) {
			return false;
		}

		return maybe_unserialize( $value );
	}

	/**
	 * Whether a lock can safely be recovered.
	 *
	 * @param mixed $lock Lock value.
	 * @return bool
	 */
	private function is_stale_lock( $lock ) {
		if ( false === $lock ) {
			return false;
		}
		if ( ! is_array( $lock ) ) {
			return true;
		}

		return empty( $lock['token'] )
			|| empty( $lock['expires_at'] )
			|| absint( $lock['expires_at'] ) <= time();
	}

	/**
	 * Conditionally delete exactly the lock value that was observed.
	 *
	 * @param mixed $lock Lock value.
	 * @return bool
	 */
	private function delete_write_lock_if_matches( $lock ) {
		global $wpdb;

		if ( false === $lock ) {
			return false;
		}

		$deleted = $wpdb->query(
			$wpdb->prepare(
				"DELETE FROM {$wpdb->options} WHERE option_name = %s AND option_value = %s",
				self::LOCK_OPTION_KEY,
				maybe_serialize( $lock )
			)
		);

		wp_cache_delete( self::LOCK_OPTION_KEY, 'options' );

		return 1 === (int) $deleted;
	}

	/**
	 * Sanitize encryption value.
	 *
	 * @param mixed $value Raw value.
	 * @return string
	 */
	private function sanitize_encryption( $value ) {
		$value   = sanitize_key( wp_unslash( $value ) );
		$allowed = array( 'none', 'ssl', 'tls' );

		return in_array( $value, $allowed, true ) ? $value : 'tls';
	}

	/**
	 * Sanitize a boolean-like setting.
	 *
	 * @param mixed $value Raw value.
	 * @return bool
	 */
	private function sanitize_bool( $value ) {
		return rest_sanitize_boolean( $value );
	}

	/**
	 * Sanitize retention period.
	 *
	 * @param mixed $value Raw value.
	 * @return int
	 */
	private function sanitize_retention_days( $value ) {
		$value   = absint( $value );
		$allowed = array( 0, 30, 60, 90, 180, 365 );

		return in_array( $value, $allowed, true ) ? $value : 90;
	}

	/**
	 * Sanitize the unread lead freshness window.
	 *
	 * @param mixed $value Raw value.
	 * @return int
	 */
	private function sanitize_new_lead_hours( $value ) {
		$value   = absint( $value );
		$allowed = array( 6, 12, 24, 48, 72, 168 );

		return in_array( $value, $allowed, true ) ? $value : 24;
	}
}
