// Plan construction: turns a detected ScriptCatalog into an ordered VerificationPlan, and resolves
// targeted-test steps from a changed-file set. All path handling goes through the workspace
// boundary (resolveWithinWorkspace + WorkspaceFs.exists); no raw node:fs. Candidate test-path
// derivation uses plain string ops (no regex), so there is no ReDoS surface.
import { basename, dirname, extname, join, relative } from "node:path";
import { resolveWithinWorkspace, } from "@oscharko-dev/keiko-workspace";
import { nodeWorkspaceFs } from "@oscharko-dev/keiko-workspace/internal/fs";
import { DEFAULT_VERIFICATION_LIMITS, } from "./types.js";
// The script-backed kinds, in run order. `targeted-test` is synthesised, not script-backed.
const SCRIPT_KINDS = [
    "typecheck",
    "lint",
    "test",
    "build",
];
function resolveLimits(overrides) {
    return { ...DEFAULT_VERIFICATION_LIMITS, ...overrides };
}
function scriptStep(kind, scriptName, limits) {
    if (scriptName === undefined) {
        return {
            kind,
            scriptName: undefined,
            command: "npm",
            args: ["run", kind],
            limits,
            skipReason: `no ${kind} script detected in package.json`,
        };
    }
    // `npm test` has a dedicated subcommand; every other kind runs via `npm run <script>`.
    const args = scriptName === "test" ? ["test"] : ["run", scriptName];
    return { kind, scriptName, command: "npm", args, limits };
}
function wants(only, kind) {
    return only === undefined || only.includes(kind);
}
// Derives candidate test paths for a changed source file: a sibling `X.test.ts`/`X.spec.ts`
// (and .tsx/.js/.jsx variants), and the same basename mirrored under each configured testDir.
function candidateTestPaths(workspace, file) {
    const ext = extname(file);
    if (ext === "") {
        return [];
    }
    const dir = dirname(file);
    const stem = basename(file, ext);
    const suffixes = [".test", ".spec"];
    const siblings = suffixes.map((s) => join(dir, `${stem}${s}${ext}`));
    const sourceSubdir = sourceRelativeDir(workspace, file);
    const mirrored = workspace.testDirs.flatMap((testDir) => suffixes.map((s) => join(testDir, sourceSubdir, `${stem}${s}${ext}`)));
    return [...siblings, ...mirrored];
}
function sourceRelativeDir(workspace, file) {
    const dir = dirname(file);
    for (const sourceDir of workspace.sourceDirs) {
        const rel = relative(sourceDir, dir);
        if (rel === "") {
            return "";
        }
        if (!rel.startsWith("..") && !rel.startsWith("/") && !rel.includes(":")) {
            return rel;
        }
    }
    return dir === "." ? "" : dir;
}
function existsInWorkspace(workspace, fs, relPath) {
    try {
        const abs = resolveWithinWorkspace(workspace.root, relPath);
        return fs.exists(abs);
    }
    catch {
        // A path that escapes the workspace is simply not a resolvable target; skip it.
        return false;
    }
}
// Builds the framework-appropriate invocation that runs ONLY the given test files. vitest and jest
// both accept positional file paths. Returns undefined for an unknown framework, so no targeted
// step is added rather than guessing. The `npx <runner> ...` shape passes the #6 allowlist.
function targetedInvocation(workspace, files) {
    if (workspace.testFramework === "vitest") {
        return { command: "npx", args: ["vitest", "run", ...files] };
    }
    if (workspace.testFramework === "jest") {
        return { command: "npx", args: ["jest", ...files] };
    }
    return undefined;
}
export function resolveTargetedTests(workspace, changedFiles, fs = nodeWorkspaceFs, limits = DEFAULT_VERIFICATION_LIMITS) {
    const resolved = [];
    for (const file of changedFiles) {
        for (const candidate of candidateTestPaths(workspace, file)) {
            if (existsInWorkspace(workspace, fs, candidate) && !resolved.includes(candidate)) {
                resolved.push(candidate);
            }
        }
    }
    if (resolved.length === 0) {
        return [];
    }
    const invocation = targetedInvocation(workspace, resolved);
    if (invocation === undefined) {
        return [];
    }
    return [
        {
            kind: "targeted-test",
            scriptName: undefined,
            command: invocation.command,
            args: invocation.args,
            limits,
        },
    ];
}
export function buildVerificationPlan(workspace, catalog, options = {}, fs = nodeWorkspaceFs) {
    const limits = resolveLimits(options.limits);
    const steps = [];
    for (const kind of SCRIPT_KINDS) {
        if (wants(options.only, kind)) {
            steps.push(scriptStep(kind, catalog.mapping[kind], limits));
        }
    }
    if (wants(options.only, "targeted-test") && options.changedFiles !== undefined) {
        steps.push(...resolveTargetedTests(workspace, options.changedFiles, fs, limits));
    }
    return { workspaceRoot: workspace.root, steps };
}
