<?php

use App\Livewire\Admin\ConfirmForgetSecret;
use App\Livewire\Admin\ConfirmSaveEnv;
use App\Livewire\Admin\ConfirmSaveSecret;
use App\Livewire\Admin\Integrations;
use App\Services\Env\EnvFileEditor;
use App\Services\Secrets\SecretVault;
use App\Support\ProvisioningSettings;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Str;
use Livewire\Livewire;

/**
 * Admin\Integrations merges the former admin.secrets and admin.infrastructure
 * pages into one, grouped by what a field configures rather than by which of
 * the two storage mechanisms holds it. This file replaces SecretsPageTest and
 * InfrastructureSettingsTest — the underlying mechanisms (SecretVault,
 * App\Support\Settings) are unchanged, only where they are displayed, so most
 * of their coverage is ported here unchanged in substance.
 *
 * New in this file: the two capabilities are independently checked (an
 * operator with only one of them sees only the matching half), the redirects
 * off the two retired routes, and Part B — the raw .env editor.
 */
function envEditorPathFor(string $seed = ''): string
{
    $path = storage_path('framework/testing/env-file-editor-'.Str::random(12).'.env');
    File::ensureDirectoryExists(dirname($path));
    File::put($path, $seed !== '' ? $seed : "APP_NAME=CluPilot\nAPP_KEY=base64:test\nDB_HOST=mariadb\n");

    return $path;
}

function useTempEnvFile(string $seed = ''): string
{
    $path = envEditorPathFor($seed);
    app()->instance(EnvFileEditor::class, new EnvFileEditor($path));

    return $path;
}

afterEach(function () {
    foreach (glob(storage_path('framework/testing/env-file-editor-*')) as $leftover) {
        @unlink($leftover);
    }
});

beforeEach(function () {
    config()->set('admin_access.secrets_key', 'base64:'.base64_encode(random_bytes(32)));

    // saveEnv() now reads/writes UpdateChannel state (config:clear + a
    // restart request) — the same shared storage/app/deploy files
    // UpdateButtonTest.php exercises. Cleared here for the same reason that
    // file clears it in its own beforeEach: PHPUnit/Pest run every test file
    // in one process, so a leftover status or request from whichever file ran
    // last would otherwise decide whether these tests see an agent as alive.
    File::deleteDirectory(storage_path('app/deploy'));
});

// ---- Reachability: either capability opens the page, neither 403s it. ----

it('is not reachable with neither hosts.manage nor secrets.manage', function () {
    Livewire::actingAs(operator('Support'), 'operator')
        ->test(Integrations::class)
        ->assertForbidden();
});

it('shows the settings sections but not the vault sections to an operator with only hosts.manage', function () {
    Livewire::actingAs(operator('Admin'), 'operator')
        ->test(Integrations::class)
        ->assertOk()
        ->assertViewHas('canInfra', true)
        ->assertViewHas('canSecrets', false)
        ->assertSee(__('integrations.dns_zone'))
        ->assertDontSee(__('secrets.item.stripe_secret'))
        ->assertDontSee(__('integrations.env_title'));
});

it('shows the vault sections but not the settings sections to an operator with only secrets.manage', function () {
    // The page is tabbed now, so each half is asserted on the tab it lives on.
    // What is being checked has not changed: a capability decides what is
    // rendered, never which tab happens to be open.
    $operator = operator('Support');
    $operator->givePermissionTo('secrets.manage');

    $page = Livewire::actingAs($operator, 'operator')->test(Integrations::class);

    $page->assertOk()
        ->assertViewHas('canInfra', false)
        ->assertViewHas('canSecrets', true)
        ->assertDontSee(__('integrations.dns_zone'))
        ->assertSee(__('secrets.item.stripe_secret'));

    $page->set('tab', 'env')->assertSee(__('integrations.env_title'));
});

it('shows both halves to the Owner, who has both capabilities', function () {
    $page = Livewire::actingAs(operator('Owner'), 'operator')->test(Integrations::class);

    $page->assertOk()
        ->assertSee(__('integrations.dns_zone'))
        ->assertSee(__('secrets.item.stripe_secret'));

    $page->set('tab', 'env')->assertSee(__('integrations.env_title'));
});

// ---- The retired pages redirect rather than 404 a bookmark. ----

it('redirects the retired /admin/secrets route to /admin/integrations', function () {
    $this->actingAs(operator('Owner'), 'operator')
        ->get(route('admin.secrets'))
        ->assertRedirect(route('admin.integrations'))
        ->assertStatus(301);
});

it('redirects the retired /admin/infrastructure route to /admin/integrations', function () {
    $this->actingAs(operator('Owner'), 'operator')
        ->get(route('admin.infrastructure'))
        ->assertRedirect(route('admin.integrations'))
        ->assertStatus(301);
});

// ---- Vault entries: ported from the former SecretsPageTest. ----

it('shows the no-key banner for a malformed SECRETS_KEY, not only a missing one', function () {
    config()->set('admin_access.secrets_key', 'zu-kurz');

    Livewire::actingAs(operator('Owner'), 'operator')
        ->test(Integrations::class)
        ->assertSee(__('secrets.no_key'));
});

it('shows nothing until the password is confirmed', function () {
    $owner = operator('Owner');
    app(SecretVault::class)->put('stripe.secret', 'sk_live_ABCDEFGH9999', $owner);

    Livewire::actingAs($owner, 'operator')->test(Integrations::class)
        ->assertSee(__('secrets.locked_title'))
        ->assertDontSee('9999')
        ->assertViewHas('unlocked', false);
});

it('refuses every vault and env action while locked, not merely hiding the buttons', function () {
    $owner = operator('Owner');
    useTempEnvFile();

    foreach ([['save', 'stripe.secret'], ['forget', 'stripe.secret'], ['test', 'stripe.secret']] as [$action, $arg]) {
        Livewire::actingAs($owner, 'operator')
            ->test(Integrations::class)
            ->call($action, $arg)
            ->assertForbidden();
    }

    Livewire::actingAs($owner, 'operator')
        ->test(Integrations::class)
        ->call('saveEnv')
        ->assertForbidden();
});

it('does not let an operator with only hosts.manage touch a vault action', function () {
    Livewire::actingAs(operator('Admin'), 'operator')
        ->test(Integrations::class)
        ->call('save', 'stripe.secret')
        ->assertForbidden();
});

it('unlocks with the password, then stores and clears the entered value', function () {
    $owner = operator('Owner');

    $page = Livewire::actingAs($owner, 'operator')
        ->test(Integrations::class)
        ->set('confirmablePassword', 'password')
        ->call('confirmPassword')
        ->assertHasNoErrors()
        ->assertViewHas('unlocked', true);

    $page->set('entered.stripe_secret', 'sk_live_NEWKEY1234')
        ->call('save', 'stripe.secret')
        ->assertHasNoErrors();

    expect(app(SecretVault::class)->get('stripe.secret'))->toBe('sk_live_NEWKEY1234')
        ->and($page->get('entered')['stripe_secret'])->toBe('');
});

it('never puts a stored secret into the component snapshot', function () {
    $owner = operator('Owner');
    app(SecretVault::class)->put('stripe.secret', 'sk_live_TOPSECRET42', $owner);

    $page = Livewire::actingAs($owner, 'operator')
        ->test(Integrations::class)
        ->set('confirmablePassword', 'password')
        ->call('confirmPassword');

    expect(json_encode($page->snapshot))->not->toContain('sk_live_TOPSECRET42');
});

it('can be locked again without signing out, which also drops the .env content it was holding', function () {
    $owner = operator('Owner');
    useTempEnvFile();

    Livewire::actingAs($owner, 'operator')
        ->test(Integrations::class)
        ->set('confirmablePassword', 'password')
        ->call('confirmPassword')
        ->assertViewHas('unlocked', true)
        ->assertSet('envLoaded', true)
        ->call('forgetPasswordConfirmation')
        ->assertViewHas('unlocked', false)
        ->assertSet('envContent', '')
        ->assertSet('envLoaded', false);
});

it('drops .env content from state when the confirmation window expires on its own, not only on an explicit lock', function () {
    // Codex review, P1: nobody has to click "Wieder sperren" for the window
    // to end — it simply ages out. Before this was fixed, $unlocked went
    // false on the next render but $envContent — the whole credential file —
    // stayed in the component snapshot, reachable to anyone left with the
    // browser. Triggered here with an ordinary property set(), never
    // forgetPasswordConfirmation() itself, specifically to prove the render()
    // side of the fix and not the explicit-lock side already covered above.
    $owner = operator('Owner');
    useTempEnvFile("REAL_SECRET_MARKER=abcdef\n");

    $page = Livewire::actingAs($owner, 'operator')
        ->test(Integrations::class)
        ->set('confirmablePassword', 'password')
        ->call('confirmPassword');

    expect($page->get('envContent'))->toContain('REAL_SECRET_MARKER=abcdef');

    // ConfirmsPassword times its marker with a raw time(), which does not
    // hear Carbon::setTestNow() (travel()/travelTo() would silently do
    // nothing here) — so the window is aged out the same way it is set:
    // by writing the marker directly, in the same session key format
    // passwordConfirmationSessionKey() builds.
    session(['auth.password_confirmed_at.operator.'.$owner->getAuthIdentifier() => time() - 999999]);

    $page->set('dnsZone', 'still-here')
        ->assertViewHas('unlocked', false)
        ->assertSet('envContent', '')
        ->assertSet('envLoaded', false);
});

it('does not open the save/forget confirmation without the capability', function () {
    Livewire::actingAs(operator('Admin'), 'operator')
        ->test(ConfirmSaveSecret::class, ['key' => 'stripe.secret'])
        ->assertForbidden();

    Livewire::actingAs(operator('Admin'), 'operator')
        ->test(ConfirmForgetSecret::class, ['key' => 'stripe.secret'])
        ->assertForbidden();
});

it('confirms a save through the modal without storing anything itself', function () {
    $owner = operator('Owner');

    Livewire::actingAs($owner, 'operator')
        ->test(ConfirmSaveSecret::class, ['key' => 'stripe.secret'])
        ->assertSee(__('secrets.item.stripe_secret'))
        ->call('confirm')
        ->assertDispatched('secret-save-confirmed', key: 'stripe.secret');

    expect(app(SecretVault::class)->has('stripe.secret'))->toBeFalse();
});

it('saves the typed value once the page receives the confirmed event', function () {
    $owner = operator('Owner');

    Livewire::actingAs($owner, 'operator')
        ->test(Integrations::class)
        ->set('confirmablePassword', 'password')
        ->call('confirmPassword')
        ->set('entered.stripe_secret', 'sk_live_VIACONFIRM1')
        ->call('onSaveConfirmed', 'stripe.secret')
        ->assertHasNoErrors();

    expect(app(SecretVault::class)->get('stripe.secret'))->toBe('sk_live_VIACONFIRM1');
});

it('forgets the stored value once the page receives the confirmed event', function () {
    $owner = operator('Owner');
    app(SecretVault::class)->put('stripe.secret', 'sk_live_TOFORGET999', $owner);

    Livewire::actingAs($owner, 'operator')
        ->test(Integrations::class)
        ->set('confirmablePassword', 'password')
        ->call('confirmPassword')
        ->call('onForgetConfirmed', 'stripe.secret');

    expect(app(SecretVault::class)->has('stripe.secret'))->toBeFalse();
});

it('stores the SSH identity with its internal newlines intact', function () {
    $owner = operator('Owner');
    $pem = "-----BEGIN OPENSSH PRIVATE KEY-----\nabcdef\nghijkl\n-----END OPENSSH PRIVATE KEY-----";

    Livewire::actingAs($owner, 'operator')
        ->test(Integrations::class)
        ->set('confirmablePassword', 'password')
        ->call('confirmPassword')
        ->set('entered.ssh_private_key', $pem)
        ->call('save', 'ssh.private_key')
        ->assertHasNoErrors();

    expect(app(SecretVault::class)->get('ssh.private_key'))->toBe($pem);
});

it('binds the form to a key Livewire can actually write to', function () {
    expect(Integrations::field('stripe.secret'))->toBe('stripe_secret')
        ->and(str_contains(Integrations::field('stripe.secret'), '.'))->toBeFalse();
});

// ---- Plain settings: ported from the former InfrastructureSettingsTest. ----

it('loads the .env-derived value when nothing has been saved from the console yet', function () {
    // The premise has to be established now: a migration moves customer
    // instances onto their own zone on a fresh install, so "nothing saved" is
    // no longer the state a new database is in.
    App\Support\Settings::forget('provisioning.dns_zone');
    config()->set('provisioning.dns.zone', 'env-zone.example');

    Livewire::actingAs(operator('Owner'), 'operator')
        ->test(Integrations::class)
        ->assertSet('dnsZone', 'env-zone.example');
});

it('saves every settings field, and the consumer reads the saved value back', function () {
    Livewire::actingAs(operator('Owner'), 'operator')
        ->test(Integrations::class)
        ->set('dnsZone', 'console-zone.example')
        ->set('wgEndpoint', 'vpn.example:51820')
        ->set('wgHubPubkey', 'hubpubkey==')
        ->set('traefikDynamicPath', '/etc/traefik/dynamic')
        ->set('sshPublicKey', 'ssh-ed25519 AAAAC3 clupilot')
        ->set('monitoringUrl', 'http://kuma-bridge:8080')
        ->call('saveInfra')
        ->assertHasNoErrors();

    expect(ProvisioningSettings::dnsZone())->toBe('console-zone.example')
        ->and(ProvisioningSettings::wgEndpoint())->toBe('vpn.example:51820')
        ->and(ProvisioningSettings::wgHubPublicKey())->toBe('hubpubkey==')
        ->and(ProvisioningSettings::traefikDynamicPath())->toBe('/etc/traefik/dynamic')
        ->and(ProvisioningSettings::sshPublicKey())->toBe('ssh-ed25519 AAAAC3 clupilot')
        ->and(ProvisioningSettings::monitoringUrl())->toBe('http://kuma-bridge:8080');
});

it('refuses a monitoring URL that is not a URL', function () {
    Livewire::actingAs(operator('Owner'), 'operator')
        ->test(Integrations::class)
        ->set('monitoringUrl', 'not a url')
        ->call('saveInfra')
        ->assertHasErrors('monitoringUrl');
});

it('does not let an operator with only secrets.manage save the settings half', function () {
    $operator = operator('Support');
    $operator->givePermissionTo('secrets.manage');

    Livewire::actingAs($operator, 'operator')
        ->test(Integrations::class)
        ->call('saveInfra')
        ->assertForbidden();
});

// ---- Part B: the raw .env editor. ----

it('does not open the .env confirm modal without secrets.manage', function () {
    Livewire::actingAs(operator('Admin'), 'operator')
        ->test(ConfirmSaveEnv::class)
        ->assertForbidden();
});

it('mutation target: refuses to save .env without a confirmed password even with secrets.manage', function () {
    $owner = operator('Owner');
    useTempEnvFile();

    Livewire::actingAs($owner, 'operator')
        ->test(Integrations::class)
        ->set('envContent', "NEW=value\n")
        ->call('saveEnv')
        ->assertForbidden();
});

it('loads the real file content only after the password is confirmed', function () {
    $owner = operator('Owner');
    useTempEnvFile("REAL_SECRET_MARKER=abcdef\n");

    $locked = Livewire::actingAs($owner, 'operator')->test(Integrations::class);
    expect($locked->get('envContent'))->toBe('')
        ->and($locked->get('envLoaded'))->toBeFalse();

    $unlocked = Livewire::actingAs($owner, 'operator')
        ->test(Integrations::class)
        ->set('confirmablePassword', 'password')
        ->call('confirmPassword');

    expect($unlocked->get('envContent'))->toContain('REAL_SECRET_MARKER=abcdef')
        ->and($unlocked->get('envLoaded'))->toBeTrue();
});

it('marks which .env keys the vault currently overrides', function () {
    $owner = operator('Owner');
    useTempEnvFile();
    app(SecretVault::class)->put('stripe.secret', 'sk_live_MARK1234567', $owner);

    $page = Livewire::actingAs($owner, 'operator')
        ->test(Integrations::class)
        ->set('confirmablePassword', 'password')
        ->call('confirmPassword')
        ->set('tab', 'env');

    $page->assertSee('STRIPE_SECRET')
        ->assertSee(__('integrations.env_vault_active'))
        ->assertSee(__('integrations.env_vault_inactive'));
});

it('mutation target: refuses an invalid .env body and writes nothing', function () {
    $owner = operator('Owner');
    $path = useTempEnvFile("ORIGINAL=untouched\n");

    Livewire::actingAs($owner, 'operator')
        ->test(Integrations::class)
        ->set('confirmablePassword', 'password')
        ->call('confirmPassword')
        ->set('envContent', "ORIGINAL=untouched\nthis is not env syntax\n")
        ->call('saveEnv')
        ->assertHasErrors('envContent');

    expect(File::get($path))->toBe("ORIGINAL=untouched\n")
        ->and(glob($path.'.bak-*'))->toBe([]);
});

it('mutation target: a backup really exists after saving valid .env content', function () {
    $owner = operator('Owner');
    $path = useTempEnvFile("OLD=value\n");

    Livewire::actingAs($owner, 'operator')
        ->test(Integrations::class)
        ->set('confirmablePassword', 'password')
        ->call('confirmPassword')
        ->set('envContent', "NEW=value\n")
        ->call('saveEnv')
        ->assertHasNoErrors();

    $backups = glob($path.'.bak-*');

    expect(File::get($path))->toBe("NEW=value\n")
        ->and($backups)->not->toBe([])
        ->and(File::get($backups[0]))->toBe("OLD=value\n");
});

it('confirms an .env save through the modal without writing anything itself', function () {
    $owner = operator('Owner');
    $path = useTempEnvFile("OLD=value\n");

    Livewire::actingAs($owner, 'operator')
        ->test(ConfirmSaveEnv::class)
        ->call('confirm')
        ->assertDispatched('env-save-confirmed');

    expect(File::get($path))->toBe("OLD=value\n");
});

it('saves .env once the page receives the confirmed event', function () {
    $owner = operator('Owner');
    $path = useTempEnvFile("OLD=value\n");

    Livewire::actingAs($owner, 'operator')
        ->test(Integrations::class)
        ->set('confirmablePassword', 'password')
        ->call('confirmPassword')
        ->set('envContent', "CONFIRMED=value\n")
        ->call('onEnvSaveConfirmed')
        ->assertHasNoErrors();

    expect(File::get($path))->toBe("CONFIRMED=value\n");
});

it('names the server variable under each credential, not the internal key', function () {
    // It printed `stripe.secret` — an identifier out of SecretVault::REGISTRY
    // that means nothing on a settings page and is not something an operator can
    // type anywhere. The env name is what stands in the server file, what the
    // .env tab of this same page lists, and the answer to "which line?" when the
    // badge says the value comes from there.
    $page = Livewire::actingAs(operator('Owner'), 'operator')->test(Integrations::class);

    $page->assertSee('STRIPE_SECRET')
        ->assertSee('INBOUND_MAIL_PASSWORD')
        ->assertSee('HETZNER_DNS_TOKEN')
        ->assertSee('MONITORING_API_TOKEN');

    // Not asserted by absence from the HTML: the key legitimately stays in
    // wire:key and in the modal arguments, where it is an identifier nobody
    // reads. What must not happen is printing it AS TEXT under the label.
    $component = Illuminate\Support\Facades\File::get(
        resource_path('views/components/admin/secret-field.blade.php')
    );
    $withoutComments = (string) preg_replace('/\{\{--.*?--\}\}/s', '', $component);

    // Between tags, i.e. as a text node — the key stays inside attributes
    // (wire:key, wire:click, the modal arguments), where it is an identifier
    // nobody reads and removing it would break the page.
    expect($withoutComments)->toMatch('/>\s*\{\{ \$entry\[.envKey.\] \}\}\s*</')
        ->and($withoutComments)->not->toMatch('/>\s*\{\{ \$entry\[.key.\] \}\}\s*</');
});
