/**
 * Search Results Cache
 *
 * Caches search and grep results with file-change-aware invalidation.
 * Key features:
 * - Query normalization for better hit rates
 * - File dependency tracking for invalidation
 * - Concurrent request deduplication
 * - Pattern-based invalidation
 *
 * Search operations are expensive - caching provides significant speedup
 * for repeated queries.
 */
import { EventEmitter } from 'events';
import * as crypto from 'crypto';
// ============================================================================
// Default Configuration
// ============================================================================
const DEFAULT_CONFIG = {
    enabled: true,
    ttlMs: 2 * 60 * 1000, // 2 minutes (searches can become stale quickly)
    maxEntries: 200,
    invalidateOnFileChange: true,
    deduplicateRequests: true,
    normalizeQueries: true,
    tieredTtl: true,
    textSearchTtlMs: 1 * 60 * 1000, // 1 minute - text content changes frequently
    fileSearchTtlMs: 5 * 60 * 1000, // 5 minutes - file structure changes less
    symbolSearchTtlMs: 10 * 60 * 1000, // 10 minutes - symbols change rarely
    enableRankingOptimization: true,
    minExecutionTimeToCache: 50, // Don't cache queries faster than 50ms
    enablePartialMatch: true,
    partialMatchThreshold: 0.75,
};
// ============================================================================
// Search Results Cache
// ============================================================================
export class SearchResultsCache extends EventEmitter {
    cache = new Map();
    config;
    stats = {
        totalEntries: 0,
        hits: 0,
        misses: 0,
        hitRate: 0,
        deduplicatedRequests: 0,
        invalidations: 0,
        evictions: 0,
        byType: {
            text: 0,
            file: 0,
            symbol: 0,
            reference: 0,
        },
        partialHits: 0,
        avgTimeSavedMs: 0,
        totalTimeSavedMs: 0,
        efficiencyScore: 0,
    };
    // Pending requests for deduplication
    pendingRequests = new Map();
    // File -> cache keys mapping for invalidation
    fileToKeys = new Map();
    // Time savings tracking
    timeSavings = [];
    // Query frequency tracking for ranking optimization
    queryFrequency = new Map();
    constructor(config = {}) {
        super();
        this.config = { ...DEFAULT_CONFIG, ...config };
    }
    /**
     * Get TTL based on search type
     */
    getTtlForType(searchType) {
        if (!this.config.tieredTtl) {
            return this.config.ttlMs;
        }
        switch (searchType) {
            case 'text':
                return this.config.textSearchTtlMs;
            case 'file':
                return this.config.fileSearchTtlMs;
            case 'symbol':
            case 'reference':
                return this.config.symbolSearchTtlMs;
            default:
                return this.config.ttlMs;
        }
    }
    /**
     * Get cached search result
     */
    get(query, searchType, options) {
        if (!this.config.enabled)
            return null;
        const key = this.createKey(query, searchType, options);
        const entry = this.cache.get(key);
        // Track query frequency for ranking
        if (this.config.enableRankingOptimization) {
            const normalizedQuery = this.normalizeQuery(query);
            this.queryFrequency.set(normalizedQuery, (this.queryFrequency.get(normalizedQuery) || 0) + 1);
        }
        if (entry && !this.isExpired(entry)) {
            entry.hits++;
            this.stats.hits++;
            this.stats.byType[searchType]++;
            // Track time saved
            if (entry.metadata?.executionTimeMs) {
                this.recordTimeSaved(entry.metadata.executionTimeMs);
            }
            this.updateHitRate();
            this.updateEfficiencyScore();
            this.emit('cache:hit', { key, searchType });
            return entry.results;
        }
        // Try partial match if enabled
        if (this.config.enablePartialMatch) {
            const partialMatch = this.findPartialMatch(query, searchType, options);
            if (partialMatch) {
                this.stats.partialHits++;
                this.stats.hits++;
                this.stats.byType[searchType]++;
                this.updateHitRate();
                this.updateEfficiencyScore();
                this.emit('cache:partial-hit', { key, searchType, similarity: partialMatch.similarity });
                return partialMatch.entry.results;
            }
        }
        // Remove expired entry if present
        if (entry) {
            this.removeEntry(key, entry);
        }
        this.stats.misses++;
        this.updateHitRate();
        this.updateEfficiencyScore();
        return null;
    }
    /**
     * Find a partial match for a query
     */
    findPartialMatch(query, searchType, _options) {
        const normalizedQuery = this.normalizeQuery(query);
        const queryWords = new Set(normalizedQuery.split(/\s+/));
        let bestMatch = null;
        let bestSimilarity = 0;
        for (const entry of this.cache.values()) {
            if (entry.searchType !== searchType)
                continue;
            if (this.isExpired(entry))
                continue;
            // Calculate Jaccard similarity
            const entryWords = new Set(entry.normalizedQuery.split(/\s+/));
            const intersection = new Set([...queryWords].filter(w => entryWords.has(w)));
            const union = new Set([...queryWords, ...entryWords]);
            const similarity = intersection.size / union.size;
            if (similarity >= this.config.partialMatchThreshold && similarity > bestSimilarity) {
                bestSimilarity = similarity;
                bestMatch = entry;
            }
        }
        return bestMatch ? { entry: bestMatch, similarity: bestSimilarity } : null;
    }
    /**
     * Record time saved for metrics
     */
    recordTimeSaved(timeMs) {
        this.timeSavings.push(timeMs);
        if (this.timeSavings.length > 1000) {
            this.timeSavings.shift();
        }
        this.stats.totalTimeSavedMs += timeMs;
        this.stats.avgTimeSavedMs = this.timeSavings.reduce((a, b) => a + b, 0) / this.timeSavings.length;
    }
    /**
     * Update efficiency score
     */
    updateEfficiencyScore() {
        const total = this.stats.hits + this.stats.misses;
        if (total === 0) {
            this.stats.efficiencyScore = 0;
            return;
        }
        // Efficiency = weighted combination of hit rate, partial hit rate, and deduplication
        const hitRate = this.stats.hits / total;
        const partialRate = this.stats.partialHits / Math.max(1, this.stats.hits);
        const dedupeRate = this.stats.deduplicatedRequests / Math.max(1, total);
        this.stats.efficiencyScore = hitRate * 0.6 + partialRate * 0.2 + dedupeRate * 0.2;
    }
    /**
     * Store search result in cache
     */
    set(query, searchType, results, options) {
        if (!this.config.enabled)
            return;
        // Skip caching fast queries if configured
        if (options?.executionTimeMs !== undefined &&
            options.executionTimeMs < this.config.minExecutionTimeToCache) {
            return;
        }
        // Evict if needed
        this.evictIfNeeded();
        const key = this.createKey(query, searchType, options?.options);
        const normalizedQuery = this.config.normalizeQueries ? this.normalizeQuery(query) : query;
        const affectedFiles = options?.affectedFiles || [];
        // Use tiered TTL based on search type
        const ttl = this.getTtlForType(searchType);
        const entry = {
            key,
            queryHash: this.hashString(query),
            normalizedQuery,
            results,
            affectedFiles,
            timestamp: Date.now(),
            expiresAt: Date.now() + ttl,
            hits: 0,
            searchType,
            metadata: {
                resultCount: Array.isArray(results) ? results.length : undefined,
                executionTimeMs: options?.executionTimeMs,
                options: options?.options,
            },
        };
        // Remove old entry if exists
        const existing = this.cache.get(key);
        if (existing) {
            this.removeFileMapping(key, existing.affectedFiles);
        }
        this.cache.set(key, entry);
        // Track file dependencies
        if (this.config.invalidateOnFileChange) {
            this.addFileMapping(key, affectedFiles);
        }
        this.stats.totalEntries = this.cache.size;
        this.emit('cache:set', { key, searchType, ttl });
    }
    /**
     * Get or compute search result with deduplication
     */
    async getOrCompute(query, searchType, computeFn, options) {
        // Try cache first
        const cached = this.get(query, searchType, options?.options);
        if (cached !== null) {
            return { results: cached, cached: true };
        }
        // Deduplicate concurrent requests
        if (this.config.deduplicateRequests) {
            const key = this.createKey(query, searchType, options?.options);
            const pending = this.pendingRequests.get(key);
            if (pending) {
                this.stats.deduplicatedRequests++;
                const results = await pending;
                return { results, cached: false };
            }
            // Execute and cache
            const promise = (async () => {
                const startTime = Date.now();
                const results = await computeFn();
                const executionTimeMs = Date.now() - startTime;
                this.set(query, searchType, results, {
                    ...options,
                    executionTimeMs,
                });
                return results;
            })();
            this.pendingRequests.set(key, promise);
            try {
                const results = await promise;
                return { results, cached: false };
            }
            finally {
                this.pendingRequests.delete(key);
            }
        }
        // No deduplication
        const startTime = Date.now();
        const results = await computeFn();
        const executionTimeMs = Date.now() - startTime;
        this.set(query, searchType, results, {
            ...options,
            executionTimeMs,
        });
        return { results, cached: false };
    }
    /**
     * Invalidate cache entries affected by a file change
     */
    invalidateForFile(filePath) {
        if (!this.config.invalidateOnFileChange)
            return 0;
        const keys = this.fileToKeys.get(filePath);
        if (!keys)
            return 0;
        let count = 0;
        for (const key of keys) {
            const entry = this.cache.get(key);
            if (entry) {
                this.removeEntry(key, entry);
                count++;
            }
        }
        this.stats.invalidations += count;
        this.emit('cache:invalidate', { file: filePath, count });
        return count;
    }
    /**
     * Invalidate entries matching a pattern
     */
    invalidatePattern(pattern) {
        let count = 0;
        for (const [key, entry] of this.cache.entries()) {
            if (pattern.test(entry.normalizedQuery)) {
                this.removeEntry(key, entry);
                count++;
            }
        }
        this.stats.invalidations += count;
        return count;
    }
    /**
     * Invalidate entries by search type
     */
    invalidateByType(searchType) {
        let count = 0;
        for (const [key, entry] of this.cache.entries()) {
            if (entry.searchType === searchType) {
                this.removeEntry(key, entry);
                count++;
            }
        }
        this.stats.invalidations += count;
        return count;
    }
    /**
     * Create cache key
     */
    createKey(query, searchType, options) {
        const normalizedQuery = this.config.normalizeQueries ? this.normalizeQuery(query) : query;
        const parts = [searchType, this.hashString(normalizedQuery)];
        if (options) {
            const sortedOptions = Object.keys(options)
                .sort()
                .map(k => `${k}=${JSON.stringify(options[k])}`)
                .join('&');
            parts.push(this.hashString(sortedOptions));
        }
        return parts.join(':');
    }
    /**
     * Normalize query for better cache hits
     */
    normalizeQuery(query) {
        return query
            .toLowerCase()
            .replace(/\s+/g, ' ')
            .trim();
    }
    /**
     * Hash string
     */
    hashString(str) {
        return crypto.createHash('sha256').update(str).digest('hex').slice(0, 12);
    }
    /**
     * Check if entry is expired
     */
    isExpired(entry) {
        return Date.now() > entry.expiresAt;
    }
    /**
     * Remove entry and its file mappings
     */
    removeEntry(key, entry) {
        this.removeFileMapping(key, entry.affectedFiles);
        this.cache.delete(key);
    }
    /**
     * Add file -> key mapping
     */
    addFileMapping(key, files) {
        for (const file of files) {
            let keys = this.fileToKeys.get(file);
            if (!keys) {
                keys = new Set();
                this.fileToKeys.set(file, keys);
            }
            keys.add(key);
        }
    }
    /**
     * Remove file -> key mapping
     */
    removeFileMapping(key, files) {
        for (const file of files) {
            const keys = this.fileToKeys.get(file);
            if (keys) {
                keys.delete(key);
                if (keys.size === 0) {
                    this.fileToKeys.delete(file);
                }
            }
        }
    }
    /**
     * Evict entries if needed
     */
    evictIfNeeded() {
        // Remove expired first
        for (const [key, entry] of this.cache.entries()) {
            if (this.isExpired(entry)) {
                this.removeEntry(key, entry);
                this.stats.evictions++;
            }
        }
        // Evict LRU if still over capacity
        while (this.cache.size >= this.config.maxEntries) {
            this.evictLRU();
        }
    }
    /**
     * Evict least recently used entry
     */
    evictLRU() {
        let oldestKey = null;
        let oldestTime = Infinity;
        for (const [key, entry] of this.cache.entries()) {
            const score = entry.timestamp + entry.hits * 1000;
            if (score < oldestTime) {
                oldestTime = score;
                oldestKey = key;
            }
        }
        if (oldestKey) {
            const entry = this.cache.get(oldestKey);
            if (entry) {
                this.removeEntry(oldestKey, entry);
            }
            this.stats.evictions++;
            this.emit('cache:evict', { key: oldestKey });
        }
    }
    /**
     * Update hit rate statistic
     */
    updateHitRate() {
        const total = this.stats.hits + this.stats.misses;
        this.stats.hitRate = total > 0 ? this.stats.hits / total : 0;
    }
    /**
     * Get statistics
     */
    getStats() {
        return { ...this.stats };
    }
    /**
     * Format statistics
     */
    formatStats() {
        return [
            'Search Results Cache Statistics',
            `  Entries: ${this.stats.totalEntries}`,
            `  Hit Rate: ${(this.stats.hitRate * 100).toFixed(1)}%`,
            `  Deduplicated: ${this.stats.deduplicatedRequests}`,
            `  Invalidations: ${this.stats.invalidations}`,
            `  By Type:`,
            `    Text: ${this.stats.byType.text}`,
            `    File: ${this.stats.byType.file}`,
            `    Symbol: ${this.stats.byType.symbol}`,
            `    Reference: ${this.stats.byType.reference}`,
        ].join('\n');
    }
    /**
     * Clear cache
     */
    clear() {
        this.cache.clear();
        this.fileToKeys.clear();
        this.pendingRequests.clear();
        this.timeSavings = [];
        this.queryFrequency.clear();
        this.stats = {
            totalEntries: 0,
            hits: 0,
            misses: 0,
            hitRate: 0,
            deduplicatedRequests: 0,
            invalidations: 0,
            evictions: 0,
            byType: {
                text: 0,
                file: 0,
                symbol: 0,
                reference: 0,
            },
            partialHits: 0,
            avgTimeSavedMs: 0,
            totalTimeSavedMs: 0,
            efficiencyScore: 0,
        };
        this.emit('cache:clear');
    }
    /**
     * Dispose resources
     */
    dispose() {
        this.cache.clear();
        this.fileToKeys.clear();
        this.pendingRequests.clear();
        this.removeAllListeners();
    }
}
// ============================================================================
// Singleton
// ============================================================================
let instance = null;
export function getSearchResultsCache(config) {
    if (!instance) {
        instance = new SearchResultsCache(config);
    }
    return instance;
}
export function resetSearchResultsCache() {
    if (instance) {
        instance.dispose();
    }
    instance = null;
}
//# sourceMappingURL=search-results-cache.js.map