<?php

use App\Models\Operator;
use App\Services\Dns\HttpHetznerDnsClient;
use App\Services\Monitoring\HttpMonitoringClient;
use App\Services\Secrets\SecretVault;
use App\Services\Stripe\HttpStripeClient;
use App\Support\OperatingMode;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;

/**
 * Credentials stored in the database instead of the .env file.
 *
 * The stakes are a live payment key, so what is tested here is mostly what the
 * vault REFUSES to do.
 */
beforeEach(function () {
    config()->set('admin_access.secrets_key', 'base64:'.base64_encode(random_bytes(32)));
    config()->set('services.stripe.secret', 'sk_env_fallback');

    // None of these tests are about the test/live split (that is
    // SecretVaultModeTest); they call put()/get() without a $mode and must
    // therefore land on a known slot regardless of what an earlier test file
    // left in the array cache that Settings and OperatingMode share.
    OperatingMode::set(OperatingMode::Live);
});

it('prefers a stored key over the environment, and says which is in force', function () {
    $vault = app(SecretVault::class);

    // This test uses dns.token (not stripe.secret) to preserve the original
    // assertion: a non-strict entry falls back to the environment and reports
    // it as its source. Stripe is exempt from fallback (it is strict),
    // tested separately in StripeStrictModeTest.
    config()->set('provisioning.dns.token', 'env_token_fallback');

    expect($vault->get('dns.token'))->toBe('env_token_fallback')
        ->and($vault->source('dns.token'))->toBe('environment');

    $vault->put('dns.token', 'stored_value', Operator::factory()->create());

    expect($vault->get('dns.token'))->toBe('stored_value')
        ->and($vault->source('dns.token'))->toBe('stored');

    // Removing it falls back rather than leaving nothing.
    $vault->forget('dns.token');
    expect($vault->get('dns.token'))->toBe('env_token_fallback');
});

it('strips the environment from stripe when nothing is stored', function () {
    // Stripe is a strict entry: it never falls back to the environment, and
    // source() never reports it as in force when it is not stored. This
    // prevents the silent contradiction: get() returns null while source()
    // claims 'environment', and the console would tell the operator a key is
    // in force while the billing system refuses to use it.
    $vault = app(SecretVault::class);

    config()->set('services.stripe.secret', 'sk_env_from_config');

    expect($vault->get('stripe.secret'))->toBeNull()
        ->and($vault->source('stripe.secret'))->toBe('none');
});

it('stores the value encrypted, and never in the clear', function () {
    app(SecretVault::class)->put('stripe.secret', 'sk_live_verysecret', Operator::factory()->create());

    // The row key carries a mode suffix (SecretVaultModeTest) — the active
    // mode here is pinned to live above.
    $stored = DB::table('app_secrets')->where('key', 'stripe.secret:live')->value('value');

    expect($stored)->not->toContain('sk_live_verysecret');
});

it('shows only an outline, never the value', function () {
    app(SecretVault::class)->put('stripe.secret', 'sk_live_ABCDEFGH1234', Operator::factory()->create());

    $outline = app(SecretVault::class)->outline('stripe.secret');

    expect($outline)->toContain('1234')->and($outline)->not->toContain('ABCDEFGH');
});

it('refuses a key it does not know', function () {
    // A form that can set any environment variable is a privilege-escalation
    // primitive. The registry is the whole point.
    expect(fn () => app(SecretVault::class)->put('app.key', 'whatever', Operator::factory()->create()))
        ->toThrow(RuntimeException::class);
});

it('refuses to store or read anything without its own key', function () {
    // Falling back to APP_KEY would put payment credentials under a key that
    // gets rotated as routine maintenance.
    config()->set('admin_access.secrets_key', '');

    expect(app(SecretVault::class)->isUsable())->toBeFalse()
        ->and(fn () => app(SecretVault::class)->put('stripe.secret', 'sk_x', Operator::factory()->create()))
        ->toThrow(RuntimeException::class);
});

it('reports unusable for a malformed key too, not only an empty one', function () {
    // Codex R15#3, P2: SecretVault::isUsable() only delegates to
    // SecretCipher::isUsable() — this proves the delegation actually carries
    // the fixed verdict through, rather than assuming it does because the
    // one call is a one-liner.
    config()->set('admin_access.secrets_key', 'viel-zu-kurz');

    expect(app(SecretVault::class)->isUsable())->toBeFalse();
});

it('fails loudly when a stored value cannot be decrypted, instead of using the old one', function () {
    // "The key cannot be read" and "there is no key" call for different
    // actions, and only one of them is fixed by typing it in again.
    app(SecretVault::class)->put('stripe.secret', 'sk_live_stored', Operator::factory()->create());
    config()->set('admin_access.secrets_key', 'base64:'.base64_encode(random_bytes(32)));

    expect(fn () => app(SecretVault::class)->get('stripe.secret'))->toThrow(RuntimeException::class);
});

it('hands the Stripe client the stored key rather than the environment one', function () {
    app(SecretVault::class)->put('stripe.secret', 'sk_live_from_console', Operator::factory()->create());

    Http::fake(['*' => Http::response(['id' => 'prod_1'], 200)]);

    app(HttpStripeClient::class)->createProduct('Test');

    Http::assertSent(
        fn ($request) => $request->hasHeader('Authorization', 'Bearer sk_live_from_console'),
    );
});

it('hands the Hetzner DNS client the stored token rather than the environment one', function () {
    // Mutation boundary (Part A): before this, HttpHetznerDnsClient read
    // config('provisioning.dns.token') directly — a value stored here was
    // encrypted, kept, and never once reached the outgoing request.
    config()->set('provisioning.dns.token', 'env-token-should-not-be-used');
    app(SecretVault::class)->put('dns.token', 'dns-token-from-console', Operator::factory()->create());

    Http::fake([
        'api.hetzner.cloud/v1/zones/*/rrsets' => Http::response(['rrset' => ['id' => 'fsn-01/A']], 201),
    ]);

    app(HttpHetznerDnsClient::class)->upsertRecord('fsn-01.'.config('provisioning.dns.zone'), 'A', '10.0.0.1');

    Http::assertSent(fn ($request) => $request->hasHeader('Authorization', 'Bearer dns-token-from-console'));
    Http::assertNotSent(fn ($request) => $request->hasHeader('Authorization', 'Bearer env-token-should-not-be-used'));
});

it('hands the monitoring client the stored token rather than the environment one', function () {
    config()->set('services.monitoring.url', 'https://kuma-bridge.test');
    config()->set('services.monitoring.token', 'env-token-should-not-be-used');
    app(SecretVault::class)->put('monitoring.token', 'mon-token-from-console', Operator::factory()->create());

    Http::fake([
        'kuma-bridge.test/monitors*' => Http::response(['monitors' => [], 'monitor' => ['id' => 5]]),
    ]);

    app(HttpMonitoringClient::class)->registerTarget('probe', 'https://probe.example');

    Http::assertSent(fn ($request) => $request->hasHeader('Authorization', 'Bearer mon-token-from-console'));
    Http::assertNotSent(fn ($request) => $request->hasHeader('Authorization', 'Bearer env-token-should-not-be-used'));
});

it('accepts the key in the format the documentation tells you to generate', function () {
    // `head -c 32 /dev/urandom | base64` produces raw base64 with no prefix.
    // Requiring the prefix made every read and write fail on exactly the
    // documented setup — a 44-byte key where AES-256 wants 32.
    $raw = random_bytes(32);

    foreach ([base64_encode($raw), 'base64:'.base64_encode($raw)] as $spelling) {
        config()->set('admin_access.secrets_key', $spelling);

        $vault = app(SecretVault::class);
        $vault->put('stripe.secret', 'sk_live_roundtrip', Operator::factory()->create());

        expect($vault->get('stripe.secret'))->toBe('sk_live_roundtrip', $spelling);
    }
});

it('refuses a key that is not 32 bytes rather than failing later', function () {
    config()->set('admin_access.secrets_key', 'viel-zu-kurz');

    expect(fn () => app(SecretVault::class)->put('stripe.secret', 'sk_x', Operator::factory()->create()))
        ->toThrow(RuntimeException::class);
});

it('keeps the original creation date when a key is rotated', function () {
    // The one piece of audit metadata that answers "how long has this been
    // here?" must survive a rotation.
    $operator = Operator::factory()->create();
    app(SecretVault::class)->put('stripe.secret', 'sk_first', $operator);

    $created = DB::table('app_secrets')->where('key', 'stripe.secret:live')->value('created_at');

    $this->travel(2)->days();
    app(SecretVault::class)->put('stripe.secret', 'sk_second', $operator);

    expect(DB::table('app_secrets')->where('key', 'stripe.secret:live')->value('created_at'))->toBe($created);
});
