"""Unit tests for the retrieval cascade -- no DB, no real HTTP/Ollama."""
from __future__ import annotations

import pytest

from kb_retrieval.retrieval import (
    DEFAULT_K,
    DEFAULT_N,
    cascade_query,
    cascade_retrieve,
    flat_query,
    flat_retrieve,
    hybrid_query,
    hybrid_retrieve,
)


class _FakeConn:
    """summaries: [(envelope_id, dist), ...] already in distance order (mirrors what the real
    `ORDER BY embedding <=> $1` would hand back). chunks_by_envelope: envelope_id -> [(chunk_index,
    text, dist), ...]. Both stage-1 and the flat path are served from the same fixture so a test
    can assert the cascade excludes chunks the flat path would have surfaced.

    mail_chunks_by_source: source -> [(envelope_id, chunk_index, text, dist), ...] -- served by
    hybrid_retrieve's direct summaryless-source scan (JOIN envelope ... source = ANY), routed
    separately from stage 2's envelope_id = ANY query by checking for "JOIN envelope" first."""

    def __init__(self, summaries=None, chunks_by_envelope=None, mail_chunks_by_source=None):
        self._summaries = list(summaries or [])
        self._chunks_by_envelope = chunks_by_envelope or {}
        self._mail_chunks_by_source = mail_chunks_by_source or {}
        self.queries: list[tuple] = []

    async def fetch(self, query, *params):
        self.queries.append((query, params))
        if "FROM document_summary" in query:
            _, _model, limit = params
            return [{"envelope_id": eid, "dist": dist} for eid, dist in self._summaries[:limit]]
        if "JOIN envelope" in query:  # hybrid's direct summaryless-source chunk scan
            _, sources, limit = params
            rows = [
                {"envelope_id": eid, "chunk_index": idx, "text": text, "dist": dist}
                for source in sources
                for eid, idx, text, dist in self._mail_chunks_by_source.get(source, [])
            ]
            rows.sort(key=lambda r: r["dist"])
            return rows[:limit]
        if "FROM document_chunk" in query and "= ANY" in query:
            _, envelope_ids, limit = params
            rows = [
                {"envelope_id": eid, "chunk_index": idx, "text": text, "dist": dist}
                for eid in envelope_ids
                for idx, text, dist in self._chunks_by_envelope.get(eid, [])
            ]
            rows.sort(key=lambda r: r["dist"])
            return rows[:limit]
        if "FROM document_chunk" in query:  # flat path: no envelope pre-filter
            _, limit = params
            rows = [
                {"envelope_id": eid, "chunk_index": idx, "text": text, "dist": dist}
                for eid, chunk_list in self._chunks_by_envelope.items()
                for idx, text, dist in chunk_list
            ]
            rows.sort(key=lambda r: r["dist"])
            return rows[:limit]
        raise AssertionError(f"unexpected query: {query}")

    async def close(self):
        pass


class _FakeEmbedResponse:
    def __init__(self, payload):
        self._payload = payload

    async def __aenter__(self):
        return self

    async def __aexit__(self, *exc):
        return False

    def raise_for_status(self):
        pass

    async def json(self):
        return self._payload


class _FakeSession:
    def __init__(self):
        self.post_calls: list[dict] = []

    def post(self, url, json):
        self.post_calls.append({"url": url, "json": json})
        return _FakeEmbedResponse({"embedding": [0.01] * 1024})


class TestDefaults:
    def test_plan_start_values(self):
        # plan §6.1: "Start: N=10, k=5" -- a regression guard against silently drifting off
        # the value the quality gate (docs/kb/modules/05-faza3-plan.md §6.2) was run against.
        assert DEFAULT_N == 10
        assert DEFAULT_K == 5


class TestFlatRetrieve:
    async def test_ranks_across_all_envelopes(self):
        conn = _FakeConn(
            chunks_by_envelope={
                "paperless:1": [(0, "a", 0.5)],
                "paperless:2": [(0, "b", 0.1)],
            }
        )
        result = await flat_retrieve(conn, "[0.1]", k=5)
        assert [c["envelope_id"] for c in result] == ["paperless:2", "paperless:1"]
        assert all(c["source"] == "flat" for c in result)

    async def test_respects_k(self):
        conn = _FakeConn(
            chunks_by_envelope={"paperless:1": [(i, f"c{i}", i / 10) for i in range(10)]}
        )
        result = await flat_retrieve(conn, "[0.1]", k=3)
        assert len(result) == 3


class TestCascadeRetrieve:
    async def test_stage1_narrows_stage2(self):
        # paperless:3 has the single closest chunk overall, but it never enters stage 1's
        # top-N summaries -- the cascade must not surface it, unlike a flat scan would.
        conn = _FakeConn(
            summaries=[("paperless:1", 0.1), ("paperless:2", 0.2)],
            chunks_by_envelope={
                "paperless:1": [(0, "a", 0.3)],
                "paperless:2": [(0, "b", 0.4)],
                "paperless:3": [(0, "c", 0.01)],
            },
        )
        result = await cascade_retrieve(conn, "[0.1]", "claude-haiku-4-5", n=2, k=5)
        envelope_ids = {c["envelope_id"] for c in result["chunks"]}
        assert envelope_ids == {"paperless:1", "paperless:2"}
        assert all(c["source"] == "cascade" for c in result["chunks"])

    async def test_envelope_without_active_chunks_yields_no_chunks_for_it(self):
        # e.g. every chunk in this envelope is excluded_reason-flagged (junk/duplicate) --
        # the summary still exists and surfaces in stage 1, stage 2 just finds nothing there.
        conn = _FakeConn(summaries=[("paperless:1", 0.1)], chunks_by_envelope={})
        result = await cascade_retrieve(conn, "[0.1]", "claude-haiku-4-5", n=10, k=5)
        assert result["stage1_summaries"] == [{"envelope_id": "paperless:1", "dist": 0.1}]
        assert result["chunks"] == []

    async def test_n_larger_than_available_summaries_returns_all_of_them(self):
        conn = _FakeConn(
            summaries=[("paperless:1", 0.1), ("paperless:2", 0.2)],
            chunks_by_envelope={"paperless:1": [(0, "a", 0.3)], "paperless:2": [(0, "b", 0.4)]},
        )
        result = await cascade_retrieve(conn, "[0.1]", "claude-haiku-4-5", n=1000, k=5)
        assert len(result["stage1_summaries"]) == 2
        assert {c["envelope_id"] for c in result["chunks"]} == {"paperless:1", "paperless:2"}

    async def test_no_summaries_short_circuits_before_stage2_query(self):
        conn = _FakeConn(summaries=[], chunks_by_envelope={"paperless:1": [(0, "a", 0.1)]})
        result = await cascade_retrieve(conn, "[0.1]", "claude-haiku-4-5", n=10, k=5)
        assert result == {"stage1_summaries": [], "chunks": []}
        assert len(conn.queries) == 1  # stage 2 never ran -- nothing to narrow into


class TestHybridRetrieve:
    async def test_merges_cascade_and_mail_branches_by_dist(self):
        conn = _FakeConn(
            summaries=[("paperless:1", 0.1)],
            chunks_by_envelope={"paperless:1": [(0, "a", 0.3)]},
            mail_chunks_by_source={"gmail": [("msgid1@x", 0, "b", 0.2)]},
        )
        result = await hybrid_retrieve(conn, "[0.1]", "claude-haiku-4-5", ("gmail",), n=10, k=5)
        assert [c["envelope_id"] for c in result["chunks"]] == ["msgid1@x", "paperless:1"]
        assert all(c["source"] == "hybrid" for c in result["chunks"])

    async def test_empty_cascade_branch_still_returns_mail_chunks(self):
        # no summaries at all (e.g. corpus not summarized yet) -- cascade short-circuits to [].
        conn = _FakeConn(
            summaries=[],
            mail_chunks_by_source={"gmail": [("msgid1@x", 0, "b", 0.2)]},
        )
        result = await hybrid_retrieve(conn, "[0.1]", "claude-haiku-4-5", ("gmail",), n=10, k=5)
        assert [c["envelope_id"] for c in result["chunks"]] == ["msgid1@x"]

    async def test_empty_mail_branch_still_returns_cascade_chunks(self):
        conn = _FakeConn(
            summaries=[("paperless:1", 0.1)],
            chunks_by_envelope={"paperless:1": [(0, "a", 0.3)]},
            mail_chunks_by_source={},
        )
        result = await hybrid_retrieve(conn, "[0.1]", "claude-haiku-4-5", ("gmail",), n=10, k=5)
        assert [c["envelope_id"] for c in result["chunks"]] == ["paperless:1"]

    async def test_truncates_merged_results_to_k(self):
        conn = _FakeConn(
            summaries=[("paperless:1", 0.1)],
            chunks_by_envelope={"paperless:1": [(i, f"c{i}", i / 10) for i in range(5)]},
            mail_chunks_by_source={
                "gmail": [(f"msg{i}@x", 0, f"m{i}", i / 10 + 0.05) for i in range(5)]
            },
        )
        result = await hybrid_retrieve(conn, "[0.1]", "claude-haiku-4-5", ("gmail",), n=10, k=3)
        assert len(result["chunks"]) == 3
        dists = [c["dist"] for c in result["chunks"]]
        assert dists == sorted(dists)


class TestQueryEntryPoints:
    async def test_flat_query_embeds_once_and_returns_query_text(self):
        conn = _FakeConn(chunks_by_envelope={"paperless:1": [(0, "a", 0.2)]})
        session = _FakeSession()
        result = await flat_query(conn, session, "http://fake-ollama", "sernik z rodzynkami", k=5)
        assert result["query"] == "sernik z rodzynkami"
        assert len(session.post_calls) == 1
        assert result["chunks"][0]["source"] == "flat"

    async def test_cascade_query_embeds_once_shared_across_both_stages(self):
        conn = _FakeConn(
            summaries=[("paperless:1", 0.1)],
            chunks_by_envelope={"paperless:1": [(0, "a", 0.2)]},
        )
        session = _FakeSession()
        result = await cascade_query(
            conn, session, "http://fake-ollama", "polisa PZU", summary_model="claude-haiku-4-5", n=10, k=5
        )
        assert result["query"] == "polisa PZU"
        assert result["n"] == 10
        assert result["k"] == 5
        # one embed call total, reused for both the stage-1 and stage-2 SQL queries
        assert len(session.post_calls) == 1
        assert result["chunks"][0]["source"] == "cascade"

    async def test_hybrid_query_embeds_once_and_merges(self):
        conn = _FakeConn(
            summaries=[("paperless:1", 0.1)],
            chunks_by_envelope={"paperless:1": [(0, "a", 0.3)]},
            mail_chunks_by_source={"gmail": [("msgid1@x", 0, "b", 0.2)]},
        )
        session = _FakeSession()
        result = await hybrid_query(
            conn, session, "http://fake-ollama", "od kogo ta wiadomosc",
            summary_model="claude-haiku-4-5", summaryless_sources=("gmail",), n=10, k=5,
        )
        assert result["query"] == "od kogo ta wiadomosc"
        assert len(session.post_calls) == 1
        assert [c["envelope_id"] for c in result["chunks"]] == ["msgid1@x", "paperless:1"]
        assert all(c["source"] == "hybrid" for c in result["chunks"])
