<?php

namespace WPSN\Core\Secrets;

defined('ABSPATH') || exit;

/**
 * Encrypts sensitive option values at rest (stored in wp_options).
 *
 * We store an array wrapper so existing code that expects an array can still
 * work after decrypting.
 *
 * Wrapper format:
 *  [
 *    '__wpsn_enc' => 1,
 *    'alg' => 'aes-256-cbc+hmac-sha256',
 *    'kid' => 'v1',
 *    'v'   => 'base64(iv).base64(cipher).base64(hmac)'
 *  ]
 */
final class EncryptedOption
{
    private const MARKER = '__wpsn_enc';
    private const ALG = 'aes-256-cbc+hmac-sha256';
    private const KID = 'v1';

    public static function encryptionAvailable(): bool
    {
        return function_exists('openssl_encrypt')
            && function_exists('openssl_decrypt')
            && function_exists('hash_hmac')
            && function_exists('hash_equals');
    }

    /**
     * @param mixed $value
     */
    public static function isEncrypted($value): bool
    {
        return is_array($value)
            && isset($value[self::MARKER])
            && (int) $value[self::MARKER] === 1
            && isset($value['v'])
            && is_string($value['v'])
            && $value['v'] !== '';
    }

    /**
     * @return array<string,mixed>
     */
    public static function getArray(string $optionName): array
    {
        $raw = get_option($optionName);

        if (self::isEncrypted($raw)) {
            $json = self::decryptWrapper($raw);
            if ($json === null) {
                return [];
            }

            $decoded = json_decode($json, true);
            return is_array($decoded) ? $decoded : [];
        }

        return is_array($raw) ? $raw : [];
    }

    /**
     * @param array<string,mixed> $data
     */
    public static function updateArray(string $optionName, array $data): void
    {
        $toStore = $data;

        $json = wp_json_encode($data);
        if (!is_string($json)) {
            $json = json_encode($data);
        }

        if (is_string($json) && $json !== '' && self::encryptionAvailable()) {
            $enc = self::encryptString($json);
            if (is_array($enc)) {
                $toStore = $enc;
            }
        }

        self::updateOptionNoAutoload($optionName, $toStore);
    }

    public static function getString(string $optionName): string
    {
        $raw = get_option($optionName, '');

        if (self::isEncrypted($raw)) {
            $plain = self::decryptWrapper($raw);
            return is_string($plain) ? $plain : '';
        }

        return is_string($raw) ? trim($raw) : '';
    }

    public static function updateString(string $optionName, string $value): void
    {
        $value = trim($value);
        $toStore = $value;

        if ($value !== '' && self::encryptionAvailable()) {
            $enc = self::encryptString($value);
            if (is_array($enc)) {
                $toStore = $enc;
            }
        }

        self::updateOptionNoAutoload($optionName, $toStore);
    }

    /**
     * If the option currently stores a plaintext array (legacy), encrypt it.
     */
    public static function maybeEncryptLegacyArray(string $optionName): bool
    {
        $raw = get_option($optionName, null);
        if (!is_array($raw) || self::isEncrypted($raw)) {
            return false;
        }

        if (empty($raw)) {
            return false;
        }

        if (!self::encryptionAvailable()) {
            return false;
        }

        self::updateArray($optionName, $raw);
        return true;
    }

    public static function maybeEncryptLegacyString(string $optionName): bool
    {
        $raw = get_option($optionName, null);
        if ($raw === null || self::isEncrypted($raw)) {
            return false;
        }

        if (!is_string($raw)) {
            return false;
        }

        $raw = trim($raw);
        if ($raw === '') {
            return false;
        }

        if (!self::encryptionAvailable()) {
            return false;
        }

        self::updateString($optionName, $raw);
        return true;
    }

    /**
     * @param mixed $value
     */
    private static function updateOptionNoAutoload(string $optionName, $value): void
    {
        // autoload = no (avoid bloating options autoload)
        if (get_option($optionName, null) === null) {
            add_option($optionName, $value, '', false);
            return;
        }

        update_option($optionName, $value, false);
    }

    /**
     * @return array<string,mixed>|null
     */
    private static function encryptString(string $plaintext): ?array
    {
        $material = self::keyMaterial();
        if ($material === '') {
            return null;
        }

        $encKey = hash('sha256', $material . '|enc', true);
        $macKey = hash('sha256', $material . '|mac', true);

        try {
            $iv = random_bytes(16);
        } catch (\Throwable $e) {
            return null;
        }

        $cipher = openssl_encrypt($plaintext, 'aes-256-cbc', $encKey, OPENSSL_RAW_DATA, $iv);
        if (!is_string($cipher) || $cipher === '') {
            return null;
        }

        $hmac = hash_hmac('sha256', $iv . $cipher, $macKey, true);

        $payload = rtrim(base64_encode($iv), '=')
            . '.' . rtrim(base64_encode($cipher), '=')
            . '.' . rtrim(base64_encode($hmac), '=');

        return [
            self::MARKER => 1,
            'alg' => self::ALG,
            'kid' => self::KID,
            'v'   => $payload,
        ];
    }

    /**
     * @param array<string,mixed> $wrapper
     */
    private static function decryptWrapper(array $wrapper): ?string
    {
        if (!isset($wrapper['v']) || !is_string($wrapper['v']) || $wrapper['v'] === '') {
            return null;
        }

        $material = self::keyMaterial();
        if ($material === '') {
            return null;
        }

        $encKey = hash('sha256', $material . '|enc', true);
        $macKey = hash('sha256', $material . '|mac', true);

        $parts = explode('.', $wrapper['v']);
        if (count($parts) !== 3) {
            return null;
        }

        [$ivB64, $cipherB64, $hmacB64] = $parts;

        $iv = base64_decode($ivB64, true);
        $cipher = base64_decode($cipherB64, true);
        $hmac = base64_decode($hmacB64, true);

        if (!is_string($iv) || !is_string($cipher) || !is_string($hmac)) {
            return null;
        }

        $calc = hash_hmac('sha256', $iv . $cipher, $macKey, true);
        if (!hash_equals($calc, $hmac)) {
            return null;
        }

        $plain = openssl_decrypt($cipher, 'aes-256-cbc', $encKey, OPENSSL_RAW_DATA, $iv);
        return is_string($plain) ? $plain : null;
    }

    private static function keyMaterial(): string
    {
        // Allow a dedicated constant for predictable keying across environments.
        if (defined('WPSN_SECRET_KEY')) {
            $k = (string) constant('WPSN_SECRET_KEY');
            $k = trim($k);
            if ($k !== '') {
                return $k;
            }
        }

        // Prefer WP salts (stable per site).
        if (function_exists('wp_salt')) {
            $salt = (string) wp_salt('auth');
            $salt = trim($salt);
            if ($salt !== '') {
                return $salt;
            }
        }

        // Fallback: build from known constants.
        $parts = [];
        foreach (['AUTH_KEY', 'SECURE_AUTH_KEY', 'LOGGED_IN_KEY', 'NONCE_KEY'] as $c) {
            if (defined($c)) {
                $v = trim((string) constant($c));
                if ($v !== '') {
                    $parts[] = $v;
                }
            }
        }

        $site = function_exists('site_url') ? (string) site_url() : '';
        $site = trim($site);
        if ($site !== '') {
            $parts[] = $site;
        }

        $material = implode('|', $parts);
        return trim($material);
    }
}
