"""tests/test_bot_api.py — engine_v2/bot_api.py TDD RED 단계 테스트 스위트.

bot_api.py는 아직 존재하지 않으므로, 이 파일을 실행하면 ImportError로 실패한다.
구현 후 GREEN 단계에서 모든 테스트가 통과해야 한다.

패치 전략:
  - CLIRunner.run_xxx 메서드를 AsyncMock으로 패치하여 CLIResult를 직접 반환한다.
  - subprocess 레벨이 아닌 CLIRunner 레벨에서 모킹하므로 bot_api.py 내부 로직만 검증한다.
"""

from __future__ import annotations

import os
import sys

sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))

from unittest.mock import AsyncMock, patch

import pytest
from engine_v2.bot_api import call_claude, call_codex, call_gemini
from engine_v2.cli_runner import CLIResult

# ---------------------------------------------------------------------------
# 헬퍼: CLIResult 팩토리
# ---------------------------------------------------------------------------


def _ok(engine: str, stdout: str, stderr: str = "") -> CLIResult:
    """returncode=0인 정상 CLIResult를 생성한다."""
    return CLIResult(
        stdout=stdout,
        stderr=stderr,
        returncode=0,
        engine=engine,  # type: ignore[arg-type]
        timed_out=False,
    )


def _err(engine: str, stdout: str = "", stderr: str = "", returncode: int = 1) -> CLIResult:
    """returncode != 0인 CLIResult를 생성한다."""
    return CLIResult(
        stdout=stdout,
        stderr=stderr,
        returncode=returncode,
        engine=engine,  # type: ignore[arg-type]
        timed_out=False,
    )


def _timeout(engine: str, timeout_sec: int = 600) -> CLIResult:
    """timed_out=True인 CLIResult를 생성한다."""
    return CLIResult(
        stdout="",
        stderr=f"Timeout after {timeout_sec}s",
        returncode=-1,
        engine=engine,  # type: ignore[arg-type]
        timed_out=True,
    )


# ---------------------------------------------------------------------------
# TestCallClaude
# ---------------------------------------------------------------------------


class TestCallClaude:
    """call_claude() — CLIRunner.run_claude 래퍼 동작 검증."""

    # ── 1. 정상 응답 ─────────────────────────────────────────────────────────

    @pytest.mark.asyncio
    async def test_normal_response_returns_stdout(self) -> None:
        """정상 응답 시 CLIResult.stdout을 그대로 반환해야 한다."""
        result = _ok("claude", "Claude의 정상 응답입니다.")

        with patch("engine_v2.bot_api.CLIRunner.run_claude", new=AsyncMock(return_value=result)):
            response = await call_claude("테스트 프롬프트")

        assert response == "Claude의 정상 응답입니다."

    # ── 2. 타임아웃 ──────────────────────────────────────────────────────────

    @pytest.mark.asyncio
    async def test_timeout_returns_timeout_message(self) -> None:
        """timed_out=True인 CLIResult가 반환되면 '⏱ 응답 시간 초과 (N초)' 메시지를 반환해야 한다."""
        result = _timeout("claude", timeout_sec=600)

        with patch("engine_v2.bot_api.CLIRunner.run_claude", new=AsyncMock(return_value=result)):
            response = await call_claude("타임아웃 테스트", timeout=600)

        assert response == "⏱ 응답 시간 초과 (600초)"

    @pytest.mark.asyncio
    async def test_timeout_message_reflects_custom_timeout(self) -> None:
        """timeout 파라미터 값이 타임아웃 메시지에 반영되어야 한다."""
        result = _timeout("claude", timeout_sec=30)

        with patch("engine_v2.bot_api.CLIRunner.run_claude", new=AsyncMock(return_value=result)):
            response = await call_claude("커스텀 타임아웃", timeout=30)

        assert response == "⏱ 응답 시간 초과 (30초)"

    # ── 3. 에러 (returncode != 0) ─────────────────────────────────────────────

    @pytest.mark.asyncio
    async def test_nonzero_returncode_no_stdout_filters_error_lines(self) -> None:
        """returncode != 0이고 stdout이 없을 때 stderr에서 'error' 포함 줄만 필터링하여 반환해야 한다."""
        stderr = "info: starting\nError: authentication failed\ndebug: done\n"
        result = _err("claude", stdout="", stderr=stderr, returncode=1)

        with patch("engine_v2.bot_api.CLIRunner.run_claude", new=AsyncMock(return_value=result)):
            response = await call_claude("에러 필터링 테스트")

        assert "Error: authentication failed" in response
        assert "info: starting" not in response
        assert "debug: done" not in response
        assert "❌ Claude CLI 에러 (exit 1):" in response

    @pytest.mark.asyncio
    async def test_nonzero_returncode_no_stdout_no_error_lines_returns_generic(self) -> None:
        """returncode != 0이고 stdout이 없고 stderr에 'error' 줄도 없으면 '상세 내용 없음'을 반환해야 한다."""
        stderr = "warning: something odd\ninfo: exiting\n"
        result = _err("claude", stdout="", stderr=stderr, returncode=2)

        with patch("engine_v2.bot_api.CLIRunner.run_claude", new=AsyncMock(return_value=result)):
            response = await call_claude("error 줄 없음 테스트")

        assert "❌ Claude CLI 에러 (exit 2):" in response
        assert "상세 내용 없음" in response

    @pytest.mark.asyncio
    async def test_nonzero_returncode_with_stdout_returns_stdout(self) -> None:
        """returncode != 0이더라도 stdout에 출력이 있으면 에러를 무시하고 stdout을 반환해야 한다."""
        result = _err("claude", stdout="partial output from claude", stderr="Error: some stderr", returncode=1)

        with patch("engine_v2.bot_api.CLIRunner.run_claude", new=AsyncMock(return_value=result)):
            response = await call_claude("출력 있는 에러 테스트")

        assert response == "partial output from claude"

    # ── 4. 빈 응답 ───────────────────────────────────────────────────────────

    @pytest.mark.asyncio
    async def test_empty_stdout_returns_empty_response_message(self) -> None:
        """returncode=0이고 stdout이 빈 문자열이면 경고 메시지를 반환해야 한다."""
        result = _ok("claude", stdout="")

        with patch("engine_v2.bot_api.CLIRunner.run_claude", new=AsyncMock(return_value=result)):
            response = await call_claude("빈 응답 테스트")

        assert response == "⚠️ Claude CLI에서 빈 응답이 반환되었습니다."

    # ── 5. code_analysis 파라미터 전달 ───────────────────────────────────────

    @pytest.mark.asyncio
    async def test_code_analysis_false_passed_to_runner(self) -> None:
        """code_analysis=False(기본값)이 CLIRunner.run_claude에 그대로 전달되어야 한다."""
        result = _ok("claude", stdout="응답")
        mock_run = AsyncMock(return_value=result)

        with patch("engine_v2.bot_api.CLIRunner.run_claude", new=mock_run):
            await call_claude("일반 질문")

        _, kwargs = mock_run.call_args
        assert kwargs.get("code_analysis") is False

    @pytest.mark.asyncio
    async def test_code_analysis_true_passed_to_runner(self) -> None:
        """code_analysis=True가 CLIRunner.run_claude에 그대로 전달되어야 한다."""
        result = _ok("claude", stdout="코드 분석 결과")
        mock_run = AsyncMock(return_value=result)

        with patch("engine_v2.bot_api.CLIRunner.run_claude", new=mock_run):
            await call_claude("코드 분석 요청", code_analysis=True)

        _, kwargs = mock_run.call_args
        assert kwargs.get("code_analysis") is True

    @pytest.mark.asyncio
    async def test_timeout_param_passed_to_runner(self) -> None:
        """timeout 파라미터가 CLIRunner.run_claude에 전달되어야 한다."""
        result = _ok("claude", stdout="응답")
        mock_run = AsyncMock(return_value=result)

        with patch("engine_v2.bot_api.CLIRunner.run_claude", new=mock_run):
            await call_claude("타임아웃 값 확인", timeout=120)

        _, kwargs = mock_run.call_args
        assert kwargs.get("timeout") == 120

    @pytest.mark.asyncio
    async def test_prompt_passed_to_runner(self) -> None:
        """프롬프트 문자열이 CLIRunner.run_claude에 첫 번째 인자로 전달되어야 한다."""
        result = _ok("claude", stdout="응답")
        mock_run = AsyncMock(return_value=result)
        prompt = "이 프롬프트가 전달되어야 한다"

        with patch("engine_v2.bot_api.CLIRunner.run_claude", new=mock_run):
            await call_claude(prompt)

        args, _ = mock_run.call_args
        assert args[0] == prompt


# ---------------------------------------------------------------------------
# TestCallCodex
# ---------------------------------------------------------------------------


class TestCallCodex:
    """call_codex() — CLIRunner.run_codex 래퍼 동작 검증."""

    # ── 1. 정상 응답 ─────────────────────────────────────────────────────────

    @pytest.mark.asyncio
    async def test_normal_response_returns_stdout(self) -> None:
        """정상 응답 시 CLIResult.stdout을 그대로 반환해야 한다."""
        result = _ok("codex", stdout="Codex의 정상 응답입니다.")

        with patch("engine_v2.bot_api.CLIRunner.run_codex", new=AsyncMock(return_value=result)):
            response = await call_codex("코드 리뷰 요청")

        assert response == "Codex의 정상 응답입니다."

    # ── 2. 타임아웃 ──────────────────────────────────────────────────────────

    @pytest.mark.asyncio
    async def test_timeout_returns_timeout_message(self) -> None:
        """timed_out=True인 CLIResult가 반환되면 '⏱ 응답 시간 초과 (N초)' 메시지를 반환해야 한다."""
        result = _timeout("codex", timeout_sec=600)

        with patch("engine_v2.bot_api.CLIRunner.run_codex", new=AsyncMock(return_value=result)):
            response = await call_codex("타임아웃 테스트", timeout=600)

        assert response == "⏱ 응답 시간 초과 (600초)"

    @pytest.mark.asyncio
    async def test_timeout_message_reflects_custom_timeout(self) -> None:
        """timeout 파라미터 값이 타임아웃 메시지에 반영되어야 한다."""
        result = _timeout("codex", timeout_sec=45)

        with patch("engine_v2.bot_api.CLIRunner.run_codex", new=AsyncMock(return_value=result)):
            response = await call_codex("커스텀 타임아웃", timeout=45)

        assert response == "⏱ 응답 시간 초과 (45초)"

    # ── 3. 에러 — auth/login ─────────────────────────────────────────────────

    @pytest.mark.asyncio
    async def test_nonzero_returncode_login_in_stderr_returns_auth_message(self) -> None:
        """returncode != 0이고 stderr에 'login'이 포함되면 로그인 안내 메시지를 반환해야 한다."""
        result = _err("codex", stderr="Error: Not authenticated. Please run `codex login`.", returncode=1)

        with patch("engine_v2.bot_api.CLIRunner.run_codex", new=AsyncMock(return_value=result)):
            response = await call_codex("로그인 없이 요청")

        assert response == "🔑 Codex 로그인이 필요합니다. `codex login`을 실행해주세요."

    @pytest.mark.asyncio
    async def test_nonzero_returncode_auth_in_stderr_returns_auth_message(self) -> None:
        """returncode != 0이고 stderr에 'auth'가 포함되면 로그인 안내 메시지를 반환해야 한다."""
        result = _err("codex", stderr="authentication required", returncode=1)

        with patch("engine_v2.bot_api.CLIRunner.run_codex", new=AsyncMock(return_value=result)):
            response = await call_codex("auth 에러 테스트")

        assert response == "🔑 Codex 로그인이 필요합니다. `codex login`을 실행해주세요."

    # ── 4. 에러 — usage limit ─────────────────────────────────────────────────

    @pytest.mark.asyncio
    async def test_nonzero_returncode_usage_limit_in_stderr_returns_limit_message(self) -> None:
        """returncode != 0이고 stderr에 'usage limit'이 포함되면 사용량 한도 초과 메시지를 반환해야 한다."""
        result = _err("codex", stderr="Error: You have reached your usage limit.", returncode=1)

        with patch("engine_v2.bot_api.CLIRunner.run_codex", new=AsyncMock(return_value=result)):
            response = await call_codex("사용량 한도 테스트")

        assert response == "⚠️ Codex 사용량 한도 초과. 잠시 후 다시 시도해주세요."

    @pytest.mark.asyncio
    async def test_nonzero_returncode_hit_your_in_stderr_returns_limit_message(self) -> None:
        """returncode != 0이고 stderr에 'hit your'가 포함되면 사용량 한도 초과 메시지를 반환해야 한다."""
        result = _err("codex", stderr="You have hit your monthly quota.", returncode=1)

        with patch("engine_v2.bot_api.CLIRunner.run_codex", new=AsyncMock(return_value=result)):
            response = await call_codex("hit your 에러 테스트")

        assert response == "⚠️ Codex 사용량 한도 초과. 잠시 후 다시 시도해주세요."

    # ── 5. 에러 — 일반 CLI 에러 ───────────────────────────────────────────────

    @pytest.mark.asyncio
    async def test_nonzero_returncode_error_lines_filtered_and_returned(self) -> None:
        """returncode != 0이고 stderr에 'error' 줄이 있으면 필터링된 줄만 반환되어야 한다."""
        stderr = "info: starting process\nError: connection refused\ndebug: retrying\n"
        result = _err("codex", stderr=stderr, returncode=1)

        with patch("engine_v2.bot_api.CLIRunner.run_codex", new=AsyncMock(return_value=result)):
            response = await call_codex("에러 필터링 테스트")

        assert "Error: connection refused" in response
        assert "❌ Codex CLI 에러 (exit 1):" in response
        assert "info: starting process" not in response
        assert "debug: retrying" not in response

    @pytest.mark.asyncio
    async def test_nonzero_returncode_no_error_lines_returns_generic_message(self) -> None:
        """returncode != 0이고 stderr에 'error' 줄이 없으면 '상세 내용 없음'을 반환해야 한다."""
        stderr = "warning: something happened\ninfo: process exited\n"
        result = _err("codex", stderr=stderr, returncode=1)

        with patch("engine_v2.bot_api.CLIRunner.run_codex", new=AsyncMock(return_value=result)):
            response = await call_codex("error 줄 없음 테스트")

        assert "❌ Codex CLI 에러 (exit 1):" in response
        assert "상세 내용 없음" in response

    @pytest.mark.asyncio
    async def test_error_filtered_content_max_300_chars(self) -> None:
        """필터링된 에러 내용이 300자를 초과하면 잘려야 한다."""
        long_error = "Error: " + "x" * 400
        result = _err("codex", stderr=long_error, returncode=1)

        with patch("engine_v2.bot_api.CLIRunner.run_codex", new=AsyncMock(return_value=result)):
            response = await call_codex("300자 제한 테스트")

        prefix = "❌ Codex CLI 에러 (exit 1): "
        error_part = response[len(prefix) :]
        assert len(error_part) <= 300

    # ── 6. 빈 응답 ───────────────────────────────────────────────────────────

    @pytest.mark.asyncio
    async def test_empty_stdout_returns_empty_response_message(self) -> None:
        """returncode=0이고 stdout이 빈 문자열이면 경고 메시지를 반환해야 한다."""
        result = _ok("codex", stdout="")

        with patch("engine_v2.bot_api.CLIRunner.run_codex", new=AsyncMock(return_value=result)):
            response = await call_codex("빈 응답 테스트")

        assert response == "⚠️ Codex CLI에서 빈 응답이 반환되었습니다."

    # ── 7. model 파라미터 전달 ────────────────────────────────────────────────

    @pytest.mark.asyncio
    async def test_default_model_passed_to_runner(self) -> None:
        """model 파라미터 없이 호출하면 기본값 'gpt-5.1-codex-mini'가 CLIRunner에 전달되어야 한다."""
        result = _ok("codex", stdout="ok")
        mock_run = AsyncMock(return_value=result)

        with patch("engine_v2.bot_api.CLIRunner.run_codex", new=mock_run):
            await call_codex("기본 모델 테스트")

        _, kwargs = mock_run.call_args
        assert kwargs.get("model") == "gpt-5.1-codex-mini"

    @pytest.mark.asyncio
    async def test_custom_model_passed_to_runner(self) -> None:
        """model 파라미터가 CLIRunner.run_codex에 그대로 전달되어야 한다."""
        result = _ok("codex", stdout="ok")
        mock_run = AsyncMock(return_value=result)

        with patch("engine_v2.bot_api.CLIRunner.run_codex", new=mock_run):
            await call_codex("커스텀 모델 테스트", model="gpt-5.2-codex")

        _, kwargs = mock_run.call_args
        assert kwargs.get("model") == "gpt-5.2-codex"

    @pytest.mark.asyncio
    async def test_timeout_param_passed_to_runner(self) -> None:
        """timeout 파라미터가 CLIRunner.run_codex에 전달되어야 한다."""
        result = _ok("codex", stdout="ok")
        mock_run = AsyncMock(return_value=result)

        with patch("engine_v2.bot_api.CLIRunner.run_codex", new=mock_run):
            await call_codex("타임아웃 전달 테스트", timeout=90)

        _, kwargs = mock_run.call_args
        assert kwargs.get("timeout") == 90

    @pytest.mark.asyncio
    async def test_prompt_passed_to_runner(self) -> None:
        """프롬프트 문자열이 CLIRunner.run_codex에 첫 번째 인자로 전달되어야 한다."""
        result = _ok("codex", stdout="ok")
        mock_run = AsyncMock(return_value=result)
        prompt = "이 프롬프트가 전달되어야 한다"

        with patch("engine_v2.bot_api.CLIRunner.run_codex", new=mock_run):
            await call_codex(prompt)

        args, _ = mock_run.call_args
        assert args[0] == prompt


# ---------------------------------------------------------------------------
# TestCallGemini
# ---------------------------------------------------------------------------


class TestCallGemini:
    """call_gemini() — CLIRunner.run_gemini 래퍼 동작 검증."""

    # ── 1. 정상 응답 ─────────────────────────────────────────────────────────

    @pytest.mark.asyncio
    async def test_normal_response_returns_stdout(self) -> None:
        """정상 응답 시 CLIResult.stdout을 그대로 반환해야 한다."""
        result = _ok("gemini", stdout="Gemini의 정상 응답입니다.")

        with patch("engine_v2.bot_api.CLIRunner.run_gemini", new=AsyncMock(return_value=result)):
            response = await call_gemini("테스트 프롬프트")

        assert response == "Gemini의 정상 응답입니다."

    # ── 2. 타임아웃 ──────────────────────────────────────────────────────────

    @pytest.mark.asyncio
    async def test_timeout_returns_timeout_message(self) -> None:
        """timed_out=True인 CLIResult가 반환되면 '⏱ 응답 시간 초과 (N초)' 메시지를 반환해야 한다."""
        result = _timeout("gemini", timeout_sec=600)

        with patch("engine_v2.bot_api.CLIRunner.run_gemini", new=AsyncMock(return_value=result)):
            response = await call_gemini("타임아웃 테스트", timeout=600)

        assert response == "⏱ 응답 시간 초과 (600초)"

    @pytest.mark.asyncio
    async def test_timeout_message_reflects_custom_timeout(self) -> None:
        """timeout 파라미터 값이 타임아웃 메시지에 반영되어야 한다."""
        result = _timeout("gemini", timeout_sec=60)

        with patch("engine_v2.bot_api.CLIRunner.run_gemini", new=AsyncMock(return_value=result)):
            response = await call_gemini("커스텀 타임아웃", timeout=60)

        assert response == "⏱ 응답 시간 초과 (60초)"

    # ── 3. 에러 (returncode != 0) ─────────────────────────────────────────────

    @pytest.mark.asyncio
    async def test_nonzero_returncode_returns_error_with_stderr(self) -> None:
        """returncode != 0이면 stderr 내용을 포함한 에러 메시지를 반환해야 한다."""
        stderr = "gemini: command failed: API quota exceeded"
        result = _err("gemini", stderr=stderr, returncode=1)

        with patch("engine_v2.bot_api.CLIRunner.run_gemini", new=AsyncMock(return_value=result)):
            response = await call_gemini("Gemini 에러 테스트")

        assert "❌ Gemini CLI 에러 (exit 1):" in response
        assert stderr in response

    @pytest.mark.asyncio
    async def test_nonzero_returncode_exit_code_in_message(self) -> None:
        """에러 메시지에 실제 exit code가 포함되어야 한다."""
        result = _err("gemini", stderr="some error", returncode=2)

        with patch("engine_v2.bot_api.CLIRunner.run_gemini", new=AsyncMock(return_value=result)):
            response = await call_gemini("exit code 확인")

        assert "exit 2" in response

    @pytest.mark.asyncio
    async def test_nonzero_returncode_empty_stderr_returns_error_message(self) -> None:
        """returncode != 0이고 stderr가 비어 있어도 에러 메시지 형식을 반환해야 한다."""
        result = _err("gemini", stderr="", returncode=3)

        with patch("engine_v2.bot_api.CLIRunner.run_gemini", new=AsyncMock(return_value=result)):
            response = await call_gemini("빈 stderr 에러")

        assert "❌ Gemini CLI 에러 (exit 3):" in response

    # ── 4. 빈 응답 ───────────────────────────────────────────────────────────

    @pytest.mark.asyncio
    async def test_empty_stdout_returns_empty_response_message(self) -> None:
        """returncode=0이고 stdout이 빈 문자열이면 경고 메시지를 반환해야 한다."""
        result = _ok("gemini", stdout="")

        with patch("engine_v2.bot_api.CLIRunner.run_gemini", new=AsyncMock(return_value=result)):
            response = await call_gemini("빈 응답 테스트")

        assert response == "⚠️ Gemini에서 빈 응답이 반환되었습니다."

    # ── 5. stderr 내용 그대로 포함 (Gemini 특수 케이스) ─────────────────────────

    @pytest.mark.asyncio
    async def test_error_includes_full_stderr_content(self) -> None:
        """Gemini 에러는 stderr 필터링 없이 내용 전체를 반환해야 한다."""
        stderr = "warning: some warning\ninfo: detail info\nfatal error occurred"
        result = _err("gemini", stderr=stderr, returncode=1)

        with patch("engine_v2.bot_api.CLIRunner.run_gemini", new=AsyncMock(return_value=result)):
            response = await call_gemini("stderr 전체 포함 테스트")

        # Gemini는 error 줄 필터링 없이 stderr 전체를 포함해야 한다
        assert stderr in response

    @pytest.mark.asyncio
    async def test_timeout_param_passed_to_runner(self) -> None:
        """timeout 파라미터가 CLIRunner.run_gemini에 전달되어야 한다."""
        result = _ok("gemini", stdout="ok")
        mock_run = AsyncMock(return_value=result)

        with patch("engine_v2.bot_api.CLIRunner.run_gemini", new=mock_run):
            await call_gemini("타임아웃 전달 테스트", timeout=180)

        _, kwargs = mock_run.call_args
        assert kwargs.get("timeout") == 180

    @pytest.mark.asyncio
    async def test_prompt_passed_to_runner(self) -> None:
        """프롬프트 문자열이 CLIRunner.run_gemini에 첫 번째 인자로 전달되어야 한다."""
        result = _ok("gemini", stdout="ok")
        mock_run = AsyncMock(return_value=result)
        prompt = "Gemini에게 전달할 프롬프트"

        with patch("engine_v2.bot_api.CLIRunner.run_gemini", new=mock_run):
            await call_gemini(prompt)

        args, _ = mock_run.call_args
        assert args[0] == prompt
