<?php
/**
 * FormForge Crypto helper — encrypt sensitive option values at rest.
 *
 * Stripe secret keys, webhook secrets, reCAPTCHA secret, third-party API keys
 * are stored in `wp_options` as plaintext historically. A DB dump (backup,
 * stolen export, mis-permissioned phpMyAdmin) leaks every site's credentials
 * in one shot. Encrypt the values with AES-256-CBC keyed off `wp_salt` so
 * the ciphertext alone is useless without the secrets file.
 *
 * Backwards compatibility:
 * - decrypt detects an encrypted payload by the "ffenc1:" prefix.
 * Anything without the prefix is returned as-is so legacy plaintext
 * options keep working until the next save (which writes ciphertext).
 *
 * Constraints:
 * - PHP 7.4 baseline. openssl_* extension is part of WP requirements.
 * - wp_salt values rotate only when the user explicitly rotates them.
 * We use `secure_auth` (high-entropy, one of the 4 main salts) — same
 * salt used by `class-submissions-ai.php` for the OpenAI API key.
 *
 * @package FormForge
 * @since 1.0.2
 */

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

class FORMFORGE_Crypto {

    /**
 * Versioned prefix so we can identify FF-encrypted blobs vs. plaintext
 * (legacy) and in future migrate to a different cipher / key scheme
 * without breaking older payloads.
 */
    const PREFIX = 'ffenc1:';

    const CIPHER = 'aes-256-cbc';

    /**
 * Encrypt plaintext, returning a base64 payload prefixed with PREFIX.
 * Empty strings pass through unchanged so option getters can stay simple.
 */
    public static function encrypt($plaintext) {
        if (!is_string($plaintext) || $plaintext === '') {
            return $plaintext;
        }
        // Idempotent: if the value is already encrypted, don't double-wrap.
        if (self::is_encrypted($plaintext)) {
            return $plaintext;
        }
        if (!function_exists('openssl_encrypt') || !function_exists('openssl_random_pseudo_bytes')) {
            // openssl not available — fail open (return plaintext) rather than
            // silently dropping the value. WP core requires openssl on every
            // host that supports HTTPS to itself, so this branch is theoretical.
            return $plaintext;
        }

        $key = self::derive_key();
        $iv  = openssl_random_pseudo_bytes(16);
        $cipher_raw = openssl_encrypt($plaintext, self::CIPHER, $key, OPENSSL_RAW_DATA, $iv);
        if ($cipher_raw === false) {
            return $plaintext;
        }
        return self::PREFIX . base64_encode($iv . $cipher_raw);
    }

    /**
 * Decrypt a value previously produced by encrypt. Plaintext or any
 * unrecognised payload is returned as-is (backwards compat).
 */
    public static function decrypt($value) {
        if (!is_string($value) || $value === '') {
            return $value;
        }
        if (!self::is_encrypted($value)) {
            // Legacy plaintext value — return unchanged so callers keep working.
            return $value;
        }
        if (!function_exists('openssl_decrypt')) {
            return '';
        }

        $b64 = substr($value, strlen(self::PREFIX));
        $raw = base64_decode($b64, true);
        if ($raw === false || strlen($raw) < 17) {
            return '';
        }
        $iv         = substr($raw, 0, 16);
        $cipher_raw = substr($raw, 16);
        $plain      = openssl_decrypt($cipher_raw, self::CIPHER, self::derive_key(), OPENSSL_RAW_DATA, $iv);
        return ($plain === false) ? '' : $plain;
    }

    /**
 * True if the value is an FF-encrypted blob (PREFIX + base64). Used by
 * settings-render code to detect "is the stored value already encrypted?"
 * without round-tripping through decrypt.
 */
    public static function is_encrypted($value) {
        return is_string($value) && strpos($value, self::PREFIX) === 0;
    }

    /**
 * 32-byte key derived from wp_salt. hash with raw_output=true gives
 * exactly the 32 bytes AES-256 needs.
 */
    private static function derive_key() {
        $salt = function_exists('wp_salt') ? wp_salt('secure_auth') : '';
        // Defensive fallback if wp_salt isn't available (CLI install partial).
        if (!is_string($salt) || $salt === '') {
            $salt = (defined('AUTH_KEY') ? AUTH_KEY : '') . (defined('SECURE_AUTH_KEY') ? SECURE_AUTH_KEY : '');
        }
        return hash('sha256', 'formforge|crypto|v1|' . $salt, true);
    }

    /**
 * Convenience wrappers for nested option arrays. Walks the supplied
 * array, encrypts every value whose key matches one of $sensitive_keys,
 * and returns the new array. Caller-friendly so save handlers stay
 * one-liners.
 *
 * Example:
 * $stripe = FORMFORGE_Crypto::encrypt_keys($input, [
 * 'test_secret_key', 'live_secret_key', 'webhook_secret'
 * ]);
 * update_option('formforge_stripe_settings', $stripe);
 */
    public static function encrypt_keys(array $values, array $sensitive_keys) {
        foreach ($sensitive_keys as $k) {
            if (isset($values[$k]) && is_string($values[$k]) && $values[$k] !== '') {
                $values[$k] = self::encrypt($values[$k]);
            }
        }
        return $values;
    }
}
