import ast
import importlib.util
import io
import json
import os
import sys
import tarfile
import urllib.error
from pathlib import Path

import pytest
import tomllib

ROOT = Path(__file__).resolve().parents[1]


def _load_root_format_script_module():
    """Load the root formatter driver so tests exercise its real command graph."""
    script_path = ROOT.parents[1] / "scripts" / "format.py"
    spec = importlib.util.spec_from_file_location("format_repo", script_path)
    if spec is None or spec.loader is None:
        raise AssertionError(f"Failed to load script module: {script_path}")
    module = importlib.util.module_from_spec(spec)
    sys.modules[spec.name] = module
    spec.loader.exec_module(module)
    return module


def _load_update_script_module():
    """Load the maintenance script as a module so tests exercise real helpers."""
    script_path = ROOT / "scripts" / "update_sdk_artifacts.py"
    spec = importlib.util.spec_from_file_location("update_sdk_artifacts", script_path)
    if spec is None or spec.loader is None:
        raise AssertionError(f"Failed to load script module: {script_path}")
    module = importlib.util.module_from_spec(spec)
    sys.modules[spec.name] = module
    spec.loader.exec_module(module)
    return module


def _load_runtime_setup_module():
    """Load runtime setup without importing the SDK package under test."""
    runtime_setup_path = ROOT / "_runtime_setup.py"
    spec = importlib.util.spec_from_file_location("_runtime_setup", runtime_setup_path)
    if spec is None or spec.loader is None:
        raise AssertionError(f"Failed to load runtime setup module: {runtime_setup_path}")
    module = importlib.util.module_from_spec(spec)
    sys.modules[spec.name] = module
    spec.loader.exec_module(module)
    return module


def _write_fake_codex_package(package_dir: Path, script) -> Path:
    (package_dir / "bin").mkdir(parents=True)
    (package_dir / "codex-resources").mkdir()
    (package_dir / "codex-path").mkdir()
    (package_dir / "codex-package.json").write_text('{"variant":"codex"}\n')
    (package_dir / "bin" / script.runtime_binary_name()).write_text("fake codex\n")
    (package_dir / "codex-resources" / "bwrap").write_text("fake bwrap\n")
    (package_dir / "codex-path" / "rg").write_text("fake rg\n")
    return package_dir


def _write_fake_codex_package_archive(tmp_path: Path, script) -> Path:
    package_dir = _write_fake_codex_package(tmp_path / "codex-package", script)
    archive_path = tmp_path / "codex-package.tar.gz"
    _write_package_archive(package_dir, archive_path)
    return archive_path


def _write_package_archive(package_dir: Path, archive_path: Path) -> None:
    with tarfile.open(archive_path, "w:gz") as archive:
        for path in package_dir.rglob("*"):
            archive.add(path, arcname=path.relative_to(package_dir))


def test_generation_has_single_maintenance_entrypoint_script() -> None:
    """Keep artifact workflows routed through one script instead of side entrypoints."""
    scripts = sorted(p.name for p in (ROOT / "scripts").glob("*.py"))
    assert scripts == ["update_sdk_artifacts.py"]


def test_root_fmt_recipes_use_shared_formatter_driver() -> None:
    """The root formatting recipes should use the shared cross-platform driver."""
    justfile = ROOT.parents[1] / "justfile"
    lines = justfile.read_text().splitlines()
    fmt_index = lines.index("fmt:")
    fmt_check_index = lines.index("fmt-check:")
    next_recipe_index = next(
        index
        for index in range(fmt_check_index + 1, len(lines))
        if lines[index] and not lines[index].startswith((" ", "\t", "#"))
    )
    actual = {
        "working_directory": lines[0],
        "fmt_comment": next(line for line in reversed(lines[:fmt_index]) if line.startswith("#")),
        "fmt_commands": [
            line.strip()
            for line in lines[fmt_index + 1 : fmt_check_index]
            if line.strip() and not line.startswith("#")
        ],
        "fmt_check_comment": next(
            line for line in reversed(lines[:fmt_check_index]) if line.startswith("#")
        ),
        "fmt_check_commands": [
            line.strip() for line in lines[fmt_check_index + 1 : next_recipe_index] if line.strip()
        ],
    }
    expected = {
        "working_directory": 'set working-directory := "codex-rs"',
        "fmt_comment": (
            "# Format the justfile, Rust, Bazel/Starlark, Python SDK code, and Python scripts."
        ),
        "fmt_commands": ["{{ python }} ../scripts/format.py"],
        "fmt_check_comment": "# Check formatting without modifying files.",
        "fmt_check_commands": ["{{ python }} ../scripts/format.py --check"],
    }

    assert actual == expected, (
        "The root formatting recipes must use the shared formatter driver. "
        "Fix the recipes in `justfile`, then run `just fmt`.\n"
        f"Expected: {json.dumps(expected, indent=2)}\n"
        f"Actual: {json.dumps(actual, indent=2)}"
    )


def test_root_format_driver_covers_all_formatter_groups(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """The shared driver should retain every formatter in both modes."""
    script = _load_root_format_script_module()
    git_ls_files_args = [
        "git",
        "ls-files",
        "-z",
        "--cached",
        "--others",
        "--exclude-standard",
    ]

    def fake_check_output(args, *, cwd):
        assert args == git_ls_files_args
        assert cwd == script.REPO_ROOT
        return b"MODULE.bazel\0README.md\0third_party/v8/libcxx.BUILD.bazel\0"

    monkeypatch.setattr(script.subprocess, "check_output", fake_check_output)
    formatters = script.formatter_groups(check=False)
    checks = script.formatter_groups(check=True)

    assert [group.name for group in formatters] == [
        "Just",
        "Rust",
        "Bazel/Starlark",
        "Python SDK",
        "Python scripts",
    ]
    assert [group.name for group in checks] == [group.name for group in formatters]
    assert [len(group.commands) for group in formatters] == [1, 1, 1, 2, 1]
    assert [len(group.commands) for group in checks] == [
        len(group.commands) for group in formatters
    ]
    sdk_uv_run_args = (
        "uv",
        "run",
        "--frozen",
        "--project",
        "sdk/python",
        "--only-group",
        "format",
    )
    scripts_uv_run_args = (
        "uv",
        "run",
        "--frozen",
        "--project",
        "scripts",
    )
    assert all(
        command.args[: len(sdk_uv_run_args)] == sdk_uv_run_args
        for group in (formatters[3], checks[3])
        for command in group.commands
    )
    assert all(
        command.args[: len(scripts_uv_run_args)] == scripts_uv_run_args
        for group in (formatters[4], checks[4])
        for command in group.commands
    )
    assert formatters[3].commands[0].args[-5:] == (
        "ruff",
        "check",
        "--fix",
        "--fix-only",
        "sdk/python",
    )
    assert checks[3].commands[0].args[-4:] == (
        "ruff",
        "check",
        "--diff",
        "sdk/python",
    )
    assert formatters[0].commands[-1].args == ("just", "--unstable", "--fmt")
    assert checks[0].commands[-1].args == ("just", "--unstable", "--fmt", "--check")
    assert formatters[1].commands[-1].args == (
        "cargo",
        "fmt",
        "--",
        "--config",
        "imports_granularity=Item",
    )
    assert checks[1].commands[-1].args == (
        "cargo",
        "fmt",
        "--",
        "--config",
        "imports_granularity=Item",
        "--check",
    )
    format_buildifier_args = formatters[2].commands[-1].args
    check_buildifier_args = checks[2].commands[-1].args
    assert format_buildifier_args[:4] == (
        "dotslash",
        str(script.REPO_ROOT / "tools" / "buildifier"),
        "-mode=fix",
        "-lint=off",
    )
    assert check_buildifier_args[:4] == (
        "dotslash",
        str(script.REPO_ROOT / "tools" / "buildifier"),
        "-mode=check",
        "-lint=off",
    )
    assert format_buildifier_args[4:] == check_buildifier_args[4:]
    assert format_buildifier_args[4:] == (
        "MODULE.bazel",
        "third_party/v8/libcxx.BUILD.bazel",
    )
    assert [group.commands[-1].args[-3:] for group in formatters[3:]] == [
        ("ruff", "format", "sdk/python"),
        ("ruff", "format", "scripts"),
    ]
    assert [group.commands[-1].args[-4:] for group in checks[3:]] == [
        ("ruff", "format", "--check", "sdk/python"),
        ("ruff", "format", "--check", "scripts"),
    ]


def test_generate_types_wires_all_generation_steps() -> None:
    """The type generation command should refresh every schema-derived artifact."""
    source = (ROOT / "scripts" / "update_sdk_artifacts.py").read_text()
    tree = ast.parse(source)

    generate_types_fn = next(
        (
            node
            for node in tree.body
            if isinstance(node, ast.FunctionDef) and node.name == "generate_types_from_schema_dir"
        ),
        None,
    )
    assert generate_types_fn is not None

    calls: list[str] = []
    for node in generate_types_fn.body:
        if isinstance(node, ast.Expr) and isinstance(node.value, ast.Call):
            fn = node.value.func
            if isinstance(fn, ast.Name):
                calls.append(fn.id)

    assert calls == [
        "generate_v2_all",
        "generate_notification_registry",
        "generate_public_api_flat_methods",
    ]


def _load_runtime_schema_bundle(tmp_path: Path) -> dict:
    """Ask the pinned runtime package for a real schema bundle used by tests."""
    script = _load_update_script_module()
    schema_dir = script.generate_schema_from_pinned_runtime(tmp_path / "schema")
    return json.loads(script.schema_bundle_path(schema_dir).read_text())


def test_schema_normalization_only_flattens_string_literal_oneofs(
    tmp_path: Path,
) -> None:
    """Schema normalization should only flatten the enum-shaped oneOf variants."""
    script = _load_update_script_module()
    schema = _load_runtime_schema_bundle(tmp_path)
    definitions = schema["definitions"]
    flattened = [
        name
        for name, definition in definitions.items()
        if isinstance(definition, dict) and script._flatten_string_enum_one_of(definition.copy())
    ]

    assert flattened == [
        "MessagePhase",
        "TurnItemsView",
        "AuthMode",
        "PluginAvailability",
        "InputModality",
        "ExperimentalFeatureStage",
        "ProcessOutputStream",
        "CommandExecOutputStream",
        "AutoCompactTokenLimitScope",
    ]


def test_python_codegen_schema_annotation_adds_stable_variant_titles(
    tmp_path: Path,
) -> None:
    """Schema annotations should give generated protocol classes stable names."""
    script = _load_update_script_module()
    schema = _load_runtime_schema_bundle(tmp_path)
    script._annotate_schema(schema)
    definitions = schema["definitions"]

    server_notification_titles = {
        variant.get("title")
        for variant in definitions["ServerNotification"]["oneOf"]
        if isinstance(variant, dict)
    }
    assert "ErrorServerNotification" in server_notification_titles
    assert "ThreadStartedServerNotification" in server_notification_titles
    assert "ErrorNotification" not in server_notification_titles
    assert "Thread/startedNotification" not in server_notification_titles

    ask_for_approval_titles = [
        variant.get("title") for variant in definitions["AskForApproval"]["oneOf"]
    ]
    assert ask_for_approval_titles == [
        "AskForApprovalValue",
        "GranularAskForApproval",
    ]

    reasoning_summary_titles = [
        variant.get("title") for variant in definitions["ReasoningSummary"]["oneOf"]
    ]
    assert reasoning_summary_titles == [
        "ReasoningSummaryValue",
        "NoneReasoningSummary",
    ]


def test_generate_v2_all_uses_titles_for_generated_names() -> None:
    source = (ROOT / "scripts" / "update_sdk_artifacts.py").read_text()
    assert "--use-title-as-name" in source
    assert "--use-annotated" in source
    assert "--formatters" in source
    assert "ruff-format" in source


def test_runtime_package_template_has_no_checked_in_binaries() -> None:
    runtime_root = ROOT.parent / "python-runtime" / "src" / "codex_cli_bin"
    assert sorted(
        path.name
        for path in runtime_root.rglob("*")
        if path.is_file() and "__pycache__" not in path.parts
    ) == ["__init__.py"]


def test_examples_readme_points_to_runtime_version_source_of_truth() -> None:
    """Document that examples should point at the dependency pin, not release lore."""
    readme = (ROOT / "examples" / "README.md").read_text()
    assert "The pinned runtime version comes from the SDK package dependency." in readme


def test_runtime_distribution_name_is_consistent() -> None:
    script = _load_update_script_module()
    runtime_setup = _load_runtime_setup_module()
    from openai_codex import _version, client as client_module

    assert script.SDK_DISTRIBUTION_NAME == "openai-codex"
    assert runtime_setup.SDK_PACKAGE_NAME == "openai-codex"
    assert _version.DISTRIBUTION_NAME == "openai-codex"
    assert script.RUNTIME_DISTRIBUTION_NAME == "openai-codex-cli-bin"
    assert runtime_setup.PACKAGE_NAME == "openai-codex-cli-bin"
    assert client_module.RUNTIME_PKG_NAME == "openai-codex-cli-bin"
    assert (
        "importlib.metadata.version('codex-cli-bin')"
        not in (ROOT / "_runtime_setup.py").read_text()
    )


def test_source_sdk_template_pins_published_runtime() -> None:
    """The source template should carry a development version and reviewed runtime pin."""
    script = _load_update_script_module()
    pyproject = tomllib.loads((ROOT / "pyproject.toml").read_text())

    assert {
        "sdk_template_version": pyproject["project"]["version"],
        "runtime_pin": script.pinned_runtime_version(),
        "dependencies": pyproject["project"]["dependencies"],
    } == {
        "sdk_template_version": "0.0.0-dev",
        "runtime_pin": "0.137.0a4",
        "dependencies": [
            "pydantic>=2.12",
            "openai-codex-cli-bin==0.137.0a4",
        ],
    }


def test_source_sdk_package_declares_beta_documentation() -> None:
    """Public package metadata should link beta docs."""
    pyproject = tomllib.loads((ROOT / "pyproject.toml").read_text())
    readme = (ROOT / "README.md").read_text()

    assert {
        "description": pyproject["project"]["description"],
        "is_beta": "Development Status :: 4 - Beta" in pyproject["project"]["classifiers"],
        "license": pyproject["project"]["license"],
        "documentation": pyproject["project"]["urls"]["Documentation"],
        "readme_is_beta": "# OpenAI Codex Python SDK (Beta)" in readme,
        "local_license_file": (ROOT / "LICENSE").exists(),
    } == {
        "description": "Python SDK for Codex",
        "is_beta": True,
        "license": "Apache-2.0",
        "documentation": "https://github.com/openai/codex/tree/main/sdk/python/docs",
        "readme_is_beta": True,
        "local_license_file": False,
    }


def test_release_metadata_retries_without_invalid_auth(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    runtime_setup = _load_runtime_setup_module()
    authorizations: list[str | None] = []

    def fake_urlopen(request):
        authorization = request.headers.get("Authorization")
        authorizations.append(authorization)
        if authorization is not None:
            raise urllib.error.HTTPError(
                request.full_url,
                401,
                "Unauthorized",
                hdrs=None,
                fp=None,
            )
        return io.StringIO('{"assets": []}')

    monkeypatch.setenv("GH_TOKEN", "invalid-token")
    monkeypatch.setattr(runtime_setup.urllib.request, "urlopen", fake_urlopen)

    assert runtime_setup._release_metadata("1.2.3") == {"assets": []}
    assert authorizations == ["Bearer invalid-token", None]


def test_runtime_setup_reads_independent_runtime_pin_and_release_tags() -> None:
    """Runtime package pins remain independent of the SDK beta version."""
    runtime_setup = _load_runtime_setup_module()
    pyproject = tomllib.loads((ROOT / "pyproject.toml").read_text())

    assert {
        "package_name": runtime_setup.PACKAGE_NAME,
        "sdk_template_version": pyproject["project"]["version"],
        "runtime_pin": runtime_setup.pinned_runtime_version(),
        "normalized_release_version": runtime_setup._normalized_package_version(
            "rust-v0.116.0-alpha.1"
        ),
        "release_tag": runtime_setup._release_tag("0.116.0a1"),
    } == {
        "package_name": "openai-codex-cli-bin",
        "sdk_template_version": "0.0.0-dev",
        "runtime_pin": "0.137.0a4",
        "normalized_release_version": "0.116.0a1",
        "release_tag": "rust-v0.116.0-alpha.1",
    }


@pytest.mark.parametrize(
    ("system", "machine", "asset_name"),
    [
        ("Darwin", "arm64", "codex-package-aarch64-apple-darwin.tar.gz"),
        ("Linux", "x86_64", "codex-package-x86_64-unknown-linux-musl.tar.gz"),
        ("Windows", "AMD64", "codex-package-x86_64-pc-windows-msvc.tar.gz"),
    ],
)
def test_runtime_setup_downloads_codex_package_archives(
    monkeypatch: pytest.MonkeyPatch,
    system: str,
    machine: str,
    asset_name: str,
) -> None:
    runtime_setup = _load_runtime_setup_module()
    monkeypatch.setattr(runtime_setup.platform, "system", lambda: system)
    monkeypatch.setattr(runtime_setup.platform, "machine", lambda: machine)

    assert runtime_setup.platform_asset_name() == asset_name


def test_runtime_package_is_wheel_only_and_builds_platform_specific_wheels() -> None:
    pyproject = tomllib.loads((ROOT.parent / "python-runtime" / "pyproject.toml").read_text())
    hook_source = (ROOT.parent / "python-runtime" / "hatch_build.py").read_text()
    hook_tree = ast.parse(hook_source)
    initialize_fn = next(
        node
        for node in ast.walk(hook_tree)
        if isinstance(node, ast.FunctionDef) and node.name == "initialize"
    )

    sdist_guard = next(
        (
            node
            for node in initialize_fn.body
            if isinstance(node, ast.If)
            and isinstance(node.test, ast.Compare)
            and isinstance(node.test.left, ast.Attribute)
            and isinstance(node.test.left.value, ast.Name)
            and node.test.left.value.id == "self"
            and node.test.left.attr == "target_name"
            and len(node.test.ops) == 1
            and isinstance(node.test.ops[0], ast.Eq)
            and len(node.test.comparators) == 1
            and isinstance(node.test.comparators[0], ast.Constant)
            and node.test.comparators[0].value == "sdist"
        ),
        None,
    )
    build_data_assignments = {}
    for node in initialize_fn.body:
        if (
            not isinstance(node, ast.Assign)
            or len(node.targets) != 1
            or not isinstance(node.targets[0], ast.Subscript)
            or not isinstance(node.targets[0].value, ast.Name)
            or node.targets[0].value.id != "build_data"
            or not isinstance(node.targets[0].slice, ast.Constant)
            or not isinstance(node.targets[0].slice.value, str)
        ):
            continue
        if isinstance(node.value, ast.Constant):
            build_data_assignments[node.targets[0].slice.value] = node.value.value
        elif isinstance(node.value, ast.JoinedStr):
            build_data_assignments[node.targets[0].slice.value] = "joined-string"

    assert pyproject["project"]["name"] == "openai-codex-cli-bin"
    assert pyproject["tool"]["hatch"]["build"]["targets"]["wheel"] == {
        "packages": ["src/codex_cli_bin"],
        "include": [
            "src/codex_cli_bin/codex-package.json",
            "src/codex_cli_bin/bin/**",
            "src/codex_cli_bin/codex-resources/**",
            "src/codex_cli_bin/codex-path/**",
        ],
        "hooks": {"custom": {}},
    }
    assert pyproject["tool"]["hatch"]["build"]["targets"]["sdist"] == {
        "hooks": {"custom": {}},
    }
    assert sdist_guard is not None
    assert build_data_assignments == {
        "pure_python": False,
        "infer_tag": False,
        "tag": "joined-string",
    }


def test_stage_runtime_release_copies_package_layout_and_sets_version(
    tmp_path: Path,
) -> None:
    script = _load_update_script_module()
    package_archive = _write_fake_codex_package_archive(tmp_path, script)

    staged = script.stage_python_runtime_package(
        tmp_path / "runtime-stage",
        "1.2.3",
        package_archive,
    )
    package_root = script.staged_runtime_package_root(staged)

    assert {
        "metadata": (package_root / "codex-package.json").read_text(),
        "codex": (package_root / "bin" / script.runtime_binary_name()).read_text(),
        "bwrap": (package_root / "codex-resources" / "bwrap").read_text(),
        "rg": (package_root / "codex-path" / "rg").read_text(),
    } == {
        "metadata": '{"variant":"codex"}\n',
        "codex": "fake codex\n",
        "bwrap": "fake bwrap\n",
        "rg": "fake rg\n",
    }
    assert 'name = "openai-codex-cli-bin"' in (staged / "pyproject.toml").read_text()
    assert 'version = "1.2.3"' in (staged / "pyproject.toml").read_text()


def test_normalize_codex_version_accepts_release_tags_and_pep440_versions() -> None:
    script = _load_update_script_module()

    assert script.normalize_codex_version("rust-v0.116.0-alpha.1") == "0.116.0a1"
    assert script.normalize_codex_version("v0.116.0-beta.2") == "0.116.0b2"
    assert script.normalize_codex_version("0.116.0rc3") == "0.116.0rc3"
    assert script.normalize_codex_version("0.116.0") == "0.116.0"


def test_stage_runtime_release_replaces_existing_staging_dir(tmp_path: Path) -> None:
    script = _load_update_script_module()
    staging_dir = tmp_path / "runtime-stage"
    old_file = staging_dir / "stale.txt"
    old_file.parent.mkdir(parents=True)
    old_file.write_text("stale")
    package_archive = _write_fake_codex_package_archive(tmp_path, script)

    staged = script.stage_python_runtime_package(
        staging_dir,
        "1.2.3",
        package_archive,
    )

    assert staged == staging_dir
    assert not old_file.exists()
    package_root = script.staged_runtime_package_root(staged)
    assert (package_root / "bin" / script.runtime_binary_name()).read_text() == "fake codex\n"


def test_stage_runtime_release_can_pin_wheel_platform_tag(tmp_path: Path) -> None:
    script = _load_update_script_module()
    package_archive = _write_fake_codex_package_archive(tmp_path, script)

    staged = script.stage_python_runtime_package(
        tmp_path / "runtime-stage",
        "0.116.0a1",
        package_archive,
        platform_tag="manylinux_2_17_x86_64",
    )

    pyproject = (staged / "pyproject.toml").read_text()
    assert 'platform-tag = "manylinux_2_17_x86_64"' in pyproject


def test_stage_runtime_release_rejects_incomplete_package_layout(tmp_path: Path) -> None:
    script = _load_update_script_module()
    package_dir = tmp_path / "codex-package"
    (package_dir / "bin").mkdir(parents=True)
    package_archive = tmp_path / "codex-package.tar.gz"
    _write_package_archive(package_dir, package_archive)

    with pytest.raises(RuntimeError, match="Missing Codex package layout entries"):
        script.stage_python_runtime_package(tmp_path / "runtime-stage", "1.2.3", package_archive)


def test_runtime_package_layout_is_included_by_wheel_config(
    tmp_path: Path,
) -> None:
    script = _load_update_script_module()
    package_archive = _write_fake_codex_package_archive(tmp_path, script)

    staged = script.stage_python_runtime_package(
        tmp_path / "runtime-stage",
        "1.2.3",
        package_archive,
    )

    pyproject = tomllib.loads((staged / "pyproject.toml").read_text())
    assert pyproject["tool"]["hatch"]["build"]["targets"]["wheel"]["include"] == [
        "src/codex_cli_bin/codex-package.json",
        "src/codex_cli_bin/bin/**",
        "src/codex_cli_bin/codex-resources/**",
        "src/codex_cli_bin/codex-path/**",
    ]


def test_stage_sdk_release_preserves_reviewed_runtime_pin(tmp_path: Path) -> None:
    script = _load_update_script_module()
    staged = script.stage_python_sdk_package(
        tmp_path / "sdk-stage",
        "0.1.0b1",
    )

    pyproject = tomllib.loads((staged / "pyproject.toml").read_text())
    assert {
        "name": pyproject["project"]["name"],
        "version": pyproject["project"]["version"],
        "dependencies": pyproject["project"]["dependencies"],
    } == {
        "name": "openai-codex",
        "version": "0.1.0b1",
        "dependencies": [
            "pydantic>=2.12",
            "openai-codex-cli-bin==0.137.0a4",
        ],
    }
    assert (
        '__version__ = "0.1.0b1"'
        not in (staged / "src" / "openai_codex" / "__init__.py").read_text()
    )
    assert (
        'client_version: str = "0.1.0b1"'
        not in (staged / "src" / "openai_codex" / "client.py").read_text()
    )
    assert not any((staged / "src" / "openai_codex").glob("bin/**"))


def test_stage_sdk_release_replaces_existing_staging_dir(tmp_path: Path) -> None:
    script = _load_update_script_module()
    staging_dir = tmp_path / "sdk-stage"
    old_file = staging_dir / "stale.txt"
    old_file.parent.mkdir(parents=True)
    old_file.write_text("stale")

    staged = script.stage_python_sdk_package(staging_dir, "0.1.0b1")

    assert staged == staging_dir
    assert not old_file.exists()


def test_sdk_beta_release_can_pin_stable_runtime(tmp_path: Path) -> None:
    script = _load_update_script_module()
    package_archive = _write_fake_codex_package_archive(tmp_path, script)

    sdk_stage = script.stage_python_sdk_package(
        tmp_path / "sdk-stage",
        "0.1.0b1",
    )
    runtime_stage = script.stage_python_runtime_package(
        tmp_path / "runtime-stage",
        "0.137.0a4",
        package_archive,
    )

    sdk_pyproject = tomllib.loads((sdk_stage / "pyproject.toml").read_text())
    runtime_pyproject = tomllib.loads((runtime_stage / "pyproject.toml").read_text())

    assert {
        "sdk_version": sdk_pyproject["project"]["version"],
        "runtime_version": runtime_pyproject["project"]["version"],
        "sdk_dependencies": sdk_pyproject["project"]["dependencies"],
    } == {
        "sdk_version": "0.1.0b1",
        "runtime_version": "0.137.0a4",
        "sdk_dependencies": [
            "pydantic>=2.12",
            "openai-codex-cli-bin==0.137.0a4",
        ],
    }


def test_stage_sdk_runs_type_generation_before_staging(tmp_path: Path) -> None:
    script = _load_update_script_module()
    calls: list[str] = []
    args = script.parse_args(
        [
            "stage-sdk",
            str(tmp_path / "sdk-stage"),
            "--sdk-version",
            "0.1.0b1",
        ]
    )

    def fake_generate_types() -> None:
        calls.append("generate_types")

    def fake_stage_sdk_package(_staging_dir: Path, sdk_version: str) -> Path:
        calls.append(f"stage_sdk:{sdk_version}")
        return tmp_path / "sdk-stage"

    def fake_stage_runtime_package(
        _staging_dir: Path,
        _runtime_version: str,
        _package_dir: Path,
        _platform_tag: str | None,
    ) -> Path:
        raise AssertionError("runtime staging should not run for stage-sdk")

    def fake_current_sdk_version() -> str:
        return "0.116.0a1"

    ops = script.CliOps(
        generate_types=fake_generate_types,
        stage_python_sdk_package=fake_stage_sdk_package,
        stage_python_runtime_package=fake_stage_runtime_package,
        current_sdk_version=fake_current_sdk_version,
    )

    script.run_command(args, ops)

    assert calls == ["generate_types", "stage_sdk:0.1.0b1"]


def test_stage_runtime_stages_package_without_type_generation(tmp_path: Path) -> None:
    script = _load_update_script_module()
    package_archive = _write_fake_codex_package_archive(tmp_path, script)
    calls: list[str] = []
    args = script.parse_args(
        [
            "stage-runtime",
            str(tmp_path / "runtime-stage"),
            str(package_archive),
            "--codex-version",
            "rust-v0.116.0-alpha.1",
            "--platform-tag",
            "manylinux_2_17_x86_64",
        ]
    )

    def fake_generate_types() -> None:
        calls.append("generate_types")

    def fake_stage_sdk_package(_staging_dir: Path, _codex_version: str) -> Path:
        raise AssertionError("sdk staging should not run for stage-runtime")

    def fake_stage_runtime_package(
        _staging_dir: Path,
        codex_version: str,
        package_archive: Path,
        platform_tag: str | None,
    ) -> Path:
        calls.append(f"stage_runtime:{codex_version}:{platform_tag}:{package_archive.name}")
        return tmp_path / "runtime-stage"

    def fake_current_sdk_version() -> str:
        return "0.116.0a1"

    ops = script.CliOps(
        generate_types=fake_generate_types,
        stage_python_sdk_package=fake_stage_sdk_package,
        stage_python_runtime_package=fake_stage_runtime_package,
        current_sdk_version=fake_current_sdk_version,
    )

    script.run_command(args, ops)

    assert calls == ["stage_runtime:0.116.0a1:manylinux_2_17_x86_64:codex-package.tar.gz"]


def test_default_runtime_is_resolved_from_installed_runtime_package(
    tmp_path: Path,
) -> None:
    from openai_codex import client as client_module

    fake_binary = tmp_path / ("codex.exe" if client_module.os.name == "nt" else "codex")
    fake_binary.write_text("")
    ops = client_module.CodexBinResolverOps(
        installed_codex_path=lambda: fake_binary,
        path_exists=lambda path: path == fake_binary,
    )

    config = client_module.CodexConfig()
    assert config.codex_bin is None
    assert client_module.resolve_codex_bin(config, ops) == fake_binary


def test_runtime_path_dir_is_prepended_without_duplicates(tmp_path: Path) -> None:
    from openai_codex import client as client_module

    path_dir = tmp_path / "codex-path"
    env = {"PATH": os.pathsep.join(["/usr/bin", str(path_dir), "/bin"])}

    client_module._prepend_path_dirs(env, (path_dir,))

    assert env["PATH"] == os.pathsep.join([str(path_dir), "/usr/bin", "/bin"])


def test_runtime_path_dir_preserves_windows_path_key(
    monkeypatch: pytest.MonkeyPatch,
    tmp_path: Path,
) -> None:
    from openai_codex import client as client_module

    path_dir = tmp_path / "codex-path"
    monkeypatch.setattr(client_module.os, "name", "nt")
    env = {
        "PATH": "/usr/bin",
        "Path": os.pathsep.join(["C\\Windows", str(path_dir)]),
    }

    client_module._prepend_path_dirs(env, (path_dir,))

    assert env == {"Path": os.pathsep.join([str(path_dir), "C\\Windows"])}


def test_explicit_codex_bin_override_takes_priority(tmp_path: Path) -> None:
    from openai_codex import client as client_module

    explicit_binary = tmp_path / (
        "custom-codex.exe" if client_module.os.name == "nt" else "custom-codex"
    )
    explicit_binary.write_text("")
    ops = client_module.CodexBinResolverOps(
        installed_codex_path=lambda: (_ for _ in ()).throw(
            AssertionError("packaged runtime should not be used")
        ),
        path_exists=lambda path: path == explicit_binary,
    )

    config = client_module.CodexConfig(codex_bin=str(explicit_binary))
    assert client_module.resolve_codex_bin(config, ops) == explicit_binary


def test_missing_runtime_package_requires_explicit_codex_bin() -> None:
    from openai_codex import client as client_module

    ops = client_module.CodexBinResolverOps(
        installed_codex_path=lambda: (_ for _ in ()).throw(
            FileNotFoundError("missing packaged runtime")
        ),
        path_exists=lambda _path: False,
    )

    with pytest.raises(FileNotFoundError, match="missing packaged runtime"):
        client_module.resolve_codex_bin(client_module.CodexConfig(), ops)


def test_broken_runtime_package_does_not_fall_back() -> None:
    from openai_codex import client as client_module

    ops = client_module.CodexBinResolverOps(
        installed_codex_path=lambda: (_ for _ in ()).throw(
            FileNotFoundError("missing packaged binary")
        ),
        path_exists=lambda _path: False,
    )

    with pytest.raises(FileNotFoundError) as exc_info:
        client_module.resolve_codex_bin(client_module.CodexConfig(), ops)

    assert str(exc_info.value) == ("missing packaged binary")
