<?php

namespace App\Services\Provisioning;

use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Throwable;

/**
 * What a machine costs today, from the provider's own live list.
 *
 * So the question "what would clearing this queue cost me" is answered on the
 * page that asks it, rather than in another tab against a list that has to be
 * read and filtered by hand every time.
 *
 * Read-only and cached. This orders nothing: buying a server is a contract, and
 * a bug in a capacity calculation must not be able to sign one.
 */
class ServerMarket
{
    /** Hetzner's published live feed for the server auction. */
    private const FEED = 'https://www.hetzner.com/_resources/app/data/app/live_data_sb_EUR.json';

    /**
     * Cached for a quarter of an hour.
     *
     * The list changes as machines sell, but not by the second, and an operator
     * refreshing a page must not put a request on somebody else's server every
     * time.
     */
    private const TTL_MINUTES = 15;

    /**
     * Offers that could actually take the work, cheapest first.
     *
     * @param  int  $needsGb  the largest single package that has to fit
     * @return array<int, array{cpu: string, ram: int, disks: string, flash_gb: int, ecc: bool, datacenter: string, price: float}>
     */
    public function offers(int $needsGb, int $limit = 6): array
    {
        $usable = (int) ceil($needsGb * 1.25);   // RAID 1 headroom plus the host's own footprint

        return collect($this->feed())
            ->map(function (array $s) {
                $disks = $s['serverDiskData'] ?? [];
                $flash = array_sum($disks['nvme'] ?? []) + array_sum($disks['sata'] ?? []);

                return [
                    'cpu' => (string) ($s['cpu'] ?? ''),
                    'ram' => (int) ($s['ram_size'] ?? 0),
                    // The feed sends this as a LIST of disk descriptions
                    // ("2x SSD M.2 NVMe 512 GB"), not a string. Casting an
                    // array to string is a warning and the word "Array".
                    'disks' => trim(implode(', ', array_map(
                        fn ($d) => is_scalar($d) ? (string) $d : '',
                        (array) ($s['hdd_hr'] ?? [])
                    ))),
                    // Half, because the pair is a mirror. A machine's usable
                    // space is not the sum of its disks on a platform that
                    // sells "your data survives a disk dying".
                    'flash_gb' => (int) floor($flash / 2),
                    'ecc' => (bool) ($s['is_ecc'] ?? false),
                    'datacenter' => (string) ($s['datacenter'] ?? ''),
                    'price' => (float) ($s['price'] ?? 0),
                ];
            })
            // All-flash only, and ECC only. Both are product decisions, not
            // preferences: one VM per customer means every database lives on
            // the same disks, and ECC is part of what the backups promise.
            ->filter(fn (array $s) => $s['flash_gb'] >= $usable && $s['ecc'] && $s['ram'] >= 32)
            ->sortBy('price')
            ->take($limit)
            ->values()
            ->all();
    }

    /** @return array<int, array<string, mixed>> */
    private function feed(): array
    {
        return Cache::remember('server-market', now()->addMinutes(self::TTL_MINUTES), function () {
            try {
                $response = Http::timeout(8)->get(self::FEED);

                return $response->successful() ? ($response->json('server') ?? []) : [];
            } catch (Throwable) {
                // An outside list being unreachable is not a reason for the
                // capacity page to fail. It shows the queue either way; the
                // prices are the convenience.
                return [];
            }
        });
    }
}
