#!/usr/bin/env python3
"""헤임달(개발2팀 테스터): codex_gate_check 테스트 선행 작성 — TDD RED 단계.

대상: /home/jay/workspace/scripts/codex_gate_check.py
함수: codex_gate_check(task_file, affected_files, workspace_root) -> dict
"""

import json
import os
import subprocess
import sys
from pathlib import Path
from unittest.mock import MagicMock, patch

# scripts 디렉토리를 sys.path에 추가
sys.path.insert(0, str(Path(__file__).parent.parent))

from codex_gate_check import codex_gate_check, _maat_fallback_check, _normalize_affected_item  # type: ignore[import-untyped]  # noqa: E402

# ---------------------------------------------------------------------------
# 헬퍼: subprocess.run 모킹용 가짜 응답 생성
# ---------------------------------------------------------------------------


def _make_codex_result(risks: list, suggestions: list | None = None) -> MagicMock:
    """Codex CLI가 정상 반환하는 JSON stdout을 모킹한다."""
    payload = {
        "risks": risks,
        "suggestions": suggestions or [],
    }
    mock_proc = MagicMock()
    mock_proc.returncode = 0
    mock_proc.stdout = json.dumps(payload)
    mock_proc.stderr = ""
    return mock_proc


def _make_maat_result(risks: list, suggestions: list | None = None) -> MagicMock:
    """마아트 폴백이 반환하는 JSON stdout을 모킹한다."""
    payload = {
        "risks": risks,
        "suggestions": suggestions or [],
    }
    mock_proc = MagicMock()
    mock_proc.returncode = 0
    mock_proc.stdout = json.dumps(payload)
    mock_proc.stderr = ""
    return mock_proc


# ---------------------------------------------------------------------------
# 1. critical 리스크 0개 → pass=True
# ---------------------------------------------------------------------------
class TestCodexPassNoCritical:
    """critical severity 리스크가 없으면 gate PASS."""

    def test_codex_pass_no_critical(self, tmp_path):
        task_file = str(tmp_path / "task.md")
        Path(task_file).write_text("# 설계 문서")

        risks = [
            {"severity": "high", "description": "성능 저하 가능성"},
            {"severity": "medium", "description": "로그 누락 우려"},
            {"severity": "low", "description": "주석 부족"},
        ]
        mock_proc = _make_codex_result(risks, suggestions=["캐시 레이어 추가 검토"])

        with patch("subprocess.run", return_value=mock_proc):
            result = codex_gate_check(
                task_file=task_file,
                affected_files=["src/api.py"],
                workspace_root="/home/jay/workspace",
            )

        assert result["pass"] is True, "critical 없으면 PASS여야 함"

    def test_codex_pass_empty_risks(self, tmp_path):
        """리스크가 아예 없어도 PASS."""
        task_file = str(tmp_path / "task.md")
        Path(task_file).write_text("# 빈 설계")

        mock_proc = _make_codex_result(risks=[], suggestions=[])

        with patch("subprocess.run", return_value=mock_proc):
            result = codex_gate_check(
                task_file=task_file,
                affected_files=[],
                workspace_root="/home/jay/workspace",
            )

        assert result["pass"] is True


# ---------------------------------------------------------------------------
# 2. critical 리스크 1개 이상 → pass=False
# ---------------------------------------------------------------------------
class TestCodexFailCriticalExists:
    """critical severity 리스크가 1개 이상이면 gate FAIL."""

    def test_codex_fail_single_critical(self, tmp_path):
        task_file = str(tmp_path / "task.md")
        Path(task_file).write_text("# 설계 문서")

        risks = [
            {"severity": "critical", "description": "인증 우회 취약점"},
            {"severity": "medium", "description": "로그 누락"},
        ]
        mock_proc = _make_codex_result(risks)

        with patch("subprocess.run", return_value=mock_proc):
            result = codex_gate_check(
                task_file=task_file,
                affected_files=["src/auth.py"],
                workspace_root="/home/jay/workspace",
            )

        assert result["pass"] is False, "critical 존재 시 FAIL이어야 함"

    def test_codex_fail_multiple_criticals(self, tmp_path):
        """critical 2개: 여전히 FAIL."""
        task_file = str(tmp_path / "task.md")
        Path(task_file).write_text("# 설계")

        risks = [
            {"severity": "critical", "description": "SQL 인젝션"},
            {"severity": "critical", "description": "권한 상승 가능"},
        ]
        mock_proc = _make_codex_result(risks)

        with patch("subprocess.run", return_value=mock_proc):
            result = codex_gate_check(
                task_file=task_file,
                affected_files=["src/db.py", "src/auth.py"],
                workspace_root="/home/jay/workspace",
            )

        assert result["pass"] is False
        critical_count = sum(1 for r in result["risks"] if r["severity"] == "critical")
        assert critical_count == 2


# ---------------------------------------------------------------------------
# 3. Codex 타임아웃 → source="maat_fallback"
# ---------------------------------------------------------------------------
class TestCodexTimeoutFallbackToMaat:
    """subprocess.run이 TimeoutExpired를 발생시키면 마아트 폴백을 사용한다."""

    def test_codex_timeout_fallback_to_maat(self, tmp_path):
        task_file = str(tmp_path / "task.md")
        Path(task_file).write_text("# 설계 문서")

        # 마아트 폴백 응답 (두 번째 subprocess.run 호출)
        maat_risks = [{"severity": "low", "description": "폴백 경고"}]
        maat_proc = _make_maat_result(maat_risks, suggestions=["마아트 제안"])

        def side_effect(*args, **kwargs):
            cmd = args[0] if args else kwargs.get("args", [])
            # codex 호출 시 타임아웃 발생
            if any("codex" in str(c) for c in cmd):
                raise subprocess.TimeoutExpired(cmd=cmd, timeout=30)
            return maat_proc

        with patch("subprocess.run", side_effect=side_effect):
            result = codex_gate_check(
                task_file=task_file,
                affected_files=["src/service.py"],
                workspace_root="/home/jay/workspace",
            )

        assert result["source"] == "maat_fallback"

    def test_codex_timeout_result_is_valid(self, tmp_path):
        """타임아웃 폴백 결과도 유효한 딕셔너리 구조를 가져야 한다."""
        task_file = str(tmp_path / "task.md")
        Path(task_file).write_text("# 설계")

        maat_proc = _make_maat_result([], suggestions=[])

        def side_effect(*args, **kwargs):
            cmd = args[0] if args else kwargs.get("args", [])
            if any("codex" in str(c) for c in cmd):
                raise subprocess.TimeoutExpired(cmd=cmd, timeout=30)
            return maat_proc

        with patch("subprocess.run", side_effect=side_effect):
            result = codex_gate_check(
                task_file=task_file,
                affected_files=[],
                workspace_root="/home/jay/workspace",
            )

        required_keys = {"pass", "risks", "suggestions", "source", "error"}
        assert required_keys.issubset(result.keys())


# ---------------------------------------------------------------------------
# 4. Codex API 오류 → source="maat_fallback"
# ---------------------------------------------------------------------------
class TestCodexApiErrorFallbackToMaat:
    """Codex CLI가 비정상 종료(returncode != 0)하면 마아트 폴백을 사용한다."""

    def test_codex_api_error_fallback_to_maat(self, tmp_path):
        task_file = str(tmp_path / "task.md")
        Path(task_file).write_text("# 설계")

        # Codex 오류 응답
        error_proc = MagicMock()
        error_proc.returncode = 1
        error_proc.stdout = ""
        error_proc.stderr = "API rate limit exceeded"

        # AST callers 호출용 빈 응답 (정상 반환)
        ast_proc = MagicMock()
        ast_proc.returncode = 0
        ast_proc.stdout = json.dumps({"blast_radius": {"callers": []}})
        ast_proc.stderr = ""

        def side_effect(*args, **kwargs):
            cmd = args[0] if args else kwargs.get("args", [])
            # codex CLI 호출은 에러 반환
            if any("codex" in str(c) for c in cmd):
                return error_proc
            # AST 스크립트 호출은 정상 반환
            return ast_proc

        with patch("subprocess.run", side_effect=side_effect):
            result = codex_gate_check(
                task_file=task_file,
                affected_files=["src/api.py"],
                workspace_root="/home/jay/workspace",
            )

        assert result["source"] == "maat_fallback"

    def test_codex_api_error_sets_error_field(self, tmp_path):
        """API 오류 시 마아트 폴백으로 전환되며 source='maat_fallback'이어야 한다.

        구현체는 returncode != 0이면 즉시 _maat_fallback_check()를 호출한다.
        마아트 폴백 결과의 error 필드는 None으로 설정된다(폴백 자체는 성공).
        대신 source='maat_fallback'으로 오류 전환을 알린다.
        """
        task_file = str(tmp_path / "task.md")
        Path(task_file).write_text("# 설계")

        error_proc = MagicMock()
        error_proc.returncode = 1
        error_proc.stdout = ""
        error_proc.stderr = "connection refused"

        # 마아트 폴백은 subprocess.run을 호출하지 않으므로 단일 mock으로 충분
        with patch("subprocess.run", return_value=error_proc):
            result = codex_gate_check(
                task_file=task_file,
                affected_files=[],
                workspace_root="/home/jay/workspace",
            )

        # Codex API 오류 시 마아트 폴백으로 전환 확인
        assert result["source"] == "maat_fallback"
        # 폴백 자체는 성공적으로 완료되므로 error=None
        assert result["error"] is None


# ---------------------------------------------------------------------------
# 5. 반환 딕셔너리 구조 검증 (필수 키 존재)
# ---------------------------------------------------------------------------
class TestOutputJsonFormat:
    """반환 딕셔너리가 명세된 스키마를 충족해야 한다."""

    REQUIRED_KEYS = {"pass", "risks", "suggestions", "source", "error"}

    def test_all_required_keys_present(self, tmp_path):
        task_file = str(tmp_path / "task.md")
        Path(task_file).write_text("# 설계")

        mock_proc = _make_codex_result([])

        with patch("subprocess.run", return_value=mock_proc):
            result = codex_gate_check(
                task_file=task_file,
                affected_files=["src/main.py"],
                workspace_root="/home/jay/workspace",
            )

        assert self.REQUIRED_KEYS.issubset(result.keys()), f"누락 키: {self.REQUIRED_KEYS - result.keys()}"

    def test_pass_is_bool(self, tmp_path):
        task_file = str(tmp_path / "task.md")
        Path(task_file).write_text("# 설계")

        mock_proc = _make_codex_result([])

        with patch("subprocess.run", return_value=mock_proc):
            result = codex_gate_check(
                task_file=task_file,
                affected_files=[],
                workspace_root="/home/jay/workspace",
            )

        assert isinstance(result["pass"], bool)

    def test_risks_is_list(self, tmp_path):
        task_file = str(tmp_path / "task.md")
        Path(task_file).write_text("# 설계")

        mock_proc = _make_codex_result([{"severity": "high", "description": "경고"}])

        with patch("subprocess.run", return_value=mock_proc):
            result = codex_gate_check(
                task_file=task_file,
                affected_files=["src/x.py"],
                workspace_root="/home/jay/workspace",
            )

        assert isinstance(result["risks"], list)

    def test_suggestions_is_list(self, tmp_path):
        task_file = str(tmp_path / "task.md")
        Path(task_file).write_text("# 설계")

        mock_proc = _make_codex_result([], suggestions=["제안 1", "제안 2"])

        with patch("subprocess.run", return_value=mock_proc):
            result = codex_gate_check(
                task_file=task_file,
                affected_files=[],
                workspace_root="/home/jay/workspace",
            )

        assert isinstance(result["suggestions"], list)

    def test_source_is_codex_on_success(self, tmp_path):
        """Codex 정상 응답 시 source는 codex_companion."""
        task_file = str(tmp_path / "task.md")
        Path(task_file).write_text("# 설계")

        mock_proc = _make_codex_result([])

        with patch("subprocess.run", return_value=mock_proc):
            result = codex_gate_check(
                task_file=task_file,
                affected_files=[],
                workspace_root="/home/jay/workspace",
            )

        assert result["source"] == "codex_companion"

    def test_error_is_none_on_success(self, tmp_path):
        """정상 응답 시 error=None."""
        task_file = str(tmp_path / "task.md")
        Path(task_file).write_text("# 설계")

        mock_proc = _make_codex_result([])

        with patch("subprocess.run", return_value=mock_proc):
            result = codex_gate_check(
                task_file=task_file,
                affected_files=[],
                workspace_root="/home/jay/workspace",
            )

        assert result["error"] is None

    def test_risk_item_has_severity_and_description(self, tmp_path):
        """리스크 항목은 severity, description 키를 가져야 한다."""
        task_file = str(tmp_path / "task.md")
        Path(task_file).write_text("# 설계")

        risks = [
            {"severity": "high", "description": "고위험 항목"},
            {"severity": "low", "description": "저위험 항목"},
        ]
        mock_proc = _make_codex_result(risks)

        with patch("subprocess.run", return_value=mock_proc):
            result = codex_gate_check(
                task_file=task_file,
                affected_files=["src/y.py"],
                workspace_root="/home/jay/workspace",
            )

        for item in result["risks"]:
            assert "severity" in item, "risk 항목에 severity 키 없음"
            assert "description" in item, "risk 항목에 description 키 없음"

    def test_severity_values_are_valid(self, tmp_path):
        """severity는 critical|high|medium|low 중 하나여야 한다."""
        task_file = str(tmp_path / "task.md")
        Path(task_file).write_text("# 설계")

        valid_severities = {"critical", "high", "medium", "low"}
        risks = [
            {"severity": "critical", "description": "치명"},
            {"severity": "high", "description": "높음"},
            {"severity": "medium", "description": "중간"},
            {"severity": "low", "description": "낮음"},
        ]
        mock_proc = _make_codex_result(risks)

        with patch("subprocess.run", return_value=mock_proc):
            result = codex_gate_check(
                task_file=task_file,
                affected_files=["src/z.py"],
                workspace_root="/home/jay/workspace",
            )

        for item in result["risks"]:
            assert item["severity"] in valid_severities, f"허용되지 않는 severity 값: {item['severity']}"


# ---------------------------------------------------------------------------
# 6. 마아트 폴백 시에도 유효한 결과 반환
# ---------------------------------------------------------------------------
class TestMaatFallbackReturnsValidResult:
    """마아트 폴백 경로에서도 완전한 result 딕셔너리를 반환해야 한다."""

    REQUIRED_KEYS = {"pass", "risks", "suggestions", "source", "error"}

    def _force_maat_fallback(self, tmp_path, maat_risks, maat_suggestions=None):
        """Codex 오류를 유발하여 마아트 폴백을 강제하는 헬퍼.

        마아트 폴백은 파일 존재 여부를 직접 검사하므로 affected_files는
        실제 존재하는 파일만 넘기거나 빈 목록을 사용한다.
        task_file은 tmp_path 안에 생성하여 실제로 존재하게 한다.
        """
        task_file = str(tmp_path / "task.md")
        Path(task_file).write_text("# 설계")

        error_proc = MagicMock()
        error_proc.returncode = 1
        error_proc.stdout = ""
        error_proc.stderr = "codex unavailable"

        with patch("subprocess.run", return_value=error_proc):
            with patch(
                "codex_gate_check._maat_fallback_check",
                return_value={
                    "pass": not any(r["severity"] == "critical" for r in maat_risks),
                    "risks": maat_risks,
                    "suggestions": maat_suggestions or [],
                    "source": "maat_fallback",
                    "error": None,
                },
            ):
                return codex_gate_check(
                    task_file=task_file,
                    affected_files=[],
                    workspace_root="/home/jay/workspace",
                )

    def test_maat_fallback_has_all_required_keys(self, tmp_path):
        result = self._force_maat_fallback(tmp_path, maat_risks=[])
        assert self.REQUIRED_KEYS.issubset(result.keys())

    def test_maat_fallback_source_is_maat_fallback(self, tmp_path):
        result = self._force_maat_fallback(tmp_path, maat_risks=[])
        assert result["source"] == "maat_fallback"

    def test_maat_fallback_pass_true_when_no_critical(self, tmp_path):
        """마아트 폴백에서도 critical 없으면 PASS."""
        maat_risks = [{"severity": "high", "description": "마아트 고위험"}]
        result = self._force_maat_fallback(tmp_path, maat_risks=maat_risks)
        assert result["pass"] is True

    def test_maat_fallback_pass_false_when_critical(self, tmp_path):
        """마아트 폴백에서 critical 있으면 FAIL."""
        maat_risks = [{"severity": "critical", "description": "마아트 치명 오류"}]
        result = self._force_maat_fallback(tmp_path, maat_risks=maat_risks)
        assert result["pass"] is False

    def test_maat_fallback_risks_preserved(self, tmp_path):
        """마아트 폴백 리스크 목록이 결과에 그대로 담겨야 한다."""
        maat_risks = [
            {"severity": "medium", "description": "마아트 중간 위험"},
            {"severity": "low", "description": "마아트 낮은 위험"},
        ]
        result = self._force_maat_fallback(tmp_path, maat_risks=maat_risks)
        assert len(result["risks"]) == 2

    def test_maat_fallback_suggestions_preserved(self, tmp_path):
        """마아트 폴백 제안 목록이 결과에 담겨야 한다."""
        maat_risks = []
        maat_suggestions = ["마아트 제안 A", "마아트 제안 B"]
        result = self._force_maat_fallback(tmp_path, maat_risks=maat_risks, maat_suggestions=maat_suggestions)
        assert len(result["suggestions"]) == 2


# ---------------------------------------------------------------------------
# 7. AST callers 컨텍스트 통합 테스트
# ---------------------------------------------------------------------------
class TestCallersContext:
    """_get_callers_context가 프롬프트에 통합되는지 검증."""

    def test_callers_context_included_in_prompt(self, tmp_path):
        """AST callers 컨텍스트가 Codex 프롬프트에 포함되어야 한다."""
        task_file = str(tmp_path / "task.md")
        Path(task_file).write_text("# 설계 문서")

        # AST 스크립트 더미 파일 생성 (경로 체크 통과용)
        scripts_dir = tmp_path / "scripts"
        scripts_dir.mkdir()
        (scripts_dir / "ast_dependency_map.py").write_text("# dummy")

        # AST 스크립트 결과를 모킹 (딕셔너리 형태 — _get_callers_context가 data.get("blast_radius") 사용)
        ast_result = MagicMock()
        ast_result.returncode = 0
        ast_result.stdout = json.dumps(
            {
                "changed_file": "src/api.py",
                "blast_radius": {
                    "callers": ["server.py:42", "routes.py:15"],
                    "direct_importers": ["server.py", "routes.py"],
                    "test_files": ["test_api.py"],
                    "total_affected": 3,
                },
            }
        )
        ast_result.stderr = ""

        # Codex 결과를 모킹
        codex_result = _make_codex_result([], suggestions=[])

        call_count = {"n": 0}
        captured_prompt_files = []

        def side_effect(*args, **kwargs):
            call_count["n"] += 1
            cmd = args[0] if args else kwargs.get("args", [])
            # 첫번째 호출: AST 스크립트 (ast_dependency_map.py 포함)
            if any("ast_dependency_map" in str(c) for c in cmd):
                return ast_result
            # 나머지: Codex CLI — --prompt-file 경로 캡처
            cmd_list = list(cmd)
            if "--prompt-file" in cmd_list:
                idx = cmd_list.index("--prompt-file")
                if idx + 1 < len(cmd_list):
                    prompt_path = cmd_list[idx + 1]
                    import os
                    if os.path.isfile(prompt_path):
                        with open(prompt_path, "r", encoding="utf-8") as pf:
                            captured_prompt_files.append(pf.read())
            return codex_result

        with patch("subprocess.run", side_effect=side_effect):
            result = codex_gate_check(
                task_file=task_file,
                affected_files=["src/api.py"],
                workspace_root=str(tmp_path),
            )

        assert result["pass"] is True
        # --prompt-file로 전달된 프롬프트에 callers 정보("호출됨")가 포함되어야 한다
        assert any("호출됨" in content for content in captured_prompt_files)

    def test_callers_context_fallback_on_ast_failure(self, tmp_path):
        """AST 스크립트 실패 시에도 codex_gate_check는 정상 동작해야 한다."""
        task_file = str(tmp_path / "task.md")
        Path(task_file).write_text("# 설계 문서")

        # AST 스크립트가 없으므로 _get_callers_context는 빈 문자열 반환
        codex_result = _make_codex_result([], suggestions=[])

        with patch("subprocess.run", return_value=codex_result):
            result = codex_gate_check(
                task_file=task_file,
                affected_files=["nonexistent.py"],
                workspace_root="/tmp/nonexistent",
            )

        # AST 실패해도 codex_gate_check 자체는 정상 동작
        assert "pass" in result
        assert "source" in result


# ---------------------------------------------------------------------------
# 8. _get_callers_context 단위 테스트
# ---------------------------------------------------------------------------
from codex_gate_check import _get_callers_context  # type: ignore[import-untyped]  # noqa: E402


class TestGetCallersContext:
    """_get_callers_context 헬퍼 함수 단위 테스트."""

    def test_returns_empty_when_no_ast_script(self):
        """AST 스크립트가 없으면 빈 문자열 반환."""
        result = _get_callers_context(["src/api.py"], "/tmp/nonexistent")
        assert result == ""

    def test_returns_context_on_success(self, tmp_path):
        """AST 호출 성공 시 호출 관계 문자열 반환."""
        # AST 스크립트 더미 파일 생성 (경로 체크 통과용)
        scripts_dir = tmp_path / "scripts"
        scripts_dir.mkdir()
        (scripts_dir / "ast_dependency_map.py").write_text("# dummy")

        # 실제 파일 생성 (함수명 추출 대상)
        api_file = tmp_path / "api.py"
        api_file.write_text("def get_data():\n    pass\n")

        # AST 스크립트 결과 모킹
        ast_output = json.dumps(
            {
                "changed_file": "api.py",
                "blast_radius": {
                    "direct_importers": ["server.py"],
                    "callers": ["server.py:10"],
                    "test_files": [],
                    "total_affected": 2,
                },
            }
        )

        mock_proc = MagicMock()
        mock_proc.returncode = 0
        mock_proc.stdout = ast_output
        mock_proc.stderr = ""

        with patch("subprocess.run", return_value=mock_proc):
            result = _get_callers_context([str(api_file)], str(tmp_path))

        assert "호출됨" in result
        assert "server.py:10" in result

    def test_returns_empty_on_ast_error(self, tmp_path):
        """AST 호출 실패 시 빈 문자열 반환."""
        scripts_dir = tmp_path / "scripts"
        scripts_dir.mkdir()
        (scripts_dir / "ast_dependency_map.py").write_text("# dummy")

        mock_proc = MagicMock()
        mock_proc.returncode = 1
        mock_proc.stdout = ""
        mock_proc.stderr = "error"

        with patch("subprocess.run", return_value=mock_proc):
            result = _get_callers_context(["api.py"], str(tmp_path))

        assert result == ""


# ---------------------------------------------------------------------------
# 9. 3단계 캐스케이드(companion → exec → maat) 동작 검증
# ---------------------------------------------------------------------------
class TestCodexCascade:
    """3단계 캐스케이드(companion → exec → maat) 동작 검증."""

    def test_companion_success_returns_codex_companion_source(self, tmp_path):
        """codex-companion 성공 시 source='codex_companion'."""
        task_file = str(tmp_path / "task.md")
        Path(task_file).write_text("# 설계")
        mock_proc = _make_codex_result([])
        with patch("subprocess.run", return_value=mock_proc):
            with patch("codex_gate_check.os.path.isfile", return_value=True):
                result = codex_gate_check(
                    task_file=task_file, affected_files=[], workspace_root="/home/jay/workspace",
                )
        assert result["source"] == "codex_companion"

    def test_companion_fail_falls_back_to_maat(self, tmp_path):
        """companion 실패 시 마아트 폴백으로 전환."""
        task_file = str(tmp_path / "task.md")
        Path(task_file).write_text("# 설계")

        fail_proc = MagicMock()
        fail_proc.returncode = 1
        fail_proc.stdout = ""
        fail_proc.stderr = "companion error"

        with patch("subprocess.run", return_value=fail_proc):
            result = codex_gate_check(
                task_file=task_file, affected_files=[], workspace_root="/home/jay/workspace",
            )
        assert result["source"] == "maat_fallback"

    def test_both_codex_fail_returns_maat_fallback(self, tmp_path):
        """companion, exec 모두 실패 시 maat 폴백."""
        task_file = str(tmp_path / "task.md")
        Path(task_file).write_text("# 설계")

        fail_proc = MagicMock()
        fail_proc.returncode = 1
        fail_proc.stdout = ""
        fail_proc.stderr = "error"

        with patch("subprocess.run", return_value=fail_proc):
            result = codex_gate_check(
                task_file=task_file, affected_files=[], workspace_root="/home/jay/workspace",
            )
        assert result["source"] == "maat_fallback"


# ---------------------------------------------------------------------------
# 10. 마아트 폴백 강화 기능 검증
# ---------------------------------------------------------------------------
class TestMaatFallbackEnhanced:
    """마아트 폴백 강화 기능 검증."""

    def test_large_scope_warning(self, tmp_path):
        """affected_files 6개 이상이면 high 리스크 경고."""
        task_file = str(tmp_path / "task.md")
        Path(task_file).write_text("# 설계 문서")
        # 6개 파일 생성
        files = []
        for i in range(6):
            f = tmp_path / f"file{i}.py"
            f.write_text(f"# file {i}")
            files.append(str(f))

        result = _maat_fallback_check(task_file, files, str(tmp_path))
        high_risks = [r for r in result["risks"] if r["severity"] == "high" and "변경 범위" in r["description"]]
        assert len(high_risks) >= 1

    def test_security_keyword_detection(self, tmp_path):
        """task_file에 보안 민감 키워드 포함 시 medium 리스크."""
        task_file = str(tmp_path / "task.md")
        Path(task_file).write_text("# 인증 모듈 변경\n보안 취약점 수정 및 API키 갱신")

        result = _maat_fallback_check(task_file, [], str(tmp_path))
        medium_risks = [r for r in result["risks"] if r["severity"] == "medium" and "보안 민감 키워드" in r["description"]]
        assert len(medium_risks) >= 1

    def test_no_false_positive_on_clean_task(self, tmp_path):
        """보안 키워드 없는 일반 task에서는 medium 리스크 미생성."""
        task_file = str(tmp_path / "task.md")
        Path(task_file).write_text("# 로깅 개선\n로그 포맷 변경")

        result = _maat_fallback_check(task_file, [], str(tmp_path))
        medium_risks = [r for r in result["risks"] if r["severity"] == "medium"]
        assert len(medium_risks) == 0


# ---------------------------------------------------------------------------
# 11. API 키 사전 검증 테스트 (task-2076)
# ---------------------------------------------------------------------------
class TestApiKeyValidation:
    """OPENAI_API_KEY 사전 검증 테스트."""

    def test_no_api_key_still_attempts_companion_then_fallback(self, tmp_path):
        """OPENAI_API_KEY 없어도 companion 호출을 시도하고, 실패 시 마아트 폴백."""
        task_file = str(tmp_path / "task.md")
        Path(task_file).write_text("# 설계")

        with patch.dict(os.environ, {}, clear=True):
            result = codex_gate_check(
                task_file=task_file,
                affected_files=[],
                workspace_root=str(tmp_path),
            )

        # ChatGPT 계정 인증이므로 API 키와 무관하게 companion 시도 후 폴백
        assert result["source"] == "maat_fallback"
        assert "fallback_reason" in result
        assert result["fallback_reason"] is not None

    def test_api_key_present_attempts_codex(self, tmp_path):
        """OPENAI_API_KEY가 있으면 companion 호출을 시도해야 한다."""
        task_file = str(tmp_path / "task.md")
        Path(task_file).write_text("# 설계")

        mock_proc = _make_codex_result([])

        with patch.dict(os.environ, {"OPENAI_API_KEY": "sk-test-key"}):
            with patch("subprocess.run", return_value=mock_proc):
                with patch("codex_gate_check.os.path.isfile", return_value=True):
                    result = codex_gate_check(
                        task_file=task_file,
                        affected_files=[],
                        workspace_root="/home/jay/workspace",
                    )

        assert result["source"] == "codex_companion"


# ---------------------------------------------------------------------------
# 12. subprocess에 env 전달 검증 (task-2076)
# ---------------------------------------------------------------------------
class TestSubprocessEnvPassing:
    """subprocess.run에 env=os.environ 전달 검증."""

    def test_subprocess_receives_env(self, tmp_path):
        """_run_codex_companion이 subprocess.run에 env를 전달해야 한다."""
        task_file = str(tmp_path / "task.md")
        Path(task_file).write_text("# 설계")

        captured_kwargs = {}

        def capture_run(*args, **kwargs):
            captured_kwargs.update(kwargs)
            mock = MagicMock()
            mock.returncode = 0
            mock.stdout = json.dumps({"risks": [], "suggestions": []})
            mock.stderr = ""
            return mock

        with patch.dict(os.environ, {"OPENAI_API_KEY": "sk-test"}):
            with patch("subprocess.run", side_effect=capture_run):
                with patch("codex_gate_check.os.path.isfile", return_value=True):
                    from codex_gate_check import _run_codex_companion
                    _run_codex_companion("test prompt", "/home/jay/workspace")

        assert "env" in captured_kwargs
        assert "OPENAI_API_KEY" in captured_kwargs["env"]


# ---------------------------------------------------------------------------
# 13. fallback_reason 필드 검증 (task-2076)
# ---------------------------------------------------------------------------
class TestFallbackReason:
    """폴백 사유 필드 검증."""

    def test_companion_timeout_includes_reason(self, tmp_path):
        """companion 타임아웃 시 fallback_reason에 타임아웃 사유 포함."""
        task_file = str(tmp_path / "task.md")
        Path(task_file).write_text("# 설계")

        with patch.dict(os.environ, {"OPENAI_API_KEY": "sk-test"}):
            with patch("subprocess.run", side_effect=subprocess.TimeoutExpired(cmd="node", timeout=120)):
                result = codex_gate_check(
                    task_file=task_file,
                    affected_files=[],
                    workspace_root=str(tmp_path),
                )

        assert result["source"] == "maat_fallback"
        assert "fallback_reason" in result
        assert "timeout" in result["fallback_reason"].lower() or "타임아웃" in result["fallback_reason"]

    def test_companion_error_includes_reason(self, tmp_path):
        """companion 비정상 종료 시 fallback_reason에 에러 사유 포함."""
        task_file = str(tmp_path / "task.md")
        Path(task_file).write_text("# 설계")

        error_proc = MagicMock()
        error_proc.returncode = 1
        error_proc.stdout = ""
        error_proc.stderr = "API rate limit exceeded"

        with patch.dict(os.environ, {"OPENAI_API_KEY": "sk-test"}):
            with patch("subprocess.run", return_value=error_proc):
                result = codex_gate_check(
                    task_file=task_file,
                    affected_files=[],
                    workspace_root=str(tmp_path),
                )

        assert result["source"] == "maat_fallback"
        assert "fallback_reason" in result

    def test_codex_success_no_fallback_reason(self, tmp_path):
        """Codex 성공 시 fallback_reason은 None."""
        task_file = str(tmp_path / "task.md")
        Path(task_file).write_text("# 설계")

        mock_proc = _make_codex_result([])

        with patch.dict(os.environ, {"OPENAI_API_KEY": "sk-test"}):
            with patch("subprocess.run", return_value=mock_proc):
                with patch("codex_gate_check.os.path.isfile", return_value=True):
                    result = codex_gate_check(
                        task_file=task_file,
                        affected_files=[],
                        workspace_root="/home/jay/workspace",
                    )

        assert result["fallback_reason"] is None


# ---------------------------------------------------------------------------
# 14. Sanitize 게이트 자동 적용 검증 (task-2087)
# ---------------------------------------------------------------------------
class TestSanitizeGateIntegration:
    """Codex 호출 전 PII 마스킹 자동 적용 검증."""

    def test_pii_in_prompt_is_masked_before_codex_call(self, tmp_path):
        """프롬프트에 PII(전화번호)가 포함되면 마스킹 후 Codex에 전달."""
        task_file = str(tmp_path / "task.md")
        Path(task_file).write_text("# 설계\n연락처: 010-1234-5678\n주민번호: 900101-1234567")

        captured_prompts = []

        def capture_run(*args, **kwargs):
            cmd = args[0] if args else kwargs.get("args", [])
            cmd_list = list(cmd)
            if "--prompt-file" in cmd_list:
                idx = cmd_list.index("--prompt-file")
                if idx + 1 < len(cmd_list):
                    prompt_path = cmd_list[idx + 1]
                    if os.path.isfile(prompt_path):
                        with open(prompt_path, "r", encoding="utf-8") as pf:
                            captured_prompts.append(pf.read())
            mock = MagicMock()
            mock.returncode = 0
            mock.stdout = json.dumps({"risks": [], "suggestions": []})
            mock.stderr = ""
            return mock

        with patch("subprocess.run", side_effect=capture_run):
            with patch("codex_gate_check.os.path.isfile", return_value=True):
                result = codex_gate_check(
                    task_file=task_file,
                    affected_files=[],
                    workspace_root=str(tmp_path),
                )

        assert result["pass"] is True
        # 프롬프트가 캡처되었으면 PII가 마스킹되었는지 확인
        if captured_prompts:
            prompt_text = captured_prompts[0]
            assert "010-1234-5678" not in prompt_text, "전화번호가 마스킹되지 않았음"
            assert "900101-1234567" not in prompt_text, "주민번호가 마스킹되지 않았음"

    def test_sanitize_unavailable_still_works(self, tmp_path):
        """sanitize_gate import 실패해도 codex_gate_check는 정상 동작."""
        task_file = str(tmp_path / "task.md")
        Path(task_file).write_text("# 설계\n연락처: 010-1234-5678")

        mock_proc = _make_codex_result([])

        with patch("codex_gate_check._SANITIZE_AVAILABLE", False):
            with patch("subprocess.run", return_value=mock_proc):
                with patch("codex_gate_check.os.path.isfile", return_value=True):
                    result = codex_gate_check(
                        task_file=task_file,
                        affected_files=[],
                        workspace_root="/home/jay/workspace",
                    )

        assert result["pass"] is True
        assert result["source"] == "codex_companion"

    def test_no_pii_no_masking_log(self, tmp_path):
        """PII 없는 프롬프트에서는 마스킹 감지 0건."""
        task_file = str(tmp_path / "task.md")
        Path(task_file).write_text("# 일반 설계 문서\n로깅 개선 작업")

        mock_proc = _make_codex_result([])

        with patch("subprocess.run", return_value=mock_proc):
            with patch("codex_gate_check.os.path.isfile", return_value=True):
                result = codex_gate_check(
                    task_file=task_file,
                    affected_files=[],
                    workspace_root="/home/jay/workspace",
                )

        assert result["pass"] is True


# ---------------------------------------------------------------------------
# 14. 결과 파일 자동 생성 검증 (task-2086)
# ---------------------------------------------------------------------------
class TestGateFileAutoGeneration:
    """codex_gate_check()가 task_id 전달 시 .codex-gate 결과 파일을 자동 생성하는지 검증."""

    def test_gate_file_created_on_codex_success(self, tmp_path):
        """Codex 성공 시 결과 파일이 생성되어야 한다."""
        task_file = str(tmp_path / "task.md")
        Path(task_file).write_text("# 설계")
        events_dir = tmp_path / "memory" / "events"
        events_dir.mkdir(parents=True)

        mock_proc = _make_codex_result([])
        with patch("subprocess.run", return_value=mock_proc):
            with patch("codex_gate_check.os.path.isfile", return_value=True):
                result = codex_gate_check(
                    task_file=task_file,
                    affected_files=[],
                    workspace_root=str(tmp_path),
                    task_id="task-test-001",
                )

        gate_file = events_dir / "task-test-001.codex-gate"
        assert gate_file.exists(), "결과 파일이 생성되어야 함"
        data = json.loads(gate_file.read_text())
        assert data["task_id"] == "task-test-001"
        assert "timestamp" in data
        assert data["pass"] == result["pass"]

    def test_gate_file_created_on_maat_fallback(self, tmp_path):
        """마아트 폴백 시에도 결과 파일이 생성되어야 한다."""
        task_file = str(tmp_path / "task.md")
        Path(task_file).write_text("# 설계")
        events_dir = tmp_path / "memory" / "events"
        events_dir.mkdir(parents=True)

        error_proc = MagicMock()
        error_proc.returncode = 1
        error_proc.stdout = ""
        error_proc.stderr = "error"

        with patch("subprocess.run", return_value=error_proc):
            codex_gate_check(
                task_file=task_file,
                affected_files=[],
                workspace_root=str(tmp_path),
                task_id="task-test-002",
            )

        gate_file = events_dir / "task-test-002.codex-gate"
        assert gate_file.exists(), "폴백 시에도 결과 파일이 생성되어야 함"
        data = json.loads(gate_file.read_text())
        assert data["source"] == "maat_fallback"

    def test_no_gate_file_without_task_id(self, tmp_path):
        """task_id 없으면 결과 파일이 생성되지 않아야 한다."""
        task_file = str(tmp_path / "task.md")
        Path(task_file).write_text("# 설계")
        events_dir = tmp_path / "memory" / "events"
        events_dir.mkdir(parents=True)

        mock_proc = _make_codex_result([])
        with patch("subprocess.run", return_value=mock_proc):
            codex_gate_check(
                task_file=task_file,
                affected_files=[],
                workspace_root=str(tmp_path),
            )

        gate_files = list(events_dir.glob("*.codex-gate"))
        assert len(gate_files) == 0, "task_id 없으면 결과 파일 미생성"


# ---------------------------------------------------------------------------
# 15. is_new 메타데이터 지원 테스트 (task-2162)
# ---------------------------------------------------------------------------


class TestNormalizeAffectedItem:
    """_normalize_affected_item 헬퍼 함수 단위 테스트."""

    def test_string_returns_path_and_false(self):
        """문자열 입력 시 (path, False) 반환."""
        path, is_new = _normalize_affected_item("file.py")
        assert path == "file.py"
        assert is_new is False

    def test_dict_with_is_new_true(self):
        """dict + is_new=True 입력 시 (path, True) 반환."""
        path, is_new = _normalize_affected_item({"path": "new_file.py", "is_new": True})
        assert path == "new_file.py"
        assert is_new is True

    def test_dict_with_is_new_false(self):
        """dict + is_new=False 입력 시 (path, False) 반환."""
        path, is_new = _normalize_affected_item({"path": "existing.py", "is_new": False})
        assert path == "existing.py"
        assert is_new is False

    def test_dict_without_is_new_defaults_false(self):
        """dict에 is_new 키 없으면 False 기본값."""
        path, is_new = _normalize_affected_item({"path": "file.py"})
        assert path == "file.py"
        assert is_new is False


class TestIsNewMaatFallback:
    """is_new 메타데이터에 따른 마아트 폴백 동작 테스트."""

    def test_new_file_missing_is_info(self, tmp_path):
        """is_new=True인 미존재 파일은 info severity."""
        task_file = str(tmp_path / "task.md")
        Path(task_file).write_text("# 설계")

        result = _maat_fallback_check(
            task_file,
            [{"path": "brand_new.py", "is_new": True}],
            str(tmp_path),
        )
        assert result["pass"] is True
        info_risks = [r for r in result["risks"] if r["severity"] == "info"]
        assert len(info_risks) == 1
        assert "신규 파일" in info_risks[0]["description"]

    def test_existing_file_missing_is_high(self, tmp_path):
        """is_new=False인 미존재 파일은 high severity."""
        task_file = str(tmp_path / "task.md")
        Path(task_file).write_text("# 설계")

        result = _maat_fallback_check(
            task_file,
            [{"path": "should_exist.py", "is_new": False}],
            str(tmp_path),
        )
        assert result["pass"] is True  # high는 PASS (critical만 FAIL)
        high_risks = [r for r in result["risks"] if r["severity"] == "high"]
        assert len(high_risks) == 1
        assert "오타 또는 삭제됨" in high_risks[0]["description"]

    def test_string_missing_file_is_high(self, tmp_path):
        """문자열 형식(기존 호환)의 미존재 파일은 high severity."""
        task_file = str(tmp_path / "task.md")
        Path(task_file).write_text("# 설계")

        result = _maat_fallback_check(
            task_file,
            ["missing_file.py"],
            str(tmp_path),
        )
        assert result["pass"] is True
        high_risks = [r for r in result["risks"] if r["severity"] == "high"]
        assert len(high_risks) == 1

    def test_mixed_format_affected_files(self, tmp_path):
        """문자열 + dict 혼합 형식 지원."""
        task_file = str(tmp_path / "task.md")
        Path(task_file).write_text("# 설계")
        # 실제 존재하는 파일 하나
        existing = tmp_path / "exists.py"
        existing.write_text("# ok")

        result = _maat_fallback_check(
            task_file,
            [
                str(existing),                                    # 존재하는 문자열 → 리스크 없음
                "not_here.py",                                    # 미존재 문자열 → high
                {"path": "new_module.py", "is_new": True},       # 신규 미존재 → info
                {"path": "deleted.py", "is_new": False},          # 기존 미존재 → high
            ],
            str(tmp_path),
        )
        assert result["pass"] is True
        high_risks = [r for r in result["risks"] if r["severity"] == "high"]
        info_risks = [r for r in result["risks"] if r["severity"] == "info"]
        assert len(high_risks) == 2  # not_here.py + deleted.py
        assert len(info_risks) == 1  # new_module.py

    def test_existing_file_no_risk(self, tmp_path):
        """실제 존재하는 파일은 is_new 여부 무관 리스크 없음."""
        task_file = str(tmp_path / "task.md")
        Path(task_file).write_text("# 설계")
        existing = tmp_path / "real.py"
        existing.write_text("# real file")

        result = _maat_fallback_check(
            task_file,
            [{"path": str(existing), "is_new": True}],
            str(tmp_path),
        )
        # 파일이 존재하면 리스크 0 (보안 키워드 등 제외)
        file_risks = [r for r in result["risks"] if "real.py" in r.get("description", "")]
        assert len(file_risks) == 0
