// engine/detect-duplication.ts — SPEC-970: Token-based code duplication detection
import { glob } from 'glob';
import { createHash } from 'node:crypto';
import { readFile } from 'node:fs/promises';
import { relative } from 'node:path';
const DEFAULT_MIN_LINES = 5;
const DEFAULT_EXCLUDE = ['node_modules/**', 'dist/**', '.git/**', 'tests/fixtures/**'];
const DEFAULT_LANGUAGES = ['typescript', 'javascript'];
const LANGUAGE_EXTENSIONS = {
    typescript: ['.ts', '.tsx'],
    javascript: ['.js', '.jsx', '.mjs', '.cjs'],
};
/**
 * Build glob patterns for the requested languages.
 */
function buildPatterns(languages) {
    const langs = languages?.length ? languages : DEFAULT_LANGUAGES;
    const exts = langs.flatMap((lang) => LANGUAGE_EXTENSIONS[lang] ?? []);
    if (exts.length === 0) {
        return ['**/*.ts', '**/*.js'];
    }
    return exts.map((ext) => `**/*${ext}`);
}
/**
 * Build ignore patterns merging defaults with user exclusions.
 */
function buildIgnorePatterns(userExclude) {
    const user = userExclude ?? [];
    return [...DEFAULT_EXCLUDE, ...user];
}
/**
 * Normalize a single line: remove inline comments, trim, lowercase.
 */
function normalizeLine(line) {
    let trimmed = line;
    // Remove single-line comments (but not URLs like http://)
    const commentIdx = trimmed.indexOf('//');
    if (commentIdx >= 0) {
        trimmed = trimmed.slice(0, commentIdx);
    }
    return trimmed.trim().toLowerCase();
}
/**
 * Remove multi-line comment blocks from source text.
 */
function stripMultilineComments(text) {
    return text.replace(/\/\*[\s\S]*?\*\//g, '');
}
/**
 * Hash an array of normalized lines using SHA-256 (first 16 chars for speed).
 */
function hashLines(lines) {
    const content = lines.join('\n');
    return createHash('sha256').update(content).digest('hex').slice(0, 16);
}
/**
 * Extract sliding windows of `windowSize` consecutive non-empty normalized lines.
 * Returns blocks with their original line numbers.
 */
function extractBlocks(filePath, rawLines, windowSize) {
    const blocks = [];
    const normalizedLines = [];
    for (let i = 0; i < rawLines.length; i++) {
        const line = rawLines[i];
        if (line === undefined) {
            continue;
        }
        const normalized = normalizeLine(line);
        if (normalized.length > 0) {
            normalizedLines.push({ text: normalized, originalIndex: i + 1 });
        }
    }
    if (normalizedLines.length < windowSize) {
        return blocks;
    }
    for (let i = 0; i <= normalizedLines.length - windowSize; i++) {
        const windowLines = normalizedLines.slice(i, i + windowSize);
        const firstWindowLine = windowLines[0];
        const lastWindowLine = windowLines[windowLines.length - 1];
        if (!firstWindowLine || !lastWindowLine) {
            continue;
        }
        blocks.push({
            path: filePath,
            startLine: firstWindowLine.originalIndex,
            endLine: lastWindowLine.originalIndex,
            lines: windowLines.map((w) => w.text),
        });
    }
    return blocks;
}
/**
 * Merge overlapping or adjacent blocks within the same file to avoid
 * redundant reporting of the same duplicated region.
 */
function mergeBlocksInFile(blocks) {
    if (blocks.length <= 1) {
        return blocks;
    }
    const sorted = [...blocks].sort((a, b) => {
        if (a.startLine !== b.startLine) {
            return a.startLine - b.startLine;
        }
        return a.endLine - b.endLine;
    });
    const first = sorted[0];
    if (!first) {
        return blocks;
    }
    const merged = [first];
    for (let i = 1; i < sorted.length; i++) {
        const last = merged[merged.length - 1];
        const current = sorted[i];
        if (!last || !current) {
            continue;
        }
        // Merge if overlapping or adjacent (gap <= 2 lines)
        if (current.path === last.path && current.startLine <= last.endLine + 2) {
            if (current.endLine > last.endLine) {
                last.endLine = current.endLine;
            }
        }
        else {
            merged.push(current);
        }
    }
    return merged;
}
/**
 * Determine severity based on duplicated line count.
 */
function severityFromLineCount(lineCount) {
    if (lineCount < 3) {
        return 'info';
    }
    if (lineCount <= 10) {
        return 'warning';
    }
    return 'error';
}
/**
 * Build a human-readable extraction suggestion.
 */
function buildSuggestion(cluster) {
    const { files, lineCount } = cluster;
    if (files.length < 2) {
        return '';
    }
    const first = files[0];
    const second = files[1];
    if (!first || !second) {
        return '';
    }
    return `Extract ${String(lineCount)} lines from ${first.path}:${String(first.startLine)} and ${second.path}:${String(second.startLine)} into shared utility`;
}
import { fastFindDuplicateBlocks } from './core-bridge.js';
async function detectDuplicateBlocksFallback(uniqueFiles, minLines) {
    const hashMap = new Map();
    for (const filePath of uniqueFiles) {
        let content;
        try {
            content = await readFile(filePath, 'utf-8');
        }
        catch {
            continue;
        }
        content = stripMultilineComments(content);
        const rawLines = content.split('\n');
        const blocks = extractBlocks(filePath, rawLines, minLines);
        for (const block of blocks) {
            const h = hashLines(block.lines);
            const existing = hashMap.get(h) ?? [];
            existing.push(block);
            hashMap.set(h, existing);
        }
    }
    const rawClusters = [];
    for (const [h, locations] of hashMap.entries()) {
        if (locations.length >= 2) {
            rawClusters.push({ hash: h, locations });
        }
    }
    return rawClusters;
}
function isDuplicateRawBlockArray(value) {
    if (!Array.isArray(value)) {
        return false;
    }
    return value.every((item) => {
        if (typeof item !== 'object' || item === null) {
            return false;
        }
        const candidate = item;
        return typeof candidate.hash === 'string' && Array.isArray(candidate.locations);
    });
}
/**
 * Detect code duplication across project files.
 */
export async function detectDuplication(input) {
    const { projectPath, minLines = DEFAULT_MIN_LINES, exclude, languages } = input;
    const patterns = buildPatterns(languages);
    const ignorePatterns = buildIgnorePatterns(exclude);
    const allFiles = [];
    for (const pattern of patterns) {
        const matches = await glob(pattern, {
            cwd: projectPath,
            ignore: ignorePatterns,
            absolute: true,
            nodir: true,
        });
        allFiles.push(...matches);
    }
    // Deduplicate file list
    const uniqueFiles = [...new Set(allFiles)];
    const bridgeResult = fastFindDuplicateBlocks(uniqueFiles, minLines);
    const rawClusters = isDuplicateRawBlockArray(bridgeResult)
        ? bridgeResult
        : await detectDuplicateBlocksFallback(uniqueFiles, minLines);
    // Group locations into clusters, merging per-file overlaps
    const clusters = [];
    let clusterId = 0;
    for (const raw of rawClusters) {
        // Merge overlapping blocks in the same file
        const merged = mergeBlocksInFile(raw.locations);
        // Only keep if still 2+ distinct locations after merge
        if (merged.length < 2) {
            continue;
        }
        const firstBlock = merged[0];
        if (!firstBlock) {
            continue;
        }
        const lineCount = firstBlock.endLine - firstBlock.startLine + 1;
        const severity = severityFromLineCount(lineCount);
        const relativePaths = merged.map((m) => ({
            path: relative(projectPath, m.path),
            startLine: m.startLine,
            endLine: m.endLine,
        }));
        const cluster = {
            id: `dup-${String(clusterId++).padStart(3, '0')}`,
            files: relativePaths,
            similarity: 100,
            lineCount,
            severity,
            suggestion: '',
        };
        cluster.suggestion = buildSuggestion(cluster);
        clusters.push(cluster);
    }
    // Deduplicate clusters that share the same set of file ranges
    const uniqueClusters = deduplicateClusters(clusters);
    const totalFilesAffected = new Set(uniqueClusters.flatMap((c) => c.files.map((f) => f.path)))
        .size;
    const overallSeverity = uniqueClusters.reduce((max, c) => {
        const order = { info: 0, warning: 1, error: 2 };
        return order[c.severity] > order[max] ? c.severity : max;
    }, 'info');
    return {
        clusters: uniqueClusters,
        totalClusters: uniqueClusters.length,
        totalFilesAffected,
        severity: overallSeverity,
    };
}
/**
 * Deduplicate clusters that cover the exact same file ranges.
 */
function deduplicateClusters(clusters) {
    const seen = new Set();
    const result = [];
    for (const cluster of clusters) {
        const key = cluster.files
            .map((f) => `${f.path}:${String(f.startLine)}-${String(f.endLine)}`)
            .join('|');
        if (!seen.has(key)) {
            seen.add(key);
            result.push(cluster);
        }
    }
    return result;
}
//# sourceMappingURL=detect-duplication.js.map