<?php
/**
 * Settings Manager for BoostBot.
 *
 * @package BoostBot
 * @since   1.0.0
 */

// Prevent direct access
if ( ! defined( 'ABSPATH' ) ) {
    exit;
}

/**
 * Class BoostBot_Settings
 *
 * Manages plugin settings with encryption support for sensitive data.
 *
 * @since 1.0.0
 */
class BoostBot_Settings {

    /**
     * Credits backend base URL (hardcoded).
     *
     * This must not be user-configurable. Admin UI should not expose it, and
     * REST settings updates must ignore it.
     *
     * @since 1.0.0
     */
    const HARD_CODED_BACKEND_BASE_URL = 'https://boostbot.boostmedia.co.il';

    /**
     * Option name prefix.
     *
     * @var string
     */
    const PREFIX = 'boostbot_';

    /**
     * Default settings values.
     *
     * @var array
     */
    private static $defaults = array(
        // =========================================================================
        // BYOK FUNCTIONALITY - FOR FUTURE DELETION
        // The following keys were used for Bring Your Own Key (BYOK) model.
        // Now all AI calls go through the credits backend (Week 26+).
        // Kept for backward compatibility with existing database entries.
        // =========================================================================
        'api_key'                  => '', // BYOK - FOR FUTURE DELETION (Claude API key)
        'ai_provider'              => 'claude', // BYOK - FOR FUTURE DELETION
        'openai_api_key'           => '', // BYOK - FOR FUTURE DELETION (OpenAI API key)
        'openai_model'             => 'gpt-4o-mini', // BYOK - FOR FUTURE DELETION
        'claude_model'             => 'claude-sonnet-4-5-20250929', // BYOK - FOR FUTURE DELETION
        'gemini_api_key'           => '', // BYOK - FOR FUTURE DELETION (local Gemini key)
        // END BYOK DEFAULTS
        // Default indexing model (Gemini 2.5 Flash as recommended for bulk indexing)
        'gemini_indexing_model'    => 'gemini-2.5-flash',
        // Indexing LLM provider: gemini (preferred) with claude fallback
        'indexing_llm_provider'    => 'gemini',
        // Credits backend (Week 26)
        'backend_base_url'         => '',
        'backend_site_id'          => '',
        // Per-site backend API key (returned once from /sites/register; stored encrypted in WP)
        'backend_site_api_key'     => '',

        // Week 29: Optional local ColBERT service (wp_local).
        // If configured, WP can prefer ColBERT retrieval and fall back to embeddings.
        // Example: http://127.0.0.1:7777
        'colbert_base_url'         => '',
        'plan_level'               => 'basic',
        // Monthly usage limits are deprecated (credits is the only limiter).
        'monthly_limit'            => -1,
        'current_usage'            => 0,
        'bot_name'                 => 'BoostBot',
        'welcome_message'          => 'שלום! 👋 איך אוכל לעזור לך היום?',
        'primary_color'            => '#3BC2D0',
        'accent_color'             => '#E32774',
        'position'                 => 'bottom-right',
        'is_active'                => true,
        // Setup wizard completion flag (shown post-install until completed)
        'onboarding_completed'     => false,
        // Setup wizard step-by-step progress (JSON object with step IDs as keys)
        'wizard_progress'          => '{}',
        // Whether the floating wizard panel is shown across all admin pages
        'floating_wizard_enabled'  => true,
        'debug_mode'               => false,
        // Trace logs feature archived — see boostbot/_archive/trace-logs/

        // Week 27.6: Global BrandCard storage (JSON string + version).
        // Stored as per-key boostbot_* options (not a single settings blob).
        'brand_card_global_json'   => '',
        'brand_card_global_version'=> 1,

        // Week 27.7: ServiceCatalog overlay (manual edits + hidden flags).
        // Stored as JSON string to avoid schema churn and keep admin UI simple.
        'service_catalog_overrides_json'    => '',
        'service_catalog_overrides_version' => 1,
        'delete_data_on_uninstall' => true,
        'license_status'           => 'free',
        'settings_frozen'          => false,

        // Week 28: Character caps (visitor input + bot output)
        // Visitor input limiter (widget + server). Default enabled with 500 chars.
        'visitor_input_char_limit_enabled' => true,
        'visitor_input_char_limit'         => 500,
        // Global bot output limiter (words). Per-bot can only reduce.
        'bot_output_char_limit_global'     => 150,

        // Performance: Direct SSE streaming (browser → backend) legacy runtime path.
        // Canonical runtime uses orchestrated flow; keep direct path as fallback.
        'direct_streaming_enabled'         => false,

        // Performance: Gemini Orchestrated flow (Phase 2+3 architecture)
        // When enabled, uses Gemini Flash as orchestrator + GPT executor
        // Provides two-tier chunks, progressive responses, and proactive triggers
        'orchestrated_flow_enabled'        => true,

        // Week 30: Resilient indexing scheduler controls
        // Seconds between processing attempts (default 5s)
        'indexing_rate_limit'              => 5,
        // aggressive|normal|conservative|scheduled
        'indexing_mode'                    => 'normal',
        // Scheduled window (hours 0-23) for 'scheduled' mode
        'indexing_schedule_start'          => 2,
        'indexing_schedule_end'            => 6,
        // Max 1-minute load average before pausing (Linux only, best-effort)
        'indexing_max_load'                => 2.0,

        // CRM / Integrations (Outgoing Webhooks)
        'crm_webhook_enabled'              => false,
        'crm_webhook_url'                  => '',
        // Optional shared secret / API key for the customer's endpoint.
        'crm_webhook_api_key'              => '',
        // Event toggles for webhook sending.
        'crm_webhook_events'               => array(
            'lead_created'         => true,
            'conversation_started' => true,
            'meeting_booked'       => true,
        ),

        // Page transition triggers (when user navigates to page with different bot)
        // When enabled, bot sends contextual message when user moves between pages with different bots
        'page_transition_triggers_enabled' => true,

        // Global rules & forbidden topics (apply to all bots)
        'global_default_welcome_text' => '',
        'global_rules'            => '',
        'global_forbidden_topics' => '',

        // Low-credits alert: threshold & email toggle.
        // When purchased (accumulated) credits drop below threshold, show admin notice + optional email.
        'low_credits_threshold'            => 100,
        'low_credits_email_enabled'        => true,

        // Per-visitor message rate limiting (Week 33).
        // Limits how many messages a single visitor can send to the chatbot per hour / day.
        'visitor_message_limit_enabled'    => true,
        'visitor_messages_per_hour'        => 50,
        'visitor_messages_per_day'         => 200,
        // Burst cooldown: automatically slows down rapid-fire senders.
        'visitor_cooldown_enabled'         => true,
        // Spam alert: notify admin when a visitor hits limits suspiciously often.
        'spam_alert_enabled'               => true,
        'spam_alert_email_enabled'         => true,

        // Smart auto-indexing: auto deep-index new/updated pages covered by rules
        'auto_deep_index_enabled'          => true,
        // Staleness threshold (days) for the "re-index stale" button
        'stale_index_days'                 => 7,

        // Mobile Layout Settings (vw/vh based for responsive chat window positioning)
        // These ensure the chat window fits properly on all mobile devices
        'primary_language'                 => '',        // Auto-detected from locale; admin can override
        'show_on_mobile'                   => true,     // Show widget on mobile devices
        'mobile_layout_width_vw'           => 94,       // Chat window width (94% of viewport width)
        'mobile_layout_height_vh'          => 75,       // Chat window height (75% of viewport height)
        'mobile_layout_margin_top_vh'      => 20,       // Top margin (20vh to leave space for site header)
        'mobile_layout_margin_bottom_vh'   => 5,        // Bottom margin (5vh)
        // Note: side margin is auto-calculated from width: (100 - width) / 2

        // Watermark opt-in (WP.org Guideline 10: must default to off)
        'show_watermark'                   => 'no',
    );

    /**
     * Keys that should be encrypted.
     *
     * @var array
     */
    private static $encrypted_keys = array(
        // BYOK FUNCTIONALITY - FOR FUTURE DELETION (keep for existing data decryption)
        'api_key', // BYOK - FOR FUTURE DELETION
        'openai_api_key', // BYOK - FOR FUTURE DELETION
        'gemini_api_key', // BYOK - FOR FUTURE DELETION
        // END BYOK KEYS
        'backend_site_api_key', // Credits backend - STILL NEEDED
        'crm_webhook_api_key', // CRM webhook - STILL NEEDED
        // Remote-support management key. New writes are encrypted at rest.
        // Legacy plaintext values remain readable: decrypt() returns any value
        // without a known cipher prefix as-is, and the key is 64 hex chars so it
        // can never collide with the sodium:/openssl:/base64: prefixes.
        'management_key_plaintext',
    );

    /**
     * Cache for decrypted values.
     *
     * @var array
     */
    private static $cache = array();

    /**
     * Keys that are per-language (stored as {key}_{language}).
     *
     * @var array
     */
    private static $per_language_keys = array(
        'global_rules',
        'global_forbidden_topics',
        'global_default_welcome_text',
        'brand_card_global_json',
        'brand_card_global_version',
        'service_catalog_overrides_json',
        'service_catalog_overrides_version',
    );

    /**
     * Check whether a settings key is per-language.
     *
     * @param string $key The setting key (without prefix).
     * @return bool
     */
    public static function is_per_language( $key ) {
        return in_array( $key, self::$per_language_keys, true );
    }

    /**
     * Get the list of per-language setting keys.
     *
     * @return array
     */
    public static function get_per_language_keys() {
        return self::$per_language_keys;
    }

    /**
     * Get a per-language setting value.
     *
     * Falls back to the unscoped (legacy) value when no per-language value exists,
     * which allows pre-migration data to keep working.
     *
     * @param string $key      The setting key (without prefix).
     * @param string $language Two-letter language code.
     * @param mixed  $default  Optional default.
     * @return mixed
     */
    public static function get_for_language( $key, $language, $default = null ) {
        if ( empty( $language ) ) {
            return self::get( $key, $default );
        }

        $lang_key   = $key . '_' . $language;
        $option_name = self::PREFIX . $lang_key;
        $value       = get_option( $option_name, null );

        // Distinguish "option row missing" (null) from "explicitly saved empty string".
        // An explicitly-saved '' is a real per-language value (e.g. a cleared global rule /
        // override) and must be honored, not treated as unset and overridden by the legacy
        // unscoped value.
        if ( null !== $value ) {
            if ( isset( self::$cache[ $lang_key ] ) ) {
                return self::$cache[ $lang_key ];
            }
            self::$cache[ $lang_key ] = $value;
            return $value;
        }

        // Fall back to unscoped legacy value (pre-migration).
        return self::get( $key, $default );
    }

    /**
     * Set a per-language setting value.
     *
     * @param string $key      The setting key (without prefix).
     * @param string $language Two-letter language code.
     * @param mixed  $value    The value to set.
     * @return bool
     */
    public static function set_for_language( $key, $language, $value ) {
        if ( empty( $language ) ) {
            return self::set( $key, $value );
        }

        $lang_key    = $key . '_' . $language;
        $option_name = self::PREFIX . $lang_key;

        $result = update_option( $option_name, $value );

        unset( self::$cache[ $lang_key ] );
        // Also bust the unscoped cache so get_for_language fallback re-evaluates.
        unset( self::$cache[ $key ] );

        return $result;
    }

    /**
     * Get a setting value.
     *
     * @param string $key     The setting key (without prefix).
     * @param mixed  $default Optional. Default value if not found.
     * @return mixed The setting value.
     */
    public static function get( $key, $default = null ) {
        // Check cache first
        if ( isset( self::$cache[ $key ] ) ) {
            return self::$cache[ $key ];
        }

        // Credits backend base URL: allow wp-config.php override, otherwise hardcoded.
        if ( 'backend_base_url' === $key ) {
            if ( defined( 'BOOSTBOT_BACKEND_BASE_URL' ) && BOOSTBOT_BACKEND_BASE_URL ) {
                $value = rtrim( (string) BOOSTBOT_BACKEND_BASE_URL, '/' );
            } else {
                $value = rtrim( (string) self::HARD_CODED_BACKEND_BASE_URL, '/' );
            }
            self::$cache[ $key ] = $value;
            return $value;
        }

        // Build full option name
        $option_name = self::PREFIX . $key;

        // Get default from our defaults array if not provided
        if ( null === $default && isset( self::$defaults[ $key ] ) ) {
            $default = self::$defaults[ $key ];
        }

        // Get option value
        $value = get_option( $option_name, $default );

        // Decrypt if necessary
        if ( in_array( $key, self::$encrypted_keys, true ) && ! empty( $value ) ) {
            $value = self::decrypt( $value );
        }

        // Cache the value
        self::$cache[ $key ] = $value;

        return $value;
    }

    /**
     * Set a setting value.
     *
     * @param string $key   The setting key (without prefix).
     * @param mixed  $value The value to set.
     * @return bool True on success, false on failure.
     */
    public static function set( $key, $value ) {
        // Credits backend base URL is hardcoded and not user-configurable.
        if ( 'backend_base_url' === $key ) {
            // Ensure callers can't cache a stale value.
            unset( self::$cache[ $key ] );
            return true;
        }

        // Build full option name
        $option_name = self::PREFIX . $key;

        // Encrypt if necessary
        if ( in_array( $key, self::$encrypted_keys, true ) && ! empty( $value ) ) {
            $value = self::encrypt( $value );
        }

        // Update the option
        $result = update_option( $option_name, $value );

        // Clear cache
        unset( self::$cache[ $key ] );

        // Log setting change
        if ( defined( 'WP_DEBUG' ) && WP_DEBUG && class_exists( 'BoostBot_Logger' ) ) {
            BoostBot_Logger::debug( 'Setting updated', array(
                'key'    => $key,
                'result' => $result,
            ) );
        }

        return $result;
    }

    /**
     * Delete a setting.
     *
     * @param string $key The setting key (without prefix).
     * @return bool True on success, false on failure.
     */
    public static function delete( $key ) {
        $option_name = self::PREFIX . $key;
        $result      = delete_option( $option_name );

        // Clear cache
        unset( self::$cache[ $key ] );

        return $result;
    }

    /**
     * Get all settings.
     *
     * @return array All settings with their values.
     */
    public static function get_all() {
        $settings = array();

        foreach ( array_keys( self::$defaults ) as $key ) {
            $settings[ $key ] = self::get( $key );
        }

        return $settings;
    }

    /**
     * Set default values for all settings.
     *
     * Only sets values that don't already exist.
     *
     * @return void
     */
    public static function set_defaults() {
        foreach ( self::$defaults as $key => $default_value ) {
            $option_name = self::PREFIX . $key;

            if ( false === get_option( $option_name ) ) {
                // Keep welcome message as Hebrew default
                if ( 'welcome_message' === $key && empty( $default_value ) ) {
                    $default_value = 'שלום! 👋 איך אוכל לעזור לך היום?';
                }

                add_option( $option_name, $default_value );
            }
        }
    }

    /**
     * Delete all settings.
     *
     * Use with caution - for uninstall only.
     *
     * @return void
     */
    public static function delete_all() {
        foreach ( array_keys( self::$defaults ) as $key ) {
            self::delete( $key );
        }

        // Clear entire cache
        self::$cache = array();
    }

    /**
     * Get default value for a setting.
     *
     * @param string $key The setting key.
     * @return mixed|null The default value or null if not defined.
     */
    public static function get_default( $key ) {
        return isset( self::$defaults[ $key ] ) ? self::$defaults[ $key ] : null;
    }

    /**
     * Check if a setting exists.
     *
     * @param string $key The setting key (without prefix).
     * @return bool
     */
    public static function exists( $key ) {
        $option_name = self::PREFIX . $key;
        return false !== get_option( $option_name );
    }

    /**
     * Encrypt a value.
     *
     * @param string $value The value to encrypt.
     * @return string The encrypted value.
     */
    public static function encrypt( $value ) {
        if ( empty( $value ) ) {
            return '';
        }

        // Use WordPress salt as the encryption key base
        $key = self::get_encryption_key();

        // Try to use sodium if available (PHP 7.2+)
        if ( function_exists( 'sodium_crypto_secretbox' ) ) {
            try {
                // Generate a nonce
                $nonce = random_bytes( SODIUM_CRYPTO_SECRETBOX_NONCEBYTES );

                // Encrypt
                $ciphertext = sodium_crypto_secretbox( $value, $nonce, $key );

                // Combine nonce + ciphertext and encode
                $encrypted = base64_encode( $nonce . $ciphertext ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode

                return 'sodium:' . $encrypted;
            } catch ( Exception $e ) {
                // Fall back to OpenSSL
                if ( defined( 'WP_DEBUG' ) && WP_DEBUG && class_exists( 'BoostBot_Logger' ) ) {
                    BoostBot_Logger::warning( 'Sodium encryption failed, using OpenSSL', array(
                        'error' => $e->getMessage(),
                    ) );
                }
            }
        }

        // Fallback to OpenSSL
        if ( function_exists( 'openssl_encrypt' ) ) {
            $method  = 'AES-256-CBC';
            $iv      = openssl_random_pseudo_bytes( openssl_cipher_iv_length( $method ) );
            $encrypted = openssl_encrypt( $value, $method, $key, 0, $iv );

            if ( false !== $encrypted ) {
                return 'openssl:' . base64_encode( $iv . '::' . $encrypted ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
            }
        }

        // Last resort: base64 encoding (not secure, but better than plaintext)
        return 'base64:' . base64_encode( $value ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
    }

    /**
     * Decrypt a value.
     *
     * @param string $value The value to decrypt.
     * @return string The decrypted value.
     */
    public static function decrypt( $value ) {
        if ( empty( $value ) ) {
            return '';
        }

        $key = self::get_encryption_key();

        // Sodium encrypted
        if ( strpos( $value, 'sodium:' ) === 0 ) {
            if ( ! function_exists( 'sodium_crypto_secretbox_open' ) ) {
                return '';
            }

            try {
                $encoded = substr( $value, 7 );
                $decoded = base64_decode( $encoded ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode

                if ( false === $decoded ) {
                    return '';
                }

                // Extract nonce and ciphertext
                $nonce      = substr( $decoded, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES );
                $ciphertext = substr( $decoded, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES );

                // Decrypt
                $plaintext = sodium_crypto_secretbox_open( $ciphertext, $nonce, $key );

                return false !== $plaintext ? $plaintext : '';
            } catch ( Exception $e ) {
                if ( defined( 'WP_DEBUG' ) && WP_DEBUG && class_exists( 'BoostBot_Logger' ) ) {
                    BoostBot_Logger::error( 'Decryption failed', array(
                        'error' => $e->getMessage(),
                    ) );
                }
                return '';
            }
        }

        // OpenSSL encrypted
        if ( strpos( $value, 'openssl:' ) === 0 ) {
            if ( ! function_exists( 'openssl_decrypt' ) ) {
                return '';
            }

            $encoded = substr( $value, 8 );
            $decoded = base64_decode( $encoded ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode

            if ( false === $decoded || strpos( $decoded, '::' ) === false ) {
                return '';
            }

            list( $iv, $encrypted ) = explode( '::', $decoded, 2 );
            $method = 'AES-256-CBC';

            $decrypted = openssl_decrypt( $encrypted, $method, $key, 0, $iv );

            return false !== $decrypted ? $decrypted : '';
        }

        // Base64 encoded (fallback)
        if ( strpos( $value, 'base64:' ) === 0 ) {
            $encoded = substr( $value, 7 );
            $decoded = base64_decode( $encoded ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode

            return false !== $decoded ? $decoded : '';
        }

        // No prefix - might be old unencrypted value, return as is
        return $value;
    }

    /**
     * Get the encryption key derived from WordPress salts.
     *
     * @return string The 32-byte encryption key.
     */
    private static function get_encryption_key() {
        // Combine multiple salts for better entropy
        $salt_string = '';

        if ( defined( 'AUTH_KEY' ) ) {
            $salt_string .= AUTH_KEY;
        }
        if ( defined( 'SECURE_AUTH_KEY' ) ) {
            $salt_string .= SECURE_AUTH_KEY;
        }
        if ( defined( 'LOGGED_IN_KEY' ) ) {
            $salt_string .= LOGGED_IN_KEY;
        }

        // Fallback if no salts defined
        if ( empty( $salt_string ) ) {
            $salt_string = get_option( 'siteurl' ) . 'boostbot_default_salt';
        }

        // Create a 32-byte key using SHA-256
        return substr( hash( 'sha256', $salt_string, true ), 0, SODIUM_CRYPTO_SECRETBOX_KEYBYTES ?? 32 );
    }

    /**
     * Check if API key is configured.
     *
     * @return bool
     */
    public static function has_api_key() {
        // BYOK is no longer supported. We require the credits backend to be configured.
        $backend = trim( (string) self::get( 'backend_base_url', '' ) );
        return ! empty( $backend );
    }

    /**
     * Check if the plugin is properly configured.
     *
     * @return array Configuration status with details.
     */
    public static function get_configuration_status() {
        $backend = trim( (string) self::get( 'backend_base_url', '' ) );

        $status = array(
            'is_configured' => false,
            'has_api_key'   => self::has_api_key(),
            'is_active'     => self::get( 'is_active' ),
            'issues'        => array(),
        );

        // Credits backend is required.
        if ( empty( $backend ) ) {
            $status['issues'][] = __( 'Credits backend not configured', 'boostbot' );
        }

        // Check if tables exist
        $db_manager = new BoostBot_DB_Manager();
        if ( ! $db_manager->all_tables_exist() ) {
            $status['issues'][] = __( 'Database tables not properly created', 'boostbot' );
        }

        // Set overall configured status
        $status['is_configured'] = empty( $status['issues'] );

        return $status;
    }

    /**
     * Export settings (for backup/migration).
     *
     * Note: API key is NOT exported for security reasons.
     *
     * @return array Exportable settings.
     */
    public static function export() {
        $settings = self::get_all();

        // Remove sensitive data
        unset( $settings['api_key'] );
        unset( $settings['openai_api_key'] );
        // Credits backend base URL is hardcoded and should not be exposed/exported.
        unset( $settings['backend_base_url'] );

        return array(
            'version'  => BOOSTBOT_VERSION,
            'exported' => current_time( 'mysql' ),
            'settings' => $settings,
        );
    }

    /**
     * Import settings.
     *
     * @param array $data The exported data to import.
     * @return bool|WP_Error True on success, WP_Error on failure.
     */
    public static function import( $data ) {
        if ( ! isset( $data['settings'] ) || ! is_array( $data['settings'] ) ) {
            return new WP_Error( 'invalid_data', __( 'Invalid import data format', 'boostbot' ) );
        }

        // Don't import certain sensitive fields
        $skip_fields = array( 'api_key', 'openai_api_key' );

        foreach ( $data['settings'] as $key => $value ) {
            if ( in_array( $key, $skip_fields, true ) ) {
                continue;
            }

            // Only import known settings
            if ( isset( self::$defaults[ $key ] ) ) {
                self::set( $key, $value );
            }
        }

        return true;
    }

    /**
     * Clear the settings cache.
     *
     * @return void
     */
    public static function clear_cache() {
        self::$cache = array();
    }
}







