import { decode, encode } from '@msgpack/msgpack';
import { search, upsert, type AnyOrama } from '@orama/orama';
import * as Comlink from 'comlink';
import Graph from 'graphology';
import forceAtlas2 from 'graphology-layout-forceatlas2';

import { GRAPH_CONSTANTS, ONTOLOGY_CONSTANTS, SEARCH_CONSTANTS, WORKER_INDEXER_CONSTANTS, WORKER_LATENCY_CONSTANTS } from '../constants';
import { STORES, StorageProvider } from '../services/StorageProvider';
import { type GraphNodeData, type GraphSearchResult, type WorkerAPI, type WorkerConfig, type FileUpdateData, type SynonymCandidate } from '../types/graph';
import { calculateInheritedScore, computeCentroid, ensureArray, estimateTokens, extractHeaders, parseYaml, sanitizeExcalidrawContent, sanitizeProperty, semanticSplit, shuffleArray, stripStopWords } from '../utils/indexer-utils';
import { resolveEngineLanguage, resolveStopwordKey } from '../utils/language-utils';
import { extractLinks, fastHash, resolvePath, splitFrontmatter, workerNormalizePath } from '../utils/link-parsing';

let graph: Graph;
let orama: AnyOrama;
let config: WorkerConfig;
let embedderProxy: ((text: string | string[], title: string) => Promise<{ vector: number[], vectors?: number[][], tokenCount: number }>) | null = null;
const aliasMap: Map<string, string> = new Map(); // alias lower -> canonical path
let latestGraphUpdateId = 0;
let currentStopWords: string[] = []; // Loaded dynamically
const storage = new StorageProvider();

interface StopWordsModule {
    stopwords: string[];
}

// Helper to normalize language code for Orama engine
function getOramaLanguage(language: string): string {
    return resolveEngineLanguage(language);
}

// Language Normalization & Stop Word Loading
async function loadStopWords(language: string): Promise<string[]> {
    try {
        const langCode = resolveStopwordKey(language);
        switch (langCode) {
            case 'arabic': return (await import('@orama/stopwords/arabic') as StopWordsModule).stopwords;
            case 'armenian': return (await import('@orama/stopwords/armenian') as StopWordsModule).stopwords;
            case 'bulgarian': return (await import('@orama/stopwords/bulgarian') as StopWordsModule).stopwords;
            case 'mandarin': return (await import('@orama/stopwords/mandarin') as StopWordsModule).stopwords;
            case 'danish': return (await import('@orama/stopwords/danish') as StopWordsModule).stopwords;
            case 'dutch': return (await import('@orama/stopwords/dutch') as StopWordsModule).stopwords;
            case 'english': return (await import('@orama/stopwords/english') as StopWordsModule).stopwords;
            case 'finnish': return (await import('@orama/stopwords/finnish') as StopWordsModule).stopwords;
            case 'french': return (await import('@orama/stopwords/french') as StopWordsModule).stopwords;
            case 'german': return (await import('@orama/stopwords/german') as StopWordsModule).stopwords;
            case 'greek': return (await import('@orama/stopwords/greek') as StopWordsModule).stopwords;
            case 'indian': return (await import('@orama/stopwords/indian') as StopWordsModule).stopwords;
            case 'hungarian': return (await import('@orama/stopwords/hungarian') as StopWordsModule).stopwords;
            case 'indonesian': return (await import('@orama/stopwords/indonesian') as StopWordsModule).stopwords;
            case 'irish': return (await import('@orama/stopwords/irish') as StopWordsModule).stopwords;
            case 'italian': return (await import('@orama/stopwords/italian') as StopWordsModule).stopwords;
            case 'japanese': return (await import('@orama/stopwords/japanese') as StopWordsModule).stopwords;
            case 'nepali': return (await import('@orama/stopwords/nepali') as StopWordsModule).stopwords;
            case 'norwegian': return (await import('@orama/stopwords/norwegian') as StopWordsModule).stopwords;
            case 'portuguese': return (await import('@orama/stopwords/portuguese') as StopWordsModule).stopwords;
            case 'romanian': return (await import('@orama/stopwords/romanian') as StopWordsModule).stopwords;
            case 'russian': return (await import('@orama/stopwords/russian') as StopWordsModule).stopwords;
            case 'sanskrit': return (await import('@orama/stopwords/sanskrit') as StopWordsModule).stopwords;
            case 'serbian': return (await import('@orama/stopwords/serbian') as StopWordsModule).stopwords;
            case 'slovenian': return (await import('@orama/stopwords/slovenian') as StopWordsModule).stopwords;
            case 'spanish': return (await import('@orama/stopwords/spanish') as StopWordsModule).stopwords;
            case 'swedish': return (await import('@orama/stopwords/swedish') as StopWordsModule).stopwords;
            case 'tamil': return (await import('@orama/stopwords/tamil') as StopWordsModule).stopwords;
            case 'turkish': return (await import('@orama/stopwords/turkish') as StopWordsModule).stopwords;
            case 'ukrainian': return (await import('@orama/stopwords/ukrainian') as StopWordsModule).stopwords;
            default:
                return (await import('@orama/stopwords/english') as StopWordsModule).stopwords;
        }
    } catch (e) {
        workerLogger.warn(`Failed to load stop words for ${language}, defaulting to empty.`, e);
        return [];
    }
}

interface OramaDocument {
    anchorHash: number;
    author?: string;
    content: string;
    context: string;
    created: number;
    embedding?: number[];
    end: number;
    id: string;
    links?: string[];
    mtime: number;
    params: string[];
    path: string;
    start: number;
    status: string;
    title: string;
    tokenCount: number;
}

interface OramaHit {
    doc: OramaDocument;
    id: string;
    score: number;
}

interface OramaResultHit {
    // This is not a bug - see https://github.com/obsidianmd/eslint-plugin/issues/150
    document: OramaDocument;
    id: string;
    score: number;
}

interface SerializedIndexState {
    embeddingChunkSize?: number;
    embeddingDimension: number;
    embeddingModel: string;
    graph: object;
    orama: unknown;
}

const workerLogger = {
    debug: (msg: string, ...args: unknown[]) => self.console.debug(`[VaultIntelligence:DEBUG] [IndexerWorker] ${msg}`, ...args),
    error: (msg: string, ...args: unknown[]) => self.console.error(`[VaultIntelligence:ERROR] [IndexerWorker] ${msg}`, ...args),
    info: (msg: string, ...args: unknown[]) => self.console.warn(`[VaultIntelligence:INFO] [IndexerWorker] ${msg}`, ...args),
    warn: (msg: string, ...args: unknown[]) => self.console.warn(`[VaultIntelligence:WARN] [IndexerWorker] ${msg}`, ...args)
};

// 2. Semantic Splitter (Header-Based with Fallback)

const IndexerWorker: WorkerAPI = {
    async buildPriorityPayload(queryVector: number[], query: string): Promise<unknown[]> {
        if (!orama) throw new Error("[IndexerWorker] Orama index not initialized");
        if (!config) throw new Error("[IndexerWorker] Configuration not initialized");

        const LATENCY_BUDGET_TOKENS = (config.embeddingChunkSize || 512) * WORKER_LATENCY_CONSTANTS.LATENCY_BUDGET_FACTOR;

        const vectorPromise = search(orama, {
            includeVectors: false,
            limit: 100,
            mode: 'vector',
            similarity: WORKER_INDEXER_CONSTANTS.SIMILARITY_THRESHOLD_STRICT,
            vector: {
                property: 'embedding',
                value: queryVector
            }
        });

        const keywordPromise = search(orama, {
            includeVectors: false,
            limit: 50,
            properties: ['content', 'title', 'context'],
            similarity: WORKER_INDEXER_CONSTANTS.SIMILARITY_THRESHOLD_STRICT,
            term: stripStopWords(query, currentStopWords),
            threshold: WORKER_INDEXER_CONSTANTS.RECALL_THRESHOLD_PERMISSIVE
        });

        const [vectorResults, keywordResults] = await Promise.all([vectorPromise, keywordPromise]);
        const candidates = new Map<string, { id: string; score: number; type: 'vector' | 'graph'; source?: string; content?: string }>();

        for (const hit of vectorResults.hits) {
            const doc = (hit as unknown as OramaResultHit).document;
            if (!candidates.has(hit.id)) {
                candidates.set(hit.id, {
                    content: doc.content,
                    id: hit.id,
                    score: hit.score,
                    type: 'vector'
                });
            }
        }

        for (const hit of keywordResults.hits) {
            const doc = (hit as unknown as OramaResultHit).document;
            if (!candidates.has(hit.id)) {
                candidates.set(hit.id, {
                    content: doc.content,
                    id: hit.id,
                    score: hit.score,
                    type: 'vector'
                });
            }
        }

        const seeds = Array.from(candidates.values());
        for (const seed of seeds) {
            const seedId = seed.id.split('#')[0] || seed.id;
            const path = workerNormalizePath(seedId);

            if (graph.hasNode(path)) {
                const neighbors = graph.neighbors(path);
                const degree = graph.degree(path);

                for (const neighbor of neighbors) {
                    const inherited = calculateInheritedScore(seed.score, degree);
                    const neighborId = neighbor;

                    const existing = candidates.get(neighborId);
                    if (!existing || existing.score < inherited) {
                        candidates.set(neighborId, {
                            id: neighborId,
                            score: inherited,
                            source: path,
                            type: 'graph'
                        });
                    }
                }
            }
        }

        const sorted = Array.from(candidates.values()).sort((a, b) => b.score - a.score);
        const payload: Array<{ content?: string; id: string; score: number; type: 'vector' | 'graph'; source?: string }> = [];
        let currentTokens = 0;
        const idsToHydrate: string[] = [];

        for (const candidate of sorted) {
            if (currentTokens >= LATENCY_BUDGET_TOKENS) break;

            if (candidate.type === 'vector') {
                // Use actual token count from Orama if available, fallback to estimate
                const tokens = (candidate as { tokenCount?: number }).tokenCount || (candidate.content ? estimateTokens(candidate.content) : 128);

                if (currentTokens + tokens <= LATENCY_BUDGET_TOKENS) {
                    payload.push({
                        content: candidate.content || "", // Pass empty string to be hydrated later
                        id: candidate.id,
                        score: candidate.score,
                        type: 'vector'
                    });
                    currentTokens += tokens;
                }
            } else {
                const EST_TOKENS = 200;
                if (currentTokens + EST_TOKENS <= LATENCY_BUDGET_TOKENS) {
                    idsToHydrate.push(candidate.id);
                    payload.push({
                        id: candidate.id,
                        score: candidate.score,
                        source: candidate.source,
                        type: candidate.type
                    });
                    currentTokens += EST_TOKENS;
                }
            }
        }

        if (idsToHydrate.length > 0) {
            const hydrationResults = await search(orama, {
                limit: idsToHydrate.length * 2,
                where: {
                    id: { in: idsToHydrate }
                }
            });

            const hydrationMap = new Map<string, string>();
            for (const hit of hydrationResults.hits) {
                const doc = (hit as unknown as OramaResultHit).document;
                hydrationMap.set(hit.id, doc.content);
                hydrationMap.set(doc.path, doc.content);
            }

            for (const item of payload) {
                if (!item.content) {
                    let content = hydrationMap.get(item.id);
                    if (!content) {
                        const baseId = item.id.split('#')[0];
                        if (baseId) content = hydrationMap.get(baseId);
                    }
                    item.content = content ?? "(Content unavailable)";
                }
            }
        }

        return payload
            .filter(p => p.type === 'graph' || (p.content !== "(Content unavailable)"))
            .map(p => {
                const { body } = splitFrontmatter(p.content || "");
                return {
                    content: body.trim(),
                    id: p.id,
                    score: p.score,
                    type: p.type
                };
            });
    },

    async clearIndex() {
        await recreateOrama();
    },

    async deleteFile(path: string) {
        const normalizedPath = workerNormalizePath(path);
        if (graph.hasNode(normalizedPath)) {
            graph.dropNode(normalizedPath);
        }
        await IndexerWorker.deleteOramaRecord(normalizedPath);
    },

    async deleteOramaRecord(path: string) {
        const normalizedPath = workerNormalizePath(path);
        try {
            const results = await search(orama, {
                limit: 1000,
                where: { path: { eq: normalizedPath } }
            });

            const ids = results.hits.map(h => h.id);
            if (ids.length > 0) {
                const { remove } = await import('@orama/orama');
                for (const id of ids) {
                    await remove(orama, id);
                }
            }
        } catch (e) {
            workerLogger.warn(`Failed to remove ${normalizedPath} from Orama:`, e);
        }
    },

    async findOntologySynonyms(semanticThreshold: number): Promise<SynonymCandidate[]> {
        if (!graph || !orama) throw new Error("[IndexerWorker] Not initialized");
        await Promise.resolve();

        const ontologyPrefix = workerNormalizePath(config.ontologyPath);
        const candidates: SynonymCandidate[] = [];
        
        // 1. Gather ontology nodes
        const ontologyNodes: string[] = [];
        graph.forEachNode((node, attr) => {
            const a = attr as GraphNodeData;
            if (a.type === 'file' && node.startsWith(ontologyPrefix + '/')) {
                ontologyNodes.push(node);
            }
        });

        // Precompute values
        const nodeData = new Map<string, { inNeighbors: Set<string>, name: string, size: number, embedding?: number[] }>();

        
        for (const node of ontologyNodes) {
            const attr = graph.getNodeAttributes(node) as GraphNodeData;
            const inNeighbors = new Set(graph.inNeighbors(node));
            const name = node.split('/').pop()?.toLowerCase().replace('.md', '') || '';
            const collectedVectors: number[][] = [];
            
            // 1. Explicit Topic Definition: Get embedding if > TOPIC_MIN_EXPLICIT_BYTES
            let topicEmbedding: number[] | undefined = undefined;
            if (attr.size > ONTOLOGY_CONSTANTS.TOPIC_MIN_EXPLICIT_BYTES) {
                const results = await search(orama, {
                    includeVectors: true,
                    limit: 1,
                    where: { path: { eq: node } }
                });
                if (results.hits.length > 0 && (results.hits[0] as unknown as OramaResultHit)?.document?.embedding) {
                    topicEmbedding = (results.hits[0] as unknown as OramaResultHit).document.embedding || [];
                    collectedVectors.push(topicEmbedding);
                }
            }

            // 2. Contextual Topic Definition: Get embeddings from inNeighbors
            if (inNeighbors.size > 0) {
                // Safeguard Event Loop / RAM: Limit context to max neighbours per topic
                let neighborsArray = Array.from(inNeighbors);
                if (neighborsArray.length > ONTOLOGY_CONSTANTS.MAX_CENTROID_NEIGHBORS) {
                    neighborsArray = shuffleArray(neighborsArray).slice(0, ONTOLOGY_CONSTANTS.MAX_CENTROID_NEIGHBORS);
                }
                const neighborResults = await search(orama, {
                    includeVectors: true,
                    limit: ONTOLOGY_CONSTANTS.MAX_CENTROID_NEIGHBORS, // Important: keep limit wide enough to catch all sliced elements
                    where: { path: { in: neighborsArray } }
                });
                for (const hit of neighborResults.hits) {
                    // Safety check needed since typed as unknown internally
                    const doc = (hit as unknown as OramaResultHit).document;
                    if (doc.embedding) {
                        collectedVectors.push(doc.embedding);
                    }
                }
            }
            
            // 3. Hybrid Signature: Mean vector of explicit def + all contexts
            const embedding = computeCentroid(collectedVectors);

            nodeData.set(node, {
                embedding,
                inNeighbors,
                name,
                size: attr.size
            });
        }

        const oCS = (a: number[], b: number[]) => {
            let dotProduct = 0; let normA = 0; let normB = 0;
            for (let i = 0; i < a.length; i++) {
                const valA = a[i] ?? 0;
                const valB = b[i] ?? 0;
                dotProduct += valA * valB;
                normA += valA * valA;
                normB += valB * valB;
            }
            if (normA === 0 || normB === 0) return 0;
            return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB));
        };
        
        const lev = (a: string, b: string): number => {
            if(a.length == 0) return b.length; 
            if(b.length == 0) return a.length; 
            const matrix: number[][] = [];
            let i: number;
            for(i = 0; i <= b.length; i++) matrix[i] = [i];
            let j: number;
            const row0 = matrix[0] ?? [];
            for(j = 0; j <= a.length; j++) row0[j] = j;
            matrix[0] = row0;
            for(i = 1; i <= b.length; i++){
                for(j = 1; j <= a.length; j++){
                    const rowI = matrix[i] ?? [];
                    const rowIPrev = matrix[i-1] ?? [];
                    if(b.charAt(i-1) == a.charAt(j-1)) rowI[j] = rowIPrev[j-1] ?? 0;
                    else rowI[j] = Math.min((rowIPrev[j-1] ?? 0) + 1, Math.min((rowI[j-1] ?? 0) + 1, (rowIPrev[j] ?? 0) + 1));
                }
            }
            return (matrix[b.length] ?? [])[a.length] ?? 0;
        };

        const maxLen = (a: string, b: string) => Math.max(a.length, b.length);
        const levSim = (a: string, b: string) => maxLen(a, b) === 0 ? 1 : 1 - (lev(a, b) / maxLen(a, b));

        for (let i = 0; i < ontologyNodes.length; i++) {
            for (let j = i + 1; j < ontologyNodes.length; j++) {
                const nodeA = ontologyNodes[i] ?? "";
                const nodeB = ontologyNodes[j] ?? "";
                const dataA = nodeData.get(nodeA);
                const dataB = nodeData.get(nodeB);
                if (!dataA || !dataB) continue;

                // Intersection
                let sharedNeighbors = 0;
                for (const elem of dataA.inNeighbors) if (dataB.inNeighbors.has(elem)) sharedNeighbors++;

                let matched = false;
                let reason = "";
                let score = 0;

                // 1. Lexical Loop
                const lSim = levSim(dataA.name, dataB.name);
                if (lSim > 0.85 && sharedNeighbors >= 1) {
                    matched = true;
                    reason = "Lexical match (Spelling variation)";
                    score = lSim;
                } 
                else {
                    // 2. Structural Loop
                    const unionSize = dataA.inNeighbors.size + dataB.inNeighbors.size - sharedNeighbors;
                    const jaccard = unionSize === 0 ? 0 : sharedNeighbors / unionSize;

                    if (jaccard > 0.75 && sharedNeighbors >= 2) {
                        matched = true;
                        reason = "Structural match (Shared context)";
                        score = jaccard;
                    } 
                    // 3. Semantic Loop
                    else if (dataA.embedding && dataB.embedding) {
                        const cosine = oCS(dataA.embedding, dataB.embedding);
                        if (cosine >= semanticThreshold) {
                            matched = true;
                            // Add extra weight logic or format reason slightly differently depending on user threshold
                            reason = "Semantic match (Similar definitions)";
                            score = cosine;
                        }
                    }
                }

                if (matched) {
                    candidates.push({
                        reason,
                        sharedNeighbors,
                        similarityScore: score,
                        topicA: nodeA,
                        topicB: nodeB
                    });
                }
            }
        }

        // Sort descending by score
        return candidates.sort((a, b) => b.similarityScore - a.similarityScore);
    },

    async findOrphanCandidates(ontologyPrefix: string, gracePeriodMs: number): Promise<string[]> {
        if (!graph) throw new Error("[IndexerWorker] Graph not initialized");
        await Promise.resolve();
        const prefix = workerNormalizePath(ontologyPrefix);
        const candidates: string[] = [];
        const now = Date.now();

        graph.forEachNode((node, attr) => {
            const a = attr as GraphNodeData;
            // 1. Must be a file
            if (a.type !== 'file') return;
            // 2. Must be in the ontology folder (or subfolder)
            if (!node.startsWith(prefix + '/')) return;

            // 3. Must not be an index file (e.g. Ontology/Concepts/Concepts.md)
            const parts = node.split('/');
            if (parts.length >= 2) {
                const folderName = parts[parts.length - 2];
                const fileName = parts[parts.length - 1];
                if (folderName && fileName === folderName + '.md') {
                    return; // Skip index files
                }
            }

            // 4. Must not be the special Instructions.md settings file
            const instructionsPath = prefix ? `${prefix}/instructions.md` : `instructions.md`;
            if (node.toLowerCase() === instructionsPath.toLowerCase()) {
                return; // Skip instructions
            }

            // 4. Must have inDegree 0
            if (graph.inDegree(node) > 0) return;

            // 5. Must obey grace period
            if (gracePeriodMs > 0 && (now - a.mtime) < gracePeriodMs) return;

            candidates.push(node);
        });

        return candidates;
    },

    async fullReset() {
        if (graph) graph.clear();
        await recreateOrama();
        workerLogger.info("Full reset complete.");
    },

    async getBatchCentrality(paths: string[]): Promise<Record<string, number>> {
        if (!graph) throw new Error("[IndexerWorker] Graph not initialized");
        await Promise.resolve();
        const results: Record<string, number> = {};
        const totalNodes = graph.order;
        for (const path of paths) {
            const normalizedPath = workerNormalizePath(path);
            if (!graph.hasNode(normalizedPath)) {
                results[path] = 0;
            } else {
                const degree = graph.degree(normalizedPath);
                results[path] = totalNodes > 1 ? degree / (totalNodes - 1) : 0;
            }
        }
        return results;
    },

    async getBatchMetadata(paths: string[]): Promise<Record<string, { title?: string, headers?: string[], tokenCount?: number }>> {
        if (!graph) throw new Error("[IndexerWorker] Graph not initialized");
        await Promise.resolve();
        const results: Record<string, { title?: string, headers?: string[], tokenCount?: number }> = {};
        for (const path of paths) {
            const normalizedPath = workerNormalizePath(path);
            if (graph.hasNode(normalizedPath)) {
                const attr = graph.getNodeAttributes(normalizedPath) as GraphNodeData;
                results[path] = { headers: attr.headers, title: attr.title, tokenCount: attr.tokenCount };
            } else {
                results[path] = {};
            }
        }
        return results;
    },

    async getCentrality(path: string): Promise<number> {
        if (!graph) throw new Error("[IndexerWorker] Graph not initialized");
        await Promise.resolve();
        const normalizedPath = workerNormalizePath(path);
        if (!graph.hasNode(normalizedPath)) return 0;
        const degree = graph.degree(normalizedPath);
        const totalNodes = graph.order;
        return totalNodes > 1 ? degree / (totalNodes - 1) : 0;
    },

    async getFileState(path: string) {
        await Promise.resolve();
        const normalized = workerNormalizePath(path);
        if (!graph || !graph.hasNode(normalized)) return null;
        const attr = graph.getNodeAttributes(normalized) as GraphNodeData;
        if (attr.type !== 'file') return null;
        return { hash: attr.hash || '', mtime: attr.mtime, size: attr.size };
    },

    async getFileStates() {
        await Promise.resolve();
        const states: Record<string, { hash: string, mtime: number, size: number }> = {};
        if (graph) {
            graph.forEachNode((node, attr) => {
                const a = attr as GraphNodeData;
                if (a.type === 'file') {
                    states[node] = { hash: a.hash || '', mtime: a.mtime, size: a.size };
                }
            });
        }
        return states;
    },

    async getNeighbors(path: string, options?: { direction?: 'both' | 'inbound' | 'outbound'; mode?: 'simple' | 'ontology'; decay?: number }): Promise<GraphSearchResult[]> {
        const normalizedPath = workerNormalizePath(path);
        if (!graph.hasNode(normalizedPath)) return [];

        const direction = options?.direction || 'both';
        const mode = options?.mode || 'simple';

        const getOneHop = (node: string, dir: 'both' | 'inbound' | 'outbound') => {
            if (dir === 'outbound') return graph.outNeighbors(node);
            if (dir === 'inbound') return graph.inNeighbors(node);
            return graph.neighbors(node);
        };

        await Promise.resolve();
        const initialNeighbors = getOneHop(normalizedPath, direction);
        const results = new Map<string, GraphSearchResult>();

        for (const neighbor of initialNeighbors) {
            const attr = graph.getNodeAttributes(neighbor) as GraphNodeData;
            if (!attr.mtime || !attr.size || attr.type !== 'file') continue;

            results.set(neighbor, {
                excerpt: "",
                path: neighbor,
                score: 1.0,
                title: attr.title || neighbor.split('/').pop()?.replace('.md', '')
            });
        }

        if (mode === 'ontology') {
            for (const neighbor of initialNeighbors) {
                const configuredOntology = workerNormalizePath(config.ontologyPath || 'Ontology');
                const isOntologyPath = neighbor.startsWith(configuredOntology + '/');
                const degree = graph.inDegree(neighbor);
                const isHub = degree >= ONTOLOGY_CONSTANTS.HUB_MIN_DEGREE;

                if (isOntologyPath || isHub) {
                    const siblings = graph.inNeighbors(neighbor);
                    for (const sibling of siblings) {
                        if (sibling === normalizedPath || results.has(sibling)) continue;
                        let score = options?.decay ?? ONTOLOGY_CONSTANTS.SIBLING_DECAY;
                        if (ONTOLOGY_CONSTANTS.HUB_PENALTY_ENABLED) {
                            // Using Math.log (Natural Log) is the Information Retrieval standard (e.g. TF-IDF).
                            // It is ~2.3x more aggressive than log10, effectively suppressed "noisy" hubs 
                            // with 10-15 connections in both small and large vaults.
                            score = score / Math.max(1, Math.log(degree + 1));
                        }
                        const attr = graph.getNodeAttributes(sibling) as GraphNodeData;
                        if (!attr.mtime || !attr.size || attr.type !== 'file') continue;

                        results.set(sibling, {
                            description: `(Sibling via ${neighbor})`,
                            excerpt: undefined,
                            path: sibling,
                            score: score,
                            title: attr.title || sibling.split('/').pop()?.replace('.md', '')
                        });
                    }
                }
            }
        }
        return Array.from(results.values());
    },

    async getSimilar(path: string, limit: number = WORKER_INDEXER_CONSTANTS.SEARCH_LIMIT_DEFAULT, minScore: number = 0): Promise<GraphSearchResult[]> {
        const currentOrama = orama;
        const currentConfig = config;
        if (!currentOrama || !currentConfig) return [];
        
        const normalizedPath = workerNormalizePath(path);

        const docResult = await search(currentOrama, {
            includeVectors: true,
            limit: 100,
            where: { path: { eq: normalizedPath } }
        });

        const sourceVectors: number[][] = [];
        for (const hit of docResult.hits) {
            const doc = (hit as unknown as OramaResultHit).document;
            if (doc.embedding && Array.isArray(doc.embedding)) {
                sourceVectors.push(doc.embedding);
            }
        }
        
        if (sourceVectors.length === 0) {
            workerLogger.warn(`[IndexerWorker] No embeddings found for source path: ${normalizedPath}.`);
            return [];
        }

        const firstVec = sourceVectors[0];
        if (!firstVec) return [];
        const dim = firstVec.length;
        const centroid: number[] = new Array<number>(dim).fill(0);
        
        for (const vec of sourceVectors) {
            for (let i = 0; i < dim; i++) {
                const val = vec[i];
                if (typeof val === 'number') {
                    const currentSum = centroid[i] ?? 0;
                    centroid[i] = currentSum + val;
                }
            }
        }

        let magnitude = 0;
        for (let i = 0; i < dim; i++) {
            const current = centroid[i] ?? 0;
            const avg = current / sourceVectors.length;
            centroid[i] = avg;
            magnitude += avg * avg;
        }
        magnitude = Math.sqrt(magnitude);

        if (magnitude > 1e-6) {
            for (let i = 0; i < dim; i++) {
                const current = centroid[i] ?? 0;
                centroid[i] = current / magnitude;
            }
        }
        
        const results = await search(currentOrama, {
            limit: limit * WORKER_INDEXER_CONSTANTS.SEARCH_OVERSHOOT_FACTOR_VECTOR,
            mode: 'vector',
            similarity: WORKER_INDEXER_CONSTANTS.SIMILARITY_THRESHOLD_STRICT,
            vector: { property: 'embedding', value: centroid },
            where: { path: { nin: [normalizedPath] } }
        });

        return maxPoolResults(results.hits.map(h => ({
            doc: (h as unknown as OramaResultHit).document,
            id: h.id,
            score: h.score
        })), limit, minScore);
    },

    async getSubgraph(centerPath: string, updateId: number, existingPositions?: Record<string, { x: number, y: number }>, attractionMultiplier: number = 1.0): Promise<unknown> {
        if (!graph || !orama) return null;
        latestGraphUpdateId = updateId;

        const normalizedCenter = workerNormalizePath(centerPath);
        // if (!graph.hasNode(normalizedCenter)) return "";

        const limit = config.semanticGraphNodeLimit || 250;
        const structuralLimit = Math.floor(limit * 0.8);

        // BFS for structural neighbors
        const subgraph = new Graph({ type: 'undirected' });
        const queue: [string, number][] = [[normalizedCenter, 0]];
        const visited = new Set<string>();
        visited.add(normalizedCenter);

        // Helper to add node to subgraph with unified attributes
        const addNodeToSubgraph = (node: string, type: 'center' | 'structural' | 'semantic') => {
            if (subgraph.hasNode(node)) return;
            const attr = graph.hasNode(node) ? graph.getNodeAttributes(node) as GraphNodeData : undefined;
            const pos = existingPositions?.[node];

            // Extract dominant topic for coloring
            let mainTopic = "default";
            if (attr?.topics && attr.topics.length > 0) {
                mainTopic = attr.topics[0] || "default";
            } else if (attr?.tags && attr.tags.length > 0) {
                mainTopic = attr.tags[0] || "default"; // fallback to tag
            }

            subgraph.addNode(node, {
                color: "#ccc", // Placeholder for main thread resolution
                label: attr?.title || node.split('/').pop()?.replace('.md', '') || node,
                nodeType: type,
                size: type === 'center' ? 10 : (type === 'structural' ? 5 : 4),
                topics: [mainTopic], // Pass topic to UI
                // CRITICAL FIX: Seed randomly around (0,0) to prevent FA2 gravity implosions
                // Also ensures NaN positions are never injected into the physics engine
                x: (pos && typeof pos.x === 'number' && !isNaN(pos.x)) ? pos.x : ((Math.random() - 0.5) * 100),
                y: (pos && typeof pos.y === 'number' && !isNaN(pos.y)) ? pos.y : ((Math.random() - 0.5) * 100)
            });
        };

        addNodeToSubgraph(normalizedCenter, 'center');

        if (graph.hasNode(normalizedCenter)) {
            while (queue.length > 0 && subgraph.order < structuralLimit) {
                const shifted = queue.shift();
                if (!shifted) break;
                const [node, depth] = shifted;
                if (depth >= 2) continue;

                graph.forEachNeighbor(node, (neighbor) => {
                    if (subgraph.order < structuralLimit && !subgraph.hasNode(neighbor)) {
                        addNodeToSubgraph(neighbor, 'structural');
                        if (!visited.has(neighbor)) {
                            visited.add(neighbor);
                            queue.push([neighbor, depth + 1]);
                        }
                    }

                    if (subgraph.hasNode(neighbor)) {
                        // Prevent self loops and duplicate edges
                        if (node !== neighbor && !subgraph.hasEdge(node, neighbor)) {
                            // Weight 3.0 ensures standard links pull strongly to the center
                            subgraph.addEdge(node, neighbor, { edgeType: 'structural', size: config.structuralEdgeThickness || 1.0, type: 'line', weight: 3.0 });
                        }
                    }
                });
            }
        }

        // Semantic Injection
        const semanticLimit = Math.min(limit - subgraph.order, Math.max(3, Math.floor(limit * 0.10)));
        if (semanticLimit > 0) {
            let similar = await IndexerWorker.getSimilar(centerPath, semanticLimit);

            // Fallback to BM25 keyword search if embeddings are missing/disabled
            if (similar.length === 0) {
                const centerAttr = graph.hasNode(normalizedCenter) ? graph.getNodeAttributes(normalizedCenter) as GraphNodeData : null;
                const fallbackQuery = [centerAttr?.title, ...(centerAttr?.topics || []), ...(centerAttr?.tags || [])].filter(Boolean).join(" ");
                if (fallbackQuery.trim().length > 0) {
                    similar = await IndexerWorker.keywordSearch(fallbackQuery, semanticLimit);
                    // Artificially assign a score to keyword matches so the UI handles it gracefully
                    similar.forEach(s => s.score = s.score && s.score <= 1.0 ? s.score : 0.85);
                }
            }

            for (const item of similar) {
                const path = item.path;

                // CRITICAL FIX: Prevent fatal Graphology error by skipping self-loops
                if (path === normalizedCenter) continue;

                if (!subgraph.hasNode(path)) {
                    addNodeToSubgraph(path, 'semantic');
                }

                if (subgraph.hasNode(path)) {
                    // Physics weight scaled by similarity score (ranges from roughly 2.0 to 6.0)
                    // High-score semantic nodes will be violently pulled towards the center
                    const physicsWeight = 2.0 + (item.score * 4.0);

                    if (!subgraph.hasEdge(normalizedCenter, path)) {
                        subgraph.addEdge(normalizedCenter, path, { edgeType: 'semantic', score: item.score, size: config.semanticEdgeThickness || 0.5, type: 'line', weight: physicsWeight, zIndex: 0 });
                    } else {
                        // Upgrade existing structural edge so Graphology doesn't throw a collision error
                        const edge = subgraph.edge(normalizedCenter, path) || subgraph.edge(path, normalizedCenter);
                        // If it already had a weight (e.g. from structural), take the max
                        if (edge) subgraph.mergeEdgeAttributes(edge, { edgeType: 'semantic', score: item.score, size: config.structuralEdgeThickness || 1.0, weight: Math.max((subgraph.getEdgeAttribute(edge, 'weight') as number) || 1, physicsWeight), zIndex: 1 });
                    }
                }
            }
        }

        // Relational Clustering: Connect neighbors to each other if they are linked in the master graph
        const subgraphNodes = subgraph.nodes();
        for (let i = 0; i < subgraphNodes.length; i++) {
            const u = subgraphNodes[i];
            for (let j = i + 1; j < subgraphNodes.length; j++) {
                const v = subgraphNodes[j];
                // Check master graph for connection
                if (graph.hasNode(u) && graph.hasNode(v) && graph.hasEdge(u, v)) {
                    if (!subgraph.hasEdge(u, v) && !subgraph.hasEdge(v, u)) {
                        subgraph.addEdge(u, v, { edgeType: 'structural', size: (config.structuralEdgeThickness || 1.0) * 0.5, type: 'line', weight: 1.5, zIndex: 0 }); // Thinner lines, but pull together
                    }
                }
            }
        }

        if (subgraph.order <= 1) return subgraph.export();

        // Layout: Single block execution to preserve physics momentum.
        // For ~250 nodes this executes synchronously in under ~15ms, zero UI thread blocking.
        const maxIterations = Math.min(300, Math.max(100, subgraph.order));
        // scalingRatio determines repulsion. We scale it up with attraction to prevent crushing the graph into a 1D line under heavy edge weights.
        // Disable strongGravityMode which causes nodes with many edges to aggressively clump and collapse.
        const layoutSettings = { edgeWeightInfluence: 2.0 * attractionMultiplier, gravity: 1.5, linLogMode: true, scalingRatio: 2.0 * Math.max(1.0, attractionMultiplier * 0.8), strongGravityMode: false };

        // Abort if stale before starting expensive layout
        if (latestGraphUpdateId !== updateId) {
            workerLogger.debug(`[getSubgraph] Aborting stale layout for \${centerPath}`);
            return null;
        }

        try {
            forceAtlas2.assign(subgraph, { iterations: maxIterations, settings: layoutSettings });
        } catch (e) {
            workerLogger.error(`[getSubgraph] FA2 Layout failed:`, e);
        }

        return subgraph.export();
    },

    async initialize(conf: WorkerConfig, _fetcher?: unknown, embedder?: (text: string | string[], title: string) => Promise<{ vector: number[], vectors?: number[][], tokenCount: number }>) {
        config = conf;
        graph = new Graph();
        if (typeof embedder === 'function') embedderProxy = embedder;

        await recreateOrama();
        currentStopWords = await loadStopWords(conf.agentLanguage);
        workerLogger.info(`Initialized Orama with ${conf.embeddingDimension} dimensions. Loaded ${currentStopWords.length} stopwords.`);

        return true;
    },

    async keywordSearch(query: string, limit: number = WORKER_INDEXER_CONSTANTS.SEARCH_LIMIT_DEFAULT): Promise<GraphSearchResult[]> {
        if (!orama) throw new Error("[IndexerWorker] Orama index not initialized");
        const results = await search(orama, {
            limit: limit * WORKER_INDEXER_CONSTANTS.SEARCH_OVERSHOOT_FACTOR_KEYWORD,
            properties: ['title', 'content', 'context'],
            term: stripStopWords(query, currentStopWords),
            threshold: WORKER_INDEXER_CONSTANTS.RECALL_THRESHOLD_PERMISSIVE,
            tolerance: WORKER_INDEXER_CONSTANTS.KEYWORD_TOLERANCE
        });
        return maxPoolResults(results.hits.map(h => ({
            doc: (h as unknown as OramaResultHit).document,
            id: h.id,
            score: h.score
        })), limit, 0);
    },

    async loadIndex(data: string | Uint8Array): Promise<boolean> {
        let parsed: SerializedIndexState;
        try {
            if (typeof data === 'string') {
                parsed = JSON.parse(data) as SerializedIndexState;
            } else {
                parsed = decode(data) as SerializedIndexState;
            }
        } catch (e) {
            workerLogger.error("Failed to decode index state", e);
            return false;
        }

        if (parsed.graph) graph.import(parsed.graph);

        if (parsed.orama) {
            const loadedDimension = parsed.embeddingDimension;
            const expectedDimension = config.embeddingDimension;
            const loadedModel = parsed.embeddingModel;
            const expectedModel = config.embeddingModel;

            if (loadedModel !== expectedModel || loadedDimension !== expectedDimension) {
                workerLogger.warn(`Index mismatch: ${loadedModel} vs ${expectedModel}`);
                await recreateOrama();
                return false;
            }

            try {
                // Try to load FULL index from Hot Store first!
                let loadedFull = false;
                try {
                    const fullRaw = await storage.get(STORES.VECTORS, `orama_index_${config.sanitizedModelId}`);
                    if (fullRaw) {
                        const oramaLib = await import('@orama/orama') as unknown as OramaV3;
                        await oramaLib.load(orama, fullRaw);
                        workerLogger.info("[loadIndex] Restored FULL index from Hot Store.");
                        loadedFull = true;
                    }
                } catch (e) {
                    workerLogger.warn("[loadIndex] Failed to read Hot Store, falling back to SLIM index.", e);
                }

                if (!loadedFull) {
                    workerLogger.info("[loadIndex] Hot Store empty or failed, loading SLIM index.");
                    const oramaLib = await import('@orama/orama') as unknown as OramaV3;
                    await oramaLib.load(orama, parsed.orama);
                }
            } catch (e) {
                workerLogger.warn("Orama load failed", e);
                return false;
            }
        }
        return true;
    },

    async pruneOrphans(validPaths: string[]) {
        const validSet = new Set(validPaths.map(p => workerNormalizePath(p)));
        const orphans: string[] = [];
        graph.forEachNode((node, attr) => {
            const a = attr as GraphNodeData;
            if (a.type === 'file' && !validSet.has(node)) orphans.push(node);
        });

        for (const orphan of orphans) {
            await IndexerWorker.deleteFile(orphan);
        }
    },

    async saveIndex(): Promise<Uint8Array> {
        const oramaLib = await import('@orama/orama') as unknown as OramaV3;
        const rawFull = await oramaLib.save(orama);

        // 1. Save FULL index to "Hot Store" (IndexedDB)
        try {
            await storage.put(STORES.VECTORS, `orama_index_${config.sanitizedModelId}`, rawFull);
            workerLogger.info(`[saveIndex] Hot Store (IDB) updated for ${config.sanitizedModelId}.`);
        } catch (e) {
            workerLogger.warn("[saveIndex] Hot Store update failed:", e);
        }

        // 2. Prepare SLIM index for "Cold Store" (Vault File)
        // We create a SLIM COPY of the documents record to avoid modifying the IDB data in-place
        const rawFullTyped = rawFull as OramaRawDocs;
        const hollowDocs: Record<string, Record<string, unknown>> = {};
        
        if (rawFullTyped.docs?.docs) {
            for (const [id, oramaDoc] of Object.entries(rawFullTyped.docs.docs)) {
                hollowDocs[id] = {
                    ...oramaDoc,
                    content: "",
                    context: ""
                };
            }
        }

        const oramaData: unknown = {
            ...rawFullTyped,
            docs: {
                ...rawFullTyped.docs,
                docs: hollowDocs
            }
        };

        const serialized: SerializedIndexState = {
            embeddingChunkSize: config.embeddingChunkSize,
            embeddingDimension: config.embeddingDimension,
            embeddingModel: config.embeddingModel,
            graph: graph.export(),
            orama: oramaData,
        };

        const encoded: Uint8Array = encode(serialized, { maxDepth: GRAPH_CONSTANTS.MAX_SERIALIZATION_DEPTH });
        return encoded;
    },

    async search(query: string, limit: number = WORKER_INDEXER_CONSTANTS.SEARCH_LIMIT_DEFAULT): Promise<GraphSearchResult[]> {
        const vectorPromise = search(orama, {
            limit: limit * WORKER_INDEXER_CONSTANTS.SEARCH_OVERSHOOT_FACTOR_VECTOR,
            mode: 'vector',
            similarity: WORKER_INDEXER_CONSTANTS.SIMILARITY_THRESHOLD_STRICT,
            vector: {
                property: 'embedding',
                value: (await generateEmbedding(query, 'Query')).vector
            }
        });

        const keywordPromise = search(orama, {
            limit: limit * WORKER_INDEXER_CONSTANTS.SEARCH_OVERSHOOT_FACTOR_KEYWORD,
            properties: ['title', 'content', 'context'],
            term: stripStopWords(query, currentStopWords),
            threshold: WORKER_INDEXER_CONSTANTS.RECALL_THRESHOLD_PERMISSIVE
        });

        const [vectorResults, keywordResults] = await Promise.all([vectorPromise, keywordPromise]);
        const hits = new Map<string, OramaHit>();

        for (const hit of vectorResults.hits) {
            const h = hit as unknown as OramaResultHit;
            hits.set(h.id, { doc: h.document, id: h.id, score: h.score });
        }

        let maxKS = 0;
        for (const h of keywordResults.hits) if (h.score > maxKS) maxKS = h.score;
        const norm = Math.max(1.0, maxKS);

        for (const hit of keywordResults.hits) {
            const hTyped = hit as unknown as OramaResultHit;
            const h: OramaHit = { doc: hTyped.document, id: hTyped.id, score: hTyped.score };
            const score = h.score / norm;
            if (hits.has(h.id)) {
                const existing = hits.get(h.id);
                if (existing) {
                    existing.score += (score * 0.5);
                }
            } else {
                h.score = score * 0.9;
                hits.set(h.id, h);
            }
        }

        return maxPoolResults(Array.from(hits.values()), limit, config.minSimilarityScore ?? 0);
    },

    async searchInPaths(query: string, paths: string[], limit: number = WORKER_INDEXER_CONSTANTS.SEARCH_LIMIT_DEFAULT): Promise<GraphSearchResult[]> {
        const normalizedPaths = paths.map(p => workerNormalizePath(p));
        const results = await search(orama, {
            limit: limit * WORKER_INDEXER_CONSTANTS.SEARCH_OVERSHOOT_FACTOR_VECTOR,
            mode: 'vector',
            vector: {
                property: 'embedding',
                value: (await generateEmbedding(query, 'Query')).vector
            },
            where: { path: { in: normalizedPaths } }
        });
        return maxPoolResults(results.hits.map(h => ({
            doc: (h as unknown as OramaResultHit).document,
            id: h.id,
            score: h.score
        })), limit, config.minSimilarityScore ?? 0);
    },

    async updateAliasMap(map: Record<string, string>) {
        await Promise.resolve();
        aliasMap.clear();
        for (const [alias, path] of Object.entries(map)) {
            aliasMap.set(alias.toLowerCase(), workerNormalizePath(path));
        }
        workerLogger.debug(`Updated alias map: ${aliasMap.size} entries.`);
    },

    async updateConfig(newConfig: Partial<WorkerConfig>) {
        if (!config) throw new Error("[IndexerWorker] Configuration not initialized");
        workerLogger.info("Updating worker configuration.");
        const dimChanged = newConfig.embeddingDimension !== undefined && newConfig.embeddingDimension !== config.embeddingDimension;
        const modelChanged = newConfig.embeddingModel !== undefined && newConfig.embeddingModel !== config.embeddingModel;

        config = { ...config, ...newConfig };
        if (dimChanged || modelChanged) await recreateOrama();

        if (newConfig.agentLanguage && newConfig.agentLanguage !== config.agentLanguage) {
            currentStopWords = await loadStopWords(newConfig.agentLanguage);
        }
    },

    async updateFile(path: string, content: string, mtime: number, size: number, title: string, links: string[] = []) {
        if (!orama) throw new Error("[IndexerWorker] Orama index not initialized");
        if (!graph) throw new Error("[IndexerWorker] Graph not initialized");
        if (!config) throw new Error("[IndexerWorker] Configuration not initialized");

        const normalizedPath = workerNormalizePath(path);
        const hash = await computeHash(content);

        // We update the graph immediately (fast, local) to ensure structural neighbors work
        updateGraphNode(normalizedPath, content, mtime, size, title, hash, 0);
        updateGraphEdges(normalizedPath, content, links);

        if (content.trim().length === 0) {
            await IndexerWorker.deleteFile(normalizedPath);
            return;
        }

        const cleanlyContent = sanitizeExcalidrawContent(content);
        const { body, frontmatter } = splitFrontmatter(cleanlyContent);
        const parsedFM = parseYaml(frontmatter);
        
        const pathSegments = normalizedPath.split('/').filter(Boolean);
        const dir = pathSegments.length > 1 ? pathSegments.slice(0, -1).join('/') : '';
        const context = generateContextString(title, dir, parsedFM, config);

        const bodyOffset = cleanlyContent.indexOf(body);
        const chunks = semanticSplit(body, (config.embeddingChunkSize || 512) * SEARCH_CONSTANTS.CHARS_PER_TOKEN_ESTIMATE);

        const batchedDocs: OramaDocument[] = [];
        let totalTokens = 0;
        
        const fullTexts = chunks.map(chunk => (context + "\n" + chunk.text).trim());
        const validFullTexts = fullTexts.filter(text => text.length > 0);

        if (validFullTexts.length > 0) {
            try {
                workerLogger.debug(`[IndexerWorker] Generating embeddings for ${normalizedPath}: ${validFullTexts.length} chunks. Context length: ${context.length}`);
                const res = (await generateEmbedding(validFullTexts, title)) as { vectors?: number[][]; vector?: number[]; tokenCount: number };
                
                // Atomic update: only delete the old version if the new one was successfully embedded
                await IndexerWorker.deleteOramaRecord(normalizedPath);

                totalTokens = res.tokenCount;
                // The res.vectors contains the batched embeddings for the full texts
                let vectorIdx = 0;
                
                for (let i = 0; i < chunks.length; i++) {
                    const chunk = chunks[i];
                    if (!chunk) continue;

                    const fullText = (context + "\n" + chunk.text).trim();
                    const chunkId = `${normalizedPath}#${i}`;

                    if (fullText.length === 0) continue;

                    const resVectors = res.vectors;
                    const resVector = res.vector;
                    const vector = (resVectors && resVectors[vectorIdx] ? resVectors[vectorIdx++] : resVector) as number[];

                    if (!vector || vector.length === 0) {
                        workerLogger.warn(`[IndexerWorker] Skipping Orama upsert for ${normalizedPath} chunk ${i} due to missing embedding.`);
                        continue;
                    }

                    batchedDocs.push({
                        anchorHash: fastHash(chunk.text),
                        author: parsedFM.author ? sanitizeProperty(parsedFM.author) : undefined,
                        content: chunk.text,
                        context: context,
                        created: mtime,
                        embedding: vector,
                        end: chunk.end + bodyOffset,
                        id: chunkId,
                        mtime: mtime,
                        params: [],
                        path: normalizedPath,
                        start: chunk.start + bodyOffset,
                        status: parsedFM.status ? sanitizeProperty(parsedFM.status) : 'active',
                        title: title,
                        tokenCount: totalTokens / validFullTexts.length,
                    });
                }

                for (const doc of batchedDocs) {
                    await upsert(orama, doc);
                }
            } catch (error) {
                workerLogger.error(`[IndexerWorker] Failed to embed ${path}:`, error);
                // We DON'T delete the old Orama record here, so the old version persists.
                // However, we still update the graph node with the new content (structural only).
            }
        }

        // Final patch for structural node
        updateGraphNode(normalizedPath, content, mtime, size, title, hash, totalTokens);
    },

    async updateFiles(files: FileUpdateData[]) {
        for (const file of files) {
            await IndexerWorker.updateFile(
                file.path,
                file.content,
                file.mtime,
                file.size,
                file.title,
                file.links
            );
        }
    }
};

// --- Helper Functions ---

function maxPoolResults(hits: OramaHit[], limit: number, minScore: number): GraphSearchResult[] {
    const uniqueHits = new Map<string, GraphSearchResult>();
    for (const hit of hits) {
        if (hit.score < minScore) continue;
        const doc = hit.doc;
        const existing = uniqueHits.get(doc.path);
        if (!existing || hit.score > existing.score) {
            uniqueHits.set(doc.path, {
                anchorHash: doc.anchorHash,
                end: doc.end,
                excerpt: doc.content,
                path: doc.path,
                score: hit.score,
                start: doc.start,
                title: doc.title,
                tokenCount: doc.tokenCount,
            });
        }
    }

    const finalHits = Array.from(uniqueHits.values());

    return finalHits
        .sort((a, b) => b.score - a.score)
        .slice(0, limit);
}

function generateContextString(title: string, dir: string, fm: Record<string, unknown>, conf: WorkerConfig): string {
    const parts: string[] = [];
    const props = conf.contextAwareHeaderProperties || ['title', 'topics', 'tags', 'type', 'author', 'status'];
    for (const key of props) {
        let val = fm[key];
        if (key === 'title' && !val) val = title;
        if (val) {
            const sanitized = sanitizeProperty(val);
            if (sanitized) parts.push(`${key.charAt(0).toUpperCase() + key.slice(1)}: ${sanitized}.`);
        }
    }

    // Inject folder string safely to prevent truncating metadata
    if (dir && conf.implicitFolderSemantics && conf.implicitFolderSemantics !== 'none') {
        const safeDir = dir.substring(0, 200); // Prevent token bloat
        parts.push(`Folder Structure: ${safeDir}.`);
    }

    return parts.join(' ').substring(0, 1000);
}

function updateGraphNode(path: string, content: string, mtime: number, size: number, title: string, hash: string, tokenCount: number) {
    const headers = extractHeaders(content);
    const fm = parseYaml(splitFrontmatter(content).frontmatter);

    // Extract topics for UI coloring
    let topics: string[] = [];
    const propertyKeys = config.contextAwareHeaderProperties || ['topics', 'tags', 'topic', 'tags_list', 'author'];
    for (const key of propertyKeys) {
        if (fm[key]) {
            topics = [...topics, ...ensureArray(fm[key])].filter(t => typeof t === 'string').map(t => sanitizeProperty(t));
        }
    }
    // Remove duplicates
    topics = [...new Set(topics.filter(Boolean))];

    if (!graph.hasNode(path)) {
        graph.addNode(path, { hash, headers, mtime, path, size, title, tokenCount, topics, type: 'file' });
    } else {
        graph.updateNodeAttributes(path, (attr: unknown) => ({ ...(attr as GraphNodeData), hash, headers, mtime, size, title, tokenCount, topics }));
    }
}

function updateGraphEdges(path: string, content: string, resolvedLinks: string[]) {
    const { body, frontmatter } = splitFrontmatter(content);
    const fm = parseYaml(frontmatter);
    const dir = path.split('/').slice(0, -1).join('/');

    // 1. Process Pre-Resolved Links from MetadataCache (Wikilinks)
    for (const link of resolvedLinks) {
        // Obisidan resolved link is already a correct canonical path, just normalize backslashes
        const resolved = workerNormalizePath(link);
        if (!graph.hasNode(resolved)) graph.addNode(resolved, { mtime: 0, path: resolved, size: 0, type: 'topic' });
        if (!graph.hasEdge(path, resolved)) {
            graph.addEdge(path, resolved, { source: 'body', type: 'link', weight: ONTOLOGY_CONSTANTS.EDGE_WEIGHTS.BODY });
        }
    }

    // 2. Fallback Extraction (for standard MD links starting with '/' not tracked by Obsidian core)
    const extractedLinks = new Set([...extractLinks(body), ...extractLinks(frontmatter)]);
    for (const link of extractedLinks) {
        const resolved = resolvePath(link, aliasMap, dir);
        if (!graph.hasNode(resolved)) graph.addNode(resolved, { mtime: 0, path: resolved, size: 0, type: 'topic' });
        if (!graph.hasEdge(path, resolved)) {
            graph.addEdge(path, resolved, { source: 'body', type: 'link', weight: ONTOLOGY_CONSTANTS.EDGE_WEIGHTS.BODY });
        }
    }

    // Semantic Property Link Extraction
    const propertyKeys = config.contextAwareHeaderProperties || ['topics', 'tags', 'topic', 'tags_list', 'author'];
    for (const key of propertyKeys) {
        const val = fm[key];
        if (!val) continue;

        const items = ensureArray(val);
        for (const rawItem of items) {
            if (typeof rawItem !== 'string') continue;
            const item = sanitizeProperty(rawItem);
            if (!item) continue;

            const resolved = resolvePath(item, aliasMap, dir);
            if (!graph.hasNode(resolved)) graph.addNode(resolved, { mtime: 0, path: resolved, size: 0, type: 'file' });
            if (!graph.hasEdge(path, resolved)) {
                graph.addEdge(path, resolved, {
                    source: 'frontmatter-property',
                    type: 'link',
                    weight: ONTOLOGY_CONSTANTS.EDGE_WEIGHTS.FRONTMATTER
                });
            }
        }
    }

    // Implicit Folder Edge Injection
    if (config.implicitFolderSemantics && config.implicitFolderSemantics !== 'none') {
        const folders = path.split('/').filter(Boolean).slice(0, -1); // Exclude basename
        const ontologyRoot = workerNormalizePath(config.ontologyPath || 'Ontology');
        
        for (const folder of folders) {
            const folderNameLower = folder.toLowerCase();
            let shouldInject = false;
            let targetResolved = resolvePath(folder, aliasMap, dir);
            
            if (config.implicitFolderSemantics === 'ontology') {
                if (aliasMap.has(folderNameLower) && targetResolved.startsWith(ontologyRoot + '/')) {
                    const segments = targetResolved.split('/');
                    const targetBasename = (segments[segments.length - 1] || '').replace(/\.md$/i, '').toLowerCase();
                    const targetParentName = segments.length > 1 ? (segments[segments.length - 2] || '').toLowerCase() : '';
                    if (targetBasename && targetBasename !== targetParentName) {
                        shouldInject = true;
                    }
                }
            } else if (config.implicitFolderSemantics === 'all') {
                shouldInject = true;
                if (!aliasMap.has(folderNameLower)) {
                    targetResolved = `_implicit_folder_/${folder}`;
                }
            }
            
            if (shouldInject) {
                if (!graph.hasNode(targetResolved)) {
                    graph.addNode(targetResolved, { mtime: 0, path: targetResolved, size: 0, type: 'topic' });
                }
                
                if (!graph.hasEdge(path, targetResolved)) {
                    graph.addEdge(path, targetResolved, {
                        source: 'implicit-folder',
                        type: 'link',
                        weight: ONTOLOGY_CONSTANTS.EDGE_WEIGHTS.IMPLICIT || 0.8
                    });
                }
            }
        }
    }
}

interface OramaV3 {
    create: (config: unknown) => Promise<AnyOrama>;
    load: (orama: AnyOrama, data: unknown) => Promise<void>;
    save: (orama: AnyOrama) => Promise<unknown>;
}

interface OramaRawDocs {
    docs: {
        count: number;
        docs: Record<string, Record<string, unknown>>;
    };
}

async function recreateOrama() {
    try {
        const oramaLib = await import('@orama/orama') as unknown as OramaV3;
        orama = await oramaLib.create({
            language: getOramaLanguage(config.agentLanguage || 'english'),
            schema: {
                anchorHash: 'number',
                author: 'string',
                content: 'string',
                context: 'string',
                created: 'number',
                embedding: `vector[${config.embeddingDimension}]`,
                end: 'number',
                id: 'enum',
                mtime: 'number',
                params: 'enum[]',
                path: 'enum',
                start: 'number',
                status: 'enum',
                title: 'string',
                tokenCount: 'number',
            }
        });
    } catch (e) {
        workerLogger.error("Failed to recreate Orama:", e);
    }
}

async function computeHash(text: string): Promise<string> {
    const msg = new TextEncoder().encode(text);
    const hash = await crypto.subtle.digest('SHA-256', msg);
    return Array.from(new Uint8Array(hash)).map(b => b.toString(16).padStart(2, '0')).join('');
}

async function generateEmbedding(text: string | string[], title: string): Promise<{ vector: number[]; vectors?: number[][]; tokenCount: number }> {
    if (!embedderProxy) throw new Error("Embedding proxy not initialized.");
    const res = await embedderProxy(text, title);
    return {
        tokenCount: res.tokenCount,
        vector: res.vector,
        vectors: res.vectors
    };
}

if (typeof postMessage !== 'undefined' && typeof addEventListener !== 'undefined') {
    Comlink.expose(IndexerWorker);
}

export default class IndexerWorkerHelper extends Worker {
    constructor() { super('worker'); }
}
