<?php

use App\Livewire\Admin\ConfirmDeleteVpnPeer;
use App\Livewire\Admin\ConfirmReissueVpnPeer;
use App\Livewire\Admin\Settings;
use App\Livewire\Admin\Vpn;
use App\Livewire\Admin\VpnConfigAccess;
use App\Models\Host;
use App\Models\Operator;
use App\Models\User;
use App\Models\VpnPeer;
use App\Provisioning\Jobs\ApplyVpnPeer;
use App\Provisioning\Jobs\PurgeHost;
use App\Provisioning\Jobs\RemoveWireguardPeer;
use App\Provisioning\Jobs\SyncVpnPeers;
use App\Services\Wireguard\ConfigHandoff;
use App\Services\Wireguard\ConfigVault;
use App\Services\Wireguard\FakeWireguardHub;
use App\Services\Wireguard\Keypair;
use App\Services\Wireguard\LocalWireguardHub;
use App\Services\Wireguard\PeerSnapshot;
use App\Services\Wireguard\WireguardHub;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
use Spatie\Permission\PermissionRegistrar;

function vpnHub(): FakeWireguardHub
{
    $hub = new FakeWireguardHub;
    app()->instance(WireguardHub::class, $hub);

    return $hub;
}

it('keeps the VPN entry in the navigation for every operator', function () {
    vpnHub();

    // Regression guard: the entry used to hang on a capability that the
    // capability split deleted, which would have hidden the page from everyone.
    foreach (['Owner', 'Admin', 'Support', 'Developer', 'Read-only'] as $role) {
        $this->actingAs(operator($role), 'operator')->get(route('admin.overview'))
            ->assertOk()
            ->assertSee(route('admin.vpn'));
    }
});

it('shows each operator only their own access unless they may see all', function () {
    vpnHub();
    $support = operator('Support');
    $mine = VpnPeer::factory()->ownedBy($support)->create(['name' => 'Support-Notebook']);
    $foreign = VpnPeer::factory()->create(['name' => 'Fremdes-Notebook']);

    // Support reaches the page — to find their own access — but sees only it.
    $this->actingAs($support, 'operator')->get(route('admin.vpn'))
        ->assertOk()
        ->assertSee('Support-Notebook')
        ->assertDontSee('Fremdes-Notebook');

    // Developer may see everything, by capability rather than by ownership.
    $this->actingAs(operator('Developer'), 'operator')->get(route('admin.vpn'))
        ->assertOk()
        ->assertSee('Support-Notebook')
        ->assertSee('Fremdes-Notebook');

    expect($mine->fresh()->user_id)->toBe($support->id)
        ->and($foreign->fresh()->user_id)->not->toBe($support->id);
});

it('lets only managers create an access', function () {
    vpnHub();

    // The form is not even offered to someone who cannot use it.
    $this->actingAs(operator('Support'), 'operator')->get(route('admin.vpn'))
        ->assertOk()
        ->assertDontSee(__('vpn.store_config'));

    $this->actingAs(operator('Owner'), 'operator')->get(route('admin.vpn'))
        ->assertOk()
        ->assertSee(__('vpn.store_config'));

    Livewire::actingAs(operator('Support'), 'operator')->test(Vpn::class)
        ->set('name', 'selbst ausgestellt')
        ->call('create')
        ->assertForbidden();

    expect(VpnPeer::withTrashed()->count())->toBe(0);
});

it('creates an access, hands out the config once and pushes it to the hub', function () {
    vpnHub();
    Queue::fake();

    $component = Livewire::actingAs(operator('Owner'), 'operator')
        ->test(Vpn::class)
        ->set('name', 'Notebook Boban')
        ->call('create')
        ->assertHasNoErrors();

    $peer = VpnPeer::query()->firstOrFail();
    expect($peer->name)->toBe('Notebook Boban')
        ->and($peer->enabled)->toBeTrue()
        ->and($peer->present)->toBeFalse();   // not on the hub until the job ran

    $config = ConfigHandoff::get($component->get('configToken'));
    expect($config)->toContain('[Interface]')
        ->toContain('Address = '.$peer->allowed_ip.'/32')
        ->toContain('PublicKey = HUBPUBLICKEY0000000000000000000000000000000=')
        ->toContain('Endpoint = vpn.clupilot.test:51820');

    // The private key is handed over once and not kept anywhere.
    preg_match('/PrivateKey = (\S+)/', $config, $m);
    expect(Keypair::isValidKey($m[1]))->toBeTrue()
        ->and(VpnPeer::query()->where('public_key', $m[1])->exists())->toBeFalse()
        ->and($peer->config_secret)->toBeNull()
        // …and it never travels in the component snapshot, only a handle does.
        ->and($component->get('configToken'))->not->toContain($m[1]);

    Queue::assertPushed(ApplyVpnPeer::class, fn ($job) => $job->publicKey === $peer->public_key && $job->enabled);
});

it('accepts a peer-supplied public key and then has no private key to show', function () {
    vpnHub();
    Queue::fake();
    $own = Keypair::generate()->publicKey;

    $component = Livewire::actingAs(operator('Owner'), 'operator')
        ->test(Vpn::class)
        ->set('name', 'Proxmox fsn1')
        ->set('publicKey', $own)
        ->call('create')
        ->assertHasNoErrors();

    expect(VpnPeer::query()->where('public_key', $own)->exists())->toBeTrue()
        ->and($component->get('configToken'))->toBeNull();
});

it('rejects a malformed key and a key that already has an access', function () {
    vpnHub();
    $existing = VpnPeer::factory()->create();

    Livewire::actingAs(operator('Owner'), 'operator')->test(Vpn::class)
        ->set('name', 'kaputt')->set('publicKey', 'nope')
        ->call('create')
        ->assertHasErrors('publicKey');

    Livewire::actingAs(operator('Owner'), 'operator')->test(Vpn::class)
        ->set('name', 'doppelt')->set('publicKey', $existing->public_key)
        ->call('create')
        ->assertHasErrors('publicKey');

    expect(VpnPeer::query()->count())->toBe(1);
});

it('blocks and unblocks an access without losing the record', function () {
    vpnHub();
    Queue::fake();
    $peer = VpnPeer::factory()->create();

    Livewire::actingAs(operator('Owner'), 'operator')->test(Vpn::class)->call('toggle', $peer->uuid);
    expect($peer->fresh()->enabled)->toBeFalse();
    Queue::assertPushed(ApplyVpnPeer::class, fn ($job) => ! $job->enabled);

    Livewire::actingAs(operator('Owner'), 'operator')->test(Vpn::class)->call('toggle', $peer->uuid);
    expect($peer->fresh()->enabled)->toBeTrue()
        ->and(VpnPeer::query()->count())->toBe(1);
});

it('removes the peer from the hub before deleting the row', function () {
    vpnHub();
    Queue::fake();
    $peer = VpnPeer::factory()->create();

    Livewire::actingAs(operator('Owner'), 'operator')
        ->test(ConfirmDeleteVpnPeer::class, ['uuid' => $peer->uuid])
        ->call('delete');

    expect(VpnPeer::query()->count())->toBe(0);
    Queue::assertPushed(ApplyVpnPeer::class, fn ($job) => $job->publicKey === $peer->public_key && ! $job->enabled);
});

it('warns that deleting a host access cuts CluPilot off from that host', function () {
    vpnHub();
    $host = Host::factory()->active()->create(['name' => 'pve-fsn1']);
    $peer = VpnPeer::factory()->create(['host_id' => $host->id]);

    Livewire::actingAs(operator('Owner'), 'operator')
        ->test(ConfirmDeleteVpnPeer::class, ['uuid' => $peer->uuid])
        ->assertSee('pve-fsn1');
});

it('stops mutations from a session whose capability was revoked mid-flight', function () {
    vpnHub();
    Queue::fake();
    $peer = VpnPeer::factory()->create();          // someone else's access
    $user = operator('Owner');

    // Page is open and legitimate…
    $component = Livewire::actingAs($user, 'operator')->test(Vpn::class);

    // …then the account is demoted. The already-rendered page must not keep its
    // powers: every action re-checks, it does not trust the mount. And it must
    // refuse out loud, not quietly do nothing.
    $user->syncRoles(['Support']);
    app(PermissionRegistrar::class)->forgetCachedPermissions();

    $component->call('toggle', $peer->uuid)->assertForbidden();

    expect($peer->fresh()->enabled)->toBeTrue();
    Queue::assertNotPushed(ApplyVpnPeer::class);
});

it('lets an owner switch their own access off and on', function () {
    vpnHub();
    Queue::fake();
    $support = operator('Support');
    $peer = VpnPeer::factory()->ownedBy($support)->create();

    Livewire::actingAs($support, 'operator')->test(Vpn::class)->call('toggle', $peer->uuid);

    expect($peer->fresh()->enabled)->toBeFalse();
    Queue::assertPushed(ApplyVpnPeer::class, fn ($job) => ! $job->enabled);
});

it('adopts host peers from the hub and marks vanished ones absent', function () {
    $hub = vpnHub();
    $key = Keypair::generate()->publicKey;
    $host = Host::factory()->active()->create(['name' => 'pve-fsn1', 'wg_pubkey' => $key]);

    $hub->addPeer($key, '10.66.0.7');
    $hub->snapshots[$key] = new PeerSnapshot(
        publicKey: $key,
        endpoint: '203.0.113.9:51820',
        allowedIps: '10.66.0.7/32',
        latestHandshake: now()->subSeconds(30)->toImmutable(),
        rxBytes: 2048,
        txBytes: 4096,
    );

    // A peer that exists in the console but is no longer on the interface.
    $stale = VpnPeer::factory()->create(['present' => true]);

    (new SyncVpnPeers)->handle($hub);

    $adopted = VpnPeer::query()->where('public_key', $key)->firstOrFail();
    expect($adopted->name)->toBe('pve-fsn1')          // named after its host, not "unknown"
        ->and($adopted->kind)->toBe(VpnPeer::KIND_HOST)
        ->and($adopted->host_id)->toBe($host->id)
        ->and($adopted->present)->toBeTrue()
        ->and($adopted->rx_bytes)->toBe(2048)
        ->and($adopted->endpoint)->toBe('203.0.113.9:51820')
        ->and($adopted->status())->toBe('online')
        ->and($stale->fresh()->present)->toBeFalse();
});

it('never lets a sync overwrite the operator intent to block', function () {
    $hub = vpnHub();
    $peer = VpnPeer::factory()->blocked()->create();

    // The hub still reports it (removal has not been applied yet).
    $hub->addPeer($peer->public_key, $peer->allowed_ip);
    (new SyncVpnPeers)->handle($hub);

    $peer->refresh();
    expect($peer->enabled)->toBeFalse()      // intent survives
        ->and($peer->present)->toBeTrue()    // reality is recorded
        ->and($peer->status())->toBe('pending');
});

it('does not hand the same tunnel address to a host and a VPN access', function () {
    $host = Host::factory()->active()->create(['wg_ip' => '10.66.0.2']);
    VpnPeer::factory()->create(['allowed_ip' => '10.66.0.3']);

    // The real allocator, not the fake — this is the collision the fake cannot show.
    expect((new LocalWireguardHub)->allocateIp())->toBe('10.66.0.4');
});

it('does not let a failed removal resurrect a revoked access', function () {
    $hub = vpnHub();
    Queue::fake();
    $peer = VpnPeer::factory()->create();
    $hub->addPeer($peer->public_key, $peer->allowed_ip);

    Livewire::actingAs(operator('Owner'), 'operator')
        ->test(ConfirmDeleteVpnPeer::class, ['uuid' => $peer->uuid])
        ->call('delete');

    // Removal is queued but has not run — the peer is still on the hub.
    expect(VpnPeer::query()->count())->toBe(0)
        ->and(VpnPeer::withTrashed()->count())->toBe(1);

    (new SyncVpnPeers)->handle($hub);

    // The sync must NOT adopt it back as a live access…
    expect(VpnPeer::query()->count())->toBe(0);
    // …it must retry the removal instead.
    Queue::assertPushed(ApplyVpnPeer::class, fn ($job) => $job->publicKey === $peer->public_key && ! $job->enabled);
});

it('clears the tombstone once the peer is really gone', function () {
    $hub = vpnHub();
    $peer = VpnPeer::factory()->create();
    $hub->addPeer($peer->public_key, $peer->allowed_ip);
    $peer->delete();

    (new ApplyVpnPeer($peer->public_key, null, false))->handle($hub);

    expect($hub->peerIps())->toBe([])
        ->and(VpnPeer::withTrashed()->count())->toBe(0);
});

it('ignores a stale job that would undo a newer decision', function () {
    $hub = vpnHub();
    $peer = VpnPeer::factory()->create();
    $hub->addPeer($peer->public_key, $peer->allowed_ip);

    // Operator blocks, then immediately unblocks: two jobs are now in flight.
    $blockJob = new ApplyVpnPeer($peer->public_key, $peer->allowed_ip, false);
    $peer->update(['enabled' => false]);
    $peer->update(['enabled' => true]);          // newer intent wins

    // The older block job runs late (a retry, say). It must not cut access.
    $blockJob->handle($hub);

    expect($hub->peerIps())->toHaveKey($peer->public_key)
        ->and($peer->fresh()->present)->toBeTrue();
});

it('keeps a revoked address and key reserved until the hub confirms', function () {
    vpnHub();
    $peer = VpnPeer::factory()->create(['allowed_ip' => '10.66.0.2']);
    $peer->delete();

    // The address is still configured on the hub — handing it out again would
    // give two machines the same tunnel IP.
    expect((new LocalWireguardHub)->allocateIp())->not->toBe('10.66.0.2');

    Livewire::actingAs(operator('Owner'), 'operator')->test(Vpn::class)
        ->set('name', 'wieder da')->set('publicKey', $peer->public_key)
        ->call('create')
        ->assertHasErrors('publicKey');
});

it('narrows what an open page shows once the account is demoted', function () {
    vpnHub();
    $user = operator('Owner');
    VpnPeer::factory()->create(['name' => 'Fremdes-Notebook']);
    $component = Livewire::actingAs($user, 'operator')->test(Vpn::class)->assertSee('Fremdes-Notebook');

    $user->syncRoles(['Support']);
    app(PermissionRegistrar::class)->forgetCachedPermissions();

    // The five-second poll keeps working — the page is still theirs — but it
    // must stop serving other people's accesses to a demoted account.
    $component->call('refreshPeers')->assertDontSee('Fremdes-Notebook');
});

it('holds the same allocation lock the host pipeline uses', function () {
    vpnHub();

    // Onboarding (ConfigureWireguard) holds this exact lock while it reserves a
    // host address. Addresses come from one subnet but live in two tables, so
    // no unique index can catch the other side's insert — waiting is the only
    // thing that keeps a host and an access off the same tunnel IP.
    // Short TTL so the test measures the wait instead of sitting out a timeout.
    expect(Cache::lock('wireguard:allocate', 1)->get())->toBeTrue();

    $started = microtime(true);
    Livewire::actingAs(operator('Owner'), 'operator')->test(Vpn::class)
        ->set('name', 'parallel')
        ->call('create')
        ->assertHasNoErrors();
    $elapsed = microtime(true) - $started;

    expect(VpnPeer::query()->count())->toBe(1)
        ->and($elapsed)->toBeGreaterThan(0.8);   // it waited for the lock to expire
});

it('never re-adds a peer whose row is gone, however stale the job', function () {
    $hub = vpnHub();
    $peer = VpnPeer::factory()->create();

    // The add job is retried late — after the access was revoked and its
    // tombstone already purged by a sync that saw the peer gone.
    $staleAdd = ApplyVpnPeer::for($peer);
    $peer->forceDelete();

    $staleAdd->handle($hub);

    expect($hub->peerIps())->toBe([])
        ->and(VpnPeer::withTrashed()->count())->toBe(0);
});

it('serializes hub reads against hub mutations', function () {
    $hub = vpnHub();
    $peer = VpnPeer::factory()->create();
    $hub->addPeer($peer->public_key, $peer->allowed_ip);

    // A removal in flight holds this lock. A sync must wait for it instead of
    // acting on a snapshot taken around the mutation.
    expect(Cache::lock('wireguard:hub', 1)->get())->toBeTrue();

    $started = microtime(true);
    (new SyncVpnPeers)->handle($hub);

    expect(microtime(true) - $started)->toBeGreaterThan(0.8)
        ->and($peer->fresh()->present)->toBeTrue();
});

it('reports a lost creation race as a validation error, not a 500', function () {
    // A hub whose allocation inserts a colliding row mid-flight: exactly what a
    // concurrent request that skipped the lock would do between our check and
    // our insert.
    $key = Keypair::generate()->publicKey;
    $hub = new class($key) extends FakeWireguardHub
    {
        public function __construct(private string $collidingKey) {}

        public function allocateIp(int $from = 1): string
        {
            VpnPeer::create([
                'name' => 'gleichzeitig',
                'public_key' => $this->collidingKey,
                'allowed_ip' => '10.66.0.90',
                'enabled' => true,
            ]);

            return '10.66.0.91';
        }
    };
    app()->instance(WireguardHub::class, $hub);

    Livewire::actingAs(operator('Owner'), 'operator')->test(Vpn::class)
        ->set('name', 'verloren')
        ->set('publicKey', $key)
        ->call('create')
        ->assertHasErrors('publicKey');

    // What matters: the loser gets told, not a 500, and creates nothing.
    // (The competing row is written inside our own transaction here, so the
    // rollback removes it too — a real competitor's row would survive, which a
    // single-connection sqlite test cannot reproduce.)
    expect(VpnPeer::withTrashed()->where('name', 'verloren')->count())->toBe(0);
});

it('keeps host peer removal behind the same hub lock', function () {
    $hub = vpnHub();
    $hub->addPeer('HOSTKEY', '10.66.0.30');

    expect(Cache::lock('wireguard:hub', 1)->get())->toBeTrue();

    $started = microtime(true);
    (new RemoveWireguardPeer('HOSTKEY'))->handle($hub);

    expect(microtime(true) - $started)->toBeGreaterThan(0.8)
        ->and($hub->peerIps())->toBe([]);
});

it('refuses a public key that already belongs to a host', function () {
    vpnHub();
    $key = Keypair::generate()->publicKey;
    // The host exists but the scheduled sync has not adopted its peer yet.
    Host::factory()->active()->create(['name' => 'pve-fsn1', 'wg_pubkey' => $key]);

    Livewire::actingAs(operator('Owner'), 'operator')->test(Vpn::class)
        ->set('name', 'entführt')
        ->set('publicKey', $key)
        ->call('create')
        ->assertHasErrors('publicKey');

    expect(VpnPeer::withTrashed()->count())->toBe(0);
});

it('renames a peer it adopted before the host link was known', function () {
    $hub = vpnHub();
    $key = Keypair::generate()->publicKey;
    $hub->addPeer($key, '10.66.0.7');

    // First sync: the step has not stored wg_pubkey yet → unknown peer.
    (new SyncVpnPeers)->handle($hub);
    $peer = VpnPeer::query()->where('public_key', $key)->firstOrFail();
    expect($peer->name)->toBe(__('vpn.unknown_peer'))
        ->and($peer->host_id)->toBeNull()
        // Not staff: a staff access has an owner, and this one has nobody.
        ->and($peer->kind)->toBe(VpnPeer::KIND_SYSTEM);

    Host::factory()->active()->create(['name' => 'pve-fsn1', 'wg_pubkey' => $key]);
    (new SyncVpnPeers)->handle($hub);

    expect($peer->fresh()->name)->toBe('pve-fsn1')
        ->and($peer->fresh()->host_id)->not->toBeNull()
        ->and($peer->fresh()->kind)->toBe(VpnPeer::KIND_HOST);
});

it('does not rename an access an operator named themselves', function () {
    $hub = vpnHub();
    $user = operator('Owner');
    $key = Keypair::generate()->publicKey;
    $peer = VpnPeer::factory()->create(['public_key' => $key, 'name' => 'Notebook Boban', 'created_by' => $user->id]);
    $hub->addPeer($key, $peer->allowed_ip);

    Host::factory()->active()->create(['name' => 'pve-fsn1', 'wg_pubkey' => $key]);
    (new SyncVpnPeers)->handle($hub);

    expect($peer->fresh()->name)->toBe('Notebook Boban');
});

it('does not leave a phantom access behind when a host is purged', function () {
    $hub = vpnHub();
    $key = Keypair::generate()->publicKey;
    $host = Host::factory()->active()->create(['name' => 'pve-fsn1', 'wg_pubkey' => $key]);
    $hub->addPeer($key, '10.66.0.7');

    (new SyncVpnPeers)->handle($hub);           // console adopts the host peer
    expect(VpnPeer::query()->count())->toBe(1);

    (new PurgeHost($host->uuid))->handle();

    // No enabled-but-absent leftover the operator could toggle back on.
    expect(VpnPeer::query()->count())->toBe(0)
        ->and(VpnPeer::onlyTrashed()->count())->toBe(1);

    // The queued removal clears both the hub peer and the tombstone.
    (new ApplyVpnPeer($key, null, false))->handle($hub);
    expect($hub->peerIps())->toBe([])
        ->and(VpnPeer::withTrashed()->count())->toBe(0);
});

it('stores the config only when asked, and encrypted', function () {
    vpnHub();
    Queue::fake();
    $owner = operator('Owner');

    Livewire::actingAs($owner, 'operator')->test(Vpn::class)
        ->set('name', 'Notebook')->set('ownerId', $owner->id)
        ->set('storeConfig', true)
        ->call('create')
        ->assertHasNoErrors();

    $peer = VpnPeer::query()->firstOrFail();
    expect($peer->config_secret)->not->toBeNull()
        // Not readable without the key: no plaintext key material in the column.
        ->and($peer->config_secret)->not->toContain('PrivateKey')
        ->and($peer->config_secret)->toStartWith('v1:')
        ->and(ConfigVault::decrypt($peer->config_secret))->toContain('[Interface]');
});

it('refuses to store when there is no key to store it under', function () {
    vpnHub();
    config()->set('admin_access.vpn_config_key', '');

    Livewire::actingAs(operator('Owner'), 'operator')->test(Vpn::class)
        ->set('name', 'Notebook')->set('storeConfig', true)
        ->call('create')
        ->assertHasErrors('storeConfig');

    // Nothing half-created: no access without the storage it was asked for.
    expect(VpnPeer::withTrashed()->count())->toBe(0);
});

it('hands a stored config back only to its owner, and only with the password', function () {
    vpnHub();
    $support = operator('Support');
    $support->forceFill(['password' => Hash::make('geheim-1234')])->save();
    $peer = VpnPeer::factory()->ownedBy($support)->create([
        'config_secret' => ConfigVault::encrypt("[Interface]\nPrivateKey = XYZ\n"),
    ]);

    // Wrong password: nothing comes back.
    $modal = Livewire::actingAs($support, 'operator')->test(VpnConfigAccess::class, ['uuid' => $peer->uuid])
        ->set('password', 'falsch')
        ->call('reveal')
        ->assertHasErrors('password');
    expect($modal->get('token'))->toBeNull();

    // Right password: the config is handed over, and the retrieval is recorded.
    $modal->set('password', 'geheim-1234')->call('reveal')->assertHasNoErrors();
    expect(ConfigHandoff::get($modal->get('token')))->toContain('PrivateKey = XYZ')
        ->and($peer->fresh()->download_count)->toBe(1)
        ->and($peer->fresh()->last_downloaded_at)->not->toBeNull();
});

it('never hands someone else the private key, not even an Owner', function () {
    vpnHub();
    $support = operator('Support');
    $peer = VpnPeer::factory()->ownedBy($support)->create([
        'config_secret' => ConfigVault::encrypt("[Interface]\nPrivateKey = XYZ\n"),
    ]);

    // The boss can see the access and revoke it — but holding its key is a
    // different thing, and the policy says no to everyone but the owner.
    foreach (['Owner', 'Admin', 'Developer'] as $role) {
        $other = operator($role);
        expect($other->can('view', $peer))->toBeTrue()
            ->and($other->can('downloadConfig', $peer))->toBeFalse();

        // The page shows them the access without offering its config…
        $this->actingAs($other, 'operator')->get(route('admin.vpn'))
            ->assertOk()
            ->assertSee($peer->name)
            ->assertDontSee('admin.vpn-config-access');

        // …and going straight at the modal is refused, not merely hidden.
        Livewire::actingAs($other, 'operator')
            ->test(VpnConfigAccess::class, ['uuid' => $peer->uuid])
            ->assertForbidden();
    }

    // The owner still gets the button.
    $this->actingAs($support, 'operator')->get(route('admin.vpn'))
        ->assertOk()
        ->assertSee('admin.vpn-config-access');
});

it('locks out password guessing', function () {
    vpnHub();
    $support = operator('Support');
    $peer = VpnPeer::factory()->ownedBy($support)->create([
        'config_secret' => ConfigVault::encrypt("[Interface]\n"),
    ]);

    $modal = Livewire::actingAs($support, 'operator')->test(VpnConfigAccess::class, ['uuid' => $peer->uuid]);
    foreach (range(1, 5) as $attempt) {
        $modal->set('password', 'falsch'.$attempt)->call('reveal')->assertHasErrors('password');
    }

    // Even the correct password is refused while the account is throttled.
    $modal->set('password', 'password')->call('reveal');
    expect($modal->get('token'))->toBeNull();
});

it('sends the operator to sign in instead of a 500 when the session has ended', function () {
    // reveal() re-checks $this->authorize('downloadConfig', $peer) on every
    // call, which already refuses a guest (Gate auto-denies a policy whose
    // first parameter is a non-nullable Operator). That is a separate,
    // already-correct guard — not the one this test is for. A Gate::before
    // with a NULLABLE $user isolates the one line this regression is about:
    // Auth::guard('operator')->user() coming back null past that point,
    // which used to be dereferenced unguarded ($operator->password).
    Gate::before(fn (?Operator $user, string $ability) => $ability === 'downloadConfig' ? true : null);

    vpnHub();
    $support = operator('Support');
    $peer = VpnPeer::factory()->ownedBy($support)->create([
        'config_secret' => ConfigVault::encrypt("[Interface]\nPrivateKey = XYZ\n"),
    ]);

    $modal = Livewire::actingAs($support, 'operator')->test(VpnConfigAccess::class, ['uuid' => $peer->uuid]);

    // The modal sat open long enough for the operator's own session to end.
    Auth::guard('operator')->logout();

    $modal->set('password', 'geheim-1234')->call('reveal')
        ->assertRedirect(route('admin.login'));
});

it('destroys the stored config when the access is revoked', function () {
    vpnHub();
    Queue::fake();
    $support = operator('Support');
    $peer = VpnPeer::factory()->ownedBy($support)->create([
        'config_secret' => ConfigVault::encrypt("[Interface]\nPrivateKey = XYZ\n"),
    ]);

    Livewire::actingAs(operator('Owner'), 'operator')
        ->test(ConfirmDeleteVpnPeer::class, ['uuid' => $peer->uuid])
        ->call('delete');

    // The tombstone must not keep a usable credential.
    $tombstone = VpnPeer::withTrashed()->firstOrFail();
    expect($tombstone->config_secret)->toBeNull()
        ->and($tombstone->secret_purged_at)->not->toBeNull();
});

it('takes the tunnel away when a staff member is revoked', function () {
    vpnHub();
    Queue::fake();
    $support = operator('Support');
    $peer = VpnPeer::factory()->ownedBy($support)->create([
        'config_secret' => ConfigVault::encrypt("[Interface]\n"),
    ]);

    Livewire::actingAs(operator('Owner'), 'operator')->test(Settings::class)
        ->call('revokeStaff', $support->id);

    expect($support->fresh()->isOperator())->toBeFalse()
        ->and(VpnPeer::query()->count())->toBe(0)
        ->and(VpnPeer::withTrashed()->first()->config_secret)->toBeNull();
    Queue::assertPushed(ApplyVpnPeer::class, fn ($job) => $job->publicKey === $peer->public_key && ! $job->enabled);
});

it('removes a revoked colleague from the hub only after the rows are committed', function () {
    $hub = vpnHub();
    $support = operator('Support');
    $peer = VpnPeer::factory()->ownedBy($support)->create();
    $hub->addPeer($peer->public_key, $peer->allowed_ip);

    // Sync queue: the job runs the moment it is dispatched, so if that happened
    // inside the transaction it would still see a live peer and leave it up.
    Livewire::actingAs(operator('Owner'), 'operator')->test(Settings::class)
        ->call('revokeStaff', $support->id);

    expect($hub->peerIps())->toBe([])
        ->and(VpnPeer::withTrashed()->count())->toBe(0);   // tombstone cleared by the removal
});

it('stops serving a revealed config once the access is revoked', function () {
    vpnHub();
    Queue::fake();
    $support = operator('Support');
    $support->forceFill(['password' => Hash::make('geheim-1234')])->save();
    $peer = VpnPeer::factory()->ownedBy($support)->create([
        'config_secret' => ConfigVault::encrypt("[Interface]\nPrivateKey = XYZ\n"),
    ]);

    $modal = Livewire::actingAs($support, 'operator')->test(VpnConfigAccess::class, ['uuid' => $peer->uuid])
        ->set('password', 'geheim-1234')
        ->call('reveal');
    $token = $modal->get('token');
    expect(ConfigHandoff::get($token))->toContain('PrivateKey = XYZ');

    // Revoked while the modal is still open on someone's screen.
    Livewire::actingAs(operator('Owner'), 'operator')
        ->test(ConfirmDeleteVpnPeer::class, ['uuid' => $peer->uuid])
        ->call('delete');

    // The open modal must not keep handing the key out of the cache.
    $modal->call('toggleQr')->assertForbidden();
    expect(ConfigHandoff::get($token))->toBeNull();
});

it('never leaves the previous config on screen after another access is created', function () {
    vpnHub();
    Queue::fake();
    $owner = operator('Owner');

    $component = Livewire::actingAs($owner, 'operator')->test(Vpn::class)
        ->set('name', 'Erster')->set('ownerId', $owner->id)
        ->call('create');
    $firstToken = $component->get('configToken');
    expect(ConfigHandoff::get($firstToken))->toContain('PrivateKey');

    // Second access brings its own key, so there is no config to show — the
    // first one must not stay up wearing the second one's name.
    $component->set('name', 'Zweiter')->set('publicKey', Keypair::generate()->publicKey)
        ->call('create')
        ->assertHasNoErrors();

    expect($component->get('configToken'))->toBeNull()
        ->and($component->get('newConfigName'))->toBeNull()
        ->and(ConfigHandoff::get($firstToken))->toBeNull();
});

it('does not keep polling while a private config is on screen', function () {
    vpnHub();
    Queue::fake();
    $owner = operator('Owner');

    $component = Livewire::actingAs($owner, 'operator')->test(Vpn::class)->assertSee('wire:poll', false);

    $component->set('name', 'Notebook')->set('ownerId', $owner->id)->call('create');

    // Otherwise every poll would put the private key into another response.
    $component->assertDontSee('wire:poll', false)->assertSee('PrivateKey');

    $component->call('dismissConfig')
        ->assertSee('wire:poll', false)
        ->assertDontSee('PrivateKey');
});

it('will not issue an access to someone who is no longer staff', function () {
    vpnHub();
    $owner = operator('Owner');
    $former = User::factory()->create();   // no operator role

    // Not $former->id directly: operators and users are independent id
    // sequences, and the in-memory test database restarts both near 1 after
    // every test's rollback, so a users id colliding with a real operator's is
    // routine here, not a one-in-a-billion accident. Past the highest id either
    // sequence has handed out this test is an id no operator can hold — proving
    // the same thing ownerId=$former->id would, without depending on the two
    // sequences staying apart by luck.
    $ownerId = max($former->id, $owner->id) + 1;

    Livewire::actingAs($owner, 'operator')->test(Vpn::class)
        ->set('name', 'Ex-Kollege')->set('ownerId', $ownerId)
        ->call('create')
        ->assertHasErrors('ownerId');

    expect(VpnPeer::withTrashed()->count())->toBe(0);
});

it('closes the owner doors as soon as the roles are gone', function () {
    vpnHub();
    $support = operator('Support');
    $peer = VpnPeer::factory()->ownedBy($support)->create([
        'config_secret' => ConfigVault::encrypt("[Interface]\n"),
    ]);
    expect($support->can('downloadConfig', $peer))->toBeTrue();

    // Roles removed directly, not through revokeStaff() — ownership alone must
    // not keep the modals reachable.
    $support->syncRoles([]);
    app(PermissionRegistrar::class)->forgetCachedPermissions();
    $support = $support->fresh();

    expect($support->can('downloadConfig', $peer))->toBeFalse()
        ->and($support->can('delete', $peer))->toBeFalse()
        ->and($support->can('block', $peer))->toBeFalse();

    Livewire::actingAs($support, 'operator')->test(VpnConfigAccess::class, ['uuid' => $peer->uuid])->assertForbidden();
});

it('re-issues a key for an access whose config was never stored', function () {
    vpnHub();
    Queue::fake();
    $owner = operator('Owner');
    $peer = VpnPeer::factory()->ownedBy($owner)->create();
    $oldKey = $peer->public_key;

    $component = Livewire::actingAs($owner, 'operator')->test(Vpn::class)->call('reissue', $peer->uuid);

    // A new key, and the config handed over once — the only honest answer when
    // the private half of the old one exists nowhere.
    $peer->refresh();
    expect($peer->public_key)->not->toBe($oldKey)
        ->and($peer->present)->toBeFalse()
        ->and(ConfigHandoff::get($component->get('configToken')))
        ->toContain('Address = '.$peer->allowed_ip);

    // Old key off the hub, new key on — in that order, or two peers would
    // briefly claim the same tunnel address.
    Queue::assertPushed(ApplyVpnPeer::class, fn ($job) => $job->publicKey === $oldKey && ! $job->enabled);
    Queue::assertPushed(ApplyVpnPeer::class, fn ($job) => $job->publicKey === $peer->public_key && $job->enabled);
});

// R23: confirmation moved from wire:confirm to ConfirmReissueVpnPeer, which
// mirrors ConfirmDeleteVpnPeer above but dispatches back to Vpn::reissue()
// instead of mutating the peer itself, so the page can still show the new
// config after the modal closes.

it('does not open the reissue confirmation without vpn.manage.all', function () {
    vpnHub();
    $peer = VpnPeer::factory()->ownedBy(operator('Support'))->create();

    Livewire::actingAs(operator('Support'), 'operator')
        ->test(ConfirmReissueVpnPeer::class, ['uuid' => $peer->uuid])
        ->assertForbidden();
});

it('does not offer the reissue confirmation for a host access', function () {
    vpnHub();
    $host = Host::factory()->active()->create();
    $peer = VpnPeer::factory()->forHost()->create(['host_id' => $host->id]);

    Livewire::actingAs(operator('Owner'), 'operator')
        ->test(ConfirmReissueVpnPeer::class, ['uuid' => $peer->uuid])
        ->assertNotFound();
});

it('confirms a reissue through the modal without changing the key itself', function () {
    vpnHub();
    $owner = operator('Owner');
    $peer = VpnPeer::factory()->ownedBy($owner)->create(['name' => 'Notebook-Boban']);
    $oldKey = $peer->public_key;

    Livewire::actingAs($owner, 'operator')
        ->test(ConfirmReissueVpnPeer::class, ['uuid' => $peer->uuid])
        ->assertSee('Notebook-Boban')
        ->call('confirm')
        ->assertDispatched('vpn-peer-reissue-confirmed', uuid: $peer->uuid);

    expect($peer->fresh()->public_key)->toBe($oldKey);
});

it('re-issues once the page receives the confirmed event', function () {
    vpnHub();
    Queue::fake();
    $owner = operator('Owner');
    $peer = VpnPeer::factory()->ownedBy($owner)->create();
    $oldKey = $peer->public_key;

    // Exercises the forwarding listener directly — see the equivalent note in
    // SecretsPageTest for why the cross-component event round trip itself is
    // checked manually rather than in a single-component Livewire::test().
    Livewire::actingAs($owner, 'operator')->test(Vpn::class)->call('onReissueConfirmed', $peer->uuid);

    expect($peer->fresh()->public_key)->not->toBe($oldKey);
});

it('keeps a stored config in step when the key is re-issued', function () {
    vpnHub();
    Queue::fake();
    $owner = operator('Owner');
    $peer = VpnPeer::factory()->ownedBy($owner)->create([
        'config_secret' => ConfigVault::encrypt("[Interface]\nPrivateKey = ALT\n"),
    ]);

    Livewire::actingAs($owner, 'operator')->test(Vpn::class)->call('reissue', $peer->uuid);

    // Otherwise the owner would download a configuration whose key the hub no
    // longer accepts.
    $stored = ConfigVault::decrypt($peer->fresh()->config_secret);
    expect($stored)->not->toContain('PrivateKey = ALT')
        ->and($stored)->toContain('Address = '.$peer->allowed_ip);
});

it('does not re-issue a host peer from the VPN page', function () {
    vpnHub();
    Queue::fake();
    $host = Host::factory()->active()->create();
    $peer = VpnPeer::factory()->forHost()->create(['host_id' => $host->id]);
    $oldKey = $peer->public_key;

    Livewire::actingAs(operator('Owner'), 'operator')->test(Vpn::class)->call('reissue', $peer->uuid);

    // Its key belongs to the host's own wg0 — swapping it here would cut the
    // machine off with nothing to repair it.
    expect($peer->fresh()->public_key)->toBe($oldKey);
    Queue::assertNotPushed(ApplyVpnPeer::class);
});

it('revokes the replaced key even after a sync adopted it', function () {
    $hub = vpnHub();
    $owner = operator('Owner');
    $peer = VpnPeer::factory()->ownedBy($owner)->create();
    $oldKey = $peer->public_key;
    $hub->addPeer($oldKey, $peer->allowed_ip);

    // Queue faked on purpose: the window only exists while the removal is still
    // pending, which a synchronous queue would close instantly.
    Queue::fake();
    Livewire::actingAs($owner, 'operator')->test(Vpn::class)->call('reissue', $peer->uuid);

    // A sync lands before the removal runs. It must neither adopt the old key
    // (its address belongs to the live access) nor blow up on the unique index,
    // which would stop every other peer's state from being updated too.
    (new SyncVpnPeers)->handle($hub);

    expect(VpnPeer::query()->where('public_key', $oldKey)->exists())->toBeFalse()
        ->and(VpnPeer::query()->count())->toBe(1);

    (new ApplyVpnPeer($oldKey, null, false, force: true))->handle($hub);

    expect($hub->peerIps())->not->toHaveKey($oldKey)
        ->and(VpnPeer::withTrashed()->where('public_key', $oldKey)->exists())->toBeFalse();
});

it('does not let a late revocation delete an access that reused the key', function () {
    $hub = vpnHub();
    $owner = operator('Owner');
    $key = Keypair::generate()->publicKey;

    // Someone created a new access with a key that used to belong elsewhere.
    $fresh = VpnPeer::factory()->ownedBy($owner)->create(['public_key' => $key]);
    $hub->addPeer($key, $fresh->allowed_ip);

    // The old revocation job finally runs.
    (new ApplyVpnPeer($key, null, false, force: true))->handle($hub);

    expect(VpnPeer::query()->whereKey($fresh->id)->exists())->toBeTrue()
        ->and($hub->peerIps())->toHaveKey($key);   // and they stay connected
});

it('re-issues even when the config vault is unavailable', function () {
    vpnHub();
    Queue::fake();
    $owner = operator('Owner');
    $peer = VpnPeer::factory()->ownedBy($owner)->create([
        'config_secret' => ConfigVault::encrypt("[Interface]\n"),
    ]);

    // The very situation the console tells people to fix by re-issuing.
    config()->set('admin_access.vpn_config_key', '');

    Livewire::actingAs($owner, 'operator')->test(Vpn::class)->call('reissue', $peer->uuid);

    expect($peer->fresh()->config_secret)->toBeNull();   // shown once instead
});

it('does not route the WireGuard endpoint through its own tunnel', function () {
    // The obvious fix for "the VPN does not reach the console" is to add the
    // server's public address to AllowedIPs. It cannot work: the endpoint is
    // that same address, so the handshake packets would be routed into the
    // tunnel they are trying to establish. Same blank page, now with no way in
    // at all.
    config()->set('provisioning.wireguard.subnet', '10.66.0.0/24');

    $method = new ReflectionMethod(Vpn::class, 'allowedIps');
    $method->setAccessible(true);

    foreach (['203.0.113.10:51820', '[2001:db8::10]:51820', 'vpn.example.test:51820'] as $endpoint) {
        expect($method->invoke(null, $endpoint))->toBe('10.66.0.0/24', $endpoint);
    }
});

it('points a client at the tunnel resolver only when the console is reachable there', function () {
    // Setting DNS otherwise hands the device a resolver that does not exist and
    // takes its name resolution down with it for as long as the tunnel is up.
    config()->set('provisioning.wireguard.hub_address', '10.66.0.1');

    $build = new ReflectionMethod(Vpn::class, 'clientConfig');
    $build->setAccessible(true);
    $page = new Vpn;

    $keypair = new Keypair('priv', 'pub');

    // Not ready: no resolver is running, so naming one would take the device's
    // whole name resolution down for as long as the tunnel is up.
    config()->set('admin_access.vpn_ready', false);
    expect($build->invoke($page, $keypair, '10.66.0.9'))->not->toContain('DNS =');

    config()->set('admin_access.vpn_ready', true);
    expect($build->invoke($page, $keypair, '10.66.0.9'))->toContain('DNS = 10.66.0.1');
});

/*
|--------------------------------------------------------------------------
| Zwei Gruppen, nicht eine Liste
|--------------------------------------------------------------------------
|
| Personen und Hosts standen gemischt untereinander, nach Verbindungszustand
| sortiert — ein Host-Zugang konnte also zwischen zwei Mitarbeitern stehen.
| Beide sind Peers im selben Netz, aber es sind zwei verschiedene Fragen: „wer
| von uns ist im Netz" und „welche Maschinen hängen dran".
*/

it('keeps host accesses in their own group, apart from the people', function () {
    vpnHub();
    $host = Host::factory()->create(['name' => 'pve-fns-1']);
    VpnPeer::factory()->create(['name' => 'pve-fns-1', 'host_id' => $host->id, 'kind' => VpnPeer::KIND_HOST]);
    VpnPeer::factory()->create(['name' => 'Notebook Boban', 'host_id' => null, 'kind' => VpnPeer::KIND_STAFF]);

    Livewire::actingAs(admin(), 'operator')->test(Vpn::class)
        ->assertViewHas('peopleAccesses', fn ($peers) => $peers->pluck('name')->all() === ['Notebook Boban'])
        ->assertViewHas('hostAccesses', fn ($peers) => $peers->pluck('name')->all() === ['pve-fns-1'])
        ->assertSee(__('vpn.group_people'))
        ->assertSee(__('vpn.group_hosts'));
});

it('draws no heading for a group with nothing in it', function () {
    // Eine leere Überschrift ist eine Aussage über nichts — und auf einer
    // frischen Installation stünde „Hosts" über einer Lücke, bevor je einer
    // angelegt wurde.
    vpnHub();
    VpnPeer::factory()->create(['name' => 'Notebook Boban', 'host_id' => null, 'kind' => VpnPeer::KIND_STAFF]);

    Livewire::actingAs(admin(), 'operator')->test(Vpn::class)
        ->assertSee(__('vpn.group_people'))
        ->assertDontSee(__('vpn.group_hosts'));
});
