<?php

declare(strict_types=1);

namespace PpPhp\Core\Auth;

use PpPhp\Core\DB;

/**
 * Field Encryption for a generated app — encrypts sensitive columns at rest.
 *
 * Which columns are encrypted is authored in the console (Security → Advanced →
 * Field Encryption) and stored in `field_encryption` (shared project DB), so it
 * is read LIVE here — no rebuild needed when the list changes (like RLS).
 *
 * Scheme (chosen for security + safety + maintainability):
 *  - AES-256-GCM (authenticated: tampering is detected) with a random 12-byte IV
 *    per value, so the same plaintext never yields the same ciphertext (no
 *    equality leakage). The trade-off — encrypted columns are NOT searchable —
 *    is intentional and documented; encrypt only truly sensitive fields.
 *  - Self-describing, versioned token: `ppenc:1:` . base64(iv|tag|ciphertext).
 *    `decrypt()` returns any NON-prefixed value unchanged, so plaintext that
 *    predates encryption keeps working and no forced migration is required.
 *  - The key lives ONLY in the app config (PP_ENC_KEY constant), never in the
 *    database, so a database leak alone cannot decrypt the data.
 */
final class FieldCrypto
{
    private const PREFIX = 'ppenc:1:';
    private const CIPHER = 'aes-256-gcm';
    private const IV_LEN = 12;
    private const TAG_LEN = 16;

    /** '' once resolved-and-empty, the raw 32-byte key once resolved-and-valid, null before resolving. */
    private static ?string $key = null;
    /** @var array<string,string[]> table => enabled column names */
    private static array $colCache = [];

    /** The raw key, or null when encryption is not configured/available. */
    private static function key(): ?string
    {
        if (self::$key !== null) return self::$key !== '' ? self::$key : null;
        $b64 = defined('PP_ENC_KEY') ? (string) constant('PP_ENC_KEY') : '';
        $raw = $b64 !== '' ? base64_decode($b64, true) : false;
        self::$key = ($raw !== false && strlen($raw) === 32 && function_exists('openssl_encrypt')) ? $raw : '';
        return self::$key !== '' ? self::$key : null;
    }

    public static function enabled(): bool
    {
        return self::key() !== null;
    }

    /**
     * Override the key (base64 of 32 bytes) — used by the console migration, which
     * supplies the persisted project key rather than the app's PP_ENC_KEY constant.
     */
    public static function useKey(string $b64): bool
    {
        $raw = base64_decode($b64, true);
        self::$key = ($raw !== false && strlen($raw) === 32 && function_exists('openssl_encrypt')) ? $raw : '';
        self::$colCache = [];
        return self::$key !== '';
    }

    /**
     * One-time migration: encrypt existing PLAINTEXT values in the enabled columns.
     * Idempotent — already-encrypted tokens are skipped. Runs against the current DB
     * connection. Returns per-table counts.
     *
     * @return array{ok:bool,error?:string,encrypted:int,tables:array<string,int>}
     */
    public static function migrate(?string $only = null): array
    {
        if (self::key() === null) return ['ok' => false, 'error' => 'Encryption key is not available.', 'encrypted' => 0, 'tables' => []];
        $out = ['ok' => true, 'encrypted' => 0, 'tables' => []];
        try {
            $rules = DB::FetchAll('SELECT table_name, column_name FROM `field_encryption` WHERE enabled = 1');
        } catch (\Throwable $e) {
            return ['ok' => false, 'error' => 'Could not read the encryption settings.', 'encrypted' => 0, 'tables' => []];
        }
        $byTable = [];
        foreach ((array) $rules as $r) {
            $t = (string) $r['table_name']; $c = (string) $r['column_name'];
            if ($only !== null && $t !== $only) continue;
            if (preg_match('/^[A-Za-z0-9_]+$/', $t) && preg_match('/^[A-Za-z0-9_]+$/', $c)) $byTable[$t][] = $c;
        }
        foreach ($byTable as $table => $cols) {
            $n = 0;
            $sel = implode(', ', array_map(static fn($c) => "`$c`", $cols));
            try {
                $rows = DB::FetchAll("SELECT id, $sel FROM `$table`");
            } catch (\Throwable $e) {
                $out['tables'][$table] = 0;
                continue;
            }
            foreach ((array) $rows as $row) {
                $upd = [];
                foreach ($cols as $c) {
                    $v = $row[$c] ?? null;
                    if (is_string($v) && $v !== '' && !self::isToken($v)) $upd[$c] = self::encrypt($v);
                }
                if ($upd !== [] && isset($row['id'])) {
                    try { DB::Update($table, $upd, ['id' => $row['id']]); $n += count($upd); }
                    catch (\Throwable $e) { /* e.g. column too small — skip this row */ }
                }
            }
            $out['tables'][$table] = $n;
            $out['encrypted'] += $n;
        }
        return $out;
    }

    public static function isToken(string $v): bool
    {
        return strncmp($v, self::PREFIX, strlen(self::PREFIX)) === 0;
    }

    /** Encrypt one value → token. No-op if no key, empty, or already a token. */
    public static function encrypt(string $plain): string
    {
        $key = self::key();
        if ($key === null || $plain === '' || self::isToken($plain)) return $plain;
        try {
            $iv = random_bytes(self::IV_LEN);
            $tag = '';
            $ct = openssl_encrypt($plain, self::CIPHER, $key, OPENSSL_RAW_DATA, $iv, $tag);
            if ($ct === false) return $plain;
            return self::PREFIX . base64_encode($iv . $tag . $ct);
        } catch (\Throwable $e) {
            return $plain; // never lose the write over an encryption hiccup
        }
    }

    /** Decrypt a token → plaintext. Any non-token value passes through unchanged. */
    public static function decrypt(string $v): string
    {
        if (!self::isToken($v)) return $v;
        $key = self::key();
        if ($key === null) return $v;
        $raw = base64_decode(substr($v, strlen(self::PREFIX)), true);
        if ($raw === false || strlen($raw) <= self::IV_LEN + self::TAG_LEN) return $v;
        $iv  = substr($raw, 0, self::IV_LEN);
        $tag = substr($raw, self::IV_LEN, self::TAG_LEN);
        $ct  = substr($raw, self::IV_LEN + self::TAG_LEN);
        try {
            $pt = openssl_decrypt($ct, self::CIPHER, $key, OPENSSL_RAW_DATA, $iv, $tag);
            return $pt === false ? $v : $pt;
        } catch (\Throwable $e) {
            return $v;
        }
    }

    /** Encrypt the enabled columns present in a write payload (insert/update). */
    public static function encryptRow(string $table, array $data): array
    {
        if (self::key() === null) return $data;
        foreach (self::columnsFor($table) as $c) {
            if (array_key_exists($c, $data) && $data[$c] !== null && !is_array($data[$c])) {
                $data[$c] = self::encrypt((string) $data[$c]);
            }
        }
        return $data;
    }

    /** Decrypt the enabled columns of a single fetched row. */
    public static function decryptRow(string $table, array $row): array
    {
        if (self::key() === null) return $row;
        foreach (self::columnsFor($table) as $c) {
            if (isset($row[$c]) && is_string($row[$c])) $row[$c] = self::decrypt($row[$c]);
        }
        return $row;
    }

    /** Decrypt the enabled columns across a list of rows (by reference-safe copy). */
    public static function decryptRows(string $table, array $rows): array
    {
        if (self::key() === null || $rows === []) return $rows;
        $cols = self::columnsFor($table);
        if ($cols === []) return $rows;
        foreach ($rows as &$row) {
            if (is_array($row)) {
                foreach ($cols as $c) {
                    if (isset($row[$c]) && is_string($row[$c])) $row[$c] = self::decrypt($row[$c]);
                }
            }
        }
        unset($row);
        return $rows;
    }

    /** @return string[] the columns marked encrypted for $table (cached). */
    public static function columnsFor(string $table): array
    {
        if (isset(self::$colCache[$table])) return self::$colCache[$table];
        $cols = [];
        try {
            $rows = DB::FetchAll('SELECT column_name FROM `field_encryption` WHERE table_name = ? AND enabled = 1', [$table]);
            // FetchAll may return false (not an exception) when the table is
            // absent — the common case for apps that never configured field
            // encryption. (array) false is [false], so guard each row is an array
            // before reading the column, or every list/view with data warns
            // "array offset on false".
            foreach ((array) $rows as $r) {
                if (is_array($r) && isset($r['column_name'])) {
                    $cols[] = (string) $r['column_name'];
                }
            }
        } catch (\Throwable $e) {
            $cols = []; // table missing on this deployment → nothing encrypted (safe)
        }
        return self::$colCache[$table] = $cols;
    }
}
