"""Tests for spens.runner helpers."""

import json
from pathlib import Path

import pytest
from spens.runner import (
    _agent_config_mask_args,
    _agent_network_args,
    _agent_spens_mask_args,
    _configuration_mounts,
    _docker_path,
    _inject_env_mapping,
    _interceptor_network_args,
    _network_ip,
    _wait_for_interceptor_ready,
    run_session,
)


def _tmpfs_spec(mountpoint: str) -> str:
    return f"{mountpoint}:rw,noexec,nosuid,nodev,mode=1777"


def test_unix_path_unchanged() -> None:
    assert _docker_path(Path("/workspace/my dir")) == "/workspace/my dir"


def test_windows_path_normalized() -> None:
    assert _docker_path(Path("C:/Users\\me\\proj")) == "C:/Users/me/proj"


def test_backslashes_replaced() -> None:
    assert _docker_path(Path("a\\b\\c")) == "a/b/c"


def test_configuration_mounts_explicit_include(tmp_path: Path) -> None:
    src = tmp_path / "cfg"
    src.mkdir()
    (src / "config.json").write_text("{}")
    (src / "other.txt").write_text("x")

    mounts = _configuration_mounts(
        [{"source": str(src), "destination": "~/.config/agent", "include": ["config.json"]}]
    )
    assert mounts == [(_docker_path(src / "config.json"), "/home/spens/.config/agent/config.json")]


def test_configuration_mounts_glob_include(tmp_path: Path) -> None:
    src = tmp_path / "cfg"
    src.mkdir()
    (src / "a.toml").write_text("x")
    (src / "b.toml").write_text("x")
    (src / "c.txt").write_text("x")

    mounts = _configuration_mounts(
        [{"source": str(src), "destination": "/home/spens/.config/agent", "include": ["*.toml"]}]
    )
    hosts = [h for h, _ in mounts]
    assert hosts == [_docker_path(src / "a.toml"), _docker_path(src / "b.toml")]


def test_configuration_mounts_exclude(tmp_path: Path) -> None:
    src = tmp_path / "cfg"
    src.mkdir()
    (src / "settings.json").write_text("x")
    (src / "secrets.json").write_text("x")
    (src / "secrets.toml").write_text("x")

    mounts = _configuration_mounts(
        [
            {
                "source": str(src),
                "destination": "~/.config/agent",
                "include": ["*.json", "*.toml"],
                "exclude": ["secrets.*"],
            }
        ]
    )
    assert [d for _, d in mounts] == ["/home/spens/.config/agent/settings.json"]


def test_configuration_mounts_no_include_mounts_all_files(tmp_path: Path) -> None:
    src = tmp_path / "cfg"
    src.mkdir()
    (src / "one.json").write_text("x")
    (src / "two.toml").write_text("x")
    sub = src / "sub"
    sub.mkdir()

    mounts = _configuration_mounts(
        [{"source": str(src), "destination": "/home/spens/.config/agent"}]
    )
    assert sorted(d for _, d in mounts) == [
        "/home/spens/.config/agent/one.json",
        "/home/spens/.config/agent/two.toml",
    ]


def test_configuration_mounts_duplicate_destination_first_entry_wins(
    tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    """Files resolving to the same container path mount once: first entry wins.

    Mirrors the built-in agent templates: a local ``{spens_dir}`` override is
    listed before the global ``~`` source, and the same file existing in both
    host locations must not produce two ``-v`` flags for one container path.
    """
    workspace = tmp_path / "workspace"
    workspace.mkdir()
    spens_dir = workspace / ".spens"
    local_src = spens_dir / ".pi" / "agent"
    local_src.mkdir(parents=True)
    (local_src / "settings.json").write_text('"local"')

    global_src = tmp_path / "home" / ".pi" / "agent"
    global_src.mkdir(parents=True)
    (global_src / "settings.json").write_text('"global"')
    (global_src / "models.json").write_text('{}')  # only exists globally

    (tmp_path / "elsewhere").mkdir()
    monkeypatch.chdir(tmp_path / "elsewhere")

    mounts = _configuration_mounts(
        [
            {
                "source": "{spens_dir}/.pi/agent",
                "destination": "~/.pi/agent",
                "include": ["settings.json", "models.json"],
            },
            {
                "source": str(global_src),
                "destination": "~/.pi/agent",
                "include": ["settings.json", "models.json"],
            },
        ],
        workspace_path=workspace,
        spens_dir=spens_dir,
    )
    # settings.json comes from the local override (first entry); the global
    # duplicate is skipped. models.json only exists globally, so it falls
    # through to the second entry.
    assert mounts == [
        (
            _docker_path(local_src / "settings.json"),
            "/home/spens/.pi/agent/settings.json",
        ),
        (
            _docker_path(global_src / "models.json"),
            "/home/spens/.pi/agent/models.json",
        ),
    ]


def test_configuration_mounts_missing_source_skipped(tmp_path: Path) -> None:
    mounts = _configuration_mounts(
        [{"source": str(tmp_path / "nope"), "destination": "/home/spens/.config/agent"}]
    )
    assert mounts == []


def test_configuration_mounts_relative_source_resolved_against_workspace(
    tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    """Relative ``source`` paths resolve against the workspace, not the CWD."""
    workspace = tmp_path / "workspace"
    src = workspace / ".spens" / ".pi" / "agent"
    src.mkdir(parents=True)
    (src / "settings.json").write_text("{}")

    # Run from an unrelated CWD so a CWD-relative resolution would fail.
    (tmp_path / "elsewhere").mkdir()
    monkeypatch.chdir(tmp_path / "elsewhere")

    mounts = _configuration_mounts(
        [
            {
                "source": ".spens/.pi/agent",
                "destination": "~/.pi/agent",
                "include": ["settings.json", "models.json"],
            }
        ],
        workspace_path=workspace,
    )
    assert mounts == [
        (_docker_path(src / "settings.json"), "/home/spens/.pi/agent/settings.json")
    ]


def test_configuration_mounts_relative_source_without_workspace_uses_cwd(
    tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    """Without a workspace, relative sources keep resolving against the CWD."""
    src = tmp_path / "cfg"
    src.mkdir()
    (src / "settings.json").write_text("x")
    monkeypatch.chdir(tmp_path)

    mounts = _configuration_mounts(
        [{"source": "cfg", "destination": "/home/spens/.config/agent"}]
    )
    assert mounts == [
        (_docker_path(src / "settings.json"), "/home/spens/.config/agent/settings.json")
    ]


def test_configuration_mounts_workspace_token(
    tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    """``{workspace}`` expands to the resolved workspace directory."""
    workspace = tmp_path / "workspace"
    src = workspace / "agent-config"
    src.mkdir(parents=True)
    (src / "settings.json").write_text("{}")

    (tmp_path / "elsewhere").mkdir()
    monkeypatch.chdir(tmp_path / "elsewhere")  # CWD must not matter

    mounts = _configuration_mounts(
        [{"source": "{workspace}/agent-config", "destination": "~/.config/agent"}],
        workspace_path=workspace,
    )
    assert mounts == [
        (_docker_path(src / "settings.json"), "/home/spens/.config/agent/settings.json")
    ]


def test_configuration_mounts_spens_dir_token(
    tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    """``{spens_dir}`` expands to the session's spens directory.

    Covers both the default ``<workspace>/.spens`` and a custom
    ``--spens-dir`` outside the workspace.
    """
    workspace = tmp_path / "workspace"
    workspace.mkdir()

    # Default: spens dir lives inside the workspace.
    default_src = workspace / ".spens" / ".pi" / "agent"
    default_src.mkdir(parents=True)
    (default_src / "settings.json").write_text("{}")

    # Custom: spens dir lives elsewhere.
    custom_spens = tmp_path / "session-state"
    custom_src = custom_spens / ".pi" / "agent"
    custom_src.mkdir(parents=True)
    (custom_src / "settings.json").write_text("{}")

    (tmp_path / "elsewhere").mkdir()
    monkeypatch.chdir(tmp_path / "elsewhere")  # CWD must not matter

    entry = {
        "source": "{spens_dir}/.pi/agent",
        "destination": "~/.pi/agent",
        "include": ["settings.json"],
    }

    default_mounts = _configuration_mounts(
        [entry], workspace_path=workspace, spens_dir=workspace / ".spens"
    )
    assert default_mounts == [
        (
            _docker_path(default_src / "settings.json"),
            "/home/spens/.pi/agent/settings.json",
        )
    ]

    custom_mounts = _configuration_mounts(
        [entry], workspace_path=workspace, spens_dir=custom_spens
    )
    assert custom_mounts == [
        (
            _docker_path(custom_src / "settings.json"),
            "/home/spens/.pi/agent/settings.json",
        )
    ]


def test_configuration_mounts_token_without_directory_is_an_error() -> None:
    """A token used without its directory fails loudly, not silently."""
    with pytest.raises(ValueError, match="{spens_dir}"):
        _configuration_mounts(
            [{"source": "{spens_dir}/.pi/agent", "destination": "~/.pi/agent"}]
        )
    with pytest.raises(ValueError, match="{workspace}"):
        _configuration_mounts(
            [{"source": "{workspace}/cfg", "destination": "~/.config/agent"}]
        )


def test_configuration_mounts_missing_token_source_skipped(tmp_path: Path) -> None:
    """A token source that does not exist is skipped like any other."""
    workspace = tmp_path / "workspace"
    workspace.mkdir()

    mounts = _configuration_mounts(
        [{"source": "{spens_dir}/.pi/agent", "destination": "~/.pi/agent"}],
        workspace_path=workspace,
        spens_dir=workspace / ".spens",
    )
    assert mounts == []


def test_configuration_mounts_missing_included_file_skipped(tmp_path: Path) -> None:
    src = tmp_path / "cfg"
    src.mkdir()
    (src / "config.json").write_text("{}")

    mounts = _configuration_mounts(
        [
            {
                "source": str(src),
                "destination": "/home/spens/.config/agent",
                "include": ["config.json", "absent.json"],
            }
        ]
    )
    assert mounts == [(_docker_path(src / "config.json"), "/home/spens/.config/agent/config.json")]


def test_configuration_mounts_multiple_entries(tmp_path: Path) -> None:
    a = tmp_path / "a"
    a.mkdir()
    (a / "a.json").write_text("x")
    b = tmp_path / "b"
    b.mkdir()
    (b / "b.json").write_text("x")

    mounts = _configuration_mounts(
        [
            {"source": str(a), "destination": "~/.a", "include": ["a.json"]},
            {"source": str(b), "destination": "~/.b", "include": ["b.json"]},
        ]
    )
    assert mounts == [
        (_docker_path(a / "a.json"), "/home/spens/.a/a.json"),
        (_docker_path(b / "b.json"), "/home/spens/.b/b.json"),
    ]


def test_configuration_mounts_strips_destination_trailing_slash(tmp_path: Path) -> None:
    src = tmp_path / "cfg"
    src.mkdir()
    (src / "config.json").write_text("{}")

    mounts = _configuration_mounts(
        [{"source": str(src), "destination": "~/.config/agent/", "include": ["config.json"]}]
    )
    assert mounts == [(_docker_path(src / "config.json"), "/home/spens/.config/agent/config.json")]


def _write_local_agent(workspace: Path, name: str, **fields) -> None:
    templates_dir = workspace / "templates"
    templates_dir.mkdir(exist_ok=True)
    data = {"template_type": "agent", "name": name, "installation_command": "true", **fields}
    (templates_dir / f"{name}.json").write_text(json.dumps(data), encoding="utf-8")


def test_run_session_rejects_prompt_without_yolo_command(tmp_path: Path) -> None:
    _write_local_agent(tmp_path, "no-yolo")
    with pytest.raises(ValueError, match="yolo_command"):
        run_session("node-20", "no-yolo", tmp_path, prompt="do something")


# -- custom --spens-dir -----------------------------------------------------


def test_spens_mask_default_workspace_spens(tmp_path: Path) -> None:
    """Default: <workspace>/.spens is masked at /workspace/.spens."""
    ws = tmp_path / "ws"
    ws.mkdir()
    args = _agent_spens_mask_args(ws, ws / ".spens")
    assert args == ["--tmpfs", _tmpfs_spec("/workspace/.spens")]


def test_spens_mask_custom_dir_outside_workspace(tmp_path: Path) -> None:
    """A custom --spens-dir outside the workspace is unreachable by the
    agent (not mounted at all), but /workspace/.spens is still masked in
    case a stale default directory exists in the workspace."""
    ws = tmp_path / "ws"
    ws.mkdir()
    outside = tmp_path / "state"
    outside.mkdir()
    args = _agent_spens_mask_args(ws, outside)
    assert args == ["--tmpfs", _tmpfs_spec("/workspace/.spens")]


def test_spens_mask_custom_dir_inside_workspace(tmp_path: Path) -> None:
    """A custom --spens-dir inside the workspace is visible through the
    workspace mount, so it gets its own tmpfs mask in addition to the
    default /workspace/.spens mask."""
    ws = tmp_path / "ws"
    ws.mkdir()
    args = _agent_spens_mask_args(ws, ws / "spens-state")
    assert args == [
        "--tmpfs", _tmpfs_spec("/workspace/.spens"),
        "--tmpfs", _tmpfs_spec("/workspace/spens-state"),
    ]


def test_spens_mask_custom_dir_nested_inside_workspace(tmp_path: Path) -> None:
    ws = tmp_path / "ws"
    ws.mkdir()
    args = _agent_spens_mask_args(ws, ws / "a" / "b")
    assert "--tmpfs" in args
    assert _tmpfs_spec("/workspace/a/b") in args


def test_spens_mask_deduplicates_default_path(tmp_path: Path) -> None:
    """Masking the workspace itself (degenerate case) must not produce a
    duplicate or bogus mountpoint."""
    ws = tmp_path / "ws"
    ws.mkdir()
    args = _agent_spens_mask_args(ws, ws)
    # rel.parts is empty -> no extra mountpoint beyond the default mask
    assert args == ["--tmpfs", _tmpfs_spec("/workspace/.spens")]


def test_run_session_creates_session_state_under_custom_spens_dir(
    tmp_path: Path, monkeypatch
) -> None:
    """Session state (traces/audit/interceptor config) lands under a custom
    --spens-dir, not under <workspace>/.spens."""
    ws = tmp_path / "ws"
    ws.mkdir()
    state = tmp_path / "state"

    # Fail fast at the first docker call (volume create) -- everything before
    # it (workspace check, template loading, session dir setup) is host-side.
    monkeypatch.setattr(
        "spens.runner._run", lambda cmd, **kw: _FakeResult(returncode=1, stderr="boom")
    )
    exit_code = run_session("node-20", "opencode", ws, spens_dir=state)
    assert exit_code == 1

    sessions = state.resolve() / "sessions"
    session_dirs = list(sessions.iterdir()) if sessions.is_dir() else []
    assert len(session_dirs) == 1
    session_dir = session_dirs[0]
    assert (session_dir / "traces").is_dir()
    assert (session_dir / "nono-audit").is_dir()
    assert (session_dir / "interceptor_config.json").is_file()
    # The workspace must stay untouched: no default .spens created there.
    assert not (ws / ".spens").exists()


def test_run_session_defaults_to_workspace_spens_dir(
    tmp_path: Path, monkeypatch
) -> None:
    """Without --spens-dir, session state is created under <workspace>/.spens
    (unchanged default behavior)."""
    ws = tmp_path / "ws"
    ws.mkdir()

    monkeypatch.setattr(
        "spens.runner._run", lambda cmd, **kw: _FakeResult(returncode=1, stderr="boom")
    )
    exit_code = run_session("node-20", "opencode", ws)
    assert exit_code == 1

    sessions = ws / ".spens" / "sessions"
    assert sessions.is_dir()
    assert len(list(sessions.iterdir())) == 1


# -- enforced-egress network plumbing --------------------------------------


def test_interceptor_network_args() -> None:
    """Interceptor joins the internal network under a stable alias so the
    agent can reach the proxy at http://spens-interceptor:9090."""
    assert _interceptor_network_args("spens-internal-abc") == [
        "--network", "spens-internal-abc",
        "--network-alias", "spens-interceptor",
    ]


def test_agent_network_args_with_dns() -> None:
    args = _agent_network_args("spens-internal-abc", "172.18.0.2")
    assert args == [
        "--network", "spens-internal-abc",
        "--cap-drop", "NET_RAW",
        "--dns", "172.18.0.2",
    ]


def test_agent_network_args_without_dns() -> None:
    """Without a resolvable interceptor IP the agent keeps the isolated
    network (egress still enforced); only DNS capture is skipped."""
    assert _agent_network_args("spens-internal-abc", None) == [
        "--network", "spens-internal-abc",
        "--cap-drop", "NET_RAW",
    ]


class _FakeResult:
    def __init__(self, returncode: int = 0, stdout: str = "", stderr: str = "") -> None:
        self.returncode = returncode
        self.stdout = stdout
        self.stderr = stderr


def test_network_ip_parses_inspect_output(monkeypatch) -> None:
    monkeypatch.setattr(
        "spens.runner._run",
        lambda cmd, **kw: _FakeResult(stdout="172.18.0.2\n"),
    )
    assert _network_ip("spens-interceptor-abc", "spens-internal-abc") == "172.18.0.2"


def test_network_ip_returns_none_on_error(monkeypatch) -> None:
    monkeypatch.setattr(
        "spens.runner._run",
        lambda cmd, **kw: _FakeResult(returncode=1, stderr="No such object"),
    )
    assert _network_ip("spens-interceptor-abc", "spens-internal-abc") is None


def test_network_ip_returns_none_when_empty(monkeypatch) -> None:
    monkeypatch.setattr(
        "spens.runner._run",
        lambda cmd, **kw: _FakeResult(stdout="\n"),
    )
    assert _network_ip("spens-interceptor-abc", "spens-internal-abc") is None


def test_wait_for_interceptor_ready_requires_both_markers(monkeypatch) -> None:
    """Readiness gating must cover the mitmproxy addon AND the DNS forwarder
    (both write markers into the cert volume), so no agent traffic of either
    kind can leave before interception works."""
    calls: list[list[str]] = []

    def fake_run(cmd, **kw):
        calls.append(cmd)
        return _FakeResult(returncode=0)

    monkeypatch.setattr("spens.runner._run", fake_run)
    assert _wait_for_interceptor_ready("spens-interceptor-abc") is True
    assert calls == [[
        "docker", "exec", "spens-interceptor-abc",
        "test", "-f", "/root/.mitmproxy/spens_addon_ready",
        "-a", "-f", "/root/.mitmproxy/spens_dns_ready",
    ]]


def test_wait_for_interceptor_ready_times_out(monkeypatch) -> None:
    monkeypatch.setattr("spens.runner._run", lambda cmd, **kw: _FakeResult(returncode=1))
    monkeypatch.setattr("spens.runner.time.sleep", lambda s: None)
    monkeypatch.setattr("spens.runner.time.time", lambda: 0.0)  # deadline never passes
    assert _wait_for_interceptor_ready("c", timeout=0) is False


def test_inject_env_mapping_none_config() -> None:
    agent_envs, interceptor_envs = _inject_env_mapping(None)
    assert agent_envs == []
    assert interceptor_envs == []


def test_inject_env_mapping_no_inject_headers() -> None:
    config = {"env": ["FOO"], "addition_capture_urls": []}
    agent_envs, interceptor_envs = _inject_env_mapping(config)
    assert agent_envs == []
    assert interceptor_envs == []


def test_inject_env_mapping_placeholder_and_env_var(monkeypatch) -> None:
    monkeypatch.setenv("ANTHROPIC_KEY", "sk-real-secret")
    config = {
        "inject_headers": [
            {"placeholder": "ANTHROPIC_API_KEY", "env_var": "ANTHROPIC_KEY"},
        ],
    }
    agent_envs, interceptor_envs = _inject_env_mapping(config)
    # Agent gets the placeholder string as its env var value
    assert agent_envs == [("ANTHROPIC_API_KEY", "ANTHROPIC_API_KEY")]
    # Interceptor gets the real secret
    assert interceptor_envs == [("ANTHROPIC_KEY", "sk-real-secret")]


def test_inject_env_mapping_missing_env_var(monkeypatch) -> None:
    monkeypatch.delenv("MISSING_KEY", raising=False)
    config = {
        "inject_headers": [
            {"placeholder": "MY_KEY", "env_var": "MISSING_KEY"},
        ],
    }
    agent_envs, interceptor_envs = _inject_env_mapping(config)
    assert agent_envs == [("MY_KEY", "MY_KEY")]
    assert interceptor_envs == [("MISSING_KEY", "")]


def test_inject_env_mapping_multiple_rules(monkeypatch) -> None:
    monkeypatch.setenv("KEY_A", "secret-a")
    monkeypatch.setenv("KEY_B", "secret-b")
    config = {
        "inject_headers": [
            {"placeholder": "PLACEHOLDER_A", "env_var": "KEY_A"},
            {"placeholder": "PLACEHOLDER_B", "env_var": "KEY_B"},
        ],
    }
    agent_envs, interceptor_envs = _inject_env_mapping(config)
    assert agent_envs == [
        ("PLACEHOLDER_A", "PLACEHOLDER_A"),
        ("PLACEHOLDER_B", "PLACEHOLDER_B"),
    ]
    assert interceptor_envs == [
        ("KEY_A", "secret-a"),
        ("KEY_B", "secret-b"),
    ]


# -- _agent_config_mask_args ------------------------------------------------


def test_config_mask_masks_config_file(tmp_path: Path) -> None:
    (tmp_path / ".spens.config.json").write_text("{}")
    args = _agent_config_mask_args(tmp_path, {})
    assert "-v" in args
    assert "/dev/null:/workspace/.spens.config.json:ro" in args


def test_config_mask_masks_templates_dir(tmp_path: Path) -> None:
    (tmp_path / "templates").mkdir()
    args = _agent_config_mask_args(tmp_path, None)
    assert "--tmpfs" in args
    tmpfs_spec = args[args.index("--tmpfs") + 1]
    assert tmpfs_spec.startswith("/workspace/templates:")
    assert "noexec" in tmpfs_spec and "nosuid" in tmpfs_spec and "nodev" in tmpfs_spec


def test_config_mask_masks_nono_override(tmp_path: Path) -> None:
    (tmp_path / "profile_override.json").write_text("{}")
    args = _agent_config_mask_args(tmp_path, {"nono_override": "profile_override.json"})
    assert "/dev/null:/workspace/profile_override.json:ro" in args


def test_config_mask_skips_missing_inputs(tmp_path: Path) -> None:
    # Nothing present in the workspace -> nothing to mask.
    assert _agent_config_mask_args(tmp_path, {"nono_override": "nope.json"}) == []


def test_config_mask_ignores_override_outside_workspace(tmp_path: Path) -> None:
    outside = tmp_path / "outside.json"
    outside.write_text("{}")
    ws = tmp_path / "ws"
    ws.mkdir()
    args = _agent_config_mask_args(ws, {"nono_override": "../outside.json"})
    # The override resolves outside the workspace mount, so it is unreachable
    # by the agent and must not be masked (nor mounted).
    assert args == []


# -- session scaffolding ----------------------------------------------------


def test_session_names_derive_from_the_session_id() -> None:
    from spens.runner import SessionNames

    names = SessionNames.for_session("abc123")
    assert names.internal_net == "spens-internal-abc123"
    assert names.egress_net == "spens-egress-abc123"
    assert names.cert_volume == "spens-certs-abc123"
    assert names.interceptor == "spens-interceptor-abc123"
    assert names.agent == "spens-agent-abc123"


def test_run_checked_returns_result_on_success(monkeypatch) -> None:
    from spens.runner import _run_checked

    monkeypatch.setattr(
        "spens.runner._run", lambda cmd, **kw: _FakeResult(returncode=0, stdout="ok")
    )
    assert _run_checked(["docker", "info"], "boom").stdout == "ok"


def test_run_checked_raises_with_stderr_on_failure(monkeypatch) -> None:
    from spens.runner import _run_checked

    monkeypatch.setattr(
        "spens.runner._run",
        lambda cmd, **kw: _FakeResult(returncode=1, stderr="no such network"),
    )
    try:
        _run_checked(["docker", "network", "create", "x"], "Failed to create network x")
    except RuntimeError as exc:
        assert "Failed to create network x" in str(exc)
        assert "no such network" in str(exc)
    else:
        raise AssertionError("expected RuntimeError")


def test_prepare_session_paths_creates_state_and_config(tmp_path: Path) -> None:
    from spens.runner import _prepare_session_paths

    paths = _prepare_session_paths(tmp_path / "state", "sid1", None)
    assert paths.traces_dir.is_dir()
    assert paths.audit_dir.is_dir()
    assert paths.interceptor_config.is_file()
    assert paths.session_dir == tmp_path / "state" / "sessions" / "sid1"
    # The interceptor config is always well-formed, even with no user config.
    cfg = json.loads(paths.interceptor_config.read_text())
    assert cfg["domain_rules"] == []
    assert cfg["inject_headers"] == []


def test_warn_about_inject_headers_flags_unusable_rules(event_capture) -> None:
    from spens.runner import _warn_about_inject_headers

    config = {"inject_headers": [{"placeholder": "KEY", "env_var": "REAL", "for_domains": []}]}
    _warn_about_inject_headers(config, [("REAL", "")])
    messages = event_capture.messages()
    assert any("'REAL' is not set on the host" in m for m in messages)
    assert any("no 'for_domains' patterns" in m for m in messages)
    assert event_capture.named() == ["warning", "warning"]


def test_warn_about_inject_headers_silent_when_configured(event_capture) -> None:
    from spens.runner import _warn_about_inject_headers

    config = {"inject_headers": [
        {"placeholder": "KEY", "env_var": "REAL", "for_domains": ["*api.anthropic.com*"]}
    ]}
    _warn_about_inject_headers(config, [("REAL", "sk-value")])
    assert event_capture.events == []


def test_warn_about_inject_headers_tty_rendering_is_verbatim() -> None:
    """Golden test: the warnings render byte-identically through TtySink."""
    import io

    from spens import events
    from spens.runner import _warn_about_inject_headers
    from spens.sinks.tty import TtySink

    stream = io.StringIO()
    events.configure(events.Emitter("sid", [TtySink(stream)]))
    config = {"inject_headers": [{"placeholder": "KEY", "env_var": "REAL", "for_domains": []}]}
    _warn_about_inject_headers(config, [("REAL", "")])
    out = stream.getvalue()
    assert out == (
        "[spens] Warning: inject_headers env var 'REAL' is not set on the host. "
        "The placeholder will NOT be replaced by the interceptor, and API "
        "requests using it will fail authentication. Export the variable in the "
        "shell running spens, or fix 'env_var' in .spens.config.json.\n"
        "[spens] Warning: inject_headers rule (placeholder 'KEY') has no "
        "'for_domains' patterns. Secrets are only substituted on authorized "
        "URLs, so this placeholder will NEVER be substituted. Add a for_domains "
        "list (e.g. [\"*api.anthropic.com*\"]) in .spens.config.json.\n"
    )


def test_warn_about_egress_mode_emits_warning(event_capture) -> None:
    from spens.runner import _warn_about_egress_mode

    _warn_about_egress_mode(True)
    assert event_capture.named() == ["warning"]
    assert "egress mode 'legacy'" in event_capture.messages()[0]
    # enforced egress stays silent
    _warn_about_egress_mode(False)
    assert event_capture.named() == ["warning"]


def _agent_cmd(tmp_path: Path, **overrides) -> list[str]:
    from spens.runner import SessionNames, SessionPaths, _build_agent_command

    ws = tmp_path / "ws"
    ws.mkdir(exist_ok=True)
    session = tmp_path / "state" / "sessions" / "sid1"
    (session / "traces").mkdir(parents=True, exist_ok=True)
    (session / "nono-audit").mkdir(parents=True, exist_ok=True)
    kwargs = {
        "tag": "spens-agent:node-20-codex",
        "workspace_path": ws,
        "spens_root": tmp_path / "state",
        "agent": {"name": "codex"},
        "agent_home": "/home/spens",
        "config": None,
        "inject_agent_envs": [],
        "dns_ip": "10.0.0.5",
        "legacy_egress": False,
        "prompt": None,
    }
    kwargs.update(overrides)
    return _build_agent_command(
        SessionNames.for_session("sid1"),
        SessionPaths(
            session_dir=session,
            traces_dir=session / "traces",
            audit_dir=session / "nono-audit",
            interceptor_config=session / "interceptor_config.json",
        ),
        **kwargs,
    )


def test_build_agent_command_enforced_egress(tmp_path: Path) -> None:
    cmd = _agent_cmd(tmp_path)
    assert cmd[:3] == ["docker", "run", "-it"]
    assert cmd[-1] == "spens-agent:node-20-codex"
    assert "--init" in cmd
    # Isolated network, no raw sockets, DNS pointed at the interceptor.
    assert "spens-internal-sid1" in cmd
    assert "NET_RAW" in cmd
    assert "10.0.0.5" in cmd
    # The host's session state is masked from the agent.
    assert "/workspace/.spens:rw,noexec,nosuid,nodev,mode=1777" in cmd
    assert f"container:{'spens-interceptor-sid1'}" not in cmd


def test_build_agent_command_non_tty_mode_drops_dash_it(tmp_path: Path) -> None:
    """-it only in tty output mode (the interactive exemption); jsonl and
    background run without it so output can be collected as agent_output
    events."""
    cmd = _agent_cmd(tmp_path, tty=False)
    assert cmd[:3] == ["docker", "run", "--init"]
    assert "-it" not in cmd
    assert "-i" not in cmd
    assert "-t" not in cmd
    # everything else is unchanged
    tty_cmd = _agent_cmd(tmp_path)
    assert sorted(cmd[2:]) == sorted(a for a in tty_cmd[2:] if a != "-it")


def test_run_session_forwards_change_flags_to_image_build(
    tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    """--reject-changes is a build-time input, like --accept-changes."""
    recorded: dict = {}

    def fake_build(tag, env, agent, config, workspace, **kwargs):
        recorded.update(kwargs)
        raise RuntimeError("stop after recording")

    monkeypatch.setattr("spens.runner.build_agent_image", fake_build)
    monkeypatch.setattr(
        "spens.runner._run", lambda cmd, **kw: _FakeResult(returncode=0)
    )
    _write_local_agent(tmp_path, "yolo-agent", yolo_command="a run {prompt}")
    run_session(
        "node-20", "yolo-agent", tmp_path,
        prompt="hi", reject_changes=True, accept_changes=False,
        output_mode="jsonl", spens_dir=tmp_path / "state",
    )
    assert recorded["reject_changes"] is True
    assert recorded["accept_changes"] is False


def test_build_agent_command_legacy_egress_shares_netns(tmp_path: Path) -> None:
    cmd = _agent_cmd(tmp_path, legacy_egress=True)
    assert "container:spens-interceptor-sid1" in cmd
    assert "NET_RAW" not in cmd


def test_build_agent_command_passes_placeholders_and_prompt(tmp_path: Path) -> None:
    cmd = _agent_cmd(
        tmp_path,
        inject_agent_envs=[("ANTHROPIC_API_KEY", "ANTHROPIC_API_KEY")],
        prompt="fix the bug",
    )
    assert "ANTHROPIC_API_KEY=ANTHROPIC_API_KEY" in cmd
    assert "SPENS_YOLO_PROMPT=fix the bug" in cmd


def test_build_agent_command_forwards_config_env(tmp_path: Path, monkeypatch) -> None:
    monkeypatch.setenv("FORWARDED", "yes")
    cmd = _agent_cmd(tmp_path, config={"env": ["FORWARDED", "ABSENT"]})
    assert "FORWARDED=yes" in cmd
    # An unset variable is still forwarded, empty, rather than omitted.
    assert "ABSENT=" in cmd


def test_report_session_end_emits_summary_as_json(
    tmp_path: Path, monkeypatch, event_capture
) -> None:
    """The recap travels in the finished event as a dict, not as prose.

    The multi-line text recap that used to be embedded in ``message`` made
    ``events.jsonl`` noisy and forced jsonl consumers to parse formatted
    text; the summary is now machine-readable JSON and the tty sink renders
    the panel from it.
    """
    from spens import runner

    session = tmp_path / "sessions" / "sid1"
    (session / "traces").mkdir(parents=True)
    (session / "nono-audit").mkdir(parents=True)
    paths = runner.SessionPaths(
        session_dir=session,
        traces_dir=session / "traces",
        audit_dir=session / "nono-audit",
        interceptor_config=session / "interceptor_config.json",
    )
    summary = {"session_id": "sid1", "llm": {"turns": 4}, "exit_code": 0}
    monkeypatch.setattr(
        runner, "write_summary", lambda session_dir: (session / "summary.json", summary)
    )

    runner._report_session_end("sid1", paths, exit_code=0)

    (event,) = [e for e in event_capture.events if e.event == "finished"]
    assert event.data["summary"] == summary
    assert event.data["summary_path"] == str(session / "summary.json")
    assert event.data["exit_code"] == 0
    # the message is a single line again -- no embedded recap text
    assert "\n" not in event.data["message"]
    assert "[spens] Session sid1 finished." in event.data["message"]


def test_report_session_end_survives_summary_failure(
    tmp_path: Path, monkeypatch, event_capture
) -> None:
    from spens import runner

    session = tmp_path / "sessions" / "sid1"
    (session / "traces").mkdir(parents=True)
    (session / "nono-audit").mkdir(parents=True)
    paths = runner.SessionPaths(
        session_dir=session,
        traces_dir=session / "traces",
        audit_dir=session / "nono-audit",
        interceptor_config=session / "interceptor_config.json",
    )

    def boom(session_dir):
        raise RuntimeError("no summary for you")

    monkeypatch.setattr(runner, "write_summary", boom)
    runner._report_session_end("sid1", paths, exit_code=2)

    names = event_capture.named()
    assert names == ["finished", "warning"]
    assert "summary" not in event_capture.events[0].data
    assert "could not generate session summary" in event_capture.messages()[1]


def test_tty_mode_streams_builds_into_a_progress_region(
    tmp_path: Path, monkeypatch
) -> None:
    """In tty mode the docker builds feed a constrained live region.

    jsonl/background keep the plain stderr redirection so the stdout event
    stream stays clean; only the tty renderer gets a BuildProgress.
    """
    from spens.ui import BuildProgress

    recorded: list[tuple] = []

    def fake_build(tag, env, agent, config, workspace, **kwargs):
        recorded.append((kwargs.get("progress"), kwargs.get("stdout")))
        raise RuntimeError("stop after recording")

    monkeypatch.setattr("spens.runner.build_agent_image", fake_build)
    monkeypatch.setattr("spens.runner._run", lambda cmd, **kw: _FakeResult(returncode=0))
    _write_local_agent(tmp_path, "yolo-agent", yolo_command="a run {prompt}")

    for mode, state_dir in (("jsonl", tmp_path / "state"), ("tty", tmp_path / "state2")):
        run_session(
            "node-20", "yolo-agent", tmp_path,
            prompt="hi", accept_changes=True,
            output_mode=mode, spens_dir=state_dir,
        )

    jsonl_progress, jsonl_stdout = recorded[0]
    tty_progress, tty_stdout = recorded[1]
    assert jsonl_progress is None and jsonl_stdout is not None
    assert isinstance(tty_progress, BuildProgress)
    assert "agent image" in tty_progress.title
    assert tty_stdout is None
