"""Tests for EpisodeDigestor — combine, closed session detection, tick cycle."""

import time
from unittest.mock import AsyncMock

import pytest

from neo_cortex.conversation_log import ConversationLog
from neo_cortex.digestor import EpisodeDigestor, SESSION_CLOSED_SECS
from neo_cortex.models import ConversationAppendRequest, ConversationEntry


def make_entry(
    role: str = "user",
    content: str = "hello",
    session_id: str = "sess-001",
    timestamp: float | None = None,
) -> ConversationEntry:
    return ConversationEntry(
        id=0,
        timestamp=timestamp or time.time(),
        session_id=session_id,
        role=role,
        content=content,
    )


@pytest.fixture
def digestor():
    """Digestor with mocked cortex (no real API calls)."""
    cortex = AsyncMock()
    cortex.ingest_episode = AsyncMock(return_value="ep_test_12345")
    return EpisodeDigestor(log=None, cortex=cortex)


def _make_closed_log(tmp_path, sessions: dict[str, int]) -> ConversationLog:
    """Create a log with closed sessions (old timestamps)."""
    db = str(tmp_path / "log.db")
    log = ConversationLog(db)
    old_ts = time.time() - SESSION_CLOSED_SECS - 60  # well in the past
    for sid, count in sessions.items():
        for i in range(count):
            log.append(ConversationAppendRequest(
                session_id=sid,
                role="user" if i % 2 == 0 else "assistant",
                content=f"message {i}",
                timestamp=old_ts + i,
            ))
    return log


class TestCombineEntries:
    def test_combine_user_assistant_turns(self, digestor):
        """Combines user+assistant turns into readable episode text."""
        entries = [
            make_entry(role="user", content="Usiamo NetworkX o Kuzu?"),
            make_entry(role="assistant", content="NetworkX è zero-dep, Kuzu serve solo >10k nodi..."),
            make_entry(role="user", content="ok, va bene"),
            make_entry(role="assistant", content="Perfetto, uso NetworkX."),
        ]
        text = digestor._combine_entries(entries)
        assert "NetworkX" in text
        assert "ok, va bene" in text
        assert "Kuzu" in text

    def test_combine_skips_tool_result_verbatim(self, digestor):
        """tool_result content is not included verbatim."""
        entries = [
            make_entry(role="user", content="leggi il file"),
            make_entry(role="tool_use", content='{"tool": "Read", "input": {"path": "/foo/bar.py"}}'),
            make_entry(role="tool_result", content="x" * 5000),
            make_entry(role="assistant", content="Il file contiene la classe Foo..."),
        ]
        text = digestor._combine_entries(entries)
        assert len(text) < 1000
        assert "Read" in text or "bar.py" in text

    def test_combine_respects_max_length(self, digestor):
        """Episode truncated if too long."""
        entries = [make_entry(role="assistant", content="x" * 5000) for _ in range(5)]
        text = digestor._combine_entries(entries)
        assert len(text) <= 8500

    def test_combine_empty_entries(self, digestor):
        assert digestor._combine_entries([]) == ""

    def test_combine_strips_thinking_tags(self, digestor):
        """<thinking> blocks are removed from assistant content."""
        entries = [
            make_entry(role="assistant", content="<thinking>internal reasoning</thinking>The actual answer."),
        ]
        text = digestor._combine_entries(entries)
        assert "internal reasoning" not in text
        assert "The actual answer" in text


class TestClosedSessionDetection:
    @pytest.mark.asyncio
    async def test_closed_session_gets_digested(self, tmp_path):
        """Session with old timestamps → digested."""
        log = _make_closed_log(tmp_path, {"sess-old": 6})
        cortex = AsyncMock()
        cortex.ingest_episode = AsyncMock(return_value="ep_old_1")
        d = EpisodeDigestor(log=log, cortex=cortex)
        await d.tick()
        assert cortex.ingest_episode.call_count == 1
        assert log.get_undigested() == []

    @pytest.mark.asyncio
    async def test_open_session_skipped(self, tmp_path):
        """Session with recent timestamps → left alone."""
        db = str(tmp_path / "log.db")
        log = ConversationLog(db)
        for i in range(5):
            log.append(ConversationAppendRequest(
                session_id="sess-open",
                role="user" if i % 2 == 0 else "assistant",
                content=f"msg {i}",
                # default timestamp = now → session is open
            ))
        cortex = AsyncMock()
        cortex.ingest_episode = AsyncMock(return_value="ep_x")
        d = EpisodeDigestor(log=log, cortex=cortex)
        await d.tick()
        assert cortex.ingest_episode.call_count == 0
        assert len(log.get_undigested()) == 5  # untouched

    @pytest.mark.asyncio
    async def test_mixed_closed_and_open(self, tmp_path):
        """Closed sessions digested, open sessions left alone."""
        db = str(tmp_path / "log.db")
        log = ConversationLog(db)
        old_ts = time.time() - SESSION_CLOSED_SECS - 60
        # Closed session
        for i in range(4):
            log.append(ConversationAppendRequest(
                session_id="sess-closed",
                role="user" if i % 2 == 0 else "assistant",
                content=f"old {i}",
                timestamp=old_ts + i,
            ))
        # Open session
        for i in range(3):
            log.append(ConversationAppendRequest(
                session_id="sess-open",
                role="user" if i % 2 == 0 else "assistant",
                content=f"new {i}",
            ))
        cortex = AsyncMock()
        cortex.ingest_episode = AsyncMock(return_value="ep_closed_1")
        d = EpisodeDigestor(log=log, cortex=cortex)
        await d.tick()
        assert cortex.ingest_episode.call_count == 1  # only closed
        remaining = log.get_undigested()
        assert len(remaining) == 3  # open session untouched
        assert all(e.session_id == "sess-open" for e in remaining)


class TestTickDrainsAll:
    @pytest.mark.asyncio
    async def test_tick_digests_multiple_sessions(self, tmp_path):
        """tick() digests all closed sessions in one call."""
        log = _make_closed_log(tmp_path, {"s1": 5, "s2": 8, "s3": 3})
        cortex = AsyncMock()
        cortex.ingest_episode = AsyncMock(return_value="ep_x")
        d = EpisodeDigestor(log=log, cortex=cortex)
        await d.tick()
        assert cortex.ingest_episode.call_count == 3
        assert log.get_undigested() == []

    @pytest.mark.asyncio
    async def test_tick_noop_when_empty(self, tmp_path):
        """tick() does nothing when log is empty."""
        db = str(tmp_path / "log.db")
        log = ConversationLog(db)
        cortex = AsyncMock()
        d = EpisodeDigestor(log=log, cortex=cortex)
        await d.tick()
        assert cortex.ingest_episode.call_count == 0

    @pytest.mark.asyncio
    async def test_double_tick_no_double_process(self, tmp_path):
        """Second tick doesn't re-process already digested sessions."""
        log = _make_closed_log(tmp_path, {"s1": 5, "s2": 4})
        cortex = AsyncMock()
        cortex.ingest_episode = AsyncMock(return_value="ep_x")
        d = EpisodeDigestor(log=log, cortex=cortex)
        await d.tick()
        await d.tick()
        assert cortex.ingest_episode.call_count == 2  # not 4

    @pytest.mark.asyncio
    async def test_whole_session_as_one_episode(self, tmp_path):
        """Each session is digested as one episode, not chunked."""
        log = _make_closed_log(tmp_path, {"big-session": 50})
        cortex = AsyncMock()
        cortex.ingest_episode = AsyncMock(return_value="ep_big")
        d = EpisodeDigestor(log=log, cortex=cortex)
        await d.tick()
        # One call, not 5 chunks of 10
        assert cortex.ingest_episode.call_count == 1
        text = cortex.ingest_episode.call_args[0][0]
        assert "message 0" in text
        assert "message 49" in text
