import hashlib
import json
import os
import platform
import re
import shutil
import subprocess
import uuid
from dataclasses import dataclass, field
from pathlib import Path
from subprocess import DEVNULL, STDOUT, CalledProcessError, check_output
from typing import Iterator

import boto3
import pytest
import yaml
from helpers import (
    RattlerBuild,
    check_build_output,
    get_extracted_package,
    get_package,
)


def test_functionality(rattler_build: RattlerBuild):
    suffix = ".exe" if os.name == "nt" else ""
    text = rattler_build("--help").splitlines()
    assert text[0] == f"Usage: rattler-build{suffix} [OPTIONS] [COMMAND]"


def test_completion_stderr_is_clean(rattler_build: RattlerBuild):
    """Shell completions are sourced at shell startup, so stderr must stay
    free of startup banners like the version or config-loading messages."""
    result = rattler_build("completion", "--shell", "nushell", capture_output=True)
    assert result.returncode == 0
    assert "extern" in result.stdout
    assert result.stderr == ""


def test_license_glob(rattler_build: RattlerBuild, recipes: Path, tmp_path: Path):
    rattler_build.build(recipes / "globtest", tmp_path)
    pkg = get_extracted_package(tmp_path, "globtest")
    assert (pkg / "info/licenses/LICENSE").exists()
    # Random files we moved into the package license folder
    assert (pkg / "info/licenses/cmake/FindTBB.cmake").exists()
    assert (pkg / "info/licenses/docs/ghp_environment.yml").exists()
    assert (pkg / "info/licenses/docs/rtd_environment.yml").exists()
    assert (pkg / "info/licenses/tools/check_circular.py").exists()

    # Check that the total number of files under the license folder is correct
    # 5 files + 3 folders = 8
    assert len(list(pkg.glob("info/licenses/**/*"))) == 8


def test_missing_license_file(
    rattler_build: RattlerBuild, recipes: Path, tmp_path: Path
):
    """Test that building fails when a specified license file is missing."""
    try:
        rattler_build.build(recipes / "missing_license_file", tmp_path)
        assert False, "Build should have failed"
    except CalledProcessError:
        # The build correctly failed as expected
        pass


def test_missing_license_glob(
    rattler_build: RattlerBuild, recipes: Path, tmp_path: Path
):
    """Test that building fails when a license glob pattern matches no files."""
    try:
        rattler_build.build(recipes / "missing_license_glob", tmp_path)
        assert False, "Build should have failed"
    except CalledProcessError:
        # The build correctly failed as expected
        pass


def test_spaces_in_paths(rattler_build: RattlerBuild, recipes: Path, tmp_path: Path):
    """Test that building a package with spaces in output paths works correctly."""
    output_dir = tmp_path / "Output Space Dir"
    output_dir.mkdir(exist_ok=True)

    rattler_build.build(
        recipes / "spaces-in-paths" / "recipe.yaml",
        output_dir,
    )
    pkg = get_extracted_package(output_dir, "spaces-in-paths")
    assert (pkg / "test.txt").exists()
    assert (pkg / "dir with spaces").exists()
    assert (pkg / "dir with spaces" / "file.txt").exists()
    assert (
        pkg / "dir with spaces" / "file.txt"
    ).read_text().strip() == "This file is in a directory with spaces"

    # Build the recipe with quoted paths on all platforms
    rattler_build.build(
        recipes / "spaces-in-paths" / "recipe-with-quotes.yaml",
        output_dir,
    )
    pkg_quoted = get_extracted_package(output_dir, "spaces-in-paths-quotes")
    assert (pkg_quoted / "test.txt").exists()

    # Check directories with spaces on all platforms
    assert (pkg_quoted / "dir with spaces").exists()
    assert (pkg_quoted / "dir with spaces" / "file.txt").exists()
    assert (
        pkg_quoted / "dir with spaces" / "file.txt"
    ).read_text().strip() == "This file is in a directory with spaces"


def check_info(folder: Path, expected: Path):
    for f in ["index.json", "about.json", "link.json", "paths.json"]:
        assert (folder / "info" / f).exists()
        cmp = json.loads((expected / f).read_text())

        actual = json.loads((folder / "info" / f).read_text())
        if f == "index.json":
            # We need to remove the timestamp from the index.json
            cmp["timestamp"] = actual["timestamp"]

        if f == "paths.json":
            assert len(actual["paths"]) == len(cmp["paths"])

            for i, p in enumerate(actual["paths"]):
                c = cmp["paths"][i]
                assert c["_path"] == p["_path"]
                assert c["path_type"] == p["path_type"]
                if "dist-info" not in p["_path"]:
                    assert c["sha256"] == p["sha256"]
                    assert c["size_in_bytes"] == p["size_in_bytes"]
                assert c.get("no_link") is None
        else:
            if actual != cmp:
                print(f"Expected {f} to be {cmp} but was {actual}")
                raise AssertionError(f"Expected {f} to be {cmp} but was {actual}")


def test_python_noarch(rattler_build: RattlerBuild, recipes: Path, tmp_path: Path):
    rattler_build.build(recipes / "toml", tmp_path)
    pkg = get_extracted_package(tmp_path, "toml")

    assert (pkg / "info/licenses/LICENSE").exists()
    assert (pkg / "site-packages/toml-0.10.2.dist-info/INSTALLER").exists()
    installer = pkg / "site-packages/toml-0.10.2.dist-info/INSTALLER"
    assert installer.read_text().strip() == "conda"

    check_info(pkg, expected=recipes / "toml" / "expected")

    # load index.json and make sure that `python` is in `depends`
    index_json = json.loads((pkg / "info/index.json").read_text())
    assert "depends" in index_json
    # check that python is in there from `run_exports`
    assert "python" in index_json["depends"]
    # check that the direct python requirement is _also_ there
    assert "python >=3.11" in index_json["depends"]


def test_render_only_with_solve_does_not_install_packages(
    rattler_build: RattlerBuild, recipes: Path, tmp_path: Path
):
    result = rattler_build.render(
        recipes / "toml",
        tmp_path,
        with_solve=True,
        custom_channels=["conda-forge"],
        raw=True,
    )

    assert result.returncode == 0
    combined = (result.stdout or "") + "\n" + (result.stderr or "")

    # Verify we did not install environments (run exports collection is allowed)
    assert "Installing host environment" not in combined
    assert "Installing build environment" not in combined

    outputs = json.loads(result.stdout or "[]")
    assert isinstance(outputs, list) and len(outputs) >= 1
    deps = outputs[0].get("finalized_dependencies", {})
    resolved_len = 0
    host = deps.get("host")
    if isinstance(host, dict):
        resolved_len = len(host.get("resolved", []))
    if resolved_len == 0:
        build = deps.get("build")
        if isinstance(build, dict):
            resolved_len = len(build.get("resolved", []))
    assert resolved_len >= 1


def test_abi3_cross_compile_ignores_python_run_exports(
    rattler_build: RattlerBuild, recipes: Path, tmp_path: Path
):
    """Test that abi3 (version_independent) packages don't inherit python version
    run exports from cross-python in the build environment.

    When cross-compiling, cross-python_<target> in the build env and python in the
    host env both have run exports that pin a specific Python version (e.g.
    "python 3.10.* *_cpython" and "python_abi 3.10.* *_cp310"). For abi3 packages
    these must be ignored since the package is Python-version independent.
    """
    env = {**os.environ, "CONDA_OVERRIDE_GLIBC": "2.38"}

    result = rattler_build.render(
        recipes / "abi3-cross-compile",
        tmp_path,
        with_solve=True,
        custom_channels=["conda-forge"],
        extra_args=[
            "--build-platform",
            "linux-64",
            "--target-platform",
            "linux-ppc64le",
        ],
        raw=True,
        env=env,
    )

    assert result.returncode == 0, f"render failed:\n{result.stderr}"

    outputs = json.loads(result.stdout or "[]")
    assert len(outputs) >= 1, "expected at least one output"

    run_deps = (
        outputs[0].get("finalized_dependencies", {}).get("run", {}).get("depends", [])
    )

    # Collect all run-export entries (they have a "run_export" key in the JSON)
    run_export_deps = [dep for dep in run_deps if "run_export" in dep]

    # cross-python_linux-ppc64le exports a python version pin to run deps; for abi3
    # packages this must be suppressed
    cross_python_exports = [
        dep
        for dep in run_export_deps
        if dep.get("run_export", "").startswith("cross-python")
    ]
    assert not cross_python_exports, (
        f"abi3 package should not inherit python run exports from cross-python, "
        f"got: {cross_python_exports}"
    )

    # python in host exports python_abi; likewise must be suppressed for abi3
    python_abi_exports = [
        dep
        for dep in run_export_deps
        if dep.get("run_export") == "python" and "python_abi" in dep.get("spec", "")
    ]
    assert not python_abi_exports, (
        f"abi3 package should not inherit python_abi run exports, "
        f"got: {python_abi_exports}"
    )

    # The explicit "run: - python" dep must still be present
    run_specs = [dep.get("spec", dep.get("source", "")) for dep in run_deps]
    assert any("python" in s for s in run_specs), (
        f"explicit python run dep should still be present, got: {run_specs}"
    )


def test_render_only_ignores_nonexistent_output_dir(
    rattler_build: RattlerBuild, recipes: Path, tmp_path: Path
):
    """Test that --render-only ignores --output-dir even if it doesn't exist.

    When using --render-only, no output files are produced, so the output
    directory should not be required to be writable.
    """
    # Create a file and try to use a subdirectory of it as output-dir
    # This path cannot be created because the parent is a file, not a directory
    blocking_file = tmp_path / "blocking_file"
    blocking_file.write_text("I am a file, not a directory")
    invalid_output_dir = blocking_file / "subdir"

    result = rattler_build(
        "build",
        "--recipe",
        str(recipes / "toml"),
        "--output-dir",
        str(invalid_output_dir),
        "--render-only",
        need_result_object=True,
        text=True,
        capture_output=True,
    )

    assert result.returncode == 0, f"render-only failed: {result.stderr}"


def test_render_only_does_not_create_output_dir(
    rattler_build: RattlerBuild, recipes: Path, tmp_path: Path
):
    """Test that --render-only does not create the output directory.

    Even when the output directory path is writable, --render-only should
    not create it since no output files are produced.
    """
    output_dir = tmp_path / "should" / "not" / "be" / "created"
    assert not output_dir.exists()

    result = rattler_build(
        "build",
        "--recipe",
        str(recipes / "toml"),
        "--output-dir",
        str(output_dir),
        "--render-only",
        need_result_object=True,
        text=True,
        capture_output=True,
    )

    assert not output_dir.exists(), (
        "output directory should not be created with --render-only"
    )
    assert result.returncode == 0, f"render-only failed: {result.stderr}"


def test_render_only_with_solve_missing_output_dir(
    rattler_build: RattlerBuild, recipes: Path, tmp_path: Path
):
    """Test that --render-only --with-solve works when the output directory
    does not exist yet.

    Solving used to register the output directory as a local channel
    unconditionally, which panicked with "path is a not a valid absolute path"
    when the directory was missing (see issue #2611). It should now solve
    successfully and, consistent with plain --render-only, not create the
    output directory.
    """
    output_dir = tmp_path / "does" / "not" / "exist"
    assert not output_dir.exists()

    result = rattler_build.render(
        recipes / "toml",
        output_dir,
        with_solve=True,
        custom_channels=["conda-forge"],
        raw=True,
    )

    assert result.returncode == 0, f"render-only with solve failed: {result.stderr}"
    assert "is a not a valid absolute path" not in (result.stderr or "")
    assert not output_dir.exists(), (
        "output directory should not be created with --render-only"
    )

    outputs = json.loads(result.stdout or "[]")
    assert isinstance(outputs, list) and len(outputs) >= 1


def test_run_exports(
    rattler_build: RattlerBuild, recipes: Path, tmp_path: Path, snapshot_json
):
    rattler_build.build(recipes / "run_exports", tmp_path)
    pkg = get_extracted_package(tmp_path, "run_exports_test")

    assert (pkg / "info/run_exports.json").exists()
    actual_run_export = json.loads((pkg / "info/run_exports.json").read_text())
    assert set(actual_run_export.keys()) == {"weak"}
    assert len(actual_run_export["weak"]) == 1
    x = actual_run_export["weak"][0]
    assert x.startswith("run_exports_test ==1.0.0 h") and x.endswith("_0")

    assert (pkg / "info/index.json").exists()
    index_json = json.loads((pkg / "info/index.json").read_text())
    assert index_json.get("depends") == []

    rendered = rattler_build.render(
        recipes / "run_exports/multi_run_exports_list.yaml", tmp_path
    )
    assert rendered[0]["recipe"]["requirements"]["run_exports"] == {
        "weak": ["abc", "def"]
    }

    rendered = rattler_build.render(
        recipes / "run_exports/multi_run_exports_dict.yaml", tmp_path
    )
    assert rendered[0]["recipe"]["requirements"]["run_exports"] == snapshot_json


def host_subdir():
    """return conda subdir based on current platform"""
    plat = platform.system()
    if plat == "Linux":
        if platform.machine().endswith("aarch64"):
            return "linux-aarch64"
        return "linux-64"
    elif plat == "Darwin":
        if platform.machine().endswith("arm64"):
            return "osx-arm64"
        return "osx-64"
    elif plat == "Windows":
        return "win-64"
    else:
        raise RuntimeError("Unsupported platform")


def variant_hash(variant):
    hash_length = 7
    m = hashlib.sha1()
    m.update(json.dumps(variant, sort_keys=True).encode())
    return f"h{m.hexdigest()[:hash_length]}"


def test_pkg_hash(rattler_build: RattlerBuild, recipes: Path, tmp_path: Path):
    rattler_build.build(recipes / "pkg_hash", tmp_path, extra_args=["--test=skip"])
    pkg = get_package(tmp_path, "pkg_hash")
    expected_hash = variant_hash({"target_platform": host_subdir()})
    assert pkg.name.endswith(f"pkg_hash-1.0.0-{expected_hash}_my_pkg.tar.bz2")


def test_strict_mode_fail(rattler_build: RattlerBuild, recipes: Path, tmp_path: Path):
    """Test that strict mode fails when unmatched files exist"""
    recipe_dir = recipes / "strict-mode"
    output_dir = tmp_path / "output"
    output_dir.mkdir()

    with pytest.raises(CalledProcessError):
        rattler_build.build(recipe_dir / "recipe-fail.yaml", output_dir)


def test_strict_mode_pass(rattler_build: RattlerBuild, recipes: Path, tmp_path: Path):
    """Test that strict mode passes when all files are matched"""
    recipe_dir = recipes / "strict-mode"
    output_dir = tmp_path / "output"
    output_dir.mkdir()

    rattler_build.build(recipe_dir / "recipe-pass.yaml", output_dir)


def test_strict_mode_many_files(
    rattler_build: RattlerBuild, recipes: Path, tmp_path: Path
):
    """Test that strict mode shows all unmatched files, not just the first few"""
    recipe_dir = recipes / "strict-mode"
    output_dir = tmp_path / "output"
    output_dir.mkdir()

    build_args = rattler_build.build_args(
        recipe_dir / "recipe-many-files.yaml",
        output_dir,
        extra_args=["--log-style=json"],
    )
    result = subprocess.run(
        [str(rattler_build.path), *build_args],
        capture_output=True,
        text=True,
        encoding="utf-8",
        errors="replace",
    )
    assert result.returncode != 0

    logs = []
    stderr = result.stderr if result.stderr else ""
    for line in stderr.splitlines():
        if line.strip() and line.strip().startswith("{"):
            try:
                logs.append(json.loads(line))
            except json.JSONDecodeError:
                continue

    stdout = result.stdout if result.stdout else ""
    error_output = stderr + stdout
    assert "unmatched1.txt" in error_output
    assert "unmatched2.txt" in error_output
    assert "unmatched3.txt" in error_output
    assert "unmatched4.txt" in error_output
    assert "unmatched5.txt" in error_output
    assert "unmatched6.txt" in error_output
    assert "unmatched7.txt" in error_output


@pytest.mark.skipif(
    not os.environ.get("PREFIX_DEV_READ_ONLY_TOKEN", ""),
    reason="requires PREFIX_DEV_READ_ONLY_TOKEN",
)
def test_auth_file(
    rattler_build: RattlerBuild, recipes: Path, tmp_path: Path, monkeypatch
):
    auth_file = tmp_path / "auth.json"
    monkeypatch.setenv("RATTLER_AUTH_FILE", str(auth_file))

    with pytest.raises(CalledProcessError):
        rattler_build.build(
            recipes / "private-repository",
            tmp_path,
            custom_channels=["conda-forge", "https://repo.prefix.dev/setup-pixi-test"],
        )

    auth_file.write_text(
        json.dumps(
            {
                "repo.prefix.dev": {
                    "BearerToken": os.environ["PREFIX_DEV_READ_ONLY_TOKEN"]
                }
            }
        )
    )

    rattler_build.build(
        recipes / "private-repository",
        tmp_path,
        custom_channels=["conda-forge", "https://repo.prefix.dev/setup-pixi-test"],
    )


@dataclass
class S3Config:
    access_key_id: str
    secret_access_key: str
    region: str = "auto"
    endpoint_url: str = (
        "https://e1a7cde76f1780ec06bac859036dbaf7.r2.cloudflarestorage.com"
    )
    bucket_name: str = "rattler-build-upload-test"
    channel_name: str = field(default_factory=lambda: f"channel{uuid.uuid4()}")


@pytest.fixture()
def s3_config() -> S3Config:
    access_key_id = os.environ.get("S3_ACCESS_KEY_ID")
    if not access_key_id:
        pytest.skip("S3_ACCESS_KEY_ID environment variable is not set")
    secret_access_key = os.environ.get("S3_SECRET_ACCESS_KEY")
    if not secret_access_key:
        pytest.skip("S3_SECRET_ACCESS_KEY environment variable is not set")
    return S3Config(
        access_key_id=access_key_id,
        secret_access_key=secret_access_key,
    )


@pytest.fixture()
def s3_client(s3_config: S3Config):
    return boto3.client(
        service_name="s3",
        endpoint_url=s3_config.endpoint_url,
        aws_access_key_id=s3_config.access_key_id,
        aws_secret_access_key=s3_config.secret_access_key,
        region_name=s3_config.region,
    )


@pytest.fixture()
def s3_channel(s3_config: S3Config, s3_client) -> Iterator[str]:
    channel_url = f"s3://{s3_config.bucket_name}/{s3_config.channel_name}"

    yield channel_url

    # Clean up the channel after the test
    objects_to_delete = s3_client.list_objects_v2(
        Bucket=s3_config.bucket_name, Prefix=f"{s3_config.channel_name}/"
    )
    delete_keys = [{"Key": obj["Key"]} for obj in objects_to_delete.get("Contents", [])]
    if delete_keys:
        result = s3_client.delete_objects(
            Bucket=s3_config.bucket_name, Delete={"Objects": delete_keys}
        )
        assert result["ResponseMetadata"]["HTTPStatusCode"] == 200


@pytest.fixture()
def s3_credentials_file(
    tmp_path: Path,
    s3_config: S3Config,
    s3_channel: str,
) -> Path:
    path = tmp_path / "credentials.json"
    path.write_text(
        f"""\
{{
    "{s3_channel}": {{
        "S3Credentials": {{
            "access_key_id": "{s3_config.access_key_id}",
            "secret_access_key": "{s3_config.secret_access_key}"
        }}
    }}
}}"""
    )
    return path


def test_s3_minio_upload(
    rattler_build: RattlerBuild,
    recipes: Path,
    tmp_path: Path,
    s3_credentials_file: Path,
    s3_config: S3Config,
    s3_channel: str,
    s3_client,
    monkeypatch,
):
    monkeypatch.setenv("RATTLER_AUTH_FILE", str(s3_credentials_file))
    rattler_build.build(recipes / "globtest", tmp_path)
    cmd = [
        "upload",
        "-vvv",
        "s3",
        "--channel",
        s3_channel,
        "--region",
        s3_config.region,
        "--endpoint-url",
        s3_config.endpoint_url,
        "--addressing-style",
        "path",
        str(get_package(tmp_path, "globtest")),
    ]
    rattler_build(*cmd)

    # Check if package was correctly uploaded
    package_key = f"{s3_config.channel_name}/{host_subdir()}/globtest-0.24.6-{variant_hash({'target_platform': host_subdir()})}_0.tar.bz2"
    result = s3_client.head_object(
        Bucket=s3_config.bucket_name,
        Key=package_key,
    )
    assert result["ResponseMetadata"]["HTTPStatusCode"] == 200

    # Raise an error if the same package is uploaded again
    with pytest.raises(CalledProcessError):
        rattler_build(*cmd)


@pytest.mark.skipif(
    os.name == "nt", reason="recipe does not support execution on windows"
)
def test_cross_testing(
    rattler_build: RattlerBuild, recipes: Path, tmp_path: Path
) -> None:
    native_platform = host_subdir()
    if native_platform.startswith("linux"):
        target_platform = "osx-64"
    elif native_platform.startswith("osx"):
        target_platform = "linux-64"

    rattler_build.build(
        recipes / "test-execution/recipe-test-succeed.yaml",
        tmp_path,
        extra_args=["--target-platform", target_platform],
    )

    pkg = get_extracted_package(tmp_path, "test-execution")

    assert (pkg / "info/paths.json").exists()
    # make sure that the recipe is renamed to `recipe.yaml` in the package
    assert (pkg / "info/recipe/recipe.yaml").exists()


def test_additional_entrypoints(
    rattler_build: RattlerBuild, recipes: Path, tmp_path: Path
):
    rattler_build.build(
        recipes / "entry_points/additional_entrypoints.yaml",
        tmp_path,
    )

    pkg = get_extracted_package(tmp_path, "additional_entrypoints")

    if os.name == "nt":
        assert (pkg / "Scripts/additional_entrypoints-script.py").exists()
        assert (pkg / "Scripts/additional_entrypoints.exe").exists()
    else:
        assert (pkg / "bin/additional_entrypoints").exists()


def test_always_copy_files(rattler_build: RattlerBuild, recipes: Path, tmp_path: Path):
    rattler_build.build(
        recipes / "always-copy-files/recipe.yaml",
        tmp_path,
    )

    pkg = get_extracted_package(tmp_path, "always_copy_files")

    assert (pkg / "info/paths.json").exists()
    paths = json.loads((pkg / "info/paths.json").read_text())
    assert len(paths["paths"]) == 1
    assert paths["paths"][0]["_path"] == "hello.txt"
    assert paths["paths"][0]["no_link"] is True


def test_always_include_files(
    rattler_build: RattlerBuild, recipes: Path, tmp_path: Path
):
    rattler_build.build(
        recipes / "always-include-files/recipe.yaml",
        tmp_path,
    )

    pkg = get_extracted_package(tmp_path, "force-include-base")

    assert (pkg / "info/paths.json").exists()
    paths = json.loads((pkg / "info/paths.json").read_text())
    assert len(paths["paths"]) == 1
    assert paths["paths"][0]["_path"] == "hello.txt"
    assert paths["paths"][0].get("no_link") is None

    assert "Hello, world!" in (pkg / "hello.txt").read_text()

    pkg_sanity = get_extracted_package(tmp_path, "force-include-sanity-check")
    paths = json.loads((pkg_sanity / "info/paths.json").read_text())
    assert len(paths["paths"]) == 0

    pkg_force = get_extracted_package(tmp_path, "force-include-forced")
    paths = json.loads((pkg_force / "info/paths.json").read_text())
    assert len(paths["paths"]) == 1
    assert paths["paths"][0]["_path"] == "hello.txt"
    assert paths["paths"][0].get("no_link") is None
    assert (pkg_force / "hello.txt").exists()
    assert "Force include new file" in (pkg_force / "hello.txt").read_text()


@pytest.mark.skipif(
    os.name == "nt", reason="recipe does not support execution on windows"
)
def test_package_files_override(
    rattler_build: RattlerBuild, recipes: Path, tmp_path: Path
):
    """The build script writes an explicit list of paths to
    $RATTLER_BUILD_PACKAGE_FILES; only those should end up in the package."""
    rattler_build.build(
        recipes / "package_files_override/recipe.yaml",
        tmp_path,
    )

    pkg = get_extracted_package(tmp_path, "package_files_override")
    paths = json.loads((pkg / "info/paths.json").read_text())
    listed = sorted(p["_path"] for p in paths["paths"])

    assert listed == ["bin/also-keep.sh", "bin/keep.sh"]
    assert (pkg / "bin/keep.sh").exists()
    assert (pkg / "bin/also-keep.sh").exists()
    assert not (pkg / "bin/skip.sh").exists()
    assert not (pkg / "share/doc/skip.txt").exists()


def test_script_env_in_recipe(
    rattler_build: RattlerBuild, recipes: Path, tmp_path: Path
):
    rattler_build.build(
        recipes / "script_env/recipe.yaml",
        tmp_path,
    )
    pkg = get_extracted_package(tmp_path, "script_env")

    assert (pkg / "info/paths.json").exists()
    content = (pkg / "hello.txt").read_text()
    # Windows adds quotes to the string so we just check with `in`
    assert "FOO is Hello World!" in content


def test_crazy_characters(rattler_build: RattlerBuild, recipes: Path, tmp_path: Path):
    rattler_build.build(
        recipes / "crazy_characters/recipe.yaml",
        tmp_path,
    )
    pkg = get_extracted_package(tmp_path, "crazy_characters")
    assert (pkg / "info/paths.json").exists()

    file_1 = pkg / "files" / "File(Glob …).tmSnippet"
    assert file_1.read_text() == file_1.name

    file_2 = (
        pkg / "files" / "a $random_crazy file name with spaces and (parentheses).txt"
    )
    assert file_2.read_text() == file_2.name

    # limit on Windows is 260 chars
    file_3 = pkg / "files" / ("a_really_long_" + ("a" * 200) + ".txt")
    assert file_3.read_text() == file_3.name


def test_variant_config(rattler_build: RattlerBuild, recipes: Path, tmp_path: Path):
    rattler_build.build(
        recipes / "variant_config/recipe.yaml",
        tmp_path,
        variant_config=recipes / "variant_config/variant_config.yaml",
    )
    v1 = get_extracted_package(tmp_path, "bla-0.1.0-h2c65b68_0")
    v2 = get_extracted_package(tmp_path, "bla-0.1.0-h48a45df_0")

    assert (v1 / "info/paths.json").exists()
    assert (v2 / "info/paths.json").exists()

    assert (v1 / "info/hash_input.json").exists()
    assert (v2 / "info/hash_input.json").exists()
    print(v1)
    print(v2)
    print((v1 / "info/hash_input.json").read_text())
    print((v2 / "info/hash_input.json").read_text())

    hash_input = json.loads((v1 / "info/hash_input.json").read_text())
    assert hash_input["some_option"] == "DEF"
    hash_input = json.loads((v2 / "info/hash_input.json").read_text())
    assert hash_input["some_option"] == "ABC"


def test_compile_python(rattler_build: RattlerBuild, recipes: Path, tmp_path: Path):
    rattler_build.build(
        recipes / "python_compilation/recipe.yaml",
        tmp_path,
    )

    pkg = get_extracted_package(tmp_path, "python_compilation")

    assert (pkg / "info/paths.json").exists()
    paths = json.loads((pkg / "info/paths.json").read_text())
    pyc_paths = [p["_path"] for p in paths["paths"] if p["_path"].endswith(".pyc")]
    assert len(pyc_paths) == 3
    assert "just_a_.cpython-311.pyc" in pyc_paths
    assert len([p for p in paths["paths"] if p["_path"].endswith(".py")]) == 4

    # make sure that we include the `info/recipe/recipe.py` file
    py_files = list(pkg.glob("**/*.py"))
    assert len(py_files) == 5


def test_down_prioritize(rattler_build: RattlerBuild, recipes: Path, tmp_path: Path):
    rattler_build.build(
        recipes / "down_prioritize/recipe.yaml",
        tmp_path,
    )

    pkg = get_extracted_package(tmp_path, "down_prioritize")

    assert (pkg / "info/index.json").exists()
    index = json.loads((pkg / "info/index.json").read_text())
    assert isinstance(index["track_features"], str)
    assert (
        index["track_features"]
        == "down_prioritize-p-0 down_prioritize-p-1 down_prioritize-p-2 down_prioritize-p-3"
    )


def test_prefix_detection(rattler_build: RattlerBuild, recipes: Path, tmp_path: Path):
    rattler_build.build(
        recipes / "prefix_detection/recipe.yaml",
        tmp_path,
    )

    pkg = get_extracted_package(tmp_path, "prefix_detection")

    assert (pkg / "info/index.json").exists()
    assert (pkg / "info/paths.json").exists()

    def check_path(p, t):
        if t is None:
            assert "file_mode" not in p
            assert "prefix_placeholder" not in p
        else:
            assert p["file_mode"] == t
            assert len(p["prefix_placeholder"]) > 10

    win = os.name == "nt"

    paths = json.loads((pkg / "info/paths.json").read_text())
    for p in paths["paths"]:
        path = p["_path"]
        if path == "is_binary/file_with_prefix":
            if not win:
                check_path(p, "binary")
            else:
                # On Windows, we do not look into binary files
                # and we also don't do any prefix replacement
                check_path(p, None)
        elif path == "is_text/file_with_prefix":
            check_path(p, "text")
        elif path == "is_binary/file_without_prefix":
            check_path(p, None)
        elif path == "is_text/file_without_prefix":
            check_path(p, None)
        elif path == "force_text/file_with_prefix":
            if not win:
                check_path(p, "text")
            else:
                # On Windows, we do not look into binary files (even if forced to text)
                # and thus we also don't do any prefix replacement
                check_path(p, None)
        elif path == "force_text/file_without_prefix":
            check_path(p, None)
        elif path == "force_binary/file_with_prefix":
            if not win:
                check_path(p, "binary")
            else:
                # On Windows, we do not look into binary files
                # and we also don't do any prefix replacement
                check_path(p, None)

        elif path == "force_binary/file_without_prefix":
            check_path(p, None)
        elif path == "ignore/file_with_prefix":
            check_path(p, None)
        elif path == "ignore/text_with_prefix":
            check_path(p, None)
        elif path == "is_text/file_with_forwardslash_prefix":
            assert "\\" not in p["prefix_placeholder"]
            assert "/" in p["prefix_placeholder"]
            check_path(p, "text")


def test_empty_folder(rattler_build: RattlerBuild, recipes: Path, tmp_path: Path):
    path_to_recipe = recipes / "empty_folder"
    (path_to_recipe / "empty_folder_in_recipe").mkdir(parents=True, exist_ok=True)

    rattler_build.build(
        recipes / "empty_folder/recipe.yaml",
        tmp_path,
    )

    pkg = get_extracted_package(tmp_path, "empty_folder")

    assert (pkg / "info/index.json").exists()
    assert (pkg / "info/recipe/empty_folder_in_recipe").exists()
    assert (pkg / "info/recipe/empty_folder_in_recipe").is_dir()

    assert not (pkg / "empty_folder").exists()
    assert not (pkg / "empty_folder").is_dir()

    # read paths json
    paths = json.loads((pkg / "info/paths.json").read_text())
    assert len(paths["paths"]) == 0


@pytest.mark.skipif(
    os.name == "nt", reason="recipe does not support execution on windows"
)
def test_console_logging(rattler_build: RattlerBuild, recipes: Path, tmp_path: Path):
    path_to_recipe = recipes / "console_logging"
    os.environ["SECRET"] = "hahaha"
    args = rattler_build.build_args(
        path_to_recipe,
        tmp_path,
    )

    output = check_output([str(rattler_build.path), *args], stderr=STDOUT, text=True)
    assert "hahaha" not in output
    assert "I am hahaha" not in output
    assert "I am ********" in output


def test_secret_passthrough(rattler_build: RattlerBuild, recipes: Path, tmp_path: Path):
    """Verify that secrets declared in the recipe are available as env vars
    inside the build script, even in strict isolation mode."""
    os.environ["BUILD_SECRET"] = "s3cret_token_42"
    rattler_build.build(recipes / "secret_passthrough", tmp_path)
    pkg = get_extracted_package(tmp_path, "secret_passthrough")
    value_file = pkg / "secret_check" / "value.txt"
    assert value_file.exists(), "build script did not write secret value file"
    contents = value_file.read_text().strip()
    assert contents == "s3cret_token_42", (
        f"secret was not passed through to build script, got: {contents!r}"
    )


@pytest.mark.skipif(
    os.name == "nt", reason="recipe does not support execution on windows"
)
def test_git_submodule(
    rattler_build: RattlerBuild, recipes: Path, tmp_path: Path, snapshot_json
):
    path_to_recipe = recipes / "git_source_submodule"
    args = rattler_build.build_args(
        path_to_recipe,
        tmp_path,
    )

    _ = check_output([str(rattler_build.path), *args], stderr=STDOUT, text=True)
    pkg = get_extracted_package(tmp_path, "nanobind")

    assert (pkg / "info/paths.json").exists()
    assert (pkg / "info/recipe/rendered_recipe.yaml").exists()
    # load recipe as YAML

    text = (pkg / "info/recipe/rendered_recipe.yaml").read_text()

    # parse the rendered recipe
    rendered_recipe = yaml.safe_load(text)
    assert snapshot_json == rendered_recipe["finalized_sources"]


@pytest.mark.skipif(
    os.name == "nt", reason="recipe does not support execution on windows"
)
def test_git_patch(rattler_build: RattlerBuild, recipes: Path, tmp_path: Path):
    path_to_recipe = recipes / "git_source_patch"
    args = rattler_build.build_args(
        path_to_recipe,
        tmp_path,
    )

    _ = check_output([str(rattler_build.path), *args], stderr=STDOUT, text=True)
    pkg = get_extracted_package(tmp_path, "ament_package")

    assert (pkg / "info/paths.json").exists()
    assert (pkg / "info/recipe/rendered_recipe.yaml").exists()
    # load recipe as YAML

    text = (pkg / "info/recipe/rendered_recipe.yaml").read_text()

    # parse the rendered recipe
    rendered_recipe = yaml.safe_load(text)
    sources = rendered_recipe["finalized_sources"]

    assert len(sources) == 1
    source = sources[0]
    assert source["git"] == "https://github.com/ros2-gbp/ament_package-release.git"
    assert source["rev"] == "00da147b17c19bc225408dc693ed8fdc14c314ab"


@pytest.mark.skipif(
    os.name == "nt", reason="recipe does not support execution on windows"
)
def test_patch_strip_level(rattler_build: RattlerBuild, recipes: Path, tmp_path: Path):
    path_to_recipe = recipes / "patch_with_strip"
    args = rattler_build.build_args(
        path_to_recipe,
        tmp_path,
    )

    _ = check_output([str(rattler_build.path), *args], stderr=STDOUT, text=True)
    pkg = get_extracted_package(tmp_path, "patch_with_strip")

    assert (pkg / "info/paths.json").exists()
    assert (pkg / "info/recipe/rendered_recipe.yaml").exists()

    text = (pkg / "somefile").read_text()

    assert text == "123\n"


@pytest.mark.skipif(
    os.name == "nt", reason="recipe does not support execution on windows"
)
def test_patch_creates_new_files(
    rattler_build: RattlerBuild, recipes: Path, tmp_path: Path
):
    """Test that patches creating multiple new files work correctly.

    This tests the fix for an issue where patches creating new files from /dev/null
    could fail with 'Is a directory' error if the strip level was calculated incorrectly.
    """
    path_to_recipe = recipes / "patch_new_files"
    args = rattler_build.build_args(
        path_to_recipe,
        tmp_path,
    )

    _ = check_output([str(rattler_build.path), *args], stderr=STDOUT, text=True)
    pkg = get_extracted_package(tmp_path, "patch_new_files")

    assert (pkg / "info/paths.json").exists()

    # Check that all files created by the patch exist in the package
    assert (pkg / "existing.txt").exists()
    assert (pkg / "new_file1.txt").exists()
    assert (pkg / "new_file2.txt").exists()
    assert (pkg / "subdir/new_file3.txt").exists()

    # Verify content of the new files
    new_file1_content = (pkg / "new_file1.txt").read_text()
    assert "This is the first new file" in new_file1_content

    new_file3_content = (pkg / "subdir/new_file3.txt").read_text()
    assert "This is the third new file" in new_file3_content


@pytest.mark.skipif(
    os.name == "nt", reason="recipe does not support execution on windows"
)
def test_symlink_recipe(
    rattler_build: RattlerBuild, recipes: Path, tmp_path: Path, snapshot_json
):
    path_to_recipe = recipes / "symlink"
    args = rattler_build.build_args(
        path_to_recipe,
        tmp_path,
    )

    rattler_build(*args)

    pkg = get_extracted_package(tmp_path, "symlink")
    assert snapshot_json == json.loads((pkg / "info/paths.json").read_text())


@pytest.mark.skipif(
    os.name == "nt", reason="recipe does not support execution on windows"
)
def test_read_only_removal(rattler_build: RattlerBuild, recipes: Path, tmp_path: Path):
    path_to_recipe = recipes / "read_only_build_files"
    args = rattler_build.build_args(
        path_to_recipe,
        tmp_path,
    )

    rattler_build(*args)
    pkg = get_extracted_package(tmp_path, "read-only-build-files")

    assert (pkg / "info/index.json").exists()


def test_noarch_variants(rattler_build: RattlerBuild, recipes: Path, tmp_path: Path):
    path_to_recipe = recipes / "noarch_variant"
    args = rattler_build.build_args(
        path_to_recipe,
        tmp_path,
    )

    output = rattler_build(
        *args, "--target-platform=linux-64", "--render-only", stderr=DEVNULL
    )

    # parse as json
    rendered = json.loads(output)
    assert len(rendered) == 2

    assert rendered[0]["recipe"]["requirements"]["run"] == ["__unix"]
    assert rendered[0]["recipe"]["build"]["string"] == "unix_5600cae_0"

    assert rendered[0]["build_configuration"]["variant"] == {
        "__unix": "__unix",
        "target_platform": "noarch",
    }

    pin = {
        "pin_subpackage": {
            "name": "rattler-build-demo",
            "exact": True,
        }
    }
    assert rendered[1]["recipe"]["build"]["string"] == "unix_63d9094_0"
    assert rendered[1]["recipe"]["build"]["noarch"] == "generic"
    assert rendered[1]["recipe"]["requirements"]["run"] == [pin]
    assert rendered[1]["build_configuration"]["variant"] == {
        "rattler_build_demo": "1 unix_5600cae_0",
        "target_platform": "noarch",
    }

    output = rattler_build(
        *args, "--target-platform=win-64", "--render-only", stderr=DEVNULL
    )
    rendered = json.loads(output)
    assert len(rendered) == 2

    assert rendered[0]["recipe"]["requirements"]["run"] == ["__win >=11.0.123 foobar"]
    assert rendered[0]["recipe"]["build"]["string"] == "win_19aa286_0"

    assert rendered[0]["build_configuration"]["variant"] == {
        "__win": "__win >=11.0.123 foobar",
        "target_platform": "noarch",
    }

    pin = {
        "pin_subpackage": {
            "name": "rattler-build-demo",
            "exact": True,
        }
    }
    assert rendered[1]["recipe"]["build"]["string"] == "win_95d38b2_0"
    assert rendered[1]["recipe"]["build"]["noarch"] == "generic"
    assert rendered[1]["recipe"]["requirements"]["run"] == [pin]
    assert rendered[1]["build_configuration"]["variant"] == {
        "rattler_build_demo": "1 win_19aa286_0",
        "target_platform": "noarch",
    }


def test_platform_selectors_use_host_platform(
    rattler_build: RattlerBuild, recipes: Path, tmp_path: Path
):
    """Test that platform selectors (unix, win, etc.) are based on host_platform, not target_platform.

    When --host-platform and --target-platform differ, the selectors should
    reflect the host platform (where the package will run).
    """
    path_to_recipe = recipes / "noarch_variant"
    args = rattler_build.build_args(path_to_recipe, tmp_path)

    # Set host_platform=linux-64 but target_platform=win-64
    # The `unix` selector should be true (based on host), not `win` (based on target)
    output = rattler_build(
        *args,
        "--host-platform=linux-64",
        "--target-platform=win-64",
        "--render-only",
        stderr=DEVNULL,
    )

    rendered = json.loads(output)
    assert len(rendered) == 2

    # unix should be true because host_platform is linux-64
    assert rendered[0]["recipe"]["requirements"]["run"] == ["__unix"]
    assert "unix" in rendered[0]["recipe"]["build"]["string"]

    # Now flip: host_platform=win-64, target_platform=linux-64
    output = rattler_build(
        *args,
        "--host-platform=win-64",
        "--target-platform=linux-64",
        "--render-only",
        stderr=DEVNULL,
    )

    rendered = json.loads(output)
    assert len(rendered) == 2

    # win should be true because host_platform is win-64
    assert rendered[0]["recipe"]["requirements"]["run"] == ["__win >=11.0.123 foobar"]
    assert "win" in rendered[0]["recipe"]["build"]["string"]


def test_regex_post_process(rattler_build: RattlerBuild, recipes: Path, tmp_path: Path):
    path_to_recipe = recipes / "regex_post_process"
    args = rattler_build.build_args(
        path_to_recipe,
        tmp_path,
    )

    _ = rattler_build(*args)

    pkg = get_extracted_package(tmp_path, "regex-post-process")

    assert (pkg / "info/paths.json").exists()

    test_text = (pkg / "test.txt").read_text().splitlines()
    assert test_text[0] == "Building the regex-post-process-replaced package"
    assert test_text[1] == "Do not replace /some/path/to/sysroot/and/more this"

    text_pc = (pkg / "test.pc").read_text().splitlines()
    expect_begin = "I am a test file with $(CONDA_BUILD_SYSROOT_S)and/some/more"
    expect_end = "and: $(CONDA_BUILD_SYSROOT_S)and/some/more"
    assert text_pc[0] == expect_begin
    assert text_pc[2] == expect_end

    text_cmake = (pkg / "test.cmake").read_text()
    assert text_cmake.startswith(
        'target_compile_definitions(test PRIVATE "some_path;$ENV{CONDA_BUILD_SYSROOT}/and/more;some_other_path;$ENV{CONDA_BUILD_SYSROOT}/and/more")'  # noqa: E501
    )


@pytest.mark.skipif(
    os.name == "nt", reason="recipe does not support execution on windows"
)
def test_filter_files(
    rattler_build: RattlerBuild, recipes: Path, tmp_path: Path, snapshot_json
):
    path_to_recipe = recipes / "filter_files"
    args = rattler_build.build_args(
        path_to_recipe,
        tmp_path,
    )

    rattler_build(*args)
    pkg = get_extracted_package(tmp_path, "filter_files")

    assert snapshot_json == json.loads((pkg / "info/paths.json").read_text())


def test_double_license(rattler_build: RattlerBuild, recipes: Path, tmp_path: Path):
    path_to_recipe = recipes / "double_license"
    args = rattler_build.build_args(path_to_recipe, tmp_path)
    output = rattler_build(*args, stderr=STDOUT)
    assert "warning License file from source directory was overwritten" in output


@pytest.mark.skipif(
    os.name == "nt", reason="recipe does not support execution on windows"
)
def test_post_link(
    rattler_build: RattlerBuild, recipes: Path, tmp_path: Path, snapshot_json
):
    path_to_recipe = recipes / "post-link"
    args = rattler_build.build_args(
        path_to_recipe,
        tmp_path,
    )
    rattler_build(*args)

    pkg = get_extracted_package(tmp_path, "postlink")
    assert snapshot_json == json.loads((pkg / "info/paths.json").read_text())


@pytest.mark.skipif(
    os.name == "nt", reason="recipe does not support execution on windows"
)
def test_post_link_does_not_leak_into_downstream(
    rattler_build: RattlerBuild, recipes: Path, tmp_path: Path
):
    rattler_build.build(recipes / "post-link-leaker", tmp_path)
    rattler_build.build(
        recipes / "post-link-downstream",
        tmp_path,
        custom_channels=[f"file://{tmp_path}"],
    )
    pkg = get_extracted_package(tmp_path, "post-link-downstream")
    paths = json.loads((pkg / "info/paths.json").read_text())
    packaged_paths = [p["_path"] for p in paths["paths"]]

    assert any("post-link-downstream" in p for p in packaged_paths)
    leaked = [p for p in packaged_paths if "leaked-cache" in p]
    assert leaked == [], f"Post-link files leaked into downstream package: {leaked}"


@pytest.mark.skipif(
    os.name == "nt", reason="recipe does not support execution on windows"
)
def test_build_files(
    rattler_build: RattlerBuild, recipes: Path, tmp_path: Path, snapshot_json
):
    path_to_recipe = recipes / "build_files"
    args = rattler_build.build_args(
        path_to_recipe,
        tmp_path,
    )
    rattler_build(*args)

    pkg = get_extracted_package(tmp_path, "build_files")
    assert snapshot_json == json.loads((pkg / "info/paths.json").read_text())


@pytest.mark.skipif(
    os.name == "nt", reason="recipe does not support execution on windows"
)
def test_source_filter(rattler_build: RattlerBuild, recipes: Path, tmp_path: Path):
    path_to_recipe = recipes / "source_filter"
    args = rattler_build.build_args(
        path_to_recipe,
        tmp_path,
    )
    rattler_build(*args)


def test_source_filter_archive(
    rattler_build: RattlerBuild, recipes: Path, tmp_path: Path
):
    # The filter is applied to the extracted contents of an archive source.
    path_to_recipe = recipes / "source_filter_archive"
    args = rattler_build.build_args(
        path_to_recipe,
        tmp_path,
    )
    rattler_build(*args)


def test_nushell_script_detection(
    rattler_build: RattlerBuild, recipes: Path, tmp_path: Path
):
    rattler_build.build(
        recipes / "nushell-script-detection/recipe.yaml",
        tmp_path,
    )
    pkg = get_extracted_package(tmp_path, "nushell")

    assert (pkg / "info/paths.json").exists()
    content = (pkg / "hello.txt").read_text()
    assert "Hello, world!" == content


def test_brush(rattler_build: RattlerBuild, recipes: Path, tmp_path: Path):
    # Builds a recipe with `interpreter: brush`. `brush` is pulled from the
    # build environment (requirements/build), the bash wrapper activates the
    # environment, and brush then runs the bash-syntax script.
    rattler_build.build(
        recipes / "brush-test/recipe.yaml",
        tmp_path,
    )
    pkg = get_extracted_package(tmp_path, "brush-test")

    assert (pkg / "info/paths.json").exists()
    content = (pkg / "hello.txt").read_text()
    assert "Hello from brush!" == content.strip()


def test_channel_specific(rattler_build: RattlerBuild, recipes: Path, tmp_path: Path):
    rattler_build.build(
        recipes / "channel_specific/recipe.yaml",
        tmp_path,
        extra_args="-c conda-forge -c quantstack".split(),
    )
    pkg = get_extracted_package(tmp_path, "channel_specific")

    assert (pkg / "info/recipe/rendered_recipe.yaml").exists()
    # load yaml
    text = (pkg / "info/recipe/rendered_recipe.yaml").read_text()
    rendered_recipe = yaml.safe_load(text)
    print(text)
    deps = rendered_recipe["finalized_dependencies"]["host"]["resolved"]

    for d in deps:
        if d["name"] == "sphinx":
            assert d["channel"] == "https://conda.anaconda.org/quantstack/"


def test_run_exports_from(
    rattler_build: RattlerBuild, recipes: Path, tmp_path: Path, snapshot_json
):
    rattler_build.build(
        recipes / "run_exports_from",
        tmp_path,
    )
    pkg = get_extracted_package(tmp_path, "run_exports_test")

    assert (pkg / "info/run_exports.json").exists()

    actual_run_export = json.loads((pkg / "info/run_exports.json").read_text())
    assert set(actual_run_export.keys()) == {"weak"}
    assert len(actual_run_export["weak"]) == 1
    x = actual_run_export["weak"][0]
    assert x.startswith("run_exports_test ==1.0.0 h") and x.endswith("_0")

    index_json = json.loads((pkg / "info/index.json").read_text())
    assert index_json.get("depends") == []


def test_script_execution(rattler_build: RattlerBuild, recipes: Path, tmp_path: Path):
    rattler_build.build(
        recipes / "script",
        tmp_path,
    )
    pkg = get_extracted_package(tmp_path, "script-test")

    # grab paths.json
    paths = json.loads((pkg / "info/paths.json").read_text())
    assert len(paths["paths"]) == 1
    assert paths["paths"][0]["_path"] == "script-executed.txt"

    rattler_build.build(
        recipes / "script/recipe_with_extensions.yaml",
        tmp_path,
    )
    pkg = get_extracted_package(tmp_path, "script-test-ext")

    # grab paths.json
    paths = json.loads((pkg / "info/paths.json").read_text())
    assert len(paths["paths"]) == 1
    assert paths["paths"][0]["_path"] == "script-executed.txt"


def test_noarch_flask(
    rattler_build: RattlerBuild, recipes: Path, tmp_path: Path, snapshot
):
    rattler_build.build(
        recipes / "flask",
        tmp_path,
    )
    pkg = get_extracted_package(tmp_path, "flask")

    # this is to ensure that the clone happens correctly
    license_file = pkg / "info/licenses/LICENSE.rst"
    assert license_file.exists()

    assert (pkg / "info/tests/tests.yaml").exists()

    # The serialized test script stores the command list verbatim (the same on
    # all platforms); per-command error handling is applied when the test runs.
    test_yaml = (pkg / "info/tests/tests.yaml").read_text()
    assert test_yaml == snapshot

    # make sure that the entry point does not exist
    assert not (pkg / "python-scripts/flask").exists()

    assert (pkg / "info/link.json").exists()


def test_downstream_test(
    rattler_build: RattlerBuild, recipes: Path, tmp_path: Path, snapshot_json
):
    # The downstream noarch package's own Python test matrix includes a version
    # incompatible with the arch-specific upstream artifact. Build both outputs
    # first, then verify the downstream test selects the compatible version.
    rattler_build.build(
        recipes / "downstream_test/succeed.yaml",
        tmp_path,
        extra_args=["--no-test"],
    )

    pkg = next(tmp_path.rglob("**/upstream-good-*"))
    test_result = rattler_build.test(pkg, "-c", str(tmp_path), "-c", "conda-forge")

    assert "Running downstream test for package: downstream-good" in test_result
    assert "Downstream test could not run" not in test_result
    assert "Running test in downstream package" in test_result

    rattler_build.build(
        recipes / "downstream_test/fail_prelim.yaml",
        tmp_path,
    )

    with pytest.raises(CalledProcessError) as e:
        rattler_build.build(
            recipes / "downstream_test/fail.yaml",
            tmp_path,
        )

        assert "│ Failing test in downstream package" in e.value.output
        assert "│ Downstream test failed" in e.value.output


@pytest.mark.skip(reason="Cache not implemented yet")
def test_cache_runexports(
    rattler_build: RattlerBuild, recipes: Path, tmp_path: Path, snapshot_json
):
    rattler_build.build(recipes / "cache_run_exports/helper.yaml", tmp_path)
    rattler_build.build(
        recipes / "cache_run_exports/recipe_test_1.yaml",
        tmp_path,
        extra_args=["--experimental"],
    )

    pkg = get_extracted_package(tmp_path, "cache-run-exports")

    assert (pkg / "info/index.json").exists()
    index = json.loads((pkg / "info/index.json").read_text())
    assert index["depends"] == ["normal-run-exports"]

    pkg = get_extracted_package(tmp_path, "no-cache-by-name-run-exports")
    assert (pkg / "info/index.json").exists()
    index = json.loads((pkg / "info/index.json").read_text())
    assert index["name"] == "no-cache-by-name-run-exports"
    assert index.get("depends", []) == []

    pkg = get_extracted_package(tmp_path, "no-cache-from-package-run-exports")
    assert (pkg / "info/index.json").exists()
    index = json.loads((pkg / "info/index.json").read_text())
    assert index["name"] == "no-cache-from-package-run-exports"
    print(index)
    assert index.get("depends", []) == []

    rattler_build.build(
        recipes / "cache_run_exports/recipe_test_2.yaml",
        tmp_path,
        extra_args=["--experimental"],
    )
    pkg = get_extracted_package(tmp_path, "cache-ignore-run-exports")
    index = json.loads((pkg / "info/index.json").read_text())
    assert index["name"] == "cache-ignore-run-exports"
    assert index.get("depends", []) == []

    rattler_build.build(
        recipes / "cache_run_exports/recipe_test_3.yaml",
        tmp_path,
        extra_args=["--experimental"],
    )
    pkg = get_extracted_package(tmp_path, "cache-ignore-run-exports-by-name")
    index = json.loads((pkg / "info/index.json").read_text())
    assert index["name"] == "cache-ignore-run-exports-by-name"
    assert index.get("depends", []) == []


def test_extra_meta_is_recorded_into_about_json(
    rattler_build: RattlerBuild, recipes: Path, tmp_path: Path, snapshot_json
):
    rattler_build.build(
        recipes / "toml",
        tmp_path,
        extra_meta={"flow_run_id": "some_id", "sha": "24ee3"},
    )

    pkg = get_extracted_package(tmp_path, "toml")

    about_json = json.loads((pkg / "info/about.json").read_text())

    assert snapshot_json == about_json


def test_used_vars(rattler_build: RattlerBuild, recipes: Path, tmp_path: Path):
    args = rattler_build.build_args(
        recipes / "used-vars/recipe_1.yaml",
        tmp_path,
    )

    output = rattler_build(
        *args, "--target-platform=linux-64", "--render-only", stderr=DEVNULL
    )

    rendered = json.loads(output)
    assert len(rendered) == 1
    assert rendered[0]["build_configuration"]["variant"] == {
        "target_platform": "noarch"
    }


@pytest.mark.skip(reason="Cache not implemented yet")
def test_cache_install(
    rattler_build: RattlerBuild, recipes: Path, tmp_path: Path, snapshot_json
):
    rattler_build.build(
        recipes / "cache/recipe-cmake.yaml", tmp_path, extra_args=["--experimental"]
    )

    pkg1 = get_extracted_package(tmp_path, "check-1")
    pkg2 = get_extracted_package(tmp_path, "check-2")
    assert (pkg1 / "info/index.json").exists()
    assert (pkg2 / "info/index.json").exists()


@pytest.mark.skip(reason="Need to support jinja templates script")
def test_env_vars_override(rattler_build: RattlerBuild, recipes: Path, tmp_path: Path):
    rattler_build.build(
        recipes / "env_vars",
        tmp_path,
    )

    pkg = get_extracted_package(tmp_path, "env_var_test")

    # assert (pkg / "info/paths.json").exists()
    text = (pkg / "makeflags.txt").read_text()
    assert text.strip() == "OVERRIDDEN_MAKEFLAGS"

    variant_config = json.loads((pkg / "info/hash_input.json").read_text())
    assert variant_config["MAKEFLAGS"] == "OVERRIDDEN_MAKEFLAGS"

    text = (pkg / "pybind_abi.txt").read_text()
    assert text.strip() == "4"
    assert variant_config["pybind11_abi"] == 4

    # Check that we used the variant in the rendered recipe
    rendered_recipe = yaml.safe_load(
        (pkg / "info/recipe/rendered_recipe.yaml").read_text()
    )
    assert rendered_recipe["finalized_dependencies"]["build"]["specs"][0] == {
        "variant": "pybind11-abi",
        "spec": "pybind11-abi 4.*",
    }


def test_pin_subpackage(
    rattler_build: RattlerBuild, recipes: Path, tmp_path: Path, snapshot_json
):
    rattler_build.build(
        recipes / "pin_subpackage",
        tmp_path,
    )
    pkg = get_extracted_package(tmp_path, "my.package-a")
    assert (pkg / "info/index.json").exists()


def test_transitive_test_dependencies(
    rattler_build: RattlerBuild, recipes: Path, tmp_path: Path
):
    rattler_build.build(recipes / "transitive-test-dependencies", tmp_path)
    assert get_package(tmp_path, "transitive-test-root").exists()


def test_testing_strategy(
    rattler_build: RattlerBuild,
    recipes: Path,
    tmp_path: Path,
    capfd,
):
    # --test=skip
    check_build_output(
        rattler_build,
        capfd,
        recipe_path=recipes / "test_strategy" / "recipe.yaml",
        output_path=tmp_path,
        extra_args=["--test=skip"],
        string_to_check="Skipping tests because the argument --test=skip was set",
    )

    # --test=native
    check_build_output(
        rattler_build,
        capfd,
        recipe_path=recipes / "test_strategy" / "recipe.yaml",
        output_path=tmp_path,
        extra_args=["--test=native"],
        string_to_check="all tests passed!",
    )

    # --test=native and cross-compiling
    check_build_output(
        rattler_build,
        capfd,
        recipe_path=recipes / "test_strategy" / "recipe.yaml",
        output_path=tmp_path,
        extra_args=[
            "--test=native",
            "--target-platform=linux-64",
            "--build-platform=osx-64",
        ],
        string_to_check="Skipping tests because the argument "
        "--test=native was set and the build is a cross-compilation",
    )

    # --test=native-and-emulated
    check_build_output(
        rattler_build,
        capfd,
        recipe_path=recipes / "test_strategy" / "recipe.yaml",
        output_path=tmp_path,
        extra_args=["--test=native-and-emulated"],
        string_to_check="all tests passed!",
    )

    #  --test=native-and-emulated and cross-compiling
    check_build_output(
        rattler_build,
        capfd,
        recipe_path=recipes / "test_strategy" / "recipe.yaml",
        output_path=tmp_path,
        extra_args=[
            "--test=native-and-emulated",
            "--target-platform=linux-64",
            "--build-platform=osx-64",
        ],
        string_to_check="all tests passed!",
    )

    # --test=native and cross-compiling and noarch
    check_build_output(
        rattler_build,
        capfd,
        recipe_path=recipes / "test_strategy" / "recipe-noarch.yaml",
        output_path=tmp_path,
        extra_args=[
            "--test=native",
            "--target-platform=linux-64",
            "--build-platform=osx-64",
        ],
        string_to_check="all tests passed!",
    )


def test_pin_compatible(
    rattler_build: RattlerBuild, recipes: Path, tmp_path: Path, snapshot_json
):
    rendered = rattler_build.render(recipes / "pin_compatible", tmp_path)

    assert snapshot_json == rendered[0]["recipe"]["requirements"]


def test_render_variants(rattler_build: RattlerBuild, recipes: Path, tmp_path: Path):
    rendered = rattler_build.render(
        recipes / "race-condition/recipe-undefined-variant.yaml", tmp_path
    )
    assert [rx["recipe"]["package"]["name"] for rx in rendered] == [
        "my-package-a",
        "my-package-b",
    ]


def test_race_condition(rattler_build: RattlerBuild, recipes: Path, tmp_path: Path):
    # make sure that tests are ran in the right order and that the packages are built correctly
    rattler_build.build(recipes / "race-condition", tmp_path)


def test_variant_sorting(rattler_build: RattlerBuild, recipes: Path, tmp_path: Path):
    # make sure that tests are ran in the right order and that the packages are built correctly
    rendered = rattler_build.render(
        recipes / "race-condition" / "recipe-pin-subpackage.yaml", tmp_path
    )
    assert [rx["recipe"]["package"]["name"] for rx in rendered] == ["test1", "test2"]


def test_missing_pin_subpackage(
    rattler_build: RattlerBuild, recipes: Path, tmp_path: Path
):
    # make sure that tests are ran in the right order and that the packages are built correctly
    with pytest.raises(CalledProcessError) as e:
        rattler_build.render(
            recipes / "race-condition" / "recipe-pin-invalid.yaml",
            tmp_path,
            stderr=STDOUT,
        )
    stdout = e.value.output
    assert "missing output: test1" in stdout


def test_cycle_detection(rattler_build: RattlerBuild, recipes: Path, tmp_path: Path):
    # make sure that tests are ran in the right order and that the packages are built correctly
    with pytest.raises(CalledProcessError) as e:
        rattler_build.render(
            recipes / "race-condition" / "recipe-cycle.yaml",
            tmp_path,
            stderr=STDOUT,
        )
    stdout = e.value.output
    assert "Cycle detected in recipe outputs: bazbus, foobar" in stdout


def test_non_exact_run_constraint_no_cycle(
    rattler_build: RattlerBuild, recipes: Path, tmp_path: Path
):
    # Issue #2531: a non-exact run_constraints pin must not edge the graph.
    rendered = rattler_build.render(
        recipes / "race-condition" / "recipe-constraint-no-cycle.yaml",
        tmp_path,
    )
    # libopenblas first via openblas's exact run pin.
    assert [rx["recipe"]["package"]["name"] for rx in rendered] == [
        "libopenblas",
        "openblas",
    ]


def test_sibling_run_dep_ordering(
    rattler_build: RattlerBuild, recipes: Path, tmp_path: Path
):
    """Test that outputs with plain run deps on sibling outputs are built in the right order.

    repro-wrapper has a run dep on repro-runtime but is listed first in the recipe.
    The build should succeed because repro-runtime must be built before repro-wrapper's
    test environment is solved.
    """
    rattler_build.build(
        recipes / "race-condition" / "recipe-sibling-run-dep.yaml", tmp_path
    )


def test_python_min_render(
    rattler_build: RattlerBuild, recipes: Path, tmp_path: Path, snapshot_json
):
    rendered = rattler_build.render(
        recipes / "race-condition" / "recipe-python-min.yaml", tmp_path
    )

    assert snapshot_json == rendered[0]["recipe"]["requirements"]


def test_recipe_variant_render(
    rattler_build: RattlerBuild, recipes: Path, tmp_path: Path, snapshot_json
):
    rendered = rattler_build.render(
        recipes / "recipe_variant" / "recipe.yaml", tmp_path, "--with-solve"
    )

    assert snapshot_json == [output["recipe"]["requirements"] for output in rendered]
    assert snapshot_json == [
        (
            output["finalized_dependencies"]["build"]["specs"],
            output["finalized_dependencies"]["run"],
        )
        for output in rendered
    ]


@pytest.mark.skip(reason="Cache not implemented yet")
@pytest.mark.skipif(
    os.name == "nt", reason="recipe does not support execution on windows"
)
def test_cache_select_files(rattler_build: RattlerBuild, recipes: Path, tmp_path: Path):
    rattler_build.build(
        recipes / "cache/recipe-compiler.yaml", tmp_path, extra_args=["--experimental"]
    )
    pkg = get_extracted_package(tmp_path, "testlib-so-version")

    assert (pkg / "info/paths.json").exists()
    paths = json.loads((pkg / "info/paths.json").read_text())

    assert len(paths["paths"]) == 2
    assert paths["paths"][0]["_path"] == "lib/libdav1d.so.7"
    assert paths["paths"][0]["path_type"] == "softlink"
    assert paths["paths"][1]["_path"] == "lib/libdav1d.so.7.0.0"
    assert paths["paths"][1]["path_type"] == "hardlink"


@pytest.mark.skipif(
    os.name == "nt", reason="recipe does not support execution on windows"
)
def test_abi3(rattler_build: RattlerBuild, recipes: Path, tmp_path: Path):
    rattler_build.build(recipes / "abi3", tmp_path)
    pkg = get_extracted_package(tmp_path, "python-abi3-package-sample")

    assert (pkg / "info/paths.json").exists()
    paths = json.loads((pkg / "info/paths.json").read_text())
    # ensure that all paths start with `site-packages`
    for p in paths["paths"]:
        assert p["_path"].startswith("site-packages")

    actual_paths = [p["_path"] for p in paths["paths"]]
    if os.name == "nt":
        assert "site-packages\\spam.dll" in actual_paths
    else:
        assert "site-packages/spam.abi3.so" in actual_paths

    # load index.json
    index = json.loads((pkg / "info/index.json").read_text())
    assert index["name"] == "python-abi3-package-sample"
    assert index["noarch"] == "python"
    assert index["subdir"] == host_subdir()
    assert index["platform"] == host_subdir().split("-")[0]

    # CEP-20: abi3 packages should NOT have python_abi in their run
    # dependencies (python's run exports should be ignored), but SHOULD have
    # cpython and _python_abi3_support from python-abi3's run exports.
    dep_names = [d.split(" ")[0] for d in index.get("depends", [])]
    assert "python_abi" not in dep_names, (
        "python_abi should not be in abi3 package dependencies"
    )
    assert "cpython" in dep_names, (
        "cpython (from python-abi3 run_exports) should be in abi3 package dependencies"
    )
    assert "_python_abi3_support" in dep_names, (
        "_python_abi3_support (from python-abi3 run_exports) should be in abi3 package dependencies"
    )


@pytest.mark.skipif(
    os.name == "nt" or platform.system() == "Darwin",
    reason="Filesystem case-insensitivity prevents testing collision warning trigger",
)
def test_case_insensitive_collision_warning(
    rattler_build: RattlerBuild, tmp_path: Path
):
    recipe_content = """
context:
  name: test-case-collision
  version: 0.1.0

package:
  name: test-case-collision
  version: 0.1.0

build:
  script:
    # Create directories with case difference
    - mkdir -p case_test
    - echo "UPPER CASE FILE" > case_test/CASE-FILE.txt
    - echo "lower case file" > case_test/case-file.txt
    # Install the directory into the prefix to trigger packaging
    - cp -r case_test $PREFIX/
    # Add another test file to ensure packaging works
    - echo "test content" > regular-file.txt
    - cp regular-file.txt $PREFIX/

about:
  summary: A test package for case-insensitive file collisions
"""
    recipe_path = tmp_path / "recipe.yaml"
    recipe_path.write_text(recipe_content)

    args = rattler_build.build_args(
        recipe_path,
        tmp_path / "output",
        extra_args=["-vvv"],
    )

    output = rattler_build(*args, stderr=STDOUT, text=True)
    pkg = get_extracted_package(tmp_path / "output", "test-case-collision")
    extracted_files_list = [str(f.relative_to(pkg)) for f in pkg.glob("**/*")]

    assert "case_test/CASE-FILE.txt" in extracted_files_list, (
        "CASE-FILE.txt not found in package"
    )
    assert "case_test/case-file.txt" in extracted_files_list, (
        "case-file.txt not found in package"
    )
    assert "regular-file.txt" in extracted_files_list, (
        "regular-file.txt not found in package"
    )

    collision_warning_pattern = (
        r"Mixed-case filenames detected, case-insensitive filesystems may break:"
        r"\n  - case_test/CASE-FILE.txt"
        r"\n  - case_test/case-file.txt"
    )

    assert re.search(collision_warning_pattern, output, flags=re.IGNORECASE), (
        f"Case collision warning not found in build output. Output contains:\n{output}"
    )


# This is how cf-scripts is using rattler-build - rendering recipes from stdin
def test_rendering_from_stdin(
    rattler_build: RattlerBuild, recipes: Path, tmp_path: Path
):
    text = (recipes / "abi3" / "recipe.yaml").read_text()
    # variants = recipes / "abi3" / "variants.yaml" "-m", variants (without '--recipe' it will pick up the recipe from root folder)
    rendered = rattler_build(
        "build", "--recipe", "-", "--render-only", input=text, text=True
    )
    loaded = json.loads(rendered)

    assert loaded[0]["recipe"]["package"]["name"] == "python-abi3-package-sample"


def test_jinja_types(
    rattler_build: RattlerBuild, recipes: Path, tmp_path: Path, snapshot_json
):
    # render only and snapshot json
    rendered = rattler_build.render(
        recipes / "jinja-types", tmp_path, extra_args=["--experimental"]
    )
    print(rendered)
    # load as json
    assert snapshot_json == rendered[0]["recipe"]["context"]
    variant = rendered[0]["build_configuration"]["variant"]
    # remove target_platform from the variant
    variant.pop("target_platform")
    assert snapshot_json == variant


@pytest.mark.skipif(platform.system() != "Darwin", reason="macos-only")
def test_relink_rpath(rattler_build: RattlerBuild, recipes: Path, tmp_path: Path):
    rattler_build.build(recipes / "test-relink", tmp_path)


def test_ignore_run_exports(
    rattler_build: RattlerBuild, recipes: Path, tmp_path: Path, clean_path_on_win32
):
    rattler_build.build(
        recipes / "test-parsing/recipe_ignore_run_exports.yaml",
        tmp_path,
    )
    pkg = get_extracted_package(tmp_path, "mypkg")

    assert (pkg / "info/recipe/rendered_recipe.yaml").exists()
    # load yaml
    text = (pkg / "info/recipe/rendered_recipe.yaml").read_text()
    rendered_recipe = yaml.safe_load(text)

    current_subdir = host_subdir()
    if current_subdir.startswith("linux"):
        expected_compiler = f"gxx_{current_subdir}"
    elif current_subdir.startswith("osx"):
        expected_compiler = f"clangxx_{current_subdir}"
    elif current_subdir.startswith("win"):
        expected_compiler = f"vs2022_{current_subdir}"
    else:
        pytest.fail(f"Unsupported platform for compiler check: {current_subdir}")

    # verify ignore_run_exports is rendered correctly using the multiple-os expectation
    assert "requirements" in rendered_recipe["recipe"]
    assert "ignore_run_exports" in rendered_recipe["recipe"]["requirements"]
    assert (
        "from_package"
        in rendered_recipe["recipe"]["requirements"]["ignore_run_exports"]
    )
    assert rendered_recipe["recipe"]["requirements"]["ignore_run_exports"][
        "from_package"
    ] == [expected_compiler]


def test_python_version_spec(
    rattler_build: RattlerBuild, recipes: Path, tmp_path: Path
):
    with pytest.raises(CalledProcessError) as exc_info:
        args = rattler_build.build_args(recipes / "python-version-spec", tmp_path)
        rattler_build(*args, stderr=STDOUT)

    error_output = exc_info.value.output
    # Check that the error mentions the invalid version spec
    assert "=.*" in error_output and (
        "MatchSpecParsing" in error_output or "parse version spec" in error_output
    )


def test_hatch_vcs_versions(rattler_build: RattlerBuild, recipes: Path, tmp_path: Path):
    rattler_build.build(
        recipes / "hatch_vcs/recipe.yaml",
        tmp_path,
    )

    pkg = get_extracted_package(tmp_path, "hatch-vcs-example")

    assert (pkg / "info/index.json").exists()
    index = json.loads((pkg / "info/index.json").read_text())
    assert index["version"] == "0.1.0.dev12+ga47bad07"


@pytest.mark.skipif(os.name != "nt", reason="Test requires Windows PowerShell behavior")
def test_line_breaks(rattler_build: RattlerBuild, recipes: Path, tmp_path: Path):
    path_to_recipe = recipes / "line-breaks"
    args = rattler_build.build_args(
        path_to_recipe,
        tmp_path,
    )

    output = check_output(
        [str(rattler_build.path), *args], stderr=STDOUT, text=True, encoding="utf-8"
    )
    output_lines = output.splitlines()
    found_lines = {i: False for i in range(1, 11)}
    for line in output_lines:
        for i in range(1, 11):
            if f"line {i}" in line:
                found_lines[i] = True

    for i in range(1, 11):
        assert found_lines[i], f"Expected to find 'line {i}' in the output"

    assert any("done" in line for line in output_lines)


def test_r_interpreter(
    rattler_build: RattlerBuild, recipes: Path, tmp_path: Path, snapshot
):
    rattler_build.build(recipes / "r-test", tmp_path)
    pkg = get_extracted_package(tmp_path, "r-test")

    assert (pkg / "r-test-output.txt").exists()

    output_content = (pkg / "r-test-output.txt").read_text()
    assert (
        "This file was created by the R interpreter in rattler-build" in output_content
    )
    assert "R version:" in output_content
    assert "PREFIX:" in output_content
    assert (pkg / "info/recipe/recipe.yaml").exists()
    assert (pkg / "info/tests/tests.yaml").exists()

    # Verify tests.yaml content (was test_r_tests in test_tests.py)
    tests_content = (pkg / "info/tests/tests.yaml").read_text()
    assert snapshot == tests_content

    # Verify index.json exists before running test
    assert (pkg / "info/index.json").exists(), "index.json file missing from package"

    pkg_file = get_package(tmp_path, "r-test")
    test_result = rattler_build.test(pkg_file)
    assert "Running R test" in test_result
    assert "all tests passed!" in test_result


def test_rendering_of_tests_yaml(
    rattler_build: RattlerBuild, recipes: Path, tmp_path: Path
):
    rattler_build.build(recipes / "test-rendering", tmp_path, extra_args=["--no-test"])
    pkg = get_extracted_package(tmp_path, "test-rendering")

    assert (pkg / "info/recipe/rendered_recipe.yaml").exists()
    assert (pkg / "info/tests/tests.yaml").exists()

    # expected file is under recipes/test-rendering/tests.yaml, make sure it's identical
    expected_tests_yaml = (recipes / "test-rendering" / "tests.yaml").read_text()
    actual_tests_yaml = (pkg / "info/tests/tests.yaml").read_text()
    assert expected_tests_yaml == actual_tests_yaml


def test_channel_sources(
    rattler_build: RattlerBuild, recipes: Path, tmp_path: Path, monkeypatch
):
    with pytest.raises(CalledProcessError):
        # channel_sources and channels cannot both be set at the same time
        rattler_build.build(
            recipes / "channel_sources",
            tmp_path,
            custom_channels=["conda-forge"],
        )

    output = rattler_build.build(
        recipes / "channel_sources",
        tmp_path,
        extra_args=["--render-only"],
    )

    output_json = json.loads(output)
    assert output_json[0]["build_configuration"]["channels"] == [
        "https://conda.anaconda.org/conda-forge/label/rust_dev",
        "https://conda.anaconda.org/conda-forge",
    ]


def test_channel_sources_with_default_channels_config(
    rattler_build: RattlerBuild, recipes: Path, tmp_path: Path
):
    # default-channels from the configuration file is a fallback, so
    # channel_sources takes precedence instead of raising a conflict error
    config = tmp_path / "config.toml"
    config.write_text('default-channels = ["https://prefix.dev/conda-forge"]\n')

    output = rattler_build.build(
        recipes / "channel_sources",
        tmp_path,
        extra_args=["--render-only", "--config-file", str(config)],
    )

    output_json = json.loads(output)
    assert output_json[0]["build_configuration"]["channels"] == [
        "https://conda.anaconda.org/conda-forge/label/rust_dev",
        "https://conda.anaconda.org/conda-forge",
    ]


def test_relative_file_loading(
    rattler_build: RattlerBuild, recipes: Path, tmp_path: Path
):
    # build the package with experimental flag to enable the feature
    rattler_build.build(
        recipes / "relative_file_loading",
        tmp_path,
        extra_args=["--experimental"],
    )

    pkg = get_extracted_package(tmp_path, "relative-file-loading")
    assert (pkg / "info/index.json").exists()
    index_json = json.loads((pkg / "info/index.json").read_text())
    assert index_json["name"] == "relative-file-loading"
    assert index_json["version"] == "1.0.0"

    assert (pkg / "info/about.json").exists()
    about_json = json.loads((pkg / "info/about.json").read_text())
    assert "Loaded from relative file" in about_json["description"]
    assert (pkg / "info/recipe/data/package_data.yaml").exists()
    recipe_data = yaml.safe_load(
        (pkg / "info/recipe/data/package_data.yaml").read_text()
    )
    assert recipe_data["name"] == "test-relative-loading"
    assert recipe_data["version"] == "1.0.0"
    assert recipe_data["description"] == "Loaded from relative file"

    # Check the rendered recipe
    assert (pkg / "info/recipe/rendered_recipe.yaml").exists()
    rendered_recipe = yaml.safe_load(
        (pkg / "info/recipe/rendered_recipe.yaml").read_text()
    )
    print("\nRendered recipe structure:")
    print(yaml.dump(rendered_recipe, default_flow_style=False))

    assert "recipe" in rendered_recipe
    assert "context" in rendered_recipe["recipe"]

    context = rendered_recipe["recipe"]["context"]
    assert "loaded_data" in context
    assert "loaded_name" in context
    assert "loaded_version" in context
    assert "loaded_description" in context
    assert context["loaded_name"] == "test-relative-loading"
    assert context["loaded_version"] == "1.0.0"
    assert context["loaded_description"] == "Loaded from relative file"
    assert "about" in rendered_recipe["recipe"]
    assert "description" in rendered_recipe["recipe"]["about"]
    assert (
        rendered_recipe["recipe"]["about"]["description"] == "Loaded from relative file"
    )


@pytest.mark.parametrize(
    "interpreter",
    [
        pytest.param(
            "bash",
            marks=pytest.mark.skipif(os.name == "nt", reason="bash only on unix"),
        ),
        pytest.param(
            "bat",
            marks=pytest.mark.skipif(os.name != "nt", reason="bat only on windows"),
        ),
        "py",
        "pl",
        "nu",
        "r",
        "powershell",
    ],
)
def test_interpreter_detection(
    rattler_build: RattlerBuild, recipes: Path, tmp_path: Path, interpreter: str
):
    """
    Tests that rattler-build automatically detects the required interpreter
    for build and test scripts based on their file extension, without explicit
    interpreter specification in the recipe.
    """
    recipe_dir = recipes / "interpreter-detection" / interpreter
    pkg_name = f"test-interpreter-{interpreter}"

    try:
        rattler_build.build(recipe_dir, tmp_path)
    except CalledProcessError as e:
        print(f"Build failed for interpreter: {interpreter}")
        print(f"STDOUT:\n{e.stdout.decode() if e.stdout else ''}")
        print(f"STDERR:\n{e.stderr.decode() if e.stderr else ''}")
        raise

    pkg_file = get_package(tmp_path, pkg_name)
    assert pkg_file.exists()

    test_output = rattler_build.test(pkg_file)

    if interpreter == "bat":
        expected_output = "Hello from Cmd!"
    elif interpreter == "py":
        expected_output = "Hello from Python!"
    elif interpreter == "pl":
        expected_output = "Hello from Perl!"
    elif interpreter == "nu":
        expected_output = "Hello from Nushell!"
    elif interpreter == "r":
        expected_output = "Hello from R!"
    elif interpreter == "powershell":
        expected_output = "Hello from PowerShell!"
    else:
        expected_output = f"Hello from {interpreter.upper()}!"

    assert expected_output in test_output
    assert "all tests passed!" in test_output


def test_relative_git_path_py(
    rattler_build: RattlerBuild, recipes: Path, tmp_path: Path
):
    """
    Tests building a recipe with a relative Git source path.
    """
    repo_dir = tmp_path / "repo"
    recipe_dir = tmp_path / "recipe_dir" / "subdir"
    recipe_dir.mkdir(parents=True, exist_ok=True)
    repo_dir.mkdir(parents=True, exist_ok=True)

    try:
        subprocess.run(
            ["git", "init", "--initial-branch=main"],
            cwd=repo_dir,
            check=True,
            capture_output=True,
            text=True,
        )
        subprocess.run(
            ["git", "config", "user.name", "Test User"],
            cwd=repo_dir,
            check=True,
            capture_output=True,
            text=True,
        )
        subprocess.run(
            ["git", "config", "user.email", "test@example.com"],
            cwd=repo_dir,
            check=True,
            capture_output=True,
            text=True,
        )
    except FileNotFoundError:
        pytest.skip("Git executable not found, skipping test")
    except subprocess.CalledProcessError as e:
        pytest.fail(f"Git command failed: {e.stderr}")

    readme_path = repo_dir / "README.md"
    readme_path.write_text("test content")
    try:
        subprocess.run(
            ["git", "add", "README.md"],
            cwd=repo_dir,
            check=True,
            capture_output=True,
            text=True,
        )
        subprocess.run(
            ["git", "commit", "-m", "Initial commit"],
            cwd=repo_dir,
            check=True,
            capture_output=True,
            text=True,
        )
        # get the original commit hash
        result = subprocess.run(
            ["git", "rev-parse", "HEAD"],
            cwd=repo_dir,
            check=True,
            capture_output=True,
            text=True,
        )
        original_commit = result.stdout.strip()
    except subprocess.CalledProcessError as e:
        pytest.fail(f"Git command failed: {e.stderr}")

    # We are gonna create the recipe file here, because we are gonna use git with commit history too.
    recipe_path = recipe_dir / "recipe.yaml"
    recipe_content = """
package:
  name: test-relative-git
  version: 1.0.0
source:
  git: ../../repo
build:
  script:
    - if: unix
      then:
        - cp README.md $PREFIX/README_from_build.md
      else:
        - copy README.md %PREFIX%\\README_from_build.md
"""
    recipe_path.write_text(recipe_content)

    build_output_path = tmp_path / "build_output"
    rattler_build.build(recipe_path, build_output_path)

    pkg = get_extracted_package(build_output_path, "test-relative-git")

    cloned_readme = pkg / "README_from_build.md"
    assert cloned_readme.exists(), (
        "README_from_build.md should exist in the built package"
    )
    assert cloned_readme.read_text() == "test content", "Cloned README content mismatch"

    rendered_recipe_path = pkg / "info/recipe/rendered_recipe.yaml"
    assert rendered_recipe_path.exists(), (
        "rendered_recipe.yaml not found in package info"
    )
    rendered_recipe = yaml.safe_load(rendered_recipe_path.read_text())

    assert "finalized_sources" in rendered_recipe, (
        "'finalized_sources' missing in rendered recipe"
    )
    assert len(rendered_recipe["finalized_sources"]) == 1, (
        "Expected exactly one finalized source"
    )
    final_source = rendered_recipe["finalized_sources"][0]
    assert "rev" in final_source, "'rev' missing in finalized source"
    resolved_commit = final_source["rev"]
    assert resolved_commit == original_commit, (
        f"Resolved commit hash mismatch: expected {original_commit}, got {resolved_commit}"
    )


@pytest.mark.skipif(
    os.name == "nt", reason="Test requires Unix-like environment for shell commands"
)
def test_merge_build_and_host(
    rattler_build: RattlerBuild, recipes: Path, tmp_path: Path
):
    # simply run the recipe "merge_build_and_host/recipe.yaml"
    rattler_build.build(
        recipes / "merge_build_and_host/recipe.yaml",
        tmp_path,
    )


@pytest.mark.skipif(os.name == "nt", reason="Not applicable on Windows")
def test_error_on_binary_prefix(
    rattler_build: RattlerBuild, recipes: Path, tmp_path: Path
):
    """Test that --error-prefix-in-binary flag correctly detects prefix in binaries"""
    recipe_path = recipes / "binary_prefix_test"
    args = rattler_build.build_args(recipe_path, tmp_path)
    rattler_build(*args)

    shutil.rmtree(tmp_path)
    tmp_path.mkdir()
    args = rattler_build.build_args(recipe_path, tmp_path)
    args = list(args) + ["--error-prefix-in-binary"]

    if os.name == "nt":
        # On Windows, we don't deal with binary prefixes in the same way,
        # so this test is not applicable
        rattler_build(*args, stderr=STDOUT)
        return

    try:
        rattler_build(*args, stderr=STDOUT)
        pytest.fail("Expected build to fail with binary prefix error")
    except CalledProcessError as e:
        output = e.output
        assert "Binary file" in output and "contains host prefix" in output


@pytest.mark.skipif(
    platform.system() != "Linux", reason="Symlink test only runs on Linux"
)
def test_symlinks(rattler_build: RattlerBuild, recipes: Path, tmp_path: Path):
    """Test that symlinks work correctly on Linux"""
    recipe_path = recipes / "symlink_test"
    args = rattler_build.build_args(recipe_path, tmp_path)

    rattler_build(*args)
    pkg = get_extracted_package(tmp_path, "symlink-test")

    # Verify the symlinks exist and are correct
    assert (pkg / "bin/symlink_script").exists()
    assert (pkg / "bin/another_symlink").exists()
    assert (pkg / "bin/real_script").exists()

    # Verify they are actually symlinks
    assert (pkg / "bin/symlink_script").is_symlink()
    assert (pkg / "bin/another_symlink").is_symlink()

    # Verify they point to the right target
    assert os.readlink(pkg / "bin/symlink_script") == "real_script"
    assert os.readlink(pkg / "bin/another_symlink") == "real_script"


def test_secret_leaking(rattler_build: RattlerBuild, recipes: Path, tmp_path: Path):
    # build the package with experimental flag to enable the feature
    rattler_build.build(
        recipes / "empty_folder",
        tmp_path,
        extra_args=[
            "-c",
            "https://iamasecretusername:123412341234@foobar.com/some-channel",
            "-c",
            "https://bizbar.com/t/token1234567/channel-name",
        ],
    )
    pkg = get_extracted_package(tmp_path, "empty_folder")
    # scan all files to make sure that the secret is not present
    for file in pkg.rglob("**/*"):
        if file.is_file():
            print("Checking file:", file)
            content = file.read_text()
            assert "iamasecretusername" not in content, f"Secret found in {file}"
            assert "123412341234" not in content, f"Secret found in {file}"

            assert "token1234567" not in content, f"Token found in {file}"


def test_extracted_timestamps(
    rattler_build: RattlerBuild, recipes: Path, tmp_path: Path
):
    # simply run the recipe "merge_build_and_host/recipe.yaml"
    rattler_build.build(
        recipes / "timestamps/recipe.yaml",
        tmp_path,
    )


def test_url_source_ignore_files(rattler_build: RattlerBuild, tmp_path: Path):
    """Test that .ignore files don't affect URL sources."""
    recipe_path = Path("test-data/recipes/url-source-with-ignore/recipe.yaml")

    # This should succeed since we don't respect .ignore files anymore
    rattler_build.build(
        recipe_path,
        tmp_path,
    )

    pkg = get_extracted_package(tmp_path, "test-url-source-ignore")
    assert (pkg / "info/index.json").exists()
    index_json = json.loads((pkg / "info/index.json").read_text())
    assert index_json["name"] == "test-url-source-ignore"
    assert index_json["version"] == "1.0.0"


def test_condapackageignore(rattler_build: RattlerBuild, recipes: Path, tmp_path: Path):
    """Test that .condapackageignore files are respected during source copying."""
    test_dir = tmp_path / "rattlerbuildignore-src"
    test_dir.mkdir()
    shutil.copy(
        recipes / "rattlerbuildignore" / "recipe.yaml", test_dir / "recipe.yaml"
    )

    # Create .condapackageignore
    (test_dir / ".condapackageignore").write_text("ignored.txt\n*.pyc\n")

    # Create test files
    (test_dir / "included.txt").write_text("This should be included")
    (test_dir / "ignored.txt").write_text("This should be ignored")
    (test_dir / "test.pyc").write_text("This should also be ignored")

    output_dir = tmp_path / "output"
    rattler_build.build(test_dir, output_dir)

    pkg = get_extracted_package(output_dir, "test-rattlerbuildignore")
    files_dir = pkg / "files"

    assert (files_dir / "included.txt").exists()
    assert (files_dir / "recipe.yaml").exists()
    assert not (files_dir / "ignored.txt").exists()
    assert not (files_dir / "test.pyc").exists()


@pytest.mark.skipif(os.name != "nt", reason="Test requires Windows for symlink testing")
def test_windows_symlinks(rattler_build: RattlerBuild, recipes: Path, tmp_path: Path):
    """Test that Windows symlinks are created correctly during package building"""
    rattler_build.build(
        recipes / "win-symlink-test",
        tmp_path,
        extra_args=["--allow-symlinks-on-windows"],
    )
    pkg = get_extracted_package(tmp_path, "win-symlink-test")

    # Debug: Print all files in the package
    print("\nFiles in package:")
    for f in pkg.rglob("*"):
        print(f"  {f.relative_to(pkg)}")

    # Verify the target file and executable exist
    assert (pkg / "lib" / "target.txt").exists()
    assert (pkg / "bin" / "real_exe.bat").exists()

    # Check if the symlink file exists in the package directory listing
    bin_dir = pkg / "bin"
    assert any(f.name == "symlink_to_target.txt" for f in bin_dir.iterdir())


def test_caseinsensitive(rattler_build: RattlerBuild, recipes: Path, tmp_path: Path):
    """Test that case-insensitive file systems handle files correctly."""
    # Build the package with a recipe that has mixed-case filenames
    rattler_build.build(
        recipes / "case-insensitive/recipe.yaml",
        tmp_path,
    )

    pkg = get_extracted_package(tmp_path, "c2")

    # check if the current filesystem is case-insensitive by creating a temporary file with a mixed case name
    test_file = tmp_path / "MixedCaseFile.txt"
    mixed_case_file = tmp_path / "mixedcasefile.txt"

    # create the mixed-case files
    test_file.write_text("This is a test.")
    case_insensitive = mixed_case_file.exists()

    paths_json = (pkg / "info/paths.json").read_text()
    paths = json.loads(paths_json)
    paths = [p["_path"] for p in paths["paths"]]

    if case_insensitive:
        # we don't package `cmake/test_file.txt` again, because our dependency already contains `CMake/test_file.txt`
        assert len(paths) == 1
        assert "TEST.txt" in paths or "test.txt" in paths
    else:
        assert len(paths) == 3
        assert "cmake/test_file.txt" in paths
        assert "TEST.txt" in paths
        assert "test.txt" in paths


def test_ruby(rattler_build: RattlerBuild, recipes: Path, tmp_path: Path):
    """Test Ruby recipes (ruby-test, ruby-extension-test, ruby-imports-test).

    Builds all three in the same tmp_path to share the conda package cache.
    """
    rattler_build.build(recipes / "ruby-test/recipe.yaml", tmp_path)
    pkg = get_extracted_package(tmp_path, "ruby-test")
    assert (pkg / "info/index.json").exists()
    assert (pkg / "info/tests/tests.yaml").exists()

    rattler_build.build(recipes / "ruby-extension-test/recipe.yaml", tmp_path)
    pkg_ext = get_extracted_package(tmp_path, "ruby-extension-test")
    assert (pkg_ext / "info/index.json").exists()

    rattler_build.build(recipes / "ruby-imports-test/recipe.yaml", tmp_path)
    pkg_imp = get_extracted_package(tmp_path, "ruby-imports-test")
    assert (pkg_imp / "info/index.json").exists()


def test_nodejs(rattler_build: RattlerBuild, recipes: Path, tmp_path: Path):
    """Test NodeJS recipes (simple-nodejs-test, nodejs-extension-test, nodejs-test).

    Builds all three in the same tmp_path to share the conda package cache.
    """
    rattler_build.build(recipes / "simple-nodejs-test/recipe.yaml", tmp_path)
    pkg_simple = get_extracted_package(tmp_path, "simple-nodejs-test")
    assert (pkg_simple / "info/index.json").exists()

    rattler_build.build(recipes / "nodejs-extension-test/recipe.yaml", tmp_path)
    pkg_ext = get_extracted_package(tmp_path, "nodejs-extension-test")
    assert (pkg_ext / "info/index.json").exists()

    rattler_build.build(recipes / "nodejs-test/recipe.yaml", tmp_path)
    pkg = get_extracted_package(tmp_path, "nodejs-test")
    assert (pkg / "info/index.json").exists()


def test_simple_powershell_test(
    rattler_build: RattlerBuild, recipes: Path, tmp_path: Path
):
    rattler_build.build(
        recipes / "simple-powershell-test/recipe.yaml",
        tmp_path,
    )
    pkg = get_extracted_package(tmp_path, "simple-powershell-test")

    assert (pkg / "info/index.json").exists()


def test_powershell(rattler_build: RattlerBuild, recipes: Path, tmp_path: Path):
    rattler_build.build(
        recipes / "powershell-test/recipe.yaml",
        tmp_path,
    )
    pkg = get_extracted_package(tmp_path, "powershell-test")

    assert (pkg / "info/index.json").exists()


@pytest.mark.skipif(
    platform.system() != "Windows",
    reason="powershell default test only relevant on Windows",
)
def test_powershell_default(rattler_build: RattlerBuild, recipes: Path, tmp_path: Path):
    rattler_build.build(
        recipes / "powershell-default/recipe.yaml",
        tmp_path,
    )
    pkg = get_extracted_package(tmp_path, "powershell-default")

    assert (pkg / "info/index.json").exists()


@pytest.mark.skipif(
    platform.system() != "Windows", reason="prefer bat test only relevant on Windows"
)
def test_prefer_bat(rattler_build: RattlerBuild, recipes: Path, tmp_path: Path):
    rattler_build.build(
        recipes / "prefer-bat/recipe.yaml",
        tmp_path,
    )
    pkg = get_extracted_package(tmp_path, "prefer-bat")

    assert (pkg / "info/index.json").exists()


@pytest.mark.skipif(
    platform.system() != "Windows", reason="PE header test only relevant on Windows"
)
def test_pe_header_signature_error(
    rattler_build: RattlerBuild, recipes: Path, tmp_path: Path
):
    """Malformed PE in Library/bin should be skipped by relinker; build succeeds."""
    recipe = recipes / "pe-malformed-windows/recipe.yaml"
    rattler_build.build(recipe, tmp_path)
    pkg = get_extracted_package(tmp_path, "pe-test")
    assert (pkg / "info/index.json").exists()


@pytest.mark.skipif(os.name == "nt", reason="Test uses Unix-style paths and commands")
def test_corrupted_git_cache(
    rattler_build: RattlerBuild, recipes: Path, tmp_path: Path
):
    """
    Test that corrupted git cache directories are detected and re-cloned.

    This test verifies the fix that checks if a git cache directory is valid
    using 'git rev-parse --git-dir' and removes/re-clones if corrupted.
    """
    # Create a git repository
    repo_dir = tmp_path / "test_repo"
    repo_dir.mkdir()

    try:
        subprocess.run(
            ["git", "init", "--initial-branch=main"],
            cwd=repo_dir,
            check=True,
            capture_output=True,
            text=True,
        )
        subprocess.run(
            ["git", "config", "user.name", "Test User"],
            cwd=repo_dir,
            check=True,
            capture_output=True,
            text=True,
        )
        subprocess.run(
            ["git", "config", "user.email", "test@example.com"],
            cwd=repo_dir,
            check=True,
            capture_output=True,
            text=True,
        )
    except FileNotFoundError:
        pytest.skip("Git executable not found, skipping test")
    except subprocess.CalledProcessError as e:
        pytest.fail(f"Git init failed: {e.stderr}")

    # Create a test file and commit it
    test_file = repo_dir / "test.txt"
    test_file.write_text("Hello from git repo!")

    try:
        subprocess.run(
            ["git", "add", "test.txt"],
            cwd=repo_dir,
            check=True,
            capture_output=True,
            text=True,
        )
        subprocess.run(
            ["git", "commit", "-m", "Initial commit"],
            cwd=repo_dir,
            check=True,
            capture_output=True,
            text=True,
        )
    except subprocess.CalledProcessError as e:
        pytest.fail(f"Git commit failed: {e.stderr}")

    # Create a recipe that uses this git repository
    recipe_dir = tmp_path / "recipe"
    recipe_dir.mkdir()
    recipe_path = recipe_dir / "recipe.yaml"
    recipe_content = f"""
package:
  name: test-corrupted-git-cache
  version: 1.0.0

source:
  git: {repo_dir}

build:
  script:
    - cp test.txt $PREFIX/test.txt

about:
  summary: Test package for corrupted git cache detection
"""
    recipe_path.write_text(recipe_content)

    # Build the package once - this will populate the git cache
    build_output_dir = tmp_path / "build_output"
    build_output_dir.mkdir()

    # First build - populates the cache (using default cache directory)
    rattler_build.build(
        recipe_path,
        build_output_dir,
    )

    # Verify first build succeeded
    pkg1 = get_extracted_package(build_output_dir, "test-corrupted-git-cache")
    assert (pkg1 / "test.txt").exists()
    assert (pkg1 / "test.txt").read_text() == "Hello from git repo!"

    # Find the git cache directory in the source cache
    # The refactor stores git repos at src_cache/git/db/<hash>/
    src_cache = build_output_dir / "src_cache"
    git_cache_dir = src_cache / "git" / "db"

    assert git_cache_dir.exists(), (
        f"Git cache directory should exist after first build: {git_cache_dir}"
    )

    # Find the actual cached repo directory (hash-based directory name)
    cached_repos = [d for d in git_cache_dir.iterdir() if d.is_dir()]
    assert len(cached_repos) > 0, (
        f"Should have at least one cached git repo in {git_cache_dir}"
    )
    cached_repo = cached_repos[0]

    # Corrupt the cache by removing the .git directory
    git_dir = cached_repo / ".git"
    assert git_dir.exists(), f"Expected .git directory at {git_dir}"
    shutil.rmtree(git_dir)

    # Verify the cache is now corrupted (git commands should fail)
    # Set GIT_CEILING_DIRECTORIES so git doesn't walk up into the project repo
    result = subprocess.run(
        ["git", "rev-parse", "--git-dir"],
        cwd=cached_repo,
        capture_output=True,
        env={**os.environ, "GIT_CEILING_DIRECTORIES": str(cached_repo.parent)},
    )
    assert result.returncode != 0, "Git command should fail on corrupted cache"

    # Clean the build output directory for second build
    shutil.rmtree(build_output_dir)
    build_output_dir.mkdir()

    # Second build - should detect corruption, remove cache, and re-clone
    # The build should succeed despite the corrupted cache
    args = rattler_build.build_args(
        recipe_path,
        build_output_dir,
    )
    output2 = check_output(
        [str(rattler_build.path), *args],
        stderr=STDOUT,
        text=True,
        encoding="utf-8",
    )

    # Verify the warning message about corrupted cache appears in output
    assert (
        "Detected corrupted git cache" in output2 or "corrupted" in output2.lower()
    ), "Warning about corrupted git cache should appear in output"

    # Verify second build succeeded
    pkg2 = get_extracted_package(build_output_dir, "test-corrupted-git-cache")
    assert (pkg2 / "test.txt").exists()
    assert (pkg2 / "test.txt").read_text() == "Hello from git repo!"

    # Verify the cache was re-created and is now valid
    # The cache directory should still exist (may have been re-cloned)
    cached_repos_after = [d for d in git_cache_dir.iterdir() if d.is_dir()]
    assert len(cached_repos_after) > 0, (
        "Should have at least one cached git repo after re-clone"
    )

    # Verify the cache is now valid
    for repo in cached_repos_after:
        result = subprocess.run(
            ["git", "rev-parse", "--git-dir"],
            cwd=repo,
            capture_output=True,
        )
        if result.returncode == 0:
            # Found a valid repo, test passed
            break
    else:
        pytest.fail("No valid git repository found in cache after re-clone")


def test_topological_sort_with_variants(
    rattler_build: RattlerBuild, recipes: Path, tmp_path: Path
):
    """Test that topological sort correctly orders packages with multiple variants.

    This test verifies that when multiple packages have variants (e.g., different
    Python versions), the topological sort correctly orders ALL variants of
    dependencies before ALL variants of packages that depend on them.

    The test uses:
    - pkg-a: no dependencies, 2 Python variants
    - pkg-b: depends on pkg-a, 2 Python variants
    - pkg-c: depends on pkg-b, 2 Python variants

    Expected order: all pkg-a variants, then all pkg-b variants, then all pkg-c variants.
    """
    recipe_dir = recipes / "topological-sort-variants"

    # Use render-only to get the sorted output order without actually building
    args = ["build", "--recipe-dir", str(recipe_dir), "--render-only"]
    result = subprocess.run(
        [str(rattler_build.path), *args],
        capture_output=True,
        text=True,
        encoding="utf-8",
    )

    # The JSON is on stdout, debug messages are on stderr
    assert result.returncode == 0, f"Build failed: {result.stderr}"
    rendered = json.loads(result.stdout)

    # Extract package names in order
    package_names = [r["recipe"]["package"]["name"] for r in rendered]

    # Find first occurrence of each package
    first_a = next((i for i, name in enumerate(package_names) if name == "pkg-a"), -1)
    first_b = next((i for i, name in enumerate(package_names) if name == "pkg-b"), -1)
    first_c = next((i for i, name in enumerate(package_names) if name == "pkg-c"), -1)

    # Find last occurrence of each package
    last_a = (
        len(package_names)
        - 1
        - next(
            (i for i, name in enumerate(reversed(package_names)) if name == "pkg-a"), -1
        )
    )
    last_b = (
        len(package_names)
        - 1
        - next(
            (i for i, name in enumerate(reversed(package_names)) if name == "pkg-b"), -1
        )
    )

    # Verify correct ordering:
    # All pkg-a variants should come before any pkg-b variant
    assert last_a < first_b, (
        f"All pkg-a variants ({first_a}-{last_a}) should come before pkg-b ({first_b})"
    )

    # All pkg-b variants should come before any pkg-c variant
    assert last_b < first_c, (
        f"All pkg-b variants ({first_b}-{last_b}) should come before pkg-c ({first_c})"
    )

    # Verify we have the expected number of packages (2 variants each = 6 total)
    assert len(package_names) == 6, (
        f"Expected 6 packages (2 variants each), got {len(package_names)}"
    )


@pytest.mark.skipif(
    not (
        (platform.system() == "Darwin" and platform.machine() == "arm64")
        or (platform.system() == "Linux" and platform.machine() == "x86_64")
    ),
    reason="Only runs on macOS arm64 or Linux x86_64",
)
def test_v0_legacy_tests(rattler_build: RattlerBuild, tmp_path: Path):
    """Test that legacy v0 packages with run_test.sh and run_test.py execute correctly.

    This is a regression test for a bug where CopyDir was called with a file
    path instead of the test directory, causing 'File already exists' errors.
    """
    import urllib.request

    if platform.system() == "Darwin":
        url = "https://conda.anaconda.org/conda-forge/osx-arm64/zstandard-0.23.0-py39he7485ab_3.conda"
        filename = "zstandard-0.23.0-py39he7485ab_3.conda"
    else:
        url = "https://conda.anaconda.org/conda-forge/linux-64/zstandard-0.23.0-py311hbc35293_1.conda"
        filename = "zstandard-0.23.0-py311hbc35293_1.conda"

    package_path = tmp_path / filename
    urllib.request.urlretrieve(url, package_path)

    rattler_build.test(str(package_path))


def test_git_lfs_local_source(rattler_build: RattlerBuild, tmp_path: Path):
    """
    Tests that git sources with LFS-tracked files work correctly for local repos.

    This exercises the fix for LFS on Windows where:
    1. git clone --local / git reset --hard must skip the LFS smudge filter
       (the bare database doesn't have LFS objects)
    2. git lfs fetch needs the original source path (not the database) and
       must use a plain path instead of file:// URLs
    """
    # Check git-lfs is installed
    try:
        subprocess.run(
            ["git", "lfs", "version"],
            check=True,
            capture_output=True,
            text=True,
        )
    except (FileNotFoundError, subprocess.CalledProcessError):
        pytest.skip("git-lfs not installed, skipping test")

    repo_dir = tmp_path / "lfs_repo"
    recipe_dir = tmp_path / "recipe"
    repo_dir.mkdir()
    recipe_dir.mkdir()

    def git(*args, cwd=repo_dir):
        subprocess.run(
            ["git", *args],
            cwd=cwd,
            check=True,
            capture_output=True,
            text=True,
        )

    # Set up a git repo with LFS tracking
    git("init", "--initial-branch=main")
    git("config", "user.name", "Test User")
    git("config", "user.email", "test@example.com")
    git("lfs", "install", "--local")
    git("lfs", "track", "*.bin")
    git("add", ".gitattributes")
    git("commit", "-m", "Add LFS tracking")

    # Create an LFS-tracked file and a regular file
    lfs_content = b"lfs-tracked-binary-content-1234567890"
    (repo_dir / "data.bin").write_bytes(lfs_content)
    (repo_dir / "README.md").write_text("hello")
    git("add", "data.bin", "README.md")
    git("commit", "-m", "Add files")

    recipe_content = f"""\
package:
  name: test-git-lfs
  version: 1.0.0
source:
  git: {repo_dir.as_posix()}
  lfs: true
build:
  noarch: generic
  script:
    - if: unix
      then:
        - cp data.bin $PREFIX/data.bin
        - cp README.md $PREFIX/README.md
      else:
        - copy data.bin %PREFIX%\\data.bin
        - copy README.md %PREFIX%\\README.md
"""
    (recipe_dir / "recipe.yaml").write_text(recipe_content)

    build_output = tmp_path / "output"
    rattler_build.build(recipe_dir / "recipe.yaml", build_output)

    pkg = get_extracted_package(build_output, "test-git-lfs")

    # The LFS file should contain the actual content, not a pointer stub
    resolved = (pkg / "data.bin").read_bytes()
    assert resolved == lfs_content, (
        f"LFS file should contain actual content, got: {resolved!r}"
    )
    assert (pkg / "README.md").read_text() == "hello"


def test_git_source(rattler_build: RattlerBuild, recipes: Path, tmp_path: Path):
    rattler_build.build(recipes / "llamacpp", tmp_path)
    pkg = get_extracted_package(tmp_path, "llama.cpp")
    license_file = pkg / "info/licenses/LICENSE"
    assert license_file.exists()
    assert " Georgi " in license_file.read_text()


def test_package_content_test_execution(
    rattler_build: RattlerBuild, recipes: Path, tmp_path: Path
):
    rattler_build.build(
        recipes / "package-content-tests/recipe-test-succeed.yaml", tmp_path
    )

    with pytest.raises(CalledProcessError):
        rattler_build.build(
            recipes / "package-content-tests/recipe-test-fail.yaml",
            tmp_path / "fail",
        )


def test_test_execution(rattler_build: RattlerBuild, recipes: Path, tmp_path: Path):
    rattler_build.build(recipes / "test-execution/recipe-test-succeed.yaml", tmp_path)

    with pytest.raises(CalledProcessError):
        rattler_build.build(
            recipes / "test-execution/recipe-test-fail.yaml", tmp_path / "fail"
        )


@pytest.mark.skipif(os.name == "nt", reason="Unix-only test")
def test_files_copy(rattler_build: RattlerBuild, recipes: Path, tmp_path: Path):
    rattler_build.build(recipes / "test-sources", tmp_path)


def test_tar_source(rattler_build: RattlerBuild, recipes: Path, tmp_path: Path):
    rattler_build.build(recipes / "tar-source", tmp_path)


def test_zip_source(rattler_build: RattlerBuild, recipes: Path, tmp_path: Path):
    rattler_build.build(recipes / "zip-source", tmp_path)


def test_7z_source(rattler_build: RattlerBuild, recipes: Path, tmp_path: Path):
    rattler_build.build(recipes / "7z-source", tmp_path)


def test_dry_run_cf_upload(rattler_build: RattlerBuild, recipes: Path, tmp_path: Path):
    variant_config = recipes / "polarify" / "linux_64_.yaml"
    rattler_build.build(recipes / "polarify", tmp_path, variant_config=variant_config)

    pkg_path = get_package(tmp_path, "polarify")
    output = rattler_build(
        "upload",
        "-vvv",
        "conda-forge",
        "--feedstock",
        "polarify",
        "--feedstock-token",
        "fake-feedstock-token",
        "--staging-token",
        "fake-staging-token",
        "--dry-run",
        str(pkg_path),
        stderr=STDOUT,
    )
    assert "Done uploading packages to conda-forge" in output


def test_correct_sha256(rattler_build: RattlerBuild, recipes: Path, tmp_path: Path):
    rattler_build.build(recipes / "correct-sha", tmp_path)


@pytest.mark.skipif(os.name == "nt", reason="Unix-only test")
def test_rpath(rattler_build: RattlerBuild, recipes: Path, tmp_path: Path):
    rattler_build.build(
        recipes / "rpath", tmp_path, extra_args=["--target-platform", "linux-64"]
    )


@pytest.mark.skipif(platform.system() != "Linux", reason="Linux-only test")
def test_overlinking_check(rattler_build: RattlerBuild, recipes: Path, tmp_path: Path):
    args = rattler_build.build_args(
        recipes / "overlinking",
        tmp_path,
        extra_args=["--target-platform", "linux-64"],
    )
    try:
        rattler_build(*args, stderr=STDOUT)
        pytest.fail("Expected build to fail with overlinking error")
    except CalledProcessError as e:
        assert "linking check error: Overlinking against" in e.output


@pytest.mark.skipif(platform.system() != "Linux", reason="Linux-only test")
def test_overdepending_check(
    rattler_build: RattlerBuild, recipes: Path, tmp_path: Path
):
    args = rattler_build.build_args(
        recipes / "overdepending",
        tmp_path,
        extra_args=["--target-platform", "linux-64"],
    )
    try:
        rattler_build(*args, stderr=STDOUT)
        pytest.fail("Expected build to fail with overdepending error")
    except CalledProcessError as e:
        assert "linking check error: Overdepending against" in e.output


@pytest.mark.skipif(platform.system() != "Linux", reason="Linux-only test")
def test_overlinking_host_not_run(
    rattler_build: RattlerBuild, recipes: Path, tmp_path: Path
):
    """Regression test for https://github.com/prefix-dev/rattler-build/issues/2192

    When a library (zlib) is in host but NOT in run requirements (because
    ignore_run_exports suppresses the automatic addition), the overlinking check
    should detect this and fail the build.
    """
    args = rattler_build.build_args(
        recipes / "overlinking_host_not_run",
        tmp_path,
        extra_args=["--target-platform", "linux-64"],
    )
    try:
        rattler_build(*args, stderr=STDOUT)
        pytest.fail("Expected build to fail with overlinking error")
    except CalledProcessError as e:
        assert "linking check error: Overlinking against" in e.output


@pytest.mark.skipif(platform.system() != "Linux", reason="Linux-only test")
def test_allow_missing_dso(rattler_build: RattlerBuild, recipes: Path, tmp_path: Path):
    args = rattler_build.build_args(
        recipes / "allow_missing_dso",
        tmp_path,
        extra_args=["--target-platform", "linux-64"],
    )
    output = rattler_build(*args, stderr=STDOUT)
    assert "it is included in the allow list. Skipping..." in output


@pytest.mark.skipif(
    platform.system() not in ("Darwin", "Linux"),
    reason="Cross-platform render test (non-Windows)",
)
def test_render_only_cross_platform(
    rattler_build: RattlerBuild, recipes: Path, tmp_path: Path
):
    """Test that --render-only with cross-platform target doesn't install packages."""
    rattler_build.render(
        recipes / "flask",
        tmp_path,
        extra_args=["--target-platform", "win-64"],
    )


def test_script_content_with_jinja(rattler_build: RattlerBuild, tmp_path: Path):
    """Test script content with Jinja templating (both scalar and sequence forms)."""
    # Scalar form
    scalar_dir = tmp_path / "recipe-scalar"
    scalar_dir.mkdir()
    (scalar_dir / "recipe.yaml").write_text(
        """\
package:
  name: hellopackage
  version: 1.0.0
build:
  script:
    content: ${{ PYTHON }} --help
requirements:
  host:
    - python
"""
    )
    rattler_build.build(scalar_dir, tmp_path / "output-scalar")

    # Sequence form
    seq_dir = tmp_path / "recipe-seq"
    seq_dir.mkdir()
    (seq_dir / "recipe.yaml").write_text(
        """\
package:
  name: hellopackage
  version: 1.0.0
build:
  script:
    content:
      - ${{ PYTHON }} --help
requirements:
  host:
    - python
"""
    )
    rattler_build.build(seq_dir, tmp_path / "output-seq")


@pytest.mark.skipif(os.name == "nt", reason="Uses Unix /tmp paths")
def test_absolute_path_license_without_flag(
    rattler_build: RattlerBuild, recipes: Path, tmp_path: Path
):
    args = rattler_build.build_args(recipes / "absolute_path_license", tmp_path)
    try:
        rattler_build(*args, stderr=STDOUT)
        pytest.fail("Expected build to fail with absolute license path error")
    except CalledProcessError as e:
        assert "Absolute paths in license_file are not allowed" in e.output
        assert "allow-absolute" in e.output


@pytest.mark.skipif(os.name == "nt", reason="Uses Unix /tmp paths")
def test_absolute_path_license_with_flag(
    rattler_build: RattlerBuild, recipes: Path, tmp_path: Path
):
    rattler_build.build(
        recipes / "absolute_path_license",
        tmp_path,
        extra_args=["--allow-absolute-license-paths"],
    )
    pkg = get_extracted_package(tmp_path, "absolute-path-license")
    assert (pkg / "info/licenses/LICENSE").exists()
    assert (pkg / "info/licenses/external_license.txt").exists()


def test_late_bound_license_path(
    rattler_build: RattlerBuild, recipes: Path, tmp_path: Path
):
    # A license file installed into ${{ PREFIX }} during the build is collected
    # without requiring --allow-absolute-license-paths.
    rattler_build.build(recipes / "late_bound_license", tmp_path)
    pkg = get_extracted_package(tmp_path, "late-bound-license")
    license_file = pkg / "info/licenses/LICENSE"
    assert license_file.exists()
    assert "late bound license content" in license_file.read_text()


def test_late_bound_license_glob(
    rattler_build: RattlerBuild, recipes: Path, tmp_path: Path
):
    # A glob rooted at a late-bound ${{ PREFIX }} path collects every matching
    # license file, preserving the directory structure under info/licenses.
    rattler_build.build(recipes / "late_bound_license_glob", tmp_path)
    pkg = get_extracted_package(tmp_path, "late-bound-license-glob")
    foo_license = pkg / "info/licenses/foo/LICENSE"
    bar_license = pkg / "info/licenses/bar/LICENSE"
    assert foo_license.exists()
    assert bar_license.exists()
    assert "foo license content" in foo_license.read_text()
    assert "bar license content" in bar_license.read_text()


def test_late_bound_license_traversal_rejected(
    rattler_build: RattlerBuild, recipes: Path, tmp_path: Path
):
    # A late-bound license path that escapes the build directories via `..` must
    # be rejected, otherwise it would be an absolute-path backdoor around
    # --allow-absolute-license-paths.
    with pytest.raises(CalledProcessError):
        rattler_build.build(recipes / "late_bound_license_traversal", tmp_path)


def test_patch_from_other_source(
    rattler_build: RattlerBuild, recipes: Path, tmp_path: Path
):
    # A patch that ships inside another source can be applied by referencing it
    # through the late-bound ${{ SRC_DIR }} variable. The build script fails if
    # the patch was not applied.
    rattler_build.build(recipes / "patch_from_other_source", tmp_path)
    get_extracted_package(tmp_path, "patch-from-other-source")


def test_sourceforge_redirects(
    rattler_build: RattlerBuild, recipes: Path, tmp_path: Path
):
    rattler_build.build(recipes / "sourceforge-redirects", tmp_path)


def test_target_platform_in_variant_config_warning(
    rattler_build: RattlerBuild, recipes: Path, tmp_path: Path
):
    variant_config = (
        Path(__file__).parent.parent.parent
        / "test-data"
        / "variant_files"
        / "variant_with_target_platform.yaml"
    )
    result = rattler_build.render(
        recipes / "binary_prefix_test",
        tmp_path,
        variant_config=variant_config,
        raw=True,
        # Use JSON log style so the warning is emitted on stderr as plain ASCII
        # on a single line. The default "fancy" style wraps lines to the
        # terminal width and uses box-drawing characters, which breaks the
        # substring check (and capture) on the Windows CI console.
        extra_args=["--log-style=json"],
        encoding="utf-8",
        errors="replace",
    )
    combined = (result.stdout or "") + "\n" + (result.stderr or "")
    assert (
        "Setting 'target_platform' in a variant config file is not supported"
        in combined
    )
    assert "Please use the '--target-platform' command-line flag" in combined


def test_c_compilation(
    rattler_build: RattlerBuild, recipes: Path, tmp_path: Path, clean_path_on_win32
):
    rattler_build.build(recipes / "c_compilation", tmp_path)
    pkg = get_extracted_package(tmp_path, "c_compilation")
    if platform.system() == "Windows":
        assert (pkg / "bin/hello.exe").exists()
    else:
        assert (pkg / "bin/hello").exists()


def test_rust_compilation(rattler_build: RattlerBuild, recipes: Path, tmp_path: Path):
    rattler_build.build(recipes / "rust_compilation", tmp_path)
    pkg = get_extracted_package(tmp_path, "rust_compilation")
    if platform.system() == "Windows":
        assert (pkg / "bin/hello-rust.exe").exists()
    else:
        assert (pkg / "bin/hello-rust").exists()
