"""Tests for ProcessRunner secret sanitization and PIPE+communicate drain."""

from __future__ import annotations

import os
import sys
from pathlib import Path
from types import SimpleNamespace

import pytest

from dual_agent.infra.process import ProcessRunner, collect_secrets, PROVIDER_CREDENTIALS

SENTINEL = "DUAL_AGENT_TEST_SECRET_SENTINEL_TOKEN_xK9mQ"
FIXTURE = str(Path(__file__).parent / "fixtures" / "fake_dual_stream.py")


# ---------------------------------------------------------- fake_dual_stream

def test_fake_dual_stream_direct():
    """The fixture script must write at least 1 MB to both streams and include the sentinel."""
    import subprocess
    r = subprocess.run([sys.executable, FIXTURE], capture_output=True, timeout=30)
    assert r.returncode == 0
    assert len(r.stdout) >= 1024 * 1024, f"stdout too small: {len(r.stdout)}"
    assert len(r.stderr) >= 1024 * 1024, f"stderr too small: {len(r.stderr)}"
    assert SENTINEL.encode() in r.stdout, "sentinel missing from stdout"
    assert SENTINEL.encode() in r.stderr, "sentinel missing from stderr"


# ---------------------------------------------------------- ProcessRunner

def test_runner_sanitizes_sentinel(tmp_path):
    """After ProcessRunner.run with secrets={SENTINEL}, log files must not contain the sentinel."""
    runner = ProcessRunner(tmp_path / "logs")
    result = runner.run(
        [sys.executable, FIXTURE],
        cwd=tmp_path,
        timeout_s=30,
        secrets=frozenset({SENTINEL}),
    )
    assert result.status == "SUCCESS"
    stdout_text = result.stdout_path.read_text(encoding="utf-8")
    stderr_text = result.stderr_path.read_text(encoding="utf-8")
    assert SENTINEL not in stdout_text, "sentinel leaked into stdout log"
    assert SENTINEL not in stderr_text, "sentinel leaked into stderr log"
    min_size = 1024 * 1024 - 200
    assert len(stdout_text) >= min_size, f"stdout log too small after sanitization: {len(stdout_text)}"
    assert len(stderr_text) >= min_size, f"stderr log too small after sanitization: {len(stderr_text)}"


def test_runner_empty_secrets_passthrough(tmp_path):
    """With no secrets, the sentinel must be preserved as-is in the logs."""
    runner = ProcessRunner(tmp_path / "logs")
    result = runner.run(
        [sys.executable, FIXTURE],
        cwd=tmp_path,
        timeout_s=30,
        secrets=frozenset(),
    )
    assert result.status == "SUCCESS"
    assert SENTINEL in result.stdout_path.read_text(encoding="utf-8")
    assert SENTINEL in result.stderr_path.read_text(encoding="utf-8")


def test_runner_longer_secret_replaced_first(tmp_path):
    """Longer secrets are replaced before shorter prefixes to avoid partial leakage."""
    short = "SECRET"
    long_secret = "SECRET_LONGER"
    script = [
        sys.executable, "-c",
        f"import sys; sys.stdout.write('{long_secret}'); sys.stderr.write('{long_secret}')"
    ]
    runner = ProcessRunner(tmp_path / "logs")
    result = runner.run(
        script,
        cwd=tmp_path,
        timeout_s=10,
        secrets=frozenset({short, long_secret}),
    )
    stdout = result.stdout_path.read_text()
    stderr = result.stderr_path.read_text()
    assert long_secret not in stdout
    assert long_secret not in stderr
    assert short not in stdout
    assert short not in stderr


def test_runner_non_utf8_output(tmp_path):
    """Binary output is decoded with errors=replace — no UnicodeDecodeError."""
    script = [
        sys.executable, "-c",
        "import sys; sys.stdout.buffer.write(b'\\xff\\xfe hello'); sys.stderr.buffer.write(b'\\x80\\x81')"
    ]
    runner = ProcessRunner(tmp_path / "logs")
    result = runner.run(script, cwd=tmp_path, timeout_s=10, secrets=frozenset())
    assert result.status == "SUCCESS"
    text = result.stdout_path.read_text(encoding="utf-8")
    assert "hello" in text


def test_runner_skips_empty_secrets(tmp_path):
    """Empty strings in the secrets frozenset must not cause replacements."""
    script = [sys.executable, "-c", "import sys; sys.stdout.write('hello world')"]
    runner = ProcessRunner(tmp_path / "logs")
    result = runner.run(
        script,
        cwd=tmp_path,
        timeout_s=10,
        secrets=frozenset({"", "   ", "hello"}),
    )
    stdout = result.stdout_path.read_text()
    assert "hello" not in stdout
    assert "world" in stdout


def test_runner_timeout_drains_both_streams(tmp_path):
    """On timeout, output written before the kill is still captured in the log files."""
    script = [
        sys.executable, "-c",
        "import sys, time; sys.stdout.write('before_timeout\\n'); sys.stdout.flush(); time.sleep(60)"
    ]
    runner = ProcessRunner(tmp_path / "logs")
    result = runner.run(script, cwd=tmp_path, timeout_s=2, secrets=frozenset())
    assert result.status == "TIMEOUT"
    assert result.exit_code is None
    stdout_text = result.stdout_path.read_text(encoding="utf-8")
    assert "before_timeout" in stdout_text


# ---------------------------------------------------------- collect_secrets

def test_collect_secrets_returns_env_values(monkeypatch):
    """collect_secrets picks up non-empty PROVIDER_CREDENTIALS values from os.environ."""
    monkeypatch.setenv("ANTHROPIC_API_KEY", "test-key-abc")
    monkeypatch.setenv("OPENAI_API_KEY", "")
    secrets = collect_secrets()
    assert "test-key-abc" in secrets
    assert "" not in secrets


def test_collect_secrets_includes_extra_keys(monkeypatch):
    """Extra env var names provided by the caller are included in the collected secrets."""
    monkeypatch.setenv("MY_CUSTOM_TOKEN", "custom-val-xyz")
    secrets = collect_secrets(("MY_CUSTOM_TOKEN",))
    assert "custom-val-xyz" in secrets


def test_collect_secrets_deduplicates(monkeypatch):
    """When two env vars hold the same value it appears only once in the frozenset."""
    monkeypatch.setenv("ANTHROPIC_API_KEY", "shared-value")
    monkeypatch.setenv("MY_EXTRA_KEY", "shared-value")
    secrets = collect_secrets(("MY_EXTRA_KEY",))
    assert "shared-value" in secrets
    assert list(secrets).count("shared-value") == 1


def test_collect_secrets_empty_when_no_env(monkeypatch):
    """When no credential vars are set, the result is an empty frozenset."""
    for key in PROVIDER_CREDENTIALS:
        monkeypatch.delenv(key, raising=False)
    secrets = collect_secrets()
    assert len(secrets) == 0


# ---------------------------------------------------------- descendant pipe-hold

@pytest.mark.skipif(os.name == "nt", reason="POSIX process-group kill required")
def test_runner_timeout_descendant_holds_pipe(tmp_path):
    """run returns TIMEOUT in bounded time when a SIGTERM-ignoring descendant holds pipes."""
    import time as _time

    # Script: spawns a grandchild that inherits stdout/stderr and ignores SIGTERM,
    # then the parent exits.  Without SIGKILL to the group the grandchild keeps the
    # pipes open and communicate() would block forever.
    script = [
        sys.executable, "-c",
        (
            "import sys, subprocess\n"
            "subprocess.Popen(\n"
            "    [sys.executable, '-c',\n"
            "     'import signal, time;"
            " signal.signal(signal.SIGTERM, lambda s,f: None);"
            " time.sleep(60)'],\n"
            ")\n"
            "sys.stdout.write('parent_output\\n'); sys.stdout.flush()\n"
        ),
    ]
    runner = ProcessRunner(tmp_path / "logs")
    t0 = _time.monotonic()
    result = runner.run(script, cwd=tmp_path, timeout_s=2, secrets=frozenset())
    elapsed = _time.monotonic() - t0
    assert result.status == "TIMEOUT"
    assert elapsed < 20, f"run blocked {elapsed:.1f}s — descendants still holding pipes"
    assert result.stdout_path.exists()
    assert result.stderr_path.exists()
# ---------------------------------------------------------- adapter secret wiring

def test_all_adapter_process_paths_forward_collected_secrets(tmp_path, monkeypatch):
    from dual_agent.adapters.base import AgentRequest
    from dual_agent.adapters.command import CommandAgentAdapter
    from dual_agent.adapters.codex import CodexAdapter
    from dual_agent.adapters.claude import ClaudeAdapter

    monkeypatch.setenv("ANTHROPIC_API_KEY", "provider-secret")
    monkeypatch.setenv("CUSTOM_PROVIDER_TOKEN", "extra-secret")
    captured = []

    def fake_run(self, argv, cwd, timeout_s, **kwargs):
        captured.append(kwargs.get("secrets"))
        return SimpleNamespace(
            status="TIMEOUT", exit_code=None,
            stdout_path=tmp_path / "stdout.log",
            stderr_path=tmp_path / "stderr.log", duration_ms=1,
        )

    monkeypatch.setattr("dual_agent.infra.process.ProcessRunner.run", fake_run)
    request = AgentRequest("r1", "PLAN", tmp_path, "goal")

    CommandAgentAdapter([sys.executable], tmp_path / "command-logs").run(request)

    codex = CodexAdapter(log_root=tmp_path / "codex-logs", passthrough_env=("CUSTOM_PROVIDER_TOKEN",))
    codex._capability = SimpleNamespace(features={"resume": False})
    codex.ask("question", tmp_path)
    codex.run(request)

    claude = ClaudeAdapter(log_root=tmp_path / "claude-logs", passthrough_env=("CUSTOM_PROVIDER_TOKEN",))
    claude._capability = SimpleNamespace(features={"resume": False})
    claude.ask("question", tmp_path)
    claude.run(request)

    assert captured[0] == frozenset({"provider-secret"})
    assert captured[1:] == [frozenset({"provider-secret", "extra-secret"})] * 4

