<?php
// gemini_client.php
declare(strict_types=1);

/**
 * Gemini API Client – REST v1beta
 *
 * - Uses v1beta generateContent endpoint
 * - Model: gemini-2.5-flash
 * - Forces JSON output via responseMimeType
 * - Repairs common JSON issues + thousand separators
 * - Sanitizes illegal control characters + UTF-8 BOM
 * - Robustly extracts first JSON value ({...} or [...]) via bracket matching
 */

const GEMINI_MODEL_URL =
    'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent';

/**
 * Remove markdown fences and trim.
 */
function gemini_strip_fences(string $text): string
{
    $text = trim($text);
    $text = preg_replace('/^```(?:json)?\s*/i', '', $text);
    $text = preg_replace('/\s*```$/', '', $text);
    return trim((string)$text);
}

/**
 * Remove UTF-8 BOM if present.
 */
function gemini_strip_bom(string $s): string
{
    // UTF-8 BOM: EF BB BF
    if (strncmp($s, "\xEF\xBB\xBF", 3) === 0) {
        $s = substr($s, 3);
    }
    return (string)$s;
}

/**
 * Remove illegal ASCII control characters that can break json_decode:
 * - Removes 0x00–0x1F except TAB(09), LF(0A), CR(0D)
 */
function gemini_sanitize_control_chars(string $s): string
{
    $s = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F]/u', '', $s);
    return (string)$s;
}

/**
 * Fix common JSON issues produced by LLMs:
 * - Trailing commas
 * - Locale thousand separators in numbers (e.g. 30,063 or 74.230)
 */
function gemini_repair_json(string $raw): string
{
    $s = gemini_strip_fences($raw);

    // Fix trailing commas: {"a":1,} or [1,2,]
    $s = preg_replace('/,(\s*[\]\}])/', '$1', $s);

    // Repair numbers with thousand separators (numeric): 30,063 -> 30063 ; 74.230 -> 74230
    $s = preg_replace_callback(
        '/(:\s*)(-?\d{1,3}(?:[.,]\d{3})+)(?=[,\s}\]])/m',
        static function (array $m): string {
            return $m[1] . str_replace([',', '.'], '', $m[2]);
        },
        $s
    );

    // Repair numbers with thousand separators when returned as strings: "30,063" -> "30063"
    $s = preg_replace_callback(
        '/(:\s*")(\d{1,3}(?:[.,]\d{3})+)(")/m',
        static function (array $m): string {
            return $m[1] . str_replace([',', '.'], '', $m[2]) . $m[3];
        },
        $s
    );

    return trim((string)$s);
}

/**
 * Convert snake_case genConfig to API camelCase generationConfig.
 */
function normalize_generation_config(array $genConfig): array
{
    $out = [];

    if (isset($genConfig['max_output_tokens'])) $out['maxOutputTokens'] = (int)$genConfig['max_output_tokens'];
    if (isset($genConfig['temperature']))       $out['temperature']     = (float)$genConfig['temperature'];
    if (isset($genConfig['topP']))              $out['topP']            = (float)$genConfig['topP'];
    if (isset($genConfig['topK']))              $out['topK']            = (int)$genConfig['topK'];

    // Allow official keys too
    foreach (['maxOutputTokens','temperature','topP','topK','candidateCount','stopSequences','responseMimeType'] as $k) {
        if (isset($genConfig[$k])) $out[$k] = $genConfig[$k];
    }

    return $out;
}

/**
 * Extract the first JSON value (object OR array) from a string using bracket matching.
 * Handles extra commentary before/after JSON.
 */
function gemini_extract_first_json(string $s): string
{
    $s = trim($s);
    $s = gemini_strip_bom($s);

    // Find first { or [
    $posObj = strpos($s, '{');
    $posArr = strpos($s, '[');

    if ($posObj === false && $posArr === false) {
        throw new RuntimeException("No JSON start found (no '{' or '[').");
    }

    $start = null;
    $open = null;
    $close = null;

    if ($posObj !== false && ($posArr === false || $posObj < $posArr)) {
        $start = $posObj; $open = '{'; $close = '}';
    } else {
        $start = $posArr; $open = '['; $close = ']';
    }

    $depth = 0;
    $inString = false;
    $escape = false;
    $end = null;
    $len = strlen($s);

    for ($i = $start; $i < $len; $i++) {
        $ch = $s[$i];

        if ($inString) {
            if ($escape) {
                $escape = false;
                continue;
            }
            if ($ch === '\\') {
                $escape = true;
                continue;
            }
            if ($ch === '"') {
                $inString = false;
            }
            continue;
        }

        if ($ch === '"') {
            $inString = true;
            continue;
        }

        if ($ch === $open) {
            $depth++;
        } elseif ($ch === $close) {
            $depth--;
            if ($depth === 0) {
                $end = $i;
                break;
            }
        }
    }

    if ($end === null) {
        throw new RuntimeException("JSON start found but could not locate matching closing bracket.");
    }

    return substr($s, $start, $end - $start + 1);
}

/**
 * Low-level Gemini call: returns raw text from the model.
 */
function gemini_call_raw(string $prompt, array $genConfig = []): string
{
    $apiKey = getenv('GEMINI_API_KEY') ?: '';

    // ⚠️ Strongly recommended: REMOVE hardcoded fallback key in production
    if ($apiKey === '') {
        $apiKey = 'AIzaSyDyBIZX4RBnx83WG6earOvnGOqsscwnfEw';
    }

    if ($apiKey === '') {
        http_response_code(500);
        echo json_encode([
            'status'  => 'error',
            'message' => 'GEMINI_API_KEY is not set and no fallback key is configured.',
        ]);
        exit;
    }

    $payload = [
        'contents' => [
            [
                'role'  => 'user',
                'parts' => [
                    ['text' => $prompt],
                ],
            ],
        ],
        'generationConfig' => array_merge([
            'maxOutputTokens'  => 2000,
            'temperature'      => 0.0,
            'topP'             => 1,
            'candidateCount'   => 1,
            'responseMimeType' => 'application/json',
        ], normalize_generation_config($genConfig)),
    ];

    $url = GEMINI_MODEL_URL . '?key=' . urlencode($apiKey);

    $ch = curl_init($url);
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_POST           => true,
        CURLOPT_HTTPHEADER     => ['Content-Type: application/json'],
        CURLOPT_POSTFIELDS     => json_encode($payload),
        CURLOPT_TIMEOUT        => 60,
    ]);

    $response = curl_exec($ch);
    if ($response === false) {
        $err = curl_error($ch);
        curl_close($ch);

        http_response_code(502);
        echo json_encode([
            'status'  => 'error',
            'message' => "Gemini request failed: $err",
        ]);
        exit;
    }

    $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);

    if ($httpCode !== 200) {
        http_response_code($httpCode);
        echo json_encode([
            'status'  => 'error',
            'message' => "Gemini returned HTTP $httpCode",
            'detail'  => $response,
        ]);
        exit;
    }

    $data = json_decode($response, true);

    $text = $data['candidates'][0]['content']['parts'][0]['text'] ?? null;
    if (!is_string($text)) {
        http_response_code(502);
        echo json_encode([
            'status'  => 'error',
            'message' => 'Unexpected Gemini API response format',
            'detail'  => $data,
        ]);
        exit;
    }

    $text = gemini_strip_fences($text);
    $text = gemini_strip_bom($text);

    return $text;
}

/**
 * Safely returns decoded JSON from Gemini.
 * - Repairs + sanitizes
 * - If decode fails, extracts first JSON block ({...} or [...]) and decodes that.
 */
function gemini_call_json(string $prompt, array $genConfig = []): array
{
    $raw = gemini_call_raw($prompt, $genConfig);

    $clean = gemini_repair_json($raw);
    $clean = gemini_strip_bom($clean);
    $clean = gemini_sanitize_control_chars($clean);

    $decoded = json_decode($clean, true);
    if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) {
        return $decoded;
    }

    // Try extracting first JSON value (handles extra text + arrays)
    try {
        $block = gemini_extract_first_json($clean);
        $block = gemini_sanitize_control_chars(gemini_repair_json($block));
        $decoded2 = json_decode($block, true);

        if (json_last_error() === JSON_ERROR_NONE && is_array($decoded2)) {
            return $decoded2;
        }
    } catch (Throwable $e) {
        // fall through to error response
    }

    http_response_code(502);
    echo json_encode([
        'status'     => 'error',
        'message'    => 'Gemini did not return valid JSON',
        'raw_output' => mb_substr($clean, 0, 1500),
        'json_error' => json_last_error_msg(),
    ]);
    exit;
}