# Copyright (c) 2026 Pyry Haulos
#
# This software is released under the MIT License.
# https://opensource.org/licenses/MIT

"""Tests for airut/sandbox_cli.py -- CLI entry point for airut-sandbox."""

from __future__ import annotations

import argparse
import logging
import signal
import sys
from collections.abc import Callable
from pathlib import Path
from typing import cast
from unittest.mock import AsyncMock, MagicMock, patch

import pytest

from airut.sandbox.secrets import (
    MaskedSecret,
    PreparedSecrets,
    SecretReplacements,
)
from airut.sandbox.types import (
    CommandResult,
    ResourceLimits,
)
from airut.sandbox_cli import (
    EXIT_INFRA_ERROR,
    EXIT_TIMEOUT,
    _build_container_env,
    _build_image_specs,
    _build_repo_spec,
    _dispatch_image,
    _execute,
    _image_hash,
    _image_load,
    _image_save,
    _install_signal_handlers,
    _load_allowlist,
    _load_config,
    _make_network_line_callback,
    _map_exit_code,
    _parse_args,
    _parse_env,
    _parse_masked_secrets,
    _parse_mount,
    _parse_network_sandbox,
    _parse_pass_env,
    _parse_resource_limits,
    _parse_signing_credentials,
    _require_env,
    _resolve_image_paths,
    _run,
    _setup_logging,
    _setup_network_log,
    main,
)
from airut.yaml_env import EnvVar, YamlValue


# -------------------------------------------------------------------
# _parse_args
# -------------------------------------------------------------------


class TestParseArgs:
    """Tests for CLI argument parsing."""

    def test_run_with_double_dash_separator(self) -> None:
        """Arguments split on -- with command after."""
        args = _parse_args(["run", "--", "ls", "-la"])
        assert args.subcommand == "run"
        assert args.command == ["ls", "-la"]

    def test_run_without_double_dash(self) -> None:
        """No -- means empty command list."""
        args = _parse_args(["run"])
        assert args.subcommand == "run"
        assert args.command == []

    def test_no_subcommand(self) -> None:
        """No subcommand returns None."""
        args = _parse_args([])
        assert args.subcommand is None
        assert args.command == []

    def test_run_all_options(self, tmp_path: Path) -> None:
        """All CLI options are parsed correctly."""
        config = tmp_path / "config.yaml"
        config.touch()
        dockerfile = tmp_path / "Dockerfile"
        dockerfile.touch()
        context = tmp_path / "context"
        context.mkdir()
        allowlist = tmp_path / "allowlist.yaml"
        allowlist.touch()
        netlog = tmp_path / "net.log"

        args = _parse_args(
            [
                "run",
                "--config",
                str(config),
                "--dockerfile",
                str(dockerfile),
                "--context-dir",
                str(context),
                "--allowlist",
                str(allowlist),
                "--timeout",
                "300",
                "--container-command",
                "docker",
                "--mount",
                "/src:/dst",
                "--mount",
                "/a:/b:ro",
                "--network-log",
                str(netlog),
                "--max-image-age",
                "12",
                "--verbose",
                "--",
                "echo",
                "hello",
            ]
        )
        assert args.subcommand == "run"
        assert args.config == config
        assert args.dockerfile == dockerfile
        assert args.context_dir == context
        assert args.allowlist == allowlist
        assert args.timeout == 300
        assert args.container_command == "docker"
        assert args.mount == ["/src:/dst", "/a:/b:ro"]
        assert args.network_log == netlog
        assert args.max_image_age == 12
        assert args.verbose is True
        assert args.command == ["echo", "hello"]

    def test_debug_flag(self) -> None:
        """Debug flag is parsed correctly."""
        args = _parse_args(["run", "--debug", "--", "cmd"])
        assert args.debug is True

    def test_log_flag(self, tmp_path: Path) -> None:
        """--log flag is parsed correctly."""
        log_path = tmp_path / "sandbox.log"
        args = _parse_args(["run", "--log", str(log_path), "--", "cmd"])
        assert args.log == log_path

    def test_network_log_live_flag(self) -> None:
        """--network-log-live flag is parsed correctly."""
        args = _parse_args(["run", "--network-log-live", "--", "cmd"])
        assert args.network_log_live is True

    def test_defaults(self) -> None:
        """Default values are None/False/[]."""
        args = _parse_args(["run", "--", "cmd"])
        assert args.config is None
        assert args.dockerfile is None
        assert args.context_dir is None
        assert args.allowlist is None
        assert args.timeout is None
        assert args.container_command is None
        assert args.mount == []
        assert args.network_log is None
        assert args.log is None
        assert args.network_log_live is False
        assert args.max_image_age is None
        assert args.verbose is False
        assert args.debug is False

    def test_double_dash_no_command(self) -> None:
        """Double dash with nothing after yields empty command."""
        args = _parse_args(["run", "--"])
        assert args.command == []


# -------------------------------------------------------------------
# _setup_logging
# -------------------------------------------------------------------


class TestSetupLogging:
    """Tests for logging configuration."""

    def _cleanup_root_logger(self) -> None:
        """Remove handlers added by _setup_logging from root logger."""
        root = logging.getLogger()
        for h in root.handlers[:]:
            root.removeHandler(h)
            h.close()

    def test_default_level(self) -> None:
        """Default level is ERROR (quiet by default)."""
        _setup_logging()
        try:
            root = logging.getLogger()
            assert root.level == logging.ERROR
        finally:
            self._cleanup_root_logger()

    def test_verbose_level(self) -> None:
        """Verbose enables INFO level."""
        _setup_logging(verbose=True)
        try:
            root = logging.getLogger()
            assert root.level == logging.INFO
        finally:
            self._cleanup_root_logger()

    def test_debug_level(self) -> None:
        """Debug enables DEBUG level."""
        _setup_logging(debug=True)
        try:
            root = logging.getLogger()
            assert root.level == logging.DEBUG
        finally:
            self._cleanup_root_logger()

    def test_debug_overrides_verbose(self) -> None:
        """Debug takes precedence over verbose."""
        _setup_logging(verbose=True, debug=True)
        try:
            root = logging.getLogger()
            assert root.level == logging.DEBUG
        finally:
            self._cleanup_root_logger()

    def test_stream_is_stderr(self) -> None:
        """Default output goes to stderr via StreamHandler."""
        _setup_logging()
        try:
            root = logging.getLogger()
            handler = root.handlers[-1]
            assert isinstance(handler, logging.StreamHandler)
            assert handler.stream is sys.stderr
        finally:
            self._cleanup_root_logger()

    def test_log_file(self, tmp_path: Path) -> None:
        """--log PATH writes to a file via FileHandler."""
        log_path = tmp_path / "sandbox.log"
        _setup_logging(log_file=log_path)
        try:
            root = logging.getLogger()
            handler = root.handlers[-1]
            assert isinstance(handler, logging.FileHandler)
            assert Path(handler.baseFilename) == log_path.resolve()
        finally:
            self._cleanup_root_logger()

    def test_log_file_creates_parents(self, tmp_path: Path) -> None:
        """--log PATH creates parent directories."""
        log_path = tmp_path / "deep" / "nested" / "sandbox.log"
        _setup_logging(log_file=log_path)
        try:
            assert log_path.parent.exists()
        finally:
            self._cleanup_root_logger()

    def test_log_file_appends(self, tmp_path: Path) -> None:
        """--log PATH appends to existing file."""
        log_path = tmp_path / "sandbox.log"
        log_path.write_text("existing content\n")
        _setup_logging(log_file=log_path)
        try:
            root = logging.getLogger()
            handler = root.handlers[-1]
            assert isinstance(handler, logging.FileHandler)
            assert handler.mode == "a"
        finally:
            self._cleanup_root_logger()


# -------------------------------------------------------------------
# _require_env
# -------------------------------------------------------------------


class TestRequireEnv:
    """Tests for env var resolution with fail-closed behavior."""

    def test_plain_string(self) -> None:
        """Plain strings are returned as-is."""
        assert _require_env("hello", "path") == "hello"

    def test_integer_value(self) -> None:
        """Non-string values are stringified."""
        assert _require_env(42, "path") == "42"

    def test_env_var_set(self, monkeypatch: pytest.MonkeyPatch) -> None:
        """EnvVar resolves when the env var is set."""
        monkeypatch.setenv("MY_VAR", "secret-value")
        result = _require_env(EnvVar("MY_VAR"), "path.to.field")
        assert result == "secret-value"

    def test_env_var_not_set(self, monkeypatch: pytest.MonkeyPatch) -> None:
        """EnvVar raises _ConfigError when env var is not set."""
        monkeypatch.delenv("MISSING_VAR", raising=False)
        from airut.sandbox_cli import _ConfigError

        with pytest.raises(_ConfigError, match="environment variable"):
            _require_env(EnvVar("MISSING_VAR"), "some.path")

    def test_none_value(self) -> None:
        """None value raises _ConfigError."""
        from airut.sandbox_cli import _ConfigError

        with pytest.raises(_ConfigError, match="value is missing"):
            _require_env(None, "some.path")


# -------------------------------------------------------------------
# _load_config
# -------------------------------------------------------------------


class TestLoadConfig:
    """Tests for YAML config file loading."""

    def test_missing_file_returns_defaults(self, tmp_path: Path) -> None:
        """Missing config file returns default config."""
        config = _load_config(tmp_path / "nonexistent.yaml")
        assert config.env == {}
        assert config.pass_env == []
        assert config.masked_secrets == []
        assert config.signing_credentials == []
        assert config.network_sandbox is True
        assert config.resource_limits == ResourceLimits()

    def test_empty_file_returns_defaults(self, tmp_path: Path) -> None:
        """Empty YAML file returns default config."""
        cfg_file = tmp_path / "config.yaml"
        cfg_file.write_text("")
        config = _load_config(cfg_file)
        assert config.env == {}
        assert config.network_sandbox is True

    def test_invalid_yaml(self, tmp_path: Path) -> None:
        """Invalid YAML raises _ConfigError."""
        cfg_file = tmp_path / "config.yaml"
        cfg_file.write_text(": :\n  :\n[invalid")
        from airut.sandbox_cli import _ConfigError

        with pytest.raises(_ConfigError, match="Invalid YAML"):
            _load_config(cfg_file)

    def test_non_mapping_raises(self, tmp_path: Path) -> None:
        """Non-mapping YAML root raises _ConfigError."""
        cfg_file = tmp_path / "config.yaml"
        cfg_file.write_text("- item1\n- item2")
        from airut.sandbox_cli import _ConfigError

        with pytest.raises(_ConfigError, match="must be a YAML mapping"):
            _load_config(cfg_file)

    def test_full_config(self, tmp_path: Path) -> None:
        """Complete config is parsed correctly."""
        cfg_file = tmp_path / "config.yaml"
        cfg_file.write_text(
            "env:\n"
            "  FOO: bar\n"
            "pass_env:\n"
            "  - HOME\n"
            "network_sandbox: false\n"
            "resource_limits:\n"
            "  timeout: 120\n"
            "  memory: 2g\n"
        )
        config = _load_config(cfg_file)
        assert config.env == {"FOO": "bar"}
        assert config.pass_env == ["HOME"]
        assert config.network_sandbox is False
        assert config.resource_limits.timeout == 120
        assert config.resource_limits.memory == "2g"


# -------------------------------------------------------------------
# _parse_env
# -------------------------------------------------------------------


class TestParseEnv:
    """Tests for static env parsing."""

    def test_none_returns_empty(self) -> None:
        """None returns empty dict."""
        assert _parse_env(None) == {}

    def test_valid_mapping(self) -> None:
        """Dict values are stringified."""
        result = _parse_env(cast(YamlValue, {"KEY": "val", "NUM": 42}))
        assert result == {"KEY": "val", "NUM": "42"}

    def test_env_var_resolved(self, monkeypatch: pytest.MonkeyPatch) -> None:
        """EnvVar values are resolved from host environment."""
        monkeypatch.setenv("MY_VAR", "hello")
        result = _parse_env(cast(YamlValue, {"KEY": EnvVar("MY_VAR")}))
        assert result == {"KEY": "hello"}

    def test_env_var_missing_raises(
        self, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """Missing EnvVar raises _ConfigError."""
        from airut.sandbox_cli import _ConfigError

        monkeypatch.delenv("MISSING_VAR", raising=False)
        with pytest.raises(_ConfigError, match="environment variable"):
            _parse_env(cast(YamlValue, {"KEY": EnvVar("MISSING_VAR")}))

    def test_non_mapping_raises(self) -> None:
        """Non-dict raises _ConfigError."""
        from airut.sandbox_cli import _ConfigError

        with pytest.raises(_ConfigError, match="'env' must be a mapping"):
            _parse_env(cast(YamlValue, ["not", "a", "dict"]))


# -------------------------------------------------------------------
# _parse_pass_
# -------------------------------------------------------------------


class TestParsePassEnv:
    """Tests for pass_env parsing."""

    def test_none_returns_empty(self) -> None:
        """None returns empty list."""
        assert _parse_pass_env(None) == []

    def test_valid_list(self) -> None:
        """List items are stringified."""
        result = _parse_pass_env(cast(YamlValue, ["HOME", "PATH"]))
        assert result == ["HOME", "PATH"]

    def test_non_list_raises(self) -> None:
        """Non-list raises _ConfigError."""
        from airut.sandbox_cli import _ConfigError

        with pytest.raises(_ConfigError, match="'pass_env' must be a list"):
            _parse_pass_env(cast(YamlValue, {"not": "a list"}))


# -------------------------------------------------------------------
# _parse_masked_secrets
# -------------------------------------------------------------------


class TestParseMaskedSecrets:
    """Tests for masked secrets parsing."""

    def test_none_returns_empty(self) -> None:
        """None returns empty list."""
        assert _parse_masked_secrets(None) == []

    def test_non_mapping_raises(self) -> None:
        """Non-dict raises _ConfigError."""
        from airut.sandbox_cli import _ConfigError

        with pytest.raises(
            _ConfigError, match="'masked_secrets' must be a mapping"
        ):
            _parse_masked_secrets(cast(YamlValue, ["not", "a", "dict"]))

    def test_entry_not_mapping_raises(self) -> None:
        """Non-dict entry raises _ConfigError."""
        from airut.sandbox_cli import _ConfigError

        with pytest.raises(_ConfigError, match="must be a mapping"):
            _parse_masked_secrets(
                cast(YamlValue, {"MY_TOKEN": "string_not_dict"})
            )

    def test_missing_scopes_raises(self) -> None:
        """Missing scopes raises _ConfigError."""
        from airut.sandbox_cli import _ConfigError

        with pytest.raises(_ConfigError, match="scopes must be a non-empty"):
            _parse_masked_secrets(
                cast(
                    YamlValue,
                    {
                        "MY_TOKEN": {
                            "value": "secret",
                            "headers": ["Authorization"],
                        }
                    },
                )
            )

    def test_empty_scopes_raises(self) -> None:
        """Empty scopes list raises _ConfigError."""
        from airut.sandbox_cli import _ConfigError

        with pytest.raises(_ConfigError, match="scopes must be a non-empty"):
            _parse_masked_secrets(
                cast(
                    YamlValue,
                    {
                        "MY_TOKEN": {
                            "value": "secret",
                            "scopes": [],
                            "headers": ["Authorization"],
                        }
                    },
                )
            )

    def test_missing_headers_raises(self) -> None:
        """Missing headers raises _ConfigError."""
        from airut.sandbox_cli import _ConfigError

        with pytest.raises(_ConfigError, match="headers must be a non-empty"):
            _parse_masked_secrets(
                cast(
                    YamlValue,
                    {
                        "MY_TOKEN": {
                            "value": "secret",
                            "scopes": ["api.example.com"],
                        }
                    },
                )
            )

    def test_empty_headers_raises(self) -> None:
        """Empty headers list raises _ConfigError."""
        from airut.sandbox_cli import _ConfigError

        with pytest.raises(_ConfigError, match="headers must be a non-empty"):
            _parse_masked_secrets(
                cast(
                    YamlValue,
                    {
                        "MY_TOKEN": {
                            "value": "secret",
                            "scopes": ["api.example.com"],
                            "headers": [],
                        }
                    },
                )
            )

    def test_valid_secret(self) -> None:
        """Valid masked secret is parsed correctly."""
        result = _parse_masked_secrets(
            cast(
                YamlValue,
                {
                    "MY_TOKEN": {
                        "value": "secret123",
                        "scopes": ["api.example.com"],
                        "headers": ["Authorization"],
                    }
                },
            )
        )
        assert len(result) == 1
        assert result[0] == MaskedSecret(
            env_var="MY_TOKEN",
            real_value="secret123",
            scopes=("api.example.com",),
            headers=("Authorization",),
        )

    def test_value_with_env_var(self, monkeypatch: pytest.MonkeyPatch) -> None:
        """EnvVar value is resolved from environment."""
        monkeypatch.setenv("SECRET_VAR", "resolved-secret")
        result = _parse_masked_secrets(
            cast(
                YamlValue,
                {
                    "MY_TOKEN": {
                        "value": EnvVar("SECRET_VAR"),
                        "scopes": ["*.example.com"],
                        "headers": ["Authorization", "X-Api-Key"],
                    }
                },
            )
        )
        assert result[0].real_value == "resolved-secret"
        assert result[0].scopes == ("*.example.com",)
        assert result[0].headers == ("Authorization", "X-Api-Key")

    def test_allow_foreign_credentials_true(self) -> None:
        """allow_foreign_credentials: true is parsed and propagated."""
        result = _parse_masked_secrets(
            cast(
                YamlValue,
                {
                    "CF_TOKEN": {
                        "value": "cf-secret",
                        "scopes": ["api.cloudflare.com"],
                        "headers": ["Authorization"],
                        "allow_foreign_credentials": True,
                    }
                },
            )
        )
        assert len(result) == 1
        assert result[0].allow_foreign_credentials is True

    def test_allow_foreign_credentials_false(self) -> None:
        """allow_foreign_credentials: false is parsed explicitly."""
        result = _parse_masked_secrets(
            cast(
                YamlValue,
                {
                    "MY_TOKEN": {
                        "value": "secret",
                        "scopes": ["api.example.com"],
                        "headers": ["Authorization"],
                        "allow_foreign_credentials": False,
                    }
                },
            )
        )
        assert result[0].allow_foreign_credentials is False

    def test_allow_foreign_credentials_absent_defaults_false(self) -> None:
        """Omitted allow_foreign_credentials defaults to False."""
        result = _parse_masked_secrets(
            cast(
                YamlValue,
                {
                    "MY_TOKEN": {
                        "value": "secret",
                        "scopes": ["api.example.com"],
                        "headers": ["Authorization"],
                    }
                },
            )
        )
        assert result[0].allow_foreign_credentials is False

    def test_scopes_not_list_raises(self) -> None:
        """Non-list scopes raises _ConfigError."""
        from airut.sandbox_cli import _ConfigError

        with pytest.raises(_ConfigError, match="scopes must be a non-empty"):
            _parse_masked_secrets(
                cast(
                    YamlValue,
                    {
                        "MY_TOKEN": {
                            "value": "secret",
                            "scopes": "not-a-list",
                            "headers": ["Authorization"],
                        }
                    },
                )
            )

    def test_headers_not_list_raises(self) -> None:
        """Non-list headers raises _ConfigError."""
        from airut.sandbox_cli import _ConfigError

        with pytest.raises(_ConfigError, match="headers must be a non-empty"):
            _parse_masked_secrets(
                cast(
                    YamlValue,
                    {
                        "MY_TOKEN": {
                            "value": "secret",
                            "scopes": ["api.example.com"],
                            "headers": "not-a-list",
                        }
                    },
                )
            )


# -------------------------------------------------------------------
# _parse_signing_credentials
# -------------------------------------------------------------------


class TestParseSigningCredentials:
    """Tests for signing credential parsing."""

    def test_none_returns_empty(self) -> None:
        """None returns empty list."""
        assert _parse_signing_credentials(None) == []

    def test_non_mapping_raises(self) -> None:
        """Non-dict raises _ConfigError."""
        from airut.sandbox_cli import _ConfigError

        with pytest.raises(
            _ConfigError,
            match="'signing_credentials' must be a mapping",
        ):
            _parse_signing_credentials(cast(YamlValue, ["not", "a", "dict"]))

    def test_entry_not_mapping_raises(self) -> None:
        """Non-dict entry raises _ConfigError."""
        from airut.sandbox_cli import _ConfigError

        with pytest.raises(_ConfigError, match="must be a mapping"):
            _parse_signing_credentials(
                cast(YamlValue, {"aws": "string_not_dict"})
            )

    def test_invalid_type_raises(self) -> None:
        """Wrong type field raises _ConfigError."""
        from airut.sandbox_cli import _ConfigError

        with pytest.raises(_ConfigError, match="type must be 'aws-sigv4'"):
            _parse_signing_credentials(
                cast(
                    YamlValue,
                    {
                        "aws": {
                            "type": "aws-sigv2",
                            "access_key_id": "AKIATEST",
                            "secret_access_key": "secret",
                            "scopes": ["bedrock.us-east-1.amazonaws.com"],
                        }
                    },
                )
            )

    def test_missing_type_raises(self) -> None:
        """Missing type field raises _ConfigError."""
        from airut.sandbox_cli import _ConfigError

        with pytest.raises(_ConfigError, match="type must be 'aws-sigv4'"):
            _parse_signing_credentials(
                cast(
                    YamlValue,
                    {
                        "aws": {
                            "access_key_id": "AKIATEST",
                            "secret_access_key": "secret",
                            "scopes": ["bedrock.us-east-1.amazonaws.com"],
                        }
                    },
                )
            )

    def test_missing_scopes_raises(self) -> None:
        """Missing scopes raises _ConfigError."""
        from airut.sandbox_cli import _ConfigError

        with pytest.raises(_ConfigError, match="scopes must be a non-empty"):
            _parse_signing_credentials(
                cast(
                    YamlValue,
                    {
                        "aws": {
                            "type": "aws-sigv4",
                            "access_key_id": "AKIATEST",
                            "secret_access_key": "secret",
                        }
                    },
                )
            )

    def test_empty_scopes_raises(self) -> None:
        """Empty scopes raises _ConfigError."""
        from airut.sandbox_cli import _ConfigError

        with pytest.raises(_ConfigError, match="scopes must be a non-empty"):
            _parse_signing_credentials(
                cast(
                    YamlValue,
                    {
                        "aws": {
                            "type": "aws-sigv4",
                            "access_key_id": "AKIATEST",
                            "secret_access_key": "secret",
                            "scopes": [],
                        }
                    },
                )
            )

    def test_valid_credential_plain_strings(self) -> None:
        """Plain string credentials use default env var names."""
        result = _parse_signing_credentials(
            cast(
                YamlValue,
                {
                    "aws": {
                        "type": "aws-sigv4",
                        "access_key_id": "AKIATEST123",
                        "secret_access_key": "secret123",
                        "scopes": ["bedrock.us-east-1.amazonaws.com"],
                    }
                },
            )
        )
        assert len(result) == 1
        cred = result[0]
        assert cred.access_key_id_env_var == "AWS_ACCESS_KEY_ID"
        assert cred.access_key_id == "AKIATEST123"
        assert cred.secret_access_key_env_var == "AWS_SECRET_ACCESS_KEY"
        assert cred.secret_access_key == "secret123"
        assert cred.session_token_env_var is None
        assert cred.session_token is None
        assert cred.scopes == ("bedrock.us-east-1.amazonaws.com",)

    def test_valid_credential_with_env_vars(
        self, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """EnvVar values resolve env var names from the tag."""
        monkeypatch.setenv("MY_ACCESS_KEY", "AKIATEST456")
        monkeypatch.setenv("MY_SECRET_KEY", "secret456")
        result = _parse_signing_credentials(
            cast(
                YamlValue,
                {
                    "aws": {
                        "type": "aws-sigv4",
                        "access_key_id": EnvVar("MY_ACCESS_KEY"),
                        "secret_access_key": EnvVar("MY_SECRET_KEY"),
                        "scopes": ["*.amazonaws.com"],
                    }
                },
            )
        )
        cred = result[0]
        assert cred.access_key_id_env_var == "MY_ACCESS_KEY"
        assert cred.access_key_id == "AKIATEST456"
        assert cred.secret_access_key_env_var == "MY_SECRET_KEY"
        assert cred.secret_access_key == "secret456"

    def test_credential_with_session_token_plain(self) -> None:
        """Session token as plain string uses default env var name."""
        result = _parse_signing_credentials(
            cast(
                YamlValue,
                {
                    "aws": {
                        "type": "aws-sigv4",
                        "access_key_id": "ASIATEST789",
                        "secret_access_key": "secret789",
                        "session_token": "token789",
                        "scopes": ["s3.amazonaws.com"],
                    }
                },
            )
        )
        cred = result[0]
        assert cred.session_token_env_var == "AWS_SESSION_TOKEN"
        assert cred.session_token == "token789"

    def test_credential_with_session_token_env_var(
        self, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """Session token as EnvVar resolves from env."""
        monkeypatch.setenv("MY_SESSION_TOKEN", "session-token-value")
        result = _parse_signing_credentials(
            cast(
                YamlValue,
                {
                    "aws": {
                        "type": "aws-sigv4",
                        "access_key_id": "ASIATEST",
                        "secret_access_key": "secret",
                        "session_token": EnvVar("MY_SESSION_TOKEN"),
                        "scopes": ["s3.amazonaws.com"],
                    }
                },
            )
        )
        cred = result[0]
        assert cred.session_token_env_var == "MY_SESSION_TOKEN"
        assert cred.session_token == "session-token-value"

    def test_scopes_not_list_raises(self) -> None:
        """Non-list scopes raises _ConfigError."""
        from airut.sandbox_cli import _ConfigError

        with pytest.raises(_ConfigError, match="scopes must be a non-empty"):
            _parse_signing_credentials(
                cast(
                    YamlValue,
                    {
                        "aws": {
                            "type": "aws-sigv4",
                            "access_key_id": "AKIATEST",
                            "secret_access_key": "secret",
                            "scopes": "not-a-list",
                        }
                    },
                )
            )


# -------------------------------------------------------------------
# _parse_network_sandbox
# -------------------------------------------------------------------


class TestParseNetworkSandbox:
    """Tests for network_sandbox bool parsing."""

    def test_none_returns_true(self) -> None:
        """Default is True when unspecified."""
        assert _parse_network_sandbox(None) is True

    def test_bool_true(self) -> None:
        """Native bool True passes through."""
        assert _parse_network_sandbox(True) is True

    def test_bool_false(self) -> None:
        """Native bool False passes through."""
        assert _parse_network_sandbox(False) is False

    @pytest.mark.parametrize("value", ["true", "1", "yes", "on", "TRUE", "Yes"])
    def test_truthy_strings(self, value: str) -> None:
        """Various truthy strings return True."""
        assert _parse_network_sandbox(value) is True

    @pytest.mark.parametrize(
        "value", ["false", "0", "no", "off", "FALSE", "No"]
    )
    def test_falsy_strings(self, value: str) -> None:
        """Various falsy strings return False."""
        assert _parse_network_sandbox(value) is False

    def test_invalid_string_raises(self) -> None:
        """Invalid string raises _ConfigError."""
        from airut.sandbox_cli import _ConfigError

        with pytest.raises(_ConfigError, match="must be a boolean"):
            _parse_network_sandbox("maybe")


# -------------------------------------------------------------------
# _parse_resource_limits
# -------------------------------------------------------------------


class TestParseResourceLimits:
    """Tests for resource limits parsing."""

    def test_none_returns_defaults(self) -> None:
        """None returns default ResourceLimits."""
        result = _parse_resource_limits(None)
        assert result == ResourceLimits()

    def test_non_mapping_raises(self) -> None:
        """Non-dict raises _ConfigError."""
        from airut.sandbox_cli import _ConfigError

        with pytest.raises(
            _ConfigError, match="'resource_limits' must be a mapping"
        ):
            _parse_resource_limits("not-a-dict")

    def test_all_fields(self) -> None:
        """All fields are parsed with correct types."""
        result = _parse_resource_limits(
            cast(
                YamlValue,
                {
                    "timeout": 600,
                    "memory": "4g",
                    "cpus": 2.5,
                    "pids_limit": 100,
                },
            )
        )
        assert result.timeout == 600
        assert result.memory == "4g"
        assert result.cpus == 2.5
        assert result.pids_limit == 100

    def test_partial_fields(self) -> None:
        """Missing fields remain None."""
        result = _parse_resource_limits(cast(YamlValue, {"timeout": 120}))
        assert result.timeout == 120
        assert result.memory is None
        assert result.cpus is None
        assert result.pids_limit is None

    def test_empty_dict(self) -> None:
        """Empty dict returns defaults."""
        result = _parse_resource_limits({})
        assert result == ResourceLimits()

    def test_string_timeout_converted_to_int(self) -> None:
        """String timeout is converted to int."""
        result = _parse_resource_limits(cast(YamlValue, {"timeout": "300"}))
        assert result.timeout == 300

    def test_string_cpus_converted_to_float(self) -> None:
        """String cpus is converted to float."""
        result = _parse_resource_limits(cast(YamlValue, {"cpus": "1.5"}))
        assert result.cpus == 1.5

    def test_string_pids_limit_converted_to_int(self) -> None:
        """String pids_limit is converted to int."""
        result = _parse_resource_limits(cast(YamlValue, {"pids_limit": "50"}))
        assert result.pids_limit == 50

    def test_invalid_timeout_raises(self) -> None:
        """Non-numeric timeout raises _ConfigError."""
        from airut.sandbox_cli import _ConfigError

        with pytest.raises(_ConfigError, match="invalid value"):
            _parse_resource_limits(cast(YamlValue, {"timeout": "fast"}))


# -------------------------------------------------------------------
# _parse_mount
# -------------------------------------------------------------------


class TestParseMount:
    """Tests for mount string parsing."""

    def test_src_dst(self) -> None:
        """SRC:DST mount is parsed correctly."""
        mount = _parse_mount("/host/path:/container/path")
        assert mount.host_path == Path("/host/path").resolve()
        assert mount.container_path == "/container/path"
        assert mount.read_only is False

    def test_src_dst_ro(self) -> None:
        """SRC:DST:ro mount is parsed correctly."""
        mount = _parse_mount("/host/path:/container/path:ro")
        assert mount.host_path == Path("/host/path").resolve()
        assert mount.container_path == "/container/path"
        assert mount.read_only is True

    def test_invalid_format_one_part(self) -> None:
        """Single component raises _ConfigError."""
        from airut.sandbox_cli import _ConfigError

        with pytest.raises(_ConfigError, match="Invalid mount format"):
            _parse_mount("/only/one/part")

    def test_invalid_format_four_parts(self) -> None:
        """Four components raises _ConfigError."""
        from airut.sandbox_cli import _ConfigError

        with pytest.raises(_ConfigError, match="Invalid mount format"):
            _parse_mount("/a:/b:ro:extra")

    def test_three_parts_not_ro(self) -> None:
        """Three components with third != 'ro' raises _ConfigError."""
        from airut.sandbox_cli import _ConfigError

        with pytest.raises(_ConfigError, match="Invalid mount format"):
            _parse_mount("/a:/b:rw")


# -------------------------------------------------------------------
# _setup_network_log
# -------------------------------------------------------------------


class TestSetupNetworkLog:
    """Tests for network log file setup."""

    def test_with_user_path(self, tmp_path: Path) -> None:
        """User-specified path is resolved and touched."""
        user_path = tmp_path / "logs" / "network.log"
        log_path, cleanup = _setup_network_log(user_path)

        try:
            # User path is created
            assert user_path.exists()
            # log_path matches resolved user path
            assert log_path == user_path.resolve()
        finally:
            cleanup()

        # User file persists after cleanup
        assert user_path.exists()

    def test_without_user_path(self) -> None:
        """No user path creates temp file, cleaned up on exit."""
        log_path, cleanup = _setup_network_log(None)

        try:
            assert log_path.exists()
            assert "airut-netlog-" in str(log_path)
        finally:
            cleanup()

        assert not log_path.exists()

    def test_cleanup_default_removes_log_file(self) -> None:
        """Default cleanup removes temp log file."""
        log_path, cleanup = _setup_network_log(None)

        try:
            # Simulate proxy creating log data
            log_path.write_text("some log data")
        finally:
            cleanup()

        assert not log_path.exists()

    def test_with_user_path_parent_created(self, tmp_path: Path) -> None:
        """Parent directories for user path are created."""
        user_path = tmp_path / "deep" / "nested" / "dir" / "net.log"
        log_path, cleanup = _setup_network_log(user_path)

        try:
            assert user_path.parent.exists()
        finally:
            cleanup()


# -------------------------------------------------------------------
# _load_allowlist
# -------------------------------------------------------------------


class TestLoadAllowlist:
    """Tests for allowlist loading."""

    def test_sandbox_disabled(self, tmp_path: Path) -> None:
        """Returns None when network sandbox is disabled."""
        result = _load_allowlist(
            tmp_path / "allowlist.yaml", network_sandbox=False
        )
        assert result is None

    def test_missing_file_returns_empty(self, tmp_path: Path) -> None:
        """Missing file returns empty allowlist."""
        result = _load_allowlist(
            tmp_path / "nonexistent.yaml", network_sandbox=True
        )
        assert result is not None
        assert result.domains == ()
        assert result.url_patterns == ()

    def test_valid_file(self, tmp_path: Path) -> None:
        """Valid allowlist file is parsed correctly."""
        allowlist_path = tmp_path / "allowlist.yaml"
        allowlist_path.write_text(
            "domains:\n"
            "  - api.example.com\n"
            "url_prefixes:\n"
            "  - host: pypi.org\n"
            "    methods: [GET]\n"
        )
        result = _load_allowlist(allowlist_path, network_sandbox=True)
        assert result is not None
        assert len(result.domains) == 1
        assert result.domains[0].host == "api.example.com"

    def test_invalid_file_raises(self, tmp_path: Path) -> None:
        """Invalid YAML in allowlist raises _ConfigError."""
        from airut.sandbox_cli import _ConfigError

        allowlist_path = tmp_path / "allowlist.yaml"
        allowlist_path.write_bytes(b"[invalid: yaml: :")
        with pytest.raises(_ConfigError, match="Invalid allowlist"):
            _load_allowlist(allowlist_path, network_sandbox=True)


# -------------------------------------------------------------------
# _build_container_env
# -------------------------------------------------------------------


class TestBuildContainerEnv:
    """Tests for container environment building."""

    def test_static_env(self) -> None:
        """Static env vars are included."""
        from airut.sandbox_cli import _SandboxCliConfig

        config = _SandboxCliConfig(env={"FOO": "bar", "BAZ": "qux"})
        prepared = PreparedSecrets(
            env_vars={}, replacements=SecretReplacements()
        )
        result = _build_container_env(config, prepared)
        assert result.variables == {"FOO": "bar", "BAZ": "qux"}

    def test_pass_env_from_host(self, monkeypatch: pytest.MonkeyPatch) -> None:
        """pass_env picks up host environment variables."""
        from airut.sandbox_cli import _SandboxCliConfig

        monkeypatch.setenv("PASS_ME", "value123")
        monkeypatch.delenv("MISSING", raising=False)
        config = _SandboxCliConfig(pass_env=["PASS_ME", "MISSING"])
        prepared = PreparedSecrets(
            env_vars={}, replacements=SecretReplacements()
        )
        result = _build_container_env(config, prepared)
        assert result.variables == {"PASS_ME": "value123"}

    def test_prepared_secrets_override(self) -> None:
        """Prepared secrets override earlier values."""
        from airut.sandbox_cli import _SandboxCliConfig

        config = _SandboxCliConfig(env={"TOKEN": "static"})
        prepared = PreparedSecrets(
            env_vars={"TOKEN": "surrogate"},
            replacements=SecretReplacements(),
        )
        result = _build_container_env(config, prepared)
        assert result.variables["TOKEN"] == "surrogate"

    def test_merge_order(self, monkeypatch: pytest.MonkeyPatch) -> None:
        """Merge order: static < pass_env < prepared secrets."""
        from airut.sandbox_cli import _SandboxCliConfig

        monkeypatch.setenv("MY_VAR", "from-host")
        config = _SandboxCliConfig(
            env={"MY_VAR": "from-static", "OTHER": "static"},
            pass_env=["MY_VAR"],
        )
        prepared = PreparedSecrets(
            env_vars={"MY_VAR": "from-prepared"},
            replacements=SecretReplacements(),
        )
        result = _build_container_env(config, prepared)
        assert result.variables["MY_VAR"] == "from-prepared"
        assert result.variables["OTHER"] == "static"


# -------------------------------------------------------------------
# _install_signal_handlers
# -------------------------------------------------------------------


class TestInstallSignalHandlers:
    """Tests for signal handler installation."""

    def test_installs_handlers_for_command_task(self) -> None:
        """Signal handlers installed for real CommandTask."""
        from airut.sandbox.task import CommandTask

        mock_task = MagicMock(spec=CommandTask)
        with patch("airut.sandbox_cli.signal.signal") as mock_signal:
            _install_signal_handlers(mock_task)

            assert mock_signal.call_count == 2
            calls = mock_signal.call_args_list
            signal_numbers = {c[0][0] for c in calls}
            assert signal.SIGTERM in signal_numbers
            assert signal.SIGINT in signal_numbers

    def test_handler_calls_stop(self) -> None:
        """Installed handler calls task.stop() on signal."""
        import types

        from airut.sandbox.task import CommandTask

        mock_task = MagicMock(spec=CommandTask)
        handlers: dict[int, Callable[[int, types.FrameType | None], None]] = {}

        def capture_handler(
            signum: int,
            handler: Callable[[int, types.FrameType | None], None],
        ) -> None:
            handlers[signum] = handler

        with patch(
            "airut.sandbox_cli.signal.signal", side_effect=capture_handler
        ):
            _install_signal_handlers(mock_task)

        # Call the SIGTERM handler
        handler = handlers[signal.SIGTERM]
        handler(signal.SIGTERM, None)
        mock_task.stop.assert_called_once()


# -------------------------------------------------------------------
# _map_exit_code
# -------------------------------------------------------------------


class TestMapExitCode:
    """Tests for exit code mapping."""

    def test_normal_exit(self) -> None:
        """Normal exit code passes through."""
        result = CommandResult(
            exit_code=0,
            stdout="",
            stderr="",
            duration_ms=100,
            timed_out=False,
        )
        assert _map_exit_code(result) == 0

    def test_nonzero_exit(self) -> None:
        """Non-zero exit code passes through."""
        result = CommandResult(
            exit_code=1,
            stdout="",
            stderr="",
            duration_ms=100,
            timed_out=False,
        )
        assert _map_exit_code(result) == 1

    def test_timeout_returns_124(self) -> None:
        """Timeout returns EXIT_TIMEOUT (124)."""
        result = CommandResult(
            exit_code=0,
            stdout="",
            stderr="",
            duration_ms=30000,
            timed_out=True,
        )
        assert _map_exit_code(result) == EXIT_TIMEOUT

    def test_oom_exit_137_warns(self) -> None:
        """Exit code 137 logs OOM warning and returns 137."""
        result = CommandResult(
            exit_code=137,
            stdout="",
            stderr="",
            duration_ms=100,
            timed_out=False,
        )
        with patch("airut.sandbox_cli.logger") as mock_logger:
            code = _map_exit_code(result)
            assert code == 137
            mock_logger.warning.assert_called_once()
            assert "OOM" in mock_logger.warning.call_args[0][0]

    def test_timeout_takes_priority_over_exit_code(self) -> None:
        """Timeout is checked before exit code."""
        result = CommandResult(
            exit_code=137,
            stdout="",
            stderr="",
            duration_ms=30000,
            timed_out=True,
        )
        assert _map_exit_code(result) == EXIT_TIMEOUT


# -------------------------------------------------------------------
# _execute
# -------------------------------------------------------------------


class TestExecute:
    """Tests for the main execution orchestration."""

    def _make_args(
        self,
        command: list[str] | None = None,
        tmp_path: Path | None = None,
        **overrides: list[str] | Path | str | int | bool | None,
    ) -> argparse.Namespace:
        """Create a minimal args Namespace for testing _execute."""
        defaults: dict[str, list[str] | Path | str | int | bool | None] = {
            "command": command if command is not None else ["echo", "hello"],
            "dockerfile": None,
            "context_dir": None,
            "allowlist": None,
            "timeout": None,
            "container_command": None,
            "max_image_age": None,
            "mount": [],
            "network_log": None,
            "network_log_live": False,
        }
        defaults.update(overrides)
        return argparse.Namespace(**defaults)

    def test_no_command_returns_infra_error(self) -> None:
        """Empty command returns EXIT_INFRA_ERROR."""
        args = self._make_args(command=[])
        from airut.sandbox_cli import _SandboxCliConfig

        result = _execute(args, _SandboxCliConfig())
        assert result == EXIT_INFRA_ERROR

    def test_missing_dockerfile_returns_infra_error(
        self, tmp_path: Path
    ) -> None:
        """Missing Dockerfile returns EXIT_INFRA_ERROR."""
        from airut.sandbox_cli import _SandboxCliConfig

        args = self._make_args(
            dockerfile=tmp_path / "nonexistent" / "Dockerfile"
        )
        result = _execute(args, _SandboxCliConfig())
        assert result == EXIT_INFRA_ERROR

    def test_invalid_mount_returns_infra_error(self, tmp_path: Path) -> None:
        """Invalid mount format returns EXIT_INFRA_ERROR."""
        from airut.sandbox_cli import _SandboxCliConfig

        dockerfile = tmp_path / "Dockerfile"
        dockerfile.write_text("FROM alpine")
        args = self._make_args(
            dockerfile=dockerfile, mount=["invalid-mount-format"]
        )
        result = _execute(args, _SandboxCliConfig(network_sandbox=False))
        assert result == EXIT_INFRA_ERROR

    def test_invalid_allowlist_returns_infra_error(
        self, tmp_path: Path
    ) -> None:
        """Invalid allowlist returns EXIT_INFRA_ERROR."""
        from airut.sandbox_cli import _SandboxCliConfig

        dockerfile = tmp_path / "Dockerfile"
        dockerfile.write_text("FROM alpine")
        allowlist_path = tmp_path / "bad.yaml"
        allowlist_path.write_bytes(b"[invalid: yaml: :")
        args = self._make_args(dockerfile=dockerfile, allowlist=allowlist_path)
        result = _execute(args, _SandboxCliConfig(network_sandbox=True))
        assert result == EXIT_INFRA_ERROR

    @patch("airut.sandbox_cli.Sandbox")
    @patch("airut.sandbox_cli.prepare_secrets")
    @patch("airut.sandbox_cli._install_signal_handlers")
    def test_successful_execution(
        self,
        mock_signal_handlers: MagicMock,
        mock_prepare: MagicMock,
        mock_sandbox_cls: MagicMock,
        tmp_path: Path,
    ) -> None:
        """Successful execution returns command exit code."""
        from airut.sandbox_cli import _SandboxCliConfig

        # Set up Dockerfile
        dockerfile = tmp_path / "Dockerfile"
        dockerfile.write_text("FROM alpine")

        # Mock prepare_secrets
        mock_prepare.return_value = PreparedSecrets(
            env_vars={}, replacements=SecretReplacements()
        )

        # Mock sandbox
        mock_sandbox = MagicMock()
        mock_sandbox_cls.return_value = mock_sandbox
        mock_sandbox.ensure_image.return_value = "test-image:latest"

        mock_task = MagicMock()
        mock_task.execute = AsyncMock(
            return_value=CommandResult(
                exit_code=0,
                stdout="hello\n",
                stderr="",
                duration_ms=50,
                timed_out=False,
            )
        )
        mock_sandbox.create_command_task.return_value = mock_task

        args = self._make_args(dockerfile=dockerfile)
        result = _execute(args, _SandboxCliConfig(network_sandbox=False))

        assert result == 0
        mock_sandbox.startup.assert_called_once()
        mock_sandbox.ensure_image.assert_called_once()
        mock_sandbox.create_command_task.assert_called_once()
        mock_task.execute.assert_called_once()
        mock_sandbox.shutdown.assert_called_once()

    @patch("airut.sandbox_cli.Sandbox")
    @patch("airut.sandbox_cli.prepare_secrets")
    @patch("airut.sandbox_cli._install_signal_handlers")
    def test_sandbox_error_returns_infra_error(
        self,
        mock_signal_handlers: MagicMock,
        mock_prepare: MagicMock,
        mock_sandbox_cls: MagicMock,
        tmp_path: Path,
    ) -> None:
        """Sandbox exception returns EXIT_INFRA_ERROR."""
        from airut.sandbox_cli import _SandboxCliConfig

        dockerfile = tmp_path / "Dockerfile"
        dockerfile.write_text("FROM alpine")

        mock_prepare.return_value = PreparedSecrets(
            env_vars={}, replacements=SecretReplacements()
        )

        mock_sandbox = MagicMock()
        mock_sandbox_cls.return_value = mock_sandbox
        mock_sandbox.startup.side_effect = RuntimeError("podman not found")

        args = self._make_args(dockerfile=dockerfile)
        result = _execute(args, _SandboxCliConfig(network_sandbox=False))

        assert result == EXIT_INFRA_ERROR

    @patch("airut.sandbox_cli.Sandbox")
    @patch("airut.sandbox_cli.prepare_secrets")
    @patch("airut.sandbox_cli._install_signal_handlers")
    def test_timeout_override(
        self,
        mock_signal_handlers: MagicMock,
        mock_prepare: MagicMock,
        mock_sandbox_cls: MagicMock,
        tmp_path: Path,
    ) -> None:
        """CLI --timeout overrides config timeout."""
        from airut.sandbox_cli import _SandboxCliConfig

        dockerfile = tmp_path / "Dockerfile"
        dockerfile.write_text("FROM alpine")

        mock_prepare.return_value = PreparedSecrets(
            env_vars={}, replacements=SecretReplacements()
        )

        mock_sandbox = MagicMock()
        mock_sandbox_cls.return_value = mock_sandbox
        mock_sandbox.ensure_image.return_value = "img:latest"

        mock_task = MagicMock()
        mock_task.execute = AsyncMock(
            return_value=CommandResult(
                exit_code=0,
                stdout="",
                stderr="",
                duration_ms=50,
                timed_out=False,
            )
        )
        mock_sandbox.create_command_task.return_value = mock_task

        config = _SandboxCliConfig(
            resource_limits=ResourceLimits(timeout=600, memory="2g")
        )
        args = self._make_args(dockerfile=dockerfile, timeout=120)
        _execute(args, config)

        # Verify the resource_limits passed have overridden timeout
        create_call = mock_sandbox.create_command_task.call_args
        resource_limits = create_call.kwargs["resource_limits"]
        assert resource_limits.timeout == 120
        assert resource_limits.memory == "2g"

    @patch("airut.sandbox_cli.Sandbox")
    @patch("airut.sandbox_cli.prepare_secrets")
    @patch("airut.sandbox_cli._install_signal_handlers")
    def test_container_command_default_podman(
        self,
        mock_signal_handlers: MagicMock,
        mock_prepare: MagicMock,
        mock_sandbox_cls: MagicMock,
        tmp_path: Path,
    ) -> None:
        """Default container command is podman."""
        from airut.sandbox_cli import _SandboxCliConfig

        dockerfile = tmp_path / "Dockerfile"
        dockerfile.write_text("FROM alpine")

        mock_prepare.return_value = PreparedSecrets(
            env_vars={}, replacements=SecretReplacements()
        )

        mock_sandbox = MagicMock()
        mock_sandbox_cls.return_value = mock_sandbox
        mock_sandbox.ensure_image.return_value = "img:latest"

        mock_task = MagicMock()
        mock_task.execute = AsyncMock(
            return_value=CommandResult(
                exit_code=0,
                stdout="",
                stderr="",
                duration_ms=50,
                timed_out=False,
            )
        )
        mock_sandbox.create_command_task.return_value = mock_task

        args = self._make_args(dockerfile=dockerfile)
        _execute(args, _SandboxCliConfig(network_sandbox=False))

        from airut.sandbox import SandboxConfig

        mock_sandbox_cls.assert_called_once_with(
            SandboxConfig(
                container_command="podman",
                resource_prefix="airut-cli",
                max_image_age_hours=24,
            )
        )

    @patch("airut.sandbox_cli.Sandbox")
    @patch("airut.sandbox_cli.prepare_secrets")
    @patch("airut.sandbox_cli._install_signal_handlers")
    def test_container_command_override(
        self,
        mock_signal_handlers: MagicMock,
        mock_prepare: MagicMock,
        mock_sandbox_cls: MagicMock,
        tmp_path: Path,
    ) -> None:
        """Custom container command is passed through."""
        from airut.sandbox_cli import _SandboxCliConfig

        dockerfile = tmp_path / "Dockerfile"
        dockerfile.write_text("FROM alpine")

        mock_prepare.return_value = PreparedSecrets(
            env_vars={}, replacements=SecretReplacements()
        )

        mock_sandbox = MagicMock()
        mock_sandbox_cls.return_value = mock_sandbox
        mock_sandbox.ensure_image.return_value = "img:latest"

        mock_task = MagicMock()
        mock_task.execute = AsyncMock(
            return_value=CommandResult(
                exit_code=0,
                stdout="",
                stderr="",
                duration_ms=50,
                timed_out=False,
            )
        )
        mock_sandbox.create_command_task.return_value = mock_task

        args = self._make_args(
            dockerfile=dockerfile, container_command="docker"
        )
        _execute(args, _SandboxCliConfig(network_sandbox=False))

        from airut.sandbox import SandboxConfig

        mock_sandbox_cls.assert_called_once_with(
            SandboxConfig(
                container_command="docker",
                resource_prefix="airut-cli",
                max_image_age_hours=24,
            )
        )

    @patch("airut.sandbox_cli.Sandbox")
    @patch("airut.sandbox_cli.prepare_secrets")
    @patch("airut.sandbox_cli._install_signal_handlers")
    def test_max_image_age_zero(
        self,
        mock_signal_handlers: MagicMock,
        mock_prepare: MagicMock,
        mock_sandbox_cls: MagicMock,
        tmp_path: Path,
    ) -> None:
        """--max-image-age 0 propagates to SandboxConfig."""
        from airut.sandbox_cli import _SandboxCliConfig

        dockerfile = tmp_path / "Dockerfile"
        dockerfile.write_text("FROM alpine")

        mock_prepare.return_value = PreparedSecrets(
            env_vars={}, replacements=SecretReplacements()
        )

        mock_sandbox = MagicMock()
        mock_sandbox_cls.return_value = mock_sandbox
        mock_sandbox.ensure_image.return_value = "img:latest"

        mock_task = MagicMock()
        mock_task.execute = AsyncMock(
            return_value=CommandResult(
                exit_code=0,
                stdout="",
                stderr="",
                duration_ms=50,
                timed_out=False,
            )
        )
        mock_sandbox.create_command_task.return_value = mock_task

        args = self._make_args(dockerfile=dockerfile, max_image_age=0)
        _execute(args, _SandboxCliConfig(network_sandbox=False))

        from airut.sandbox import SandboxConfig

        mock_sandbox_cls.assert_called_once_with(
            SandboxConfig(
                container_command="podman",
                resource_prefix="airut-cli",
                max_image_age_hours=0,
            )
        )

    @patch("airut.sandbox_cli.Sandbox")
    @patch("airut.sandbox_cli.prepare_secrets")
    @patch("airut.sandbox_cli._install_signal_handlers")
    def test_network_sandbox_enabled(
        self,
        mock_signal_handlers: MagicMock,
        mock_prepare: MagicMock,
        mock_sandbox_cls: MagicMock,
        tmp_path: Path,
    ) -> None:
        """Network sandbox config is passed when enabled."""
        from airut.sandbox_cli import _SandboxCliConfig

        dockerfile = tmp_path / "Dockerfile"
        dockerfile.write_text("FROM alpine")

        allowlist_path = tmp_path / "allowlist.yaml"
        allowlist_path.write_text("domains:\n  - api.example.com\n")

        mock_prepare.return_value = PreparedSecrets(
            env_vars={}, replacements=SecretReplacements()
        )

        mock_sandbox = MagicMock()
        mock_sandbox_cls.return_value = mock_sandbox
        mock_sandbox.ensure_image.return_value = "img:latest"

        mock_task = MagicMock()
        mock_task.execute = AsyncMock(
            return_value=CommandResult(
                exit_code=0,
                stdout="",
                stderr="",
                duration_ms=50,
                timed_out=False,
            )
        )
        mock_sandbox.create_command_task.return_value = mock_task

        args = self._make_args(dockerfile=dockerfile, allowlist=allowlist_path)
        _execute(args, _SandboxCliConfig(network_sandbox=True))

        create_call = mock_sandbox.create_command_task.call_args
        network_sandbox = create_call.kwargs["network_sandbox"]
        assert network_sandbox is not None
        assert network_sandbox.allowlist is not None
        assert network_sandbox.allowlist.domains[0].host == "api.example.com"

    @patch("airut.sandbox_cli.Sandbox")
    @patch("airut.sandbox_cli.prepare_secrets")
    @patch("airut.sandbox_cli._install_signal_handlers")
    def test_network_sandbox_disabled(
        self,
        mock_signal_handlers: MagicMock,
        mock_prepare: MagicMock,
        mock_sandbox_cls: MagicMock,
        tmp_path: Path,
    ) -> None:
        """Network sandbox config is None when disabled."""
        from airut.sandbox_cli import _SandboxCliConfig

        dockerfile = tmp_path / "Dockerfile"
        dockerfile.write_text("FROM alpine")

        mock_prepare.return_value = PreparedSecrets(
            env_vars={}, replacements=SecretReplacements()
        )

        mock_sandbox = MagicMock()
        mock_sandbox_cls.return_value = mock_sandbox
        mock_sandbox.ensure_image.return_value = "img:latest"

        mock_task = MagicMock()
        mock_task.execute = AsyncMock(
            return_value=CommandResult(
                exit_code=0,
                stdout="",
                stderr="",
                duration_ms=50,
                timed_out=False,
            )
        )
        mock_sandbox.create_command_task.return_value = mock_task

        args = self._make_args(dockerfile=dockerfile)
        _execute(args, _SandboxCliConfig(network_sandbox=False))

        create_call = mock_sandbox.create_command_task.call_args
        assert create_call.kwargs["network_sandbox"] is None

    @patch("airut.sandbox_cli.Sandbox")
    @patch("airut.sandbox_cli.prepare_secrets")
    @patch("airut.sandbox_cli._install_signal_handlers")
    def test_context_files_loaded(
        self,
        mock_signal_handlers: MagicMock,
        mock_prepare: MagicMock,
        mock_sandbox_cls: MagicMock,
        tmp_path: Path,
    ) -> None:
        """Context directory files (excluding Dockerfile) are loaded."""
        from airut.sandbox_cli import _SandboxCliConfig

        context = tmp_path / "context"
        context.mkdir()
        dockerfile = context / "Dockerfile"
        dockerfile.write_text("FROM alpine")
        (context / "requirements.txt").write_bytes(b"flask\n")
        # Create a subdirectory that should be ignored (is_file check)
        (context / "subdir").mkdir()

        mock_prepare.return_value = PreparedSecrets(
            env_vars={}, replacements=SecretReplacements()
        )

        mock_sandbox = MagicMock()
        mock_sandbox_cls.return_value = mock_sandbox
        mock_sandbox.ensure_image.return_value = "img:latest"

        mock_task = MagicMock()
        mock_task.execute = AsyncMock(
            return_value=CommandResult(
                exit_code=0,
                stdout="",
                stderr="",
                duration_ms=50,
                timed_out=False,
            )
        )
        mock_sandbox.create_command_task.return_value = mock_task

        args = self._make_args(dockerfile=dockerfile, context_dir=context)
        _execute(args, _SandboxCliConfig(network_sandbox=False))

        ensure_image_call = mock_sandbox.ensure_image.call_args
        context_files = ensure_image_call[0][1]
        assert "requirements.txt" in context_files
        assert "Dockerfile" not in context_files
        assert "subdir" not in context_files

    @patch("airut.sandbox_cli.Sandbox")
    @patch("airut.sandbox_cli.prepare_secrets")
    @patch("airut.sandbox_cli._install_signal_handlers")
    def test_context_dir_defaults_to_dockerfile_parent(
        self,
        mock_signal_handlers: MagicMock,
        mock_prepare: MagicMock,
        mock_sandbox_cls: MagicMock,
        tmp_path: Path,
    ) -> None:
        """Context dir defaults to Dockerfile's parent directory."""
        from airut.sandbox_cli import _SandboxCliConfig

        container_dir = tmp_path / "container"
        container_dir.mkdir()
        dockerfile = container_dir / "Dockerfile"
        dockerfile.write_text("FROM alpine")
        (container_dir / "extra.sh").write_bytes(b"#!/bin/sh\necho hi\n")

        mock_prepare.return_value = PreparedSecrets(
            env_vars={}, replacements=SecretReplacements()
        )

        mock_sandbox = MagicMock()
        mock_sandbox_cls.return_value = mock_sandbox
        mock_sandbox.ensure_image.return_value = "img:latest"

        mock_task = MagicMock()
        mock_task.execute = AsyncMock(
            return_value=CommandResult(
                exit_code=0,
                stdout="",
                stderr="",
                duration_ms=50,
                timed_out=False,
            )
        )
        mock_sandbox.create_command_task.return_value = mock_task

        args = self._make_args(dockerfile=dockerfile)
        _execute(args, _SandboxCliConfig(network_sandbox=False))

        ensure_image_call = mock_sandbox.ensure_image.call_args
        context_files = ensure_image_call[0][1]
        assert "extra.sh" in context_files

    @patch("airut.sandbox_cli.Sandbox")
    @patch("airut.sandbox_cli.prepare_secrets")
    @patch("airut.sandbox_cli._install_signal_handlers")
    def test_cwd_mount_inserted_first(
        self,
        mock_signal_handlers: MagicMock,
        mock_prepare: MagicMock,
        mock_sandbox_cls: MagicMock,
        tmp_path: Path,
    ) -> None:
        """CWD is mounted as first mount at /workspace."""
        from airut.sandbox_cli import _SandboxCliConfig

        dockerfile = tmp_path / "Dockerfile"
        dockerfile.write_text("FROM alpine")

        mock_prepare.return_value = PreparedSecrets(
            env_vars={}, replacements=SecretReplacements()
        )

        mock_sandbox = MagicMock()
        mock_sandbox_cls.return_value = mock_sandbox
        mock_sandbox.ensure_image.return_value = "img:latest"

        mock_task = MagicMock()
        mock_task.execute = AsyncMock(
            return_value=CommandResult(
                exit_code=0,
                stdout="",
                stderr="",
                duration_ms=50,
                timed_out=False,
            )
        )
        mock_sandbox.create_command_task.return_value = mock_task

        args = self._make_args(
            dockerfile=dockerfile,
            mount=["/extra:/extra"],
        )
        _execute(args, _SandboxCliConfig(network_sandbox=False))

        create_call = mock_sandbox.create_command_task.call_args
        mounts = create_call.kwargs["mounts"]
        assert mounts[0].container_path == "/workspace"
        assert len(mounts) == 2
        assert mounts[1].container_path == "/extra"

    @patch("airut.sandbox_cli.Sandbox")
    @patch("airut.sandbox_cli.prepare_secrets")
    @patch("airut.sandbox_cli._install_signal_handlers")
    def test_passthrough_entrypoint(
        self,
        mock_signal_handlers: MagicMock,
        mock_prepare: MagicMock,
        mock_sandbox_cls: MagicMock,
        tmp_path: Path,
    ) -> None:
        """ensure_image is called with passthrough_entrypoint=True."""
        from airut.sandbox_cli import _SandboxCliConfig

        dockerfile = tmp_path / "Dockerfile"
        dockerfile.write_text("FROM alpine")

        mock_prepare.return_value = PreparedSecrets(
            env_vars={}, replacements=SecretReplacements()
        )

        mock_sandbox = MagicMock()
        mock_sandbox_cls.return_value = mock_sandbox
        mock_sandbox.ensure_image.return_value = "img:latest"

        mock_task = MagicMock()
        mock_task.execute = AsyncMock(
            return_value=CommandResult(
                exit_code=0,
                stdout="",
                stderr="",
                duration_ms=50,
                timed_out=False,
            )
        )
        mock_sandbox.create_command_task.return_value = mock_task

        args = self._make_args(dockerfile=dockerfile)
        _execute(args, _SandboxCliConfig(network_sandbox=False))

        ensure_image_call = mock_sandbox.ensure_image.call_args
        assert ensure_image_call.kwargs["passthrough_entrypoint"] is True

    @patch("airut.sandbox_cli.Sandbox")
    @patch("airut.sandbox_cli.prepare_secrets")
    @patch("airut.sandbox_cli._install_signal_handlers")
    def test_context_dir_not_existing(
        self,
        mock_signal_handlers: MagicMock,
        mock_prepare: MagicMock,
        mock_sandbox_cls: MagicMock,
        tmp_path: Path,
    ) -> None:
        """Non-existent context dir yields empty context files."""
        from airut.sandbox_cli import _SandboxCliConfig

        # Place Dockerfile in tmp_path, but set context_dir to non-existent
        dockerfile = tmp_path / "Dockerfile"
        dockerfile.write_text("FROM alpine")
        nonexistent_ctx = tmp_path / "no-such-dir"

        mock_prepare.return_value = PreparedSecrets(
            env_vars={}, replacements=SecretReplacements()
        )

        mock_sandbox = MagicMock()
        mock_sandbox_cls.return_value = mock_sandbox
        mock_sandbox.ensure_image.return_value = "img:latest"

        mock_task = MagicMock()
        mock_task.execute = AsyncMock(
            return_value=CommandResult(
                exit_code=0,
                stdout="",
                stderr="",
                duration_ms=50,
                timed_out=False,
            )
        )
        mock_sandbox.create_command_task.return_value = mock_task

        args = self._make_args(
            dockerfile=dockerfile, context_dir=nonexistent_ctx
        )
        _execute(args, _SandboxCliConfig(network_sandbox=False))

        ensure_image_call = mock_sandbox.ensure_image.call_args
        context_files = ensure_image_call[0][1]
        assert context_files == {}

    @patch("airut.sandbox_cli.Sandbox")
    @patch("airut.sandbox_cli.prepare_secrets")
    @patch("airut.sandbox_cli._install_signal_handlers")
    def test_network_log_cleanup_on_oserror(
        self,
        mock_signal_handlers: MagicMock,
        mock_prepare: MagicMock,
        mock_sandbox_cls: MagicMock,
        tmp_path: Path,
    ) -> None:
        """OSError during network log cleanup is suppressed."""
        from airut.sandbox_cli import _SandboxCliConfig

        dockerfile = tmp_path / "Dockerfile"
        dockerfile.write_text("FROM alpine")

        mock_prepare.return_value = PreparedSecrets(
            env_vars={}, replacements=SecretReplacements()
        )

        mock_sandbox = MagicMock()
        mock_sandbox_cls.return_value = mock_sandbox
        mock_sandbox.startup.side_effect = RuntimeError("fail")

        args = self._make_args(dockerfile=dockerfile)
        # Just verify it doesn't raise even with network sandbox enabled
        # and a broken state
        result = _execute(args, _SandboxCliConfig(network_sandbox=True))
        assert result == EXIT_INFRA_ERROR

    @patch("airut.sandbox_cli.Sandbox")
    @patch("airut.sandbox_cli.prepare_secrets")
    @patch("airut.sandbox_cli._install_signal_handlers")
    def test_shutdown_called_on_error_during_image_build(
        self,
        mock_signal_handlers: MagicMock,
        mock_prepare: MagicMock,
        mock_sandbox_cls: MagicMock,
        tmp_path: Path,
    ) -> None:
        """Sandbox is shut down even when ensure_image fails."""
        from airut.sandbox_cli import _SandboxCliConfig

        dockerfile = tmp_path / "Dockerfile"
        dockerfile.write_text("FROM alpine")

        mock_prepare.return_value = PreparedSecrets(
            env_vars={}, replacements=SecretReplacements()
        )

        mock_sandbox = MagicMock()
        mock_sandbox_cls.return_value = mock_sandbox
        mock_sandbox.ensure_image.side_effect = RuntimeError("build failed")

        args = self._make_args(dockerfile=dockerfile)
        result = _execute(args, _SandboxCliConfig(network_sandbox=False))

        assert result == EXIT_INFRA_ERROR
        mock_sandbox.shutdown.assert_called_once()

    @patch("airut.sandbox_cli.Sandbox")
    @patch("airut.sandbox_cli.prepare_secrets")
    @patch("airut.sandbox_cli._install_signal_handlers")
    def test_shutdown_exception_does_not_swallow_exit_code(
        self,
        mock_signal_handlers: MagicMock,
        mock_prepare: MagicMock,
        mock_sandbox_cls: MagicMock,
        tmp_path: Path,
    ) -> None:
        """Shutdown error does not override the command's exit code."""
        from airut.sandbox_cli import _SandboxCliConfig

        dockerfile = tmp_path / "Dockerfile"
        dockerfile.write_text("FROM alpine")

        mock_prepare.return_value = PreparedSecrets(
            env_vars={}, replacements=SecretReplacements()
        )

        mock_sandbox = MagicMock()
        mock_sandbox_cls.return_value = mock_sandbox
        mock_sandbox.ensure_image.return_value = "img:latest"

        mock_task = MagicMock()
        mock_task.execute = AsyncMock(
            return_value=CommandResult(
                exit_code=0,
                stdout="",
                stderr="",
                duration_ms=50,
                timed_out=False,
            )
        )
        mock_sandbox.create_command_task.return_value = mock_task
        mock_sandbox.shutdown.side_effect = RuntimeError("shutdown failed")

        args = self._make_args(dockerfile=dockerfile)
        result = _execute(args, _SandboxCliConfig(network_sandbox=False))

        # Exit code from task.execute (0) is preserved, not EXIT_INFRA_ERROR
        assert result == 0
        mock_sandbox.shutdown.assert_called_once()

    @patch("airut.sandbox_cli.Sandbox")
    @patch("airut.sandbox_cli.prepare_secrets")
    @patch("airut.sandbox_cli._install_signal_handlers")
    def test_network_log_with_user_path(
        self,
        mock_signal_handlers: MagicMock,
        mock_prepare: MagicMock,
        mock_sandbox_cls: MagicMock,
        tmp_path: Path,
    ) -> None:
        """Network log user path is forwarded correctly."""
        from airut.sandbox_cli import _SandboxCliConfig

        dockerfile = tmp_path / "Dockerfile"
        dockerfile.write_text("FROM alpine")
        net_log = tmp_path / "net.log"

        mock_prepare.return_value = PreparedSecrets(
            env_vars={}, replacements=SecretReplacements()
        )

        mock_sandbox = MagicMock()
        mock_sandbox_cls.return_value = mock_sandbox
        mock_sandbox.ensure_image.return_value = "img:latest"

        mock_task = MagicMock()
        mock_task.execute = AsyncMock(
            return_value=CommandResult(
                exit_code=0,
                stdout="",
                stderr="",
                duration_ms=50,
                timed_out=False,
            )
        )
        mock_sandbox.create_command_task.return_value = mock_task

        args = self._make_args(dockerfile=dockerfile, network_log=net_log)
        _execute(args, _SandboxCliConfig(network_sandbox=True))

        create_call = mock_sandbox.create_command_task.call_args
        network_log_path = create_call.kwargs["network_log_path"]
        assert network_log_path == net_log.resolve()

    @patch("airut.sandbox_cli.Sandbox")
    @patch("airut.sandbox_cli.prepare_secrets")
    @patch("airut.sandbox_cli._install_signal_handlers")
    def test_verbose_sets_airut_verbose_env(
        self,
        mock_signal_handlers: MagicMock,
        mock_prepare: MagicMock,
        mock_sandbox_cls: MagicMock,
        tmp_path: Path,
    ) -> None:
        """Verbose flag injects AIRUT_VERBOSE=1 into container env."""
        from airut.sandbox_cli import _SandboxCliConfig

        dockerfile = tmp_path / "Dockerfile"
        dockerfile.write_text("FROM alpine")

        mock_prepare.return_value = PreparedSecrets(
            env_vars={}, replacements=SecretReplacements()
        )

        mock_sandbox = MagicMock()
        mock_sandbox_cls.return_value = mock_sandbox
        mock_sandbox.ensure_image.return_value = "img:latest"

        mock_task = MagicMock()
        mock_task.execute = AsyncMock(
            return_value=CommandResult(
                exit_code=0,
                stdout="",
                stderr="",
                duration_ms=50,
                timed_out=False,
            )
        )
        mock_sandbox.create_command_task.return_value = mock_task

        args = self._make_args(dockerfile=dockerfile, verbose=True)
        _execute(args, _SandboxCliConfig(network_sandbox=False))

        create_call = mock_sandbox.create_command_task.call_args
        env = create_call.kwargs["env"]
        assert env.variables["AIRUT_VERBOSE"] == "1"

    @patch("airut.sandbox_cli.Sandbox")
    @patch("airut.sandbox_cli.prepare_secrets")
    @patch("airut.sandbox_cli._install_signal_handlers")
    def test_debug_sets_airut_verbose_env(
        self,
        mock_signal_handlers: MagicMock,
        mock_prepare: MagicMock,
        mock_sandbox_cls: MagicMock,
        tmp_path: Path,
    ) -> None:
        """Debug flag also injects AIRUT_VERBOSE=1 into container env."""
        from airut.sandbox_cli import _SandboxCliConfig

        dockerfile = tmp_path / "Dockerfile"
        dockerfile.write_text("FROM alpine")

        mock_prepare.return_value = PreparedSecrets(
            env_vars={}, replacements=SecretReplacements()
        )

        mock_sandbox = MagicMock()
        mock_sandbox_cls.return_value = mock_sandbox
        mock_sandbox.ensure_image.return_value = "img:latest"

        mock_task = MagicMock()
        mock_task.execute = AsyncMock(
            return_value=CommandResult(
                exit_code=0,
                stdout="",
                stderr="",
                duration_ms=50,
                timed_out=False,
            )
        )
        mock_sandbox.create_command_task.return_value = mock_task

        args = self._make_args(dockerfile=dockerfile, debug=True)
        _execute(args, _SandboxCliConfig(network_sandbox=False))

        create_call = mock_sandbox.create_command_task.call_args
        env = create_call.kwargs["env"]
        assert env.variables["AIRUT_VERBOSE"] == "1"

    @patch("airut.sandbox_cli.Sandbox")
    @patch("airut.sandbox_cli.prepare_secrets")
    @patch("airut.sandbox_cli._install_signal_handlers")
    def test_no_verbose_omits_airut_verbose_env(
        self,
        mock_signal_handlers: MagicMock,
        mock_prepare: MagicMock,
        mock_sandbox_cls: MagicMock,
        tmp_path: Path,
    ) -> None:
        """Without verbose, AIRUT_VERBOSE is not in container env."""
        from airut.sandbox_cli import _SandboxCliConfig

        dockerfile = tmp_path / "Dockerfile"
        dockerfile.write_text("FROM alpine")

        mock_prepare.return_value = PreparedSecrets(
            env_vars={}, replacements=SecretReplacements()
        )

        mock_sandbox = MagicMock()
        mock_sandbox_cls.return_value = mock_sandbox
        mock_sandbox.ensure_image.return_value = "img:latest"

        mock_task = MagicMock()
        mock_task.execute = AsyncMock(
            return_value=CommandResult(
                exit_code=0,
                stdout="",
                stderr="",
                duration_ms=50,
                timed_out=False,
            )
        )
        mock_sandbox.create_command_task.return_value = mock_task

        args = self._make_args(dockerfile=dockerfile)
        _execute(args, _SandboxCliConfig(network_sandbox=False))

        create_call = mock_sandbox.create_command_task.call_args
        env = create_call.kwargs["env"]
        assert "AIRUT_VERBOSE" not in env.variables

    @patch("airut.sandbox_cli.Sandbox")
    @patch("airut.sandbox_cli.prepare_secrets")
    @patch("airut.sandbox_cli._install_signal_handlers")
    def test_network_log_live_passes_callback(
        self,
        mock_signal_handlers: MagicMock,
        mock_prepare: MagicMock,
        mock_sandbox_cls: MagicMock,
        tmp_path: Path,
    ) -> None:
        """--network-log-live passes on_network_line callback to execute."""
        from airut.sandbox_cli import _SandboxCliConfig

        dockerfile = tmp_path / "Dockerfile"
        dockerfile.write_text("FROM alpine")

        mock_prepare.return_value = PreparedSecrets(
            env_vars={}, replacements=SecretReplacements()
        )

        mock_sandbox = MagicMock()
        mock_sandbox_cls.return_value = mock_sandbox
        mock_sandbox.ensure_image.return_value = "img:latest"

        mock_task = MagicMock()
        mock_task.execute = AsyncMock(
            return_value=CommandResult(
                exit_code=0,
                stdout="",
                stderr="",
                duration_ms=50,
                timed_out=False,
            )
        )
        mock_sandbox.create_command_task.return_value = mock_task

        args = self._make_args(dockerfile=dockerfile, network_log_live=True)
        _execute(args, _SandboxCliConfig(network_sandbox=True))

        execute_call = mock_task.execute.call_args
        assert execute_call.kwargs["on_network_line"] is not None

    @patch("airut.sandbox_cli.Sandbox")
    @patch("airut.sandbox_cli.prepare_secrets")
    @patch("airut.sandbox_cli._install_signal_handlers")
    def test_no_network_log_live_omits_callback(
        self,
        mock_signal_handlers: MagicMock,
        mock_prepare: MagicMock,
        mock_sandbox_cls: MagicMock,
        tmp_path: Path,
    ) -> None:
        """Without --network-log-live, on_network_line is None."""
        from airut.sandbox_cli import _SandboxCliConfig

        dockerfile = tmp_path / "Dockerfile"
        dockerfile.write_text("FROM alpine")

        mock_prepare.return_value = PreparedSecrets(
            env_vars={}, replacements=SecretReplacements()
        )

        mock_sandbox = MagicMock()
        mock_sandbox_cls.return_value = mock_sandbox
        mock_sandbox.ensure_image.return_value = "img:latest"

        mock_task = MagicMock()
        mock_task.execute = AsyncMock(
            return_value=CommandResult(
                exit_code=0,
                stdout="",
                stderr="",
                duration_ms=50,
                timed_out=False,
            )
        )
        mock_sandbox.create_command_task.return_value = mock_task

        args = self._make_args(dockerfile=dockerfile)
        _execute(args, _SandboxCliConfig(network_sandbox=False))

        execute_call = mock_task.execute.call_args
        assert execute_call.kwargs["on_network_line"] is None

    @pytest.mark.parametrize(
        ("callback_kwarg", "stream_name", "line"),
        [
            ("on_output", "stdout", "hello\n"),
            ("on_stderr", "stderr", "warning\n"),
        ],
    )
    @patch("airut.sandbox_cli.Sandbox")
    @patch("airut.sandbox_cli.prepare_secrets")
    @patch("airut.sandbox_cli._install_signal_handlers")
    def test_output_callbacks_flush(
        self,
        mock_signal_handlers: MagicMock,
        mock_prepare: MagicMock,
        mock_sandbox_cls: MagicMock,
        tmp_path: Path,
        callback_kwarg: str,
        stream_name: str,
        line: str,
    ) -> None:
        """on_output/on_stderr callbacks write and flush during execution."""
        from airut.sandbox_cli import _SandboxCliConfig

        dockerfile = tmp_path / "Dockerfile"
        dockerfile.write_text("FROM alpine")

        mock_prepare.return_value = PreparedSecrets(
            env_vars={}, replacements=SecretReplacements()
        )

        mock_sandbox = MagicMock()
        mock_sandbox_cls.return_value = mock_sandbox
        mock_sandbox.ensure_image.return_value = "img:latest"

        mock_task = MagicMock()
        mock_sandbox.create_command_task.return_value = mock_task

        # Have execute invoke the callback during execution, testing
        # the real data flow rather than extracting the callback after.
        mock_stream = MagicMock()

        async def _fake_execute(
            command: list[str],
            **kwargs: Callable[[str], None] | None,
        ) -> CommandResult:
            callback = kwargs[callback_kwarg]
            assert callback is not None
            callback(line)
            return CommandResult(
                exit_code=0,
                stdout="",
                stderr="",
                duration_ms=50,
                timed_out=False,
            )

        mock_task.execute = _fake_execute

        args = self._make_args(dockerfile=dockerfile)
        with patch("airut.sandbox_cli.sys") as mock_sys:
            setattr(mock_sys, stream_name, mock_stream)
            # Keep the other stream as a MagicMock so it doesn't error
            other = "stderr" if stream_name == "stdout" else "stdout"
            setattr(mock_sys, other, MagicMock())
            _execute(args, _SandboxCliConfig(network_sandbox=False))

        mock_stream.write.assert_called_once_with(line)
        mock_stream.flush.assert_called_once()


# -------------------------------------------------------------------
# _make_network_line_callback
# -------------------------------------------------------------------


class TestMakeNetworkLineCallback:
    """Tests for the network log line callback factory."""

    def test_writes_prefixed_line_to_stderr(self) -> None:
        """Callback writes [net] prefixed lines to stderr."""
        callback = _make_network_line_callback()
        with patch("airut.sandbox_cli.sys") as mock_sys:
            mock_sys.stderr = MagicMock()
            callback("allowed GET https://api.github.com/repos -> 200")
            mock_sys.stderr.write.assert_called_once_with(
                "[net] allowed GET https://api.github.com/repos -> 200\n"
            )
            mock_sys.stderr.flush.assert_called_once()

    def test_writes_blocked_line(self) -> None:
        """Callback formats blocked lines correctly."""
        callback = _make_network_line_callback()
        with patch("airut.sandbox_cli.sys") as mock_sys:
            mock_sys.stderr = MagicMock()
            callback("BLOCKED GET https://evil.com/exfil -> 403")
            mock_sys.stderr.write.assert_called_once_with(
                "[net] BLOCKED GET https://evil.com/exfil -> 403\n"
            )


# -------------------------------------------------------------------
# _run
# -------------------------------------------------------------------


class TestRun:
    """Tests for the top-level CLI runner."""

    def test_no_subcommand_returns_infra_error(self) -> None:
        """No subcommand yields EXIT_INFRA_ERROR."""
        with patch("airut.sandbox_cli._setup_logging"):
            result = _run([])
        assert result == EXIT_INFRA_ERROR

    @patch("airut.sandbox_cli._execute")
    @patch("airut.sandbox_cli._load_config")
    def test_config_error_returns_infra_error(
        self,
        mock_load_config: MagicMock,
        mock_execute: MagicMock,
    ) -> None:
        """Config error yields EXIT_INFRA_ERROR."""
        from airut.sandbox_cli import _ConfigError

        mock_load_config.side_effect = _ConfigError("bad config")
        with patch("airut.sandbox_cli._setup_logging"):
            result = _run(["run", "--", "echo"])
        assert result == EXIT_INFRA_ERROR
        mock_execute.assert_not_called()

    @patch("airut.sandbox_cli._execute")
    @patch("airut.sandbox_cli._load_config")
    def test_successful_run_delegates_to_execute(
        self,
        mock_load_config: MagicMock,
        mock_execute: MagicMock,
    ) -> None:
        """Successful run delegates to _execute."""
        from airut.sandbox_cli import _SandboxCliConfig

        mock_load_config.return_value = _SandboxCliConfig()
        mock_execute.return_value = 0

        with patch("airut.sandbox_cli._setup_logging"):
            result = _run(["run", "--", "echo", "hello"])
        assert result == 0
        mock_execute.assert_called_once()

    @patch("airut.sandbox_cli._execute")
    @patch("airut.sandbox_cli._load_config")
    def test_custom_config_path(
        self,
        mock_load_config: MagicMock,
        mock_execute: MagicMock,
        tmp_path: Path,
    ) -> None:
        """Custom --config path is passed to _load_config."""
        from airut.sandbox_cli import _SandboxCliConfig

        config_path = tmp_path / "custom.yaml"
        mock_load_config.return_value = _SandboxCliConfig()
        mock_execute.return_value = 0

        with patch("airut.sandbox_cli._setup_logging"):
            _run(
                [
                    "run",
                    "--config",
                    str(config_path),
                    "--",
                    "echo",
                ]
            )

        mock_load_config.assert_called_once_with(config_path)

    @patch("airut.sandbox_cli._execute")
    @patch("airut.sandbox_cli._load_config")
    def test_default_config_path(
        self,
        mock_load_config: MagicMock,
        mock_execute: MagicMock,
    ) -> None:
        """Default config path is .airut/sandbox.yaml."""
        from airut.sandbox_cli import _SandboxCliConfig

        mock_load_config.return_value = _SandboxCliConfig()
        mock_execute.return_value = 0

        with patch("airut.sandbox_cli._setup_logging"):
            _run(["run", "--", "echo"])

        mock_load_config.assert_called_once_with(Path(".airut/sandbox.yaml"))

    @patch("airut.sandbox_cli._execute")
    @patch("airut.sandbox_cli._load_config")
    def test_verbose_passed_to_setup_logging(
        self,
        mock_load_config: MagicMock,
        mock_execute: MagicMock,
    ) -> None:
        """Verbose flag is passed to _setup_logging."""
        from airut.sandbox_cli import _SandboxCliConfig

        mock_load_config.return_value = _SandboxCliConfig()
        mock_execute.return_value = 0

        with patch("airut.sandbox_cli._setup_logging") as mock_logging:
            _run(["run", "--verbose", "--", "echo"])
            mock_logging.assert_called_once_with(
                verbose=True, debug=False, log_file=None
            )

    @patch("airut.sandbox_cli._execute")
    @patch("airut.sandbox_cli._load_config")
    def test_debug_passed_to_setup_logging(
        self,
        mock_load_config: MagicMock,
        mock_execute: MagicMock,
    ) -> None:
        """Debug flag is passed to _setup_logging."""
        from airut.sandbox_cli import _SandboxCliConfig

        mock_load_config.return_value = _SandboxCliConfig()
        mock_execute.return_value = 0

        with patch("airut.sandbox_cli._setup_logging") as mock_logging:
            _run(["run", "--debug", "--", "echo"])
            mock_logging.assert_called_once_with(
                verbose=False, debug=True, log_file=None
            )

    @patch("airut.sandbox_cli._execute")
    @patch("airut.sandbox_cli._load_config")
    def test_log_file_passed_to_setup_logging(
        self,
        mock_load_config: MagicMock,
        mock_execute: MagicMock,
        tmp_path: Path,
    ) -> None:
        """--log path is passed to _setup_logging."""
        from airut.sandbox_cli import _SandboxCliConfig

        log_path = tmp_path / "sandbox.log"
        mock_load_config.return_value = _SandboxCliConfig()
        mock_execute.return_value = 0

        with patch("airut.sandbox_cli._setup_logging") as mock_logging:
            _run(["run", "--log", str(log_path), "--", "echo"])
            mock_logging.assert_called_once_with(
                verbose=False, debug=False, log_file=log_path
            )


# -------------------------------------------------------------------
# main
# -------------------------------------------------------------------


class TestMain:
    """Tests for the entry point."""

    @patch("airut.sandbox_cli._run")
    def test_main_calls_sys_exit(self, mock_run: MagicMock) -> None:
        """main() calls sys.exit with _run's return value."""
        mock_run.return_value = 42
        with pytest.raises(SystemExit) as exc_info:
            main()
        assert exc_info.value.code == 42
        mock_run.assert_called_once_with(sys.argv[1:])

    @patch("airut.sandbox_cli._run")
    def test_main_exit_zero(self, mock_run: MagicMock) -> None:
        """main() exits 0 on success."""
        mock_run.return_value = 0
        with pytest.raises(SystemExit) as exc_info:
            main()
        assert exc_info.value.code == 0


# -------------------------------------------------------------------
# _SandboxCliConfig dataclass
# -------------------------------------------------------------------


class TestSandboxCliConfig:
    """Tests for the _SandboxCliConfig dataclass."""

    def test_defaults(self) -> None:
        """Default config values."""
        from airut.sandbox_cli import _SandboxCliConfig

        config = _SandboxCliConfig()
        assert config.env == {}
        assert config.pass_env == []
        assert config.masked_secrets == []
        assert config.signing_credentials == []
        assert config.network_sandbox is True
        assert config.resource_limits == ResourceLimits()

    def test_frozen(self) -> None:
        """Config is immutable (frozen dataclass)."""
        from airut.sandbox_cli import _SandboxCliConfig

        config = _SandboxCliConfig()
        with pytest.raises(AttributeError):
            config.network_sandbox = False  # type: ignore[misc]


# -------------------------------------------------------------------
# _ConfigError
# -------------------------------------------------------------------


class TestConfigError:
    """Tests for the _ConfigError exception class."""

    def test_is_exception(self) -> None:
        """_ConfigError is an Exception."""
        from airut.sandbox_cli import _ConfigError

        err = _ConfigError("test message")
        assert isinstance(err, Exception)
        assert str(err) == "test message"


# -------------------------------------------------------------------
# Integration-style tests for _load_config with !env
# -------------------------------------------------------------------


class TestRunIntegration:
    """Integration-style tests calling _run without mocking _execute.

    These tests exercise code paths through _run -> _execute that the
    isolated unit tests miss due to coverage tracing limitations.
    """

    def test_no_command_after_double_dash(self) -> None:
        """_run with empty command reaches _execute early return."""
        with patch("airut.sandbox_cli._setup_logging"):
            result = _run(["run", "--"])
        assert result == EXIT_INFRA_ERROR

    def test_unknown_subcommand_branch(self) -> None:
        """Unknown subcommand triggers the else branch in _run."""
        with patch("airut.sandbox_cli._setup_logging"):
            with patch("airut.sandbox_cli._parse_args") as mock_parse:
                mock_parse.return_value = argparse.Namespace(
                    subcommand="unknown",
                    verbose=False,
                    debug=False,
                    command=[],
                )
                result = _run(["unknown", "--", "cmd"])
        assert result == EXIT_INFRA_ERROR

    @patch("airut.sandbox_cli.Sandbox")
    @patch("airut.sandbox_cli.prepare_secrets")
    @patch("airut.sandbox_cli._install_signal_handlers")
    def test_network_log_cleanup_oserror_suppressed(
        self,
        mock_signal_handlers: MagicMock,
        mock_prepare: MagicMock,
        mock_sandbox_cls: MagicMock,
        tmp_path: Path,
    ) -> None:
        """OSError during network_log_cleanup is silently caught."""
        from airut.sandbox_cli import _SandboxCliConfig

        dockerfile = tmp_path / "Dockerfile"
        dockerfile.write_text("FROM alpine")

        mock_prepare.return_value = PreparedSecrets(
            env_vars={}, replacements=SecretReplacements()
        )

        mock_sandbox = MagicMock()
        mock_sandbox_cls.return_value = mock_sandbox
        mock_sandbox.ensure_image.return_value = "img:latest"

        mock_task = MagicMock()
        mock_task.execute = AsyncMock(
            return_value=CommandResult(
                exit_code=0,
                stdout="",
                stderr="",
                duration_ms=50,
                timed_out=False,
            )
        )
        mock_sandbox.create_command_task.return_value = mock_task

        # Patch _setup_network_log to return a cleanup that raises OSError
        def bad_cleanup() -> None:
            raise OSError("disk full")

        with patch(
            "airut.sandbox_cli._setup_network_log",
            return_value=(tmp_path / "net.log", bad_cleanup),
        ):
            args = argparse.Namespace(
                command=["echo", "hi"],
                dockerfile=dockerfile,
                context_dir=None,
                allowlist=None,
                timeout=None,
                container_command=None,
                mount=[],
                network_log=tmp_path / "net.log",
                network_log_live=False,
            )
            result = _execute(args, _SandboxCliConfig(network_sandbox=True))

        assert result == 0


class TestLoadConfigWithEnvTag:
    """Tests for config loading with !env tag resolution."""

    def test_env_tag_resolved(
        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """!env tags in config are resolved from environment."""
        monkeypatch.setenv("MY_SECRET", "secret-value-123")
        cfg_file = tmp_path / "config.yaml"
        cfg_file.write_text(
            "masked_secrets:\n"
            "  TOKEN:\n"
            "    value: !env MY_SECRET\n"
            "    scopes:\n"
            "      - api.example.com\n"
            "    headers:\n"
            "      - Authorization\n"
        )
        config = _load_config(cfg_file)
        assert len(config.masked_secrets) == 1
        assert config.masked_secrets[0].real_value == "secret-value-123"

    def test_env_tag_missing_raises(
        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """Missing !env var raises _ConfigError."""
        monkeypatch.delenv("UNSET_VAR", raising=False)
        cfg_file = tmp_path / "config.yaml"
        cfg_file.write_text(
            "masked_secrets:\n"
            "  TOKEN:\n"
            "    value: !env UNSET_VAR\n"
            "    scopes:\n"
            "      - api.example.com\n"
            "    headers:\n"
            "      - Authorization\n"
        )
        from airut.sandbox_cli import _ConfigError

        with pytest.raises(_ConfigError, match="environment variable"):
            _load_config(cfg_file)

    def test_signing_credentials_with_env(
        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """Signing credentials with !env tags are resolved."""
        monkeypatch.setenv("AWS_AKI", "AKIATEST")
        monkeypatch.setenv("AWS_SAK", "secretkey")
        monkeypatch.setenv("AWS_ST", "sessiontoken")
        cfg_file = tmp_path / "config.yaml"
        cfg_file.write_text(
            "signing_credentials:\n"
            "  aws:\n"
            "    type: aws-sigv4\n"
            "    access_key_id: !env AWS_AKI\n"
            "    secret_access_key: !env AWS_SAK\n"
            "    session_token: !env AWS_ST\n"
            "    scopes:\n"
            "      - bedrock.us-east-1.amazonaws.com\n"
        )
        config = _load_config(cfg_file)
        assert len(config.signing_credentials) == 1
        cred = config.signing_credentials[0]
        assert cred.access_key_id == "AKIATEST"
        assert cred.secret_access_key == "secretkey"
        assert cred.session_token == "sessiontoken"
        assert cred.access_key_id_env_var == "AWS_AKI"
        assert cred.secret_access_key_env_var == "AWS_SAK"
        assert cred.session_token_env_var == "AWS_ST"

    def test_allow_foreign_credentials_from_yaml(
        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """allow_foreign_credentials flows through YAML loading."""
        monkeypatch.setenv("CF_TOKEN_VAL", "cf-secret-123")
        cfg_file = tmp_path / "config.yaml"
        cfg_file.write_text(
            "masked_secrets:\n"
            "  CF_TOKEN:\n"
            "    value: !env CF_TOKEN_VAL\n"
            "    scopes:\n"
            "      - api.cloudflare.com\n"
            "    headers:\n"
            "      - Authorization\n"
            "    allow_foreign_credentials: true\n"
        )
        config = _load_config(cfg_file)
        assert len(config.masked_secrets) == 1
        assert config.masked_secrets[0].allow_foreign_credentials is True


# -------------------------------------------------------------------
# Image subcommand argument parsing
# -------------------------------------------------------------------


class TestParseArgsImage:
    """Tests for image subcommand argument parsing."""

    def test_image_hash_basic(self) -> None:
        """Image hash is parsed correctly."""
        args = _parse_args(["image", "hash"])
        assert args.subcommand == "image"
        assert args.image_command == "hash"

    def test_image_hash_with_dockerfile(self, tmp_path: Path) -> None:
        """Image hash --dockerfile is parsed."""
        df = tmp_path / "Dockerfile"
        df.touch()
        args = _parse_args(["image", "hash", "--dockerfile", str(df)])
        assert args.dockerfile == df

    def test_image_hash_with_context_dir(self, tmp_path: Path) -> None:
        """Image hash --context-dir is parsed."""
        ctx = tmp_path / "context"
        ctx.mkdir()
        args = _parse_args(["image", "hash", "--context-dir", str(ctx)])
        assert args.context_dir == ctx

    def test_image_hash_verbose(self) -> None:
        """Image hash --verbose is parsed."""
        args = _parse_args(["image", "hash", "--verbose"])
        assert args.verbose is True

    def test_image_hash_debug(self) -> None:
        """Image hash --debug is parsed."""
        args = _parse_args(["image", "hash", "--debug"])
        assert args.debug is True

    def test_image_save_basic(self, tmp_path: Path) -> None:
        """Image save DIR is parsed correctly."""
        args = _parse_args(["image", "save", str(tmp_path)])
        assert args.subcommand == "image"
        assert args.image_command == "save"
        assert args.directory == tmp_path

    def test_image_save_with_options(self, tmp_path: Path) -> None:
        """Image save with all options is parsed."""
        df = tmp_path / "Dockerfile"
        df.touch()
        out = tmp_path / "out"
        args = _parse_args(
            [
                "image",
                "save",
                str(out),
                "--dockerfile",
                str(df),
                "--container-command",
                "docker",
                "--verbose",
            ]
        )
        assert args.directory == out
        assert args.dockerfile == df
        assert args.container_command == "docker"
        assert args.verbose is True

    def test_image_load_basic(self, tmp_path: Path) -> None:
        """Image load DIR is parsed correctly."""
        args = _parse_args(["image", "load", str(tmp_path)])
        assert args.subcommand == "image"
        assert args.image_command == "load"
        assert args.directory == tmp_path

    def test_image_load_with_container_command(self, tmp_path: Path) -> None:
        """Image load --container-command is parsed."""
        args = _parse_args(
            [
                "image",
                "load",
                str(tmp_path),
                "--container-command",
                "docker",
            ]
        )
        assert args.container_command == "docker"

    def test_image_no_subcommand(self) -> None:
        """Image with no subcommand sets image_command to None."""
        args = _parse_args(["image"])
        assert args.subcommand == "image"
        assert args.image_command is None

    def test_double_dash_irrelevant_for_image(self) -> None:
        """-- separator doesn't affect image subcommands."""
        args = _parse_args(["image", "hash"])
        assert args.command == []


# -------------------------------------------------------------------
# _resolve_image_paths
# -------------------------------------------------------------------


class TestResolveImagePaths:
    """Tests for _resolve_image_paths."""

    def test_defaults(self) -> None:
        """Uses default paths when args have None."""
        args = argparse.Namespace(dockerfile=None, context_dir=None)
        df, ctx = _resolve_image_paths(args)
        assert df == Path(".airut/container/Dockerfile")
        assert ctx == Path(".airut/container")

    def test_explicit_dockerfile(self, tmp_path: Path) -> None:
        """Explicit --dockerfile overrides default."""
        df_path = tmp_path / "Dockerfile"
        args = argparse.Namespace(dockerfile=df_path, context_dir=None)
        df, ctx = _resolve_image_paths(args)
        assert df == df_path
        assert ctx == tmp_path  # parent of dockerfile

    def test_explicit_context_dir(self, tmp_path: Path) -> None:
        """Explicit --context-dir overrides default."""
        ctx_path = tmp_path / "ctx"
        args = argparse.Namespace(dockerfile=None, context_dir=ctx_path)
        df, ctx = _resolve_image_paths(args)
        assert ctx == ctx_path


# -------------------------------------------------------------------
# _build_repo_spec
# -------------------------------------------------------------------


class TestBuildRepoSpec:
    """Tests for _build_repo_spec."""

    def test_basic_spec(self, tmp_path: Path) -> None:
        """Creates spec from Dockerfile and context files."""
        dockerfile = tmp_path / "Dockerfile"
        dockerfile.write_text("FROM alpine\n")
        ctx_file = tmp_path / "setup.sh"
        ctx_file.write_text("#!/bin/bash\n")

        spec = _build_repo_spec(dockerfile, tmp_path)

        assert spec.kind == "repo"
        assert spec.dockerfile == b"FROM alpine\n"
        assert "setup.sh" in spec.context_files
        assert spec.context_files["setup.sh"] == b"#!/bin/bash\n"

    def test_excludes_dockerfile_from_context(self, tmp_path: Path) -> None:
        """Dockerfile is not included in context_files."""
        dockerfile = tmp_path / "Dockerfile"
        dockerfile.write_text("FROM alpine\n")

        spec = _build_repo_spec(dockerfile, tmp_path)
        assert "Dockerfile" not in spec.context_files

    def test_missing_dockerfile_raises(self, tmp_path: Path) -> None:
        """Raises _ConfigError when Dockerfile doesn't exist."""
        from airut.sandbox_cli import _ConfigError

        with pytest.raises(_ConfigError, match="Dockerfile not found"):
            _build_repo_spec(tmp_path / "nonexistent", tmp_path)

    def test_nonexistent_context_dir(self, tmp_path: Path) -> None:
        """Non-directory context_dir produces empty context_files."""
        dockerfile = tmp_path / "Dockerfile"
        dockerfile.write_text("FROM alpine\n")

        spec = _build_repo_spec(dockerfile, tmp_path / "missing")
        assert spec.context_files == {}


# -------------------------------------------------------------------
# _build_image_specs
# -------------------------------------------------------------------


class TestBuildImageSpecs:
    """Tests for _build_image_specs."""

    @patch("airut.sandbox_cli.default_proxy_dir")
    def test_returns_repo_and_proxy(
        self, mock_proxy_dir: MagicMock, tmp_path: Path
    ) -> None:
        """Returns both repo and proxy specs."""
        # Set up repo
        dockerfile = tmp_path / "Dockerfile"
        dockerfile.write_text("FROM alpine\n")

        # Set up proxy dir
        proxy_dir = tmp_path / "proxy"
        proxy_dir.mkdir()
        (proxy_dir / "proxy.dockerfile").write_text("FROM python:3.13\n")
        (proxy_dir / "filter.py").write_text("# filter\n")
        mock_proxy_dir.return_value = proxy_dir

        args = argparse.Namespace(dockerfile=dockerfile, context_dir=tmp_path)
        repo_spec, proxy_spec = _build_image_specs(args)

        assert repo_spec.kind == "repo"
        assert proxy_spec.kind == "proxy"


# -------------------------------------------------------------------
# _image_hash
# -------------------------------------------------------------------


class TestImageHash:
    """Tests for _image_hash."""

    @patch("airut.sandbox_cli.default_proxy_dir")
    def test_outputs_hashes(
        self,
        mock_proxy_dir: MagicMock,
        tmp_path: Path,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        """Prints repo and proxy hashes to stdout."""
        # Set up files
        dockerfile = tmp_path / "Dockerfile"
        dockerfile.write_text("FROM alpine\n")
        proxy_dir = tmp_path / "proxy"
        proxy_dir.mkdir()
        (proxy_dir / "proxy.dockerfile").write_text("FROM python:3.13\n")
        mock_proxy_dir.return_value = proxy_dir

        args = argparse.Namespace(dockerfile=dockerfile, context_dir=tmp_path)
        result = _image_hash(args)

        assert result == 0
        captured = capsys.readouterr()
        lines = captured.out.strip().split("\n")
        assert len(lines) == 2
        assert lines[0].startswith("repo=")
        assert lines[1].startswith("proxy=")
        # Hashes are hex digests (64 chars for SHA-256)
        assert len(lines[0].split("=")[1]) == 64
        assert len(lines[1].split("=")[1]) == 64

    def test_missing_dockerfile_returns_error(self, tmp_path: Path) -> None:
        """Returns EXIT_INFRA_ERROR when Dockerfile is missing."""
        args = argparse.Namespace(
            dockerfile=tmp_path / "nonexistent", context_dir=tmp_path
        )
        result = _image_hash(args)
        assert result == EXIT_INFRA_ERROR


# -------------------------------------------------------------------
# _image_save
# -------------------------------------------------------------------


class TestImageSave:
    """Tests for _image_save."""

    @patch("airut.sandbox_cli.subprocess.run")
    @patch("airut.sandbox_cli.ImageCache")
    @patch("airut.sandbox_cli.default_proxy_dir")
    def test_builds_and_saves(
        self,
        mock_proxy_dir: MagicMock,
        mock_cache_cls: MagicMock,
        mock_run: MagicMock,
        tmp_path: Path,
    ) -> None:
        """Builds images via ImageCache and saves tarballs."""
        # Set up files
        dockerfile = tmp_path / "Dockerfile"
        dockerfile.write_text("FROM alpine\n")
        proxy_dir = tmp_path / "proxy"
        proxy_dir.mkdir()
        (proxy_dir / "proxy.dockerfile").write_text("FROM python:3.13\n")
        mock_proxy_dir.return_value = proxy_dir

        out_dir = tmp_path / "cache"

        # Mock ImageCache
        mock_cache = MagicMock()
        mock_cache.ensure.side_effect = [
            "airut-cli-repo:abc123",
            "airut-cli-proxy:def456",
        ]
        mock_cache_cls.return_value = mock_cache

        # Mock podman save success
        mock_run.return_value = MagicMock(returncode=0)

        args = argparse.Namespace(
            dockerfile=dockerfile,
            context_dir=tmp_path,
            directory=out_dir,
            container_command=None,
        )
        result = _image_save(args)

        assert result == 0
        assert mock_cache.ensure.call_count == 2
        assert mock_run.call_count == 2

        # Check podman save commands
        save_calls = mock_run.call_args_list
        assert str(out_dir / "repo.tar") in str(save_calls[0])
        assert str(out_dir / "proxy.tar") in str(save_calls[1])

    @patch("airut.sandbox_cli.subprocess.run")
    @patch("airut.sandbox_cli.ImageCache")
    @patch("airut.sandbox_cli.default_proxy_dir")
    def test_removes_existing_tarball_before_save(
        self,
        mock_proxy_dir: MagicMock,
        mock_cache_cls: MagicMock,
        mock_run: MagicMock,
        tmp_path: Path,
    ) -> None:
        """Deletes pre-existing tarball before podman save (docker-archive)."""
        # Set up files
        dockerfile = tmp_path / "Dockerfile"
        dockerfile.write_text("FROM alpine\n")
        proxy_dir = tmp_path / "proxy"
        proxy_dir.mkdir()
        (proxy_dir / "proxy.dockerfile").write_text("FROM python:3.13\n")
        mock_proxy_dir.return_value = proxy_dir

        out_dir = tmp_path / "cache"
        out_dir.mkdir()

        # Pre-existing tarballs (as left by a previous image load)
        (out_dir / "repo.tar").write_bytes(b"old repo data")
        (out_dir / "proxy.tar").write_bytes(b"old proxy data")

        mock_cache = MagicMock()
        mock_cache.ensure.side_effect = [
            "airut-cli-repo:abc123",
            "airut-cli-proxy:def456",
        ]
        mock_cache_cls.return_value = mock_cache

        mock_run.return_value = MagicMock(returncode=0)

        args = argparse.Namespace(
            dockerfile=dockerfile,
            context_dir=tmp_path,
            directory=out_dir,
            container_command=None,
        )
        result = _image_save(args)

        assert result == 0
        # Tarballs must not exist before podman save is called,
        # otherwise podman fails with "docker-archive doesn't
        # support modifying existing images".
        assert not (out_dir / "repo.tar").exists()
        assert not (out_dir / "proxy.tar").exists()

    @patch("airut.sandbox_cli.subprocess.run")
    @patch("airut.sandbox_cli.ImageCache")
    @patch("airut.sandbox_cli.default_proxy_dir")
    def test_save_failure_returns_error(
        self,
        mock_proxy_dir: MagicMock,
        mock_cache_cls: MagicMock,
        mock_run: MagicMock,
        tmp_path: Path,
    ) -> None:
        """Returns EXIT_INFRA_ERROR when podman save fails."""
        dockerfile = tmp_path / "Dockerfile"
        dockerfile.write_text("FROM alpine\n")
        proxy_dir = tmp_path / "proxy"
        proxy_dir.mkdir()
        (proxy_dir / "proxy.dockerfile").write_text("FROM python:3.13\n")
        mock_proxy_dir.return_value = proxy_dir

        mock_cache = MagicMock()
        mock_cache.ensure.return_value = "airut-cli-repo:abc123"
        mock_cache_cls.return_value = mock_cache

        mock_run.return_value = MagicMock(returncode=1, stderr="disk full")

        args = argparse.Namespace(
            dockerfile=dockerfile,
            context_dir=tmp_path,
            directory=tmp_path / "cache",
            container_command=None,
        )
        result = _image_save(args)
        assert result == EXIT_INFRA_ERROR

    @patch("airut.sandbox_cli.ImageCache")
    @patch("airut.sandbox_cli.default_proxy_dir")
    def test_build_failure_returns_error(
        self,
        mock_proxy_dir: MagicMock,
        mock_cache_cls: MagicMock,
        tmp_path: Path,
    ) -> None:
        """Returns EXIT_INFRA_ERROR when image build fails."""
        from airut.sandbox._image_cache import ImageBuildError

        dockerfile = tmp_path / "Dockerfile"
        dockerfile.write_text("FROM alpine\n")
        proxy_dir = tmp_path / "proxy"
        proxy_dir.mkdir()
        (proxy_dir / "proxy.dockerfile").write_text("FROM python:3.13\n")
        mock_proxy_dir.return_value = proxy_dir

        mock_cache = MagicMock()
        mock_cache.ensure.side_effect = ImageBuildError("build failed")
        mock_cache_cls.return_value = mock_cache

        args = argparse.Namespace(
            dockerfile=dockerfile,
            context_dir=tmp_path,
            directory=tmp_path / "cache",
            container_command=None,
        )
        result = _image_save(args)
        assert result == EXIT_INFRA_ERROR

    def test_missing_dockerfile_returns_error(self, tmp_path: Path) -> None:
        """Returns EXIT_INFRA_ERROR when Dockerfile is missing."""
        args = argparse.Namespace(
            dockerfile=tmp_path / "nonexistent",
            context_dir=tmp_path,
            directory=tmp_path / "cache",
            container_command=None,
        )
        result = _image_save(args)
        assert result == EXIT_INFRA_ERROR

    @patch("airut.sandbox_cli.subprocess.run")
    @patch("airut.sandbox_cli.ImageCache")
    @patch("airut.sandbox_cli.default_proxy_dir")
    def test_creates_output_directory(
        self,
        mock_proxy_dir: MagicMock,
        mock_cache_cls: MagicMock,
        mock_run: MagicMock,
        tmp_path: Path,
    ) -> None:
        """Creates the output directory if it doesn't exist."""
        dockerfile = tmp_path / "Dockerfile"
        dockerfile.write_text("FROM alpine\n")
        proxy_dir = tmp_path / "proxy"
        proxy_dir.mkdir()
        (proxy_dir / "proxy.dockerfile").write_text("FROM python:3.13\n")
        mock_proxy_dir.return_value = proxy_dir

        out_dir = tmp_path / "nested" / "cache"

        mock_cache = MagicMock()
        mock_cache.ensure.return_value = "airut-cli-repo:abc123"
        mock_cache_cls.return_value = mock_cache
        mock_run.return_value = MagicMock(returncode=0)

        args = argparse.Namespace(
            dockerfile=dockerfile,
            context_dir=tmp_path,
            directory=out_dir,
            container_command=None,
        )
        _image_save(args)
        assert out_dir.exists()

    @patch("airut.sandbox_cli.subprocess.run")
    @patch("airut.sandbox_cli.ImageCache")
    @patch("airut.sandbox_cli.default_proxy_dir")
    def test_uses_custom_container_command(
        self,
        mock_proxy_dir: MagicMock,
        mock_cache_cls: MagicMock,
        mock_run: MagicMock,
        tmp_path: Path,
    ) -> None:
        """Uses --container-command for ImageCache and podman save."""
        dockerfile = tmp_path / "Dockerfile"
        dockerfile.write_text("FROM alpine\n")
        proxy_dir = tmp_path / "proxy"
        proxy_dir.mkdir()
        (proxy_dir / "proxy.dockerfile").write_text("FROM python:3.13\n")
        mock_proxy_dir.return_value = proxy_dir

        mock_cache = MagicMock()
        mock_cache.ensure.return_value = "tag:abc"
        mock_cache_cls.return_value = mock_cache
        mock_run.return_value = MagicMock(returncode=0)

        args = argparse.Namespace(
            dockerfile=dockerfile,
            context_dir=tmp_path,
            directory=tmp_path / "out",
            container_command="docker",
        )
        _image_save(args)

        # ImageCache created with docker
        from airut.sandbox_cli import _CLI_RESOURCE_PREFIX, _SAVE_MAX_AGE_HOURS

        mock_cache_cls.assert_called_once_with(
            container_command="docker",
            resource_prefix=_CLI_RESOURCE_PREFIX,
            max_age_hours=_SAVE_MAX_AGE_HOURS,
        )
        # podman save uses docker
        assert mock_run.call_args_list[0][0][0][0] == "docker"


# -------------------------------------------------------------------
# _image_load
# -------------------------------------------------------------------


class TestImageLoad:
    """Tests for _image_load."""

    @patch("airut.sandbox_cli.subprocess.run")
    def test_loads_both_tarballs(
        self, mock_run: MagicMock, tmp_path: Path
    ) -> None:
        """Loads repo.tar and proxy.tar."""
        (tmp_path / "repo.tar").write_bytes(b"repo-data")
        (tmp_path / "proxy.tar").write_bytes(b"proxy-data")

        mock_run.return_value = MagicMock(returncode=0)

        args = argparse.Namespace(directory=tmp_path, container_command=None)
        result = _image_load(args)

        assert result == 0
        assert mock_run.call_count == 2

    @patch("airut.sandbox_cli.subprocess.run")
    def test_skips_missing_tarballs(
        self, mock_run: MagicMock, tmp_path: Path
    ) -> None:
        """Missing tarballs are silently skipped."""
        # Only proxy.tar exists
        (tmp_path / "proxy.tar").write_bytes(b"proxy-data")
        mock_run.return_value = MagicMock(returncode=0)

        args = argparse.Namespace(directory=tmp_path, container_command=None)
        result = _image_load(args)

        assert result == 0
        # Only proxy.tar loaded
        assert mock_run.call_count == 1

    @patch("airut.sandbox_cli.subprocess.run")
    def test_load_failure_continues(
        self, mock_run: MagicMock, tmp_path: Path
    ) -> None:
        """Load failure is logged but returns 0."""
        (tmp_path / "repo.tar").write_bytes(b"corrupted")
        (tmp_path / "proxy.tar").write_bytes(b"proxy-data")

        mock_run.side_effect = [
            MagicMock(returncode=1, stderr="corrupted tarball"),
            MagicMock(returncode=0),
        ]

        args = argparse.Namespace(directory=tmp_path, container_command=None)
        result = _image_load(args)
        assert result == 0
        assert mock_run.call_count == 2

    @patch("airut.sandbox_cli.subprocess.run")
    def test_empty_directory(self, mock_run: MagicMock, tmp_path: Path) -> None:
        """No tarballs means no loads, still returns 0."""
        args = argparse.Namespace(directory=tmp_path, container_command=None)
        result = _image_load(args)
        assert result == 0
        mock_run.assert_not_called()

    @patch("airut.sandbox_cli.subprocess.run")
    def test_uses_custom_container_command(
        self, mock_run: MagicMock, tmp_path: Path
    ) -> None:
        """Uses --container-command for podman load."""
        (tmp_path / "repo.tar").write_bytes(b"data")
        mock_run.return_value = MagicMock(returncode=0)

        args = argparse.Namespace(
            directory=tmp_path, container_command="docker"
        )
        _image_load(args)

        assert mock_run.call_args[0][0][0] == "docker"


# -------------------------------------------------------------------
# _dispatch_image
# -------------------------------------------------------------------


class TestDispatchImage:
    """Tests for _dispatch_image."""

    def test_no_image_command(self) -> None:
        """Returns EXIT_INFRA_ERROR when image_command is None."""
        args = argparse.Namespace(image_command=None)
        assert _dispatch_image(args) == EXIT_INFRA_ERROR

    @patch("airut.sandbox_cli._image_hash", return_value=0)
    def test_dispatches_hash(self, mock_hash: MagicMock) -> None:
        """Dispatches to _image_hash."""
        args = argparse.Namespace(image_command="hash")
        result = _dispatch_image(args)
        assert result == 0
        mock_hash.assert_called_once_with(args)

    @patch("airut.sandbox_cli._image_save", return_value=0)
    def test_dispatches_save(self, mock_save: MagicMock) -> None:
        """Dispatches to _image_save."""
        args = argparse.Namespace(image_command="save")
        result = _dispatch_image(args)
        assert result == 0
        mock_save.assert_called_once_with(args)

    @patch("airut.sandbox_cli._image_load", return_value=0)
    def test_dispatches_load(self, mock_load: MagicMock) -> None:
        """Dispatches to _image_load."""
        args = argparse.Namespace(image_command="load")
        result = _dispatch_image(args)
        assert result == 0
        mock_load.assert_called_once_with(args)

    def test_unknown_image_command(self) -> None:
        """Returns EXIT_INFRA_ERROR for unknown image command."""
        args = argparse.Namespace(image_command="unknown")
        assert _dispatch_image(args) == EXIT_INFRA_ERROR


# -------------------------------------------------------------------
# _run with image subcommands
# -------------------------------------------------------------------


class TestRunImageIntegration:
    """Tests for _run dispatching to image subcommands."""

    @patch("airut.sandbox_cli._dispatch_image", return_value=0)
    def test_run_dispatches_image(self, mock_dispatch: MagicMock) -> None:
        """_run dispatches 'image' to _dispatch_image."""
        result = _run(["image", "hash"])
        assert result == 0
        mock_dispatch.assert_called_once()

    def test_no_subcommand_mentions_image(self) -> None:
        """Error message for no subcommand mentions 'image'."""
        result = _run([])
        assert result == EXIT_INFRA_ERROR
