<?php

namespace App\Src;

use App\Casts\OptionalEncrypted;
use Illuminate\Contracts\Encryption\DecryptException;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;

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

    /** 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) {
                $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, function ($rows) use ($table, $encryptedCols, $searchableCols) {

            $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') $allUpdateCols[$k] = true;
                }
            }
            $colsPerRow   = 1 /*id*/ + count($allUpdateCols);
            $maxRowsByPH  = (int) floor(60000 / 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) {
                $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, function ($rows) use ($table, $encryptedCols, $searchableCols) {

            $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 $e) {
                        // 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') $allUpdateCols[$k] = true;
                }
            }
            $colsPerRow  = 1 + count($allUpdateCols);             // id + N columns
            $maxRowsPH   = max(1, (int) floor(60000 / $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');
    }

}