<?php

use App\Models\Host;
use App\Provisioning\Jobs\RemoveWireguardPeer;
use App\Services\Dns\FileHostDnsDirectory;
use App\Services\Dns\HttpHetznerDnsClient;
use App\Services\Proxmox\FakeProxmoxClient;
use App\Services\Ssh\CommandResult;
use App\Services\Ssh\FakeRemoteShell;
use App\Services\Wireguard\FakeWireguardHub;
use App\Services\Wireguard\LocalWireguardHub;
use App\Support\ProvisioningSettings;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Http;

it('scripts remote command output and records calls (FakeRemoteShell)', function () {
    $shell = new FakeRemoteShell;
    $shell->script('dpkg -l proxmox-ve', CommandResult::success('ii  proxmox-ve  8.2'));
    $shell->connectWithPassword('1.2.3.4', 'root', 'pw');

    expect($shell->run('dpkg -l proxmox-ve')->stdout)->toContain('proxmox-ve')
        ->and($shell->run('whoami')->ok())->toBeTrue()          // default success
        ->and($shell->ran('dpkg -l proxmox-ve'))->toBeTrue();

    $shell->putFile('/etc/wireguard/wg0.conf', '[Interface]');
    expect($shell->files()['/etc/wireguard/wg0.conf'])->toBe('[Interface]')
        ->and($shell->connectionsWith('password'))->toHaveCount(1);
});

it('removes a peer via the RemoveWireguardPeer job', function () {
    $hub = new FakeWireguardHub;
    $hub->addPeer('PK', '10.66.0.9');

    (new RemoveWireguardPeer('PK'))->handle($hub);

    expect($hub->peerIps())->toBe([]);
});

it('provides a deterministic VM lifecycle (FakeProxmoxClient)', function () {
    $pve = new FakeProxmoxClient;

    $a = $pve->nextVmid();
    $b = $pve->nextVmid();
    expect($b)->toBe($a + 1);

    $upid = $pve->cloneVm('pve', 9000, $a, 'nc-test');
    expect($upid)->toContain((string) $a)
        ->and($pve->clonedVmids)->toContain($a)
        ->and($pve->taskStatus('pve', $upid))->toBe(['status' => 'stopped', 'exitstatus' => 'OK'])
        ->and($pve->vmStatus('pve', $a)['status'])->toBe('stopped');

    $pve->startVm('pve', $a);
    expect($pve->vmStatus('pve', $a)['status'])->toBe('running')
        ->and($pve->guestAgentPing('pve', $a))->toBeTrue();

    $pve->guestScript('occ status', 0, 'installed: true');
    expect($pve->guestExec('pve', $a, 'occ status')['out-data'])->toContain('installed')
        ->and($pve->guestExec('pve', $a, 'whoami')['exitcode'])->toBe(0)
        ->and($pve->guestRan('occ status'))->toBeTrue();
});

it('can force a running task and a failed exit status (FakeProxmoxClient)', function () {
    $pve = new FakeProxmoxClient;

    $pve->forceTaskStatus = 'running';
    expect($pve->taskStatus('pve', 'UPID')['status'])->toBe('running');

    $pve->forceTaskStatus = null;
    $pve->taskExitStatus = 'clone failed';
    expect($pve->taskStatus('pve', 'UPID')['exitstatus'])->toBe('clone failed');
});

it('allocates ips and tracks peers (FakeWireguardHub)', function () {
    $hub = new FakeWireguardHub;

    $a = $hub->allocateIp();
    $b = $hub->allocateIp();
    expect($a)->not->toBe($b);

    $hub->addPeer('PUBKEY', '10.66.0.9');
    expect($hub->peerIps())->toBe(['PUBKEY' => '10.66.0.9']);

    $hub->removePeer('PUBKEY');
    expect($hub->peerIps())->toBe([]);
});

it('allocates addresses within a /24 subnet, skipping hub and used (LocalWireguardHub)', function () {
    config()->set('provisioning.wireguard.subnet', '10.66.0.0/24');
    config()->set('provisioning.wireguard.hub_ip', '10.66.0.1');
    Host::factory()->create(['wg_ip' => '10.66.0.2']);

    // .0 network, .1 hub, .2 used → first free is .3
    expect((new LocalWireguardHub)->allocateIp())->toBe('10.66.0.3');
});

it('respects a non-/24 subnet (LocalWireguardHub)', function () {
    config()->set('provisioning.wireguard.subnet', '10.66.5.128/25');
    config()->set('provisioning.wireguard.hub_ip', '10.66.5.129');

    // network .128, hub .129 → first free is .130
    expect((new LocalWireguardHub)->allocateIp())->toBe('10.66.5.130');
});

it('reports node capacity for a host (FakeProxmoxClient)', function () {
    $host = Host::factory()->create(['wg_ip' => '10.66.0.5']);
    $client = (new FakeProxmoxClient)->forHost($host);

    expect($client->listNodes())->not->toBeEmpty()
        ->and($client->nodeStatus('pve')['memory']['total'])->toBeGreaterThan(0)
        ->and($client->nodeStorage('pve')[0]['total'])->toBeGreaterThan(0);
});

it('treats an already-deleted DNS record as done', function () {
    Http::fake([
        '*' => Http::response(['error' => 'not found'], 404),
    ]);

    // A lost response, or a retry after a later step failed, must not make the
    // record impossible to clean up — and its host impossible to purge.
    (new HttpHetznerDnsClient)->deleteRecord('rec-123');
})->throwsNoExceptions();

it('finds an existing DNS record past the first page instead of creating a second one', function () {
    // Hetzner pages `GET /records` at a hundred entries, and this asked only for
    // the first page. Past a hundred records the lookup missed an entry that was
    // plainly there, fell through to POST, and Hetzner accepted a SECOND A record
    // for the same name: two addresses round-robined for one instance, one of them
    // a host the machine is not on, so the cloud was up about half the time. The
    // `address` and `plan-change` pipelines upsert on every run, so it compounded.
    $filler = fn (int $from) => collect(range($from, $from + 99))
        ->map(fn (int $i) => ['id' => 'rec-'.$i, 'name' => 'other-'.$i, 'type' => 'A'])
        ->all();

    // Matched by reading the page out of the query rather than with a URL glob:
    // `page=1` is a substring of `per_page=100`, so a pattern would answer page
    // two with page one's records and the test would pass against the bug.
    Http::fake(function ($request) use ($filler) {
        parse_str((string) parse_url($request->url(), PHP_URL_QUERY), $query);

        if (str_contains($request->url(), '/zones')) {
            return Http::response(['zones' => [['id' => 'zone-1']]]);
        }

        if ($request->method() === 'GET') {
            $page = (int) ($query['page'] ?? 1);

            return Http::response([
                'records' => $page === 2
                    ? array_merge($filler(101), [['id' => 'rec-berger', 'name' => 'berger', 'type' => 'A']])
                    : $filler(1),
                'meta' => ['pagination' => ['page' => $page, 'per_page' => 100, 'last_page' => 2]],
            ]);
        }

        // A POST is the bug: the record exists and has to be updated.
        return Http::response(['record' => ['id' => 'rec-new']], 201);
    });

    $id = (new HttpHetznerDnsClient)->upsertRecord(
        'berger.'.ProvisioningSettings::dnsZone(), 'A', '203.0.113.9'
    );

    expect($id)->toBe('rec-berger');
    Http::assertSent(fn ($request) => $request->method() === 'PUT'
        && str_contains($request->url(), '/records/rec-berger'));
    Http::assertNotSent(fn ($request) => $request->method() === 'POST');
});

it('writes and removes a real hostsdir entry on disk (FileHostDnsDirectory)', function () {
    $dir = sys_get_temp_dir().'/clupilot-dns-hosts-test-'.uniqid();
    config()->set('provisioning.dns.hosts_dir', $dir);
    $writer = new FileHostDnsDirectory;

    // The directory itself is created on first write — dnsmasq --hostsdir
    // only needs it to exist, not to be pre-provisioned by anything else.
    $writer->write('fsn-01', 'fsn-01.node.clupilot.com', '10.66.0.11');
    $path = $dir.'/fsn-01.hosts';

    expect(is_file($path))->toBeTrue()
        ->and(trim(file_get_contents($path)))->toBe('10.66.0.11 fsn-01.node.clupilot.com');

    // Overwrite is the update path — RegisterHostDns calls write() again on
    // retry with the same name, and that must replace, not append.
    $writer->write('fsn-01', 'fsn-01.node.clupilot.com', '10.66.0.99');
    expect(trim(file_get_contents($path)))->toBe('10.66.0.99 fsn-01.node.clupilot.com');

    $writer->remove('fsn-01');
    expect(is_file($path))->toBeFalse();

    File::deleteDirectory($dir);
});

it('treats removing a name that was never written as done (FileHostDnsDirectory)', function () {
    $dir = sys_get_temp_dir().'/clupilot-dns-hosts-test-'.uniqid();
    config()->set('provisioning.dns.hosts_dir', $dir);

    // Mirrors PurgeHost's guard (filled($host->dns_name)) not always holding
    // — a host that never got a name still has to purge cleanly.
    (new FileHostDnsDirectory)->remove('never-written');
})->throwsNoExceptions();
