/**
 * Local TF-IDF codebase indexer for CLI (mirrors Vigthoria Code extension indexer).
 */
import * as crypto from 'crypto';
import * as fs from 'fs';
import * as path from 'path';
export class CodebaseIndexer {
    workspaceRoot;
    index = new Map();
    invertedIndex = new Map();
    chunkStore = new Map();
    fileHashes = new Map();
    indexedFileCount = 0;
    totalChunks = 0;
    isIndexing = false;
    maxFileSize = 1024 * 1024;
    chunkSize = 50;
    chunkOverlap = 5;
    maxResults = 10;
    ignorePatterns = [
        'node_modules', '.git', 'dist', 'build', 'out', '.next',
        '__pycache__', '.pytest_cache', '.mypy_cache', 'venv', 'env',
        '.DS_Store', 'package-lock.json', 'yarn.lock', 'pnpm-lock.yaml',
        '.vsix', '.map', '.min.js', '.min.css', 'coverage',
        '.cache', '.tmp', 'tmp', 'temp', '.idea', '.vscode',
        '.vigthoria',
    ];
    supportedExtensions = new Set([
        '.js', '.ts', '.jsx', '.tsx', '.py', '.java', '.go', '.rs',
        '.c', '.cpp', '.h', '.hpp', '.cs', '.rb', '.php', '.swift',
        '.kt', '.scala', '.vue', '.svelte', '.astro',
        '.html', '.css', '.scss', '.less', '.sass',
        '.json', '.yaml', '.yml', '.toml', '.xml',
        '.md', '.txt', '.rst', '.sh', '.bash', '.zsh',
        '.sql', '.graphql', '.prisma', '.env',
        '.dockerfile', '.tf', '.hcl',
    ]);
    constructor(workspaceRoot) {
        this.workspaceRoot = path.resolve(workspaceRoot);
    }
    getStatus() {
        return {
            indexedFileCount: this.indexedFileCount,
            totalChunks: this.totalChunks,
            isIndexing: this.isIndexing,
        };
    }
    static metaPath(workspaceRoot) {
        return path.join(workspaceRoot, '.vigthoria', 'index', 'meta.json');
    }
    static loadMeta(workspaceRoot) {
        try {
            const metaPath = CodebaseIndexer.metaPath(workspaceRoot);
            if (fs.existsSync(metaPath)) {
                return JSON.parse(fs.readFileSync(metaPath, 'utf8'));
            }
        }
        catch {
            // fresh workspace
        }
        return null;
    }
    saveMeta(meta) {
        const dir = path.join(this.workspaceRoot, '.vigthoria', 'index');
        fs.mkdirSync(dir, { recursive: true });
        fs.writeFileSync(CodebaseIndexer.metaPath(this.workspaceRoot), `${JSON.stringify(meta, null, 2)}\n`);
    }
    hasLocalIndex() {
        const meta = CodebaseIndexer.loadMeta(this.workspaceRoot);
        return !!(meta && meta.indexedFileCount > 0) || this.totalChunks > 0;
    }
    countCandidateFiles() {
        return this.findFiles(this.workspaceRoot).length;
    }
    countIndexableFiles() {
        return this.findFiles(this.workspaceRoot).length;
    }
    async indexWorkspace() {
        if (this.isIndexing) {
            return {
                indexedFileCount: this.indexedFileCount,
                totalChunks: this.totalChunks,
                indexHash: '',
                topFiles: [],
                indexedAt: new Date().toISOString(),
            };
        }
        this.isIndexing = true;
        this.index.clear();
        this.invertedIndex.clear();
        this.chunkStore.clear();
        this.fileHashes.clear();
        this.indexedFileCount = 0;
        this.totalChunks = 0;
        try {
            const files = this.findFiles(this.workspaceRoot);
            for (const filePath of files) {
                this.indexFile(filePath);
            }
            // The synchronization contract is content-addressed. Stable file and
            // chunk counts must never hide a source modification from the Brain.
            const contentHasher = crypto.createHash('sha256');
            const contentEntries = Array.from(this.fileHashes.entries())
                .map(([filePath, fileHash]) => [
                path.relative(this.workspaceRoot, filePath).split(path.sep).join('/'),
                fileHash,
            ])
                .sort(([left], [right]) => left.localeCompare(right));
            for (const [relativePath, fileHash] of contentEntries) {
                contentHasher.update(relativePath, 'utf8');
                contentHasher.update('\0', 'utf8');
                contentHasher.update(fileHash, 'ascii');
                contentHasher.update('\n', 'utf8');
            }
            // Empty workspaces still receive a deterministic, valid SHA-256 value.
            const indexHash = contentHasher.digest('hex');
            const topFiles = files.slice(0, 12).map((filePath) => path.relative(this.workspaceRoot, filePath));
            const meta = {
                indexedFileCount: this.indexedFileCount,
                totalChunks: this.totalChunks,
                indexHash,
                topFiles,
                indexedAt: new Date().toISOString(),
            };
            this.saveMeta(meta);
            return meta;
        }
        finally {
            this.isIndexing = false;
        }
    }
    search(query, maxResults = this.maxResults) {
        if (this.totalChunks === 0) {
            return [];
        }
        const queryTerms = this.tokenize(query.toLowerCase());
        const scores = new Map();
        for (const term of queryTerms) {
            const matchingChunks = this.invertedIndex.get(term);
            if (!matchingChunks)
                continue;
            const idf = Math.log(this.totalChunks / matchingChunks.size);
            for (const chunkId of matchingChunks) {
                const chunk = this.chunkStore.get(chunkId);
                if (!chunk)
                    continue;
                const tf = (chunk.terms.get(term) || 0) / chunk.totalTerms;
                scores.set(chunkId, (scores.get(chunkId) || 0) + tf * idf);
            }
        }
        for (const [chunkId, score] of scores) {
            const chunk = this.chunkStore.get(chunkId);
            if (!chunk)
                continue;
            const fileName = path.basename(chunk.filePath).toLowerCase();
            for (const term of queryTerms) {
                if (fileName.includes(term)) {
                    scores.set(chunkId, score * 1.5);
                }
            }
        }
        return Array.from(scores.entries())
            .sort((a, b) => b[1] - a[1])
            .slice(0, maxResults)
            .map(([chunkId, score]) => {
            const chunk = this.chunkStore.get(chunkId);
            return {
                filePath: chunk.filePath,
                relativePath: chunk.relativePath,
                startLine: chunk.startLine,
                endLine: chunk.endLine,
                content: chunk.content,
                language: chunk.language,
                score: Math.round(score * 1000) / 1000,
                symbol: chunk.symbol,
            };
        });
    }
    getContextForQuery(query, maxTokens = 4000) {
        const results = this.search(query, 15);
        if (results.length === 0) {
            return '';
        }
        let context = '### Relevant code from workspace:\n\n';
        let currentTokens = 0;
        const avgCharsPerToken = 4;
        for (const result of results) {
            const entry = `**${result.relativePath}** (lines ${result.startLine}-${result.endLine}):\n\`\`\`${result.language}\n${result.content}\n\`\`\`\n\n`;
            const entryTokens = Math.ceil(entry.length / avgCharsPerToken);
            if (currentTokens + entryTokens > maxTokens)
                break;
            context += entry;
            currentTokens += entryTokens;
        }
        return context;
    }
    formatSearchResults(query, maxResults = 30) {
        const results = this.search(query, Math.min(maxResults, 20));
        if (results.length === 0) {
            return 'No indexed codebase matches found. Run /index or wait for workspace indexing to finish.';
        }
        return results.map((result) => (`[indexed:${result.score}] ${result.relativePath}:${result.startLine}-${result.endLine}`
            + (result.symbol ? ` (${result.symbol})` : '')
            + `\n${result.content.split('\n').slice(0, 8).join('\n')}`)).join('\n\n');
    }
    findFiles(rootPath) {
        const files = [];
        const walk = (currentDir, depth) => {
            if (depth > 14 || files.length >= 10000)
                return;
            let entries;
            try {
                entries = fs.readdirSync(currentDir, { withFileTypes: true });
            }
            catch {
                return;
            }
            for (const entry of entries) {
                if (this.shouldIgnore(entry.name, path.join(currentDir, entry.name)))
                    continue;
                const fullPath = path.join(currentDir, entry.name);
                if (entry.isDirectory()) {
                    walk(fullPath, depth + 1);
                }
                else if (entry.isFile()) {
                    const ext = path.extname(entry.name).toLowerCase();
                    if (this.supportedExtensions.has(ext)) {
                        files.push(fullPath);
                    }
                }
            }
        };
        walk(rootPath, 0);
        return files;
    }
    indexFile(filePath) {
        try {
            const stat = fs.statSync(filePath);
            if (!stat.isFile() || stat.size > this.maxFileSize || stat.size === 0)
                return;
            const content = fs.readFileSync(filePath, 'utf8');
            const hash = crypto.createHash('md5').update(content).digest('hex');
            if (this.fileHashes.get(filePath) === hash)
                return;
            this.fileHashes.set(filePath, hash);
            const relativePath = path.relative(this.workspaceRoot, filePath);
            const language = this.getLanguage(filePath);
            const lines = content.split('\n');
            const chunks = this.chunkFile(lines, filePath, relativePath, language);
            const chunkIds = [];
            for (const chunk of chunks) {
                const chunkId = `${relativePath}:${chunk.startLine}-${chunk.endLine}`;
                const terms = new Map();
                const tokens = this.tokenize(chunk.content.toLowerCase());
                let totalTerms = 0;
                for (const token of tokens) {
                    terms.set(token, (terms.get(token) || 0) + 1);
                    totalTerms += 1;
                    if (!this.invertedIndex.has(token)) {
                        this.invertedIndex.set(token, new Set());
                    }
                    this.invertedIndex.get(token).add(chunkId);
                }
                this.chunkStore.set(chunkId, { ...chunk, terms, totalTerms });
                chunkIds.push(chunkId);
                this.totalChunks += 1;
            }
            this.index.set(filePath, chunkIds);
            this.indexedFileCount += 1;
        }
        catch {
            // skip unreadable files
        }
    }
    chunkFile(lines, filePath, relativePath, language) {
        const chunks = [];
        for (let i = 0; i < lines.length; i += this.chunkSize - this.chunkOverlap) {
            const endLine = Math.min(i + this.chunkSize, lines.length);
            const chunkLines = lines.slice(i, endLine);
            if (chunkLines.join('').trim().length === 0)
                continue;
            chunks.push({
                filePath,
                relativePath,
                startLine: i + 1,
                endLine,
                content: chunkLines.join('\n'),
                language,
                symbol: null,
            });
        }
        return chunks;
    }
    tokenize(text) {
        return text
            .replace(/[^a-zA-Z0-9_$]/g, ' ')
            .split(/\s+/)
            .filter((token) => token.length > 2)
            .filter((token) => !this.isStopWord(token));
    }
    isStopWord(word) {
        const stopWords = new Set([
            'the', 'and', 'for', 'that', 'this', 'with', 'from', 'are',
            'was', 'were', 'been', 'have', 'has', 'had', 'not', 'but',
            'its', 'can', 'will', 'would', 'could', 'should', 'may',
            'var', 'let', 'const', 'function', 'return', 'true', 'false',
            'null', 'undefined', 'new', 'class', 'import', 'export',
            'require', 'module', 'exports', 'default', 'async', 'await',
        ]);
        return stopWords.has(word);
    }
    getLanguage(filePath) {
        const ext = path.extname(filePath).toLowerCase();
        const map = {
            '.js': 'javascript', '.jsx': 'javascript',
            '.ts': 'typescript', '.tsx': 'typescript',
            '.py': 'python', '.html': 'html', '.css': 'css', '.json': 'json',
            '.md': 'markdown', '.sh': 'shell', '.go': 'go', '.rs': 'rust',
        };
        return map[ext] || 'text';
    }
    shouldIgnore(name, fullPath) {
        if (name.startsWith('.') && name !== '.env')
            return true;
        const relativePath = path.relative(this.workspaceRoot, fullPath);
        const segments = relativePath.split(path.sep).map((segment) => segment.toLowerCase());
        const fileName = segments.at(-1) || '';
        const suffixPatterns = new Set(['.vsix', '.map', '.min.js', '.min.css']);
        return this.ignorePatterns.some((rawPattern) => {
            const pattern = rawPattern.toLowerCase();
            if (suffixPatterns.has(pattern)) {
                return fileName.endsWith(pattern);
            }
            return segments.includes(pattern);
        });
    }
}
