// `gjsify foreach [flags] <script>` — yarn-workspaces-foreach replacement.
//
// Replaces every `yarn workspaces foreach -A -p --no-private --exclude
// '@girs/*' --topological run build` style invocation in monorepo
// scripts. Flags mirror yarn 4's shape so root package.json scripts can
// move over with a 1:1 substitution.
//
// Output is line-prefixed `[<workspace-name>]` when --parallel is set,
// matching yarn's interactive flow. Exit code is non-zero if any child
// process failed; first failure's stderr is forwarded.
import { spawn } from 'node:child_process';
import { cpus } from 'node:os';
import { buildDependencyGraph, discoverWorkspaces, filterWorkspaces, topologicalSort, } from '@gjsify/workspace';
import { findWorkspaceRoot } from '../utils/workspace-root.js';
export const foreachCommand = {
    command: 'foreach [script] [args..]',
    description: 'Run a workspace script across all (or filtered) workspaces. Drop-in for `yarn workspaces foreach`: -A/--all, -p/--parallel, -t/--topological, --include, --exclude, --no-private. Pass --exec to run an arbitrary command instead of a script.',
    builder: (yargs) => yargs
        .positional('script', {
        description: 'Script name to run in each workspace (`run <name>`-equivalent). With --exec, the command to run instead.',
        type: 'string',
    })
        .positional('args', {
        description: 'Extra arguments forwarded to each child invocation.',
        type: 'string',
        array: true,
    })
        .option('all', {
        description: 'Include workspaces declared as `private: true`.',
        type: 'boolean',
        alias: 'A',
        default: false,
    })
        .option('parallel', {
        description: 'Run workspaces in parallel (capped by --jobs).',
        type: 'boolean',
        alias: 'p',
        default: false,
    })
        .option('topological', {
        description: "Wait for each workspace's deps to finish before starting it (production deps only).",
        type: 'boolean',
        alias: 't',
        default: false,
    })
        .option('topological-dev', {
        description: 'Like --topological but also respects devDependencies (often cyclic — use sparingly).',
        type: 'boolean',
        default: false,
    })
        .option('include', {
        description: 'Glob pattern to include workspaces by name (repeatable).',
        type: 'string',
        array: true,
    })
        .option('exclude', {
        description: 'Glob pattern to exclude workspaces by name (repeatable).',
        type: 'string',
        array: true,
    })
        .option('private', {
        // Yargs auto-negates `--no-private` to `private=false`, so the
        // user-facing flag stays `--no-private` (yarn-compatible).
        description: 'Include private workspaces (default true). Pass --no-private to skip them.',
        type: 'boolean',
        default: true,
    })
        .option('verbose', {
        description: 'Echo every spawned command before running it.',
        type: 'boolean',
        alias: 'v',
        default: false,
    })
        .option('jobs', {
        description: 'Maximum concurrent workspaces in --parallel mode (default: cpu count).',
        type: 'number',
        alias: 'j',
    })
        .option('exec', {
        description: 'Treat <script> [args..] as an arbitrary command (yarn `workspaces foreach exec`-equivalent) instead of a package.json script lookup. Workspace filtering by script presence is skipped. Use `-- <cmd> <args...>` to pass flags to the command without yargs intercepting them.',
        type: 'boolean',
        default: false,
    })
        .parserConfiguration({
        // Preserve `--` as args._['--'] so callers can write
        //   gjsify foreach --exec -- npm publish --tag latest
        // without yargs grabbing --tag/--access/etc.
        'populate--': true,
    }),
    handler: async (args) => {
        // Walk up to the monorepo root — foreach is sometimes invoked
        // from inside a child workspace's script chain.
        const cwd = findWorkspaceRoot(process.cwd()) ?? process.cwd();
        const allWorkspaces = discoverWorkspaces(cwd);
        const exec = args.exec === true;
        // In --exec mode, support both
        //   gjsify foreach --exec npm something          (no flags in command)
        //   gjsify foreach --exec -- npm publish --tag X (flags in command)
        // The `--` form is the typical one: `populate--: true` puts the
        // post-separator argv into args._['--'], where yargs cannot grab
        // --tag/--access/etc. as its own options.
        let cmd = args.script;
        let cmdArgs = args.args ?? [];
        if (exec) {
            // With populate--:true, anything after the literal `--`
            // separator lands in top-level args['--']. yargs DOES NOT
            // attach it to args._ — it's a sibling array.
            const fromDoubleDash = (args['--'] ?? []).filter((v) => typeof v === 'string');
            if (fromDoubleDash.length > 0) {
                if (!cmd) {
                    cmd = fromDoubleDash[0];
                    cmdArgs = [...cmdArgs, ...fromDoubleDash.slice(1)];
                }
                else {
                    cmdArgs = [...cmdArgs, ...fromDoubleDash];
                }
            }
            if (!cmd) {
                console.error('gjsify foreach --exec: missing command. Pass it after `--`, e.g. `gjsify foreach --exec -- npm publish --tag latest`.');
                process.exit(1);
            }
        }
        let selected = filterWorkspaces(allWorkspaces, {
            include: args.include,
            exclude: args.exclude,
            noPrivate: args.private === false,
        });
        // In script mode, only run on workspaces that actually have the
        // requested script — yarn does this too, otherwise every project
        // that doesn't declare `<script>` would fail and force the user to
        // `--exclude` it. In --exec mode the command runs unconditionally
        // (yarn's `workspaces foreach exec` semantics).
        if (!exec) {
            if (!cmd) {
                console.error('gjsify foreach: missing <script> positional. Pass --exec to run an arbitrary command instead.');
                process.exit(1);
            }
            const scriptName = cmd;
            selected = selected.filter((ws) => {
                const scripts = ws.manifest.scripts ?? {};
                return typeof scripts[scriptName] === 'string';
            });
        }
        if (selected.length === 0) {
            console.log(`gjsify foreach: no workspaces match (${exec ? 'exec' : 'script'}="${cmd}", include=${JSON.stringify(args.include ?? [])}, exclude=${JSON.stringify(args.exclude ?? [])})`);
            return;
        }
        if (args.topological || args['topological-dev']) {
            const graph = buildDependencyGraph(selected, {
                includeDev: args['topological-dev'] === true,
            });
            selected = topologicalSort(graph);
        }
        const verbose = args.verbose === true;
        // `cmd` is guaranteed string at this point — both branches above
        // exit on undefined, but TS doesn't narrow through them.
        const finalCmd = cmd;
        try {
            if (args.parallel && !args.topological && !args['topological-dev']) {
                const jobs = args.jobs && args.jobs > 0 ? args.jobs : cpus().length;
                await runParallel(selected, finalCmd, cmdArgs, jobs, verbose, exec);
            }
            else if (args.parallel) {
                // Topological + parallel: each workspace starts as soon as its
                // deps (in the selected set) have finished. Yarn calls this
                // "topological order with concurrency"; we cap at --jobs.
                const jobs = args.jobs && args.jobs > 0 ? args.jobs : cpus().length;
                await runTopologicalParallel(selected, finalCmd, cmdArgs, jobs, verbose, args['topological-dev'] === true, exec);
            }
            else {
                await runSequential(selected, finalCmd, cmdArgs, verbose, exec);
            }
        }
        catch (err) {
            console.error(err.message);
            process.exit(1);
        }
        // ensureMainLoop() (called inside spawn) keeps GJS alive after every
        // child exits — without an explicit process.exit() the success path
        // would park the loop forever.
        process.exit(0);
    },
};
async function runSequential(workspaces, script, args, verbose, exec) {
    for (const ws of workspaces) {
        await runOne(ws, script, args, /* prefixOutput */ false, verbose, exec);
    }
}
async function runParallel(workspaces, script, args, concurrency, verbose, exec) {
    let cursor = 0;
    const workers = [];
    for (let w = 0; w < concurrency; w++) {
        workers.push((async () => {
            while (cursor < workspaces.length) {
                const i = cursor++;
                await runOne(workspaces[i], script, args, /* prefixOutput */ true, verbose, exec);
            }
        })());
    }
    await Promise.all(workers);
}
async function runTopologicalParallel(workspaces, script, args, concurrency, verbose, includeDev, exec) {
    const selectedNames = new Set(workspaces.map((w) => w.name));
    const remaining = new Map();
    for (const ws of workspaces) {
        const wsDeps = new Set();
        const m = ws.manifest;
        for (const block of [m.dependencies, includeDev ? m.devDependencies : undefined, m.optionalDependencies]) {
            if (!block)
                continue;
            for (const [name, spec] of Object.entries(block)) {
                if (typeof spec !== 'string')
                    continue;
                if (!spec.startsWith('workspace:'))
                    continue;
                if (selectedNames.has(name))
                    wsDeps.add(name);
            }
        }
        remaining.set(ws.name, wsDeps);
    }
    const byName = new Map(workspaces.map((w) => [w.name, w]));
    const done = new Set();
    let inflight = 0;
    return new Promise((resolve, reject) => {
        let error = null;
        const pump = () => {
            if (error)
                return;
            while (inflight < concurrency) {
                const ready = [...remaining.entries()]
                    .filter(([, deps]) => [...deps].every((d) => done.has(d)))
                    .map(([n]) => n);
                if (ready.length === 0)
                    break;
                const next = ready.sort()[0];
                remaining.delete(next);
                inflight++;
                runOne(byName.get(next), script, args, /* prefixOutput */ true, verbose, exec)
                    .then(() => {
                    inflight--;
                    done.add(next);
                    if (remaining.size === 0 && inflight === 0) {
                        resolve();
                        return;
                    }
                    pump();
                })
                    .catch((e) => {
                    error = e instanceof Error ? e : new Error(String(e));
                    // Wait for in-flight tasks to finish (yarn does the
                    // same — surfaces all errors instead of abruptly
                    // killing siblings).
                    if (inflight === 0)
                        reject(error);
                });
            }
            if (remaining.size > 0 && inflight === 0 && !error) {
                reject(new Error(`gjsify foreach --topological: stuck — workspaces ${[...remaining.keys()].join(', ')} have unsatisfied deps in the selected set`));
            }
        };
        pump();
    });
}
async function runOne(ws, script, args, prefixOutput, verbose, exec) {
    if (exec) {
        // Arbitrary-command mode: spawn `<script> <args...>` directly
        // (yarn `workspaces foreach exec`-equivalent). Used by callers
        // that need to run binaries the workspace doesn't expose as a
        // package.json script — e.g. `gjsify foreach --exec npm publish`.
        if (verbose) {
            console.error(`[${ws.name}] $ ${script} ${args.join(' ')}`);
        }
        await spawnPrefixed(script, args, ws.location, prefixOutput ? `[${ws.name}] ` : null);
        return;
    }
    // Use the same package manager that invoked us — yarn under yarn,
    // npm under npm, gjsify under gjsify. Default to `npm` for portability
    // when nothing is detectable; the script-runner (D.5) will replace
    // this once `gjsify run` ships.
    const runner = detectPackageManager();
    const argv = runner === 'gjsify' ? ['run', script, ...args] : ['run', script, ...(args.length > 0 ? ['--', ...args] : [])];
    if (verbose) {
        console.error(`[${ws.name}] $ ${runner} ${argv.join(' ')}`);
    }
    await spawnPrefixed(runner, argv, ws.location, prefixOutput ? `[${ws.name}] ` : null);
}
function detectPackageManager() {
    // `npm_config_user_agent` is set by npm/yarn/pnpm — first token is
    // `<name>/<version>`. Reuse it so `gjsify foreach build` invoked
    // through `yarn run` keeps using yarn, etc.
    const ua = process.env.npm_config_user_agent ?? '';
    if (ua.startsWith('yarn/'))
        return 'yarn';
    if (ua.startsWith('gjsify/'))
        return 'gjsify';
    return 'npm';
}
function spawnPrefixed(cmd, args, cwd, prefix) {
    // Default FORCE_COLOR=1 unless the user explicitly opted out, so tools
    // that key on `process.stdout.isTTY` (chalk, picocolors, …) still emit
    // ANSI colors when run under gjsify foreach. Mirrors yarn / npm.
    const colorEnv = process.env.FORCE_COLOR !== undefined || process.env.NO_COLOR !== undefined ? {} : { FORCE_COLOR: '1' };
    return new Promise((resolve, reject) => {
        const child = spawn(cmd, args, {
            cwd,
            stdio: prefix ? ['ignore', 'pipe', 'pipe'] : 'inherit',
            env: { ...process.env, ...colorEnv },
        });
        if (prefix && child.stdout && child.stderr) {
            prefixLines(child.stdout, process.stdout, prefix);
            prefixLines(child.stderr, process.stderr, prefix);
        }
        child.on('close', (code) => {
            if (code === 0)
                resolve();
            else
                reject(new Error(`${cmd} ${args.join(' ')} exited with code ${code}`));
        });
        child.on('error', (err) => reject(err));
    });
}
function prefixLines(src, sink, prefix) {
    let buf = '';
    src.setEncoding('utf-8');
    src.on('data', (chunk) => {
        buf += chunk;
        let idx;
        while ((idx = buf.indexOf('\n')) !== -1) {
            sink.write(prefix + buf.slice(0, idx + 1));
            buf = buf.slice(idx + 1);
        }
    });
    src.on('end', () => {
        if (buf.length > 0)
            sink.write(prefix + buf + '\n');
    });
}
