import asyncio
import json
import os
import sys
from datetime import datetime
from pathlib import Path
from unittest.mock import AsyncMock, Mock, patch

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

import pytest
from conversation_memory import ChatMessage, ConversationMemory


class TestAddAndGetMessages:
    """test_add_and_get_messages - 메시지 추가 후 get_context로 조회 가능"""

    def test_add_and_get_messages(self):
        mem = ConversationMemory()
        chat_id = 1

        mem.add_message(chat_id, "제이회장님", "안녕하세요", is_bot=False)
        mem.add_message(chat_id, "잼민이", "안녕하세요!", is_bot=True)

        messages = mem.get_context(chat_id)
        assert len(messages) == 2
        assert messages[0].sender == "제이회장님"
        assert messages[0].text == "안녕하세요"
        assert messages[0].is_bot is False
        assert messages[1].sender == "잼민이"
        assert messages[1].text == "안녕하세요!"
        assert messages[1].is_bot is True


class TestRingBufferEviction:
    """test_ring_buffer_eviction - max_messages(20) 초과 시 오래된 메시지 자동 제거"""

    def test_ring_buffer_eviction(self):
        mem = ConversationMemory(max_messages=20)
        chat_id = 2

        for i in range(25):
            mem.add_message(chat_id, "제이회장님", f"메시지 {i}", is_bot=False)

        messages = mem.get_context(chat_id, limit=20)
        assert len(messages) == 20
        # 가장 오래된 메시지(0~4)는 제거되고 5~24만 남아야 함
        assert messages[0].text == "메시지 5"
        assert messages[-1].text == "메시지 24"


class TestMultiChatIsolation:
    """test_multi_chat_isolation - 서로 다른 chat_id의 메시지가 격리됨"""

    def test_multi_chat_isolation(self):
        mem = ConversationMemory()
        chat_a = 100
        chat_b = 200

        mem.add_message(chat_a, "제이회장님", "채팅A 메시지", is_bot=False)
        mem.add_message(chat_b, "잼민이", "채팅B 메시지", is_bot=True)

        msgs_a = mem.get_context(chat_a)
        msgs_b = mem.get_context(chat_b)

        assert len(msgs_a) == 1
        assert msgs_a[0].text == "채팅A 메시지"

        assert len(msgs_b) == 1
        assert msgs_b[0].text == "채팅B 메시지"

        # 서로 다른 채팅의 메시지가 섞이지 않음
        assert msgs_a[0].text != msgs_b[0].text


class TestGetContextWithLimit:
    """test_get_context_with_limit - limit 파라미터로 최근 N개만 조회"""

    def test_get_context_with_limit(self):
        mem = ConversationMemory()
        chat_id = 3

        for i in range(10):
            mem.add_message(chat_id, "제이회장님", f"메시지 {i}", is_bot=False)

        recent_5 = mem.get_context(chat_id, limit=5)
        assert len(recent_5) == 5
        assert recent_5[0].text == "메시지 5"
        assert recent_5[-1].text == "메시지 9"

    def test_get_context_limit_larger_than_stored(self):
        mem = ConversationMemory()
        chat_id = 4

        mem.add_message(chat_id, "제이회장님", "하나", is_bot=False)
        mem.add_message(chat_id, "잼민이", "둘", is_bot=True)

        messages = mem.get_context(chat_id, limit=100)
        assert len(messages) == 2


class TestFormatContextBasic:
    """test_format_context_basic - format_context가 올바른 문자열 생성"""

    def test_format_context_basic(self):
        mem = ConversationMemory()
        chat_id = 5

        mem.add_message(chat_id, "잼민이", "데이터 기반 접근이 중요합니다", is_bot=True)
        mem.add_message(chat_id, "제이회장님", "그래서 결론이 뭐야?", is_bot=False)

        result = mem.format_context(chat_id, "gemini_view_bot", "현재 질문입니다")

        assert "[이전 대화]" in result
        assert "잼민이: 데이터 기반 접근이 중요합니다" in result
        assert "제이회장님: 그래서 결론이 뭐야?" in result
        assert "[현재 질문/발언]" in result
        assert "제이회장님: 현재 질문입니다" in result


class TestFormatContextWithPersona:
    """test_format_context_with_persona - format_context에 봇 이름과 페르소나 프롬프트 포함"""

    def test_format_context_gemini_persona(self):
        mem = ConversationMemory()
        chat_id = 6

        result = mem.format_context(chat_id, "gemini_view_bot", "테스트 질문")

        assert "너는 잼민이다" in result
        assert "이전 발언들을 참고하되 중복되지 않는 새로운 관점을 제시해라" in result

    def test_format_context_codex_persona(self):
        mem = ConversationMemory()
        chat_id = 7

        result = mem.format_context(chat_id, "codex_view_bot", "코드 질문")

        assert "너는 코덱스다" in result
        assert "이전 발언들을 참고하되 중복되지 않는 새로운 관점을 제시해라" in result

    def test_format_context_claude_persona(self):
        mem = ConversationMemory()
        chat_id = 8

        result = mem.format_context(chat_id, "claude_view_bot", "균형 잡힌 분석")

        assert "너는 클로디다" in result
        assert "이전 발언들을 참고하되 중복되지 않는 새로운 관점을 제시해라" in result

    def test_format_context_unknown_bot(self):
        """알 수 없는 봇 이름은 bot_username을 그대로 사용"""
        mem = ConversationMemory()
        chat_id = 9

        result = mem.format_context(chat_id, "unknown_bot", "질문")

        assert "이전 발언들을 참고하되 중복되지 않는 새로운 관점을 제시해라" in result


class TestEmptyContext:
    """test_empty_context - 빈 채팅의 format_context는 빈 문자열이 아닌 최소 프롬프트"""

    def test_empty_context_not_empty_string(self):
        mem = ConversationMemory()
        chat_id = 10

        result = mem.format_context(chat_id, "gemini_view_bot")

        assert result != ""
        assert len(result) > 0

    def test_empty_context_contains_persona(self):
        mem = ConversationMemory()
        chat_id = 11

        result = mem.format_context(chat_id, "gemini_view_bot")

        assert "이전 발언들을 참고하되 중복되지 않는 새로운 관점을 제시해라" in result

    def test_empty_context_no_previous_conversation_header(self):
        """메시지가 없으면 [이전 대화] 섹션이 없어야 함"""
        mem = ConversationMemory()
        chat_id = 12

        result = mem.format_context(chat_id, "gemini_view_bot")

        assert "[이전 대화]" not in result


class TestMessageOrdering:
    """test_message_ordering - 메시지가 시간 순서대로 반환됨"""

    def test_message_ordering(self):
        mem = ConversationMemory()
        chat_id = 13

        mem.add_message(chat_id, "제이회장님", "첫 번째", is_bot=False)
        mem.add_message(chat_id, "잼민이", "두 번째", is_bot=True)
        mem.add_message(chat_id, "코덱스", "세 번째", is_bot=True)

        messages = mem.get_context(chat_id)

        assert messages[0].text == "첫 번째"
        assert messages[1].text == "두 번째"
        assert messages[2].text == "세 번째"

    def test_message_timestamps_ordered(self):
        mem = ConversationMemory()
        chat_id = 14

        mem.add_message(chat_id, "제이회장님", "A", is_bot=False)
        mem.add_message(chat_id, "잼민이", "B", is_bot=True)
        mem.add_message(chat_id, "코덱스", "C", is_bot=True)

        messages = mem.get_context(chat_id)

        for i in range(len(messages) - 1):
            assert messages[i].timestamp <= messages[i + 1].timestamp


class TestDefaultMaxMessages:
    """test_default_max_messages - 기본값이 50임을 확인"""

    def test_default_max_messages(self):
        mem = ConversationMemory()
        assert mem._max_messages == 50

    def test_default_max_messages_enforced(self):
        mem = ConversationMemory()
        chat_id = 15

        for i in range(55):
            mem.add_message(chat_id, "제이회장님", f"메시지 {i}", is_bot=False)

        all_messages = mem.get_context(chat_id, limit=100)
        assert len(all_messages) == 50


class TestCustomMaxMessages:
    """test_custom_max_messages - 커스텀 max_messages 동작 확인"""

    def test_custom_max_messages_5(self):
        mem = ConversationMemory(max_messages=5)
        chat_id = 16

        for i in range(10):
            mem.add_message(chat_id, "제이회장님", f"메시지 {i}", is_bot=False)

        all_messages = mem.get_context(chat_id, limit=100)
        assert len(all_messages) == 5
        assert all_messages[0].text == "메시지 5"
        assert all_messages[-1].text == "메시지 9"

    def test_custom_max_messages_3(self):
        mem = ConversationMemory(max_messages=3)
        chat_id = 17

        mem.add_message(chat_id, "제이회장님", "하나", is_bot=False)
        mem.add_message(chat_id, "잼민이", "둘", is_bot=True)
        mem.add_message(chat_id, "코덱스", "셋", is_bot=True)
        mem.add_message(chat_id, "제이회장님", "넷", is_bot=False)

        all_messages = mem.get_context(chat_id, limit=100)
        assert len(all_messages) == 3
        assert all_messages[0].text == "둘"
        assert all_messages[-1].text == "넷"

    def test_custom_max_messages_stored_correctly(self):
        mem = ConversationMemory(max_messages=7)
        assert mem._max_messages == 7


# ---------------------------------------------------------------------------
# 새 테스트 클래스 (TDD - RED 단계)
# ---------------------------------------------------------------------------


class TestPersistence:
    """JSONL 파일 저장/로드 테스트"""

    def test_add_message_creates_jsonl_file(self, tmp_path):
        """add_message 호출 시 날짜별 JSONL 파일이 생성되어야 한다."""
        mem = ConversationMemory(storage_base=str(tmp_path))
        chat_id = 1001

        mem.add_message(chat_id, "제이회장님", "테스트 메시지", is_bot=False)

        today = datetime.now().strftime("%Y-%m-%d")
        jsonl_path = tmp_path / f"{today}.jsonl"
        assert jsonl_path.exists(), f"JSONL 파일이 생성되어야 한다: {jsonl_path}"

    def test_add_message_appends_jsonl_line(self, tmp_path):
        """add_message 호출 시 JSONL 파일에 메시지가 추가되어야 한다."""
        mem = ConversationMemory(storage_base=str(tmp_path))
        chat_id = 1002

        mem.add_message(chat_id, "잼민이", "안녕하세요", is_bot=True)
        mem.add_message(chat_id, "제이회장님", "반갑습니다", is_bot=False)

        today = datetime.now().strftime("%Y-%m-%d")
        jsonl_path = tmp_path / f"{today}.jsonl"
        lines = jsonl_path.read_text(encoding="utf-8").strip().splitlines()
        assert len(lines) == 2

    def test_jsonl_line_format(self, tmp_path):
        """JSONL 각 라인이 올바른 JSON 형식이어야 한다."""
        mem = ConversationMemory(storage_base=str(tmp_path))
        chat_id = 1003

        mem.add_message(chat_id, "잼민이", "데이터 분석 중", is_bot=True)

        today = datetime.now().strftime("%Y-%m-%d")
        jsonl_path = tmp_path / f"{today}.jsonl"
        line = jsonl_path.read_text(encoding="utf-8").strip()
        data = json.loads(line)

        assert data["sender"] == "잼민이"
        assert data["text"] == "데이터 분석 중"
        assert data["is_bot"] is True
        assert data["chat_id"] == chat_id
        assert "timestamp" in data

    def test_storage_base_none_no_file_created(self, tmp_path):
        """storage_base=None이면 파일을 생성하지 않는다 (인메모리만 동작)."""
        mem = ConversationMemory(storage_base=None)
        chat_id = 1004

        mem.add_message(chat_id, "제이회장님", "파일 없음", is_bot=False)

        # 파일이 생성되지 않아야 함 (디렉토리가 존재하지 않거나 비어있음)
        today = datetime.now().strftime("%Y-%m-%d")
        default_path = Path("/home/jay/workspace/memory/groupchat") / f"{today}.jsonl"
        # 기존 기본 경로에 파일이 없어야 한다 (테스트 환경)
        # 인메모리 동작은 여전히 작동해야 함
        messages = mem.get_context(chat_id)
        assert len(messages) == 1
        assert messages[0].text == "파일 없음"

    def test_multiple_chat_ids_in_same_file(self, tmp_path):
        """여러 chat_id의 메시지가 같은 날짜 파일에 저장된다."""
        mem = ConversationMemory(storage_base=str(tmp_path))
        chat_a = 2001
        chat_b = 2002

        mem.add_message(chat_a, "제이회장님", "채팅A", is_bot=False)
        mem.add_message(chat_b, "잼민이", "채팅B", is_bot=True)

        today = datetime.now().strftime("%Y-%m-%d")
        jsonl_path = tmp_path / f"{today}.jsonl"
        lines = jsonl_path.read_text(encoding="utf-8").strip().splitlines()
        assert len(lines) == 2

        chat_ids_in_file = {json.loads(l)["chat_id"] for l in lines}
        assert chat_a in chat_ids_in_file
        assert chat_b in chat_ids_in_file


class TestLoadToday:
    """load_today() 메서드 테스트"""

    def test_load_today_restores_messages(self, tmp_path):
        """오늘 JSONL 파일에서 해당 chat_id의 메시지를 로드한다."""
        # 먼저 메시지를 저장
        mem1 = ConversationMemory(storage_base=str(tmp_path))
        chat_id = 3001
        mem1.add_message(chat_id, "제이회장님", "첫 번째 메시지", is_bot=False)
        mem1.add_message(chat_id, "잼민이", "두 번째 메시지", is_bot=True)

        # 새 인스턴스에서 로드
        mem2 = ConversationMemory(storage_base=str(tmp_path))
        mem2.load_today(chat_id)

        messages = mem2.get_context(chat_id)
        assert len(messages) == 2
        assert messages[0].text == "첫 번째 메시지"
        assert messages[1].text == "두 번째 메시지"

    def test_load_today_no_file_empty_deque(self, tmp_path):
        """파일이 없으면 빈 deque로 시작한다 (정상)."""
        mem = ConversationMemory(storage_base=str(tmp_path))
        chat_id = 3002

        # 파일이 없어도 예외가 발생하지 않아야 함
        mem.load_today(chat_id)
        messages = mem.get_context(chat_id)
        assert messages == []

    def test_load_today_filters_by_chat_id(self, tmp_path):
        """로드 시 해당 chat_id의 메시지만 가져온다."""
        mem1 = ConversationMemory(storage_base=str(tmp_path))
        chat_a = 4001
        chat_b = 4002

        mem1.add_message(chat_a, "제이회장님", "채팅A 메시지", is_bot=False)
        mem1.add_message(chat_b, "잼민이", "채팅B 메시지", is_bot=True)

        mem2 = ConversationMemory(storage_base=str(tmp_path))
        mem2.load_today(chat_a)

        messages = mem2.get_context(chat_a)
        assert len(messages) == 1
        assert messages[0].text == "채팅A 메시지"

    def test_load_today_limits_to_50(self, tmp_path):
        """load_today는 최근 50개 메시지만 로드한다."""
        mem1 = ConversationMemory(storage_base=str(tmp_path), max_messages=200)
        chat_id = 5001

        for i in range(60):
            mem1.add_message(chat_id, "제이회장님", f"메시지 {i}", is_bot=False)

        mem2 = ConversationMemory(storage_base=str(tmp_path))
        mem2.load_today(chat_id)

        messages = mem2.get_context(chat_id, limit=100)
        assert len(messages) == 50
        # 가장 최근 50개
        assert messages[-1].text == "메시지 59"

    def test_load_today_message_fields_restored(self, tmp_path):
        """로드된 메시지의 모든 필드가 올바르게 복원된다."""
        mem1 = ConversationMemory(storage_base=str(tmp_path))
        chat_id = 5002
        mem1.add_message(chat_id, "코덱스", "코드 리뷰 완료", is_bot=True)

        mem2 = ConversationMemory(storage_base=str(tmp_path))
        mem2.load_today(chat_id)

        msg = mem2.get_context(chat_id)[0]
        assert msg.sender == "코덱스"
        assert msg.text == "코드 리뷰 완료"
        assert msg.is_bot is True
        assert isinstance(msg.timestamp, datetime)


class TestSummaryGeneration:
    """요약 생성 mock 테스트"""

    def test_message_count_tracked_per_chat(self, tmp_path):
        """chat_id별 메시지 카운트가 추적된다."""
        mem = ConversationMemory(storage_base=str(tmp_path))
        chat_id = 6001

        mem.add_message(chat_id, "제이회장님", "메시지1", is_bot=False)
        mem.add_message(chat_id, "잼민이", "메시지2", is_bot=True)

        assert mem._message_count.get(chat_id, 0) == 2

    def test_summary_triggered_at_50_messages(self, tmp_path):
        """50개 메시지가 쌓이면 요약이 트리거된다."""
        mem = ConversationMemory(storage_base=str(tmp_path))
        chat_id = 6002

        with patch.object(mem, "_generate_summary") as mock_summary:
            for i in range(50):
                mem.add_message(chat_id, "제이회장님", f"메시지 {i}", is_bot=False)

            # 50번째 메시지 추가 시 요약 트리거
            assert mock_summary.called

    def test_generate_summary_calls_call_claude(self, tmp_path):
        """_generate_summary가 call_claude를 호출한다."""
        mem = ConversationMemory(storage_base=str(tmp_path))
        chat_id = 6003

        for i in range(5):
            mem.add_message(chat_id, "제이회장님", f"내용 {i}", is_bot=False)

        async def run():
            with patch("conversation_memory.call_claude", new_callable=AsyncMock) as mock_claude:
                mock_claude.return_value = "요약 결과입니다"
                await mem._generate_summary(chat_id)
                assert mock_claude.called
                call_args = mock_claude.call_args[0][0]
                assert "아래 대화의 핵심 논점과 결론을 3~5줄로 요약해줘" in call_args

        asyncio.run(run())

    def test_generate_summary_saves_json_file(self, tmp_path):
        """_generate_summary가 summaries/ 디렉토리에 JSON 파일을 저장한다."""
        mem = ConversationMemory(storage_base=str(tmp_path))
        chat_id = 6004

        mem.add_message(chat_id, "잼민이", "AI 기술 동향", is_bot=True)
        mem.add_message(chat_id, "코덱스", "코드 품질 향상", is_bot=True)

        async def run():
            with patch("conversation_memory.call_claude", new_callable=AsyncMock) as mock_claude:
                mock_claude.return_value = "핵심 논점: AI와 코드 품질"
                await mem._generate_summary(chat_id)

            summaries_dir = tmp_path / "summaries"
            assert summaries_dir.exists()
            json_files = list(summaries_dir.glob("*.json"))
            assert len(json_files) == 1

        asyncio.run(run())

    def test_generate_summary_json_structure(self, tmp_path):
        """저장된 요약 JSON 파일이 올바른 구조를 가진다."""
        mem = ConversationMemory(storage_base=str(tmp_path))
        chat_id = 6005

        mem.add_message(chat_id, "잼민이", "주제 토론", is_bot=True)
        mem.add_message(chat_id, "제이회장님", "질문입니다", is_bot=False)

        async def run():
            with patch("conversation_memory.call_claude", new_callable=AsyncMock) as mock_claude:
                mock_claude.return_value = "요약 텍스트"
                await mem._generate_summary(chat_id)

            summaries_dir = tmp_path / "summaries"
            json_file = list(summaries_dir.glob("*.json"))[0]
            data = json.loads(json_file.read_text(encoding="utf-8"))

            assert "timestamp" in data
            assert "message_range" in data
            assert "from" in data["message_range"]
            assert "to" in data["message_range"]
            assert "summary" in data
            assert "key_topics" in data
            assert "participants" in data

        asyncio.run(run())

    def test_generate_summary_failure_does_not_raise(self, tmp_path):
        """요약 생성 실패 시 예외를 발생시키지 않는다."""
        mem = ConversationMemory(storage_base=str(tmp_path))
        chat_id = 6006

        mem.add_message(chat_id, "잼민이", "메시지", is_bot=True)

        async def run():
            with patch("conversation_memory.call_claude", new_callable=AsyncMock) as mock_claude:
                mock_claude.side_effect = Exception("API 오류")
                # 예외가 발생하지 않아야 함
                await mem._generate_summary(chat_id)

        asyncio.run(run())

    def test_summary_counter_increments(self, tmp_path):
        """요약 생성마다 summary_counter가 증가한다."""
        mem = ConversationMemory(storage_base=str(tmp_path))
        chat_id = 6007

        # 메시지를 추가해야 _generate_summary가 early return하지 않음
        mem.add_message(chat_id, "잼민이", "주제 논의", is_bot=True)

        async def run():
            with patch("conversation_memory.call_claude", new_callable=AsyncMock) as mock_claude:
                mock_claude.return_value = "요약1"
                await mem._generate_summary(chat_id)
                count1 = mem._summary_counter.get(chat_id, 0)
                await mem._generate_summary(chat_id)
                count2 = mem._summary_counter.get(chat_id, 0)
                assert count2 == count1 + 1

        asyncio.run(run())


class TestFormatContextWithSummaries:
    """요약 포함 format_context 테스트"""

    def test_format_context_includes_summary_section(self, tmp_path):
        """요약이 있을 때 [이전 대화 요약] 섹션이 포함된다."""
        mem = ConversationMemory(storage_base=str(tmp_path))
        chat_id = 7001

        # 요약 파일 직접 생성
        summaries_dir = tmp_path / "summaries"
        summaries_dir.mkdir(parents=True)
        today = datetime.now().strftime("%Y-%m-%d")
        summary_file = summaries_dir / f"{today}_001.json"
        summary_data = {
            "timestamp": datetime.now().isoformat(),
            "message_range": {"from": 1, "to": 50},
            "summary": "AI와 코드 품질에 대한 논의",
            "key_topics": ["AI", "코드 품질"],
            "participants": ["잼민이", "코덱스"],
        }
        summary_file.write_text(json.dumps(summary_data, ensure_ascii=False), encoding="utf-8")

        mem.add_message(chat_id, "잼민이", "최근 메시지", is_bot=True)
        result = mem.format_context(chat_id, "gemini_view_bot", "현재 질문")

        assert "[이전 대화 요약]" in result
        assert "AI와 코드 품질에 대한 논의" in result

    def test_format_context_no_summary_section_when_no_summaries(self, tmp_path):
        """요약이 없을 때 [이전 대화 요약] 섹션이 없다."""
        mem = ConversationMemory(storage_base=str(tmp_path))
        chat_id = 7002

        mem.add_message(chat_id, "잼민이", "메시지", is_bot=True)
        result = mem.format_context(chat_id, "gemini_view_bot", "질문")

        assert "[이전 대화 요약]" not in result

    def test_format_context_recent_conversation_section(self, tmp_path):
        """요약이 있을 때 [최근 대화] 섹션도 포함된다."""
        mem = ConversationMemory(storage_base=str(tmp_path))
        chat_id = 7003

        summaries_dir = tmp_path / "summaries"
        summaries_dir.mkdir(parents=True)
        today = datetime.now().strftime("%Y-%m-%d")
        summary_file = summaries_dir / f"{today}_001.json"
        summary_data = {
            "timestamp": datetime.now().isoformat(),
            "message_range": {"from": 1, "to": 50},
            "summary": "테스트 요약",
            "key_topics": ["테스트"],
            "participants": ["제이회장님"],
        }
        summary_file.write_text(json.dumps(summary_data, ensure_ascii=False), encoding="utf-8")

        mem.add_message(chat_id, "잼민이", "최근 메시지", is_bot=True)
        result = mem.format_context(chat_id, "gemini_view_bot", "현재 질문")

        assert "[최근 대화]" in result

    def test_get_recent_summaries_returns_list(self, tmp_path):
        """get_recent_summaries가 리스트를 반환한다."""
        mem = ConversationMemory(storage_base=str(tmp_path))

        result = mem.get_recent_summaries()
        assert isinstance(result, list)

    def test_get_recent_summaries_reads_today_files(self, tmp_path):
        """get_recent_summaries가 오늘 요약 파일들을 읽는다."""
        mem = ConversationMemory(storage_base=str(tmp_path))

        summaries_dir = tmp_path / "summaries"
        summaries_dir.mkdir(parents=True)
        today = datetime.now().strftime("%Y-%m-%d")

        for i in range(1, 4):
            summary_file = summaries_dir / f"{today}_{i:03d}.json"
            summary_data = {
                "timestamp": datetime.now().isoformat(),
                "message_range": {"from": (i - 1) * 50 + 1, "to": i * 50},
                "summary": f"요약 {i}",
                "key_topics": [f"주제{i}"],
                "participants": ["제이회장님"],
            }
            summary_file.write_text(json.dumps(summary_data, ensure_ascii=False), encoding="utf-8")

        summaries = mem.get_recent_summaries(limit=3)
        assert len(summaries) == 3

    def test_get_recent_summaries_limit(self, tmp_path):
        """get_recent_summaries의 limit 파라미터가 동작한다."""
        mem = ConversationMemory(storage_base=str(tmp_path))

        summaries_dir = tmp_path / "summaries"
        summaries_dir.mkdir(parents=True)
        today = datetime.now().strftime("%Y-%m-%d")

        for i in range(1, 6):
            summary_file = summaries_dir / f"{today}_{i:03d}.json"
            summary_data = {
                "timestamp": datetime.now().isoformat(),
                "message_range": {"from": 1, "to": 50},
                "summary": f"요약 {i}",
                "key_topics": [],
                "participants": [],
            }
            summary_file.write_text(json.dumps(summary_data, ensure_ascii=False), encoding="utf-8")

        summaries = mem.get_recent_summaries(limit=2)
        assert len(summaries) == 2


class TestGenerateInsight:
    """insight 생성 mock 테스트"""

    def test_generate_insight_returns_string(self, tmp_path):
        """generate_insight가 문자열을 반환한다."""
        mem = ConversationMemory(storage_base=str(tmp_path))
        chat_id = 8001

        mem.add_message(chat_id, "제이회장님", "오늘 논의 내용", is_bot=False)

        async def run():
            with patch("conversation_memory.call_claude", new_callable=AsyncMock) as mock_claude:
                mock_claude.return_value = "핵심 논점: ...\n결론: ...\n액션 아이템: ..."
                result = await mem.generate_insight(chat_id)
                assert isinstance(result, str)
                assert len(result) > 0

        asyncio.run(run())

    def test_generate_insight_saves_md_file(self, tmp_path):
        """generate_insight가 insights/ 디렉토리에 .md 파일을 저장한다."""
        mem = ConversationMemory(storage_base=str(tmp_path))
        chat_id = 8002

        mem.add_message(chat_id, "잼민이", "주요 논의", is_bot=True)

        async def run():
            with patch("conversation_memory.call_claude", new_callable=AsyncMock) as mock_claude:
                mock_claude.return_value = "인사이트 내용"
                await mem.generate_insight(chat_id)

            insights_dir = tmp_path / "insights"
            assert insights_dir.exists()
            md_files = list(insights_dir.glob("*.md"))
            assert len(md_files) == 1

        asyncio.run(run())

    def test_generate_insight_md_filename_format(self, tmp_path):
        """generate_insight 파일명이 YYYY-MM-DD_insight_NNN.md 형식이다."""
        mem = ConversationMemory(storage_base=str(tmp_path))
        chat_id = 8003

        mem.add_message(chat_id, "코덱스", "코드 리뷰", is_bot=True)

        async def run():
            with patch("conversation_memory.call_claude", new_callable=AsyncMock) as mock_claude:
                mock_claude.return_value = "인사이트"
                await mem.generate_insight(chat_id)

            insights_dir = tmp_path / "insights"
            md_files = list(insights_dir.glob("*.md"))
            today = datetime.now().strftime("%Y-%m-%d")
            assert any(f.name.startswith(f"{today}_insight_") for f in md_files)

        asyncio.run(run())

    def test_generate_insight_calls_call_claude_with_summary_prompt(self, tmp_path):
        """generate_insight가 call_claude를 핵심 논점/결론/액션 아이템 요청으로 호출한다."""
        mem = ConversationMemory(storage_base=str(tmp_path))
        chat_id = 8004

        mem.add_message(chat_id, "제이회장님", "논의 내용", is_bot=False)

        async def run():
            with patch("conversation_memory.call_claude", new_callable=AsyncMock) as mock_claude:
                mock_claude.return_value = "핵심 논점과 결론"
                await mem.generate_insight(chat_id)
                assert mock_claude.called
                call_args = mock_claude.call_args[0][0]
                # 핵심 논점, 결론, 액션 아이템 관련 내용이 포함되어야 함
                prompt_lower = call_args.lower()
                assert any(keyword in call_args for keyword in ["핵심 논점", "결론", "액션 아이템", "요약"])

        asyncio.run(run())


class TestGracefulDegradation:
    """파일 저장 실패 시 graceful degradation 테스트"""

    def test_file_write_failure_does_not_raise(self, tmp_path):
        """파일 저장 실패 시 예외가 발생하지 않는다."""
        mem = ConversationMemory(storage_base=str(tmp_path))
        chat_id = 9001

        with patch("builtins.open", side_effect=OSError("디스크 꽉 참")):
            # 예외가 발생하지 않아야 함
            mem.add_message(chat_id, "제이회장님", "파일 실패 테스트", is_bot=False)

    def test_file_write_failure_still_stores_in_memory(self, tmp_path):
        """파일 저장 실패 시 인메모리에는 저장된다."""
        mem = ConversationMemory(storage_base=str(tmp_path))
        chat_id = 9002

        with patch("builtins.open", side_effect=OSError("디스크 꽉 참")):
            mem.add_message(chat_id, "제이회장님", "메모리 저장 확인", is_bot=False)

        messages = mem.get_context(chat_id)
        assert len(messages) == 1
        assert messages[0].text == "메모리 저장 확인"

    def test_load_today_corrupt_line_skipped(self, tmp_path):
        """load_today 시 손상된 라인은 건너뛴다."""
        today = datetime.now().strftime("%Y-%m-%d")
        jsonl_path = tmp_path / f"{today}.jsonl"

        # 정상 라인 + 손상된 라인 + 정상 라인
        valid_line = json.dumps(
            {
                "sender": "잼민이",
                "text": "정상 메시지",
                "timestamp": datetime.now().isoformat(),
                "is_bot": True,
                "chat_id": 9003,
            },
            ensure_ascii=False,
        )
        corrupt_line = "{ 손상된 JSON }"
        jsonl_path.write_text(f"{valid_line}\n{corrupt_line}\n", encoding="utf-8")

        mem = ConversationMemory(storage_base=str(tmp_path))
        # 예외 없이 정상 라인만 로드
        mem.load_today(9003)
        messages = mem.get_context(9003)
        assert len(messages) == 1
        assert messages[0].text == "정상 메시지"


class TestInactivityTimer:
    """30분 무활동 자동 요약 트리거 테스트"""

    def test_last_activity_updated_on_add_message(self, tmp_path):
        """add_message 호출 시 _last_activity가 갱신된다."""
        mem = ConversationMemory(storage_base=str(tmp_path))
        chat_id = 10001
        mem.add_message(chat_id, "제이회장님", "테스트", is_bot=False)
        assert chat_id in mem._last_activity
        assert isinstance(mem._last_activity[chat_id], datetime)

    def test_inactivity_tasks_dict_exists(self):
        """_inactivity_tasks 딕셔너리가 초기화된다."""
        mem = ConversationMemory()
        assert hasattr(mem, "_inactivity_tasks")
        assert isinstance(mem._inactivity_tasks, dict)

    def test_check_inactivity_triggers_summary_when_no_new_messages(self, tmp_path):
        """무활동 시 요약이 트리거된다."""
        mem = ConversationMemory(storage_base=str(tmp_path))
        chat_id = 10002
        # 최소 5개 메시지 추가
        for i in range(6):
            mem.add_message(chat_id, "제이회장님", f"메시지 {i}", is_bot=False)

        async def run():
            with patch.object(mem, "_schedule_summary") as mock_summary:
                # _last_activity를 설정하고 _check_inactivity를 직접 호출 (sleep을 mock)
                with patch("conversation_memory.asyncio.sleep", new_callable=AsyncMock):
                    await mem._check_inactivity(chat_id)
                    mock_summary.assert_called_once_with(chat_id)

        asyncio.run(run())

    def test_check_inactivity_skips_when_new_message_arrived(self, tmp_path):
        """새 메시지가 있으면 요약을 트리거하지 않는다."""
        mem = ConversationMemory(storage_base=str(tmp_path))
        chat_id = 10003
        for i in range(6):
            mem.add_message(chat_id, "제이회장님", f"메시지 {i}", is_bot=False)

        async def run():
            with patch.object(mem, "_schedule_summary") as mock_summary:

                async def fake_sleep(seconds):
                    # sleep 중에 새 메시지가 온 것처럼 시뮬레이션
                    mem._last_activity[chat_id] = datetime.now()

                with patch("conversation_memory.asyncio.sleep", side_effect=fake_sleep):
                    await mem._check_inactivity(chat_id)
                    mock_summary.assert_not_called()

        asyncio.run(run())

    def test_check_inactivity_skips_when_few_messages(self, tmp_path):
        """메시지가 5개 미만이면 요약을 트리거하지 않는다."""
        mem = ConversationMemory(storage_base=str(tmp_path))
        chat_id = 10004
        mem.add_message(chat_id, "제이회장님", "하나", is_bot=False)
        mem.add_message(chat_id, "잼민이", "둘", is_bot=True)

        async def run():
            with patch.object(mem, "_schedule_summary") as mock_summary:
                with patch("conversation_memory.asyncio.sleep", new_callable=AsyncMock):
                    await mem._check_inactivity(chat_id)
                    mock_summary.assert_not_called()

        asyncio.run(run())

    def test_existing_timer_cancelled_on_new_message(self, tmp_path):
        """새 메시지가 오면 기존 타이머가 취소된다."""
        mem = ConversationMemory(storage_base=str(tmp_path))
        chat_id = 10005

        # Mock task를 _inactivity_tasks에 설정
        mock_task = Mock()
        mock_task.done.return_value = False
        mem._inactivity_tasks[chat_id] = mock_task

        mem.add_message(chat_id, "제이회장님", "새 메시지", is_bot=False)
        mock_task.cancel.assert_called_once()


class TestKeyTopicsExtraction:
    """key_topics 자동 추출 테스트"""

    def test_key_topics_populated_from_json_response(self, tmp_path):
        """call_claude가 JSON 응답을 주면 key_topics가 채워진다."""
        mem = ConversationMemory(storage_base=str(tmp_path))
        chat_id = 11001
        mem.add_message(chat_id, "잼민이", "AI 기술 동향", is_bot=True)

        async def run():
            json_response = json.dumps(
                {
                    "summary": "AI 기술 동향에 대한 논의",
                    "key_topics": ["AI", "기술", "동향"],
                },
                ensure_ascii=False,
            )

            with patch("conversation_memory.call_claude", new_callable=AsyncMock) as mock_claude:
                mock_claude.return_value = json_response
                await mem._generate_summary(chat_id)

            summaries_dir = tmp_path / "summaries"
            json_files = list(summaries_dir.glob("*.json"))
            assert len(json_files) == 1
            data = json.loads(json_files[0].read_text(encoding="utf-8"))
            assert data["key_topics"] == ["AI", "기술", "동향"]

        asyncio.run(run())

    def test_key_topics_empty_on_non_json_response(self, tmp_path):
        """call_claude가 일반 텍스트를 주면 key_topics는 빈 배열이다."""
        mem = ConversationMemory(storage_base=str(tmp_path))
        chat_id = 11002
        mem.add_message(chat_id, "잼민이", "일반 대화", is_bot=True)

        async def run():
            with patch("conversation_memory.call_claude", new_callable=AsyncMock) as mock_claude:
                mock_claude.return_value = "그냥 요약 텍스트입니다"
                await mem._generate_summary(chat_id)

            summaries_dir = tmp_path / "summaries"
            json_files = list(summaries_dir.glob("*.json"))
            data = json.loads(json_files[0].read_text(encoding="utf-8"))
            assert data["key_topics"] == []
            assert data["summary"] == "그냥 요약 텍스트입니다"

        asyncio.run(run())

    def test_generate_summary_prompt_requests_json(self, tmp_path):
        """_generate_summary의 프롬프트가 JSON 형식을 요청한다."""
        mem = ConversationMemory(storage_base=str(tmp_path))
        chat_id = 11003
        mem.add_message(chat_id, "제이회장님", "토론 내용", is_bot=False)

        async def run():
            with patch("conversation_memory.call_claude", new_callable=AsyncMock) as mock_claude:
                mock_claude.return_value = '{"summary": "요약", "key_topics": ["주제"]}'
                await mem._generate_summary(chat_id)
                call_args = mock_claude.call_args[0][0]
                assert "key_topics" in call_args or "키워드" in call_args
                assert "JSON" in call_args or "json" in call_args

        asyncio.run(run())


class TestInsightDMSending:
    """insight DM 전송 테스트"""

    def test_send_insight_to_owner_called_on_cleanup(self, tmp_path):
        """cleanup 시 _send_insight_to_owner가 호출되는지 확인은 main_bot 통합 테스트에서."""
        # 이 테스트는 main_bot.py의 통합 테스트에 해당
        # conversation_memory 단위에서는 generate_insight의 반환값만 확인
        pass


class TestInsightEvent:
    """insight 이벤트 파일 생성 테스트"""

    def test_generate_insight_calls_create_event(self, tmp_path):
        """generate_insight 후 _create_insight_event가 호출된다."""
        mem = ConversationMemory(storage_base=str(tmp_path))
        chat_id = 12001
        mem.add_message(chat_id, "제이회장님", "논의 내용", is_bot=False)

        async def run():
            with patch("conversation_memory.call_claude", new_callable=AsyncMock) as mock_claude:
                mock_claude.return_value = "인사이트 결과"
                with patch.object(mem, "_create_insight_event") as mock_event:
                    await mem.generate_insight(chat_id)
                    mock_event.assert_called_once()

        asyncio.run(run())

    def test_create_insight_event_creates_file(self, tmp_path):
        """_create_insight_event가 이벤트 파일을 생성한다."""
        mem = ConversationMemory(storage_base=str(tmp_path))
        events_dir = tmp_path / "events"

        mem._create_insight_event(events_dir=events_dir)

        event_files = list(events_dir.glob("groupchat-insight-*.event"))
        assert len(event_files) == 1

    def test_create_insight_event_json_structure(self, tmp_path):
        """이벤트 파일이 올바른 JSON 구조를 가진다."""
        mem = ConversationMemory(storage_base=str(tmp_path))
        events_dir = tmp_path / "events"

        mem._create_insight_event(events_dir=events_dir)

        event_files = list(events_dir.glob("*.event"))
        data = json.loads(event_files[0].read_text(encoding="utf-8"))
        assert data["type"] == "groupchat-insight"
        assert "timestamp" in data
        assert "insights_dir" in data

    def test_create_insight_event_failure_no_raise(self, tmp_path):
        """이벤트 파일 생성 실패 시 예외가 발생하지 않는다."""
        mem = ConversationMemory(storage_base=str(tmp_path))
        # 존재하지 않는 읽기 전용 경로로 실패 시뮬레이션
        mem._create_insight_event(events_dir=Path("/nonexistent/readonly/path"))


# ---------------------------------------------------------------------------
# Phase 1 & 2: 미팅 합의사항 반영 테스트 (신규)
# ---------------------------------------------------------------------------


class TestSummaryCounterRecovery:
    """_summary_counter 복구 테스트 - 재시작 시 기존 파일 수를 확인해 번호 이어받기"""

    def test_counter_recovery_from_existing_files(self, tmp_path):
        """기존 요약 파일 3개 존재 시, 새 요약이 004번으로 생성되는지 확인."""
        mem = ConversationMemory(storage_base=str(tmp_path))
        chat_id = 20001

        # 기존 요약 파일 3개 미리 생성
        summaries_dir = tmp_path / "summaries"
        summaries_dir.mkdir(parents=True)
        today = datetime.now().strftime("%Y-%m-%d")
        for i in range(1, 4):
            fp = summaries_dir / f"{today}_general_{i:03d}.json"
            fp.write_text(
                json.dumps({"summary": f"요약{i}", "key_topics": []}),
                encoding="utf-8",
            )

        # 메시지 추가 후 요약 생성
        mem.add_message(chat_id, "잼민이", "새 논의", is_bot=True)

        async def run():
            with patch("conversation_memory.call_claude", new_callable=AsyncMock) as mock_claude:
                mock_claude.return_value = json.dumps(
                    {
                        "summary": "새 요약",
                        "key_topics": ["주제"],
                        "topic_tag": "general",
                        "key_decisions": [],
                        "action_items": [],
                        "consensus_level": "exploratory",
                    },
                    ensure_ascii=False,
                )
                await mem._generate_summary(chat_id)

            json_files = sorted(summaries_dir.glob(f"{today}_*.json"))
            # 4개 파일이 존재해야 함 (기존 3 + 신규 1)
            assert len(json_files) == 4
            # 새로 생성된 파일 번호가 004여야 함
            new_file = json_files[-1]
            assert "_004" in new_file.name

        asyncio.run(run())


class TestPIIMasking:
    """PII 마스킹 테스트 - JSONL에는 마스킹, 인메모리에는 원본"""

    def test_phone_number_masked_in_jsonl(self, tmp_path):
        """전화번호(010-1234-5678 → [전화번호])가 JSONL에서 마스킹된다."""
        mem = ConversationMemory(storage_base=str(tmp_path))
        chat_id = 21001
        original_text = "제 번호는 010-1234-5678입니다."

        mem.add_message(chat_id, "제이회장님", original_text, is_bot=False)

        today = datetime.now().strftime("%Y-%m-%d")
        jsonl_path = tmp_path / f"{today}.jsonl"
        line = jsonl_path.read_text(encoding="utf-8").strip()
        data = json.loads(line)

        assert "[전화번호]" in data["text"]
        assert "010-1234-5678" not in data["text"]

    def test_resident_number_masked_in_jsonl(self, tmp_path):
        """주민번호(900101-1234567 → [주민번호])가 JSONL에서 마스킹된다."""
        mem = ConversationMemory(storage_base=str(tmp_path))
        chat_id = 21002
        original_text = "주민번호는 900101-1234567입니다."

        mem.add_message(chat_id, "제이회장님", original_text, is_bot=False)

        today = datetime.now().strftime("%Y-%m-%d")
        jsonl_path = tmp_path / f"{today}.jsonl"
        line = jsonl_path.read_text(encoding="utf-8").strip()
        data = json.loads(line)

        assert "[주민번호]" in data["text"]
        assert "900101-1234567" not in data["text"]

    def test_account_number_masked_in_jsonl(self, tmp_path):
        """계좌번호(110-123-456789 → [계좌번호])가 JSONL에서 마스킹된다."""
        mem = ConversationMemory(storage_base=str(tmp_path))
        chat_id = 21003
        original_text = "계좌번호는 110-123-456789입니다."

        mem.add_message(chat_id, "제이회장님", original_text, is_bot=False)

        today = datetime.now().strftime("%Y-%m-%d")
        jsonl_path = tmp_path / f"{today}.jsonl"
        line = jsonl_path.read_text(encoding="utf-8").strip()
        data = json.loads(line)

        assert "[계좌번호]" in data["text"]
        assert "110-123-456789" not in data["text"]

    def test_original_preserved_in_memory(self, tmp_path):
        """인메모리 deque에는 원본 텍스트가 저장된다."""
        mem = ConversationMemory(storage_base=str(tmp_path))
        chat_id = 21004
        original_text = "연락처 010-9999-8888로 전화해줘."

        mem.add_message(chat_id, "제이회장님", original_text, is_bot=False)

        messages = mem.get_context(chat_id)
        assert len(messages) == 1
        # 인메모리에는 원본 저장
        assert messages[0].text == original_text
        assert "010-9999-8888" in messages[0].text


class TestTopicTag:
    """topic_tag 필드 포함 확인"""

    def test_jsonl_record_has_topic_tag(self, tmp_path):
        """JSONL 레코드에 topic_tag 필드가 포함된다."""
        mem = ConversationMemory(storage_base=str(tmp_path))
        chat_id = 22001

        mem.add_message(chat_id, "잼민이", "오늘 날씨가 좋네요", is_bot=True)

        today = datetime.now().strftime("%Y-%m-%d")
        jsonl_path = tmp_path / f"{today}.jsonl"
        line = jsonl_path.read_text(encoding="utf-8").strip()
        data = json.loads(line)

        assert "topic_tag" in data

    def test_topic_tag_default_value_is_general(self, tmp_path):
        """topic_tag의 기본값이 'general'이다."""
        mem = ConversationMemory(storage_base=str(tmp_path))
        chat_id = 22002

        mem.add_message(chat_id, "잼민이", "일반 메시지", is_bot=True)

        today = datetime.now().strftime("%Y-%m-%d")
        jsonl_path = tmp_path / f"{today}.jsonl"
        line = jsonl_path.read_text(encoding="utf-8").strip()
        data = json.loads(line)

        assert data["topic_tag"] == "general"

    def test_current_topic_initialized_to_general(self):
        """_current_topic 딕셔너리가 초기화된다."""
        mem = ConversationMemory()
        assert hasattr(mem, "_current_topic")
        assert isinstance(mem._current_topic, dict)


class TestTopicChangeDetection:
    """주제 전환 감지 테스트"""

    def test_keyword_detects_topic_change(self, tmp_path):
        """전환 키워드('다른 주제')로 주제 전환이 감지된다."""
        mem = ConversationMemory(storage_base=str(tmp_path))
        chat_id = 23001

        # 첫 메시지로 _last_activity 설정
        mem.add_message(chat_id, "제이회장님", "일반 이야기", is_bot=False)

        msg = ChatMessage(
            sender="제이회장님",
            text="다른 주제로 넘어가죠",
            timestamp=datetime.now(),
            is_bot=False,
        )
        result = mem._detect_topic_change(chat_id, msg)
        assert result is True

    def test_keyword_geugeon_detects_topic_change(self, tmp_path):
        """전환 키워드('그건 그렇고')로 주제 전환이 감지된다."""
        mem = ConversationMemory(storage_base=str(tmp_path))
        chat_id = 23002

        mem.add_message(chat_id, "제이회장님", "일반 이야기", is_bot=False)

        msg = ChatMessage(
            sender="잼민이",
            text="그건 그렇고 오늘 회의는요?",
            timestamp=datetime.now(),
            is_bot=True,
        )
        result = mem._detect_topic_change(chat_id, msg)
        assert result is True

    def test_silence_gap_detects_topic_change(self, tmp_path):
        """침묵 갭(5분 초과)으로 주제 전환이 감지된다."""
        from datetime import timedelta

        mem = ConversationMemory(storage_base=str(tmp_path))
        chat_id = 23003

        # 6분 전 활동 시간 설정
        mem._last_activity[chat_id] = datetime.now() - timedelta(seconds=360)

        msg = ChatMessage(
            sender="제이회장님",
            text="새로운 이야기",
            timestamp=datetime.now(),
            is_bot=False,
        )
        result = mem._detect_topic_change(chat_id, msg)
        assert result is True

    def test_normal_message_no_topic_change(self, tmp_path):
        """일반 메시지에서는 주제 전환이 감지되지 않는다."""
        from datetime import timedelta

        mem = ConversationMemory(storage_base=str(tmp_path))
        chat_id = 23004

        # 1분 전 활동 (침묵 갭 미달)
        mem._last_activity[chat_id] = datetime.now() - timedelta(seconds=60)

        msg = ChatMessage(
            sender="잼민이",
            text="계속 이야기해요",
            timestamp=datetime.now(),
            is_bot=True,
        )
        result = mem._detect_topic_change(chat_id, msg)
        assert result is False

    def test_topic_change_sets_pending(self, tmp_path):
        """주제 전환 감지 시 _current_topic이 'pending'으로 변경된다."""
        mem = ConversationMemory(storage_base=str(tmp_path))
        chat_id = 23005

        mem.add_message(chat_id, "제이회장님", "일반 이야기", is_bot=False)
        mem.add_message(chat_id, "제이회장님", "다른 주제로 넘어가죠", is_bot=False)

        assert mem._current_topic.get(chat_id) == "pending"


class TestSummaryMetadataSchema:
    """요약 메타데이터 스키마 확인"""

    def test_summary_has_key_decisions(self, tmp_path):
        """저장된 요약 JSON에 key_decisions 필드가 존재한다."""
        mem = ConversationMemory(storage_base=str(tmp_path))
        chat_id = 24001
        mem.add_message(chat_id, "잼민이", "결정 사항 토론", is_bot=True)

        async def run():
            with patch("conversation_memory.call_claude", new_callable=AsyncMock) as mock_claude:
                mock_claude.return_value = json.dumps(
                    {
                        "summary": "요약",
                        "key_topics": ["주제"],
                        "topic_tag": "meeting",
                        "key_decisions": ["결정1"],
                        "action_items": ["액션1"],
                        "consensus_level": "agreed",
                    },
                    ensure_ascii=False,
                )
                await mem._generate_summary(chat_id)

            summaries_dir = tmp_path / "summaries"
            json_files = list(summaries_dir.glob("*.json"))
            data = json.loads(json_files[0].read_text(encoding="utf-8"))

            assert "key_decisions" in data
            assert data["key_decisions"] == ["결정1"]

        asyncio.run(run())

    def test_summary_has_action_items(self, tmp_path):
        """저장된 요약 JSON에 action_items 필드가 존재한다."""
        mem = ConversationMemory(storage_base=str(tmp_path))
        chat_id = 24002
        mem.add_message(chat_id, "잼민이", "액션 아이템 논의", is_bot=True)

        async def run():
            with patch("conversation_memory.call_claude", new_callable=AsyncMock) as mock_claude:
                mock_claude.return_value = json.dumps(
                    {
                        "summary": "요약",
                        "key_topics": [],
                        "topic_tag": "action",
                        "key_decisions": [],
                        "action_items": ["할 일1", "할 일2"],
                        "consensus_level": "tentative",
                    },
                    ensure_ascii=False,
                )
                await mem._generate_summary(chat_id)

            summaries_dir = tmp_path / "summaries"
            json_files = list(summaries_dir.glob("*.json"))
            data = json.loads(json_files[0].read_text(encoding="utf-8"))

            assert "action_items" in data
            assert data["action_items"] == ["할 일1", "할 일2"]

        asyncio.run(run())

    def test_summary_has_consensus_level(self, tmp_path):
        """저장된 요약 JSON에 consensus_level 필드가 존재한다."""
        mem = ConversationMemory(storage_base=str(tmp_path))
        chat_id = 24003
        mem.add_message(chat_id, "코덱스", "합의 수준 테스트", is_bot=True)

        async def run():
            with patch("conversation_memory.call_claude", new_callable=AsyncMock) as mock_claude:
                mock_claude.return_value = json.dumps(
                    {
                        "summary": "요약",
                        "key_topics": [],
                        "topic_tag": "consensus",
                        "key_decisions": [],
                        "action_items": [],
                        "consensus_level": "decided",
                    },
                    ensure_ascii=False,
                )
                await mem._generate_summary(chat_id)

            summaries_dir = tmp_path / "summaries"
            json_files = list(summaries_dir.glob("*.json"))
            data = json.loads(json_files[0].read_text(encoding="utf-8"))

            assert "consensus_level" in data
            assert data["consensus_level"] == "decided"

        asyncio.run(run())

    def test_summary_filename_includes_topic_slug(self, tmp_path):
        """요약 파일명에 topic_tag가 포함된다."""
        mem = ConversationMemory(storage_base=str(tmp_path))
        chat_id = 24004
        mem.add_message(chat_id, "잼민이", "주제 파일명 테스트", is_bot=True)

        async def run():
            with patch("conversation_memory.call_claude", new_callable=AsyncMock) as mock_claude:
                mock_claude.return_value = json.dumps(
                    {
                        "summary": "요약",
                        "key_topics": [],
                        "topic_tag": "ai_tech",
                        "key_decisions": [],
                        "action_items": [],
                        "consensus_level": "exploratory",
                    },
                    ensure_ascii=False,
                )
                await mem._generate_summary(chat_id)

            summaries_dir = tmp_path / "summaries"
            json_files = list(summaries_dir.glob("*.json"))
            assert len(json_files) == 1
            # 파일명에 topic_slug가 포함되어야 함
            assert "ai_tech" in json_files[0].name

        asyncio.run(run())

    def test_summary_has_date_field(self, tmp_path):
        """저장된 요약 JSON에 date 필드가 존재한다."""
        mem = ConversationMemory(storage_base=str(tmp_path))
        chat_id = 24005
        mem.add_message(chat_id, "잼민이", "날짜 필드 확인", is_bot=True)

        async def run():
            with patch("conversation_memory.call_claude", new_callable=AsyncMock) as mock_claude:
                mock_claude.return_value = json.dumps(
                    {
                        "summary": "요약",
                        "key_topics": [],
                        "topic_tag": "general",
                        "key_decisions": [],
                        "action_items": [],
                        "consensus_level": "exploratory",
                    },
                    ensure_ascii=False,
                )
                await mem._generate_summary(chat_id)

            summaries_dir = tmp_path / "summaries"
            json_files = list(summaries_dir.glob("*.json"))
            data = json.loads(json_files[0].read_text(encoding="utf-8"))

            assert "date" in data
            today = datetime.now().strftime("%Y-%m-%d")
            assert data["date"] == today

        asyncio.run(run())

    def test_summary_fallback_has_empty_fields(self, tmp_path):
        """JSON 파싱 실패(fallback) 시 key_decisions, action_items가 빈 배열이다."""
        mem = ConversationMemory(storage_base=str(tmp_path))
        chat_id = 24006
        mem.add_message(chat_id, "잼민이", "fallback 테스트", is_bot=True)

        async def run():
            with patch("conversation_memory.call_claude", new_callable=AsyncMock) as mock_claude:
                mock_claude.return_value = "일반 텍스트 응답 (파싱 불가)"
                await mem._generate_summary(chat_id)

            summaries_dir = tmp_path / "summaries"
            json_files = list(summaries_dir.glob("*.json"))
            data = json.loads(json_files[0].read_text(encoding="utf-8"))

            assert data["key_decisions"] == []
            assert data["action_items"] == []
            assert data["consensus_level"] == "exploratory"

        asyncio.run(run())


class TestXMLTagSeparation:
    """XML 태그 분리 확인"""

    def test_generate_summary_prompt_has_xml_user_content_tag(self, tmp_path):
        """_generate_summary 프롬프트에 <user_content> 태그가 포함된다."""
        mem = ConversationMemory(storage_base=str(tmp_path))
        chat_id = 25001
        mem.add_message(chat_id, "제이회장님", "XML 태그 테스트", is_bot=False)

        async def run():
            with patch("conversation_memory.call_claude", new_callable=AsyncMock) as mock_claude:
                mock_claude.return_value = json.dumps(
                    {
                        "summary": "요약",
                        "key_topics": [],
                        "topic_tag": "general",
                        "key_decisions": [],
                        "action_items": [],
                        "consensus_level": "exploratory",
                    },
                    ensure_ascii=False,
                )
                await mem._generate_summary(chat_id)
                call_args = mock_claude.call_args[0][0]
                assert "<user_content>" in call_args
                assert "</user_content>" in call_args

        asyncio.run(run())

    def test_generate_insight_prompt_has_xml_user_content_tag(self, tmp_path):
        """generate_insight 프롬프트에 <user_content> 태그가 포함된다."""
        mem = ConversationMemory(storage_base=str(tmp_path))
        chat_id = 25002
        mem.add_message(chat_id, "제이회장님", "인사이트 XML 태그 테스트", is_bot=False)

        async def run():
            with patch("conversation_memory.call_claude", new_callable=AsyncMock) as mock_claude:
                mock_claude.return_value = "인사이트 결과"
                await mem.generate_insight(chat_id)
                call_args = mock_claude.call_args[0][0]
                assert "<user_content>" in call_args
                assert "</user_content>" in call_args

        asyncio.run(run())


class TestFilePermissions:
    """파일 퍼미션 테스트"""

    def test_jsonl_directory_has_700_permission(self, tmp_path):
        """JSONL 디렉토리가 0o700으로 설정된다."""
        mem = ConversationMemory(storage_base=str(tmp_path))
        chat_id = 26001

        mem.add_message(chat_id, "제이회장님", "퍼미션 테스트", is_bot=False)

        # 디렉토리 퍼미션 확인 (하위 비트만)
        dir_mode = oct(os.stat(tmp_path).st_mode)[-3:]
        assert dir_mode == "700"

    def test_jsonl_file_has_600_permission(self, tmp_path):
        """JSONL 파일이 0o600으로 설정된다."""
        mem = ConversationMemory(storage_base=str(tmp_path))
        chat_id = 26002

        mem.add_message(chat_id, "제이회장님", "파일 퍼미션 테스트", is_bot=False)

        today = datetime.now().strftime("%Y-%m-%d")
        jsonl_path = tmp_path / f"{today}.jsonl"
        assert jsonl_path.exists()

        file_mode = oct(os.stat(jsonl_path).st_mode)[-3:]
        assert file_mode == "600"


class TestSummaryLock:
    """요약 중복 실행 방지 세마포어 테스트"""

    def test_summary_lock_exists_in_init(self):
        """_summary_lock 딕셔너리가 __init__에서 초기화된다."""
        mem = ConversationMemory()
        assert hasattr(mem, "_summary_lock")
        assert isinstance(mem._summary_lock, dict)

    def test_summary_lock_set_during_execution(self, tmp_path):
        """_generate_summary 실행 중 _summary_lock[chat_id]가 True이다."""
        mem = ConversationMemory(storage_base=str(tmp_path))
        chat_id = 30001
        mem.add_message(chat_id, "잼민이", "테스트 메시지", is_bot=True)

        lock_during_execution = []

        async def run():
            original_call_claude = AsyncMock()

            async def capture_lock(prompt):
                lock_during_execution.append(mem._summary_lock.get(chat_id, False))
                return json.dumps(
                    {
                        "summary": "요약",
                        "key_topics": [],
                        "topic_tag": "general",
                        "key_decisions": [],
                        "action_items": [],
                        "consensus_level": "exploratory",
                    }
                )

            original_call_claude.side_effect = capture_lock

            with patch("conversation_memory.call_claude", original_call_claude):
                await mem._generate_summary(chat_id)

            # 실행 중 lock이 True였어야 함
            assert lock_during_execution[0] is True
            # 완료 후 lock이 False여야 함
            assert mem._summary_lock.get(chat_id) is False

        asyncio.run(run())

    def test_summary_skipped_when_locked(self, tmp_path):
        """lock이 걸려있으면 _generate_summary가 스킵된다."""
        mem = ConversationMemory(storage_base=str(tmp_path))
        chat_id = 30002
        mem.add_message(chat_id, "잼민이", "테스트 메시지", is_bot=True)

        # 미리 lock 설정
        mem._summary_lock[chat_id] = True

        async def run():
            with patch("conversation_memory.call_claude", new_callable=AsyncMock) as mock_claude:
                await mem._generate_summary(chat_id)
                # lock 때문에 call_claude가 호출되지 않아야 함
                mock_claude.assert_not_called()

        asyncio.run(run())

    def test_summary_lock_released_on_failure(self, tmp_path):
        """요약 생성 실패 시에도 lock이 해제된다."""
        mem = ConversationMemory(storage_base=str(tmp_path))
        chat_id = 30003
        mem.add_message(chat_id, "잼민이", "테스트 메시지", is_bot=True)

        async def run():
            with patch("conversation_memory.call_claude", new_callable=AsyncMock) as mock_claude:
                mock_claude.side_effect = Exception("API 오류")
                await mem._generate_summary(chat_id)
                # 실패 후에도 lock이 해제되어야 함
                assert mem._summary_lock.get(chat_id) is False

        asyncio.run(run())


class TestRateLimit:
    """일일 LLM 호출 예산 테스트"""

    def test_llm_call_count_exists_in_init(self):
        """_llm_call_count 딕셔너리가 초기화된다."""
        mem = ConversationMemory()
        assert hasattr(mem, "_llm_call_count")
        assert isinstance(mem._llm_call_count, dict)

    def test_daily_llm_budget_default_50(self):
        """_daily_llm_budget의 기본값이 50이다."""
        mem = ConversationMemory()
        assert hasattr(mem, "_daily_llm_budget")
        assert mem._daily_llm_budget == 50

    def test_llm_call_count_increments_on_summary(self, tmp_path):
        """요약 생성 시 _llm_call_count가 증가한다."""
        mem = ConversationMemory(storage_base=str(tmp_path))
        chat_id = 31001
        mem.add_message(chat_id, "잼민이", "테스트", is_bot=True)

        async def run():
            with patch("conversation_memory.call_claude", new_callable=AsyncMock) as mock_claude:
                mock_claude.return_value = json.dumps(
                    {
                        "summary": "요약",
                        "key_topics": [],
                        "topic_tag": "general",
                        "key_decisions": [],
                        "action_items": [],
                        "consensus_level": "exploratory",
                    }
                )
                today = datetime.now().strftime("%Y-%m-%d")
                before = mem._llm_call_count.get(today, 0)
                await mem._generate_summary(chat_id)
                after = mem._llm_call_count.get(today, 0)
                assert after == before + 1

        asyncio.run(run())

    def test_summary_skipped_when_budget_exceeded(self, tmp_path):
        """예산 초과 시 요약 생성이 스킵된다."""
        mem = ConversationMemory(storage_base=str(tmp_path))
        mem._daily_llm_budget = 5  # 테스트용 낮은 예산
        chat_id = 31002
        mem.add_message(chat_id, "잼민이", "테스트", is_bot=True)

        # 예산 소진
        today = datetime.now().strftime("%Y-%m-%d")
        mem._llm_call_count[today] = 5

        async def run():
            with patch("conversation_memory.call_claude", new_callable=AsyncMock) as mock_claude:
                await mem._generate_summary(chat_id)
                mock_claude.assert_not_called()

        asyncio.run(run())

    def test_insight_returns_error_when_budget_exceeded(self, tmp_path):
        """예산 초과 시 generate_insight가 에러 메시지를 반환한다."""
        mem = ConversationMemory(storage_base=str(tmp_path))
        mem._daily_llm_budget = 3
        chat_id = 31003
        mem.add_message(chat_id, "잼민이", "테스트", is_bot=True)

        today = datetime.now().strftime("%Y-%m-%d")
        mem._llm_call_count[today] = 3

        async def run():
            result = await mem.generate_insight(chat_id)
            assert "예산" in result or "초과" in result

        asyncio.run(run())


class TestDateTransitionBoundary:
    """날짜 전환 경계 시간순 역전 방지 테스트"""

    def test_today_jsonl_path_accepts_timestamp(self, tmp_path):
        """_today_jsonl_path가 timestamp 파라미터를 받는다."""
        mem = ConversationMemory(storage_base=str(tmp_path))
        ts = datetime(2026, 3, 14, 23, 59, 59)
        path = mem._today_jsonl_path(ts=ts)
        assert path is not None
        assert "2026-03-14" in str(path)

    def test_today_jsonl_path_default_is_today(self, tmp_path):
        """timestamp 미제공 시 현재 날짜를 사용한다."""
        mem = ConversationMemory(storage_base=str(tmp_path))
        path = mem._today_jsonl_path()
        today = datetime.now().strftime("%Y-%m-%d")
        assert path is not None
        assert today in str(path)

    def test_message_written_to_correct_date_file(self, tmp_path):
        """메시지 timestamp 기준으로 올바른 날짜 파일에 기록된다."""
        mem = ConversationMemory(storage_base=str(tmp_path))
        chat_id = 32001

        mem.add_message(chat_id, "제이회장님", "오늘 메시지", is_bot=False)

        today = datetime.now().strftime("%Y-%m-%d")
        jsonl_path = tmp_path / f"{today}.jsonl"
        assert jsonl_path.exists()


class TestTopicConfirmation:
    """pending topic이 요약 시 확정되는 로직 테스트"""

    def test_pending_topic_confirmed_after_summary(self, tmp_path):
        """pending 상태의 topic이 _generate_summary 후 확정된다."""
        mem = ConversationMemory(storage_base=str(tmp_path))
        chat_id = 33001
        mem.add_message(chat_id, "잼민이", "다른 주제로 가죠", is_bot=True)
        # 위 메시지에서 "다른 주제" 키워드로 pending 설정됨

        async def run():
            with patch("conversation_memory.call_claude", new_callable=AsyncMock) as mock_claude:
                mock_claude.return_value = json.dumps(
                    {
                        "summary": "요약",
                        "key_topics": [],
                        "topic_tag": "ai_tech",
                        "key_decisions": [],
                        "action_items": [],
                        "consensus_level": "exploratory",
                    }
                )
                mem._current_topic[chat_id] = "pending"
                await mem._generate_summary(chat_id)
                assert mem._current_topic[chat_id] == "ai_tech"

        asyncio.run(run())

    def test_non_pending_topic_not_changed(self, tmp_path):
        """pending이 아닌 topic은 _generate_summary에서 변경되지 않는다."""
        mem = ConversationMemory(storage_base=str(tmp_path))
        chat_id = 33002
        mem.add_message(chat_id, "잼민이", "이야기 계속", is_bot=True)
        mem._current_topic[chat_id] = "existing_topic"

        async def run():
            with patch("conversation_memory.call_claude", new_callable=AsyncMock) as mock_claude:
                mock_claude.return_value = json.dumps(
                    {
                        "summary": "요약",
                        "key_topics": [],
                        "topic_tag": "new_topic",
                        "key_decisions": [],
                        "action_items": [],
                        "consensus_level": "exploratory",
                    }
                )
                await mem._generate_summary(chat_id)
                assert mem._current_topic[chat_id] == "existing_topic"

        asyncio.run(run())

    def test_pending_topic_with_fallback_to_general(self, tmp_path):
        """LLM이 topic_tag를 주지 않으면 pending이 'general'로 확정된다."""
        mem = ConversationMemory(storage_base=str(tmp_path))
        chat_id = 33003
        mem.add_message(chat_id, "잼민이", "테스트", is_bot=True)
        mem._current_topic[chat_id] = "pending"

        async def run():
            with patch("conversation_memory.call_claude", new_callable=AsyncMock) as mock_claude:
                mock_claude.return_value = "일반 텍스트 (JSON 파싱 실패)"
                await mem._generate_summary(chat_id)
                # fallback 시 topic_slug는 "general"
                assert mem._current_topic[chat_id] == "general"

        asyncio.run(run())


class TestGetAllSummaryFiles:
    """get_all_summary_files 메서드 테스트"""

    def test_get_all_summary_files_returns_list(self, tmp_path):
        """get_all_summary_files가 리스트를 반환한다."""
        mem = ConversationMemory(storage_base=str(tmp_path))
        # summaries 디렉토리 생성
        summaries_dir = tmp_path / "summaries"
        summaries_dir.mkdir(parents=True)

        result = mem.get_all_summary_files()
        assert isinstance(result, list)

    def test_get_all_summary_files_includes_filename(self, tmp_path):
        """반환된 각 항목에 filename 필드가 있다."""
        mem = ConversationMemory(storage_base=str(tmp_path))
        summaries_dir = tmp_path / "summaries"
        summaries_dir.mkdir(parents=True)
        today = datetime.now().strftime("%Y-%m-%d")
        fp = summaries_dir / f"{today}_general_001.json"
        fp.write_text(
            json.dumps(
                {
                    "summary": "요약",
                    "date": today,
                    "topic_tag": "general",
                    "key_topics": [],
                    "key_decisions": [],
                    "action_items": [],
                    "consensus_level": "exploratory",
                }
            ),
            encoding="utf-8",
        )

        result = mem.get_all_summary_files()
        assert len(result) == 1
        assert "filename" in result[0]
        assert result[0]["filename"] == f"{today}_general_001"


# ---------------------------------------------------------------------------
# Phase 2 신규 테스트 클래스
# ---------------------------------------------------------------------------


class TestSmartSearch:
    """smart_search(query, chat_id) 메서드 테스트"""

    def _make_summary_file(self, summaries_dir, filename, summary, topic_tag, key_decisions=None):
        """테스트용 요약 JSON 파일을 생성하는 헬퍼."""
        today = datetime.now().strftime("%Y-%m-%d")
        fp = summaries_dir / filename
        data = {
            "timestamp": datetime.now().isoformat(),
            "date": today,
            "summary": summary,
            "topic_tag": topic_tag,
            "key_topics": [topic_tag],
            "key_decisions": key_decisions or [],
            "action_items": [],
            "consensus_level": "exploratory",
            "participants": ["제이회장님"],
        }
        fp.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
        return fp

    def test_smart_search_returns_string(self, tmp_path):
        """smart_search가 문자열을 반환한다."""
        mem = ConversationMemory(storage_base=str(tmp_path))
        chat_id = 40001

        summaries_dir = tmp_path / "summaries"
        summaries_dir.mkdir(parents=True)
        today = datetime.now().strftime("%Y-%m-%d")
        self._make_summary_file(
            summaries_dir,
            f"{today}_ai_tech_001.json",
            "AI 기술 동향 논의",
            "ai_tech",
        )

        async def run():
            with patch("conversation_memory.call_claude", new_callable=AsyncMock) as mock_claude:
                mock_claude.return_value = "AI 관련 검색 결과입니다."
                result = await mem.smart_search("AI", chat_id)
                assert isinstance(result, str)

        asyncio.run(run())

    def test_smart_search_with_matching_keyword(self, tmp_path):
        """요약 파일에 키워드가 매칭되면 LLM 호출 후 결과를 반환한다."""
        mem = ConversationMemory(storage_base=str(tmp_path))
        chat_id = 40002

        summaries_dir = tmp_path / "summaries"
        summaries_dir.mkdir(parents=True)
        today = datetime.now().strftime("%Y-%m-%d")
        self._make_summary_file(
            summaries_dir,
            f"{today}_ai_tech_001.json",
            "AI 기술 동향에 대한 심층 논의",
            "ai_tech",
            key_decisions=["AI 도입 결정"],
        )

        async def run():
            with patch("conversation_memory.call_claude", new_callable=AsyncMock) as mock_claude:
                mock_claude.return_value = "AI 기술 관련 내용이 있습니다."
                result = await mem.smart_search("AI", chat_id)
                assert mock_claude.called
                assert isinstance(result, str)
                assert len(result) > 0

        asyncio.run(run())

    def test_smart_search_no_results(self, tmp_path):
        """매칭 결과가 없을 때 '검색 결과가 없습니다' 메시지를 반환한다."""
        mem = ConversationMemory(storage_base=str(tmp_path))
        chat_id = 40003

        summaries_dir = tmp_path / "summaries"
        summaries_dir.mkdir(parents=True)
        today = datetime.now().strftime("%Y-%m-%d")
        self._make_summary_file(
            summaries_dir,
            f"{today}_weather_001.json",
            "날씨 이야기",
            "weather",
        )

        async def run():
            with patch("conversation_memory.call_claude", new_callable=AsyncMock) as mock_claude:
                result = await mem.smart_search("블록체인", chat_id)
                # 매칭이 없으므로 LLM은 호출되지 않아야 함
                mock_claude.assert_not_called()
                assert "검색 결과가 없습니다" in result

        asyncio.run(run())

    def test_smart_search_rate_limit(self, tmp_path):
        """LLM 예산 초과 시 예산 초과 메시지를 반환한다."""
        mem = ConversationMemory(storage_base=str(tmp_path))
        mem._daily_llm_budget = 2
        chat_id = 40004

        summaries_dir = tmp_path / "summaries"
        summaries_dir.mkdir(parents=True)
        today = datetime.now().strftime("%Y-%m-%d")
        self._make_summary_file(
            summaries_dir,
            f"{today}_ai_001.json",
            "AI 논의 내용",
            "ai",
        )

        # 예산 소진
        mem._llm_call_count[today] = 2

        async def run():
            result = await mem.smart_search("AI", chat_id)
            assert "예산" in result or "초과" in result

        asyncio.run(run())

    def test_smart_search_xml_tag_separation(self, tmp_path):
        """LLM에 전달되는 프롬프트에 <user_content> XML 태그가 포함된다."""
        mem = ConversationMemory(storage_base=str(tmp_path))
        chat_id = 40005

        summaries_dir = tmp_path / "summaries"
        summaries_dir.mkdir(parents=True)
        today = datetime.now().strftime("%Y-%m-%d")
        self._make_summary_file(
            summaries_dir,
            f"{today}_ai_001.json",
            "AI 관련 대화",
            "ai",
        )

        async def run():
            with patch("conversation_memory.call_claude", new_callable=AsyncMock) as mock_claude:
                mock_claude.return_value = "검색 결과"
                await mem.smart_search("AI", chat_id)
                assert mock_claude.called
                call_args = mock_claude.call_args[0][0]
                assert "<user_content>" in call_args
                assert "</user_content>" in call_args

        asyncio.run(run())


class TestDeferredIndexing:
    """지연 인덱싱 — summaries/ 파일 200개 초과 시 _index.json 자동 생성"""

    def _create_summary_files(self, summaries_dir, count):
        """테스트용 요약 파일을 count개 생성하는 헬퍼."""
        today = datetime.now().strftime("%Y-%m-%d")
        for i in range(count):
            fp = summaries_dir / f"{today}_general_{i:04d}.json"
            data = {
                "timestamp": datetime.now().isoformat(),
                "date": today,
                "summary": f"요약 내용 {i}",
                "topic_tag": "general",
                "key_topics": ["general"],
                "key_decisions": [],
                "action_items": [],
                "consensus_level": "exploratory",
                "participants": ["제이회장님"],
            }
            fp.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")

    def test_index_not_created_under_200(self, tmp_path):
        """파일이 200개 이하일 때 _index.json이 생성되지 않는다."""
        mem = ConversationMemory(storage_base=str(tmp_path))

        summaries_dir = tmp_path / "summaries"
        summaries_dir.mkdir(parents=True)
        self._create_summary_files(summaries_dir, 200)

        # get_all_summary_files 호출 — 인덱스 생성 트리거
        mem.get_all_summary_files()

        index_path = summaries_dir / "_index.json"
        assert not index_path.exists()

    def test_index_created_over_200(self, tmp_path):
        """파일이 200개 초과(201개)일 때 _index.json이 자동 생성된다."""
        mem = ConversationMemory(storage_base=str(tmp_path))

        summaries_dir = tmp_path / "summaries"
        summaries_dir.mkdir(parents=True)
        self._create_summary_files(summaries_dir, 201)

        # get_all_summary_files 호출 — 인덱스 생성 트리거
        mem.get_all_summary_files()

        index_path = summaries_dir / "_index.json"
        assert index_path.exists(), "_index.json이 201개 초과 시 생성되어야 한다"

    def test_get_all_summary_files_uses_index(self, tmp_path):
        """_index.json이 존재할 때 get_all_summary_files가 인덱스를 사용한다."""
        mem = ConversationMemory(storage_base=str(tmp_path))

        summaries_dir = tmp_path / "summaries"
        summaries_dir.mkdir(parents=True)
        today = datetime.now().strftime("%Y-%m-%d")

        # 인덱스 파일만 생성 (실제 요약 .json 파일은 없음)
        index_data = [
            {
                "filename": f"{today}_ai_001",
                "date": today,
                "topic_tag": "ai",
                "summary": "AI 관련 요약"[:50],
            }
        ]
        index_path = summaries_dir / "_index.json"
        index_path.write_text(json.dumps(index_data, ensure_ascii=False), encoding="utf-8")

        # 실제 .json 파일이 없어도 인덱스에서 결과를 반환해야 함
        result = mem.get_all_summary_files()

        assert isinstance(result, list)
        # 인덱스가 있으면 인덱스를 읽어 결과를 반환해야 함 (glob 없이도)
        found = any(item.get("topic_tag") == "ai" for item in result)
        assert found, "인덱스의 ai 항목이 결과에 포함되어야 한다"

    def test_index_updated_on_new_summary(self, tmp_path):
        """새 요약이 생성될 때 _index.json이 자동 갱신된다."""
        mem = ConversationMemory(storage_base=str(tmp_path))
        chat_id = 41004

        summaries_dir = tmp_path / "summaries"
        summaries_dir.mkdir(parents=True)
        today = datetime.now().strftime("%Y-%m-%d")

        # 인덱스 파일 미리 생성 (기존 항목 1개)
        index_data = [
            {
                "filename": f"{today}_old_001",
                "date": today,
                "topic_tag": "old_topic",
                "summary": "이전 요약",
            }
        ]
        index_path = summaries_dir / "_index.json"
        index_path.write_text(json.dumps(index_data, ensure_ascii=False), encoding="utf-8")

        # 메시지 추가 후 새 요약 생성
        mem.add_message(chat_id, "잼민이", "새로운 논의 내용", is_bot=True)

        async def run():
            with patch("conversation_memory.call_claude", new_callable=AsyncMock) as mock_claude:
                mock_claude.return_value = json.dumps(
                    {
                        "summary": "새로운 요약 내용",
                        "key_topics": ["신주제"],
                        "topic_tag": "new_topic",
                        "key_decisions": [],
                        "action_items": [],
                        "consensus_level": "exploratory",
                    },
                    ensure_ascii=False,
                )
                await mem._generate_summary(chat_id)

            # 인덱스가 갱신되었는지 확인
            updated_index = json.loads(index_path.read_text(encoding="utf-8"))
            new_topics = [item["topic_tag"] for item in updated_index]
            assert "new_topic" in new_topics, "새 요약의 topic_tag가 인덱스에 추가되어야 한다"

        asyncio.run(run())


class TestJSONLRotation:
    """JSONL rotation — 단일 파일 5만 줄 초과 시 자동 분할"""

    def test_rotation_under_50k_no_split(self, tmp_path):
        """5만 줄 미만일 때 기존 파일에 계속 append하고 _part2 파일이 생성되지 않는다."""
        mem = ConversationMemory(storage_base=str(tmp_path))
        chat_id = 42001

        # 줄 수 캐시를 mock하여 49999로 설정 (메서드가 없으면 AttributeError로 RED)
        today = datetime.now().strftime("%Y-%m-%d")
        jsonl_path = tmp_path / f"{today}.jsonl"
        jsonl_path.write_text("", encoding="utf-8")

        with patch.object(mem, "_get_jsonl_line_count", return_value=49999, create=True):
            mem.add_message(chat_id, "제이회장님", "마지막 메시지", is_bot=False)

        part2_path = tmp_path / f"{today}_part2.jsonl"
        assert not part2_path.exists(), "_part2.jsonl 파일이 생성되면 안 된다"

    def test_rotation_over_50k_creates_part2(self, tmp_path):
        """5만 줄 초과 시 _part2.jsonl 파일이 생성된다."""
        mem = ConversationMemory(storage_base=str(tmp_path))
        chat_id = 42002

        today = datetime.now().strftime("%Y-%m-%d")
        jsonl_path = tmp_path / f"{today}.jsonl"
        jsonl_path.write_text("", encoding="utf-8")

        # 줄 수 캐시를 직접 조작하여 threshold 초과로 설정
        mem._jsonl_line_count[str(jsonl_path)] = 50001

        mem.add_message(chat_id, "제이회장님", "로테이션 트리거 메시지", is_bot=False)

        part2_path = tmp_path / f"{today}_part2.jsonl"
        assert part2_path.exists(), "50001줄 초과 시 _part2.jsonl이 생성되어야 한다"

    def test_load_today_loads_all_parts(self, tmp_path):
        """load_today가 같은 날짜의 모든 part 파일을 로드한다."""
        today = datetime.now().strftime("%Y-%m-%d")
        chat_id = 42003

        # part1 파일 생성
        part1_path = tmp_path / f"{today}.jsonl"
        record1 = json.dumps(
            {
                "sender": "제이회장님",
                "text": "파트1 메시지",
                "timestamp": datetime.now().isoformat(),
                "is_bot": False,
                "chat_id": chat_id,
                "topic_tag": "general",
            },
            ensure_ascii=False,
        )
        part1_path.write_text(record1 + "\n", encoding="utf-8")

        # part2 파일 생성
        part2_path = tmp_path / f"{today}_part2.jsonl"
        record2 = json.dumps(
            {
                "sender": "잼민이",
                "text": "파트2 메시지",
                "timestamp": datetime.now().isoformat(),
                "is_bot": True,
                "chat_id": chat_id,
                "topic_tag": "general",
            },
            ensure_ascii=False,
        )
        part2_path.write_text(record2 + "\n", encoding="utf-8")

        mem = ConversationMemory(storage_base=str(tmp_path))
        mem.load_today(chat_id)

        messages = mem.get_context(chat_id, limit=100)
        texts = [m.text for m in messages]
        assert "파트1 메시지" in texts, "part1 파일의 메시지가 로드되어야 한다"
        assert "파트2 메시지" in texts, "part2 파일의 메시지가 로드되어야 한다"

    def test_line_count_cached(self, tmp_path):
        """줄 수가 캐시되어 매번 파일을 전체 읽지 않는다."""
        mem = ConversationMemory(storage_base=str(tmp_path))

        today = datetime.now().strftime("%Y-%m-%d")
        jsonl_path = tmp_path / f"{today}.jsonl"
        jsonl_path.write_text("line1\nline2\nline3\n", encoding="utf-8")

        # 첫 호출로 캐시 채우기 (파일 읽기 발생)
        path1 = mem._get_jsonl_path_with_rotation(jsonl_path)
        assert str(jsonl_path) in mem._jsonl_line_count, "첫 호출 후 캐시가 채워져야 한다"
        cached_count = mem._jsonl_line_count[str(jsonl_path)]

        # 두 번째 호출: 캐시가 있으므로 파일을 다시 읽지 않음
        with patch("builtins.open", side_effect=IOError("파일 접근 금지")):
            path2 = mem._get_jsonl_path_with_rotation(jsonl_path)

        # 동일 파일 반환 + 캐시 count 증가
        assert path1 == jsonl_path
        assert path2 == jsonl_path
        assert mem._jsonl_line_count[str(jsonl_path)] == cached_count + 1


class TestKoreanPromptHints:
    """한국어 특성 대응 프롬프트 힌트 확인"""

    def test_korean_subject_omission_hint(self, tmp_path):
        """_do_generate_summary 프롬프트에 '주어는 자주 생략' 힌트가 포함된다."""
        mem = ConversationMemory(storage_base=str(tmp_path))
        chat_id = 43001
        mem.add_message(chat_id, "제이회장님", "주어 생략 테스트", is_bot=False)

        async def run():
            with patch("conversation_memory.call_claude", new_callable=AsyncMock) as mock_claude:
                mock_claude.return_value = json.dumps(
                    {
                        "summary": "요약",
                        "key_topics": [],
                        "topic_tag": "general",
                        "key_decisions": [],
                        "action_items": [],
                        "consensus_level": "exploratory",
                    },
                    ensure_ascii=False,
                )
                await mem._do_generate_summary(chat_id)
                call_args = mock_claude.call_args[0][0]
                assert "주어" in call_args and "생략" in call_args

        asyncio.run(run())

    def test_korean_interjection_ignore_hint(self, tmp_path):
        """_do_generate_summary 프롬프트에 '감탄사' 무시 힌트가 포함된다."""
        mem = ConversationMemory(storage_base=str(tmp_path))
        chat_id = 43002
        mem.add_message(chat_id, "제이회장님", "감탄사 테스트 ㅋㅋ", is_bot=False)

        async def run():
            with patch("conversation_memory.call_claude", new_callable=AsyncMock) as mock_claude:
                mock_claude.return_value = json.dumps(
                    {
                        "summary": "요약",
                        "key_topics": [],
                        "topic_tag": "general",
                        "key_decisions": [],
                        "action_items": [],
                        "consensus_level": "exploratory",
                    },
                    ensure_ascii=False,
                )
                await mem._do_generate_summary(chat_id)
                call_args = mock_claude.call_args[0][0]
                assert "감탄사" in call_args

        asyncio.run(run())


# ---------------------------------------------------------------------------
# 롤링 서머리 + format_context phase 테스트 (TDD)
# ---------------------------------------------------------------------------


class TestGetContextDefaultLimit:
    """get_context() 기본 limit 변경 테스트"""

    def test_default_limit_is_30(self):
        """기본 limit이 30으로 변경됨"""
        mem = ConversationMemory(storage_base=None)
        chat_id = 900
        # 35개 메시지 추가
        for i in range(35):
            mem.add_message(chat_id, f"user{i}", f"msg {i}", is_bot=False)
        result = mem.get_context(chat_id)  # 기본 limit
        assert len(result) == 30


class TestRollingSummary:
    """롤링 서머리 테스트"""

    def test_rolling_summary_stored(self):
        """generate_rolling_summary() 호출 시 _rolling_summaries에 저장"""
        from unittest.mock import AsyncMock, patch

        mem = ConversationMemory(storage_base=None)
        chat_id = 910
        for i in range(5):
            mem.add_message(chat_id, f"bot{i}", f"의견 {i}", is_bot=True)

        async def run():
            with patch("conversation_memory.call_claude", new_callable=AsyncMock) as mock_claude:
                mock_claude.return_value = "요약: 핵심 논점 정리..."
                result = await mem.generate_rolling_summary(chat_id)
            assert result == "요약: 핵심 논점 정리..."
            assert mem._rolling_summaries[chat_id] == "요약: 핵심 논점 정리..."

        asyncio.run(run())

    def test_rolling_summary_empty_messages(self):
        """메시지 없으면 빈 문자열 반환"""
        mem = ConversationMemory(storage_base=None)

        async def run():
            result = await mem.generate_rolling_summary(999)
            assert result == ""

        asyncio.run(run())

    def test_rolling_summary_budget_exceeded(self):
        """LLM 예산 초과 시 기존 서머리 반환"""
        mem = ConversationMemory(storage_base=None)
        chat_id = 911
        for i in range(5):
            mem.add_message(chat_id, f"bot{i}", f"의견 {i}", is_bot=True)

        # 예산 소진
        today = datetime.now().strftime("%Y-%m-%d")
        mem._llm_call_count[today] = 50
        mem._rolling_summaries[chat_id] = "이전 요약"

        async def run():
            result = await mem.generate_rolling_summary(chat_id)
            assert result == "이전 요약"

        asyncio.run(run())


class TestFormatContextWithPhase:
    """format_context() Phase 프롬프트 테스트"""

    def test_diverge_phase_prompt(self):
        """phase='diverge'일 때 발산 프롬프트"""
        mem = ConversationMemory(storage_base=None)
        chat_id = 920
        mem.add_message(chat_id, "잼민이", "의견입니다", is_bot=True)
        result = mem.format_context(chat_id, "gemini_view_bot", phase="diverge")
        assert "새로운 관점과 아이디어를 자유롭게 제시하라" in result

    def test_converge_phase_prompt(self):
        """phase='converge'일 때 수렴 프롬프트"""
        mem = ConversationMemory(storage_base=None)
        chat_id = 921
        mem.add_message(chat_id, "잼민이", "의견입니다", is_bot=True)
        result = mem.format_context(chat_id, "gemini_view_bot", phase="converge")
        assert "공통점을 먼저 정리하라" in result

    def test_consensus_phase_prompt(self):
        """phase='consensus'일 때 합의 프롬프트"""
        mem = ConversationMemory(storage_base=None)
        chat_id = 922
        mem.add_message(chat_id, "잼민이", "의견입니다", is_bot=True)
        result = mem.format_context(chat_id, "gemini_view_bot", phase="consensus")
        assert "최종 합의문을 작성하라" in result

    def test_no_phase_default_prompt(self):
        """phase=None일 때 기존 기본 프롬프트"""
        mem = ConversationMemory(storage_base=None)
        chat_id = 923
        mem.add_message(chat_id, "잼민이", "의견입니다", is_bot=True)
        result = mem.format_context(chat_id, "gemini_view_bot")
        assert "중복되지 않는 새로운 관점을 제시해라" in result

    def test_rolling_summary_included(self):
        """롤링 서머리가 format_context 결과에 포함"""
        mem = ConversationMemory(storage_base=None)
        chat_id = 924
        mem.add_message(chat_id, "잼민이", "의견입니다", is_bot=True)
        mem._rolling_summaries[chat_id] = "이것은 롤링 서머리입니다"
        result = mem.format_context(chat_id, "gemini_view_bot")
        assert "[현재 토론 요약]" in result
        assert "이것은 롤링 서머리입니다" in result
