"""
Tests for BaseDownloader abstract class and utility methods.
"""

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


class ConcreteDownloader(BaseDownloader):
    """Concrete implementation for testing abstract BaseDownloader."""

    def can_handle(self, url: str) -> bool:
        return "test.example.com" in url

    def download(self, url: str, content_type: ContentType = ContentType.PDF):
        if "success" in url:
            return b"%PDF-1.4 test content"
        return None


class TestContentTypeEnum:
    """Tests for ContentType enum."""

    def test_pdf_value(self):
        """ContentType.PDF has correct value."""
        assert ContentType.PDF.value == "pdf"

    def test_text_value(self):
        """ContentType.TEXT has correct value."""
        assert ContentType.TEXT.value == "text"


class TestDownloadResult:
    """Tests for DownloadResult namedtuple."""

    def test_default_values(self):
        """DownloadResult has correct default values."""
        result = DownloadResult()
        assert result.content is None
        assert result.skip_reason is None
        assert result.is_success is False

    def test_success_result(self):
        """DownloadResult with successful content."""
        result = DownloadResult(content=b"test", is_success=True)
        assert result.content == b"test"
        assert result.is_success is True
        assert result.skip_reason is None

    def test_skip_result(self):
        """DownloadResult with skip reason."""
        result = DownloadResult(skip_reason="Not available")
        assert result.content is None
        assert result.is_success is False
        assert result.skip_reason == "Not available"


class TestBaseDownloaderInit:
    """Tests for BaseDownloader initialization."""

    def test_default_timeout(self):
        """Default timeout is 30 seconds."""
        downloader = ConcreteDownloader()
        assert downloader.timeout == 30

    def test_custom_timeout(self):
        """Custom timeout is set correctly."""
        downloader = ConcreteDownloader(timeout=60)
        assert downloader.timeout == 60

    def test_session_created(self):
        """requests.Session is created."""
        downloader = ConcreteDownloader()
        assert downloader.session is not None

    def test_rate_tracker_created(self):
        """AdaptiveRateLimitTracker is created."""
        downloader = ConcreteDownloader()
        assert downloader.rate_tracker is not None


class TestCanHandle:
    """Tests for can_handle method."""

    def test_can_handle_matching_url(self):
        """Returns True for matching URL."""
        downloader = ConcreteDownloader()
        assert (
            downloader.can_handle("https://test.example.com/paper.pdf") is True
        )

    def test_can_handle_non_matching_url(self):
        """Returns False for non-matching URL."""
        downloader = ConcreteDownloader()
        assert downloader.can_handle("https://other.com/paper.pdf") is False


class TestDownload:
    """Tests for download method."""

    def test_download_success(self):
        """Returns content for successful download."""
        downloader = ConcreteDownloader()
        content = downloader.download("https://test.example.com/success.pdf")
        assert content is not None
        assert b"PDF" in content

    def test_download_failure(self):
        """Returns None for failed download."""
        downloader = ConcreteDownloader()
        content = downloader.download("https://test.example.com/failure.pdf")
        assert content is None


class TestDownloadPdf:
    """Tests for download_pdf convenience method."""

    def test_download_pdf_calls_download(self):
        """download_pdf calls download with PDF content type."""
        downloader = ConcreteDownloader()
        content = downloader.download_pdf(
            "https://test.example.com/success.pdf"
        )
        assert content is not None


class TestDownloadWithResult:
    """Tests for download_with_result method."""

    def test_download_with_result_success(self):
        """Returns DownloadResult with content on success."""
        downloader = ConcreteDownloader()
        result = downloader.download_with_result(
            "https://test.example.com/success.pdf"
        )
        assert result.is_success is True
        assert result.content is not None

    def test_download_with_result_failure(self):
        """Returns DownloadResult with skip_reason on failure."""
        downloader = ConcreteDownloader()
        result = downloader.download_with_result(
            "https://test.example.com/failure.pdf"
        )
        assert result.is_success is False
        assert result.skip_reason is not None


class TestIsPdfContent:
    """Tests for _is_pdf_content helper method."""

    def test_is_pdf_content_by_content_type(self, mocker):
        """Detects PDF by content-type header."""
        downloader = ConcreteDownloader()
        response = mocker.Mock()
        response.headers = {"content-type": "application/pdf"}
        response.content = b"some content"
        assert downloader._is_pdf_content(response) is True

    def test_is_pdf_content_by_magic_bytes(self, mocker):
        """Detects PDF by magic bytes."""
        downloader = ConcreteDownloader()
        response = mocker.Mock()
        response.headers = {"content-type": "application/octet-stream"}
        response.content = b"%PDF-1.4 content"
        assert downloader._is_pdf_content(response) is True

    def test_is_not_pdf_content(self, mocker):
        """Returns False for non-PDF content."""
        downloader = ConcreteDownloader()
        response = mocker.Mock()
        response.headers = {"content-type": "text/html"}
        response.content = b"<html>Not a PDF</html>"
        assert downloader._is_pdf_content(response) is False


class TestDownloadPdfHelper:
    """Tests for _download_pdf helper method."""

    def test_download_pdf_success(self, mocker, mock_pdf_content):
        """Successfully downloads PDF."""
        downloader = ConcreteDownloader()

        mock_response = mocker.Mock()
        mock_response.status_code = 200
        mock_response.content = mock_pdf_content
        mock_response.headers = {"content-type": "application/pdf"}

        mocker.patch.object(
            downloader.session, "get", return_value=mock_response
        )
        mocker.patch.object(
            downloader.rate_tracker, "apply_rate_limit", return_value=0
        )
        mocker.patch.object(downloader.rate_tracker, "record_outcome")

        content = downloader._download_pdf("https://example.com/paper.pdf")
        assert content is not None
        assert content == mock_pdf_content

    def test_download_pdf_rate_limited(self, mocker):
        """Handles rate limiting (HTTP 429)."""
        downloader = ConcreteDownloader()

        mock_response = mocker.Mock()
        mock_response.status_code = 429
        mock_response.content = b""
        mock_response.headers = {}

        mocker.patch.object(
            downloader.session, "get", return_value=mock_response
        )
        mocker.patch.object(
            downloader.rate_tracker, "apply_rate_limit", return_value=0
        )
        mocker.patch.object(downloader.rate_tracker, "record_outcome")

        content = downloader._download_pdf("https://example.com/paper.pdf")
        assert content is None

    def test_download_pdf_not_found(self, mocker):
        """Handles HTTP 404."""
        downloader = ConcreteDownloader()

        mock_response = mocker.Mock()
        mock_response.status_code = 404
        mock_response.content = b""
        mock_response.headers = {}

        mocker.patch.object(
            downloader.session, "get", return_value=mock_response
        )
        mocker.patch.object(
            downloader.rate_tracker, "apply_rate_limit", return_value=0
        )
        mocker.patch.object(downloader.rate_tracker, "record_outcome")

        content = downloader._download_pdf("https://example.com/paper.pdf")
        assert content is None

    def test_download_pdf_timeout(self, mocker):
        """Handles request timeout."""
        import requests

        downloader = ConcreteDownloader()

        mocker.patch.object(
            downloader.session,
            "get",
            side_effect=requests.exceptions.Timeout("Connection timed out"),
        )
        mocker.patch.object(
            downloader.rate_tracker, "apply_rate_limit", return_value=0
        )
        mocker.patch.object(downloader.rate_tracker, "record_outcome")

        content = downloader._download_pdf("https://example.com/paper.pdf")
        assert content is None

    def test_download_pdf_connection_error(self, mocker):
        """Handles connection error."""
        import requests

        downloader = ConcreteDownloader()

        mocker.patch.object(
            downloader.session,
            "get",
            side_effect=requests.exceptions.ConnectionError(
                "Connection refused"
            ),
        )
        mocker.patch.object(
            downloader.rate_tracker, "apply_rate_limit", return_value=0
        )
        mocker.patch.object(downloader.rate_tracker, "record_outcome")

        content = downloader._download_pdf("https://example.com/paper.pdf")
        assert content is None


class TestExtractTextFromPdf:
    """Tests for extract_text_from_pdf static method."""

    def test_extract_text_from_valid_pdf(self, mock_pdf_content):
        """Extracts text from valid PDF (may be empty for minimal PDF)."""
        # Note: minimal PDF has no actual text content
        text = BaseDownloader.extract_text_from_pdf(mock_pdf_content)
        # Minimal PDF has no text, so result should be None or empty
        assert text is None or text == ""

    def test_extract_text_from_invalid_pdf(self):
        """Returns None for invalid PDF content."""
        invalid_content = b"This is not a PDF"
        text = BaseDownloader.extract_text_from_pdf(invalid_content)
        assert text is None

    def test_extract_text_from_empty_content(self):
        """Returns None for empty content."""
        text = BaseDownloader.extract_text_from_pdf(b"")
        assert text is None

    def test_extract_text_multipage(self, mocker):
        """Extracts text from all pages of multi-page PDF."""
        from unittest.mock import MagicMock

        # Mock PdfReader with multiple pages
        mock_reader = MagicMock()
        mock_page1 = MagicMock()
        mock_page1.extract_text.return_value = "First page text"
        mock_page2 = MagicMock()
        mock_page2.extract_text.return_value = "Second page text"
        mock_page3 = MagicMock()
        mock_page3.extract_text.return_value = "Third page text"
        mock_reader.pages = [mock_page1, mock_page2, mock_page3]

        # Patch pypdf.PdfReader since it's imported inside the function
        mocker.patch("pypdf.PdfReader", return_value=mock_reader)

        text = BaseDownloader.extract_text_from_pdf(b"%PDF-1.4 multipage")

        assert text is not None
        assert "First page text" in text
        assert "Second page text" in text
        assert "Third page text" in text
        # Pages joined with single newline in base implementation
        assert "\n" in text

    def test_extract_text_malformed_pdf(self, mocker):
        """Returns None for malformed PDF that causes pypdf to raise exception."""
        # Patch pypdf.PdfReader since it's imported inside the function
        mocker.patch(
            "pypdf.PdfReader",
            side_effect=Exception("Cannot parse malformed PDF"),
        )

        # Truncated PDF content
        malformed = b"%PDF-1.4\n1 0 obj\n<<\n/Type /Catalog"
        text = BaseDownloader.extract_text_from_pdf(malformed)

        # Should gracefully return None
        assert text is None

    def test_extract_text_pages_with_none(self, mocker):
        """Handles pages that return None from extract_text (scanned images)."""
        from unittest.mock import MagicMock

        mock_reader = MagicMock()
        mock_page1 = MagicMock()
        mock_page1.extract_text.return_value = "Text from page 1"
        mock_page2 = MagicMock()
        mock_page2.extract_text.return_value = None  # Scanned image
        mock_page3 = MagicMock()
        mock_page3.extract_text.return_value = "Text from page 3"
        mock_reader.pages = [mock_page1, mock_page2, mock_page3]

        # Patch pypdf.PdfReader since it's imported inside the function
        mocker.patch("pypdf.PdfReader", return_value=mock_reader)

        text = BaseDownloader.extract_text_from_pdf(b"%PDF-1.4 mixed")

        assert text is not None
        assert "Text from page 1" in text
        assert "Text from page 3" in text

    def test_extract_text_all_pages_no_text(self, mocker):
        """Returns None when all pages have no extractable text."""
        from unittest.mock import MagicMock

        mock_reader = MagicMock()
        mock_page1 = MagicMock()
        mock_page1.extract_text.return_value = None
        mock_page2 = MagicMock()
        mock_page2.extract_text.return_value = ""
        mock_reader.pages = [mock_page1, mock_page2]

        # Patch pypdf.PdfReader since it's imported inside the function
        mocker.patch("pypdf.PdfReader", return_value=mock_reader)

        text = BaseDownloader.extract_text_from_pdf(b"%PDF-1.4 scanned")

        assert text is None


class TestGetMetadata:
    """Tests for get_metadata method."""

    def test_get_metadata_default(self):
        """Default implementation returns empty dict."""
        downloader = ConcreteDownloader()
        metadata = downloader.get_metadata("https://example.com/paper.pdf")
        assert metadata == {}


class TestBaseDownloaderResourceCleanup:
    """Tests for session cleanup and resource management."""

    def test_close_closes_session(self):
        """Test that close() properly closes the session."""
        downloader = ConcreteDownloader()

        # Verify session exists
        assert downloader.session is not None

        # Close the downloader
        downloader.close()

        # Session should be None after close
        assert downloader.session is None

    def test_close_handles_none_session(self):
        """Test that close() handles already-closed session gracefully."""
        downloader = ConcreteDownloader()

        # Close twice - should not raise
        downloader.close()
        downloader.close()  # Should not raise

        assert downloader.session is None

    def test_close_handles_exception(self, mocker):
        """Test that close() handles session.close() exceptions."""
        downloader = ConcreteDownloader()

        # Mock session to raise on close
        mock_session = mocker.Mock()
        mock_session.close.side_effect = Exception("Close failed")
        downloader.session = mock_session

        # Should not raise, just log the exception
        downloader.close()

        # Session should be set to None even after exception
        assert downloader.session is None

    def test_del_calls_close(self, mocker):
        """Test that __del__ calls close()."""
        downloader = ConcreteDownloader()

        mock_close = mocker.patch.object(downloader, "close")

        downloader.__del__()

        mock_close.assert_called_once()

    def test_context_manager_calls_close(self, mocker):
        """Test that exiting context manager calls close()."""
        downloader = ConcreteDownloader()
        # Patch on the INSTANCE (same pattern as the __del__ test above),
        # not on BaseDownloader: __del__ also calls self.close(), so a
        # class-level mock is shared by every downloader instance alive in
        # the process — whenever the GC happens to finalize one left over
        # from an earlier test while this test runs, the count flakes
        # ("Expected 'close' to have been called once. Called 2 times.").
        # An instance-level patch only sees calls from THIS downloader.
        mock_close = mocker.patch.object(downloader, "close")

        with downloader as ctx:
            assert ctx is downloader

        mock_close.assert_called_once()

    def test_context_manager_returns_self(self):
        """Test that __enter__ returns self."""
        downloader = ConcreteDownloader()

        result = downloader.__enter__()

        assert result is downloader

        # Clean up
        downloader.close()

    def test_context_manager_closes_on_exception(self):
        """Test that context manager closes session even when exception occurs."""
        downloader = None
        try:
            with ConcreteDownloader() as dl:
                downloader = dl
                raise ValueError("Test exception")
        except ValueError:
            pass

        # Session should be closed even after exception
        assert downloader.session is None
