<?php

namespace App\Services\Wireguard;

use App\Models\Host;
use App\Models\VpnPeer;
use App\Support\ProvisioningSettings;
use Illuminate\Support\Facades\Process;
use RuntimeException;

/**
 * Real hub on the CluPilot VM. Allocates the next free address in the configured
 * subnet and manages peers with `wg`. Watch-item: the app runs in a container, so
 * the wg interface must be reachable from here (host network / mounted config);
 * verified on the real VM, not in the mocked test-suite.
 */
class LocalWireguardHub implements WireguardHub
{
    public function allocateIp(int $from = 1): string
    {
        // Both tables hand out addresses from the same subnet — an operator VPN
        // access and a host peer would otherwise be given the same tunnel IP.
        $used = array_flip(array_merge(
            Host::query()->whereNotNull('wg_ip')->pluck('wg_ip')->all(),
            // withTrashed: a revoked peer keeps its address until the hub drops it.
            VpnPeer::withTrashed()->pluck('allowed_ip')->all(),
        ));
        $hubIp = (string) config('provisioning.wireguard.hub_ip');

        [$network, $bits] = explode('/', config('provisioning.wireguard.subnet', '10.66.0.0/24'));
        $bits = (int) $bits;
        $hostBits = 32 - $bits;
        $size = 2 ** $hostBits;
        $base = ip2long($network) & (0xFFFFFFFF << $hostBits) & 0xFFFFFFFF;

        // Skip the network address (offset 0) and broadcast (offset size-1).
        //
        // Zwei Durchgänge, weil `$from` eine BEVORZUGUNG ist und keine
        // Bedingung: erst ab dort, dann von vorn. Das konfigurierte Subnetz darf
        // kleiner sein als der Versatz — in einem /26 liegt 100 ausserhalb, und
        // eine Vergabe, die „Subnetz erschöpft" meldet, während unten alles frei
        // ist, wäre die schlechtere Antwort als eine Adresse aus dem
        // Personenbereich (Codex P1).
        //
        // Der Versatz ist damit auch keine Obergrenze: läuft der Hostbereich
        // voll, zählt der erste Durchgang einfach weiter.
        foreach ([max(1, $from), 1] as $start) {
            for ($offset = $start; $offset < $size - 1; $offset++) {
                $ip = long2ip($base + $offset);
                if ($ip !== $hubIp && ! isset($used[$ip])) {
                    return $ip;
                }
            }
        }

        throw new RuntimeException('WireGuard management subnet exhausted.');
    }

    public function addPeer(string $publicKey, string $ip): void
    {
        Process::run(['wg', 'set', 'wg0', 'peer', $publicKey, 'allowed-ips', $ip.'/32'])->throw();
        Process::run('wg-quick save wg0')->throw(); // persist, else the peer is lost on restart
    }

    public function removePeer(string $publicKey): void
    {
        Process::run(['wg', 'set', 'wg0', 'peer', $publicKey, 'remove'])->throw();
        Process::run('wg-quick save wg0')->throw(); // persist, else the peer is lost on restart
    }

    public function peers(): array
    {
        $result = Process::run(['wg', 'show', 'wg0', 'dump']);
        if (! $result->successful()) {
            throw new RuntimeException('wg show failed: '.trim($result->errorOutput()));
        }

        $peers = [];
        // The first line describes the interface itself, not a peer.
        foreach (array_slice(preg_split('/\R/', trim($result->output())) ?: [], 1) as $line) {
            if (trim($line) === '') {
                continue;
            }
            $snapshot = PeerSnapshot::fromDumpLine(explode("\t", $line));
            if ($snapshot !== null) {
                $peers[$snapshot->publicKey] = $snapshot;
            }
        }

        return $peers;
    }

    public function endpoint(): string
    {
        return ProvisioningSettings::wgEndpoint();
    }

    public function publicKey(): string
    {
        return ProvisioningSettings::wgHubPublicKey();
    }
}
