<?php
/**
 * Costudio_Standalone_Settings
 *
 * Standalone implementation of the settings manager.
 * Stores settings in 'costudio_settings' option.
 *
 * @package Contentosapp_Studio
 * @since   1.0.0
 */

defined( 'ABSPATH' ) || exit;

class Costudio_Standalone_Settings {

	/**
	 * Singleton instance.
	 */
	private static ?self $instance = null;

	/**
	 * Option names.
	 */
	private const OPTION_KEY    = 'costudio_settings';
	private const IV_OPTION_KEY = 'costudio_settings_iv';
	private const SEARCH_GROUNDING_AUTO_MIGRATED_OPTION = 'costudio_search_grounding_auto_migrated';
	private const PREFIX_V1     = 'costudio_enc:';
	private const PREFIX_V2     = 'costudio_gcm:';
	private const ALGO_V1       = 'AES-256-CBC';
	private const ALGO_V2       = 'AES-256-GCM';
	private const IV_LEN_V2     = 12;
	private const TAG_LEN       = 16;
	public const IMAGE_QUANTITY_MIN = 1;
	public const IMAGE_QUANTITY_MAX = 3;
	public const MANAGED_IMAGE_QUANTITY_MAX = 10;
	public const SEARCH_RESULTS_MIN = 1;
	public const SEARCH_RESULTS_MAX = 20;

	/**
	 * Encrypted fields.
	 */
	private array $encrypted_fields = array(
		'ai_api_key',
		'ai_image_api_key',
		'search_api_key',
	);

	/**
	 * Default settings.
	 */
	private array $defaults = array(
		'ai_provider'         => 'gemini',
		'ai_api_key'          => '',
		'ai_model'            => '',
		'ai_image_provider'   => '',
		'ai_image_model'      => '',
		'ai_image_api_key'    => '',
		'search_api_provider' => '',
		'search_api_key'      => '',
		'search_grounding'    => false,
		'search_max_results'  => 5,
		'costudio_image_mode'      => 'generate',
		'costudio_image_quantity'  => 2,
		'default_language'    => 'en_US',
		'costudio_pro_license_key' => '',
	);

	/**
	 * In-memory cache.
	 */
	private ?array $cache = null;

	/**
	 * Private constructor.
	 */
	private function __construct() {}

	/**
	 * Get instance.
	 */
	public static function get_instance(): self {
		if ( null === self::$instance ) {
			self::$instance = new self();
		}
		return self::$instance;
	}

	/**
	 * Read a setting value.
	 *
	 * @param string $key Setting key.
	 * @param mixed  $default Default value if not set.
	 * @return mixed
	 */
	public function get( string $key, $default = null ) {
		$all   = $this->load();
		$value = isset( $all[ $key ] ) ? $all[ $key ] : null;

		if ( null !== $value && '' !== $value && in_array( $key, $this->encrypted_fields, true ) ) {
			$value = $this->decrypt( $key, (string) $value );
		}

		if ( null === $value || '' === $value ) {
			$value = null !== $default ? $default : ( isset( $this->defaults[ $key ] ) ? $this->defaults[ $key ] : null );
		}

		if ( 'costudio_image_quantity' === $key ) {
			$ai_mode = isset( $all['ai_mode'] ) ? (string) $all['ai_mode'] : null;
			return self::clamp_image_quantity( $value, $ai_mode );
		}

		if ( 'search_max_results' === $key ) {
			return self::clamp_search_results( $value );
		}

		return $value;
	}

	/**
	 * Normalize article image quantity across UI, saved settings and runtime.
	 */
	public static function clamp_image_quantity( $value, ?string $ai_mode = null ): int {
		return max( self::IMAGE_QUANTITY_MIN, min( self::image_quantity_max_for_mode( $ai_mode ), intval( $value ) ) );
	}

	public static function image_quantity_max_for_mode( ?string $ai_mode = null ): int {
		if ( 'contentos' === $ai_mode && class_exists( 'Costudio_Standalone_License' ) ) {
			$limit = Costudio_Standalone_License::get_instance()->get_managed_image_limit_per_article();
			return max( self::IMAGE_QUANTITY_MIN, min( self::MANAGED_IMAGE_QUANTITY_MAX, $limit ) );
		}

		return self::IMAGE_QUANTITY_MAX;
	}

	/**
	 * Normalize the number of structured web-search sources requested per call.
	 */
	public static function clamp_search_results( $value ): int {
		return max( self::SEARCH_RESULTS_MIN, min( self::SEARCH_RESULTS_MAX, intval( $value ) ) );
	}

	/**
	 * Resolve the effective execution mode used by the pipeline.
	 */
	public function resolve_ai_mode( ?string $ai_mode = null ): string {
		$mode = is_string( $ai_mode ) && '' !== $ai_mode
			? $ai_mode
			: (string) $this->get( 'ai_mode', '' );

		if ( in_array( $mode, array( 'contentos', 'byok' ), true ) ) {
			return $mode;
		}

		return '' === (string) $this->get( 'ai_api_key', '' ) ? 'contentos' : 'byok';
	}

	/**
	 * One-time compatibility migration: ContentOS Auto should ground by default.
	 */
	public function maybe_migrate_contentos_auto_grounding(): void {
		if ( (bool) get_option( self::SEARCH_GROUNDING_AUTO_MIGRATED_OPTION, false ) ) {
			return;
		}

		if ( ! (bool) get_option( 'costudio_enable_web_search', true ) ) {
			return;
		}

		if ( 'contentos' !== $this->resolve_ai_mode() ) {
			return;
		}

		$all = $this->load();
		$all['search_grounding'] = true;
		$this->cache = $all;

		update_option( self::OPTION_KEY, $all, false );
		update_option( self::SEARCH_GROUNDING_AUTO_MIGRATED_OPTION, time(), false );
	}

	/**
	 * True when ContentOS Auto native grounding may run for the current request.
	 */
	public function contentos_native_search_enabled( ?string $ai_mode = null ): bool {
		if ( ! (bool) get_option( 'costudio_enable_web_search', true ) ) {
			return false;
		}

		if ( 'contentos' !== $this->resolve_ai_mode( $ai_mode ) ) {
			return false;
		}

		$this->maybe_migrate_contentos_auto_grounding();

		return (bool) $this->get( 'search_grounding', true );
	}

	/**
	 * Set a setting value.
	 *
	 * @param string $key Setting key.
	 * @param mixed  $value Value to store.
	 * @return bool
	 */
	public function set( string $key, $value ): bool {
		$all = $this->load();

		if ( 'costudio_image_quantity' === $key ) {
			$ai_mode = isset( $all['ai_mode'] ) ? (string) $all['ai_mode'] : null;
			$value = self::clamp_image_quantity( $value, $ai_mode );
		}
		if ( 'search_max_results' === $key ) {
			$value = self::clamp_search_results( $value );
		}

		if ( in_array( $key, $this->encrypted_fields, true ) && '' !== (string) $value ) {
			$all[ $key ] = $this->encrypt( $key, (string) $value );
		} else {
			$all[ $key ] = $value;
		}

		$this->cache = $all;

		return update_option( self::OPTION_KEY, $all, false );
	}

	/**
	 * Set multiple settings.
	 *
	 * @param array $values Associative array.
	 * @return bool
	 */
	public function set_many( array $values ): bool {
		$all = $this->load();
		$ai_mode = isset( $values['ai_mode'] ) ? (string) $values['ai_mode'] : ( isset( $all['ai_mode'] ) ? (string) $all['ai_mode'] : null );

		foreach ( $values as $key => $value ) {
			if ( 'costudio_image_quantity' === $key ) {
				$value = self::clamp_image_quantity( $value, $ai_mode );
			}
			if ( 'search_max_results' === $key ) {
				$value = self::clamp_search_results( $value );
			}
			if ( in_array( $key, $this->encrypted_fields, true ) && '' !== (string) $value ) {
				$all[ $key ] = $this->encrypt( $key, (string) $value );
			} else {
				$all[ $key ] = $value;
			}
		}

		$this->cache = $all;

		return update_option( self::OPTION_KEY, $all, false );
	}

	/**
	 * Get all settings decrypted.
	 *
	 * @return array
	 */
	public function get_all(): array {
		$all    = $this->load();
		$result = array();

		foreach ( array_keys( array_merge( $this->defaults, $all ) ) as $key ) {
			$result[ $key ] = $this->get( $key );
		}

		return $result;
	}

	/**
	 * Get all settings masked for display.
	 *
	 * @return array
	 */
	public function get_all_masked(): array {
		$all = $this->get_all();

		foreach ( $this->encrypted_fields as $field ) {
			if ( ! empty( $all[ $field ] ) ) {
				$all[ $field ] = str_repeat( '•', 8 ) . substr( (string) $all[ $field ], -4 );
			}
		}

		return $all;
	}

	/**
	 * Check if key exists.
	 *
	 * @param string $key Setting key.
	 * @return bool
	 */
	public function has( string $key ): bool {
		$all = $this->load();
		return array_key_exists( $key, $all );
	}

	/**
	 * Load settings from DB.
	 */
	private function load(): array {
		if ( null !== $this->cache ) {
			return $this->cache;
		}

		$stored      = get_option( self::OPTION_KEY, array() );
		$this->cache = is_array( $stored ) ? $stored : array();

		return $this->cache;
	}

	/**
	 * Encrypt sensitive value.
	 */
	private function encrypt( string $field, string $value ): string {
		if ( ! function_exists( 'openssl_encrypt' ) ) {
			return $value;
		}

		$ciphers = function_exists( 'openssl_get_cipher_methods' )
			? array_map( 'strtolower', openssl_get_cipher_methods() )
			: array();
		if ( ! in_array( strtolower( self::ALGO_V2 ), $ciphers, true ) ) {
			return $this->encrypt_legacy( $field, $value );
		}

		try {
			$iv = random_bytes( self::IV_LEN_V2 );
		} catch ( \Throwable $e ) {
			return $this->encrypt_legacy( $field, $value );
		}

		$tag        = '';
		$encrypted  = openssl_encrypt(
			$value,
			self::ALGO_V2,
			$this->derive_key( 'v2' ),
			OPENSSL_RAW_DATA,
			$iv,
			$tag,
			$field,
			self::TAG_LEN
		);

		if ( false === $encrypted || '' === $tag ) {
			return $this->encrypt_legacy( $field, $value );
		}

		$json = wp_json_encode(
			array(
				'v'   => 2,
				'alg' => self::ALGO_V2,
				'iv'  => base64_encode( $iv ),
				'ct'  => base64_encode( $encrypted ),
				'tag' => base64_encode( $tag ),
			)
		);

		if ( ! is_string( $json ) || '' === $json ) {
			return $this->encrypt_legacy( $field, $value );
		}

		return self::PREFIX_V2 . base64_encode( $json );
	}

	/**
	 * Legacy AES-256-CBC encryption kept only as a fallback for hosts without GCM.
	 */
	private function encrypt_legacy( string $field, string $value ): string {
		$ivs = get_option( self::IV_OPTION_KEY, array() );

		if ( empty( $ivs[ $field ] ) ) {
			try {
				$iv = function_exists( 'random_bytes' ) ? random_bytes( 16 ) : openssl_random_pseudo_bytes( 16 );
			} catch ( \Throwable $e ) {
				return $value;
			}

			if ( false === $iv ) {
				return $value;
			}
			$ivs[ $field ] = base64_encode( $iv );
			update_option( self::IV_OPTION_KEY, $ivs, false );
		} else {
			$iv = base64_decode( (string) $ivs[ $field ], true );
			if ( false === $iv || 16 !== strlen( $iv ) ) {
				return $value;
			}
		}

		$encrypted = openssl_encrypt( $value, self::ALGO_V1, $this->derive_key( 'v1' ), 0, $iv );

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

		return self::PREFIX_V1 . base64_encode( $encrypted );
	}

	/**
	 * Decrypt sensitive value.
	 */
	private function decrypt( string $field, string $value ): string {
		if ( strncmp( $value, self::PREFIX_V2, strlen( self::PREFIX_V2 ) ) === 0 ) {
			return $this->decrypt_gcm( $field, $value );
		}

		if ( strncmp( $value, self::PREFIX_V1, strlen( self::PREFIX_V1 ) ) === 0 ) {
			$plain = $this->decrypt_legacy( $field, $value );

			if ( '' !== $plain ) {
				$this->migrate_field_to_gcm( $field, $value, $plain );
			}

			return $plain;
		}

		return $value;
	}

	/**
	 * Decrypt the current AES-256-GCM payload.
	 */
	private function decrypt_gcm( string $field, string $value ): string {
		if ( ! function_exists( 'openssl_decrypt' ) ) {
			return '';
		}

		$raw  = base64_decode( substr( $value, strlen( self::PREFIX_V2 ) ), true );
		$data = json_decode( false !== $raw ? $raw : '', true );

		if ( ! is_array( $data ) || ! isset( $data['iv'], $data['ct'], $data['tag'] ) ) {
			return '';
		}

		$iv     = base64_decode( (string) $data['iv'], true );
		$cipher = base64_decode( (string) $data['ct'], true );
		$tag    = base64_decode( (string) $data['tag'], true );

		if ( false === $iv || false === $cipher || false === $tag || self::IV_LEN_V2 !== strlen( $iv ) ) {
			return '';
		}

		foreach ( $this->decryption_keys( 'v2' ) as $key ) {
			$decrypted = openssl_decrypt(
				$cipher,
				self::ALGO_V2,
				$key,
				OPENSSL_RAW_DATA,
				$iv,
				$tag,
				$field
			);

			if ( false !== $decrypted ) {
				return (string) $decrypted;
			}
		}

		return '';
	}

	/**
	 * Decrypt legacy AES-256-CBC payloads written before P1.2.
	 */
	private function decrypt_legacy( string $field, string $value ): string {
		if ( strncmp( $value, self::PREFIX_V1, strlen( self::PREFIX_V1 ) ) !== 0 ) {
			return $value;
		}

		if ( ! function_exists( 'openssl_decrypt' ) ) {
			return '';
		}

		$ivs       = get_option( self::IV_OPTION_KEY, array() );
		$iv        = isset( $ivs[ $field ] ) ? base64_decode( (string) $ivs[ $field ], true ) : '';
		$cipher    = base64_decode( substr( $value, strlen( self::PREFIX_V1 ) ), true );

		if ( false === $iv || false === $cipher || 16 !== strlen( $iv ) ) {
			return '';
		}

		foreach ( $this->decryption_keys( 'v1' ) as $key ) {
			$decrypted = openssl_decrypt( $cipher, self::ALGO_V1, $key, 0, $iv );

			if ( false !== $decrypted ) {
				return (string) $decrypted;
			}
		}

		return '';
	}

	/**
	 * Transparently rewrite a legacy CBC value as GCM after successful read.
	 */
	private function migrate_field_to_gcm( string $field, string $stored_value, string $plain ): void {
		$all = $this->load();

		if ( ! isset( $all[ $field ] ) || $all[ $field ] !== $stored_value ) {
			return;
		}

		$encrypted = $this->encrypt( $field, $plain );
		if ( strncmp( $encrypted, self::PREFIX_V2, strlen( self::PREFIX_V2 ) ) !== 0 ) {
			return;
		}

		$all[ $field ] = $encrypted;
		$this->cache   = $all;
		update_option( self::OPTION_KEY, $all, false );
	}

	/**
	 * Derive an encryption key from the current per-install secret.
	 */
	private function derive_key( string $version ): string {
		$secret = function_exists( 'costudio_encryption_secret' )
			? costudio_encryption_secret()
			: ( defined( 'AUTH_KEY' ) && '' !== AUTH_KEY ? AUTH_KEY : wp_salt( 'auth' ) );

		$salt = 'v1' === $version ? 'costudio-standalone-v1' : 'costudio-settings-v2';

		return hash( 'sha256', $secret . $salt, true );
	}

	/**
	 * Candidate keys for reads. The static fallback is decrypt-only so old
	 * values from sites without AUTH_KEY still open and then migrate to GCM.
	 *
	 * @return string[]
	 */
	private function decryption_keys( string $version ): array {
		$keys = array( $this->derive_key( $version ) );

		if ( ! defined( 'AUTH_KEY' ) || '' === AUTH_KEY ) {
			$salt   = 'v1' === $version ? 'costudio-standalone-v1' : 'costudio-settings-v2';
			$legacy = hash( 'sha256', 'costudio-standalone-fallback-key' . $salt, true );
			if ( $legacy !== $keys[0] ) {
				$keys[] = $legacy;
			}
		}

		return $keys;
	}
}
