<?php

namespace App\Provisioning\Jobs;

use App\Models\Host;
use App\Models\VpnPeer;
use App\Services\Wireguard\WireguardHub;
use Illuminate\Support\Facades\Cache;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;

/**
 * Copy the hub's live peer state into the database so the console can show it.
 *
 * The console runs in the web container, which has no wg0 — only this worker
 * can read `wg show`. Everything the operator sees (handshakes, traffic, who is
 * connected) therefore travels through this job.
 *
 * Reconciles in both directions: peers found on the interface are upserted
 * (adopting host peers the provisioning pipeline registered, so the list has no
 * blind spots), and rows no longer on the interface are marked absent rather
 * than deleted — a missing peer is information, not a reason to forget it.
 */
class SyncVpnPeers implements ShouldBeUnique, ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    /** Long enough that a stuck job cannot block syncing forever. */
    public int $uniqueFor = 120;

    public int $tries = 1;

    public function __construct()
    {
        $this->onQueue('provisioning');
    }

    public function handle(WireguardHub $hub): void
    {
        // Reading the hub and reconciling must not straddle a peer removal: a
        // snapshot taken before ApplyVpnPeer removes a peer and purges its
        // tombstone would otherwise be adopted afterwards as a fresh, enabled
        // access — restoring exactly what was revoked. ApplyVpnPeer holds the
        // same lock, so read and mutation are mutually exclusive.
        $retryRemoval = [];
        Cache::lock('wireguard:hub', 30)->block(10, function () use ($hub, &$retryRemoval) {
            $this->reconcile($hub, $retryRemoval);
        });

        // Dispatched outside the lock: with the sync queue driver this executes
        // inline, and ApplyVpnPeer takes the very same lock.
        foreach ($retryRemoval as $publicKey) {
            // force: a key with no row of its own can only be revoked, never kept.
            ApplyVpnPeer::dispatch($publicKey, null, false, force: true);
        }
    }

    /** @param list<string> $retryRemoval collected, dispatched by the caller */
    private function reconcile(WireguardHub $hub, array &$retryRemoval): void
    {
        $snapshots = $hub->peers();
        $now = now();

        // hosts.wg_pubkey => host id, so pipeline-created peers show up
        // under their host name instead of as an unknown key.
        $hostsByKey = Host::query()
            ->whereNotNull('wg_pubkey')
            ->get(['id', 'name', 'wg_pubkey'])
            ->keyBy('wg_pubkey');

        foreach ($snapshots as $publicKey => $snapshot) {
            $peer = VpnPeer::withTrashed()->where('public_key', $publicKey)->first();
            $host = $hostsByKey[$publicKey] ?? null;

            // Revoked, yet still on the hub: the removal never landed. Retry it
            // rather than adopting the peer back into the console — otherwise a
            // failed removal quietly becomes a live access again.
            if ($peer?->trashed()) {
                $retryRemoval[] = $publicKey;

                continue;
            }
            $observed = [
                'present' => true,
                'endpoint' => $snapshot->endpoint,
                'last_handshake_at' => $snapshot->latestHandshake,
                'rx_bytes' => $snapshot->rxBytes,
                'tx_bytes' => $snapshot->txBytes,
                'observed_at' => $now,
            ];

            if ($peer === null) {
                // The address already belongs to a live access: this is a key
                // that was just replaced and whose removal has not run yet.
                // Adopting it would violate the unique index and abort the whole
                // reconciliation — one stale key would stop every peer's state
                // from being updated.
                if (VpnPeer::query()->where('allowed_ip', $ip = strtok($snapshot->allowedIps, '/') ?: $snapshot->allowedIps)->exists()) {
                    $retryRemoval[] = $publicKey;

                    continue;
                }

                VpnPeer::create($observed + [
                    'public_key' => $publicKey,
                    // Fall back to the dump's allowed-ips for peers that were
                    // never created through the console.
                    'allowed_ip' => $ip,
                    'host_id' => $host?->id,
                    // Never "staff": a staff access has an owner, and nobody is
                    // behind a peer we merely found on the interface.
                    'kind' => $host !== null ? VpnPeer::KIND_HOST : VpnPeer::KIND_SYSTEM,
                    'name' => $host?->name ?? __('vpn.unknown_peer'),
                    'enabled' => true,
                ]);

                continue;
            }

            // A sync that ran between addPeer() and the step storing wg_pubkey
            // adopts the peer as "unknown". Once the link is known, name it
            // after its host — but only if no operator ever named it
            // (created_by is null exactly for sync-adopted rows).
            if ($peer->host_id === null && $host !== null) {
                $observed['host_id'] = $host->id;
                if ($peer->kind === VpnPeer::KIND_SYSTEM) {
                    $observed['kind'] = VpnPeer::KIND_HOST;
                }
                if ($peer->created_by === null) {
                    $observed['name'] = $host->name;
                }
            }

            // Never overwrite `enabled`: that is the operator's intent, and a
            // peer being present only says the hub has not caught up yet.
            $peer->update($observed);
        }

        $seen = array_keys($snapshots);

        VpnPeer::query()
            ->whereNotIn('public_key', $seen)
            ->update(['present' => false, 'observed_at' => $now]);

        // A tombstone whose peer is gone from the hub has done its job.
        VpnPeer::onlyTrashed()->whereNotIn('public_key', $seen)->forceDelete();
    }
}
