"""Unit tests for
`nextcloud_mcp_server.search.context.get_chunk_bbox_and_page_from_qdrant`.

Covers the two paths the helper handles:
- Indexed lookup via `chunk_index` (the preferred path post
  cbcoutinho/astrolabe#75)
- Legacy offset fallback via `(chunk_start_offset, chunk_end_offset)`, which
  may 400 in Qdrant Cloud strict mode

Plus the regression case from PR #767 review: when the payload has
`chunk_bbox` but no `page_number`, the helper must surface that as
`(bbox, None)` so callers can preserve their context-derived page_number
fallback.
"""

from unittest.mock import AsyncMock, MagicMock, patch

import pytest

# Import via the auth surface first to side-step the known
# `nextcloud_mcp_server.search.__init__` circular-init issue (same workaround
# used in test_chunk_context_offset_gate.py).
import nextcloud_mcp_server.auth.viz_routes  # noqa: F401
from nextcloud_mcp_server.search import context as context_module
from nextcloud_mcp_server.search.context import get_chunk_bbox_and_page_from_qdrant

pytestmark = pytest.mark.unit


def _make_point(payload: dict) -> MagicMock:
    point = MagicMock()
    point.payload = payload
    return point


def _patch_qdrant(scroll_return=None, scroll_side_effect=None):
    qdrant_client = MagicMock()
    if scroll_side_effect is not None:
        qdrant_client.scroll = AsyncMock(side_effect=scroll_side_effect)
    else:
        qdrant_client.scroll = AsyncMock(return_value=scroll_return)
    return patch.object(
        context_module,
        "get_qdrant_client",
        new_callable=AsyncMock,
        return_value=qdrant_client,
    ), qdrant_client


class TestIndexedPath:
    """When `chunk_index` is supplied, the helper must use the indexed
    `chunk_index` filter (not the offset fallback)."""

    async def test_returns_bbox_and_page_when_payload_complete(self):
        bbox = [[0, 0, 100, 50]]
        point = _make_point({"chunk_bbox": bbox, "page_number": 7})
        ctx, qdrant_client = _patch_qdrant(scroll_return=([point], None))
        with ctx:
            result = await get_chunk_bbox_and_page_from_qdrant(
                user_id="alice",
                doc_id="42",
                chunk_index=3,
                chunk_start=0,
                chunk_end=100,
            )

        assert result == (bbox, 7)
        # One scroll call, and the filter must include chunk_index (not offsets)
        qdrant_client.scroll.assert_awaited_once()
        scroll_kwargs = qdrant_client.scroll.await_args.kwargs
        # Skip nested Filters (the ACL ownership sub-filter) — only field
        # conditions carry a `.key`.
        filter_keys = [
            c.key for c in scroll_kwargs["scroll_filter"].must if hasattr(c, "key")
        ]
        assert "chunk_index" in filter_keys
        assert "chunk_start_offset" not in filter_keys
        assert "chunk_end_offset" not in filter_keys


class TestOffsetFallbackPath:
    """When `chunk_index` is None, the helper must use the offset filter."""

    async def test_returns_bbox_and_page_when_payload_complete(self):
        bbox = [[10, 20, 110, 70]]
        point = _make_point({"chunk_bbox": bbox, "page_number": 2})
        ctx, qdrant_client = _patch_qdrant(scroll_return=([point], None))
        with ctx:
            result = await get_chunk_bbox_and_page_from_qdrant(
                user_id="bob",
                doc_id="99",
                chunk_index=None,
                chunk_start=500,
                chunk_end=600,
            )

        assert result == (bbox, 2)
        scroll_kwargs = qdrant_client.scroll.await_args.kwargs
        # Skip nested Filters (the ACL ownership sub-filter) — only field
        # conditions carry a `.key`.
        filter_keys = [
            c.key for c in scroll_kwargs["scroll_filter"].must if hasattr(c, "key")
        ]
        assert "chunk_start_offset" in filter_keys
        assert "chunk_end_offset" in filter_keys
        assert "chunk_index" not in filter_keys

    async def test_strict_mode_400_returns_none_pair_and_warns(self, caplog):
        """Qdrant Cloud strict mode 400s on unindexed offset filters; the
        helper must swallow the exception, log a warning, and degrade
        gracefully so the route can still return chunk text."""
        ctx, _ = _patch_qdrant(scroll_side_effect=Exception("strict mode: 400"))
        with ctx, caplog.at_level("WARNING"):
            result = await get_chunk_bbox_and_page_from_qdrant(
                user_id="bob",
                doc_id="99",
                chunk_index=None,
                chunk_start=0,
                chunk_end=100,
            )

        assert result == (None, None)
        assert any("Failed to fetch chunk bbox" in r.message for r in caplog.records)


class TestPayloadShape:
    """Each payload field can be missing independently — callers rely on
    that to decide whether to overwrite their fallback values."""

    async def test_empty_points_returns_none_pair(self):
        ctx, _ = _patch_qdrant(scroll_return=([], None))
        with ctx:
            result = await get_chunk_bbox_and_page_from_qdrant(
                user_id="alice",
                doc_id="1",
                chunk_index=0,
                chunk_start=0,
                chunk_end=10,
            )

        assert result == (None, None)

    async def test_missing_page_returns_bbox_only(self):
        """Regression for PR #767 review issue #1: when Qdrant returns a
        point whose payload lacks `page_number`, the helper must return
        `(bbox, None)` so callers preserve their `chunk_context.page_number`
        fallback rather than clobbering it to None."""
        bbox = [[0, 0, 100, 50]]
        point = _make_point({"chunk_bbox": bbox})  # no page_number
        ctx, _ = _patch_qdrant(scroll_return=([point], None))
        with ctx:
            result = await get_chunk_bbox_and_page_from_qdrant(
                user_id="alice",
                doc_id="42",
                chunk_index=3,
                chunk_start=0,
                chunk_end=100,
            )

        assert result == (bbox, None)

    async def test_missing_bbox_returns_page_only(self):
        point = _make_point({"page_number": 5})  # no chunk_bbox
        ctx, _ = _patch_qdrant(scroll_return=([point], None))
        with ctx:
            result = await get_chunk_bbox_and_page_from_qdrant(
                user_id="alice",
                doc_id="42",
                chunk_index=3,
                chunk_start=0,
                chunk_end=100,
            )

        assert result == (None, 5)

    async def test_empty_payload_returns_none_pair(self):
        point = _make_point({})
        ctx, _ = _patch_qdrant(scroll_return=([point], None))
        with ctx:
            result = await get_chunk_bbox_and_page_from_qdrant(
                user_id="alice",
                doc_id="42",
                chunk_index=3,
                chunk_start=0,
                chunk_end=100,
            )

        assert result == (None, None)

    async def test_falsy_payload_treated_as_no_point(self):
        """`if not points[0].payload` short-circuits when payload is None or
        an empty dict, mirroring the original guards in the route handlers."""
        point = MagicMock()
        point.payload = None
        ctx, _ = _patch_qdrant(scroll_return=([point], None))
        with ctx:
            result = await get_chunk_bbox_and_page_from_qdrant(
                user_id="alice",
                doc_id="42",
                chunk_index=3,
                chunk_start=0,
                chunk_end=100,
            )

        assert result == (None, None)


class TestExceptionHandling:
    """Any error from Qdrant must produce `(None, None)` — never propagate."""

    async def test_indexed_path_exception_returns_none_pair(self, caplog):
        ctx, _ = _patch_qdrant(scroll_side_effect=RuntimeError("qdrant unavailable"))
        with ctx, caplog.at_level("WARNING"):
            result = await get_chunk_bbox_and_page_from_qdrant(
                user_id="alice",
                doc_id="42",
                chunk_index=3,
                chunk_start=0,
                chunk_end=100,
            )

        assert result == (None, None)
        assert any("Failed to fetch chunk bbox" in r.message for r in caplog.records)
