"""
test_dispatch_gate.py

보리스 게이트 시스템 Phase 1 단위 테스트 (아르고스 작성, task-1838)

테스트 항목:
1. gate_instructions 동작 (GATE_INSTRUCTIONS 딕셔너리, get_gate_instructions, format_for_prompt)
2. affected_files 파싱 (_parse_affected_files)
3. affected_files 겹침 감지 (_check_affected_files_overlap)
4. Lv.2+ affected_files 미기재 경고 (_warn_missing_affected_files)
5. batch_id 완료 추적 (check_batch_completion)
6. 레벨 자동 추정 (_estimate_task_level)
7. task 레벨 파싱 (_parse_task_level)
"""

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

import pytest

# workspace sys.path 보정 (tests/ 디렉토리에서 실행 시)
# worktree 경로를 최우선으로 삽입하여 메인 workspace dispatch.py 대신 worktree 버전을 사용
_WORKSPACE = Path(__file__).parent.parent
sys.path.insert(0, str(_WORKSPACE))

from dispatch import (
    _check_affected_files_overlap,
    _estimate_task_level,
    _get_ast_blast_radius,
    _parse_affected_files,
    _parse_task_level,
    _warn_missing_affected_files,
    check_batch_completion,
)
from prompts.gate_instructions import (
    GATE_INSTRUCTIONS,
    format_for_prompt,
    get_gate_instructions,
)

# ===========================================================================
# 1. gate_instructions 동작
# ===========================================================================


class TestGateInstructions:
    def test_gate_instructions_levels_0_to_4(self):
        """모든 레벨(0-4) 딕셔너리가 존재하고 g1/g2/g3 키를 포함해야 한다."""
        for level in range(5):
            assert level in GATE_INSTRUCTIONS, f"레벨 {level}이 GATE_INSTRUCTIONS에 없음"
            gates = GATE_INSTRUCTIONS[level]
            for key in ("g1", "g2", "g3"):
                assert key in gates, f"레벨 {level}에 '{key}' 키 없음"

    def test_gate_instructions_unknown_level_fallback(self):
        """존재하지 않는 레벨(999) 요청 시 레벨 0 딕셔너리로 폴백해야 한다."""
        result = get_gate_instructions(999)
        assert result == GATE_INSTRUCTIONS[0], "unknown 레벨은 레벨 0으로 폴백되어야 함"

    def test_format_for_prompt_level_0(self):
        """Lv.0 → G1은 비어있어 스킵, G2/G3만 포함된 문자열 반환."""
        result = format_for_prompt(0)
        assert "[G1 설계 게이트]" not in result, "Lv.0에서 G1은 포함되면 안 됨"
        assert "[G2 구현 게이트]" in result, "Lv.0에서 G2가 포함되어야 함"
        assert "[G3 머지 게이트]" in result, "Lv.0에서 G3가 포함되어야 함"

    def test_format_for_prompt_level_2(self):
        """Lv.2 → G1/G2/G3 모두 포함되고 'affected_files' 텍스트가 있어야 한다."""
        result = format_for_prompt(2)
        assert "[G1 설계 게이트]" in result, "Lv.2에서 G1이 포함되어야 함"
        assert "[G2 구현 게이트]" in result, "Lv.2에서 G2가 포함되어야 함"
        assert "[G3 머지 게이트]" in result, "Lv.2에서 G3가 포함되어야 함"
        assert "affected_files" in result, "Lv.2 G1에는 'affected_files' 텍스트가 포함되어야 함"

    def test_format_for_prompt_level_4(self):
        """Lv.4 → 'Graduated Auto-Gate' 텍스트가 포함되어야 한다."""
        result = format_for_prompt(4)
        assert "Graduated Auto-Gate" in result, "Lv.4 G3에는 'Graduated Auto-Gate' 텍스트가 포함되어야 함"


# ===========================================================================
# 2. affected_files 파싱
# ===========================================================================


class TestParseAffectedFiles:
    def test_parse_affected_files_basic(self):
        """'affected_files: server.py, app.js' → ['server.py', 'app.js'] 파싱."""
        task_desc = "affected_files: server.py, app.js"
        result = _parse_affected_files(task_desc)
        assert result == ["server.py", "app.js"], f"파싱 결과 불일치: {result}"

    def test_parse_affected_files_empty(self):
        """affected_files 라인이 없으면 빈 리스트를 반환해야 한다."""
        task_desc = "이 작업은 affected_files 라인이 없습니다.\n단순 수정 작업입니다."
        result = _parse_affected_files(task_desc)
        assert result == [], f"빈 리스트를 기대했으나 {result} 반환"

    def test_parse_affected_files_with_spaces(self):
        """파일명 사이의 공백이 올바르게 처리(strip)되어야 한다."""
        task_desc = "affected_files:  server.py ,   app.js  ,  utils.py  "
        result = _parse_affected_files(task_desc)
        assert result == ["server.py", "app.js", "utils.py"], f"공백 처리 실패: {result}"

    def test_parse_affected_files_multiline(self):
        """여러 줄 task_desc에서 affected_files 라인만 정확히 파싱되어야 한다."""
        task_desc = (
            "# task-100: 다중 파일 수정\n"
            "레벨: Lv.2\n"
            "affected_files: dispatch.py, config/loader.py\n"
            "\n"
            "## 작업 내용\n"
            "dispatch.py를 리팩토링합니다.\n"
        )
        result = _parse_affected_files(task_desc)
        assert result == ["dispatch.py", "config/loader.py"], f"다중 라인 파싱 실패: {result}"

    def test_parse_affected_files_section_format(self):
        """## affected_files 섹션 형식 파싱 — 하이픈 목록을 파일 리스트로 반환해야 한다."""
        task_desc = (
            "## affected_files\n"
            "- dispatch.py\n"
            "- tests/test_dispatch_gate.py\n"
        )
        result = _parse_affected_files(task_desc)
        assert result == ["dispatch.py", "tests/test_dispatch_gate.py"], f"섹션 형식 파싱 실패: {result}"

    def test_parse_affected_files_section_format_with_context(self):
        """실제 task 파일과 유사한 전체 구조에서 섹션 형식 파싱이 동작해야 한다."""
        task_desc = (
            "# Task 수정 작업\n"
            "## 배경\n"
            "기존 파서에 버그 있음\n"
            "\n"
            "## affected_files\n"
            "- src/main.py\n"
            "- src/utils/helper.py\n"
            "- tests/test_main.py\n"
            "\n"
            "## 작업 내용\n"
            "파서를 수정합니다.\n"
        )
        result = _parse_affected_files(task_desc)
        assert result == ["src/main.py", "src/utils/helper.py", "tests/test_main.py"], f"전체 구조 섹션 파싱 실패: {result}"

    def test_parse_affected_files_section_stops_at_next_heading(self):
        """## affected_files 섹션은 다음 ## 헤더에서 목록 수집을 중단해야 한다."""
        task_desc = (
            "## affected_files\n"
            "- alpha.py\n"
            "- beta.py\n"
            "\n"
            "## 다른 섹션\n"
            "이 내용은 파일 목록이 아닙니다\n"
            "- 이것도 파일이 아님\n"
        )
        result = _parse_affected_files(task_desc)
        assert result == ["alpha.py", "beta.py"], f"다음 헤더에서 종료 실패: {result}"

    def test_parse_affected_files_inline_still_works(self):
        """기존 인라인 형식(affected_files: ...)은 섹션 형식 추가 후에도 계속 동작해야 한다."""
        task_desc = "affected_files: server.py, config.yaml"
        result = _parse_affected_files(task_desc)
        assert result == ["server.py", "config.yaml"], f"인라인 형식 하위 호환 실패: {result}"

    def test_parse_affected_files_section_empty_list(self):
        """## affected_files 섹션 헤더만 있고 하이픈 목록이 없으면 빈 리스트를 반환해야 한다."""
        task_desc = (
            "## affected_files\n"
            "\n"
            "## 작업 내용\n"
            "없음\n"
        )
        result = _parse_affected_files(task_desc)
        assert result == [], f"빈 섹션은 빈 리스트를 반환해야 함: {result}"

    def test_parse_affected_files_section_inline_comma(self):
        """## affected_files 헤더 아래 쉼표 구분 인라인 값도 파싱되어야 한다."""
        task_desc = (
            "## affected_files\n"
            "dashboard/wiki_engine.py, dashboard/routes_post.py, dashboard/routes_get.py\n"
            "\n"
            "## 검증 시나리오\n"
        )
        result = _parse_affected_files(task_desc)
        assert result == [
            "dashboard/wiki_engine.py",
            "dashboard/routes_post.py",
            "dashboard/routes_get.py",
        ], f"섹션 인라인 쉼표 파싱 실패: {result}"

    def test_parse_affected_files_section_inline_with_comment(self):
        """## affected_files 아래 괄호 코멘트(예: '(신규)')가 제거되어야 한다."""
        task_desc = (
            "## affected_files\n"
            "dashboard/wiki_engine.py (신규)\n"
            "\n"
            "## 다음\n"
        )
        result = _parse_affected_files(task_desc)
        assert result == ["dashboard/wiki_engine.py"], f"괄호 코멘트 제거 실패: {result}"


# ===========================================================================
# 3. affected_files 겹침 감지
# ===========================================================================


class TestCheckAffectedFilesOverlap:
    def _make_timer_file(self, tmp_path: Path, tasks: dict) -> Path:
        """task-timers.json을 tmp_path/memory/ 하위에 생성하고 경로를 반환한다."""
        memory_dir = tmp_path / "memory"
        memory_dir.mkdir(parents=True, exist_ok=True)
        timer_file = memory_dir / "task-timers.json"
        timer_file.write_text(
            json.dumps({"tasks": tasks}, ensure_ascii=False, indent=2),
            encoding="utf-8",
        )
        return timer_file

    def test_check_overlap_no_timer_file(self, tmp_path, monkeypatch):
        """task-timers.json이 없을 때 → 빈 리스트 반환."""
        import dispatch

        monkeypatch.setattr(dispatch, "WORKSPACE", tmp_path)
        result = _check_affected_files_overlap(["server.py"], "task-999")
        assert result == [], f"타이머 파일 없을 때 빈 리스트를 기대했으나: {result}"

    def test_check_overlap_detected(self, tmp_path, monkeypatch):
        """다른 running task가 같은 파일을 수정 중이면 경고 메시지를 반환해야 한다."""
        import dispatch

        tasks = {
            "task-100": {
                "status": "running",
                "team_id": "dev2-team",
                "affected_files": ["server.py", "utils.py"],
            }
        }
        self._make_timer_file(tmp_path, tasks)
        monkeypatch.setattr(dispatch, "WORKSPACE", tmp_path)

        result = _check_affected_files_overlap(["server.py"], "task-200")
        assert len(result) > 0, "겹침이 감지되어야 함"
        assert any("server.py" in w for w in result), f"server.py 겹침 경고가 포함되어야 함: {result}"

    def test_check_overlap_no_conflict(self, tmp_path, monkeypatch):
        """affected_files가 겹치지 않으면 빈 리스트를 반환해야 한다."""
        import dispatch

        tasks = {
            "task-100": {
                "status": "running",
                "team_id": "dev2-team",
                "affected_files": ["other_module.py", "utils.py"],
            }
        }
        self._make_timer_file(tmp_path, tasks)
        monkeypatch.setattr(dispatch, "WORKSPACE", tmp_path)

        result = _check_affected_files_overlap(["server.py", "app.js"], "task-200")
        assert result == [], f"겹침이 없어야 하는데 경고가 반환됨: {result}"

    def test_check_overlap_self_excluded(self, tmp_path, monkeypatch):
        """현재 task_id와 동일한 엔트리는 겹침 감지에서 제외되어야 한다."""
        import dispatch

        tasks = {
            "task-200": {
                "status": "running",
                "team_id": "dev1-team",
                "affected_files": ["server.py"],
            }
        }
        self._make_timer_file(tmp_path, tasks)
        monkeypatch.setattr(dispatch, "WORKSPACE", tmp_path)

        # task-200 자신이 server.py를 가지고 있어도 current_task_id=task-200이면 제외
        result = _check_affected_files_overlap(["server.py"], "task-200")
        assert result == [], f"자기 자신은 겹침에서 제외되어야 함: {result}"


# ===========================================================================
# 4. Lv.2+ affected_files 미기재 경고
# ===========================================================================


class TestWarnMissingAffectedFiles:
    def test_warn_missing_af_level2_no_files(self):
        """Lv.2 + affected_files 미기재 → 경고 문자열 반환."""
        task_desc = "# task-100: 수정\n레벨: Lv.2\n작업 내용: 무언가를 수정합니다."
        result = _warn_missing_affected_files(task_desc, task_level=2)
        assert result is not None, "Lv.2 + 미기재 시 경고가 반환되어야 함"
        assert isinstance(result, str), "경고는 문자열이어야 함"

    def test_warn_missing_af_level1(self):
        """Lv.1 → None 반환 (경고 없음)."""
        task_desc = "# task-101: 수정\n레벨: Lv.1\n작업 내용: 단순 수정."
        result = _warn_missing_affected_files(task_desc, task_level=1)
        assert result is None, f"Lv.1에서는 경고가 없어야 함: {result}"

    def test_warn_missing_af_level2_with_files(self):
        """Lv.2 + affected_files 기재 → None 반환 (경고 없음)."""
        task_desc = (
            "# task-102: 수정\n"
            "레벨: Lv.2\n"
            "affected_files: server.py, app.js\n"
            "작업 내용: server.py를 수정합니다."
        )
        result = _warn_missing_affected_files(task_desc, task_level=2)
        assert result is None, f"affected_files가 기재되면 경고 없어야 함: {result}"


# ===========================================================================
# 5. batch_id 완료 추적
# ===========================================================================


class TestCheckBatchCompletion:
    def _make_timer_file(self, tmp_path: Path, tasks: dict) -> Path:
        """task-timers.json을 tmp_path/memory/ 하위에 생성하고 경로를 반환한다."""
        memory_dir = tmp_path / "memory"
        memory_dir.mkdir(parents=True, exist_ok=True)
        timer_file = memory_dir / "task-timers.json"
        timer_file.write_text(
            json.dumps({"tasks": tasks}, ensure_ascii=False, indent=2),
            encoding="utf-8",
        )
        return timer_file

    def test_batch_completion_all_done(self, tmp_path, monkeypatch):
        """모든 task가 done 상태이면 complete=True, pending=[] 반환."""
        import dispatch

        batch_id = "batch-20260415-test"
        tasks = {
            "task-301": {"status": "done", "batch_id": batch_id},
            "task-302": {"status": "done", "batch_id": batch_id},
        }
        self._make_timer_file(tmp_path, tasks)
        monkeypatch.setattr(dispatch, "WORKSPACE", tmp_path)

        result = check_batch_completion(batch_id)
        assert result["complete"] is True, f"모두 완료 시 complete=True여야 함: {result}"
        assert result["total"] == 2, f"total=2여야 함: {result}"
        assert result["done"] == 2, f"done=2여야 함: {result}"
        assert result["pending"] == [], f"pending=[]이어야 함: {result}"

    def test_batch_completion_partial(self, tmp_path, monkeypatch):
        """일부만 done 상태이면 complete=False, pending에 미완료 task가 있어야 한다."""
        import dispatch

        batch_id = "batch-20260415-partial"
        tasks = {
            "task-401": {"status": "done", "batch_id": batch_id},
            "task-402": {"status": "running", "batch_id": batch_id},
            "task-403": {"status": "running", "batch_id": batch_id},
        }
        self._make_timer_file(tmp_path, tasks)
        monkeypatch.setattr(dispatch, "WORKSPACE", tmp_path)

        result = check_batch_completion(batch_id)
        assert result["complete"] is False, f"일부만 완료 시 complete=False여야 함: {result}"
        assert result["total"] == 3, f"total=3이어야 함: {result}"
        assert result["done"] == 1, f"done=1이어야 함: {result}"
        assert sorted(result["pending"]) == ["task-402", "task-403"], f"pending 목록 불일치: {result}"

    def test_batch_completion_no_match(self, tmp_path, monkeypatch):
        """batch_id가 일치하는 task가 없으면 total=0, complete=False 반환."""
        import dispatch

        tasks = {
            "task-501": {"status": "done", "batch_id": "other-batch"},
        }
        self._make_timer_file(tmp_path, tasks)
        monkeypatch.setattr(dispatch, "WORKSPACE", tmp_path)

        result = check_batch_completion("nonexistent-batch-id")
        assert result["total"] == 0, f"매칭되는 task 없으면 total=0이어야 함: {result}"
        assert result["complete"] is False, f"total=0이면 complete=False여야 함: {result}"


# ===========================================================================
# 6. 레벨 자동 추정
# ===========================================================================


class TestEstimateTaskLevel:
    def test_estimate_level_many_files(self):
        """affected_files 5개 → 최소 Lv.2 권장."""
        files = ["a.py", "b.py", "c.py", "d.py", "e.py"]
        level, reason = _estimate_task_level("일반 작업", files)
        assert level >= 2, f"5개 파일이면 Lv.2 이상 권장이어야 함: level={level}, reason={reason}"
        assert reason, "이유가 있어야 함"

    def test_estimate_level_server_py(self):
        """server.py 포함 → 최소 Lv.2 권장."""
        files = ["server.py"]
        level, reason = _estimate_task_level("서버 수정", files)
        assert level >= 2, f"server.py 포함 시 Lv.2 이상 권장이어야 함: level={level}, reason={reason}"
        assert "server.py" in reason, f"이유에 'server.py'가 포함되어야 함: {reason}"

    def test_estimate_level_architecture_keyword(self):
        """'아키텍처' 키워드 포함 → 최소 Lv.3 권장."""
        task_desc = "전체 아키텍처를 변경하는 대형 작업입니다."
        level, reason = _estimate_task_level(task_desc, [])
        assert level >= 3, f"'아키텍처' 키워드 시 Lv.3 이상 권장이어야 함: level={level}, reason={reason}"

    def test_estimate_level_simple(self):
        """파일 1개, 키워드 없음 → Lv.1 (기본)."""
        task_desc = "단순 오타 수정"
        files = ["readme.txt"]
        level, reason = _estimate_task_level(task_desc, files)
        assert level == 1, f"단순 작업은 Lv.1이어야 함: level={level}, reason={reason}"
        assert reason == "", f"단순 작업은 이유가 없어야 함: reason={reason!r}"


# ===========================================================================
# 7. task 레벨 파싱
# ===========================================================================


class TestParseTaskLevel:
    def test_parse_task_level_lv2(self):
        """'Lv.2' 포함 task_desc → 2 반환."""
        task_desc = "# task-100: 수정\n레벨: Lv.2\n작업 내용: 중간 규모 수정."
        result = _parse_task_level(task_desc)
        assert result == 2, f"Lv.2 파싱 실패: {result}"

    def test_parse_task_level_default(self):
        """레벨 표기 없음 → 기본값 1 반환."""
        task_desc = "# task-101: 수정\n레벨 표기가 없는 간단한 작업."
        result = _parse_task_level(task_desc)
        assert result == 1, f"레벨 없을 때 기본값 1이어야 함: {result}"


# ---------------------------------------------------------------------------
# AST blast radius 통합 테스트 (task-1869_2.2+3)
# ---------------------------------------------------------------------------
class TestAstBlastRadius:
    """_get_ast_blast_radius 함수 테스트."""

    def test_returns_empty_when_no_py_files(self):
        """py 확장자 없는 affected_files면 빈 dict 반환."""
        result = _get_ast_blast_radius(["README.md"], "/tmp")
        assert result["direct_importers"] == []
        assert result["total_affected"] == 0

    def test_returns_importers_on_success(self):
        """AST 호출 성공 시 direct_importers 반환."""
        ast_output = json.dumps({
            "changed_file": "data_loader.py",
            "blast_radius": {
                "direct_importers": ["server.py", "routes.py"],
                "callers": [],
                "test_files": ["test_data_loader.py"],
                "total_affected": 3,
            },
        })

        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_ast_blast_radius(["data_loader.py"], "/tmp")

        assert "server.py" in result["direct_importers"]
        assert "routes.py" in result["direct_importers"]

    def test_returns_empty_on_timeout(self):
        """AST 타임아웃 시 빈 dict 반환."""
        with patch("subprocess.run", side_effect=subprocess.TimeoutExpired(cmd=[], timeout=30)):
            result = _get_ast_blast_radius(["data_loader.py"], "/tmp")

        assert result["direct_importers"] == []

    def test_returns_empty_on_script_error(self):
        """AST 스크립트 실패 시 빈 dict 반환."""
        mock_proc = MagicMock()
        mock_proc.returncode = 1
        mock_proc.stdout = ""
        mock_proc.stderr = "error"

        with patch("subprocess.run", return_value=mock_proc):
            result = _get_ast_blast_radius(["data_loader.py"], "/tmp")

        assert result["direct_importers"] == []
