import json
import os
from pathlib import Path
import signal
import subprocess
import sys
import time
from urllib.error import HTTPError
from urllib.error import URLError

import pytest

from a2a_agent import executor


FAKE_CURSOR_API_KEY = "test-cursor-api-key-345"
FAKE_OLLAMA_API_KEY = "test-ollama-api-key-246"


class FakeCompletedProcess:
    def __init__(self, returncode: int = 0) -> None:
        self.returncode = returncode


class FakeExternalProcess:
    pid = 12345
    returncode = 0
    stdout = None
    stderr = None

    def __init__(self, observed: dict[str, object]) -> None:
        self._observed = observed

    def communicate(self, timeout: int):
        self._observed["communicate_timeout"] = timeout
        return "ok", ""


class FakeResultProcess:
    pid = 23456
    stdout = None
    stderr = None

    def __init__(
        self,
        *,
        stdout: str,
        stderr: str,
        returncode: int,
    ) -> None:
        self._stdout = stdout
        self._stderr = stderr
        self.returncode = returncode

    def communicate(self, input: str, timeout: int) -> tuple[str, str]:
        return self._stdout, self._stderr


class FakeKillableProcess:
    pid = 4321
    stdout = None
    stderr = None

    def __init__(self) -> None:
        self.kill_calls = 0

    def kill(self) -> None:
        self.kill_calls += 1


class FakeStream:
    def __init__(self) -> None:
        self.closed = False

    def close(self) -> None:
        self.closed = True


class FakeHttpResponse:
    def __init__(self, body: bytes | str) -> None:
        self._body = body
        self.closed = False

    def read(self) -> bytes | str:
        return self._body

    def close(self) -> None:
        self.closed = True


class FakeStubbornProcess:
    pid = 6789
    returncode = None

    def __init__(self) -> None:
        self.communicate_calls = 0
        self.kill_calls = 0
        self.wait_calls = 0
        self.stdout = FakeStream()
        self.stderr = FakeStream()

    def communicate(self, timeout: int):
        self.communicate_calls += 1
        raise subprocess.TimeoutExpired("fake-cli", timeout)

    def kill(self) -> None:
        self.kill_calls += 1

    def wait(self, timeout: int) -> int:
        self.wait_calls += 1
        return -9


def _process_is_running(pid: int) -> bool:
    try:
        os.kill(pid, 0)
    except ProcessLookupError:
        return False

    # kill(pid, 0)는 좀비도 존재한다고 보므로 실제 실행 상태를 함께 확인한다.
    completed = subprocess.run(
        ["ps", "-o", "stat=", "-p", str(pid)],
        check=False,
        capture_output=True,
        text=True,
    )
    state = completed.stdout.strip()
    return bool(state) and not state.startswith("Z")


def _wait_until_process_stopped(pid: int, timeout: float = 5.0) -> bool:
    deadline = time.monotonic() + timeout
    while time.monotonic() < deadline:
        if not _process_is_running(pid):
            return True
        time.sleep(0.05)
    return False


def test_executor_timeout_defaults_to_540_seconds(monkeypatch) -> None:
    monkeypatch.delenv(executor.EXECUTOR_TIMEOUT_ENV, raising=False)

    assert executor.executor_timeout_seconds() == 540


def test_run_external_uses_timeout_environment_override(monkeypatch) -> None:
    observed: dict[str, object] = {}

    def fake_popen(command, **kwargs):
        observed["command"] = command
        observed["kwargs"] = kwargs
        return FakeExternalProcess(observed)

    monkeypatch.setenv(executor.EXECUTOR_TIMEOUT_ENV, "7")
    monkeypatch.setattr(executor, "_build_command", lambda program, args: [program])
    monkeypatch.setattr(executor.subprocess, "Popen", fake_popen)

    result = executor._run_external("fake-cli", ["arg"], label="fake-cli")

    assert result.ok
    assert result.text == "ok"
    assert result.returncode == 0
    assert observed["communicate_timeout"] == 7
    popen_kwargs = observed["kwargs"]
    assert isinstance(popen_kwargs, dict)
    assert popen_kwargs["stdout"] is subprocess.PIPE
    assert popen_kwargs["stderr"] is subprocess.PIPE
    assert popen_kwargs["text"] is True
    assert popen_kwargs["encoding"] == "utf-8"
    assert popen_kwargs["errors"] == "replace"
    if os.name != "nt":
        assert popen_kwargs["start_new_session"] is True


def test_run_external_can_combine_failure_stdout_and_stderr(monkeypatch) -> None:
    process = FakeResultProcess(
        stdout="database is locked",
        stderr="opencode startup is still warming",
        returncode=1,
    )

    monkeypatch.setattr(executor, "_build_command", lambda program, args: [program])
    monkeypatch.setattr(executor.subprocess, "Popen", lambda *args, **kwargs: process)

    result = executor._run_external(
        "opencode",
        list(executor.OPENCODE_RUN_ARGS),
        label="opencode",
        stdin_text="hello",
        combine_error_output=True,
    )

    assert not result.ok
    assert "database is locked" in result.text
    assert "opencode startup is still warming" in result.text


def test_windows_batch_command_quotes_executable_and_arguments(monkeypatch) -> None:
    executable = r"C:\Program Files\Grok Build\grok.cmd"
    prompt_reference = (
        r"Follow the exact instructions in this file: "
        r"@C:\Users\Test User\Temp\a2a-agent-prompt.txt"
    )
    monkeypatch.setattr(executor.shutil, "which", lambda program: executable)

    command = executor._build_command(
        "grok",
        ["--always-approve", "-p", prompt_reference],
        platform_name="nt",
    )

    assert command == (
        'cmd /d /s /v:off /c "'
        '"C:\\Program Files\\Grok Build\\grok.cmd" '
        '--always-approve -p '
        '"Follow the exact instructions in this file: '
        '@C:\\Users\\Test User\\Temp\\a2a-agent-prompt.txt"'
        '"'
    )


def test_build_command_prefers_path_before_cursor_standard_locations(
    monkeypatch, tmp_path: Path
) -> None:
    standard_agent = tmp_path / "home" / ".local" / "bin" / "agent"
    standard_agent.parent.mkdir(parents=True)
    standard_agent.write_text("standard", encoding="utf-8")
    monkeypatch.setattr(executor.shutil, "which", lambda program: "/tools/agent")

    command = executor._build_command(
        "agent",
        ["-p"],
        platform_name="posix",
        home=tmp_path / "home",
        environ={},
    )

    assert command == ["/tools/agent", "-p"]


@pytest.mark.parametrize("install_parts", [(".local", "bin"), (".cursor", "bin")])
def test_build_command_uses_cursor_standard_location_after_path_miss(
    monkeypatch, tmp_path: Path, install_parts: tuple[str, str]
) -> None:
    home = tmp_path / "home"
    standard_agent = home.joinpath(*install_parts, "agent")
    standard_agent.parent.mkdir(parents=True)
    standard_agent.write_text("standard", encoding="utf-8")
    standard_agent.chmod(0o755)
    monkeypatch.setattr(executor.shutil, "which", lambda program: None)

    command = executor._build_command(
        "agent",
        ["-p", "--trust", "--force"],
        platform_name="posix",
        home=home,
        environ={},
    )

    assert command == [
        str(standard_agent.resolve()),
        "-p",
        "--trust",
        "--force",
    ]


def test_build_command_skips_non_executable_posix_cursor_candidate(
    monkeypatch, tmp_path: Path
) -> None:
    home = tmp_path / "home"
    standard_agent = home / ".local" / "bin" / "agent"
    standard_agent.parent.mkdir(parents=True)
    standard_agent.write_text("not executable", encoding="utf-8")
    monkeypatch.setattr(executor.shutil, "which", lambda program: None)

    command = executor._build_command(
        "agent",
        ["-p"],
        platform_name="posix",
        home=home,
        environ={},
    )

    assert command is None


@pytest.mark.parametrize(
    ("environment_key", "directory_parts", "filename"),
    [
        ("LOCALAPPDATA", ("Programs", "cursor-agent"), "agent.exe"),
        ("USERPROFILE", (".local", "bin"), "agent.cmd"),
        (
            "USERPROFILE",
            ("AppData", "Local", "Programs", "cursor-agent"),
            "agent.exe",
        ),
    ],
)
def test_build_command_uses_windows_cursor_standard_locations(
    monkeypatch,
    tmp_path: Path,
    environment_key: str,
    directory_parts: tuple[str, ...],
    filename: str,
) -> None:
    root = tmp_path / environment_key.lower()
    standard_agent = root.joinpath(*directory_parts, filename)
    standard_agent.parent.mkdir(parents=True)
    standard_agent.write_text("@echo off", encoding="utf-8")
    monkeypatch.setattr(executor.shutil, "which", lambda program: None)

    command = executor._build_command(
        "agent",
        ["-p"],
        platform_name="nt",
        environ={environment_key: str(root)},
    )

    if filename == "agent.cmd":
        assert isinstance(command, str)
        assert str(standard_agent.resolve()) in command
    else:
        assert command == [str(standard_agent.resolve()), "-p"]


@pytest.mark.parametrize("program", ["opencode", "grok"])
def test_non_cursor_executors_do_not_use_cursor_standard_locations(
    monkeypatch, tmp_path: Path, program: str
) -> None:
    home = tmp_path / "home"
    unrelated_binary = home / ".local" / "bin" / program
    unrelated_binary.parent.mkdir(parents=True)
    unrelated_binary.write_text("not a Cursor binary", encoding="utf-8")
    monkeypatch.setattr(executor.shutil, "which", lambda candidate: None)

    command = executor._build_command(
        program,
        ["run"],
        platform_name="posix",
        home=home,
        environ={},
    )

    assert command is None


def test_run_cursor_prefers_agent_with_official_noninteractive_args(monkeypatch) -> None:
    observed: dict[str, object] = {}

    class FakeCursorProcess:
        returncode = 0

        def communicate(self, input: str, timeout: int) -> tuple[str, str]:
            observed["stdin"] = input
            observed["timeout"] = timeout
            return "cursor response", ""

    def fake_popen(command, **kwargs):
        observed["command"] = command
        observed["kwargs"] = kwargs
        return FakeCursorProcess()

    monkeypatch.setattr(
        executor.shutil,
        "which",
        lambda program: {
            "agent": "/tools/agent",
            "cursor-agent": "/tools/cursor-agent",
        }.get(program),
    )
    monkeypatch.setattr(executor, "_standard_executable_paths", lambda *args, **kwargs: ())
    monkeypatch.setattr(executor.subprocess, "Popen", fake_popen)

    result = executor._run_cursor("prompt with > and %VAR%")

    assert result == executor.ExecutorResult(
        ok=True,
        text="cursor response",
        returncode=0,
    )
    assert observed["command"] == ["/tools/agent", "-p", "--trust", "--force"]
    assert observed["stdin"] == "prompt with > and %VAR%"
    kwargs = observed["kwargs"]
    assert isinstance(kwargs, dict)
    assert kwargs["stdin"] is subprocess.PIPE


def test_cursor_uses_config_key_only_in_child_environment_and_redacts_output(
    monkeypatch,
) -> None:
    observed: dict[str, object] = {}

    class FakeCursorProcess:
        returncode = 0

        def communicate(self, input: str, timeout: int) -> tuple[str, str]:
            observed["stdin"] = input
            return f"authenticated with {FAKE_CURSOR_API_KEY}", ""

    def fake_popen(command, **kwargs):
        observed["command"] = command
        observed["kwargs"] = kwargs
        return FakeCursorProcess()

    monkeypatch.delenv(executor.CURSOR_API_KEY_ENV, raising=False)
    monkeypatch.delenv("A2A_EXECUTOR", raising=False)
    monkeypatch.setattr(
        executor.shutil,
        "which",
        lambda program: "/tools/agent" if program == "agent" else None,
    )
    monkeypatch.setattr(executor, "_standard_executable_paths", lambda *args, **kwargs: ())
    monkeypatch.setattr(executor.subprocess, "Popen", fake_popen)

    result = executor.execute_text(
        "cursor prompt",
        executor.AgentConfig(
            executor="cursor",
            cursor_api_key=FAKE_CURSOR_API_KEY,
        ),
    )

    kwargs = observed["kwargs"]
    assert isinstance(kwargs, dict)
    child_env = kwargs["env"]
    assert isinstance(child_env, dict)
    assert child_env[executor.CURSOR_API_KEY_ENV] == FAKE_CURSOR_API_KEY
    assert executor.CURSOR_API_KEY_ENV not in os.environ
    assert FAKE_CURSOR_API_KEY not in result.text
    assert "<redacted>" in result.text


def test_cursor_environment_key_takes_priority_over_config_key(monkeypatch) -> None:
    observed: dict[str, object] = {}
    environment_key = "test-environment-cursor-key-678"

    class FakeCursorProcess:
        returncode = 0

        def communicate(self, input: str, timeout: int) -> tuple[str, str]:
            return "cursor response", ""

    def fake_popen(command, **kwargs):
        observed["kwargs"] = kwargs
        return FakeCursorProcess()

    monkeypatch.setenv(executor.CURSOR_API_KEY_ENV, environment_key)
    monkeypatch.delenv("A2A_EXECUTOR", raising=False)
    monkeypatch.setattr(executor.shutil, "which", lambda program: "/tools/agent")
    monkeypatch.setattr(executor, "_standard_executable_paths", lambda *args, **kwargs: ())
    monkeypatch.setattr(executor.subprocess, "Popen", fake_popen)

    result = executor.execute_text(
        "cursor prompt",
        executor.AgentConfig(
            executor="cursor",
            cursor_api_key=FAKE_CURSOR_API_KEY,
        ),
    )

    assert result.ok
    kwargs = observed["kwargs"]
    assert isinstance(kwargs, dict)
    child_env = kwargs["env"]
    assert isinstance(child_env, dict)
    assert child_env[executor.CURSOR_API_KEY_ENV] == environment_key


def test_cursor_loads_persisted_key_when_running_config_has_none(monkeypatch) -> None:
    observed: dict[str, object] = {}

    class FakeCursorProcess:
        returncode = 0

        def communicate(self, input: str, timeout: int) -> tuple[str, str]:
            return "cursor response", ""

    def fake_popen(command, **kwargs):
        observed["kwargs"] = kwargs
        return FakeCursorProcess()

    monkeypatch.delenv(executor.CURSOR_API_KEY_ENV, raising=False)
    monkeypatch.delenv("A2A_EXECUTOR", raising=False)
    monkeypatch.setattr(
        executor,
        "load_config",
        lambda: executor.AgentConfig(cursor_api_key=FAKE_CURSOR_API_KEY),
    )
    monkeypatch.setattr(executor.shutil, "which", lambda program: "/tools/agent")
    monkeypatch.setattr(executor, "_standard_executable_paths", lambda *args, **kwargs: ())
    monkeypatch.setattr(executor.subprocess, "Popen", fake_popen)

    result = executor.execute_text("cursor prompt", executor.AgentConfig(executor="cursor"))

    assert result.ok
    kwargs = observed["kwargs"]
    assert isinstance(kwargs, dict)
    child_env = kwargs["env"]
    assert isinstance(child_env, dict)
    assert child_env[executor.CURSOR_API_KEY_ENV] == FAKE_CURSOR_API_KEY


@pytest.mark.parametrize("executor_name", ["opencode", "grok"])
def test_non_cursor_executors_do_not_receive_cursor_key_environment(
    monkeypatch,
    executor_name: str,
) -> None:
    observed: dict[str, object] = {}

    class FakeProcess:
        returncode = 0

        def communicate(self, *args, **kwargs) -> tuple[str, str]:
            return "executor response", ""

    def fake_popen(command, **kwargs):
        observed["kwargs"] = kwargs
        return FakeProcess()

    monkeypatch.delenv("A2A_EXECUTOR", raising=False)
    monkeypatch.setattr(executor, "_build_command", lambda program, args: [program])
    monkeypatch.setattr(executor.subprocess, "Popen", fake_popen)

    result = executor.execute_text(
        "executor prompt",
        executor.AgentConfig(
            executor=executor_name,
            cursor_api_key=FAKE_CURSOR_API_KEY,
        ),
    )

    assert result.ok
    kwargs = observed["kwargs"]
    assert isinstance(kwargs, dict)
    assert "env" not in kwargs


def test_run_cursor_falls_back_to_cursor_agent_when_agent_is_missing(monkeypatch) -> None:
    observed: dict[str, object] = {}

    class FakeCursorProcess:
        returncode = 0

        def communicate(self, input: str, timeout: int) -> tuple[str, str]:
            observed["stdin"] = input
            return "legacy cursor response", ""

    def fake_popen(command, **kwargs):
        observed["command"] = command
        return FakeCursorProcess()

    monkeypatch.setattr(
        executor.shutil,
        "which",
        lambda program: "/tools/cursor-agent" if program == "cursor-agent" else None,
    )
    monkeypatch.setattr(executor, "_standard_executable_paths", lambda *args, **kwargs: ())
    monkeypatch.setattr(executor.subprocess, "Popen", fake_popen)

    result = executor._run_cursor("legacy prompt")

    assert result == executor.ExecutorResult(
        ok=True,
        text="legacy cursor response",
        returncode=0,
    )
    assert observed["command"] == [
        "/tools/cursor-agent",
        "-p",
        "--trust",
        "--force",
    ]
    assert observed["stdin"] == "legacy prompt"


def test_run_cursor_keeps_not_found_message_when_no_binary_is_available(monkeypatch) -> None:
    monkeypatch.setattr(executor.shutil, "which", lambda program: None)
    monkeypatch.setattr(executor, "_standard_executable_paths", lambda *args, **kwargs: ())

    result = executor._run_cursor("prompt")

    assert result == executor.ExecutorResult(
        ok=False,
        text=(
            "cursor-agent을(를) PATH에서 찾지 못했습니다. cursor-agent 설치 후 "
            "새 터미널에서 워커를 다시 실행하세요."
        ),
    )


def test_echo_executor_does_not_use_external_binary_lookup(monkeypatch) -> None:
    monkeypatch.setattr(
        executor,
        "_run_external",
        lambda *args, **kwargs: pytest.fail("echo must not invoke an external executor"),
    )

    result = executor.execute_text(
        "echo prompt",
        executor.AgentConfig(
            executor="echo",
            cursor_api_key=FAKE_CURSOR_API_KEY,
        ),
    )

    assert result == executor.ExecutorResult(ok=True, text="echo prompt")


def test_ollama_executor_posts_direct_api_request_with_config_fallback(
    monkeypatch,
) -> None:
    observed: dict[str, object] = {}
    response = FakeHttpResponse(b'{"message": {"content": "ollama response"}}')

    def fake_urlopen(request, timeout: int):
        observed["request"] = request
        observed["timeout"] = timeout
        return response

    monkeypatch.delenv(executor.OLLAMA_API_KEY_ENV, raising=False)
    monkeypatch.delenv(executor.OLLAMA_MODEL_ENV, raising=False)
    monkeypatch.delenv(executor.OLLAMA_HOST_ENV, raising=False)
    monkeypatch.setenv(executor.EXECUTOR_TIMEOUT_ENV, "17")
    monkeypatch.setattr(executor, "load_config", lambda: executor.AgentConfig())
    monkeypatch.setattr(executor.urllib.request, "urlopen", fake_urlopen)

    result = executor.execute_text(
        "direct cloud prompt",
        executor.AgentConfig(
            executor="ollama",
            ollama_api_key=FAKE_OLLAMA_API_KEY,
            ollama_model="config-model",
        ),
    )

    assert result == executor.ExecutorResult(ok=True, text="ollama response")
    assert observed["timeout"] == 17
    request = observed["request"]
    assert isinstance(request, executor.urllib.request.Request)
    assert request.full_url == executor.OLLAMA_CHAT_ENDPOINT
    assert request.get_method() == "POST"
    assert dict(request.header_items())["Authorization"] == (
        f"Bearer {FAKE_OLLAMA_API_KEY}"
    )
    assert dict(request.header_items())["Content-type"] == "application/json"
    assert json.loads(request.data.decode("utf-8")) == {
        "model": "config-model",
        "messages": [{"role": "user", "content": "direct cloud prompt"}],
        "stream": False,
    }
    assert response.closed


def test_ollama_executor_environment_overrides_latest_and_injected_settings(
    monkeypatch,
) -> None:
    observed: dict[str, object] = {}
    environment_key = "test-ollama-environment-key-975"

    def fake_urlopen(request, timeout: int):
        observed["request"] = request
        return FakeHttpResponse(b'{"message": {"content": "environment response"}}')

    monkeypatch.setenv(executor.OLLAMA_API_KEY_ENV, environment_key)
    monkeypatch.setenv(executor.OLLAMA_MODEL_ENV, "environment-model")
    monkeypatch.setenv(executor.OLLAMA_HOST_ENV, "https://ollama-proxy.example/")
    monkeypatch.setattr(
        executor,
        "load_config",
        lambda: executor.AgentConfig(
            ollama_api_key="test-ollama-persisted-key-864",
            ollama_model="persisted-model",
        ),
    )
    monkeypatch.setattr(executor.urllib.request, "urlopen", fake_urlopen)

    result = executor._run_ollama(
        "environment prompt",
        executor.AgentConfig(
            ollama_api_key=FAKE_OLLAMA_API_KEY,
            ollama_model="injected-model",
        ),
    )

    assert result == executor.ExecutorResult(ok=True, text="environment response")
    request = observed["request"]
    assert isinstance(request, executor.urllib.request.Request)
    assert request.full_url == "https://ollama-proxy.example/api/chat"
    assert dict(request.header_items())["Authorization"] == f"Bearer {environment_key}"
    assert json.loads(request.data.decode("utf-8"))["model"] == "environment-model"


def test_ollama_executor_reloads_persisted_settings_for_every_task(monkeypatch) -> None:
    persisted_configs = iter(
        [
            executor.AgentConfig(
                ollama_api_key="test-ollama-latest-key-one-531",
                ollama_model="latest-model-one",
            ),
            executor.AgentConfig(
                ollama_api_key="test-ollama-latest-key-two-642",
                ollama_model="latest-model-two",
            ),
        ]
    )
    observed_requests: list[object] = []

    def fake_urlopen(request, timeout: int):
        observed_requests.append(request)
        return FakeHttpResponse(b'{"message": {"content": "updated response"}}')

    monkeypatch.delenv(executor.OLLAMA_API_KEY_ENV, raising=False)
    monkeypatch.delenv(executor.OLLAMA_MODEL_ENV, raising=False)
    monkeypatch.setattr(executor, "load_config", lambda: next(persisted_configs))
    monkeypatch.setattr(executor.urllib.request, "urlopen", fake_urlopen)
    stale_config = executor.AgentConfig(
        ollama_api_key=FAKE_OLLAMA_API_KEY,
        ollama_model="stale-model",
    )

    first = executor._run_ollama("first task", stale_config)
    second = executor._run_ollama("second task", stale_config)

    assert first.ok
    assert second.ok
    assert len(observed_requests) == 2
    first_request, second_request = observed_requests
    assert isinstance(first_request, executor.urllib.request.Request)
    assert isinstance(second_request, executor.urllib.request.Request)
    assert dict(first_request.header_items())["Authorization"] == (
        "Bearer test-ollama-latest-key-one-531"
    )
    assert dict(second_request.header_items())["Authorization"] == (
        "Bearer test-ollama-latest-key-two-642"
    )
    assert json.loads(first_request.data.decode("utf-8"))["model"] == "latest-model-one"
    assert json.loads(second_request.data.decode("utf-8"))["model"] == "latest-model-two"


def test_ollama_executor_uses_default_model_when_none_is_configured(monkeypatch) -> None:
    observed: dict[str, object] = {}

    def fake_urlopen(request, timeout: int):
        observed["request"] = request
        return FakeHttpResponse(b'{"message": {"content": "default model response"}}')

    monkeypatch.delenv(executor.OLLAMA_API_KEY_ENV, raising=False)
    monkeypatch.delenv(executor.OLLAMA_MODEL_ENV, raising=False)
    monkeypatch.setattr(executor, "load_config", lambda: executor.AgentConfig())
    monkeypatch.setattr(executor.urllib.request, "urlopen", fake_urlopen)

    result = executor._run_ollama(
        "default model prompt",
        executor.AgentConfig(ollama_api_key=FAKE_OLLAMA_API_KEY),
    )

    assert result.ok
    request = observed["request"]
    assert isinstance(request, executor.urllib.request.Request)
    assert json.loads(request.data.decode("utf-8"))["model"] == (
        executor.DEFAULT_OLLAMA_MODEL
    )


def test_ollama_executor_returns_missing_key_without_making_request(monkeypatch) -> None:
    monkeypatch.delenv(executor.OLLAMA_API_KEY_ENV, raising=False)
    monkeypatch.setattr(executor, "load_config", lambda: executor.AgentConfig())
    monkeypatch.setattr(
        executor.urllib.request,
        "urlopen",
        lambda *args, **kwargs: pytest.fail("missing key must not call ollama API"),
    )

    result = executor._run_ollama("no key", executor.AgentConfig())

    assert result == executor.ExecutorResult(ok=False, text="ollama api key not set")


def test_ollama_executor_redacts_key_from_http_and_network_errors(monkeypatch) -> None:
    failures = iter(
        [
            HTTPError(
                executor.OLLAMA_CHAT_ENDPOINT,
                401,
                f"invalid bearer {FAKE_OLLAMA_API_KEY}",
                None,
                None,
            ),
            URLError(f"connection refused for {FAKE_OLLAMA_API_KEY}"),
        ]
    )

    def fake_urlopen(request, timeout: int):
        raise next(failures)

    monkeypatch.delenv(executor.OLLAMA_API_KEY_ENV, raising=False)
    monkeypatch.setattr(
        executor,
        "load_config",
        lambda: executor.AgentConfig(ollama_api_key=FAKE_OLLAMA_API_KEY),
    )
    monkeypatch.setattr(executor.urllib.request, "urlopen", fake_urlopen)

    http_result = executor._run_ollama("http failure", executor.AgentConfig())
    network_result = executor._run_ollama("network failure", executor.AgentConfig())

    assert not http_result.ok
    assert "HTTP 401" in http_result.text
    assert not network_result.ok
    assert "network error" in network_result.text
    assert FAKE_OLLAMA_API_KEY not in http_result.text
    assert FAKE_OLLAMA_API_KEY not in network_result.text


@pytest.mark.parametrize(
    ("body", "expected_error"),
    [
        (
            f"not JSON and never expose {FAKE_OLLAMA_API_KEY}".encode("utf-8"),
            "invalid JSON",
        ),
        (
            json.dumps({"message": {"detail": FAKE_OLLAMA_API_KEY}}).encode("utf-8"),
            "missing message.content",
        ),
    ],
)
def test_ollama_executor_handles_invalid_json_and_missing_content(
    monkeypatch,
    body: bytes,
    expected_error: str,
) -> None:
    monkeypatch.delenv(executor.OLLAMA_API_KEY_ENV, raising=False)
    monkeypatch.setattr(
        executor,
        "load_config",
        lambda: executor.AgentConfig(ollama_api_key=FAKE_OLLAMA_API_KEY),
    )
    monkeypatch.setattr(
        executor.urllib.request,
        "urlopen",
        lambda request, timeout: FakeHttpResponse(body),
    )

    result = executor._run_ollama("invalid response", executor.AgentConfig())

    assert not result.ok
    assert expected_error in result.text
    assert FAKE_OLLAMA_API_KEY not in result.text


def test_ollama_executor_redacts_key_from_success_response(monkeypatch) -> None:
    monkeypatch.delenv(executor.OLLAMA_API_KEY_ENV, raising=False)
    monkeypatch.setattr(
        executor,
        "load_config",
        lambda: executor.AgentConfig(ollama_api_key=FAKE_OLLAMA_API_KEY),
    )
    monkeypatch.setattr(
        executor.urllib.request,
        "urlopen",
        lambda request, timeout: FakeHttpResponse(
            json.dumps(
                {"message": {"content": f"received {FAKE_OLLAMA_API_KEY}"}}
            ).encode("utf-8")
        ),
    )

    result = executor._run_ollama("redact response", executor.AgentConfig())

    assert result == executor.ExecutorResult(ok=True, text="received <redacted>")
    assert FAKE_OLLAMA_API_KEY not in result.text


def test_diagnose_executor_runs_fixed_stdin_and_argv_probes(monkeypatch) -> None:
    config = executor.AgentConfig(
        executor="cursor",
        cursor_api_key=FAKE_CURSOR_API_KEY,
        ollama_api_key=FAKE_OLLAMA_API_KEY,
        ollama_model="diagnostic-ollama-model",
    )
    paths = {
        "opencode": "/tools/opencode",
        "cursor-agent": None,
        "agent": "/tools/agent",
        "grok": None,
    }
    observed: dict[str, object] = {}

    monkeypatch.setattr(executor.platform, "system", lambda: "Windows")
    monkeypatch.setattr(executor.platform, "python_version", lambda: "3.14.0")
    monkeypatch.setattr(executor, "_find_executable", lambda program: paths[program])

    def fake_build_command(program: str, args: list[str]):
        observed["command"] = (program, args)
        return "cmd /d /s /v:off /c \"opencode run -m opencode/big-pickle\""

    def fake_run_opencode(prompt: str, *, timeout: int | None = None):
        observed["stdin"] = (prompt, timeout)
        return executor.ExecutorResult(ok=True, text="PONG", returncode=0)

    def fake_run_external(
        program: str,
        args: list[str],
        timeout: int | None = None,
        label: str = "external executor",
        stdin_text: str | None = None,
    ):
        observed["argv"] = (program, args, timeout, label, stdin_text)
        return executor.ExecutorResult(ok=False, text="echoed prompt", returncode=7)

    monkeypatch.setattr(executor, "_build_command", fake_build_command)
    monkeypatch.setattr(executor, "_run_opencode", fake_run_opencode)
    monkeypatch.setattr(executor, "_run_external", fake_run_external)

    result = executor.diagnose_executor(config, timeout=45)

    assert result.ok
    assert observed["command"] == (
        "opencode",
        ["run", "-m", "opencode/big-pickle"],
    )
    assert observed["stdin"] == (
        executor.DIAG_EXECUTOR_PROBE_PROMPT,
        45,
    )
    assert observed["argv"] == (
        "opencode",
        [
            "run",
            "-m",
            "opencode/big-pickle",
            executor.DIAG_EXECUTOR_PROBE_PROMPT,
        ],
        45,
        "opencode argv probe",
        None,
    )
    assert "platform.system: Windows" in result.text
    assert "os.name:" in result.text
    assert "python.version: 3.14.0" in result.text
    assert "selected_executor: cursor" in result.text
    assert "cursor_api_key: set" in result.text
    assert "ollama_api_key: set" in result.text
    assert "ollama_model: diagnostic-ollama-model" in result.text
    assert FAKE_CURSOR_API_KEY not in result.text
    assert FAKE_OLLAMA_API_KEY not in result.text
    assert "binary.opencode: /tools/opencode" in result.text
    assert "binary.cursor-agent: None" in result.text
    assert "binary.agent: /tools/agent" in result.text
    assert "binary.grok: None" in result.text
    assert "binary.ollama: n/a" in result.text
    assert "opencode.command: 'cmd /d /s /v:off /c" in result.text
    assert "stdin_probe.ok: true" in result.text
    assert "stdin_probe.returncode: 0" in result.text
    assert "stdin_probe.text: 'PONG'" in result.text
    assert "argv_probe.ok: false" in result.text
    assert "argv_probe.returncode: 7" in result.text
    assert "argv_probe.text: 'echoed prompt'" in result.text


@pytest.mark.parametrize(
    ("run_executor", "expected_args"),
    [
        (
            executor._run_opencode,
            ["run", "-m", "opencode/big-pickle"],
        ),
        (
            executor._run_cursor,
            ["-p", "--trust", "--force"],
        ),
    ],
)
def test_stdin_executor_preserves_windows_special_characters(
    monkeypatch,
    tmp_path: Path,
    run_executor,
    expected_args: list[str],
) -> None:
    prompt = '첫 줄 > output.txt\n%VAR%와 "큰따옴표"를 그대로 전달'
    fake_executor = tmp_path / "fake-stdin-executor.py"
    fake_executor.write_text(
        "import sys\n"
        "sys.stdout.write(sys.stdin.read())\n",
        encoding="utf-8",
    )
    observed: dict[str, object] = {}

    def fake_build_command(program: str, args: list[str]) -> list[str]:
        observed["program"] = program
        observed["args"] = args
        return [sys.executable, str(fake_executor), *args]

    monkeypatch.setattr(executor, "_build_command", fake_build_command)

    result = run_executor(prompt)

    assert result.ok
    assert result.text == prompt
    assert observed["args"] == expected_args
    assert prompt not in observed["args"]


def test_run_opencode_retries_database_lock_then_succeeds(monkeypatch) -> None:
    processes = iter(
        [
            FakeResultProcess(
                stdout="\x1b[31mDATABASE IS LOCKED\x1b[0m",
                stderr="opencode startup is still warming",
                returncode=1,
            ),
            FakeResultProcess(stdout="WARM2", stderr="", returncode=0),
        ]
    )
    calls: list[dict[str, object]] = []
    sleeps: list[float] = []

    def fake_popen(*args, **kwargs):
        calls.append(kwargs)
        return next(processes)

    monkeypatch.setattr(executor, "_build_command", lambda program, args: [program])
    monkeypatch.setattr(executor.subprocess, "Popen", fake_popen)

    result = executor._run_opencode("hello", sleep_fn=sleeps.append)

    assert result == executor.ExecutorResult(ok=True, text="WARM2", returncode=0)
    assert len(calls) == 2
    assert all(call["stdin"] is subprocess.PIPE for call in calls)
    assert sleeps == [2]


def test_run_opencode_returns_last_failure_after_persistent_lock(monkeypatch) -> None:
    calls: list[dict[str, object]] = []
    sleeps: list[float] = []

    def fake_run_external(*args, **kwargs):
        calls.append(kwargs)
        return executor.ExecutorResult(
            ok=False,
            text="opencode failed: database is locked",
            returncode=1,
        )

    monkeypatch.setattr(executor, "_run_external", fake_run_external)

    result = executor._run_opencode("hello", sleep_fn=sleeps.append)

    assert not result.ok
    assert result.returncode == 1
    assert "opencode database lock persisted after 2 retries" in result.text
    assert len(calls) == 3
    assert sleeps == [2, 3]


@pytest.mark.parametrize(
    "initial_result",
    [
        executor.ExecutorResult(
            ok=False,
            text="opencode failed: permission denied",
            returncode=1,
        ),
        executor.ExecutorResult(ok=True, text="ready", returncode=0),
    ],
)
def test_run_opencode_skips_retry_for_non_lock_failure_or_success(
    monkeypatch, initial_result: executor.ExecutorResult
) -> None:
    calls: list[dict[str, object]] = []
    sleeps: list[float] = []

    def fake_run_external(*args, **kwargs):
        calls.append(kwargs)
        return initial_result

    monkeypatch.setattr(executor, "_run_external", fake_run_external)

    result = executor._run_opencode("hello", sleep_fn=sleeps.append)

    assert result is initial_result
    assert len(calls) == 1
    assert sleeps == []


def test_run_opencode_honors_retry_count_environment_override(monkeypatch) -> None:
    calls: list[dict[str, object]] = []
    sleeps: list[float] = []

    def fake_run_external(*args, **kwargs):
        calls.append(kwargs)
        return executor.ExecutorResult(
            ok=False,
            text="database is locked",
            returncode=1,
        )

    monkeypatch.setenv(executor.OPENCODE_LOCK_RETRY_ENV, "1")
    monkeypatch.setattr(executor, "_run_external", fake_run_external)

    result = executor._run_opencode("hello", sleep_fn=sleeps.append)

    assert not result.ok
    assert "opencode database lock persisted after 1 retries" in result.text
    assert len(calls) == 2
    assert sleeps == [2]


@pytest.mark.parametrize(
    ("raw_retries", "expected_retries"),
    [("0", 0), ("1", 1), ("2", 2), ("9", 2), ("-1", 0), ("invalid", 2)],
)
def test_opencode_lock_retry_count_stays_within_supported_range(
    monkeypatch, raw_retries: str, expected_retries: int
) -> None:
    monkeypatch.setenv(executor.OPENCODE_LOCK_RETRY_ENV, raw_retries)

    assert executor.opencode_lock_retry_count() == expected_retries


def test_grok_executor_uses_temporary_prompt_file_without_argv_damage(
    monkeypatch,
    tmp_path: Path,
) -> None:
    prompt = '첫 줄 > output.txt\n%VAR%와 "큰따옴표"를 그대로 전달'
    fake_executor = tmp_path / "fake-grok-executor.py"
    fake_executor.write_text(
        "from pathlib import Path\n"
        "import sys\n"
        "prompt_path = sys.argv[-1].split('@', 1)[1]\n"
        "sys.stdout.write(Path(prompt_path).read_text(encoding='utf-8'))\n",
        encoding="utf-8",
    )
    observed: dict[str, object] = {}

    def fake_build_command(program: str, args: list[str]) -> list[str]:
        observed["program"] = program
        observed["args"] = args
        return [sys.executable, str(fake_executor), *args]

    monkeypatch.setattr(executor, "_build_command", fake_build_command)

    result = executor._run_grok(prompt)

    assert result.ok
    assert result.text == prompt
    args = observed["args"]
    assert isinstance(args, list)
    assert prompt not in args
    prompt_path = Path(args[-1].split("@", 1)[1])
    assert not prompt_path.exists()


@pytest.mark.parametrize("raw_timeout", ["invalid", "0", "-1", ""])
def test_invalid_executor_timeout_falls_back_with_warning(
    monkeypatch, capsys, raw_timeout: str
) -> None:
    monkeypatch.setenv(executor.EXECUTOR_TIMEOUT_ENV, raw_timeout)

    assert executor.executor_timeout_seconds() == 540
    warning = capsys.readouterr().err
    assert executor.EXECUTOR_TIMEOUT_ENV in warning
    assert "using default 540" in warning


@pytest.mark.parametrize(
    ("taskkill_returncode", "expected_direct_kills"),
    [(0, 0), (1, 1)],
)
def test_windows_tree_kill_uses_taskkill_and_falls_back_on_failure(
    monkeypatch, taskkill_returncode: int, expected_direct_kills: int
) -> None:
    proc = FakeKillableProcess()
    observed: dict[str, object] = {}

    def fake_run(command, **kwargs):
        observed["command"] = command
        observed["kwargs"] = kwargs
        return FakeCompletedProcess(taskkill_returncode)

    monkeypatch.setattr(executor.subprocess, "run", fake_run)

    executor._kill_process_tree(proc, platform="nt")

    assert observed["command"] == [
        "taskkill",
        "/F",
        "/T",
        "/PID",
        str(proc.pid),
    ]
    assert proc.kill_calls == expected_direct_kills


def test_second_communicate_timeout_kills_and_reaps_direct_process(monkeypatch) -> None:
    proc = FakeStubbornProcess()
    tree_kill_calls: list[int] = []

    monkeypatch.setattr(executor, "_build_command", lambda program, args: [program])
    monkeypatch.setattr(executor.subprocess, "Popen", lambda *args, **kwargs: proc)
    monkeypatch.setattr(
        executor, "_kill_process_tree", lambda process: tree_kill_calls.append(process.pid)
    )

    result = executor._run_external("fake-cli", [], timeout=1, label="fake-cli")

    assert not result.ok
    assert result.text == "fake-cli timed out after 1 seconds; process tree killed"
    assert tree_kill_calls == [proc.pid]
    assert proc.communicate_calls == 2
    assert proc.kill_calls == 1
    assert proc.wait_calls == 1
    assert proc.stdout.closed
    assert proc.stderr.closed


@pytest.mark.skipif(os.name == "nt", reason="POSIX process-group behavior only")
def test_run_external_timeout_kills_parent_and_child_processes(
    monkeypatch, tmp_path: Path
) -> None:
    pid_file = tmp_path / "processes.pid"
    script = tmp_path / "spawn-child-and-wait.sh"
    script.write_text(
        "#!/bin/sh\n"
        "sleep 60 &\n"
        "child=$!\n"
        "printf '%s %s\\n' \"$$\" \"$child\" > \"$1\"\n"
        "wait \"$child\"\n",
        encoding="utf-8",
    )
    script.chmod(0o755)
    monkeypatch.setenv(executor.EXECUTOR_TIMEOUT_ENV, "1")

    started = time.monotonic()
    result = executor._run_external(
        str(script), [str(pid_file)], label="test-shell"
    )
    elapsed = time.monotonic() - started

    assert not result.ok
    assert "timed out after 1 seconds; process tree killed" in result.text
    assert elapsed < 8
    assert pid_file.exists()

    parent_pid, child_pid = (int(value) for value in pid_file.read_text().split())
    try:
        assert _wait_until_process_stopped(parent_pid)
        assert _wait_until_process_stopped(child_pid)
    except AssertionError:
        for pid in (parent_pid, child_pid):
            if _process_is_running(pid):
                try:
                    os.kill(pid, signal.SIGKILL)
                except ProcessLookupError:
                    pass
        raise
