<?php

namespace App\Console\Commands;

use App\Src\Encryption;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Throwable;

/**
 * Rebuilds missing/empty *_hash blind-index columns.
 *
 * `email_hash` (and the other searchable hashes) are how members are found at
 * login — Member::where('email_hash', makeHash($email)). Rows that never went
 * through the model's saved hook (legacy/raw inserts, or the encrypt() backfill
 * gap) have a NULL/empty hash and cannot be looked up, so the member is locked
 * out despite correct credentials.
 *
 * This recomputes the hash from the decrypted plaintext for any row whose hash
 * is currently empty or NULL.
 *
 * IMPORTANT: must run with the SAME APP_KEY the data was encrypted with — the
 * hash is salted with APP_KEY and the plaintext is read back through the cast
 * (which decrypts with APP_KEY). If a value cannot be decrypted the cast returns
 * the raw ciphertext, which would produce a wrong hash, so it is skipped here.
 * Run with --dry-run first and spot-check the counts.
 */
class BackfillSearchHashes extends Command
{
    protected $signature = 'encryption:backfill-hashes {--dry-run : Report what would change without writing}';

    protected $description = 'Rebuild missing/empty *_hash blind-index columns so members can be found at login.';

    public function handle(): int
    {
        foreach (Encryption::$modelsWithEncryption as $modelClass) {
            try {
                $this->backfill($modelClass);
            } catch (Throwable $e) {
                $this->error("Backfill failed for {$modelClass}: " . $e->getMessage());
            }
        }

        return 0;
    }

    private function backfill(string $modelClass): void
    {
        $searchable = $this->searchableAttributes($modelClass);

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

        $repaired = 0;

        $this->rowsWithMissingHashes($modelClass, $searchable)
            ->chunkById(500, function ($rows) use ($searchable, &$repaired) {
                foreach ($rows as $row) {
                    $repaired += $this->repairRow($row, $searchable) ? 1 : 0;
                }
            });

        $this->report($modelClass, $repaired);
    }

    private function rowsWithMissingHashes(string $modelClass, array $searchable): Builder
    {
        return $modelClass::query()->where(function (Builder $query) use ($searchable) {
            foreach ($searchable as $attribute) {
                $column = $attribute . '_hash';
                $query->orWhereNull($column)->orWhere($column, '');
            }
        });
    }

    private function repairRow(Model $row, array $searchable): bool
    {
        $changed = false;

        foreach ($searchable as $attribute) {
            if (!$this->needsHash($row, $attribute)) {
                continue;
            }

            $row->{$attribute . '_hash'} = Encryption::makeHash($row->getAttribute($attribute));
            $changed = true;
        }

        if ($changed && !$this->option('dry-run')) {
            $row->saveQuietly();
        }

        return $changed;
    }

    private function needsHash(Model $row, string $attribute): bool
    {
        if (!empty($row->{$attribute . '_hash'})) {
            return false;
        }

        $value = $row->getAttribute($attribute); // decrypted via the cast

        // Skip blanks (nothing to hash) and undecryptable ciphertext (wrong key → wrong hash).
        return $value !== null && $value !== '' && !$this->looksEncrypted($value);
    }

    private function searchableAttributes(string $modelClass): array
    {
        $model = new $modelClass();

        return property_exists($model, 'searchable') ? (array) $model::$searchable : [];
    }

    private function report(string $modelClass, int $repaired): void
    {
        $verb = $this->option('dry-run') ? '[dry-run] would update' : 'updated';

        $this->info(sprintf('%s: %s %d row(s)', $modelClass, $verb, $repaired));
    }

    /**
     * True if the value still looks like a Laravel ciphertext payload, i.e. the
     * cast could not decrypt it (wrong key) — hashing it would be wrong.
     */
    private function looksEncrypted($value): bool
    {
        if (!is_string($value) || !preg_match('~^[A-Za-z0-9/+]+=*$~', $value)) {
            return false;
        }

        $json = base64_decode($value, true);
        $arr  = $json === false ? null : json_decode($json, true);

        return is_array($arr) && (isset($arr['iv'], $arr['value'], $arr['mac']) || isset($arr['tag']));
    }
}
