<?php
/**
 * TODO-CLIENT-API-001 (2026-08-01): encrypted at-rest storage for sensitive
 * credentials in wp_options.
 *
 * Wraps sodium_crypto_secretbox keyed off WordPress's per-install secret
 * (LOGGED_IN_KEY + LOGGED_IN_SALT, falling back to AUTH_KEY + AUTH_SALT).
 *
 * Storage format (single option value):
 *   base64( nonce(24 bytes) || ciphertext )
 *
 * Why this matters:
 *   - HMAC shared secret + license key were previously stored plaintext
 *     in wp_options. A leaked DB (backup, SQL injection in another plugin,
 *     shared hosting breach) = credential theft.
 *   - sodium_crypto_secretbox is authenticated encryption (AE): tampered
 *     ciphertexts fail decryption (no silent garbage output).
 *   - Keying off LOGGED_IN_KEY makes the cipher per-install. If the operator
 *     rotates their WP secret salts (rare but happens), EncryptedOption
 *     will fail to decrypt → returns $default → caller re-prompts for the
 *     secret via /public-key.
 *
 * Migration path:
 *   - Existing plaintext values get auto-re-encrypted on first read.
 *     We detect "this value is plaintext (not base64-ciphertext)" and
 *     transparently migrate it.
 *
 * @package SwapAds\Client\Security
 * @since   1.5.9
 */

declare(strict_types=1);

namespace SwapAds\Client\Security;

/**
 * Class EncryptedOption
 *
 * @since 1.5.9
 */
final class EncryptedOption
{
    /**
     * Version byte prepended to the ciphertext.
     *
     * Bumped if we ever change the cipher (e.g., switch from secretbox to
     * secretbox_xchacha20poly1305). Allows future migration logic to know
     * which cipher was used for a given stored value.
     *
     * Layout in storage:
     *   v1 = base64( version(1) || nonce(24) || ciphertext )
     */
    private const CIPHER_VERSION = 0x01;

    /**
     * Expected ciphertext prefix length (version + nonce).
     */
    private const HEADER_BYTES = 1 + SODIUM_CRYPTO_SECRETBOX_NONCEBYTES;

    /**
     * Store a plaintext value encrypted at rest.
     *
     * @param string $key       WordPress option key.
     * @param string $plaintext The plaintext value to encrypt.
     *
     * @return bool True on success.
     *
     * @since 1.5.9
     */
    public static function put(string $key, string $plaintext): bool
    {
        if ($plaintext === '') {
            return (bool) update_option($key, '', '');
        }

        if (!self::sodiumReady()) {
            // Fallback to plaintext + an audit-flag option so we can
            // detect sites where encryption is unavailable (PHP < 7.2).
            update_option($key . '_unencrypted', '1', '');
            return (bool) update_option($key, $plaintext, '');
        }

        $keyBytes = self::deriveKey();
        $nonce    = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
        $cipher   = sodium_crypto_secretbox($plaintext, $nonce, $keyBytes);
        $blob     = chr(self::CIPHER_VERSION) . $nonce . $cipher;
        $stored   = base64_encode($blob);

        delete_option($key . '_unencrypted');
        return (bool) update_option($key, $stored, '');
    }

    /**
     * Retrieve and decrypt a previously-stored value.
     *
     * If the stored value is in legacy plaintext format (no version prefix),
     * transparently migrate it: return the plaintext AND re-encrypt on disk.
     *
     * @param string $key     WordPress option key.
     * @param string $default Default to return when nothing is stored or
     *                        decryption fails.
     *
     * @return string Decrypted plaintext, or $default.
     *
     * @since 1.5.9
     */
    public static function get(string $key, string $default = ''): string
    {
        $stored = (string) get_option($key, '');
        if ($stored === '') {
            return $default;
        }

        if (!self::sodiumReady()) {
            return $stored;
        }

        $raw = base64_decode($stored, true);
        if ($raw === false || strlen($raw) < self::HEADER_BYTES) {
            // Legacy plaintext OR corrupt. Try as plaintext.
            // Migrate forward (re-encrypt on disk).
            update_option($key, $stored, '');
            self::put($key, $stored);
            return $stored;
        }

        $version = ord($raw[0]);
        if ($version !== self::CIPHER_VERSION) {
            // Unknown version — treat as legacy plaintext.
            return $stored;
        }

        $keyBytes = self::deriveKey();
        $nonce    = substr($raw, 1, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
        $cipher   = substr($raw, 1 + SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
        $plain    = sodium_crypto_secretbox_open($cipher, $nonce, $keyBytes);

        if ($plain === false) {
            // Decryption failed — wrong key (LOGGED_IN_KEY rotated) or
            // tampered ciphertext. Return $default; the caller will
            // re-fetch from /public-key on next ensure.
            return $default;
        }

        return $plain;
    }

    /**
     * Delete an encrypted option (and its legacy migration flag).
     *
     * @param string $key WordPress option key.
     *
     * @return bool True on success.
     *
     * @since 1.5.9
     */
    public static function delete(string $key): bool
    {
        delete_option($key . '_unencrypted');
        return (bool) delete_option($key);
    }

    /**
     * Is the lib sodium extension available?
     *
     * PHP 7.2+ ships with sodium bundled; 8.0+ requires the extension.
     * If absent, we fall back to plaintext (with an audit flag).
     *
     * @return bool
     *
     * @since 1.5.9
     */
    public static function sodiumReady(): bool
    {
        return extension_loaded('sodium')
            && function_exists('sodium_crypto_secretbox')
            && function_exists('sodium_crypto_secretbox_open')
            && defined('SODIUM_CRYPTO_SECRETBOX_NONCEBYTES');
    }

    /**
     * Derive a 32-byte cipher key from WP's per-install secret salts.
     *
     * We use SHA-256 of the salt + a domain tag to produce a fixed-length
     * key suitable for secretbox. The domain tag prevents the same key
     * from being used for other purposes.
     *
     * @return string 32-byte raw key.
     *
     * @since 1.5.9
     */
    private static function deriveKey(): string
    {
        $base = '';
        if (defined('LOGGED_IN_KEY') && LOGGED_IN_KEY !== '') {
            $base = (string) LOGGED_IN_KEY;
        } elseif (defined('AUTH_KEY') && AUTH_KEY !== '') {
            $base = (string) AUTH_KEY;
        }
        $salt = '';
        if (defined('LOGGED_IN_SALT') && LOGGED_IN_SALT !== '') {
            $salt = (string) LOGGED_IN_SALT;
        } elseif (defined('AUTH_SALT') && AUTH_SALT !== '') {
            $salt = (string) AUTH_SALT;
        }

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