import { realpath, stat } from 'node:fs/promises';
import path from 'node:path';
import { DEFAULT_EXCLUDE_DIRS, discoverCodeFiles, wouldDiscoverPath } from './discover.js';
import { getLanguageSpec, normalizePath, stripImportSuffix } from './languages.js';
import { resolveImportPath } from './parsers.js';
import { abortIfNeeded, createIgnoreMatcher, mapWithConcurrency } from '../utils.js';
function buildDiff(added, modified, deleted) {
    return {
        added,
        modified,
        deleted,
        hasChanges: added.length > 0 || modified.length > 0 || deleted.length > 0,
    };
}
/**
 * Diff the manifest against a full discovery pass: files on disk that are
 * missing from the manifest are added, size/mtime mismatches are modified,
 * and manifest entries absent from discovery are deleted.
 */
export async function computeManifestDiff(manifest, repoRoot, discoverOptions = {}, signal) {
    const resolvedRoot = path.resolve(repoRoot);
    abortIfNeeded(signal);
    const discovered = await discoverCodeFiles(resolvedRoot, { ...discoverOptions, signal });
    const statted = await mapWithConcurrency(discovered, 32, async (filePath) => {
        try {
            const fileStat = await stat(filePath);
            if (!fileStat.isFile())
                return null;
            return {
                path: filePath,
                relativePath: normalizePath(path.relative(resolvedRoot, filePath)),
                size: fileStat.size,
                mtimeMs: fileStat.mtimeMs,
            };
        }
        catch {
            // Disappeared between discovery and stat — treated as absent.
            return null;
        }
    }, signal);
    const manifestByRel = new Map();
    for (const entry of manifest)
        manifestByRel.set(entry.relativePath, entry);
    const added = [];
    const modified = [];
    const seen = new Set();
    for (const current of statted) {
        if (!current)
            continue;
        seen.add(current.relativePath);
        const previous = manifestByRel.get(current.relativePath);
        if (!previous) {
            added.push(current);
        }
        else if (previous.size !== current.size || previous.mtimeMs !== current.mtimeMs) {
            modified.push(current);
        }
    }
    const deleted = [];
    for (const entry of manifest) {
        if (!seen.has(entry.relativePath)) {
            deleted.push({ path: path.resolve(resolvedRoot, entry.relativePath), relativePath: entry.relativePath });
        }
    }
    return buildDiff(added, modified, deleted);
}
/**
 * Watcher fast path: classify an explicit changed-file list without a
 * discovery pass. Entries outside the repo, without a language spec,
 * rejected by the same ignore matcher discovery uses, or that discovery
 * would never yield (dot-segments, excluded directories, symlinks — see
 * wouldDiscoverPath) are dropped — required so the result is equivalent to
 * what a full rebuild would have seen. On case-insensitive filesystems a
 * stat can succeed for a stale casing after a case-only rename; such entries
 * are redirected to the on-disk casing (stale casing deleted if indexed,
 * real casing classified normally).
 */
export async function classifyChangedFiles(changedFiles, manifest, repoRoot, discoverOptions = {}, signal) {
    const resolvedRoot = path.resolve(repoRoot);
    abortIfNeeded(signal);
    const excludeDir = discoverOptions.excludeDir ?? [...DEFAULT_EXCLUDE_DIRS];
    const ignoreMatcher = await createIgnoreMatcher(resolvedRoot, {
        excludeDir,
        noGitignore: discoverOptions.noGitignore,
        ignorePatterns: discoverOptions.ignorePatterns,
        overlayIgnorePatterns: discoverOptions.overlayIgnorePatterns,
    });
    const manifestByRel = new Map();
    for (const entry of manifest)
        manifestByRel.set(entry.relativePath, entry);
    const added = [];
    const modified = [];
    const deleted = [];
    const seen = new Set();
    let realRoot;
    // Case-rename redirects append the on-disk casing for another pass.
    const queue = [...changedFiles];
    for (let cursor = 0; cursor < queue.length; cursor++) {
        abortIfNeeded(signal);
        const changed = queue[cursor];
        const absolutePath = path.isAbsolute(changed) ? path.resolve(changed) : path.resolve(resolvedRoot, changed);
        const relativePath = normalizePath(path.relative(resolvedRoot, absolutePath));
        if (relativePath.startsWith('..'))
            continue;
        if (!getLanguageSpec(absolutePath))
            continue;
        if (ignoreMatcher.ignores(relativePath))
            continue;
        if (seen.has(relativePath))
            continue;
        seen.add(relativePath);
        if (!(await wouldDiscoverPath(resolvedRoot, absolutePath, excludeDir)))
            continue;
        try {
            const fileStat = await stat(absolutePath);
            if (!fileStat.isFile())
                continue;
            // Case-insensitive filesystems (macOS APFS default) stat a stale
            // casing successfully after a case-only rename; indexing it would
            // duplicate the file. Compare against the on-disk casing (realpath
            // is symlink-safe here — wouldDiscoverPath already rejected symlinks)
            // and redirect to the real path.
            realRoot ??= await realpath(resolvedRoot);
            const realRelativePath = normalizePath(path.relative(realRoot, await realpath(absolutePath)));
            if (realRelativePath !== relativePath) {
                if (manifestByRel.has(relativePath))
                    deleted.push({ path: absolutePath, relativePath });
                queue.push(path.resolve(resolvedRoot, realRelativePath));
                continue;
            }
            const entry = {
                path: absolutePath,
                relativePath,
                size: fileStat.size,
                mtimeMs: fileStat.mtimeMs,
            };
            if (manifestByRel.has(relativePath))
                modified.push(entry);
            else
                added.push(entry);
        }
        catch {
            // Absent on disk: a deletion if indexed before, otherwise nothing to do.
            if (manifestByRel.has(relativePath))
                deleted.push({ path: absolutePath, relativePath });
        }
    }
    return buildDiff(added, modified, deleted);
}
/**
 * Candidate stems for an import specifier: the last path segment, the same
 * segment without its extension, and the last dotted segment (Python-style
 * `pkg.mod` specifiers). Used only as a conservative prefilter — a match
 * triggers re-resolution, never a direct edit of the cached parse.
 */
function specifierStems(specifier) {
    const stripped = stripImportSuffix(specifier);
    const lastSegment = stripped.split('/').pop() ?? stripped;
    const stems = new Set([lastSegment]);
    const withoutExtension = lastSegment.replace(/\.[^.]+$/, '');
    if (withoutExtension)
        stems.add(withoutExtension);
    const lastDotted = stripped.split('.').filter(Boolean).pop();
    if (lastDotted)
        stems.add(lastDotted);
    return [...stems];
}
/**
 * `ParsedFile.imports[].resolvedPath` is parse-time state: adding or deleting
 * files can change how an *unchanged* importer resolves. Returns the absolute
 * paths of cached files whose imports would resolve differently today, so the
 * caller can re-parse them (imports are never patched in place).
 *
 * Only imports that could plausibly be affected are re-checked: those whose
 * specifier stem matches an added file's basename or parent directory (covers
 * both `./x` → `x.ts` and `./x` → `x/index.ts`, plus extension-priority
 * shadowing of an existing resolution), bare-directory specifiers (`.`/`..`)
 * when an `index.*`/`__init__.py` was added, still-unresolved non-relative
 * specifiers when anything was added (tsconfig `paths` exact aliases like
 * `@api` never stem-match the mapped file's basename), and those whose
 * resolved target was deleted. A flagged import is re-resolved via the same
 * resolveImportPath the parsers use; any difference from the cached
 * annotation flags the file.
 */
export function findResolutionImpactedFiles(cachedFiles, diff, repoRoot) {
    if (diff.added.length === 0 && diff.deleted.length === 0)
        return [];
    const addedStems = new Set();
    let addedIndexLike = false;
    for (const added of diff.added) {
        const baseName = path.basename(added.path, path.extname(added.path));
        addedStems.add(baseName);
        addedStems.add(path.basename(path.dirname(added.path)));
        if (baseName === 'index' || baseName === '__init__')
            addedIndexLike = true;
    }
    const deletedSet = new Set(diff.deleted.map(entry => entry.path));
    const hasAdded = diff.added.length > 0;
    const impacted = new Set();
    for (const file of cachedFiles) {
        if (deletedSet.has(file.path))
            continue; // Deleted files are removed, never re-parsed.
        const language = getLanguageSpec(file.path)?.name ?? file.language;
        for (const fileImport of file.imports) {
            const stems = specifierStems(fileImport.specifier);
            const stemMatch = stems.some(stem => addedStems.has(stem));
            // `.`/`./`/`..`-style specifiers carry no usable stem; a new index.*
            // (or __init__.py) anywhere can change how they resolve.
            const directoryMatch = addedIndexLike && stems.every(stem => stem === '.' || stem === '..' || stem === '');
            // Exact tsconfig `paths` aliases resolve to files whose basenames never
            // intersect the alias stem, so every still-unresolved non-relative
            // specifier is re-checked whenever files appear. Cheap: tsconfig
            // parsing is memoized per repoRoot and misses stay undefined.
            const unresolvedBare = hasAdded && fileImport.resolvedPath == null && !fileImport.specifier.startsWith('.');
            const targetDeleted = fileImport.resolvedPath != null && deletedSet.has(fileImport.resolvedPath);
            if (!stemMatch && !directoryMatch && !unresolvedBare && !targetDeleted)
                continue;
            const outcome = resolveImportPath(language, { repoRoot }, file.path, fileImport.specifier);
            if (outcome !== fileImport.resolvedPath) {
                impacted.add(file.path);
                break;
            }
        }
    }
    return [...impacted].sort();
}
/**
 * Merge cached parse results with fresh ones: drop `removals` (deleted files
 * plus everything re-parsed), insert the fresh entries, and sort both outputs
 * by absolute path with the default string comparator — reproducing the
 * `discovered.sort()` order a full build would produce, which is what makes
 * incremental output deep-equal a fresh full build.
 */
export function spliceIndexInputs(cachedFiles, cachedSkipped, removals, fresh) {
    const parsedByPath = new Map();
    for (const file of cachedFiles)
        parsedByPath.set(file.path, file);
    const skippedByPath = new Map();
    for (const skipped of cachedSkipped)
        skippedByPath.set(skipped.path, skipped);
    for (const removal of removals) {
        parsedByPath.delete(removal);
        skippedByPath.delete(removal);
    }
    for (const file of fresh.parsedFiles) {
        parsedByPath.set(file.path, file);
        skippedByPath.delete(file.path);
    }
    for (const skipped of fresh.skippedFiles) {
        skippedByPath.set(skipped.path, skipped);
        parsedByPath.delete(skipped.path);
    }
    const byPath = (left, right) => left.path < right.path ? -1 : left.path > right.path ? 1 : 0;
    return {
        parsedFiles: [...parsedByPath.values()].sort(byPath),
        skippedFiles: [...skippedByPath.values()].sort(byPath),
    };
}
//# sourceMappingURL=incremental.js.map