# allow: no-sut-import — black-box HTTP test; drives real routes through the Flask test client
"""
Tests for collection search and research history routes.

Uses the Flask test client with mocked authentication.
"""

import json
from contextlib import contextmanager
from unittest.mock import MagicMock, patch

import pytest


TEST_COLLECTION_ID = "test-collection-id-123"


@pytest.fixture
def auth_client(app):
    """Create a test client that bypasses the login_required checks."""
    # Mock db_manager.connections to have an entry for testuser
    mock_connections = {"testuser": MagicMock()}

    # Patch db_manager in all modules that import it directly, so that
    # before_request handlers (session_cleanup, database_middleware) also
    # see the mock and don't clear the session or fail to initialise.
    with (
        patch(
            "local_deep_research.web.auth.decorators.db_manager"
        ) as mock_db_manager,
        patch(
            "local_deep_research.web.auth.session_cleanup.db_manager",
            mock_db_manager,
        ),
        patch(
            "local_deep_research.web.auth.database_middleware.db_manager",
            mock_db_manager,
        ),
    ):
        mock_db_manager.connections = mock_connections
        mock_db_manager.is_user_connected.return_value = True
        mock_db_manager.has_encryption = False
        client = app.test_client()

        with client.session_transaction() as sess:
            sess["username"] = "testuser"
            sess["session_id"] = "test-session-id"

        yield client


class TestSearchCollectionRoute:
    """Tests for POST /library/api/collections/<collection_id>/search"""

    def test_search_empty_query_400(self, auth_client):
        """Empty query should return 400."""
        response = auth_client.post(
            f"/library/api/collections/{TEST_COLLECTION_ID}/search",
            data=json.dumps({"query": ""}),
            content_type="application/json",
        )
        assert response.status_code == 400
        data = response.get_json()
        assert data["success"] is False
        assert "required" in data["error"].lower()

    def test_search_missing_query_400(self, auth_client):
        """Missing query field should return 400."""
        response = auth_client.post(
            f"/library/api/collections/{TEST_COLLECTION_ID}/search",
            data=json.dumps({}),
            content_type="application/json",
        )
        assert response.status_code == 400

    def test_collection_not_found_404(self, auth_client):
        """Non-existent collection should return 404 with success=False."""

        @contextmanager
        def mock_session(*_args, **_kwargs):
            session = MagicMock()
            session.query.return_value.filter_by.return_value.first.return_value = None
            yield session

        with patch(
            "local_deep_research.database.session_context.get_user_db_session",
            mock_session,
        ):
            response = auth_client.post(
                f"/library/api/collections/{TEST_COLLECTION_ID}/search",
                data=json.dumps({"query": "test"}),
                content_type="application/json",
            )

        assert response.status_code == 404
        data = response.get_json()
        assert data["success"] is False
        assert "not found" in data["error"].lower()

    def test_enrich_default_fields_when_document_not_in_db(self, auth_client):
        """When the enrichment query finds no matching document rows,
        results should receive default sentinel fields (type='source',
        research_id=None, research_title='', etc.)."""

        mock_collection = MagicMock()
        mock_collection.collection_type = "research_history"
        mock_collection.name = "Test"

        call_count = 0

        @contextmanager
        def mock_session(*_args, **_kwargs):
            nonlocal call_count
            call_count += 1
            db_session = MagicMock()
            if call_count == 1:
                # First call: collection lookup
                db_session.query.return_value.filter_by.return_value.first.return_value = mock_collection
            else:
                # Second call (inside _enrich_with_research_metadata):
                # complex join query → no rows found
                db_session.query.return_value.join.return_value.outerjoin.return_value.filter.return_value.all.return_value = []
            yield db_session

        fake_results = [
            {
                "title": "Some Source",
                "snippet": "snippet text",
                "relevance_score": 0.85,
                "metadata": {
                    "document_id": "doc-id-not-in-db",
                    "source": "https://example.com",
                },
            }
        ]

        mock_engine = MagicMock()
        mock_engine.search.return_value = fake_results

        with (
            patch(
                "local_deep_research.database.session_context.get_user_db_session",
                mock_session,
            ),
            patch(
                "local_deep_research.web_search_engines.engines.search_engine_collection.CollectionSearchEngine",
                return_value=mock_engine,
            ),
        ):
            response = auth_client.post(
                f"/library/api/collections/{TEST_COLLECTION_ID}/search",
                data=json.dumps({"query": "test query"}),
                content_type="application/json",
            )

        assert response.status_code == 200
        data = response.get_json()
        assert data["success"] is True
        assert len(data["results"]) == 1
        result = data["results"][0]
        assert result["type"] == "source"
        assert result["research_id"] is None
        assert result["research_title"] == ""
        assert result["research_query"] is None
        assert result["research_created_at"] is None

    def test_exception_response_generic(self, auth_client):
        """Exception responses should not leak internal details."""
        # Mock the db session to raise when querying Collection
        with patch(
            "local_deep_research.database.session_context.get_user_db_session",
            side_effect=RuntimeError("secret DB connection string"),
        ):
            response = auth_client.post(
                f"/library/api/collections/{TEST_COLLECTION_ID}/search",
                data=json.dumps({"query": "test query"}),
                content_type="application/json",
            )
            assert response.status_code == 500
            data = response.get_json()
            assert data["success"] is False
            # Must NOT contain the exception message
            assert "secret" not in data["error"]
            assert "DB connection" not in data["error"]
            # Should contain generic message from handle_api_error
            assert "internal error" in data["error"].lower()


class TestEnrichWithDocumentMetadata:
    """Tests for _enrich_with_document_metadata enrichment on search results."""

    def test_enriches_file_type_and_domain(self, auth_client):
        """Search results for a user_collection should include file_type,
        domain, and created_at from the Document model."""
        from datetime import datetime, timezone

        mock_collection = MagicMock()
        mock_collection.collection_type = "user_collection"
        mock_collection.name = "My Docs"

        mock_doc_row = MagicMock()
        mock_doc_row.document_id = "doc-123"
        mock_doc_row.file_type = "pdf"
        mock_doc_row.original_url = "https://arxiv.org/abs/2301.12345"
        mock_doc_row.created_at = datetime(2025, 1, 15, tzinfo=timezone.utc)

        call_count = 0

        @contextmanager
        def mock_session(*_args, **_kwargs):
            nonlocal call_count
            call_count += 1
            db_session = MagicMock()
            if call_count == 1:
                # Collection lookup
                db_session.query.return_value.filter_by.return_value.first.return_value = mock_collection
            else:
                # Document metadata query
                db_session.query.return_value.filter.return_value.all.return_value = [
                    mock_doc_row
                ]
            yield db_session

        fake_results = [
            {
                "title": "Test Paper",
                "snippet": "snippet",
                "relevance_score": 0.9,
                "metadata": {
                    "document_id": "doc-123",
                    "source": "https://arxiv.org/abs/2301.12345",
                },
            }
        ]
        mock_engine = MagicMock()
        mock_engine.search.return_value = fake_results

        with (
            patch(
                "local_deep_research.database.session_context.get_user_db_session",
                mock_session,
            ),
            patch(
                "local_deep_research.web_search_engines.engines.search_engine_collection.CollectionSearchEngine",
                return_value=mock_engine,
            ),
        ):
            response = auth_client.post(
                f"/library/api/collections/{TEST_COLLECTION_ID}/search",
                data=json.dumps({"query": "quantum"}),
                content_type="application/json",
            )

        assert response.status_code == 200
        data = response.get_json()
        assert data["success"] is True
        result = data["results"][0]
        assert result["file_type"] == "pdf"
        assert result["domain"] == "arxiv.org"
        assert result["created_at"] is not None

    def test_default_fields_when_document_not_found(self, auth_client):
        """Document not in DB should get default metadata values."""
        mock_collection = MagicMock()
        mock_collection.collection_type = "user_collection"
        mock_collection.name = "My Docs"

        call_count = 0

        @contextmanager
        def mock_session(*_args, **_kwargs):
            nonlocal call_count
            call_count += 1
            db_session = MagicMock()
            if call_count == 1:
                db_session.query.return_value.filter_by.return_value.first.return_value = mock_collection
            else:
                db_session.query.return_value.filter.return_value.all.return_value = []
            yield db_session

        fake_results = [
            {
                "title": "Unknown Doc",
                "snippet": "snippet",
                "relevance_score": 0.7,
                "metadata": {
                    "document_id": "doc-not-in-db",
                },
            }
        ]
        mock_engine = MagicMock()
        mock_engine.search.return_value = fake_results

        with (
            patch(
                "local_deep_research.database.session_context.get_user_db_session",
                mock_session,
            ),
            patch(
                "local_deep_research.web_search_engines.engines.search_engine_collection.CollectionSearchEngine",
                return_value=mock_engine,
            ),
        ):
            response = auth_client.post(
                f"/library/api/collections/{TEST_COLLECTION_ID}/search",
                data=json.dumps({"query": "test"}),
                content_type="application/json",
            )

        assert response.status_code == 200
        data = response.get_json()
        result = data["results"][0]
        assert result["file_type"] == "unknown"
        assert result["domain"] is None
        assert result["created_at"] is None

    def test_document_with_none_original_url(self, auth_client):
        """Document in DB with original_url=None should have domain=None."""
        from datetime import datetime, timezone

        mock_collection = MagicMock()
        mock_collection.collection_type = "user_collection"
        mock_collection.name = "My Docs"

        mock_doc_row = MagicMock()
        mock_doc_row.document_id = "doc-456"
        mock_doc_row.file_type = "txt"
        mock_doc_row.original_url = None
        mock_doc_row.created_at = datetime(2025, 6, 1, tzinfo=timezone.utc)

        call_count = 0

        @contextmanager
        def mock_session(*_args, **_kwargs):
            nonlocal call_count
            call_count += 1
            db_session = MagicMock()
            if call_count == 1:
                db_session.query.return_value.filter_by.return_value.first.return_value = mock_collection
            else:
                db_session.query.return_value.filter.return_value.all.return_value = [
                    mock_doc_row
                ]
            yield db_session

        fake_results = [
            {
                "title": "Local Doc",
                "snippet": "snippet",
                "relevance_score": 0.8,
                "metadata": {"document_id": "doc-456"},
            }
        ]
        mock_engine = MagicMock()
        mock_engine.search.return_value = fake_results

        with (
            patch(
                "local_deep_research.database.session_context.get_user_db_session",
                mock_session,
            ),
            patch(
                "local_deep_research.web_search_engines.engines.search_engine_collection.CollectionSearchEngine",
                return_value=mock_engine,
            ),
        ):
            response = auth_client.post(
                f"/library/api/collections/{TEST_COLLECTION_ID}/search",
                data=json.dumps({"query": "local"}),
                content_type="application/json",
            )

        assert response.status_code == 200
        data = response.get_json()
        result = data["results"][0]
        assert result["file_type"] == "txt"
        assert result["domain"] is None
        assert result["created_at"] is not None

    def test_malformed_url_returns_unknown_domain(self, auth_client):
        """Malformed original_url should set domain to 'unknown'."""
        mock_collection = MagicMock()
        mock_collection.collection_type = "user_collection"
        mock_collection.name = "My Docs"

        mock_doc_row = MagicMock()
        mock_doc_row.document_id = "doc-789"
        mock_doc_row.file_type = "html"
        mock_doc_row.original_url = "not-a-valid-url"
        mock_doc_row.created_at = None

        # urlparse("not-a-valid-url").netloc returns '' (empty), not an exception
        # but we test the general path — domain will be empty string from netloc
        call_count = 0

        @contextmanager
        def mock_session(*_args, **_kwargs):
            nonlocal call_count
            call_count += 1
            db_session = MagicMock()
            if call_count == 1:
                db_session.query.return_value.filter_by.return_value.first.return_value = mock_collection
            else:
                db_session.query.return_value.filter.return_value.all.return_value = [
                    mock_doc_row
                ]
            yield db_session

        fake_results = [
            {
                "title": "Bad URL Doc",
                "snippet": "snippet",
                "relevance_score": 0.6,
                "metadata": {"document_id": "doc-789"},
            }
        ]
        mock_engine = MagicMock()
        mock_engine.search.return_value = fake_results

        with (
            patch(
                "local_deep_research.database.session_context.get_user_db_session",
                mock_session,
            ),
            patch(
                "local_deep_research.web_search_engines.engines.search_engine_collection.CollectionSearchEngine",
                return_value=mock_engine,
            ),
        ):
            response = auth_client.post(
                f"/library/api/collections/{TEST_COLLECTION_ID}/search",
                data=json.dumps({"query": "bad"}),
                content_type="application/json",
            )

        assert response.status_code == 200
        data = response.get_json()
        result = data["results"][0]
        assert result["file_type"] == "html"
        # urlparse sets netloc to empty string for non-URL strings
        assert result["domain"] is not None

    def test_results_without_document_id_skipped(self, auth_client):
        """Results missing document_id should not cause errors."""
        mock_collection = MagicMock()
        mock_collection.collection_type = "user_collection"
        mock_collection.name = "My Docs"

        call_count = 0

        @contextmanager
        def mock_session(*_args, **_kwargs):
            nonlocal call_count
            call_count += 1
            db_session = MagicMock()
            if call_count == 1:
                db_session.query.return_value.filter_by.return_value.first.return_value = mock_collection
            else:
                # No doc_ids to look up, so this shouldn't be called
                db_session.query.return_value.filter.return_value.all.return_value = []
            yield db_session

        fake_results = [
            {
                "title": "No Doc ID",
                "snippet": "snippet",
                "relevance_score": 0.5,
                "metadata": {},
            }
        ]
        mock_engine = MagicMock()
        mock_engine.search.return_value = fake_results

        with (
            patch(
                "local_deep_research.database.session_context.get_user_db_session",
                mock_session,
            ),
            patch(
                "local_deep_research.web_search_engines.engines.search_engine_collection.CollectionSearchEngine",
                return_value=mock_engine,
            ),
        ):
            response = auth_client.post(
                f"/library/api/collections/{TEST_COLLECTION_ID}/search",
                data=json.dumps({"query": "none"}),
                content_type="application/json",
            )

        assert response.status_code == 200
        data = response.get_json()
        assert data["success"] is True
        assert len(data["results"]) == 1


PATCH_INDEXER = "local_deep_research.research_library.search.services.research_history_indexer.ResearchHistoryIndexer"


class TestGetResearchHistoryCollectionRoute:
    """Tests for GET /library/api/research-history/collection"""

    def test_happy_path_200(self, auth_client):
        """Returns 200 with collection_id and status fields on success."""
        fake_uuid = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"

        mock_indexer = MagicMock()
        mock_indexer.get_or_create_collection.return_value = fake_uuid

        # Mock the DB session for the inline counting queries
        mock_query = MagicMock()
        # Chain: .query().filter().filter().filter().count() → total_research
        # Chain: .query().join().filter().filter().distinct().count() → indexed_research
        # Chain: .query().filter().count() → total_documents
        # Chain: .query().filter().filter().count() → indexed_documents
        mock_query.count.return_value = 5
        mock_query.filter.return_value = mock_query
        mock_query.filter_by.return_value = mock_query
        mock_query.join.return_value = mock_query
        mock_query.distinct.return_value = mock_query

        @contextmanager
        def mock_session(*_args, **_kwargs):
            session = MagicMock()
            session.query.return_value = mock_query
            yield session

        with (
            patch(PATCH_INDEXER, return_value=mock_indexer),
            patch(
                "local_deep_research.database.session_context.get_user_db_session",
                mock_session,
            ),
        ):
            response = auth_client.get(
                "/library/api/research-history/collection"
            )

        assert response.status_code == 200
        data = response.get_json()
        assert data["success"] is True
        assert data["collection_id"] == fake_uuid
        # All counts come from the same mock returning 5
        assert data["total_research"] == 5
        assert data["indexed_research"] == 5
        assert data["total_documents"] == 5
        assert data["indexed_documents"] == 5

    def test_get_does_not_trigger_convert(self, auth_client):
        """GET endpoint must stay read-only — no convert_all_research call.

        This used to fire on every page load, doing ~56 queries + 17
        commits per request and creating perpetual reconvert loops on
        duplicate-content research entries.
        """
        fake_uuid = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
        mock_indexer = MagicMock()
        mock_indexer.get_or_create_collection.return_value = fake_uuid

        mock_query = MagicMock()
        mock_query.count.return_value = 0
        mock_query.filter.return_value = mock_query
        mock_query.filter_by.return_value = mock_query
        mock_query.join.return_value = mock_query
        mock_query.distinct.return_value = mock_query

        @contextmanager
        def mock_session(*_args, **_kwargs):
            session = MagicMock()
            session.query.return_value = mock_query
            yield session

        with (
            patch(PATCH_INDEXER, return_value=mock_indexer),
            patch(
                "local_deep_research.database.session_context.get_user_db_session",
                mock_session,
            ),
        ):
            response = auth_client.get(
                "/library/api/research-history/collection"
            )

        assert response.status_code == 200
        mock_indexer.convert_all_research.assert_not_called()

    def test_exception_returns_500(self, auth_client):
        """Unhandled exception in indexer → 500 with generic error."""
        mock_indexer = MagicMock()
        mock_indexer.get_or_create_collection.side_effect = RuntimeError(
            "secret internal error"
        )

        with patch(PATCH_INDEXER, return_value=mock_indexer):
            response = auth_client.get(
                "/library/api/research-history/collection"
            )

        assert response.status_code == 500
        data = response.get_json()
        assert data["success"] is False
        assert "secret" not in data["error"]


class TestConvertAllRoute:
    """Tests for POST /library/api/research-history/convert-all"""

    def test_happy_path_200(self, auth_client):
        """Successful convert-all returns 200 with success=True and counts."""
        mock_indexer = MagicMock()
        mock_indexer.convert_all_research.return_value = {
            "converted": 3,
            "skipped": 1,
            "failed": 0,
            "collection_id": "col-abc",
        }

        with patch(PATCH_INDEXER, return_value=mock_indexer):
            response = auth_client.post(
                "/library/api/research-history/convert-all",
                data=json.dumps({"force": False}),
                content_type="application/json",
            )

        assert response.status_code == 200
        data = response.get_json()
        assert data["success"] is True
        assert data["converted"] == 3
        assert data["skipped"] == 1
        assert data["failed"] == 0
        assert data["collection_id"] == "col-abc"
        mock_indexer.convert_all_research.assert_called_once_with(force=False)

    def test_exception_returns_500(self, auth_client):
        """Unhandled exception → 500 with generic error message."""
        mock_indexer = MagicMock()
        mock_indexer.convert_all_research.side_effect = RuntimeError(
            "secret db error"
        )

        with patch(PATCH_INDEXER, return_value=mock_indexer):
            response = auth_client.post(
                "/library/api/research-history/convert-all",
                data=json.dumps({}),
                content_type="application/json",
            )

        assert response.status_code == 500
        data = response.get_json()
        assert data["success"] is False
        assert "secret" not in data["error"]
        assert "internal error" in data["error"].lower()


class TestAddToCollectionRoute:
    """Tests for POST /library/api/research/<id>/add-to-collection"""

    def test_missing_collection_id_400(self, auth_client):
        """Missing collection_id should return 400."""
        response = auth_client.post(
            "/library/api/research/some-research-id/add-to-collection",
            data=json.dumps({}),
            content_type="application/json",
        )
        assert response.status_code == 400
        data = response.get_json()
        assert data["success"] is False
        assert "collection_id" in data["error"].lower()

    def test_collection_not_found_404(self, auth_client):
        """Non-existent collection should return 404."""

        @contextmanager
        def mock_session(*_args, **_kwargs):
            session = MagicMock()
            session.query.return_value.filter.return_value.first.return_value = None
            yield session

        with patch(
            "local_deep_research.database.session_context.get_user_db_session",
            mock_session,
        ):
            response = auth_client.post(
                "/library/api/research/some-id/add-to-collection",
                data=json.dumps({"collection_id": "nonexistent-id"}),
                content_type="application/json",
            )
        assert response.status_code == 404
        data = response.get_json()
        assert data["success"] is False
        assert "not found" in data["error"].lower()

    def test_success_returns_200(self, auth_client):
        """Successful add-to-collection returns 200 with result fields."""
        mock_collection = MagicMock()
        mock_collection.name = "My Collection"

        @contextmanager
        def mock_session(*_args, **_kwargs):
            session = MagicMock()
            session.query.return_value.filter.return_value.first.return_value = mock_collection
            yield session

        mock_indexer = MagicMock()
        mock_indexer.index_research.return_value = {
            "status": "success",
            "documents_added": 3,
            "sources_indexed": 2,
        }

        with (
            patch(
                "local_deep_research.database.session_context.get_user_db_session",
                mock_session,
            ),
            patch(PATCH_INDEXER, return_value=mock_indexer),
        ):
            response = auth_client.post(
                "/library/api/research/some-id/add-to-collection",
                data=json.dumps({"collection_id": "coll-123"}),
                content_type="application/json",
            )

        assert response.status_code == 200
        data = response.get_json()
        assert data["success"] is True
        assert data["collection_name"] == "My Collection"
        assert data["documents_added"] == 3


class TestEnrichDatetimeTypeGuards:
    """Tests for the isinstance(str) type guards on datetime fields in
    _enrich_with_research_metadata and _enrich_with_document_metadata.

    These guards handle cases where the DB driver returns a string
    instead of a datetime object (e.g. SQLite text columns).
    """

    def test_document_created_at_as_string_passthrough(self, auth_client):
        """When created_at is already a string, return it as-is (no .isoformat())."""
        mock_collection = MagicMock()
        mock_collection.collection_type = "user_collection"
        mock_collection.name = "Docs"

        mock_doc_row = MagicMock()
        mock_doc_row.document_id = "doc-str-date"
        mock_doc_row.file_type = "pdf"
        mock_doc_row.original_url = None
        mock_doc_row.created_at = (
            "2025-06-15T10:00:00+00:00"  # already a string
        )

        call_count = 0

        @contextmanager
        def mock_session(*_args, **_kwargs):
            nonlocal call_count
            call_count += 1
            db_session = MagicMock()
            if call_count == 1:
                db_session.query.return_value.filter_by.return_value.first.return_value = mock_collection
            else:
                db_session.query.return_value.filter.return_value.all.return_value = [
                    mock_doc_row
                ]
            yield db_session

        fake_results = [
            {
                "title": "String Date Doc",
                "snippet": "s",
                "relevance_score": 0.9,
                "metadata": {"document_id": "doc-str-date"},
            }
        ]
        mock_engine = MagicMock()
        mock_engine.search.return_value = fake_results

        with (
            patch(
                "local_deep_research.database.session_context.get_user_db_session",
                mock_session,
            ),
            patch(
                "local_deep_research.web_search_engines.engines.search_engine_collection.CollectionSearchEngine",
                return_value=mock_engine,
            ),
        ):
            response = auth_client.post(
                f"/library/api/collections/{TEST_COLLECTION_ID}/search",
                data=json.dumps({"query": "date test"}),
                content_type="application/json",
            )

        assert response.status_code == 200
        data = response.get_json()
        result = data["results"][0]
        assert result["created_at"] == "2025-06-15T10:00:00+00:00"

    def test_document_created_at_as_datetime(self, auth_client):
        """When created_at is a datetime, call .isoformat()."""
        from datetime import datetime, timezone

        mock_collection = MagicMock()
        mock_collection.collection_type = "user_collection"
        mock_collection.name = "Docs"

        mock_doc_row = MagicMock()
        mock_doc_row.document_id = "doc-dt-date"
        mock_doc_row.file_type = "pdf"
        mock_doc_row.original_url = None
        mock_doc_row.created_at = datetime(
            2025, 6, 15, 10, 0, 0, tzinfo=timezone.utc
        )

        call_count = 0

        @contextmanager
        def mock_session(*_args, **_kwargs):
            nonlocal call_count
            call_count += 1
            db_session = MagicMock()
            if call_count == 1:
                db_session.query.return_value.filter_by.return_value.first.return_value = mock_collection
            else:
                db_session.query.return_value.filter.return_value.all.return_value = [
                    mock_doc_row
                ]
            yield db_session

        fake_results = [
            {
                "title": "Datetime Date Doc",
                "snippet": "s",
                "relevance_score": 0.9,
                "metadata": {"document_id": "doc-dt-date"},
            }
        ]
        mock_engine = MagicMock()
        mock_engine.search.return_value = fake_results

        with (
            patch(
                "local_deep_research.database.session_context.get_user_db_session",
                mock_session,
            ),
            patch(
                "local_deep_research.web_search_engines.engines.search_engine_collection.CollectionSearchEngine",
                return_value=mock_engine,
            ),
        ):
            response = auth_client.post(
                f"/library/api/collections/{TEST_COLLECTION_ID}/search",
                data=json.dumps({"query": "date test"}),
                content_type="application/json",
            )

        assert response.status_code == 200
        data = response.get_json()
        result = data["results"][0]
        assert result["created_at"] == "2025-06-15T10:00:00+00:00"

    def test_document_created_at_none(self, auth_client):
        """When created_at is None, return None."""
        mock_collection = MagicMock()
        mock_collection.collection_type = "user_collection"
        mock_collection.name = "Docs"

        mock_doc_row = MagicMock()
        mock_doc_row.document_id = "doc-no-date"
        mock_doc_row.file_type = "pdf"
        mock_doc_row.original_url = None
        mock_doc_row.created_at = None

        call_count = 0

        @contextmanager
        def mock_session(*_args, **_kwargs):
            nonlocal call_count
            call_count += 1
            db_session = MagicMock()
            if call_count == 1:
                db_session.query.return_value.filter_by.return_value.first.return_value = mock_collection
            else:
                db_session.query.return_value.filter.return_value.all.return_value = [
                    mock_doc_row
                ]
            yield db_session

        fake_results = [
            {
                "title": "No Date Doc",
                "snippet": "s",
                "relevance_score": 0.9,
                "metadata": {"document_id": "doc-no-date"},
            }
        ]
        mock_engine = MagicMock()
        mock_engine.search.return_value = fake_results

        with (
            patch(
                "local_deep_research.database.session_context.get_user_db_session",
                mock_session,
            ),
            patch(
                "local_deep_research.web_search_engines.engines.search_engine_collection.CollectionSearchEngine",
                return_value=mock_engine,
            ),
        ):
            response = auth_client.post(
                f"/library/api/collections/{TEST_COLLECTION_ID}/search",
                data=json.dumps({"query": "date test"}),
                content_type="application/json",
            )

        assert response.status_code == 200
        data = response.get_json()
        result = data["results"][0]
        assert result["created_at"] is None

    def test_research_created_at_as_string_passthrough(self, auth_client):
        """When research_created_at is already a string, return as-is."""
        mock_collection = MagicMock()
        mock_collection.collection_type = "research_history"
        mock_collection.name = "Research History"

        mock_research_row = MagicMock()
        mock_research_row.document_id = "rdoc-1"
        mock_research_row.source_type_name = "source"
        mock_research_row.research_id = 42
        mock_research_row.research_title = "Test Research"
        mock_research_row.research_query = "test query"
        mock_research_row.research_created_at = "2025-03-20T12:00:00+00:00"

        call_count = 0

        @contextmanager
        def mock_session(*_args, **_kwargs):
            nonlocal call_count
            call_count += 1
            db_session = MagicMock()
            if call_count == 1:
                # Collection lookup
                db_session.query.return_value.filter_by.return_value.first.return_value = mock_collection
            else:
                # Both _enrich_with_research_metadata (outerjoin chain) and
                # _enrich_with_document_metadata (filter chain) go through here.
                # MagicMock auto-chains, so .outerjoin().outerjoin().filter().all()
                # returns the same mock chain ending in .all()
                q = db_session.query.return_value
                q.outerjoin.return_value = q
                q.filter.return_value = q
                q.all.return_value = (
                    [mock_research_row] if call_count == 2 else []
                )
            yield db_session

        fake_results = [
            {
                "title": "Result",
                "snippet": "s",
                "relevance_score": 0.8,
                "metadata": {"document_id": "rdoc-1"},
            }
        ]
        mock_engine = MagicMock()
        mock_engine.search.return_value = fake_results

        with (
            patch(
                "local_deep_research.database.session_context.get_user_db_session",
                mock_session,
            ),
            patch(
                "local_deep_research.web_search_engines.engines.search_engine_collection.CollectionSearchEngine",
                return_value=mock_engine,
            ),
        ):
            response = auth_client.post(
                f"/library/api/collections/{TEST_COLLECTION_ID}/search",
                data=json.dumps({"query": "research date"}),
                content_type="application/json",
            )

        assert response.status_code == 200
        data = response.get_json()
        result = data["results"][0]
        assert result["research_created_at"] == "2025-03-20T12:00:00+00:00"

    def test_research_created_at_as_datetime(self, auth_client):
        """When research_created_at is a datetime, call .isoformat()."""
        from datetime import datetime, timezone

        mock_collection = MagicMock()
        mock_collection.collection_type = "research_history"
        mock_collection.name = "Research History"

        mock_research_row = MagicMock()
        mock_research_row.document_id = "rdoc-2"
        mock_research_row.source_type_name = "research_report"
        mock_research_row.research_id = 99
        mock_research_row.research_title = "Datetime Research"
        mock_research_row.research_query = "test"
        mock_research_row.research_created_at = datetime(
            2025, 3, 20, 12, 0, 0, tzinfo=timezone.utc
        )

        call_count = 0

        @contextmanager
        def mock_session(*_args, **_kwargs):
            nonlocal call_count
            call_count += 1
            db_session = MagicMock()
            if call_count == 1:
                db_session.query.return_value.filter_by.return_value.first.return_value = mock_collection
            else:
                q = db_session.query.return_value
                q.outerjoin.return_value = q
                q.filter.return_value = q
                q.all.return_value = (
                    [mock_research_row] if call_count == 2 else []
                )
            yield db_session

        fake_results = [
            {
                "title": "Result",
                "snippet": "s",
                "relevance_score": 0.8,
                "metadata": {"document_id": "rdoc-2"},
            }
        ]
        mock_engine = MagicMock()
        mock_engine.search.return_value = fake_results

        with (
            patch(
                "local_deep_research.database.session_context.get_user_db_session",
                mock_session,
            ),
            patch(
                "local_deep_research.web_search_engines.engines.search_engine_collection.CollectionSearchEngine",
                return_value=mock_engine,
            ),
        ):
            response = auth_client.post(
                f"/library/api/collections/{TEST_COLLECTION_ID}/search",
                data=json.dumps({"query": "research datetime"}),
                content_type="application/json",
            )

        assert response.status_code == 200
        data = response.get_json()
        result = data["results"][0]
        assert result["research_created_at"] == "2025-03-20T12:00:00+00:00"
