<?php

namespace App\Src;

use App\Casts\OptionalEncrypted;

use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;

class Encryption
{
    public static array $modelsWithEncryption = [
        \Mtc\Modules\Members\Models\Member::class,
        \Mtc\Modules\Members\Models\MembersAddress::class,
        \Mtc\Modules\Members\Models\MemberAttribute::class,
        \Mtc\Modules\Members\Models\MemberNote::class,
        \Mtc\Plugins\MembersMessaging\Classes\Message::class,
        \Mtc\Plugins\NHS\Classes\NHSMember::class,
        \Mtc\Plugins\AccountVerifier\Classes\AccountVerifications::class,
        \Mtc\Shop\Assessment\Answer::class,
        \Mtc\Shop\Order\Note::class,
        \Mtc\Plugins\Clinic\Src\Models\PatientFile::class,
        \Mtc\Plugins\Clinic\Src\Models\Treatment::class,
        \Mtc\Plugins\Clinic\Src\Models\Review::class,
        \Mtc\Plugins\Wisebee\Classes\Models\WisebeeConsultation::class,
        \Mtc\Plugins\Wisebee\Classes\Models\WisebeeDocument::class,
        \Mtc\Plugins\Wisebee\Classes\Models\WisebeeParticipant::class,
        \Mtc\Shop\Basket\Address::class,
        \Mtc\Shop\Basket\Info::class,
        \Mtc\Modules\BlackList\Classes\BlackList::class,
        \Mtc\Plugins\NewsletterSignup\Classes\DoubleOptInEmail::class,
        \Mtc\Plugins\NewsletterSignup\Classes\MailList::class,
        \Mtc\Core\EmailsQueued::class,
        \App\Models\EventLog::class,
        \Mtc\Shop\Order\Contact::class,
        \Mtc\Shop\Order\Address::class,
        \Mtc\Shop\Order\Info::class,
        \Mtc\Core\AdminUser::class,
        \Mtc\Modules\FormBuilder\Classes\Form::class,
    ];

    /** Fast seed/hash helper (deterministic) */
    public static function makeHash($string): string
    {
        return hash('sha512', strtolower(trim((string) $string)).config('app.key'));
    }

    /**
     * Encrypt any plaintext values in OptionalEncrypted-cast columns.
     * Builds missing *_hash for attributes listed in $model::$searchable.
     * Streams rows in chunks and upserts only changed rows.
     */
    public static function encrypt(string $modelClass, int $chunkSize = 2000): void
    {
        /** @var \Illuminate\Database\Eloquent\Model $model */
        $model = new $modelClass;
        $table = $model->getTable();
        $casts = $model->getCasts();

        // 1) Which columns are encrypted via cast?
        $encryptedCols = [];
        foreach ($casts as $attr => $castType) {
            if ($castType !== OptionalEncrypted::class) { continue; }

$encryptedCols[] = $attr;
        }
        if (empty($encryptedCols)) {
            return; // nothing to do
        }

        // 2) Which encrypted columns are also searchable?
        $searchable = property_exists($model, 'searchable') ? (array) $model::$searchable : [];
        $searchableCols = array_values(array_intersect($encryptedCols, $searchable));

        // 3) Build a base query with only the columns we need (and id)
        //    Use the query builder (not Eloquent) to avoid hydration overhead.
        $selectCols = array_merge(['id'], $encryptedCols);
        // If you want to skip rows already processed, you can add a heuristic WHERE:
        // e.g. whereNull('email_hash') OR where('email_hash','') if hashes exist. We keep it broad for safety.
        $qb = DB::table($table)->select($selectCols)->orderBy('id');

        // 4) Stream in chunks and upsert changed rows
        $qb->chunkById(
            $chunkSize,
            static function ($rows) use ($table, $encryptedCols, $searchableCols): void {
                $updates = []; // each element: ['id'=>..., '<col>'=>enc, '<col>_hash'=>...]
                foreach ($rows as $row) {
                    $rowUpdate = ['id' => $row->id];
                    $changed = false;

                    foreach ($encryptedCols as $col) {
                        $raw = $row->$col;

                        // Skip NULL/empty
                        if ($raw === null || $raw === '') {
                            continue;
                        }

                        // 4a) Cheap check to detect if already Laravel-encrypted
                        // Laravel's encrypter returns a base64 string of a JSON payload containing iv/value/mac[/tag].
                        // Avoid try/catch overhead on every row.
                        if (self::looksEncrypted($raw)) {
                            // Already encrypted – just ensure hash exists if searchable and plaintext is unknown.
                            // (We can't compute hash without plaintext. If you need to rebuild hashes, run decrypt() then encrypt().)
                        } else {
                            // 4b) Encrypt plaintext
                            $cipher = Crypt::encryptString($raw);
                            $rowUpdate[$col] = $cipher;
                            $changed = true;

                            // 4c) Hash if searchable (we have plaintext in $raw right here)
                            if (in_array($col, $searchableCols, true)) {
                                $rowUpdate[$col.'_hash'] = self::makeHash($raw);
                            }
                        }
                    }

                    if ($changed) {
                        $updates[] = $rowUpdate;
                    }
                }

                if (empty($updates)) {
                    return;
                }

                // 5) Upsert in sub-chunks to avoid too many placeholders
                // Count distinct columns to update for placeholder limit calculations.
                $allUpdateCols = [];
                foreach ($updates as $u) {
                    foreach (array_keys($u) as $k) {
                        if ($k === 'id') { continue; }

$allUpdateCols[$k] = true;
                    }
                }
                $colsPerRow = 1 /*id*/ + count($allUpdateCols);
                $maxRowsByPH = (int) floor(60_000 / max(1, $colsPerRow)); // stay <65k placeholders
                $hardCap = 1000;
                $chunk = min($maxRowsByPH, $hardCap);
                $updateCols = array_keys($allUpdateCols);

                for ($i = 0, $n = count($updates); $i < $n; $i += $chunk) {
                    $slice = array_slice($updates, $i, $chunk);
                    DB::table($table)->upsert($slice, ['id'], $updateCols);
                }
            },
            'id',
        ); // ensure chunkById uses 'id' as key
    }

    /**
     * Heuristic: detect Laravel-style encrypted strings without throwing.
     * Returns true if value looks like base64(JSON with iv/value/mac[&tag]).
     */
    private static function looksEncrypted($value): bool
    {
        if (! is_string($value))
            return false;
        // fast prefilter: base64 characters only
        if (! preg_match('~^[A-Za-z0-9/+]+=*$~', $value))
            return false;

        $json = base64_decode($value, true);
        if ($json === false)
            return false;

        $arr = json_decode($json, true);
        if (! is_array($arr))
            return false;

        // Laravel 9+: iv/value/mac plus optional tag
        return isset($arr['iv'], $arr['value'], $arr['mac']) || isset($arr['tag']);
    }

    public static function updateHashes($model): void
    {
        if (empty($model::$searchable))
            return;
        foreach ($model::$searchable as $attribute) {
            $model->{$attribute.'_hash'} = self::makeHash($model->getAttribute($attribute));
        }
        $model->saveQuietly();
    }

    public static function decrypt(string $modelClass, int $chunkSize = 2000): void
    {
        /** @var \Illuminate\Database\Eloquent\Model $model */
        $model = new $modelClass;
        $table = $model->getTable();
        $casts = $model->getCasts();

        // 1) Which columns are encrypted via cast?
        $encryptedCols = [];
        foreach ($casts as $attr => $castType) {
            if ($castType !== OptionalEncrypted::class) { continue; }

$encryptedCols[] = $attr;
        }
        if (empty($encryptedCols)) {
            return; // nothing to do
        }

        // 2) Searchable encrypted columns (to rebuild *_hash while we have plaintext)
        $searchable = property_exists($model, 'searchable') ? (array) $model::$searchable : [];
        $searchableCols = array_values(array_intersect($encryptedCols, $searchable));

        // 3) Only select what we need
        $selectCols = array_merge(['id'], $encryptedCols);
        $qb = \DB::table($table)->select($selectCols)->orderBy('id');

        // Optional: skip rows that already look decrypted (all encrypted cols fail looksEncrypted)
        // Keeping it broad by default for safety.

        $qb->chunkById(
            $chunkSize,
            static function ($rows) use ($table, $encryptedCols, $searchableCols): void {
                $updates = []; // rows to upsert: ['id'=>..., '<col>'=>plaintext, '<col>_hash'=>...]
                foreach ($rows as $row) {
                    $rowUpdate = ['id' => $row->id];
                    $changed = false;

                    foreach ($encryptedCols as $col) {
                        $val = $row->$col;

                        // Skip null/empty
                        if ($val === null || $val === '')
                            continue;

                        // Only attempt decrypt if it *looks* like Laravel ciphertext
                        if (! self::looksEncrypted($val)) {
                            // already plaintext; still (re)build hash if searchable and empty/missing
                            if (in_array($col, $searchableCols, true)) {
                                $rowUpdate[$col.'_hash'] = self::makeHash($val);
                                $changed = true;
                            }
                            continue;
                        }

                        // Decrypt (now a single try/catch per actually-encrypted cell)
                        try {
                            $plain = \Crypt::decryptString($val);
                            $rowUpdate[$col] = $plain;
                            $changed = true;

                            if (in_array($col, $searchableCols, true)) {
                                $rowUpdate[$col.'_hash'] = self::makeHash($plain);
                            }
                        } catch (\Throwable) {
                            // Corrupt/foreign payload: skip safely (or log if you want)
                            continue;
                        }
                    }

                    if ($changed) {
                        $updates[] = $rowUpdate;
                    }
                }

                if (empty($updates))
                    return;

                // 4) Upsert in sub-chunks sized under placeholder cap
                $allUpdateCols = [];
                foreach ($updates as $u) {
                    foreach (array_keys($u) as $k) {
                        if ($k === 'id') { continue; }

$allUpdateCols[$k] = true;
                    }
                }
                $colsPerRow = 1 + count($allUpdateCols); // id + N columns
                $maxRowsPH = max(1, (int) floor(60_000 / $colsPerRow)); // keep < ~65k placeholders
                $hardCap = 1000; // avoid huge packets
                $chunk = min($maxRowsPH, $hardCap);
                $updateCols = array_keys($allUpdateCols);

                for ($i = 0, $n = count($updates); $i < $n; $i += $chunk) {
                    \DB::table($table)->upsert(array_slice($updates, $i, $chunk), ['id'], $updateCols);
                }
            },
            'id',
        );
    }
}
