<?php
/**
 * Antara — AES-256-GCM symmetric encryption with managed master key.
 *
 * Storage format (string, ASCII safe):
 *   v1|<iv_b64url>|<tag_b64url>|<ciphertext_b64url>
 *
 * The master key is preferentially stored OUTSIDE the document root, at
 * optional parent/{opaque_basename}/master.key (basename from installer or
 * default fx_site_{hash} from the app install path), then legacy parent/antara_secrets/master.key
 * at the same parent levels, then storage/secure/.master.key inside the app.
 *
 * If that location is not writable (permission issue, hosted env), it falls
 * back to <docroot>/storage/secure/.master.key, which is protected by
 * .htaccess "Require all denied" + a top-level deny on /storage.
 *
 * The key file contains 32 raw bytes (no encoding, no trailing newline).
 */

if (!defined('ANTARA_ROOT')) {
    define('ANTARA_ROOT', dirname(__DIR__, 2));
}

require_once __DIR__ . '/vault_state.php';

final class AntaraCrypto
{
    private const CIPHER = 'aes-256-gcm';
    private const KEY_BYTES = 32;
    private const IV_BYTES = 12;     // 96-bit IV recommended for GCM
    private const TAG_BYTES = 16;
    private const VERSION = 'v1';

    private static ?string $cachedKey = null;
    private static ?string $cachedKeyPath = null;

    /** Returns the absolute path to the master key, creating it if missing. */
    public static function masterKeyPath(): string
    {
        if (self::$cachedKeyPath !== null) {
            return self::$cachedKeyPath;
        }

        $candidates = AntaraVaultState::masterKeyCandidatePaths();

        foreach ($candidates as $path) {
            if (is_file($path) && filesize($path) >= self::KEY_BYTES) {
                return self::$cachedKeyPath = $path;
            }
        }

        if (AntaraVaultState::hasNonEmptyStoreCiphertexts()) {
            throw new RuntimeException(
                'Hay datos cifrados guardados pero no se encontró una clave de cifrado válida para leerlos. '
                . 'No se creará una clave nueva sola (evitaría descifrar lo que ya tienes). '
                . 'Restaura la clave correcta de esta instalación o la zona interna protegida, '
                . 'o abre la recuperación desde el instalador: ' . self::recoveryRelativeHint()
            );
        }

        foreach ($candidates as $path) {
            $dir = dirname($path);
            if (!is_dir($dir)) {
                if (!@mkdir($dir, 0700, true) && !is_dir($dir)) {
                    continue;
                }
            }
            if (!is_writable($dir)) {
                continue;
            }
            $bytes = random_bytes(self::KEY_BYTES);
            $tmp = $path . '.tmp.' . bin2hex(random_bytes(4));
            if (@file_put_contents($tmp, $bytes, LOCK_EX) === false) {
                continue;
            }
            @chmod($tmp, 0600);
            if (!@rename($tmp, $path)) {
                @unlink($tmp);
                continue;
            }
            @chmod($path, 0600);
            return self::$cachedKeyPath = $path;
        }

        throw new RuntimeException('No se pudo crear la clave de cifrado en ninguna ubicación permitida; revisa permisos con quien administra el hosting.');
    }

    /** Clears cached master key path/key (e.g. after changing external secrets basename during install). */
    public static function resetMasterKeyPathCache(): void
    {
        self::$cachedKeyPath = null;
        self::$cachedKey = null;
    }

    /** Human hint for recovery URL (no dependency on install_public). */
    private static function recoveryRelativeHint(): string
    {
        $sn = str_replace('\\', '/', (string) ($_SERVER['SCRIPT_NAME'] ?? ''));
        if (preg_match('#^(.+)/#', $sn, $m)) {
            return rtrim($m[1], '/') . '/install/recover.php';
        }
        return '/install/recover.php';
    }

    public static function masterKey(): string
    {
        if (self::$cachedKey !== null) {
            return self::$cachedKey;
        }
        $path = self::masterKeyPath();
        $raw = @file_get_contents($path);
        if ($raw === false || strlen($raw) < self::KEY_BYTES) {
            throw new RuntimeException('La clave de cifrado no está disponible o está dañada.');
        }
        return self::$cachedKey = substr($raw, 0, self::KEY_BYTES);
    }

    public static function encrypt(string $plaintext, string $aad = ''): string
    {
        $key = self::masterKey();
        $iv = random_bytes(self::IV_BYTES);
        $tag = '';
        $ciphertext = openssl_encrypt(
            $plaintext,
            self::CIPHER,
            $key,
            OPENSSL_RAW_DATA,
            $iv,
            $tag,
            $aad,
            self::TAG_BYTES
        );
        if ($ciphertext === false) {
            throw new RuntimeException('AntaraCrypto: encryption failed.');
        }
        return self::VERSION . '|' . self::b64u_encode($iv) . '|' . self::b64u_encode($tag) . '|' . self::b64u_encode($ciphertext);
    }

    public static function decrypt(string $payload, string $aad = ''): string
    {
        $parts = explode('|', $payload, 4);
        if (count($parts) !== 4 || $parts[0] !== self::VERSION) {
            throw new RuntimeException('AntaraCrypto: unsupported payload format.');
        }
        $iv = self::b64u_decode($parts[1]);
        $tag = self::b64u_decode($parts[2]);
        $ciphertext = self::b64u_decode($parts[3]);
        if (strlen($iv) !== self::IV_BYTES || strlen($tag) !== self::TAG_BYTES) {
            throw new RuntimeException('AntaraCrypto: invalid IV or tag length.');
        }
        $plaintext = openssl_decrypt(
            $ciphertext,
            self::CIPHER,
            self::masterKey(),
            OPENSSL_RAW_DATA,
            $iv,
            $tag,
            $aad
        );
        if ($plaintext === false) {
            throw new RuntimeException('AntaraCrypto: decryption failed (tag mismatch or corrupt data).');
        }
        return $plaintext;
    }

    public static function encryptJson($data, string $aad = ''): string
    {
        $json = json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
        return self::encrypt($json, $aad);
    }

    public static function decryptJson(string $payload, string $aad = '', bool $assoc = true)
    {
        $json = self::decrypt($payload, $aad);
        return json_decode($json, $assoc, 512, JSON_THROW_ON_ERROR);
    }

    /** Atomic write: tmp file + rename. Returns true on success. */
    public static function writeAtomic(string $path, string $contents): bool
    {
        require_once __DIR__ . '/paths.php';
        if (!AntaraPaths::isStorageManagedFile($path)) {
            if (class_exists('AntaraLogger', false)) {
                AntaraLogger::log('crypto.write_rejected_path', ['basename' => basename($path)]);
            }
            return false;
        }
        $dir = dirname($path);
        if (!is_dir($dir) && !@mkdir($dir, 0700, true) && !is_dir($dir)) {
            return false;
        }
        $tmp = $path . '.tmp.' . bin2hex(random_bytes(4));
        if (@file_put_contents($tmp, $contents, LOCK_EX) === false) {
            return false;
        }
        @chmod($tmp, 0600);
        if (!@rename($tmp, $path)) {
            @unlink($tmp);
            return false;
        }
        @chmod($path, 0600);
        return true;
    }

    public static function b64u_encode(string $bin): string
    {
        return rtrim(strtr(base64_encode($bin), '+/', '-_'), '=');
    }

    public static function b64u_decode(string $s): string
    {
        $pad = strlen($s) % 4;
        if ($pad) $s .= str_repeat('=', 4 - $pad);
        $bin = base64_decode(strtr($s, '-_', '+/'), true);
        if ($bin === false) {
            throw new RuntimeException('AntaraCrypto: invalid base64url input.');
        }
        return $bin;
    }
}
