"""Tests for PlonePGCatalogTool — helper methods and write path."""

from contextlib import contextmanager
from datetime import UTC
from plone.pgcatalog.catalog import PlonePGCatalogTool
from plone.pgcatalog.columns import get_registry
from plone.pgcatalog.columns import IndexType
from plone.pgcatalog.extraction import _path_value_to_string
from plone.pgcatalog.interfaces import IPGCatalogTool
from unittest import mock

import os


class TestImplementsInterface:
    def test_implements_ipgcatalogtool(self):
        assert IPGCatalogTool.implementedBy(PlonePGCatalogTool)


class TestSecurityDeclarations:
    """Verify Zope security declarations on PlonePGCatalogTool."""

    def test_unrestricted_search_is_private(self):
        # declarePrivate sets MethodName__roles__ = () (empty tuple = private)
        roles = getattr(PlonePGCatalogTool, "unrestrictedSearchResults__roles__", None)
        assert roles == (), f"Expected () for private, got {roles!r}"

    def test_refresh_catalog_is_protected(self):
        # declareProtected sets MethodName__roles__ = PermissionRole(...)
        roles = getattr(PlonePGCatalogTool, "refreshCatalog__roles__", None)
        assert roles is not None and roles != ()

    def test_reindex_index_is_protected(self):
        roles = getattr(PlonePGCatalogTool, "reindexIndex__roles__", None)
        assert roles is not None and roles != ()

    def test_clear_find_and_rebuild_is_protected(self):
        roles = getattr(PlonePGCatalogTool, "clearFindAndRebuild__roles__", None)
        assert roles is not None and roles != ()

    def test_ac_permissions_includes_manage_entries(self):
        # __ac_permissions__ tuples: (perm_name, methods) or (perm_name, methods, roles)
        perms = PlonePGCatalogTool.__ac_permissions__
        manage_entries = None
        for entry in perms:
            perm_name, methods = entry[0], entry[1]
            if perm_name == "Manage ZCatalog Entries":
                manage_entries = methods
                break
        assert manage_entries is not None, (
            "Manage ZCatalog Entries not in __ac_permissions__"
        )
        assert "refreshCatalog" in manage_entries
        assert "reindexIndex" in manage_entries
        assert "clearFindAndRebuild" in manage_entries

    def test_search_results_is_protected(self):
        roles = getattr(PlonePGCatalogTool, "searchResults__roles__", None)
        assert roles is not None and roles != ()

    def test_call_is_protected(self):
        roles = getattr(PlonePGCatalogTool, "__call____roles__", None)
        assert roles is not None and roles != ()

    def test_catalog_object_is_protected(self):
        roles = getattr(PlonePGCatalogTool, "catalog_object__roles__", None)
        assert roles is not None and roles != ()

    def test_uncatalog_object_is_protected(self):
        roles = getattr(PlonePGCatalogTool, "uncatalog_object__roles__", None)
        assert roles is not None and roles != ()

    def test_index_management_is_protected(self):
        for name in (
            "addIndex",
            "delIndex",
            "addColumn",
            "delColumn",
            "getIndexObjects",
        ):
            roles = getattr(PlonePGCatalogTool, f"{name}__roles__", None)
            assert roles is not None and roles != (), f"{name} should be protected"

    def test_read_methods_are_protected(self):
        for name in ("indexes", "schema", "getpath", "getrid", "getIndexDataForRID"):
            roles = getattr(PlonePGCatalogTool, f"{name}__roles__", None)
            assert roles is not None and roles != (), f"{name} should be protected"

    def test_private_methods(self):
        for name in (
            "indexObject",
            "unindexObject",
            "reindexObject",
            "_indexObject",
            "_unindexObject",
            "_reindexObject",
            "_listAllowedRolesAndUsers",
            "_increment_counter",
        ):
            roles = getattr(PlonePGCatalogTool, f"{name}__roles__", None)
            assert roles == (), f"{name} should be private, got {roles!r}"

    def test_permission_defaults(self):
        """setPermissionDefault assigns default roles for each permission."""
        perms = PlonePGCatalogTool.__ac_permissions__
        for entry in perms:
            perm_name = entry[0]
            if perm_name == "Search ZCatalog" and len(entry) > 2:
                assert "Anonymous" in entry[2], "Search should default to Anonymous"
                break
        else:
            # Search ZCatalog permission might not have default roles tuple
            # if no methods are declared — this is fine
            pass


class TestObjToZoid:
    def test_with_p_oid(self):
        obj = mock.Mock()
        obj._p_oid = b"\x00\x00\x00\x00\x00\x00\x01\x00"
        assert PlonePGCatalogTool._obj_to_zoid(obj) == 256

    def test_with_zero_oid(self):
        obj = mock.Mock()
        obj._p_oid = b"\x00\x00\x00\x00\x00\x00\x00\x00"
        assert PlonePGCatalogTool._obj_to_zoid(obj) == 0

    def test_without_p_oid(self):
        obj = mock.Mock(spec=[])
        assert PlonePGCatalogTool._obj_to_zoid(obj) is None


class TestExtractSearchableText:
    def test_extracts_string(self):
        wrapper = mock.Mock()
        wrapper.SearchableText = "hello world"
        assert PlonePGCatalogTool._extract_searchable_text(wrapper) == "hello world"

    def test_extracts_from_callable(self):
        wrapper = mock.Mock()
        wrapper.SearchableText.return_value = "hello callable"
        assert PlonePGCatalogTool._extract_searchable_text(wrapper) == "hello callable"

    def test_returns_none_for_non_string(self):
        wrapper = mock.Mock()
        wrapper.SearchableText = 42
        assert PlonePGCatalogTool._extract_searchable_text(wrapper) is None

    def test_returns_none_on_missing_attr(self):
        wrapper = mock.Mock(spec=[])
        assert PlonePGCatalogTool._extract_searchable_text(wrapper) is None

    def test_returns_none_on_exception(self):
        wrapper = mock.Mock()
        type(wrapper).SearchableText = mock.PropertyMock(side_effect=RuntimeError)
        assert PlonePGCatalogTool._extract_searchable_text(wrapper) is None


class TestExtractIdx:
    def _make_tool(self):
        # PlonePGCatalogTool.__init__ chains to CatalogTool which does a lot;
        # bypass by calling _extract_idx as unbound method via the class.
        return PlonePGCatalogTool.__new__(PlonePGCatalogTool)

    def test_extracts_known_indexes(self):
        tool = self._make_tool()
        wrapper = mock.Mock()
        wrapper.portal_type = "Document"
        wrapper.review_state = "published"
        wrapper.Title = "Hello"
        wrapper.is_folderish = False

        idx = tool._extract_idx(wrapper)
        assert idx["portal_type"] == "Document"
        assert idx["review_state"] == "published"
        assert idx["Title"] == "Hello"

    def test_partial_reindex(self):
        tool = self._make_tool()
        wrapper = mock.Mock()
        wrapper.portal_type = "Document"
        wrapper.review_state = "published"

        idx = tool._extract_idx(wrapper, idxs=["portal_type"])
        assert "portal_type" in idx
        assert "review_state" not in idx

    def test_skips_on_exception(self):
        tool = self._make_tool()
        wrapper = mock.Mock()
        # Make portal_type a callable that raises (simulating a broken indexer)
        wrapper.portal_type.side_effect = RuntimeError("indexer error")
        wrapper.review_state = "published"

        idx = tool._extract_idx(wrapper)
        assert "portal_type" not in idx
        assert idx["review_state"] == "published"


class TestWrapObject:
    def test_wraps_with_adapter(self):
        tool = PlonePGCatalogTool.__new__(PlonePGCatalogTool)
        obj = mock.Mock()
        wrapped = mock.Mock()
        with mock.patch(
            "zope.component.queryMultiAdapter",
            return_value=wrapped,
        ):
            result = tool._wrap_object(obj)
            assert result is wrapped

    def test_returns_obj_if_no_adapter(self):
        tool = PlonePGCatalogTool.__new__(PlonePGCatalogTool)
        obj = mock.Mock()
        with mock.patch(
            "zope.component.queryMultiAdapter",
            return_value=None,
        ):
            result = tool._wrap_object(obj)
            assert result is obj


def _mock_pg_connection(mock_conn):
    """Create a mock _pg_connection context manager that yields mock_conn."""

    @contextmanager
    def _cm(_self):
        yield mock_conn

    return _cm


class TestCatalogObjectWritePath:
    def test_new_object_uses_dict_fallback(self):
        """New objects (no _p_oid yet) store catalog data in obj.__dict__."""
        from plone.pgcatalog.processor import ANNOTATION_KEY

        tool = PlonePGCatalogTool.__new__(PlonePGCatalogTool)
        obj = mock.Mock(spec=["getPhysicalPath"])
        obj.getPhysicalPath.return_value = ("", "plone", "doc")
        # No _p_oid → uses __dict__ fallback, set_pending NOT called
        with (
            mock.patch.object(PlonePGCatalogTool, "_wrap_object", return_value=obj),
            mock.patch.object(PlonePGCatalogTool, "_extract_idx", return_value={}),
            mock.patch.object(
                PlonePGCatalogTool, "_extract_searchable_text", return_value=None
            ),
            mock.patch(
                "plone.pgcatalog.catalog.extract_content_type", return_value=None
            ),
            mock.patch("plone.pgcatalog.catalog.set_pending") as pending_mock,
        ):
            tool.catalog_object(obj)
            pending_mock.assert_not_called()
            assert ANNOTATION_KEY in obj.__dict__
            assert obj.__dict__[ANNOTATION_KEY]["path"] == "/plone/doc"

    def test_sets_pending_annotation(self):
        """catalog_object() sets PG pending annotation (no BTree writes)."""
        tool = PlonePGCatalogTool.__new__(PlonePGCatalogTool)
        obj = mock.Mock()
        obj.getPhysicalPath.return_value = ("", "plone", "doc")
        obj._p_oid = b"\x00\x00\x00\x00\x00\x00\x00\x01"

        with (
            mock.patch.object(PlonePGCatalogTool, "_wrap_object", return_value=obj),
            mock.patch.object(PlonePGCatalogTool, "_extract_idx", return_value={}),
            mock.patch.object(
                PlonePGCatalogTool, "_extract_searchable_text", return_value=None
            ),
            mock.patch("plone.pgcatalog.catalog.set_pending") as pending_mock,
        ):
            tool.catalog_object(obj)
            # PG annotation was set
            pending_mock.assert_called_once()

    def test_noop_without_path(self):
        """catalog_object() is a no-op when object has no getPhysicalPath."""
        tool = PlonePGCatalogTool.__new__(PlonePGCatalogTool)
        obj = mock.Mock(spec=[])  # no getPhysicalPath
        with mock.patch("plone.pgcatalog.catalog.set_pending") as pending_mock:
            tool.catalog_object(obj)
            # No PG annotation (no physical path)
            pending_mock.assert_not_called()


class TestUncatalogObjectWritePath:
    def test_uncatalog_uses_pg(self):
        """uncatalog_object() calls PG uncatalog (no BTree writes)."""
        tool = PlonePGCatalogTool.__new__(PlonePGCatalogTool)
        mock_conn = mock.Mock()
        mock_cursor = mock.MagicMock()
        mock_conn.cursor.return_value.__enter__ = mock.Mock(return_value=mock_cursor)
        mock_conn.cursor.return_value.__exit__ = mock.Mock(return_value=False)
        mock_cursor.fetchone.return_value = {"zoid": 42}

        with (
            mock.patch.object(
                PlonePGCatalogTool, "_pg_connection", _mock_pg_connection(mock_conn)
            ),
            mock.patch("plone.pgcatalog.catalog._sql_uncatalog") as uncatalog_mock,
        ):
            tool.uncatalog_object("/plone/doc")
            uncatalog_mock.assert_called_once_with(mock_conn, zoid=42)


# ---------------------------------------------------------------------------
# Dynamic extraction tests (IndexRegistry-based)
# ---------------------------------------------------------------------------


class TestExtractIdxDynamic:
    """Test _extract_idx with dynamic IndexRegistry instead of static KNOWN_INDEXES."""

    def _make_tool(self):
        return PlonePGCatalogTool.__new__(PlonePGCatalogTool)

    def test_extracts_dynamically_registered_index(self, populated_registry):
        """A dynamically registered FieldIndex should be extracted."""
        registry = get_registry()
        registry.register("my_addon_field", IndexType.FIELD, "my_addon_field")
        try:
            tool = self._make_tool()
            wrapper = mock.Mock()
            wrapper.my_addon_field = "addon_value"
            wrapper.portal_type = "Document"

            idx = tool._extract_idx(wrapper)
            assert idx["my_addon_field"] == "addon_value"
            assert idx["portal_type"] == "Document"
        finally:
            # Clean up dynamic registration
            registry._indexes.pop("my_addon_field", None)

    def test_uses_source_attrs_not_index_name(self, populated_registry):
        """When source_attrs differs from index name, extraction uses source_attrs."""
        registry = get_registry()
        # Register an index "myIndex" that reads from attribute "other_attr"
        registry.register(
            "myIndex", IndexType.FIELD, "myIndex", source_attrs=["other_attr"]
        )
        try:
            tool = self._make_tool()
            wrapper = mock.Mock(spec=["other_attr"])
            wrapper.other_attr = "from_other"

            idx = tool._extract_idx(wrapper)
            assert idx["myIndex"] == "from_other"
        finally:
            registry._indexes.pop("myIndex", None)

    def test_source_attrs_multi_uses_first_non_none(self, populated_registry):
        """With multiple source_attrs, use first non-None value."""
        registry = get_registry()
        registry.register(
            "multiAttr",
            IndexType.FIELD,
            "multiAttr",
            source_attrs=["attr_a", "attr_b"],
        )
        try:
            tool = self._make_tool()
            wrapper = mock.Mock(spec=["attr_a", "attr_b"])
            wrapper.attr_a = None
            wrapper.attr_b = "fallback_value"

            idx = tool._extract_idx(wrapper)
            assert idx["multiAttr"] == "fallback_value"
        finally:
            registry._indexes.pop("multiAttr", None)

    def test_extracts_metadata_columns(self, populated_registry):
        """Metadata-only columns should also be extracted into idx."""
        registry = get_registry()
        registry.add_metadata("custom_meta")
        try:
            tool = self._make_tool()
            wrapper = mock.Mock()
            wrapper.custom_meta = "meta_value"

            idx = tool._extract_idx(wrapper)
            assert idx["custom_meta"] == "meta_value"
        finally:
            registry._metadata.discard("custom_meta")

    def test_partial_reindex_with_dynamic_index(self, populated_registry):
        """Partial reindex (idxs param) should work with dynamic indexes."""
        registry = get_registry()
        registry.register("dyn_a", IndexType.FIELD, "dyn_a")
        registry.register("dyn_b", IndexType.FIELD, "dyn_b")
        try:
            tool = self._make_tool()
            wrapper = mock.Mock()
            wrapper.dyn_a = "val_a"
            wrapper.dyn_b = "val_b"

            idx = tool._extract_idx(wrapper, idxs=["dyn_a"])
            assert "dyn_a" in idx
            assert "dyn_b" not in idx
        finally:
            registry._indexes.pop("dyn_a", None)
            registry._indexes.pop("dyn_b", None)

    def test_skips_special_indexes(self, populated_registry):
        """Indexes with idx_key=None (special) should be skipped in extraction."""
        tool = self._make_tool()
        wrapper = mock.Mock()

        idx = tool._extract_idx(wrapper)
        # SearchableText, effectiveRange, path have idx_key=None → not in idx
        assert "SearchableText" not in idx
        assert "effectiveRange" not in idx
        assert "path" not in idx

    def test_callable_value_extracted(self, populated_registry):
        """Callable attributes (indexers) should be called for their value."""
        registry = get_registry()
        registry.register("callable_idx", IndexType.FIELD, "callable_idx")
        try:
            tool = self._make_tool()
            wrapper = mock.Mock()
            wrapper.callable_idx.return_value = "called_value"

            idx = tool._extract_idx(wrapper)
            assert idx["callable_idx"] == "called_value"
        finally:
            registry._indexes.pop("callable_idx", None)

    def test_exception_in_extraction_skipped(self, populated_registry):
        """If an indexer raises, that field is skipped gracefully."""
        registry = get_registry()
        registry.register("broken_idx", IndexType.FIELD, "broken_idx")
        try:
            tool = self._make_tool()
            wrapper = mock.Mock()
            wrapper.broken_idx.side_effect = RuntimeError("broken")
            wrapper.portal_type = "Document"

            idx = tool._extract_idx(wrapper)
            assert "broken_idx" not in idx
            assert idx["portal_type"] == "Document"
        finally:
            registry._indexes.pop("broken_idx", None)


class TestIPGIndexTranslatorExtract:
    """IPGIndexTranslator.extract() fallback in _extract_idx."""

    def _make_tool(self):
        return PlonePGCatalogTool.__new__(PlonePGCatalogTool)

    def test_translator_extract_called(self, populated_registry):
        """Custom translator.extract() is called during indexing."""
        from plone.pgcatalog.interfaces import IPGIndexTranslator
        from zope.component import getSiteManager

        translator = mock.Mock()
        translator.extract.return_value = {
            "event_start": "2025-01-15T10:00:00",
            "event_end": "2025-01-20T18:00:00",
        }

        sm = getSiteManager()
        sm.registerUtility(translator, IPGIndexTranslator, name="event_range")
        try:
            tool = self._make_tool()
            wrapper = mock.Mock()

            idx = tool._extract_idx(wrapper)
            translator.extract.assert_called_once_with(wrapper, "event_range")
            assert idx["event_start"] == "2025-01-15T10:00:00"
            assert idx["event_end"] == "2025-01-20T18:00:00"
        finally:
            sm.unregisterUtility(provided=IPGIndexTranslator, name="event_range")

    def test_translator_extract_exception_skipped(self, populated_registry):
        """If translator.extract() raises, it's skipped gracefully."""
        from plone.pgcatalog.interfaces import IPGIndexTranslator
        from zope.component import getSiteManager

        translator = mock.Mock()
        translator.extract.side_effect = RuntimeError("broken translator")

        sm = getSiteManager()
        sm.registerUtility(translator, IPGIndexTranslator, name="broken_range")
        try:
            tool = self._make_tool()
            wrapper = mock.Mock()
            wrapper.portal_type = "Document"

            idx = tool._extract_idx(wrapper)
            # Doesn't crash, regular indexes still extracted
            assert idx["portal_type"] == "Document"
        finally:
            sm.unregisterUtility(provided=IPGIndexTranslator, name="broken_range")


# ---------------------------------------------------------------------------
# _path_value_to_string tests
# ---------------------------------------------------------------------------


class TestPathValueToString:
    def test_none_returns_none(self):
        assert _path_value_to_string(None) is None

    def test_non_empty_string(self):
        assert _path_value_to_string("/plone/doc") == "/plone/doc"

    def test_empty_string_returns_none(self):
        assert _path_value_to_string("") is None

    def test_list_of_components(self):
        assert (
            _path_value_to_string(["uuid1", "uuid2", "uuid3"]) == "/uuid1/uuid2/uuid3"
        )

    def test_tuple_of_components(self):
        assert _path_value_to_string(("a", "b")) == "/a/b"

    def test_empty_list_returns_none(self):
        assert _path_value_to_string([]) is None

    def test_empty_tuple_returns_none(self):
        assert _path_value_to_string(()) is None

    def test_other_type_returns_str(self):
        assert _path_value_to_string(42) == "42"


# ---------------------------------------------------------------------------
# _pg_connection context manager tests
# ---------------------------------------------------------------------------


class TestPgConnection:
    def test_reuses_request_scoped_connection(self):
        """_pg_connection reuses an existing request-scoped connection."""
        import plone.pgcatalog.pending as pending_mod

        tool = PlonePGCatalogTool.__new__(PlonePGCatalogTool)
        mock_conn = mock.Mock()
        mock_conn.closed = False

        pending_mod._local.pgcat_conn = mock_conn
        try:
            with tool._pg_connection() as conn:
                assert conn is mock_conn
        finally:
            pending_mod._local.pgcat_conn = None
            pending_mod._local.pgcat_pool = None

    def test_borrows_from_pool_when_no_request_conn(self):
        """_pg_connection borrows from pool when no request-scoped conn."""
        import plone.pgcatalog.pending as pending_mod

        tool = PlonePGCatalogTool.__new__(PlonePGCatalogTool)
        mock_pool = mock.Mock()
        mock_conn = mock.Mock()
        mock_pool.getconn.return_value = mock_conn

        pending_mod._local.pgcat_conn = None
        try:
            with (
                mock.patch("plone.pgcatalog.catalog.get_pool", return_value=mock_pool),
            ):
                with tool._pg_connection() as conn:
                    assert conn is mock_conn
                mock_pool.putconn.assert_called_once_with(mock_conn)
        finally:
            pending_mod._local.pgcat_conn = None
            pending_mod._local.pgcat_pool = None


# ---------------------------------------------------------------------------
# indexObject / reindexObject tests
# ---------------------------------------------------------------------------


class TestIndexObjectReindexObject:
    def test_indexObject_calls_set_pg_annotation(self):
        tool = PlonePGCatalogTool.__new__(PlonePGCatalogTool)
        obj = mock.Mock()
        with mock.patch.object(tool, "_set_pg_annotation") as ann_mock:
            tool.indexObject(obj)
            ann_mock.assert_called_once_with(obj)

    def test_reindexObject_calls_set_pg_annotation(self):
        tool = PlonePGCatalogTool.__new__(PlonePGCatalogTool)
        obj = mock.Mock()
        with mock.patch.object(tool, "_set_pg_annotation") as ann_mock:
            tool.reindexObject(obj, uid="/plone/doc")
            ann_mock.assert_called_once_with(obj, "/plone/doc")


# ---------------------------------------------------------------------------
# uncatalog_object — persistent object branch
# ---------------------------------------------------------------------------


class TestUncatalogObjectPersistent:
    def test_uncatalog_with_persistent_obj(self):
        """uncatalog_object sets None pending when object has _p_oid."""
        tool = PlonePGCatalogTool.__new__(PlonePGCatalogTool)
        obj = mock.Mock()
        obj._p_oid = b"\x00\x00\x00\x00\x00\x00\x00\x05"

        with (
            mock.patch.object(
                PlonePGCatalogTool, "unrestrictedTraverse", return_value=obj
            ),
            mock.patch("plone.pgcatalog.catalog.set_pending") as pending_mock,
        ):
            tool.uncatalog_object("/plone/doc")
            pending_mock.assert_called_once_with(5, None)
            assert obj._p_changed is True

    def test_uncatalog_traverse_exception(self):
        """uncatalog_object handles unrestrictedTraverse raising."""
        tool = PlonePGCatalogTool.__new__(PlonePGCatalogTool)

        with (
            mock.patch.object(
                PlonePGCatalogTool,
                "unrestrictedTraverse",
                side_effect=RuntimeError("traverse failed"),
            ),
            mock.patch.object(
                PlonePGCatalogTool, "_pg_connection", _mock_pg_connection(mock.Mock())
            ),
        ):
            # Should not raise
            tool.uncatalog_object("/plone/missing")


# ---------------------------------------------------------------------------
# searchResults / unrestrictedSearchResults
# ---------------------------------------------------------------------------


class TestSearchResults:
    def test_searchResults_uses_storage_connection(self):
        """searchResults uses the ZODB storage instance connection."""
        tool = PlonePGCatalogTool.__new__(PlonePGCatalogTool)
        mock_conn = mock.Mock()
        mock_results = mock.Mock()

        with (
            mock.patch("AccessControl.getSecurityManager") as sm_mock,
            mock.patch.object(
                tool, "_listAllowedRolesAndUsers", return_value=["Anonymous"]
            ),
            mock.patch(
                "plone.pgcatalog.catalog.get_storage_connection",
                return_value=mock_conn,
            ),
            mock.patch(
                "plone.pgcatalog.catalog._run_search", return_value=mock_results
            ) as run_mock,
            mock.patch(
                "plone.pgcatalog.catalog.apply_security_filters",
                side_effect=lambda q, r, **kw: q,
            ),
        ):
            sm_mock.return_value.checkPermission.return_value = False
            result = tool.searchResults({"portal_type": "Document"})
            assert result is mock_results
            run_mock.assert_called_once()
            # Verify lazy_conn is the storage connection
            call_kwargs = run_mock.call_args
            assert call_kwargs.kwargs.get("lazy_conn") is mock_conn

    def test_searchResults_falls_back_to_pool(self):
        """searchResults falls back to pool when no storage connection."""
        tool = PlonePGCatalogTool.__new__(PlonePGCatalogTool)
        mock_pool = mock.Mock()
        mock_conn = mock.Mock()
        mock_results = mock.Mock()

        with (
            mock.patch("AccessControl.getSecurityManager") as sm_mock,
            mock.patch.object(
                tool, "_listAllowedRolesAndUsers", return_value=["Anonymous"]
            ),
            mock.patch(
                "plone.pgcatalog.catalog.get_storage_connection", return_value=None
            ),
            mock.patch("plone.pgcatalog.catalog.get_pool", return_value=mock_pool),
            mock.patch(
                "plone.pgcatalog.catalog.get_request_connection", return_value=mock_conn
            ),
            mock.patch(
                "plone.pgcatalog.catalog._run_search", return_value=mock_results
            ) as run_mock,
            mock.patch(
                "plone.pgcatalog.catalog.apply_security_filters",
                side_effect=lambda q, r, **kw: q,
            ),
        ):
            sm_mock.return_value.checkPermission.return_value = False
            result = tool.searchResults({"portal_type": "Document"})
            assert result is mock_results
            run_mock.assert_called_once()

    def test_unrestrictedSearchResults_uses_storage_connection(self):
        """unrestrictedSearchResults uses the ZODB storage instance connection."""
        tool = PlonePGCatalogTool.__new__(PlonePGCatalogTool)
        mock_conn = mock.Mock()
        mock_results = mock.Mock()

        with (
            mock.patch(
                "plone.pgcatalog.catalog.get_storage_connection",
                return_value=mock_conn,
            ),
            mock.patch(
                "plone.pgcatalog.catalog._run_search", return_value=mock_results
            ) as run_mock,
        ):
            result = tool.unrestrictedSearchResults(portal_type="Document")
            assert result is mock_results
            run_mock.assert_called_once()
            assert run_mock.call_args.kwargs.get("lazy_conn") is mock_conn


# ---------------------------------------------------------------------------
# Maintenance methods
# ---------------------------------------------------------------------------


class TestMaintenanceMethods:
    def test_refreshCatalog_no_clear(self):
        """refreshCatalog(clear=0) queries PG for cataloged paths and re-catalogs."""
        tool = PlonePGCatalogTool.__new__(PlonePGCatalogTool)
        mock_conn = mock.Mock()
        # Simulate PG returning two rows
        mock_cursor = mock.Mock()
        mock_cursor.fetchall.return_value = [
            {"path": "/plone/doc1"},
            {"path": "/plone/doc2"},
        ]
        mock_conn.cursor.return_value.__enter__ = mock.Mock(return_value=mock_cursor)
        mock_conn.cursor.return_value.__exit__ = mock.Mock(return_value=False)
        with (
            mock.patch.object(
                PlonePGCatalogTool, "_get_pg_read_connection", return_value=mock_conn
            ),
            mock.patch.object(
                PlonePGCatalogTool, "unrestrictedTraverse", return_value=mock.Mock()
            ),
            mock.patch.object(PlonePGCatalogTool, "catalog_object") as cat_mock,
        ):
            result = tool.refreshCatalog()
            assert result == 2
            assert cat_mock.call_count == 2

    def test_refreshCatalog_with_clear(self):
        """refreshCatalog(clear=1) delegates to clearFindAndRebuild."""
        tool = PlonePGCatalogTool.__new__(PlonePGCatalogTool)
        with mock.patch.object(PlonePGCatalogTool, "clearFindAndRebuild") as cfr_mock:
            tool.refreshCatalog(clear=1)
            cfr_mock.assert_called_once()

    def test_refreshCatalog_accepts_pghandler(self):
        """refreshCatalog accepts pghandler for ZCatalog API compat."""
        tool = PlonePGCatalogTool.__new__(PlonePGCatalogTool)
        with mock.patch.object(PlonePGCatalogTool, "clearFindAndRebuild"):
            # Should not raise TypeError
            tool.refreshCatalog(clear=1, pghandler=mock.Mock())

    def test_reindexIndex_extracts_from_zodb(self):
        """reindexIndex loads objects and calls _partial_reindex."""
        tool = PlonePGCatalogTool.__new__(PlonePGCatalogTool)
        tool._p_jar = mock.Mock()
        content_obj = mock.Mock()

        mock_pool, _, _ = self._mock_rebuild_pool(["/Plone/doc1", "/Plone/doc2"])

        with (
            mock.patch("plone.pgcatalog.catalog.get_pool", return_value=mock_pool),
            mock.patch.object(
                PlonePGCatalogTool,
                "unrestrictedTraverse",
                return_value=content_obj,
            ),
            mock.patch.object(
                PlonePGCatalogTool, "_partial_reindex", return_value=True
            ) as partial_mock,
        ):
            result = tool.reindexIndex("portal_type")
            assert result == 2
            partial_mock.assert_any_call(content_obj, ["portal_type"])

    def test_reindexIndex_accepts_pghandler(self):
        """reindexIndex accepts pghandler kwarg (ZCatalog compat)."""
        tool = PlonePGCatalogTool.__new__(PlonePGCatalogTool)
        tool._p_jar = mock.Mock()
        mock_pool, _, _ = self._mock_rebuild_pool([])

        with mock.patch("plone.pgcatalog.catalog.get_pool", return_value=mock_pool):
            # Should not raise TypeError
            tool.reindexIndex("portal_type", None, pghandler=mock.Mock())

    def test_reindexIndex_no_jar(self):
        """reindexIndex returns 0 when _p_jar is None."""
        tool = PlonePGCatalogTool.__new__(PlonePGCatalogTool)
        tool._p_jar = None
        assert tool.reindexIndex("portal_type") == 0

    def _mock_rebuild_pool(self, paths):
        """Create a mock pool + cursor returning given paths.

        paths should be a list of path strings. Returns dicts to match
        the storage pool's dict_row factory.
        """
        mock_cursor = mock.Mock()
        mock_cursor.fetchall.return_value = [{"path": p} for p in paths]

        mock_pool_conn = mock.Mock()
        mock_pool_conn.cursor.return_value.__enter__ = mock.Mock(
            return_value=mock_cursor
        )
        mock_pool_conn.cursor.return_value.__exit__ = mock.Mock(return_value=False)

        mock_pool = mock.Mock()
        mock_pool.getconn.return_value = mock_pool_conn
        return mock_pool, mock_pool_conn, mock_cursor

    def test_clearFindAndRebuild_snapshots_paths_then_clears(self):
        """clearFindAndRebuild snapshots paths, clears, then re-indexes."""
        tool = PlonePGCatalogTool.__new__(PlonePGCatalogTool)
        mock_pg_conn = mock.Mock()
        mock_pool, mock_pool_conn, mock_cursor = self._mock_rebuild_pool([])
        tool._p_jar = mock.Mock()

        with (
            mock.patch.object(
                PlonePGCatalogTool, "_pg_connection", _mock_pg_connection(mock_pg_conn)
            ),
            mock.patch(
                "plone.pgcatalog.catalog.clear_catalog_data", return_value=10
            ) as clear_mock,
            mock.patch("plone.pgcatalog.catalog.get_pool", return_value=mock_pool),
        ):
            tool.clearFindAndRebuild()
            clear_mock.assert_called_once_with(mock_pg_conn)
            # Should query paths with class_mod exclusions
            mock_cursor.execute.assert_called_once()
            query = mock_cursor.execute.call_args[0][0]
            assert "object_state" in query
            assert "NOT LIKE" in query
            assert "path IS NOT NULL" in query
            mock_pool.putconn.assert_called_once_with(mock_pool_conn)

    def test_clearFindAndRebuild_indexes_by_path(self):
        """clearFindAndRebuild traverses paths and re-indexes objects."""
        tool = PlonePGCatalogTool.__new__(PlonePGCatalogTool)
        mock_pg_conn = mock.Mock()
        content_obj = mock.Mock()

        mock_pool, _, _ = self._mock_rebuild_pool(["/Plone/doc1", "/Plone/doc2"])
        tool._p_jar = mock.Mock()

        with (
            mock.patch.object(
                PlonePGCatalogTool, "_pg_connection", _mock_pg_connection(mock_pg_conn)
            ),
            mock.patch("plone.pgcatalog.catalog.clear_catalog_data"),
            mock.patch("plone.pgcatalog.catalog.get_pool", return_value=mock_pool),
            mock.patch.object(
                PlonePGCatalogTool,
                "unrestrictedTraverse",
                return_value=content_obj,
            ),
            mock.patch.object(PlonePGCatalogTool, "catalog_object") as cat_mock,
        ):
            tool.clearFindAndRebuild()
            assert cat_mock.call_count == 2
            cat_mock.assert_any_call(content_obj, "/Plone/doc1")
            cat_mock.assert_any_call(content_obj, "/Plone/doc2")

    def test_clearFindAndRebuild_skips_missing_objects(self):
        """clearFindAndRebuild skips paths that don't resolve."""
        tool = PlonePGCatalogTool.__new__(PlonePGCatalogTool)
        mock_pg_conn = mock.Mock()

        mock_pool, _, _ = self._mock_rebuild_pool(["/Plone/gone"])
        tool._p_jar = mock.Mock()

        with (
            mock.patch.object(
                PlonePGCatalogTool, "_pg_connection", _mock_pg_connection(mock_pg_conn)
            ),
            mock.patch("plone.pgcatalog.catalog.clear_catalog_data"),
            mock.patch("plone.pgcatalog.catalog.get_pool", return_value=mock_pool),
            mock.patch.object(
                PlonePGCatalogTool, "unrestrictedTraverse", return_value=None
            ),
            mock.patch.object(PlonePGCatalogTool, "catalog_object") as cat_mock,
        ):
            tool.clearFindAndRebuild()
            cat_mock.assert_not_called()

    def test_clearFindAndRebuild_no_jar(self):
        """clearFindAndRebuild returns early when _p_jar is None."""
        tool = PlonePGCatalogTool.__new__(PlonePGCatalogTool)
        tool._p_jar = None

        with mock.patch.object(PlonePGCatalogTool, "catalog_object") as cat_mock:
            tool.clearFindAndRebuild()
            cat_mock.assert_not_called()

    def test_manage_catalogReindex(self):
        """manage_catalogReindex delegates to refreshCatalog and redirects."""
        tool = PlonePGCatalogTool.__new__(PlonePGCatalogTool)
        mock_response = mock.Mock()
        with mock.patch.object(PlonePGCatalogTool, "refreshCatalog") as refresh_mock:
            tool.manage_catalogReindex(
                RESPONSE=mock_response, URL1="http://localhost/portal_catalog"
            )
            refresh_mock.assert_called_once_with(clear=0)
            mock_response.redirect.assert_called_once_with(
                "http://localhost/portal_catalog"
                "/manage_catalogAdvanced?manage_tabs_message=Catalog+updated."
            )

    def test_manage_catalogRebuild(self):
        """manage_catalogRebuild delegates to clearFindAndRebuild and redirects."""
        tool = PlonePGCatalogTool.__new__(PlonePGCatalogTool)
        mock_response = mock.Mock()
        with mock.patch.object(PlonePGCatalogTool, "clearFindAndRebuild") as cfr_mock:
            tool.manage_catalogRebuild(
                RESPONSE=mock_response, URL1="http://localhost/portal_catalog"
            )
            cfr_mock.assert_called_once()
            mock_response.redirect.assert_called_once_with(
                "http://localhost/portal_catalog"
                "/manage_catalogAdvanced?manage_tabs_message=Catalog+rebuilt."
            )

    def test_manage_get_tika_status_disabled(self):
        """manage_get_tika_status returns disabled when env var not set."""
        tool = PlonePGCatalogTool.__new__(PlonePGCatalogTool)
        with mock.patch.dict(os.environ, {}, clear=False):
            os.environ.pop("PGCATALOG_TIKA_URL", None)
            result = tool.manage_get_tika_status()
        assert result["enabled"] is False

    def test_manage_get_tika_status_enabled(self):
        """manage_get_tika_status returns info when Tika is configured."""
        tool = PlonePGCatalogTool.__new__(PlonePGCatalogTool)
        tool._p_jar = mock.Mock()
        with (
            mock.patch.dict(
                os.environ,
                {"PGCATALOG_TIKA_URL": "http://tika:9998"},
            ),
            mock.patch("plone.pgcatalog.catalog.get_pool") as mock_pool,
        ):
            # Mock pool to avoid PG connection
            mock_conn = mock.Mock()
            mock_conn.cursor.return_value.__enter__ = mock.Mock(
                return_value=mock.Mock(fetchall=mock.Mock(return_value=[]))
            )
            mock_conn.cursor.return_value.__exit__ = mock.Mock(return_value=False)
            mock_pool.return_value.getconn.return_value = mock_conn

            result = tool.manage_get_tika_status()
        assert result["enabled"] is True
        assert result["tika_url"] == "http://tika:9998"
        assert "content_types" in result

    def test_manage_get_slow_query_stats_empty(self):
        """manage_get_slow_query_stats returns empty list on error."""
        tool = PlonePGCatalogTool.__new__(PlonePGCatalogTool)
        with mock.patch("plone.pgcatalog.catalog.get_pool", side_effect=RuntimeError):
            result = tool.manage_get_slow_query_stats()
        assert result == []

    def test_manage_clear_slow_queries(self):
        """manage_clear_slow_queries calls DELETE."""
        tool = PlonePGCatalogTool.__new__(PlonePGCatalogTool)
        mock_conn = mock.Mock()
        with mock.patch("plone.pgcatalog.catalog.get_pool") as mock_pool:
            mock_pool.return_value.getconn.return_value = mock_conn
            tool.manage_clear_slow_queries()
        mock_conn.execute.assert_called_once_with("DELETE FROM pgcatalog_slow_queries")


# ---------------------------------------------------------------------------
# _run_search lazy mode tests
# ---------------------------------------------------------------------------


class TestRunSearchLazyMode:
    def test_lazy_mode_wires_result_set(self):
        """_run_search with lazy_conn wires brain._result_set for lazy loading."""
        from plone.pgcatalog.search import _run_search

        mock_conn = mock.MagicMock()
        mock_cursor = mock.MagicMock()
        mock_conn.cursor.return_value.__enter__ = mock.Mock(return_value=mock_cursor)
        mock_conn.cursor.return_value.__exit__ = mock.Mock(return_value=False)
        mock_cursor.fetchall.return_value = [
            {"zoid": 1, "path": "/plone/a"},
            {"zoid": 2, "path": "/plone/b"},
        ]

        with mock.patch("plone.pgcatalog.search.build_query") as bq:
            bq.return_value = {
                "where": "TRUE",
                "params": {},
                "order_by": None,
                "limit": None,
                "offset": None,
            }
            results = _run_search(mock_conn, {}, lazy_conn=mock_conn)

        assert len(results) == 2
        # Brains should have _result_set wired
        for brain in results:
            assert brain._result_set is results


# ---------------------------------------------------------------------------
# _extract_idx — metadata callable extraction
# ---------------------------------------------------------------------------


class TestExtractIdxMetadataCallable:
    def test_metadata_callable_is_called(self, populated_registry):
        """Metadata columns with callable values should be called."""
        registry = get_registry()
        registry.add_metadata("dynamic_meta")
        try:
            tool = PlonePGCatalogTool.__new__(PlonePGCatalogTool)
            wrapper = mock.Mock()
            wrapper.dynamic_meta.return_value = "called_meta_value"

            idx = tool._extract_idx(wrapper)
            assert idx["dynamic_meta"] == "called_meta_value"
        finally:
            registry._metadata.discard("dynamic_meta")

    def test_metadata_exception_skipped(self, populated_registry):
        """If a metadata accessor raises, it's skipped."""
        registry = get_registry()
        registry.add_metadata("broken_meta")
        try:
            tool = PlonePGCatalogTool.__new__(PlonePGCatalogTool)
            wrapper = mock.Mock()
            wrapper.broken_meta.side_effect = RuntimeError("broken")
            wrapper.portal_type = "Document"

            idx = tool._extract_idx(wrapper)
            assert "broken_meta" not in idx
            assert idx["portal_type"] == "Document"
        finally:
            registry._metadata.discard("broken_meta")


# ---------------------------------------------------------------------------
# PATH-type index extraction tests
# ---------------------------------------------------------------------------


class TestExtractIdxPathType:
    def test_path_index_extracts_parent_and_depth(self, populated_registry):
        """PATH-type indexes store path + _parent + _depth in idx JSONB."""
        tool = PlonePGCatalogTool.__new__(PlonePGCatalogTool)
        wrapper = mock.Mock()
        wrapper.tgpath = "/plone/folder/doc"

        idx = tool._extract_idx(wrapper)
        assert idx["tgpath"] == "/plone/folder/doc"
        assert idx["tgpath_parent"] == "/plone/folder"
        assert idx["tgpath_depth"] == 3


# ---------------------------------------------------------------------------
# _is_json_native tests
# ---------------------------------------------------------------------------


class TestIsJsonNative:
    def test_primitives(self):
        from plone.pgcatalog.extraction import _is_json_native

        assert _is_json_native(None) is True
        assert _is_json_native(True) is True
        assert _is_json_native(False) is True
        assert _is_json_native(42) is True
        assert _is_json_native(3.14) is True
        assert _is_json_native("hello") is True
        assert _is_json_native("") is True
        assert _is_json_native(0) is True

    def test_datetime_false(self):
        from DateTime import DateTime
        from datetime import date
        from datetime import datetime
        from plone.pgcatalog.extraction import _is_json_native

        assert _is_json_native(DateTime("2024/01/01")) is False
        assert _is_json_native(datetime(2024, 1, 1)) is False
        assert _is_json_native(date(2024, 1, 1)) is False

    def test_nested_list_native(self):
        from plone.pgcatalog.extraction import _is_json_native

        assert _is_json_native(["a", "b"]) is True
        assert _is_json_native([1, 2, 3]) is True
        assert _is_json_native(("a", 1)) is True

    def test_nested_list_nonnative(self):
        from DateTime import DateTime
        from plone.pgcatalog.extraction import _is_json_native

        assert _is_json_native(["a", DateTime("2024/01/01")]) is False

    def test_nested_dict_native(self):
        from plone.pgcatalog.extraction import _is_json_native

        assert _is_json_native({"a": 1, "b": "two"}) is True

    def test_nested_dict_nonnative(self):
        from DateTime import DateTime
        from plone.pgcatalog.extraction import _is_json_native

        assert _is_json_native({"a": DateTime("2024/01/01")}) is False


# ---------------------------------------------------------------------------
# Metadata extraction with @meta — type preservation
# ---------------------------------------------------------------------------


class TestExtractMetadataTypes:
    """Test metadata type splitting: JSON-native → top-level idx, other → @meta.

    Uses types.SimpleNamespace instead of mock.Mock() so that only explicitly
    set attributes exist — unset metadata fields return None via getattr default.
    """

    def _make_tool(self):
        return PlonePGCatalogTool.__new__(PlonePGCatalogTool)

    def test_json_native_no_meta_key(self, populated_registry):
        """Metadata with only JSON-native values → no @meta key."""
        from types import SimpleNamespace

        tool = self._make_tool()
        wrapper = SimpleNamespace(
            portal_type="Document",
            CreationDate="2024-01-01",
            getObjSize="42 KB",
        )

        idx = tool._extract_idx(wrapper)
        assert "@meta" not in idx
        assert idx["CreationDate"] == "2024-01-01"
        assert idx["getObjSize"] == "42 KB"

    def test_datetime_goes_to_meta(self, populated_registry):
        """Zope DateTime metadata → stored in @meta, not top-level idx."""
        from DateTime import DateTime
        from types import SimpleNamespace

        tool = self._make_tool()
        dt = DateTime("2008/03/17 08:03:00 GMT+1")
        wrapper = SimpleNamespace(portal_type="Document", EffectiveDate=dt)

        idx = tool._extract_idx(wrapper)
        assert "@meta" in idx
        assert "EffectiveDate" in idx["@meta"]

    def test_python_datetime_goes_to_meta(self, populated_registry):
        """stdlib datetime metadata → stored in @meta."""
        from datetime import datetime
        from types import SimpleNamespace

        tool = self._make_tool()
        dt = datetime(2024, 1, 15, 12, 30, tzinfo=UTC)
        wrapper = SimpleNamespace(portal_type="Document", ModificationDate=dt)

        idx = tool._extract_idx(wrapper)
        assert "@meta" in idx
        assert "ModificationDate" in idx["@meta"]

    def test_python_date_goes_to_meta(self, populated_registry):
        """stdlib date metadata → stored in @meta."""
        from datetime import date
        from types import SimpleNamespace

        tool = self._make_tool()
        d = date(2024, 6, 15)
        wrapper = SimpleNamespace(portal_type="Document", ExpirationDate=d)

        idx = tool._extract_idx(wrapper)
        assert "@meta" in idx
        assert "ExpirationDate" in idx["@meta"]

    def test_mixed_native_and_nonnative(self, populated_registry):
        """Mix: native in top-level idx, non-native in @meta."""
        from DateTime import DateTime
        from types import SimpleNamespace

        tool = self._make_tool()
        dt = DateTime("2024/01/01")
        wrapper = SimpleNamespace(
            portal_type="Document",
            getObjSize="10 KB",
            EffectiveDate=dt,
        )

        idx = tool._extract_idx(wrapper)
        assert idx["getObjSize"] == "10 KB"
        assert "@meta" in idx
        assert "EffectiveDate" in idx["@meta"]

    def test_field_also_index(self, populated_registry):
        """Field is both DateIndex and metadata → index in idx, original in @meta."""
        from DateTime import DateTime
        from types import SimpleNamespace

        tool = self._make_tool()
        dt = DateTime("2008/03/17 08:03:00 GMT+1")
        wrapper = SimpleNamespace(portal_type="Document", effective=dt)

        idx = tool._extract_idx(wrapper)
        # Index value: converted by convert_value (ISO string)
        assert isinstance(idx["effective"], str)
        assert "2008-03-17" in idx["effective"]
        # Metadata value: original DateTime in @meta
        assert "@meta" in idx
        assert "effective" in idx["@meta"]

    def test_none_stays_in_idx(self, populated_registry):
        """None metadata value → top-level idx (JSON-native)."""
        from types import SimpleNamespace

        tool = self._make_tool()
        wrapper = SimpleNamespace(portal_type="Document", getRemoteUrl=None)

        idx = tool._extract_idx(wrapper)
        # None should be at top-level (not in @meta)
        if "@meta" in idx:
            assert "getRemoteUrl" not in idx["@meta"]

    def test_list_of_strings_stays_in_idx(self, populated_registry):
        """List of strings → top-level idx (JSON-native)."""
        from types import SimpleNamespace

        tool = self._make_tool()
        wrapper = SimpleNamespace(
            portal_type="Document",
            listCreators=["admin", "editor"],
        )

        idx = tool._extract_idx(wrapper)
        assert idx.get("listCreators") == ["admin", "editor"]
        if "@meta" in idx:
            assert "listCreators" not in idx["@meta"]

    def test_callable_returning_datetime(self, populated_registry):
        """Callable metadata returning DateTime → stored in @meta."""
        from DateTime import DateTime
        from types import SimpleNamespace

        tool = self._make_tool()
        dt = DateTime("2024/06/01")
        wrapper = SimpleNamespace(
            portal_type="Document",
            CreationDate=lambda: dt,
        )

        idx = tool._extract_idx(wrapper)
        assert "@meta" in idx
        assert "CreationDate" in idx["@meta"]


# ---------------------------------------------------------------------------
# Codec round-trip integration tests
# ---------------------------------------------------------------------------


class TestCodecRoundTrip:
    def test_datetime_roundtrip(self):
        """Zope DateTime survives pickle → codec → pickle round-trip."""
        from DateTime import DateTime
        from zodb_json_codec import dict_to_pickle
        from zodb_json_codec import pickle_to_dict

        import pickle

        dt = DateTime("2008/03/17 08:03:00 GMT+1")
        original = {"effective": dt}

        coded = pickle_to_dict(pickle.dumps(original, protocol=3))
        restored = pickle.loads(dict_to_pickle(coded))

        assert isinstance(restored["effective"], DateTime)
        assert restored["effective"] == dt

    def test_python_datetime_roundtrip(self):
        """stdlib datetime survives codec round-trip."""
        from datetime import datetime
        from zodb_json_codec import dict_to_pickle
        from zodb_json_codec import pickle_to_dict

        import pickle

        dt = datetime(2024, 1, 15, 12, 30, tzinfo=UTC)
        original = {"modified": dt}

        coded = pickle_to_dict(pickle.dumps(original, protocol=3))
        restored = pickle.loads(dict_to_pickle(coded))

        assert isinstance(restored["modified"], datetime)
        assert restored["modified"] == dt

    def test_mixed_dict_roundtrip(self):
        """Dict with mixed types survives codec round-trip."""
        from DateTime import DateTime
        from datetime import date
        from zodb_json_codec import dict_to_pickle
        from zodb_json_codec import pickle_to_dict

        import pickle

        original = {
            "effective": DateTime("2024/01/01"),
            "Title": "Hello",
            "count": 42,
            "ExpirationDate": date(2025, 12, 31),
        }

        coded = pickle_to_dict(pickle.dumps(original, protocol=3))
        restored = pickle.loads(dict_to_pickle(coded))

        assert isinstance(restored["effective"], DateTime)
        assert restored["Title"] == "Hello"
        assert restored["count"] == 42
        assert isinstance(restored["ExpirationDate"], date)

    def test_none_and_empty_roundtrip(self):
        """None and empty strings survive codec round-trip."""
        from zodb_json_codec import dict_to_pickle
        from zodb_json_codec import pickle_to_dict

        import pickle

        original = {"a": None, "b": "", "c": 0}
        coded = pickle_to_dict(pickle.dumps(original, protocol=3))
        restored = pickle.loads(dict_to_pickle(coded))

        assert restored["a"] is None
        assert restored["b"] == ""
        assert restored["c"] == 0
