// engine/spec-searcher.ts — Pure search engine for specs (SPEC-295)
// Fuzzy matching, multi-field filtering, scoring, sorting, pagination.
// ---------------------------------------------------------------------------
// SPEC-514: Token-based semantic similarity
// ---------------------------------------------------------------------------
const STOPWORDS = new Set([
    'a',
    'an',
    'the',
    'and',
    'or',
    'but',
    'in',
    'on',
    'at',
    'to',
    'for',
    'of',
    'with',
    'by',
    'from',
    'is',
    'are',
    'was',
    'were',
    'be',
    'been',
    'being',
    'have',
    'has',
    'had',
    'do',
    'does',
    'did',
    'will',
    'would',
    'could',
    'should',
    'may',
    'might',
    'can',
    'that',
    'this',
    'it',
    'its',
]);
/**
 * Tokenizes text: lowercase, split on non-alphanumeric, remove stopwords and single chars.
 */
export function tokenize(text) {
    return text
        .toLowerCase()
        .split(/[^a-z0-9]+/)
        .filter((t) => t.length > 1 && !STOPWORDS.has(t));
}
/** Compute Jaccard similarity between two token sets. */
function jaccardSimilarity(a, b) {
    if (a.length === 0 || b.length === 0) {
        return 0;
    }
    const setA = new Set(a);
    const setB = new Set(b);
    let intersection = 0;
    for (const t of setA) {
        if (setB.has(t)) {
            intersection++;
        }
    }
    const union = setA.size + setB.size - intersection;
    return union === 0 ? 0 : intersection / union;
}
/**
 * SPEC-514: Compute token-overlap semantic score for a spec against a query.
 * Weights: title (3x) > tags (2x) > slug (0.5x). Normalized to [0,1].
 */
export function computeSemanticScore(query, spec) {
    const queryTokens = tokenize(query);
    if (queryTokens.length === 0) {
        return { specId: spec.id, title: spec.title, score: 0, matchedFields: [] };
    }
    const matchedFields = [];
    let weightedSum = 0;
    let totalWeight = 0;
    // Title (weight 3)
    const titleTokens = tokenize(spec.title);
    const titleSim = jaccardSimilarity(queryTokens, titleTokens);
    if (titleSim > 0) {
        matchedFields.push('title');
    }
    weightedSum += titleSim * 3;
    totalWeight += 3;
    // Tags (weight 2)
    const tagTokens = spec.tags.flatMap((t) => tokenize(t));
    const tagSim = jaccardSimilarity(queryTokens, tagTokens);
    if (tagSim > 0 && !matchedFields.includes('tags')) {
        matchedFields.push('tags');
    }
    weightedSum += tagSim * 2;
    totalWeight += 2;
    // Slug (weight 0.5)
    const slugTokens = tokenize(spec.slug);
    const slugSim = jaccardSimilarity(queryTokens, slugTokens);
    if (slugSim > 0) {
        matchedFields.push('slug');
    }
    weightedSum += slugSim * 0.5;
    totalWeight += 0.5;
    const score = totalWeight > 0 ? weightedSum / totalWeight : 0;
    return { specId: spec.id, title: spec.title, score, matchedFields };
}
/**
 * SPEC-514: Find specs semantically similar to query above threshold.
 * Zero external dependencies — pure token overlap (Jaccard).
 */
export function findSimilarSpecs(query, specs, options = {}) {
    const { threshold = 0.3, topN = 5 } = options;
    if (!query.trim()) {
        return [];
    }
    return specs
        .map((s) => computeSemanticScore(query, s))
        .filter((r) => r.score >= threshold)
        .sort((a, b) => b.score - a.score)
        .slice(0, topN);
}
// ---------------------------------------------------------------------------
// Fuzzy matching
// ---------------------------------------------------------------------------
/**
 * Returns a score 0-1 for how well `query` matches `text`.
 * Exact substring → 1.0. Subsequence match → query.length / text.length.
 * No match → 0.
 */
export function fuzzyMatch(text, query) {
    if (query.length === 0) {
        return 1;
    }
    if (text.length === 0) {
        return 0;
    }
    const lowerText = text.toLowerCase();
    const lowerQuery = query.toLowerCase();
    // Exact substring match
    if (lowerText.includes(lowerQuery)) {
        return 1;
    }
    // Subsequence match: all query chars appear in order in text
    let qi = 0;
    for (let ti = 0; ti < lowerText.length && qi < lowerQuery.length; ti++) {
        if (lowerText[ti] === lowerQuery[qi]) {
            qi++;
        }
    }
    if (qi < lowerQuery.length) {
        return 0;
    } // not a subsequence
    return lowerQuery.length / lowerText.length;
}
// ---------------------------------------------------------------------------
// Filter matching
// ---------------------------------------------------------------------------
/**
 * Returns true if spec matches ALL provided filters.
 * Within each array filter, any match is sufficient (OR logic).
 * Across filters, all must match (AND logic).
 */
export function matchesFilters(spec, input) {
    // status filter
    if (input.status && input.status.length > 0) {
        if (!input.status.includes(spec.status)) {
            return false;
        }
    }
    // type filter
    if (input.type && input.type.length > 0) {
        if (!input.type.includes(spec.type)) {
            return false;
        }
    }
    // target filter
    if (input.target && input.target.length > 0) {
        if (!input.target.includes(spec.target)) {
            return false;
        }
    }
    // risk filter
    if (input.risk && input.risk.length > 0) {
        if (!input.risk.includes(spec.risk)) {
            return false;
        }
    }
    // tags filter — spec must have at least one matching tag
    if (input.tags && input.tags.length > 0) {
        const hasTag = input.tags.some((tag) => spec.tags.includes(tag));
        if (!hasTag) {
            return false;
        }
    }
    // difficulty range filter
    if (input.difficulty) {
        if (input.difficulty.min !== undefined && spec.difficulty < input.difficulty.min) {
            return false;
        }
        if (input.difficulty.max !== undefined && spec.difficulty > input.difficulty.max) {
            return false;
        }
    }
    // date filters
    if (input.createdAfter && spec.createdAt < input.createdAfter) {
        return false;
    }
    if (input.createdBefore && spec.createdAt > input.createdBefore) {
        return false;
    }
    if (input.updatedAfter && spec.updatedAt < input.updatedAfter) {
        return false;
    }
    if (input.updatedBefore && spec.updatedAt > input.updatedBefore) {
        return false;
    }
    return true;
}
// ---------------------------------------------------------------------------
// Match scoring
// ---------------------------------------------------------------------------
/**
 * Computes a relevance score [0-1] for a spec against a query.
 * Higher weight given to: exact id match, then title, then tags.
 */
export function computeMatchScore(spec, query) {
    if (!query || query.trim().length === 0) {
        return { score: 1, matchedFields: [] };
    }
    const matchedFields = [];
    let score = 0;
    // Exact ID match → highest score
    if (spec.id.toLowerCase() === query.toLowerCase()) {
        return { score: 1.0, matchedFields: ['id'] };
    }
    // Title fuzzy match
    const titleScore = fuzzyMatch(spec.title, query);
    if (titleScore > 0) {
        matchedFields.push('title');
        score = Math.max(score, titleScore * 0.9);
    }
    // Tags fuzzy match
    for (const tag of spec.tags) {
        const tagScore = fuzzyMatch(tag, query);
        if (tagScore > 0) {
            if (!matchedFields.includes('tags')) {
                matchedFields.push('tags');
            }
            score = Math.max(score, tagScore * 0.7);
        }
    }
    // ID partial match (starts with or contains)
    const idScore = fuzzyMatch(spec.id, query);
    if (idScore > 0) {
        if (!matchedFields.includes('id')) {
            matchedFields.push('id');
        }
        score = Math.max(score, idScore * 0.8);
    }
    return { score, matchedFields };
}
// ---------------------------------------------------------------------------
// Comparators for sorting
// ---------------------------------------------------------------------------
function compareSpecs(a, b, sortBy, sortOrder) {
    const direction = sortOrder === 'desc' ? -1 : 1;
    if (sortBy === 'relevance' || !sortBy) {
        // Higher score first (desc by default for relevance)
        return (b.score - a.score) * direction;
    }
    if (sortBy === 'createdAt') {
        return a.spec.createdAt.localeCompare(b.spec.createdAt) * direction;
    }
    if (sortBy === 'updatedAt') {
        return a.spec.updatedAt.localeCompare(b.spec.updatedAt) * direction;
    }
    // sortBy === 'difficulty'
    return (a.spec.difficulty - b.spec.difficulty) * direction;
}
// ---------------------------------------------------------------------------
// Main search function
// ---------------------------------------------------------------------------
/**
 * Filter, score, sort, and paginate specs according to SearchInput.
 */
export function searchSpecs(specs, input) {
    const limit = Math.min(Math.max(input.limit ?? 20, 1), 100);
    const offset = Math.max(input.offset ?? 0, 0);
    // Score and filter
    const scored = [];
    for (const spec of specs) {
        if (!matchesFilters(spec, input)) {
            continue;
        }
        const { score, matchedFields } = computeMatchScore(spec, input.query ?? '');
        if (input.query && input.query.trim().length > 0 && score === 0) {
            continue;
        }
        scored.push({ spec, score, matchedFields });
    }
    // Sort
    const sortBy = input.sortBy ?? (input.query ? 'relevance' : 'updatedAt');
    const sortOrder = input.sortOrder ?? (sortBy === 'relevance' ? 'desc' : 'asc');
    scored.sort((a, b) => compareSpecs(a, b, sortBy, sortOrder));
    const total = scored.length;
    const paginated = scored.slice(offset, offset + limit);
    const items = paginated.map(({ spec, score, matchedFields }) => ({
        specId: spec.id,
        title: spec.title,
        status: spec.status,
        type: spec.type,
        tags: spec.tags,
        matchScore: score,
        matchedFields,
    }));
    return {
        total,
        hasMore: offset + limit < total,
        items,
    };
}
// ---------------------------------------------------------------------------
// Suggestions
// ---------------------------------------------------------------------------
/**
 * Returns unique field values from all specs that partially match the query.
 */
export function getSuggestions(specs, input) {
    const { query, field = 'tags' } = input;
    const lowerQuery = query.toLowerCase();
    const values = new Set();
    for (const spec of specs) {
        if (field === 'tags') {
            for (const tag of spec.tags) {
                if (tag.toLowerCase().includes(lowerQuery)) {
                    values.add(tag);
                }
            }
        }
        else if (field === 'type') {
            if (spec.type.toLowerCase().includes(lowerQuery)) {
                values.add(spec.type);
            }
        }
        else if (field === 'status') {
            if (spec.status.toLowerCase().includes(lowerQuery)) {
                values.add(spec.status);
            }
        }
        else {
            // field === 'id'
            if (spec.id.toLowerCase().includes(lowerQuery)) {
                values.add(spec.id);
            }
        }
    }
    return { suggestions: Array.from(values).sort() };
}
//# sourceMappingURL=spec-searcher.js.map