"""
Tests for LibraryService collections and stats methods.

Covers:
- get_all_collections: empty, None doc_count, dict structure
- get_unique_domains: None filtering, empty results
- get_research_list_with_stats: empty, with stats, ratings, missing ratings
- open_file_location: no URL, no tracker, successful open
"""

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


# ============== Helper ==============


def _make_service():
    from local_deep_research.research_library.services.library_service import (
        LibraryService,
    )

    with patch.object(LibraryService, "__init__", lambda self, username: None):
        service = LibraryService.__new__(LibraryService)
        service.username = "test_user"
    return service


def _mock_session_cm(mocker, mock_session):
    """Patch get_user_db_session as a proper context manager."""

    @contextmanager
    def _cm(username, password=None):
        yield mock_session

    mocker.patch(
        "local_deep_research.research_library.services.library_service.get_user_db_session",
        side_effect=_cm,
    )


# ============== get_all_collections ==============


class TestGetAllCollectionsEdgeCases:
    """Edge cases for get_all_collections."""

    def test_empty_collections_returns_empty_list(self, mocker):
        """No collections → empty list."""
        service = _make_service()
        mock_session = MagicMock()

        mock_query = MagicMock()
        mock_query.outerjoin.return_value = mock_query
        mock_query.group_by.return_value = mock_query
        mock_query.order_by.return_value = mock_query
        mock_query.all.return_value = []
        mock_session.query.return_value = mock_query
        _mock_session_cm(mocker, mock_session)

        result = service.get_all_collections()

        assert result == []

    def test_none_doc_count_uses_zero_fallback(self, mocker):
        """Collection with None doc_count → uses 0 fallback."""
        service = _make_service()
        mock_session = MagicMock()

        mock_coll = Mock()
        mock_coll.id = "coll-1"
        mock_coll.name = "Empty Collection"
        mock_coll.description = "No docs"
        mock_coll.is_default = False

        mock_query = MagicMock()
        mock_query.outerjoin.return_value = mock_query
        mock_query.group_by.return_value = mock_query
        mock_query.order_by.return_value = mock_query
        mock_query.all.return_value = [(mock_coll, None, None)]
        mock_session.query.return_value = mock_query
        _mock_session_cm(mocker, mock_session)

        result = service.get_all_collections()

        assert len(result) == 1
        assert result[0]["document_count"] == 0

    def test_collection_dict_has_all_expected_keys(self, mocker):
        """Each collection dict has: id, name, description, is_default, document_count, indexed_document_count."""
        service = _make_service()
        mock_session = MagicMock()

        mock_coll = Mock()
        mock_coll.id = "coll-1"
        mock_coll.name = "Test"
        mock_coll.description = "Desc"
        mock_coll.is_default = True

        mock_query = MagicMock()
        mock_query.outerjoin.return_value = mock_query
        mock_query.group_by.return_value = mock_query
        mock_query.order_by.return_value = mock_query
        mock_query.all.return_value = [(mock_coll, 7, 3)]
        mock_session.query.return_value = mock_query
        _mock_session_cm(mocker, mock_session)

        result = service.get_all_collections()
        entry = result[0]

        assert set(entry.keys()) == {
            "id",
            "name",
            "description",
            "is_default",
            "document_count",
            "indexed_document_count",
        }
        assert entry["id"] == "coll-1"
        assert entry["name"] == "Test"
        assert entry["is_default"] is True
        assert entry["document_count"] == 7
        assert entry["indexed_document_count"] == 3

    def test_multiple_collections_all_present(self, mocker):
        """Multiple collections all appear in result."""
        service = _make_service()
        mock_session = MagicMock()

        colls = []
        for i in range(3):
            c = Mock()
            c.id = f"coll-{i}"
            c.name = f"Collection {i}"
            c.description = f"Desc {i}"
            c.is_default = i == 0
            colls.append((c, i * 5, i * 2))

        mock_query = MagicMock()
        mock_query.outerjoin.return_value = mock_query
        mock_query.group_by.return_value = mock_query
        mock_query.order_by.return_value = mock_query
        mock_query.all.return_value = colls
        mock_session.query.return_value = mock_query
        _mock_session_cm(mocker, mock_session)

        result = service.get_all_collections()

        assert len(result) == 3
        ids = [r["id"] for r in result]
        assert "coll-0" in ids
        assert "coll-1" in ids
        assert "coll-2" in ids


# ============== get_unique_domains ==============


class TestGetUniqueDomainsEdgeCases:
    """Edge cases for get_unique_domains.

    Returns the sorted set of unique netlocs from all document URLs.
    """

    def test_none_url_values_filtered_out(self, mocker):
        """None URLs are skipped when extracting netlocs."""
        service = _make_service()
        mock_session = MagicMock()
        mock_session.query.return_value.filter.return_value.yield_per.return_value = [
            (None,),
            ("https://nature.com/paper",),
        ]
        _mock_session_cm(mocker, mock_session)

        result = service.get_unique_domains()

        assert result == ["nature.com"]

    def test_empty_query_results_returns_empty_list(self, mocker):
        """No documents → empty domain list."""
        service = _make_service()
        mock_session = MagicMock()
        mock_session.query.return_value.filter.return_value.yield_per.return_value = []
        _mock_session_cm(mocker, mock_session)

        result = service.get_unique_domains()

        assert result == []

    def test_unique_netlocs_deduplicated_and_sorted(self, mocker):
        """Multiple URLs with same netloc deduplicated; result is sorted."""
        service = _make_service()
        mock_session = MagicMock()
        mock_session.query.return_value.filter.return_value.yield_per.return_value = [
            ("https://nature.com/a",),
            ("https://nature.com/b",),
            ("https://arxiv.org/abs/1234",),
            ("https://sciencedirect.com/c",),
        ]
        _mock_session_cm(mocker, mock_session)

        result = service.get_unique_domains()

        assert result == ["arxiv.org", "nature.com", "sciencedirect.com"]

    def test_scans_urls_in_bounded_batches(self, mocker):
        """The URL scan streams via ``yield_per`` rather than ``.all()`` so a
        very large library is never fully materialized (#4560). Fails if the
        query reverts to an unbounded load."""
        from local_deep_research.research_library.services.library_service import (
            _DOMAIN_SCAN_BATCH_SIZE,
        )

        service = _make_service()
        mock_session = MagicMock()
        mock_filter = mock_session.query.return_value.filter.return_value
        mock_filter.yield_per.return_value = []
        _mock_session_cm(mocker, mock_session)

        service.get_unique_domains()

        mock_filter.yield_per.assert_called_once_with(_DOMAIN_SCAN_BATCH_SIZE)
        mock_filter.all.assert_not_called()


# ============== get_research_list_for_dropdown ==============


class TestGetResearchListForDropdown:
    """Tests for get_research_list_for_dropdown."""

    def test_returns_id_title_query_only(self, mocker):
        """Returned dicts contain only id, title, and query."""
        service = _make_service()
        mock_session = MagicMock()

        mock_row1 = Mock()
        mock_row1.id = "r1"
        mock_row1.title = "My Research"
        mock_row1.query = "quantum computing"

        mock_row2 = Mock()
        mock_row2.id = "r2"
        mock_row2.title = None
        mock_row2.query = "neural networks"

        mock_query = MagicMock()
        mock_query.order_by.return_value = mock_query
        mock_query.limit.return_value = mock_query
        mock_query.all.return_value = [mock_row1, mock_row2]
        mock_session.query.return_value = mock_query
        _mock_session_cm(mocker, mock_session)

        result = service.get_research_list_for_dropdown()

        assert len(result) == 2
        assert result[0] == {
            "id": "r1",
            "title": "My Research",
            "query": "quantum computing",
        }
        assert result[1] == {
            "id": "r2",
            "title": None,
            "query": "neural networks",
        }
        # Ensure no extra keys
        assert set(result[0].keys()) == {"id", "title", "query"}

    def test_empty_returns_empty_list(self, mocker):
        """No research sessions → empty list."""
        service = _make_service()
        mock_session = MagicMock()

        mock_query = MagicMock()
        mock_query.order_by.return_value = mock_query
        mock_query.limit.return_value = mock_query
        mock_query.all.return_value = []
        mock_session.query.return_value = mock_query
        _mock_session_cm(mocker, mock_session)

        result = service.get_research_list_for_dropdown()

        assert result == []

    def test_query_is_bounded_by_dropdown_limit(self, mocker):
        """The dropdown query applies the safety cap so a very large history
        cannot be loaded unbounded (#4560). Fails if ``.limit()`` is dropped."""
        from local_deep_research.research_library.services.library_service import (
            _DROPDOWN_RESEARCH_LIMIT,
        )

        service = _make_service()
        mock_session = MagicMock()
        mock_query = MagicMock()
        mock_query.order_by.return_value = mock_query
        mock_query.limit.return_value = mock_query
        mock_query.all.return_value = []
        mock_session.query.return_value = mock_query
        _mock_session_cm(mocker, mock_session)

        service.get_research_list_for_dropdown()

        mock_query.limit.assert_called_once_with(_DROPDOWN_RESEARCH_LIMIT)
        # Cap is applied after ordering by recency, not before.
        mock_query.order_by.assert_called_once()


# ============== get_research_list_with_stats ==============


class TestGetResearchListWithStats:
    """Tests for get_research_list_with_stats."""

    def test_empty_research_list(self, mocker):
        """No research sessions → empty list, no ratings query."""
        service = _make_service()
        mock_session = MagicMock()

        mock_query = MagicMock()
        mock_query.outerjoin.return_value = mock_query
        mock_query.group_by.return_value = mock_query
        mock_query.order_by.return_value = mock_query
        mock_query.all.return_value = []
        mock_session.query.return_value = mock_query
        _mock_session_cm(mocker, mock_session)

        result = service.get_research_list_with_stats()

        assert result == []

    def test_research_with_correct_stat_values(self, mocker):
        """Research entry includes correct total/downloaded/downloadable counts."""
        service = _make_service()
        mock_session = MagicMock()

        mock_research = Mock()
        mock_research.id = 1
        mock_research.title = "Test Research"
        mock_research.query = "quantum"
        mock_research.mode = "deep"
        mock_research.status = "completed"
        mock_research.created_at = "2024-01-01"
        mock_research.duration_seconds = 120

        # Main query
        mock_main_query = MagicMock()
        mock_main_query.outerjoin.return_value = mock_main_query
        mock_main_query.group_by.return_value = mock_main_query
        mock_main_query.order_by.return_value = mock_main_query
        mock_main_query.all.return_value = [(mock_research, 10, 8, 5)]

        # Ratings query
        mock_ratings_query = MagicMock()
        mock_ratings_query.filter.return_value = mock_ratings_query
        mock_ratings_query.all.return_value = []

        # Domain query
        mock_domain_query = MagicMock()
        mock_domain_query.filter.return_value = mock_domain_query
        mock_domain_query.group_by.return_value = mock_domain_query
        mock_domain_query.limit.return_value = mock_domain_query
        mock_domain_query.all.return_value = [(1, "arxiv.org", 3)]

        call_count = {"n": 0}

        def query_side_effect(*args):
            call_count["n"] += 1
            if call_count["n"] == 1:
                return mock_main_query
            if call_count["n"] == 2:
                return mock_ratings_query
            return mock_domain_query

        mock_session.query.side_effect = query_side_effect
        _mock_session_cm(mocker, mock_session)

        result = service.get_research_list_with_stats()

        assert len(result) == 1
        entry = result[0]
        assert entry["total_resources"] == 10
        assert entry["downloaded_count"] == 8
        assert entry["downloadable_count"] == 5

    def test_rating_from_preloaded_dict(self, mocker):
        """Rating value correctly looked up from preloaded ratings dict."""
        service = _make_service()
        mock_session = MagicMock()

        mock_research = Mock()
        mock_research.id = 42
        mock_research.title = "Rated Research"
        mock_research.query = "test"
        mock_research.mode = "quick"
        mock_research.status = "completed"
        mock_research.created_at = "2024-01-01"
        mock_research.duration_seconds = 60

        mock_rating = Mock()
        mock_rating.research_id = 42
        mock_rating.rating = 5

        mock_main_query = MagicMock()
        mock_main_query.outerjoin.return_value = mock_main_query
        mock_main_query.group_by.return_value = mock_main_query
        mock_main_query.order_by.return_value = mock_main_query
        mock_main_query.all.return_value = [(mock_research, 3, 2, 1)]

        mock_ratings_query = MagicMock()
        mock_ratings_query.filter.return_value = mock_ratings_query
        mock_ratings_query.all.return_value = [mock_rating]

        mock_domain_query = MagicMock()
        mock_domain_query.filter.return_value = mock_domain_query
        mock_domain_query.group_by.return_value = mock_domain_query
        mock_domain_query.limit.return_value = mock_domain_query
        mock_domain_query.all.return_value = []

        call_count = {"n": 0}

        def query_side_effect(*args):
            call_count["n"] += 1
            if call_count["n"] == 1:
                return mock_main_query
            if call_count["n"] == 2:
                return mock_ratings_query
            return mock_domain_query

        mock_session.query.side_effect = query_side_effect
        _mock_session_cm(mocker, mock_session)

        result = service.get_research_list_with_stats()

        assert result[0]["rating"] == 5

    def test_missing_rating_returns_none(self, mocker):
        """Research with no rating entry → rating is None."""
        service = _make_service()
        mock_session = MagicMock()

        mock_research = Mock()
        mock_research.id = 99
        mock_research.title = "Unrated"
        mock_research.query = "test"
        mock_research.mode = "quick"
        mock_research.status = "completed"
        mock_research.created_at = "2024-01-01"
        mock_research.duration_seconds = 30

        mock_main_query = MagicMock()
        mock_main_query.outerjoin.return_value = mock_main_query
        mock_main_query.group_by.return_value = mock_main_query
        mock_main_query.order_by.return_value = mock_main_query
        mock_main_query.all.return_value = [(mock_research, 1, 0, 0)]

        mock_ratings_query = MagicMock()
        mock_ratings_query.filter.return_value = mock_ratings_query
        mock_ratings_query.all.return_value = []

        mock_domain_query = MagicMock()
        mock_domain_query.filter.return_value = mock_domain_query
        mock_domain_query.group_by.return_value = mock_domain_query
        mock_domain_query.limit.return_value = mock_domain_query
        mock_domain_query.all.return_value = []

        call_count = {"n": 0}

        def query_side_effect(*args):
            call_count["n"] += 1
            if call_count["n"] == 1:
                return mock_main_query
            if call_count["n"] == 2:
                return mock_ratings_query
            return mock_domain_query

        mock_session.query.side_effect = query_side_effect
        _mock_session_cm(mocker, mock_session)

        result = service.get_research_list_with_stats()

        assert result[0]["rating"] is None

    def test_top_domains_present(self, mocker):
        """Domain breakdown appears as top_domains list."""
        service = _make_service()
        mock_session = MagicMock()

        mock_research = Mock()
        mock_research.id = 1
        mock_research.title = "Test"
        mock_research.query = "test"
        mock_research.mode = "quick"
        mock_research.status = "completed"
        mock_research.created_at = "2024-01-01"
        mock_research.duration_seconds = 30

        mock_main_query = MagicMock()
        mock_main_query.outerjoin.return_value = mock_main_query
        mock_main_query.group_by.return_value = mock_main_query
        mock_main_query.order_by.return_value = mock_main_query
        mock_main_query.all.return_value = [(mock_research, 5, 3, 2)]

        mock_ratings_query = MagicMock()
        mock_ratings_query.filter.return_value = mock_ratings_query
        mock_ratings_query.all.return_value = []

        mock_domain_query = MagicMock()
        mock_domain_query.filter.return_value = mock_domain_query
        mock_domain_query.group_by.return_value = mock_domain_query
        mock_domain_query.limit.return_value = mock_domain_query
        mock_domain_query.all.return_value = [
            (1, "arxiv.org", 3),
            (1, "pubmed", 2),
        ]

        call_count = {"n": 0}

        def query_side_effect(*args):
            call_count["n"] += 1
            if call_count["n"] == 1:
                return mock_main_query
            if call_count["n"] == 2:
                return mock_ratings_query
            return mock_domain_query

        mock_session.query.side_effect = query_side_effect
        _mock_session_cm(mocker, mock_session)

        result = service.get_research_list_with_stats()

        assert "top_domains" in result[0]
        assert len(result[0]["top_domains"]) == 2

    def test_pagination_applies_offset_limit(self, mocker):
        """When limit > 0, query receives offset() and limit() calls."""
        service = _make_service()
        mock_session = MagicMock()

        mock_query = MagicMock()
        mock_query.outerjoin.return_value = mock_query
        mock_query.group_by.return_value = mock_query
        mock_query.order_by.return_value = mock_query
        mock_query.offset.return_value = mock_query
        mock_query.limit.return_value = mock_query
        mock_query.all.return_value = []
        mock_session.query.return_value = mock_query
        _mock_session_cm(mocker, mock_session)

        service.get_research_list_with_stats(limit=10, offset=20)

        mock_query.offset.assert_called_once_with(20)
        mock_query.limit.assert_called_once_with(10)

    def test_no_pagination_when_limit_zero(self, mocker):
        """Default limit=0 skips offset/limit calls (backwards compat)."""
        service = _make_service()
        mock_session = MagicMock()

        mock_query = MagicMock()
        mock_query.outerjoin.return_value = mock_query
        mock_query.group_by.return_value = mock_query
        mock_query.order_by.return_value = mock_query
        mock_query.all.return_value = []
        mock_session.query.return_value = mock_query
        _mock_session_cm(mocker, mock_session)

        service.get_research_list_with_stats()

        mock_query.offset.assert_not_called()
        mock_query.limit.assert_not_called()


# ============== get_download_manager_summary_stats ==============


class TestGetDownloadManagerSummaryStats:
    """Tests for get_download_manager_summary_stats."""

    def test_returns_correct_stat_keys(self, mocker):
        """Result contains all expected summary keys."""
        service = _make_service()
        mock_session = MagicMock()

        mock_row = Mock()
        mock_row.total_researches = 5
        mock_row.total_resources = 20
        mock_row.downloaded_count = 8
        mock_row.downloadable_count = 12

        mock_query = MagicMock()
        mock_query.select_from.return_value = mock_query
        mock_query.outerjoin.return_value = mock_query
        mock_query.one.return_value = mock_row
        mock_session.query.return_value = mock_query
        _mock_session_cm(mocker, mock_session)

        result = service.get_download_manager_summary_stats()

        assert result == {
            "total_researches": 5,
            "total_resources": 20,
            "already_downloaded": 8,
            "available_to_download": 4,  # 12 - 8
        }

    def test_none_values_default_to_zero(self, mocker):
        """NULL aggregate results treated as 0."""
        service = _make_service()
        mock_session = MagicMock()

        mock_row = Mock()
        mock_row.total_researches = None
        mock_row.total_resources = None
        mock_row.downloaded_count = None
        mock_row.downloadable_count = None

        mock_query = MagicMock()
        mock_query.select_from.return_value = mock_query
        mock_query.outerjoin.return_value = mock_query
        mock_query.one.return_value = mock_row
        mock_session.query.return_value = mock_query
        _mock_session_cm(mocker, mock_session)

        result = service.get_download_manager_summary_stats()

        assert result["total_researches"] == 0
        assert result["available_to_download"] == 0

    def test_available_never_negative(self, mocker):
        """available_to_download is clamped to 0 when downloaded > downloadable."""
        service = _make_service()
        mock_session = MagicMock()

        mock_row = Mock()
        mock_row.total_researches = 1
        mock_row.total_resources = 5
        mock_row.downloaded_count = 10
        mock_row.downloadable_count = 3

        mock_query = MagicMock()
        mock_query.select_from.return_value = mock_query
        mock_query.outerjoin.return_value = mock_query
        mock_query.one.return_value = mock_row
        mock_session.query.return_value = mock_query
        _mock_session_cm(mocker, mock_session)

        result = service.get_download_manager_summary_stats()

        assert result["available_to_download"] == 0


# ============== get_pdf_previews_batch ==============


class TestGetPdfPreviewsBatch:
    """Tests for get_pdf_previews_batch."""

    def test_empty_research_ids_returns_empty(self, mocker):
        """No research IDs → empty dict, no DB call."""
        service = _make_service()

        result = service.get_pdf_previews_batch([])
        assert result == {}

    def test_groups_documents_by_research_id(self, mocker):
        """Documents are grouped under correct research_id keys."""
        service = _make_service()
        mock_session = MagicMock()

        doc1 = Mock()
        doc1.id = "d1"
        doc1.research_id = "r1"
        doc1.file_type = "pdf"
        doc1.status = "completed"
        doc1.original_url = "https://arxiv.org/pdf/1234.pdf"
        doc1.filename = "paper1.pdf"

        doc2 = Mock()
        doc2.id = "d2"
        doc2.research_id = "r2"
        doc2.file_type = "pdf"
        doc2.status = "pending"
        doc2.original_url = "https://example.com/doc.pdf"
        doc2.filename = "doc.pdf"

        res1 = Mock()
        res1.url = "https://arxiv.org/abs/1234"
        res1.title = "Paper One"

        res2 = Mock()
        res2.url = "https://example.com/doc.pdf"
        res2.title = "Doc Two"

        mock_query = MagicMock()
        mock_query.outerjoin.return_value = mock_query
        mock_query.filter.return_value = mock_query
        mock_query.order_by.return_value = mock_query
        mock_query.limit.return_value = mock_query
        mock_query.all.return_value = [(doc1, res1), (doc2, res2)]
        mock_session.query.return_value = mock_query
        _mock_session_cm(mocker, mock_session)

        result = service.get_pdf_previews_batch(["r1", "r2"])

        assert "r1" in result
        assert "r2" in result
        assert len(result["r1"]["pdf_sources"]) == 1
        assert result["r1"]["pdf_sources"][0]["document_title"] == "Paper One"

    def test_caps_pdf_sources_at_limit(self, mocker):
        """pdf_sources list is capped at limit_per_research."""
        service = _make_service()
        mock_session = MagicMock()

        docs = []
        for i in range(5):
            doc = Mock()
            doc.id = f"d{i}"
            doc.research_id = "r1"
            doc.file_type = "pdf"
            doc.status = "completed"
            doc.original_url = f"https://example.com/{i}.pdf"
            doc.filename = f"{i}.pdf"
            res = Mock()
            res.url = f"https://example.com/{i}.pdf"
            res.title = f"Paper {i}"
            docs.append((doc, res))

        mock_query = MagicMock()
        mock_query.outerjoin.return_value = mock_query
        mock_query.filter.return_value = mock_query
        mock_query.order_by.return_value = mock_query
        mock_query.limit.return_value = mock_query
        mock_query.all.return_value = docs
        mock_session.query.return_value = mock_query
        _mock_session_cm(mocker, mock_session)

        result = service.get_pdf_previews_batch(["r1"], limit_per_research=3)

        assert len(result["r1"]["pdf_sources"]) == 3
        # domains should still reflect all 5
        total_domain_docs = sum(
            d["total"] for d in result["r1"]["domains"].values()
        )
        assert total_domain_docs == 5

    def test_domain_breakdown_counts(self, mocker):
        """Domain breakdown correctly counts total/pdfs/downloaded."""
        service = _make_service()
        mock_session = MagicMock()

        doc1 = Mock()
        doc1.id = "d1"
        doc1.research_id = "r1"
        doc1.file_type = "pdf"
        doc1.status = "completed"
        doc1.original_url = None
        doc1.filename = "a.pdf"

        doc2 = Mock()
        doc2.id = "d2"
        doc2.research_id = "r1"
        doc2.file_type = "pdf"
        doc2.status = "pending"
        doc2.original_url = None
        doc2.filename = "b.pdf"

        res1 = Mock()
        res1.url = "https://arxiv.org/pdf/1.pdf"
        res1.title = "A"
        res2 = Mock()
        res2.url = "https://arxiv.org/pdf/2.pdf"
        res2.title = "B"

        mock_query = MagicMock()
        mock_query.outerjoin.return_value = mock_query
        mock_query.filter.return_value = mock_query
        mock_query.order_by.return_value = mock_query
        mock_query.limit.return_value = mock_query
        mock_query.all.return_value = [(doc1, res1), (doc2, res2)]
        mock_session.query.return_value = mock_query
        _mock_session_cm(mocker, mock_session)

        result = service.get_pdf_previews_batch(["r1"])

        arxiv = result["r1"]["domains"]["arxiv.org"]
        assert arxiv["total"] == 2
        assert arxiv["pdfs"] == 2
        assert arxiv["downloaded"] == 1  # only doc1 is completed

    def test_resource_none_falls_back_to_doc_filename(self, mocker):
        """When resource is None, title falls back to doc.filename."""
        service = _make_service()
        mock_session = MagicMock()

        doc = Mock()
        doc.id = "d1"
        doc.research_id = "r1"
        doc.file_type = "pdf"
        doc.status = "completed"
        doc.original_url = "https://example.com/paper.pdf"
        doc.filename = "my_paper.pdf"

        mock_query = MagicMock()
        mock_query.outerjoin.return_value = mock_query
        mock_query.filter.return_value = mock_query
        mock_query.order_by.return_value = mock_query
        mock_query.limit.return_value = mock_query
        mock_query.all.return_value = [(doc, None)]
        mock_session.query.return_value = mock_query
        _mock_session_cm(mocker, mock_session)

        result = service.get_pdf_previews_batch(["r1"])

        assert (
            result["r1"]["pdf_sources"][0]["document_title"] == "my_paper.pdf"
        )
        assert result["r1"]["pdf_sources"][0]["domain"] == "example.com"

    def test_dedup_skips_duplicate_doc_rows(self, mocker):
        """OR-join fan-out duplicates are deduplicated by doc.id."""
        service = _make_service()
        mock_session = MagicMock()

        doc = Mock()
        doc.id = "d1"
        doc.research_id = "r1"
        doc.file_type = "pdf"
        doc.status = "completed"
        doc.original_url = None
        doc.filename = "paper.pdf"

        res = Mock()
        res.url = "https://arxiv.org/pdf/1.pdf"
        res.title = "Paper"

        # Simulate OR-join fan-out: same doc appears twice
        mock_query = MagicMock()
        mock_query.outerjoin.return_value = mock_query
        mock_query.filter.return_value = mock_query
        mock_query.order_by.return_value = mock_query
        mock_query.limit.return_value = mock_query
        mock_query.all.return_value = [(doc, res), (doc, res)]
        mock_session.query.return_value = mock_query
        _mock_session_cm(mocker, mock_session)

        result = service.get_pdf_previews_batch(["r1"])

        # Should only count once despite two rows
        assert len(result["r1"]["pdf_sources"]) == 1
        assert result["r1"]["domains"]["arxiv.org"]["total"] == 1


# ============== open_file_location ==============


class TestOpenFileLocation:
    """Tests for open_file_location method."""

    def test_doc_has_no_original_url_returns_false(self, mocker):
        """Doc with original_url=None → returns False (no tracker lookup)."""
        service = _make_service()
        mock_session = MagicMock()

        mock_doc = Mock()
        mock_doc.original_url = None

        mock_session.query.return_value.get.return_value = mock_doc
        _mock_session_cm(mocker, mock_session)

        result = service.open_file_location("doc-123")

        assert result is False

    def test_tracker_not_found_returns_false(self, mocker):
        """Doc exists but no tracker → returns False."""
        service = _make_service()
        mock_session = MagicMock()

        mock_doc = Mock()
        mock_doc.original_url = "https://example.com/doc.pdf"

        mock_doc_query = MagicMock()
        mock_doc_query.get.return_value = mock_doc
        mock_tracker_query = MagicMock()
        mock_tracker_query.filter_by.return_value.first.return_value = None

        def query_router(model):
            name = getattr(model, "__name__", str(model))
            if "DownloadTracker" in str(name) or "Tracker" in str(model):
                return mock_tracker_query
            return mock_doc_query

        mock_session.query.side_effect = query_router
        _mock_session_cm(mocker, mock_session)

        result = service.open_file_location("doc-123")

        assert result is False

    def test_successful_open_calls_utility(self, mocker):
        """Valid path that exists → calls open_file_location utility, returns True."""
        service = _make_service()
        mock_session = MagicMock()

        mock_doc = Mock()
        mock_doc.original_url = "https://example.com/doc.pdf"

        mock_tracker = Mock()
        mock_tracker.file_path = "pdfs/doc.pdf"

        mock_doc_query = MagicMock()
        mock_doc_query.get.return_value = mock_doc
        mock_tracker_query = MagicMock()
        mock_tracker_query.filter_by.return_value.first.return_value = (
            mock_tracker
        )

        def query_router(model):
            name = getattr(model, "__name__", str(model))
            if "DownloadTracker" in str(name) or "Tracker" in str(model):
                return mock_tracker_query
            return mock_doc_query

        mock_session.query.side_effect = query_router
        _mock_session_cm(mocker, mock_session)

        mock_validated_path = MagicMock()
        mock_validated_path.is_file.return_value = True

        mocker.patch(
            "local_deep_research.research_library.services.library_service.get_absolute_path_from_settings",
            return_value=MagicMock(),
        )
        mocker.patch(
            "local_deep_research.research_library.services.library_service.PathValidator.validate_safe_path",
            return_value=mock_validated_path,
        )
        mock_open_fn = mocker.patch(
            "local_deep_research.research_library.services.library_service.open_file_location",
            return_value=True,
        )

        result = service.open_file_location("doc-123")

        assert result is True
        mock_open_fn.assert_called_once()
