import fsSync, { promises as fs } from 'node:fs';
import path from 'node:path';
import readline from 'node:readline';
import { spawn } from 'cross-spawn';
import { rgPath } from '@vscode/ripgrep';
import { projectsDb, sessionsDb } from '../../../modules/database/index.js';
const SUPPORTED_PROVIDERS = new Set(['claude', 'codex', 'gemini']);
const MAX_MATCHES_PER_SESSION = 2;
const RIPGREP_FILE_CHUNK_SIZE = 40;
const RIPGREP_CHUNK_CONCURRENCY = 6;
const UNKNOWN_PROJECT_KEY = '__unknown_project__';
const INTERNAL_CONTENT_PREFIXES = [
    '<system-reminder>',
    'Caveat:',
    'Invalid API key',
    '[Request interrupted',
];
/**
 * Codex includes extra internal metadata tags that should not surface as
 * user-facing searchable conversation content.
 */
const CODEX_INTERNAL_CONTENT_PREFIXES = [
    '<environment_context>',
    '<cwd>',
];
function normalizeComparablePath(inputPath) {
    if (!inputPath || typeof inputPath !== 'string') {
        return '';
    }
    const withoutLongPathPrefix = inputPath.startsWith('\\\\?\\')
        ? inputPath.slice(4)
        : inputPath;
    const normalized = path.normalize(withoutLongPathPrefix.trim());
    if (!normalized) {
        return '';
    }
    const resolved = path.resolve(normalized);
    return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
}
function chunkArray(items, size) {
    if (size <= 0) {
        return [items];
    }
    const chunks = [];
    for (let idx = 0; idx < items.length; idx += size) {
        chunks.push(items.slice(idx, idx + size));
    }
    return chunks;
}
function getSessionKey(session) {
    return `${session.provider}:${session.session_id}`;
}
function makeProjectKey(projectPath) {
    const normalized = typeof projectPath === 'string' ? projectPath.trim() : '';
    return normalized.length > 0 ? normalized : UNKNOWN_PROJECT_KEY;
}
function toSummaryText(customName, fallback, emptyLabel) {
    const trimmedCustomName = typeof customName === 'string' ? customName.trim() : '';
    if (trimmedCustomName) {
        return trimmedCustomName;
    }
    const trimmedFallback = typeof fallback === 'string' ? fallback.trim() : '';
    if (!trimmedFallback) {
        return emptyLabel;
    }
    return trimmedFallback.length > 50 ? `${trimmedFallback.slice(0, 50)}...` : trimmedFallback;
}
function isInternalContent(content) {
    return INTERNAL_CONTENT_PREFIXES.some((prefix) => content.startsWith(prefix));
}
function isInternalCodexContent(content) {
    const normalized = content.trimStart();
    return CODEX_INTERNAL_CONTENT_PREFIXES.some((prefix) => normalized.startsWith(prefix));
}
function escapeRegex(value) {
    return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function createWordMatcher(rawQuery, words) {
    const normalizedQuery = rawQuery.trim().replace(/\s+/g, ' ');
    const requireExactPhrase = words.length > 1 && normalizedQuery.length > 0;
    const wordPatterns = words.map((word) => new RegExp(`(?<!\\p{L})${escapeRegex(word)}(?!\\p{L})`, 'u'));
    const phrasePattern = words.map((word) => escapeRegex(word)).join('\\s+');
    const phraseRegex = new RegExp(phrasePattern, 'iu');
    const allWordsMatch = (textLower) => wordPatterns.every((pattern) => pattern.test(textLower));
    const matchesQuery = (text) => {
        if (typeof text !== 'string' || text.length === 0) {
            return false;
        }
        if (requireExactPhrase) {
            return phraseRegex.test(text);
        }
        if (phraseRegex.test(text)) {
            return true;
        }
        if (words.length === 1) {
            return allWordsMatch(text.toLowerCase());
        }
        return allWordsMatch(text.toLowerCase());
    };
    const buildSnippet = (text, snippetLen = 150) => {
        const textLower = text.toLowerCase();
        let firstIndex = -1;
        let firstWordLen = 0;
        let phraseStart = -1;
        let phraseLength = 0;
        const phraseMatch = phraseRegex.exec(text);
        if (phraseMatch) {
            phraseStart = phraseMatch.index;
            phraseLength = phraseMatch[0].length;
            firstIndex = phraseStart;
            firstWordLen = phraseLength;
        }
        if (firstIndex === -1) {
            for (const word of words) {
                const regex = new RegExp(`(?<!\\p{L})${escapeRegex(word)}(?!\\p{L})`, 'u');
                const match = regex.exec(textLower);
                if (match && (firstIndex === -1 || match.index < firstIndex)) {
                    firstIndex = match.index;
                    firstWordLen = word.length;
                }
            }
        }
        if (firstIndex === -1) {
            firstIndex = 0;
        }
        const halfLen = Math.floor(snippetLen / 2);
        const start = Math.max(0, firstIndex - halfLen);
        const end = Math.min(text.length, firstIndex + halfLen + firstWordLen);
        const prefix = start > 0 ? '...' : '';
        const suffix = end < text.length ? '...' : '';
        const snippetBody = text.slice(start, end).replace(/\n/g, ' ');
        const snippet = `${prefix}${snippetBody}${suffix}`;
        const snippetLower = snippet.toLowerCase();
        const highlights = [];
        if (phraseStart >= start && phraseStart + phraseLength <= end) {
            const phraseOffset = prefix.length + (phraseStart - start);
            highlights.push({
                start: phraseOffset,
                end: phraseOffset + phraseLength,
            });
        }
        if (!requireExactPhrase) {
            for (const word of words) {
                const regex = new RegExp(`(?<!\\p{L})${escapeRegex(word)}(?!\\p{L})`, 'gu');
                let match = regex.exec(snippetLower);
                while (match) {
                    highlights.push({ start: match.index, end: match.index + word.length });
                    match = regex.exec(snippetLower);
                }
            }
        }
        highlights.sort((left, right) => left.start - right.start);
        const merged = [];
        for (const highlight of highlights) {
            const previous = merged[merged.length - 1];
            if (previous && highlight.start <= previous.end) {
                previous.end = Math.max(previous.end, highlight.end);
            }
            else {
                merged.push({ ...highlight });
            }
        }
        return { snippet, highlights: merged };
    };
    return { matchesQuery, buildSnippet };
}
function extractClaudeText(content) {
    if (typeof content === 'string') {
        return content;
    }
    if (!Array.isArray(content)) {
        return '';
    }
    return content
        .filter((part) => part?.type === 'text' && typeof part?.text === 'string')
        .map((part) => String(part.text))
        .join(' ');
}
function extractTaggedContent(content, tagName) {
    const escapedTagName = tagName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
    const match = new RegExp(`<${escapedTagName}>([\\s\\S]*?)<\\/${escapedTagName}>`).exec(content);
    return match ? match[1] : null;
}
function parseClaudeLocalCommandPayload(content) {
    const commandName = extractTaggedContent(content, 'command-name');
    const commandMessage = extractTaggedContent(content, 'command-message');
    const commandArgs = extractTaggedContent(content, 'command-args');
    if (commandName === null && commandMessage === null && commandArgs === null) {
        return null;
    }
    return {
        commandName: commandName ?? '',
        commandMessage: commandMessage ?? '',
        commandArgs: commandArgs ?? '',
    };
}
function buildClaudeLocalCommandDisplayText(payload) {
    const commandName = payload.commandName.trim();
    const commandMessage = payload.commandMessage.trim();
    const commandArgs = payload.commandArgs.trim();
    const baseCommand = commandName || commandMessage;
    if (!baseCommand) {
        return '';
    }
    return commandArgs ? `${baseCommand} ${commandArgs}` : baseCommand;
}
function stripAnsiFormatting(text) {
    return text.replace(/\u001B\[[0-9;?]*[ -/]*[@-~]/g, '');
}
/**
 * Claude mixes visible chat, compact summaries, and local command wrappers into
 * the same transcript stream. Search should operate on the user-visible meaning
 * of those rows rather than the raw wrapper syntax.
 */
function extractClaudeSearchableMessage(entry) {
    if (!entry.message?.content || entry.isApiErrorMessage) {
        return null;
    }
    const rawRole = entry.message.role;
    if (rawRole !== 'user' && rawRole !== 'assistant') {
        return null;
    }
    if (typeof entry.message.content === 'string') {
        const content = String(entry.message.content);
        if (entry.isCompactSummary === true && content.trim()) {
            return {
                text: content,
                role: 'assistant',
            };
        }
        const localCommand = parseClaudeLocalCommandPayload(content);
        if (localCommand) {
            const displayText = buildClaudeLocalCommandDisplayText(localCommand);
            return displayText
                ? {
                    text: displayText,
                    role: 'user',
                }
                : null;
        }
        const localCommandStdout = extractTaggedContent(content, 'local-command-stdout');
        if (localCommandStdout !== null) {
            const stdoutText = stripAnsiFormatting(localCommandStdout).trim();
            return stdoutText
                ? {
                    text: stdoutText,
                    role: 'assistant',
                }
                : null;
        }
        if (!content || isInternalContent(content)) {
            return null;
        }
        return {
            text: content,
            role: rawRole,
        };
    }
    const text = extractClaudeText(entry.message.content);
    if (!text) {
        return null;
    }
    if (entry.isCompactSummary === true) {
        return {
            text,
            role: 'assistant',
        };
    }
    if (isInternalContent(text)) {
        return null;
    }
    return {
        text,
        role: rawRole,
    };
}
function extractCodexText(content) {
    if (typeof content === 'string') {
        return content;
    }
    if (!Array.isArray(content)) {
        return '';
    }
    return content
        .map((item) => {
        if (!item || typeof item !== 'object') {
            return '';
        }
        const record = item;
        if ((record.type === 'input_text' || record.type === 'output_text' || record.type === 'text')
            && typeof record.text === 'string') {
            return record.text;
        }
        return '';
    })
        .filter(Boolean)
        .join(' ');
}
function extractGeminiText(content) {
    if (typeof content === 'string') {
        return content;
    }
    if (!Array.isArray(content)) {
        return '';
    }
    return content
        .filter((part) => typeof part?.text === 'string')
        .map((part) => String(part.text))
        .join(' ');
}
function normalizeSearchableSessions(userId, rows) {
    const normalizedRows = [];
    const projectArchiveStateByPath = new Map();
    for (const row of rows) {
        const provider = row.provider;
        if (!SUPPORTED_PROVIDERS.has(provider)) {
            continue;
        }
        const rawJsonlPath = typeof row.jsonl_path === 'string' ? row.jsonl_path.trim() : '';
        if (!rawJsonlPath) {
            continue;
        }
        const absoluteJsonlPath = path.resolve(rawJsonlPath);
        if (!fsSync.existsSync(absoluteJsonlPath)) {
            continue;
        }
        /**
         * Active session rows can still belong to an archived project because
         * project archiving intentionally preserves the underlying session data.
         * Global conversation search should follow the visible workspace model,
         * which means excluding any session whose owning project is archived.
         *
         * Cache the archive lookup per normalized project path so one search pass
         * does not re-query the same project row for every session in that folder.
         */
        const normalizedProjectPath = typeof row.project_path === 'string' ? row.project_path.trim() : '';
        if (normalizedProjectPath) {
            if (!projectArchiveStateByPath.has(normalizedProjectPath)) {
                const projectRow = projectsDb.getProjectPath(userId, normalizedProjectPath);
                projectArchiveStateByPath.set(normalizedProjectPath, Boolean(projectRow?.isArchived));
            }
            if (projectArchiveStateByPath.get(normalizedProjectPath) === true) {
                continue;
            }
        }
        normalizedRows.push({
            ...row,
            provider,
            jsonl_path: absoluteJsonlPath,
        });
    }
    return normalizedRows;
}
function buildProjectBuckets(userId, searchableSessions) {
    const projectBuckets = new Map();
    const projectMetadataCache = new Map();
    for (const session of searchableSessions) {
        const key = makeProjectKey(session.project_path);
        if (!projectBuckets.has(key)) {
            if (!projectMetadataCache.has(key)) {
                if (key === UNKNOWN_PROJECT_KEY) {
                    projectMetadataCache.set(key, {
                        projectId: null,
                        projectDisplayName: 'Unknown Project',
                    });
                }
                else {
                    const projectRow = projectsDb.getProjectPath(userId, key);
                    const customProjectName = typeof projectRow?.custom_project_name === 'string'
                        ? projectRow.custom_project_name.trim()
                        : '';
                    const displayName = customProjectName || path.basename(key) || key;
                    projectMetadataCache.set(key, {
                        projectId: projectRow?.project_id ?? null,
                        projectDisplayName: displayName,
                    });
                }
            }
            const metadata = projectMetadataCache.get(key);
            projectBuckets.set(key, {
                key,
                projectId: metadata.projectId,
                projectName: key,
                projectDisplayName: metadata.projectDisplayName,
                sessions: [],
            });
        }
        const bucket = projectBuckets.get(key);
        bucket.sessions.push(session);
    }
    const buckets = Array.from(projectBuckets.values());
    for (const bucket of buckets) {
        bucket.sessions.sort((left, right) => {
            const leftTs = new Date(left.updated_at || left.created_at || 0).getTime();
            const rightTs = new Date(right.updated_at || right.created_at || 0).getTime();
            return rightTs - leftTs;
        });
    }
    return buckets;
}
/**
 * Executes ripgrep with the file list explicitly provided from sessionsDb jsonl paths.
 *
 * This avoids recursive directory walks and uses a fixed known candidate list.
 */
async function runRipgrepFilesWithMatches(pattern, filePaths, signal) {
    if (!pattern || filePaths.length === 0 || signal?.aborted) {
        return new Set();
    }
    return new Promise((resolve, reject) => {
        const args = [
            '--files-with-matches',
            '--no-messages',
            '--ignore-case',
            '--fixed-strings',
            '--',
            pattern,
            ...filePaths,
        ];
        const rg = spawn(rgPath, args, {
            stdio: ['ignore', 'pipe', 'pipe'],
            windowsHide: true,
        });
        const stdoutChunks = [];
        const stderrChunks = [];
        let aborted = false;
        const abortListener = () => {
            aborted = true;
            rg.kill();
        };
        if (signal) {
            signal.addEventListener('abort', abortListener, { once: true });
        }
        rg.stdout.on('data', (chunk) => {
            stdoutChunks.push(chunk);
        });
        rg.stderr.on('data', (chunk) => {
            stderrChunks.push(chunk);
        });
        rg.on('error', (error) => {
            if (signal) {
                signal.removeEventListener('abort', abortListener);
            }
            if (aborted || signal?.aborted) {
                resolve(new Set());
                return;
            }
            reject(error);
        });
        rg.on('close', (code) => {
            if (signal) {
                signal.removeEventListener('abort', abortListener);
            }
            if (aborted || signal?.aborted) {
                resolve(new Set());
                return;
            }
            if (code !== 0 && code !== 1) {
                const stderr = Buffer.concat(stderrChunks).toString('utf8').trim();
                reject(new Error(`ripgrep failed with code ${String(code)}: ${stderr}`));
                return;
            }
            const stdout = Buffer.concat(stdoutChunks).toString('utf8');
            const matchedPaths = new Set();
            for (const line of stdout.split(/\r?\n/)) {
                const trimmed = line.trim();
                if (!trimmed) {
                    continue;
                }
                matchedPaths.add(normalizeComparablePath(trimmed));
            }
            resolve(matchedPaths);
        });
    });
}
async function findMatchedFileKeys(searchablePathEntries, rawQuery, words, signal) {
    if (searchablePathEntries.length === 0 || words.length === 0 || signal?.aborted) {
        return new Set();
    }
    const normalizedQuery = rawQuery.trim().replace(/\s+/g, ' ');
    const requireExactPhrase = words.length > 1 && normalizedQuery.length > 0;
    if (requireExactPhrase) {
        let matchedForPhrase = searchablePathEntries.slice();
        // Keep ripgrep as an over-approximation for exact phrase mode by requiring
        // each word to appear somewhere in the file, then defer strict phrase
        // validation to the in-memory matcher.
        for (const word of words) {
            if (signal?.aborted) {
                return new Set();
            }
            const matchedForWord = new Set();
            const fileChunks = chunkArray(matchedForPhrase.map((entry) => entry.absolutePath), RIPGREP_FILE_CHUNK_SIZE);
            let nextChunkIndex = 0;
            const workerCount = Math.min(RIPGREP_CHUNK_CONCURRENCY, fileChunks.length);
            const workers = Array.from({ length: workerCount }, async () => {
                while (nextChunkIndex < fileChunks.length && !signal?.aborted) {
                    const currentIndex = nextChunkIndex;
                    nextChunkIndex += 1;
                    const chunkMatches = await runRipgrepFilesWithMatches(word, fileChunks[currentIndex], signal);
                    for (const matchedPath of chunkMatches) {
                        matchedForWord.add(matchedPath);
                    }
                }
            });
            await Promise.all(workers);
            if (signal?.aborted) {
                return new Set();
            }
            matchedForPhrase = matchedForPhrase.filter((entry) => matchedForWord.has(entry.normalizedPath));
            if (matchedForPhrase.length === 0) {
                break;
            }
        }
        return new Set(matchedForPhrase.map((entry) => entry.normalizedPath));
    }
    let remainingEntries = searchablePathEntries.slice();
    // Run one ripgrep pass per term and intersect by keeping only files that
    // matched every query word.
    for (const word of words) {
        if (signal?.aborted) {
            return new Set();
        }
        const matchedForWord = new Set();
        const fileChunks = chunkArray(remainingEntries.map((entry) => entry.absolutePath), RIPGREP_FILE_CHUNK_SIZE);
        let nextChunkIndex = 0;
        const workerCount = Math.min(RIPGREP_CHUNK_CONCURRENCY, fileChunks.length);
        const workers = Array.from({ length: workerCount }, async () => {
            while (nextChunkIndex < fileChunks.length && !signal?.aborted) {
                const currentIndex = nextChunkIndex;
                nextChunkIndex += 1;
                const chunkMatches = await runRipgrepFilesWithMatches(word, fileChunks[currentIndex], signal);
                for (const matchedPath of chunkMatches) {
                    matchedForWord.add(matchedPath);
                }
            }
        });
        await Promise.all(workers);
        if (signal?.aborted) {
            return new Set();
        }
        remainingEntries = remainingEntries.filter((entry) => matchedForWord.has(entry.normalizedPath));
        if (remainingEntries.length === 0) {
            break;
        }
    }
    return new Set(remainingEntries.map((entry) => entry.normalizedPath));
}
function addSessionMatch(runtime, matches, match) {
    if (runtime.totalMatches >= runtime.limit || matches.length >= MAX_MATCHES_PER_SESSION) {
        return;
    }
    matches.push(match);
    runtime.totalMatches += 1;
}
async function parseClaudeSessionMatches(session, runtime) {
    const fileKey = normalizeComparablePath(session.jsonl_path);
    if (!fileKey) {
        return null;
    }
    if (!runtime.claudeFileResultsCache.has(fileKey)) {
        const sessionsForFile = runtime.claudeSessionsByFileKey.get(fileKey) || [];
        const matchedSessionsForFile = sessionsForFile.filter((candidate) => runtime.matchedSessionKeys.has(getSessionKey(candidate)));
        const targetSessions = matchedSessionsForFile.length > 0
            ? matchedSessionsForFile
            : [session];
        const targetSessionIds = new Set(targetSessions.map((candidate) => candidate.session_id));
        const customNameBySessionId = new Map();
        for (const candidate of targetSessions) {
            customNameBySessionId.set(candidate.session_id, candidate.custom_name ?? null);
        }
        const sessionStateById = new Map();
        const getSessionState = (sessionId) => {
            if (!sessionStateById.has(sessionId)) {
                sessionStateById.set(sessionId, {
                    matches: [],
                    pendingSummaries: new Map(),
                    fallbackUserText: null,
                    fallbackAssistantText: null,
                    resolvedSummary: null,
                });
            }
            return sessionStateById.get(sessionId);
        };
        let currentSessionId = null;
        try {
            const fileStream = fsSync.createReadStream(session.jsonl_path);
            const rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity });
            for await (const line of rl) {
                if (runtime.totalMatches >= runtime.limit || runtime.isAborted()) {
                    break;
                }
                if (!line.trim()) {
                    continue;
                }
                let entry;
                try {
                    entry = JSON.parse(line);
                }
                catch {
                    continue;
                }
                if (entry.sessionId) {
                    currentSessionId = String(entry.sessionId);
                }
                const entrySessionId = entry.sessionId
                    ? String(entry.sessionId)
                    : currentSessionId;
                if (!entrySessionId || !targetSessionIds.has(entrySessionId)) {
                    continue;
                }
                const state = getSessionState(entrySessionId);
                if (entry.type === 'summary' && entry.summary) {
                    const summaryValue = String(entry.summary);
                    if (entry.sessionId) {
                        state.resolvedSummary = summaryValue;
                    }
                    else if (entry.leafUuid) {
                        state.pendingSummaries.set(String(entry.leafUuid), summaryValue);
                    }
                }
                if (!state.resolvedSummary && entry.parentUuid) {
                    const pendingSummary = state.pendingSummaries.get(String(entry.parentUuid));
                    if (pendingSummary) {
                        state.resolvedSummary = pendingSummary;
                    }
                }
                const searchableMessage = extractClaudeSearchableMessage(entry);
                if (!searchableMessage) {
                    continue;
                }
                const { text, role } = searchableMessage;
                /**
                 * Claude compact summaries are the most faithful session-summary source
                 * after a `/compact` because they describe the post-compaction state that
                 * the resumed session actually continues from. Prefer them over generic
                 * fallback user text when present.
                 */
                if (entry.isCompactSummary === true) {
                    state.resolvedSummary = text;
                }
                if (role === 'user') {
                    state.fallbackUserText = text;
                }
                else {
                    state.fallbackAssistantText = text;
                }
                if (!runtime.matchesQuery(text)) {
                    continue;
                }
                const { snippet, highlights } = runtime.buildSnippet(text);
                addSessionMatch(runtime, state.matches, {
                    role,
                    snippet,
                    highlights,
                    timestamp: entry.timestamp ? String(entry.timestamp) : null,
                    provider: 'claude',
                    messageUuid: entry.uuid ? String(entry.uuid) : null,
                });
            }
        }
        catch {
            runtime.claudeFileResultsCache.set(fileKey, new Map());
            return null;
        }
        const fileResults = new Map();
        for (const [sessionId, state] of sessionStateById.entries()) {
            if (state.matches.length === 0) {
                continue;
            }
            fileResults.set(sessionId, {
                sessionId,
                provider: 'claude',
                sessionSummary: toSummaryText(customNameBySessionId.get(sessionId) ?? null, state.resolvedSummary || state.fallbackUserText || state.fallbackAssistantText, 'New Session'),
                matches: state.matches,
            });
        }
        runtime.claudeFileResultsCache.set(fileKey, fileResults);
    }
    return runtime.claudeFileResultsCache.get(fileKey)?.get(session.session_id) ?? null;
}
function isVisibleCodexUserMessage(payload) {
    if (!payload || payload.type !== 'user_message') {
        return false;
    }
    if (payload.kind && payload.kind !== 'plain') {
        return false;
    }
    return typeof payload.message === 'string' && payload.message.trim().length > 0;
}
async function parseCodexSessionMatches(session, runtime) {
    const matches = [];
    let latestUserMessageText = null;
    const seenMessageFingerprints = new Set();
    try {
        const fileStream = fsSync.createReadStream(session.jsonl_path);
        const rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity });
        for await (const line of rl) {
            if (runtime.totalMatches >= runtime.limit || runtime.isAborted()) {
                break;
            }
            if (!line.trim()) {
                continue;
            }
            let entry;
            try {
                entry = JSON.parse(line);
            }
            catch {
                continue;
            }
            let text = null;
            let role = null;
            if (entry.type === 'event_msg' && isVisibleCodexUserMessage(entry.payload)) {
                text = String(entry.payload.message);
                role = 'user';
            }
            else if (entry.type === 'event_msg'
                && entry.payload?.type === 'agent_reasoning'
                && typeof entry.payload?.text === 'string') {
                text = String(entry.payload.text);
                role = 'assistant';
            }
            else if (entry.type === 'response_item' && entry.payload?.type === 'message') {
                const payload = entry.payload;
                if (payload.role === 'user') {
                    text = extractCodexText(payload.content);
                    role = 'user';
                }
                else if (payload.role === 'assistant') {
                    text = extractCodexText(payload.content);
                    role = 'assistant';
                }
            }
            else if (entry.type === 'response_item' && entry.payload?.type === 'reasoning') {
                const summaryText = Array.isArray(entry.payload.summary)
                    ? entry.payload.summary
                        .map((item) => (typeof item?.text === 'string' ? item.text : ''))
                        .filter(Boolean)
                        .join('\n')
                    : '';
                if (summaryText.trim()) {
                    text = summaryText;
                    role = 'assistant';
                }
            }
            if (!text || !role) {
                continue;
            }
            if (isInternalCodexContent(text)) {
                continue;
            }
            if (role === 'user') {
                latestUserMessageText = text;
            }
            const fingerprint = `${role}:${text.trim().toLowerCase()}`;
            if (seenMessageFingerprints.has(fingerprint)) {
                continue;
            }
            seenMessageFingerprints.add(fingerprint);
            if (!runtime.matchesQuery(text)) {
                continue;
            }
            const { snippet, highlights } = runtime.buildSnippet(text);
            addSessionMatch(runtime, matches, {
                role,
                snippet,
                highlights,
                timestamp: entry.timestamp ? String(entry.timestamp) : null,
                provider: 'codex',
            });
        }
    }
    catch {
        return null;
    }
    if (matches.length === 0) {
        return null;
    }
    return {
        sessionId: session.session_id,
        provider: 'codex',
        sessionSummary: toSummaryText(session.custom_name, latestUserMessageText, 'Codex Session'),
        matches,
    };
}
async function parseGeminiSessionMatches(session, runtime) {
    let data;
    try {
        data = await fs.readFile(session.jsonl_path, 'utf8');
    }
    catch {
        return null;
    }
    let parsed;
    try {
        parsed = JSON.parse(data);
    }
    catch {
        return null;
    }
    const sourceMessages = Array.isArray(parsed.messages) ? parsed.messages : [];
    if (sourceMessages.length === 0) {
        return null;
    }
    const matches = [];
    let firstUserText = null;
    for (const msg of sourceMessages) {
        if (runtime.totalMatches >= runtime.limit || runtime.isAborted()) {
            break;
        }
        const role = msg.type === 'user'
            ? 'user'
            : (msg.type === 'gemini' || msg.type === 'assistant')
                ? 'assistant'
                : null;
        if (!role) {
            continue;
        }
        const text = extractGeminiText(msg.content);
        if (!text) {
            continue;
        }
        if (role === 'user' && !firstUserText) {
            firstUserText = text;
        }
        if (!runtime.matchesQuery(text)) {
            continue;
        }
        const { snippet, highlights } = runtime.buildSnippet(text);
        addSessionMatch(runtime, matches, {
            role,
            snippet,
            highlights,
            timestamp: msg.timestamp ? String(msg.timestamp) : null,
            provider: 'gemini',
        });
    }
    if (matches.length === 0) {
        return null;
    }
    return {
        sessionId: session.session_id,
        provider: 'gemini',
        sessionSummary: toSummaryText(session.custom_name, firstUserText, 'Gemini Session'),
        matches,
    };
}
async function parseSessionMatches(session, runtime) {
    if (session.provider === 'claude') {
        return parseClaudeSessionMatches(session, runtime);
    }
    if (session.provider === 'codex') {
        return parseCodexSessionMatches(session, runtime);
    }
    return parseGeminiSessionMatches(session, runtime);
}
export async function searchConversations(userId, query, limit = 50, onProjectResult = null, signal = null) {
    const safeQuery = typeof query === 'string' ? query.trim() : '';
    const safeLimit = Math.max(1, Math.min(Number.isFinite(limit) ? limit : 50, 200));
    const words = safeQuery.toLowerCase().split(/\s+/).filter((word) => word.length > 0);
    if (words.length === 0) {
        return { results: [], totalMatches: 0, query: safeQuery };
    }
    const isAborted = () => signal?.aborted === true;
    if (isAborted()) {
        return { results: [], totalMatches: 0, query: safeQuery };
    }
    const searchableSessions = normalizeSearchableSessions(userId, sessionsDb.getAllSessions(userId));
    if (searchableSessions.length === 0) {
        return { results: [], totalMatches: 0, query: safeQuery };
    }
    const sessionsByPathKey = new Map();
    const searchablePathEntries = [];
    for (const session of searchableSessions) {
        const normalizedPath = normalizeComparablePath(session.jsonl_path);
        if (!normalizedPath) {
            continue;
        }
        if (!sessionsByPathKey.has(normalizedPath)) {
            sessionsByPathKey.set(normalizedPath, []);
            searchablePathEntries.push({
                normalizedPath,
                absolutePath: session.jsonl_path,
            });
        }
        const pathSessions = sessionsByPathKey.get(normalizedPath);
        pathSessions.push(session);
    }
    const matchedFileKeys = await findMatchedFileKeys(searchablePathEntries, safeQuery, words, signal ?? undefined);
    if (isAborted() || matchedFileKeys.size === 0) {
        return { results: [], totalMatches: 0, query: safeQuery };
    }
    const matchedSessionKeys = new Set();
    for (const fileKey of matchedFileKeys) {
        const sessions = sessionsByPathKey.get(fileKey);
        if (!sessions) {
            continue;
        }
        for (const session of sessions) {
            matchedSessionKeys.add(getSessionKey(session));
        }
    }
    const projectBuckets = buildProjectBuckets(userId, searchableSessions);
    const totalProjects = projectBuckets.length;
    const results = [];
    let scannedProjects = 0;
    const runtime = {
        ...createWordMatcher(safeQuery, words),
        limit: safeLimit,
        totalMatches: 0,
        isAborted,
        matchedSessionKeys,
        claudeSessionsByFileKey: new Map(),
        claudeFileResultsCache: new Map(),
    };
    for (const [fileKey, sessions] of sessionsByPathKey.entries()) {
        const claudeSessions = sessions.filter((session) => session.provider === 'claude');
        if (claudeSessions.length > 0) {
            runtime.claudeSessionsByFileKey.set(fileKey, claudeSessions);
        }
    }
    for (const bucket of projectBuckets) {
        if (runtime.totalMatches >= runtime.limit || runtime.isAborted()) {
            break;
        }
        const projectResult = {
            projectId: bucket.projectId,
            projectName: bucket.projectName,
            projectDisplayName: bucket.projectDisplayName,
            sessions: [],
        };
        for (const session of bucket.sessions) {
            if (runtime.totalMatches >= runtime.limit || runtime.isAborted()) {
                break;
            }
            if (!matchedSessionKeys.has(getSessionKey(session))) {
                continue;
            }
            const sessionResult = await parseSessionMatches(session, runtime);
            if (sessionResult) {
                projectResult.sessions.push(sessionResult);
            }
        }
        scannedProjects += 1;
        if (projectResult.sessions.length > 0) {
            results.push(projectResult);
            onProjectResult?.({
                projectResult,
                totalMatches: runtime.totalMatches,
                scannedProjects,
                totalProjects,
            });
        }
        else if (onProjectResult && scannedProjects % 10 === 0) {
            onProjectResult({
                projectResult: null,
                totalMatches: runtime.totalMatches,
                scannedProjects,
                totalProjects,
            });
        }
    }
    return {
        results,
        totalMatches: runtime.totalMatches,
        query: safeQuery,
    };
}
/**
 * Application service for session-conversation search.
 *
 * Provider routes call this service so route handlers stay focused on
 * request parsing/response formatting, while search execution remains
 * centralized in one place.
 */
export const sessionConversationsSearchService = {
    /**
     * Streams progress updates while the search scans provider session logs.
     */
    async search(input) {
        await searchConversations(input.userId, input.query, input.limit, input.onProgress ?? null, input.signal ?? null);
    },
};
//# sourceMappingURL=session-conversations-search.service.js.map