<?php

namespace App\Services\Secrets;

use App\Models\Operator;
use App\Services\Stripe\StripeCheck;
use Illuminate\Contracts\Encryption\DecryptException;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use RuntimeException;

/**
 * Credentials the owner can change from the console instead of over SSH.
 *
 * Three rules, each because the obvious alternative is worse:
 *
 * 1. A CURATED registry, never arbitrary key/value. A form that can set any
 *    environment variable is a privilege-escalation primitive — APP_KEY,
 *    DB_PASSWORD, MAIL_* — and one bad value bricks the installation with no
 *    way back through that same form.
 *
 * 2. Its own encryption key (SECRETS_KEY), not APP_KEY. Rotating APP_KEY is
 *    ordinary maintenance and would otherwise render every stored credential
 *    unreadable — discovered when Stripe stops answering, not when it happens.
 *
 * 3. Read where it is USED, never overlaid onto config at boot. An overlay adds
 *    a query to every request including the public site, and leaves long-running
 *    queue workers holding whatever was true when they started.
 *
 * The webhook signing secret is deliberately NOT in here. It is read on every
 * incoming payment event; moving it into the database would turn a database
 * problem into "webhooks silently fail signature verification", which is the
 * one failure mode that must stay loud.
 */
final class SecretVault
{
    /** @var array<string, array{config: string, label: string}> */
    /**
     * What the owner may change here — curated, never "everything in config".
     *
     * The list is the whole point of the area. With one entry it was a page
     * that existed to hold a single Stripe key, which is not worth a password
     * gate; these are the credentials that actually stop the business when
     * they expire or leak, and none of them can otherwise be rotated without a
     * shell on the server. ssh.private_key is the most sensitive value here —
     * it opens every server this installation owns.
     *
     * mail.password lived here too, until the mailboxes table (see its own
     * migration) took over sending credentials — a single SMTP password could
     * not say that an invoice comes from billing@ while a maintenance notice
     * comes from no-reply@.
     *
     * `check` is optional and names a class that can verify the value before it
     * is stored. Where there is none, no test button is offered — a button that
     * silently checks something else is worse than no button.
     *
     * `env_key` is the literal .env variable name the raw editor on the console
     * page (App\Livewire\Admin\Integrations) marks as vault-managed — shown so
     * an operator editing that file directly can see why a changed line has no
     * visible effect (get() prefers a stored row over config() every time; see
     * the docblock above). Which console SECTION an entry belongs beside is not
     * metadata here — Integrations' own view picks each entry up by key, so the
     * grouping lives in one place, the template, rather than two.
     *
     * Deliberately NOT here: STRIPE_WEBHOOK_SECRET. It is read on every
     * incoming payment event, and a database problem would turn signature
     * verification into a silent failure. It stays in the server file.
     *
     * Also deliberately NOT here: KUMA_PASSWORD / KUMA_TOTP. They authenticate
     * docker/kuma-bridge/app.py — a separate Python container — to Uptime Kuma
     * at process boot; nothing in this Laravel app ever reads them, so storing
     * them here would recreate exactly the decorative-field problem this
     * registry exists to avoid. They stay in the server file.
     */
    public const REGISTRY = [
        'stripe.secret' => [
            'config' => 'services.stripe.secret',
            'label' => 'secrets.item.stripe_secret',
            'check' => StripeCheck::class,
            'env_key' => 'STRIPE_SECRET',
        ],
        'dns.token' => [
            'config' => 'provisioning.dns.token',
            'label' => 'secrets.item.dns_token',
            'env_key' => 'HETZNER_DNS_TOKEN',
        ],
        'monitoring.token' => [
            'config' => 'services.monitoring.token',
            'label' => 'secrets.item.monitoring_token',
            'env_key' => 'MONITORING_API_TOKEN',
        ],
        'ssh.private_key' => [
            'config' => 'provisioning.ssh.private_key',
            'label' => 'secrets.item.ssh_private_key',
            // File-or-inline (see config/provisioning.php's $secretFrom) — the
            // editor names both, since either can be the one actually in force.
            'env_key' => 'CLUPILOT_SSH_PRIVATE_KEY / CLUPILOT_SSH_PRIVATE_KEY_PATH',
        ],
    ];

    public function has(string $key): bool
    {
        return $this->row($key) !== null;
    }

    /**
     * The stored value, or the configured one when nothing is stored.
     *
     * Falls back only when there is genuinely no row — never on a decryption
     * failure. Falling back then would quietly resume using an old key from the
     * environment while the console shows the new one.
     */
    public function get(string $key): ?string
    {
        $this->assertKnown($key);
        $row = $this->row($key);

        if ($row === null) {
            $configured = config(self::REGISTRY[$key]['config']);

            return $configured === null ? null : (string) $configured;
        }

        try {
            return app(SecretCipher::class)->decrypt($row->value);
        } catch (DecryptException $e) {
            Log::error('A stored secret could not be decrypted', ['key' => $key]);

            throw new RuntimeException("Stored secret [{$key}] cannot be decrypted.", previous: $e);
        }
    }

    /** Where the value in force comes from: stored, environment, or nowhere. */
    public function source(string $key): string
    {
        $this->assertKnown($key);

        return match (true) {
            $this->row($key) !== null => 'stored',
            filled(config(self::REGISTRY[$key]['config'])) => 'environment',
            default => 'none',
        };
    }

    public function outline(string $key): ?string
    {
        return $this->row($key)?->outline;
    }

    public function updatedAt(string $key): ?string
    {
        return $this->row($key)?->updated_at;
    }

    public function put(string $key, string $value, Operator $by): void
    {
        $this->assertKnown($key);

        if (trim($value) === '') {
            throw new RuntimeException('Refusing to store an empty secret.');
        }

        $attributes = [
            'value' => app(SecretCipher::class)->encrypt($value),
            'outline' => $this->sketch($value),
            'updated_by' => $by->id,
            'updated_at' => now(),
        ];

        // created_at only on the first write: rotating a key must not rewrite
        // when it was first set, which is the one piece of audit metadata that
        // answers "how long has this been here?".
        if ($this->row($key) === null) {
            $attributes['created_at'] = now();
        }

        DB::table('app_secrets')->updateOrInsert(['key' => $key], $attributes);
    }

    /** Remove the stored value, falling back to whatever the environment says. */
    public function forget(string $key): void
    {
        $this->assertKnown($key);
        DB::table('app_secrets')->where('key', $key)->delete();
    }

    /** Is storing credentials possible at all on this installation? */
    public function isUsable(): bool
    {
        return app(SecretCipher::class)->isUsable();
    }

    private function row(string $key): ?object
    {
        return DB::table('app_secrets')->where('key', $key)->first();
    }

    private function assertKnown(string $key): void
    {
        if (! array_key_exists($key, self::REGISTRY)) {
            throw new RuntimeException("Unknown secret [{$key}].");
        }
    }

    /** Enough to tell two keys apart, useless to anyone reading over a shoulder. */
    private function sketch(string $value): string
    {
        return Str::length($value) <= 8
            ? str_repeat('•', Str::length($value))
            : Str::substr($value, 0, 3).str_repeat('•', 6).Str::substr($value, -4);
    }
}
