import { readFile } from 'node:fs/promises';
import path from 'node:path';
import { compareCodeUnits } from '../utils.js';
import { incomingEdges } from './query.js';
import { isTestPath } from './tests.js';
import { normalizePath } from './languages.js';
import { staticImportResolutionCapability } from './parsers.js';
import { toNormalizedSymbolKind } from './types.js';
export const CODE_FACT_PREDICATES = [
    {
        name: 'exported-unreferenced',
        description: 'Exported symbols with no indexed call, usage, inheritance, or import-binding reference.',
    },
    {
        name: 'untested-entrypoints',
        description: 'Exported entry points with no reachable test; uses entity impact when available.',
    },
    {
        name: 'cycle-members',
        description: 'Modules participating in a resolved local-import cycle.',
    },
    {
        name: 'orphan-modules',
        description: 'Indexed modules with no resolved local importer.',
    },
    {
        name: 'layer-violations',
        description: 'Resolved local imports forbidden by an explicit layer manifest.',
    },
];
const DEFAULT_FACT_LIMIT = 50;
const MAX_ENTITY_CANDIDATES = 5_000;
export function listCodeFactPredicates() {
    return CODE_FACT_PREDICATES.map(predicate => ({ ...predicate }));
}
export function parseCodeFactPredicate(value) {
    const predicate = CODE_FACT_PREDICATES.find(candidate => candidate.name === value);
    if (!predicate) {
        throw new Error(`Unknown code fact predicate "${value}". Expected one of: ${CODE_FACT_PREDICATES.map(candidate => candidate.name).join(', ')}`);
    }
    return predicate.name;
}
function isRecord(value) {
    return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function stringArray(value, label) {
    if (!Array.isArray(value) || value.some(item => typeof item !== 'string')) {
        throw new Error(`${label} must be an array of strings.`);
    }
    return value.map(item => item.trim()).filter(Boolean);
}
export function parseLayerManifest(value) {
    if (!isRecord(value) || !Array.isArray(value.layers) || value.layers.length === 0) {
        throw new Error('Layer manifest must contain a non-empty "layers" array.');
    }
    const seen = new Set();
    const layers = value.layers.map((entry, index) => {
        if (!isRecord(entry))
            throw new Error(`layers[${index}] must be an object.`);
        if (typeof entry.name !== 'string' || entry.name.trim().length === 0) {
            throw new Error(`layers[${index}].name must be a non-empty string.`);
        }
        const name = entry.name.trim();
        if (seen.has(name))
            throw new Error(`Layer manifest contains duplicate layer "${name}".`);
        seen.add(name);
        const paths = stringArray(entry.paths, `layers[${index}].paths`);
        if (paths.length === 0)
            throw new Error(`layers[${index}].paths must not be empty.`);
        const rawAllow = entry.allow ?? entry.mayImport ?? [];
        return { name, paths, allow: stringArray(rawAllow, `layers[${index}].allow`) };
    });
    for (const layer of layers) {
        for (const allowed of layer.allow) {
            if (allowed !== '*' && !seen.has(allowed)) {
                throw new Error(`Layer "${layer.name}" allows unknown layer "${allowed}".`);
            }
        }
    }
    return { layers };
}
export async function readLayerManifest(filePath) {
    let parsed;
    try {
        parsed = JSON.parse(await readFile(filePath, 'utf8'));
    }
    catch (error) {
        const message = error instanceof Error ? error.message : String(error);
        throw new Error(`Could not read layer manifest "${filePath}": ${message}`);
    }
    return parseLayerManifest(parsed);
}
function fileRelativePath(index, absolutePath) {
    return normalizePath(path.relative(index.repoRoot, absolutePath));
}
function localImports(index, includedPaths) {
    const filePaths = new Set(index.files.map(file => file.path));
    return index.edges
        .filter(edge => edge.type === 'imports' &&
        edge.status === 'resolved' &&
        edge.importKind === 'local' &&
        edge.toPath !== undefined &&
        filePaths.has(edge.fromPath) &&
        filePaths.has(edge.toPath) &&
        (includedPaths === undefined || (includedPaths.has(edge.fromPath) && includedPaths.has(edge.toPath))))
        .map(edge => ({
        from: fileRelativePath(index, edge.fromPath),
        to: fileRelativePath(index, edge.toPath),
        line: edge.line,
        specifier: edge.specifier,
    }));
}
function exportedSymbols(index) {
    const rows = [];
    for (const file of index.files) {
        for (const symbol of file.symbols) {
            if (!symbol.exported || !symbol.nodeId || symbol.containerName !== undefined)
                continue;
            rows.push({ file, symbol, nodeId: symbol.nodeId });
        }
    }
    return rows.sort((left, right) => compareCodeUnits(left.file.relativePath, right.file.relativePath) ||
        left.symbol.line - right.symbol.line ||
        compareCodeUnits(left.symbol.name, right.symbol.name));
}
function referencedImportBindings(index) {
    const referenced = new Set();
    for (const importingFile of index.files) {
        for (const imported of importingFile.imports) {
            if (!imported.resolvedPath)
                continue;
            const relativePath = fileRelativePath(index, imported.resolvedPath);
            for (const binding of imported.bindings) {
                if (binding.isNamespace) {
                    referenced.add(`${relativePath}\u0000*`);
                }
                else if (binding.isDefault) {
                    referenced.add(`${relativePath}\u0000default`);
                }
                else {
                    referenced.add(`${relativePath}\u0000${binding.importedName ?? binding.localName}`);
                }
            }
        }
    }
    return referenced;
}
function exportedUnreferenced(index, excludedLanguages) {
    const importReferences = referencedImportBindings(index);
    const unresolvedCallsByName = new Map();
    for (const edge of index.edges) {
        if (edge.type !== 'calls' || edge.status !== 'unresolved' || !edge.targetName)
            continue;
        unresolvedCallsByName.set(edge.targetName, (unresolvedCallsByName.get(edge.targetName) ?? 0) + 1);
    }
    const rows = [];
    for (const candidate of exportedSymbols(index)) {
        if (excludedLanguages.has(candidate.file.language))
            continue;
        const incoming = [
            ...incomingEdges(index, 'calls', candidate.nodeId),
            ...incomingEdges(index, 'uses', candidate.nodeId).filter(edge => edge.from !== candidate.nodeId),
            ...incomingEdges(index, 'inherits', candidate.nodeId),
            ...incomingEdges(index, 'implements', candidate.nodeId),
        ].some(edge => edge.status === 'resolved');
        const prefix = `${candidate.file.relativePath}\u0000`;
        const imported = importReferences.has(`${prefix}${candidate.symbol.name}`) ||
            importReferences.has(`${prefix}*`) ||
            importReferences.has(`${prefix}default`);
        if (incoming || imported)
            continue;
        rows.push({
            kind: 'exported-unreferenced',
            nodeId: candidate.nodeId,
            name: candidate.symbol.name,
            symbolKind: candidate.symbol.type,
            relativePath: candidate.file.relativePath,
            line: candidate.symbol.line,
            unresolvedCallEdgesNamingSymbol: unresolvedCallsByName.get(candidate.symbol.name) ?? 0,
        });
    }
    return rows;
}
function reverseImportReachability(index) {
    const reverse = new Map();
    for (const edge of localImports(index)) {
        const importers = reverse.get(edge.to);
        if (importers)
            importers.add(edge.from);
        else
            reverse.set(edge.to, new Set([edge.from]));
    }
    return reverse;
}
function reachesTest(start, reverse) {
    const queue = [start];
    const seen = new Set(queue);
    for (let index = 0; index < queue.length; index++) {
        const current = queue[index];
        if (current !== start && isTestPath(current))
            return true;
        for (const importer of reverse.get(current) ?? []) {
            if (seen.has(importer))
                continue;
            seen.add(importer);
            queue.push(importer);
        }
    }
    return false;
}
function indexOnlyUntestedEntrypoints(index) {
    const reverse = reverseImportReachability(index);
    return exportedSymbols(index)
        .filter(candidate => !isTestPath(candidate.file.relativePath))
        .filter(candidate => !reachesTest(candidate.file.relativePath, reverse))
        .map(candidate => ({
        kind: 'untested-entrypoint',
        nodeId: candidate.nodeId,
        name: candidate.symbol.name,
        symbolKind: candidate.symbol.type,
        relativePath: candidate.file.relativePath,
        line: candidate.symbol.line,
        coverage: 'index-only',
    }));
}
async function entityBackedUntestedEntrypoints(index, session, diagnostics) {
    const candidates = exportedSymbols(index).filter(candidate => !isTestPath(candidate.file.relativePath));
    const rows = [];
    const bounded = candidates.slice(0, MAX_ENTITY_CANDIDATES);
    const overflow = candidates.slice(MAX_ENTITY_CANDIDATES);
    const reverse = reverseImportReachability(index);
    let partialCount = 0;
    let unresolvedCount = 0;
    if (bounded.length < candidates.length) {
        diagnostics.push(`Entity impact was bounded at ${MAX_ENTITY_CANDIDATES} candidates; ${overflow.length} remaining candidates used index-only reverse-import reachability.`);
    }
    for (const candidate of bounded) {
        const result = await session.impact({
            name: candidate.symbol.name,
            relativePath: candidate.file.relativePath,
            kind: toNormalizedSymbolKind(candidate.symbol.type, candidate.symbol.containerName),
        }, { scope: 'tests', depth: 0 });
        if (result.status !== 'resolved') {
            unresolvedCount += 1;
            if (!reachesTest(candidate.file.relativePath, reverse)) {
                rows.push({
                    kind: 'untested-entrypoint',
                    nodeId: candidate.nodeId,
                    name: candidate.symbol.name,
                    symbolKind: candidate.symbol.type,
                    relativePath: candidate.file.relativePath,
                    line: candidate.symbol.line,
                    coverage: 'index-only',
                });
            }
            continue;
        }
        if (result.coverage !== 'full' || result.truncated) {
            partialCount += 1;
            if (!reachesTest(candidate.file.relativePath, reverse)) {
                rows.push({
                    kind: 'untested-entrypoint',
                    nodeId: candidate.nodeId,
                    name: candidate.symbol.name,
                    symbolKind: candidate.symbol.type,
                    relativePath: candidate.file.relativePath,
                    line: candidate.symbol.line,
                    coverage: 'index-only',
                });
            }
            continue;
        }
        if (result.tests.length > 0)
            continue;
        rows.push({
            kind: 'untested-entrypoint',
            nodeId: candidate.nodeId,
            name: candidate.symbol.name,
            symbolKind: candidate.symbol.type,
            relativePath: candidate.file.relativePath,
            line: candidate.symbol.line,
            coverage: 'full',
        });
    }
    for (const candidate of overflow) {
        if (reachesTest(candidate.file.relativePath, reverse))
            continue;
        rows.push({
            kind: 'untested-entrypoint',
            nodeId: candidate.nodeId,
            name: candidate.symbol.name,
            symbolKind: candidate.symbol.type,
            relativePath: candidate.file.relativePath,
            line: candidate.symbol.line,
            coverage: 'index-only',
        });
    }
    if (partialCount > 0) {
        diagnostics.push(`${partialCount} candidate(s) had partial entity-impact coverage and used index-only reverse-import reachability.`);
    }
    if (unresolvedCount > 0) {
        diagnostics.push(`${unresolvedCount} candidate(s) could not be uniquely resolved by entity impact and used index-only reverse-import reachability.`);
    }
    return {
        rows,
        bestEffort: overflow.length > 0 || partialCount > 0 || unresolvedCount > 0,
    };
}
function stronglyConnectedModules(index, includedPaths) {
    const files = index.files
        .filter(file => includedPaths.has(file.path))
        .map(file => file.relativePath)
        .sort();
    const adjacency = new Map(files.map(file => [file, new Set()]));
    for (const edge of localImports(index, includedPaths))
        adjacency.get(edge.from)?.add(edge.to);
    let nextIndex = 0;
    const indices = new Map();
    const lowLinks = new Map();
    const stack = [];
    const onStack = new Set();
    const components = [];
    const visit = (node) => {
        indices.set(node, nextIndex);
        lowLinks.set(node, nextIndex);
        nextIndex += 1;
        stack.push(node);
        onStack.add(node);
        for (const target of [...(adjacency.get(node) ?? [])].sort()) {
            if (!indices.has(target)) {
                visit(target);
                lowLinks.set(node, Math.min(lowLinks.get(node) ?? 0, lowLinks.get(target) ?? 0));
            }
            else if (onStack.has(target)) {
                lowLinks.set(node, Math.min(lowLinks.get(node) ?? 0, indices.get(target) ?? 0));
            }
        }
        if (lowLinks.get(node) !== indices.get(node))
            return;
        const component = [];
        while (stack.length > 0) {
            const member = stack.pop();
            if (!member)
                break;
            onStack.delete(member);
            component.push(member);
            if (member === node)
                break;
        }
        const isCycle = component.length > 1 || (component.length === 1 && adjacency.get(component[0])?.has(component[0]) === true);
        if (isCycle)
            components.push(component.sort());
    };
    for (const file of files)
        if (!indices.has(file))
            visit(file);
    return components
        .sort((left, right) => compareCodeUnits(left[0] ?? '', right[0] ?? ''))
        .flatMap(cycle => cycle.map(relativePath => ({ kind: 'cycle-member', relativePath, cycle: [...cycle] })));
}
function orphanModules(index, includedPaths) {
    const imported = new Set(localImports(index, includedPaths).map(edge => edge.to));
    return index.files
        .filter(file => includedPaths.has(file.path))
        .filter(file => !imported.has(file.relativePath))
        .sort((left, right) => compareCodeUnits(left.relativePath, right.relativePath))
        .map(file => ({
        kind: 'orphan-module',
        relativePath: file.relativePath,
        exportedSymbols: file.symbols.filter(symbol => symbol.exported).length,
    }));
}
function globPattern(pattern) {
    const normalized = normalizePath(pattern).replace(/^\.\//, '');
    let source = '^';
    for (let index = 0; index < normalized.length; index++) {
        const character = normalized[index];
        if (character === '*') {
            if (normalized[index + 1] === '*') {
                source += '.*';
                index += 1;
            }
            else {
                source += '[^/]*';
            }
        }
        else if ('\\^$+?.()|{}[]'.includes(character)) {
            source += `\\${character}`;
        }
        else {
            source += character;
        }
    }
    return new RegExp(`${source}$`);
}
function classifyLayers(index, manifest, diagnostics, includedPaths) {
    const compiled = manifest.layers.map(layer => ({
        layer,
        patterns: layer.paths.map(globPattern),
    }));
    const assignments = new Map();
    let unmapped = 0;
    for (const file of index.files) {
        if (!includedPaths.has(file.path))
            continue;
        const matches = compiled.filter(candidate => candidate.patterns.some(pattern => pattern.test(file.relativePath)));
        if (matches.length > 1) {
            throw new Error(`Layer manifest maps "${file.relativePath}" to multiple layers: ${matches.map(match => match.layer.name).join(', ')}.`);
        }
        if (matches[0])
            assignments.set(file.relativePath, matches[0].layer);
        else
            unmapped += 1;
    }
    if (unmapped > 0)
        diagnostics.push(`${unmapped} indexed module(s) were outside the layer manifest.`);
    return assignments;
}
function layerViolations(index, manifest, diagnostics, includedPaths) {
    const assignments = classifyLayers(index, manifest, diagnostics, includedPaths);
    const rows = [];
    for (const edge of localImports(index, includedPaths)) {
        const fromLayer = assignments.get(edge.from);
        const toLayer = assignments.get(edge.to);
        if (!fromLayer || !toLayer || fromLayer.name === toLayer.name)
            continue;
        if (fromLayer.allow.includes('*') || fromLayer.allow.includes(toLayer.name))
            continue;
        rows.push({
            kind: 'layer-violation',
            from: edge.from,
            fromLayer: fromLayer.name,
            to: edge.to,
            toLayer: toLayer.name,
            line: edge.line,
            specifier: edge.specifier,
        });
    }
    return rows.sort((left, right) => compareCodeUnits(left.from, right.from) ||
        (left.line ?? 0) - (right.line ?? 0) ||
        compareCodeUnits(left.to, right.to));
}
function languageRelationshipStats(index) {
    const byLanguage = new Map();
    const languageByPath = new Map();
    for (const file of index.files) {
        languageByPath.set(file.path, file.language);
        const existing = byLanguage.get(file.language);
        if (existing) {
            existing.files.push(file);
            existing.importSpecifiers.push(...file.imports.map(imported => imported.specifier));
        }
        else {
            byLanguage.set(file.language, {
                language: file.language,
                files: [file],
                importSpecifiers: file.imports.map(imported => imported.specifier),
                resolvedLocalImports: 0,
                callEdges: 0,
                resolvedCallEdges: 0,
                templateUsePartialFiles: file.language === 'vue' && file.provenance?.capabilities.templateUses !== 'full' ? 1 : 0,
            });
            continue;
        }
        if (file.language === 'vue' && file.provenance?.capabilities.templateUses !== 'full') {
            existing.templateUsePartialFiles++;
        }
    }
    for (const edge of index.edges) {
        const language = languageByPath.get(edge.fromPath);
        if (!language)
            continue;
        const stats = byLanguage.get(language);
        if (!stats)
            continue;
        if (edge.type === 'imports' && edge.status === 'resolved' && edge.importKind === 'local') {
            stats.resolvedLocalImports += 1;
        }
        if (edge.type === 'calls') {
            stats.callEdges += 1;
            if (edge.status === 'resolved')
                stats.resolvedCallEdges += 1;
        }
    }
    return [...byLanguage.values()].sort((left, right) => compareCodeUnits(left.language, right.language));
}
function assessImportFactCapabilities(predicate, stats, diagnostics, capabilityGaps) {
    const includedPaths = new Set();
    let bestEffort = false;
    for (const language of stats) {
        const capability = staticImportResolutionCapability(language.language, language.importSpecifiers);
        if (capability === 'unsupported') {
            bestEffort = true;
            capabilityGaps.push({
                language: language.language,
                reason: 'import-resolution-unsupported',
                files: language.files.length,
            });
            diagnostics.push(`Refused ${predicate} rows for ${language.files.length} ${language.language} file(s): static local-import resolution is unsupported.`);
            continue;
        }
        for (const file of language.files)
            includedPaths.add(file.path);
        if (language.resolvedLocalImports === 0) {
            bestEffort = true;
            diagnostics.push(`No resolved local imports were observed for ${language.files.length} ${language.language} file(s); ${predicate} is best effort.`);
        }
    }
    return { includedPaths, bestEffort };
}
function assessCallFactCapabilities(stats, diagnostics, capabilityGaps) {
    const excludedLanguages = new Set();
    for (const language of stats) {
        if (language.templateUsePartialFiles > 0) {
            for (const candidate of stats)
                excludedLanguages.add(candidate.language);
            capabilityGaps.push({
                language: language.language,
                reason: 'template-use-resolution-unavailable',
                files: language.templateUsePartialFiles,
            });
            diagnostics.push(`Refused exported-unreferenced rows repository-wide: ${language.templateUsePartialFiles} ` +
                `${language.language} template file(s) withheld usage observations.`);
        }
        if (language.callEdges === 0 || language.resolvedCallEdges > 0)
            continue;
        excludedLanguages.add(language.language);
        capabilityGaps.push({
            language: language.language,
            reason: 'call-resolution-unavailable',
            files: language.files.length,
        });
        diagnostics.push(`Refused exported-unreferenced rows for ${language.files.length} ${language.language} file(s): ` +
            `${language.callEdges} call edge(s) were observed but none resolved.`);
    }
    return excludedLanguages;
}
export async function queryCodeFacts(index, predicate, options = {}) {
    const limit = options.limit ?? DEFAULT_FACT_LIMIT;
    if (!Number.isInteger(limit) || limit <= 0)
        throw new Error('Code facts limit must be a positive integer.');
    const diagnostics = [];
    const capabilityGaps = [];
    let cachedRelationshipStats;
    const relationshipStats = () => (cachedRelationshipStats ??= languageRelationshipStats(index));
    let bestEffort = index.truncated !== undefined;
    let rows;
    switch (predicate) {
        case 'exported-unreferenced': {
            const excludedLanguages = assessCallFactCapabilities(relationshipStats(), diagnostics, capabilityGaps);
            if (excludedLanguages.size > 0)
                bestEffort = true;
            rows = exportedUnreferenced(index, excludedLanguages);
            break;
        }
        case 'untested-entrypoints':
            if (options.entitySession) {
                const status = options.entitySession.status();
                if (status.truncated) {
                    rows = indexOnlyUntestedEntrypoints(index);
                    bestEffort = true;
                    diagnostics.push('Entity cache is truncated; used index-only reverse-import reachability for every candidate.');
                }
                else {
                    const entityBacked = await entityBackedUntestedEntrypoints(index, options.entitySession, diagnostics);
                    rows = entityBacked.rows;
                    bestEffort = entityBacked.bestEffort;
                    diagnostics.push(entityBacked.bestEffort
                        ? 'Entity impact was used where complete; remaining candidates used index-only reachability.'
                        : 'Test reachability was evaluated with entity impact.');
                }
            }
            else {
                rows = indexOnlyUntestedEntrypoints(index);
                bestEffort = true;
                diagnostics.push(options.entityUnavailableReason
                    ? `Entity impact was unavailable (${options.entityUnavailableReason}); used index-only reverse-import reachability.`
                    : 'Entity impact was not supplied; used index-only reverse-import reachability.');
            }
            break;
        case 'cycle-members': {
            const capability = assessImportFactCapabilities(predicate, relationshipStats(), diagnostics, capabilityGaps);
            if (capability.bestEffort)
                bestEffort = true;
            rows = stronglyConnectedModules(index, capability.includedPaths);
            break;
        }
        case 'orphan-modules': {
            const capability = assessImportFactCapabilities(predicate, relationshipStats(), diagnostics, capabilityGaps);
            if (capability.bestEffort)
                bestEffort = true;
            rows = orphanModules(index, capability.includedPaths);
            break;
        }
        case 'layer-violations': {
            if (!options.layers) {
                throw new Error('The layer-violations predicate requires an explicit --layers <file> manifest.');
            }
            const capability = assessImportFactCapabilities(predicate, relationshipStats(), diagnostics, capabilityGaps);
            if (capability.bestEffort)
                bestEffort = true;
            rows = layerViolations(index, options.layers, diagnostics, capability.includedPaths);
            break;
        }
    }
    if (index.truncated) {
        diagnostics.push('The code index was truncated; absence-based facts may be incomplete.');
    }
    const descriptor = CODE_FACT_PREDICATES.find(candidate => candidate.name === predicate);
    const total = rows.length;
    const bounded = rows.slice(0, limit);
    const omitted = Math.max(0, total - bounded.length);
    const vueAmbiguities = (index.parserRefusals?.vueTemplate?.ambiguousComponents ?? 0) +
        (index.parserRefusals?.vueTemplate?.ambiguousIdentifiers ?? 0);
    const ambiguityCount = index.edges.filter(edge => edge.status === 'ambiguous' || (edge.type === 'imports' && edge.status === 'unresolved'))
        .length + vueAmbiguities;
    const dynamicDispatchRefusals = index.parserRefusals?.dynamicDispatch ?? 0;
    const vueRefusals = index.parserRefusals?.vueTemplate;
    const unresolvedUsageEdges = vueRefusals ? Object.values(vueRefusals).reduce((total, count) => total + count, 0) : 0;
    const unresolvedCallEdges = index.edges.filter(edge => edge.type === 'calls' && edge.status === 'unresolved').length + dynamicDispatchRefusals;
    if (dynamicDispatchRefusals > 0) {
        diagnostics.push(`${dynamicDispatchRefusals} dynamic call site(s) were withheld because no trustworthy callee could be extracted.`);
    }
    const truncated = total > bounded.length || index.truncated !== undefined;
    const coverage = bestEffort
        ? 'best-effort'
        : index.truncated || ambiguityCount > 0 || unresolvedCallEdges > 0 || unresolvedUsageEdges > 0
            ? 'partial'
            : 'full';
    return {
        status: 'resolved',
        predicate,
        description: descriptor?.description ?? predicate,
        bestEffort,
        rows: bounded,
        total,
        omitted,
        truncated,
        diagnostics,
        capabilityGaps,
        honesty: {
            freshness: 'unverified',
            stalenessReason: 'CodebaseIndex does not carry a filesystem freshness marker.',
            coverage,
            bestEffort,
            truncated,
            omitted,
            ambiguityCount,
            unresolvedCallEdges,
            unresolvedUsageEdges,
        },
    };
}
//# sourceMappingURL=facts.js.map