import os
from pathlib import Path
import signal
import subprocess
import time

import pytest

from a2a_agent import executor


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 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 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 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


@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
