load("@crates//:data.bzl", "DEP_DATA")
load("@crates//:defs.bzl", "all_crate_deps")
load("@rules_platform//platform_data:defs.bzl", "platform_data")
load("@rules_rust//cargo/private:cargo_build_script_wrapper.bzl", "cargo_build_script")
load("@rules_rust//rust:defs.bzl", "rust_binary", "rust_library", "rust_proc_macro", "rust_test")

PLATFORMS = [
    "linux_arm64_musl",
    "linux_amd64_musl",
    "macos_amd64",
    "macos_arm64",
    "windows_amd64",
    "windows_arm64",
]

# Match Cargo's Windows linker behavior so Bazel-built binaries and tests use
# the same stack reserve on both Windows ABIs and resolve UCRT imports on MSVC.
WINDOWS_RUSTC_LINK_FLAGS = select({
    "@rules_rs//rs/experimental/platforms/constraints:windows_gnullvm": [
        "-C",
        "link-arg=-Wl,--stack,8388608",  # 8 MiB
    ],
    "@rules_rs//rs/experimental/platforms/constraints:windows_msvc": [
        "-C",
        "link-arg=/STACK:8388608",  # 8 MiB
        "-C",
        "link-arg=/NODEFAULTLIB:libucrt.lib",
        "-C",
        "link-arg=ucrt.lib",
    ],
    "//conditions:default": [],
})

WINDOWS_GNULLVM_INCOMPATIBLE = select({
    "@rules_rs//rs/experimental/platforms/constraints:windows_gnullvm": ["@platforms//:incompatible"],
    "//conditions:default": [],
})

WINDOWS_GNULLVM_ONLY = select({
    "@rules_rs//rs/experimental/platforms/constraints:windows_gnullvm": [],
    "//conditions:default": ["@platforms//:incompatible"],
})

# libwebrtc uses Objective-C categories from native archives. Any Bazel-linked
# macOS binary/test that can pull it in must keep category symbols alive.
MACOS_WEBRTC_RUSTC_LINK_FLAGS = select({
    "@platforms//os:macos": [
        "-C",
        "link-arg=-ObjC",
        "-C",
        "link-arg=-lc++",
    ],
    "//conditions:default": [],
})

def multiplatform_binaries(name, platforms = PLATFORMS):
    for platform in platforms:
        platform_data(
            name = name + "_" + platform,
            platform = "@llvm//platforms:" + platform,
            target = name,
            tags = ["manual"],
        )

    native.filegroup(
        name = "release_binaries",
        srcs = [name + "_" + platform for platform in platforms],
        tags = ["manual"],
    )

def _workspace_root_test_impl(ctx):
    is_windows = ctx.target_platform_has_constraint(ctx.attr._windows_constraint[platform_common.ConstraintValueInfo])
    launcher = ctx.actions.declare_file(ctx.label.name + ".bat" if is_windows else ctx.label.name)
    test_bin = ctx.executable.test_bin
    workspace_root_marker = ctx.file.workspace_root_marker
    launcher_template = ctx.file._windows_launcher_template if is_windows else ctx.file._bash_launcher_template
    runfile_env_exports = _windows_runfile_env_exports(ctx) if is_windows else _bash_runfile_env_exports(ctx)
    workspace_root_setup = _windows_workspace_root_setup(ctx) if is_windows else _bash_workspace_root_setup(ctx)
    ctx.actions.expand_template(
        template = launcher_template,
        output = launcher,
        is_executable = True,
        substitutions = {
            "__RUNFILE_ENV_EXPORTS__": runfile_env_exports,
            "__TEST_BIN__": test_bin.short_path,
            "__WORKSPACE_ROOT_SETUP__": workspace_root_setup,
            "__WORKSPACE_ROOT_MARKER__": workspace_root_marker.short_path,
        },
    )

    runfiles = ctx.runfiles(files = [test_bin, workspace_root_marker]).merge(ctx.attr.test_bin[DefaultInfo].default_runfiles)
    for data_dep in ctx.attr.data:
        runfiles = runfiles.merge(ctx.runfiles(files = data_dep[DefaultInfo].files.to_list()))
        runfiles = runfiles.merge(data_dep[DefaultInfo].default_runfiles)
    for runfile_dep in ctx.attr.runfile_env:
        executable = runfile_dep[DefaultInfo].files_to_run.executable
        if executable == None:
            fail("{} does not provide an executable for runfile_env".format(runfile_dep.label))
        runfiles = runfiles.merge(ctx.runfiles(files = [executable]))
        runfiles = runfiles.merge(runfile_dep[DefaultInfo].default_runfiles)

    location_targets = (
        ctx.attr.data +
        [ctx.attr.test_bin, ctx.attr.workspace_root_marker] +
        ctx.attr.runfile_env.keys()
    )
    env = {
        key: ctx.expand_location(value, targets = location_targets)
        for key, value in ctx.attr.env.items()
    }

    return [
        DefaultInfo(
            executable = launcher,
            files = depset([launcher]),
            runfiles = runfiles,
        ),
        RunEnvironmentInfo(
            environment = env,
        ),
    ]

def _bash_runfile_env_exports(ctx):
    lines = []
    for runfile_dep, env_var in ctx.attr.runfile_env.items():
        executable = runfile_dep[DefaultInfo].files_to_run.executable
        if executable == None:
            fail("{} does not provide an executable for runfile_env".format(runfile_dep.label))
        lines.append('RUNFILE_ENV_ARGS+=("{}=$(resolve_runfile "{}")")'.format(env_var, executable.short_path))
    return "\n".join(lines)

def _windows_runfile_env_exports(ctx):
    lines = []
    for runfile_dep, env_var in ctx.attr.runfile_env.items():
        executable = runfile_dep[DefaultInfo].files_to_run.executable
        if executable == None:
            fail("{} does not provide an executable for runfile_env".format(runfile_dep.label))
        lines.append('call :resolve_runfile {} "{}"'.format(env_var, executable.short_path))
        lines.append("if errorlevel 1 exit /b 1")
    return "\n".join(lines)

def _bash_workspace_root_setup(ctx):
    if not ctx.attr.chdir_workspace_root:
        return ""
    return 'export INSTA_WORKSPACE_ROOT="${workspace_root}"\ncd "${workspace_root}"'

def _windows_workspace_root_setup(ctx):
    if not ctx.attr.chdir_workspace_root:
        return ""
    return """set "INSTA_WORKSPACE_ROOT=%workspace_root%"
cd /d "%workspace_root%" || exit /b 1"""

workspace_root_test = rule(
    implementation = _workspace_root_test_impl,
    test = True,
    toolchains = ["@bazel_tools//tools/test:default_test_toolchain_type"],
    attrs = {
        "chdir_workspace_root": attr.bool(
            default = True,
        ),
        "data": attr.label_list(
            allow_files = True,
        ),
        "env": attr.string_dict(),
        "runfile_env": attr.label_keyed_string_dict(
            cfg = "target",
        ),
        "test_bin": attr.label(
            cfg = "target",
            executable = True,
            mandatory = True,
        ),
        "workspace_root_marker": attr.label(
            allow_single_file = True,
            mandatory = True,
        ),
        "_windows_constraint": attr.label(
            default = "@platforms//os:windows",
            providers = [platform_common.ConstraintValueInfo],
        ),
        "_bash_launcher_template": attr.label(
            allow_single_file = True,
            default = "//:workspace_root_test_launcher.sh.tpl",
        ),
        "_windows_launcher_template": attr.label(
            allow_single_file = True,
            default = "//:workspace_root_test_launcher.bat.tpl",
        ),
    },
)

def codex_rust_crate(
        name,
        crate_name,
        crate_features = [],
        crate_srcs = None,
        crate_edition = None,
        proc_macro = False,
        build_script_enabled = True,
        build_script_data = [],
        compile_data = [],
        lib_data_extra = [],
        rustc_flags_extra = [],
        rustc_env = {},
        deps_extra = [],
        integration_compile_data_extra = [],
        integration_test_args = [],
        integration_test_timeout = None,
        test_data_extra = [],
        test_shard_counts = {},
        test_tags = [],
        unit_test_timeout = None,
        extra_binaries = []):
    """Defines a Rust crate with library, binaries, and tests wired for Bazel + Cargo parity.

    The macro mirrors Cargo conventions: it builds a library when `src/` exists,
    wires build scripts, exports `CARGO_BIN_EXE_*` for integration tests, and
    creates unit + integration test targets. Dependency buckets map to the
    Cargo.lock resolution in `@crates`.

    Args:
        name: Bazel target name for the library, should be the directory name.
            Example: `app-server`.
        crate_name: Cargo crate name from Cargo.toml
            Example: `codex_app_server`.
        crate_features: Cargo features to enable for this crate.
            Crates are only compiled in a single configuration across the workspace, i.e.
            with all features in this list enabled. So use sparingly, and prefer to refactor
            optional functionality to a separate crate.
        crate_srcs: Optional explicit srcs; defaults to `src/**/*.rs`.
        crate_edition: Rust edition override, if not default.
            You probably don't want this, it's only here for a single caller.
        proc_macro: Whether this crate builds a proc-macro library.
        build_script_data: Data files exposed to the build script at runtime.
        compile_data: Non-Rust compile-time data for the library target.
        lib_data_extra: Extra runtime data for the library target.
        rustc_env: Extra rustc_env entries to merge with defaults.
        deps_extra: Extra normal deps beyond @crates resolution.
            Typically only needed when features add additional deps.
        integration_compile_data_extra: Extra compile_data for integration tests.
        integration_test_args: Optional args for integration test binaries.
        integration_test_timeout: Optional Bazel timeout for integration test
            targets generated from `tests/*.rs`.
        test_data_extra: Extra runtime data for tests.
        test_shard_counts: Mapping from generated test target name to Bazel
            shard count. Matching tests use native Bazel sharding on the
            original test label, while rules_rust assigns each Rust test case
            to a stable bucket by hashing the test name. Matching tests are
            also marked flaky, which gives them Bazel's default three attempts.
        test_tags: Tags applied to unit + integration test targets.
            Typically used to disable the sandbox, but see https://bazel.build/reference/be/common-definitions#common.tags
        unit_test_timeout: Optional Bazel timeout for the unit-test target
            generated from `src/**/*.rs`.
        extra_binaries: Additional binary labels to surface as test data and
            `CARGO_BIN_EXE_*` environment variables. These are only needed for binaries from a different crate.
    """
    test_env = {
        # The launcher resolves an absolute workspace root at runtime so
        # manifest-only platforms like macOS still point Insta at the real
        # `codex-rs` checkout.
        "INSTA_WORKSPACE_ROOT": ".",
        "INSTA_SNAPSHOT_PATH": "src",
    }

    native.filegroup(
        name = "package-files",
        srcs = native.glob(
            ["**"],
            exclude = [
                "**/BUILD.bazel",
                "BUILD.bazel",
                "target/**",
            ],
            allow_empty = True,
        ),
        visibility = ["//visibility:public"],
    )

    rustc_env = {
        "BAZEL_PACKAGE": native.package_name(),
    } | rustc_env

    manifest_relpath = native.package_name()
    if manifest_relpath.startswith("codex-rs/"):
        manifest_relpath = manifest_relpath[len("codex-rs/"):]
    manifest_path = manifest_relpath + "/Cargo.toml"

    binaries = DEP_DATA.get(native.package_name())["binaries"]

    lib_srcs = crate_srcs or native.glob(["src/**/*.rs"], exclude = binaries.values(), allow_empty = True)

    maybe_deps = []

    if build_script_enabled and native.glob(["build.rs"], allow_empty = True):
        cargo_build_script(
            name = name + "-build-script",
            srcs = ["build.rs"],
            deps = all_crate_deps(build = True),
            data = build_script_data,
            # Some build script deps sniff version-related env vars...
            version = "0.0.0",
        )

        maybe_deps += [name + "-build-script"]

    if lib_srcs:
        lib_rule = rust_proc_macro if proc_macro else rust_library
        lib_rule(
            name = name,
            crate_name = crate_name,
            crate_features = crate_features,
            deps = all_crate_deps() + maybe_deps + deps_extra,
            compile_data = compile_data,
            data = lib_data_extra,
            srcs = lib_srcs,
            edition = crate_edition,
            rustc_flags = rustc_flags_extra,
            rustc_env = rustc_env,
            visibility = ["//visibility:public"],
        )

        unit_test_name = name + "-unit-tests"
        unit_test_binary = name + "-unit-tests-bin"
        unit_test_shard_count = _test_shard_count(test_shard_counts, unit_test_name)

        # Shard at the workspace_root_test layer. rules_rust's sharding wrapper
        # expects to run from its own runfiles cwd, while workspace_root_test
        # deliberately changes cwd so Insta sees Cargo-like snapshot paths.
        rust_test(
            name = unit_test_binary,
            crate = name,
            deps = all_crate_deps(normal = True, normal_dev = True) + maybe_deps + deps_extra,
            # Unit tests also compile to standalone Windows executables, so
            # keep their stack reserve aligned with binaries and integration
            # tests under gnullvm.
            # Bazel has emitted both `codex-rs/<crate>/...` and
            # `../codex-rs/<crate>/...` paths for `file!()`. Strip either
            # prefix so the workspace-root launcher sees Cargo-like metadata
            # such as `tui/src/...`.
            rustc_flags = rustc_flags_extra + WINDOWS_RUSTC_LINK_FLAGS + [
                "--remap-path-prefix=../codex-rs=",
                "--remap-path-prefix=codex-rs=",
            ],
            rustc_env = rustc_env,
            data = test_data_extra,
            tags = test_tags + ["manual"],
        )

        unit_test_kwargs = {}
        if unit_test_timeout:
            unit_test_kwargs["timeout"] = unit_test_timeout
        if unit_test_shard_count:
            unit_test_kwargs["shard_count"] = unit_test_shard_count
            unit_test_kwargs["flaky"] = True

        workspace_root_test(
            name = unit_test_name,
            env = test_env,
            test_bin = ":" + unit_test_binary,
            workspace_root_marker = "//codex-rs/utils/cargo-bin:repo_root.marker",
            tags = test_tags,
            **unit_test_kwargs
        )

        maybe_deps += [name]

    sanitized_binaries = []
    cargo_env = {}
    cargo_env_runfiles = {}
    for binary, main in binaries.items():
        #binary = binary.replace("-", "_")
        sanitized_binaries.append(binary)
        cargo_env_runfiles[":" + binary] = "CARGO_BIN_EXE_" + binary
        cargo_env["CARGO_BIN_EXE_" + binary] = "$(rlocationpath :%s)" % binary

        rust_binary(
            name = binary,
            crate_name = binary.replace("-", "_"),
            crate_root = main,
            deps = all_crate_deps() + maybe_deps + deps_extra,
            edition = crate_edition,
            rustc_flags = rustc_flags_extra + WINDOWS_RUSTC_LINK_FLAGS,
            srcs = native.glob(["src/**/*.rs"]),
            visibility = ["//visibility:public"],
        )

    for binary_label in extra_binaries:
        sanitized_binaries.append(binary_label)
        binary = Label(binary_label).name
        cargo_env_runfiles[binary_label] = "CARGO_BIN_EXE_" + binary
        cargo_env["CARGO_BIN_EXE_" + binary] = "$(rlocationpath %s)" % binary_label

    integration_test_kwargs = {}
    if integration_test_args:
        integration_test_kwargs["args"] = integration_test_args
    if integration_test_timeout:
        integration_test_kwargs["timeout"] = integration_test_timeout

    for test in native.glob(["tests/*.rs"], allow_empty = True):
        test_file_stem = test.removeprefix("tests/").removesuffix(".rs")
        test_crate_name = test_file_stem.replace("-", "_")
        test_name = name + "-" + test_file_stem.replace("/", "-")
        if not test_name.endswith("-test"):
            test_name += "-test"
        windows_cross_test_binary = test_name + "-windows-cross-bin"

        test_kwargs = {}
        test_kwargs.update(integration_test_kwargs)
        test_shard_count = _test_shard_count(test_shard_counts, test_name)
        if test_shard_count:
            test_kwargs["experimental_enable_sharding"] = True
            test_kwargs["shard_count"] = test_shard_count
            test_kwargs["flaky"] = True

        # Keep the existing integration test shape on non-gnullvm platforms.
        # Windows cross tests need workspace_root_test so runfile env vars
        # resolve to Windows-native absolute paths before the test starts.
        rust_test(
            name = test_name,
            crate_name = test_crate_name,
            crate_root = test,
            srcs = [test],
            data = native.glob(["tests/**"], allow_empty = True) + sanitized_binaries + test_data_extra,
            compile_data = native.glob(["tests/**"], allow_empty = True) + integration_compile_data_extra,
            deps = all_crate_deps(normal = True, normal_dev = True) + maybe_deps + deps_extra,
            # Bazel has emitted both `codex-rs/<crate>/...` and
            # `../codex-rs/<crate>/...` paths for `file!()`. Strip either
            # prefix so Insta records Cargo-like metadata such as `core/tests/...`.
            rustc_flags = rustc_flags_extra + WINDOWS_RUSTC_LINK_FLAGS + [
                "--remap-path-prefix=../codex-rs=",
                "--remap-path-prefix=codex-rs=",
            ],
            rustc_env = rustc_env,
            env = cargo_env,
            target_compatible_with = WINDOWS_GNULLVM_INCOMPATIBLE,
            tags = test_tags,
            **test_kwargs
        )

        windows_cross_test_kwargs = {}
        windows_cross_test_kwargs.update(integration_test_kwargs)
        if test_shard_count:
            windows_cross_test_kwargs["shard_count"] = test_shard_count
            windows_cross_test_kwargs["flaky"] = True

        rust_test(
            name = windows_cross_test_binary,
            crate_name = test_crate_name,
            crate_root = test,
            srcs = [test],
            data = native.glob(["tests/**"], allow_empty = True) + sanitized_binaries + test_data_extra,
            compile_data = native.glob(["tests/**"], allow_empty = True) + integration_compile_data_extra,
            deps = all_crate_deps(normal = True, normal_dev = True) + maybe_deps + deps_extra,
            rustc_flags = rustc_flags_extra + WINDOWS_RUSTC_LINK_FLAGS + [
                "--remap-path-prefix=../codex-rs=",
                "--remap-path-prefix=codex-rs=",
            ],
            rustc_env = rustc_env,
            env = cargo_env,
            target_compatible_with = WINDOWS_GNULLVM_ONLY,
            tags = test_tags + ["manual"],
        )

        workspace_root_test(
            name = test_name + "-windows-cross",
            chdir_workspace_root = False,
            env = cargo_env,
            runfile_env = cargo_env_runfiles,
            test_bin = ":" + windows_cross_test_binary,
            workspace_root_marker = "//codex-rs/utils/cargo-bin:repo_root.marker",
            target_compatible_with = WINDOWS_GNULLVM_ONLY,
            tags = test_tags,
            **windows_cross_test_kwargs
        )

def _test_shard_count(test_shard_counts, test_name):
    shard_count = test_shard_counts.get(test_name)
    if shard_count == None:
        return None

    if shard_count < 1:
        fail("test_shard_counts[{}] must be a positive integer".format(test_name))

    return shard_count
