"""Unified memory search: tiered merge, provenance, freshness."""

from datetime import datetime, timedelta, timezone

from navi.memory.search import UnifiedMemorySearch, format_hits

NOW = datetime(2026, 11, 9, 12, 0, tzinfo=timezone.utc)


class FakeMemory:
    def __init__(self, facts=None, chunks=None, summary_at=None):
        self.facts = facts or []
        self.chunks = chunks or []
        self.summary_at = summary_at
        self.calls = []

    async def search_facts(self, query, user_id=None, limit=15):
        self.calls.append(("facts", user_id, limit))
        return self.facts[:limit]

    async def search_chunks(self, query, user_id=None, session_id=None, limit=8):
        self.calls.append(("raw", user_id, session_id, limit))
        return self.chunks[:limit]

    async def latest_summary_at(self, session_id):
        return self.summary_at


class FakeKv:
    def __init__(self, rows=None):
        self.rows = rows or []
        self.calls = []

    async def search_values(self, user_id, scope, terms, limit=10, session_id=None, prefer_session_id=None):
        self.calls.append((user_id, scope, tuple(terms), limit, prefer_session_id))
        return self.rows[:limit]


def _fact(key, value, updated=NOW, category="technical"):
    return {
        "id": key, "category": category, "key": key, "value": value,
        "updated_at": updated, "source_session_id": "sess-fact",
    }


def _chunk(content, session="s1", start=1, end=2, ended=NOW, score=0.9):
    return {
        "id": f"chunk-{content[:6]}", "session_id": session,
        "start_seq": start, "end_seq": end, "started_at": None,
        "ended_at": ended, "content": content, "breadcrumbs": ["port"],
        "created_at": ended, "score": score, "terms_hit": 0,
    }


class TestTiering:
    async def test_facts_come_first_then_raw_then_sketches(self):
        memory = FakeMemory(facts=[_fact("server_port", "8099")], chunks=[_chunk("port was 8098")])
        kv = FakeKv([{"session_id": "s1", "key": "findings", "value": "port 8098", "updated_at": NOW}])
        search = UnifiedMemorySearch(memory, kv)

        hits = await search.search("port", user_id="u1")

        assert [h.source_type for h in hits] == ["fact", "raw", "sketch"]

    async def test_each_scope_reads_only_its_store(self):
        memory = FakeMemory(facts=[_fact("k", "v")], chunks=[_chunk("c")])
        kv = FakeKv([{"session_id": "s1", "key": "k", "value": "v", "updated_at": NOW}])
        search = UnifiedMemorySearch(memory, kv)

        assert [h.source_type for h in await search.search("q", scope="facts")] == ["fact"]
        assert [h.source_type for h in await search.search("q", scope="raw")] == ["raw"]
        assert [h.source_type for h in await search.search("q", scope="sketches")] == ["sketch"]

    async def test_an_unknown_scope_behaves_like_all(self):
        memory = FakeMemory(facts=[_fact("k", "v")])
        search = UnifiedMemorySearch(memory, FakeKv())
        hits = await search.search("q", scope="nonsense")
        assert [h.source_type for h in hits] == ["fact"]

    async def test_an_empty_query_returns_nothing_without_touching_stores(self):
        memory = FakeMemory(facts=[_fact("k", "v")])
        search = UnifiedMemorySearch(memory, FakeKv())
        assert await search.search("   ") == []
        assert memory.calls == []

    async def test_k_is_clamped(self):
        memory = FakeMemory(facts=[_fact("k", "v")])
        kv = FakeKv()
        search = UnifiedMemorySearch(memory, kv)
        await search.search("q", k=999)
        await search.search("q", k=0)   # falsy k falls back to the default
        fact_calls = [c for c in memory.calls if c[0] == "facts"]
        assert fact_calls[0][2] == 20
        assert fact_calls[1][2] == 5

    async def test_a_failing_store_does_not_sink_the_others(self):
        class BrokenMemory(FakeMemory):
            async def search_facts(self, query, user_id=None, limit=15):
                raise RuntimeError("db down")

        memory = BrokenMemory(chunks=[_chunk("recoverable")])
        search = UnifiedMemorySearch(memory, FakeKv())
        hits = await search.search("q")
        assert [h.source_type for h in hits] == ["raw"]


class TestProvenance:
    async def test_a_fact_carries_category_key_and_update_time(self):
        search = UnifiedMemorySearch(FakeMemory(facts=[_fact("server_port", "8099")]), FakeKv())
        hit = (await search.search("port"))[0]
        assert hit.category == "technical"
        assert hit.key == "server_port"
        assert hit.timestamp == NOW
        assert hit.session_id == "sess-fact"

    async def test_raw_carries_session_and_turn_range(self):
        search = UnifiedMemorySearch(FakeMemory(chunks=[_chunk("body", start=12, end=14)]), FakeKv())
        hit = (await search.search("body"))[0]
        assert hit.session_id == "s1"
        assert (hit.start_seq, hit.end_seq) == (12, 14)
        assert hit.breadcrumbs == ("port",)

    async def test_a_sketch_carries_its_section(self):
        kv = FakeKv([{"session_id": "s9", "key": "findings", "value": "x", "updated_at": NOW}])
        hit = (await UnifiedMemorySearch(FakeMemory(), kv).search("x"))[0]
        assert (hit.session_id, hit.key) == ("s9", "findings")


class TestFreshness:
    async def test_a_newer_fact_supersedes_an_older_raw_excerpt(self):
        memory = FakeMemory(
            facts=[_fact("server_port", "8099 is the server port", updated=NOW)],
            chunks=[_chunk("the server port was 8098", ended=NOW - timedelta(days=3))],
        )
        hit = (await UnifiedMemorySearch(memory, FakeKv()).search("server port"))[1]
        assert hit.superseded_by is not None
        assert "server_port" in hit.superseded_by

    async def test_raw_scope_still_labels_a_newer_fact(self):
        # Recall asks for raw only, but an outdated excerpt shown as
        # authoritative is worse than the extra facts query. The facts are used
        # for the label and are not returned.
        memory = FakeMemory(
            facts=[_fact("server_port", "8099 is the server port", updated=NOW)],
            chunks=[_chunk("the server port was 8098", ended=NOW - timedelta(days=3))],
        )
        hits = await UnifiedMemorySearch(memory, FakeKv()).search("server port", scope="raw")
        assert [h.source_type for h in hits] == ["raw"]
        assert hits[0].superseded_by is not None

    async def test_an_older_fact_does_not_supersede_a_newer_excerpt(self):
        memory = FakeMemory(
            facts=[_fact("server_port", "8099 is the server port", updated=NOW - timedelta(days=3))],
            chunks=[_chunk("the server port is 8100 now", ended=NOW)],
        )
        hit = (await UnifiedMemorySearch(memory, FakeKv()).search("server port"))[1]
        assert hit.superseded_by is None

    async def test_unrelated_facts_do_not_supersede(self):
        memory = FakeMemory(
            facts=[_fact("editor", "neovim", updated=NOW)],
            chunks=[_chunk("the server port was 8098", ended=NOW - timedelta(days=1))],
        )
        hit = (await UnifiedMemorySearch(memory, FakeKv()).search("server port"))[1]
        assert hit.superseded_by is None

    async def test_raw_older_than_the_current_summary_is_marked(self):
        memory = FakeMemory(
            chunks=[_chunk("old turn", ended=NOW - timedelta(days=2))],
            summary_at=NOW,
        )
        hit = (await UnifiedMemorySearch(memory, FakeKv()).search(
            "old", current_session_id="s1", summary_at=NOW,
        ))[0]
        assert hit.before_summary is True

    async def test_iso_string_timestamps_are_normalized(self):
        # `search_facts` returns `updated_at` as an ISO string (the facts store
        # formats it for the API), while chunks carry real datetimes. Comparing
        # the two used to raise TypeError and take the whole search down.
        memory = FakeMemory(
            facts=[_fact("server_port", "8099 is the server port", updated=NOW.isoformat())],
            chunks=[_chunk("the server port was 8098", ended=NOW - timedelta(days=3))],
        )
        hits = await UnifiedMemorySearch(memory, FakeKv()).search("server port")
        fact, raw = hits[0], hits[1]
        assert isinstance(fact.timestamp, datetime)
        assert raw.superseded_by is not None

    async def test_another_sessions_summary_does_not_mark_this_chunk(self):
        memory = FakeMemory(chunks=[_chunk("old turn", session="s2", ended=NOW - timedelta(days=2))])
        hit = (await UnifiedMemorySearch(memory, FakeKv()).search(
            "old", current_session_id="s1", summary_at=NOW,
        ))[0]
        assert hit.before_summary is False


class TestFormatting:
    async def test_every_line_names_its_source(self):
        memory = FakeMemory(
            facts=[_fact("server_port", "8099")],
            chunks=[_chunk("verbatim body", start=4, end=4)],
        )
        kv = FakeKv([{"session_id": "s3", "key": "findings", "value": "sketch body", "updated_at": NOW}])
        hits = await UnifiedMemorySearch(memory, kv).search("body port")
        text = format_hits(hits, "body port", "all")

        assert 'memory_search "body port" (scope=all): 3 hit(s)' in text
        assert "fact [technical]" in text
        assert "raw · session s1 · turn 4" in text
        assert "sketch · session s3 · findings" in text
        assert "keys: port" in text

    async def test_an_outdated_raw_excerpt_says_so(self):
        memory = FakeMemory(
            facts=[_fact("server_port", "8099 is the server port", updated=NOW)],
            chunks=[_chunk("the server port was 8098", ended=NOW - timedelta(days=3))],
        )
        hits = await UnifiedMemorySearch(memory, FakeKv()).search("server port")
        text = format_hits(hits, "server port", "all")
        assert "outdated: a newer curated fact overrides this" in text

    def test_no_hits_says_so(self):
        text = format_hits([], "nothing", "all")
        assert "0 hit(s)" in text
        assert "No matches" in text
