<?php

namespace App\Models;

use App\Models\Concerns\HasUuid;
use App\Provisioning\Contracts\ProvisioningSubject;
use App\Services\Secrets\SecretCipher;
use Database\Factories\HostFactory;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\Relations\MorphMany;

class Host extends Model implements ProvisioningSubject
{
    /** @use HasFactory<HostFactory> */
    use HasFactory, HasUuid;

    protected $fillable = [
        'name', 'datacenter', 'cluster', 'public_ip', 'wg_ip', 'wg_pubkey',
        'ssh_host_key', 'api_token_ref', 'total_gb', 'total_ram_mb', 'cpu_cores',
        'cpu_weight', 'reserve_pct', 'pve_version', 'node', 'status', 'last_seen_at', 'dns_record_id',
        'reserved_for_customer_id', 'security_log_seen_at',
    ];

    protected $hidden = ['api_token_ref'];

    protected function casts(): array
    {
        return [
            'last_seen_at' => 'datetime',
            'security_log_seen_at' => 'datetime',
            'total_gb' => 'integer',
            'total_ram_mb' => 'integer',
            'cpu_cores' => 'integer',
            'cpu_weight' => 'integer',
            'reserve_pct' => 'integer',
        ];
    }

    /**
     * The Proxmox API token, stored under SECRETS_KEY — NOT through Laravel's
     * `encrypted` cast.
     *
     * That cast uses APP_KEY, and SecretVault's docblock states the rule this
     * column was breaking: rotating APP_KEY is ordinary maintenance, and every
     * credential that lives behind it would become unreadable at once. For this
     * column that means every host's automation token in the same moment — no
     * Proxmox API on any host, discovered when provisioning stops rather than
     * when the key is rotated. Same handling as Mailbox::password, through the
     * same SecretCipher, so there is one key story and not two.
     */
    public function setApiTokenRefAttribute(?string $value): void
    {
        $this->attributes['api_token_ref'] = ($value === null || $value === '')
            ? null
            : app(SecretCipher::class)->encrypt($value);
    }

    public function getApiTokenRefAttribute(?string $value): ?string
    {
        return ($value === null || $value === '')
            ? null
            : app(SecretCipher::class)->decrypt($value);
    }

    /** Placement runs (polymorphic subject). */
    public function runs(): MorphMany
    {
        return $this->morphMany(ProvisioningRun::class, 'subject');
    }

    /** Runner hook: a failed onboarding run moves the host to the error status. */
    public function onProvisioningFailed(): void
    {
        $this->update(['status' => 'error']);
    }

    public function provisioningPipeline(): string
    {
        return 'host';
    }

    public function instances(): HasMany
    {
        return $this->hasMany(Instance::class);
    }

    /**
     * The synced VPN peer row SyncVpnPeers adopts for this host (see that
     * job's docblock — the console container has no wg0, so this table, not a
     * live hub call, is the only honest source for handshake state here).
     */
    public function vpnPeer(): HasOne
    {
        return $this->hasOne(VpnPeer::class);
    }

    public function maintenanceWindows(): BelongsToMany
    {
        return $this->belongsToMany(MaintenanceWindow::class);
    }

    /**
     * Wem diese Maschine als "eigener Server" versprochen ist — null heißt
     * allgemeiner Bestand. Die Migration setzt `restrictOnDelete()`, nicht
     * `nullOnDelete()`: ein Kunde, dem noch eine Maschine gehört, lässt sich
     * nicht löschen, solange niemand die Reservierung von Hand gelöst hat
     * (siehe deren Kopfkommentar) — sonst verschwände sie kommentarlos aus
     * dem Bestand, ohne dass ein Operator sie tatsächlich freigegeben hat.
     */
    public function reservedFor(): BelongsTo
    {
        return $this->belongsTo(Customer::class, 'reserved_for_customer_id');
    }

    /** Free committable storage: total minus reserve. */
    public function freeGb(): int
    {
        if ($this->total_gb === null) {
            return 0;
        }

        return (int) floor($this->total_gb * (100 - $this->reserve_pct) / 100);
    }

    /**
     * Host storage already committed, counted as the VM disk allocation
     * (disk_gb) since that is what is actually placed on the host — not the
     * (smaller) Nextcloud user quota. A failed instance still counts while its VM
     * exists (has a vmid); a failure before any VM was created releases it.
     */
    public function committedGb(): int
    {
        // Answer a preloaded sum when the caller supplied one (see
        // Instance::scopeOccupyingHost) — listing every host otherwise costs a
        // query per host, twice over once usedPct() asks again.
        //
        // Tested for PRESENCE, not for null: SUM over no rows is null, so a
        // host with nothing on it arrives preloaded-but-null. Reading that as
        // "not preloaded" would re-query for exactly the empty hosts, which is
        // the case the preload exists for.
        if (array_key_exists('committed_disk_gb', $this->getAttributes())) {
            return (int) $this->getAttributes()['committed_disk_gb'];
        }

        return (int) $this->instances()->occupyingHost()->sum('disk_gb');
    }

    /** Storage still available for new instances. */
    public function availableGb(): int
    {
        return max(0, $this->freeGb() - $this->committedGb());
    }

    /** Used-storage percentage (committed vs committable free), 0–100. */
    public function usedPct(): int
    {
        $free = $this->freeGb();

        return $free > 0 ? min(100, (int) round($this->committedGb() / $free * 100)) : 0;
    }

    /**
     * Heartbeat health from last_seen_at: online (≤5 min), stale (≤30 min),
     * offline (older or never seen). Drives the health dot in the console.
     */
    public function healthState(): string
    {
        if ($this->last_seen_at === null) {
            return 'offline';
        }
        $minutes = $this->last_seen_at->diffInMinutes(now());

        return match (true) {
            $minutes <= 5 => 'online',
            $minutes <= 30 => 'stale',
            default => 'offline',
        };
    }

    /**
     * WireGuard rekeys a live tunnel every REKEY_AFTER_TIME (120s, protocol
     * constant) whenever there is traffic, and considers a session dead outright
     * after REJECT_AFTER_TIME (180s). 180s is therefore "the tunnel protocol
     * itself would call this gone" — one rekey cycle of slack over the 120s
     * baseline, not a round number picked for looks.
     */
    public const VPN_HANDSHAKE_FRESH_SECONDS = 180;

    /**
     * How old `vpn_peers.observed_at` may be before its `last_handshake_at` is
     * no longer trusted. SyncVpnPeers runs every minute (routes/console.php);
     * 300s is five missed scheduler ticks — the same order of magnitude as
     * healthState()'s own "still counts as online" heartbeat window (5 min)
     * for a per-minute signal, generous enough to absorb ordinary queue
     * latency without being trigger-happy, tight enough to actually catch the
     * sync worker being down rather than the tunnel.
     */
    public const VPN_SYNC_STALE_SECONDS = 300;

    /**
     * VPN tunnel state for the console's hosts list.
     *
     * `wg_pubkey` only proves onboarding CONFIGURED a peer — a tunnel silent for
     * two days looks identical in the database to one that handshaked a minute
     * ago. $peer (the row SyncVpnPeers keeps current — see that job's docblock:
     * the console container has no wg0, so this table is the only honest source
     * here) carries both `last_handshake_at` and `observed_at`.
     *
     * Two different kinds of "we don't know" collapse to `unknown`, not to a
     * guessed `silent`: no row at all (never synced yet), and a row whose
     * `observed_at` is stale — because a stopped sync worker leaves
     * `last_handshake_at` frozen at whatever it last saw, which would
     * otherwise be misread as "the tunnel went silent" when the truth is
     * "nobody has looked lately".
     */
    public function vpnState(?VpnPeer $peer): string
    {
        if ($this->wg_pubkey === null) {
            return 'not_configured';
        }

        if ($peer === null || $peer->observed_at === null
            || $peer->observed_at->diffInSeconds(now()) > self::VPN_SYNC_STALE_SECONDS) {
            return 'unknown';
        }

        if ($peer->last_handshake_at !== null
            && $peer->last_handshake_at->diffInSeconds(now()) <= self::VPN_HANDSHAKE_FRESH_SECONDS) {
            return 'connected';
        }

        return 'silent';
    }

    /**
     * Placement (spec §1): first active host in the datacenter with enough free
     * committable storage. Cluster is ignored in v1.0.
     *
     * Reservierte Hosts gehören ihrem Kunden. Ohne den dritten Parameter ist
     * die Frage "wer aus dem allgemeinen Bestand kann das tragen" — und die
     * Antwort darf keine Maschine sein, die bereits jemandem versprochen ist.
     */
    public static function placeableIn(string $datacenter, int $quotaGb, ?int $forCustomerId = null): ?self
    {
        return self::query()
            ->where('datacenter', $datacenter)
            ->where('status', 'active')
            ->unreserved($forCustomerId)
            ->orderBy('name')
            ->get()
            ->first(fn (self $host) => $host->availableGb() >= $quotaGb);
    }

    /**
     * Hosts, die für diese Platzierung in Frage kommen: der allgemeine Bestand
     * und die eigenen.
     */
    public function scopeUnreserved(Builder $query, ?int $forCustomerId = null): Builder
    {
        return $query->where(fn (Builder $q) => $q
            ->whereNull('reserved_for_customer_id')
            ->when($forCustomerId !== null, fn (Builder $inner) => $inner->orWhere('reserved_for_customer_id', $forCustomerId)));
    }

    /** Could this particular host take an instance of this size right now? */
    public function canTake(int $quotaGb): bool
    {
        return $this->status === 'active' && $this->availableGb() >= $quotaGb;
    }

    /**
     * Hosts whose load is worth collecting: reachable in principle, and with a
     * token to ask with.
     *
     * `error` is in deliberately — a host in trouble is exactly the one an
     * operator opens the page for. One still being onboarded has no token yet,
     * and a query per minute against it would only fill the log.
     *
     * A scope rather than two copies of the same `whereIn`, because the two
     * readers must not drift: Jobs\CollectHostLoad decides who gets collected,
     * and Admin\HostDetail decides whether opening a page is worth asking for a
     * collection. When those disagreed, opening a host mid-onboarding scanned
     * the whole fleet and still left its tiles empty.
     */
    public function scopeCollectable(Builder $query): Builder
    {
        return $query->whereIn('status', ['active', 'error'])->whereNotNull('api_token_ref');
    }
}
