import { runBundle, runWatch, bundleToChunks } from '../bundler-pick.js';
import { gjsifyPlugin, textLoaderPlugin, resolveShebangLine, NODE_SHEBANG } from '@gjsify/rolldown-plugin-gjsify';
import { resolveUserPlugins } from '../utils/resolve-plugin-by-name.js';
import { resolveGlobalsList, writeRegisterInjectFile, detectAutoGlobals } from '@gjsify/rolldown-plugin-gjsify/globals';
import { pnpPlugin } from '@gjsify/rolldown-plugin-pnp';
import { dirname, extname } from 'node:path';
import { chmod, readFile, writeFile } from 'node:fs/promises';
import { normalizeBundlerOptions, mergeBundlerOptions } from '../utils/normalize-bundler-options.js';
const DEFAULT_GJS_SHEBANG = '#!/usr/bin/env -S gjs -m';
/**
 * `true` when `path` points at a location that's unsafe to use as a build
 * outfile (would overwrite source). Currently catches:
 *   - any TypeScript extension (`.ts`, `.tsx`, `.mts`, `.cts`, `.mtsx`, `.ctsx`)
 *   - paths that live under a `src/` segment (relative or absolute)
 */
function isUnsafeDefaultOutput(path) {
    if (/\.[cm]?tsx?$/i.test(path))
        return true;
    const norm = path.replace(/\\/g, '/');
    if (/(?:^|\/)src\//.test(norm))
        return true;
    return false;
}
/**
 * Resolve the gjsify-flavoured PnP plugin. Anchors the relay on this file's
 * URL so transitive `@gjsify/*` polyfills (reached via @gjsify/cli's deps on
 * @gjsify/{node,web}-polyfills) are resolvable for external consumers without
 * each one having to be a direct devDep.
 *
 * The path rewriter (`__filename`/`__dirname` + `import.meta.url` injection
 * for node_modules code) is registered separately by the orchestrator —
 * Rolldown's transform hooks all run sequentially, no shared `onLoad` race.
 */
async function buildPnpPlugin() {
    return pnpPlugin({ issuerUrl: import.meta.url });
}
export class BuildAction {
    configData;
    constructor(configData = {}) {
        this.configData = configData;
    }
    /** Library mode */
    async buildLibrary() {
        const { verbose, library, typescript, exclude, aliases } = this.configData;
        const lib = library ?? {};
        const userBundler = normalizeBundlerOptions(this.configData);
        const moduleOutdir = lib.module ? dirname(lib.module) : undefined;
        const mainOutdir = lib.main ? dirname(lib.main) : undefined;
        const moduleOutExt = lib.module ? extname(lib.module) : '.js';
        const mainOutExt = lib.main ? extname(lib.main) : '.js';
        const multipleBuilds = moduleOutdir && mainOutdir && moduleOutdir !== mainOutdir;
        const pnp = await buildPnpPlugin();
        const pnpPlugins = pnp ? [pnp] : [];
        const results = [];
        if (multipleBuilds) {
            const moduleFormat = moduleOutdir.includes('/cjs') || moduleOutExt === '.cjs' ? 'cjs' : 'esm';
            results.push(await runOneLibraryBuild({
                pluginOpts: {
                    debug: verbose,
                    library: moduleFormat,
                    exclude,
                    reflection: typescript?.reflection,
                    jsExtension: moduleOutExt,
                },
                userBundler,
                output: { dir: moduleOutdir },
                userAliases: aliases,
                pnpPlugins,
            }));
            const mainFormat = mainOutdir.includes('/cjs') || mainOutExt === '.cjs' ? 'cjs' : 'esm';
            results.push(await runOneLibraryBuild({
                pluginOpts: {
                    debug: verbose,
                    library: mainFormat,
                    exclude,
                    reflection: typescript?.reflection,
                    jsExtension: mainOutExt,
                },
                userBundler,
                output: { dir: mainOutdir },
                userAliases: aliases,
                pnpPlugins,
            }));
        }
        else {
            const outfilePath = userBundler.output?.file ?? lib.module ?? lib.main;
            const outExt = outfilePath ? extname(outfilePath) : '.js';
            const outdir = userBundler.output?.dir ?? (outfilePath ? dirname(outfilePath) : undefined);
            const format = userBundler.output?.format ??
                (outdir?.includes('/cjs') || outExt === '.cjs' ? 'cjs' : 'esm');
            results.push(await runOneLibraryBuild({
                pluginOpts: {
                    debug: verbose,
                    library: format,
                    exclude,
                    reflection: typescript?.reflection,
                    jsExtension: outExt,
                },
                userBundler,
                output: { dir: outdir },
                userAliases: aliases,
                pnpPlugins,
            }));
        }
        return results;
    }
    /**
     * Parse the `--globals` value into { autoMode, extras }.
     * - `auto`             → { autoMode: true, extras: '' }
     * - `auto,dom`         → { autoMode: true, extras: 'dom' }
     * - `auto,dom,fetch`   → { autoMode: true, extras: 'dom,fetch' }
     * - `dom,fetch`        → { autoMode: false, extras: 'dom,fetch' }
     * - `none` / ``        → { autoMode: false, extras: '' }
     * - `undefined`        → { autoMode: true, extras: '' }  (default)
     */
    parseGlobalsValue(value) {
        if (value === undefined)
            return { autoMode: true, extras: '' };
        if (value === 'none' || value === '')
            return { autoMode: false, extras: '' };
        const tokens = value
            .split(',')
            .map((t) => t.trim())
            .filter(Boolean);
        const hasAuto = tokens.includes('auto');
        const extras = tokens.filter((t) => t !== 'auto').join(',');
        return { autoMode: hasAuto, extras };
    }
    /**
     * Resolve the `--globals` CLI list into a pre-computed inject stub path
     * that the orchestrator appends to its input list. Only runs for
     * `--app gjs` — Node and browser builds rely on native globals.
     *
     * Used only for the explicit-only path (no `auto` token in the value).
     * The auto path is handled in `buildApp` via the iterative multi-pass build.
     */
    async resolveGlobalsInject(app, globals, verbose) {
        if (app !== 'gjs')
            return undefined;
        if (!globals)
            return undefined;
        const registerPaths = resolveGlobalsList(globals);
        if (registerPaths.size === 0)
            return undefined;
        const injectPath = await writeRegisterInjectFile(registerPaths, process.cwd());
        if (verbose && injectPath) {
            console.debug(`[gjsify] globals: injected ${registerPaths.size} register module(s) from --globals ${globals}`);
        }
        return injectPath ?? undefined;
    }
    /**
     * Post-processing: prepend the resolved shebang line and mark the
     * output executable. Runs for GJS and Node app builds with a single
     * outfile. The default line depends on the target — `gjs -m` for
     * `--app gjs`, `node` for `--app node` — so a single `shebang: true`
     * config produces a directly-executable bin for whichever target is
     * built. For GJS the shebang plugin already injects during bundling;
     * this hook is the safety net for anything that bypassed it (e.g.
     * user-supplied banners that out-ordered it) plus the chmod. For Node
     * there is no in-bundle plugin, so this hook is the sole injection point.
     */
    async applyShebang(app, outfile, verbose) {
        if (!outfile) {
            if (verbose)
                console.warn('[gjsify] --shebang skipped: no single outfile (use --outfile for executables)');
            return;
        }
        const defaultLine = app === 'node' ? NODE_SHEBANG : DEFAULT_GJS_SHEBANG;
        const line = resolveShebangLine(this.configData.shebang, defaultLine) ?? defaultLine;
        const content = await readFile(outfile, 'utf-8');
        if (content.startsWith('#!')) {
            if (verbose)
                console.debug(`[gjsify] --shebang skipped: ${outfile} already starts with a shebang`);
        }
        else {
            await writeFile(outfile, line + '\n' + content);
        }
        await chmod(outfile, 0o755);
        if (verbose)
            console.debug(`[gjsify] --shebang: wrote ${line} + chmod 0o755 to ${outfile}`);
    }
    /** Application mode */
    async buildApp(app = 'gjs', opts = {}) {
        const { verbose, typescript, exclude, library: pkg, aliases, excludeGlobals } = this.configData;
        const userBundler = normalizeBundlerOptions(this.configData);
        const formatRaw = userBundler.output?.format ??
            (userBundler.output?.file?.endsWith('.cjs') ? 'cjs' : 'esm');
        // The orchestrator only handles esm/cjs (iife is not a GJS / Node /
        // browser-bundle target we support). Coerce.
        const format = formatRaw === 'iife' ? 'esm' : formatRaw;
        // Set default outfile if no outdir is set
        let outfile = userBundler.output?.file;
        let outdir = userBundler.output?.dir;
        if (!outfile && !outdir && (pkg?.main || pkg?.module)) {
            const candidate = format === 'cjs' ? (pkg.main ?? pkg.module) : (pkg.module ?? pkg.main);
            if (candidate && isUnsafeDefaultOutput(candidate)) {
                throw new Error(`gjsify build: refusing to default --outfile to ${candidate} ` +
                    `(would overwrite a TypeScript source file). Pass --outfile/--outdir ` +
                    `explicitly, or set "gjsify.bundler.output.file" in package.json.`);
            }
            outfile = candidate;
        }
        const { consoleShim, globals } = this.configData;
        const userExternal = Array.isArray(userBundler.external) ? userBundler.external : undefined;
        const userBanner = typeof userBundler.output?.banner === 'string' ? userBundler.output.banner : undefined;
        const pluginOpts = {
            debug: verbose,
            app,
            format,
            exclude,
            reflection: typescript?.reflection,
            consoleShim,
            ...(aliases ? { aliases } : {}),
        };
        const { autoMode, extras } = this.parseGlobalsValue(globals);
        const pnp = await buildPnpPlugin();
        const pnpPlugins = pnp ? [pnp] : [];
        // User-supplied text loaders need to be available during BOTH the
        // auto-globals pre-build (`detectAutoGlobals`) and the final build —
        // otherwise Rolldown's parser hits unknown extensions like `.ui` /
        // `.asm` during the pre-build, fails to parse them as JS/JSX, and
        // the auto-globals iteration aborts before the final plugin chain is
        // ever assembled. Build the user-plugin chain once, up front, and
        // pass it into both passes.
        const userTextLoader = textLoaderPlugin({ loaders: this.configData.loaders });
        const userPlugins = userTextLoader ? [userTextLoader] : [];
        // User-supplied bundler.plugins (mix of plugin objects + by-name
        // entries) — resolved from the project's node_modules. Same
        // ordering rationale as the text loader: must be present during
        // auto-globals pre-build to avoid claiming the same files via
        // Rolldown's default classifier.
        if (userBundler.plugins?.length) {
            const resolved = await resolveUserPlugins(userBundler.plugins, process.cwd());
            userPlugins.push(...resolved);
        }
        // --- Auto mode (with optional extras): iterative multi-pass build ---
        if (app === 'gjs' && autoMode) {
            // Return the full orchestrator config (options + plugins) so
            // auto-globals can reuse the per-app `resolve.conditionNames` /
            // `mainFields` / `external` / `treeshake` for the in-memory
            // analysis bundle. Without these, native-rolldown defaults to
            // a different module-resolution condition set than npm-rolldown
            // and the detected free-global set diverges (see PR for the
            // missing-URL case under the GJS-CLI self-host loop).
            const gjsifyPluginFactory = async (opts) => {
                const cfg = await gjsifyPlugin({
                    input: userBundler.input,
                    output: { file: outfile, dir: outdir },
                    userExternal,
                    userBanner,
                    userAliases: aliases,
                    shebang: this.configData.shebang,
                }, opts);
                return { options: cfg.options, plugins: cfg.plugins };
            };
            const { injectPath } = await detectAutoGlobals({
                input: userBundler.input,
                plugins: [...pnpPlugins, ...userPlugins],
                external: userBundler.external,
                transform: userBundler.transform,
                format,
            }, pluginOpts, gjsifyPluginFactory, verbose, { extraGlobalsList: extras, excludeGlobals }, bundleToChunks);
            pluginOpts.autoGlobalsInject = injectPath;
        }
        else if (extras) {
            pluginOpts.autoGlobalsInject = await this.resolveGlobalsInject(app, extras, verbose);
        }
        // Final build: orchestrator → rolldown → write
        const cfg = await gjsifyPlugin({
            input: userBundler.input,
            output: { file: outfile, dir: outdir },
            userExternal,
            userBanner,
            userAliases: aliases,
            shebang: this.configData.shebang,
        }, pluginOpts);
        const merged = mergeBundlerOptions(cfg.options, userBundler);
        const finalOpts = {
            ...merged,
            // Drop user-config plugins from `merged` — they survived
            // mergeBundlerOptions via spread but have already been resolved
            // and appended into `userPlugins` above. Re-emitting the raw
            // entries (which may include `BundlerPluginByName` shapes
            // Rolldown doesn't understand) would crash the build.
            plugins: [...pnpPlugins, ...userPlugins, ...cfg.plugins],
        };
        if (opts.watch) {
            await this.runWatchLoop(finalOpts, app, outfile, verbose);
            return [];
        }
        const writeResult = await runBundle(finalOpts);
        if ((app === 'gjs' || app === 'node') && this.configData.shebang) {
            await this.applyShebang(app, outfile, verbose);
        }
        return [writeResult];
    }
    /**
     * Drive `rolldown.watch(...)`: rebuild on source change, apply the
     * post-bundle shebang hook on each successful build, surface errors
     * without exiting, clean up on SIGINT/SIGTERM. Resolves only when the
     * watcher closes — keeps the CLI process alive across rebuilds.
     */
    async runWatchLoop(finalOpts, app, outfile, verbose) {
        const watcher = await runWatch(finalOpts);
        const closed = new Promise((resolve) => {
            watcher.on('close', () => resolve());
        });
        let closing = false;
        const shutdown = async () => {
            if (closing)
                return;
            closing = true;
            console.log('\n[gjsify build --watch] stopping watcher…');
            try {
                await watcher.close();
            }
            catch (err) {
                console.error('[gjsify build --watch] watcher close error:', err);
            }
        };
        process.on('SIGINT', shutdown);
        process.on('SIGTERM', shutdown);
        watcher.on('event', async (event) => {
            switch (event.code) {
                case 'START':
                    if (verbose)
                        console.log('[gjsify build --watch] rebuild start');
                    break;
                case 'BUNDLE_START':
                    console.log('[gjsify build --watch] building…');
                    break;
                case 'BUNDLE_END':
                    console.log(`[gjsify build --watch] built in ${event.duration}ms`);
                    try {
                        if ((app === 'gjs' || app === 'node') && this.configData.shebang) {
                            await this.applyShebang(app, outfile, verbose);
                        }
                    }
                    finally {
                        await event.result.close();
                    }
                    break;
                case 'END':
                    console.log('[gjsify build --watch] waiting for changes…');
                    break;
                case 'ERROR':
                    console.error('[gjsify build --watch] build failed:', event.error?.message ?? event.error);
                    if (verbose && event.error?.stack)
                        console.error(event.error.stack);
                    try {
                        await event.result.close();
                    }
                    catch {
                        // best-effort cleanup
                    }
                    break;
            }
        });
        if (verbose) {
            watcher.on('change', (id, change) => {
                console.log(`[gjsify build --watch] ${change.event}: ${id}`);
            });
        }
        await closed;
    }
    async start(buildType = { app: 'gjs' }) {
        if (buildType.library) {
            if (buildType.watch) {
                throw new Error('gjsify build: --watch is not supported with --library (library mode would emit watcher rebuilds for every produced format; use --app gjs|node|browser instead).');
            }
            return await this.buildLibrary();
        }
        return await this.buildApp(buildType.app, { watch: buildType.watch });
    }
}
async function runOneLibraryBuild(args) {
    const cfg = await gjsifyPlugin({
        input: args.userBundler.input,
        output: args.output,
        userAliases: args.userAliases,
    }, args.pluginOpts);
    const merged = mergeBundlerOptions(cfg.options, args.userBundler);
    const finalOpts = {
        ...merged,
        plugins: [...args.pnpPlugins, ...cfg.plugins],
    };
    return await runBundle(finalOpts);
}
