const std = @import("std");
const html_util = @import("../util/html.zig");
const build_zig = @import("../../build.zig");
pub const InitOptions = @import("init/InitOptions.zig");

const LazyPath = std.Build.LazyPath;
const AddElementOptions = @import("../Build.zig").AddElementOptions;

pub fn init(b: *std.Build, exe: *std.Build.Step.Compile, options: InitOptions) !Build {
    const target = exe.root_module.resolved_target;
    const optimize = exe.root_module.optimize;
    const wasm_target = b.resolveTargetQuery(.{ .cpu_arch = .wasm32, .os_tag = .freestanding, .abi = .none });

    const zx_dep = b.dependencyFromBuildZig(build_zig, .{
        .optimize = optimize,
        .target = target,
        .@"exclude-core-lang" = true, // Users don't need parser/transpiler
    });

    const zx_host_dep = b.dependencyFromBuildZig(build_zig, .{
        .optimize = options.cli.optimize, // Always in release mode for faster transpilation
        // No target = host target, so zx CLI can execute during build
        .@"exclude-lsp" = true, // Skip LSP for faster build-time transpilation
    });

    // Full CLI dep (includes LSP) for the `zig build zx` step
    const zx_full_dep = b.dependencyFromBuildZig(build_zig, .{
        .optimize = options.cli.optimize,
    });

    const zx_wasm_dep = b.dependencyFromBuildZig(build_zig, .{
        .optimize = optimize,
        .target = wasm_target,
        .@"exclude-core-lang" = true, // Users don't need parser/transpiler
        .@"is-client" = true,
    });

    const zx_module = zx_dep.module("zx");
    const zx_wasm_module = zx_wasm_dep.module("zx");
    const zx_exe = zx_host_dep.artifact("zx");
    const zx_full_exe = zx_full_dep.artifact("zx");
    const ziex_js_dep = zx_dep.builder.dependency("ziex_js", .{});

    var opts: InitInnerOptions = .{
        .base_path = null,
        .site_path = b.path("app"),
        .cli_path = null,
        .site_outdir = null,
        .steps = .default,
        .client = options.client,
        .static_path = options.static_path,
        .ziex_js_dep = ziex_js_dep,
        .version = options.version,
        .server_only_stub_mode = .strict,
    };

    if (options.app) |site_opts| {
        opts.site_path = site_opts.path orelse opts.site_path;
        opts.base_path = site_opts.base_path;
        opts.features = site_opts.features;
    }

    opts.cli_path = options.cli.path;

    if (opts.cli_path == null) {
        const build_zon = @import("../../build.zig.zon");
        if (findZxInPath(b, build_zon.version)) |zx_path| {
            // std.debug.print("ziex: using zx from system path: {s}\n", .{zx_path});
            opts.cli_path = .{ .cwd_relative = zx_path };
        } else {
            // std.debug.print("ziex: building zx from source\n", .{});
        }
    }

    if (options.cli.steps) |cli_steps| {
        opts.steps = cli_steps;
    }

    return initInner(b, exe, zx_exe, zx_full_exe, zx_module, zx_wasm_module, opts);
}

const InitInnerOptions = struct {
    site_path: LazyPath,
    base_path: ?[]const u8,
    cli_path: ?LazyPath,
    site_outdir: ?LazyPath,
    steps: InitOptions.CliOptions.Steps,
    features: InitOptions.AppOptions.FeatureOptions = .default,
    client: InitOptions.ClientOptions,
    static_path: ?LazyPath,
    ziex_js_dep: *std.Build.Dependency,
    element_injections: []const AddElementOptions = &.{},
    version: ?[]const u8 = null,
    server_only_stub_mode: ServerOnlyStubMode = .strict,
};

pub fn getZxRun(b: *std.Build, zx_exe: *std.Build.Step.Compile, opts: InitInnerOptions) *std.Build.Step.Run {
    if (opts.cli_path) |cli_path| {
        const run = std.Build.Step.Run.create(b, "run zx");
        run.addFileArg(cli_path);
        return run;
    }

    return b.addRunArtifact(zx_exe);
}

fn getTranspileOutdir(transpile_cmd: *std.Build.Step.Run, opts: InitInnerOptions) std.Build.LazyPath {
    if (opts.site_outdir) |site_outdir| {
        transpile_cmd.addDirectoryArg(site_outdir);
        return site_outdir;
    }

    // if user didn't provide a path, they don't want to keep transpiled output
    // this will put the transpiled output in .zig-cache/o/{HASH}/app
    return transpile_cmd.addOutputDirectoryArg("app");
}

fn moduleRequiresLibCRec(module: *std.Build.Module, visited: *std.AutoHashMap(*std.Build.Module, void)) !bool {
    if (visited.contains(module)) return false;
    try visited.put(module, {});

    if ((module.link_libc orelse false) or (module.link_libcpp orelse false)) {
        return true;
    }

    var it = module.import_table.iterator();
    while (it.next()) |entry| {
        if (try moduleRequiresLibCRec(entry.value_ptr.*, visited)) {
            return true;
        }
    }

    for (module.link_objects.items) |obj| {
        switch (obj) {
            .system_lib => |lib| {
                if (std.mem.eql(u8, lib.name, "c") or
                    std.mem.eql(u8, lib.name, "stdc++") or
                    std.mem.eql(u8, lib.name, "c++"))
                {
                    return true;
                }
            },
            .other_step => |compile_step| {
                if (try moduleRequiresLibCRec(compile_step.root_module, visited)) {
                    return true;
                }
            },
            else => {},
        }
    }

    return false;
}

fn moduleRequiresLibC(allocator: std.mem.Allocator, module: *std.Build.Module) !bool {
    var visited = std.AutoHashMap(*std.Build.Module, void).init(allocator);
    defer visited.deinit();
    return moduleRequiresLibCRec(module, &visited);
}

fn makeServerOnlyStubModule(b: *std.Build, name: []const u8, mode: ServerOnlyStubMode) *std.Build.Module {
    const wasm_target = b.resolveTargetQuery(.{ .cpu_arch = .wasm32, .os_tag = .freestanding, .abi = .none });
    const contents = switch (mode) {
        .strict => b.fmt(
            \\// Auto-generated by ziex: strict server-only stub for `{s}`.
            \\//
            \\// This module is intentionally unavailable in wasm builds.
            \\comptime {{
            \\    @compileError(
            \\        "'{s}' is a server-only module and cannot be used from client " ++
            \\        "(wasm) code. Guard its usage with `if (builtin.target.cpu.arch " ++
            \\        "!= .wasm32)` or move it behind a server-only import boundary.",
            \\    );
            \\}}
            \\
        , .{ name, name }),
        .lazy => b.fmt(
            \\// Auto-generated by ziex: server-only stub for `{s}`.
            \\//
            \\// This module links libc/libc++ and cannot be compiled for the
            \\// wasm32-freestanding client build. Ziex has substituted this stub
            \\// so that `@import("{s}")` still resolves on the client, letting
            \\// the same source file compile for both server and client.
            \\//
            \\// Guard any usage of this module behind a comptime target check,
            \\// e.g.:
            \\//
            \\//     const builtin = @import("builtin");
            \\//     if (builtin.target.cpu.arch != .wasm32) {{
            \\//         // ... use the module here ...
            \\//     }}
            \\//
            \\// This lazy mode keeps shared imports compiling; unknown members on
            \\// this stub will still produce Zig's default "no member named" error.
            \\const __ziex_server_only_msg =
            \\    "'{s}' is a server-only module and cannot be used from client " ++
            \\    "(wasm) code. Guard its usage with `if (builtin.target.cpu.arch " ++
            \\    "!= .wasm32)` or move it behind a server-only import boundary.";
            \\
            \\pub const __ziex_server_only = @compileError(__ziex_server_only_msg);
            \\
        , .{ name, name, name }),
    };

    const wf = b.addWriteFiles();
    const stub_path = wf.add(b.fmt("ziex_server_only_stub_{s}.zig", .{name}), contents);

    return b.createModule(.{
        .root_source_file = stub_path,
        .target = wasm_target,
    });
}

fn genIntrospectRoot(
    b: *std.Build,
    source_builder: *std.Build,
    user_module: *std.Build.Module,
    host_target: ?std.Build.ResolvedTarget,
) !std.Build.LazyPath {
    const decls_wf = b.addWriteFiles();
    const decls_src = source_builder.path("src/build/introspect_decls.zig");

    const gen_mod = b.createModule(.{
        .root_source_file = decls_src,
        .target = host_target,
        .optimize = .Debug,
    });
    gen_mod.addImport("zx_app_root", user_module);

    const gen_exe = b.addExecutable(.{
        .name = "ziex_introspect_gen",
        .root_module = gen_mod,
    });

    const gen_run = b.addRunArtifact(gen_exe);
    gen_run.setName("introspect (gen root)");
    gen_run.expectExitCode(0);

    const root_src = decls_wf.addCopyFile(gen_run.captureStdOut(.{}), "ziex_introspect.zig");
    return root_src;
}

fn findZxInPath(b: *std.Build, expected_version: []const u8) ?[]const u8 {
    // TODO: disable for now, always use from source
    if (true) return null;
    const zx_path = b.findProgram(&.{"zx"}, &.{}) catch return null;
    var exit_code: u8 = undefined;
    const stdout = b.runAllowFail(&.{ zx_path, "version" }, &exit_code, .ignore) catch return null;
    const trimmed = std.mem.trim(u8, stdout, &std.ascii.whitespace);
    if (!std.mem.eql(u8, trimmed, expected_version)) return null;
    return zx_path;
}

pub fn initInner(
    b: *std.Build,
    exe: *std.Build.Step.Compile,
    zx_exe: *std.Build.Step.Compile,
    zx_full_exe: *std.Build.Step.Compile,
    zx_module: *std.Build.Module,
    zx_wasm_module: *std.Build.Module,
    opts: InitInnerOptions,
) !Build {
    const target = exe.root_module.resolved_target;
    const optimize = exe.root_module.optimize;
    const build_zon = @import("../../build.zig.zon");

    // --- ZX Options --- //
    const port_opt = b.option(u16, "port", "Port to run the Ziex server on");
    const address_opt = b.option([]const u8, "address", "Address to bind the Ziex server to");
    const rootdir_opt = b.option([]const u8, "rootdir", "Static root directory for the Ziex server");
    const cli_command_opt = b.option([]const u8, "cli-command", "Ziex CLI command mode for the app");

    const zx_options = b.addOptions();
    zx_options.addOption(?[]const u8, "jsglue_href", opts.client.jsglue_href);
    zx_options.addOption(?[]const u8, "wasm_href", opts.client.wasm_href);
    zx_options.addOption(?[]const u8, "app_base_path", opts.base_path);
    zx_options.addOption(?u16, "server_port", port_opt);
    zx_options.addOption(?[]const u8, "server_address", address_opt);
    zx_options.addOption(?[]const u8, "server_rootdir", rootdir_opt);
    zx_options.addOption([]const u8, "cli_command", cli_command_opt orelse "--");
    zx_options.addOption(bool, "introspect", b.option(bool, "introspect", "Print Ziex app metadata and exit") orelse false);
    zx_options.addOption(bool, "feature_sqlite_server", if (opts.features.sqlite) |s| s.server != null else false);
    zx_options.addOption(bool, "feature_kv_server", if (opts.features.kv) |k| k.server != null else false);
    zx_options.addOption(bool, "feature_kv_client", if (opts.features.kv) |k| k.client != null else false);
    zx_options.addOption(bool, "feature_cache_server", if (opts.features.cache) |c| c.server != null else false);

    zx_module.addOptions("zx_options", zx_options);

    // --- Dirs Setup --- //
    const static_lazypath: LazyPath = if (opts.static_path) |p| p else .{ .cwd_relative = b.pathJoin(&.{ b.install_path, "static" }) };
    const assetsdir = static_lazypath.path(b, "assets");

    // --- ZX Transpilation ---
    const transpile_cmd = getZxRun(b, zx_exe, opts);
    transpile_cmd.setName("zx transpile");
    transpile_cmd.addArg("transpile");
    transpile_cmd.addDirectoryArg(opts.site_path);
    // transpile_cmd.addArg("--verbose");
    transpile_cmd.addArg("--outdir");
    const transpile_outdir = getTranspileOutdir(transpile_cmd, opts);
    transpile_cmd.addArg("--rootdir");
    transpile_cmd.addDirectoryArg(static_lazypath);
    transpile_cmd.addArg("--dep-file");
    _ = transpile_cmd.addDepFileOutputArg("transpile.d");
    if (opts.base_path) |bp| {
        transpile_cmd.addArgs(&.{ "--base-path", bp });
    }
    // Always generate inlined sourcemaps so dev mode can remap errors to .zx files
    transpile_cmd.addArgs(&.{ "--map", "inline" });
    const cache_path_arg = b.pathJoin(&.{ b.cache_root.path orelse ".zig-cache", "zx_transpile" });
    transpile_cmd.addArgs(&.{ "--cache-dir", cache_path_arg });
    transpile_cmd.expectExitCode(0);

    const zxjs_default_href = "/assets/_/main.js";
    var zxjs_href = html_util.prefixPathWithBasePath(b.allocator, opts.base_path, opts.client.jsglue_href orelse zxjs_default_href);
    const wasm_default_href = "/assets/_/main.wasm";
    const wasm_href = html_util.prefixPathWithBasePath(b.allocator, opts.base_path, opts.client.wasm_href orelse wasm_default_href);
    // --- Static Directory Setup --- //
    {
        // Install public directory into static (only if the directory exists)
        const public_abs_path = opts.site_path.path(b, "public").getPath(b);

        if (std.Io.Dir.accessAbsolute(b.graph.io, public_abs_path, .{})) |_| {
            const install_static = b.addInstallDirectory(.{
                .source_dir = opts.site_path.path(b, "public"),
                .install_dir = .prefix,
                .install_subdir = "static",
            });
            exe.step.dependOn(&install_static.step);
        } else |_| {}

        // Also install the generated assets into static/assets (only if the directory exists)
        const assets_abs_path = opts.site_path.path(b, "assets").getPath(b);
        if (std.Io.Dir.accessAbsolute(b.graph.io, assets_abs_path, .{})) |_| {
            const install_assets = b.addInstallDirectory(.{
                .source_dir = opts.site_path.path(b, "assets"),
                .install_dir = .prefix,
                .install_subdir = "static/assets",
            });
            exe.step.dependOn(&install_assets.step);
        } else |_| {}

        var local_zxjs_path: ?LazyPath = opts.ziex_js_dep.path("wasm/init.js");

        if (opts.client.jsglue_href) |jsglue_href| {
            const is_remote = std.mem.startsWith(u8, jsglue_href, "http://") or std.mem.startsWith(u8, jsglue_href, "https://");
            if (is_remote) {
                local_zxjs_path = null;
                zxjs_href = jsglue_href;
            }
        }

        if (local_zxjs_path) |local_path| {
            // Install jsglue (wasm/init.js) from ziex_js package to static/assets/_/main.js
            const install_jsglue = b.addInstallFileWithDir(
                local_path,
                .prefix,
                "static" ++ zxjs_default_href,
            );
            exe.step.dependOn(&install_jsglue.step);
        }
    }

    // --- JS-Glue Package Install --- //
    if (opts.client.jsglue_install_subdir) |subdir| {
        const install_pkg = b.addInstallDirectory(.{
            .source_dir = opts.ziex_js_dep.path("."),
            .install_dir = .prefix,
            .include_extensions = &.{ ".js", ".ts" },
            .install_subdir = subdir,
        });
        b.getInstallStep().dependOn(&install_pkg.step);
    }

    // --- ZX Injections --- //
    const version = opts.version orelse build_zon.version;
    const injections = try b.allocator.create(Injections);
    injections.* = .{};
    for (opts.element_injections) |inj| {
        injections.add(b, inj);
    }
    // Inject wasm preload link tag into head
    injections.add(b, .{
        .parent = .head,
        .position = .ending,
        .element = .{
            .tag = .link,
            .attributes = &.{
                .{ .name = "id", .value = "__$wasmlink" },
                .{ .name = "rel", .value = "preload" },
                .{ .name = "as", .value = "fetch" },
                .{ .name = "href", .value = b.fmt("{s}?{s}", .{ wasm_href, version }) },
                .{ .name = "crossorigin" },
            },
        },
    });
    // Inject jsglue script tag via the build system
    injections.add(b, .{
        .parent = .head,
        .position = .ending,
        .element = .{
            .tag = .script,
            .attributes = &.{
                .{ .name = "defer" },
                .{ .name = "src", .value = b.fmt("{s}?{s}", .{ zxjs_href, version }) },
            },
        },
    });
    const injection_mod = b.createModule(try injections.getModOpts(b));
    zx_module.addImport("injections", injection_mod);
    zx_wasm_module.addImport("injections", injection_mod);

    // --- ZX Watch Invalidator ---
    // Use directory-level watch input so `zig build --watch` picks up changes.
    // Cache invalidation for non-watch builds is handled by the dep file (--dep-file).
    _ = try transpile_cmd.step.addDirectoryWatchInput(opts.site_path);

    // --- ZX Site Main Executable --- //
    var user_imports = std.array_list.Managed(std.Build.Module.Import).init(b.allocator);
    var import_it = exe.root_module.import_table.iterator();
    while (import_it.next()) |entry| {
        try user_imports.append(.{ .name = entry.key_ptr.*, .module = entry.value_ptr.* });
    }

    var imports = std.array_list.Managed(std.Build.Module.Import).init(b.allocator);
    for (user_imports.items) |import| {
        try imports.append(import);
    }

    // Copy all imports from the original zx_module
    var zx_import_it = zx_module.import_table.iterator();
    while (zx_import_it.next()) |entry| {
        if (!std.mem.eql(u8, entry.key_ptr.*, "app")) {
            try imports.append(.{ .name = entry.key_ptr.*, .module = entry.value_ptr.* });
        }
    }

    // Build imports for the "app" module (meta.zig needs access to zx and all other imports)
    var meta_imports = std.array_list.Managed(std.Build.Module.Import).init(b.allocator);
    for (imports.items) |import| {
        try meta_imports.append(import);
    }
    try meta_imports.append(.{ .name = "zx", .module = zx_module });

    // Inject the real generated app module into zx and directly into the user's root module
    const app_module = b.createModule(.{
        .root_source_file = transpile_outdir.path(b, "meta.zig"),
        .imports = meta_imports.items,
    });
    app_module.addImport("app", app_module);
    zx_module.addImport("app", app_module);
    exe.root_module.addImport("app", app_module);
    exe.root_module.addImport("zx", zx_module);

    exe.step.dependOn(&transpile_cmd.step);
    exe.step.name = b.fmt("install {s}server{s} {s}", .{ colors.dim, colors.reset, exe.name });
    b.installArtifact(exe);

    // --- Build-time App Metadata ---
    const is_dev_build = std.mem.eql(u8, cli_command_opt orelse "--", "dev");
    // TODO: get rid of introspection, we should be able to generate the meta
    // needed from transpile and from build-config alone. We don't need to resolve pageoptions in
    // any cases
    const can_introspect_exe = if (target) |resolved| blk: {
        const host = b.graph.host.result;
        break :blk resolved.result.cpu.arch == host.cpu.arch and
            resolved.result.os.tag == host.os.tag;
    } else true;
    const is_official_build_runner = @hasDecl(@import("root"), "printErrorMessages");

    if (is_official_build_runner and can_introspect_exe and !is_dev_build) {
        const introspect_src = try genIntrospectRoot(b, zx_module.owner, exe.root_module, target);

        const introspect_root = b.createModule(.{
            .root_source_file = introspect_src,
            .target = target,
            .optimize = optimize,
        });
        introspect_root.addImport("zx", zx_module);
        introspect_root.addImport("app", app_module);
        introspect_root.addImport("zx_app_root", exe.root_module);

        const introspect_exe = b.addExecutable(.{
            .name = b.fmt("{s}.meta", .{exe.name}),
            .root_module = introspect_root,
        });
        introspect_exe.step.dependOn(&transpile_cmd.step);

        const introspect_run = b.addRunArtifact(introspect_exe);
        introspect_run.setName(b.fmt("introspect {s}", .{exe.name}));
        introspect_run.expectExitCode(0);
        const introspect_stdout = introspect_run.captureStdOut(.{});

        const install_meta = b.addInstallFileWithDir(introspect_stdout, .bin, b.fmt("{s}.meta.zon", .{exe.name}));
        b.getInstallStep().dependOn(&install_meta.step);
    }

    // --- ZX WASM Main Executable --- //
    const wasm_target = b.resolveTargetQuery(.{ .cpu_arch = .wasm32, .os_tag = .freestanding, .abi = .none });
    const wasm_exe = b.addExecutable(.{
        .name = b.fmt("main", .{}),
        .root_module = b.createModule(.{
            .root_source_file = exe.root_module.root_source_file,
            .target = wasm_target,
            .optimize = optimize,
        }),
    });

    wasm_exe.entry = .disabled;
    wasm_exe.export_memory = true;
    wasm_exe.rdynamic = true;

    // Create a site-specific wasm module (same approach as server module)
    var wasm_imports = std.array_list.Managed(std.Build.Module.Import).init(b.allocator);
    for (user_imports.items) |import| {
        if (std.mem.eql(u8, import.name, "zx")) continue;

        const requires_libc = try moduleRequiresLibC(b.allocator, import.module);
        const wasm_module = if (requires_libc)
            makeServerOnlyStubModule(b, import.name, opts.server_only_stub_mode)
        else
            import.module;

        try wasm_imports.append(.{ .name = import.name, .module = wasm_module });
        wasm_exe.root_module.addImport(import.name, wasm_module);
    }

    var wasm_import_it = zx_wasm_module.import_table.iterator();
    while (wasm_import_it.next()) |entry| {
        if (!std.mem.eql(u8, entry.key_ptr.*, "zx_meta")) {
            try wasm_imports.append(.{ .name = entry.key_ptr.*, .module = entry.value_ptr.* });
        }
    }

    const site_wasm_module = b.createModule(.{
        .root_source_file = zx_wasm_module.root_source_file,
        .target = wasm_target,
        .optimize = zx_wasm_module.optimize,
        .imports = wasm_imports.items,
    });

    // Build imports for wasm zx_meta
    var wasm_meta_imports = std.array_list.Managed(std.Build.Module.Import).init(b.allocator);
    for (wasm_imports.items) |import| {
        try wasm_meta_imports.append(import);
    }
    try wasm_meta_imports.append(.{ .name = "zx", .module = site_wasm_module });

    site_wasm_module.addAnonymousImport("zx_meta", .{
        .root_source_file = transpile_outdir.path(b, "meta.zig"),
        .imports = wasm_meta_imports.items,
    });
    site_wasm_module.addOptions("zx_options", zx_options);
    wasm_exe.root_module.addImport("zx", site_wasm_module);
    wasm_exe.step.dependOn(&transpile_cmd.step);

    const wasm_binpath = wasm_exe.getEmittedBin();
    const install_wasm = b.addInstallFileWithDir(
        wasm_binpath,
        .{ .custom = "static/assets/_" },
        "main.wasm",
    );

    install_wasm.step.name = b.fmt("install {s}client{s} {s}", .{ colors.dim, colors.reset, exe.name });

    b.default_step.dependOn(&install_wasm.step);

    // --- Steps: ZX (Root of ZX CLI) --- //
    {
        const zx_step = b.step(
            "zx",
            b.fmt("ZX CLI - \x1b[2m{s}\x1b[0m", .{"zig build zx -- <args>"}),
        );
        const zx_cmd = getZxRun(b, zx_full_exe, opts);
        zx_step.dependOn(&zx_cmd.step);
        if (b.args) |args| zx_cmd.addArgs(args);
    }

    // --- Steps: Serve --- //
    if (opts.steps.serve) |serve_step_name| {
        const serve_step = b.step(serve_step_name, "Run the Ziex app with production behavior");
        const serve_cmd = b.addRunArtifact(exe);
        serve_cmd.step.dependOn(b.getInstallStep());
        serve_cmd.step.dependOn(&transpile_cmd.step);
        serve_step.dependOn(&serve_cmd.step);
        if (b.args) |args| serve_cmd.addArgs(args);
    }

    // --- Steps: Dev --- //
    if (opts.steps.dev) |dev_step_name| {
        const dev_cmd = getZxRun(b, zx_exe, opts);
        dev_cmd.addArgs(&.{
            "dev",
            "--binpath",
        });
        dev_cmd.addArg(b.pathJoin(&.{ b.exe_dir, exe.out_filename }));
        const dev_step = b.step(dev_step_name, "Run the Ziex app in development mode");
        dev_step.dependOn(&dev_cmd.step);
        if (b.args) |args| dev_cmd.addArgs(args);
    }

    // --- Steps: Export --- //
    if (opts.steps.@"export") |export_step_name| {
        const export_cmd = getZxRun(b, zx_exe, opts);
        export_cmd.addArgs(&.{"export"});
        const export_step = b.step(export_step_name, "Export the Ziex app for static hosting");
        export_step.dependOn(&export_cmd.step);
        if (b.args) |args| export_cmd.addArgs(args);
    }

    // --- Steps: Bundle --- //
    if (opts.steps.bundle) |bundle_step_name| {
        const bundle_cmd = getZxRun(b, zx_exe, opts);
        bundle_cmd.addArgs(&.{"bundle"});
        const bundle_step = b.step(bundle_step_name, "Bundle the Ziex app for production deployment");
        bundle_step.dependOn(&bundle_cmd.step);
        if (b.args) |args| bundle_cmd.addArgs(args);
    }

    return .{
        .build = b,
        .zx_module = zx_module,
        .app = .{ .exe = exe, .module = app_module },
        .cmd = .{
            .transpile = transpile_cmd,
        },
        .outdir = transpile_outdir,
        .assetsdir = assetsdir,
        .cli = .{
            .exe = zx_exe,
        },
        .transformer = .{ .b = b, .userdata = injections },
    };
}

const Injections = struct {
    items: std.ArrayListUnmanaged(AddElementOptions) = .empty,

    pub fn add(self: *Injections, b: *std.Build, options: AddElementOptions) void {
        self.items.append(b.allocator, options) catch @panic("OOM");
    }

    pub fn getModOpts(self: *Injections, b: *std.Build) !std.Build.Module.CreateOptions {
        var aw = std.Io.Writer.Allocating.init(b.allocator);
        defer aw.deinit();
        try std.zon.stringify.serializeArbitraryDepth(self.items.items, .{}, &aw.writer);
        const file = b.addWriteFile("injections.zon", aw.written());
        return .{ .root_source_file = file.getDirectory().path(b, "injections.zon") };
    }
};

// TODO: move to src/Build.zig
pub const Build = struct {
    pub const BuildZiex = struct {
        exe: *std.Build.Step.Compile,
    };

    pub const BuildCommand = struct {
        transpile: *std.Build.Step.Run,
    };

    pub const App = struct {
        exe: *std.Build.Step.Compile,
        module: *std.Build.Module,
    };

    /// Output transformer: injects elements into the generated output
    pub const Transformer = struct {
        b: *std.Build,
        userdata: *anyopaque,

        pub fn addElement(self: Transformer, options: AddElementOptions) void {
            const injections: *Injections = @ptrCast(@alignCast(self.userdata));
            injections.add(self.b, options);
        }
    };

    build: *std.Build,

    /// The app's canonical `zx` module. Component modules created via
    /// `addComponent` are bound to this so the whole app shares one zx graph.
    zx_module: *std.Build.Module,

    /// The app's root executable and generated `app` module.
    app: App,

    cmd: BuildCommand,

    outdir: LazyPath,
    assetsdir: LazyPath,

    cli: BuildZiex,

    transformer: Transformer,

    /// Transpile a `.zx` component file and return a Zig module wired to this
    /// app's canonical `zx` module.
    pub fn addComponent(self: Build, opts: build_zig.TranslatedZx.Options) *std.Build.Module {
        var component_opts = opts;
        // Inherit the app's target/optimize unless the caller overrode them.
        if (component_opts.target == null) component_opts.target = self.zx_module.resolved_target;
        if (component_opts.optimize == null) component_opts.optimize = self.zx_module.optimize;

        const mod = build_zig.addTranslateZx(self.build, component_opts).createModule();
        mod.addImport("zx", self.zx_module);
        return mod;
    }

    /// Like `addComponent`, but also imports the resulting module onto the app's
    /// root module and generated `app` module under `name`.
    pub fn addComponentImport(self: Build, name: []const u8, opts: build_zig.TranslatedZx.Options) void {
        const mod = self.addComponent(opts);
        self.addImport(name, mod);
    }

    /// Add an import to app's root module and generated `app` module under `name`.
    pub fn addImport(self: Build, name: []const u8, mod: *std.Build.Module) void {
        self.app.exe.root_module.addImport(name, mod);
        self.app.module.addImport(name, mod);
    }
};

const ServerOnlyStubMode = enum {
    lazy,
    strict,
};

const colors = struct {
    pub const dim: []const u8 = "\x1b[2m";
    pub const reset: []const u8 = "\x1b[0m";
};
