import { accessSync, constants, readFileSync, statSync } from 'node:fs';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import { compareCodeUnits } from '../../utils.js';
import { incomingEdges, nodeById, outgoingEdges } from '../query.js';
import { DEFAULT_ENTITY_BOUNDS } from './types.js';
import { classifyEntityEdgeEndpoint } from './edge-accounting.js';
import { parserEvidenceDiagnostic } from './parser-honesty.js';
import { inlineSourceFromText, readInlineSource } from '../source-inline.js';
/** Edge kinds that carry impact. An import alone does not mean a change reaches you. */
const IMPACT_EDGES = ['calls', 'uses', 'inherits', 'implements'];
const DEFAULT_DEPTH = 2;
const DEFAULT_MAX_ENTITIES = 500;
async function inlineForEntity(ref, contextLines, context) {
    const heldFormat = context.formatFiles?.get(ref.relativePath);
    return heldFormat !== undefined
        ? inlineSourceFromText(ref.relativePath, heldFormat, ref.startLine, ref.endLine, contextLines)
        : readInlineSource(context.index, {
            relativePath: ref.relativePath,
            startLine: ref.startLine,
            endLine: ref.endLine,
            contextLines,
        });
}
/** Add verified source projections after the synchronous graph traversal. */
export async function addEntityImpactInlineContext(payload, contextLines, context) {
    if (contextLines === 0)
        return payload;
    const diagnostics = [...payload.diagnostics];
    const diagnosed = new Set();
    const project = async (ref) => {
        const result = await inlineForEntity(ref, contextLines, context);
        if (result.diagnostic) {
            const key = `${result.diagnostic.relativePath}\0${result.diagnostic.message}`;
            if (!diagnosed.has(key)) {
                diagnosed.add(key);
                diagnostics.push(result.diagnostic);
            }
        }
        return result.block;
    };
    const projectEntries = async (entries) => Promise.all(entries.map(async (entry) => {
        const inlineContext = await project(entry.entity);
        return inlineContext ? { ...entry, inlineContext } : entry;
    }));
    const [inlineContext, dependencies, dependents, transitive, tests] = await Promise.all([
        project(payload.entity),
        projectEntries(payload.dependencies),
        projectEntries(payload.dependents),
        projectEntries(payload.transitive),
        projectEntries(payload.tests),
    ]);
    return {
        ...payload,
        ...(inlineContext ? { inlineContext } : {}),
        dependencies,
        dependents,
        transitive,
        tests,
        diagnostics,
    };
}
/**
 * Breadth-first walk with a global visited set.
 *
 * The visited set is what makes a cyclic call graph terminate, and because BFS
 * reaches every node by a shortest path first, the distance recorded on first
 * visit is final — a later, longer path to the same node adds only its edge
 * kind, never a worse distance.
 */
function traverse(direction, startNodeId, context, maxDepth, maxEntities) {
    const nodes = nodeById(context.index);
    const seen = new Map();
    const visited = new Set([startNodeId]);
    let queue = [startNodeId];
    let distance = 0;
    let truncated = false;
    let unresolved = 0;
    let unreachableEdges = 0;
    while (queue.length > 0 && (maxDepth === 0 || distance < maxDepth)) {
        distance++;
        const next = [];
        for (const nodeId of queue) {
            for (const type of IMPACT_EDGES) {
                const edges = direction === 'out' ? outgoingEdges(context.index, type, nodeId) : incomingEdges(context.index, type, nodeId);
                for (const edge of edges) {
                    const endpoint = classifyEntityEdgeEndpoint(edge, direction, nodes, context.live, startNodeId);
                    if (endpoint.status === 'unresolved') {
                        unresolved++;
                        continue;
                    }
                    if (endpoint.status === 'ignored')
                        continue;
                    if (endpoint.status === 'unreachable') {
                        unreachableEdges++;
                        continue;
                    }
                    const otherId = endpoint.nodeId;
                    const existing = seen.get(otherId);
                    if (existing) {
                        existing.via.add(type);
                        continue;
                    }
                    if (seen.size >= maxEntities) {
                        truncated = true;
                        continue;
                    }
                    seen.set(otherId, { entity: endpoint.entity, distance, via: new Set([type]) });
                    if (!visited.has(otherId)) {
                        visited.add(otherId);
                        next.push(otherId);
                    }
                }
            }
        }
        queue = next;
    }
    return { reached: [...seen.values()], truncated, unresolved, unreachableEdges };
}
/**
 * Deterministic ordering.
 *
 * Nearest first, then by location — never by traversal order, which depends on
 * edge insertion and would make the same query answer differently between two
 * builds of the same index.
 */
function order(entries) {
    return entries.sort((a, b) => a.distance - b.distance ||
        compareCodeUnits(a.entity.relativePath, b.entity.relativePath) ||
        a.entity.startLine - b.entity.startLine ||
        compareCodeUnits(a.entity.name, b.entity.name));
}
function toEntry(reached) {
    return { entity: reached.entity.ref, distance: reached.distance, via: [...reached.via].sort() };
}
function importRoot(specifier) {
    return specifier.startsWith('@')
        ? specifier.split('/').slice(0, 2).join('/')
        : (specifier.split('/')[0] ?? specifier);
}
function nearestPackageManifest(relativePath, formatFiles) {
    return formatFiles
        ? [...formatFiles.entries()]
            .filter(([file]) => file === 'package.json' || file.endsWith('/package.json'))
            .filter(([file]) => relativePath.startsWith(file.slice(0, -'package.json'.length)))
            .sort((a, b) => b[0].length - a[0].length)[0]?.[1]
        : undefined;
}
function frameworkFromManifest(relativePath, formatFiles) {
    if (!formatFiles)
        return undefined;
    const nearest = nearestPackageManifest(relativePath, formatFiles);
    if (nearest) {
        try {
            const parsed = JSON.parse(nearest);
            const material = [
                ...Object.keys(parsed.dependencies ?? {}),
                ...Object.keys(parsed.devDependencies ?? {}),
                ...Object.values(parsed.scripts ?? {}),
            ].join('\n');
            const found = new Set();
            if (/(^|[^\w])vitest([^\w]|$)/m.test(material))
                found.add('vitest');
            if (/(^|[^\w])jest([^\w]|$)/m.test(material))
                found.add('jest');
            if (/(^|[^\w])mocha([^\w]|$)/m.test(material))
                found.add('mocha');
            if (/node\b[^\n]*\s--test\b/.test(material))
                found.add('node:test');
            if (found.size === 1)
                return [...found][0];
        }
        catch {
            // A malformed manifest is not evidence for a runner.
        }
    }
    const pyproject = [...formatFiles.entries()]
        .filter(([file]) => file === 'pyproject.toml' || file.endsWith('/pyproject.toml'))
        .filter(([file]) => relativePath.startsWith(file.slice(0, -'pyproject.toml'.length)))
        .sort((a, b) => b[0].length - a[0].length)[0];
    return pyproject && /(^|[^\w])pytest([^\w]|$)/m.test(pyproject[1]) ? 'pytest' : undefined;
}
function nodeTestPrelude(relativePath, repoRoot, formatFiles) {
    if (!/\.[cm]?tsx?$/.test(relativePath))
        return [];
    const manifest = nearestPackageManifest(relativePath, formatFiles);
    if (!manifest)
        return undefined;
    try {
        const parsed = JSON.parse(manifest);
        const dependencies = { ...parsed.dependencies, ...parsed.devDependencies };
        const scripts = Object.values(parsed.scripts ?? {}).join('\n');
        if (dependencies.tsx || /node\s+--import\s+tsx\s+--test\b/.test(scripts)) {
            const loader = resolvePackageModuleEntry('tsx', '.', relativePath, repoRoot);
            return loader ? ['--import', pathToFileURL(loader).href] : undefined;
        }
        if (dependencies['ts-node'] || /node\s+--loader\s+ts-node\/esm\s+--test\b/.test(scripts)) {
            const loader = resolvePackageModuleEntry('ts-node', './esm', relativePath, repoRoot);
            return loader ? ['--loader', pathToFileURL(loader).href] : undefined;
        }
    }
    catch {
        return undefined;
    }
    return undefined;
}
function detectFramework(index, relativePath, formatFiles) {
    const file = index.files.find(item => item.relativePath === relativePath);
    if (!file)
        return undefined;
    const frameworks = new Set();
    for (const item of file.imports) {
        const root = importRoot(item.specifier);
        if (root === 'vitest')
            frameworks.add('vitest');
        else if (root === '@jest/globals' || root === 'jest')
            frameworks.add('jest');
        else if (root === 'mocha')
            frameworks.add('mocha');
        else if (item.specifier === 'node:test')
            frameworks.add('node:test');
        else if (root === 'pytest')
            frameworks.add('pytest');
    }
    if (frameworks.size === 1)
        return [...frameworks][0];
    if (frameworks.size > 1)
        return undefined;
    return frameworkFromManifest(relativePath, formatFiles);
}
function escapeRegex(value) {
    return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function existingDirectory(value) {
    if (!value)
        return undefined;
    const absolute = path.resolve(value);
    try {
        return statSync(absolute).isDirectory() ? absolute : undefined;
    }
    catch {
        return undefined;
    }
}
function canExecute(filePath) {
    try {
        if (!statSync(filePath).isFile())
            return false;
        accessSync(filePath, process.platform === 'win32' ? constants.F_OK : constants.X_OK);
        return true;
    }
    catch {
        return false;
    }
}
function searchDirectories(repoRoot, relativePath) {
    const absoluteFile = path.resolve(repoRoot, relativePath);
    const relativeFile = path.relative(repoRoot, absoluteFile);
    let current = relativeFile === '..' || relativeFile.startsWith(`..${path.sep}`) || path.isAbsolute(relativeFile)
        ? repoRoot
        : path.dirname(absoluteFile);
    const directories = [];
    for (;;) {
        directories.push(current);
        if (current === repoRoot)
            break;
        const parent = path.dirname(current);
        if (parent === current)
            break;
        current = parent;
    }
    return directories;
}
function packageExportTarget(value) {
    if (typeof value === 'string')
        return value;
    if (Array.isArray(value)) {
        for (const candidate of value) {
            const resolved = packageExportTarget(candidate);
            if (resolved)
                return resolved;
        }
        return undefined;
    }
    if (!value || typeof value !== 'object')
        return undefined;
    const conditions = value;
    for (const condition of ['node', 'import', 'default']) {
        if (!(condition in conditions))
            continue;
        const resolved = packageExportTarget(conditions[condition]);
        if (resolved)
            return resolved;
    }
    return undefined;
}
function packageExportEntry(exportsValue, exportKey) {
    if (!exportsValue || typeof exportsValue !== 'object' || Array.isArray(exportsValue)) {
        return exportKey === '.' ? packageExportTarget(exportsValue) : undefined;
    }
    const exportsMap = exportsValue;
    const subpathKeys = Object.keys(exportsMap).some(key => key.startsWith('.'));
    return subpathKeys
        ? packageExportTarget(exportsMap[exportKey])
        : exportKey === '.'
            ? packageExportTarget(exportsValue)
            : undefined;
}
function packageModuleEntry(packageRoot, exportKey) {
    try {
        const manifest = JSON.parse(readFileSync(path.join(packageRoot, 'package.json'), 'utf8'));
        const relativeEntry = packageExportEntry(manifest.exports, exportKey) ??
            (manifest.exports === undefined && exportKey === '.' ? (manifest.module ?? manifest.main) : undefined);
        if (!relativeEntry)
            return undefined;
        const absoluteEntry = path.resolve(packageRoot, relativeEntry);
        const withinPackage = path.relative(packageRoot, absoluteEntry);
        if (withinPackage === '..' || withinPackage.startsWith(`..${path.sep}`) || path.isAbsolute(withinPackage)) {
            return undefined;
        }
        return statSync(absoluteEntry).isFile() ? absoluteEntry : undefined;
    }
    catch {
        return undefined;
    }
}
function resolvePackageModuleEntry(packageName, exportKey, relativePath, repoRoot) {
    for (const directory of searchDirectories(repoRoot, relativePath)) {
        const resolved = packageModuleEntry(path.join(directory, 'node_modules', packageName), exportKey);
        if (resolved)
            return resolved;
    }
    return undefined;
}
function packageBin(packageRoot, framework) {
    try {
        const manifest = JSON.parse(readFileSync(path.join(packageRoot, 'package.json'), 'utf8'));
        const relativeBin = typeof manifest.bin === 'string'
            ? manifest.bin
            : (manifest.bin?.[framework] ??
                (manifest.bin && Object.keys(manifest.bin).length === 1 ? Object.values(manifest.bin)[0] : undefined));
        if (!relativeBin)
            return undefined;
        const absoluteBin = path.resolve(packageRoot, relativeBin);
        const withinPackage = path.relative(packageRoot, absoluteBin);
        if (withinPackage === '..' || withinPackage.startsWith(`..${path.sep}`) || path.isAbsolute(withinPackage)) {
            return undefined;
        }
        return statSync(absoluteBin).isFile() ? absoluteBin : undefined;
    }
    catch {
        return undefined;
    }
}
function resolveJavaScriptRunner(framework, relativePath, repoRoot) {
    for (const directory of searchDirectories(repoRoot, relativePath)) {
        const resolved = packageBin(path.join(directory, 'node_modules', framework), framework);
        if (resolved)
            return resolved;
    }
    return undefined;
}
function pathExecutableNames(name) {
    if (process.platform !== 'win32')
        return [name];
    const extensions = (process.env.PATHEXT ?? '.EXE;.COM')
        .split(';')
        .filter(Boolean)
        .map(extension => extension.toLowerCase())
        .filter(extension => extension === '.exe' || extension === '.com');
    return extensions.map(extension => `${name}${extension}`);
}
function resolvePathExecutable(name) {
    for (const directory of (process.env.PATH ?? '').split(path.delimiter)) {
        if (!directory)
            continue;
        for (const executable of pathExecutableNames(name)) {
            const candidate = path.resolve(directory, executable);
            if (canExecute(candidate))
                return candidate;
        }
    }
    return undefined;
}
function resolvePytest(relativePath, repoRoot) {
    const virtualEnvironmentBins = process.platform === 'win32'
        ? [
            ['.venv', 'Scripts', 'pytest.exe'],
            ['venv', 'Scripts', 'pytest.exe'],
        ]
        : [
            ['.venv', 'bin', 'pytest'],
            ['venv', 'bin', 'pytest'],
        ];
    for (const directory of searchDirectories(repoRoot, relativePath)) {
        for (const parts of virtualEnvironmentBins) {
            const candidate = path.join(directory, ...parts);
            if (canExecute(candidate))
                return candidate;
        }
    }
    return resolvePathExecutable('pytest');
}
export function buildTestSelector(ref, index, formatFiles, repoRoot) {
    const file = index.files.find(item => item.relativePath === ref.relativePath);
    const enclosingSuites = file?.symbols
        .filter(symbol => symbol.testRole === 'test_suite' &&
        symbol.line <= ref.startLine &&
        (symbol.endLine ?? symbol.line) >= ref.endLine &&
        !(symbol.line === ref.startLine && symbol.name === ref.name))
        .sort((a, b) => a.line - b.line || (b.endLine ?? b.line) - (a.endLine ?? a.line))
        .map(symbol => symbol.name) ?? [];
    const suites = enclosingSuites.length > 0 ? [...new Set(enclosingSuites)] : ref.containerName ? [ref.containerName] : [];
    const fullTitle = [...suites, ref.name].join(' ');
    const selector = {
        file: ref.relativePath,
        title: ref.name,
        suites,
        fullTitle,
    };
    const framework = detectFramework(index, ref.relativePath, formatFiles);
    if (!framework)
        return selector;
    const cwd = existingDirectory(repoRoot ?? index.repoRoot);
    if (framework === 'pytest') {
        const runnable = ref.name.startsWith('test_') || Boolean(ref.containerName?.startsWith('Test'));
        if (!runnable)
            return selector;
        selector.framework = framework;
        if (!cwd)
            return selector;
        const executable = resolvePytest(ref.relativePath, cwd);
        if (!executable)
            return selector;
        const nodeId = [ref.relativePath, ...suites, ref.name].join('::');
        selector.command = { executable, args: [nodeId], cwd };
        return selector;
    }
    if (ref.kind !== 'test' && ref.kind !== 'test_suite')
        return selector;
    const exact = ref.kind === 'test_suite' ? `^${escapeRegex(fullTitle)}(?: |$)` : `^${escapeRegex(fullTitle)}$`;
    selector.framework = framework;
    if (!cwd)
        return selector;
    if (framework === 'node:test') {
        const prelude = nodeTestPrelude(ref.relativePath, cwd, formatFiles);
        if (!prelude)
            return selector;
        selector.command = {
            executable: path.resolve(process.execPath),
            args: [...prelude, '--test', '--test-name-pattern', exact, ref.relativePath],
            cwd,
        };
        return selector;
    }
    const runner = resolveJavaScriptRunner(framework, ref.relativePath, cwd);
    if (!runner)
        return selector;
    const executable = path.resolve(process.execPath);
    if (framework === 'vitest') {
        selector.command = { executable, args: [runner, 'run', ref.relativePath, '-t', exact], cwd };
    }
    else if (framework === 'jest') {
        selector.command = {
            executable,
            args: [runner, '--runTestsByPath', ref.relativePath, '-t', exact],
            cwd,
        };
    }
    else if (framework === 'mocha') {
        selector.command = { executable, args: [runner, ref.relativePath, '--grep', exact], cwd };
    }
    return selector;
}
/**
 * What a change to this entity reaches, and what reaches it.
 *
 * Affected tests come from reverse traversal of the call graph, never from
 * filename similarity: `parseHeader` and `parseHeader.test.ts` looking alike is
 * a naming convention, not evidence that one exercises the other.
 */
export function entityImpact(entity, options, context) {
    const bounds = context.bounds ?? DEFAULT_ENTITY_BOUNDS;
    const scope = options.scope ?? 'all';
    const depth = options.depth ?? DEFAULT_DEPTH;
    const maxEntities = options.maxEntities ?? Math.min(DEFAULT_MAX_ENTITIES, bounds.maxLiveEntities);
    const diagnostics = [];
    const nodeId = entity.ref.nodeId;
    if (!nodeId) {
        // Data-format entities and anything the graph never indexed have no edges
        // at all. Reporting an empty impact as though it were a complete answer
        // would read as "nothing depends on this", which is a different claim.
        diagnostics.push({
            code: 'evidence-unavailable',
            message: `"${entity.record.qualifiedName}" has no node in the code graph, so impact cannot be traversed.`,
            relativePath: entity.record.relativePath,
        });
        return {
            entity: entity.ref,
            scope,
            dependencies: [],
            dependents: [],
            transitive: [],
            tests: [],
            truncated: false,
            omitted: 0,
            coverage: 'partial',
            unreachableEdges: 0,
            diagnostics,
        };
    }
    const wantsDeps = scope === 'deps' || scope === 'all';
    const wantsDependents = scope === 'dependents' || scope === 'tests' || scope === 'all';
    const out = wantsDeps
        ? traverse('out', nodeId, context, depth, maxEntities)
        : { reached: [], truncated: false, unresolved: 0, unreachableEdges: 0 };
    const incoming = wantsDependents
        ? traverse('in', nodeId, context, depth, maxEntities)
        : { reached: [], truncated: false, unresolved: 0, unreachableEdges: 0 };
    const dependents = order(incoming.reached.filter(item => item.distance === 1).map(toEntry));
    const transitive = order(incoming.reached.filter(item => item.distance > 1).map(toEntry));
    const tests = order(incoming.reached
        .filter(item => item.entity.ref.isTest)
        .map(item => ({
        ...toEntry(item),
        selector: buildTestSelector(item.entity.ref, context.index, context.formatFiles),
    })));
    const dependencies = order(out.reached.map(toEntry));
    const traversedPaths = [
        entity.record.relativePath,
        ...out.reached.map(item => item.entity.record.relativePath),
        ...incoming.reached.map(item => item.entity.record.relativePath),
    ];
    const parserDiagnostic = parserEvidenceDiagnostic(context.index, traversedPaths, 'impact');
    if (parserDiagnostic)
        diagnostics.push(parserDiagnostic);
    const unresolved = out.unresolved + incoming.unresolved;
    if (unresolved > 0) {
        diagnostics.push({
            code: 'evidence-unavailable',
            message: `${unresolved} graph edges were unresolved or ambiguous and were not followed.`,
            total: unresolved,
        });
    }
    const unreachableEdges = out.unreachableEdges + incoming.unreachableEdges;
    if (unreachableEdges > 0) {
        diagnostics.push({
            code: 'evidence-unavailable',
            message: `${unreachableEdges} resolved graph edges pointed to nodes or live entities absent from the indexed graph and were not followed.`,
            total: unreachableEdges,
        });
    }
    if (context.indexTruncated) {
        diagnostics.push({
            code: 'entity-budget-exhausted',
            message: 'The lineage index behind this session hit a bound, so entities it never reached cannot appear ' +
                'in this traversal. Read session.status() for the reasons.',
        });
    }
    const truncated = out.truncated || incoming.truncated;
    if (truncated) {
        diagnostics.push({
            code: 'entity-budget-exhausted',
            message: `Impact traversal stopped at ${maxEntities} entities; the result is partial.`,
            limit: maxEntities,
        });
    }
    const language = nodeById(context.index).get(nodeId)?.language;
    const callsSupported = language ? (context.index.capabilities[language]?.calls ?? false) : false;
    if (!callsSupported) {
        diagnostics.push({
            code: 'evidence-unavailable',
            message: `OCC does not extract call edges for ${language ?? 'this language'}; impact is limited to what other signals reach.`,
            relativePath: entity.record.relativePath,
        });
    }
    return {
        entity: entity.ref,
        scope,
        dependencies: scope === 'tests' ? [] : dependencies,
        dependents: scope === 'deps' || scope === 'tests' ? [] : dependents,
        transitive: scope === 'deps' || scope === 'tests' ? [] : transitive,
        tests: scope === 'deps' ? [] : tests,
        truncated,
        omitted: truncated ? Math.max(0, out.reached.length + incoming.reached.length - maxEntities) : 0,
        unreachableEdges,
        // `full` is a claim that nothing was left out. An unfollowable edge, a
        // budget stop, a language with no call extraction, or an index that was
        // itself starved of bounds each break that claim, so any of them makes the
        // answer partial.
        coverage: callsSupported &&
            unresolved === 0 &&
            unreachableEdges === 0 &&
            !truncated &&
            !context.indexTruncated &&
            parserDiagnostic === undefined
            ? 'full'
            : 'partial',
        diagnostics,
    };
}
//# sourceMappingURL=impact.js.map