"""Generate Dockerfiles, nono profiles, entrypoints and build Docker images."""

from __future__ import annotations

import json
import re
import subprocess
import tempfile
from pathlib import Path, PurePosixPath
from typing import Any, TypeAlias

from spens import events

SpensConfig: TypeAlias = dict[str, Any]

# Valid environment variable name (nono rejects invalid ``set_vars`` keys at
# profile load time, which would break the whole session).
_ENV_NAME_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")

# The agent inside the container runs as an unprivileged user instead of
# root.  Environments may override the defaults via an ``agent_user`` object
# in the environment template (e.g. the official node images already ship a
# ``node`` user with uid 1000, so they reuse it instead of creating one).
_DEFAULT_AGENT_USER: dict[str, Any] = {
    "name": "spens",
    "uid": 1000,
    "home": "/home/spens",
    "create": True,
}


# Network alias the interceptor gets on the session's internal Docker
# network.  In enforced-egress mode the agent reaches the proxy at
# ``http://<alias>:9090`` (resolved via Docker's embedded DNS); in legacy
# mode it shares the interceptor's netns and uses localhost.
INTERCEPTOR_PROXY_ALIAS = "spens-interceptor"


def _proxy_env(config: SpensConfig | None) -> tuple[str, str]:
    """Return ``(proxy_url, no_proxy)`` for the configured egress mode.

    Default (enforced): the agent sits on an isolated internal Docker network
    and reaches the interceptor by its network alias.  ``egress: legacy``:
    the agent shares the interceptor's network namespace and uses localhost —
    proxy routing is then advisory only (see specs/enforced_egress.md).
    """
    if config and config.get("egress") == "legacy":
        return "http://localhost:9090", "localhost,127.0.0.1"
    return (
        f"http://{INTERCEPTOR_PROXY_ALIAS}:9090",
        f"localhost,127.0.0.1,{INTERCEPTOR_PROXY_ALIAS}",
    )


# Cache/runtime env vars pinned to absolute paths under /tmp.
#
# The ``intentionally-left-nil/python`` base pack (extended by every profile
# we generate for a Python environment) points a set of cache and runtime env
# vars at ``$PREFIX/...`` paths.  nono expands ``set_vars`` values against a
# fixed vocabulary ($HOME, $WORKDIR, $TMPDIR, $UID, $XDG_*, $NONO_CONFIG,
# $NONO_PACKAGES) -- ``$PREFIX`` is not in it, so the literal string reaches
# the sandboxed child.  A value like ``$PREFIX/__pycache__`` is then a
# *relative* path: Python resolves it against the current working directory
# and litters the mounted workspace with literal ``$PREFIX/__pycache__/...``
# trees (same for TMP/TEMP, the pip cache, matplotlib/Jupyter cache dirs,
# ...).  Child ``set_vars`` override values inherited from extended packs, so
# pinning every affected variable here neutralizes the pack's broken values.
# /tmp is writable in every session we generate (allowed in both the nono
# profile and the agent entrypoint).
_SANITIZED_CACHE_VARS: dict[str, str] = {
    "TMP": "/tmp",
    "TEMP": "/tmp",
    "PYTHONPYCACHEPREFIX": "/tmp/__pycache__",
    "PYTHON_HISTORY": "/tmp/.python_history",
    "PIP_CACHE_DIR": "/tmp/pip",
    "JUPYTER_CONFIG_DIR": "/tmp/jupyter/config",
    "JUPYTER_DATA_DIR": "/tmp/jupyter/data",
    "JUPYTER_RUNTIME_DIR": "/tmp/jupyter/runtime",
    "IPYTHONDIR": "/tmp/ipython",
    "MPLCONFIGDIR": "/tmp/matplotlib",
    "NUMBA_CACHE_DIR": "/tmp/numba",
    "TRITON_CACHE_DIR": "/tmp/triton",
    "TORCHINDUCTOR_CACHE_DIR": "/tmp/torch_inductor",
}


def agent_user(env: dict[str, Any] | None) -> dict[str, Any]:
    """Return the unprivileged user the agent runs as for ``env``."""
    user = dict(_DEFAULT_AGENT_USER)
    if env:
        user.update(env.get("agent_user") or {})
    return user


def expand_agent_path(path: str, home: str) -> str:
    """Expand ``~`` (and legacy ``/root``) paths to the agent home directory.

    Shared with the runner, which resolves the same ``destination`` paths when
    turning an agent template's ``configuration`` block into docker mounts.
    """
    if path == "~":
        return home
    if path.startswith("~/"):
        return home + path[1:]
    if path == "/root":
        return home
    if path.startswith("/root/"):
        return home + path[len("/root"):]
    return path

# ---------------------------------------------------------------------------
# Agent image
# ---------------------------------------------------------------------------


def _pkg_install_command(pkg_manager: str, packages: list[str]) -> str:
    joined = " ".join(packages)
    if pkg_manager == "apk":
        return f"apk add --no-cache {joined}"
    # default to apt for Debian-based images
    return (
        f"apt-get update && apt-get install -y --no-install-recommends {joined} "
        "&& rm -rf /var/lib/apt/lists/*"
    )


def generate_agent_dockerfile(env: dict[str, Any], agent: dict[str, Any]) -> str:
    user = agent_user(env)
    name = user["name"]
    uid = user["uid"]
    home = user["home"]

    lines: list[str] = []
    lines.append(f"FROM {env['base-image']}")
    lines.append("")
    lines.append('SHELL ["/bin/bash", "-c"]')
    lines.append("")

    # 2. collect packages from env and agent, install them
    packages = list(env.get("packages", []))
    packages.extend(agent.get("packages", []))
    if packages:
        pkg_manager = env.get("package_manager", "apt")
        lines.append(f"RUN {_pkg_install_command(pkg_manager, packages)}")
        lines.append("")

    # create the unprivileged user the agent will run as (unless the base
    # image already provides one, e.g. the ``node`` user on node images)
    if user.get("create", True):
        if env.get("package_manager") == "apk":
            lines.append(f"RUN adduser -D -u {uid} {name}")
        else:
            lines.append(f"RUN useradd -m -u {uid} {name}")
        lines.append("")
    lines.append(f"ENV HOME={home}")
    lines.append(
        f'ENV PATH="{home}/.local/bin:{home}/.opencode/bin:'
        '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"'
    )
    lines.append("")

    # 3. run nono.sh install (system-wide, as root)
    lines.append(f"RUN {env['nono-command']}")
    lines.append("")

    # pull nono base profiles as the agent user so they land in the agent's
    # own ~/.config/nono instead of /root.  No `|| true`: a failed pull must
    # fail the build loudly -- a silently missing profile would run the
    # agent with weaker isolation than the configuration promises.
    lines.append(f"USER {name}")
    env_base = env.get("nono_base_config")
    agent_base = agent.get("nono_base_config")
    if env_base:
        lines.append(f"RUN nono pull {env_base}")
    if agent_base and agent_base != env_base:
        lines.append(f"RUN nono pull {agent_base}")
    lines.append("")

    # 4. run dependency commands for the agent. These are build-time
    # provisioning steps (compilers, runtimes, symlinks into /usr/local) and
    # typically need root; HOME is pinned to the agent home so anything they
    # install under ``$HOME`` still lands in the agent's directory.
    for dep in agent.get("dependencies", []):
        lines.append("USER root")
        lines.append(f"RUN export HOME={home} && {dep}")
    if agent.get("dependencies"):
        lines.append("")

    # 5. run the installation command for the agent (as the agent user, so
    # the binary and its config land in the agent's home, not /root)
    lines.append(f"USER {name}")
    lines.append(f"RUN {agent['installation_command']}")
    lines.append("")

    # relocate binary if specified (copying into /usr/local needs root)
    relocate = agent.get("relocate_binary")
    if relocate:
        from_path = expand_agent_path(relocate["from"], home)
        cleanup = relocate["cleanup"]
        if isinstance(cleanup, list):
            cleanup = " ".join(
                f'"{expand_agent_path(c, home)}"' for c in cleanup
            )
        else:
            cleanup = f'"{expand_agent_path(cleanup, home)}"'
        lines.append("USER root")
        lines.append(
            f'RUN cp "{from_path}" "{relocate["to"]}" '
            f'&& rm -rf {cleanup}'
        )
        lines.append(f"USER {name}")
        lines.append("")

    # write static config files into the image (after relocate/cleanup)
    config_files = agent.get("config_files", {})
    for i, dest_path in enumerate(config_files):
        dest_path = expand_agent_path(dest_path, home)
        dest_dir = str(PurePosixPath(dest_path).parent)
        lines.append(f'RUN mkdir -p "{dest_dir}"')
        lines.append(f"COPY --chown={name} config-file-{i} {dest_path}")
        lines.append("")

    # ensure allow_folders exist so nono can mount/access them
    allow_folders = [
        expand_agent_path(f, home) for f in agent.get("allow_folders", [])
    ]
    if allow_folders:
        mkdir_paths = " ".join(f'"{f}"' for f in allow_folders)
        lines.append(f"RUN mkdir -p {mkdir_paths}")
        lines.append("")

    # 6. nono profile (in the agent user's config directory)
    lines.append(f"RUN mkdir -p {home}/.config/nono/profiles")
    lines.append(
        f"COPY --chown={name} spens-profile.json "
        f"{home}/.config/nono/profiles/spens-profile.json"
    )
    lines.append("")

    # 7-8. entrypoint with proxy env vars and nono run command (root owns
    # /entrypoint.sh; the agent only needs to execute it)
    lines.append("USER root")
    lines.append("COPY entrypoint.sh /entrypoint.sh")
    lines.append("RUN chmod +x /entrypoint.sh")
    lines.append("")

    # the agent itself always runs as the unprivileged user
    lines.append(f"USER {name}")
    lines.append("WORKDIR /workspace")
    lines.append("")
    lines.append('ENTRYPOINT ["/entrypoint.sh"]')

    return "\n".join(lines) + "\n"


def generate_nono_profile(
    env: dict[str, Any],
    agent: dict[str, Any],
    config: SpensConfig | None,
    workspace: Path,
) -> str:
    # If a nono_override file is provided in the config, copy its contents
    if config and config.get("nono_override"):
        override_path = workspace / config["nono_override"]
        if override_path.exists():
            return override_path.read_text(encoding="utf-8")

    # Base packs pulled from the nono registry may define an
    # ``environment.allow_vars`` allow-list (e.g. intentionally-left-nil/python
    # does).  When such a list is in effect, nono clears the inherited
    # environment and only passes through matching variables -- which strips
    # the placeholder API-key env vars we set on the agent container via
    # ``docker -e`` (the agent then reports "credentials_not_configured").
    # ``set_vars`` is applied *after* allow/deny filtering, so baking the
    # placeholders in here guarantees they reach the agent process regardless
    # of what the base profiles filter.  The placeholder string is not a
    # secret, so embedding it in the profile (and thus the image) is safe.
    inject_placeholders: dict[str, str] = {}
    if config:
        for rule in config.get("inject_headers", []):
            placeholder = rule.get("placeholder", "")
            if not placeholder:
                continue
            if not _ENV_NAME_RE.fullmatch(placeholder):
                events.emit(
                    "warning",
                    message=(
                        f"[spens] Warning: inject_headers placeholder '{placeholder}' is not a "
                        "valid environment variable name; it will NOT be passed to the agent"
                    ),
                )
                continue
            inject_placeholders[placeholder] = placeholder

    # Otherwise create a combined profile that extends both base configs
    proxy_url, no_proxy = _proxy_env(config)
    extends: list[str] = []
    env_base = env.get("nono_base_config")
    agent_base = agent.get("nono_base_config")
    if env_base:
        extends.append(env_base)
    if agent_base and agent_base != env_base:
        extends.append(agent_base)

    profile = {
        "meta": {
            "name": "spens-combined",
            "version": "1.0.0",
            "description": f"Spens combined profile for {agent['name']} on {env['name']}",
        },
        "extends": extends,
        "environment": {
            "set_vars": {
                "TERM": "xterm-256color",
                "TMPDIR": "/tmp",
                "PREFIX": "/usr/local",
                "HTTP_PROXY": proxy_url,
                "HTTPS_PROXY": proxy_url,
                "http_proxy": proxy_url,
                "https_proxy": proxy_url,
                "NO_PROXY": no_proxy,
                "no_proxy": no_proxy,
                "NODE_USE_ENV_PROXY": "1",
                "SSL_CERT_FILE": "/certs/mitmproxy-ca-cert.pem",
                "NODE_EXTRA_CA_CERTS": "/certs/mitmproxy-ca-cert.pem",
                "CURL_CA_BUNDLE": "/certs/mitmproxy-ca-cert.pem",
                "REQUESTS_CA_BUNDLE": "/certs/mitmproxy-ca-cert.pem",
                "PIP_CERT": "/certs/mitmproxy-ca-cert.pem",

                **_SANITIZED_CACHE_VARS,
                **inject_placeholders,
            },
        },
    }
    return json.dumps(profile, indent=2) + "\n"


def _reject_changes_rollback_block() -> str:
    """Bash block reverting the workspace to its pre-session state.

    Rollback always happens INSIDE the agent container, after the agent
    process exits -- never on the host.  Verified against nono 0.77.0:
    ``nono rollback restore <SESSION_ID>`` requires an explicit session id
    (there is no implicit "latest session" target), so the newest rollback
    session in this container's state dir is resolved first -- only one
    agent session ever runs in a given container, so newest == this
    session.  Snapshot 0 is the baseline taken before the agent started;
    restoring to it reverts every workspace change the agent made.
    """
    return (
        'rb_root="${XDG_STATE_HOME:-$HOME/.local/state}/nono/rollbacks"\n'
        'if [ -d "$rb_root" ]; then\n'
        '  latest="$(ls -1t "$rb_root" 2>/dev/null | head -n 1)"\n'
        '  if [ -n "$latest" ]; then\n'
        '    echo "[spens] Rejecting changes: restoring the workspace to its '
        'pre-session state ..."\n'
        '    if ! nono rollback restore "$latest" --snapshot 0; then\n'
        '      echo "[spens] Warning: rollback failed; workspace changes may '
        'still be present."\n'
        "    fi\n"
        "  fi\n"
        "fi\n"
    )


def _reject_changes_command_block(
    interactive_cmd: str, yolo_cmd: str | None
) -> str:
    """Run the agent (no exec), then roll back, preserving the agent's rc.

    ``set +e`` is required: a nonzero agent exit must reach the rollback
    (and be reported) rather than abort the entrypoint under ``set -e``.
    """
    if yolo_cmd:
        branch = (
            'if [ -n "$SPENS_YOLO_PROMPT" ]; then\n'
            f"  {yolo_cmd}\n"
            "else\n"
            f"  {interactive_cmd}\n"
            "fi\n"
        )
    else:
        branch = f"{interactive_cmd}\n"
    return (
        "set +e\n"
        + branch
        + "agent_rc=$?\n"
        "set -e\n"
        + _reject_changes_rollback_block()
        + 'exit "$agent_rc"\n'
    )


def generate_agent_entrypoint(
    agent: dict[str, Any],
    env: dict[str, Any] | None = None,
    config: SpensConfig | None = None,
    accept_changes: bool = False,
    reject_changes: bool = False,
) -> str:
    home = agent_user(env)["home"]
    proxy_url, no_proxy = _proxy_env(config)
    allow_folders = [
        expand_agent_path(f, home) for f in agent.get("allow_folders", [])
    ]
    prefix_parts: list[str] = [
        "nono run",
        "--suppress-save-prompt / ",
        "--profile spens-profile",
        "--allow-cwd",
    ]

    for folder in allow_folders:
        prefix_parts.append(f"--allow {folder}")

    # operational requirements: read the cert, write to /tmp
    prefix_parts.append("--read /certs")
    prefix_parts.append("--allow /tmp")
    prefix_parts.append("--rollback")
    if accept_changes or reject_changes:
        # Both modes run unattended: accept keeps the changes, reject rolls
        # them back after the agent exits (below) -- either way the session
        # must never block on nono's interactive save/rollback prompt.
        prefix_parts.append("--no-rollback-prompt")

    # Only snapshot the workspace; exclude agent-internal and system dirs
    for folder in allow_folders:
        prefix_parts.append(f"--rollback-exclude {folder.rstrip('/')}")
    prefix_parts.append("--rollback-exclude /tmp")
    prefix_parts.append("--rollback-exclude /usr/local")
    prefix_parts.append(f"--rollback-exclude {home}/.config/nono")

    prefix_parts.append("--")
    nono_prefix = " ".join(prefix_parts)

    interactive_cmd = f"{nono_prefix} {agent['name']}"

    # When a yolo_command is defined and SPENS_YOLO_PROMPT is set at runtime,
    # run the agent non-interactively with that prompt instead of the TUI.
    yolo_command = agent.get("yolo_command")
    if yolo_command:
        yolo_resolved = yolo_command.replace("{prompt}", '"$SPENS_YOLO_PROMPT"')
        yolo_cmd = f"{nono_prefix} {yolo_resolved}"
    else:
        yolo_cmd = None

    if reject_changes:
        command_block = _reject_changes_command_block(
            interactive_cmd, yolo_cmd
        )
    elif yolo_cmd:
        command_block = (
            'if [ -n "$SPENS_YOLO_PROMPT" ]; then\n'
            f"  exec {yolo_cmd}\n"
            "else\n"
            f"  exec {interactive_cmd}\n"
            "fi\n"
        )
    else:
        command_block = f"exec {interactive_cmd}\n"

    # Pre-sandbox commands run after the proxy env vars are exported but
    # before nono starts the agent, so they execute unsandboxed (but still
    # as the unprivileged agent user) and route network traffic through the
    # interceptor.
    pre_commands = (config or {}).get("pre_sandbox_commands", [])
    if pre_commands:
        pre_lines = ['echo "[spens] Running pre-sandbox commands..."']
        for cmd in pre_commands:
            pre_lines.append(f'echo "[spens] $ {cmd}"')
            pre_lines.append(cmd)
        pre_block = "\n" + "\n".join(pre_lines) + "\n\n"
    else:
        pre_block = "\n"

    return (
        "#!/bin/bash\n"
        "set -e\n"
        "\n"
        # Block until the interceptor is actually running. The mitmproxy
        # addon writes /certs/spens_addon_ready (in the shared cert volume)
        # from its `running()` hook, after it is configured and the proxy is
        # listening; the DNS forwarder writes /certs/spens_dns_ready once it
        # has bound port 53. Waiting on the CA cert file alone is not
        # sufficient: the cert can exist before the addon takes over port
        # 9090, so early traffic would bypass capture, secret substitution,
        # and domain filtering.
        'echo "Waiting for interceptor addon and DNS forwarder to be ready..."\n'
        'until [ -f /certs/spens_addon_ready ] && [ -f /certs/spens_dns_ready ]; do\n'
        "  sleep 1\n"
        "done\n"
        'echo "Interceptor ready, starting agent..."\n'
        "\n"
        "export TERM=xterm-256color\n"
        "export TMPDIR=/tmp\n"
        "export PREFIX=/usr/local\n"
        f"export HTTP_PROXY={proxy_url}\n"
        f"export HTTPS_PROXY={proxy_url}\n"
        f"export http_proxy={proxy_url}\n"
        f"export https_proxy={proxy_url}\n"
        f"export NO_PROXY={no_proxy}\n"
        f"export no_proxy={no_proxy}\n"
        "export NODE_USE_ENV_PROXY=1\n"
        "export NODE_EXTRA_CA_CERTS=/certs/mitmproxy-ca-cert.pem\n"
        + pre_block
        + "\n"
        + command_block
    )


def _run_build(
    cmd: list[str], *, stdout: Any, progress: Any
) -> int:
    """Run ``docker build``, optionally streaming output into a progress panel.

    With ``progress`` (a :class:`spens.ui.BuildProgress`, used in tty mode)
    the build's combined stdout/stderr is captured and fed to the panel line
    by line, keeping the terminal constrained to the live region.  Without
    it, the build inherits/redirects stdout as before (jsonl/background
    modes keep their stderr redirection so the stdout event stream stays
    clean).
    """
    if progress is not None:
        proc = subprocess.Popen(
            cmd,
            stdout=subprocess.PIPE,
            stderr=subprocess.STDOUT,
            text=True,
        )
        assert proc.stdout is not None
        for line in proc.stdout:
            progress.update(line)
        return proc.wait()
    result = subprocess.run(cmd, check=False, stdout=stdout)
    return result.returncode


def build_agent_image(
    tag: str,
    env: dict[str, Any],
    agent: dict[str, Any],
    config: SpensConfig | None,
    workspace: Path,
    no_cache: bool = False,
    accept_changes: bool = False,
    reject_changes: bool = False,
    stdout: Any = None,
    progress: Any = None,
) -> None:
    dockerfile = generate_agent_dockerfile(env, agent)
    profile = generate_nono_profile(env, agent, config, workspace)
    entrypoint = generate_agent_entrypoint(
        agent, env, config,
        accept_changes=accept_changes, reject_changes=reject_changes,
    )

    with tempfile.TemporaryDirectory(prefix="spens-build-") as tmp:
        tmp_path = Path(tmp)
        (tmp_path / "Dockerfile").write_text(dockerfile, encoding="utf-8", newline="\n")
        (tmp_path / "spens-profile.json").write_text(profile, encoding="utf-8", newline="\n")
        (tmp_path / "entrypoint.sh").write_text(entrypoint, encoding="utf-8", newline="\n")

        for i, (_, content) in enumerate(agent.get("config_files", {}).items()):
            (tmp_path / f"config-file-{i}").write_text(content, encoding="utf-8", newline="\n")

        events.emit(
            "build_started", image=tag,
            message=f"[spens] Building agent image {tag} ...",
        )
        build_cmd = ["docker", "build", "-t", tag]
        if no_cache:
            build_cmd.append("--no-cache")
        build_cmd.append(tmp_path)
        if progress is not None:
            progress.start()
        returncode = _run_build(build_cmd, stdout=stdout, progress=progress)
        if progress is not None:
            progress.stop(ok=returncode == 0)
        if returncode != 0:
            raise RuntimeError(f"Failed to build agent image {tag}")
        events.emit("build_finished", image=tag)


# ---------------------------------------------------------------------------
# Interceptor image (mitmproxy)
# ---------------------------------------------------------------------------

_ADDON_SRC = Path(__file__).resolve().parent / "data" / "mitmproxy_addon.py"
_HELPERS_SRC = Path(__file__).resolve().parent / "data" / "mitmproxy_helpers.py"
_DNS_FORWARDER_SRC = Path(__file__).resolve().parent / "data" / "dns_forwarder.py"


def _validate_inject_rules(rules: list[Any]) -> list[dict[str, Any]]:
    """Validate ``inject_headers`` rules, returning the well-formed ones.

    Each rule needs a ``placeholder``, an ``env_var`` and a ``for_domains``
    list of domain patterns (e.g. ``["*api.anthropic.com*"]``).  Patterns
    are matched against the request hostname with suffix semantics (the
    domain itself or its subdomains), so a domain embedded in an unrelated
    hostname cannot authorize substitution.  Secrets are
    only substituted on URLs matching a ``for_domains`` pattern, so a rule
    without any authorized domains can never substitute its placeholder.
    Malformed rules are dropped with a warning rather than silently leaking
    or crashing the addon.
    """
    valid: list[dict[str, Any]] = []
    for rule in rules:
        if not isinstance(rule, dict):
            events.emit(
                "warning",
                message=f"[spens] Warning: dropping malformed inject_headers rule: {rule!r}",
            )
            continue
        placeholder = rule.get("placeholder", "")
        env_var = rule.get("env_var", "")
        if not placeholder or not env_var:
            events.emit(
                "warning",
                message=(
                    f"[spens] Warning: dropping inject_headers rule missing "
                    f"'placeholder' or 'env_var': {rule!r}"
                ),
            )
            continue
        for_domains = rule.get("for_domains", [])
        if not isinstance(for_domains, list) or not all(
            isinstance(p, str) for p in for_domains
        ):
            events.emit(
                "warning",
                message=(
                    f"[spens] Warning: inject_headers rule (placeholder '{placeholder}') has "
                    "a malformed 'for_domains' (expected a list of domain patterns) -- "
                    "secrets will only be substituted on authorized URLs, so treat this as "
                    "an empty list"
                ),
            )
            for_domains = []
        valid.append({
            "placeholder": placeholder,
            "env_var": env_var,
            "for_domains": for_domains,
        })
    return valid


def generate_interceptor_config(config: SpensConfig | None) -> str:
    """Produce the JSON config consumed by the mitmproxy addon at runtime."""
    cfg: dict[str, Any] = {
        "addition_capture_urls": [],
        "exclude_capture_urls": [],
        "domain_rules": [],
        "inject_headers": [],
    }
    if config:
        cfg["addition_capture_urls"] = config.get("addition_capture_urls", [])
        cfg["exclude_capture_urls"] = config.get("exclude_capture_urls", [])
        cfg["domain_rules"] = config.get("domain_rules", [])
        cfg["inject_headers"] = _validate_inject_rules(
            config.get("inject_headers", [])
        )
    return json.dumps(cfg, indent=2) + "\n"


def generate_interceptor_entrypoint() -> str:
    """Generate the interceptor container's entrypoint script.

    Single-phase startup: mitmdump generates the CA cert itself on first run
    (before it starts listening) and loads/configures the addon before
    binding port 9090.  There is deliberately NO separate cert-generation
    phase: a bare `mitmdump` listening on 9090 without the addon would
    silently pass traffic unintercepted (no capture, no secret substitution,
    no domain filtering) until the real proxy took over.

    ``block_global`` is disabled because in enforced-egress mode the agent
    connects from its own container IP on the isolated internal network
    (not localhost), and mitmproxy refuses non-local clients by default.  The
    only other machine on that network is this interceptor, so this is safe.

    ``rawtcp`` is disabled (it defaults to on) so that non-HTTP payloads
    inside a CONNECT tunnel are NOT blindly forwarded: mitmproxy's layer
    selector routes anything that does not look like HTTP (explicitly
    including SSH) to a raw TCP forwarding layer with no addon hooks.
    With it off, tunneled bytes must parse as HTTP; SSH and other
    non-HTTP protocols fail closed inside the proxy.  (The addon's
    ``http_connect`` hook additionally domain-checks every CONNECT target
    before the tunnel is established.)

    The DNS forwarder (dns_forwarder.py) runs alongside mitmdump so agent
    name resolution is captured too -- Docker's embedded DNS resolver is
    special-cased past the egress filtering of ``--internal`` networks and
    would otherwise be an uncaptured channel.  It binds its sockets and
    writes its readiness marker before mitmdump starts listening.
    """
    return (
        "#!/bin/bash\n"
        "set -e\n"
        "\n"
        'echo "[spens] Starting DNS forwarder (queries logged to /app/traces/dns_log.jsonl) ..."\n'
        "python3 /app/dns_forwarder.py &\n"
        "\n"
        'echo "[spens] Starting mitmproxy with spens addon (CA cert generated on first run) ..."\n'
        "exec mitmdump -s /app/mitmproxy_addon.py "
        "--set spens_config=/app/spens_interceptor_config.json "
        "--set listen_port=9090 "
        "--set block_global=false "
        # HTTP/2 is disabled: streamed-response capture (the tee that parses
        # SSE bodies as they are forwarded) was verified to work over
        # HTTP/1.1 but silently captured nothing for HTTPS/2 clients.  An
        # audit proxy prefers the path it can actually capture; remove this
        # if an agent genuinely needs h2 (e.g. gRPC).
        "--set http2=false "
        "--set rawtcp=false\n"
    )


def generate_interceptor_dockerfile() -> str:
    return (
        "FROM python:3.12-slim\n"
        "\n"
        "RUN pip install --no-cache-dir mitmproxy\n"
        "\n"
        "WORKDIR /app\n"
        "COPY mitmproxy_addon.py /app/mitmproxy_addon.py\n"
        "COPY mitmproxy_helpers.py /app/mitmproxy_helpers.py\n"
        "COPY dns_forwarder.py /app/dns_forwarder.py\n"
        "COPY entrypoint.sh /entrypoint.sh\n"
        "RUN chmod +x /entrypoint.sh\n"
        "RUN mkdir -p /app/traces /root/.mitmproxy\n"
        "\n"
        'VOLUME ["/app/traces", "/root/.mitmproxy"]\n'
        "\n"
        "# 9090: mitmproxy listener; 53: DNS forwarder the agent's resolv.conf points at\n"
        "EXPOSE 9090\n"
        "EXPOSE 53/udp\n"
        "EXPOSE 53/tcp\n"
        "\n"
        'ENTRYPOINT ["/entrypoint.sh"]\n'
    )


def build_interceptor_image(
    tag: str,
    no_cache: bool = False,
    stdout: Any = None,
    progress: Any = None,
) -> None:
    dockerfile = generate_interceptor_dockerfile()

    with tempfile.TemporaryDirectory(prefix="spens-build-") as tmp:
        tmp_path = Path(tmp)
        (tmp_path / "Dockerfile").write_text(dockerfile, encoding="utf-8", newline="\n")
        (tmp_path / "mitmproxy_addon.py").write_text(
            _ADDON_SRC.read_text(encoding="utf-8"), encoding="utf-8", newline="\n"
        )
        (tmp_path / "mitmproxy_helpers.py").write_text(
            _HELPERS_SRC.read_text(encoding="utf-8"), encoding="utf-8", newline="\n"
        )
        (tmp_path / "dns_forwarder.py").write_text(
            _DNS_FORWARDER_SRC.read_text(encoding="utf-8"), encoding="utf-8", newline="\n"
        )
        (tmp_path / "entrypoint.sh").write_text(
            generate_interceptor_entrypoint(), encoding="utf-8", newline="\n"
        )

        events.emit(
            "build_started", image=tag,
            message=f"[spens] Building interceptor image {tag} ...",
        )
        build_cmd = ["docker", "build", "-t", tag]
        if no_cache:
            build_cmd.append("--no-cache")
        build_cmd.append(tmp_path)
        if progress is not None:
            progress.start()
        returncode = _run_build(build_cmd, stdout=stdout, progress=progress)
        if progress is not None:
            progress.stop(ok=returncode == 0)
        if returncode != 0:
            raise RuntimeError(f"Failed to build interceptor image {tag}")
        events.emit("build_finished", image=tag)
