<?php
/**
 * Client di riferimento per AgileV (PHP 8, solo ext-curl ed ext-json; mbstring o iconv se ci sono). Copiatelo nel vostro progetto.
 *
 *     require 'agilev_client.php';
 *     $av = new AgileV();                              // chiave in AGILEV_KEY (o new AgileV('agv_...'))
 *     $vettori = $av->embed($testi);                   // array di vettori (1024 float), nell'ordine dei testi
 *     $ordine  = $av->rerank($domanda, $candidati, 5); // [[indice, punteggio], ...] già ordinati
 *
 * Stesse regole del gemello Python (agilev_client.py, e API.md «Timeout del client e lotti»):
 *   1. un solo handle curl riusato: la stretta di mano TLS (~250 ms) si paga una volta;
 *   2. lotti UNO ALLA VOLTA, 64 testi (massimo 128), timeout 120 s;
 *   3. limiti (128 testi, 32.000 caratteri per testo) controllati PRIMA di spedire;
 *   4. si ritenta SOLO su 429 RATE_LIMIT, 5xx e connessione caduta, con attesa 1, 2, 4, 8 s (tetto 30) oppure
 *      ESATTAMENTE Retry-After se il servizio lo manda (sul 429: i secondi al minuto nuovo, fino a 60);
 *      una connessione riusata che il server ha chiuso (keep-alive scaduto) la rifà curl da solo, senza attesa;
 *      400/401/403/413 e 429 QUOTA_EXCEEDED sono definitivi;
 *      e si FRENA prima del 429: quando una risposta dice X-RateLimit-Remaining: 0, la richiesta successiva
 *      aspetta X-RateLimit-Reset secondi prima di partire (contatori['frenate'], ultime intestazioni in $av->rate);
 *   5. al timeout il lotto si DIMEZZA, non si ripete uguale (il caso peggiore di settembre: 47 lotti identici);
 *   6. il rerank legge i primi 800 caratteri di ogni candidato;
 *   7. un documento lungo si SPEZZA prima di spedirlo: AgileV::spezza($testo) fa frammenti da 1.500 caratteri
 *      (tagliati a paragrafi, poi a frasi, poi a parole) con 150 caratteri di frasi intere in comune fra un
 *      frammento e il successivo, e ripete in testa a ognuno l'intestazione che gli date («Art. 25-septies Rubrica»):
 *      un articolo da 56.000 caratteri non è un 413 ma 40 frammenti che si ritrovano. Stessi frammenti del Python.
 *   9. la DIMENSIONE si controlla all'avvio, una volta: $av->controllaDim(1024) legge dim da /health (senza chiave,
 *      senza consumo) e si ferma se non è quella del vostro indice; da quel momento un vettore della misura
 *      sbagliata diventa un BAD_RESPONSE invece di entrare nell'indice. Si confronta la dimensione e NON il nome:
 *      /health dichiara il modello sottostante (bge-m3), /v1/models e /v1/embeddings il nome di listino
 *      (agilev-m3), e confrontarli dà un falso allarme a ogni avvio (API.md, Regola 4).
 * Il client non registra nulla: nessun testo nei log.
 *
 *     php agilev_client.php --prova              # /health senza chiave; con AGILEV_KEY anche embed e rerank veri
 */

class AgileVErrore extends RuntimeException
{
    public function __construct(public readonly int $stato, public readonly string $codice, public readonly string $messaggio)
    {
        parent::__construct("$stato $codice: $messaggio");
    }
}

class AgileVTimeout extends AgileVErrore
{
    public function __construct(float $timeout, int $testi)
    {
        parent::__construct(0, 'TIMEOUT', sprintf('nessuna risposta in %g s per un lotto di %d testi', $timeout, $testi));
    }
}

class AgileV
{
    public const URL_DEFAULT = 'https://api.agilev.agile.software';
    public const LOTTO_MAX = 128;
    public const CARATTERI_MAX = 32000;
    public const RERANK_CARATTERI = 800;
    public const ATTESA_MAX = 30.0;        // tetto dell'attesa crescente (non di Retry-After)
    public const RETRY_AFTER_MAX = 60.0;   // sul RATE_LIMIT il servizio non chiede mai più di un minuto; sul
                                           // QUOTA_EXCEEDED manda i secondi al mese nuovo (fino a 2.678.400),
                                           // e lì non si ritenta, quindi quel numero non si legge
    public const SPEZZA_CARATTERI = 1500;         // frammento consigliato (API.md, «Documenti lunghi»)
    public const SPEZZA_SOVRAPPOSIZIONE = 150;    // frasi intere in comune fra due frammenti consecutivi
    private const BIANCHI = " \t\r\n\x0B\0";     // gli stessi caratteri di strip(_BIANCHI) nel Python

    private string $chiave;
    private string $url;
    /** Da dove è arrivato l'indirizzo: argomento|getenv|server|env|predefinito. Stampatelo quando qualcosa non torna. */
    public string $sorgenteIndirizzo = 'predefinito';
    /** Da dove è arrivata la chiave: argomento|getenv|server|env. Il VALORE non si stampa mai. */
    public string $sorgenteChiave = 'assente';
    private float $timeout;
    private int $lotto;
    private int $tentativi;
    private float $attesa;
    /** @var callable */
    private $dormi;
    private ?CurlHandle $ch = null;
    public array $contatori = ['richieste' => 0, 'ritentate' => 0, 'lotti_dimezzati' => 0, 'frenate' => 0];
    /** Ultime X-RateLimit-* lette: limit, remaining, reset (secondi al minuto nuovo). */
    public array $rate = [];
    private ?float $freno = null;      // istante prima del quale non si spedisce: minuto esaurito
    /** Dimensione attesa dei vettori, se controllaDim() l'ha fissata. */
    public ?int $dim = null;

    /**
     * L'ambiente di PHP non è uno solo, e le tre sorgenti non portano lo stesso valore nella stessa
     * stanza. Misurato da noi il 29/9 su questo box, da riga di comando: `getenv()` e `$_SERVER`
     * portano il valore, `$_ENV` è VUOTO (variables_order predefinito, senza «E»). Misurato da trpg
     * il 29/9 nel loro contenitore, sotto Apache: `getenv()` e `$_ENV` portano il valore, `$_SERVER`
     * è `null`. E `php-fpm` con `clear_env = yes` (il valore predefinito) svuota l'ambiente del
     * processo: lì resta `$_SERVER`, riempito dalle righe `env[...]` del pool.
     * Chi ne legge UNA SOLA scommette sulla stanza, e la scommessa si perde in silenzio: la
     * configurazione funziona in ogni prova da riga di comando e sparisce dentro il server web.
     * Qui si leggono tutte e tre, in quest'ordine, e si dice quale ha risposto.
     *
     * Un valore che c'è ma è VUOTO è un ERRORE, non un ripiego: qualcuno l'ha impostato e non è
     * arrivato, e tirare dritto sul predefinito vuol dire mandare i testi all'indirizzo pubblico.
     *
     * @return array{0: ?string, 1: string} [valore, sorgente] — sorgente: getenv|server|env|assente
     */
    public static function dallAmbiente(string $nome): array
    {
        $sorgenti = ['getenv' => getenv($nome), 'server' => $_SERVER[$nome] ?? null, 'env' => $_ENV[$nome] ?? null];
        foreach ($sorgenti as $sorgente => $v) {
            if ($v === false || $v === null) {
                continue;
            }
            if (trim((string) $v) === '') {
                throw new InvalidArgumentException("$nome c'e' ma e' vuota (sorgente: $sorgente): "
                    . 'toglila del tutto, oppure dalle un valore — non si tira dritto sul predefinito');
            }
            return [trim((string) $v), $sorgente];
        }
        return [null, 'assente'];
    }

    /**
     * `http` (non cifrato) verso questo ospite resta dentro casa?
     *
     * Vero per il loopback, per le reti private e riservate e per il NOME `localhost`; falso per tutto
     * il resto, nome pubblico compreso. Gemello di `in_chiaro_senza_internet()` nel client Python, con
     * la libreria di casa: lì `ipaddress`, qui `filter_var`.
     *
     * NESSUNA risoluzione di nomi, apposta: un nome non si sa dove guarda, risolverlo dentro un
     * costruttore è una chiamata di rete che chi legge non si aspetta, e il risultato cambierebbe col
     * DNS. Chi ha un fronte privato con un nome proprio lo dichiara con $inChiaroAmmesso = true.
     */
    public static function inChiaroSenzaInternet(string $ospite): bool
    {
        if (in_array(strtolower($ospite), ['localhost', 'localhost.localdomain'], true)) {
            return true;
        }
        if (filter_var($ospite, FILTER_VALIDATE_IP) === false) {
            return false;        // non è un indirizzo: è un nome, e non lo risolviamo
        }
        // Un indirizzo PUBBLICO passa il filtro con NO_PRIV_RANGE|NO_RES_RANGE; se NON lo passa è
        // privato o riservato (loopback compreso), e quel filo non esce su Internet.
        return filter_var($ospite, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false;
    }

    /**
     * @param bool $inChiaroAmmesso toglie la guardia che rifiuta `http://` verso un indirizzo che NON
     *   è loopback o rete privata. Mettetelo a true solo se sapete che quel filo non passa da Internet
     *   (un nome vostro dentro una VPN): è l'uscita esplicita, dichiarata nel vostro codice, non un
     *   ripiego silenzioso. Per la stessa ragione di $indirizzoObbligatorio è un argomento e non una
     *   variabile d'ambiente.
     * @param bool $indirizzoObbligatorio se l'indirizzo finisce sul predefinito pubblico, si ferma.
     *   È un argomento del COSTRUTTORE e non una variabile d'ambiente, apposta: una guardia che vive
     *   nello stesso ambiente sparisce insieme a ciò che deve proteggere, proprio nella stanza in cui
     *   serve. Mettetelo a true se puntate AgileV su un fronte privato (WireGuard, secondo box,
     *   smistatore) e i vostri testi non devono mai uscire su Internet.
     */
    public function __construct(?string $chiave = null, ?string $url = null, float $timeout = 120.0, int $lotto = 64,
                                int $tentativi = 5, float $attesa = 1.0, ?callable $dormi = null,
                                bool $indirizzoObbligatorio = false, bool $inChiaroAmmesso = false)
    {
        [$k, $sorgenteChiave] = self::dallAmbiente('AGILEV_KEY');
        $this->chiave = $chiave ?? ($k ?? '');
        $this->sorgenteChiave = $chiave !== null ? 'argomento' : $sorgenteChiave;
        if ($this->chiave === '') {
            throw new InvalidArgumentException('chiave AgileV mancante: passala al costruttore o imposta AGILEV_KEY');
        }
        if ($url !== null && trim($url) === '') {
            throw new InvalidArgumentException('indirizzo AgileV vuoto: passa null per il predefinito, non una stringa vuota');
        }
        [$u, $s] = self::dallAmbiente('AGILEV_URL');
        $this->sorgenteIndirizzo = $url !== null ? 'argomento' : ($u !== null ? $s : 'predefinito');
        if ($indirizzoObbligatorio && $this->sorgenteIndirizzo === 'predefinito') {
            throw new InvalidArgumentException('indirizzo AgileV obbligatorio ma AGILEV_URL non arriva da nessuna '
                . 'sorgente (getenv, $_SERVER, $_ENV): dentro un server web l\'ambiente del processo puo\' essere '
                . 'vuoto. Senza questa guardia si sarebbe usato ' . self::URL_DEFAULT);
        }
        $this->url = rtrim($url ?? $u ?? self::URL_DEFAULT, '/');
        // La guardia sul CHIARO. Non è «https e basta»: il chiaro verso casa (loopback, rete privata)
        // resta possibile, perché quel filo non passa da Internet. Quello che si rifiuta è il chiaro
        // verso un indirizzo che esce: lì il rimando del bordo arriva DOPO i byte.
        $ospite = (string) (parse_url($this->url, PHP_URL_HOST) ?? '');
        $schema = strtolower((string) (parse_url($this->url, PHP_URL_SCHEME) ?? ''));
        if ($schema !== 'https' && !$inChiaroAmmesso && !self::inChiaroSenzaInternet($ospite)) {
            throw new InvalidArgumentException('indirizzo AgileV in chiaro verso «' . ($ospite !== '' ? $ospite : $this->url)
                . '»: la chiave e i vostri testi partirebbero NON cifrati. Il bordo risponde a http con un rimando '
                . 'a https, ma quel rimando arriva DOPO: quando lo leggete, chiave e testi hanno gia\' attraversato '
                . 'Internet. Usate ' . self::URL_DEFAULT . ' (https), oppure $inChiaroAmmesso = true se quel filo non '
                . 'passa da Internet — loopback, reti private e localhost sono gia\' ammessi senza dire niente');
        }
        $this->timeout = $timeout;
        $this->lotto = max(1, min($lotto, self::LOTTO_MAX));
        $this->tentativi = max(1, $tentativi);
        $this->attesa = $attesa;
        $this->dormi = $dormi ?? static fn(float $s) => usleep((int) ($s * 1e6));
    }

    /** L'indirizzo che questo client sta usando davvero. Va letto insieme a $sorgenteIndirizzo. */
    public function indirizzo(): string
    {
        return $this->url;
    }

    /** Caratteri (non byte), come li conta il servizio; senza mbstring si ripiega su iconv, poi sui byte. */
    private static function caratteri(string $s): int
    {
        if (function_exists('mb_strlen')) {
            return mb_strlen($s, 'UTF-8');
        }
        if (function_exists('iconv_strlen')) {
            $n = @iconv_strlen($s, 'UTF-8');
            if ($n !== false) {
                return $n;
            }
        }
        return strlen($s);
    }

    /**
     * I due tetti pubblicati (LOTTO_MAX testi, CARATTERI_MAX caratteri per testo), controllati PRIMA
     * di spedire e in UN SOLO POSTO — come `norm_inputs` in app.py sta in un posto solo dalla parte
     * del servizio. Fino al 10/10 il controllo viveva dentro embed(), e rerank() non ne aveva
     * nessuno: la query e il numero dei documents partivano sempre, e il cliente pagava una
     * richiesta (più un posto del minuto, che auth() consuma prima di qualunque controllo
     * sull'input) per farsi dire un numero che sta scritto fra le costanti di questa classe.
     *
     * $campo NOMINA il campo come fa il servizio, perché /v1/rerank ne ha due che portano testo e
     * un 413 che non dice quale ha sforato lascia solo la strada di indovinare. Senza $campo il
     * messaggio resta quello di sempre, parola per parola: le altre rotte hanno un campo solo.
     *
     * L'ORDINE È QUELLO DEL SERVIZIO e non è un dettaglio: prima il numero dei testi, poi la
     * lunghezza di ciascuno; e chi chiama passa la query prima dei documents. Con due difetti nella
     * stessa richiesta il servizio risponde TEXT_TOO_LONG della query, e un controllo locale in
     * ordine diverso darebbe al chiamante l'altro codice — un ramo che il servizio non usa.
     */
    private static function tetti(array $testi, ?int $quantiAlMassimo = null, string $campo = ''): void
    {
        $dove = $campo !== '' ? " (campo `$campo`)" : '';
        if ($quantiAlMassimo !== null && count($testi) > $quantiAlMassimo) {
            throw new AgileVErrore(413, 'BATCH_TOO_LARGE', "massimo $quantiAlMassimo testi per richiesta$dove");
        }
        foreach (array_values($testi) as $i => $t) {
            if (!is_string($t)) {
                throw new AgileVErrore(400, 'BAD_INPUT', "il testo $i non e' una stringa$dove");
            }
            if (self::caratteri($t) > self::CARATTERI_MAX) {
                throw new AgileVErrore(413, 'TEXT_TOO_LONG', sprintf(
                    'il testo %d ha %d caratteri, massimo %d: spezzatelo%s',
                    $i, self::caratteri($t), self::CARATTERI_MAX, $dove));
            }
        }
    }

    private static function taglia(string $s, int $n): string
    {
        if (function_exists('mb_substr')) {
            return mb_substr($s, 0, $n, 'UTF-8');
        }
        if (function_exists('iconv_substr')) {
            $t = @iconv_substr($s, 0, $n, 'UTF-8');
            if ($t !== false) {
                return $t;
            }
        }
        return substr($s, 0, $n);
    }

    private static function resto(string $s, int $n): string
    {
        if (function_exists('mb_substr')) {
            return mb_substr($s, $n, null, 'UTF-8');
        }
        if (function_exists('iconv_substr')) {
            $t = @iconv_substr($s, $n, PHP_INT_MAX, 'UTF-8');
            if ($t !== false) {
                return $t;
            }
        }
        return substr($s, $n);
    }

    // ---------------------------------------------------------------- documenti lunghi: spezza() prima di spedire
    /**
     * Spezza un documento in frammenti da al più $massimo caratteri, pronti per embed().
     * Taglia dove il testo si taglia da solo: a fine riga, poi a fine frase (. ! ? ;), poi fra le parole, e solo
     * per una parola più lunga del frammento a metà parola. Due frammenti consecutivi hanno in comune le ultime
     * frasi intere del primo fino a $sovrapposizione caratteri, così un concetto a cavallo del taglio resta intero
     * in almeno uno dei due. $intestazione (titolo, numero di articolo, rubrica) viene ripetuta in testa a ogni
     * frammento, così ogni frammento sa di cosa parla anche da solo. Il gemello Python produce gli STESSI frammenti.
     * Restituisce [] per un testo vuoto e [$testo] per uno che sta già in un frammento.
     *
     * @return string[]
     */
    public static function spezza(string $testo, int $massimo = self::SPEZZA_CARATTERI, int $sovrapposizione = self::SPEZZA_SOVRAPPOSIZIONE, string $intestazione = ''): array
    {
        $massimo = min($massimo, self::CARATTERI_MAX);
        $intestazione = trim($intestazione, self::BIANCHI);
        $budget = $massimo - ($intestazione !== '' ? self::caratteri($intestazione) + 1 : 0);
        if ($budget < 50) {
            throw new InvalidArgumentException("massimo=$massimo troppo piccolo per l'intestazione di " . self::caratteri($intestazione) . ' caratteri');
        }
        $sovrapposizione = max(0, min($sovrapposizione, intdiv($budget, 3)));
        // 1. unità: righe -> frasi -> (solo se una frase non entra) parole
        $unita = [];                                   // [testo, separatore da mettere PRIMA: "\n" a inizio riga, " " altrimenti]
        foreach (explode("\n", str_replace("\r\n", "\n", $testo)) as $riga) {
            $riga = trim($riga, self::BIANCHI);
            $sep = "\n";
            if ($riga === '') {
                continue;
            }
            foreach (preg_split('/(?<=[.!?;])[ \t]+/u', $riga) as $frase) {
                $frase = trim($frase, self::BIANCHI);
                if ($frase === '') {
                    continue;
                }
                if (self::caratteri($frase) <= $budget) {
                    $unita[] = [$frase, $sep];
                    $sep = ' ';
                    continue;
                }
                foreach (explode(' ', $frase) as $parola) {
                    while ($parola !== '') {                       // una parola più lunga del budget si taglia
                        $unita[] = [self::taglia($parola, $budget), $sep];
                        $sep = ' ';
                        $parola = self::resto($parola, $budget);
                    }
                }
            }
        }
        if ($unita === []) {
            return [];
        }
        // 2. frammenti: si riempie finché entra; il successivo riparte dalle ultime frasi intere del precedente
        $frammenti = [];
        $corrente = [];
        $lung = 0;
        foreach ($unita as $i => [$u, $_]) {
            $lu = self::caratteri($u);
            if ($corrente !== [] && $lung + 1 + $lu > $budget) {
                $frammenti[] = $corrente;
                $coda = [];
                $lc = 0;
                foreach (array_reverse($corrente) as $j) {
                    $l = self::caratteri($unita[$j][0]) + ($coda !== [] ? 1 : 0);
                    if ($lc + $l > $sovrapposizione) {
                        break;
                    }
                    array_unshift($coda, $j);
                    $lc += $l;
                }
                if ($lc + 1 + $lu > $budget) {                     // l'unità nuova deve entrare comunque
                    $coda = [];
                    $lc = 0;
                }
                $corrente = $coda;
                $lung = $lc;
            }
            $lung += $lu + ($corrente !== [] ? 1 : 0);
            $corrente[] = $i;
        }
        $frammenti[] = $corrente;
        $fuori = [];
        foreach ($frammenti as $f) {
            $t = '';
            foreach ($f as $k => $j) {
                $t .= ($k ? $unita[$j][1] : '') . $unita[$j][0];
            }
            $fuori[] = $intestazione !== '' ? "$intestazione\n$t" : $t;
        }
        return $fuori;
    }

    public function chiudi(): void
    {
        if ($this->ch !== null) {
            curl_close($this->ch);
            $this->ch = null;
        }
    }

    /** Un giro HTTP sull'handle riusato. Ritorna [stato, intestazioni, corpo]; lancia AgileVTimeout o AgileVErrore(0, CONNESSIONE). */
    private function scambio(string $metodo, string $percorso, ?array $corpo, bool $conChiave): array
    {
        if ($this->ch === null) {
            $this->ch = curl_init();
        }
        $intestazioni = ['Content-Type: application/json', 'Accept: application/json'];
        if ($conChiave) {
            $intestazioni[] = 'Authorization: Bearer ' . $this->chiave;
        }
        $testa = [];
        curl_reset($this->ch);
        curl_setopt_array($this->ch, [
            CURLOPT_URL => $this->url . $percorso,
            CURLOPT_CUSTOMREQUEST => $metodo,
            CURLOPT_POSTFIELDS => $corpo === null ? null : json_encode($corpo, JSON_UNESCAPED_UNICODE),
            CURLOPT_HTTPHEADER => $intestazioni,
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_TIMEOUT_MS => (int) ($this->timeout * 1000),
            CURLOPT_CONNECTTIMEOUT => 10,
            CURLOPT_HEADERFUNCTION => static function ($ch, string $riga) use (&$testa): int {
                if (str_contains($riga, ':')) {
                    [$k, $v] = explode(':', $riga, 2);
                    $testa[strtolower(trim($k))] = trim($v);
                }
                return strlen($riga);
            },
        ]);
        $risposta = curl_exec($this->ch);
        $this->contatori['richieste']++;
        if ($risposta === false) {
            $errno = curl_errno($this->ch);
            $msg = curl_error($this->ch);
            $this->chiudi();                       // dopo un errore l'handle non è più affidabile
            if ($errno === CURLE_OPERATION_TIMEDOUT) {
                throw new AgileVTimeout($this->timeout, is_array($corpo['input'] ?? null) ? count($corpo['input']) : 0);
            }
            throw new AgileVErrore(0, 'CONNESSIONE', "curl $errno: $msg");
        }
        return [curl_getinfo($this->ch, CURLINFO_RESPONSE_CODE), $testa, $risposta];
    }

    /**
     * Codice e messaggio da mostrare a chi chiama. Il 422 non ha `code` (è il corpo di FastAPI) e RIMANDA
     * INDIETRO I TESTI INVIATI in detail[].input: qui si legge solo QUALE campo è sbagliato, mai l'eco,
     * così i vostri testi non finiscono nei vostri log passando per un nostro errore.
     */
    private static function errore(int $stato, string $testo): array
    {
        $e = json_decode($testo, true);
        if (!is_array($e)) {
            return self::senzaCodice($stato);
        }
        if (isset($e['detail']) && is_array($e['detail']) && array_is_list($e['detail'])) {
            return ['BAD_BODY', self::campi($e['detail'])];       // codice messo dal client: il 422 non ne manda
        }
        $e = $e['detail']['error'] ?? $e['error'] ?? [];
        if (!is_array($e) || !isset($e['code']) || $e['code'] === '') {
            return self::senzaCodice($stato);
        }
        return [(string) $e['code'], (string) ($e['message'] ?? '')];
    }

    /**
     * La risposta non porta un codice nostro: lo mette il client, dallo STATO soltanto. Il corpo non si
     * legge e non entra nel messaggio — non è di AgileV (FastAPI, il bordo, un proxy di mezzo) e nessuno
     * ha promesso cosa ci metterà dentro. Lo stato HTTP basta a smistare.
     */
    private static function senzaCodice(int $stato): array
    {
        $senzaForma = [
            404 => ['NOT_FOUND', "percorso inesistente: controllate l'URL di base e la rotta"],
            405 => ['BAD_METHOD', 'metodo HTTP sbagliato per questa rotta (gli embedding vogliono POST)'],
        ];
        if (isset($senzaForma[$stato])) {
            return $senzaForma[$stato];
        }
        if ($stato >= 300 && $stato < 400) {
            // Regola 5, ramo `3xx` (1/10). NON è un errore: è la stessa risposta all'indirizzo giusto.
            // Lo scrive il framework quando il percorso ha una BARRA IN FONDO (`/v1/embeddings/`), e il
            // `307` conserva metodo e corpo. Questo client non segue i rimandi apposta (curl senza
            // CURLOPT_FOLLOWLOCATION): un rimando su una POST rispedisce il corpo, e il corpo è il
            // vostro testo. Seguirlo sarebbe sicuro — il `Location` è assoluto e in `https`, misurato —
            // ma non scrivere la barra lo è di più. Non consuma nulla: non arriva al nostro codice.
            return ['REDIRECT', "rimando ($stato): togliete la barra finale dall'URL, oppure seguite Location"];
        }
        if ($stato >= 500) {
            return ['UPSTREAM_ERROR', "guasto senza forma ($stato): bordo o servizio, ritentabile"];
        }
        return ['NO_CODE', "risposta $stato senza un codice del contratto"];
    }

    /** `body.input: Field required` — solo loc/msg/type del 422; detail[].input (l'eco) non si legge. */
    private static function campi(array $dettagli, int $massimo = 5): string
    {
        $pezzi = [];
        foreach (array_slice($dettagli, 0, $massimo) as $d) {
            if (!is_array($d)) {
                continue;
            }
            $dove = implode('.', array_map(static fn($x) => is_scalar($x) ? (string) $x : '?', (array) ($d['loc'] ?? [])));
            $pezzi[] = ltrim($dove . ': ' . ((string) ($d['msg'] ?? $d['type'] ?? '?')), ': ');
        }
        if ($pezzi === []) {
            return 'corpo della richiesta non valido';
        }
        $resto = count($dettagli) > $massimo ? sprintf(' (+%d altri)', count($dettagli) - $massimo) : '';
        return 'corpo della richiesta non valido - ' . implode('; ', $pezzi) . $resto;
    }

    /**
     * `data` in ordine di `index`, con TANTI elementi quanti i testi inviati. Un 200 di forma inattesa
     * (lotto mutilato, proxy di mezzo, versione nuova) diventa un errore BAD_RESPONSE invece di una lista
     * più corta: chi indicizza accoppierebbe i propri testi ai vettori sbagliati senza accorgersene.
     * Segnalato da trpg il 28/9 sul proprio client.
     *
     * $dim (la dimensione fissata da controllaDim()) fa lo stesso sulla LUNGHEZZA dei vettori: un vettore
     * della misura sbagliata ALZA LA VOCE invece di finire nell'indice o di essere scartato in silenzio —
     * un continue su una dimensione che non combacia dà zero risultati su un indice pieno (Regola 4).
     */
    private static function dati(mixed $risposta, int $attesi, bool $sparso, ?int $dim = null): array
    {
        $dati = is_array($risposta) ? ($risposta['data'] ?? null) : null;
        if (!is_array($dati) || !array_is_list($dati) || count($dati) !== $attesi) {
            $quanti = is_array($dati) ? (string) count($dati) : 'nessun elenco';
            throw new AgileVErrore(200, 'BAD_RESPONSE', "attesi $attesi elementi in data, ricevuti $quanti");
        }
        $indici = [];
        $lunghezze = [];
        foreach ($dati as $d) {
            if (!is_array($d) || !is_array($d['embedding'] ?? null)) {
                throw new AgileVErrore(200, 'BAD_RESPONSE', 'un elemento di data non ha embedding');
            }
            if ($sparso && !is_array($d['sparse_embedding'] ?? null)) {
                throw new AgileVErrore(200, 'BAD_RESPONSE', 'chiesto return_sparse, ma un elemento non ha sparse_embedding');
            }
            if (is_int($d['index'] ?? null)) {
                $indici[] = $d['index'];
            }
            $lunghezze[count($d['embedding'])] = true;
        }
        $lunghezze = array_keys($lunghezze);
        sort($lunghezze);
        if (count($lunghezze) > 1) {
            throw new AgileVErrore(200, 'BAD_RESPONSE',
                'i vettori del lotto hanno lunghezze diverse: [' . implode(', ', $lunghezze) . ']');
        }
        if ($dim !== null && $lunghezze !== [] && $lunghezze !== [$dim]) {
            throw new AgileVErrore(200, 'BAD_RESPONSE', "attesi vettori da $dim valori, ricevuti da {$lunghezze[0]}");
        }
        sort($indici);
        if ($indici !== ($attesi > 0 ? range(0, $attesi - 1) : [])) {
            throw new AgileVErrore(200, 'BAD_RESPONSE', sprintf('gli index di data non sono 0..%d', $attesi - 1));
        }
        usort($dati, static fn($a, $b) => $a['index'] <=> $b['index']);
        return $dati;
    }

    /** Legge X-RateLimit-* (contratto 1.2); se il minuto è esaurito arma il freno fino al minuto nuovo. */
    private function notaRate(array $testa): void
    {
        $v = [$testa['x-ratelimit-limit'] ?? null, $testa['x-ratelimit-remaining'] ?? null, $testa['x-ratelimit-reset'] ?? null];
        if (!is_numeric($v[0]) || !is_numeric($v[1]) || !is_numeric($v[2])) {
            return;
        }
        $this->rate = ['limit' => (float) $v[0], 'remaining' => (float) $v[1], 'reset' => (float) $v[2]];
        $this->freno = $this->rate['remaining'] <= 0 ? microtime(true) + min(self::RETRY_AFTER_MAX, $this->rate['reset']) : null;
    }

    /** Regola 4 bis: se l'ultima risposta diceva 0 richieste rimaste, si aspetta il minuto nuovo prima di spedire. */
    private function frena(): void
    {
        if ($this->freno === null) {
            return;
        }
        $resto = $this->freno - microtime(true);
        $this->freno = null;
        if ($resto > 0) {
            $this->contatori['frenate']++;
            ($this->dormi)($resto);
        }
    }

    /** Politica di ritentativo (regola 4); il timeout risale come AgileVTimeout (embed lo gestisce). */
    private function chiama(string $metodo, string $percorso, ?array $corpo = null, bool $conChiave = true): array
    {
        $ultimo = null;
        $retryAfter = null;
        for ($tentativo = 0; $tentativo < $this->tentativi; $tentativo++) {
            if ($tentativo > 0) {
                $this->contatori['ritentate']++;
                ($this->dormi)($retryAfter !== null ? min(self::RETRY_AFTER_MAX, $retryAfter)     // il servizio sa quando
                                                    : min(self::ATTESA_MAX, $this->attesa * 2 ** ($tentativo - 1)));
                $retryAfter = null;
            }
            if ($conChiave) {
                $this->frena();
            }
            try {
                [$stato, $testa, $testo] = $this->scambio($metodo, $percorso, $corpo, $conChiave);
            } catch (AgileVTimeout $t) {
                throw $t;
            } catch (AgileVErrore $e) {
                $ultimo = $e;
                continue;
            }
            $this->notaRate($testa);
            if ($stato === 200) {
                $dati = json_decode($testo, true);
                if (!is_array($dati)) {
                    throw new AgileVErrore(200, 'BAD_JSON', 'risposta non JSON');
                }
                return $dati;
            }
            [$codice, $messaggio] = self::errore($stato, $testo);
            $ultimo = new AgileVErrore($stato, $codice, $messaggio);
            if (($stato === 429 && $codice === 'RATE_LIMIT') || $stato >= 500) {
                $ra = $testa['retry-after'] ?? null;
                $retryAfter = is_numeric($ra) ? (float) $ra : null;
                $this->freno = null;           // l'attesa del ritentativo copre già il minuto: niente doppia attesa
                continue;
            }
            throw $ultimo;
        }
        throw $ultimo;
    }

    public function health(): array
    {
        return $this->chiama('GET', '/health', null, false);
    }

    public function usage(): array
    {
        return $this->chiama('GET', '/v1/usage');
    }

    /**
     * Da chiamare UNA VOLTA all'avvio, prima di costruire o interrogare l'indice: è la regola 9.
     *
     * Confronta la DIMENSIONE dei vettori del servizio con quella del vostro indice e si ferma subito se non
     * combacia, invece di farvelo scoprire a indice costruito. Non consuma niente e non vuole la chiave.
     *
     * Sul NOME non si confronta nulla, ed è voluto: /health dichiara il modello SOTTOSTANTE (bge-m3),
     * mentre /v1/models e /v1/embeddings usano il nome di LISTINO (agilev-m3). Sono due vocabolari e i due
     * valori NON sono confrontabili: chi li confronta ha un disallineamento a ogni avvio senza che ci sia un
     * guasto (misurato: /v1/embeddings dice agilev-m3, /embed dice bge-m3, e i vettori sono identici bit per
     * bit). Il nome si confronta solo DENTRO la stessa rotta, con quello che quella rotta dichiara.
     */
    public function controllaDim(int $attesa): int
    {
        $h = $this->health();
        $dim = $h['dim'] ?? null;
        if (!is_int($dim)) {
            throw new AgileVErrore(200, 'BAD_RESPONSE', '/health non dichiara `dim`: non si puo\' controllare la dimensione');
        }
        if ($dim !== $attesa) {
            throw new AgileVErrore(0, 'DIM_MISMATCH',
                "il servizio produce vettori da $dim valori, il vostro indice ne ha $attesa: "
                . 'non indicizzate e non interrogate, sono spazi diversi');
        }
        $this->dim = $dim;
        return $dim;
    }

    /**
     * Vettori nell'ordine dei testi (array di 1024 float); con $returnSparse coppie [denso, [token => peso]].
     * I limiti si controllano qui, prima di spedire.
     */
    public function embed(array $testi, ?string $inputType = null, bool $returnSparse = false, string $model = 'agilev-m3'): array
    {
        $testi = array_values($testi);
        // $quantiAlMassimo resta null: embed i lotti li SPEZZA lui (l'array_chunk qui sotto), quindi
        // il tetto dei testi per richiesta non può sforare e un controllo sul numero rifiuterebbe
        // proprio le liste lunghe che questo client sa servire.
        self::tetti($testi);
        $fuori = [];
        foreach (array_chunk($testi, $this->lotto) as $lotto) {
            array_push($fuori, ...$this->lotto($lotto, $inputType, $returnSparse, $model));
        }
        return $fuori;
    }

    private function lotto(array $testi, ?string $inputType, bool $returnSparse, string $model): array
    {
        if ($testi === []) {
            return [];
        }
        $corpo = ['model' => $model, 'input' => $testi];
        if ($inputType !== null) {
            $corpo['input_type'] = $inputType;
        }
        if ($returnSparse) {
            $corpo['return_sparse'] = true;
        }
        try {
            $risposta = $this->chiama('POST', '/v1/embeddings', $corpo);
        } catch (AgileVTimeout $t) {
            if (count($testi) === 1) {
                throw $t;
            }
            $this->contatori['lotti_dimezzati']++;
            $meta = intdiv(count($testi), 2);      // regola 5: mai lo stesso lotto di nuovo, uno più piccolo
            return array_merge($this->lotto(array_slice($testi, 0, $meta), $inputType, $returnSparse, $model),
                               $this->lotto(array_slice($testi, $meta), $inputType, $returnSparse, $model));
        }
        $dati = self::dati($risposta, count($testi), $returnSparse, $this->dim);
        if (!$returnSparse) {
            return array_map(static fn($d) => $d['embedding'], $dati);
        }
        return array_map(static fn($d) => [$d['embedding'], array_combine($d['sparse_embedding']['indices'], $d['sparse_embedding']['values'])], $dati);
    }

    /** [[indice_nel_vostro_elenco, punteggio], ...] dal migliore; ogni candidato letto fino a $caratteri (800; null = intero). */
    public function rerank(string $query, array $documenti, ?int $topN = null, ?int $caratteri = self::RERANK_CARATTERI, string $model = 'agilev-rerank'): array
    {
        $docs = array_map(static fn($d) => $caratteri ? self::taglia($d, $caratteri) : $d, array_values($documenti));
        // I TETTI PRIMA DI SPEDIRE, nell'ordine del servizio: la query è guardata per prima perché
        // /v1/rerank fa norm_inputs([req.query], …) e solo dopo norm_inputs(documents), e con due
        // difetti nella stessa richiesta il codice che il chiamante riceve deve essere quello che
        // avrebbe ricevuto dal servizio. I documenti si misurano DOPO il taglio a $caratteri: col
        // predefinito a 800 un candidato lungo è legittimo, e il tetto per testo morde solo quando
        // chi chiama ha chiesto i documenti interi ($caratteri = null). La query NON si taglia:
        // tagliarla cambierebbe la domanda, cioè la risposta — si rifiuta e si dice quanto è lunga.
        self::tetti([$query], null, 'query');
        self::tetti($docs, self::LOTTO_MAX, 'documents');
        // $topN NEGATIVO si ferma qui, prima di spendere: non vuol dire niente e i documenti si
        // pagano a uno a uno. 0 parte: per il servizio e per questo client vuol dire «tutti».
        if ($topN !== null && $topN < 0) {
            throw new AgileVErrore(0, 'BAD_TOP_N', "top_n=$topN: deve essere positivo (0 o assente = tutti)");
        }
        $corpo = ['model' => $model, 'query' => $query, 'documents' => $docs];
        if ($topN !== null) {
            $corpo['top_n'] = $topN;
        }
        $r = $this->chiama('POST', '/v1/rerank', $corpo);
        $ris = is_array($r) ? ($r['results'] ?? null) : null;
        $attesi = $topN ? min($topN, count($docs)) : count($docs);
        if (!is_array($ris) || !array_is_list($ris) || count($ris) !== $attesi) {
            $quanti = is_array($ris) ? (string) count($ris) : 'nessun elenco';
            throw new AgileVErrore(200, 'BAD_RESPONSE', "attesi $attesi elementi in results, ricevuti $quanti");
        }
        foreach ($ris as $x) {
            if (!is_array($x) || !is_int($x['index'] ?? null) || $x['index'] < 0 || $x['index'] >= count($docs)
                || !is_numeric($x['relevance_score'] ?? null)) {
                throw new AgileVErrore(200, 'BAD_RESPONSE', 'un elemento di results non ha index o relevance_score');
            }
        }
        return array_map(static fn($x) => [$x['index'], (float) $x['relevance_score']], $ris);
    }
}

if (PHP_SAPI === 'cli' && realpath($argv[0] ?? '') === __FILE__) {
    if (($argv[1] ?? '') === '--prova') {
        $av = new AgileV(getenv('AGILEV_KEY') ?: '-');
        printf("indirizzo: %s (sorgente: %s; chiave: %s)\n", $av->indirizzo(), $av->sorgenteIndirizzo, $av->sorgenteChiave);
        $h = $av->health();
        printf("health: ok=%s loaded=%s contract=%s\n", json_encode($h['ok'] ?? null), json_encode($h['loaded'] ?? null), $h['contract'] ?? '?');
        if (getenv('AGILEV_KEY')) {
            $v = $av->embed(['Il contratto scade il 30 settembre', 'Ricetta della carbonara'], 'document');
            printf("embed: %d vettori da %d dimensioni\n", count($v), count($v[0]));
            echo 'rerank: ', json_encode($av->rerank('scadenza del contratto', ['Il contratto scade il 30 settembre', 'Ricetta della carbonara'])), "\n";
        }
        echo 'contatori: ', json_encode($av->contatori), "\n";
        $av->chiudi();
        echo "prova ok\n";
    } elseif (($argv[1] ?? '') === '--spezza') {
        // `php agilev_client.php --spezza [massimo] [sovrapposizione] [intestazione] < documento.txt`: frammenti in JSON
        // (è anche il modo in cui i test confrontano questo spezza() con il gemello Python).
        $f = AgileV::spezza(stream_get_contents(STDIN), (int) ($argv[2] ?? 0) ?: AgileV::SPEZZA_CARATTERI,
                            ($argv[3] ?? '') !== '' ? (int) $argv[3] : AgileV::SPEZZA_SOVRAPPOSIZIONE, $argv[4] ?? '');
        echo json_encode($f, JSON_UNESCAPED_UNICODE), "\n";
    } elseif (($argv[1] ?? '') === '--prova-rete' && isset($argv[2])) {
        // Usato dai test (tests/test_esempio_client.py) contro un server finto: stesse scene del gemello Python.
        $dormite = [];
        $av = new AgileV('agv_prova', $argv[2], 0.5, 2, 5, 0.01, static function (float $s) use (&$dormite) { $dormite[] = $s; });
        $esito = [];
        $v = $av->embed(['a', 'b', 'c', 'd', 'e']);                    // 3 lotti da 2,2,1, tutti 200
        $esito['vettori'] = count($v);
        $esito['primo'] = $v[0];
        $esito['dopo_embed'] = $av->contatori;
        $esito['dopo_429_e_500'] = count($av->embed(['x', 'y']));    // il server risponde 429, 500, poi 200
        $esito['ritentate'] = $av->contatori['ritentate'];
        $esito['dormite'] = $dormite;
        try {
            $av->embed(['quota']);
        } catch (AgileVErrore $e) {
            $esito['quota'] = [$e->stato, $e->codice, $av->contatori['richieste']];
        }
        try {
            $av->embed([str_repeat('x', AgileV::CARATTERI_MAX + 1)]);
        } catch (AgileVErrore $e) {
            $esito['troppo_lungo'] = [$e->stato, $e->codice];
        }
        $prima = $av->contatori['richieste'];
        $esito['timeout'] = count($av->embed(['lento1', 'lento2', 'ok1', 'ok2']));   // il server dorme sui lotti da 2
        $esito['timeout_richieste'] = $av->contatori['richieste'] - $prima;
        $esito['dimezzati'] = $av->contatori['lotti_dimezzati'];
        $esito['rerank'] = $av->rerank('q', [str_repeat('d', 2000), 'breve']);
        // top_n negativo: rifiutato QUI, senza spedire (i documenti di un rerank si pagano a uno a uno)
        $prima = $av->contatori['richieste'];
        try {
            $av->rerank('q', [str_repeat('d', 2000), 'breve'], -1);
            $esito['top_n_negativo'] = ['nessun rifiuto', 0];
        } catch (AgileVErrore $e) {
            $esito['top_n_negativo'] = [$e->codice, $av->contatori['richieste'] - $prima];
        }
        $esito['top_n_zero'] = $av->rerank('q', [str_repeat('d', 2000), 'breve'], 0);
        $esito['freno'] = count($av->embed(['freno']));               // 200 con X-RateLimit-Remaining: 0, Reset: 7
        $esito['rate_dopo_freno'] = $av->rate;
        $n = count($dormite);
        $esito['dopo_freno'] = count($av->embed(['dopo']));           // parte dopo ~7 s, senza 429
        $esito['frenata'] = [count($dormite) - $n, end($dormite), $av->contatori['frenate'], $av->contatori['ritentate']];
        $esito['lungo429'] = count($av->embed(['lungo429']));         // 429 con Retry-After: 45 -> si aspetta 45, non 30
        $esito['dormita_lunga'] = end($dormite);
        $av->chiudi();
        echo json_encode($esito), "\n";
    } else {
        echo "uso: php agilev_client.php --prova\n";
    }
}
