from __future__ import annotations

import asyncio
import os
import queue
import re
import subprocess  # noqa: S404
import sys
import threading
from collections import deque
from collections.abc import Coroutine
from pathlib import Path
from textwrap import dedent
from typing import IO, Any, TypeVar, cast

import pytest

from acodex._internal.exec import CodexExecCommand
from acodex._internal.process_runner import (
    _ASYNC_CANCELLED,
    AsyncCodexProcessRunner,
    SyncCodexProcessRunner,
)
from acodex.exceptions import CodexCancelledError, CodexExecError

_T = TypeVar("_T")


def test_sync_stream_lines_yields_stdout_lines_without_newlines(tmp_path: Path) -> None:
    executable = _create_fake_codex_executable(tmp_path)
    runner = SyncCodexProcessRunner(executable_path=str(executable))
    command = _build_command(mode="stdout_lines")

    lines = list(runner.stream_lines(command))

    assert lines == ["first line", "second line", "third line"]


def test_sync_stream_lines_success_does_not_join_stderr_tail(
    monkeypatch: pytest.MonkeyPatch,
    tmp_path: Path,
) -> None:
    executable = _create_fake_codex_executable(tmp_path)
    runner = SyncCodexProcessRunner(executable_path=str(executable))
    command = _build_command(mode="stdout_lines")

    def fail_join(_: object) -> str:
        raise AssertionError("stderr tail should not be joined on success")

    monkeypatch.setattr(runner, "_join_sync_output_tail_chunks", fail_join)

    lines = list(runner.stream_lines(command))

    assert lines == ["first line", "second line", "third line"]


def test_sync_nonzero_exit_raises_codex_exec_error_includes_stderr(tmp_path: Path) -> None:
    executable = _create_fake_codex_executable(tmp_path)
    runner = SyncCodexProcessRunner(executable_path=str(executable))
    command = _build_command(mode="nonzero")

    with pytest.raises(CodexExecError, match=re.escape("Codex Exec exited with code 7")) as error:
        list(runner.stream_lines(command))

    assert not error.value.stdout
    assert error.value.stderr == "stderr boom\n"
    assert str(error.value) == "Codex Exec exited with code 7\n\nSTDERR:\nstderr boom"


def test_sync_nonzero_exit_before_stdout_closes_raises_codex_exec_error(tmp_path: Path) -> None:
    executable = _create_fake_codex_executable(tmp_path)
    runner = SyncCodexProcessRunner(executable_path=str(executable))
    command = _build_command(mode="exit_before_stdout_closes")

    with pytest.raises(CodexExecError, match=re.escape("Codex Exec exited with code 7")) as error:
        list(runner.stream_lines(command))

    assert error.value.stdout == "late line\n"
    assert error.value.stderr == "stderr boom\n"
    assert str(error.value) == (
        "Codex Exec exited with code 7\n\nSTDOUT:\nlate line\n\nSTDERR:\nstderr boom"
    )


def test_sync_cancel_pre_set_raises_cancelled_without_spawning(tmp_path: Path) -> None:
    executable = _create_fake_codex_executable(tmp_path)
    runner = SyncCodexProcessRunner(executable_path=str(executable))

    marker = tmp_path / "sync_pre_set_marker"
    signal = threading.Event()
    signal.set()

    command = _build_command(mode="stdout_lines", signal=signal, marker=marker)

    with pytest.raises(CodexCancelledError, match="Turn cancelled"):
        list(runner.stream_lines(command))

    assert not marker.exists()


def test_sync_cancel_mid_run_terminates_and_raises_cancelled(tmp_path: Path) -> None:
    executable = _create_fake_codex_executable(tmp_path)
    runner = SyncCodexProcessRunner(executable_path=str(executable))

    signal = threading.Event()
    command = _build_command(mode="long_running", signal=signal)

    iterator = runner.stream_lines(command)

    assert next(iterator) == "ready"

    signal.set()
    with pytest.raises(CodexCancelledError, match="Turn cancelled"):
        next(iterator)


def test_sync_spawn_oserror_raises_codex_exec_error() -> None:
    runner = SyncCodexProcessRunner(executable_path="codex")
    command = _build_command(mode="stdout_lines")

    def raise_oserror(*_: object, **__: object) -> None:
        raise OSError("nope")

    with pytest.MonkeyPatch.context() as monkeypatch:
        monkeypatch.setattr(subprocess, "Popen", raise_oserror)
        with pytest.raises(CodexExecError, match=re.escape("spawn failure: nope")):
            list(runner.stream_lines(command))


def test_sync_missing_stdio_streams_terminates_and_raises(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    runner = SyncCodexProcessRunner(executable_path="codex")
    command = _build_command(mode="stdout_lines")
    process = _MissingSyncStdioProcess()
    terminated: list[object] = []

    def fake_spawn(_: object) -> _MissingSyncStdioProcess:
        return process

    def fake_terminate(proc: object) -> None:
        terminated.append(proc)

    monkeypatch.setattr(runner, "_spawn_process", fake_spawn)
    monkeypatch.setattr(runner, "_terminate_process", fake_terminate)

    with pytest.raises(CodexExecError, match="spawn failure: missing stdio streams"):
        list(runner.stream_lines(command))

    assert terminated == [process]


def test_sync_stream_lines_handles_poll_timeouts_before_output(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    runner = SyncCodexProcessRunner(executable_path="codex")
    command = CodexExecCommand(argv=[], env={}, stdin="")
    stdout_queue: queue.Queue[Any] = queue.Queue()
    process = _PolledSyncProcess(return_code=0)
    stdout_thread = threading.Thread()

    monkeypatch.setattr("acodex._internal.process_runner._READ_POLL_INTERVAL_SECONDS", 0.001)

    lines = list(
        runner._iter_stdout_lines(
            process=cast("subprocess.Popen[str]", process),
            command=command,
            stdout_queue=cast("Any", stdout_queue),
            stdout_thread=stdout_thread,
        ),
    )

    assert lines == []


def test_sync_terminate_process_falls_back_to_kill_after_timeouts() -> None:
    process = _KillFallbackSyncProcess()

    SyncCodexProcessRunner._terminate_process(cast("subprocess.Popen[str]", process))

    assert process.kill_called


def test_sync_pump_stderr_keeps_only_tail_chunks() -> None:
    chunks: deque[str] = deque(maxlen=2)

    SyncCodexProcessRunner._pump_stderr(
        cast("IO[str]", _ChunkedSyncStderr(["first", "second", "third"])),
        chunks,
    )

    assert list(chunks) == ["second", "third"]


def test_async_stream_lines_yields_stdout_lines_without_newlines(tmp_path: Path) -> None:
    executable = _create_fake_codex_executable(tmp_path)
    runner = AsyncCodexProcessRunner(executable_path=str(executable))
    command = _build_command(mode="stdout_lines")

    async def run() -> list[str]:
        return [line async for line in runner.stream_lines(command)]

    lines = _run_async(run())

    assert lines == ["first line", "second line", "third line"]


def test_async_stream_lines_success_does_not_join_stderr_tail(
    monkeypatch: pytest.MonkeyPatch,
    tmp_path: Path,
) -> None:
    executable = _create_fake_codex_executable(tmp_path)
    runner = AsyncCodexProcessRunner(executable_path=str(executable))
    command = _build_command(mode="stdout_lines")

    def fail_join(_: object) -> str:
        raise AssertionError("stderr tail should not be joined on success")

    monkeypatch.setattr(runner, "_join_async_output_tail_chunks", fail_join)

    async def run() -> list[str]:
        return [line async for line in runner.stream_lines(command)]

    lines = _run_async(run())

    assert lines == ["first line", "second line", "third line"]


def test_async_stream_lines_handles_oversized_stdout_lines(tmp_path: Path) -> None:
    executable = _create_fake_codex_executable(tmp_path)
    runner = AsyncCodexProcessRunner(executable_path=str(executable))
    command = _build_command(mode="oversized_line")

    async def run() -> list[str]:
        return [line async for line in runner.stream_lines(command)]

    lines = _run_async(run())

    assert lines == ["x" * 70000, "tail"]


def test_async_stream_lines_yields_final_line_without_trailing_newline(tmp_path: Path) -> None:
    executable = _create_fake_codex_executable(tmp_path)
    runner = AsyncCodexProcessRunner(executable_path=str(executable))
    command = _build_command(mode="stdout_without_trailing_newline")

    async def run() -> list[str]:
        return [line async for line in runner.stream_lines(command)]

    lines = _run_async(run())

    assert lines == ["tail without newline"]


def test_async_nonzero_exit_raises_codex_exec_error_includes_stderr(tmp_path: Path) -> None:
    executable = _create_fake_codex_executable(tmp_path)
    runner = AsyncCodexProcessRunner(executable_path=str(executable))
    command = _build_command(mode="nonzero")

    async def run() -> None:
        async for _ in runner.stream_lines(command):
            pass

    with pytest.raises(CodexExecError, match=re.escape("Codex Exec exited with code 7")) as error:
        _run_async(run())

    assert not error.value.stdout
    assert error.value.stderr == "stderr boom\n"
    assert str(error.value) == "Codex Exec exited with code 7\n\nSTDERR:\nstderr boom"


def test_async_nonzero_exit_before_stdout_closes_raises_codex_exec_error(tmp_path: Path) -> None:
    executable = _create_fake_codex_executable(tmp_path)
    runner = AsyncCodexProcessRunner(executable_path=str(executable))
    command = _build_command(mode="exit_before_stdout_closes")

    async def run() -> None:
        async for _ in runner.stream_lines(command):
            pass

    with pytest.raises(CodexExecError, match=re.escape("Codex Exec exited with code 7")) as error:
        _run_async(asyncio.wait_for(run(), timeout=2.0))

    assert error.value.stdout == "late line\n"
    assert error.value.stderr == "stderr boom\n"
    assert str(error.value) == (
        "Codex Exec exited with code 7\n\nSTDOUT:\nlate line\n\nSTDERR:\nstderr boom"
    )


def test_async_cancel_pre_set_raises_cancelled_without_spawning(tmp_path: Path) -> None:
    executable = _create_fake_codex_executable(tmp_path)
    runner = AsyncCodexProcessRunner(executable_path=str(executable))

    marker = tmp_path / "async_pre_set_marker"
    signal = asyncio.Event()
    signal.set()

    command = _build_command(mode="stdout_lines", signal=signal, marker=marker)

    async def run() -> None:
        async for _ in runner.stream_lines(command):
            pass

    with pytest.raises(CodexCancelledError, match="Turn cancelled"):
        _run_async(run())

    assert not marker.exists()


def test_async_cancel_mid_run_terminates_and_raises_cancelled(tmp_path: Path) -> None:
    executable = _create_fake_codex_executable(tmp_path)
    runner = AsyncCodexProcessRunner(executable_path=str(executable))

    async def run() -> None:
        signal = asyncio.Event()
        command = _build_command(mode="long_running", signal=signal)
        iterator = runner.stream_lines(command)

        assert await anext(iterator) == "ready"

        signal.set()
        with pytest.raises(CodexCancelledError, match="Turn cancelled"):
            await anext(iterator)

    _run_async(run())


def test_async_spawn_oserror_raises_codex_exec_error() -> None:
    runner = AsyncCodexProcessRunner(executable_path="codex")
    command = _build_command(mode="stdout_lines")

    async def raise_oserror(*_: object, **__: object) -> asyncio.subprocess.Process:
        await asyncio.sleep(0)
        raise OSError("nope")

    async def run() -> None:
        async for _ in runner.stream_lines(command):
            pass

    with pytest.MonkeyPatch.context() as monkeypatch:
        monkeypatch.setattr(asyncio, "create_subprocess_exec", raise_oserror)
        with pytest.raises(CodexExecError, match=re.escape("spawn failure: nope")):
            _run_async(run())


def test_async_require_stdio_raises_when_streams_missing() -> None:
    process = _MissingAsyncStdioProcess()

    with pytest.raises(CodexExecError, match="spawn failure: missing stdio streams"):
        AsyncCodexProcessRunner._require_stdio(cast("asyncio.subprocess.Process", process))


def test_async_stream_lines_missing_stdio_terminates_process(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    runner = AsyncCodexProcessRunner(executable_path="codex")
    command = _build_command(mode="stdout_lines")
    process = _MissingAsyncStdioProcess()
    terminated: list[object] = []

    async def fake_spawn(_: object) -> asyncio.subprocess.Process:
        await asyncio.sleep(0)
        return cast("asyncio.subprocess.Process", process)

    async def fake_terminate(proc: object) -> None:
        terminated.append(proc)
        await asyncio.sleep(0)

    async def run() -> None:
        async for _ in runner.stream_lines(command):
            pass

    monkeypatch.setattr(runner, "_spawn_process", fake_spawn)
    monkeypatch.setattr(runner, "_terminate_process", fake_terminate)

    with pytest.raises(CodexExecError, match="spawn failure: missing stdio streams"):
        _run_async(run())

    assert terminated == [process]


def test_async_stream_lines_create_task_failure_terminates_and_closes_stdin(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    runner = AsyncCodexProcessRunner(executable_path="codex")
    command = _build_command(mode="stdout_lines")
    stdin = _FakeAsyncStdin()
    process = _AsyncProcessWithStdio(stdin=stdin)
    terminated: list[object] = []
    real_create_task = asyncio.create_task

    async def fake_spawn(_: object) -> asyncio.subprocess.Process:
        await asyncio.sleep(0)
        return cast("asyncio.subprocess.Process", process)

    async def fake_terminate(proc: object) -> None:
        terminated.append(proc)
        await asyncio.sleep(0)

    def fake_create_task(
        coro: Coroutine[object, object, object],
        *,
        name: str | None = None,
    ) -> asyncio.Task[object]:
        if name == "acodex-async-stderr-reader":
            coro.close()
            raise RuntimeError("boom")
        return real_create_task(coro, name=name)

    async def run() -> None:
        async for _ in runner.stream_lines(command):
            pass

    monkeypatch.setattr(runner, "_spawn_process", fake_spawn)
    monkeypatch.setattr(runner, "_terminate_process", fake_terminate)
    monkeypatch.setattr(asyncio, "create_task", fake_create_task)

    with pytest.raises(RuntimeError, match="boom"):
        _run_async(run())

    assert terminated == [process]
    assert stdin.close_called


def test_async_cancel_during_stdout_read_uses_async_event_wait_path(tmp_path: Path) -> None:
    executable = _create_fake_codex_executable(tmp_path)
    runner = AsyncCodexProcessRunner(executable_path=str(executable))

    async def run() -> None:
        signal = asyncio.Event()
        command = _build_command(mode="long_running", signal=signal)
        iterator = runner.stream_lines(command)

        assert await anext(iterator) == "ready"

        async def read_next() -> str:
            return await anext(iterator)

        next_task: asyncio.Task[str] = asyncio.create_task(read_next())
        await asyncio.sleep(0.01)
        assert not next_task.done()

        signal.set()
        with pytest.raises(CodexCancelledError, match="Turn cancelled"):
            await next_task

    _run_async(run())


def test_async_read_timeout_path_returns_timeout_then_eventually_yields(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    runner = AsyncCodexProcessRunner(executable_path="codex")
    command = CodexExecCommand(argv=[], env={}, stdin="", signal=threading.Event())
    stdout = _DelayedAsyncStdout()

    async def run() -> list[str]:
        return [
            line
            async for line in runner._iter_stdout_lines(
                command=command,
                stdout=cast("asyncio.StreamReader", stdout),
            )
        ]

    monkeypatch.setattr("acodex._internal.process_runner._READ_POLL_INTERVAL_SECONDS", 0.001)
    lines = _run_async(run())

    assert lines == ["eventual line"]


def test_async_cleanup_closes_stdin_and_cancels_stderr_task(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    runner = AsyncCodexProcessRunner(executable_path="codex")
    stdin = _FakeAsyncStdin()
    stderr_task: asyncio.Task[None] | None = None

    async def fake_terminate(_: object) -> None:
        await asyncio.sleep(0)

    async def run() -> None:
        nonlocal stderr_task
        stderr_task = asyncio.create_task(asyncio.sleep(10.0))
        monkeypatch.setattr(runner, "_terminate_process", fake_terminate)
        await runner._cleanup(
            process=cast("asyncio.subprocess.Process", _FakeAsyncProcess()),
            stdin=cast("asyncio.StreamWriter", stdin),
            stderr_task=stderr_task,
        )

    _run_async(run())

    assert stdin.close_called
    assert stderr_task is not None
    assert stderr_task.cancelled()


def test_async_read_next_chunk_returns_cancelled_when_signal_task_finishes_first() -> None:
    runner = AsyncCodexProcessRunner(executable_path="codex")
    signal = asyncio.Event()
    signal.set()
    command = CodexExecCommand(argv=[], env={}, stdin="", signal=signal)
    stdout = _ImmediateAsyncStdout()

    result = _run_async(
        runner._read_next_chunk(command=command, stdout=cast("asyncio.StreamReader", stdout)),
    )

    assert result is _ASYNC_CANCELLED


def test_async_capture_stderr_tail_keeps_only_tail_chunks() -> None:
    chunks: deque[bytes] = deque(maxlen=2)

    _run_async(
        AsyncCodexProcessRunner._capture_stderr_tail(
            stderr=cast(
                "asyncio.StreamReader",
                _ChunkedAsyncStderr([b"first", b"second", b"third"]),
            ),
            chunks=chunks,
        ),
    )

    assert list(chunks) == [b"second", b"third"]


def _build_command(
    *,
    mode: str,
    signal: threading.Event | asyncio.Event | None = None,
    marker: Path | None = None,
) -> CodexExecCommand:
    env = dict(os.environ)
    env["FAKE_CODEX_MODE"] = mode
    if marker is not None:
        env["FAKE_CODEX_MARKER"] = str(marker)

    return CodexExecCommand(
        argv=["exec", "--experimental-json"],
        env=env,
        stdin="ignored stdin",
        signal=signal,
    )


def _create_fake_codex_executable(tmp_path: Path) -> Path:
    target = tmp_path / "fake_codex_target.py"
    target.write_text(
        dedent(
            """\
            from __future__ import annotations

            import os
            import subprocess
            import sys
            import time
            from pathlib import Path

            marker = os.environ.get("FAKE_CODEX_MARKER")
            if marker:
                Path(marker).write_text("invoked", encoding="utf-8")

            mode = os.environ.get("FAKE_CODEX_MODE", "stdout_lines")
            _ = sys.stdin.read()

            if mode == "stdout_lines":
                sys.stdout.write("first line\\r\\nsecond line\\nthird line\\r\\n")
                sys.stdout.flush()
                raise SystemExit(0)

            if mode == "oversized_line":
                sys.stdout.write("x" * 70000 + "\\n" + "tail\\n")
                sys.stdout.flush()
                raise SystemExit(0)

            if mode == "stdout_without_trailing_newline":
                sys.stdout.write("tail without newline")
                sys.stdout.flush()
                raise SystemExit(0)

            if mode == "nonzero":
                sys.stderr.write("stderr boom\\n")
                sys.stderr.flush()
                raise SystemExit(7)

            if mode == "exit_before_stdout_closes":
                # Keep stdout alive briefly after parent exit to mirror TS parity race.
                subprocess.Popen(
                    [
                        sys.executable,
                        "-c",
                        "import sys, time; time.sleep(0.05); sys.stdout.write('late line\\\\n'); sys.stdout.flush()",
                    ],
                    stdin=subprocess.DEVNULL,
                )
                sys.stderr.write("stderr boom\\n")
                sys.stderr.flush()
                raise SystemExit(7)

            if mode == "long_running":
                sys.stdout.write("ready\\n")
                sys.stdout.flush()
                while True:
                    time.sleep(0.1)

            raise SystemExit(0)
            """,
        ),
        encoding="utf-8",
    )

    if os.name == "nt":
        executable = tmp_path / "codex.bat"
        executable.write_text(
            f'@echo off\r\n"{sys.executable}" "{target}" %*\r\n',
            encoding="utf-8",
        )
        return executable

    executable = tmp_path / "codex"
    executable.write_text(
        f'#!/bin/sh\nexec "{sys.executable}" "{target}" "$@"\n',
        encoding="utf-8",
    )
    executable.chmod(0o755)
    return executable


class _MissingSyncStdioProcess:
    stdin: None = None
    stdout: None = None
    stderr: None = None


class _PolledSyncProcess:
    def __init__(self, *, return_code: int) -> None:
        self._return_code = return_code

    def poll(self) -> int:
        return self._return_code


class _KillFallbackSyncProcess:
    def __init__(self) -> None:
        self.kill_called = False
        self.terminate_called = False

    def poll(self) -> int | None:
        if self.kill_called:
            return 0
        return None

    def terminate(self) -> None:
        self.terminate_called = True

    def wait(self, timeout: float | None = None) -> int:
        if not self.kill_called:
            raise subprocess.TimeoutExpired(cmd="codex", timeout=timeout or 1.0)
        return 0

    def kill(self) -> None:
        self.kill_called = True


class _ChunkedSyncStderr:
    def __init__(self, chunks: list[str]) -> None:
        self._chunks = [*chunks, ""]
        self._chunk_index = 0

    def read(self, _: int = -1) -> str:
        chunk = self._chunks[self._chunk_index]
        self._chunk_index += 1
        return chunk


class _MissingAsyncStdioProcess:
    stdin: None = None
    stdout: None = None
    stderr: None = None


class _FakeAsyncProcess:
    returncode = 0


class _AsyncProcessWithStdio:
    def __init__(self, *, stdin: _FakeAsyncStdin) -> None:
        self.stdin = stdin
        self.stdout = _UnusedAsyncStdout()
        self.stderr = _ReadableAsyncStderr()


class _FakeAsyncStdin:
    def __init__(self) -> None:
        self.close_called = False

    def is_closing(self) -> bool:
        return self.close_called

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

    async def wait_closed(self) -> None:
        _ = self.close_called


class _DelayedAsyncStdout:
    def __init__(self) -> None:
        self._delay_done = False
        self._chunks = [b"eventual ", b"line\n", b""]
        self._chunk_index = 0

    async def read(self, _: int = -1) -> bytes:
        if not self._delay_done:
            self._delay_done = True
            await asyncio.sleep(0.01)

        chunk = self._chunks[self._chunk_index]
        self._chunk_index += 1
        return chunk


class _ImmediateAsyncStdout:
    def __init__(self) -> None:
        self._read_calls = 0

    async def read(self, _: int = -1) -> bytes:
        self._read_calls += 1
        return b"ready\n"


class _ChunkedAsyncStderr:
    def __init__(self, chunks: list[bytes]) -> None:
        self._chunks = [*chunks, b""]
        self._chunk_index = 0

    async def read(self, _: int = -1) -> bytes:
        chunk = self._chunks[self._chunk_index]
        self._chunk_index += 1
        return chunk


class _UnusedAsyncStdout:
    def __init__(self) -> None:
        self.read_calls = 0

    async def read(self, _: int = -1) -> bytes:
        self.read_calls += 1
        return b""


class _ReadableAsyncStderr:
    def __init__(self) -> None:
        self.read_calls = 0

    async def read(self) -> bytes:
        self.read_calls += 1
        return b"stderr"


def _run_async(awaitable: Coroutine[object, object, _T]) -> _T:
    return asyncio.run(awaitable)
