<?php
declare(strict_types=1);

header('Content-Type: application/json; charset=utf-8');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, Authorization, X-Admin-Token, X-Companion-Token, X-Device-Token');
@ini_set('display_errors', '0');
@ini_set('log_errors', '1');

if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'OPTIONS') {
    echo gobao_json_encode(['ok' => true]);
    exit;
}

if (!function_exists('mb_strlen')) {
    function mb_strlen(string $string, ?string $encoding = null): int {
        return strlen($string);
    }
}
if (!function_exists('mb_substr')) {
    function mb_substr(string $string, int $start, ?int $length = null, ?string $encoding = null): string {
        return $length === null ? substr($string, $start) : substr($string, $start, $length);
    }
}
if (!function_exists('mb_stripos')) {
    function mb_stripos(string $haystack, string $needle, int $offset = 0, ?string $encoding = null) {
        return stripos($haystack, $needle, $offset);
    }
}
if (!function_exists('mb_strtolower')) {
    function mb_strtolower(string $string, ?string $encoding = null): string {
        return strtolower($string);
    }
}

const GOBAO_API_VERSION = '0.11.9';

function gobao_env_path(): string {
    return __DIR__ . '/.env';
}

function gobao_memory_dir(): string {
    return __DIR__ . '/memory';
}

function gobao_data_dir(): string {
    return __DIR__ . '/data';
}

function gobao_db_path(): string {
    return gobao_data_dir() . '/gobao.sqlite';
}

function gobao_self_heal_path(): string {
    return gobao_data_dir() . '/self_heal.json';
}

function gobao_self_heal_state(): array {
    $path = gobao_self_heal_path();
    if (!is_file($path)) {
        return ['updatedAt' => null, 'components' => []];
    }
    $decoded = json_decode((string)@file_get_contents($path), true);
    return is_array($decoded)
        ? array_merge(['updatedAt' => null, 'components' => []], $decoded)
        : ['updatedAt' => null, 'components' => []];
}

function gobao_self_heal_write_state(array $state): void {
    $dir = gobao_data_dir();
    if (!is_dir($dir)) {
        @mkdir($dir, 0755, true);
    }
    $state['updatedAt'] = gmdate('c');
    $tmp = gobao_self_heal_path() . '.tmp.' . bin2hex(random_bytes(4));
    $json = json_encode($state, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);
    if ($json === false || @file_put_contents($tmp, $json, LOCK_EX) === false) {
        @unlink($tmp);
        return;
    }
    if (!@rename($tmp, gobao_self_heal_path())) {
        @unlink(gobao_self_heal_path());
        @rename($tmp, gobao_self_heal_path());
    }
}

function gobao_self_heal_fingerprint(string $error): string {
    $normalized = strtolower(trim(preg_replace('/\s+/u', ' ', $error) ?: $error));
    return substr(hash('sha256', $normalized), 0, 16);
}

function gobao_self_heal_record_failure(string $component, string $error, int $threshold = 3, int $cooldownSeconds = 25): array {
    $state = gobao_self_heal_state();
    $entry = is_array($state['components'][$component] ?? null) ? $state['components'][$component] : [];
    $fingerprint = gobao_self_heal_fingerprint($error);
    $sameFailure = ($entry['fingerprint'] ?? '') === $fingerprint;
    $count = $sameFailure ? ((int)($entry['consecutiveFailures'] ?? 0) + 1) : 1;
    $openUntil = $count >= max(1, $threshold) ? time() + max(5, $cooldownSeconds) : 0;
    $entry = [
        'status' => $openUntil > time() ? 'circuit_open' : 'degraded',
        'consecutiveFailures' => $count,
        'fingerprint' => $fingerprint,
        'lastError' => mb_substr($error, 0, 500),
        'lastFailureAt' => gmdate('c'),
        'lastSuccessAt' => $entry['lastSuccessAt'] ?? null,
        'openUntil' => $openUntil > 0 ? gmdate('c', $openUntil) : null,
        'recoveries' => (int)($entry['recoveries'] ?? 0),
    ];
    $state['components'][$component] = $entry;
    gobao_self_heal_write_state($state);
    return $entry;
}

function gobao_self_heal_record_success(string $component): array {
    $state = gobao_self_heal_state();
    $previous = is_array($state['components'][$component] ?? null) ? $state['components'][$component] : [];
    $wasDegraded = !empty($previous['consecutiveFailures']) || ($previous['status'] ?? 'healthy') !== 'healthy';
    $entry = [
        'status' => 'healthy',
        'consecutiveFailures' => 0,
        'fingerprint' => null,
        'lastError' => null,
        'lastFailureAt' => $previous['lastFailureAt'] ?? null,
        'lastSuccessAt' => gmdate('c'),
        'openUntil' => null,
        'recoveries' => (int)($previous['recoveries'] ?? 0) + ($wasDegraded ? 1 : 0),
    ];
    $state['components'][$component] = $entry;
    gobao_self_heal_write_state($state);
    return $entry;
}

function gobao_self_heal_circuit_open(string $component): bool {
    $state = gobao_self_heal_state();
    $entry = $state['components'][$component] ?? null;
    if (!is_array($entry) || empty($entry['openUntil'])) {
        return false;
    }
    $until = strtotime((string)$entry['openUntil']);
    return $until !== false && $until > time();
}

function gobao_self_heal_status(): array {
    $state = gobao_self_heal_state();
    return [
        'enabled' => true,
        'mode' => 'bounded-runtime-recovery',
        'updatedAt' => $state['updatedAt'] ?? null,
        'components' => $state['components'] ?? [],
        'allowedActions' => ['retry_transient_failure', 'temporary_circuit_breaker', 'isolate_error_memory', 'record_recovery'],
        'forbiddenActions' => ['edit_production_code_without_review', 'bypass_payment_or_verification', 'delete_user_files_without_confirmation'],
    ];
}

function gobao_is_transient_error(string $error, ?int $status = null): bool {
    if ($status !== null && in_array($status, [408, 425, 429, 500, 502, 503, 504], true)) {
        return true;
    }
    return (bool)preg_match('/(timeout|timed out|temporar|network|connection|broken pipe|reset by peer|ssl|tls|eof|rate limit|too many requests|service unavailable|bad gateway|gateway timeout|database is locked|database is busy)/iu', $error);
}

function gobao_db_last_error(?string $value = null): string {
    static $error = '';
    if ($value !== null) {
        $error = $value;
    }
    return $error;
}

function gobao_iso_time($value = null): string {
    if (is_int($value) || (is_string($value) && ctype_digit($value))) {
        $n = (int) $value;
        if ($n > 100000000000) {
            $n = (int) floor($n / 1000);
        }
        return gmdate('c', $n);
    }
    if (is_string($value) && trim($value) !== '') {
        $ts = strtotime($value);
        if ($ts !== false) {
            return gmdate('c', $ts);
        }
    }
    return gmdate('c');
}

function gobao_db(): ?PDO {
    static $pdo = null;
    static $ready = false;
    if ($ready) {
        return $pdo;
    }
    $ready = true;
    if (!class_exists('PDO')) {
        gobao_db_last_error('PDO extension is not available.');
        return null;
    }
    $dir = gobao_data_dir();
    if (!is_dir($dir) && !mkdir($dir, 0755, true)) {
        gobao_db_last_error('Cannot create data directory: ' . $dir);
        return null;
    }
    @chmod($dir, 0775);
    if (!is_writable($dir)) {
        gobao_db_last_error('Data directory is not writable: ' . $dir . '. In BaoTa, set owner to www and permission to 755 or 775.');
        return null;
    }
    $dbPath = gobao_db_path();
    if (is_file($dbPath)) {
        @chmod($dbPath, 0664);
        if (!is_writable($dbPath)) {
            gobao_db_last_error('SQLite database is not writable: ' . $dbPath . '. In BaoTa, set owner to www and permission to 664.');
            return null;
        }
    }
    try {
        $pdo = new PDO('sqlite:' . $dbPath);
        $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
        $pdo->setAttribute(PDO::ATTR_TIMEOUT, 30);
        $pdo->exec('PRAGMA foreign_keys = ON');
        $pdo->exec('PRAGMA busy_timeout = 30000');
        $pdo->exec('PRAGMA journal_mode = WAL');
        gobao_init_db($pdo);
        gobao_db_last_error('');
        return $pdo;
    } catch (Throwable $e) {
        gobao_db_last_error($e->getMessage());
        $pdo = null;
        return null;
    }
}

function gobao_db_retry(callable $callback, int $attempts = 12, int $sleepMs = 250) {
    $last = null;
    for ($i = 0; $i < max(1, $attempts); $i++) {
        try {
            return $callback();
        } catch (Throwable $e) {
            $last = $e;
            $message = strtolower($e->getMessage());
            $isBusy = str_contains($message, 'database is locked')
                || str_contains($message, 'database table is locked')
                || str_contains($message, 'database is busy')
                || str_contains($message, 'busy');
            if (!$isBusy || $i === $attempts - 1) {
                throw $e;
            }
            usleep(($sleepMs + ($i * 100)) * 1000);
        }
    }
    throw $last ?: new RuntimeException('database retry failed');
}

function gobao_init_db(PDO $db): void {
    $db->exec("
        CREATE TABLE IF NOT EXISTS users (
            user_id TEXT PRIMARY KEY,
            username TEXT NOT NULL UNIQUE,
            display_name TEXT NOT NULL,
            password_hash TEXT NOT NULL,
            status TEXT NOT NULL DEFAULT 'active',
            created_at TEXT NOT NULL,
            updated_at TEXT NOT NULL,
            last_login_at TEXT
        );
        CREATE INDEX IF NOT EXISTS idx_users_status ON users(status, username);
        CREATE TABLE IF NOT EXISTS auth_sessions (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            user_id TEXT NOT NULL,
            token_hash TEXT NOT NULL UNIQUE,
            user_agent_hash TEXT,
            expires_at TEXT NOT NULL,
            last_seen_at TEXT NOT NULL,
            created_at TEXT NOT NULL,
            revoked_at TEXT,
            FOREIGN KEY(user_id) REFERENCES users(user_id) ON DELETE CASCADE
        );
        CREATE INDEX IF NOT EXISTS idx_auth_sessions_user ON auth_sessions(user_id, expires_at);
        CREATE TABLE IF NOT EXISTS device_pairings (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            user_id TEXT NOT NULL,
            code_hash TEXT NOT NULL UNIQUE,
            device_label TEXT,
            expires_at TEXT NOT NULL,
            created_at TEXT NOT NULL,
            claimed_at TEXT,
            claimed_device_id TEXT,
            canceled_at TEXT,
            FOREIGN KEY(user_id) REFERENCES users(user_id) ON DELETE CASCADE
        );
        CREATE INDEX IF NOT EXISTS idx_device_pairings_user ON device_pairings(user_id, expires_at);
        CREATE TABLE IF NOT EXISTS sessions (
            session_id TEXT PRIMARY KEY,
            created_at TEXT NOT NULL,
            updated_at TEXT NOT NULL,
            meta_json TEXT NOT NULL DEFAULT '{}'
        );
        CREATE TABLE IF NOT EXISTS messages (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            session_id TEXT NOT NULL,
            role TEXT NOT NULL,
            content TEXT NOT NULL,
            provider TEXT,
            model TEXT,
            intent TEXT,
            meta_json TEXT NOT NULL DEFAULT '{}',
            created_at TEXT NOT NULL,
            FOREIGN KEY(session_id) REFERENCES sessions(session_id) ON DELETE CASCADE
        );
        CREATE INDEX IF NOT EXISTS idx_messages_session_id ON messages(session_id, id);
        CREATE TABLE IF NOT EXISTS learning (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            session_id TEXT NOT NULL,
            query TEXT NOT NULL,
            summary TEXT NOT NULL,
            intent TEXT,
            confidence REAL,
            action_plan_json TEXT NOT NULL DEFAULT '[]',
            sources_json TEXT NOT NULL DEFAULT '[]',
            provider TEXT,
            model TEXT,
            created_at TEXT NOT NULL,
            FOREIGN KEY(session_id) REFERENCES sessions(session_id) ON DELETE CASCADE
        );
        CREATE INDEX IF NOT EXISTS idx_learning_session_id ON learning(session_id, id);
        CREATE TABLE IF NOT EXISTS orders (
            order_no TEXT PRIMARY KEY,
            session_id TEXT NOT NULL,
            order_type TEXT,
            title TEXT,
            total_amount REAL,
            status TEXT,
            pay_method TEXT,
            delivery_location TEXT,
            payload_json TEXT NOT NULL DEFAULT '{}',
            created_at TEXT NOT NULL,
            updated_at TEXT NOT NULL
        );
        CREATE INDEX IF NOT EXISTS idx_orders_session_id ON orders(session_id, updated_at);
        CREATE TABLE IF NOT EXISTS knowledge_sources (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            source_type TEXT NOT NULL,
            repo_owner TEXT,
            repo_name TEXT,
            repo_url TEXT,
            ref TEXT,
            query TEXT,
            status TEXT NOT NULL DEFAULT 'active',
            meta_json TEXT NOT NULL DEFAULT '{}',
            last_checked_at TEXT,
            created_at TEXT NOT NULL,
            updated_at TEXT NOT NULL
        );
        CREATE INDEX IF NOT EXISTS idx_knowledge_sources_repo ON knowledge_sources(repo_owner, repo_name, ref);
        CREATE TABLE IF NOT EXISTS knowledge_chunks (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            source_id INTEGER NOT NULL,
            path TEXT NOT NULL,
            blob_sha TEXT,
            content_sha TEXT NOT NULL,
            chunk_index INTEGER NOT NULL,
            content TEXT NOT NULL,
            meta_json TEXT NOT NULL DEFAULT '{}',
            created_at TEXT NOT NULL,
            FOREIGN KEY(source_id) REFERENCES knowledge_sources(id) ON DELETE CASCADE
        );
        CREATE INDEX IF NOT EXISTS idx_knowledge_chunks_source ON knowledge_chunks(source_id, path, chunk_index);
        CREATE INDEX IF NOT EXISTS idx_knowledge_chunks_sha ON knowledge_chunks(content_sha);
        CREATE TABLE IF NOT EXISTS knowledge_checks (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            source_id INTEGER NOT NULL,
            check_type TEXT NOT NULL,
            status TEXT NOT NULL,
            detail_json TEXT NOT NULL DEFAULT '{}',
            created_at TEXT NOT NULL,
            FOREIGN KEY(source_id) REFERENCES knowledge_sources(id) ON DELETE CASCADE
        );
        CREATE INDEX IF NOT EXISTS idx_knowledge_checks_source ON knowledge_checks(source_id, created_at);
        CREATE TABLE IF NOT EXISTS tool_runs (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            session_id TEXT NOT NULL,
            tool_name TEXT NOT NULL,
            title TEXT NOT NULL,
            status TEXT NOT NULL,
            requires_approval INTEGER NOT NULL DEFAULT 0,
            args_json TEXT NOT NULL DEFAULT '{}',
            result_json TEXT NOT NULL DEFAULT '{}',
            error TEXT,
            requested_by TEXT,
            approved_by TEXT,
            created_at TEXT NOT NULL,
            approved_at TEXT,
            started_at TEXT,
            finished_at TEXT
        );
        CREATE INDEX IF NOT EXISTS idx_tool_runs_status ON tool_runs(status, created_at);
        CREATE INDEX IF NOT EXISTS idx_tool_runs_session ON tool_runs(session_id, created_at);
        CREATE TABLE IF NOT EXISTS profile_claims (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            user_id TEXT NOT NULL,
            category TEXT NOT NULL,
            claim_key TEXT NOT NULL,
            claim_value TEXT NOT NULL,
            confidence REAL NOT NULL DEFAULT 0.6,
            source_type TEXT NOT NULL DEFAULT 'chat',
            source_ref TEXT,
            evidence TEXT,
            status TEXT NOT NULL DEFAULT 'active',
            created_at TEXT NOT NULL,
            updated_at TEXT NOT NULL
        );
        CREATE INDEX IF NOT EXISTS idx_profile_claims_user ON profile_claims(user_id, status, category);
        CREATE INDEX IF NOT EXISTS idx_profile_claims_key ON profile_claims(user_id, category, claim_key);
        CREATE TABLE IF NOT EXISTS audit_events (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            event_type TEXT NOT NULL,
            session_id TEXT,
            actor TEXT,
            status TEXT NOT NULL DEFAULT 'ok',
            ref_type TEXT,
            ref_id TEXT,
            title TEXT,
            payload_json TEXT NOT NULL DEFAULT '{}',
            token_input INTEGER NOT NULL DEFAULT 0,
            token_output INTEGER NOT NULL DEFAULT 0,
            latency_ms INTEGER NOT NULL DEFAULT 0,
            retry_count INTEGER NOT NULL DEFAULT 0,
            error TEXT,
            created_at TEXT NOT NULL
        );
        CREATE INDEX IF NOT EXISTS idx_audit_events_type ON audit_events(event_type, created_at);
        CREATE INDEX IF NOT EXISTS idx_audit_events_session ON audit_events(session_id, created_at);
        CREATE TABLE IF NOT EXISTS custom_tools (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            name TEXT NOT NULL UNIQUE,
            title TEXT NOT NULL,
            description TEXT,
            method TEXT NOT NULL DEFAULT 'POST',
            endpoint_url TEXT NOT NULL,
            headers_json TEXT NOT NULL DEFAULT '{}',
            body_template TEXT NOT NULL DEFAULT '{}',
            requires_approval INTEGER NOT NULL DEFAULT 1,
            timeout_seconds INTEGER NOT NULL DEFAULT 30,
            status TEXT NOT NULL DEFAULT 'active',
            created_at TEXT NOT NULL,
            updated_at TEXT NOT NULL
        );
        CREATE INDEX IF NOT EXISTS idx_custom_tools_status ON custom_tools(status, name);
        CREATE TABLE IF NOT EXISTS user_kb_settings (
            user_id TEXT PRIMARY KEY,
            enabled INTEGER NOT NULL DEFAULT 0,
            write_allowed INTEGER NOT NULL DEFAULT 0,
            use_for_answer INTEGER NOT NULL DEFAULT 0,
            sync_server INTEGER NOT NULL DEFAULT 0,
            local_mode TEXT NOT NULL DEFAULT 'browser',
            settings_json TEXT NOT NULL DEFAULT '{}',
            created_at TEXT NOT NULL,
            updated_at TEXT NOT NULL
        );
        CREATE TABLE IF NOT EXISTS local_devices (
            device_id TEXT PRIMARY KEY,
            user_id TEXT NOT NULL,
            label TEXT,
            root_path TEXT,
            capabilities_json TEXT NOT NULL DEFAULT '{}',
            status TEXT NOT NULL DEFAULT 'active',
            last_seen_at TEXT,
            created_at TEXT NOT NULL,
            updated_at TEXT NOT NULL
        );
        CREATE INDEX IF NOT EXISTS idx_local_devices_user ON local_devices(user_id, status, updated_at);
        CREATE TABLE IF NOT EXISTS local_vault_syncs (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            device_id TEXT NOT NULL,
            user_id TEXT NOT NULL,
            vault_name TEXT NOT NULL,
            root_path TEXT,
            source_id INTEGER,
            files_count INTEGER NOT NULL DEFAULT 0,
            chunks_count INTEGER NOT NULL DEFAULT 0,
            status TEXT NOT NULL DEFAULT 'completed',
            detail_json TEXT NOT NULL DEFAULT '{}',
            created_at TEXT NOT NULL
        );
        CREATE INDEX IF NOT EXISTS idx_local_vault_syncs_user ON local_vault_syncs(user_id, created_at);
        CREATE INDEX IF NOT EXISTS idx_local_vault_syncs_device ON local_vault_syncs(device_id, vault_name, created_at);
        CREATE TABLE IF NOT EXISTS agent_tasks (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            user_id TEXT NOT NULL,
            session_id TEXT,
            title TEXT NOT NULL,
            goal TEXT NOT NULL,
            status TEXT NOT NULL DEFAULT 'planned',
            priority INTEGER NOT NULL DEFAULT 3,
            risk_level TEXT NOT NULL DEFAULT 'normal',
            source TEXT NOT NULL DEFAULT 'chat',
            plan_json TEXT NOT NULL DEFAULT '[]',
            evidence_json TEXT NOT NULL DEFAULT '[]',
            metrics_json TEXT NOT NULL DEFAULT '{}',
            verification_json TEXT NOT NULL DEFAULT '{}',
            rollback_json TEXT NOT NULL DEFAULT '{}',
            created_at TEXT NOT NULL,
            updated_at TEXT NOT NULL,
            completed_at TEXT,
            canceled_at TEXT
        );
        CREATE INDEX IF NOT EXISTS idx_agent_tasks_user ON agent_tasks(user_id, status, updated_at);
        CREATE TABLE IF NOT EXISTS agent_task_steps (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            task_id INTEGER NOT NULL,
            step_index INTEGER NOT NULL,
            title TEXT NOT NULL,
            status TEXT NOT NULL DEFAULT 'pending',
            tool_name TEXT,
            input_json TEXT NOT NULL DEFAULT '{}',
            output_json TEXT NOT NULL DEFAULT '{}',
            error TEXT,
            started_at TEXT,
            finished_at TEXT,
            created_at TEXT NOT NULL,
            updated_at TEXT NOT NULL,
            FOREIGN KEY(task_id) REFERENCES agent_tasks(id) ON DELETE CASCADE
        );
        CREATE INDEX IF NOT EXISTS idx_agent_task_steps_task ON agent_task_steps(task_id, step_index);
    ");
    gobao_ensure_column($db, 'local_devices', 'device_token_hash', 'TEXT');
    gobao_ensure_column($db, 'local_devices', 'paired_at', 'TEXT');
    gobao_ensure_column($db, 'local_devices', 'revoked_at', 'TEXT');
    gobao_ensure_column($db, 'local_devices', 'token_last_used_at', 'TEXT');
}

function gobao_ensure_column(PDO $db, string $table, string $column, string $definition): void {
    $columns = $db->query("PRAGMA table_info({$table})")->fetchAll(PDO::FETCH_ASSOC) ?: [];
    foreach ($columns as $item) {
        if ((string)($item['name'] ?? '') === $column) {
            return;
        }
    }
    $db->exec("ALTER TABLE {$table} ADD COLUMN {$column} {$definition}");
}

function gobao_db_counts(): array {
    $db = gobao_db();
    if (!$db) {
        return ['enabled' => false, 'path' => gobao_db_path(), 'error' => gobao_db_last_error()];
    }
    $counts = ['enabled' => true, 'path' => gobao_db_path()];
    foreach (['users', 'auth_sessions', 'device_pairings', 'sessions', 'messages', 'learning', 'orders', 'knowledge_sources', 'knowledge_chunks', 'knowledge_checks', 'tool_runs', 'profile_claims', 'audit_events', 'custom_tools', 'user_kb_settings', 'local_devices', 'local_vault_syncs', 'agent_tasks', 'agent_task_steps'] as $table) {
        try {
            $counts[$table] = (int) $db->query("SELECT COUNT(*) FROM {$table}")->fetchColumn();
        } catch (Throwable $e) {
            $counts[$table] = null;
        }
    }
    return $counts;
}

function gobao_json_flags(): int {
    $flags = JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES;
    if (defined('JSON_INVALID_UTF8_SUBSTITUTE')) {
        $flags |= JSON_INVALID_UTF8_SUBSTITUTE;
    }
    if (defined('JSON_PARTIAL_OUTPUT_ON_ERROR')) {
        $flags |= JSON_PARTIAL_OUTPUT_ON_ERROR;
    }
    return $flags;
}

function gobao_json_encode(array $value): string {
    $json = json_encode($value, gobao_json_flags());
    if ($json === false || $json === '') {
        $fallback = [
            'ok' => false,
            'error' => 'json_encode_failed: ' . (function_exists('json_last_error_msg') ? json_last_error_msg() : 'unknown'),
        ];
        $json = json_encode($fallback, JSON_UNESCAPED_SLASHES);
    }
    return $json ?: '{"ok":false,"error":"json_encode_failed"}';
}

function gobao_redact_sensitive(string $text): string {
    $text = preg_replace('/sk-[A-Za-z0-9_-]{8,}/', 'sk-***REDACTED***', $text) ?? $text;
    $text = preg_replace('/(api[_\s-]*key|secret|token|password|passwd|密码|密钥|令牌)\s*[:=：]\s*["\']?[^"\'\s,，。]+/iu', '$1=***REDACTED***', $text) ?? $text;
    return $text;
}

function gobao_estimate_tokens(string $text): int {
    $text = trim($text);
    if ($text === '') {
        return 0;
    }
    $chars = mb_strlen($text, 'UTF-8');
    return max(1, (int)ceil($chars / 1.7));
}

function gobao_audit_record(string $eventType, array $event = []): ?array {
    $db = gobao_db();
    if (!$db) {
        return null;
    }
    $payload = $event['payload'] ?? [];
    if (!is_array($payload)) {
        $payload = ['value' => (string)$payload];
    }
    array_walk_recursive($payload, static function (&$value): void {
        if (is_string($value)) {
            $value = gobao_redact_sensitive($value);
        }
    });
    $now = gmdate('c');
    return gobao_db_retry(function () use ($db, $eventType, $event, $payload, $now): ?array {
        $stmt = $db->prepare('
            INSERT INTO audit_events(event_type, session_id, actor, status, ref_type, ref_id, title, payload_json, token_input, token_output, latency_ms, retry_count, error, created_at)
            VALUES(:event_type, :session_id, :actor, :status, :ref_type, :ref_id, :title, :payload_json, :token_input, :token_output, :latency_ms, :retry_count, :error, :created_at)
        ');
        $stmt->execute([
            ':event_type' => $eventType,
            ':session_id' => isset($event['sessionId']) ? gobao_safe_session_id((string)$event['sessionId']) : null,
            ':actor' => (string)($event['actor'] ?? 'system'),
            ':status' => (string)($event['status'] ?? 'ok'),
            ':ref_type' => isset($event['refType']) ? (string)$event['refType'] : null,
            ':ref_id' => isset($event['refId']) ? (string)$event['refId'] : null,
            ':title' => isset($event['title']) ? gobao_redact_sensitive((string)$event['title']) : null,
            ':payload_json' => gobao_json_encode($payload),
            ':token_input' => max(0, (int)($event['tokenInput'] ?? 0)),
            ':token_output' => max(0, (int)($event['tokenOutput'] ?? 0)),
            ':latency_ms' => max(0, (int)($event['latencyMs'] ?? 0)),
            ':retry_count' => max(0, (int)($event['retryCount'] ?? 0)),
            ':error' => isset($event['error']) ? gobao_redact_sensitive((string)$event['error']) : null,
            ':created_at' => $now,
        ]);
        return gobao_audit_get((int)$db->lastInsertId());
    });
}

function gobao_audit_from_row(array $row): array {
    $payload = json_decode((string)($row['payload_json'] ?? '{}'), true);
    return [
        'id' => (int)$row['id'],
        'eventType' => (string)$row['event_type'],
        'sessionId' => $row['session_id'],
        'actor' => $row['actor'],
        'status' => (string)$row['status'],
        'refType' => $row['ref_type'],
        'refId' => $row['ref_id'],
        'title' => $row['title'],
        'payload' => is_array($payload) ? $payload : [],
        'tokenInput' => (int)$row['token_input'],
        'tokenOutput' => (int)$row['token_output'],
        'latencyMs' => (int)$row['latency_ms'],
        'retryCount' => (int)$row['retry_count'],
        'error' => $row['error'],
        'createdAt' => $row['created_at'],
    ];
}

function gobao_audit_get(int $id): ?array {
    $db = gobao_db();
    if (!$db || $id <= 0) {
        return null;
    }
    $stmt = $db->prepare('SELECT * FROM audit_events WHERE id = :id');
    $stmt->execute([':id' => $id]);
    $row = $stmt->fetch(PDO::FETCH_ASSOC);
    return $row ? gobao_audit_from_row($row) : null;
}

function gobao_audit_recent(int $limit = 80, string $eventType = '', string $sessionId = ''): array {
    $db = gobao_db();
    if (!$db) {
        return [];
    }
    $limit = max(1, min(300, $limit));
    $where = [];
    $params = [];
    if ($eventType !== '') {
        $where[] = 'event_type = :event_type';
        $params[':event_type'] = $eventType;
    }
    if ($sessionId !== '') {
        $where[] = 'session_id = :session_id';
        $params[':session_id'] = gobao_safe_session_id($sessionId);
    }
    $sql = 'SELECT * FROM audit_events' . ($where ? ' WHERE ' . implode(' AND ', $where) : '') . ' ORDER BY id DESC LIMIT :limit';
    $stmt = $db->prepare($sql);
    foreach ($params as $key => $value) {
        $stmt->bindValue($key, $value, PDO::PARAM_STR);
    }
    $stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
    $stmt->execute();
    return array_map('gobao_audit_from_row', $stmt->fetchAll(PDO::FETCH_ASSOC) ?: []);
}

function gobao_profile_claim_from_row(array $row): array {
    return [
        'id' => (int)$row['id'],
        'userId' => (string)$row['user_id'],
        'category' => (string)$row['category'],
        'key' => (string)$row['claim_key'],
        'value' => (string)$row['claim_value'],
        'confidence' => (float)$row['confidence'],
        'sourceType' => (string)$row['source_type'],
        'sourceRef' => $row['source_ref'],
        'evidence' => $row['evidence'],
        'status' => (string)$row['status'],
        'createdAt' => $row['created_at'],
        'updatedAt' => $row['updated_at'],
    ];
}

function gobao_profile_add_claim(string $userId, string $category, string $key, string $value, array $meta = []): array {
    $db = gobao_db();
    if (!$db) {
        gobao_fail('Database is not available: ' . gobao_db_last_error(), 500);
    }
    $userId = gobao_safe_session_id($userId ?: 'main');
    $category = preg_replace('/[^a-zA-Z0-9_.-]/', '_', trim($category)) ?: 'general';
    $key = trim($key) !== '' ? trim($key) : 'note';
    $value = trim(gobao_redact_sensitive($value));
    if ($value === '') {
        gobao_fail('claim value is required', 400);
    }
    $now = gmdate('c');
    $stmt = $db->prepare('
        INSERT INTO profile_claims(user_id, category, claim_key, claim_value, confidence, source_type, source_ref, evidence, status, created_at, updated_at)
        VALUES(:user_id, :category, :claim_key, :claim_value, :confidence, :source_type, :source_ref, :evidence, :status, :created_at, :updated_at)
    ');
    $stmt->execute([
        ':user_id' => $userId,
        ':category' => $category,
        ':claim_key' => mb_substr($key, 0, 120),
        ':claim_value' => mb_substr($value, 0, 2000),
        ':confidence' => max(0.0, min(1.0, (float)($meta['confidence'] ?? 0.65))),
        ':source_type' => (string)($meta['sourceType'] ?? 'chat'),
        ':source_ref' => isset($meta['sourceRef']) ? (string)$meta['sourceRef'] : null,
        ':evidence' => isset($meta['evidence']) ? mb_substr(gobao_redact_sensitive((string)$meta['evidence']), 0, 2000) : null,
        ':status' => 'active',
        ':created_at' => $now,
        ':updated_at' => $now,
    ]);
    $claim = gobao_profile_get_claim((int)$db->lastInsertId());
    gobao_audit_record('profile_claim_added', [
        'sessionId' => $userId,
        'actor' => (string)($meta['actor'] ?? 'system'),
        'refType' => 'profile_claim',
        'refId' => (string)($claim['id'] ?? ''),
        'title' => $category . ':' . $key,
        'payload' => ['category' => $category, 'key' => $key, 'value' => $value, 'sourceType' => $meta['sourceType'] ?? 'chat'],
    ]);
    return $claim ?: [];
}

function gobao_profile_get_claim(int $id): ?array {
    $db = gobao_db();
    if (!$db || $id <= 0) {
        return null;
    }
    $stmt = $db->prepare('SELECT * FROM profile_claims WHERE id = :id');
    $stmt->execute([':id' => $id]);
    $row = $stmt->fetch(PDO::FETCH_ASSOC);
    return $row ? gobao_profile_claim_from_row($row) : null;
}

function gobao_profile_claims(string $userId, string $status = 'active'): array {
    $db = gobao_db();
    if (!$db) {
        return [];
    }
    $stmt = $db->prepare('SELECT * FROM profile_claims WHERE user_id = :user_id AND status = :status ORDER BY category, id DESC');
    $stmt->execute([
        ':user_id' => gobao_safe_session_id($userId ?: 'main'),
        ':status' => $status,
    ]);
    return array_map('gobao_profile_claim_from_row', $stmt->fetchAll(PDO::FETCH_ASSOC) ?: []);
}

function gobao_profile_summary(string $userId): array {
    $claims = gobao_profile_claims($userId);
    $grouped = [];
    foreach ($claims as $claim) {
        $grouped[$claim['category']][] = $claim;
    }
    return [
        'userId' => gobao_safe_session_id($userId ?: 'main'),
        'claims' => $claims,
        'grouped' => $grouped,
        'count' => count($claims),
    ];
}

function gobao_profile_delete_claim(int $id, string $actor = 'user'): array {
    $claim = gobao_profile_get_claim($id);
    if (!$claim) {
        gobao_fail('Profile claim not found', 404);
    }
    $db = gobao_db();
    $stmt = $db->prepare('UPDATE profile_claims SET status = :status, updated_at = :updated_at WHERE id = :id');
    $stmt->execute([
        ':status' => 'deleted',
        ':updated_at' => gmdate('c'),
        ':id' => $id,
    ]);
    gobao_audit_record('profile_claim_deleted', [
        'sessionId' => $claim['userId'],
        'actor' => $actor,
        'refType' => 'profile_claim',
        'refId' => (string)$id,
        'title' => $claim['category'] . ':' . $claim['key'],
        'payload' => $claim,
    ]);
    return gobao_profile_get_claim($id) ?: [];
}

function gobao_user_kb_default_settings(string $userId): array {
    return [
        'userId' => gobao_safe_session_id($userId ?: 'main'),
        'enabled' => false,
        'writeAllowed' => false,
        'useForAnswer' => false,
        'syncServer' => false,
        'localMode' => 'browser',
        'settings' => [
            'createdBy' => 'GOBAO personal knowledge base',
            'consentVersion' => 'personal-kb-v1',
        ],
        'createdAt' => null,
        'updatedAt' => null,
    ];
}

function gobao_user_kb_settings(string $userId): array {
    $safeUserId = gobao_safe_session_id($userId ?: 'main');
    $db = gobao_db();
    if (!$db) {
        return gobao_user_kb_default_settings($safeUserId);
    }
    $stmt = $db->prepare('SELECT * FROM user_kb_settings WHERE user_id = :user_id');
    $stmt->execute([':user_id' => $safeUserId]);
    $row = $stmt->fetch(PDO::FETCH_ASSOC);
    if (!$row) {
        return gobao_user_kb_default_settings($safeUserId);
    }
    $settings = json_decode((string)($row['settings_json'] ?? '{}'), true);
    if (!is_array($settings)) {
        $settings = [];
    }
    return [
        'userId' => $safeUserId,
        'enabled' => ((int)$row['enabled']) === 1,
        'writeAllowed' => ((int)$row['write_allowed']) === 1,
        'useForAnswer' => ((int)$row['use_for_answer']) === 1,
        'syncServer' => ((int)$row['sync_server']) === 1,
        'localMode' => (string)($row['local_mode'] ?? 'browser'),
        'settings' => $settings,
        'createdAt' => $row['created_at'] ?? null,
        'updatedAt' => $row['updated_at'] ?? null,
    ];
}

function gobao_user_kb_update_settings(string $userId, array $patch, string $actor = 'user'): array {
    $safeUserId = gobao_safe_session_id($userId ?: 'main');
    $db = gobao_db();
    if (!$db) {
        gobao_fail('Database is not available: ' . gobao_db_last_error(), 500);
    }
    $current = gobao_user_kb_settings($safeUserId);
    $enabled = array_key_exists('enabled', $patch) ? (bool)$patch['enabled'] : (bool)$current['enabled'];
    $writeAllowed = array_key_exists('writeAllowed', $patch) ? (bool)$patch['writeAllowed'] : (bool)$current['writeAllowed'];
    $useForAnswer = array_key_exists('useForAnswer', $patch) ? (bool)$patch['useForAnswer'] : (bool)$current['useForAnswer'];
    $syncServer = array_key_exists('syncServer', $patch) ? (bool)$patch['syncServer'] : (bool)$current['syncServer'];
    $localMode = preg_replace('/[^a-zA-Z0-9_.-]/', '_', (string)($patch['localMode'] ?? $current['localMode'] ?? 'browser')) ?: 'browser';
    $settings = is_array($current['settings'] ?? null) ? $current['settings'] : [];
    if (isset($patch['settings']) && is_array($patch['settings'])) {
        $settings = array_merge($settings, $patch['settings']);
    }
    $now = gmdate('c');
    $createdAt = $current['createdAt'] ?: $now;
    $stmt = $db->prepare('
        INSERT INTO user_kb_settings(user_id, enabled, write_allowed, use_for_answer, sync_server, local_mode, settings_json, created_at, updated_at)
        VALUES(:user_id, :enabled, :write_allowed, :use_for_answer, :sync_server, :local_mode, :settings_json, :created_at, :updated_at)
        ON CONFLICT(user_id) DO UPDATE SET
            enabled = excluded.enabled,
            write_allowed = excluded.write_allowed,
            use_for_answer = excluded.use_for_answer,
            sync_server = excluded.sync_server,
            local_mode = excluded.local_mode,
            settings_json = excluded.settings_json,
            updated_at = excluded.updated_at
    ');
    $stmt->execute([
        ':user_id' => $safeUserId,
        ':enabled' => $enabled ? 1 : 0,
        ':write_allowed' => $writeAllowed ? 1 : 0,
        ':use_for_answer' => $useForAnswer ? 1 : 0,
        ':sync_server' => $syncServer ? 1 : 0,
        ':local_mode' => $localMode,
        ':settings_json' => gobao_json_encode($settings),
        ':created_at' => $createdAt,
        ':updated_at' => $now,
    ]);
    $next = gobao_user_kb_settings($safeUserId);
    gobao_audit_record('user_kb_settings_updated', [
        'sessionId' => $safeUserId,
        'actor' => $actor,
        'refType' => 'user_kb',
        'refId' => $safeUserId,
        'title' => 'personal knowledge base settings',
        'payload' => $next,
    ]);
    return $next;
}

function gobao_user_kb_can_write(string $userId): bool {
    $settings = gobao_user_kb_settings($userId);
    return !empty($settings['enabled']) && !empty($settings['writeAllowed']);
}

function gobao_user_kb_can_answer(string $userId): bool {
    $settings = gobao_user_kb_settings($userId);
    return !empty($settings['enabled']) && !empty($settings['useForAnswer']);
}

function gobao_user_kb_claim_lines(array $claims): array {
    $labels = [
        'preference' => '偏好',
        'work' => '工作与项目',
        'strength' => '擅长',
        'weakness' => '短板',
        'relationship' => '关系',
        'project' => '项目',
        'tool' => '工具',
        'general' => '其他',
    ];
    $grouped = [];
    foreach ($claims as $claim) {
        $cat = (string)($claim['category'] ?? 'general');
        $grouped[$cat][] = '- ' . (string)($claim['key'] ?? 'note') . ': ' . (string)($claim['value'] ?? '');
    }
    $lines = [];
    foreach ($grouped as $cat => $items) {
        $lines[] = '## ' . ($labels[$cat] ?? $cat);
        $lines = array_merge($lines, $items, ['']);
    }
    return $lines;
}

function gobao_user_kb_markdown(string $userId, array $extraDocs = []): string {
    $safeUserId = gobao_safe_session_id($userId ?: 'main');
    $settings = gobao_user_kb_settings($safeUserId);
    $profile = gobao_profile_summary($safeUserId);
    $lines = [
        '---',
        'type: gobao_user_profile',
        'user_id: ' . gobao_obsidian_yaml($safeUserId),
        'enabled: ' . (!empty($settings['enabled']) ? 'true' : 'false'),
        'write_allowed: ' . (!empty($settings['writeAllowed']) ? 'true' : 'false'),
        'use_for_answer: ' . (!empty($settings['useForAnswer']) ? 'true' : 'false'),
        'sync_server: ' . (!empty($settings['syncServer']) ? 'true' : 'false'),
        'updated_at: ' . gobao_obsidian_yaml(gmdate('c')),
        '---',
        '',
        '# GOBAO Personal Knowledge Base - ' . $safeUserId,
        '',
        '> 这份资料只在用户授权后写入。用户可以随时暂停、导出、删除。',
        '',
        '## 权限状态',
        '- 允许建立个人知识库: ' . (!empty($settings['enabled']) ? '是' : '否'),
        '- 允许继续写入: ' . (!empty($settings['writeAllowed']) ? '是' : '否'),
        '- 允许用于回答: ' . (!empty($settings['useForAnswer']) ? '是' : '否'),
        '- 允许同步服务器: ' . (!empty($settings['syncServer']) ? '是' : '否'),
        '',
    ];
    $claimLines = gobao_user_kb_claim_lines($profile['claims'] ?? []);
    if ($claimLines) {
        $lines = array_merge($lines, $claimLines);
    } else {
        $lines[] = '## 当前资料';
        $lines[] = '- 暂无已授权写入的用户资料。';
        $lines[] = '';
    }
    foreach ($extraDocs as $name => $content) {
        $name = trim((string)$name);
        $content = trim(gobao_redact_sensitive((string)$content));
        if ($name === '' || $content === '') {
            continue;
        }
        $lines[] = '## ' . $name;
        $lines[] = $content;
        $lines[] = '';
    }
    return implode("\n", $lines);
}

function gobao_user_kb_server_dir(string $userId, array $env): string {
    return rtrim(gobao_obsidian_knowledge_dir($env), '/\\') . '/users/' . gobao_obsidian_slug(gobao_safe_session_id($userId ?: 'main'));
}

function gobao_user_kb_write_server(string $userId, array $env, array $extraDocs = []): array {
    $safeUserId = gobao_safe_session_id($userId ?: 'main');
    $settings = gobao_user_kb_settings($safeUserId);
    if (empty($settings['enabled']) || empty($settings['syncServer'])) {
        return ['ok' => true, 'written' => false, 'reason' => 'sync_disabled', 'settings' => $settings];
    }
    $dir = gobao_user_kb_server_dir($safeUserId, $env);
    if (!is_dir($dir) && !@mkdir($dir, 0775, true) && !is_dir($dir)) {
        gobao_fail('Cannot create user knowledge directory: ' . $dir, 500);
    }
    if (!is_writable($dir)) {
        gobao_fail('User knowledge directory is not writable: ' . $dir, 500);
    }
    $profilePath = $dir . '/profile.md';
    $settingsPath = $dir . '/settings.json';
    $auditPath = $dir . '/audit.jsonl';
    $markdown = gobao_user_kb_markdown($safeUserId, $extraDocs);
    file_put_contents($profilePath, $markdown);
    file_put_contents($settingsPath, gobao_json_encode($settings));
    file_put_contents($auditPath, gobao_json_encode([
        'event' => 'sync',
        'userId' => $safeUserId,
        'at' => gmdate('c'),
        'claims' => count(gobao_profile_claims($safeUserId)),
    ]) . "\n", FILE_APPEND);
    gobao_audit_record('user_kb_synced', [
        'sessionId' => $safeUserId,
        'actor' => 'user-kb-api',
        'refType' => 'user_kb',
        'refId' => $safeUserId,
        'title' => 'sync personal knowledge base',
        'payload' => ['dir' => $dir, 'profilePath' => $profilePath],
    ]);
    return ['ok' => true, 'written' => true, 'dir' => $dir, 'profilePath' => $profilePath, 'settingsPath' => $settingsPath];
}

function gobao_user_kb_delete_server(string $userId, array $env): array {
    $safeUserId = gobao_safe_session_id($userId ?: 'main');
    $dir = gobao_user_kb_server_dir($safeUserId, $env);
    $deleted = [];
    foreach (['profile.md', 'settings.json', 'audit.jsonl'] as $file) {
        $path = $dir . '/' . $file;
        if (is_file($path) && @unlink($path)) {
            $deleted[] = $path;
        }
    }
    return ['dir' => $dir, 'deleted' => $deleted];
}

function gobao_profile_extract_claims(string $userId, string $text, array $meta = []): array {
    $patterns = [
        ['category' => 'preference', 'key' => 'likes', 'pattern' => '/(?:我|用户)?(?:喜欢|爱好是|偏好)\s*[:：]?\s*([^。；;\n]+)/u'],
        ['category' => 'preference', 'key' => 'dislikes', 'pattern' => '/(?:我|用户)?(?:讨厌|不喜欢|反感)\s*[:：]?\s*([^。；;\n]+)/u'],
        ['category' => 'work', 'key' => 'job', 'pattern' => '/(?:我|用户)?(?:工作是|职业是|职位是|在做)\s*[:：]?\s*([^。；;\n]+)/u'],
        ['category' => 'strength', 'key' => 'good_at', 'pattern' => '/(?:我|用户)?(?:擅长|强项是|拿手)\s*[:：]?\s*([^。；;\n]+)/u'],
        ['category' => 'weakness', 'key' => 'weakness', 'pattern' => '/(?:我|用户)?(?:缺点是|不擅长|短板是)\s*[:：]?\s*([^。；;\n]+)/u'],
        ['category' => 'relationship', 'key' => 'partner', 'pattern' => '/(?:情侣|对象|伴侣|女朋友|男朋友)\s*[:：是]?\s*([^。；;\n]+)/u'],
        ['category' => 'relationship', 'key' => 'ex_partner', 'pattern' => '/(?:前任|曾经的情侣|以前的对象)\s*[:：是]?\s*([^。；;\n]+)/u'],
        ['category' => 'relationship', 'key' => 'brother', 'pattern' => '/(?:兄弟|游戏兄弟|队友)\s*[:：是]?\s*([^。；;\n]+)/u'],
        ['category' => 'project', 'key' => 'current_project', 'pattern' => '/(?:现在在做|当前项目|正在做的项目)\s*[:：是]?\s*([^。；;\n]+)/u'],
        ['category' => 'project', 'key' => 'project_done', 'pattern' => '/(?:做了|完成了|参与了)\s*([^。；;\n]+?)(?:项目|功能|任务)/u'],
    ];
    $created = [];
    foreach ($patterns as $rule) {
        if (preg_match_all($rule['pattern'], $text, $matches)) {
            foreach ($matches[1] as $value) {
                $value = trim((string)$value);
                if ($value === '' || mb_strlen($value, 'UTF-8') > 240) {
                    continue;
                }
                $created[] = gobao_profile_add_claim($userId, $rule['category'], $rule['key'], $value, [
                    'confidence' => (float)($meta['confidence'] ?? 0.62),
                    'sourceType' => (string)($meta['sourceType'] ?? 'chat_extract'),
                    'sourceRef' => (string)($meta['sourceRef'] ?? ''),
                    'evidence' => $text,
                    'actor' => (string)($meta['actor'] ?? 'auto-profile'),
                ]);
            }
        }
    }
    return $created;
}

function gobao_obsidian_root(array $env): string {
    $configured = trim((string)($env['OBSIDIAN_VAULT_PATH'] ?? ''));
    if ($configured === '') {
        $configured = gobao_memory_dir() . '/obsidian';
    }
    $configured = str_replace('\\', '/', $configured);
    if (!preg_match('~^(?:[A-Za-z]:/|/)~', $configured)) {
        $configured = gobao_memory_dir() . '/' . ltrim($configured, '/');
    }
    return rtrim($configured, '/');
}

function gobao_obsidian_knowledge_dir(array $env): string {
    $name = trim((string)($env['OBSIDIAN_KNOWLEDGE_DIR'] ?? 'GOBAO-Knowledge'));
    if ($name === '') {
        $name = 'GOBAO-Knowledge';
    }
    $name = trim(str_replace(['..', '\\'], ['', '/'], $name), '/');
    return gobao_obsidian_root($env) . '/' . $name;
}

function gobao_github_file_archive_enabled(array $env): bool {
    $value = strtolower(trim((string)($env['GITHUB_FILE_ARCHIVE_ENABLED'] ?? 'true')));
    return !in_array($value, ['0', 'false', 'off', 'no'], true);
}

function gobao_github_file_archive_root(array $env): string {
    $configured = trim((string)($env['GITHUB_FILE_ARCHIVE_DIR'] ?? '_github_files'));
    if ($configured === '') {
        $configured = '_github_files';
    }
    $configured = str_replace('\\', '/', $configured);
    if (preg_match('~^(?:[A-Za-z]:/|/)~', $configured)) {
        return rtrim($configured, '/');
    }
    $configured = trim(str_replace('..', '', $configured), '/');
    return gobao_obsidian_knowledge_dir($env) . '/' . $configured;
}

function gobao_safe_archive_segment(string $value): string {
    $value = trim($value);
    $value = preg_replace('~[\\\\/:*?"<>|\x00-\x1F]+~u', '-', $value) ?: 'item';
    $value = preg_replace('~-+~', '-', $value) ?: 'item';
    return trim($value, '- .') ?: 'item';
}

function gobao_safe_archive_relative_path(string $path): string {
    $path = str_replace('\\', '/', trim($path));
    $parts = [];
    foreach (explode('/', $path) as $part) {
        $part = trim($part);
        if ($part === '' || $part === '.' || $part === '..') {
            continue;
        }
        $parts[] = gobao_safe_archive_segment($part);
    }
    return implode('/', $parts) ?: 'file.txt';
}

function gobao_write_github_file_archive(string $owner, string $repo, int $sourceId, string $ref, string $path, string $content, array $env): ?array {
    if (!gobao_github_file_archive_enabled($env)) {
        return null;
    }
    $root = gobao_github_file_archive_root($env);
    $repoDir = gobao_safe_archive_segment($owner . '-' . $repo);
    $sourceDir = 'source-' . $sourceId . '-' . gobao_safe_archive_segment($ref !== '' ? $ref : 'default');
    $relative = gobao_safe_archive_relative_path($path);
    $target = $root . '/' . $repoDir . '/' . $sourceDir . '/' . $relative;
    $dir = dirname($target);
    if (!is_dir($dir) && !@mkdir($dir, 0775, true) && !is_dir($dir)) {
        throw new RuntimeException('Cannot create GitHub file archive directory: ' . $dir);
    }
    $bytes = file_put_contents($target, $content);
    if ($bytes === false) {
        throw new RuntimeException('Cannot write GitHub file archive: ' . $target);
    }
    return [
        'path' => $target,
        'relativePath' => $repoDir . '/' . $sourceDir . '/' . $relative,
        'bytes' => $bytes,
    ];
}

function gobao_obsidian_enabled(array $env): bool {
    $value = strtolower(trim((string)($env['OBSIDIAN_SYNC_ENABLED'] ?? 'true')));
    return !in_array($value, ['0', 'false', 'off', 'no'], true);
}

function gobao_obsidian_status(array $env): array {
    $root = gobao_obsidian_root($env);
    $dir = gobao_obsidian_knowledge_dir($env);
    $enabled = gobao_obsidian_enabled($env);
    $created = false;
    $writable = false;
    $error = null;
    if ($enabled) {
        try {
            if (!is_dir($dir)) {
                $created = @mkdir($dir, 0775, true);
            }
            $writable = is_dir($dir) && is_writable($dir);
        } catch (Throwable $e) {
            $error = $e->getMessage();
        }
    }
    return [
        'enabled' => $enabled,
        'root' => $root,
        'knowledgeDir' => $dir,
        'created' => $created,
        'writable' => $writable,
        'error' => $error,
    ];
}

function gobao_touch_session(string $sessionId, array $meta = []): void {
    $db = gobao_db();
    if (!$db) {
        return;
    }
    $now = gmdate('c');
    $stmt = $db->prepare('
        INSERT INTO sessions(session_id, created_at, updated_at, meta_json)
        VALUES(:session_id, :created_at, :updated_at, :meta_json)
        ON CONFLICT(session_id) DO UPDATE SET updated_at = excluded.updated_at
    ');
    $stmt->execute([
        ':session_id' => gobao_safe_session_id($sessionId),
        ':created_at' => $now,
        ':updated_at' => $now,
        ':meta_json' => json_encode($meta, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '{}',
    ]);
}

function gobao_session_meta(string $sessionId): array {
    $db = gobao_db();
    if (!$db) {
        return [];
    }
    try {
        $stmt = $db->prepare('SELECT meta_json FROM sessions WHERE session_id = :session_id');
        $stmt->execute([':session_id' => gobao_safe_session_id($sessionId)]);
        $raw = $stmt->fetchColumn();
        $meta = json_decode((string)$raw, true);
        return is_array($meta) ? $meta : [];
    } catch (Throwable $e) {
        return [];
    }
}

function gobao_update_session_meta(string $sessionId, array $patch): array {
    $db = gobao_db();
    if (!$db) {
        return $patch;
    }
    $safeSessionId = gobao_safe_session_id($sessionId);
    $meta = array_merge(gobao_session_meta($safeSessionId), $patch);
    $now = gmdate('c');
    try {
        $stmt = $db->prepare('
            INSERT INTO sessions(session_id, created_at, updated_at, meta_json)
            VALUES(:session_id, :created_at, :updated_at, :meta_json)
            ON CONFLICT(session_id) DO UPDATE SET
              updated_at = excluded.updated_at,
              meta_json = excluded.meta_json
        ');
        $stmt->execute([
            ':session_id' => $safeSessionId,
            ':created_at' => $now,
            ':updated_at' => $now,
            ':meta_json' => json_encode($meta, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '{}',
        ]);
    } catch (Throwable $e) {
        return $meta;
    }
    return $meta;
}

function gobao_record_conversation_turn(string $sessionId): int {
    $meta = gobao_session_meta($sessionId);
    $turns = (int)($meta['conversationTurnCount'] ?? 0) + 1;
    gobao_update_session_meta($sessionId, ['conversationTurnCount' => $turns]);
    return $turns;
}

function gobao_load_env(): array {
    $env = [];
    $file = gobao_env_path();
    if (is_file($file)) {
        $lines = file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
        foreach ($lines as $line) {
            $line = trim($line);
            if ($line === '' || str_starts_with($line, '#')) {
                continue;
            }
            $pos = strpos($line, '=');
            if ($pos === false) {
                continue;
            }
            $key = trim(substr($line, 0, $pos));
            $value = trim(substr($line, $pos + 1));
            if ((str_starts_with($value, '"') && str_ends_with($value, '"')) ||
                (str_starts_with($value, "'") && str_ends_with($value, "'"))) {
                $value = substr($value, 1, -1);
            }
            if ($key !== '') {
                $env[$key] = $value;
            }
        }
    }
    $processEnv = getenv();
    if (is_array($processEnv)) {
        foreach ($processEnv as $key => $value) {
            if (is_string($key) && $key !== '' && is_string($value)) {
                $env[$key] = $value;
            }
        }
    }
    return $env;
}

function gobao_json_input(): array {
    $raw = file_get_contents('php://input') ?: '';
    if ($raw === '') {
        return [];
    }
    $data = json_decode($raw, true);
    if (!is_array($data)) {
        gobao_fail('Invalid JSON body', 400);
    }
    return $data;
}

function gobao_ok(array $data = []): void {
    echo gobao_json_encode(['ok' => true] + $data);
    exit;
}

function gobao_fail(string $message, int $status = 400, array $extra = []): void {
    http_response_code($status);
    echo gobao_json_encode(['ok' => false, 'error' => $message] + $extra);
    exit;
}

function gobao_install_json_error_guards(): void {
    static $installed = false;
    if ($installed) {
        return;
    }
    $installed = true;
    set_exception_handler(static function (Throwable $e): void {
        if (!headers_sent()) {
            http_response_code(500);
        }
        while (ob_get_level() > 0) {
            @ob_end_clean();
        }
        echo gobao_json_encode([
            'ok' => false,
            'error' => 'unhandled_exception: ' . $e->getMessage(),
        ]);
        exit;
    });
    register_shutdown_function(static function (): void {
        $err = error_get_last();
        if (!$err || !in_array((int)($err['type'] ?? 0), [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR], true)) {
            return;
        }
        if (!headers_sent()) {
            http_response_code(500);
        }
        while (ob_get_level() > 0) {
            @ob_end_clean();
        }
        echo gobao_json_encode([
            'ok' => false,
            'error' => 'php_fatal: ' . (string)($err['message'] ?? 'unknown'),
            'file' => basename((string)($err['file'] ?? '')),
            'line' => (int)($err['line'] ?? 0),
        ]);
    });
}

gobao_install_json_error_guards();

function gobao_configured_brains(array $env): array {
    return [
        'qwen' => !empty($env['DASHSCOPE_API_KEY']) || !empty($env['QWEN_API_KEY']),
        'deepseek' => !empty($env['DEEPSEEK_API_KEY']),
        'openai' => false,
    ];
}

function gobao_safe_session_id(string $value): string {
    $safe = preg_replace('/[^a-zA-Z0-9_-]/', '_', $value);
    $safe = substr($safe ?: 'default', 0, 80);
    return $safe ?: 'default';
}

function gobao_auth_required(array $env): bool {
    $raw = strtolower(trim((string)($env['GOBAO_AUTH_REQUIRED'] ?? 'true')));
    return !in_array($raw, ['0', 'false', 'no', 'off'], true);
}

function gobao_bearer_token(): string {
    $header = trim((string)($_SERVER['HTTP_AUTHORIZATION'] ?? $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ?? ''));
    if (preg_match('/^Bearer\s+(.+)$/i', $header, $match)) {
        return trim((string)$match[1]);
    }
    return '';
}

function gobao_token_hash(string $token): string {
    return hash('sha256', $token);
}

function gobao_random_token(int $bytes = 32): string {
    return rtrim(strtr(base64_encode(random_bytes($bytes)), '+/', '-_'), '=');
}

function gobao_normalize_username(string $value): string {
    $value = strtolower(trim($value));
    if (!preg_match('/^[a-z0-9][a-z0-9_.-]{2,39}$/', $value)) {
        gobao_fail('Username must be 3-40 characters using letters, numbers, dot, dash, or underscore.', 422);
    }
    return $value;
}

function gobao_public_user(array $row): array {
    return [
        'userId' => (string)$row['user_id'],
        'username' => (string)$row['username'],
        'displayName' => (string)$row['display_name'],
        'status' => (string)$row['status'],
        'createdAt' => (string)$row['created_at'],
    ];
}

function gobao_auth_session_create(string $userId, int $ttlSeconds = 2592000): array {
    $db = gobao_db();
    if (!$db) {
        gobao_fail('Database is not available.', 500);
    }
    $token = 'gbs_' . gobao_random_token(36);
    $now = gmdate('c');
    $expiresAt = gmdate('c', time() + max(3600, min(7776000, $ttlSeconds)));
    $userAgent = (string)($_SERVER['HTTP_USER_AGENT'] ?? '');
    $stmt = $db->prepare('
        INSERT INTO auth_sessions(user_id, token_hash, user_agent_hash, expires_at, last_seen_at, created_at)
        VALUES(:user_id, :token_hash, :user_agent_hash, :expires_at, :last_seen_at, :created_at)
    ');
    $stmt->execute([
        ':user_id' => $userId,
        ':token_hash' => gobao_token_hash($token),
        ':user_agent_hash' => $userAgent !== '' ? hash('sha256', $userAgent) : null,
        ':expires_at' => $expiresAt,
        ':last_seen_at' => $now,
        ':created_at' => $now,
    ]);
    return ['token' => $token, 'expiresAt' => $expiresAt];
}

function gobao_current_user(bool $required = true): ?array {
    $token = gobao_bearer_token();
    if ($token === '') {
        if ($required) {
            gobao_fail('Authentication required.', 401);
        }
        return null;
    }
    $db = gobao_db();
    if (!$db) {
        gobao_fail('Database is not available.', 500);
    }
    $stmt = $db->prepare('
        SELECT u.*, s.id AS session_row_id, s.expires_at
        FROM auth_sessions s
        JOIN users u ON u.user_id = s.user_id
        WHERE s.token_hash = :token_hash
          AND s.revoked_at IS NULL
          AND s.expires_at > :now
          AND u.status = :status
        LIMIT 1
    ');
    $stmt->execute([
        ':token_hash' => gobao_token_hash($token),
        ':now' => gmdate('c'),
        ':status' => 'active',
    ]);
    $row = $stmt->fetch(PDO::FETCH_ASSOC) ?: null;
    if (!$row) {
        if ($required) {
            gobao_fail('Invalid or expired session.', 401);
        }
        return null;
    }
    $update = $db->prepare('UPDATE auth_sessions SET last_seen_at = :now WHERE id = :id');
    $update->execute([':now' => gmdate('c'), ':id' => (int)$row['session_row_id']]);
    return $row;
}

function gobao_request_user_id(array $env, string $fallback = 'main'): string {
    $user = gobao_current_user(gobao_auth_required($env));
    if ($user) {
        return gobao_safe_session_id((string)$user['user_id']);
    }
    return gobao_safe_session_id($fallback);
}

function gobao_require_admin(array $env): void {
    $expected = trim((string)($env['GOBAO_ADMIN_TOKEN'] ?? $env['KNOWLEDGE_ADMIN_TOKEN'] ?? ''));
    if ($expected === '') {
        gobao_fail('Administrator API is disabled until GOBAO_ADMIN_TOKEN is configured.', 403);
    }
    $provided = trim((string)($_SERVER['HTTP_X_ADMIN_TOKEN'] ?? ''));
    if ($provided === '') {
        $provided = gobao_bearer_token();
    }
    if ($provided === '' || !hash_equals($expected, $provided)) {
        gobao_fail('Administrator authentication required.', 401);
    }
}

function gobao_memory_path(string $sessionId): string {
    return gobao_memory_dir() . '/' . gobao_safe_session_id($sessionId) . '.json';
}

function gobao_read_memory(string $sessionId): array {
    $db = gobao_db();
    if ($db) {
        try {
            $stmt = $db->prepare('
                SELECT role, content, provider, model, intent, meta_json, created_at
                FROM messages
                WHERE session_id = :session_id
                ORDER BY id DESC
                LIMIT 60
            ');
            $stmt->execute([':session_id' => gobao_safe_session_id($sessionId)]);
            $rows = array_reverse($stmt->fetchAll(PDO::FETCH_ASSOC) ?: []);
            if (count($rows) > 0) {
                return array_map(static function (array $row): array {
                    $meta = json_decode((string)($row['meta_json'] ?? '{}'), true);
                    if (!is_array($meta)) {
                        $meta = [];
                    }
                    return array_filter([
                        'role' => $row['role'] ?? 'user',
                        'content' => $row['content'] ?? '',
                        'provider' => $row['provider'] ?? null,
                        'model' => $row['model'] ?? null,
                        'intent' => $row['intent'] ?? null,
                        'at' => $row['created_at'] ?? null,
                    ] + $meta, static fn($v) => $v !== null);
                }, $rows);
            }
        } catch (Throwable $e) {
            // Fall through to JSON memory for compatibility.
        }
    }
    $file = gobao_memory_path($sessionId);
    if (!is_file($file)) {
        return [];
    }
    $data = json_decode(file_get_contents($file) ?: '', true);
    if (!is_array($data) || !isset($data['messages']) || !is_array($data['messages'])) {
        return [];
    }
    return $data['messages'];
}

function gobao_write_memory(string $sessionId, array $messages): void {
    $safeSessionId = gobao_safe_session_id($sessionId);
    $messages = array_slice($messages, -60);
    $db = gobao_db();
    if ($db) {
        try {
            $db->beginTransaction();
            gobao_touch_session($safeSessionId);
            $delete = $db->prepare('DELETE FROM messages WHERE session_id = :session_id');
            $delete->execute([':session_id' => $safeSessionId]);
            $insert = $db->prepare('
                INSERT INTO messages(session_id, role, content, provider, model, intent, meta_json, created_at)
                VALUES(:session_id, :role, :content, :provider, :model, :intent, :meta_json, :created_at)
            ');
            foreach ($messages as $message) {
                if (!is_array($message)) {
                    continue;
                }
                $meta = $message;
                foreach (['role', 'content', 'at', 'provider', 'model', 'intent'] as $key) {
                    unset($meta[$key]);
                }
                $insert->execute([
                    ':session_id' => $safeSessionId,
                    ':role' => (string)($message['role'] ?? 'user'),
                    ':content' => (string)($message['content'] ?? ''),
                    ':provider' => isset($message['provider']) ? (string)$message['provider'] : null,
                    ':model' => isset($message['model']) ? (string)$message['model'] : null,
                    ':intent' => isset($message['intent']) ? (string)$message['intent'] : null,
                    ':meta_json' => json_encode($meta, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '{}',
                    ':created_at' => gobao_iso_time($message['at'] ?? null),
                ]);
            }
            $db->commit();
        } catch (Throwable $e) {
            if ($db->inTransaction()) {
                $db->rollBack();
            }
        }
    }
    $dir = gobao_memory_dir();
    if (!is_dir($dir)) {
        mkdir($dir, 0755, true);
    }
    @file_put_contents(gobao_memory_path($safeSessionId), json_encode([
        'sessionId' => $safeSessionId,
        'updatedAt' => gmdate('c'),
        'messages' => $messages,
    ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT));
}

function gobao_store_learning(string $sessionId, array $learning): void {
    $db = gobao_db();
    if (!$db) {
        return;
    }
    try {
        $safeSessionId = gobao_safe_session_id($sessionId);
        gobao_touch_session($safeSessionId);
        $stmt = $db->prepare('
            INSERT INTO learning(session_id, query, summary, intent, confidence, action_plan_json, sources_json, provider, model, created_at)
            VALUES(:session_id, :query, :summary, :intent, :confidence, :action_plan_json, :sources_json, :provider, :model, :created_at)
        ');
        $stmt->execute([
            ':session_id' => $safeSessionId,
            ':query' => (string)($learning['query'] ?? ''),
            ':summary' => (string)($learning['summary'] ?? ''),
            ':intent' => isset($learning['intent']) ? (string)$learning['intent'] : null,
            ':confidence' => isset($learning['confidence']) && is_numeric($learning['confidence']) ? (float)$learning['confidence'] : null,
            ':action_plan_json' => json_encode($learning['actionPlan'] ?? [], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '[]',
            ':sources_json' => json_encode($learning['sources'] ?? [], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '[]',
            ':provider' => isset($learning['provider']) ? (string)$learning['provider'] : null,
            ':model' => isset($learning['model']) ? (string)$learning['model'] : null,
            ':created_at' => gobao_iso_time($learning['at'] ?? null),
        ]);
    } catch (Throwable $e) {
        // Learning persistence must not block the user response.
    }
}

function gobao_store_order(array $order): array {
    $db = gobao_db();
    $sessionId = gobao_safe_session_id((string)($order['sessionId'] ?? 'main'));
    $orderNo = trim((string)($order['orderNo'] ?? $order['order_no'] ?? ''));
    if ($orderNo === '') {
        $orderNo = 'GB' . gmdate('YmdHis') . random_int(1000, 9999);
    }
    $now = gmdate('c');
    $createdAt = gobao_iso_time($order['createdAt'] ?? $order['created_at'] ?? $now);
    $normalized = [
        'orderNo' => $orderNo,
        'sessionId' => $sessionId,
        'orderType' => trim((string)($order['orderType'] ?? $order['order_type'] ?? 'payment')),
        'title' => trim((string)($order['title'] ?? '')),
        'totalAmount' => isset($order['totalAmount']) ? (float)$order['totalAmount'] : (isset($order['total_amount']) ? (float)$order['total_amount'] : null),
        'status' => trim((string)($order['status'] ?? 'pending_payment')),
        'payMethod' => trim((string)($order['payMethod'] ?? $order['pay_method'] ?? '')),
        'deliveryLocation' => trim((string)($order['deliveryLocation'] ?? $order['delivery_location'] ?? '')),
        'createdAt' => $createdAt,
        'updatedAt' => $now,
        'payload' => $order,
    ];
    if (!$db) {
        return $normalized + ['persisted' => false];
    }
    try {
        gobao_touch_session($sessionId);
        $stmt = $db->prepare('
            INSERT INTO orders(order_no, session_id, order_type, title, total_amount, status, pay_method, delivery_location, payload_json, created_at, updated_at)
            VALUES(:order_no, :session_id, :order_type, :title, :total_amount, :status, :pay_method, :delivery_location, :payload_json, :created_at, :updated_at)
            ON CONFLICT(order_no) DO UPDATE SET
                session_id = excluded.session_id,
                order_type = excluded.order_type,
                title = excluded.title,
                total_amount = excluded.total_amount,
                status = excluded.status,
                pay_method = excluded.pay_method,
                delivery_location = excluded.delivery_location,
                payload_json = excluded.payload_json,
                updated_at = excluded.updated_at
        ');
        $stmt->execute([
            ':order_no' => $normalized['orderNo'],
            ':session_id' => $normalized['sessionId'],
            ':order_type' => $normalized['orderType'],
            ':title' => $normalized['title'],
            ':total_amount' => $normalized['totalAmount'],
            ':status' => $normalized['status'],
            ':pay_method' => $normalized['payMethod'],
            ':delivery_location' => $normalized['deliveryLocation'],
            ':payload_json' => json_encode($order, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '{}',
            ':created_at' => $normalized['createdAt'],
            ':updated_at' => $normalized['updatedAt'],
        ]);
        $normalized['persisted'] = true;
    } catch (Throwable $e) {
        $normalized['persisted'] = false;
    }
    return $normalized;
}

function gobao_list_orders(string $sessionId = 'main', int $limit = 50): array {
    $db = gobao_db();
    if (!$db) {
        return [];
    }
    $limit = max(1, min(200, $limit));
    try {
        $stmt = $db->prepare('
            SELECT order_no, session_id, order_type, title, total_amount, status, pay_method, delivery_location, payload_json, created_at, updated_at
            FROM orders
            WHERE session_id = :session_id
            ORDER BY updated_at DESC
            LIMIT :limit
        ');
        $stmt->bindValue(':session_id', gobao_safe_session_id($sessionId), PDO::PARAM_STR);
        $stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
        $stmt->execute();
        return array_map(static function (array $row): array {
            $payload = json_decode((string)($row['payload_json'] ?? '{}'), true);
            return [
                'orderNo' => $row['order_no'],
                'sessionId' => $row['session_id'],
                'orderType' => $row['order_type'],
                'title' => $row['title'],
                'totalAmount' => $row['total_amount'] === null ? null : (float)$row['total_amount'],
                'status' => $row['status'],
                'payMethod' => $row['pay_method'],
                'deliveryLocation' => $row['delivery_location'],
                'createdAt' => $row['created_at'],
                'updatedAt' => $row['updated_at'],
                'payload' => is_array($payload) ? $payload : [],
            ];
        }, $stmt->fetchAll(PDO::FETCH_ASSOC) ?: []);
    } catch (Throwable $e) {
        return [];
    }
}

function gobao_require_knowledge_admin(array $env): void {
    $token = trim((string)($env['KNOWLEDGE_ADMIN_TOKEN'] ?? ''));
    if ($token === '') {
        return;
    }
    $header = $_SERVER['HTTP_AUTHORIZATION'] ?? $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ?? '';
    if (!preg_match('/Bearer\s+(.+)/i', $header, $match) || !hash_equals($token, trim($match[1]))) {
        gobao_fail('Knowledge admin token required', 401);
    }
}

function gobao_github_headers(array $env): array {
    $headers = [
        'User-Agent: GO-Bao-KnowledgeBot/0.2',
        'Accept: application/vnd.github+json',
        'X-GitHub-Api-Version: 2022-11-28',
    ];
    $token = trim((string)($env['GITHUB_TOKEN'] ?? ''));
    if ($token !== '') {
        $headers[] = 'Authorization: Bearer ' . $token;
    }
    return $headers;
}

function gobao_parse_github_repo(string $repo): array {
    $repo = trim($repo);
    $ref = '';
    $subPath = '';
    if (preg_match('~^https?://github\.com/([^/\s]+)/([^/\s#?]+)(?:/(tree|blob)/([^/\s]+)(?:/(.*))?)?~i', $repo, $m)) {
        $owner = $m[1];
        $name = preg_replace('/\.git$/i', '', $m[2]);
        $ref = $m[4] ?? '';
        $subPath = $m[5] ?? '';
        return [$owner, $name, $ref, $subPath, 'https://github.com/' . $owner . '/' . $name];
    }
    if (preg_match('~^([^/\s]+)/([^/\s#?]+)$~', $repo, $m)) {
        $owner = $m[1];
        $name = preg_replace('/\.git$/i', '', $m[2]);
        return [$owner, $name, '', '', 'https://github.com/' . $owner . '/' . $name];
    }
    gobao_fail('Invalid GitHub repo. Use owner/repo or https://github.com/owner/repo', 400);
}

function gobao_github_repo_meta(string $owner, string $repo, array $env): array {
    return gobao_http_get_json('https://api.github.com/repos/' . rawurlencode($owner) . '/' . rawurlencode($repo), gobao_github_headers($env), 20);
}

function gobao_github_commit(string $owner, string $repo, string $ref, array $env): array {
    $url = 'https://api.github.com/repos/' . rawurlencode($owner) . '/' . rawurlencode($repo) . '/commits/' . rawurlencode($ref);
    return gobao_http_get_json($url, gobao_github_headers($env), 20);
}

function gobao_github_tree(string $owner, string $repo, string $ref, array $env): array {
    if ($ref === '') {
        $meta = gobao_github_repo_meta($owner, $repo, $env);
        $ref = (string)($meta['default_branch'] ?? 'main');
    }
    $commit = gobao_github_commit($owner, $repo, $ref, $env);
    $treeSha = (string)($commit['commit']['tree']['sha'] ?? '');
    if ($treeSha === '') {
        gobao_fail('Cannot resolve GitHub tree sha for ref: ' . $ref, 502);
    }
    $url = 'https://api.github.com/repos/' . rawurlencode($owner) . '/' . rawurlencode($repo) . '/git/trees/' . rawurlencode($treeSha) . '?recursive=1';
    $tree = gobao_http_get_json($url, gobao_github_headers($env), 30);
    return [
        'ref' => (string)($commit['sha'] ?? $ref),
        'branch' => $ref,
        'treeSha' => $treeSha,
        'truncated' => (bool)($tree['truncated'] ?? false),
        'items' => is_array($tree['tree'] ?? null) ? $tree['tree'] : [],
    ];
}

function gobao_github_blob_text(string $owner, string $repo, string $sha, array $env): string {
    $url = 'https://api.github.com/repos/' . rawurlencode($owner) . '/' . rawurlencode($repo) . '/git/blobs/' . rawurlencode($sha);
    $blob = gobao_http_get_json($url, gobao_github_headers($env), 25);
    if (($blob['encoding'] ?? '') !== 'base64') {
        gobao_fail('Unsupported GitHub blob encoding', 502);
    }
    $decoded = base64_decode((string)($blob['content'] ?? ''), true);
    if ($decoded === false) {
        gobao_fail('Cannot decode GitHub blob', 502);
    }
    return $decoded;
}

function gobao_knowledge_allowed_path(string $path): bool {
    $p = str_replace('\\', '/', strtolower($path));
    if (preg_match('~(^|/)(\.git|node_modules|vendor|dist|build|coverage|target|__pycache__|\.next|\.nuxt)(/|$)~', $p)) {
        return false;
    }
    if (preg_match('~(^|/)(package-lock\.json|pnpm-lock\.yaml|yarn\.lock|composer\.lock|poetry\.lock|uv\.lock|cargo\.lock)$~', $p)) {
        return false;
    }
    if (preg_match('~\.min\.(js|css)$~', $p)) {
        return false;
    }
    if (preg_match('~\.(png|jpe?g|gif|webp|ico|pdf|zip|gz|tar|7z|rar|mp4|mov|mp3|wav|woff2?|ttf|eot|exe|dll|sqlite|db)$~', $p)) {
        return false;
    }
    return (bool) preg_match('~(\.mdx?|\.txt|\.php|\.js|\.mjs|\.cjs|\.ts|\.tsx|\.jsx|\.json|\.ya?ml|\.css|\.html?|\.sql|\.py|\.go|\.java|\.rs|\.sh|\.env\.example|composer\.json|package\.json|README|LICENSE)$~i', $path);
}

function gobao_knowledge_rank_path(string $path, string $query): int {
    $score = 0;
    $p = strtolower($path);
    $base = basename($p);
    if (preg_match('~^(readme|design|skill|agents?|contributing|quickstart|getting[-_ ]?started)(\.[a-z0-9]+)?$~', $base)) {
        $score += 40;
    }
    if (preg_match('~^(package|composer|pyproject|requirements|cargo|go\.mod|tsconfig|vite\.config|next\.config|wrangler|docker-compose)(\.[a-z0-9]+)?$~', $base)) {
        $score += 26;
    }
    if (preg_match('~(^|/)(examples?|samples?|templates?)(/|$)~', $p)) {
        $score += 10;
    }
    if (preg_match('~(^|/)(index|main|app|server|cli|config|routes?|api|schema)\.(js|ts|tsx|jsx|py|php|go|rs|json|ya?ml)$~', $p)) {
        $score += 14;
    }
    if (preg_match('~(^|/)(test|tests|spec|__tests__)(/|$)~', $p)) {
        $score -= 4;
    }
    if (preg_match('~(^|/)(readme|docs?|api|src|lib|server|backend|routes?|controllers?|services?|config|schema|database)~', $p)) {
        $score += 8;
    }
    if (preg_match('~\.(md|mdx|php|js|ts|json)$~', $p)) {
        $score += 4;
    }
    $tokens = preg_split('/[\s,，。:：\/\\\\_-]+/u', strtolower($query), -1, PREG_SPLIT_NO_EMPTY) ?: [];
    foreach ($tokens as $token) {
        if (mb_strlen($token) >= 2 && str_contains($p, $token)) {
            $score += 10;
        }
    }
    return $score;
}

function gobao_select_github_files(array $items, string $query, array $wantedPaths, int $limit, int $maxBytes): array {
    $wanted = array_filter(array_map(static fn($p) => trim(str_replace('\\', '/', (string)$p)), $wantedPaths));
    $files = [];
    foreach ($items as $item) {
        if (!is_array($item) || ($item['type'] ?? '') !== 'blob') {
            continue;
        }
        $path = (string)($item['path'] ?? '');
        $size = (int)($item['size'] ?? 0);
        if ($path === '' || $size <= 0 || $size > $maxBytes || !gobao_knowledge_allowed_path($path)) {
            continue;
        }
        if (count($wanted) > 0) {
            $ok = false;
            foreach ($wanted as $prefix) {
                if ($path === $prefix || str_starts_with($path, rtrim($prefix, '/') . '/')) {
                    $ok = true;
                    break;
                }
            }
            if (!$ok) {
                continue;
            }
        }
        $files[] = [
            'path' => $path,
            'sha' => (string)($item['sha'] ?? ''),
            'size' => $size,
            'score' => gobao_knowledge_rank_path($path, $query),
        ];
    }
    usort($files, static fn($a, $b) => ($b['score'] <=> $a['score']) ?: ($a['size'] <=> $b['size']));
    return array_slice($files, 0, $limit);
}

function gobao_chunk_text_for_knowledge(string $text, int $maxLen = 3600, int $overlap = 240): array {
    $text = str_replace(["\r\n", "\r"], "\n", $text);
    if (mb_strlen($text) <= $maxLen) {
        return [$text];
    }
    $chunks = [];
    $len = mb_strlen($text);
    $pos = 0;
    while ($pos < $len && count($chunks) < 80) {
        $slice = mb_substr($text, $pos, $maxLen);
        $break = max(mb_strrpos($slice, "\n\n") ?: 0, mb_strrpos($slice, "\n") ?: 0);
        if ($break > 900) {
            $slice = mb_substr($slice, 0, $break);
        }
        $slice = trim($slice);
        if ($slice !== '') {
            $chunks[] = $slice;
        }
        $pos += max(1, mb_strlen($slice) - $overlap);
    }
    return $chunks;
}

function gobao_create_knowledge_source(array $source): int {
    $db = gobao_db();
    if (!$db) {
        gobao_fail('Database is not available: ' . gobao_db_last_error(), 500);
    }
    $now = gmdate('c');
    return gobao_db_retry(function () use ($db, $source, $now): int {
        $stmt = $db->prepare('
            INSERT INTO knowledge_sources(source_type, repo_owner, repo_name, repo_url, ref, query, status, meta_json, created_at, updated_at)
            VALUES(:source_type, :repo_owner, :repo_name, :repo_url, :ref, :query, :status, :meta_json, :created_at, :updated_at)
        ');
        $stmt->execute([
            ':source_type' => $source['sourceType'] ?? 'github',
            ':repo_owner' => $source['repoOwner'] ?? null,
            ':repo_name' => $source['repoName'] ?? null,
            ':repo_url' => $source['repoUrl'] ?? null,
            ':ref' => $source['ref'] ?? null,
            ':query' => $source['query'] ?? null,
            ':status' => $source['status'] ?? 'active',
            ':meta_json' => json_encode($source['meta'] ?? [], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '{}',
            ':created_at' => $now,
            ':updated_at' => $now,
        ]);
        return (int)$db->lastInsertId();
    }, 20, 250);
}

function gobao_store_knowledge_chunks(int $sourceId, array $files, string $owner, string $repo, array $env): array {
    $db = gobao_db();
    if (!$db) {
        gobao_fail('Database is not available: ' . gobao_db_last_error(), 500);
    }
    $stored = [];
    $skipped = [];
    $insert = $db->prepare('
        INSERT INTO knowledge_chunks(source_id, path, blob_sha, content_sha, chunk_index, content, meta_json, created_at)
        VALUES(:source_id, :path, :blob_sha, :content_sha, :chunk_index, :content, :meta_json, :created_at)
    ');
    foreach ($files as $file) {
        try {
            $text = gobao_github_blob_text($owner, $repo, $file['sha'], $env);
            if (preg_match('/\x00/', $text)) {
                $skipped[] = ['path' => $file['path'], 'reason' => 'binary'];
                continue;
            }
            $archive = null;
            try {
                $archive = gobao_write_github_file_archive($owner, $repo, $sourceId, (string)($file['ref'] ?? ''), (string)$file['path'], $text, $env);
            } catch (Throwable $e) {
                $skipped[] = ['path' => $file['path'], 'reason' => 'archive_failed: ' . $e->getMessage()];
            }
            $chunks = gobao_chunk_text_for_knowledge($text);
            foreach ($chunks as $i => $chunk) {
                gobao_db_retry(static function () use ($insert, $sourceId, $file, $i, $chunk, $archive): void {
                    $insert->execute([
                        ':source_id' => $sourceId,
                        ':path' => $file['path'],
                        ':blob_sha' => $file['sha'],
                        ':content_sha' => hash('sha256', $chunk),
                        ':chunk_index' => $i,
                        ':content' => $chunk,
                        ':meta_json' => json_encode(['size' => $file['size'], 'score' => $file['score'], 'localPath' => $archive['path'] ?? null], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '{}',
                        ':created_at' => gmdate('c'),
                    ]);
                }, 20, 250);
            }
            $stored[] = ['path' => $file['path'], 'sha' => $file['sha'], 'size' => $file['size'], 'chunks' => count($chunks), 'localPath' => $archive['path'] ?? null];
        } catch (Throwable $e) {
            $skipped[] = ['path' => $file['path'], 'reason' => $e->getMessage()];
        }
    }
    return ['stored' => $stored, 'skipped' => $skipped];
}

function gobao_store_text_knowledge(string $sourceType, string $title, string $path, string $content, array $meta, array $env): array {
    $db = gobao_db();
    if (!$db) {
        gobao_fail('Database is not available: ' . gobao_db_last_error(), 500);
    }
    $sourceId = gobao_create_knowledge_source([
        'sourceType' => $sourceType,
        'repoOwner' => 'gobao',
        'repoName' => $sourceType,
        'repoUrl' => null,
        'ref' => $meta['sessionId'] ?? null,
        'query' => $title,
        'status' => 'active',
        'meta' => array_merge($meta, ['title' => $title]),
    ]);
    $chunks = gobao_chunk_text_for_knowledge($content, 3200, 180);
    $insert = $db->prepare('
        INSERT INTO knowledge_chunks(source_id, path, blob_sha, content_sha, chunk_index, content, meta_json, created_at)
        VALUES(:source_id, :path, :blob_sha, :content_sha, :chunk_index, :content, :meta_json, :created_at)
    ');
    $now = gmdate('c');
    foreach ($chunks as $i => $chunk) {
        $insert->execute([
            ':source_id' => $sourceId,
            ':path' => $path,
            ':blob_sha' => 'local:' . hash('sha1', $path . $now),
            ':content_sha' => hash('sha256', $chunk),
            ':chunk_index' => $i,
            ':content' => $chunk,
            ':meta_json' => json_encode($meta, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '{}',
            ':created_at' => $now,
        ]);
    }
    $obsidian = null;
    try {
        $obsidian = gobao_obsidian_write_source($sourceId, $env);
    } catch (Throwable $e) {
        $obsidian = ['ok' => false, 'error' => $e->getMessage()];
    }
    return [
        'sourceId' => $sourceId,
        'sourceType' => $sourceType,
        'title' => $title,
        'path' => $path,
        'chunks' => count($chunks),
        'obsidian' => $obsidian,
    ];
}

function gobao_github_ingest(array $input, array $env): array {
    [$owner, $repo, $urlRef, $subPath, $repoUrl] = gobao_parse_github_repo((string)($input['repo'] ?? $input['repoUrl'] ?? ''));
    $query = trim((string)($input['query'] ?? ''));
    $ref = trim((string)($input['ref'] ?? $urlRef));
    $limit = max(1, min(80, (int)($input['limit'] ?? 30)));
    $maxBytes = max(1024, min(300000, (int)($input['maxFileBytes'] ?? 120000)));
    $wantedPaths = $input['paths'] ?? [];
    if (!is_array($wantedPaths)) {
        $wantedPaths = [$wantedPaths];
    }
    if ($subPath !== '') {
        $wantedPaths[] = $subPath;
    }
    $tree = gobao_github_tree($owner, $repo, $ref, $env);
    $files = gobao_select_github_files($tree['items'], $query, $wantedPaths, $limit, $maxBytes);
    foreach ($files as &$file) {
        $file['ref'] = $tree['branch'];
    }
    unset($file);
    if (count($files) === 0) {
        gobao_fail('No ingestible text/code files found for this repo/query.', 400, ['treeTruncated' => $tree['truncated']]);
    }
    $sourceId = gobao_create_knowledge_source([
        'repoOwner' => $owner,
        'repoName' => $repo,
        'repoUrl' => $repoUrl,
        'ref' => $tree['branch'],
        'query' => $query,
        'meta' => [
            'branch' => $tree['branch'],
            'commitSha' => $tree['ref'],
            'treeSha' => $tree['treeSha'],
            'treeTruncated' => $tree['truncated'],
            'limit' => $limit,
            'maxFileBytes' => $maxBytes,
        ],
    ]);
    $result = gobao_store_knowledge_chunks($sourceId, $files, $owner, $repo, $env);
    $obsidian = null;
    try {
        $obsidian = gobao_obsidian_write_source($sourceId, $env);
    } catch (Throwable $e) {
        $obsidian = ['ok' => false, 'error' => $e->getMessage()];
    }
    return [
        'sourceId' => $sourceId,
        'repo' => $owner . '/' . $repo,
        'ref' => $tree['branch'],
        'commitSha' => $tree['ref'],
        'branch' => $tree['branch'],
        'treeTruncated' => $tree['truncated'],
        'selectedFiles' => count($files),
        'storedFiles' => count($result['stored']),
        'storedChunks' => array_sum(array_map(static fn($f) => (int)($f['chunks'] ?? 0), $result['stored'])),
        'files' => $result['stored'],
        'skipped' => $result['skipped'],
        'obsidian' => $obsidian,
    ];
}

function gobao_github_auto_enabled(array $env): bool {
    $value = strtolower(trim((string)($env['GITHUB_AUTO_INGEST_ENABLED'] ?? 'true')));
    return !in_array($value, ['0', 'false', 'off', 'no'], true);
}

function gobao_github_auto_obsidian_enabled(array $env): bool {
    $value = strtolower(trim((string)($env['GITHUB_AUTO_OBSIDIAN_SYNC'] ?? 'true')));
    return !in_array($value, ['0', 'false', 'off', 'no'], true);
}

function gobao_github_auto_limit(string $message, array $env): int {
    $limit = (int)($env['GITHUB_AUTO_INGEST_LIMIT'] ?? 5);
    if (preg_match('/(?:抓取|拉取|读取|学习|导入|fetch|ingest|learn).{0,12}?(\d{1,2})\s*(?:个|files?|份)?/iu', $message, $m)) {
        $limit = (int)$m[1];
    } elseif (preg_match('/(?:抓取|拉取|读取|学习|导入).{0,12}?(一|二|两|三|四|五|六|七|八|九|十)\s*个/iu', $message, $m)) {
        $map = ['一' => 1, '二' => 2, '两' => 2, '三' => 3, '四' => 4, '五' => 5, '六' => 6, '七' => 7, '八' => 8, '九' => 9, '十' => 10];
        $limit = $map[$m[1]] ?? $limit;
    }
    return max(1, min(10, $limit ?: 5));
}

function gobao_github_message_mentions_ingest(string $message): bool {
    return (bool)preg_match('/(github|git\s*hub|仓库|开源|源码|项目|repo|repository|抓取|拉取|读取|学习|导入|同步|更新|fetch|ingest|learn)/iu', $message);
}

function gobao_github_force_refresh_requested(string $message): bool {
    return (bool)preg_match('/(抓取|拉取|读取|导入|同步|更新|重新|刷新|refresh|fetch|ingest|reload)/iu', $message);
}

function gobao_parse_github_repo_safe(string $repo): ?array {
    $repo = trim($repo);
    $repo = rtrim($repo, " \t\r\n.,，。;；!！?？)）]】\"'");
    if ($repo === '') {
        return null;
    }
    if (preg_match('~^https?://github\.com/([^/\s]+)/([^/\s#?]+)(?:/(tree|blob)/([^/\s#?]+)(?:/([^\s#?]+))?)?~i', $repo, $m)) {
        $owner = $m[1];
        $name = preg_replace('/\.git$/i', '', $m[2]);
        return [
            'owner' => $owner,
            'repo' => $name,
            'ref' => $m[4] ?? '',
            'subPath' => $m[5] ?? '',
            'repoUrl' => 'https://github.com/' . $owner . '/' . $name,
            'input' => $repo,
        ];
    }
    if (preg_match('~^([A-Za-z0-9](?:[A-Za-z0-9-]{0,38}))/([A-Za-z0-9._-]{1,100})$~', $repo, $m)) {
        $owner = $m[1];
        $name = preg_replace('/\.git$/i', '', $m[2]);
        if ($name === '' || preg_match('~\.(md|txt|php|js|ts|tsx|jsx|json|ya?ml|css|html?|py|go|rs|java|sh)$~i', $name)) {
            return null;
        }
        return [
            'owner' => $owner,
            'repo' => $name,
            'ref' => '',
            'subPath' => '',
            'repoUrl' => 'https://github.com/' . $owner . '/' . $name,
            'input' => $repo,
        ];
    }
    return null;
}

function gobao_extract_github_repo_mentions(string $message, int $maxRepos = 3): array {
    $repos = [];
    $seen = [];
    $add = static function (string $raw) use (&$repos, &$seen, $maxRepos): void {
        if (count($repos) >= $maxRepos) {
            return;
        }
        $parsed = gobao_parse_github_repo_safe($raw);
        if (!$parsed) {
            return;
        }
        $key = strtolower($parsed['owner'] . '/' . $parsed['repo'] . '@' . ($parsed['ref'] ?? '') . '/' . ($parsed['subPath'] ?? ''));
        if (isset($seen[$key])) {
            return;
        }
        $seen[$key] = true;
        $repos[] = $parsed;
    };

    if (preg_match_all('~https?://github\.com/[^\s<>"\'\)）\]】]+~i', $message, $matches)) {
        foreach ($matches[0] as $raw) {
            $add((string)$raw);
        }
    }

    if (gobao_github_message_mentions_ingest($message) && preg_match_all('~(?<![A-Za-z0-9_.-])([A-Za-z0-9](?:[A-Za-z0-9-]{0,38}))/([A-Za-z0-9._-]{1,100})(?![A-Za-z0-9_.-])~', $message, $matches, PREG_SET_ORDER)) {
        foreach ($matches as $m) {
            $add($m[1] . '/' . $m[2]);
        }
    }

    return $repos;
}

function gobao_latest_github_source_summary(string $owner, string $repo): ?array {
    $db = gobao_db();
    if (!$db) {
        return null;
    }
    try {
        $stmt = $db->prepare('
            SELECT ks.id, ks.repo_owner, ks.repo_name, ks.repo_url, ks.ref, ks.query, ks.status, ks.updated_at, COUNT(kc.id) AS chunks
            FROM knowledge_sources ks
            LEFT JOIN knowledge_chunks kc ON kc.source_id = ks.id
            WHERE lower(ks.repo_owner) = lower(:owner)
              AND lower(ks.repo_name) = lower(:repo)
              AND ks.source_type = :source_type
            GROUP BY ks.id
            HAVING chunks > 0
            ORDER BY ks.updated_at DESC, ks.id DESC
            LIMIT 1
        ');
        $stmt->execute([
            ':owner' => $owner,
            ':repo' => $repo,
            ':source_type' => 'github',
        ]);
        $row = $stmt->fetch(PDO::FETCH_ASSOC);
        if (!$row) {
            return null;
        }
        return [
            'sourceId' => (int)$row['id'],
            'repo' => trim((string)$row['repo_owner'] . '/' . (string)$row['repo_name'], '/'),
            'repoUrl' => $row['repo_url'],
            'ref' => $row['ref'],
            'query' => $row['query'],
            'status' => $row['status'],
            'chunks' => (int)$row['chunks'],
            'updatedAt' => $row['updated_at'],
        ];
    } catch (Throwable $e) {
        return null;
    }
}

function gobao_github_source_is_fresh(array $source, array $env): bool {
    if (($source['status'] ?? '') !== 'fresh' && ($source['status'] ?? '') !== 'active') {
        return false;
    }
    $ttlHours = max(1, min(168, (int)($env['GOBAO_GITHUB_FRESH_TTL_HOURS'] ?? 6)));
    $updatedAt = trim((string)($source['updatedAt'] ?? ''));
    $updatedTs = $updatedAt !== '' ? strtotime($updatedAt) : false;
    return $updatedTs !== false && $updatedTs >= time() - ($ttlHours * 3600);
}

function gobao_http_get_json_result(string $url, array $headers = [], int $timeout = 20): array {
    if (!function_exists('curl_init')) {
        return ['ok' => false, 'error' => 'PHP curl extension is not available'];
    }
    $ch = curl_init($url);
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPGET => true,
        CURLOPT_HTTPHEADER => $headers,
        CURLOPT_TIMEOUT => $timeout,
    ]);
    $body = curl_exec($ch);
    $err = curl_error($ch);
    $status = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);
    if ($body === false || $err !== '') {
        return ['ok' => false, 'error' => $err !== '' ? $err : 'HTTP request failed', 'status' => $status];
    }
    $data = json_decode($body ?: '', true);
    if ($status < 200 || $status >= 300 || !is_array($data)) {
        $msg = is_array($data) ? (string)($data['message'] ?? ('HTTP ' . $status)) : ('HTTP ' . $status);
        return ['ok' => false, 'error' => $msg, 'status' => $status];
    }
    return ['ok' => true, 'data' => $data, 'status' => $status];
}

function gobao_github_tree_result(string $owner, string $repo, string $ref, array $env): array {
    $headers = gobao_github_headers($env);
    if ($ref === '') {
        $meta = gobao_http_get_json_result('https://api.github.com/repos/' . rawurlencode($owner) . '/' . rawurlencode($repo), $headers, 20);
        if (empty($meta['ok'])) {
            return ['ok' => false, 'error' => 'GitHub repo metadata failed: ' . ($meta['error'] ?? 'unknown')];
        }
        $ref = (string)($meta['data']['default_branch'] ?? 'main');
    }
    $commit = gobao_http_get_json_result('https://api.github.com/repos/' . rawurlencode($owner) . '/' . rawurlencode($repo) . '/commits/' . rawurlencode($ref), $headers, 20);
    if (empty($commit['ok'])) {
        return ['ok' => false, 'error' => 'GitHub commit lookup failed: ' . ($commit['error'] ?? 'unknown')];
    }
    $treeSha = (string)($commit['data']['commit']['tree']['sha'] ?? '');
    if ($treeSha === '') {
        return ['ok' => false, 'error' => 'Cannot resolve GitHub tree sha for ref: ' . $ref];
    }
    $tree = gobao_http_get_json_result('https://api.github.com/repos/' . rawurlencode($owner) . '/' . rawurlencode($repo) . '/git/trees/' . rawurlencode($treeSha) . '?recursive=1', $headers, 30);
    if (empty($tree['ok'])) {
        return ['ok' => false, 'error' => 'GitHub tree lookup failed: ' . ($tree['error'] ?? 'unknown')];
    }
    return [
        'ok' => true,
        'ref' => (string)($commit['data']['sha'] ?? $ref),
        'branch' => $ref,
        'treeSha' => $treeSha,
        'truncated' => (bool)($tree['data']['truncated'] ?? false),
        'items' => is_array($tree['data']['tree'] ?? null) ? $tree['data']['tree'] : [],
    ];
}

function gobao_github_blob_text_result(string $owner, string $repo, string $sha, array $env): array {
    $blob = gobao_http_get_json_result('https://api.github.com/repos/' . rawurlencode($owner) . '/' . rawurlencode($repo) . '/git/blobs/' . rawurlencode($sha), gobao_github_headers($env), 25);
    if (empty($blob['ok'])) {
        return ['ok' => false, 'error' => $blob['error'] ?? 'GitHub blob lookup failed'];
    }
    if (($blob['data']['encoding'] ?? '') !== 'base64') {
        return ['ok' => false, 'error' => 'Unsupported GitHub blob encoding'];
    }
    $decoded = base64_decode((string)($blob['data']['content'] ?? ''), true);
    if ($decoded === false) {
        return ['ok' => false, 'error' => 'Cannot decode GitHub blob'];
    }
    return ['ok' => true, 'text' => $decoded];
}

function gobao_store_github_chunks_safe(int $sourceId, array $files, string $owner, string $repo, array $env): array {
    $db = gobao_db();
    if (!$db) {
        return ['stored' => [], 'skipped' => [['path' => '', 'reason' => 'Database is not available: ' . gobao_db_last_error()]]];
    }
    $stored = [];
    $skipped = [];
    $insert = $db->prepare('
        INSERT INTO knowledge_chunks(source_id, path, blob_sha, content_sha, chunk_index, content, meta_json, created_at)
        VALUES(:source_id, :path, :blob_sha, :content_sha, :chunk_index, :content, :meta_json, :created_at)
    ');
    foreach ($files as $file) {
        $path = (string)($file['path'] ?? '');
        try {
            $blob = gobao_github_blob_text_result($owner, $repo, (string)($file['sha'] ?? ''), $env);
            if (empty($blob['ok'])) {
                $skipped[] = ['path' => $path, 'reason' => $blob['error'] ?? 'blob_failed'];
                continue;
            }
            $text = (string)$blob['text'];
            if (preg_match('/\x00/', $text)) {
                $skipped[] = ['path' => $path, 'reason' => 'binary'];
                continue;
            }
            $archive = null;
            try {
                $archive = gobao_write_github_file_archive($owner, $repo, $sourceId, (string)($file['ref'] ?? ''), $path, $text, $env);
            } catch (Throwable $e) {
                $skipped[] = ['path' => $path, 'reason' => 'archive_failed: ' . $e->getMessage()];
            }
            $chunks = gobao_chunk_text_for_knowledge($text);
            foreach ($chunks as $i => $chunk) {
                gobao_db_retry(static function () use ($insert, $sourceId, $path, $file, $i, $chunk, $archive): void {
                    $insert->execute([
                        ':source_id' => $sourceId,
                        ':path' => $path,
                        ':blob_sha' => (string)($file['sha'] ?? ''),
                        ':content_sha' => hash('sha256', $chunk),
                        ':chunk_index' => $i,
                        ':content' => $chunk,
                        ':meta_json' => json_encode(['size' => (int)($file['size'] ?? 0), 'score' => (int)($file['score'] ?? 0), 'autoIngest' => true, 'localPath' => $archive['path'] ?? null], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '{}',
                        ':created_at' => gmdate('c'),
                    ]);
                }, 20, 250);
            }
            $stored[] = ['path' => $path, 'sha' => (string)($file['sha'] ?? ''), 'size' => (int)($file['size'] ?? 0), 'chunks' => count($chunks), 'score' => (int)($file['score'] ?? 0), 'localPath' => $archive['path'] ?? null];
        } catch (Throwable $e) {
            $skipped[] = ['path' => $path, 'reason' => $e->getMessage()];
        }
    }
    return ['stored' => $stored, 'skipped' => $skipped];
}

function gobao_github_ingest_for_chat(array $repoInfo, string $query, array $env, int $limit = 5): array {
    $db = gobao_db();
    if (!$db) {
        return ['ok' => false, 'error' => 'Database is not available: ' . gobao_db_last_error()];
    }
    $owner = (string)$repoInfo['owner'];
    $repo = (string)$repoInfo['repo'];
    $ref = trim((string)($repoInfo['ref'] ?? ''));
    $wantedPaths = [];
    if (!empty($repoInfo['subPath'])) {
        $wantedPaths[] = (string)$repoInfo['subPath'];
    }
    $tree = gobao_github_tree_result($owner, $repo, $ref, $env);
    if (empty($tree['ok'])) {
        return ['ok' => false, 'repo' => $owner . '/' . $repo, 'error' => $tree['error'] ?? 'github_tree_failed'];
    }
    $maxBytes = max(1024, min(300000, (int)($env['GITHUB_AUTO_MAX_FILE_BYTES'] ?? 80000)));
    $files = gobao_select_github_files($tree['items'], $query, $wantedPaths, $limit, $maxBytes);
    foreach ($files as &$file) {
        $file['ref'] = $tree['branch'];
    }
    unset($file);
    if (count($files) === 0) {
        return ['ok' => false, 'repo' => $owner . '/' . $repo, 'error' => 'No ingestible important text/code files found.', 'treeTruncated' => $tree['truncated']];
    }
    try {
        $sourceId = gobao_create_knowledge_source([
            'sourceType' => 'github',
            'repoOwner' => $owner,
            'repoName' => $repo,
            'repoUrl' => (string)($repoInfo['repoUrl'] ?? ('https://github.com/' . $owner . '/' . $repo)),
            'ref' => $tree['branch'],
            'query' => $query,
            'meta' => [
                'branch' => $tree['branch'],
                'commitSha' => $tree['ref'],
                'treeSha' => $tree['treeSha'],
                'treeTruncated' => $tree['truncated'],
                'limit' => $limit,
                'maxFileBytes' => $maxBytes,
                'autoIngest' => true,
            ],
        ]);
        $stored = gobao_store_github_chunks_safe($sourceId, $files, $owner, $repo, $env);
        $obsidian = ['ok' => true, 'enabled' => false, 'message' => 'Auto Obsidian sync disabled'];
        if (gobao_github_auto_obsidian_enabled($env) && gobao_obsidian_enabled($env)) {
            $dir = gobao_obsidian_knowledge_dir($env);
            if ((is_dir($dir) || @mkdir($dir, 0775, true)) && is_writable($dir)) {
                $obsidian = gobao_obsidian_write_source($sourceId, $env);
            } else {
                $obsidian = ['ok' => false, 'enabled' => true, 'error' => 'Obsidian directory is not writable: ' . $dir];
            }
        }
        return [
            'ok' => true,
            'sourceId' => $sourceId,
            'repo' => $owner . '/' . $repo,
            'ref' => $tree['branch'],
            'commitSha' => $tree['ref'],
            'selectedFiles' => count($files),
            'storedFiles' => count($stored['stored']),
            'storedChunks' => array_sum(array_map(static fn($f) => (int)($f['chunks'] ?? 0), $stored['stored'])),
            'files' => $stored['stored'],
            'skipped' => $stored['skipped'],
            'obsidian' => $obsidian,
        ];
    } catch (Throwable $e) {
        return ['ok' => false, 'repo' => $owner . '/' . $repo, 'error' => $e->getMessage()];
    }
}

function gobao_maybe_auto_ingest_github(string $message, array $env): array {
    $status = [
        'enabled' => gobao_github_auto_enabled($env),
        'triggered' => false,
        'limit' => gobao_github_auto_limit($message, $env),
        'repos' => [],
        'ingested' => [],
        'reused' => [],
        'errors' => [],
        'queryBoost' => '',
    ];
    if (!$status['enabled']) {
        return $status;
    }
    $repos = gobao_extract_github_repo_mentions($message, 3);
    if (count($repos) === 0) {
        return $status;
    }
    $status['triggered'] = true;
    $status['repos'] = array_map(static fn($r) => $r['owner'] . '/' . $r['repo'], $repos);
    $force = gobao_github_force_refresh_requested($message);
    $boost = [];
    foreach ($repos as $repoInfo) {
        $owner = (string)$repoInfo['owner'];
        $repo = (string)$repoInfo['repo'];
        $repoName = $owner . '/' . $repo;
        $boost[] = $repoName;
        $existing = gobao_latest_github_source_summary($owner, $repo);
        if ($existing && !$force && gobao_github_source_is_fresh($existing, $env)) {
            $status['reused'][] = $existing;
            continue;
        }
        $result = gobao_github_ingest_for_chat($repoInfo, $message, $env, (int)$status['limit']);
        if (!empty($result['ok'])) {
            $status['ingested'][] = $result;
            foreach ($result['files'] ?? [] as $file) {
                if (!empty($file['path'])) {
                    $boost[] = (string)$file['path'];
                }
            }
        } else {
            $status['errors'][] = [
                'repo' => $repoName,
                'error' => (string)($result['error'] ?? 'unknown_error'),
            ];
            if ($existing) {
                $status['reused'][] = $existing + ['note' => 'Reused existing source after refresh failed'];
            }
        }
    }
    $status['queryBoost'] = trim(implode(' ', array_unique($boost)));
    return $status;
}

function gobao_format_github_auto_context(array $status): string {
    if (empty($status['triggered'])) {
        return '';
    }
    $lines = [
        'GitHub auto-ingest status for this turn:',
        '- Requested important file limit: ' . (int)($status['limit'] ?? 5),
    ];
    foreach (($status['ingested'] ?? []) as $item) {
        $files = array_map(static fn($f) => (string)($f['path'] ?? ''), is_array($item['files'] ?? null) ? $item['files'] : []);
        $files = array_values(array_filter($files));
        $lines[] = '- Fetched ' . (string)($item['repo'] ?? '') . ' into source #' . (int)($item['sourceId'] ?? 0) . '; files: ' . implode(', ', $files);
    }
    foreach (($status['reused'] ?? []) as $item) {
        $lines[] = '- Reused existing knowledge source #' . (int)($item['sourceId'] ?? 0) . ' for ' . (string)($item['repo'] ?? '') . ' with ' . (int)($item['chunks'] ?? 0) . ' chunks.';
    }
    foreach (($status['errors'] ?? []) as $item) {
        $lines[] = '- GitHub fetch warning for ' . (string)($item['repo'] ?? '') . ': ' . (string)($item['error'] ?? 'unknown error');
    }
    $lines[] = 'Use newly fetched repository snippets in the answer when they are relevant. Mention fetched file paths if the user asks what was learned.';
    return implode("\n", $lines);
}

function gobao_knowledge_search(string $query, int $limit = 8): array {
    $db = gobao_db();
    if (!$db) {
        gobao_fail('Database is not available: ' . gobao_db_last_error(), 500);
    }
    $terms = preg_split('/[\s,，。:：\/\\\\_-]+/u', trim($query), -1, PREG_SPLIT_NO_EMPTY) ?: [];
    $where = [];
    $params = [];
    $score = [];
    $repoExpr = "(COALESCE(ks.repo_owner, '') || '/' || COALESCE(ks.repo_name, ''))";
    foreach (array_slice($terms, 0, 8) as $i => $term) {
        if (mb_strlen($term) < 2) {
            continue;
        }
        $key = ':q' . $i;
        $where[] = '(' . $repoExpr . ' LIKE ' . $key . ' OR ks.repo_owner LIKE ' . $key . ' OR ks.repo_name LIKE ' . $key . ' OR kc.path LIKE ' . $key . ' OR kc.content LIKE ' . $key . ' OR ks.query LIKE ' . $key . ')';
        $score[] = '('
            . 'CASE WHEN ' . $repoExpr . ' LIKE ' . $key . ' THEN 34 ELSE 0 END + '
            . 'CASE WHEN ks.repo_name LIKE ' . $key . ' THEN 28 ELSE 0 END + '
            . 'CASE WHEN ks.repo_owner LIKE ' . $key . ' THEN 18 ELSE 0 END + '
            . 'CASE WHEN kc.path LIKE ' . $key . ' THEN 12 ELSE 0 END + '
            . 'CASE WHEN kc.content LIKE ' . $key . ' THEN 2 ELSE 0 END + '
            . 'CASE WHEN ks.query LIKE ' . $key . ' THEN 1 ELSE 0 END'
            . ')';
        $params[$key] = '%' . $term . '%';
    }
    if (count($where) === 0) {
        $where[] = '1=1';
    }
    $scoreSql = count($score) ? implode(' + ', $score) : '0';
    $sql = '
        SELECT kc.id, kc.source_id, kc.path, kc.chunk_index, kc.content, kc.blob_sha, ks.repo_owner, ks.repo_name, ks.repo_url, ks.ref, ks.query,
               ks.source_type, ks.status AS source_status, ks.last_checked_at, ks.updated_at AS source_updated_at,
               (' . $scoreSql . ') AS relevance
        FROM knowledge_chunks kc
        JOIN knowledge_sources ks ON ks.id = kc.source_id
        WHERE ' . implode(' OR ', $where) . '
        ORDER BY relevance DESC, kc.id DESC
        LIMIT :limit
    ';
    $stmt = $db->prepare($sql);
    foreach ($params as $key => $value) {
        $stmt->bindValue($key, $value, PDO::PARAM_STR);
    }
    $stmt->bindValue(':limit', max(1, min(30, $limit)), PDO::PARAM_INT);
    $stmt->execute();
    return array_map(static function (array $row): array {
        return [
            'chunkId' => (int)$row['id'],
            'sourceId' => (int)$row['source_id'],
            'repo' => ($row['repo_owner'] ?? '') . '/' . ($row['repo_name'] ?? ''),
            'repoUrl' => $row['repo_url'],
            'ref' => $row['ref'],
            'path' => $row['path'],
            'chunkIndex' => (int)$row['chunk_index'],
            'text' => mb_substr((string)$row['content'], 0, 1200),
            'blobSha' => $row['blob_sha'],
            'sourceQuery' => $row['query'],
            'sourceType' => $row['source_type'],
            'sourceStatus' => $row['source_status'],
            'lastCheckedAt' => $row['last_checked_at'],
            'sourceUpdatedAt' => $row['source_updated_at'],
            'relevance' => (int)($row['relevance'] ?? 0),
        ];
    }, $stmt->fetchAll(PDO::FETCH_ASSOC) ?: []);
}

function gobao_knowledge_sources(int $limit = 50): array {
    $db = gobao_db();
    if (!$db) {
        gobao_fail('Database is not available: ' . gobao_db_last_error(), 500);
    }
    $stmt = $db->prepare('
        SELECT ks.*, COUNT(kc.id) AS chunks
        FROM knowledge_sources ks
        LEFT JOIN knowledge_chunks kc ON kc.source_id = ks.id
        GROUP BY ks.id
        ORDER BY ks.updated_at DESC
        LIMIT :limit
    ');
    $stmt->bindValue(':limit', max(1, min(200, $limit)), PDO::PARAM_INT);
    $stmt->execute();
    return array_map(static function (array $row): array {
        return [
            'id' => (int)$row['id'],
            'sourceType' => $row['source_type'],
            'repo' => trim(($row['repo_owner'] ?? '') . '/' . ($row['repo_name'] ?? ''), '/'),
            'repoUrl' => $row['repo_url'],
            'ref' => $row['ref'],
            'query' => $row['query'],
            'status' => $row['status'],
            'chunks' => (int)$row['chunks'],
            'lastCheckedAt' => $row['last_checked_at'],
            'createdAt' => $row['created_at'],
            'updatedAt' => $row['updated_at'],
        ];
    }, $stmt->fetchAll(PDO::FETCH_ASSOC) ?: []);
}

function gobao_knowledge_freshness_summary(array $env): array {
    $db = gobao_db();
    if (!$db) {
        return ['enabled' => false, 'error' => gobao_db_last_error(), 'generatedAt' => gmdate('c')];
    }
    $sourceRows = $db->query('
        SELECT source_type, status, COUNT(*) AS total, MAX(updated_at) AS latest_updated_at,
               MAX(last_checked_at) AS latest_checked_at
        FROM knowledge_sources
        GROUP BY source_type, status
        ORDER BY source_type, status
    ')->fetchAll(PDO::FETCH_ASSOC) ?: [];
    $latestSync = $db->query('
        SELECT device_id, vault_name, root_path, files_count, chunks_count, status, created_at
        FROM local_vault_syncs
        ORDER BY id DESC
        LIMIT 1
    ')->fetch(PDO::FETCH_ASSOC) ?: null;
    $ttlHours = max(1, min(168, (int)($env['GOBAO_GITHUB_FRESH_TTL_HOURS'] ?? 6)));
    $cutoff = gmdate('c', time() - ($ttlHours * 3600));
    $dueStmt = $db->prepare('
        SELECT COUNT(*) FROM knowledge_sources ks
        INNER JOIN (
            SELECT lower(repo_owner) AS owner_key, lower(repo_name) AS repo_key, MAX(id) AS latest_id
            FROM knowledge_sources
            WHERE source_type = :source_type
            GROUP BY lower(repo_owner), lower(repo_name)
        ) latest ON latest.latest_id = ks.id
        WHERE ks.status NOT IN (:superseded, :disabled)
          AND (ks.last_checked_at IS NULL OR ks.last_checked_at < :cutoff OR ks.status = :stale)
    ');
    $dueStmt->execute([
        ':source_type' => 'github',
        ':superseded' => 'superseded',
        ':disabled' => 'disabled',
        ':cutoff' => $cutoff,
        ':stale' => 'stale',
    ]);
    $lastSyncAt = $latestSync['created_at'] ?? null;
    $lastSyncTs = $lastSyncAt ? strtotime((string)$lastSyncAt) : false;
    return [
        'enabled' => true,
        'generatedAt' => gmdate('c'),
        'githubTtlHours' => $ttlHours,
        'githubSourcesDue' => (int)$dueStmt->fetchColumn(),
        'sources' => array_map(static fn(array $row): array => [
            'sourceType' => (string)$row['source_type'],
            'status' => (string)$row['status'],
            'total' => (int)$row['total'],
            'latestUpdatedAt' => $row['latest_updated_at'],
            'latestCheckedAt' => $row['latest_checked_at'],
        ], $sourceRows),
        'localVault' => $latestSync ? [
            'deviceId' => (string)$latestSync['device_id'],
            'vaultName' => (string)$latestSync['vault_name'],
            'rootPath' => (string)$latestSync['root_path'],
            'filesCount' => (int)$latestSync['files_count'],
            'chunksCount' => (int)$latestSync['chunks_count'],
            'status' => (string)$latestSync['status'],
            'lastSyncAt' => $lastSyncAt,
            'ageSeconds' => $lastSyncTs ? max(0, time() - $lastSyncTs) : null,
            'fresh' => $lastSyncTs !== false && $lastSyncTs >= time() - 1200,
        ] : [
            'fresh' => false,
            'lastSyncAt' => null,
            'status' => 'never_synced',
        ],
    ];
}

function gobao_obsidian_slug(string $value): string {
    $value = trim($value);
    $value = preg_replace('~[\\\\/:*?"<>|#\[\]\n\r\t]+~u', '-', $value) ?: 'knowledge';
    $value = preg_replace('~-+~', '-', $value) ?: 'knowledge';
    $value = trim($value, '- .');
    return mb_substr($value !== '' ? $value : 'knowledge', 0, 90);
}

function gobao_obsidian_yaml(string $value): string {
    return '"' . str_replace(['\\', '"', "\r", "\n"], ['\\\\', '\"', ' ', ' '], $value) . '"';
}

function gobao_obsidian_write_source(int $sourceId, array $env): array {
    if (!gobao_obsidian_enabled($env)) {
        return ['ok' => true, 'enabled' => false, 'message' => 'Obsidian sync disabled'];
    }
    $db = gobao_db();
    if (!$db) {
        gobao_fail('Database is not available: ' . gobao_db_last_error(), 500);
    }
    $sourceStmt = $db->prepare('SELECT * FROM knowledge_sources WHERE id = :id');
    $sourceStmt->execute([':id' => $sourceId]);
    $source = $sourceStmt->fetch(PDO::FETCH_ASSOC);
    if (!$source) {
        gobao_fail('Knowledge source not found', 404);
    }
    $chunkStmt = $db->prepare('
        SELECT path, blob_sha, chunk_index, content, created_at
        FROM knowledge_chunks
        WHERE source_id = :id
        ORDER BY path ASC, chunk_index ASC
    ');
    $chunkStmt->execute([':id' => $sourceId]);
    $chunks = $chunkStmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
    $dir = gobao_obsidian_knowledge_dir($env);
    if (!is_dir($dir) && !@mkdir($dir, 0775, true) && !is_dir($dir)) {
        gobao_fail('Cannot create Obsidian knowledge directory: ' . $dir, 500);
    }
    if (!is_writable($dir)) {
        gobao_fail('Obsidian knowledge directory is not writable: ' . $dir, 500);
    }
    $repo = trim((string)($source['repo_owner'] ?? '') . '/' . (string)($source['repo_name'] ?? ''), '/');
    $title = ($repo !== '' ? $repo : 'knowledge-source') . ' #' . $sourceId;
    $filename = gobao_obsidian_slug(($repo !== '' ? $repo : 'source') . '-' . $sourceId) . '.md';
    $path = $dir . '/' . $filename;
    $now = gmdate('c');
    $meta = json_decode((string)($source['meta_json'] ?? '{}'), true);
    if (!is_array($meta)) {
        $meta = [];
    }
    $lines = [
        '---',
        'source_id: ' . $sourceId,
        'source_type: ' . gobao_obsidian_yaml((string)($source['source_type'] ?? 'github')),
        'repo: ' . gobao_obsidian_yaml($repo),
        'repo_url: ' . gobao_obsidian_yaml((string)($source['repo_url'] ?? '')),
        'ref: ' . gobao_obsidian_yaml((string)($source['ref'] ?? '')),
        'query: ' . gobao_obsidian_yaml((string)($source['query'] ?? '')),
        'status: ' . gobao_obsidian_yaml((string)($source['status'] ?? 'active')),
        'chunks: ' . count($chunks),
        'last_checked_at: ' . gobao_obsidian_yaml((string)($source['last_checked_at'] ?? '')),
        'updated_at: ' . gobao_obsidian_yaml($now),
        'tags:',
        '  - gobao',
        '  - github',
        '  - knowledge',
        '---',
        '',
        '# ' . $title,
        '',
        '- Repo: ' . ($source['repo_url'] ? '[' . $repo . '](' . $source['repo_url'] . ')' : $repo),
        '- Ref: `' . (string)($source['ref'] ?? '') . '`',
        '- Query: ' . (string)($source['query'] ?? ''),
        '- Status: `' . (string)($source['status'] ?? 'active') . '`',
        '- Commit: `' . (string)($meta['commitSha'] ?? '') . '`',
        '',
        '## Knowledge Chunks',
        '',
    ];
    $currentPath = null;
    foreach ($chunks as $chunk) {
        $chunkPath = (string)$chunk['path'];
        if ($chunkPath !== $currentPath) {
            $currentPath = $chunkPath;
            $lines[] = '';
            $lines[] = '### `' . $chunkPath . '`';
            $lines[] = '';
            $lines[] = '- Blob SHA: `' . (string)($chunk['blob_sha'] ?? '') . '`';
            $lines[] = '';
        }
        $lines[] = '#### Chunk ' . ((int)$chunk['chunk_index'] + 1);
        $lines[] = '';
        $lines[] = '````text';
        $lines[] = rtrim((string)$chunk['content']);
        $lines[] = '````';
        $lines[] = '';
    }
    $bytes = file_put_contents($path, implode("\n", $lines));
    if ($bytes === false) {
        gobao_fail('Cannot write Obsidian note: ' . $path, 500);
    }
    return [
        'ok' => true,
        'enabled' => true,
        'sourceId' => $sourceId,
        'path' => $path,
        'bytes' => $bytes,
        'chunks' => count($chunks),
    ];
}

function gobao_obsidian_write_all(array $env, int $limit = 100): array {
    $sources = gobao_knowledge_sources($limit);
    $written = [];
    foreach ($sources as $source) {
        $written[] = gobao_obsidian_write_source((int)$source['id'], $env);
    }
    return $written;
}

function gobao_knowledge_check_source(int $sourceId, array $env): array {
    $db = gobao_db();
    if (!$db) {
        gobao_fail('Database is not available: ' . gobao_db_last_error(), 500);
    }
    $stmt = $db->prepare('SELECT * FROM knowledge_sources WHERE id = :id');
    $stmt->execute([':id' => $sourceId]);
    $source = $stmt->fetch(PDO::FETCH_ASSOC);
    if (!$source) {
        gobao_fail('Knowledge source not found', 404);
    }
    $tree = gobao_github_tree((string)$source['repo_owner'], (string)$source['repo_name'], (string)($source['ref'] ?: 'main'), $env);
    $current = [];
    foreach ($tree['items'] as $item) {
        if (is_array($item) && ($item['type'] ?? '') === 'blob') {
            $current[(string)$item['path']] = (string)($item['sha'] ?? '');
        }
    }
    $rows = $db->prepare('SELECT DISTINCT path, blob_sha FROM knowledge_chunks WHERE source_id = :id');
    $rows->execute([':id' => $sourceId]);
    $changed = [];
    $deleted = [];
    $ok = [];
    foreach ($rows->fetchAll(PDO::FETCH_ASSOC) ?: [] as $row) {
        $path = (string)$row['path'];
        $sha = (string)$row['blob_sha'];
        if (!array_key_exists($path, $current)) {
            $deleted[] = $path;
        } elseif ($current[$path] !== $sha) {
            $changed[] = ['path' => $path, 'oldSha' => $sha, 'newSha' => $current[$path]];
        } else {
            $ok[] = $path;
        }
    }
    $status = (count($changed) || count($deleted) || $tree['truncated']) ? 'stale' : 'fresh';
    $detail = ['changed' => $changed, 'deleted' => $deleted, 'fresh' => count($ok), 'treeTruncated' => $tree['truncated']];
    $now = gmdate('c');
    gobao_db_retry(function () use ($db, $status, $now, $sourceId, $detail): void {
        $db->prepare('UPDATE knowledge_sources SET status = :status, last_checked_at = :checked, updated_at = :updated WHERE id = :id')
           ->execute([':status' => $status, ':checked' => $now, ':updated' => $now, ':id' => $sourceId]);
        $db->prepare('INSERT INTO knowledge_checks(source_id, check_type, status, detail_json, created_at) VALUES(:source_id, :check_type, :status, :detail_json, :created_at)')
           ->execute([
               ':source_id' => $sourceId,
               ':check_type' => 'github_tree_compare',
               ':status' => $status,
               ':detail_json' => json_encode($detail, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '{}',
               ':created_at' => $now,
           ]);
    }, 20, 250);
    $obsidian = null;
    try {
        $obsidian = gobao_obsidian_write_source($sourceId, $env);
    } catch (Throwable $e) {
        $obsidian = ['ok' => false, 'error' => $e->getMessage()];
    }
    return ['sourceId' => $sourceId, 'status' => $status, 'detail' => $detail, 'obsidian' => $obsidian];
}

function gobao_knowledge_check_source_head(int $sourceId, array $env): array {
    $db = gobao_db();
    if (!$db) {
        throw new RuntimeException('Database is not available: ' . gobao_db_last_error());
    }
    $stmt = $db->prepare('SELECT * FROM knowledge_sources WHERE id = :id');
    $stmt->execute([':id' => $sourceId]);
    $source = $stmt->fetch(PDO::FETCH_ASSOC);
    if (!$source) {
        throw new RuntimeException('Knowledge source not found: #' . $sourceId);
    }
    $owner = trim((string)($source['repo_owner'] ?? ''));
    $repo = trim((string)($source['repo_name'] ?? ''));
    $ref = trim((string)($source['ref'] ?? '')) ?: 'main';
    if ($owner === '' || $repo === '') {
        throw new RuntimeException('Knowledge source has no GitHub repository identity: #' . $sourceId);
    }
    $url = 'https://api.github.com/repos/' . rawurlencode($owner) . '/' . rawurlencode($repo) . '/commits/' . rawurlencode($ref);
    $remote = gobao_http_get_json_result($url, gobao_github_headers($env), 8);
    if (empty($remote['ok'])) {
        throw new RuntimeException('GitHub head lookup failed: ' . (string)($remote['error'] ?? 'unknown'));
    }
    $currentSha = trim((string)($remote['data']['sha'] ?? ''));
    if ($currentSha === '') {
        throw new RuntimeException('GitHub head lookup returned no commit sha');
    }
    $meta = json_decode((string)($source['meta_json'] ?? '{}'), true);
    $meta = is_array($meta) ? $meta : [];
    $storedSha = trim((string)($meta['commitSha'] ?? ''));
    $status = $storedSha !== '' && !hash_equals(strtolower($storedSha), strtolower($currentSha)) ? 'stale' : 'fresh';
    $detail = [
        'mode' => 'github_head_compare',
        'ref' => $ref,
        'storedCommitSha' => $storedSha,
        'currentCommitSha' => $currentSha,
        'changed' => $status === 'stale',
    ];
    $now = gmdate('c');
    gobao_db_retry(function () use ($db, $status, $now, $sourceId, $detail): void {
        $db->prepare('UPDATE knowledge_sources SET status = :status, last_checked_at = :checked, updated_at = :updated WHERE id = :id')
           ->execute([':status' => $status, ':checked' => $now, ':updated' => $now, ':id' => $sourceId]);
        $db->prepare('INSERT INTO knowledge_checks(source_id, check_type, status, detail_json, created_at) VALUES(:source_id, :check_type, :status, :detail_json, :created_at)')
           ->execute([
               ':source_id' => $sourceId,
               ':check_type' => 'github_head_compare',
               ':status' => $status,
               ':detail_json' => json_encode($detail, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '{}',
               ':created_at' => $now,
           ]);
    }, 20, 250);
    return ['sourceId' => $sourceId, 'status' => $status, 'detail' => $detail];
}

function gobao_knowledge_check_all(array $env, int $limit = 20): array {
    $sources = gobao_knowledge_sources($limit);
    $checks = [];
    foreach ($sources as $source) {
        if (($source['sourceType'] ?? '') !== 'github') {
            continue;
        }
        $checks[] = gobao_knowledge_check_source((int)$source['id'], $env);
    }
    return $checks;
}

function gobao_conversation_summary_enabled(array $env): bool {
    $value = strtolower(trim((string)($env['CONVERSATION_SUMMARY_ENABLED'] ?? 'true')));
    return !in_array($value, ['0', 'false', 'off', 'no'], true);
}

function gobao_conversation_summary_interval(array $env): int {
    return max(1, min(100, (int)($env['CONVERSATION_SUMMARY_INTERVAL'] ?? 10)));
}

function gobao_conversation_summary_inline(array $env): bool {
    $value = strtolower(trim((string)($env['CONVERSATION_SUMMARY_INLINE'] ?? 'false')));
    return in_array($value, ['1', 'true', 'on', 'yes'], true);
}

function gobao_conversation_summary_due(array $env, int $turnCount): bool {
    if (!gobao_conversation_summary_enabled($env) || !gobao_db()) {
        return false;
    }
    $interval = gobao_conversation_summary_interval($env);
    return $turnCount >= $interval && ($turnCount % $interval) === 0;
}

function gobao_alternate_brain_config(string $primaryProvider, array $env): ?array {
    $order = $primaryProvider === 'qwen' ? ['deepseek', 'qwen'] : ['qwen', 'deepseek'];
    foreach ($order as $brain) {
        $cfg = gobao_brain_config($brain, $env);
        if (!empty($cfg['key']) && $cfg['provider'] !== $primaryProvider) {
            return $cfg;
        }
    }
    foreach ($order as $brain) {
        $cfg = gobao_brain_config($brain, $env);
        if (!empty($cfg['key'])) {
            return $cfg;
        }
    }
    return null;
}

function gobao_format_conversation_for_summary(array $messages, int $limit = 20): string {
    $slice = array_slice($messages, -$limit);
    $lines = [];
    foreach ($slice as $i => $message) {
        if (!is_array($message)) {
            continue;
        }
        $role = (string)($message['role'] ?? 'unknown');
        $content = trim((string)($message['content'] ?? ''));
        if ($content === '') {
            continue;
        }
        $content = mb_substr($content, 0, 1800);
        $lines[] = ($i + 1) . '. ' . $role . ":\n" . $content;
    }
    return implode("\n\n", $lines);
}

function gobao_maybe_summarize_conversation(string $sessionId, array $messages, int $turnCount, array $primaryCfg, array $env): ?array {
    if (!gobao_conversation_summary_due($env, $turnCount)) {
        return null;
    }
    $interval = gobao_conversation_summary_interval($env);
    $meta = gobao_session_meta($sessionId);
    if ((int)($meta['lastSummaryTurn'] ?? 0) >= $turnCount) {
        return null;
    }

    $conversation = gobao_format_conversation_for_summary($messages, $interval * 2);
    if ($conversation === '') {
        return null;
    }

    $summaryPrompt = [
        ['role' => 'system', 'content' => implode("\n", [
            '你是 Go宝的长期记忆整理员。',
            '任务：把最近一段用户与助手对话整理成可存入知识库的核心记忆。',
            '必须用中文，准确、简洁、可检索。',
            '不要保存 API Key、Token、密码、身份证、手机号等敏感明文；如出现，写成 [敏感信息已隐藏]。',
            '输出结构：核心摘要、用户目标、已确认事实、待办事项、风险/不确定性、建议写入知识库的关键词。',
        ])],
        ['role' => 'user', 'content' => "会话ID：{$sessionId}\n当前第 {$turnCount} 轮问答。\n\n最近对话：\n{$conversation}"],
    ];
    $summary = gobao_call_model_with_fallback($primaryCfg, $summaryPrompt, $env, 35);
    if (empty($summary['ok'])) {
        gobao_update_session_meta($sessionId, [
            'lastSummaryError' => $summary['error'] ?? 'summary_failed',
            'lastSummaryErrorAt' => gmdate('c'),
        ]);
        return ['stored' => false, 'error' => $summary['error'] ?? 'summary_failed'];
    }

    $verifierCfg = gobao_alternate_brain_config((string)$primaryCfg['provider'], $env);
    if ($verifierCfg === null) {
        gobao_update_session_meta($sessionId, [
            'lastSummaryError' => 'no_verifier_model_configured',
            'lastSummaryErrorAt' => gmdate('c'),
        ]);
        return ['stored' => false, 'error' => 'no_verifier_model_configured'];
    }

    $verifyPrompt = [
        ['role' => 'system', 'content' => implode("\n", [
            '你是独立审校 AI，负责验证 Go宝对话总结是否忠实于原文。',
            '检查是否有幻觉、遗漏关键承诺、错误归因、保存敏感信息。',
            '若有问题，给出修正后的总结；若正确，说明可以写入知识库。',
            '必须用中文输出，包含：结论、问题列表、修正建议、是否允许入库。',
        ])],
        ['role' => 'user', 'content' => "原始对话：\n{$conversation}\n\n待验证总结：\n" . (string)$summary['reply']],
    ];
    $verification = gobao_call_model_safe($verifierCfg, $verifyPrompt, 35);
    if (empty($verification['ok'])) {
        gobao_update_session_meta($sessionId, [
            'lastSummaryError' => $verification['error'] ?? 'verification_failed',
            'lastSummaryErrorAt' => gmdate('c'),
        ]);
        return ['stored' => false, 'error' => $verification['error'] ?? 'verification_failed'];
    }

    $title = '对话核心总结 · ' . $sessionId . ' · 第' . $turnCount . '轮';
    $content = implode("\n\n", [
        '# ' . $title,
        '## 自动总结',
        (string)$summary['reply'],
        '## 独立 AI 校验',
        (string)$verification['reply'],
        '## 元数据',
        '- sessionId: `' . $sessionId . '`',
        '- turnCount: `' . $turnCount . '`',
        '- summarizer: `' . $primaryCfg['provider'] . ':' . $primaryCfg['model'] . '`',
        '- verifier: `' . $verifierCfg['provider'] . ':' . $verifierCfg['model'] . '`',
        '- createdAt: `' . gmdate('c') . '`',
    ]);
    $stored = gobao_store_text_knowledge(
        'conversation_summary',
        $title,
        'conversation/' . gobao_safe_session_id($sessionId) . '/summary-turn-' . $turnCount . '.md',
        $content,
        [
            'sessionId' => $sessionId,
            'turnCount' => $turnCount,
            'summarizer' => $primaryCfg['provider'] . ':' . $primaryCfg['model'],
            'verifier' => $verifierCfg['provider'] . ':' . $verifierCfg['model'],
        ],
        $env
    );

    gobao_store_learning($sessionId, [
        'query' => $title,
        'summary' => (string)$summary['reply'],
        'intent' => 'conversation_summary',
        'confidence' => 0.82,
        'actionPlan' => ['verifiedBy' => $verifierCfg['provider'], 'storedAsSourceId' => $stored['sourceId'] ?? null],
        'sources' => [['type' => 'conversation', 'sessionId' => $sessionId, 'turnCount' => $turnCount]],
        'provider' => $primaryCfg['provider'] . '+verified_by_' . $verifierCfg['provider'],
        'model' => $primaryCfg['model'] . ' + ' . $verifierCfg['model'],
        'at' => gmdate('c'),
    ]);

    gobao_update_session_meta($sessionId, [
        'lastSummaryTurn' => $turnCount,
        'lastSummaryAt' => gmdate('c'),
        'lastSummarySourceId' => $stored['sourceId'] ?? null,
        'lastSummaryVerifier' => $verifierCfg['provider'],
        'lastSummaryError' => null,
    ]);

    return ['stored' => true] + $stored;
}

function gobao_route_brain(string $message, string $requested): string {
    if ($requested !== '' && $requested !== 'auto') {
        return $requested;
    }
    if (preg_match('/(GitHub|github|repo|repository|n8n|Automa|Dify|OpenSpace|CAD|API|SSL|Broken pipe|fetch|download|ingest|learn|sync|code|file|files)/iu', $message)) {
        return 'deepseek';
    }
    if (preg_match('/(代码|文件|工程|调试|报错|修复|脚本|HTML|CSS|JS|Node|npm|git|build|bug|error|stack)/iu', $message)) {
        return 'deepseek';
    }
    if (preg_match('/(规划|复杂|多步骤|架构|工具|agent|执行器|上下文|长期记忆|推理)/iu', $message)) {
        return 'qwen';
    }
    return 'qwen';
}

function gobao_brain_config(string $brain, array $env): array {
    if ($brain === 'qwen') {
        return [
            'provider' => 'qwen',
            'key' => $env['DASHSCOPE_API_KEY'] ?? $env['QWEN_API_KEY'] ?? '',
            'baseUrl' => $env['QWEN_BASE_URL'] ?? 'https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions',
            'model' => $env['QWEN_MODEL'] ?? 'qwen-plus',
        ];
    }
    if ($brain === 'deepseek') {
        return [
            'provider' => 'deepseek',
            'key' => $env['DEEPSEEK_API_KEY'] ?? '',
            'baseUrl' => $env['DEEPSEEK_BASE_URL'] ?? 'https://api.deepseek.com/chat/completions',
            'model' => $env['DEEPSEEK_MODEL'] ?? 'deepseek-chat',
        ];
    }
    return [
        'provider' => 'qwen',
        'key' => $env['DASHSCOPE_API_KEY'] ?? $env['QWEN_API_KEY'] ?? '',
        'baseUrl' => $env['QWEN_BASE_URL'] ?? 'https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions',
        'model' => $env['QWEN_MODEL'] ?? 'qwen-plus',
    ];
}

function gobao_pick_brain(string $preferred, array $env): ?array {
    $order = array_values(array_unique(array_filter([$preferred, 'qwen', 'deepseek'])));
    foreach ($order as $brain) {
        $cfg = gobao_brain_config($brain, $env);
        if (!empty($cfg['key'])) {
            return $cfg;
        }
    }
    return null;
}

function gobao_model_fallback_enabled(array $env): bool {
    $raw = strtolower(trim((string)($env['MODEL_FALLBACK_ENABLED'] ?? $env['GOBAO_MODEL_FALLBACK'] ?? 'true')));
    return !in_array($raw, ['0', 'false', 'off', 'no', 'disabled'], true);
}

function gobao_model_signature(array $cfg): string {
    return strtolower((string)($cfg['provider'] ?? '')) . '|' . strtolower((string)($cfg['baseUrl'] ?? '')) . '|' . strtolower((string)($cfg['model'] ?? ''));
}

function gobao_model_fallback_order(array $primaryCfg, array $env): array {
    $providers = [(string)($primaryCfg['provider'] ?? '')];
    $providers[] = ($providers[0] ?? '') === 'qwen' ? 'deepseek' : 'qwen';
    $providers[] = 'deepseek';
    $providers[] = 'qwen';

    $configs = [];
    $seen = [];
    foreach ($providers as $provider) {
        if ($provider === '') {
            continue;
        }
        $cfg = $provider === (string)($primaryCfg['provider'] ?? '') ? $primaryCfg : gobao_brain_config($provider, $env);
        if (empty($cfg['key'])) {
            continue;
        }
        $sig = gobao_model_signature($cfg);
        if (isset($seen[$sig])) {
            continue;
        }
        $seen[$sig] = true;
        $configs[] = $cfg;
    }
    return $configs;
}

function gobao_model_attempt_summary(array $cfg, array $result, int $index): array {
    return array_filter([
        'index' => $index,
        'provider' => (string)($cfg['provider'] ?? ''),
        'model' => (string)($cfg['model'] ?? ''),
        'baseUrl' => (string)($cfg['baseUrl'] ?? ''),
        'ok' => !empty($result['ok']),
        'status' => $result['status'] ?? null,
        'latencyMs' => (int)($result['latencyMs'] ?? 0),
        'error' => empty($result['ok']) ? (string)($result['error'] ?? 'model_failed') : null,
    ], static fn($value) => $value !== null && $value !== '');
}

function gobao_call_model_safe(array $cfg, array $messages, int $timeout = 45): array {
    if (empty($cfg['key'])) {
        return ['ok' => false, 'error' => ($cfg['provider'] ?? 'model') . ' key is not configured'];
    }
    $payload = json_encode([
        'model' => $cfg['model'],
        'messages' => $messages,
        'temperature' => 0.4,
    ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);

    $ch = curl_init($cfg['baseUrl']);
    $t0 = microtime(true);
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_POST => true,
        CURLOPT_HTTPHEADER => [
            'Content-Type: application/json',
            'Authorization: Bearer ' . $cfg['key'],
        ],
        CURLOPT_POSTFIELDS => $payload,
        CURLOPT_TIMEOUT => $timeout,
        CURLOPT_CONNECTTIMEOUT => min(12, max(5, $timeout)),
        CURLOPT_HTTP_VERSION => defined('CURL_HTTP_VERSION_1_1') ? CURL_HTTP_VERSION_1_1 : 2,
        CURLOPT_ENCODING => '',
    ]);
    $body = curl_exec($ch);
    $err = curl_error($ch);
    $status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
    $latencyMs = (int)round((microtime(true) - $t0) * 1000);
    curl_close($ch);

    if ($body === false || $err !== '') {
        return ['ok' => false, 'error' => $cfg['provider'] . ' request failed: ' . $err, 'latencyMs' => $latencyMs];
    }
    $data = json_decode($body ?: '', true);
    if ($status < 200 || $status >= 300) {
        $msg = $data['error']['message'] ?? ('HTTP ' . $status);
        return ['ok' => false, 'error' => $cfg['provider'] . ' request failed: ' . $msg, 'status' => $status, 'latencyMs' => $latencyMs];
    }
    $content = $data['choices'][0]['message']['content'] ?? '';
    if (!is_string($content) || trim($content) === '') {
        return ['ok' => false, 'error' => $cfg['provider'] . ' returned empty response', 'latencyMs' => $latencyMs];
    }
    $usage = is_array($data['usage'] ?? null) ? $data['usage'] : [];
    return [
        'ok' => true,
        'reply' => $content,
        'provider' => $cfg['provider'],
        'model' => $cfg['model'],
        'usage' => [
            'promptTokens' => (int)($usage['prompt_tokens'] ?? $usage['input_tokens'] ?? 0),
            'completionTokens' => (int)($usage['completion_tokens'] ?? $usage['output_tokens'] ?? 0),
            'totalTokens' => (int)($usage['total_tokens'] ?? 0),
            'estimatedInputTokens' => gobao_estimate_tokens($payload),
            'estimatedOutputTokens' => gobao_estimate_tokens($content),
        ],
        'latencyMs' => $latencyMs,
    ];
}

function gobao_call_model_with_fallback(array $primaryCfg, array $messages, array $env, int $timeout = 45): array {
    $configs = gobao_model_fallback_enabled($env)
        ? gobao_model_fallback_order($primaryCfg, $env)
        : [$primaryCfg];
    if (!$configs) {
        return ['ok' => false, 'error' => 'No model API key configured', 'attempts' => [], 'retryCount' => 0, 'latencyMs' => 0];
    }

    $attempts = [];
    $totalLatencyMs = 0;
    $last = null;
    foreach ($configs as $index => $cfg) {
        $component = 'model:' . strtolower((string)($cfg['provider'] ?? 'unknown')) . ':' . strtolower((string)($cfg['model'] ?? 'default'));
        if (gobao_self_heal_circuit_open($component)) {
            $skipped = ['ok' => false, 'error' => 'circuit_open', 'status' => 503, 'latencyMs' => 0];
            $attempts[] = gobao_model_attempt_summary($cfg, $skipped, count($attempts) + 1);
            $last = $skipped;
            continue;
        }
        $maxAttempts = 2;
        for ($localAttempt = 0; $localAttempt < $maxAttempts; $localAttempt++) {
            $result = gobao_call_model_safe($cfg, $messages, $timeout);
            $totalLatencyMs += (int)($result['latencyMs'] ?? 0);
            $attempts[] = gobao_model_attempt_summary($cfg, $result, count($attempts) + 1);
            if (!empty($result['ok'])) {
                gobao_self_heal_record_success($component);
                $result['attempts'] = $attempts;
                $result['fallbackUsed'] = $index > 0;
                $result['retryCount'] = max(0, count($attempts) - 1);
                $result['latencyMs'] = $totalLatencyMs > 0 ? $totalLatencyMs : (int)($result['latencyMs'] ?? 0);
                return $result;
            }
            $last = $result;
            $error = (string)($result['error'] ?? 'model request failed');
            $status = isset($result['status']) && is_numeric($result['status']) ? (int)$result['status'] : null;
            gobao_self_heal_record_failure($component, $error);
            if ($localAttempt + 1 >= $maxAttempts || !gobao_is_transient_error($error, $status)) {
                break;
            }
            usleep(300000);
        }
    }

    return [
        'ok' => false,
        'error' => (string)($last['error'] ?? 'model request failed'),
        'status' => $last['status'] ?? null,
        'attempts' => $attempts,
        'fallbackUsed' => count($attempts) > 1,
        'retryCount' => max(0, count($attempts) - count($configs)),
        'latencyMs' => $totalLatencyMs,
    ];
}

function gobao_call_model(array $cfg, array $messages): string {
    $result = gobao_call_model_safe($cfg, $messages, 45);
    if (empty($result['ok'])) {
        gobao_fail((string)($result['error'] ?? ($cfg['provider'] . ' request failed')), 502);
    }
    return (string)$result['reply'];
}

function gobao_http_get_json(string $url, array $headers = [], int $timeout = 20): array {
    $ch = curl_init($url);
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPGET => true,
        CURLOPT_HTTPHEADER => $headers,
        CURLOPT_TIMEOUT => $timeout,
    ]);
    $body = curl_exec($ch);
    $err = curl_error($ch);
    $status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);

    if ($body === false || $err !== '') {
        gobao_fail('Search request failed: ' . $err, 502);
    }
    $data = json_decode($body ?: '', true);
    if ($status < 200 || $status >= 300 || !is_array($data)) {
        gobao_fail('Search provider returned HTTP ' . $status, 502);
    }
    return $data;
}

function gobao_http_get_text(string $url, array $headers = [], int $timeout = 20): string {
    $ch = curl_init($url);
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPGET => true,
        CURLOPT_HTTPHEADER => $headers,
        CURLOPT_TIMEOUT => $timeout,
        CURLOPT_FOLLOWLOCATION => true,
        CURLOPT_MAXREDIRS => 3,
        CURLOPT_ENCODING => '',
    ]);
    $body = curl_exec($ch);
    $err = curl_error($ch);
    $status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);

    if ($body === false || $err !== '') {
        gobao_fail('Search request failed: ' . $err, 502);
    }
    if ($status < 200 || $status >= 300) {
        gobao_fail('Search provider returned HTTP ' . $status, 502);
    }
    return (string) $body;
}

function gobao_clean_text(string $text): string {
    $text = html_entity_decode(strip_tags($text), ENT_QUOTES | ENT_HTML5, 'UTF-8');
    $text = preg_replace('/\s+/u', ' ', $text) ?: '';
    return trim($text);
}

function gobao_decode_duckduckgo_url(string $url): string {
    $url = html_entity_decode($url, ENT_QUOTES | ENT_HTML5, 'UTF-8');
    $parts = parse_url($url);
    if (!empty($parts['query'])) {
        parse_str($parts['query'], $query);
        if (!empty($query['uddg']) && is_string($query['uddg'])) {
            return $query['uddg'];
        }
    }
    return $url;
}

function gobao_search_duckduckgo(string $query, int $limit = 5): array {
    $url = 'https://duckduckgo.com/html/?q=' . rawurlencode($query);
    $html = gobao_http_get_text($url, [
        'User-Agent: Mozilla/5.0 GO-Bao-Learning/1.0',
        'Accept: text/html,application/xhtml+xml',
    ], 18);

    $results = [];
    if (preg_match_all('/<a[^>]+class="[^"]*result__a[^"]*"[^>]+href="([^"]+)"[^>]*>(.*?)<\/a>/is', $html, $matches, PREG_SET_ORDER)) {
        foreach ($matches as $match) {
            $title = gobao_clean_text($match[2]);
            $link = gobao_decode_duckduckgo_url($match[1]);
            if ($title === '' || $link === '') {
                continue;
            }
            $results[] = [
                'title' => $title,
                'url' => $link,
                'snippet' => '',
            ];
            if (count($results) >= $limit) {
                break;
            }
        }
    }

    if (preg_match_all('/<a[^>]+class="[^"]*result__snippet[^"]*"[^>]*>(.*?)<\/a>|<div[^>]+class="[^"]*result__snippet[^"]*"[^>]*>(.*?)<\/div>/is', $html, $snips, PREG_SET_ORDER)) {
        foreach ($results as $i => $row) {
            $snippetRaw = $snips[$i][1] ?? $snips[$i][2] ?? '';
            $results[$i]['snippet'] = gobao_clean_text($snippetRaw);
        }
    }

    return $results;
}

function gobao_search_web(string $query, array $env, int $limit = 5): array {
    $limit = max(1, min(8, $limit));
    if (!empty($env['SERPAPI_KEY'])) {
        $url = 'https://serpapi.com/search.json?engine=google&hl=zh-cn&num=' . $limit . '&q=' . rawurlencode($query) . '&api_key=' . rawurlencode($env['SERPAPI_KEY']);
        $data = gobao_http_get_json($url, [], 25);
        $items = $data['organic_results'] ?? [];
        $results = [];
        foreach ($items as $item) {
            if (!is_array($item)) {
                continue;
            }
            $title = trim((string)($item['title'] ?? ''));
            $link = trim((string)($item['link'] ?? ''));
            if ($title === '' || $link === '') {
                continue;
            }
            $results[] = [
                'title' => $title,
                'url' => $link,
                'snippet' => trim((string)($item['snippet'] ?? '')),
            ];
            if (count($results) >= $limit) {
                break;
            }
        }
        return $results;
    }

    return gobao_search_duckduckgo($query, $limit);
}

function gobao_search_configured(array $env): array {
    return [
        'provider' => !empty($env['SERPAPI_KEY']) ? 'serpapi' : 'duckduckgo',
        'serpapi' => !empty($env['SERPAPI_KEY']),
        'fallback' => 'duckduckgo',
    ];
}

function gobao_query_contains_secret(string $query): bool {
    return (bool) preg_match('/(sk-[a-zA-Z0-9_-]{12,}|api[_\s-]*key|secret[_\s-]*key|access[_\s-]*token|password|passwd|密码|密钥|令牌|身份证|银行卡|验证码)/iu', $query);
}


