"""Tests for spens.builder (Dockerfile / profile / entrypoint generation)."""

import json
from pathlib import Path

from spens.builder import (
    _pkg_install_command,
    generate_agent_dockerfile,
    generate_agent_entrypoint,
    generate_interceptor_config,
    generate_interceptor_dockerfile,
    generate_interceptor_entrypoint,
    generate_nono_profile,
)

ENV = {
    "name": "test-env",
    "base-image": "node:20-bookworm",
    "nono-command": "curl -fsSL https://nono.sh/install.sh | sh",
    "packages": ["curl"],
    "nono_base_config": "intentionally-left-nil/test",
    "package_manager": "apt",
}

AGENT = {
    "name": "test-agent",
    "installation_command": "curl -fsSL https://example.com/install | sh",
    "nono_base_config": "nolabs-ai/test-agent",
    "packages": ["ripgrep"],
    "relocate_binary": {
        "from": "/home/spens/.bin/test-agent",
        "to": "/usr/local/bin/test-agent",
        "cleanup": ["/home/spens/.test-agent", "/home/spens/.local/bin/test-agent"],
    },
    "allow_folders": ["/home/spens/.cache/test-agent"],
    "config_files": {"/home/spens/.test-agent/config.toml": "setting = true\n"},
    "env": ["TEST_CERT=/certs/mitmproxy-ca-cert.pem"],
}


def test_pkg_install_apk() -> None:
    assert _pkg_install_command("apk", ["curl", "git"]) == "apk add --no-cache curl git"


def test_pkg_install_apt_default() -> None:
    cmd = _pkg_install_command("apt", ["curl", "git"])
    assert "apt-get update && apt-get install -y --no-install-recommends curl git" in cmd
    assert "rm -rf /var/lib/apt/lists/*" in cmd


def test_pkg_install_unknown_manager_defaults_to_apt() -> None:
    cmd = _pkg_install_command("yum", ["curl"])
    assert "apt-get update" in cmd


def test_generated_dockerfile() -> None:
    dockerfile = generate_agent_dockerfile(ENV, AGENT)
    assert dockerfile.startswith(f"FROM {ENV['base-image']}\n")
    assert 'SHELL ["/bin/bash", "-c"]' in dockerfile
    # unprivileged agent user is created and used
    assert "RUN useradd -m -u 1000 spens" in dockerfile
    assert "ENV HOME=/home/spens" in dockerfile
    assert "USER spens" in dockerfile
    assert "USER root" in dockerfile
    # the agent runs as the unprivileged user, not root
    assert dockerfile.rstrip().endswith('ENTRYPOINT ["/entrypoint.sh"]')
    assert dockerfile.rstrip().splitlines()[-4] == "USER spens"
    assert f"RUN {ENV['nono-command']}" in dockerfile
    assert f"RUN {AGENT['installation_command']}" in dockerfile
    # nono profile pulls fail the build loudly (no `|| true`): a silently
    # missing profile would yield a less-secure runtime than configured.
    assert "RUN nono pull intentionally-left-nil/test" in dockerfile
    assert "RUN nono pull nolabs-ai/test-agent" in dockerfile
    assert "|| true" not in dockerfile
    assert "apt-get install -y --no-install-recommends curl ripgrep" in dockerfile
    assert 'RUN cp "/home/spens/.bin/test-agent" "/usr/local/bin/test-agent" && rm -rf "/home/spens/.test-agent" "/home/spens/.local/bin/test-agent"' in dockerfile
    assert 'RUN mkdir -p "/home/spens/.test-agent"' in dockerfile
    assert "COPY --chown=spens config-file-0 /home/spens/.test-agent/config.toml" in dockerfile
    assert 'RUN mkdir -p "/home/spens/.cache/test-agent"' in dockerfile
    assert "COPY --chown=spens spens-profile.json /home/spens/.config/nono/profiles/spens-profile.json" in dockerfile
    assert "COPY entrypoint.sh /entrypoint.sh" in dockerfile
    assert 'ENTRYPOINT ["/entrypoint.sh"]' in dockerfile


def test_dockerfile_uses_apk_when_configured() -> None:
    env = dict(ENV, package_manager="apk")
    dockerfile = generate_agent_dockerfile(env, {"name": "a", "installation_command": "true"})
    assert "apk add --no-cache curl" in dockerfile


def test_no_config_files_means_no_copy() -> None:
    agent = {k: v for k, v in AGENT.items() if k != "config_files"}
    dockerfile = generate_agent_dockerfile(ENV, agent)
    assert "COPY config-file-" not in dockerfile


def test_profile_is_valid_json() -> None:
    profile = generate_nono_profile(ENV, AGENT, None, Path("."))
    data = json.loads(profile)
    assert data["meta"]["name"] == "spens-combined"
    assert data["extends"] == ["intentionally-left-nil/test", "nolabs-ai/test-agent"]
    assert data["environment"]["set_vars"]["HTTP_PROXY"] == "http://spens-interceptor:9090"
    assert data["environment"]["set_vars"]["SSL_CERT_FILE"] == "/certs/mitmproxy-ca-cert.pem"


def test_profile_enforced_egress_points_at_interceptor_alias() -> None:
    """Default egress mode: proxy is the interceptor's network alias on the
    isolated internal network, and the alias itself is in NO_PROXY so a
    no-proxy-aware client cannot loop."""
    profile = generate_nono_profile(ENV, AGENT, None, Path("."))
    set_vars = json.loads(profile)["environment"]["set_vars"]
    for var in ("HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy"):
        assert set_vars[var] == "http://spens-interceptor:9090"
    for var in ("NO_PROXY", "no_proxy"):
        assert set_vars[var] == "localhost,127.0.0.1,spens-interceptor"


def test_profile_legacy_egress_uses_localhost() -> None:
    """egress=legacy keeps the old shared-netns addressing."""
    config = {"egress": "legacy"}
    profile = generate_nono_profile(ENV, AGENT, config, Path("."))
    set_vars = json.loads(profile)["environment"]["set_vars"]
    assert set_vars["HTTP_PROXY"] == "http://localhost:9090"
    assert set_vars["HTTPS_PROXY"] == "http://localhost:9090"
    assert set_vars["NO_PROXY"] == "localhost,127.0.0.1"


def test_profile_pins_prefix_cache_vars_to_tmp() -> None:
    """Cache vars from $PREFIX-based base packs must be absolute /tmp paths.

    Base packs (e.g. intentionally-left-nil/python) set cache/runtime vars to
    ``$PREFIX/...`` values.  ``$PREFIX`` is not in nono's ``set_vars``
    expansion vocabulary, so the literal string reaches the child; a relative
    ``PYTHONPYCACHEPREFIX`` (or TMP/PIP_CACHE_DIR/...) is then resolved
    against the current working directory, littering the mounted workspace
    with literal ``$PREFIX/...`` directories.  Child ``set_vars`` override
    extended packs, so the generated profile must pin them to /tmp.
    """
    profile = generate_nono_profile(ENV, AGENT, None, Path("."))
    set_vars = json.loads(profile)["environment"]["set_vars"]
    assert set_vars["TMPDIR"] == "/tmp"
    assert set_vars["TMP"] == "/tmp"
    assert set_vars["TEMP"] == "/tmp"
    assert set_vars["PYTHONPYCACHEPREFIX"] == "/tmp/__pycache__"
    assert set_vars["PIP_CACHE_DIR"] == "/tmp/pip"
    assert set_vars["MPLCONFIGDIR"] == "/tmp/matplotlib"
    assert set_vars["JUPYTER_RUNTIME_DIR"] == "/tmp/jupyter/runtime"
    # No value may smuggle an unexpandable $PREFIX reference back in.
    for key, value in set_vars.items():
        assert "$PREFIX" not in value, f"set_var {key} contains unexpanded $PREFIX"


def test_profile_bakes_inject_placeholders_into_set_vars() -> None:
    """Placeholders must survive base profiles with an env allow-list.

    Base packs (e.g. intentionally-left-nil/python) may define
    ``environment.allow_vars``; nono then clears inherited env vars that are
    not on the list -- including the placeholder API-key vars set via
    ``docker -e``.  ``set_vars`` is applied after filtering, so the generated
    profile must contain every inject_headers placeholder as a set_var.
    """
    config = {
        "inject_headers": [
            {"placeholder": "ANTHROPIC_API_KEY", "env_var": "ANTHROPIC_API_KEY"},
            {"placeholder": "OPENAI_API_KEY", "env_var": "OPENAI_KEY"},
        ]
    }
    profile = generate_nono_profile(ENV, AGENT, config, Path("."))
    data = json.loads(profile)
    set_vars = data["environment"]["set_vars"]
    assert set_vars["ANTHROPIC_API_KEY"] == "ANTHROPIC_API_KEY"
    assert set_vars["OPENAI_API_KEY"] == "OPENAI_API_KEY"
    # static proxy vars are still present
    assert set_vars["HTTP_PROXY"] == "http://spens-interceptor:9090"


def test_profile_skips_invalid_placeholder_names() -> None:
    config = {
        "inject_headers": [
            {"placeholder": "not a valid env name!", "env_var": "ANTHROPIC_API_KEY"},
            {"placeholder": "", "env_var": "ANTHROPIC_API_KEY"},
        ]
    }
    profile = generate_nono_profile(ENV, AGENT, config, Path("."))
    data = json.loads(profile)
    set_vars = data["environment"]["set_vars"]
    assert "not a valid env name!" not in set_vars
    # no allow_vars key is written: writing one would activate env restriction
    # for base packs that do not define an allow-list themselves
    assert "allow_vars" not in data["environment"]


def test_profile_extends_only_once_when_base_equal() -> None:
    agent = dict(AGENT, nono_base_config=ENV["nono_base_config"])
    profile = generate_nono_profile(ENV, agent, None, Path("."))
    data = json.loads(profile)
    assert data["extends"] == ["intentionally-left-nil/test"]


def test_override_file_wins(tmp_path) -> None:
    override = tmp_path / "override.json"
    override.write_text('{"custom": true}', encoding="utf-8")
    config = {"nono_override": "override.json"}
    profile = generate_nono_profile(ENV, AGENT, config, tmp_path)
    assert profile == '{"custom": true}'


def test_missing_override_file_falls_back_to_combined() -> None:
    config = {"nono_override": "does-not-exist.json"}
    profile = generate_nono_profile(ENV, AGENT, config, Path("."))
    data = json.loads(profile)
    assert data["meta"]["name"] == "spens-combined"


def test_entrypoint() -> None:
    entrypoint = generate_agent_entrypoint(AGENT)
    assert "#!/bin/bash" in entrypoint
    assert "nono run --suppress-save-prompt /  --profile spens-profile" in entrypoint
    assert "--allow /home/spens/.cache/test-agent" in entrypoint
    assert "--read /certs" in entrypoint
    assert "--allow /tmp" in entrypoint
    assert "--rollback-exclude /home/spens/.cache/test-agent" in entrypoint
    assert "-- test-agent" in entrypoint
    assert "export HTTP_PROXY=http://spens-interceptor:9090" in entrypoint
    assert "export NO_PROXY=localhost,127.0.0.1,spens-interceptor" in entrypoint
    assert "exec nono run --suppress-save-prompt /  --profile spens-profile" in entrypoint


def test_entrypoint_legacy_egress_uses_localhost() -> None:
    entrypoint = generate_agent_entrypoint(AGENT, None, {"egress": "legacy"})
    assert "export HTTP_PROXY=http://localhost:9090" in entrypoint
    assert "export NO_PROXY=localhost,127.0.0.1" in entrypoint
    assert "http://spens-interceptor:9090" not in entrypoint


def test_entrypoint_waits_for_addon_ready_marker() -> None:
    """The agent must block until the interceptor is actually running.

    Gating on the CA cert file is not sufficient: the cert can exist before
    the addon takes over port 9090, and traffic sent through the proxy in
    that window bypasses capture / secret substitution / domain filtering.
    The DNS forwarder marker gates agent name resolution the same way.
    """
    entrypoint = generate_agent_entrypoint(AGENT)
    assert "until [ -f /certs/spens_addon_ready ] && [ -f /certs/spens_dns_ready ]; do" in entrypoint
    # must NOT gate on the cert file alone
    assert "until [ -f /certs/mitmproxy-ca-cert.pem ]" not in entrypoint


def test_entrypoint_without_allow_folders() -> None:
    agent = {k: v for k, v in AGENT.items() if k != "allow_folders"}
    entrypoint = generate_agent_entrypoint(agent)
    assert "--read /certs" in entrypoint
    assert "--allow /home/spens/" not in entrypoint


def test_entrypoint_yolo_branch_present() -> None:
    agent = dict(AGENT, yolo_command="test-agent run --auto {prompt}")
    entrypoint = generate_agent_entrypoint(agent)
    assert 'if [ -n "$SPENS_YOLO_PROMPT" ]; then' in entrypoint
    assert "else" in entrypoint
    assert "fi" in entrypoint
    # yolo command runs the resolved yolo_command via nono
    assert 'exec nono run --suppress-save-prompt /  --profile spens-profile' in entrypoint
    assert 'test-agent run --auto "$SPENS_YOLO_PROMPT"' in entrypoint
    # interactive fallback still present
    assert "-- test-agent" in entrypoint


def test_entrypoint_yolo_replaces_prompt_placeholder() -> None:
    agent = dict(AGENT, yolo_command="my-agent exec {prompt} --full-auto")
    entrypoint = generate_agent_entrypoint(agent)
    assert 'my-agent exec "$SPENS_YOLO_PROMPT" --full-auto' in entrypoint
    # the raw placeholder must not leak through
    assert "{prompt}" not in entrypoint


def test_entrypoint_without_yolo_command_has_no_branch() -> None:
    entrypoint = generate_agent_entrypoint(AGENT)
    assert 'SPENS_YOLO_PROMPT' not in entrypoint
    assert "if [ -n" not in entrypoint
    assert "exec nono run --suppress-save-prompt /  --profile spens-profile --allow-cwd" in entrypoint


def test_entrypoint_accept_changes_adds_nono_flag() -> None:
    entrypoint = generate_agent_entrypoint(AGENT, accept_changes=True)
    assert "--no-rollback-prompt" in entrypoint
    assert entrypoint.count("--no-rollback-prompt") == 1
    # must appear after --rollback in the nono command
    assert entrypoint.index("--rollback") < entrypoint.index("--no-rollback-prompt")
    # accept keeps the changes: no rollback command runs after the agent
    assert "nono rollback" not in entrypoint


def test_entrypoint_without_accept_changes_has_no_nono_flag() -> None:
    entrypoint = generate_agent_entrypoint(AGENT, accept_changes=False)
    assert "--no-rollback-prompt" not in entrypoint


# -- --reject-changes -------------------------------------------------------


def test_entrypoint_reject_changes_adds_nono_flag_and_rollback() -> None:
    """Reject mode runs unattended (--no-rollback-prompt) and rolls the
    workspace back to its pre-session state after the agent exits."""
    entrypoint = generate_agent_entrypoint(AGENT, reject_changes=True)
    assert "--no-rollback-prompt" in entrypoint
    assert entrypoint.count("--no-rollback-prompt") == 1
    # the separate nono rollback command runs after the agent process exits
    assert "nono rollback restore" in entrypoint
    # snapshot 0 is the pre-session baseline (verified against nono 0.77.0:
    # restore takes an explicit session id and defaults to the LAST snapshot)
    assert "--snapshot 0" in entrypoint
    # the rollback session id is resolved from the container's own state dir
    # (no explicit hash is baked in at build time)
    assert "nono/rollbacks" in entrypoint


def test_entrypoint_reject_changes_never_execs_and_preserves_exit_code() -> None:
    """The agent must NOT be exec'd in reject mode: the entrypoint has to
    survive the agent's exit to run the rollback, then report the agent's
    own exit code."""
    entrypoint = generate_agent_entrypoint(AGENT, reject_changes=True)
    assert "exec nono run" not in entrypoint
    assert "set +e" in entrypoint
    assert 'agent_rc=$?' in entrypoint
    assert 'exit "$agent_rc"' in entrypoint
    # the rollback happens after the agent, before the exit
    assert entrypoint.index("agent_rc=$?") < entrypoint.index("nono rollback restore")
    assert entrypoint.index("nono rollback restore") < entrypoint.index('exit "$agent_rc"')


def test_entrypoint_reject_changes_keeps_yolo_branch() -> None:
    agent = dict(AGENT, yolo_command="test-agent run --auto {prompt}")
    entrypoint = generate_agent_entrypoint(agent, reject_changes=True)
    assert 'if [ -n "$SPENS_YOLO_PROMPT" ]; then' in entrypoint
    assert 'test-agent run --auto "$SPENS_YOLO_PROMPT"' in entrypoint
    # interactive fallback still present
    assert "-- test-agent" in entrypoint


def test_entrypoint_reject_changes_without_yolo_command() -> None:
    agent = {k: v for k, v in AGENT.items() if k != "yolo_command"}
    entrypoint = generate_agent_entrypoint(agent, reject_changes=True)
    assert "exec nono run" not in entrypoint
    assert "nono rollback restore" in entrypoint
    assert 'SPENS_YOLO_PROMPT' not in entrypoint


def test_entrypoint_reject_changes_rollback_failure_is_not_fatal() -> None:
    """A failed rollback must not mask the agent's exit code."""
    entrypoint = generate_agent_entrypoint(AGENT, reject_changes=True)
    assert "if ! nono rollback restore" in entrypoint
    assert "workspace changes may still be present" in entrypoint


def test_base_dockerfile() -> None:
    dockerfile = generate_interceptor_dockerfile()
    assert dockerfile.startswith("FROM python:3.12-slim\n")
    assert "pip install --no-cache-dir mitmproxy" in dockerfile
    assert "COPY mitmproxy_addon.py /app/mitmproxy_addon.py" in dockerfile
    assert "COPY mitmproxy_helpers.py /app/mitmproxy_helpers.py" in dockerfile
    assert "COPY dns_forwarder.py /app/dns_forwarder.py" in dockerfile
    assert "COPY entrypoint.sh /entrypoint.sh" in dockerfile
    assert "EXPOSE 9090" in dockerfile
    # DNS forwarder listeners (the agent's resolv.conf points here)
    assert "EXPOSE 53/udp" in dockerfile
    assert "EXPOSE 53/tcp" in dockerfile
    assert 'ENTRYPOINT ["/entrypoint.sh"]' in dockerfile


def test_dockerfile_no_llm_interceptor_references() -> None:
    dockerfile = generate_interceptor_dockerfile()
    entrypoint = generate_interceptor_entrypoint()
    for text in (dockerfile, entrypoint):
        assert "llm-interceptor" not in text
        assert "lli " not in text
        assert "script -qc" not in text


def test_interceptor_entrypoint_runs_mitmdump_with_addon() -> None:
    entrypoint = generate_interceptor_entrypoint()
    assert "exec mitmdump -s /app/mitmproxy_addon.py" in entrypoint
    assert "--set spens_config=/app/spens_interceptor_config.json" in entrypoint
    assert "--set listen_port=9090" in entrypoint
    # the agent connects from its own container IP on the isolated internal
    # network (not localhost); mitmproxy refuses non-local clients by default
    assert "--set block_global=false" in entrypoint


def test_interceptor_entrypoint_disables_rawtcp() -> None:
    """rawtcp must be off: it defaults to on and routes any non-HTTP payload
    inside a CONNECT tunnel (explicitly including SSH) to a raw TCP
    forwarding layer where no addon hook runs -- an open, unlogged,
    un-domain-checked tunnel.  With it off, tunneled bytes must parse as
    HTTP, so SSH and other non-HTTP protocols fail closed in the proxy.
    """
    entrypoint = generate_interceptor_entrypoint()
    assert "--set rawtcp=false" in entrypoint
    assert "rawtcp=true" not in entrypoint


def test_interceptor_entrypoint_starts_dns_forwarder_first() -> None:
    """The DNS forwarder must be running before mitmdump takes over PID 1.

    It binds port 53 and writes its readiness marker before the addon's
    marker can exist, so anyone gating on the markers knows DNS is up.
    """
    entrypoint = generate_interceptor_entrypoint()
    assert "python3 /app/dns_forwarder.py &" in entrypoint
    assert entrypoint.index("dns_forwarder.py") < entrypoint.index("exec mitmdump")


def test_interceptor_entrypoint_single_phase_mitmdump_startup() -> None:
    """Interceptor must run a single mitmdump (with addon), never a bare one.

    A separate cert-generation phase (`timeout 10 mitmdump ...` with no
    addon) binds port 9090 before the real proxy starts, so early agent
    traffic passes through an unconfigured proxy (401s from unsubstituted
    placeholder secrets, no capture, no domain filtering).
    """
    entrypoint = generate_interceptor_entrypoint()
    # no bare/addon-less mitmdump phase
    assert "timeout" not in entrypoint
    # mitmdump appears exactly once (the exec'd addon run)
    assert entrypoint.count("mitmdump") == 1
    assert "exec mitmdump -s /app/mitmproxy_addon.py" in entrypoint


def test_interceptor_config_empty() -> None:
    config_json = generate_interceptor_config(None)
    data = json.loads(config_json)
    assert data == {
        "addition_capture_urls": [],
        "exclude_capture_urls": [],
        "domain_rules": [],
        "inject_headers": [],
    }


def test_interceptor_config_with_values() -> None:
    config = {
        "addition_capture_urls": ["*opencode.ai*", "*api.fireworks.ai*"],
        "exclude_capture_urls": ["*models.opencode.ai*"],
        "domain_rules": [{"pattern": "*api.github.com*", "allow": ["GET"]}],
        "inject_headers": [
            {
                "placeholder": "ANTHROPIC_API_KEY",
                "env_var": "ANTHROPIC_KEY",
                "for_domains": ["*api.anthropic.com*"],
            }
        ],
    }
    config_json = generate_interceptor_config(config)
    data = json.loads(config_json)
    assert data["addition_capture_urls"] == ["*opencode.ai*", "*api.fireworks.ai*"]
    assert data["exclude_capture_urls"] == ["*models.opencode.ai*"]
    assert data["domain_rules"] == [{"pattern": "*api.github.com*", "allow": ["GET"]}]
    assert data["inject_headers"] == [
        {
            "placeholder": "ANTHROPIC_API_KEY",
            "env_var": "ANTHROPIC_KEY",
            "for_domains": ["*api.anthropic.com*"],
        }
    ]


def test_interceptor_config_inject_rule_without_for_domains() -> None:
    """A rule without for_domains is kept but authorizes no URLs."""
    config = {
        "inject_headers": [
            {"placeholder": "ANTHROPIC_API_KEY", "env_var": "ANTHROPIC_KEY"}
        ]
    }
    data = json.loads(generate_interceptor_config(config))
    assert data["inject_headers"] == [
        {
            "placeholder": "ANTHROPIC_API_KEY",
            "env_var": "ANTHROPIC_KEY",
            "for_domains": [],
        }
    ]


def test_interceptor_config_inject_rule_malformed_for_domains() -> None:
    """A non-list for_domains is normalized to an empty (deny-all) list."""
    config = {
        "inject_headers": [
            {
                "placeholder": "ANTHROPIC_API_KEY",
                "env_var": "ANTHROPIC_KEY",
                "for_domains": "*api.anthropic.com*",
            }
        ]
    }
    data = json.loads(generate_interceptor_config(config))
    assert data["inject_headers"] == [
        {
            "placeholder": "ANTHROPIC_API_KEY",
            "env_var": "ANTHROPIC_KEY",
            "for_domains": [],
        }
    ]


def test_interceptor_config_inject_rule_malformed_dropped() -> None:
    """Rules missing placeholder/env_var (or non-dict) are dropped."""
    config = {
        "inject_headers": [
            "not-a-rule",
            {"placeholder": "NO_ENV"},
            {"env_var": "ANTHROPIC_KEY"},
            {
                "placeholder": "ANTHROPIC_API_KEY",
                "env_var": "ANTHROPIC_KEY",
                "for_domains": ["*api.anthropic.com*"],
            },
        ]
    }
    data = json.loads(generate_interceptor_config(config))
    assert data["inject_headers"] == [
        {
            "placeholder": "ANTHROPIC_API_KEY",
            "env_var": "ANTHROPIC_KEY",
            "for_domains": ["*api.anthropic.com*"],
        }
    ]


def test_interceptor_config_partial_config() -> None:
    config = {"addition_capture_urls": ["*example.com*"]}
    config_json = generate_interceptor_config(config)
    data = json.loads(config_json)
    assert data["addition_capture_urls"] == ["*example.com*"]
    assert data["exclude_capture_urls"] == []
    assert data["domain_rules"] == []
    assert data["inject_headers"] == []


def test_interceptor_config_exclude_only() -> None:
    config = {"exclude_capture_urls": ["*models.opencode.ai*"]}
    config_json = generate_interceptor_config(config)
    data = json.loads(config_json)
    assert data["addition_capture_urls"] == []
    assert data["exclude_capture_urls"] == ["*models.opencode.ai*"]


# -- event emission (build_started / build_finished / warnings) --------------


def _fake_docker_build(monkeypatch, record):
    from spens.builder import subprocess as builder_subprocess

    class Result:
        returncode = 0

    def fake_run(cmd, check=False, stdout=None):
        record.append({"cmd": cmd, "stdout": stdout})
        return Result()

    monkeypatch.setattr(builder_subprocess, "run", fake_run)


def test_build_interceptor_image_emits_build_events(
    tmp_path, monkeypatch, event_capture
) -> None:
    from spens.builder import build_interceptor_image

    record: list[dict] = []
    _fake_docker_build(monkeypatch, record)
    build_interceptor_image("tag:latest")
    names = event_capture.named()
    assert names == ["build_started", "build_finished"]
    started, finished = event_capture.events
    assert started.data["image"] == "tag:latest"
    assert started.data["message"] == "[spens] Building interceptor image tag:latest ..."
    assert finished.data == {"image": "tag:latest"}


def test_build_agent_image_emits_build_events_and_redirects_stdout(
    tmp_path, monkeypatch, event_capture
) -> None:
    from spens.builder import build_agent_image

    record: list[dict] = []
    _fake_docker_build(monkeypatch, record)
    import sys

    build_agent_image(
        "tag:a", ENV, AGENT, None, tmp_path,
        accept_changes=True, stdout=sys.stderr,
    )
    assert event_capture.named() == ["build_started", "build_finished"]
    # the caller-provided stdout (e.g. sys.stderr in jsonl mode) reaches docker
    assert record[0]["stdout"] is sys.stderr
    assert record[0]["cmd"][:3] == ["docker", "build", "-t"]


class _FakeBuildProc:
    """A stand-in for the piped ``docker build`` process."""

    def __init__(self, lines: list[str], returncode: int = 0) -> None:
        self.stdout = iter(lines)
        self.returncode = returncode

    def wait(self) -> int:
        return self.returncode


class _RecordingProgress:
    """A stand-in for spens.ui.BuildProgress."""

    def __init__(self) -> None:
        self.lines: list[str] = []
        self.calls: list[tuple[str, bool]] = []

    def start(self) -> None:
        self.calls.append(("start", True))

    def update(self, line: str) -> None:
        self.lines.append(line)

    def stop(self, ok: bool = True) -> None:
        self.calls.append(("stop", ok))


def _fake_docker_build_popen(monkeypatch, lines: list[str], returncode: int = 0) -> None:
    from spens.builder import subprocess as builder_subprocess

    def fake_popen(cmd, **kw):
        assert kw["stdout"] is builder_subprocess.PIPE
        assert kw["stderr"] is builder_subprocess.STDOUT
        return _FakeBuildProc(lines, returncode)

    monkeypatch.setattr(builder_subprocess, "Popen", fake_popen)


def test_build_agent_image_streams_output_through_progress(
    tmp_path, monkeypatch, event_capture
) -> None:
    from spens.builder import build_agent_image

    _fake_docker_build_popen(monkeypatch, ["#1 [internal] load\n", "#2 DONE\n"])
    progress = _RecordingProgress()
    build_agent_image("tag:a", ENV, AGENT, None, tmp_path, progress=progress)
    # every build line is fed to the constrained region, in order (raw,
    # as read from the pipe; BuildProgress strips them itself)
    assert progress.lines == ["#1 [internal] load\n", "#2 DONE\n"]
    assert progress.calls == [("start", True), ("stop", True)]
    assert event_capture.named() == ["build_started", "build_finished"]


def test_build_agent_image_failure_stops_progress_and_raises(
    tmp_path, monkeypatch, event_capture
) -> None:
    import pytest
    from spens.builder import build_agent_image

    _fake_docker_build_popen(monkeypatch, ["ERROR: boom\n"], returncode=1)
    progress = _RecordingProgress()
    with pytest.raises(RuntimeError, match="Failed to build agent image"):
        build_agent_image("tag:a", ENV, AGENT, None, tmp_path, progress=progress)
    # the region is stopped with ok=False (printing the failed tail) first
    assert progress.calls[-1] == ("stop", False)


def test_generate_nono_profile_emits_placeholder_warning(event_capture) -> None:
    config = {"inject_headers": [
        {"placeholder": "not a valid env name!", "env_var": "KEY"},
    ]}
    generate_nono_profile(ENV, AGENT, config, Path("."))
    assert event_capture.named() == ["warning"]
    assert "not a valid env name!" in event_capture.messages()[0]


def test_validate_inject_rules_emits_warnings(event_capture) -> None:
    from spens.builder import _validate_inject_rules

    _validate_inject_rules(["not-a-rule", {"placeholder": "P"}])
    assert event_capture.named() == ["warning", "warning"]
