<?php
/**
 * Cliente PHP (7.4+ / 8.x) del API FaceID v1.0.0 (EPUNEMI) — kit de integración v2.
 *
 * Sin dependencias de Composer: sólo la extensión cURL (ext-curl) y ext-json.
 * Úsalo SOLO en tu backend: la API key es un secreto de servidor.
 *
 *   require_once __DIR__ . '/FaceIDClient.php';
 *   $faceid = new FaceIDClient(getenv('FACEID_API_KEY'));
 *   try {
 *       $r = $faceid->verificar('/ruta/captura.jpg', ['cod_number' => '12345']);
 *       echo $r['is_same_person'] ? 'OK' : 'NO';
 *   } catch (FaceIDException $e) {
 *       if ($e->noCoincide) { ... }        // rostro distinto
 *       // $e->status === 403 → API key inválida o empresa desactivada
 *   }
 *
 * Todas las respuestas del API tienen la forma {isSuccess, message, aData}.
 * Los errores de negocio llegan con HTTP 200 e isSuccess=false; una key inválida
 * o una empresa desactivada responden HTTP 403. Ambos casos lanzan FaceIDException.
 *
 * Por defecto se elimina `face_embedding` (vector biométrico) de las respuestas.
 */

class FaceIDException extends Exception
{
    /** @var int|null Código HTTP (null si no hubo respuesta). 403 = key inválida o empresa desactivada. */
    public $status;
    /** @var array JSON completo devuelto por FaceID. */
    public $respuesta;
    /** @var string|null Código estable del error (NO_COINCIDE, SIN_ROSTRO, SIN_REGISTRO, VARIOS_ROSTROS, CALIDAD_IMAGEN…). */
    public $code = null;
    /** @var bool true si el rostro no coincide con el registro (verificación 1:1). */
    public $noCoincide;
    /** @var float|null */
    public $distance = null;
    /** @var float|null */
    public $similarityScore = null;

    public function __construct(string $message, ?int $status = null, array $respuesta = [])
    {
        parent::__construct($message, $status ?? 0);
        $this->status = $status;
        $this->respuesta = $respuesta;
        $this->code = $respuesta['errors']['code'] ?? null;
        $this->noCoincide = $this->code !== null ? $this->code === 'NO_COINCIDE' : stripos($message, 'no coinciden') !== false;
        if (preg_match('/Distancia:\s*([0-9.eE+-]+).*?similitud:\s*([0-9.eE+-]+)/i', $message, $m)) {
            $this->distance = (float)$m[1];
            $this->similarityScore = (float)$m[2];
        }
    }
}

class FaceIDClient
{
    const API_BASE = '/api/faceid/v1.0.0/biometric/';

    /** @var string */
    private $apiKey;
    /** @var string */
    private $baseUrl;
    /** @var string|null */
    private $secretKey;
    /** @var int */
    private $timeout;
    /** @var bool */
    private $incluirEmbedding;

    /**
     * @param string      $apiKey           Token de tu empresa (Authorization: Bearer).
     * @param string|null $baseUrl          Por defecto FACEID_URL o https://faceapi.epunemi.gob.ec
     * @param string|null $secretKey        Clave administrativa (X-Secret-Key) para datosBiometricos/resetear/eliminar.
     * @param int         $timeout          Segundos (la primera verificación tras reiniciar FaceID puede tardar).
     * @param bool        $incluirEmbedding false = quitar face_embedding de las respuestas.
     */
    public function __construct(string $apiKey, ?string $baseUrl = null, ?string $secretKey = null, int $timeout = 60,
                                bool $incluirEmbedding = false)
    {
        if ($apiKey === '') {
            throw new InvalidArgumentException('Falta la API key de FaceID');
        }
        if (!function_exists('curl_init')) {
            throw new RuntimeException('El cliente FaceID requiere la extensión cURL de PHP (ext-curl).');
        }
        $base = $baseUrl ?: (getenv('FACEID_URL') ?: 'https://faceapi.epunemi.gob.ec');
        $this->apiKey = $apiKey;
        $this->baseUrl = rtrim($base, '/') . self::API_BASE;
        $this->secretKey = $secretKey;
        $this->timeout = $timeout;
        $this->incluirEmbedding = $incluirEmbedding;
    }

    // ----------------------------------------------------------------- utilidades

    /**
     * Convierte la foto en CURLFile. Acepta:
     *  - ruta de un archivo (string existente), p. ej. $_FILES['foto']['tmp_name'];
     *  - un CURLFile ya construido;
     *  - el contenido binario (string) — se escribe en un temporal que se borra al terminar.
     *
     * @param mixed $foto
     * @param array $temporales  Rutas temporales a borrar (por referencia).
     */
    private function curlFile($foto, array &$temporales): CURLFile
    {
        if ($foto instanceof CURLFile) {
            return $foto;
        }
        if (!is_string($foto) || $foto === '') {
            throw new FaceIDException('No se recibió la foto.');
        }
        // Una ruta no contiene bytes nulos; un JPEG sí. is_file() con bytes nulos falla en PHP 8.
        if (strpos($foto, "\0") === false && strlen($foto) < 4096 && is_file($foto)) {
            return new CURLFile($foto, 'image/jpeg', 'captura.jpg');
        }
        $tmp = tempnam(sys_get_temp_dir(), 'faceid_');
        if ($tmp === false || file_put_contents($tmp, $foto) === false) {
            throw new FaceIDException('No se pudo preparar la foto para el envío.');
        }
        $temporales[] = $tmp;
        return new CURLFile($tmp, 'image/jpeg', 'captura.jpg');
    }

    /** Quita recursivamente la clave face_embedding. */
    private static function sinEmbedding($valor)
    {
        if (is_array($valor)) {
            unset($valor['face_embedding']);
            foreach ($valor as $k => $v) {
                $valor[$k] = self::sinEmbedding($v);
            }
        }
        return $valor;
    }

    /**
     * @param string     $method  GET | POST | DELETE
     * @param string     $accion  p. ej. 'verify_face/'
     * @param array      $opts    ['params' => [...], 'form' => [...], 'json' => [...], 'admin' => bool]
     * @return array respuesta completa
     * @throws FaceIDException
     */
    private function request(string $method, string $accion, array $opts = []): array
    {
        $url = $this->baseUrl . $accion;
        if (!empty($opts['params'])) {
            $params = array_filter($opts['params'], function ($v) { return $v !== null && $v !== ''; });
            if ($params) {
                $url .= '?' . http_build_query($params);
            }
        }
        $headers = ['Authorization: Bearer ' . $this->apiKey, 'Accept: application/json'];
        if (!empty($opts['admin'])) {
            if (!$this->secretKey) {
                throw new FaceIDException('Esta operación requiere la clave administrativa (secretKey).');
            }
            $headers[] = 'X-Secret-Key: ' . $this->secretKey;
        }

        $ch = curl_init($url);
        $curlOpts = [
            CURLOPT_CUSTOMREQUEST => $method,
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_CONNECTTIMEOUT => 10,
            CURLOPT_TIMEOUT => $this->timeout,
            CURLOPT_SSL_VERIFYPEER => true,   // nunca desactivar la verificación TLS
            CURLOPT_SSL_VERIFYHOST => 2,
        ];
        if (isset($opts['form'])) {
            // Arreglo con CURLFile → cURL arma multipart/form-data con su boundary.
            $curlOpts[CURLOPT_POSTFIELDS] = $opts['form'];
        } elseif (isset($opts['json'])) {
            $curlOpts[CURLOPT_POSTFIELDS] = json_encode($opts['json']);
            $headers[] = 'Content-Type: application/json';
        }
        $curlOpts[CURLOPT_HTTPHEADER] = $headers;
        curl_setopt_array($ch, $curlOpts);

        $cuerpo = curl_exec($ch);
        $errno = curl_errno($ch);
        $status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
        if (PHP_VERSION_ID < 80000) {
            curl_close($ch);   // desde PHP 8 es automático (y en 8.5 está obsoleto)
        }

        if ($cuerpo === false || $errno) {
            if ($errno === CURLE_OPERATION_TIMEDOUT) {
                throw new FaceIDException('FaceID no respondió a tiempo. Intenta nuevamente.');
            }
            throw new FaceIDException('No se pudo conectar con FaceID (cURL ' . $errno . ').');
        }
        $data = json_decode($cuerpo, true);
        if (!is_array($data)) {
            throw new FaceIDException('Respuesta inválida de FaceID (HTTP ' . $status . ')', $status);
        }
        if (!$this->incluirEmbedding) {
            $data = self::sinEmbedding($data);
        }
        if ($status !== 200 || empty($data['isSuccess'])) {
            $mensaje = isset($data['message']) && $data['message'] !== '' ? (string)$data['message'] : 'Error HTTP ' . $status;
            throw new FaceIDException($mensaje, $status, $data);
        }
        return $data;
    }

    /** Ejecuta una petición multipart y borra los temporales aunque falle. */
    private function multipart(string $accion, array $campos, $foto, string $campoFoto): array
    {
        $temporales = [];
        try {
            $campos[$campoFoto] = $this->curlFile($foto, $temporales);
            return $this->request('POST', $accion, ['form' => $campos]);
        } finally {
            foreach ($temporales as $t) {
                @unlink($t);
            }
        }
    }

    // ----------------------------------------------------------------- endpoints

    /**
     * Registra (o actualiza) el rostro de un usuario. POST save/
     *
     * @param mixed $foto   Ruta, CURLFile o bytes JPEG.
     * @param array $datos  cod_number (obligatorio), username, nombre, apellido, email, dni_number,
     *                      permite_actualizar (bool).
     * @return array respuesta completa
     */
    public function enrolar($foto, array $datos): array
    {
        if (empty($datos['cod_number'])) {
            throw new InvalidArgumentException('cod_number es obligatorio');
        }
        $campos = [
            'cod_number' => (string)$datos['cod_number'],
            'username' => (string)($datos['username'] ?? ''),
            'nombre' => (string)($datos['nombre'] ?? ''),
            'apellido' => (string)($datos['apellido'] ?? ''),
            'email' => (string)($datos['email'] ?? ''),
            'dni_number' => (string)($datos['dni_number'] ?? ''),
        ];
        // El API trata cualquier valor presente como "sí": para NO actualizar, no se envía.
        if (!empty($datos['permite_actualizar'])) {
            $campos['permite_actualizar'] = 'true';
        }
        return $this->multipart('save/', $campos, $foto, 'face_photo');
    }

    /**
     * Verificación 1:1. POST verify_face/
     *
     * @param mixed $foto
     * @param array $quien  ['cod_number' => ...] o ['username' => ...] o ['email' => ...]
     * @return array aData: is_same_person, similarity_score, distance, threshold, model, biometric
     * @throws FaceIDException si no coincide ($e->noCoincide), sin rostro, sin registro, 403…
     */
    public function verificar($foto, array $quien): array
    {
        $campos = [];
        foreach (['cod_number', 'username', 'email'] as $k) {
            if (isset($quien[$k]) && $quien[$k] !== '') {
                $campos[$k] = (string)$quien[$k];
            }
        }
        if (!$campos) {
            throw new InvalidArgumentException('Indica cod_number, username o email');
        }
        return $this->multipart('verify_face/', $campos, $foto, 'face_photo_file')['aData'] ?? [];
    }

    /**
     * Identificación 1:N. POST identify/
     *
     * @return array aData: found, ambiguous, criterio, distance, similarity_score, threshold, margin,
     *               empresa, candidates_checked, candidates_without_embedding, biometric (null si no)
     */
    public function identificar($foto): array
    {
        return $this->multipart('identify/', [], $foto, 'face_photo_file')['aData'] ?? [];
    }

    /** Registros activos de un usuario + periodo. GET load_faces/ — respuesta completa (aaData + aData). */
    public function registros(string $username = '', string $email = ''): array
    {
        if ($username === '' && $email === '') {
            throw new InvalidArgumentException('Indica username o email');
        }
        return $this->request('GET', 'load_faces/', ['params' => ['username' => $username, 'email' => $email]]);
    }

    /** Un registro activo de tu empresa por id. GET load/ */
    public function obtener(int $id): array
    {
        return $this->request('GET', 'load/', ['params' => ['id' => $id]])['aData'] ?? [];
    }

    /** Registro más reciente + ruta de la foto (requiere secretKey). GET biometric_data/ */
    public function datosBiometricos(string $username = '', string $email = ''): array
    {
        if ($username === '' && $email === '') {
            throw new InvalidArgumentException('Indica username o email');
        }
        return $this->request('GET', 'biometric_data/', [
            'params' => ['username' => $username, 'email' => $email],
            'admin' => true,
        ])['aData'] ?? [];
    }

    /** Desactiva los registros activos de varios usuarios (requiere secretKey). POST reset/ */
    public function resetear(array $usernames): array
    {
        return $this->request('POST', 'reset/', ['json' => ['usernames' => array_values($usernames)], 'admin' => true])['aData'] ?? [];
    }

    /** Elimina físicamente un registro (requiere secretKey). DELETE delete/ */
    public function eliminar(int $id): array
    {
        return $this->request('DELETE', 'delete/', ['params' => ['id' => $id], 'admin' => true]);
    }
}
