// Scope walker (Epic #189, Issue #194). Given a KnowledgeSourceScope and a WorkspaceFs port,
// yields each in-scope file as a `DiscoveredFile`. Boundary guarantees, in order of check:
//
//   1. The scope's rootPath is rejected when it fails the contract validator's safe-path
//      gate (NUL, `..`, root markers, tilde, Windows drive prefix).
//   2. Every yielded file's `relativePath` joined to the scope root resolves via
//      `WorkspaceFs.realPath` to a path STILL UNDER the scope root. A symlink whose
//      realpath escapes the root is dropped and reported via `walkSource`'s second yield
//      channel (an InvalidEntry record) rather than thrown — that lets the caller log a
//      `PATH_ESCAPE` diagnostic against the file instead of aborting the whole walk.
//   3. Include/exclude globs are applied on the workspace-relative POSIX path; exclude
//      wins on overlap.
//   4. AbortSignal is checked at every directory boundary.
//
// Returns an async iterable of `WalkYield` values. The walker is otherwise PURE — no
// clock reads, no randomness — and the WorkspaceFs port is the only IO surface.
import { isSafeScopePath } from "@oscharko-dev/keiko-contracts";
import { compileGlobList, matchesAny } from "./glob.js";
import { DEFAULT_DISCOVERY_OPTIONS, } from "./types.js";
// On Windows, WorkspaceFs.realPath() may return backslash-separated paths
// (e.g. C:\Users\workspace\file). Normalise both sides to forward slashes so
// containment checks and relative-path derivation work cross-platform.
function normaliseSep(p) {
    return p.replace(/\\/g, "/");
}
function toPosixRelative(absoluteRoot, absolutePath) {
    const normRoot = normaliseSep(absoluteRoot);
    const normPath = normaliseSep(absolutePath);
    if (normPath === normRoot) {
        return "";
    }
    const prefix = normRoot.endsWith("/") ? normRoot : `${normRoot}/`;
    if (normPath.startsWith(prefix)) {
        return normPath.slice(prefix.length);
    }
    return normPath;
}
function isContained(absoluteRoot, absolutePath) {
    const normRoot = normaliseSep(absoluteRoot);
    const normPath = normaliseSep(absolutePath);
    if (normPath === normRoot) {
        return true;
    }
    const prefix = normRoot.endsWith("/") ? normRoot : `${normRoot}/`;
    return normPath.startsWith(prefix);
}
function joinAbs(root, name) {
    if (root.endsWith("/")) {
        return `${root}${name}`;
    }
    return `${root}/${name}`;
}
function deriveScopeBounds(scope) {
    if (scope.kind === "folder") {
        if (!isSafeScopePath(scope.rootPath)) {
            return { code: "INVALID_SCOPE", message: "scope.rootPath failed the safe-path gate" };
        }
        return {
            rootPath: scope.rootPath,
            recursive: scope.recursive,
            includeGlobs: compileGlobList(scope.includeGlobs),
            excludeGlobs: compileGlobList(scope.excludeGlobs),
        };
    }
    if (scope.kind === "repository") {
        if (!isSafeScopePath(scope.repositoryRoot)) {
            return {
                code: "INVALID_SCOPE",
                message: "scope.repositoryRoot failed the safe-path gate",
            };
        }
        return {
            rootPath: scope.repositoryRoot,
            recursive: true,
            includeGlobs: compileGlobList(scope.includeGlobs),
            excludeGlobs: compileGlobList(scope.excludeGlobs),
        };
    }
    if (!isSafeScopePath(scope.rootPath)) {
        return { code: "INVALID_SCOPE", message: "scope.rootPath failed the safe-path gate" };
    }
    for (const entry of scope.files) {
        if (!isSafeScopePath(entry)) {
            return {
                code: "INVALID_SCOPE",
                message: `scope.files entry failed the safe-path gate: ${entry}`,
            };
        }
    }
    // `files` scope has no glob support — every entry is explicit. We still respect the
    // realpath containment gate inside walkSource so a malicious symlink is rejected.
    return {
        rootPath: scope.rootPath,
        recursive: false,
        includeGlobs: [],
        excludeGlobs: [],
    };
}
function abortYield() {
    return {
        kind: "error",
        error: { code: "CANCELLED", message: "walk cancelled by caller" },
    };
}
function safeStatSize(fs, absolutePath) {
    try {
        const stats = fs.stat(absolutePath);
        return stats.isFile ? stats.size : undefined;
    }
    catch {
        return undefined;
    }
}
function safeRealPath(fs, absolutePath) {
    try {
        return fs.realPath(absolutePath);
    }
    catch {
        return undefined;
    }
}
const HIDDEN_OR_GENERATED_DIRS = new Set([
    ".git",
    ".hg",
    ".svn",
    ".next",
    ".turbo",
    "node_modules",
    "dist",
    "build",
    "coverage",
    "out",
]);
function isGlobMatched(bounds, relativePath) {
    // Exclude wins over include. An empty includeGlobs means "include everything"; an empty
    // excludeGlobs means "exclude nothing".
    if (matchesAny(bounds.excludeGlobs, relativePath, false)) {
        return false;
    }
    return matchesAny(bounds.includeGlobs, relativePath, true);
}
function shouldDescendIntoDirectory(entryName) {
    return !entryName.startsWith(".") && !HIDDEN_OR_GENERATED_DIRS.has(entryName);
}
function shouldSkipDirectoryEntry(ctx, entryName) {
    return !ctx.bounds.recursive || !shouldDescendIntoDirectory(entryName);
}
function* yieldFileIfAllowed(ctx, absolutePath, relativePath) {
    // realpath containment gate (boundary). Skip the entry entirely on failure rather than
    // yielding a misleading diagnostic — the entry might be a transient broken symlink.
    const real = safeRealPath(ctx.fs, absolutePath);
    if (real === undefined) {
        return;
    }
    if (!isContained(ctx.bounds.rootPath, real)) {
        yield {
            kind: "error",
            error: {
                code: "PATH_ESCAPE",
                message: `entry escapes the scope root via realpath: ${relativePath}`,
                relativePath,
            },
        };
        return;
    }
    if (!isGlobMatched(ctx.bounds, relativePath)) {
        return;
    }
    const size = safeStatSize(ctx.fs, real);
    if (size === undefined) {
        return;
    }
    ctx.filesYielded += 1;
    yield { kind: "file", file: { relativePath, sizeBytes: size } };
}
function safeReadDir(fs, absolutePath) {
    try {
        return fs.readDir(absolutePath);
    }
    catch {
        return [];
    }
}
// Read `signal?.aborted` through a function call so TypeScript control-flow analysis
// does NOT narrow the optional chain after the first false branch — a long iteration may
// observe abort between any two checks.
function isAborted(ctx) {
    return ctx.options.signal?.aborted === true;
}
function* descend(ctx, absoluteDir, depth) {
    if (isAborted(ctx)) {
        yield abortYield();
        return;
    }
    if (ctx.filesYielded >= ctx.options.maxFiles) {
        return;
    }
    if (depth > ctx.options.maxDepth) {
        return;
    }
    const entries = [...safeReadDir(ctx.fs, absoluteDir)].sort((a, b) => (a.name < b.name ? -1 : 1));
    for (const entry of entries) {
        if (ctx.filesYielded >= ctx.options.maxFiles) {
            return;
        }
        if (isAborted(ctx)) {
            yield abortYield();
            return;
        }
        const childAbs = joinAbs(absoluteDir, entry.name);
        const childRel = toPosixRelative(ctx.bounds.rootPath, childAbs);
        if (entry.isDirectory) {
            if (shouldSkipDirectoryEntry(ctx, entry.name))
                continue;
            yield* descend(ctx, childAbs, depth + 1);
            continue;
        }
        if (!entry.isFile) {
            continue;
        }
        yield* yieldFileIfAllowed(ctx, childAbs, childRel);
    }
}
function* walkFilesScope(ctx, files) {
    for (const rel of files) {
        if (isAborted(ctx)) {
            yield abortYield();
            return;
        }
        if (ctx.filesYielded >= ctx.options.maxFiles) {
            return;
        }
        const abs = joinAbs(ctx.bounds.rootPath, rel);
        yield* yieldFileIfAllowed(ctx, abs, rel);
    }
}
export function* walkSource(fs, scope, options = DEFAULT_DISCOVERY_OPTIONS) {
    const bounds = deriveScopeBounds(scope);
    if ("code" in bounds) {
        yield { kind: "error", error: bounds };
        return;
    }
    const ctx = { fs, bounds, options, filesYielded: 0 };
    if (scope.kind === "files") {
        yield* walkFilesScope(ctx, scope.files);
        return;
    }
    yield* descend(ctx, bounds.rootPath, 0);
}
