<?php
declare(strict_types=1);

define('DBN_TOOLS_ROOT', dirname(__DIR__));
define('DBN_TOOLS_VERSION', '0.1.0');

final class DbnToolsHttpException extends RuntimeException
{
    public int $status;
    public string $errorCode;
    public array $extra;

    public function __construct(string $message, int $status = 400, string $errorCode = 'bad_request', array $extra = [])
    {
        parent::__construct($message);
        $this->status = $status;
        $this->errorCode = $errorCode;
        $this->extra = $extra;
    }
}

function dbnToolsLoadEnv(string $path): void
{
    if (!is_file($path) || !is_readable($path)) {
        return;
    }

    $lines = file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
    if ($lines === false) {
        return;
    }

    foreach ($lines as $line) {
        $line = trim($line);
        if ($line === '' || str_starts_with($line, '#') || !str_contains($line, '=')) {
            continue;
        }

        [$key, $value] = explode('=', $line, 2);
        $key = trim($key);
        $value = trim($value);
        if ($key === '') {
            continue;
        }

        if ((str_starts_with($value, '"') && str_ends_with($value, '"')) ||
            (str_starts_with($value, "'") && str_ends_with($value, "'"))) {
            $value = substr($value, 1, -1);
        }

        if (getenv($key) === false) {
            putenv($key . '=' . $value);
            $_ENV[$key] = $value;
        }
    }
}

dbnToolsLoadEnv(DBN_TOOLS_ROOT . '/.env');

function dbnToolsEnv(string $key, ?string $default = null): ?string
{
    $fileKey = $key . '_FILE';
    $filePath = getenv($fileKey);
    if ($filePath !== false && $filePath !== '') {
        $value = @file_get_contents($filePath);
        if ($value === false) {
            throw new RuntimeException("Unable to read secret file for {$fileKey}");
        }
        return rtrim($value, "\r\n");
    }

    $value = getenv($key);
    if ($value === false || $value === '') {
        return $default;
    }
    return $value;
}

function dbnToolsIsHttps(): bool
{
    if (!empty($_SERVER['HTTPS']) && strtolower((string)$_SERVER['HTTPS']) !== 'off') {
        return true;
    }
    return isset($_SERVER['HTTP_X_FORWARDED_PROTO']) &&
        strtolower((string)$_SERVER['HTTP_X_FORWARDED_PROTO']) === 'https';
}

function dbnToolsStartSession(): void
{
    if (session_status() === PHP_SESSION_ACTIVE) {
        return;
    }

    session_name('dbn_tools_session');
    session_set_cookie_params([
        'lifetime' => 0,
        'path' => '/',
        'secure' => dbnToolsIsHttps(),
        'httponly' => true,
        'samesite' => 'Lax',
    ]);
    session_start();

    if (empty($_SESSION['dbn_tools_anon_id'])) {
        $_SESSION['dbn_tools_anon_id'] = bin2hex(random_bytes(16));
    }
}

dbnToolsStartSession();
require_once __DIR__ . '/i18n.php';

function dbnToolsIsAuthenticated(): bool
{
    // SSO session established via dobetternorge.no signed token
    if (!empty($_SESSION['dbn_tools_authenticated']) && !empty($_SESSION['dbn_tools_sso_uid'])) {
        return true;
    }
    // Regular Caveau session
    return !empty($_SESSION['dbn_tools_authenticated'])
        && !empty($_SESSION['dbn_tools_user_id'])
        && !empty($_SESSION['dbn_tools_client_id'])
        && (string)($_SESSION['dbn_tools_client_slug'] ?? '') === dbnToolsClientSlug();
}

/**
 * Validates a signed SSO token from dobetternorge.no.
 * Returns the decoded payload array or null on failure.
 */
function dbnToolsValidateSsoToken(string $token, string $secret): ?array
{
    $parts = explode('.', $token, 2);
    if (count($parts) !== 2) return null;
    [$payload, $sig] = $parts;
    if (!hash_equals(hash_hmac('sha256', $payload, $secret), $sig)) return null;
    $data = json_decode(base64_decode(strtr($payload, '-_', '+/')), true);
    if (!is_array($data) || ($data['exp'] ?? 0) < time()) return null;
    if (empty($data['tools_approved'])) return null;
    return $data;
}

function dbnToolsAuthenticatedUser(): ?array
{
    if (!dbnToolsIsAuthenticated()) {
        return null;
    }

    return [
        'user_id' => isset($_SESSION['dbn_tools_user_id']) ? (int)$_SESSION['dbn_tools_user_id'] : null,
        'client_id' => isset($_SESSION['dbn_tools_client_id']) ? (int)$_SESSION['dbn_tools_client_id'] : null,
        'email' => (string)($_SESSION['dbn_tools_user_email'] ?? ''),
        'role' => (string)($_SESSION['dbn_tools_user_role'] ?? ''),
    ];
}

function dbnToolsRequiredPackageSlug(): string
{
    return dbnToolsEnv('DBN_CAVEAU_PACKAGE_SLUG') ?: 'family-legal';
}

function dbnToolsAnonymousSessionId(): string
{
    $id = (string)($_SESSION['dbn_tools_anon_id'] ?? '');
    if ($id === '') {
        $id = bin2hex(random_bytes(16));
        $_SESSION['dbn_tools_anon_id'] = $id;
    }
    return substr(hash('sha256', $id), 0, 18);
}

function dbnToolsRespond(array $payload, int $status = 200): void
{
    http_response_code($status);
    header('Content-Type: application/json; charset=utf-8');
    header('Cache-Control: no-store');
    echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);
    exit;
}

function dbnToolsError(string $message, int $status = 400, string $code = 'bad_request', array $extra = []): void
{
    dbnToolsRespond(array_merge([
        'ok' => false,
        'error' => [
            'code' => $code,
            'message' => $message,
        ],
    ], $extra), $status);
}

function dbnToolsAbort(string $message, int $status = 400, string $code = 'bad_request', array $extra = []): void
{
    throw new DbnToolsHttpException($message, $status, $code, $extra);
}

function dbnToolsRequireMethod(string $method): void
{
    if (strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET') !== strtoupper($method)) {
        dbnToolsError('Method not allowed.', 405, 'method_not_allowed');
    }
}

function dbnToolsRequireAuth(): void
{
    if (!dbnToolsIsAuthenticated()) {
        dbnToolsError('Caveau session required.', 401, 'session_required');
    }
}

function dbnToolsJsonInput(int $maxBytes = 50000): array
{
    $raw = file_get_contents('php://input');
    if ($raw === false) {
        dbnToolsError('Unable to read request body.', 400, 'body_unreadable');
    }
    if (strlen($raw) > $maxBytes) {
        dbnToolsError('Request body is too large for this tool.', 413, 'body_too_large');
    }

    $data = json_decode($raw, true);
    if (!is_array($data)) {
        dbnToolsError('Request body must be valid JSON.', 400, 'invalid_json');
    }
    return $data;
}

function dbnToolsNormalizeLanguage(mixed $value): string
{
    return dbnToolsNormalizeUiLanguage($value);
}

function dbnToolsNormalizeRegion(mixed $value): string
{
    $region = strtolower(trim((string)$value));
    return in_array($region, ['nordic', 'european', 'echr', 'global'], true) ? $region : 'nordic';
}

function dbnToolsString(array $input, string $key, int $maxChars, bool $required = true): string
{
    $value = trim((string)($input[$key] ?? ''));
    if ($required && $value === '') {
        dbnToolsAbort("Missing required field: {$key}.", 422, 'missing_field');
    }
    if (mb_strlen($value, 'UTF-8') > $maxChars) {
        dbnToolsAbort("Field {$key} is too long.", 422, 'field_too_long');
    }
    return $value;
}

function dbnToolsSupportDir(): string
{
    $dir = dbnToolsEnv('DBN_TOOLS_SUPPORT_DIR');
    if ($dir === null || trim($dir) === '') {
        $dir = rtrim(sys_get_temp_dir(), "\\/") . DIRECTORY_SEPARATOR . 'dbn-tools';
    }

    if (!is_dir($dir)) {
        @mkdir($dir, 0770, true);
    }
    return $dir;
}

function dbnToolsMetadataLogPath(): string
{
    return dbnToolsEnv('DBN_TOOLS_METADATA_LOG') ?: dbnToolsSupportDir() . DIRECTORY_SEPARATOR . 'metadata.jsonl';
}

function dbnToolsLogMetadata(array $entry): void
{
    $path = dbnToolsMetadataLogPath();
    $safe = [
        'timestamp' => gmdate('c'),
        'session' => dbnToolsAnonymousSessionId(),
        'tool' => (string)($entry['tool'] ?? 'unknown'),
        'latency_ms' => (int)($entry['latency_ms'] ?? 0),
        'language' => (string)($entry['language'] ?? ''),
        'ok' => (bool)($entry['ok'] ?? false),
        'error_code' => $entry['error_code'] ?? null,
        'chunk_count' => (int)($entry['chunk_count'] ?? 0),
        'source_count' => (int)($entry['source_count'] ?? 0),
        'deployment' => $entry['deployment'] ?? dbnToolsEnv('DBN_AZURE_OPENAI_CHAT_DEPLOYMENT'),
    ];

    @file_put_contents(
        $path,
        json_encode($safe, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL,
        FILE_APPEND | LOCK_EX
    );
}

function dbnToolsWithTelemetry(string $tool, string $language, callable $handler): void
{
    $start = microtime(true);

    try {
        $payload = $handler();
        $latency = (int)round((microtime(true) - $start) * 1000);
        $payload['ok'] = $payload['ok'] ?? true;
        $payload['latency_ms'] = $latency;

        dbnToolsLogMetadata([
            'tool' => $tool,
            'language' => $language,
            'ok' => true,
            'latency_ms' => $latency,
            'chunk_count' => (int)($payload['trace_metadata']['chunk_count'] ?? 0),
            'source_count' => (int)($payload['trace_metadata']['source_count'] ?? 0),
            'deployment' => $payload['trace_metadata']['deployment'] ?? null,
        ]);

        dbnToolsRespond($payload);
    } catch (DbnToolsHttpException $e) {
        $latency = (int)round((microtime(true) - $start) * 1000);
        dbnToolsLogMetadata([
            'tool' => $tool,
            'language' => $language,
            'ok' => false,
            'latency_ms' => $latency,
            'error_code' => $e->errorCode,
        ]);
        dbnToolsError($e->getMessage(), $e->status, $e->errorCode, $e->extra);
    } catch (Throwable $e) {
        $latency = (int)round((microtime(true) - $start) * 1000);
        dbnToolsLogMetadata([
            'tool' => $tool,
            'language' => $language,
            'ok' => false,
            'latency_ms' => $latency,
            'error_code' => 'internal_error',
        ]);
        error_log('DBN tools error: ' . $e->getMessage());
        dbnToolsError('The tool could not complete this request.', 500, 'internal_error');
    }
}

function dbnToolsAiPortalRoot(): string
{
    $root = dbnToolsEnv('DBN_AI_PORTAL_ROOT');
    if ($root !== null && trim($root) !== '') {
        return rtrim($root, "\\/");
    }
    return dirname(DBN_TOOLS_ROOT) . DIRECTORY_SEPARATOR . 'ai-portal';
}

function dbnToolsBootCaveau(): void
{
    static $booted = false;
    if ($booted) {
        return;
    }

    $root = dbnToolsAiPortalRoot();
    $dbFile = $root . DIRECTORY_SEPARATOR . 'admin' . DIRECTORY_SEPARATOR . 'includes' . DIRECTORY_SEPARATOR . 'db.php';
    $ragFile = $root . DIRECTORY_SEPARATOR . 'platform' . DIRECTORY_SEPARATOR . 'includes' . DIRECTORY_SEPARATOR . 'client_rag.php';
    $agentFile = $root . DIRECTORY_SEPARATOR . 'lib' . DIRECTORY_SEPARATOR . 'ai' . DIRECTORY_SEPARATOR . 'DbnLegalAgent.php';

    if (!is_file($dbFile) || !is_file($ragFile)) {
        dbnToolsAbort('CaveauAI platform files are not available. Check DBN_AI_PORTAL_ROOT.', 503, 'caveau_unavailable');
    }

    require_once $dbFile;
    require_once $ragFile;
    if (is_file($agentFile)) {
        require_once $agentFile;
    }
    $booted = true;
}

function dbnToolsDb(): PDO
{
    dbnToolsBootCaveau();
    try {
        return getDb();
    } catch (Throwable $e) {
        throw new DbnToolsHttpException('CaveauAI database is not reachable.', 503, 'db_unavailable');
    }
}

function dbnToolsRagDb(): PDO
{
    dbnToolsBootCaveau();
    try {
        return getRagDb();
    } catch (Throwable $e) {
        throw new DbnToolsHttpException('CaveauAI corpus database is not reachable.', 503, 'rag_db_unavailable');
    }
}

/**
 * PDO connection to dobetternorge_maindb (user accounts + credits).
 * Credentials come from DBNM_DB_* env vars.
 */
function dbnmDb(): PDO
{
    static $pdo = null;
    if ($pdo !== null) {
        return $pdo;
    }

    $host = dbnToolsEnv('DBNM_DB_HOST', 'localhost');
    $name = dbnToolsEnv('DBNM_DB_NAME', 'dobetternorge_maindb');
    $user = dbnToolsEnv('DBNM_DB_USER', 'root');
    $pass = dbnToolsEnv('DBNM_DB_PASS', '');

    try {
        $pdo = new PDO(
            "mysql:host={$host};dbname={$name};charset=utf8mb4",
            $user,
            $pass,
            [
                PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
                PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
                PDO::ATTR_EMULATE_PREPARES   => false,
            ]
        );
    } catch (PDOException $e) {
        throw new DbnToolsHttpException('Credit database is not reachable.', 503, 'credit_db_unavailable');
    }

    return $pdo;
}

/**
 * True when the current session belongs to an SSO user (Google login).
 * All SSO sessions go through the credit + tier system (free, light, pro, pro_plus).
 * False for CaveauAI client sessions, which bypass all credit checks.
 *
 * Note: name is historical — paid SSO users are also subject to the credit gate.
 */
function dbnToolsIsFreeTier(): bool
{
    return !empty($_SESSION['dbn_tools_authenticated'])
        && !empty($_SESSION['dbn_tools_sso_uid']);
}

/**
 * Enforce credit + tier gate before a tool call.
 * Exits with JSON 402/429 if the user is over limit or out of credits.
 * No-op for CaveauAI sessions.
 *
 * @return int The user_id for use in dbnToolsFreeTierDeduct()
 */
function dbnToolsFreeTierCheck(string $tool): int
{
    if (!dbnToolsIsFreeTier()) {
        return 0;
    }

    require_once __DIR__ . '/FreeTier.php';
    $uid    = (int)$_SESSION['dbn_tools_sso_uid'];
    $result = FreeTier::check($uid, $tool);

    if (!$result['ok']) {
        $isRateLimit = ($result['reason'] ?? '') === 'rate_limit';
        $tier = (string)($result['tier'] ?? 'free');
        $cap = FreeTier::hourlyCap($tier);
        http_response_code($isRateLimit ? 429 : 402);
        header('Content-Type: application/json; charset=utf-8');
        header('Cache-Control: no-store');
        echo json_encode([
            'ok'      => false,
            'error'   => ['code' => $result['reason'], 'message' => $isRateLimit
                ? "Rate limit reached — your tier ({$tier}) allows {$cap} requests per hour."
                : 'No credits remaining. See /pricing.php to top up or upgrade.',
            ],
            'balance' => $result['balance'],
            'bonus_balance' => $result['bonus_balance'] ?? 0,
            'tier' => $tier,
        ], JSON_UNESCAPED_UNICODE);
        exit;
    }

    return $uid;
}

/** Return the current SSO user's tier, or 'free' if not SSO / no row. */
function dbnToolsCurrentTier(): string
{
    if (!dbnToolsIsFreeTier()) {
        return 'caveau';
    }
    require_once __DIR__ . '/FreeTier.php';
    return FreeTier::tier((int)$_SESSION['dbn_tools_sso_uid']);
}

/**
 * Inject "Min Sak" context into an agent's system prompt.
 *
 * Returns a system-prompt fragment with the top-k hybrid-search hits from the user's
 * private case corpus, or an empty string if the user is not paid / has no docs / opted out.
 *
 * CRITICAL: this resolves family-plan members to their OWNER's user_id so the search hits
 * the shared OWNER's index. Index-level isolation makes cross-user leak structurally impossible.
 *
 * Pattern (for agents):
 *   $caseBlock = dbnToolsCaseContext($intake['use_my_case'] ?? false, $userQuery);
 *   if ($caseBlock !== '') { $systemPrompt .= $caseBlock; }
 */
function dbnToolsCaseContext(bool $useMyCase, string $query, int $k = 5): string
{
    if (!$useMyCase) return '';
    if (!dbnToolsIsFreeTier()) return '';
    $userId = (int)($_SESSION['dbn_tools_sso_uid'] ?? 0);
    if ($userId <= 0) return '';

    require_once __DIR__ . '/FreeTier.php';
    $tier = FreeTier::tier($userId);
    if (!in_array($tier, ['light', 'pro', 'pro_plus'], true)) return '';

    require_once __DIR__ . '/CaseStore.php';
    $effective = CaseStore::caseResolveClientId($userId);
    $chunks = CaseStore::caseHybridSearch($effective, $query, $k);

    // Audit log: who ran what against whose case
    try {
        $db = dbnmDb();
        $db->prepare(
            'INSERT INTO case_tool_runs (user_id, tool, used_my_case, case_chunks_retrieved, doc_ids, ip_hash, created_at)
             VALUES (?, ?, 1, ?, ?, ?, NOW())'
        )->execute([
            $userId,
            (string)(debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 2)[1]['function'] ?? 'unknown'),
            count($chunks),
            json_encode(array_values(array_unique(array_map(fn($c) => (int)$c['doc_id'], $chunks))), JSON_UNESCAPED_UNICODE),
            hash('sha256', ($_SERVER['REMOTE_ADDR'] ?? '0.0.0.0') . '|case'),
        ]);
    } catch (Throwable $e) { /* audit log is non-fatal */ }

    return CaseStore::formatChunksForPrompt($chunks);
}

/** Read /etc/bnl/intersite.php for the HMAC secret shared between dobetternorge.no and tools.dobetternorge.no. */
function dbnToolsIntersiteSecret(): string
{
    static $secret = null;
    if ($secret !== null) {
        return $secret;
    }
    $envValue = (string)(dbnToolsEnv('INTERSITE_HMAC_SECRET') ?? '');
    if ($envValue !== '') {
        return $secret = $envValue;
    }
    $path = '/etc/bnl/intersite.php';
    if (is_readable($path)) {
        $cfg = require $path;
        if (is_array($cfg) && !empty($cfg['INTERSITE_HMAC_SECRET'])) {
            return $secret = (string)$cfg['INTERSITE_HMAC_SECRET'];
        }
    }
    return $secret = '';
}

/**
 * Deduct credits after a successful tool call.
 * The $uid returned from dbnToolsFreeTierCheck() must be passed in.
 * No-op when uid === 0 (non-free-tier session).
 *
 * @return int Remaining balance (for response header)
 */
function dbnToolsFreeTierDeduct(int $uid, string $tool): int
{
    if ($uid === 0) {
        return -1;
    }
    require_once __DIR__ . '/FreeTier.php';
    return FreeTier::deduct($uid, $tool);
}

function dbnToolsClientSlug(): string
{
    return dbnToolsEnv('DBN_CAVEAU_CLIENT_SLUG') ?: 'dobetter';
}

function dbnToolsFetchClient(?PDO $db = null): ?array
{
    $db = $db ?: dbnToolsDb();
    $stmt = $db->prepare('SELECT * FROM clients WHERE slug = ? LIMIT 1');
    $stmt->execute([dbnToolsClientSlug()]);
    $row = $stmt->fetch(PDO::FETCH_ASSOC);
    return $row ?: null;
}

function dbnToolsRequireClient(): array
{
    $client = dbnToolsFetchClient();
    if (!$client || empty($client['is_active'])) {
        dbnToolsAbort('Do Better Norge client tenant is not active or was not found.', 503, 'client_unavailable');
    }
    return $client;
}

function dbnToolsFetchActiveClientUser(string $email, int $clientId, ?PDO $db = null): ?array
{
    $db = $db ?: dbnToolsDb();
    $stmt = $db->prepare(
        'SELECT id, client_id, username, email, display_name, password_hash, role, is_active
         FROM client_users
         WHERE client_id = ? AND email = ? AND is_active = 1
         LIMIT 1'
    );
    $stmt->execute([$clientId, $email]);
    $row = $stmt->fetch(PDO::FETCH_ASSOC);
    return $row ?: null;
}

function dbnToolsCanUsePackage(int $clientId, string $packageSlug, ?PDO $db = null): array
{
    $db = $db ?: dbnToolsDb();
    $package = dbnToolsFetchPackage($packageSlug, $db);
    if (!$package || empty($package['is_active'])) {
        return [
            'ok' => false,
            'status' => 503,
            'code' => 'package_unavailable',
            'message' => "The {$packageSlug} corpus package is not active.",
        ];
    }

    if (!dbnToolsHasActiveSubscription($clientId, (int)$package['id'], $db)) {
        return [
            'ok' => false,
            'status' => 403,
            'code' => 'subscription_missing',
            'message' => 'This Caveau workspace does not have access to the required corpus package.',
        ];
    }

    return [
        'ok' => true,
        'package' => $package,
    ];
}

function dbnToolsFetchPackage(string $slug = 'family-legal', ?PDO $db = null): ?array
{
    $db = $db ?: dbnToolsDb();
    $stmt = $db->prepare('SELECT * FROM corpus_packages WHERE slug = ? LIMIT 1');
    $stmt->execute([$slug]);
    $row = $stmt->fetch(PDO::FETCH_ASSOC);
    return $row ?: null;
}

function dbnToolsHasActiveSubscription(int $clientId, int $packageId, ?PDO $db = null): bool
{
    $db = $db ?: dbnToolsDb();
    $stmt = $db->prepare(
        'SELECT COUNT(*) FROM client_corpus_subscriptions
         WHERE client_id = ? AND package_id = ? AND is_active = 1'
    );
    $stmt->execute([$clientId, $packageId]);
    return (int)$stmt->fetchColumn() > 0;
}

function dbnToolsDisclaimer(string $language): string
{
    $language = dbnToolsNormalizeUiLanguage($language);
    return match ($language) {
        'no' => 'Juridisk informasjon og forberedelsesstøtte, ikke endelig juridisk rådgivning.',
        'uk' => 'Юридична інформація та підтримка підготовки, не остаточна юридична порада.',
        'pl' => 'Informacje prawne i wsparcie przygotowania, nie ostateczna porada prawna.',
        default => 'Legal information and preparation support, not final legal advice.',
    };
}

function dbnToolsExcerpt(string $text, int $limit = 520): string
{
    $text = preg_replace('/\s+/u', ' ', strip_tags($text)) ?? '';
    $text = trim($text);
    if (mb_strlen($text, 'UTF-8') <= $limit) {
        return $text;
    }
    return rtrim(mb_substr($text, 0, $limit - 1, 'UTF-8')) . '…';
}

const DBN_TOOLS_EXTRACT_MAX_BYTES = 8 * 1024 * 1024;
const DBN_TOOLS_EXTRACT_TEXT_LIMIT = 128000;
const DBN_TOOLS_EXTRACT_ALLOWED_EXTS = ['txt', 'pdf', 'docx'];

function dbnToolsExtractUploadedFile(array $file): array
{
    $errCode = (int)($file['error'] ?? UPLOAD_ERR_NO_FILE);
    if ($errCode !== UPLOAD_ERR_OK) {
        $msg = match ($errCode) {
            UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE => 'The file exceeds the allowed size limit.',
            UPLOAD_ERR_NO_TMP_DIR => 'No temporary directory is available.',
            UPLOAD_ERR_CANT_WRITE => 'Unable to save the uploaded file.',
            default => 'File upload failed.',
        };
        dbnToolsAbort($msg, 422, 'upload_error');
    }

    $originalName = basename((string)($file['name'] ?? ''));
    $tmpPath      = (string)($file['tmp_name'] ?? '');
    $size         = (int)($file['size'] ?? 0);

    if (!is_uploaded_file($tmpPath)) {
        dbnToolsAbort('Invalid file upload.', 400, 'invalid_upload');
    }
    if ($size === 0) {
        dbnToolsAbort('The uploaded file is empty.', 422, 'file_empty');
    }
    if ($size > DBN_TOOLS_EXTRACT_MAX_BYTES) {
        dbnToolsAbort('File exceeds the 8 MB limit.', 413, 'file_too_large');
    }

    $ext = strtolower(pathinfo($originalName, PATHINFO_EXTENSION));
    if (!in_array($ext, DBN_TOOLS_EXTRACT_ALLOWED_EXTS, true)) {
        dbnToolsAbort('Unsupported file type. Upload a .pdf, .docx, or .txt file.', 422, 'unsupported_type');
    }

    $text = match ($ext) {
        'txt'  => dbnToolsExtractTxt($tmpPath),
        'pdf'  => dbnToolsExtractPdf($tmpPath),
        'docx' => dbnToolsExtractDocx($tmpPath),
    };

    $text = trim($text);
    if ($text === '') {
        dbnToolsAbort('No text could be extracted from this file.', 422, 'no_text');
    }

    $truncated = false;
    if (mb_strlen($text, 'UTF-8') > DBN_TOOLS_EXTRACT_TEXT_LIMIT) {
        $text      = mb_substr($text, 0, DBN_TOOLS_EXTRACT_TEXT_LIMIT, 'UTF-8');
        $truncated = true;
    }

    return [
        'ok'        => true,
        'text'      => $text,
        'filename'  => $originalName,
        'chars'     => mb_strlen($text, 'UTF-8'),
        'truncated' => $truncated,
    ];
}

function dbnToolsExtractTxt(string $path): string
{
    $content = file_get_contents($path);
    if ($content === false) {
        throw new DbnToolsHttpException('Unable to read the file.', 500, 'read_error');
    }
    return mb_convert_encoding($content, 'UTF-8', 'UTF-8, ISO-8859-1, Windows-1252');
}

function dbnToolsExtractPdf(string $path): string
{
    $cmd    = 'pdftotext ' . escapeshellarg($path) . ' - 2>/dev/null';
    $output = shell_exec($cmd);
    if ($output === null || $output === false || trim($output) === '') {
        throw new DbnToolsHttpException(
            'PDF text extraction failed. The file may be image-only or encrypted.',
            422,
            'pdf_extract_failed'
        );
    }
    return $output;
}

function dbnToolsExtractDocx(string $path): string
{
    $zip    = new ZipArchive();
    $result = $zip->open($path);
    if ($result !== true) {
        throw new DbnToolsHttpException('Unable to open the .docx file.', 422, 'docx_open_failed');
    }

    $xml = $zip->getFromName('word/document.xml');
    $zip->close();

    if ($xml === false) {
        throw new DbnToolsHttpException('No document content found in this .docx file.', 422, 'docx_no_content');
    }

    $doc = new DOMDocument();
    libxml_use_internal_errors(true);
    $doc->loadXML($xml);
    libxml_clear_errors();

    $xpath = new DOMXPath($doc);
    $xpath->registerNamespace('w', 'http://schemas.openxmlformats.org/wordprocessingml/2006/main');

    $paragraphs = [];
    foreach ($xpath->query('//w:p') as $para) {
        $runs = [];
        foreach ($xpath->query('.//w:t', $para) as $t) {
            $runs[] = $t->textContent;
        }
        $paragraphs[] = implode('', $runs);
    }

    return implode("\n", $paragraphs);
}

function dbnToolsCallGpuLlm(array $messages, array $options = []): array
{
    $url     = 'http://10.0.1.10:4000/v1/chat/completions';
    $apiKey  = (string)(dbnToolsEnv('LITELLM_MASTER_KEY') ?: 'sk-bnl-litellm-26xR9mK4qvN3wL8sTj7pB2d');
    $model   = (string)($options['model'] ?? 'qwen2.5:14b');
    $timeout = (int)($options['timeout'] ?? 90);

    $payload = [
        'model'       => $model,
        'messages'    => $messages,
        'temperature' => $options['temperature'] ?? 0.1,
        'max_tokens'  => $options['max_tokens']  ?? 8000,
    ];
    if (!empty($options['json'])) {
        $payload['response_format'] = ['type' => 'json_object'];
    }

    $body    = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
    $headers = [
        'Content-Type: application/json',
        'Authorization: Bearer ' . $apiKey,
    ];

    if (function_exists('curl_init')) {
        $ch = curl_init($url);
        curl_setopt_array($ch, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_POST           => true,
            CURLOPT_POSTFIELDS     => $body,
            CURLOPT_HTTPHEADER     => $headers,
            CURLOPT_TIMEOUT        => $timeout,
        ]);
        $response = curl_exec($ch);
        $code     = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
        $err      = curl_error($ch);
        curl_close($ch);

        if ($response === false) {
            throw new RuntimeException('GPU LiteLLM request failed: ' . $err);
        }
    } else {
        $ctx      = stream_context_create(['http' => [
            'method'        => 'POST',
            'header'        => implode("\r\n", $headers),
            'content'       => $body,
            'timeout'       => $timeout,
            'ignore_errors' => true,
        ]]);
        $response = @file_get_contents($url, false, $ctx);
        $code     = 0;
        if (isset($http_response_header[0]) && preg_match('/\s(\d{3})\s/', $http_response_header[0], $m)) {
            $code = (int)$m[1];
        }
        if ($response === false) {
            throw new RuntimeException('GPU LiteLLM request failed.');
        }
    }

    $decoded = json_decode($response, true);
    if (!is_array($decoded)) {
        throw new RuntimeException('GPU LiteLLM returned non-JSON response.');
    }
    if ($code < 200 || $code >= 300) {
        $msg = $decoded['error']['message'] ?? ('HTTP ' . $code);
        throw new RuntimeException('GPU LiteLLM error: ' . $msg);
    }
    return $decoded;
}

/**
 * Batch-embed texts via LiteLLM /v1/embeddings using cURL.
 * Returns an array of float[] indexed by input position.
 */
function dbnToolsLiteLLMEmbedBatch(array $texts, string $model = 'nomic-embed-text', int $timeout = 60): array
{
    if (empty($texts)) {
        return [];
    }

    $url    = 'http://10.0.1.10:4000/v1/embeddings';
    $apiKey = (string)(dbnToolsEnv('LITELLM_MASTER_KEY') ?: 'sk-bnl-litellm-26xR9mK4qvN3wL8sTj7pB2d');

    $payload = json_encode(['model' => $model, 'input' => array_values($texts)], JSON_UNESCAPED_UNICODE);
    $headers = [
        'Content-Type: application/json',
        'Authorization: Bearer ' . $apiKey,
    ];

    if (function_exists('curl_init')) {
        $ch = curl_init($url);
        curl_setopt_array($ch, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_POST           => true,
            CURLOPT_POSTFIELDS     => $payload,
            CURLOPT_HTTPHEADER     => $headers,
            CURLOPT_TIMEOUT        => $timeout,
        ]);
        $response = curl_exec($ch);
        $code     = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
        $err      = curl_error($ch);
        curl_close($ch);

        if ($response === false) {
            throw new RuntimeException('LiteLLM embed request failed: ' . $err);
        }
    } else {
        $ctx = stream_context_create(['http' => [
            'method'        => 'POST',
            'header'        => implode("\r\n", $headers),
            'content'       => $payload,
            'timeout'       => $timeout,
            'ignore_errors' => true,
        ]]);
        $response = @file_get_contents($url, false, $ctx);
        $code     = 0;
        if (isset($http_response_header[0]) && preg_match('/\s(\d{3})\s/', $http_response_header[0], $m)) {
            $code = (int)$m[1];
        }
        if ($response === false) {
            throw new RuntimeException('LiteLLM embed request failed.');
        }
    }

    $decoded = json_decode($response, true);
    if (!is_array($decoded)) {
        throw new RuntimeException('LiteLLM embed returned non-JSON.');
    }
    if ($code < 200 || $code >= 300) {
        $msg = $decoded['error']['message'] ?? ('HTTP ' . $code);
        throw new RuntimeException('LiteLLM embed error: ' . $msg);
    }

    // OpenAI /v1/embeddings returns data[].embedding ordered by index
    $data = $decoded['data'] ?? [];
    usort($data, fn($a, $b) => ($a['index'] ?? 0) <=> ($b['index'] ?? 0));
    return array_map(fn($d) => $d['embedding'], $data);
}

// ── dbn-legal-agent targeted check step ──────────────────────────────────────
//
// dbn-legal-agent is a QLoRA fine-tune trained for single-turn Q&A with a baked-in
// "Kilder:" citation scaffold. It loops when asked to check a full brief (no trained
// behavior for that task) but answers focused legal questions reliably in ~55s.
//
// Strategy: ask ONE targeted question per document type, cap tokens at 350 to cut
// off before the tool-planning loop kicks in, parse the answer for legal findings.

function dbnToolsRunLegalCheck(string $brief, string $docType): array
{
    $question = dbnToolsSelectCheckQuestion($docType, $brief);
    if ($question === null) {
        return [];
    }

    $opts = [
        'model'       => 'dbn-legal-agent-v3',
        'temperature' => 0.1,
        'max_tokens'  => 350,
        'timeout'     => 120,
        // No 'json' key — plain narrative, no response_format flag
    ];

    $sysMsg = 'Du er en ekspert på norsk barnevernsloven og EMD-praksis. Svar alltid på norsk med korrekt juridisk terminologi. Bruk terskler fra barnevernsloven 2021: § 4-25 krever «klar nødvendighet». Strand Lobben mot Norge (37283/13) setter krav om rehabiliteringsplan før adopsjon. Aldri oppfinn paragrafnumre, saksnumre eller dommernavn.';

    $text = '';
    try {
        $response = dbnToolsCallGpuLlm(
            [
                ['role' => 'system', 'content' => $sysMsg],
                ['role' => 'user', 'content' => $question],
            ],
            $opts
        );
        $text = trim((string)($response['choices'][0]['message']['content'] ?? ''));
    } catch (Throwable $e) {
        // v3 unavailable — fall back to qwen2.5:7b as safety net
    }

    // Fallback: if v3 timed out or returned empty, retry with qwen2.5:7b
    if (empty($text) || str_word_count($text) < 8) {
        try {
            $fallback = dbnToolsCallGpuLlm(
                [
                    ['role' => 'system', 'content' => $sysMsg],
                    ['role' => 'user', 'content' => $question],
                ],
                array_merge($opts, ['model' => 'qwen2.5:7b', 'timeout' => 60])
            );
            $text = trim((string)($fallback['choices'][0]['message']['content'] ?? ''));
        } catch (Throwable $e) {
            return [];
        }
    }

    if (empty($text) || str_word_count($text) < 15) {
        return [];
    }

    $clean = dbnToolsExtractCleanAnswer($text);
    if (mb_strlen($clean) < 40) {
        return [];
    }

    return [[
        'description'  => mb_substr($clean, 0, 280),
        'severity'     => dbnToolsInferCheckSeverity($clean),
        'legal_basis'  => dbnToolsExtractCheckLegalBasis($clean),
        'source_refs'  => [],
        'what_to_check'=> 'Verifiser med norsk familieretsadvokat',
        'check_model'  => 'dbn-legal-agent-v3',
    ]];
}

function dbnToolsSelectCheckQuestion(string $docType, string $brief): ?string
{
    $t = mb_strtolower($docType);
    $b = mb_strtolower($brief);

    if (str_contains($t, 'akutt') || str_contains($t, 'emergency')) {
        return 'Hva er den korrekte rettslige terskelen for midlertidig plassering utenfor hjemmet '
             . 'etter barnevernsloven § 4-25 (2021-loven)? Er det forskjell mellom § 4-6 (1992-loven) '
             . 'og § 4-25 (2021-loven), og er «klar nødvendighet» det riktige kravet?';
    }

    if (str_contains($t, 'adopsjon') || str_contains($t, 'adoption')
        || str_contains($b, '§ 5-8') || str_contains($b, '§ 4-20')) {
        return 'Hva krever Høyesterett og EMD (særlig Strand Lobben mot Norge, saksnr. 37283/13, 2019) '
             . 'for at fratakelse av foreldreansvar og adopsjon uten samtykke skal være lovlig? '
             . 'Hvilken rolle spiller dokumentert rehabiliteringsplan og biologiske familiebånd?';
    }

    if (str_contains($t, 'undersøkelse') || str_contains($t, 'investigation')
        || (str_contains($b, 'varsel') && str_contains($b, 'hjemmebesøk'))) {
        return 'Hva er konsekvensene etter forvaltningsloven § 17 (kontradiksjonsprinsippet) og '
             . '§ 41 (ugyldighet) dersom barnevernstjenesten gjennomfører hjemmebesøk uten å ha '
             . 'sendt varsel om undersøkelse i en ikke-akutt situasjon?';
    }

    // Generic procedural check for other document types
    return 'Hva er de viktigste prosessuelle kravene barnevernstjenesten må oppfylle ved '
         . 'inngripende vedtak etter barnevernsloven 2021, særlig knyttet til varsel, '
         . 'begrunnelse, forholdsmessighet og EMD-forpliktelser?';
}

function dbnToolsExtractCleanAnswer(string $text): string
{
    // Strip loop artifacts: tool-planning stubs and "Final:" repetition blocks
    $cutPatterns = ['Tool calls:', 'Tool_calls:', '"tool_calls"', 'Final:', 'Final answer:'];
    foreach ($cutPatterns as $marker) {
        $pos = mb_stripos($text, $marker);
        if ($pos !== false && $pos > 40) {
            $text = mb_substr($text, 0, $pos);
        }
    }
    // Preserve Kilder: section for legal citations but cap its length
    $srcPos = mb_strpos($text, 'Kilder:');
    if ($srcPos !== false && $srcPos > 60) {
        $text = mb_substr($text, 0, $srcPos + 180);
    }
    return trim($text);
}

function dbnToolsInferCheckSeverity(string $text): string
{
    if (preg_match('/ugyldig|§\s*41|kontradiksjon|klar nødvendighet|strand lobben|biologiske bånd/i', $text)) {
        return 'high';
    }
    if (preg_match('/prosessuell|varsel|terskel|paragrafnummer|forholdsmessig|rehabiliter/i', $text)) {
        return 'medium';
    }
    return 'low';
}

function dbnToolsExtractCheckLegalBasis(string $text): string
{
    preg_match_all('/§\s*\d[\d\-a-zA-Z]*/u', $text, $m);
    if (!empty($m[0])) {
        return implode(', ', array_unique(array_slice($m[0], 0, 3)));
    }
    return '';
}
