# pyright: reportMissingImports=false
"""
knowledge_extractor_v2 단위 테스트
테스터: 모리건 (개발3팀)
"""

import json
import os
import subprocess
import tempfile
from unittest.mock import patch

from kakao_knowledge.knowledge_extractor_v2 import (_cleanup_checkpoints,
                                                    _llm_refine_thread_splits,
                                                    extract_knowledge_v2)
from kakao_knowledge.models import ChatMessage, MessageType
from kakao_knowledge.models_v2 import InsightType

# ---------------------------------------------------------------------------
# 필수 키 목록 (InsightV2.model_dump() 결과 기준)
# ---------------------------------------------------------------------------

REQUIRED_V2_KEYS = {
    "id",
    "title",
    "type",
    "category",
    "summary",
    "key_points",
    "expert",
    "confidence",
    "related_topics",
    "tags",
    "source_date",
    "source_chat",
    "raw_thread",
    "participants",
    "question",
    "answer",
}

# ---------------------------------------------------------------------------
# 테스트 헬퍼 함수
# ---------------------------------------------------------------------------


def _make_qa_messages():
    """#궁금증 태그가 있는 Q&A 메시지"""
    return [
        ChatMessage(
            date="2025-12-03",
            time="17:00",
            user="질문자/인카/서울",
            content="#궁금증\n• 유형 : 보상\n광응고술이 수술에 해당하나요?",
            type=MessageType.MESSAGE,
        ),
        ChatMessage(
            date="2025-12-03",
            time="17:05",
            user="이해철/프라임/부산",
            content="네 수술에 해당합니다. 약관이 개정되었습니다.",
            type=MessageType.MESSAGE,
        ),
        ChatMessage(
            date="2025-12-03",
            time="17:06",
            user="이해철/프라임/부산",
            content="레이저를 이용한 광응고술도 수술약관에 명시되어 있습니다.",
            type=MessageType.MESSAGE,
        ),
    ]


def _make_expert_opinion_messages():
    """전문가 의견 공유 메시지 (태그 없음)"""
    return [
        ChatMessage(
            date="2025-12-03",
            time="21:42",
            user="이해철/프라임/부산",
            content="다이어트 약, 이제 정말 주의하셔야 합니다.\n비만약 처방받은 사실을 고지하지 않았다는 이유로 보험사에서 면책한 사례가 나왔습니다.",
            type=MessageType.MESSAGE,
        ),
        ChatMessage(
            date="2025-12-03",
            time="21:44",
            user="박유진/인카/서울",
            content="비만약은 건강이음에도 안뜨지 않아요?",
            type=MessageType.MESSAGE,
        ),
        ChatMessage(
            date="2025-12-03",
            time="21:45",
            user="이해철/프라임/부산",
            content="건강이음은 투약처방일수까지 알 수 없습니다.",
            type=MessageType.MESSAGE,
        ),
    ]


def _make_warning_messages():
    """경고/주의 유형 메시지"""
    return [
        ChatMessage(
            date="2025-12-04",
            time="10:00",
            user="전문가A/프라임/부산",
            content="주의하세요! 이번 달부터 고지의무 위반 심사가 강화되었습니다.",
            type=MessageType.MESSAGE,
        ),
        ChatMessage(
            date="2025-12-04",
            time="10:02",
            user="전문가A/프라임/부산",
            content="특히 비만약 관련 고지 누락은 절대 하면 안 됩니다.",
            type=MessageType.MESSAGE,
        ),
        ChatMessage(
            date="2025-12-04",
            time="10:05",
            user="설계사B/KB/대전",
            content="감사합니다. 고객들한테 꼭 안내해야겠네요.",
            type=MessageType.MESSAGE,
        ),
    ]


def _make_noise_messages():
    """노이즈 메시지만 (인사, 환영 등)"""
    return [
        ChatMessage(
            date="2025-12-03",
            time="17:38",
            user="오픈채팅봇",
            content="📚 한 발 앞서가는 설계사, 환영합니다.",
            type=MessageType.BOT,
        ),
        ChatMessage(
            date="2025-12-03",
            time="17:40",
            user="새회원/인카/서울",
            content="안녕하세요",
            type=MessageType.MESSAGE,
        ),
        ChatMessage(
            date="2025-12-03",
            time="17:41",
            user="기존회원/프라임/부산",
            content="안녕하세요!",
            type=MessageType.MESSAGE,
        ),
    ]


# ---------------------------------------------------------------------------
# A. 규칙 기반 추출 (use_llm=False)
# ---------------------------------------------------------------------------


class TestRuleBasedExtraction:
    """규칙 기반 추출 (use_llm=False) 테스트"""

    def test_empty_messages_returns_empty_list(self):
        """빈 메시지 리스트 → 빈 리스트 반환"""
        result = extract_knowledge_v2([], use_llm=False)
        assert result == [], "빈 메시지 입력 시 빈 리스트가 반환되어야 함"

    def test_single_message_thread_excluded(self):
        """단일 메시지 스레드 → 인사이트 제외 (최소 2개 메시지 필요)"""
        messages = [
            ChatMessage(
                date="2025-12-03",
                time="17:00",
                user="A/인카/서울",
                content="#궁금증\n• 유형 : 보상\n질문입니다",
                type=MessageType.MESSAGE,
            ),
        ]
        result = extract_knowledge_v2(messages, use_llm=False)
        assert len(result) == 0, "단일 메시지 스레드는 결과에서 제외되어야 함"

    def test_question_tag_creates_qa_insight(self):
        """#궁금증 태그 Q&A → qa 유형 인사이트 추출"""
        result = extract_knowledge_v2(_make_qa_messages(), use_llm=False)
        assert len(result) >= 1, "#궁금증 태그 메시지에서 인사이트가 추출되어야 함"
        types = [r["type"] for r in result]
        assert "qa" in types, f"qa 유형 인사이트가 없음. 추출된 타입: {types}"

    def test_question_pattern_creates_qa_insight(self):
        """'질문 드립니다' 패턴 → qa 유형 추출"""
        messages = [
            ChatMessage(
                date="2025-12-03",
                time="17:00",
                user="A/인카/서울",
                content="질문 드립니다. 실비 청구 관련해서요.",
                type=MessageType.MESSAGE,
            ),
            ChatMessage(
                date="2025-12-03",
                time="17:02",
                user="B/프라임/부산",
                content="실비 청구는 진단서와 영수증을 함께 제출하시면 됩니다.",
                type=MessageType.MESSAGE,
            ),
        ]
        result = extract_knowledge_v2(messages, use_llm=False)
        if len(result) > 0:
            types = [r["type"] for r in result]
            assert (
                "qa" in types
            ), f"'질문 드립니다' 패턴에서 qa 유형이 추출되어야 함. 추출된 타입: {types}"

    def test_all_required_keys_present(self):
        """모든 필수 키가 결과에 존재해야 한다"""
        result = extract_knowledge_v2(_make_qa_messages(), use_llm=False)
        assert len(result) >= 1, "결과가 비어있어 키 검증 불가"
        for entry in result:
            missing = REQUIRED_V2_KEYS - set(entry.keys())
            assert not missing, f"필수 키 누락: {missing}"

    def test_id_format_insight_prefix(self):
        """id 포맷: 'insight-001' 형식이어야 한다"""
        result = extract_knowledge_v2(_make_qa_messages(), use_llm=False)
        assert len(result) >= 1, "결과가 비어있어 id 검증 불가"
        for entry in result:
            parts = entry["id"].split("-")
            assert (
                len(parts) == 2
            ), f"id 형식 오류: {entry['id']} (하이픈으로 분리된 2부분이어야 함)"
            assert parts[1].isdigit(), f"id 숫자 부분이 숫자가 아님: {parts[1]}"
            assert len(parts[1]) == 3, f"id 숫자 부분이 3자리가 아님: {parts[1]}"

    def test_confidence_is_valid_value(self):
        """confidence 값은 high/medium/low 중 하나여야 한다"""
        result = extract_knowledge_v2(_make_qa_messages(), use_llm=False)
        assert len(result) >= 1, "결과가 비어있어 confidence 검증 불가"
        valid_confidence = {"high", "medium", "low"}
        for entry in result:
            assert (
                entry["confidence"] in valid_confidence
            ), f"유효하지 않은 confidence 값: {entry['confidence']}"

    def test_type_is_valid_insight_type(self):
        """type 값은 InsightType enum 값 중 하나여야 한다"""
        result = extract_knowledge_v2(_make_qa_messages(), use_llm=False)
        assert len(result) >= 1, "결과가 비어있어 type 검증 불가"
        valid_types = {e.value for e in InsightType}
        for entry in result:
            assert (
                entry["type"] in valid_types
            ), f"유효하지 않은 type 값: {entry['type']}. 허용값: {valid_types}"

    def test_phone_number_masked(self):
        """전화번호가 마스킹되어야 한다"""
        messages = [
            ChatMessage(
                date="2025-12-03",
                time="17:00",
                user="A/인카/서울",
                content="#궁금증\n• 유형 : 보상\n문의사항 있으시면 연락 주세요.",
                type=MessageType.MESSAGE,
            ),
            ChatMessage(
                date="2025-12-03",
                time="17:05",
                user="B/프라임/부산",
                content="문의는 010-1234-5678로 연락주세요.",
                type=MessageType.MESSAGE,
            ),
        ]
        result = extract_knowledge_v2(messages, use_llm=False)
        assert len(result) >= 1, "전화번호 마스킹 테스트를 위한 결과가 없음"
        for entry in result:
            raw_text = "\n".join(entry.get("raw_thread", []))
            assert "010-1234-5678" not in raw_text, "전화번호가 마스킹되지 않고 노출됨"
            assert (
                "***-****-****" in raw_text
            ), "전화번호 마스킹 형식(***-****-****)이 없음"

    def test_noise_messages_filtered_out(self):
        """노이즈 메시지(인사, 봇 메시지)만 있는 경우 인사이트를 추출하지 않아야 한다"""
        result = extract_knowledge_v2(_make_noise_messages(), use_llm=False)
        assert (
            len(result) == 0
        ), f"노이즈 메시지에서 인사이트가 추출되면 안 됨. 결과: {result}"


# ---------------------------------------------------------------------------
# B. 카테고리 확장 테스트
# ---------------------------------------------------------------------------


class TestCategoryMapping:
    """카테고리 확장 매핑 테스트"""

    def _extract_for_content(self, keyword_content: str) -> list[dict]:
        messages = [
            ChatMessage(
                date="2025-12-03",
                time="17:00",
                user="A/인카/서울",
                content=f"#궁금증\n• 유형 : {keyword_content}\n관련 질문입니다.",
                type=MessageType.MESSAGE,
            ),
            ChatMessage(
                date="2025-12-03",
                time="17:05",
                user="B/프라임/부산",
                content="답변입니다.",
                type=MessageType.MESSAGE,
            ),
        ]
        return extract_knowledge_v2(messages, use_llm=False)

    def test_보상_keyword_maps_to_보상_category(self):
        """'보상' 키워드 → 보상 관련 카테고리 매핑"""
        result = self._extract_for_content("보상")
        assert len(result) >= 1, "'보상' 키워드 메시지에서 결과가 없음"
        category = result[0]["category"]
        assert "보상" in category, f"카테고리에 '보상'이 포함되지 않음: {category}"

    def test_고지_keyword_maps_to_고지의무_category(self):
        """'고지' 키워드 → 고지의무 카테고리"""
        result = self._extract_for_content("고지의무")
        assert len(result) >= 1, "'고지의무' 키워드 메시지에서 결과가 없음"
        category = result[0]["category"]
        assert "고지" in category, f"카테고리에 '고지'가 포함되지 않음: {category}"

    def test_약관_keyword_maps_to_약관해석_category(self):
        """'약관' 키워드 → 약관해석 카테고리"""
        result = self._extract_for_content("약관")
        assert len(result) >= 1, "'약관' 키워드 메시지에서 결과가 없음"
        category = result[0]["category"]
        assert "약관" in category, f"카테고리에 '약관'이 포함되지 않음: {category}"

    def test_상품_keyword_maps_to_상품비교_category(self):
        """'상품' 키워드 → 상품비교 카테고리"""
        result = self._extract_for_content("상품")
        assert len(result) >= 1, "'상품' 키워드 메시지에서 결과가 없음"
        category = result[0]["category"]
        assert "상품" in category, f"카테고리에 '상품'이 포함되지 않음: {category}"


# ---------------------------------------------------------------------------
# C. 인사이트 유형 감지 테스트
# ---------------------------------------------------------------------------


class TestInsightTypeDetection:
    """인사이트 유형 감지 테스트"""

    def test_경험_공유_패턴_creates_expert_opinion(self):
        """'제 경험상' 패턴 → expert_opinion 유형"""
        messages = [
            ChatMessage(
                date="2025-12-03",
                time="17:00",
                user="이해철/프라임/부산",
                content="제 경험상 비만약 처방 이력은 반드시 고지해야 합니다.",
                type=MessageType.MESSAGE,
            ),
            ChatMessage(
                date="2025-12-03",
                time="17:02",
                user="박유진/인카/서울",
                content="그렇군요. 꼭 기억하겠습니다.",
                type=MessageType.MESSAGE,
            ),
        ]
        result = extract_knowledge_v2(messages, use_llm=False)
        if len(result) > 0:
            types = [r["type"] for r in result]
            assert (
                "expert_opinion" in types
            ), f"'제 경험상' 패턴에서 expert_opinion이 추출되어야 함. 실제: {types}"

    def test_사례_공유_패턴_creates_case_analysis(self):
        """'실제로 이런 사례가' 패턴 → case_analysis 유형"""
        messages = [
            ChatMessage(
                date="2025-12-03",
                time="17:00",
                user="이해철/프라임/부산",
                content="실제로 이런 사례가 있었습니다. 비만약 복용 후 보험 가입을 시도한 고객이 면책당한 사례입니다.",
                type=MessageType.MESSAGE,
            ),
            ChatMessage(
                date="2025-12-03",
                time="17:03",
                user="박유진/인카/서울",
                content="정말 조심해야겠네요.",
                type=MessageType.MESSAGE,
            ),
        ]
        result = extract_knowledge_v2(messages, use_llm=False)
        if len(result) > 0:
            types = [r["type"] for r in result]
            assert (
                "case_analysis" in types
            ), f"'실제로 이런 사례가' 패턴에서 case_analysis가 추출되어야 함. 실제: {types}"

    def test_주의_경고_패턴_creates_warning(self):
        """'주의하세요' 패턴 → warning 유형"""
        result = extract_knowledge_v2(_make_warning_messages(), use_llm=False)
        if len(result) > 0:
            types = [r["type"] for r in result]
            assert (
                "warning" in types
            ), f"'주의하세요' 패턴에서 warning이 추출되어야 함. 실제: {types}"

    def test_약관_해석_논쟁_creates_regulation_interpretation(self):
        """약관 해석 논쟁 → regulation_interpretation 유형"""
        messages = [
            ChatMessage(
                date="2025-12-03",
                time="17:00",
                user="A/인카/서울",
                content="약관 해석이 궁금합니다. 레이저 시술이 수술 약관에 포함되나요?",
                type=MessageType.MESSAGE,
            ),
            ChatMessage(
                date="2025-12-03",
                time="17:03",
                user="이해철/프라임/부산",
                content="약관 조항을 보면 레이저를 이용한 수술도 포함됩니다. 단, 약관 개정일 이후부터 적용됩니다.",
                type=MessageType.MESSAGE,
            ),
            ChatMessage(
                date="2025-12-03",
                time="17:05",
                user="C/KB/대전",
                content="약관마다 다를 수 있으니 각 사마다 확인이 필요합니다.",
                type=MessageType.MESSAGE,
            ),
        ]
        result = extract_knowledge_v2(messages, use_llm=False)
        if len(result) > 0:
            types = [r["type"] for r in result]
            assert (
                "regulation_interpretation" in types
            ), f"약관 해석 논쟁에서 regulation_interpretation이 추출되어야 함. 실제: {types}"


# ---------------------------------------------------------------------------
# D. 스레드 분리 개선 테스트
# ---------------------------------------------------------------------------


class TestThreadSplitting:
    """스레드 분리 개선 테스트"""

    def test_15min_gap_creates_new_thread(self):
        """15분 초과 gap → 새 스레드로 분리"""
        messages = [
            ChatMessage(
                date="2025-12-03",
                time="17:00",
                user="A/인카/서울",
                content="#궁금증\n• 유형 : 보상\n질문1입니다",
                type=MessageType.MESSAGE,
            ),
            ChatMessage(
                date="2025-12-03",
                time="17:05",
                user="B/프라임/부산",
                content="답변1입니다",
                type=MessageType.MESSAGE,
            ),
            # 16분 gap → 새 스레드
            ChatMessage(
                date="2025-12-03",
                time="17:21",
                user="C/KB/대전",
                content="#궁금증\n• 유형 : 약관\n질문2입니다",
                type=MessageType.MESSAGE,
            ),
            ChatMessage(
                date="2025-12-03",
                time="17:25",
                user="B/프라임/부산",
                content="답변2입니다",
                type=MessageType.MESSAGE,
            ),
        ]
        result = extract_knowledge_v2(messages, use_llm=False)
        assert (
            len(result) == 2
        ), f"15분 gap으로 2개 스레드가 생성되어야 함. 실제: {len(result)}"

    def test_date_change_creates_new_thread(self):
        """날짜 변경 → 새 스레드로 분리"""
        messages = [
            ChatMessage(
                date="2025-12-03",
                time="23:50",
                user="A/인카/서울",
                content="#궁금증\n• 유형 : 보상\n질문1",
                type=MessageType.MESSAGE,
            ),
            ChatMessage(
                date="2025-12-03",
                time="23:55",
                user="B/프라임/부산",
                content="답변1",
                type=MessageType.MESSAGE,
            ),
            # 날짜 변경
            ChatMessage(
                date="2025-12-04",
                time="00:05",
                user="C/KB/대전",
                content="#궁금증\n• 유형 : 고지의무\n질문2",
                type=MessageType.MESSAGE,
            ),
            ChatMessage(
                date="2025-12-04",
                time="00:10",
                user="B/프라임/부산",
                content="답변2",
                type=MessageType.MESSAGE,
            ),
        ]
        result = extract_knowledge_v2(messages, use_llm=False)
        assert (
            len(result) == 2
        ), f"날짜 변경으로 2개 스레드가 생성되어야 함. 실제: {len(result)}"

    def test_two_question_tags_in_same_timeframe_creates_two_threads(self):
        """같은 시간대 #궁금증 2번 → 2개 스레드"""
        messages = [
            ChatMessage(
                date="2025-12-03",
                time="17:00",
                user="A/인카/서울",
                content="#궁금증\n• 유형 : 보상\n질문1",
                type=MessageType.MESSAGE,
            ),
            ChatMessage(
                date="2025-12-03",
                time="17:02",
                user="B/프라임/부산",
                content="답변1",
                type=MessageType.MESSAGE,
            ),
            ChatMessage(
                date="2025-12-03",
                time="17:03",
                user="C/KB/대전",
                content="#궁금증\n• 유형 : 고지의무\n질문2",
                type=MessageType.MESSAGE,
            ),
            ChatMessage(
                date="2025-12-03",
                time="17:05",
                user="B/프라임/부산",
                content="답변2",
                type=MessageType.MESSAGE,
            ),
        ]
        result = extract_knowledge_v2(messages, use_llm=False)
        assert (
            len(result) == 2
        ), f"#궁금증 태그가 2번 나오면 2개 결과가 생성되어야 함. 실제: {len(result)}"


# ---------------------------------------------------------------------------
# E. 배치 처리 테스트
# ---------------------------------------------------------------------------


class TestBatchProcessing:
    """배치 처리 테스트"""

    def _make_bulk_messages(self, count: int = 100) -> list[ChatMessage]:
        """대량 메시지 생성 헬퍼"""
        messages = []
        for i in range(count):
            hour = 10 + (i // 10)
            minute = (i * 3) % 60
            time_str = f"{hour:02d}:{minute:02d}"
            if i % 5 == 0:
                messages.append(
                    ChatMessage(
                        date="2025-12-03",
                        time=time_str,
                        user=f"질문자{i}/인카/서울",
                        content=f"#궁금증\n• 유형 : 보상\n질문{i}입니다",
                        type=MessageType.MESSAGE,
                    )
                )
            else:
                messages.append(
                    ChatMessage(
                        date="2025-12-03",
                        time=time_str,
                        user=f"답변자{i}/프라임/부산",
                        content=f"답변{i}입니다",
                        type=MessageType.MESSAGE,
                    )
                )
        return messages

    def test_bulk_messages_no_error(self):
        """대량 메시지(100개) 처리 시 에러 없이 완료되어야 한다"""
        messages = self._make_bulk_messages(100)
        try:
            result = extract_knowledge_v2(messages, use_llm=False, batch_size=50)
        except Exception as e:
            assert False, f"대량 메시지 처리 중 에러 발생: {e}"
        assert isinstance(result, list), "결과가 리스트여야 함"

    def test_bulk_messages_returns_list(self):
        """대량 메시지 처리 결과가 리스트여야 한다"""
        messages = self._make_bulk_messages(100)
        result = extract_knowledge_v2(messages, use_llm=False, batch_size=50)
        assert isinstance(result, list), f"결과 타입이 리스트가 아님: {type(result)}"

    def test_output_dir_creates_intermediate_files(self):
        """output_dir 지정 시 중간 파일이 생성되어야 한다"""
        messages = _make_qa_messages()
        with tempfile.TemporaryDirectory() as tmpdir:
            result = extract_knowledge_v2(messages, use_llm=False, output_dir=tmpdir)
            # 결과가 있을 경우에만 파일 생성을 확인 (구현에 따라 다를 수 있음)
            if len(result) > 0:
                # tmpdir 내에 파일이 생성되었는지 확인
                files = os.listdir(tmpdir)
                # 파일이 생성될 수도 있고 아닐 수도 있으므로, 에러 없이 완료됨을 확인
                assert True, "output_dir 지정 처리 완료"


# ---------------------------------------------------------------------------
# F. LLM 경로 테스트 (mock 사용)
# ---------------------------------------------------------------------------


class TestLlmPathWithMock:
    """LLM 경로 테스트 (mock 사용)"""

    def test_stage1_haiku_mock_has_insight_true(self):
        """Stage 1 Haiku mock → has_insight=True 판별"""
        stage1_json = '{"has_insight": true, "insight_types": ["qa", "expert_opinion"], "noise_reason": ""}'
        stage2_json = '{"title": "테스트", "type": "qa", "category": "보상/일반", "summary": "요약", "key_points": ["포인트1"], "expert": "전문가", "confidence": "high", "related_topics": ["보상"], "tags": ["#테스트"], "question": "질문", "answer": "답변"}'

        def _side_effect(
            cmd: list[str], **kwargs: object
        ) -> subprocess.CompletedProcess[str]:
            model_arg = cmd[4] if len(cmd) > 4 else "haiku"
            if model_arg == "haiku":
                stdout = stage1_json
            else:
                stdout = stage2_json
            return subprocess.CompletedProcess(
                args=cmd, returncode=0, stdout=stdout, stderr=""
            )

        with patch(
            "kakao_knowledge.knowledge_extractor_v2.subprocess.run",
            side_effect=_side_effect,
        ):
            result = extract_knowledge_v2(
                _make_qa_messages(),
                use_llm=True,
            )
        assert isinstance(result, list), "LLM mock 경로에서 결과가 리스트여야 함"

    def test_stage2_sonnet_mock_extracts_insight(self):
        """Stage 2 Sonnet mock → InsightV2 추출"""
        stage1_json = (
            '{"has_insight": true, "insight_types": ["qa"], "noise_reason": ""}'
        )
        stage2_json = (
            '{"title": "광응고술 수술 해당 여부", '
            '"type": "qa", "category": "보상/일반", '
            '"summary": "광응고술은 수술약관에 해당합니다.", '
            '"key_points": ["약관 개정", "레이저 수술 포함"], '
            '"expert": "이해철/프라임/부산", "confidence": "high", '
            '"related_topics": ["보상", "수술"], "tags": ["#광응고술"], '
            '"question": "광응고술이 수술에 해당하나요?", '
            '"answer": "네 수술에 해당합니다."}'
        )

        def _side_effect(
            cmd: list[str], **kwargs: object
        ) -> subprocess.CompletedProcess[str]:
            model_arg = cmd[4] if len(cmd) > 4 else "haiku"
            if model_arg == "haiku":
                stdout = stage1_json
            else:
                stdout = stage2_json
            return subprocess.CompletedProcess(
                args=cmd, returncode=0, stdout=stdout, stderr=""
            )

        with patch(
            "kakao_knowledge.knowledge_extractor_v2.subprocess.run",
            side_effect=_side_effect,
        ):
            result = extract_knowledge_v2(
                _make_qa_messages(),
                use_llm=True,
            )
        assert isinstance(result, list), "Stage 2 mock 경로에서 결과가 리스트여야 함"

    def test_api_failure_falls_back_to_rule_based(self):
        """CLI 실패 시 규칙 기반 fallback이 동작해야 한다"""

        def _side_effect(
            cmd: list[str], **kwargs: object
        ) -> subprocess.CompletedProcess[str]:
            return subprocess.CompletedProcess(
                args=cmd,
                returncode=1,
                stdout="",
                stderr="Claude CLI 오류: connection refused",
            )

        with patch(
            "kakao_knowledge.knowledge_extractor_v2.subprocess.run",
            side_effect=_side_effect,
        ):
            result = extract_knowledge_v2(
                _make_qa_messages(),
                use_llm=True,
            )
            assert isinstance(result, list), "CLI 실패 시 fallback 결과가 리스트여야 함"


# ---------------------------------------------------------------------------
# G. 반환값 구조 검증 (추가)
# ---------------------------------------------------------------------------


class TestReturnValueStructure:
    """반환값 구조 검증"""

    def test_result_entries_are_dicts(self):
        """결과의 각 항목이 dict 타입이어야 한다"""
        result = extract_knowledge_v2(_make_qa_messages(), use_llm=False)
        for entry in result:
            assert isinstance(entry, dict), f"결과 항목이 dict가 아님: {type(entry)}"

    def test_key_points_is_list_in_result(self):
        """결과의 key_points 값이 리스트여야 한다"""
        result = extract_knowledge_v2(_make_qa_messages(), use_llm=False)
        for entry in result:
            assert isinstance(
                entry.get("key_points"), list
            ), f"key_points가 리스트가 아님: {type(entry.get('key_points'))}"

    def test_raw_thread_is_list_in_result(self):
        """결과의 raw_thread 값이 리스트여야 한다"""
        result = extract_knowledge_v2(_make_qa_messages(), use_llm=False)
        for entry in result:
            assert isinstance(
                entry.get("raw_thread"), list
            ), f"raw_thread가 리스트가 아님: {type(entry.get('raw_thread'))}"

    def test_participants_is_list_in_result(self):
        """결과의 participants 값이 리스트여야 한다"""
        result = extract_knowledge_v2(_make_qa_messages(), use_llm=False)
        for entry in result:
            assert isinstance(
                entry.get("participants"), list
            ), f"participants가 리스트가 아님: {type(entry.get('participants'))}"

    def test_source_chat_propagated(self):
        """source_chat 파라미터가 결과에 반영되어야 한다"""
        result = extract_knowledge_v2(
            _make_qa_messages(),
            use_llm=False,
            source_chat="보험설계사_테스트_채팅방",
        )
        for entry in result:
            assert (
                entry.get("source_chat") == "보험설계사_테스트_채팅방"
            ), f"source_chat이 결과에 반영되지 않음: {entry.get('source_chat')}"

    def test_consecutive_ids_are_sequential(self):
        """여러 인사이트의 id가 순차적으로 증가해야 한다"""
        messages = [
            ChatMessage(
                date="2025-12-03",
                time="17:00",
                user="A/인카/서울",
                content="#궁금증\n• 유형 : 보상\n질문1",
                type=MessageType.MESSAGE,
            ),
            ChatMessage(
                date="2025-12-03",
                time="17:02",
                user="B/프라임/부산",
                content="답변1",
                type=MessageType.MESSAGE,
            ),
            ChatMessage(
                date="2025-12-03",
                time="17:03",
                user="C/KB/대전",
                content="#궁금증\n• 유형 : 약관\n질문2",
                type=MessageType.MESSAGE,
            ),
            ChatMessage(
                date="2025-12-03",
                time="17:05",
                user="B/프라임/부산",
                content="답변2",
                type=MessageType.MESSAGE,
            ),
        ]
        result = extract_knowledge_v2(messages, use_llm=False)
        if len(result) >= 2:
            first_num = int(result[0]["id"].split("-")[1])
            second_num = int(result[1]["id"].split("-")[1])
            assert (
                second_num == first_num + 1
            ), f"id 순번이 순차적이지 않음: {result[0]['id']} → {result[1]['id']}"

    def test_expert_opinion_messages_return_results(self):
        """전문가 의견 메시지에서도 결과가 반환되어야 한다"""
        result = extract_knowledge_v2(_make_expert_opinion_messages(), use_llm=False)
        # 전문가 의견 패턴 ("주의하세요", 경험 공유 등)이 감지되어야 함
        assert isinstance(result, list), "결과가 리스트여야 함"


# ---------------------------------------------------------------------------
# H. _llm_refine_thread_splits 병합+분리 양방향 테스트
# ---------------------------------------------------------------------------


def _make_thread_with_n_messages(n: int, start_minute: int = 0):
    """N개 메시지가 있는 ThreadV2를 생성한다."""
    from kakao_knowledge.models import ChatMessage, MessageType
    from kakao_knowledge.models_v2 import ThreadV2

    msgs = []
    for i in range(n):
        msgs.append(
            ChatMessage(
                date="2025-12-03",
                time=f"17:{(start_minute + i) % 60:02d}",
                user=f"유저{i}/인카/서울",
                content=f"보험 관련 메시지 {i}입니다. 보상 청구에 대해 질문이 있습니다.",
                type=MessageType.MESSAGE,
            )
        )
    t = ThreadV2()
    t.messages = msgs
    t.start_time = f"2025-12-03 17:{start_minute:02d}"
    return t


class TestLlmRefineThreadSplits:
    """_llm_refine_thread_splits 병합+분리 양방향 지원 테스트 (개발1팀 아르고스)"""

    def test_split_at_divides_thread_into_sub_threads(self):
        """split_at이 [5, 8]이면 12개 메시지를 3개 스레드로 분리한다"""
        thread = _make_thread_with_n_messages(12)
        llm_response = {
            "threads": [
                {"merge_with_prev": False, "split_at": [5, 8]},
            ]
        }

        with patch(
            "kakao_knowledge.knowledge_extractor_v2._call_claude",
            return_value=json.dumps(llm_response),
        ):
            result = _llm_refine_thread_splits([thread])

        assert (
            len(result) == 3
        ), f"split_at=[5,8]로 3개 스레드가 생성되어야 함. 실제: {len(result)}"
        assert (
            len(result[0].messages) == 5
        ), f"첫 번째 스레드 메시지 수 오류: {len(result[0].messages)}"
        assert (
            len(result[1].messages) == 3
        ), f"두 번째 스레드 메시지 수 오류: {len(result[1].messages)}"
        assert (
            len(result[2].messages) == 4
        ), f"세 번째 스레드 메시지 수 오류: {len(result[2].messages)}"

    def test_merge_with_prev_still_works(self):
        """merge_with_prev=true 이면 두 스레드가 1개로 합쳐진다"""
        thread1 = _make_thread_with_n_messages(3, start_minute=0)
        thread2 = _make_thread_with_n_messages(3, start_minute=3)
        llm_response = {
            "threads": [
                {"merge_with_prev": False, "split_at": []},
                {"merge_with_prev": True, "split_at": []},
            ]
        }

        with patch(
            "kakao_knowledge.knowledge_extractor_v2._call_claude",
            return_value=json.dumps(llm_response),
        ):
            result = _llm_refine_thread_splits([thread1, thread2])

        assert (
            len(result) == 1
        ), f"merge_with_prev=true로 1개 스레드가 되어야 함. 실제: {len(result)}"
        assert (
            len(result[0].messages) == 6
        ), f"병합 후 메시지 수가 6개여야 함. 실제: {len(result[0].messages)}"

    def test_merge_and_split_combined(self):
        """병합 없이 둘째 스레드를 split_at=[4,7]로 3분할 → 총 4개 스레드"""
        thread1 = _make_thread_with_n_messages(3, start_minute=0)
        thread2 = _make_thread_with_n_messages(10, start_minute=3)
        llm_response = {
            "threads": [
                {"merge_with_prev": False, "split_at": []},
                {"merge_with_prev": False, "split_at": [4, 7]},
            ]
        }

        with patch(
            "kakao_knowledge.knowledge_extractor_v2._call_claude",
            return_value=json.dumps(llm_response),
        ):
            result = _llm_refine_thread_splits([thread1, thread2])

        assert (
            len(result) == 4
        ), f"첫째 스레드 그대로 + 둘째 3분할 = 4개 스레드여야 함. 실제: {len(result)}"
        assert (
            len(result[0].messages) == 3
        ), f"첫째 스레드 메시지 수 오류: {len(result[0].messages)}"
        assert (
            len(result[1].messages) == 4
        ), f"분리 첫 번째 메시지 수 오류: {len(result[1].messages)}"
        assert (
            len(result[2].messages) == 3
        ), f"분리 두 번째 메시지 수 오류: {len(result[2].messages)}"
        assert (
            len(result[3].messages) == 3
        ), f"분리 세 번째 메시지 수 오류: {len(result[3].messages)}"

    def test_empty_split_at_no_change(self):
        """split_at=[]이면 스레드가 변경 없이 그대로 유지된다"""
        thread = _make_thread_with_n_messages(5)
        llm_response = {
            "threads": [
                {"merge_with_prev": False, "split_at": []},
            ]
        }

        with patch(
            "kakao_knowledge.knowledge_extractor_v2._call_claude",
            return_value=json.dumps(llm_response),
        ):
            result = _llm_refine_thread_splits([thread])

        assert (
            len(result) == 1
        ), f"split_at=[]이면 1개 스레드 그대로여야 함. 실제: {len(result)}"
        assert (
            len(result[0].messages) == 5
        ), f"메시지 수가 변경되면 안 됨. 실제: {len(result[0].messages)}"

    def test_llm_failure_returns_original_threads(self):
        """LLM 호출 실패 시 원래 스레드 목록을 그대로 반환한다"""
        thread1 = _make_thread_with_n_messages(3, start_minute=0)
        thread2 = _make_thread_with_n_messages(4, start_minute=3)

        with patch(
            "kakao_knowledge.knowledge_extractor_v2._call_claude",
            side_effect=Exception("LLM 연결 실패"),
        ):
            result = _llm_refine_thread_splits([thread1, thread2])

        assert (
            len(result) == 2
        ), f"LLM 실패 시 원래 2개 스레드가 반환되어야 함. 실제: {len(result)}"

    def test_noise_threads_filtered_after_split(self):
        """분리 후 메시지 3개 미만 + 보험 키워드 없는 스레드는 제거된다"""
        from kakao_knowledge.models_v2 import ThreadV2

        # 8개 메시지: 0~2는 보험 관련, 3~4는 인사말, 5~7은 보험 관련
        msgs = []
        for i in range(3):
            msgs.append(
                ChatMessage(
                    date="2025-12-03",
                    time=f"17:{i:02d}",
                    user=f"유저{i}/인카/서울",
                    content=f"보험 관련 메시지 {i}입니다. 보상 청구에 대해 질문이 있습니다.",
                    type=MessageType.MESSAGE,
                )
            )
        # 인사말만 있는 노이즈 구간 (메시지 3~4)
        for i, content in enumerate(["안녕하세요", "감사합니다"], start=3):
            msgs.append(
                ChatMessage(
                    date="2025-12-03",
                    time=f"17:{i:02d}",
                    user=f"유저{i}/인카/서울",
                    content=content,
                    type=MessageType.MESSAGE,
                )
            )
        for i in range(5, 8):
            msgs.append(
                ChatMessage(
                    date="2025-12-03",
                    time=f"17:{i:02d}",
                    user=f"유저{i}/인카/서울",
                    content=f"보험 관련 메시지 {i}입니다. 보상 청구에 대해 질문이 있습니다.",
                    type=MessageType.MESSAGE,
                )
            )
        thread = ThreadV2()
        thread.messages = msgs
        thread.start_time = "2025-12-03 17:00"

        # split_at=[3, 5] → 메시지 0~2(3개), 3~4(2개 노이즈), 5~7(3개)
        llm_response = {
            "threads": [
                {"merge_with_prev": False, "split_at": [3, 5]},
            ]
        }

        with patch(
            "kakao_knowledge.knowledge_extractor_v2._call_claude",
            return_value=json.dumps(llm_response),
        ):
            result = _llm_refine_thread_splits([thread])

        # 노이즈 스레드(메시지 2개 + 보험 키워드 없음)가 제거되어 3개 미만이어야 함
        assert (
            len(result) < 3
        ), f"노이즈 스레드 필터링 후 결과가 3개 미만이어야 함. 실제: {len(result)}"


# ---------------------------------------------------------------------------
# I. 월 필터링 테스트 (month parameter)
# ---------------------------------------------------------------------------


class TestMonthFiltering:
    """--month 파라미터로 특정 월의 메시지만 필터링하는 테스트"""

    def _make_multi_month_messages(self):
        """여러 달에 걸친 메시지 생성"""
        return [
            ChatMessage(
                date="2026-02-28",
                time="17:00",
                user="A/인카/서울",
                content="#궁금증\n• 유형 : 보상\n2월 질문입니다",
                type=MessageType.MESSAGE,
            ),
            ChatMessage(
                date="2026-02-28",
                time="17:05",
                user="B/프라임/부산",
                content="2월 답변입니다 보상 관련",
                type=MessageType.MESSAGE,
            ),
            ChatMessage(
                date="2026-03-01",
                time="10:00",
                user="C/KB/대전",
                content="#궁금증\n• 유형 : 고지의무\n3월 질문입니다",
                type=MessageType.MESSAGE,
            ),
            ChatMessage(
                date="2026-03-01",
                time="10:05",
                user="B/프라임/부산",
                content="3월 답변입니다 고지의무 관련",
                type=MessageType.MESSAGE,
            ),
            ChatMessage(
                date="2026-03-15",
                time="14:00",
                user="D/인카/서울",
                content="#궁금증\n• 유형 : 약관\n3월 중순 질문",
                type=MessageType.MESSAGE,
            ),
            ChatMessage(
                date="2026-03-15",
                time="14:05",
                user="B/프라임/부산",
                content="3월 중순 답변 약관 관련",
                type=MessageType.MESSAGE,
            ),
            ChatMessage(
                date="2026-04-01",
                time="09:00",
                user="E/KB/대전",
                content="#궁금증\n• 유형 : 상품\n4월 질문입니다",
                type=MessageType.MESSAGE,
            ),
            ChatMessage(
                date="2026-04-01",
                time="09:05",
                user="B/프라임/부산",
                content="4월 답변입니다 상품 관련",
                type=MessageType.MESSAGE,
            ),
        ]

    def test_month_filter_returns_only_matching_month(self):
        """month='2026-03' → 3월 메시지만 처리"""
        messages = self._make_multi_month_messages()
        result = extract_knowledge_v2(messages, use_llm=False, month="2026-03")
        # 3월 메시지만 필터링되므로 결과의 source_date가 모두 2026-03으로 시작해야 함
        assert len(result) >= 1, "3월 메시지에서 인사이트가 추출되어야 함"
        for entry in result:
            assert entry["source_date"].startswith(
                "2026-03"
            ), f"3월 이외 날짜가 포함됨: {entry['source_date']}"

    def test_no_month_filter_returns_all(self):
        """month='' (기본값) → 전체 메시지 처리 (하위 호환)"""
        messages = self._make_multi_month_messages()
        result_all = extract_knowledge_v2(messages, use_llm=False, month="")
        result_default = extract_knowledge_v2(messages, use_llm=False)
        assert len(result_all) == len(
            result_default
        ), f"빈 month와 기본값이 같은 결과여야 함: {len(result_all)} vs {len(result_default)}"

    def test_month_filter_no_matching_messages(self):
        """매칭되는 월이 없으면 빈 리스트 반환"""
        messages = self._make_multi_month_messages()
        result = extract_knowledge_v2(messages, use_llm=False, month="2025-01")
        assert len(result) == 0, f"매칭 메시지가 없으면 빈 리스트여야 함: {len(result)}"

    def test_month_filter_preserves_insight_structure(self):
        """month 필터링 후에도 InsightV2 구조가 유지되어야 함"""
        messages = self._make_multi_month_messages()
        result = extract_knowledge_v2(messages, use_llm=False, month="2026-03")
        assert len(result) >= 1, "결과가 비어있어 구조 검증 불가"
        for entry in result:
            missing = REQUIRED_V2_KEYS - set(entry.keys())
            assert not missing, f"필수 키 누락: {missing}"

    def test_month_h1_filter(self):
        """month='2026-03-H1' → 3월 1~15일 메시지만 처리"""
        messages = self._make_multi_month_messages()
        result = extract_knowledge_v2(messages, use_llm=False, month="2026-03-H1")
        assert len(result) >= 1, "3월 상반기 메시지에서 인사이트가 추출되어야 함"
        for entry in result:
            assert entry["source_date"].startswith("2026-03"), f"3월 이외 날짜: {entry['source_date']}"
            day = int(entry["source_date"][8:10])
            assert day <= 15, f"상반기(1~15일) 이외 날짜 포함: {entry['source_date']}"

    def test_month_h2_filter(self):
        """month='2026-03-H2' → 3월 16~31일 메시지만 처리"""
        messages = self._make_multi_month_messages()
        result = extract_knowledge_v2(messages, use_llm=False, month="2026-03-H2")
        # 기존 테스트 데이터에 3월 16일 이후 메시지가 없으므로 빈 리스트
        assert len(result) == 0, f"3월 하반기 메시지가 없으므로 빈 결과여야 함: {len(result)}"

    def test_month_full_backward_compatible(self):
        """month='2026-03' (기존 형식) → 여전히 3월 전체 메시지 처리"""
        messages = self._make_multi_month_messages()
        result = extract_knowledge_v2(messages, use_llm=False, month="2026-03")
        assert len(result) >= 1, "기존 형식은 여전히 동작해야 함"
        for entry in result:
            assert entry["source_date"].startswith("2026-03"), f"3월 이외 날짜: {entry['source_date']}"


# ---------------------------------------------------------------------------
# task-1902: LLM 호출 전 로그/progress 갱신 테스트
# ---------------------------------------------------------------------------


class TestLLMPreCallLogging:
    """LLM 호출 직전에 _add_log + _write_progress가 호출되는지 검증."""

    def test_llm_pre_call_log_message(self):
        """배치 LLM 호출 전 '분석 요청 중' 로그가 기록되는지 확인."""
        import kakao_knowledge.knowledge_extractor_v2 as mod

        # _recent_logs를 초기화하고 extract를 LLM 모드로 실행 (mock)
        mod._recent_logs.clear()

        messages = [
            ChatMessage(
                date="2025-12-03",
                time="10:00",
                user="홍길동",
                content="#궁금증 실손보험 청구 방법은?",
                type=MessageType.MESSAGE,
            ),
            ChatMessage(
                date="2025-12-03",
                time="10:01",
                user="이영희",
                content="실손보험은 병원 영수증과 진단서를 보험사에 제출하면 됩니다.",
                type=MessageType.MESSAGE,
            ),
        ]

        with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
            progress_file = f.name

        try:
            with patch.object(mod, "_call_claude") as mock_claude:
                # Stage 1 필터: has_insight=true 반환
                # Stage 2 추출: 유효한 인사이트 반환
                mock_claude.side_effect = [
                    # _llm_refine_thread_splits 호출용
                    json.dumps({"threads": [{"merge_with_prev": False, "split_at": []}]}),
                    # Stage 1 (haiku)
                    json.dumps({"has_insight": True, "insight_types": ["qa"], "noise_reason": ""}),
                    # Stage 2 (sonnet)
                    json.dumps({
                        "title": "실손보험 청구",
                        "type": "qa",
                        "category": "보험청구",
                        "summary": "실손보험 청구 절차",
                        "key_points": ["영수증 제출"],
                        "confidence": 0.9,
                        "related_topics": [],
                        "tags": ["실손"],
                        "question": "실손보험 청구?",
                        "answer": "영수증과 진단서 제출",
                    }),
                ]

                extract_knowledge_v2(
                    messages,
                    use_llm=True,
                    progress_file=progress_file,
                )

            # _recent_logs에 "LLM 분석 요청 중" 포함 확인
            logs_text = " ".join(mod._recent_logs)
            assert "LLM 분석 요청 중" in logs_text, (
                f"'LLM 분석 요청 중' 로그가 없음. 로그: {mod._recent_logs}"
            )

            # progress 파일에 "LLM 분석 중" currentStep 기록 확인
            with open(progress_file) as f:
                progress_data = json.load(f)
            # 최종 상태가 완료이므로, 중간에 기록되었는지 직접 검증은 어렵지만
            # 최소한 progress 파일이 유효한 JSON인지 확인
            assert "status" in progress_data

        finally:
            os.unlink(progress_file)


# ---------------------------------------------------------------------------
# J. 체크포인트 정리 테스트 (task-1904)
# ---------------------------------------------------------------------------


class TestCheckpointCleanup:
    """체크포인트 정리 기능 테스트 (아르고스)"""

    def test_cleanup_removes_checkpoint_files(self, tmp_path):
        """output_dir에 체크포인트 파일이 있을 때 _cleanup_checkpoints 호출 시 두 파일이 삭제된다"""
        cp1 = tmp_path / "checkpoint_threads.json"
        cp2 = tmp_path / "checkpoint_refined_threads.json"
        cp1.write_text("{}", encoding="utf-8")
        cp2.write_text("{}", encoding="utf-8")

        assert cp1.exists(), "사전 조건: checkpoint_threads.json이 존재해야 함"
        assert cp2.exists(), "사전 조건: checkpoint_refined_threads.json이 존재해야 함"

        _cleanup_checkpoints(str(tmp_path))

        assert not cp1.exists(), "checkpoint_threads.json이 삭제되어야 함"
        assert not cp2.exists(), "checkpoint_refined_threads.json이 삭제되어야 함"

    def test_cleanup_nonexistent_files_no_error(self, tmp_path):
        """체크포인트 파일이 없는 빈 디렉토리에서 _cleanup_checkpoints 호출 시 에러 없이 완료된다"""
        try:
            _cleanup_checkpoints(str(tmp_path))
        except Exception as exc:
            assert False, f"체크포인트 파일이 없어도 에러 없이 완료되어야 함: {exc}"

    def test_cleanup_partial_files(self, tmp_path):
        """checkpoint_threads.json만 존재할 때 존재하는 파일만 삭제되고 에러가 발생하지 않는다"""
        cp1 = tmp_path / "checkpoint_threads.json"
        cp2 = tmp_path / "checkpoint_refined_threads.json"
        cp1.write_text("{}", encoding="utf-8")

        assert cp1.exists(), "사전 조건: checkpoint_threads.json이 존재해야 함"
        assert not cp2.exists(), "사전 조건: checkpoint_refined_threads.json은 없어야 함"

        try:
            _cleanup_checkpoints(str(tmp_path))
        except Exception as exc:
            assert False, f"부분 파일만 있어도 에러 없이 완료되어야 함: {exc}"

        assert not cp1.exists(), "존재했던 checkpoint_threads.json은 삭제되어야 함"

    def test_rule_based_extract_cleans_checkpoints(self, tmp_path):
        """extract_knowledge_v2(use_llm=False, output_dir=...) 완료 후 체크포인트 파일이 남아있지 않아야 한다"""
        messages = _make_qa_messages()
        extract_knowledge_v2(messages, use_llm=False, output_dir=str(tmp_path))

        cp1 = tmp_path / "checkpoint_threads.json"
        cp2 = tmp_path / "checkpoint_refined_threads.json"

        assert not cp1.exists(), (
            f"extract_knowledge_v2 완료 후 checkpoint_threads.json이 남아있으면 안 됨"
        )
        assert not cp2.exists(), (
            f"extract_knowledge_v2 완료 후 checkpoint_refined_threads.json이 남아있으면 안 됨"
        )
