"""Unit tests for memory extractor."""

import pytest

from navi.llm.base import Message, ToolCallRequest
from navi.memory.extractor import extract_and_update, _extract_facts
from tests.conftest_factory import FakeConnection, FakeLLMBackend, FakeRecord, make_store_with_pool


class FakeSession:
    def __init__(self, messages, session_id="sess-1", user_id=None):
        self.id = session_id
        self.messages = messages
        self.user_id = user_id


class TestExtractFacts:
    async def test_extracts_single_fact(self):
        backend = FakeLLMBackend(responses=[
            '[{"category": "profile", "key": "name", "value": "Eugene", "source": "conversation"}]'
        ])
        conn = FakeConnection()
        conn.enqueue([])  # no key aliases to merge
        conn.enqueue("INSERT 0 1")
        store = make_store_with_pool(conn)
        session = FakeSession([
            Message(role="user", content="My name is Eugene"),
        ])
        count = await _extract_facts(session, backend, "test-model", store)
        assert count == 1
        assert conn.calls[0][0] == "fetch"
        assert "user_id IS NOT DISTINCT FROM $1" in conn.calls[0][1]
        assert conn.calls[1][0] == "execute"
        assert "memory_facts" in conn.calls[1][1]

    async def test_merges_a_key_written_under_a_new_spelling(self):
        """The extractor invents `preferred_language` for a fact it already stored as
        `language`, and the exact-key upsert cannot see it — that is how the base
        grew 11 spellings of the same handful of keys.
        """
        backend = FakeLLMBackend(responses=[
            '[{"category": "preferences", "key": "preferred_language", "value": "ru"}]'
        ])
        conn = FakeConnection()
        conn.enqueue([FakeRecord(id="old-1", key="language", merged_into=None)])
        conn.enqueue("UPDATE 1")   # the mark, written before the row it points at
        conn.enqueue("INSERT 0 1")  # the new row itself
        store = make_store_with_pool(conn)
        session = FakeSession([Message(role="user", content="I prefer Russian")])
        count = await _extract_facts(session, backend, "test-model", store)
        assert count == 1

        op, query, args = conn.calls[1]
        assert op == "execute"
        assert "SET merged_into" in query
        inserted_id = conn.calls[2][2][0]
        assert args == (["old-1"], inserted_id)  # the alias points at the fresher row

    async def test_merging_a_key_that_is_already_hidden(self):
        """The second write of a spelling that was hidden by the first is the case
        the store tests cover in isolation; here it has to survive the extractor's
        whole path, because this is the shape the prod database was found in.
        """
        backend = FakeLLMBackend(responses=[
            '[{"category": "preferences", "key": "language", "value": "ru"}]'
        ])
        conn = FakeConnection()
        conn.enqueue([
            FakeRecord(id="exact-1", key="language", merged_into="live-1"),
            FakeRecord(id="live-1", key="preferred_language", merged_into=None),
        ])
        conn.enqueue("UPDATE 1")  # un-hide the row the upsert lands on
        conn.enqueue("UPDATE 1")  # hide the spelling that was live
        conn.enqueue("INSERT 0 1")
        store = make_store_with_pool(conn)
        session = FakeSession([Message(role="user", content="I prefer Russian")])
        assert await _extract_facts(session, backend, "test-model", store) == 1
        assert "SET merged_into = NULL" in conn.calls[1][1]
        assert conn.calls[2][2] == (["live-1"], "exact-1")

    async def test_leaves_a_different_key_alone(self):
        """Token equality, not overlap: `dark` and `dark_ui` are two keys."""
        backend = FakeLLMBackend(responses=[
            '[{"category": "preferences", "key": "dark_ui", "value": "true"}]'
        ])
        conn = FakeConnection()
        conn.enqueue([FakeRecord(id="old-1", key="dark", merged_into=None)])
        conn.enqueue("INSERT 0 1")
        store = make_store_with_pool(conn)
        session = FakeSession([Message(role="user", content="hi")])
        await _extract_facts(session, backend, "test-model", store)
        assert not any("SET merged_into" in c[1] for c in conn.calls)

    async def test_invalid_json_returns_zero(self):
        backend = FakeLLMBackend(responses=["not json"])
        conn = FakeConnection()
        store = make_store_with_pool(conn)
        session = FakeSession([Message(role="user", content="hi")])
        count = await _extract_facts(session, backend, "test-model", store)
        assert count == 0
        assert len(conn.calls) == 0

    async def test_empty_array_returns_zero(self):
        backend = FakeLLMBackend(responses=["[]"])
        conn = FakeConnection()
        store = make_store_with_pool(conn)
        session = FakeSession([Message(role="user", content="hi")])
        count = await _extract_facts(session, backend, "test-model", store)
        assert count == 0

    async def test_skips_invalid_fact_dict(self):
        backend = FakeLLMBackend(responses=[
            '[{"category": "profile", "key": "name", "value": "Eugene"}, "not a dict"]'
        ])
        conn = FakeConnection()
        conn.enqueue([])
        conn.enqueue("INSERT 0 1")
        store = make_store_with_pool(conn)
        session = FakeSession([Message(role="user", content="hi")])
        count = await _extract_facts(session, backend, "test-model", store)
        assert count == 1

    async def test_ignores_unknown_category(self):
        backend = FakeLLMBackend(responses=[
            '[{"category": "profile", "key": "name", "value": "Eugene"}]'
        ])
        conn = FakeConnection()
        conn.enqueue([])
        conn.enqueue("INSERT 0 1")
        store = make_store_with_pool(conn)
        session = FakeSession([Message(role="user", content="hi")])
        count = await _extract_facts(session, backend, "test-model", store)
        assert count == 1

    async def test_maps_tool_call_source(self):
        backend = FakeLLMBackend(responses=[
            '[{"category": "technical", "key": "ip", "value": "10.0.0.1", "source": "tool_call"}]'
        ])
        conn = FakeConnection()
        conn.enqueue([])
        conn.enqueue("INSERT 0 1")
        store = make_store_with_pool(conn)
        session = FakeSession([Message(role="user", content="hi")])
        count = await _extract_facts(session, backend, "test-model", store)
        assert count == 1
        # Confidence for tool_call is 95
        call_args = conn.calls[1][2]
        assert 95 in call_args  # confidence parameter

    async def test_truncates_long_tool_results(self):
        backend = FakeLLMBackend(responses=["[]"])
        conn = FakeConnection()
        store = make_store_with_pool(conn)
        long_result = "x" * 1000
        session = FakeSession([
            Message(
                role="assistant",
                tool_calls=[ToolCallRequest(id="1", name="terminal", arguments={})],
            ),
            Message(role="tool", content=long_result, name="terminal", tool_call_id="1"),
        ])
        await _extract_facts(session, backend, "test-model", store)
        # The transcript should be truncated
        prompt = backend._responses  # not directly accessible, but we can check the call

    async def test_no_messages_returns_zero(self):
        backend = FakeLLMBackend()
        conn = FakeConnection()
        store = make_store_with_pool(conn)
        session = FakeSession([])
        count = await _extract_facts(session, backend, "test-model", store)
        assert count == 0
        assert len(conn.calls) == 0


class TestExtractAndUpdate:
    async def test_calls_mark_extracted(self):
        backend = FakeLLMBackend(responses=["[]"])
        conn = FakeConnection()
        conn.enqueue("OK")  # mark_session_extracted
        store = make_store_with_pool(conn)
        session = FakeSession([Message(role="user", content="hi")], user_id="user-1")
        await extract_and_update(session, backend, "test-model", store)
        assert any("session_memory_state" in c[1] for c in conn.calls)

    async def test_regenerates_summary_when_facts_added(self):
        backend = FakeLLMBackend(responses=[
            '[{"category": "profile", "key": "name", "value": "Eugene"}]',  # extract
            "Summary: Eugene is the user.",  # regenerate summary
        ])
        conn = FakeConnection()
        conn.enqueue([])  # upsert_fact: no key aliases
        conn.enqueue("INSERT 0 1")  # upsert_fact
        conn.enqueue("OK")  # mark_session_extracted
        conn.enqueue([  # get_all_facts in _regenerate_summary
            FakeRecord(id="1", category="profile", key="name", value="Eugene",
                       updated_at=None, source="conversation", confidence=90,
                       expires_at=None, source_context=""),
        ])
        conn.enqueue("OK")  # set_summary
        store = make_store_with_pool(conn)
        session = FakeSession([Message(role="user", content="My name is Eugene")], user_id="user-1")
        await extract_and_update(session, backend, "test-model", store)
        assert any("memory_summary" in c[1] for c in conn.calls)

    async def test_no_summary_regeneration_when_no_facts(self):
        backend = FakeLLMBackend(responses=["[]"])
        conn = FakeConnection()
        conn.enqueue("OK")  # mark_session_extracted
        store = make_store_with_pool(conn)
        session = FakeSession([Message(role="user", content="hi")], user_id="user-1")
        await extract_and_update(session, backend, "test-model", store)
        # Should NOT call get_all_facts or set_summary
        assert not any("memory_summary" in c[1] for c in conn.calls)

    async def test_skips_legacy_sessions(self):
        backend = FakeLLMBackend(responses=["[]"])
        conn = FakeConnection()
        store = make_store_with_pool(conn)
        session = FakeSession([Message(role="user", content="hi")], user_id=None)
        await extract_and_update(session, backend, "test-model", store)
        # No DB calls for legacy (user_id=None) sessions
        assert len(conn.calls) == 0
