<?php

use App\Services\Secrets\SecretCipher;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;

/**
 * Re-encrypt hosts.api_token_ref from APP_KEY to SECRETS_KEY.
 *
 * The column used Laravel's `encrypted` cast, which means APP_KEY — against this
 * project's own written rule (SecretVault's docblock: rotating APP_KEY is
 * ordinary maintenance, which is exactly why credentials have their own key).
 * Rotating APP_KEY today would make every host's Proxmox token undecryptable in
 * the same moment, and nothing would say so until provisioning stopped.
 *
 * What this migration will NOT do is destroy a value it cannot read. A row whose
 * ciphertext neither key opens is left exactly as it is and logged with its host
 * id: the plaintext is a credential that cannot be reconstructed from anywhere
 * else, and a token an operator can still recover by hand from an old APP_KEY is
 * worth more than a tidy column. The same applies to the whole table when
 * SECRETS_KEY is not usable at all — there is nothing to re-encrypt WITH, so the
 * migration reports that and changes nothing.
 */
return new class extends Migration
{
    public function up(): void
    {
        $this->rewrite(
            reader: fn (string $cipher) => Crypt::decryptString($cipher),
            writer: fn (string $plain) => app(SecretCipher::class)->encrypt($plain),
            alreadyDone: fn (string $cipher) => app(SecretCipher::class)->decrypt($cipher),
            direction: 'APP_KEY -> SECRETS_KEY',
        );
    }

    public function down(): void
    {
        $this->rewrite(
            reader: fn (string $cipher) => app(SecretCipher::class)->decrypt($cipher),
            writer: fn (string $plain) => Crypt::encryptString($plain),
            alreadyDone: fn (string $cipher) => Crypt::decryptString($cipher),
            direction: 'SECRETS_KEY -> APP_KEY',
        );
    }

    /**
     * One pass over the table, re-encrypting what it can read.
     *
     * `alreadyDone` is what makes this safe to run twice, and safe to run on a
     * database somebody has already half-migrated: both keys produce ordinary
     * Laravel encryption payloads, so the only way to tell them apart is which
     * key validates the MAC. A row the TARGET key already opens is not touched
     * and not counted as a failure.
     *
     * @param  callable(string): string  $reader
     * @param  callable(string): string  $writer
     * @param  callable(string): string  $alreadyDone
     */
    private function rewrite(callable $reader, callable $writer, callable $alreadyDone, string $direction): void
    {
        if (! app(SecretCipher::class)->isUsable()) {
            // Not a failure of the migration: a fresh install has no SECRETS_KEY
            // yet and no hosts either. Throwing here would block the whole
            // migration run on an installation that has nothing to migrate.
            $pending = DB::table('hosts')->whereNotNull('api_token_ref')->count();

            if ($pending > 0) {
                Log::warning(
                    'SECRETS_KEY is not usable, so '.$pending.' host API token(s) were left encrypted under the '.
                    'old key. Set SECRETS_KEY and re-run this migration ('.$direction.') before rotating APP_KEY.'
                );
            }

            return;
        }

        $left = [];

        foreach (DB::table('hosts')->whereNotNull('api_token_ref')->get(['id', 'api_token_ref']) as $row) {
            $cipher = (string) $row->api_token_ref;

            if ($cipher === '') {
                continue;
            }

            try {
                $plain = $reader($cipher);
            } catch (Throwable) {
                try {
                    // Already in the target shape: nothing to do, nothing wrong.
                    $alreadyDone($cipher);

                    continue;
                } catch (Throwable) {
                    $left[] = $row->id;

                    continue;
                }
            }

            DB::table('hosts')->where('id', $row->id)->update(['api_token_ref' => $writer($plain)]);
        }

        if ($left !== []) {
            Log::warning(
                'Left '.count($left).' host API token(s) untouched ('.$direction.'): neither key could read them. '.
                'Host ids: '.implode(', ', $left).'. Re-mint the token on those hosts, or restore the APP_KEY they '.
                'were written under — the stored value has NOT been overwritten.'
            );
        }
    }
};
