/**
 * ============================================================
 * Brain Index — persistent, mtime-keyed tokenized cache of the Second Brain
 *
 * RAG 검색은 매 질의마다 브레인의 모든 .md 파일을 읽고 토크나이즈해서 TF-IDF 점수를
 * 계산했습니다 — 파일 수가 많아지면 그게 병목입니다.
 *
 * 이 모듈은 `<brainPath>/.astra/brain-index.json` 에 파일별 토큰 배열을 (mtime+size 키로)
 * 저장해 두고, 다음 질의에서는 *변경된 파일만* 다시 읽어 토크나이즈합니다. 나머지는 디스크/메모리
 * 캐시에서 그대로 가져옵니다. 디스크 쓰기는 디바운스되고 실패해도 in-memory 로만 동작합니다.
 * ============================================================
 */

import * as fs from 'fs';
import * as path from 'path';
import { tokenize, countConflictIndicators } from './scoring';
import { detectLessonKind } from './lessonHelpers';
import { logInfo } from '../utils';

// v4 adds optional per-file `embedding` for hybrid (sparse+dense) retrieval.
// Older v3 indexes are auto-rebuilt on first load — no migration needed because
// the cache is derivable from the brain itself.
const INDEX_VERSION = 4;
const INDEX_DIR = '.astra';
const INDEX_FILE = 'brain-index.json';
/** 인덱스가 이 개수를 넘으면 이번 스캔에서 못 본 항목을 정리합니다 (삭제된 파일 누적 방지). */
const MAX_INDEX_ENTRIES = 12000;
/** 디스크 쓰기 디바운스. */
const WRITE_DEBOUNCE_MS = 1500;

interface IndexEntry {
    mtimeMs: number;
    size: number;
    title: string;          // basename without .md
    relativePath: string;   // relative to brainPath
    tokens: string[];       // tokenize(`${title} ${content}`)
    titleTokens: string[];  // tokenize(title)
    conflictCount: number;  // countConflictIndicators(`${title} ${content}`)
    kind: string;           // '' for an ordinary note, else 'lesson' | 'playbook' | 'qa-finding'
    /**
     * Dense embedding for hybrid retrieval. Populated lazily by a background
     * pass after the file is tokenized — TF-IDF queries don't wait on it.
     * Cleared when mtimeMs/size change because the content moved on.
     */
    embedding?: number[];
    /** Embedding model the vector was produced with — invalidates the vector when the user switches models. */
    embeddingModel?: string;
}

interface PersistedIndex {
    version: number;
    entries: Record<string, IndexEntry>; // keyed by absolute file path
}

export interface IndexedBrainDoc {
    filePath: string;
    relativePath: string;
    title: string;
    tokens: string[];
    titleTokens: string[];
    conflictCount: number;
    mtimeMs: number;
    /** '' for an ordinary note; 'lesson' | 'playbook' | 'qa-finding' for an Experience-Memory card. */
    kind: string;
}

interface BrainState {
    index: PersistedIndex;
    dirty: boolean;
    diskPath: string | null;   // null if we can't determine a writable path
    writeTimer?: ReturnType<typeof setTimeout>;
}

const _states = new Map<string, BrainState>();

function indexFileFor(brainPath: string): string {
    return path.join(brainPath, INDEX_DIR, INDEX_FILE);
}

function loadState(brainPath: string): BrainState {
    const existing = _states.get(brainPath);
    if (existing) return existing;

    let index: PersistedIndex = { version: INDEX_VERSION, entries: {} };
    let diskPath: string | null = null;
    try {
        diskPath = indexFileFor(brainPath);
        if (fs.existsSync(diskPath)) {
            const raw = JSON.parse(fs.readFileSync(diskPath, 'utf8'));
            if (raw && raw.version === INDEX_VERSION && raw.entries && typeof raw.entries === 'object') {
                index = raw as PersistedIndex;
            } else {
                logInfo('Brain index is stale/unrecognized — rebuilding.', { brainPath });
            }
        }
    } catch (e: any) {
        logInfo('Brain index load failed — starting fresh.', { brainPath, error: e?.message || String(e) });
        index = { version: INDEX_VERSION, entries: {} };
    }
    const st: BrainState = { index, dirty: false, diskPath };
    _states.set(brainPath, st);
    return st;
}

function scheduleWrite(st: BrainState, brainPath: string): void {
    if (!st.dirty || !st.diskPath || st.writeTimer) return;
    const timer = setTimeout(() => {
        st.writeTimer = undefined;
        if (!st.dirty || !st.diskPath) return;
        try {
            const dir = path.dirname(st.diskPath);
            fs.mkdirSync(dir, { recursive: true });
            // One-time .gitignore so the cache dir never gets committed into a Second Brain git repo.
            const gi = path.join(dir, '.gitignore');
            if (!fs.existsSync(gi)) {
                try { fs.writeFileSync(gi, '*\n', 'utf8'); } catch { /* non-fatal */ }
            }
            const tmp = `${st.diskPath}.tmp`;
            fs.writeFileSync(tmp, JSON.stringify(st.index), 'utf8');
            fs.renameSync(tmp, st.diskPath);
            st.dirty = false;
        } catch (e: any) {
            logInfo('Brain index write failed (continuing in-memory only).', { brainPath, error: e?.message || String(e) });
        }
    }, WRITE_DEBOUNCE_MS);
    if (typeof (timer as any).unref === 'function') (timer as any).unref();
    st.writeTimer = timer;
}

/**
 * Returns tokenized representations for `files` (absolute brain-file paths, already
 * scoped/filtered by the caller). Unchanged files are served from the index; changed/new
 * files are read & tokenized and the index is updated (debounced disk write).
 *
 * Safe to call with an empty/invalid `brainPath` or empty list — returns [].
 */
export function getBrainTokenIndex(brainPath: string, files: string[]): IndexedBrainDoc[] {
    if (!brainPath || !Array.isArray(files) || files.length === 0) return [];
    const st = loadState(brainPath);
    const out: IndexedBrainDoc[] = [];
    const seen = new Set<string>();
    let reindexed = 0;

    for (const file of files) {
        seen.add(file);
        let stat: fs.Stats;
        try {
            stat = fs.statSync(file);
        } catch {
            continue; // listed but gone now — skip silently
        }
        const cached = st.index.entries[file];
        if (cached
            && cached.mtimeMs === stat.mtimeMs
            && cached.size === stat.size
            && Array.isArray(cached.tokens)
            && Array.isArray(cached.titleTokens)) {
            out.push({
                filePath: file,
                relativePath: cached.relativePath,
                title: cached.title,
                tokens: cached.tokens,
                titleTokens: cached.titleTokens,
                conflictCount: cached.conflictCount || 0,
                mtimeMs: cached.mtimeMs,
                kind: cached.kind || '',
            });
            continue;
        }
        // (Re)index this file.
        let content = '';
        try {
            content = fs.readFileSync(file, 'utf8');
        } catch {
            continue;
        }
        const relativePath = path.relative(brainPath, file);
        const title = path.basename(file, '.md');
        const combined = `${title} ${content}`;
        const entry: IndexEntry = {
            mtimeMs: stat.mtimeMs,
            size: stat.size,
            title,
            relativePath,
            tokens: tokenize(combined),
            titleTokens: tokenize(title),
            conflictCount: countConflictIndicators(combined),
            kind: detectLessonKind(relativePath, content),
        };
        st.index.entries[file] = entry;
        st.dirty = true;
        reindexed++;
        out.push({
            filePath: file,
            relativePath,
            title,
            tokens: entry.tokens,
            titleTokens: entry.titleTokens,
            conflictCount: entry.conflictCount,
            mtimeMs: entry.mtimeMs,
            kind: entry.kind,
        });
    }

    // Prune stale entries. We only prune when this looked like a (near-)full scan — i.e. we saw
    // most of the index — so an agent-scoped query doesn't evict cache for out-of-scope files.
    // (Falls back to a hard prune if the index has grown beyond MAX_INDEX_ENTRIES.)
    const entryKeys = Object.keys(st.index.entries);
    const looksFullScan = seen.size >= entryKeys.length * 0.8;
    if (looksFullScan || entryKeys.length > MAX_INDEX_ENTRIES) {
        for (const key of entryKeys) {
            if (!seen.has(key)) {
                delete st.index.entries[key];
                st.dirty = true;
            }
        }
    }

    if (reindexed > 0) {
        logInfo('Brain index updated.', { brainPath, files: files.length, reindexed, totalEntries: Object.keys(st.index.entries).length });
    }
    if (st.dirty) scheduleWrite(st, brainPath);
    return out;
}

/**
 * Pull (filePath, embedding) for every file in `filePaths` that has a current
 * cached vector under `model`. Caller uses this to rank top TF-IDF candidates
 * by cosine similarity. Files missing an embedding are silently omitted.
 */
export function getBrainEmbeddings(brainPath: string, filePaths: string[], model: string): Map<string, number[]> {
    const out = new Map<string, number[]>();
    if (!brainPath || !model.trim() || !Array.isArray(filePaths) || filePaths.length === 0) return out;
    const st = _states.get(brainPath);
    if (!st) return out;
    for (const fp of filePaths) {
        const entry = st.index.entries[fp];
        if (!entry?.embedding || entry.embeddingModel !== model) continue;
        if (!Array.isArray(entry.embedding) || entry.embedding.length === 0) continue;
        out.set(fp, entry.embedding);
    }
    return out;
}

/**
 * Background fill: for each file under `filePaths`, embed its content with
 * `embedFn` if no current vector exists for `model`. Calls `embedFn` in
 * caller-controlled batches (caller can chunk filePaths as wanted), and saves
 * the disk index. Designed to be fire-and-forget — failures are logged and
 * swallowed.
 *
 * Returns the count of newly embedded files (0 when everything was cached
 * already or the model is empty).
 */
export async function backfillBrainEmbeddings(
    brainPath: string,
    filePaths: string[],
    model: string,
    embedFn: (texts: string[]) => Promise<number[][]>,
): Promise<number> {
    if (!brainPath || !model.trim() || !Array.isArray(filePaths) || filePaths.length === 0) return 0;
    const st = _states.get(brainPath);
    if (!st) return 0;
    const stale: string[] = [];
    for (const fp of filePaths) {
        const entry = st.index.entries[fp];
        if (!entry) continue;
        if (entry.embedding && entry.embeddingModel === model) continue;
        stale.push(fp);
    }
    if (stale.length === 0) return 0;
    // Build embedding inputs from cached tokens (much cheaper than re-reading
    // the file). We re-read content only when the cached tokens are missing
    // somehow — defensive, but the index always has them after tokenization.
    const texts: string[] = [];
    const keys: string[] = [];
    for (const fp of stale) {
        const entry = st.index.entries[fp];
        if (!entry) continue;
        let text = '';
        if (Array.isArray(entry.tokens) && entry.tokens.length > 0) {
            text = `${entry.title}\n${entry.tokens.join(' ')}`;
        } else {
            try { text = fs.readFileSync(fp, 'utf8'); } catch { continue; }
        }
        if (!text.trim()) continue;
        texts.push(text);
        keys.push(fp);
    }
    if (texts.length === 0) return 0;
    try {
        const vectors = await embedFn(texts);
        for (let i = 0; i < vectors.length && i < keys.length; i++) {
            const v = vectors[i];
            if (!Array.isArray(v) || v.length === 0) continue;
            const entry = st.index.entries[keys[i]];
            if (!entry) continue;
            entry.embedding = v;
            entry.embeddingModel = model;
            st.dirty = true;
        }
        if (st.dirty) {
            logInfo('Brain embeddings backfilled.', { brainPath, model, embedded: vectors.length });
            scheduleWrite(st, brainPath);
        }
        return vectors.length;
    } catch (e: any) {
        logInfo('Brain embedding backfill failed (TF-IDF still works).', { brainPath, model, error: e?.message ?? String(e) });
        return 0;
    }
}

/** Drop the in-memory index (and pending write) for one brain, or all brains. The disk file is left as-is. */
export function clearBrainTokenIndex(brainPath?: string): void {
    if (brainPath === undefined) {
        for (const st of _states.values()) {
            if (st.writeTimer) { clearTimeout(st.writeTimer); st.writeTimer = undefined; }
        }
        _states.clear();
        return;
    }
    const st = _states.get(brainPath);
    if (st?.writeTimer) clearTimeout(st.writeTimer);
    _states.delete(brainPath);
}
