"""
Strategy and integration coverage tests for DownloadService.

Targets the remaining ~65 missing statements in download_service.py with
focused tests for:

1. download_resource — arxiv/pubmed/generic downloader strategy selection
2. download_resource — DownloadTracker finds existing document (dedup check)
3. download_as_text — text extraction success path
4. download_as_text — resource not found
5. retry_failed_download — RetryManager.record_attempt integration
6. __enter__ / __exit__ context manager
"""

from unittest.mock import MagicMock, patch

import pytest

from local_deep_research.research_library.services.download_service import (
    DownloadService,
)

MODULE = "local_deep_research.research_library.services.download_service"


# ---------------------------------------------------------------------------
# Shared fixture — bypass __init__ entirely
# ---------------------------------------------------------------------------


@pytest.fixture
def svc():
    """DownloadService with __init__ bypassed and sensible defaults."""
    with patch.object(DownloadService, "__init__", lambda self, *a, **kw: None):
        service = DownloadService.__new__(DownloadService)
        service.username = "test_user"
        service.password = "test_pass"
        service._closed = False
        service.downloaders = []
        service.retry_manager = MagicMock()
        service.settings = MagicMock()
        service.library_root = "/tmp/test_library"
        service._pubmed_delay = 1.0
        service._last_pubmed_request = 0.0
        return service


def _make_ctx(session):
    """Return a context manager mock that yields *session*."""
    ctx = MagicMock()
    ctx.__enter__ = MagicMock(return_value=session)
    ctx.__exit__ = MagicMock(return_value=False)
    return ctx


# ---------------------------------------------------------------------------
# 1. download_resource — ArxivDownloader strategy selected for arxiv URL
# ---------------------------------------------------------------------------


class TestDownloadResourceArxivStrategy:
    """_download_pdf is reached with an ArxivDownloader for arxiv URLs."""

    def test_arxiv_downloader_handles_arxiv_url(self, svc):
        """ArxivDownloader.can_handle returns True for arxiv.org URLs."""
        from local_deep_research.research_library.downloaders import (
            ArxivDownloader,
        )

        # Use a real ArxivDownloader instance to verify strategy selection
        arxiv_dl = ArxivDownloader(timeout=10)
        assert arxiv_dl.can_handle("https://arxiv.org/abs/2301.00001") is True

    def test_download_resource_routes_arxiv_url_through_arxiv_downloader(
        self, svc
    ):
        """download_resource uses the arxiv downloader for arxiv URLs."""
        session = MagicMock()
        resource = MagicMock()
        resource.id = 1
        resource.url = "https://arxiv.org/abs/2301.00001"
        session.query.return_value.get.return_value = resource

        # Simulate: existing_doc=None, queue=None, tracker=None, post-download queue=None
        session.query.return_value.filter_by.return_value.first.return_value = (
            None
        )
        session.query.return_value.filter_by.return_value.order_by.return_value.first.return_value = None

        # Install one mock downloader that identifies as ArxivDownloader
        from local_deep_research.research_library.downloaders import (
            ArxivDownloader,
        )

        mock_arxiv_dl = MagicMock(spec=ArxivDownloader)
        mock_arxiv_dl.can_handle.return_value = True
        result_obj = MagicMock()
        result_obj.is_success = False
        result_obj.content = None
        result_obj.skip_reason = "no pdf"
        mock_arxiv_dl.download_with_result.return_value = result_obj
        svc.downloaders = [mock_arxiv_dl]

        with patch(
            f"{MODULE}.get_user_db_session", return_value=_make_ctx(session)
        ):
            success, reason = svc.download_resource(1)

        # _download_pdf was called which iterated downloaders and tried ArxivDownloader
        mock_arxiv_dl.can_handle.assert_called_with(
            "https://arxiv.org/abs/2301.00001"
        )
        mock_arxiv_dl.download_with_result.assert_called_once()

    def test_arxiv_downloader_is_present_in_service_downloaders(self, mocker):
        """After normal __init__, DownloadService includes an ArxivDownloader."""
        mock_settings = MagicMock()
        mock_settings.get_setting.return_value = "/tmp/test_library"
        mocker.patch(
            f"{MODULE}.get_settings_manager", return_value=mock_settings
        )
        mocker.patch("pathlib.Path.mkdir")
        mocker.patch(f"{MODULE}.RetryManager")

        from local_deep_research.research_library.downloaders import (
            ArxivDownloader,
        )

        service = DownloadService(username="test_user")
        types = [type(d) for d in service.downloaders]
        assert ArxivDownloader in types


# ---------------------------------------------------------------------------
# 2. download_resource — PubMedDownloader strategy
# ---------------------------------------------------------------------------


class TestDownloadResourcePubmedStrategy:
    """_download_pdf uses PubMedDownloader for pubmed.ncbi.nlm.nih.gov URLs."""

    def test_pubmed_downloader_handles_pubmed_url(self):
        from local_deep_research.research_library.downloaders import (
            PubMedDownloader,
        )

        dl = PubMedDownloader(timeout=10)
        assert dl.can_handle("https://pubmed.ncbi.nlm.nih.gov/12345678") is True

    def test_download_resource_routes_pubmed_url_through_pubmed_downloader(
        self, svc
    ):
        """download_resource uses the pubmed downloader for pubmed.ncbi URLs."""
        session = MagicMock()
        resource = MagicMock()
        resource.id = 2
        resource.url = "https://pubmed.ncbi.nlm.nih.gov/12345678"
        session.query.return_value.get.return_value = resource
        session.query.return_value.filter_by.return_value.first.return_value = (
            None
        )
        session.query.return_value.filter_by.return_value.order_by.return_value.first.return_value = None

        from local_deep_research.research_library.downloaders import (
            PubMedDownloader,
        )

        mock_pubmed_dl = MagicMock(spec=PubMedDownloader)
        mock_pubmed_dl.can_handle.return_value = True
        result_obj = MagicMock()
        result_obj.is_success = False
        result_obj.content = None
        result_obj.skip_reason = "no pdf available"
        mock_pubmed_dl.download_with_result.return_value = result_obj
        svc.downloaders = [mock_pubmed_dl]

        with patch(
            f"{MODULE}.get_user_db_session", return_value=_make_ctx(session)
        ):
            success, reason = svc.download_resource(2)

        mock_pubmed_dl.can_handle.assert_called_with(
            "https://pubmed.ncbi.nlm.nih.gov/12345678"
        )
        mock_pubmed_dl.download_with_result.assert_called_once()

    def test_pubmed_downloader_is_present_in_service_downloaders(self, mocker):
        """After normal __init__, DownloadService includes a PubMedDownloader."""
        mock_settings = MagicMock()
        mock_settings.get_setting.return_value = "/tmp/test_library"
        mocker.patch(
            f"{MODULE}.get_settings_manager", return_value=mock_settings
        )
        mocker.patch("pathlib.Path.mkdir")
        mocker.patch(f"{MODULE}.RetryManager")

        from local_deep_research.research_library.downloaders import (
            PubMedDownloader,
        )

        service = DownloadService(username="test_user")
        types = [type(d) for d in service.downloaders]
        assert PubMedDownloader in types


# ---------------------------------------------------------------------------
# 3. download_resource — GenericDownloader fallback
# ---------------------------------------------------------------------------


class TestDownloadResourceGenericFallback:
    """GenericDownloader is used when no specialist downloader matches."""

    def test_generic_downloader_is_last_in_chain(self, mocker):
        """GenericDownloader is always last so it acts as fallback."""
        mock_settings = MagicMock()
        mock_settings.get_setting.return_value = "/tmp/test_library"
        mocker.patch(
            f"{MODULE}.get_settings_manager", return_value=mock_settings
        )
        mocker.patch("pathlib.Path.mkdir")
        mocker.patch(f"{MODULE}.RetryManager")

        from local_deep_research.research_library.downloaders import (
            GenericDownloader,
        )

        service = DownloadService(username="test_user")
        assert isinstance(service.downloaders[-1], GenericDownloader)

    def test_download_pdf_falls_through_to_generic_downloader(self, svc):
        """_download_pdf tries all downloaders; GenericDownloader is the last."""
        from local_deep_research.research_library.downloaders import (
            GenericDownloader,
        )

        # First downloader rejects the URL, second (generic) accepts it but fails
        dl_miss = MagicMock()
        dl_miss.can_handle.return_value = False

        dl_generic = MagicMock(spec=GenericDownloader)
        dl_generic.can_handle.return_value = True
        result_obj = MagicMock()
        result_obj.is_success = False
        result_obj.content = None
        result_obj.skip_reason = "Generic fallback failed"
        dl_generic.download_with_result.return_value = result_obj

        svc.downloaders = [dl_miss, dl_generic]

        resource = MagicMock()
        resource.url = "https://generic-site.example.com/paper"
        resource.id = 3
        resource.title = "A Paper Title"

        tracker = MagicMock()
        tracker.url_hash = "hash_generic"
        tracker.download_attempts = MagicMock()
        tracker.download_attempts.count.return_value = 0

        session = MagicMock()
        success, reason, _status_code = svc._download_pdf(
            resource, tracker, session
        )

        assert success is False
        assert reason == "Generic fallback failed"
        dl_miss.can_handle.assert_called_once_with(resource.url)
        dl_generic.download_with_result.assert_called_once()

    def test_generic_downloader_is_tried_when_others_skip(self, svc):
        """When specialist downloaders emit skip_reason, generic is still attempted."""
        from local_deep_research.research_library.downloaders import (
            GenericDownloader,
        )

        # Specialist: handles but skips (non-fatal skip)
        dl_specialist = MagicMock()
        dl_specialist.can_handle.return_value = True
        skip_result = MagicMock()
        skip_result.is_success = False
        skip_result.content = None
        skip_result.skip_reason = "No open access"
        dl_specialist.download_with_result.return_value = skip_result
        # Not a GenericDownloader instance, so loop continues
        type(dl_specialist).__name__ = "DirectPDFDownloader"

        dl_generic = MagicMock(spec=GenericDownloader)
        dl_generic.can_handle.return_value = True
        generic_result = MagicMock()
        generic_result.is_success = True
        generic_result.content = b"%PDF-1.4 generic pdf content"
        generic_result.skip_reason = None
        dl_generic.download_with_result.return_value = generic_result

        svc.downloaders = [dl_specialist, dl_generic]
        svc.settings.get_setting.side_effect = lambda key, default=None: {
            "research_library.pdf_storage_mode": "none",
            "research_library.max_pdf_size_mb": 100,
        }.get(key, default)

        resource = MagicMock()
        resource.url = "https://example.com/paper"
        resource.id = 4
        resource.title = "Generic Fallback Paper"
        resource.research_id = "res-generic"

        tracker = MagicMock()
        tracker.url_hash = "hash_gen2"
        tracker.download_attempts = MagicMock()
        tracker.download_attempts.count.return_value = 0

        session = MagicMock()
        mock_psm = MagicMock()
        mock_psm.save_pdf.return_value = (None, None)

        with (
            patch(
                f"{MODULE}.get_document_for_resource",
                side_effect=[None, MagicMock()],
            ),
            patch(f"{MODULE}.get_source_type_id", return_value="src-1"),
            patch(f"{MODULE}.get_default_library_id", return_value="lib-1"),
            patch(f"{MODULE}.PDFStorageManager", return_value=mock_psm),
            patch(f"{MODULE}.uuid.uuid4", return_value="new-uuid"),
            patch.object(svc, "_extract_text_from_pdf", return_value=None),
        ):
            success, reason, _status_code = svc._download_pdf(
                resource, tracker, session
            )

        assert success is True


# ---------------------------------------------------------------------------
# 4. download_resource — DownloadTracker dedup check (existing completed doc)
# ---------------------------------------------------------------------------


class TestDownloadResourceDedupCheck:
    """download_resource short-circuits when a completed Document already exists."""

    def test_existing_completed_document_returns_success_without_download(
        self, svc
    ):
        """If a COMPLETED Document exists for the resource, skip re-downloading."""
        session = MagicMock()
        resource = MagicMock()
        resource.id = 10
        resource.url = "https://arxiv.org/abs/2301.99999"
        session.query.return_value.get.return_value = resource

        existing_doc = MagicMock()
        # First filter_by call (existing_doc query) returns an existing document
        session.query.return_value.filter_by.return_value.first.return_value = (
            existing_doc
        )

        with patch(
            f"{MODULE}.get_user_db_session", return_value=_make_ctx(session)
        ):
            success, reason = svc.download_resource(10)

        assert success is True
        assert reason is None
        # _download_pdf must NOT have been called since we short-circuited
        svc.retry_manager.record_attempt.assert_not_called()

    def test_download_tracker_reused_when_already_exists(self, svc):
        """If a DownloadTracker already exists for the URL hash, it is reused (not re-added)."""
        session = MagicMock()
        resource = MagicMock()
        resource.id = 11
        resource.url = "https://arxiv.org/abs/2301.12345"
        session.query.return_value.get.return_value = resource

        existing_tracker = MagicMock()
        existing_tracker.url_hash = "hash_existing"

        # Return values in order:
        # 1. existing_doc (COMPLETED) -> None  (not yet downloaded)
        # 2. queue_entry -> None
        # 3. tracker (DownloadTracker by url_hash) -> existing_tracker
        # 4. post-download queue_entry -> None
        session.query.return_value.filter_by.return_value.first.side_effect = [
            None,  # existing_doc
            None,  # queue_entry (pre-download)
            existing_tracker,  # tracker
            None,  # queue_entry (post-download)
        ]
        session.query.return_value.filter_by.return_value.order_by.return_value.first.return_value = None

        with (
            patch(
                f"{MODULE}.get_user_db_session", return_value=_make_ctx(session)
            ),
            patch.object(svc, "_get_url_hash", return_value="hash_existing"),
            patch.object(
                svc, "_download_pdf", return_value=(True, None, None)
            ) as mock_dl_pdf,
        ):
            success, reason = svc.download_resource(11)

            assert success is True
            # session.add should NOT have been called for a new DownloadTracker
            # (existing_tracker was found, not a new one created)
            # Verify _download_pdf received the existing tracker
            call_args = mock_dl_pdf.call_args
            tracker_arg = call_args[0][1]
            assert tracker_arg is existing_tracker

    def test_new_tracker_created_when_none_exists(self, svc):
        """A new DownloadTracker is created and added to session when none exists."""
        session = MagicMock()
        resource = MagicMock()
        resource.id = 12
        resource.url = "https://example.com/new-paper.pdf"
        session.query.return_value.get.return_value = resource

        # All filter_by queries return None (no existing doc, queue, or tracker)
        session.query.return_value.filter_by.return_value.first.return_value = (
            None
        )
        session.query.return_value.filter_by.return_value.order_by.return_value.first.return_value = None

        with (
            patch(
                f"{MODULE}.get_user_db_session", return_value=_make_ctx(session)
            ),
            patch.object(svc, "_get_url_hash", return_value="hash_new"),
            patch.object(
                svc, "_download_pdf", return_value=(False, "Failed", None)
            ),
        ):
            success, reason = svc.download_resource(12)

        # session.add must have been called at least once (new tracker + possibly attempt)
        assert session.add.called


# ---------------------------------------------------------------------------
# 5. download_as_text — success path (existing text in database)
# ---------------------------------------------------------------------------


class TestDownloadAsTextSuccess:
    """download_as_text returns (True, None) when text already exists in DB."""

    def test_existing_text_in_document_returns_success(self, svc):
        """If _try_existing_text succeeds, download_as_text returns (True, None)."""
        session = MagicMock()
        resource = MagicMock()
        resource.source_type = "web"
        resource.url = "https://arxiv.org/abs/2301.00001"

        # First filter_by call returns the resource itself
        session.query.return_value.filter_by.return_value.first.return_value = (
            resource
        )

        with (
            patch(
                f"{MODULE}.get_user_db_session", return_value=_make_ctx(session)
            ),
            patch.object(svc, "_try_existing_text", return_value=(True, None)),
        ):
            success, error = svc.download_as_text(1)

        assert success is True
        assert error is None

    def test_api_text_extraction_success(self, svc):
        """download_as_text returns (True, None) via _try_api_text_extraction."""
        session = MagicMock()
        resource = MagicMock()
        resource.source_type = "web"
        resource.url = "https://arxiv.org/abs/2301.55555"
        session.query.return_value.filter_by.return_value.first.return_value = (
            resource
        )

        with (
            patch(
                f"{MODULE}.get_user_db_session", return_value=_make_ctx(session)
            ),
            patch.object(svc, "_try_existing_text", return_value=None),
            patch.object(svc, "_try_legacy_text_file", return_value=None),
            patch.object(
                svc, "_try_existing_pdf_extraction", return_value=None
            ),
            patch.object(
                svc, "_try_api_text_extraction", return_value=(True, None)
            ),
        ):
            success, error = svc.download_as_text(1)

        assert success is True
        assert error is None

    def test_fallback_pdf_extraction_success(self, svc):
        """download_as_text returns success via _fallback_pdf_extraction."""
        session = MagicMock()
        resource = MagicMock()
        resource.source_type = "web"
        resource.url = "https://biorxiv.org/content/10.1101/2023.01.01.123456v1"
        session.query.return_value.filter_by.return_value.first.return_value = (
            resource
        )

        with (
            patch(
                f"{MODULE}.get_user_db_session", return_value=_make_ctx(session)
            ),
            patch.object(svc, "_try_existing_text", return_value=None),
            patch.object(svc, "_try_legacy_text_file", return_value=None),
            patch.object(
                svc, "_try_existing_pdf_extraction", return_value=None
            ),
            patch.object(svc, "_try_api_text_extraction", return_value=None),
            patch.object(
                svc, "_fallback_pdf_extraction", return_value=(True, None)
            ),
        ):
            success, error = svc.download_as_text(1)

        assert success is True
        assert error is None

    def test_library_resource_routes_to_library_handler(self, svc):
        """A resource with source_type='library' calls _try_library_text_extraction."""
        session = MagicMock()
        resource = MagicMock()
        resource.source_type = "library"
        resource.url = "/library/document/doc-uuid-abc"
        session.query.return_value.filter_by.return_value.first.return_value = (
            resource
        )

        with (
            patch(
                f"{MODULE}.get_user_db_session", return_value=_make_ctx(session)
            ),
            patch.object(
                svc, "_try_library_text_extraction", return_value=(True, None)
            ) as mock_lib,
        ):
            success, error = svc.download_as_text(42)

        assert success is True
        mock_lib.assert_called_once_with(session, resource)


# ---------------------------------------------------------------------------
# 6. download_as_text — resource not found
# ---------------------------------------------------------------------------


class TestDownloadAsTextNoResource:
    """download_as_text returns (False, 'Resource not found') when resource is missing."""

    def test_resource_not_found_returns_false(self, svc):
        session = MagicMock()
        session.query.return_value.filter_by.return_value.first.return_value = (
            None
        )

        with patch(
            f"{MODULE}.get_user_db_session", return_value=_make_ctx(session)
        ):
            success, error = svc.download_as_text(999)

        assert success is False
        assert error == "Resource not found"

    def test_resource_not_found_for_any_id(self, svc):
        """Works for arbitrary missing resource IDs."""
        session = MagicMock()
        session.query.return_value.filter_by.return_value.first.return_value = (
            None
        )

        for resource_id in (0, 1, 42, 9999):
            with patch(
                f"{MODULE}.get_user_db_session", return_value=_make_ctx(session)
            ):
                success, error = svc.download_as_text(resource_id)
            assert success is False
            assert "not found" in error.lower()


# ---------------------------------------------------------------------------
# 7. retry_failed_download — RetryManager.record_attempt integration
# ---------------------------------------------------------------------------


class TestRetryFailedDownload:
    """download_resource calls retry_manager.record_attempt with correct args."""

    def test_record_attempt_called_on_success(self, svc):
        """retry_manager.record_attempt is called with success=True after download."""
        session = MagicMock()
        resource = MagicMock()
        resource.id = 20
        resource.url = "https://arxiv.org/abs/2301.11111"
        session.query.return_value.get.return_value = resource
        session.query.return_value.filter_by.return_value.first.return_value = (
            None
        )
        session.query.return_value.filter_by.return_value.order_by.return_value.first.return_value = None

        with (
            patch(
                f"{MODULE}.get_user_db_session", return_value=_make_ctx(session)
            ),
            patch.object(svc, "_get_url_hash", return_value="hash_success"),
            patch.object(svc, "_download_pdf", return_value=(True, None, None)),
        ):
            svc.download_resource(20)

        svc.retry_manager.record_attempt.assert_called_once()
        call_kwargs = svc.retry_manager.record_attempt.call_args[1]
        assert call_kwargs["resource_id"] == 20
        assert call_kwargs["result"] == (True, None)

    def test_record_attempt_called_on_failure(self, svc):
        """retry_manager.record_attempt is called with success=False after failure."""
        session = MagicMock()
        resource = MagicMock()
        resource.id = 21
        resource.url = "https://example.com/paywalled.pdf"
        session.query.return_value.get.return_value = resource
        session.query.return_value.filter_by.return_value.first.return_value = (
            None
        )
        session.query.return_value.filter_by.return_value.order_by.return_value.first.return_value = None

        with (
            patch(
                f"{MODULE}.get_user_db_session", return_value=_make_ctx(session)
            ),
            patch.object(svc, "_get_url_hash", return_value="hash_fail"),
            patch.object(
                svc, "_download_pdf", return_value=(False, "Access denied", 403)
            ),
        ):
            success, reason = svc.download_resource(21)

        assert success is False
        assert reason == "Access denied"
        svc.retry_manager.record_attempt.assert_called_once()
        call_kwargs = svc.retry_manager.record_attempt.call_args[1]
        assert call_kwargs["resource_id"] == 21
        assert call_kwargs["result"][0] is False

    def test_record_attempt_receives_url(self, svc):
        """retry_manager.record_attempt receives the resource URL."""
        session = MagicMock()
        resource = MagicMock()
        resource.id = 22
        resource.url = "https://pubmed.ncbi.nlm.nih.gov/77777777"
        session.query.return_value.get.return_value = resource
        session.query.return_value.filter_by.return_value.first.return_value = (
            None
        )
        session.query.return_value.filter_by.return_value.order_by.return_value.first.return_value = None

        with (
            patch(
                f"{MODULE}.get_user_db_session", return_value=_make_ctx(session)
            ),
            patch.object(svc, "_get_url_hash", return_value="hash_pubmed"),
            patch.object(svc, "_download_pdf", return_value=(True, None, None)),
        ):
            svc.download_resource(22)

        call_kwargs = svc.retry_manager.record_attempt.call_args[1]
        assert call_kwargs["url"] == "https://pubmed.ncbi.nlm.nih.gov/77777777"

    def test_record_attempt_receives_session(self, svc):
        """retry_manager.record_attempt receives the active db session."""
        session = MagicMock()
        resource = MagicMock()
        resource.id = 23
        resource.url = "https://example.com/paper.pdf"
        session.query.return_value.get.return_value = resource
        session.query.return_value.filter_by.return_value.first.return_value = (
            None
        )
        session.query.return_value.filter_by.return_value.order_by.return_value.first.return_value = None

        with (
            patch(
                f"{MODULE}.get_user_db_session", return_value=_make_ctx(session)
            ),
            patch.object(svc, "_get_url_hash", return_value="hash_sess"),
            patch.object(svc, "_download_pdf", return_value=(True, None, None)),
        ):
            svc.download_resource(23)

        call_kwargs = svc.retry_manager.record_attempt.call_args[1]
        assert call_kwargs["session"] is session

    def test_queue_status_updated_to_completed_on_success(self, svc):
        """Queue entry status is set to COMPLETED when download succeeds."""
        session = MagicMock()
        resource = MagicMock()
        resource.id = 24
        resource.url = "https://example.com/paper.pdf"
        session.query.return_value.get.return_value = resource

        queue_entry = MagicMock()
        # Sequence: existing_doc=None, pre-download queue_entry=None, tracker=None,
        # post-download queue_entry=queue_entry
        session.query.return_value.filter_by.return_value.first.side_effect = [
            None,  # existing_doc
            None,  # pre-download queue_entry
            None,  # tracker
            queue_entry,  # post-download queue_entry
        ]
        session.query.return_value.filter_by.return_value.order_by.return_value.first.return_value = None

        from local_deep_research.database.models.library import DocumentStatus

        with (
            patch(
                f"{MODULE}.get_user_db_session", return_value=_make_ctx(session)
            ),
            patch.object(svc, "_get_url_hash", return_value="hash_q"),
            patch.object(svc, "_download_pdf", return_value=(True, None, None)),
        ):
            svc.download_resource(24)

        assert queue_entry.status == DocumentStatus.COMPLETED

    def test_queue_status_updated_to_failed_on_failure(self, svc):
        """Queue entry status is set to FAILED when download fails."""
        session = MagicMock()
        resource = MagicMock()
        resource.id = 25
        resource.url = "https://example.com/paper.pdf"
        session.query.return_value.get.return_value = resource

        queue_entry = MagicMock()
        session.query.return_value.filter_by.return_value.first.side_effect = [
            None,  # existing_doc
            None,  # pre-download queue_entry
            None,  # tracker
            queue_entry,  # post-download queue_entry
        ]
        session.query.return_value.filter_by.return_value.order_by.return_value.first.return_value = None

        from local_deep_research.database.models.library import DocumentStatus

        with (
            patch(
                f"{MODULE}.get_user_db_session", return_value=_make_ctx(session)
            ),
            patch.object(svc, "_get_url_hash", return_value="hash_qf"),
            patch.object(
                svc, "_download_pdf", return_value=(False, "No PDF found", None)
            ),
        ):
            svc.download_resource(25)

        assert queue_entry.status == DocumentStatus.FAILED


# ---------------------------------------------------------------------------
# 8. __enter__ / __exit__ context manager
# ---------------------------------------------------------------------------


class TestContextManagerEnterExit:
    """DownloadService implements the context manager protocol correctly."""

    def test_enter_returns_self(self, svc):
        """__enter__ returns the service instance itself."""
        result = svc.__enter__()
        assert result is svc

    def test_exit_calls_close(self, svc):
        """__exit__ calls close() regardless of exception state."""
        with patch.object(svc, "close") as mock_close:
            ret = svc.__exit__(None, None, None)
        mock_close.assert_called_once()
        assert ret is False

    def test_exit_returns_false_to_not_suppress_exceptions(self, svc):
        """__exit__ returns False so exceptions propagate."""
        with patch.object(svc, "close"):
            ret = svc.__exit__(ValueError, ValueError("test"), None)
        assert ret is False

    def test_context_manager_closes_on_normal_exit(self, svc):
        """Using 'with' statement causes close() to be called on normal exit."""
        with patch.object(svc, "close") as mock_close:
            with svc:
                pass
        mock_close.assert_called_once()

    def test_context_manager_closes_even_on_exception(self, svc):
        """Using 'with' statement causes close() even when an exception is raised."""
        with patch.object(svc, "close") as mock_close:
            try:
                with svc:
                    raise RuntimeError("test error")
            except RuntimeError:
                pass
        mock_close.assert_called_once()

    def test_close_sets_closed_flag(self, svc):
        """close() sets _closed=True to prevent double-closing."""
        with patch("local_deep_research.utilities.resource_utils.safe_close"):
            svc.close()
        assert svc._closed is True

    def test_close_is_idempotent(self, svc):
        """Calling close() twice does not raise and only closes downloaders once."""
        d = MagicMock()
        svc.downloaders = [d]

        with patch(
            "local_deep_research.utilities.resource_utils.safe_close"
        ) as mock_safe_close:
            svc.close()
            svc.close()

        # safe_close is called twice in the first close() (once per downloader,
        # once for settings); the second close() is a no-op thanks to _closed.
        assert mock_safe_close.call_count == 2

    def test_close_clears_references(self, svc):
        """close() sets downloaders=[], retry_manager=None, settings=None."""
        svc.downloaders = [MagicMock()]
        svc.retry_manager = MagicMock()
        svc.settings = MagicMock()

        with patch("local_deep_research.utilities.resource_utils.safe_close"):
            svc.close()

        assert svc.downloaders == []
        assert svc.retry_manager is None
        assert svc.settings is None
