"""
Comprehensive coverage tests for research_library/downloaders/pubmed.py

Focuses on:
- can_handle() URL hostname checking for pubmed/ncbi/pmc
- _apply_rate_limit() time-based rate limiting
- _download_pdf_with_result() full download flow with various URL patterns
- _download_text() and _fetch_text_from_europe_pmc() metadata/text extraction
- Error handling and edge cases across all code paths
"""

import time
from unittest.mock import MagicMock, patch

import pytest

from local_deep_research.research_library.downloaders.pubmed import (
    PubMedDownloader,
)
from local_deep_research.research_library.downloaders.base import (
    ContentType,
    DownloadResult,
)


# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------


@pytest.fixture
def downloader():
    """Create a PubMedDownloader with rate limiting disabled."""
    dl = PubMedDownloader(rate_limit_delay=0.0)
    dl.last_request_time = 0
    return dl


@pytest.fixture
def downloader_with_rate_limit():
    """Create a PubMedDownloader with a short rate limit for testing."""
    return PubMedDownloader(rate_limit_delay=0.15)


# ---------------------------------------------------------------------------
# can_handle – URL hostname checking
# ---------------------------------------------------------------------------


class TestCanHandleURLPatterns:
    """Thorough URL pattern matching for can_handle()."""

    def test_pubmed_standard_url(self, downloader):
        assert (
            downloader.can_handle("https://pubmed.ncbi.nlm.nih.gov/12345678")
            is True
        )

    def test_pubmed_trailing_slash(self, downloader):
        assert (
            downloader.can_handle("https://pubmed.ncbi.nlm.nih.gov/12345678/")
            is True
        )

    def test_pubmed_with_query_params(self, downloader):
        assert (
            downloader.can_handle(
                "https://pubmed.ncbi.nlm.nih.gov/12345678?from=search"
            )
            is True
        )

    def test_pubmed_with_fragment(self, downloader):
        assert (
            downloader.can_handle(
                "https://pubmed.ncbi.nlm.nih.gov/12345678#abstract"
            )
            is True
        )

    def test_pubmed_http_scheme(self, downloader):
        assert (
            downloader.can_handle("http://pubmed.ncbi.nlm.nih.gov/12345678")
            is True
        )

    def test_pubmed_no_article_id(self, downloader):
        """Even the root pubmed URL should match (hostname check only)."""
        assert downloader.can_handle("https://pubmed.ncbi.nlm.nih.gov/") is True

    def test_ncbi_pmc_url(self, downloader):
        assert (
            downloader.can_handle(
                "https://ncbi.nlm.nih.gov/pmc/articles/PMC1234567"
            )
            is True
        )

    def test_ncbi_pmc_trailing_slash(self, downloader):
        assert (
            downloader.can_handle(
                "https://ncbi.nlm.nih.gov/pmc/articles/PMC1234567/"
            )
            is True
        )

    def test_ncbi_without_pmc_path_rejected(self, downloader):
        """ncbi.nlm.nih.gov without /pmc in path must be rejected."""
        assert (
            downloader.can_handle("https://ncbi.nlm.nih.gov/gene/12345")
            is False
        )

    def test_ncbi_pmc_nested_path(self, downloader):
        assert (
            downloader.can_handle(
                "https://ncbi.nlm.nih.gov/pmc/articles/PMC9999/pdf/"
            )
            is True
        )

    def test_europe_pmc_bare_domain(self, downloader):
        assert (
            downloader.can_handle("https://europepmc.org/article/PMC/1234567")
            is True
        )

    def test_europe_pmc_www_subdomain(self, downloader):
        assert (
            downloader.can_handle(
                "https://www.europepmc.org/article/PMC/1234567"
            )
            is True
        )

    def test_europe_pmc_other_subdomain(self, downloader):
        """Any subdomain of europepmc.org should be accepted."""
        assert downloader.can_handle("https://api.europepmc.org/search") is True

    def test_reject_google(self, downloader):
        assert downloader.can_handle("https://google.com") is False

    def test_reject_arxiv(self, downloader):
        assert (
            downloader.can_handle("https://arxiv.org/abs/2101.00001") is False
        )

    def test_reject_nature(self, downloader):
        assert (
            downloader.can_handle("https://nature.com/articles/s123") is False
        )

    def test_reject_empty_string(self, downloader):
        assert downloader.can_handle("") is False

    def test_reject_plain_text(self, downloader):
        assert downloader.can_handle("not a url at all") is False

    def test_reject_none_hostname(self, downloader):
        """URL with no hostname parsed returns False."""
        assert downloader.can_handle("://") is False

    def test_reject_similar_domain(self, downloader):
        """Domains that contain 'pubmed' but are not the real one."""
        assert (
            downloader.can_handle("https://fakepubmed.ncbi.nlm.nih.gov/123")
            is False
        )

    def test_reject_europepmc_in_different_tld(self, downloader):
        assert (
            downloader.can_handle("https://europepmc.com/article/PMC/123")
            is False
        )

    def test_ncbi_with_www_prefix_no_pmc(self, downloader):
        """www.ncbi.nlm.nih.gov is not the same hostname as ncbi.nlm.nih.gov."""
        # The code checks hostname == "ncbi.nlm.nih.gov" exactly
        assert (
            downloader.can_handle(
                "https://www.ncbi.nlm.nih.gov/pmc/articles/PMC123"
            )
            is False
        )


# ---------------------------------------------------------------------------
# _apply_rate_limit – time-based rate limiting
# ---------------------------------------------------------------------------


class TestApplyRateLimit:
    """Test _apply_rate_limit() behaviour with mocked time."""

    def test_first_request_no_sleep(self, downloader):
        """First request (last_request_time=0) should not sleep."""
        with patch(
            "local_deep_research.research_library.downloaders.pubmed.time"
        ) as mock_time:
            mock_time.time.return_value = 1000.0
            downloader.last_request_time = 0
            downloader.rate_limit_delay = 1.0
            downloader._apply_rate_limit()
            mock_time.sleep.assert_not_called()
            assert downloader.last_request_time == 1000.0

    def test_rapid_request_sleeps(self, downloader):
        """Second request within the delay window should sleep."""
        with patch(
            "local_deep_research.research_library.downloaders.pubmed.time"
        ) as mock_time:
            mock_time.time.return_value = 1000.5
            downloader.last_request_time = 1000.0
            downloader.rate_limit_delay = 1.0
            downloader._apply_rate_limit()
            mock_time.sleep.assert_called_once_with(
                pytest.approx(0.5, abs=0.01)
            )

    def test_sufficient_gap_no_sleep(self, downloader):
        """Request after the delay window should not sleep."""
        with patch(
            "local_deep_research.research_library.downloaders.pubmed.time"
        ) as mock_time:
            mock_time.time.return_value = 1005.0
            downloader.last_request_time = 1000.0
            downloader.rate_limit_delay = 1.0
            downloader._apply_rate_limit()
            mock_time.sleep.assert_not_called()

    def test_updates_last_request_time(self, downloader):
        """_apply_rate_limit must update last_request_time after sleeping."""
        with patch(
            "local_deep_research.research_library.downloaders.pubmed.time"
        ) as mock_time:
            # First call to time.time() is for current_time, second is after sleep
            mock_time.time.side_effect = [1000.5, 1001.0]
            downloader.last_request_time = 1000.0
            downloader.rate_limit_delay = 1.0
            downloader._apply_rate_limit()
            assert downloader.last_request_time == 1001.0

    def test_real_timing_delay(self, downloader_with_rate_limit):
        """Integration-style test: real time.sleep with short delay."""
        dl = downloader_with_rate_limit
        dl._apply_rate_limit()
        start = time.time()
        dl._apply_rate_limit()
        elapsed = time.time() - start
        assert elapsed >= 0.1  # at least most of 0.15s delay

    def test_zero_delay_no_sleep(self):
        """When rate_limit_delay=0, should never sleep."""
        dl = PubMedDownloader(rate_limit_delay=0.0)
        dl._apply_rate_limit()
        start = time.time()
        dl._apply_rate_limit()
        elapsed = time.time() - start
        assert elapsed < 0.05


# ---------------------------------------------------------------------------
# _download_pdf_with_result – full download flow
# ---------------------------------------------------------------------------


class TestDownloadPdfWithResult:
    """Tests for _download_pdf_with_result covering all three URL branches."""

    # -- PMC URL branch --

    def test_pmc_url_invalid_format_no_digits(self, downloader):
        """PMC URL with PMC prefix but no digits returns invalid format."""
        result = downloader._download_pdf_with_result(
            "https://ncbi.nlm.nih.gov/pmc/articles/PMC/"
        )
        assert result.is_success is False
        assert "Invalid PMC URL format" in result.skip_reason

    def test_pmc_url_no_pmc_prefix(self, downloader):
        """PMC URL without PMC prefix falls to unsupported branch."""
        result = downloader._download_pdf_with_result(
            "https://ncbi.nlm.nih.gov/pmc/articles/"
        )
        assert result.is_success is False
        assert result.skip_reason is not None

    def test_pmc_url_europe_pmc_success(self, downloader):
        """PMC URL: Europe PMC succeeds on first try."""
        with patch.object(
            downloader, "_download_via_europe_pmc", return_value=b"%PDF"
        ):
            result = downloader._download_pdf_with_result(
                "https://ncbi.nlm.nih.gov/pmc/articles/PMC1234567/"
            )
        assert result.is_success is True
        assert result.content == b"%PDF"

    def test_pmc_url_fallback_to_ncbi(self, downloader):
        """PMC URL: Europe PMC fails, NCBI PMC succeeds."""
        with (
            patch.object(
                downloader, "_download_via_europe_pmc", return_value=None
            ),
            patch.object(
                downloader, "_download_via_ncbi_pmc", return_value=b"%PDF-ncbi"
            ),
        ):
            result = downloader._download_pdf_with_result(
                "https://ncbi.nlm.nih.gov/pmc/articles/PMC1234567/"
            )
        assert result.is_success is True
        assert result.content == b"%PDF-ncbi"

    def test_pmc_url_both_fail(self, downloader):
        """PMC URL: both sources fail gives descriptive skip reason."""
        with (
            patch.object(
                downloader, "_download_via_europe_pmc", return_value=None
            ),
            patch.object(
                downloader, "_download_via_ncbi_pmc", return_value=None
            ),
        ):
            result = downloader._download_pdf_with_result(
                "https://ncbi.nlm.nih.gov/pmc/articles/PMC1234567/"
            )
        assert result.is_success is False
        assert "PMC1234567" in result.skip_reason
        assert (
            "retracted" in result.skip_reason
            or "embargoed" in result.skip_reason
        )

    # -- PubMed URL branch --

    def test_pubmed_url_invalid_format(self, downloader):
        """PubMed URL without PMID returns skip reason."""
        result = downloader._download_pdf_with_result(
            "https://pubmed.ncbi.nlm.nih.gov/"
        )
        assert result.is_success is False
        assert "Invalid PubMed URL format" in result.skip_reason

    def test_pubmed_url_not_open_access(self, downloader):
        """PubMed article that is not open access returns subscription skip."""
        mock_resp = MagicMock()
        mock_resp.status_code = 200
        mock_resp.json.return_value = {
            "resultList": {
                "result": [
                    {
                        "isOpenAccess": "N",
                        "journalTitle": "Nature Medicine",
                    }
                ]
            }
        }
        with patch.object(downloader.session, "get", return_value=mock_resp):
            result = downloader._download_pdf_with_result(
                "https://pubmed.ncbi.nlm.nih.gov/99999999/"
            )
        assert result.is_success is False
        assert "subscription" in result.skip_reason.lower()
        assert "Nature Medicine" in result.skip_reason

    def test_pubmed_url_no_pdf_available(self, downloader):
        """PubMed article is open access but has no PDF."""
        mock_resp = MagicMock()
        mock_resp.status_code = 200
        mock_resp.json.return_value = {
            "resultList": {
                "result": [
                    {
                        "isOpenAccess": "Y",
                        "hasPDF": "N",
                    }
                ]
            }
        }
        with patch.object(downloader.session, "get", return_value=mock_resp):
            result = downloader._download_pdf_with_result(
                "https://pubmed.ncbi.nlm.nih.gov/99999999/"
            )
        assert result.is_success is False
        assert "No PDF" in result.skip_reason

    def test_pubmed_url_open_access_with_pdf(self, downloader):
        """PubMed open access article with PDF available and PMCID."""
        api_resp = MagicMock()
        api_resp.status_code = 200
        api_resp.json.return_value = {
            "resultList": {
                "result": [
                    {
                        "isOpenAccess": "Y",
                        "hasPDF": "Y",
                        "pmcid": "PMC555",
                    }
                ]
            }
        }
        with (
            patch.object(downloader.session, "get", return_value=api_resp),
            patch.object(
                downloader, "_download_via_europe_pmc", return_value=b"%PDF-ok"
            ),
        ):
            result = downloader._download_pdf_with_result(
                "https://pubmed.ncbi.nlm.nih.gov/99999999/"
            )
        assert result.is_success is True
        assert result.content == b"%PDF-ok"

    def test_pubmed_url_not_found_in_europe_pmc(self, downloader):
        """PMID not found in Europe PMC database."""
        api_resp = MagicMock()
        api_resp.status_code = 200
        api_resp.json.return_value = {"resultList": {"result": []}}
        # Also mock the fallback _get_pmc_id_from_pmid
        with (
            patch.object(downloader.session, "get", return_value=api_resp),
            patch.object(
                downloader, "_get_pmc_id_from_pmid", return_value=None
            ),
        ):
            result = downloader._download_pdf_with_result(
                "https://pubmed.ncbi.nlm.nih.gov/99999999/"
            )
        assert result.is_success is False
        assert "not found" in result.skip_reason.lower()

    def test_pubmed_url_api_exception_fallback_to_ncbi(self, downloader):
        """Europe PMC API throws, fallback to NCBI PMC ID lookup succeeds."""
        with (
            patch.object(
                downloader.session, "get", side_effect=Exception("timeout")
            ),
            patch.object(
                downloader, "_get_pmc_id_from_pmid", return_value="PMC777"
            ),
            patch.object(
                downloader, "_download_via_europe_pmc", return_value=b"%PDF-fb"
            ),
        ):
            result = downloader._download_pdf_with_result(
                "https://pubmed.ncbi.nlm.nih.gov/99999999/"
            )
        assert result.is_success is True

    def test_pubmed_url_ncbi_pmc_id_found_but_pdf_fails(self, downloader):
        """PMC ID found via NCBI but PDF download from both sources fails."""
        with (
            patch.object(
                downloader.session, "get", side_effect=Exception("err")
            ),
            patch.object(
                downloader, "_get_pmc_id_from_pmid", return_value="PMC888"
            ),
            patch.object(
                downloader, "_download_via_europe_pmc", return_value=None
            ),
            patch.object(
                downloader, "_download_via_ncbi_pmc", return_value=None
            ),
        ):
            result = downloader._download_pdf_with_result(
                "https://pubmed.ncbi.nlm.nih.gov/99999999/"
            )
        assert result.is_success is False
        assert "PMC888" in result.skip_reason

    def test_pubmed_url_no_pmc_id_paywalled(self, downloader):
        """No PMC ID available at all — article is paywalled."""
        with (
            patch.object(
                downloader.session, "get", side_effect=Exception("err")
            ),
            patch.object(
                downloader, "_get_pmc_id_from_pmid", return_value=None
            ),
        ):
            result = downloader._download_pdf_with_result(
                "https://pubmed.ncbi.nlm.nih.gov/99999999/"
            )
        assert result.is_success is False
        assert "paywalled" in result.skip_reason.lower()

    # -- Europe PMC URL branch --

    def test_europe_pmc_url_success(self, downloader):
        """Europe PMC URL with valid PMC ID succeeds."""
        with patch.object(
            downloader, "_download_via_europe_pmc", return_value=b"%PDF-eu"
        ):
            result = downloader._download_pdf_with_result(
                "https://europepmc.org/article/PMC7654321"
            )
        assert result.is_success is True
        assert result.content == b"%PDF-eu"

    def test_europe_pmc_url_download_fails(self, downloader):
        """Europe PMC URL with valid PMC ID but download fails."""
        with patch.object(
            downloader, "_download_via_europe_pmc", return_value=None
        ):
            result = downloader._download_pdf_with_result(
                "https://europepmc.org/article/PMC7654321"
            )
        assert result.is_success is False
        assert "PMC7654321" in result.skip_reason

    def test_europe_pmc_url_no_pmc_id(self, downloader):
        """Europe PMC URL without PMC ID in URL."""
        result = downloader._download_pdf_with_result(
            "https://europepmc.org/article/invalid-path"
        )
        assert result.is_success is False
        assert "Invalid Europe PMC URL format" in result.skip_reason

    def test_europe_pmc_subdomain_url(self, downloader):
        """Europe PMC with www subdomain."""
        with patch.object(
            downloader, "_download_via_europe_pmc", return_value=b"%PDF"
        ):
            result = downloader._download_pdf_with_result(
                "https://www.europepmc.org/article/PMC1111"
            )
        assert result.is_success is True

    # -- Unsupported URL branch --

    def test_unsupported_url_format(self, downloader):
        """URL that matches none of the branches."""
        result = downloader._download_pdf_with_result(
            "https://example.com/something"
        )
        assert result.is_success is False
        assert "Unsupported" in result.skip_reason


# ---------------------------------------------------------------------------
# download_with_result – public API
# ---------------------------------------------------------------------------


class TestDownloadWithResult:
    """Test the public download_with_result() method."""

    def test_text_success(self, downloader):
        with patch.object(
            downloader, "_download_text", return_value=b"some text"
        ):
            result = downloader.download_with_result(
                "https://pubmed.ncbi.nlm.nih.gov/123/", ContentType.TEXT
            )
        assert result.is_success is True
        assert result.content == b"some text"

    def test_text_failure(self, downloader):
        with patch.object(downloader, "_download_text", return_value=None):
            result = downloader.download_with_result(
                "https://pubmed.ncbi.nlm.nih.gov/123/", ContentType.TEXT
            )
        assert result.is_success is False
        assert "subscription" in result.skip_reason.lower()

    def test_pdf_delegates_to_download_pdf_with_result(self, downloader):
        expected = DownloadResult(content=b"%PDF", is_success=True)
        with patch.object(
            downloader, "_download_pdf_with_result", return_value=expected
        ):
            result = downloader.download_with_result(
                "https://pubmed.ncbi.nlm.nih.gov/123/", ContentType.PDF
            )
        assert result.is_success is True


# ---------------------------------------------------------------------------
# download – public API
# ---------------------------------------------------------------------------


class TestDownload:
    """Test the public download() method."""

    def test_pdf_calls_download_pdf_content(self, downloader):
        with patch.object(
            downloader, "_download_pdf_content", return_value=b"%PDF"
        ) as mock_dl:
            result = downloader.download(
                "https://pubmed.ncbi.nlm.nih.gov/123/", ContentType.PDF
            )
        assert result == b"%PDF"
        mock_dl.assert_called_once()

    def test_text_calls_download_text(self, downloader):
        with patch.object(
            downloader, "_download_text", return_value=b"text"
        ) as mock_dl:
            result = downloader.download(
                "https://pubmed.ncbi.nlm.nih.gov/123/", ContentType.TEXT
            )
        assert result == b"text"
        mock_dl.assert_called_once()


# ---------------------------------------------------------------------------
# _download_pdf_content – URL routing
# ---------------------------------------------------------------------------


class TestDownloadPdfContentRouting:
    """Test _download_pdf_content routes to the correct handler."""

    def test_routes_pmc_url(self, downloader):
        with patch.object(
            downloader, "_download_pmc_direct", return_value=b"pmc"
        ) as mock_dl:
            result = downloader._download_pdf_content(
                "https://ncbi.nlm.nih.gov/pmc/articles/PMC123/"
            )
        assert result == b"pmc"
        mock_dl.assert_called_once()

    def test_routes_pubmed_url(self, downloader):
        with patch.object(
            downloader, "_download_pubmed", return_value=b"pm"
        ) as mock_dl:
            result = downloader._download_pdf_content(
                "https://pubmed.ncbi.nlm.nih.gov/123/"
            )
        assert result == b"pm"
        mock_dl.assert_called_once()

    def test_routes_europe_pmc_url(self, downloader):
        with patch.object(
            downloader, "_download_europe_pmc", return_value=b"eu"
        ) as mock_dl:
            result = downloader._download_pdf_content(
                "https://europepmc.org/article/PMC123"
            )
        assert result == b"eu"
        mock_dl.assert_called_once()

    def test_routes_europe_pmc_subdomain(self, downloader):
        with patch.object(
            downloader, "_download_europe_pmc", return_value=b"eu"
        ):
            result = downloader._download_pdf_content(
                "https://www.europepmc.org/article/PMC123"
            )
        assert result == b"eu"

    def test_unrecognised_url_returns_none(self, downloader):
        result = downloader._download_pdf_content("https://example.com/foo.pdf")
        assert result is None


# ---------------------------------------------------------------------------
# _download_pmc_direct
# ---------------------------------------------------------------------------


class TestDownloadPmcDirect:
    """Test _download_pmc_direct."""

    def test_no_pmc_id_returns_none(self, downloader):
        result = downloader._download_pmc_direct(
            "https://ncbi.nlm.nih.gov/pmc/articles/"
        )
        assert result is None

    def test_europe_pmc_succeeds(self, downloader):
        with patch.object(
            downloader, "_download_via_europe_pmc", return_value=b"ok"
        ):
            result = downloader._download_pmc_direct(
                "https://ncbi.nlm.nih.gov/pmc/articles/PMC999/"
            )
        assert result == b"ok"

    def test_europe_pmc_fails_ncbi_succeeds(self, downloader):
        with (
            patch.object(
                downloader, "_download_via_europe_pmc", return_value=None
            ),
            patch.object(
                downloader, "_download_via_ncbi_pmc", return_value=b"ncbi"
            ),
        ):
            result = downloader._download_pmc_direct(
                "https://ncbi.nlm.nih.gov/pmc/articles/PMC999/"
            )
        assert result == b"ncbi"


# ---------------------------------------------------------------------------
# _download_pubmed
# ---------------------------------------------------------------------------


class TestDownloadPubmed:
    def test_no_pmid_returns_none(self, downloader):
        result = downloader._download_pubmed("https://pubmed.ncbi.nlm.nih.gov/")
        assert result is None

    def test_europe_pmc_api_success(self, downloader):
        with patch.object(
            downloader, "_try_europe_pmc_api", return_value=b"pdf"
        ):
            result = downloader._download_pubmed(
                "https://pubmed.ncbi.nlm.nih.gov/12345/"
            )
        assert result == b"pdf"

    def test_europe_pmc_api_fails_ncbi_fallback(self, downloader):
        with (
            patch.object(downloader, "_try_europe_pmc_api", return_value=None),
            patch.object(
                downloader, "_get_pmc_id_from_pmid", return_value="PMC111"
            ),
            patch.object(
                downloader, "_download_via_europe_pmc", return_value=b"via-eu"
            ),
        ):
            result = downloader._download_pubmed(
                "https://pubmed.ncbi.nlm.nih.gov/12345/"
            )
        assert result == b"via-eu"

    def test_no_pmc_id_returns_none(self, downloader):
        with (
            patch.object(downloader, "_try_europe_pmc_api", return_value=None),
            patch.object(
                downloader, "_get_pmc_id_from_pmid", return_value=None
            ),
        ):
            result = downloader._download_pubmed(
                "https://pubmed.ncbi.nlm.nih.gov/12345/"
            )
        assert result is None


# ---------------------------------------------------------------------------
# _try_europe_pmc_api
# ---------------------------------------------------------------------------


class TestTryEuropePmcApi:
    def test_open_access_with_pdf_and_pmcid(self, downloader):
        api_resp = MagicMock()
        api_resp.status_code = 200
        api_resp.json.return_value = {
            "resultList": {
                "result": [
                    {"isOpenAccess": "Y", "hasPDF": "Y", "pmcid": "PMC42"}
                ]
            }
        }
        with (
            patch.object(downloader.session, "get", return_value=api_resp),
            patch.object(
                downloader, "_download_via_europe_pmc", return_value=b"ok"
            ) as mock_dl,
        ):
            result = downloader._try_europe_pmc_api("12345")
        assert result == b"ok"
        mock_dl.assert_called_once_with("PMC42")

    def test_not_open_access(self, downloader):
        api_resp = MagicMock()
        api_resp.status_code = 200
        api_resp.json.return_value = {
            "resultList": {"result": [{"isOpenAccess": "N"}]}
        }
        with patch.object(downloader.session, "get", return_value=api_resp):
            result = downloader._try_europe_pmc_api("12345")
        assert result is None

    def test_open_access_no_pdf(self, downloader):
        api_resp = MagicMock()
        api_resp.status_code = 200
        api_resp.json.return_value = {
            "resultList": {"result": [{"isOpenAccess": "Y", "hasPDF": "N"}]}
        }
        with patch.object(downloader.session, "get", return_value=api_resp):
            result = downloader._try_europe_pmc_api("12345")
        assert result is None

    def test_no_pmcid(self, downloader):
        api_resp = MagicMock()
        api_resp.status_code = 200
        api_resp.json.return_value = {
            "resultList": {"result": [{"isOpenAccess": "Y", "hasPDF": "Y"}]}
        }
        with patch.object(downloader.session, "get", return_value=api_resp):
            result = downloader._try_europe_pmc_api("12345")
        assert result is None

    def test_empty_results(self, downloader):
        api_resp = MagicMock()
        api_resp.status_code = 200
        api_resp.json.return_value = {"resultList": {"result": []}}
        with patch.object(downloader.session, "get", return_value=api_resp):
            result = downloader._try_europe_pmc_api("12345")
        assert result is None

    def test_non_200_status(self, downloader):
        api_resp = MagicMock()
        api_resp.status_code = 500
        with patch.object(downloader.session, "get", return_value=api_resp):
            result = downloader._try_europe_pmc_api("12345")
        assert result is None

    def test_network_exception(self, downloader):
        with patch.object(
            downloader.session, "get", side_effect=Exception("fail")
        ):
            result = downloader._try_europe_pmc_api("12345")
        assert result is None


# ---------------------------------------------------------------------------
# _get_pmc_id_from_pmid
# ---------------------------------------------------------------------------


class TestGetPmcIdFromPmid:
    def test_success(self, downloader):
        resp = MagicMock()
        resp.status_code = 200
        resp.json.return_value = {
            "linksets": [{"linksetdbs": [{"dbto": "pmc", "links": [7654321]}]}]
        }
        with patch.object(downloader.session, "get", return_value=resp):
            assert downloader._get_pmc_id_from_pmid("123") == "PMC7654321"

    def test_no_linksetdbs(self, downloader):
        resp = MagicMock()
        resp.status_code = 200
        resp.json.return_value = {"linksets": [{}]}
        # Also need to handle fallback scraping
        scrape_resp = MagicMock()
        scrape_resp.status_code = 404
        with patch.object(
            downloader.session, "get", side_effect=[resp, scrape_resp]
        ):
            assert downloader._get_pmc_id_from_pmid("123") is None

    def test_linksetdbs_wrong_db(self, downloader):
        resp = MagicMock()
        resp.status_code = 200
        resp.json.return_value = {
            "linksets": [{"linksetdbs": [{"dbto": "gene", "links": [999]}]}]
        }
        scrape_resp = MagicMock()
        scrape_resp.status_code = 404
        with patch.object(
            downloader.session, "get", side_effect=[resp, scrape_resp]
        ):
            assert downloader._get_pmc_id_from_pmid("123") is None

    def test_empty_links(self, downloader):
        resp = MagicMock()
        resp.status_code = 200
        resp.json.return_value = {
            "linksets": [{"linksetdbs": [{"dbto": "pmc", "links": []}]}]
        }
        scrape_resp = MagicMock()
        scrape_resp.status_code = 404
        with patch.object(
            downloader.session, "get", side_effect=[resp, scrape_resp]
        ):
            assert downloader._get_pmc_id_from_pmid("123") is None

    def test_api_fails_scrape_succeeds(self, downloader):
        """When E-utilities fails, fallback to scraping the PubMed page."""
        scrape_resp = MagicMock()
        scrape_resp.status_code = 200
        scrape_resp.text = (
            '<a href="/pmc/articles/PMC8888/">Free PMC article</a>'
        )
        with patch.object(
            downloader.session,
            "get",
            side_effect=[Exception("timeout"), scrape_resp],
        ):
            assert downloader._get_pmc_id_from_pmid("123") == "PMC8888"

    def test_both_fail(self, downloader):
        with patch.object(
            downloader.session, "get", side_effect=Exception("fail")
        ):
            assert downloader._get_pmc_id_from_pmid("123") is None


# ---------------------------------------------------------------------------
# _download_via_europe_pmc / _download_via_ncbi_pmc
# ---------------------------------------------------------------------------


class TestDownloadViaMethods:
    def test_europe_pmc_constructs_correct_url(self, downloader):
        with patch.object(downloader, "_download_pdf") as mock_dl:
            mock_dl.return_value = b"pdf"
            downloader._download_via_europe_pmc("PMC555")
            mock_dl.assert_called_once_with(
                "https://europepmc.org/backend/ptpmcrender.fcgi?accid=PMC555&blobtype=pdf"
            )

    def test_europe_pmc_returns_none_on_failure(self, downloader):
        with patch.object(downloader, "_download_pdf", return_value=None):
            assert downloader._download_via_europe_pmc("PMC555") is None

    def test_ncbi_pmc_tries_two_urls(self, downloader):
        """NCBI PMC should try /pdf/ then /pdf/main.pdf."""
        with patch.object(
            downloader, "_download_pdf", side_effect=[None, b"pdf2"]
        ) as mock_dl:
            result = downloader._download_via_ncbi_pmc("PMC999")
        assert result == b"pdf2"
        assert mock_dl.call_count == 2

    def test_ncbi_pmc_first_url_succeeds(self, downloader):
        with patch.object(
            downloader, "_download_pdf", return_value=b"pdf1"
        ) as mock_dl:
            result = downloader._download_via_ncbi_pmc("PMC999")
        assert result == b"pdf1"
        assert mock_dl.call_count == 1

    def test_ncbi_pmc_both_fail(self, downloader):
        with patch.object(downloader, "_download_pdf", return_value=None):
            assert downloader._download_via_ncbi_pmc("PMC999") is None


# ---------------------------------------------------------------------------
# _download_europe_pmc
# ---------------------------------------------------------------------------


class TestDownloadEuropePmc:
    def test_extracts_pmc_id_and_downloads(self, downloader):
        with patch.object(
            downloader, "_download_via_europe_pmc", return_value=b"ok"
        ) as mock_dl:
            result = downloader._download_europe_pmc(
                "https://europepmc.org/article/PMC123"
            )
        mock_dl.assert_called_once_with("PMC123")
        assert result == b"ok"

    def test_no_pmc_id_returns_none(self, downloader):
        result = downloader._download_europe_pmc(
            "https://europepmc.org/article/invalid"
        )
        assert result is None


# ---------------------------------------------------------------------------
# _fetch_text_from_europe_pmc
# ---------------------------------------------------------------------------


class TestFetchTextFromEuropePmc:
    def test_no_identifiers_returns_none(self, downloader):
        assert downloader._fetch_text_from_europe_pmc(None, None) is None

    def test_pmcid_query_construction(self, downloader):
        """When pmc_id provided, query should use PMC: prefix."""
        api_resp = MagicMock()
        api_resp.status_code = 200
        api_resp.json.return_value = {"resultList": {"result": []}}
        with patch.object(
            downloader.session, "get", return_value=api_resp
        ) as mock_get:
            downloader._fetch_text_from_europe_pmc(None, "PMC123")
        call_kwargs = mock_get.call_args
        assert call_kwargs[1]["params"]["query"] == "PMC:123"

    def test_pmid_query_construction(self, downloader):
        """When only pmid provided, query should use EXT_ID: prefix."""
        api_resp = MagicMock()
        api_resp.status_code = 200
        api_resp.json.return_value = {"resultList": {"result": []}}
        with patch.object(
            downloader.session, "get", return_value=api_resp
        ) as mock_get:
            downloader._fetch_text_from_europe_pmc("56789", None)
        call_kwargs = mock_get.call_args
        assert call_kwargs[1]["params"]["query"] == "EXT_ID:56789"

    def test_open_access_with_full_text(self, downloader):
        meta_resp = MagicMock()
        meta_resp.status_code = 200
        meta_resp.json.return_value = {
            "resultList": {"result": [{"isOpenAccess": "Y", "pmcid": "PMC42"}]}
        }
        xml_resp = MagicMock()
        xml_resp.status_code = 200
        xml_resp.text = "<article><body><p>Hello world</p></body></article>"

        with patch.object(
            downloader.session, "get", side_effect=[meta_resp, xml_resp]
        ):
            result = downloader._fetch_text_from_europe_pmc("123", None)
        assert result is not None
        assert "Hello world" in result

    def test_xml_tags_stripped(self, downloader):
        meta_resp = MagicMock()
        meta_resp.status_code = 200
        meta_resp.json.return_value = {
            "resultList": {"result": [{"isOpenAccess": "Y", "pmcid": "PMC42"}]}
        }
        xml_resp = MagicMock()
        xml_resp.status_code = 200
        xml_resp.text = "<root><a>one</a><b>two</b></root>"

        with patch.object(
            downloader.session, "get", side_effect=[meta_resp, xml_resp]
        ):
            result = downloader._fetch_text_from_europe_pmc("123", None)
        assert "<" not in result
        assert "one" in result and "two" in result

    def test_not_open_access_returns_none(self, downloader):
        meta_resp = MagicMock()
        meta_resp.status_code = 200
        meta_resp.json.return_value = {
            "resultList": {"result": [{"isOpenAccess": "N"}]}
        }
        with patch.object(downloader.session, "get", return_value=meta_resp):
            assert downloader._fetch_text_from_europe_pmc("123", None) is None

    def test_no_pmcid_in_result(self, downloader):
        meta_resp = MagicMock()
        meta_resp.status_code = 200
        meta_resp.json.return_value = {
            "resultList": {"result": [{"isOpenAccess": "Y"}]}
        }
        with patch.object(downloader.session, "get", return_value=meta_resp):
            assert downloader._fetch_text_from_europe_pmc("123", None) is None

    def test_xml_fetch_non_200(self, downloader):
        meta_resp = MagicMock()
        meta_resp.status_code = 200
        meta_resp.json.return_value = {
            "resultList": {"result": [{"isOpenAccess": "Y", "pmcid": "PMC42"}]}
        }
        xml_resp = MagicMock()
        xml_resp.status_code = 500

        with patch.object(
            downloader.session, "get", side_effect=[meta_resp, xml_resp]
        ):
            assert downloader._fetch_text_from_europe_pmc("123", None) is None

    def test_network_exception_returns_none(self, downloader):
        with patch.object(
            downloader.session, "get", side_effect=Exception("fail")
        ):
            assert downloader._fetch_text_from_europe_pmc("123", None) is None

    def test_empty_xml_returns_none(self, downloader):
        meta_resp = MagicMock()
        meta_resp.status_code = 200
        meta_resp.json.return_value = {
            "resultList": {"result": [{"isOpenAccess": "Y", "pmcid": "PMC42"}]}
        }
        xml_resp = MagicMock()
        xml_resp.status_code = 200
        xml_resp.text = ""

        with patch.object(
            downloader.session, "get", side_effect=[meta_resp, xml_resp]
        ):
            assert downloader._fetch_text_from_europe_pmc("123", None) is None


# ---------------------------------------------------------------------------
# _download_text
# ---------------------------------------------------------------------------


class TestDownloadText:
    def test_pubmed_url_extracts_pmid(self, downloader):
        with patch.object(
            downloader, "_fetch_text_from_europe_pmc", return_value="text"
        ) as mock_dl:
            result = downloader._download_text(
                "https://pubmed.ncbi.nlm.nih.gov/55555/"
            )
        assert result == b"text"
        mock_dl.assert_called_once_with("55555", None)

    def test_pmc_url_extracts_pmc_id(self, downloader):
        with patch.object(
            downloader, "_fetch_text_from_europe_pmc", return_value="text"
        ) as mock_dl:
            result = downloader._download_text(
                "https://ncbi.nlm.nih.gov/pmc/articles/PMC7777/"
            )
        assert result == b"text"
        mock_dl.assert_called_once_with(None, "PMC7777")

    def test_text_not_available_fallback_to_pdf(self, downloader):
        """When text API fails, tries PDF extraction."""
        with (
            patch.object(
                downloader, "_fetch_text_from_europe_pmc", return_value=None
            ),
            patch.object(
                downloader, "_download_pdf_content", return_value=b"fakepdf"
            ),
            patch.object(
                downloader, "extract_text_from_pdf", return_value="extracted"
            ),
        ):
            result = downloader._download_text(
                "https://pubmed.ncbi.nlm.nih.gov/123/"
            )
        assert result == b"extracted"

    def test_text_and_pdf_both_fail(self, downloader):
        with (
            patch.object(
                downloader, "_fetch_text_from_europe_pmc", return_value=None
            ),
            patch.object(
                downloader, "_download_pdf_content", return_value=None
            ),
        ):
            assert (
                downloader._download_text(
                    "https://pubmed.ncbi.nlm.nih.gov/123/"
                )
                is None
            )

    def test_pdf_extraction_returns_none(self, downloader):
        with (
            patch.object(
                downloader, "_fetch_text_from_europe_pmc", return_value=None
            ),
            patch.object(
                downloader, "_download_pdf_content", return_value=b"pdf"
            ),
            patch.object(
                downloader, "extract_text_from_pdf", return_value=None
            ),
        ):
            assert (
                downloader._download_text(
                    "https://pubmed.ncbi.nlm.nih.gov/123/"
                )
                is None
            )

    def test_unrecognised_url_no_ids(self, downloader):
        """URL that yields neither pmid nor pmc_id still tries PDF fallback."""
        with (
            patch.object(
                downloader, "_fetch_text_from_europe_pmc"
            ) as mock_fetch,
            patch.object(
                downloader, "_download_pdf_content", return_value=None
            ),
        ):
            result = downloader._download_text("https://example.com/something")
        # _fetch_text_from_europe_pmc should not be called since both are None
        mock_fetch.assert_not_called()
        assert result is None


# ---------------------------------------------------------------------------
# Initialization
# ---------------------------------------------------------------------------


class TestInit:
    def test_defaults(self):
        dl = PubMedDownloader()
        assert dl.timeout == 30
        assert dl.rate_limit_delay == 1.0
        assert dl.last_request_time == 0

    def test_custom_params(self):
        dl = PubMedDownloader(timeout=60, rate_limit_delay=2.5)
        assert dl.timeout == 60
        assert dl.rate_limit_delay == 2.5

    def test_has_session(self):
        dl = PubMedDownloader()
        assert hasattr(dl, "session")

    def test_inherits_base_downloader(self):
        from local_deep_research.research_library.downloaders.base import (
            BaseDownloader,
        )

        dl = PubMedDownloader()
        assert isinstance(dl, BaseDownloader)
