"""
Tests for authorization scoping in SearchRequest and the Cypher scope clause.

Phase 2 collapsed the old ``workspace_id`` / ``allowed_libraries`` split
into a single ``resolved_libraries: list[str] | None`` axis, materialized
by the MCP auth middleware (or trusted-caller stand-ins in the HTML
views). See ``docs/DAEDALUS_PALLAS_INTEGRATION_v1.md`` §3.3.

These tests cover the dataclass-level normalization rules and the
construction of the Cypher ``_RESOLVED_LIBRARIES_CLAUSE`` snippet. The
actual Cypher execution against Neo4j is validated by the end-to-end
test plan — here we only verify the shape of what SearchService hands
to ``db.cypher_query``.
"""

from unittest.mock import MagicMock, patch

from django.test import TestCase

from library.services.search import (
    _RESOLVED_LIBRARIES_CLAUSE,
    SearchRequest,
    SearchService,
)


# ---------------------------------------------------------------------------
# SearchRequest.resolved_libraries normalization
# ---------------------------------------------------------------------------


class SearchRequestResolvedLibrariesTests(TestCase):
    """Dataclass-level normalization rules for ``resolved_libraries``.

    The tri-state must survive the constructor intact:

    * ``None`` — no auth clause, trusted caller (admin UI with session
      auth, Django management command, library/views.py default path
      for an in-process operator). Cypher clause short-circuits true.
    * ``[]`` — fail-closed. ``$resolved_libraries IS NULL`` is False and
      ``lib.uid IN []`` is False → zero rows. This is what the MCP
      middleware produces when the bearer grants zero libraries.
    * ``["uid1", "uid2"]`` — exact allowlist.
    """

    def test_default_is_none_trusted_caller(self):
        """No explicit arg → ``None`` → trusted-caller branch."""
        req = SearchRequest(query="hello")
        self.assertIsNone(req.resolved_libraries)

    def test_none_preserved(self):
        req = SearchRequest(query="hello", resolved_libraries=None)
        self.assertIsNone(req.resolved_libraries)

    def test_empty_list_preserved_as_fail_closed(self):
        """``[]`` must NOT collapse to ``None``.

        Collapsing would silently upgrade a zero-library bearer to
        "see everything" — a critical authorization regression.
        """
        req = SearchRequest(query="hello", resolved_libraries=[])
        self.assertEqual(req.resolved_libraries, [])
        self.assertIsNotNone(req.resolved_libraries)

    def test_populated_list_preserved(self):
        req = SearchRequest(
            query="hello",
            resolved_libraries=["lib_a", "lib_b"],
        )
        self.assertEqual(req.resolved_libraries, ["lib_a", "lib_b"])

    def test_falsy_entries_stripped_from_list(self):
        """Empty-string / None entries would break the Cypher ``IN`` clause."""
        req = SearchRequest(
            query="hello",
            resolved_libraries=["lib_a", "", None, "lib_b"],
        )
        self.assertEqual(req.resolved_libraries, ["lib_a", "lib_b"])

    def test_all_falsy_collapses_to_empty_list_not_none(self):
        """Stripping everything still yields ``[]``, preserving fail-closed."""
        req = SearchRequest(
            query="hello",
            resolved_libraries=["", None],
        )
        self.assertEqual(req.resolved_libraries, [])
        self.assertIsNotNone(req.resolved_libraries)


# ---------------------------------------------------------------------------
# Unrelated-filter normalization (regression guard)
# ---------------------------------------------------------------------------


class SearchRequestFilterNormalizationTests(TestCase):
    """Empty strings on filter fields must not slip through as truthy."""

    def test_empty_string_library_uid_normalized_to_none(self):
        req = SearchRequest(query="hello", library_uid="")
        self.assertIsNone(req.library_uid)

    def test_empty_string_library_type_normalized_to_none(self):
        req = SearchRequest(query="hello", library_type="")
        self.assertIsNone(req.library_type)

    def test_empty_string_collection_uid_normalized_to_none(self):
        req = SearchRequest(query="hello", collection_uid="")
        self.assertIsNone(req.collection_uid)


# ---------------------------------------------------------------------------
# _RESOLVED_LIBRARIES_CLAUSE sanity
# ---------------------------------------------------------------------------


class ResolvedLibrariesClauseTests(TestCase):
    """Literal-string checks on the Cypher snippet.

    The snippet is a single boolean expression that must satisfy:

    * ``None``  → short-circuits true (no filter applied)
    * ``[]``    → second branch evaluates false → zero rows
    * ``[...]`` → ``lib.uid IN <list>`` gate

    Guarding the exact shape here is a cheap regression check against
    refactors that accidentally drop the ``IS NULL`` short-circuit (which
    would break the in-process admin UI) or the parenthesization (which
    would change operator precedence relative to the appended WHERE).
    """

    def test_clause_references_lib_uid(self):
        self.assertIn("lib.uid", _RESOLVED_LIBRARIES_CLAUSE)

    def test_clause_references_resolved_libraries_param(self):
        self.assertIn("$resolved_libraries", _RESOLVED_LIBRARIES_CLAUSE)

    def test_clause_has_is_null_short_circuit(self):
        """Trusted-caller branch: ``$resolved_libraries IS NULL``."""
        self.assertIn("IS NULL", _RESOLVED_LIBRARIES_CLAUSE)

    def test_clause_has_in_branch(self):
        """Restricted-caller branch: ``lib.uid IN $resolved_libraries``."""
        self.assertIn(" IN ", _RESOLVED_LIBRARIES_CLAUSE)

    def test_clause_starts_with_AND_so_it_appends_safely(self):
        """The clause is concatenated after an existing WHERE filter."""
        self.assertTrue(
            _RESOLVED_LIBRARIES_CLAUSE.lstrip().startswith("AND"),
            f"Clause must start with AND: {_RESOLVED_LIBRARIES_CLAUSE!r}",
        )

    def test_clause_is_parenthesized(self):
        """The OR branches must be wrapped so outer ANDs bind correctly.

        Without parens ``A AND B IS NULL OR C IN D`` would parse as
        ``(A AND B IS NULL) OR (C IN D)`` and silently leak everything.
        """
        stripped = _RESOLVED_LIBRARIES_CLAUSE.strip()
        # e.g. "AND ($resolved_libraries IS NULL OR lib.uid IN $resolved_libraries)"
        self.assertIn("(", stripped)
        self.assertIn(")", stripped)
        self.assertLess(stripped.index("("), stripped.index(" OR "))
        self.assertGreater(stripped.rindex(")"), stripped.index(" OR "))


# ---------------------------------------------------------------------------
# End-to-end wiring: clause appears in every query, param is forwarded
# ---------------------------------------------------------------------------


class SearchServicePassesResolvedLibrariesToCypherTests(TestCase):
    """Every Cypher path threads ``resolved_libraries`` through to the driver.

    Rather than asserting on five individual private methods, we patch
    ``neomodel.db.cypher_query`` and spy on the parameter dict that each
    search type sends down. This is also defense-in-depth against a
    future contributor adding a sixth query and forgetting the clause.
    """

    def setUp(self):
        self.service = SearchService()

    def _capture_cypher_calls(self, cypher_query_mock):
        """Return (query, params) tuples for each call."""
        return [
            (call.args[0], call.args[1] if len(call.args) > 1 else call.kwargs.get("params", {}))
            for call in cypher_query_mock.call_args_list
        ]

    @patch("library.services.search.db.cypher_query")
    def test_fulltext_search_sends_resolved_libraries_param(self, mock_cq):
        mock_cq.return_value = ([], [])

        req = SearchRequest(
            query="hello",
            resolved_libraries=["lib_a"],
            search_types=["fulltext"],
        )
        self.service._fulltext_search(req)

        # Fulltext may issue more than one query (chunks + items);
        # assert the clause + param are threaded through each.
        self.assertGreaterEqual(mock_cq.call_count, 1)
        for query, params in self._capture_cypher_calls(mock_cq):
            self.assertIn(
                _RESOLVED_LIBRARIES_CLAUSE.strip(),
                query.replace("\n", " "),
            )
            self.assertEqual(params.get("resolved_libraries"), ["lib_a"])

    @patch("library.services.search.db.cypher_query")
    def test_fulltext_search_with_none_threads_none_through(self, mock_cq):
        """``None`` stays ``None`` — the short-circuit branch fires in Neo4j."""
        mock_cq.return_value = ([], [])

        req = SearchRequest(
            query="hello",
            resolved_libraries=None,
            search_types=["fulltext"],
        )
        self.service._fulltext_search(req)

        for _, params in self._capture_cypher_calls(mock_cq):
            self.assertIsNone(params.get("resolved_libraries"))

    @patch("library.services.search.db.cypher_query")
    def test_fulltext_search_with_empty_list_is_fail_closed(self, mock_cq):
        """``[]`` is passed verbatim — Cypher's ``lib.uid IN []`` returns nothing."""
        mock_cq.return_value = ([], [])

        req = SearchRequest(
            query="hello",
            resolved_libraries=[],
            search_types=["fulltext"],
        )
        self.service._fulltext_search(req)

        for _, params in self._capture_cypher_calls(mock_cq):
            self.assertEqual(params.get("resolved_libraries"), [])

    @patch("library.services.search.db.cypher_query")
    def test_vector_search_sends_resolved_libraries_param(self, mock_cq):
        mock_cq.return_value = ([], [])

        req = SearchRequest(
            query="hello",
            resolved_libraries=["lib_a", "lib_b"],
            search_types=["vector"],
        )
        self.service._vector_search(req, query_vector=[0.0] * 2048)

        self.assertGreaterEqual(mock_cq.call_count, 1)
        for query, params in self._capture_cypher_calls(mock_cq):
            self.assertIn(
                _RESOLVED_LIBRARIES_CLAUSE.strip(),
                query.replace("\n", " "),
            )
            self.assertEqual(
                params.get("resolved_libraries"), ["lib_a", "lib_b"]
            )

    @patch("library.services.search.db.cypher_query")
    def test_graph_search_sends_resolved_libraries_param(self, mock_cq):
        mock_cq.return_value = ([], [])

        req = SearchRequest(
            query="hello",
            resolved_libraries=["lib_a"],
            search_types=["graph"],
        )
        self.service._graph_search(req)

        self.assertGreaterEqual(mock_cq.call_count, 1)
        for query, params in self._capture_cypher_calls(mock_cq):
            self.assertIn(
                _RESOLVED_LIBRARIES_CLAUSE.strip(),
                query.replace("\n", " "),
            )
            self.assertEqual(params.get("resolved_libraries"), ["lib_a"])

    @patch("library.services.search.db.cypher_query")
    def test_image_search_sends_resolved_libraries_param(self, mock_cq):
        mock_cq.return_value = ([], [])

        req = SearchRequest(
            query="hello",
            resolved_libraries=["lib_a"],
            search_types=["vector"],
            include_images=True,
        )
        self.service._image_search(req, query_vector=[0.0] * 2048)

        self.assertGreaterEqual(mock_cq.call_count, 1)
        for query, params in self._capture_cypher_calls(mock_cq):
            self.assertIn(
                _RESOLVED_LIBRARIES_CLAUSE.strip(),
                query.replace("\n", " "),
            )
            self.assertEqual(params.get("resolved_libraries"), ["lib_a"])
