import * as fs from 'fs';
import * as path from 'path';
import { findBrainFiles, summarizeText } from '../utils';
import { isInside } from '../lib/paths';
import { tokenize, expandQuery, scoreTfIdfPreTokenized, extractBestExcerpt } from '../retrieval/scoring';
import { getBrainTokenIndex } from '../retrieval/brainIndex';
import { estimateTokens } from '../retrieval/contextBudget';

/**
 * Lightweight RAG that only searches a subset of the active brain.
 *
 * Why this is separate from RetrievalOrchestrator:
 *  - The orchestrator pulls in MemoryManager (5 cognitive layers) plus chat
 *    history. That payload makes sense for in-IDE chat, but not for a Telegram
 *    handler that has no chat-history continuity per chat-id and no
 *    workspace-scoped memory. Attaching memory layers to a Telegram thread
 *    would also leak unrelated short-term context across users.
 *  - This retriever is a pure function of (query, brainRoot, scopeFolders) —
 *    easy to reason about, no side effects, no coupling to VS Code.
 *
 * Folder scoping is the whole point: the agent-knowledge-map says
 * "this agent only sees `10_Wiki/Topics`" and the Telegram bot must respect
 * that. When `scopeFolders` is empty, we fall back to the entire brain
 * (matching the legacy behavior so a missing mapping doesn't silently
 * starve the bot of context).
 */

export interface ScopedRetrievalOptions {
    /** Cap on returned excerpts. Default 6. */
    maxResults?: number;
    /** Per-excerpt length cap (chars). Default 400. */
    excerptLength?: number;
    /** Whether to include `00_Raw` / `conversations` style folders. Default false. */
    includeRawConversations?: boolean;
}

export interface ScopedRetrievalChunk {
    /** Path relative to brain root, used as the title in assembled context. */
    relativePath: string;
    /** Absolute file path on disk (logging / debug). */
    filePath: string;
    excerpt: string;
    score: number;
    tokenEstimate: number;
}

export interface ScopedRetrievalResult {
    query: string;
    chunks: ScopedRetrievalChunk[];
    /** Number of files considered after scope filtering. */
    candidateCount: number;
    /** True iff `scopeFolders` constrained the search. */
    scoped: boolean;
}

function _isRawConversation(relativePath: string): boolean {
    return /(^|[\\/])(00_Raw|raw-data|conversations?|transcripts?)([\\/]|$)/i.test(relativePath);
}

function _filterToScope(allFiles: string[], scopeFolders: string[]): string[] {
    if (scopeFolders.length === 0) return allFiles;
    return allFiles.filter((file) => scopeFolders.some((folder) => isInside(folder, file)));
}

/**
 * Run TF-IDF retrieval over the scope-filtered subset of the brain.
 * Returns the top `maxResults` excerpts ranked by score.
 */
export function retrieveScoped(
    query: string,
    brainRoot: string,
    scopeFolders: string[],
    options: ScopedRetrievalOptions = {}
): ScopedRetrievalResult {
    const maxResults = options.maxResults ?? 6;
    const excerptLength = options.excerptLength ?? 400;
    const includeRaw = options.includeRawConversations ?? false;

    const empty: ScopedRetrievalResult = {
        query,
        chunks: [],
        candidateCount: 0,
        scoped: scopeFolders.length > 0,
    };
    if (!brainRoot || !fs.existsSync(brainRoot)) return empty;

    const allBrainFiles = findBrainFiles(brainRoot);
    const scopeFiltered = _filterToScope(allBrainFiles, scopeFolders);
    const candidates = scopeFiltered.filter((file) => {
        const rel = path.relative(brainRoot, file);
        return includeRaw || !_isRawConversation(rel);
    });
    if (candidates.length === 0) return { ...empty, candidateCount: 0 };

    // Tokenized docs from the persistent mtime-keyed brain index — unchanged files
    // are not re-read or re-tokenized. The index tokenizes `${basename} ${content}`
    // (titleTokens = tokenize(basename)), which is exactly what the previous
    // `scoreTfIdf` call computed here, so scoring stays byte-identical.
    const indexed = getBrainTokenIndex(brainRoot, candidates);
    if (indexed.length === 0) return { ...empty, candidateCount: candidates.length };

    const queryTokens = tokenize(query);
    const expanded = expandQuery(queryTokens);
    const scored = scoreTfIdfPreTokenized(
        expanded,
        indexed.map((d) => ({
            tokens: d.tokens,
            titleTokens: d.titleTokens,
            lastModified: d.mtimeMs,
            conflictCount: d.conflictCount,
        }))
    );

    const chunks = scored
        .filter((s) => s.score > 0)
        .sort((a, b) => b.score - a.score)
        .slice(0, maxResults)
        .map<ScopedRetrievalChunk>((s) => {
            const doc = indexed[s.index];
            // Only the chosen top-`maxResults` files are read off disk (for excerpt extraction).
            let content = '';
            try { content = fs.readFileSync(doc.filePath, 'utf8'); } catch { /* deleted just now — empty excerpt */ }
            const excerpt = extractBestExcerpt(content, expanded, excerptLength);
            const summary = summarizeText(excerpt, excerptLength);
            return {
                relativePath: doc.relativePath,
                filePath: doc.filePath,
                excerpt: summary,
                score: s.score,
                tokenEstimate: estimateTokens(summary),
            };
        });

    return {
        query,
        chunks,
        candidateCount: candidates.length,
        scoped: scopeFolders.length > 0,
    };
}

/**
 * Render the retrieval result as a single context block suitable for prefixing
 * a chat prompt. Returns an empty string when there are no chunks (so callers
 * can simply concatenate without a conditional).
 */
export function buildContextBlock(result: ScopedRetrievalResult): string {
    if (result.chunks.length === 0) return '';
    const header = result.scoped
        ? '[제2뇌 컨텍스트 — 매핑된 지식 폴더에서 검색]'
        : '[제2뇌 컨텍스트 — 전체 브레인 검색]';
    const body = result.chunks
        .map((c, i) => `(#${i + 1}) ${c.relativePath}\n${c.excerpt}`)
        .join('\n\n---\n\n');
    return `${header}\n\n${body}`;
}
