/**
 * `Indexer` — orchestrates the flow from `Indexable`s to FlexSearch
 * documents.
 *
 * Responsibilities (design §3, §9, §13):
 *   - Incremental upsert: dedup by `contentHash`, chunk long text, push
 *     to the store.
 *   - Backlog counter + debounced `index:progress` emission.
 *   - Full rebuild from a set of `IndexSource`s: clear the store,
 *     drain each source, write meta.
 *
 * This module is store-agnostic in spirit — it talks to `FlexSearchStore`
 * through a narrow surface (`upsert` / `deleteByIds` / `deleteWhere` /
 * `clear` / `getHashForEntry` / `writeMeta`). Source iteration lives in
 * `sources/*.ts`.
 *
 * Design reference: docs/design/portable-search.md §3, §6, §13.
 */
import { FLEX_VERSION } from "./flex-store.js";
import { chunkText, approxTokenCount } from "./chunker.js";
import { CONTENT_POLICY_VERSION } from "./content-policy.js";
import { buildCurrentMeta } from "./meta.js";
// ── Tunables ─────────────────────────────────────────────────────────
const UPSERT_BATCH_SIZE = 128;
const MAX_TOKENS = 2000;
const CHUNK_OVERLAP = 200;
const PROGRESS_DEBOUNCE_MS = 500;
// ── Indexer ──────────────────────────────────────────────────────────
export class Indexer {
    projectId;
    store;
    progressBus;
    progressDebounceMs;
    maxTokens;
    chunkOverlap;
    _backlog = 0;
    _lastProgressEmit = 0;
    _progressTimer = null;
    _pendingPhase = "incremental";
    _pendingTotal = 0;
    _pendingCompleted = 0;
    constructor(opts) {
        this.store = opts.store;
        this.progressBus = opts.progressBus;
        this.projectId = opts.projectId;
        this.progressDebounceMs = opts.progressDebounceMs ?? PROGRESS_DEBOUNCE_MS;
        this.maxTokens = opts.maxTokens ?? MAX_TOKENS;
        this.chunkOverlap = opts.chunkOverlap ?? CHUNK_OVERLAP;
    }
    get backlog() {
        return this._backlog;
    }
    // ── Public API ───────────────────────────────────────────────────
    async upsertEntries(entries) {
        if (entries.length === 0)
            return;
        this._backlog += entries.length;
        this._pendingPhase = "incremental";
        this._pendingTotal = entries.length;
        this._pendingCompleted = 0;
        this._scheduleProgress();
        try {
            // Dedup by contentHash.
            const fresh = this._filterUnchanged(entries);
            const skipped = entries.length - fresh.length;
            if (skipped > 0) {
                this._backlog = Math.max(0, this._backlog - skipped);
                this._pendingCompleted += skipped;
                this._scheduleProgress();
            }
            const expanded = this._expandWithChunks(fresh);
            for (let i = 0; i < expanded.length; i += UPSERT_BATCH_SIZE) {
                await this.store.upsert(expanded.slice(i, i + UPSERT_BATCH_SIZE).map((b) => b.doc));
                const originalsInBatch = new Set(expanded.slice(i, i + UPSERT_BATCH_SIZE).map((b) => b.originalIndex)).size;
                this._backlog = Math.max(0, this._backlog - originalsInBatch);
                this._pendingCompleted = Math.min(fresh.length, this._pendingCompleted + originalsInBatch);
                this._scheduleProgress();
            }
            this._flushProgress();
        }
        catch (err) {
            this._backlog = Math.max(0, this._backlog - entries.length);
            this._flushProgress();
            const message = err instanceof Error ? err.message : String(err);
            this.progressBus.emit("index:error", {
                projectId: this.projectId,
                message,
                recoverable: true,
            });
            throw err;
        }
    }
    async removeEntries(ids) {
        if (ids.length === 0)
            return;
        await this.store.deleteByIds(ids);
    }
    /**
     * Back-compat shim for the legacy SQL-filter API. The only call-site
     * today is `removeMessagesForSession` which passes
     * `session_id = '<sid>' AND source_id = 'messages'`. Parse that
     * narrow shape; throw for anything else.
     */
    async removeByFilter(filter) {
        if (typeof filter === "object") {
            await this.store.deleteWhere(filter);
            return;
        }
        // Structured parse of "k = 'v' AND k = 'v'" — fine for the one
        // historic caller, safer than a general SQL parser.
        const parts = filter.split(/\s+AND\s+/i);
        const where = {};
        for (const p of parts) {
            const m = p.trim().match(/^(\w+)\s*=\s*'([^']*)'$/);
            if (!m)
                throw new Error(`Indexer.removeByFilter: cannot parse "${filter}"`);
            const [, k, v] = m;
            if (k === "session_id")
                where.session_id = v;
            else if (k === "source_id")
                where.source_id = v;
            else
                throw new Error(`Indexer.removeByFilter: unsupported filter key "${k}"`);
        }
        await this.store.deleteWhere(where);
    }
    async rebuildFromSources(sources, ctx) {
        const startedAt = Date.now();
        this._pendingPhase = "rebuild";
        this._pendingTotal = 0;
        this._pendingCompleted = 0;
        try {
            await this.store.clear();
            let rowsWritten = 0;
            let buffer = [];
            const FLUSH_AT = UPSERT_BATCH_SIZE;
            const yieldToLoop = () => new Promise((resolve) => setImmediate(resolve));
            const flush = async () => {
                if (buffer.length === 0)
                    return;
                const expanded = this._expandWithChunks(buffer);
                for (let i = 0; i < expanded.length; i += UPSERT_BATCH_SIZE) {
                    const batch = expanded.slice(i, i + UPSERT_BATCH_SIZE).map((b) => b.doc);
                    await this.store.upsert(batch);
                    rowsWritten += batch.length;
                    await yieldToLoop();
                }
                this._pendingCompleted += buffer.length;
                this._scheduleProgress();
                buffer = [];
            };
            for (const src of sources) {
                for await (const entry of src.iterate(ctx)) {
                    buffer.push(entry);
                    this._pendingTotal++;
                    if (buffer.length >= FLUSH_AT)
                        await flush();
                }
            }
            await flush();
            this._flushProgress();
            await this.store.writeMeta(buildCurrentMeta({
                engine: "flexsearch",
                engineVersion: FLEX_VERSION,
                contentPolicyVersion: CONTENT_POLICY_VERSION,
            }));
            this.progressBus.emit("index:complete", {
                projectId: this.projectId,
                phase: "rebuild",
                durationMs: Date.now() - startedAt,
                rowsWritten,
            });
        }
        catch (err) {
            this._flushProgress();
            const message = err instanceof Error ? err.message : String(err);
            this.progressBus.emit("index:error", {
                projectId: this.projectId,
                message,
                recoverable: true,
            });
            throw err;
        }
    }
    // ── Internals ────────────────────────────────────────────────────
    _filterUnchanged(entries) {
        return entries.filter((e) => this.store.getHashForEntry(e.id) !== e.contentHash);
    }
    _expandWithChunks(entries) {
        const out = [];
        for (let i = 0; i < entries.length; i++) {
            const e = entries[i];
            if (!e.text || e.text.trim().length === 0)
                continue;
            const tokenCount = approxTokenCount(e.text);
            if (tokenCount <= this.maxTokens) {
                out.push({ originalIndex: i, doc: indexableToDoc(e, e.text, null) });
                continue;
            }
            const chunks = chunkText(e.text, e.id, {
                maxTokens: this.maxTokens,
                overlap: this.chunkOverlap,
            });
            for (const c of chunks) {
                const doc = indexableToDoc(e, c.text, e.id);
                doc.id = c.id;
                out.push({ originalIndex: i, doc });
            }
        }
        return out;
    }
    _scheduleProgress() {
        const now = Date.now();
        const elapsed = now - this._lastProgressEmit;
        if (elapsed >= this.progressDebounceMs) {
            this._flushProgress();
            return;
        }
        if (this._progressTimer)
            return;
        const delay = this.progressDebounceMs - elapsed;
        this._progressTimer = setTimeout(() => {
            this._progressTimer = null;
            this._flushProgress();
        }, delay);
        if (typeof this._progressTimer.unref === "function")
            this._progressTimer.unref();
    }
    _flushProgress() {
        if (this._progressTimer) {
            clearTimeout(this._progressTimer);
            this._progressTimer = null;
        }
        this._lastProgressEmit = Date.now();
        this.progressBus.emit("index:progress", {
            projectId: this.projectId,
            phase: this._pendingPhase,
            total: this._pendingTotal,
            completed: this._pendingCompleted,
            backlog: this._backlog,
        });
    }
}
// ── Pure helpers ─────────────────────────────────────────────────────
export function indexableToDoc(e, text, parentId) {
    const md = e.metadata ?? {};
    const goalId = pickString(md.goal_id ?? md.goalId);
    const sessionId = pickString(md.session_id ?? md.sessionId);
    const sessionTitle = pickString(md.session_title ?? md.sessionTitle);
    const title = e.display?.title ?? null;
    const filePath = e.display?.filePath ?? null;
    const startLine = typeof e.display?.startLine === "number" ? e.display.startLine : null;
    const endLine = typeof e.display?.endLine === "number" ? e.display.endLine : null;
    const archived = e.archived === true;
    return {
        id: e.id,
        source_id: e.sourceId,
        project_id: e.projectId,
        entity_type: entityTypeFor(e.sourceId),
        parent_id: parentId,
        archived,
        archived_tag: archived ? "true" : "false",
        timestamp: e.timestamp,
        content_hash: e.contentHash,
        weight: e.weight,
        role: e.role ?? null,
        title,
        text,
        identifier_text: "", // derived inside FlexSearchStore._prepare
        goal_id: goalId,
        session_id: sessionId,
        session_title: sessionTitle,
        file_path: filePath,
        start_line: startLine,
        end_line: endLine,
    };
}
function entityTypeFor(sourceId) {
    switch (sourceId) {
        case "goals": return "goal";
        case "sessions": return "session";
        case "messages": return "message";
        case "staff": return "staff";
        case "files": return "file";
    }
}
function pickString(v) {
    return typeof v === "string" && v.length > 0 ? v : null;
}
//# sourceMappingURL=indexer.js.map