"""
Tests for DownloadService that actually invoke methods and assert behavior.

Replaces 8 of 9 shallow tests (lines 1200-1420 in existing test file) that only
assert hasattr(), service.username == "test_user", or service is not None.

Source: src/local_deep_research/research_library/services/download_service.py
"""

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

import pytest


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


def _create_service(tmp_path, username="testuser", password="testpw"):
    """Create a DownloadService with mocked dependencies."""
    mock_settings = Mock()
    mock_settings.get_setting.side_effect = lambda key, default=None: {
        "research_library.storage_path": str(tmp_path),
        "search.engine.web.semantic_scholar.api_key": "",
        "research_library.pdf_storage_mode": "none",
        "research_library.max_pdf_size_mb": 100,
    }.get(key, default)

    with (
        patch(
            "local_deep_research.research_library.services.download_service.get_settings_manager",
            return_value=mock_settings,
        ),
        patch(
            "local_deep_research.research_library.services.download_service.RetryManager",
        ),
        patch(
            "local_deep_research.research_library.services.download_service.get_library_directory",
            return_value=tmp_path,
        ),
    ):
        from local_deep_research.research_library.services.download_service import (
            DownloadService,
        )

        service = DownloadService(username, password)
        return service


# ---------------------------------------------------------------------------
# Context manager / close()
# ---------------------------------------------------------------------------


class TestContextManagerAndClose:
    """Test context manager and close() behavior."""

    def test_close_closes_all_downloaders(self, tmp_path):
        """close() calls close() on all downloaders that have it."""
        service = _create_service(tmp_path)

        # Replace downloaders with mocks that have close()
        mock_dl1 = Mock()
        mock_dl2 = Mock()
        mock_dl2.close = Mock()
        service.downloaders = [mock_dl1, mock_dl2]

        service.close()

        # Both should have close() called (mock_dl1 has it via Mock)
        assert service._closed is True
        assert service.downloaders == []

    def test_close_is_idempotent(self, tmp_path):
        """Calling close() twice does not raise."""
        service = _create_service(tmp_path)
        service.close()
        service.close()  # Should not raise

    def test_context_manager_calls_close(self, tmp_path):
        """__exit__ calls close()."""
        service = _create_service(tmp_path)
        service.__enter__()
        service.__exit__(None, None, None)
        assert service._closed is True

    def test_context_manager_returns_self(self, tmp_path):
        """__enter__ returns self."""
        service = _create_service(tmp_path)
        result = service.__enter__()
        assert result is service
        service.close()


# ---------------------------------------------------------------------------
# get_text_content
# ---------------------------------------------------------------------------


class TestGetTextContent:
    """Test get_text_content method."""

    def test_resource_not_found_returns_none(self, tmp_path):
        """get_text_content returns None when resource not found."""
        service = _create_service(tmp_path)

        mock_session = Mock()
        mock_session.query.return_value.get.return_value = None

        @contextmanager
        def fake_session(*args, **kwargs):
            yield mock_session

        with patch(
            "local_deep_research.research_library.services.download_service.get_user_db_session",
            side_effect=fake_session,
        ):
            result = service.get_text_content(999)

        assert result is None
        service.close()

    def test_success_returns_text(self, tmp_path):
        """get_text_content returns text when downloader succeeds."""
        service = _create_service(tmp_path)

        resource = Mock()
        resource.url = "https://example.com/paper"
        resource.title = "Test Paper"

        mock_session = Mock()
        mock_session.query.return_value.get.return_value = resource

        # Mock downloader that can handle the URL
        mock_downloader = Mock()
        mock_downloader.can_handle.return_value = True
        mock_downloader.download_text.return_value = "Extracted text content"
        service.downloaders = [mock_downloader]

        @contextmanager
        def fake_session(*args, **kwargs):
            yield mock_session

        with patch(
            "local_deep_research.research_library.services.download_service.get_user_db_session",
            side_effect=fake_session,
        ):
            result = service.get_text_content(1)

        assert result == "Extracted text content"
        service.close()

    def test_no_matching_downloader_returns_none(self, tmp_path):
        """get_text_content returns None when no downloader can handle URL."""
        service = _create_service(tmp_path)

        resource = Mock()
        resource.url = "ftp://weird-protocol.com/file"
        resource.title = "Test"

        mock_session = Mock()
        mock_session.query.return_value.get.return_value = resource

        # No downloaders can handle it
        for dl in service.downloaders:
            dl.can_handle = Mock(return_value=False)

        @contextmanager
        def fake_session(*args, **kwargs):
            yield mock_session

        with patch(
            "local_deep_research.research_library.services.download_service.get_user_db_session",
            side_effect=fake_session,
        ):
            result = service.get_text_content(1)

        assert result is None
        service.close()

    def test_downloader_raises_returns_none(self, tmp_path):
        """get_text_content returns None when downloader raises."""
        service = _create_service(tmp_path)

        resource = Mock()
        resource.url = "https://example.com/paper"
        resource.title = "Test"

        mock_session = Mock()
        mock_session.query.return_value.get.return_value = resource

        mock_downloader = Mock()
        mock_downloader.can_handle.return_value = True
        mock_downloader.download_text.side_effect = RuntimeError(
            "Network error"
        )
        service.downloaders = [mock_downloader]

        @contextmanager
        def fake_session(*args, **kwargs):
            yield mock_session

        with patch(
            "local_deep_research.research_library.services.download_service.get_user_db_session",
            side_effect=fake_session,
        ):
            result = service.get_text_content(1)

        assert result is None
        service.close()


# ---------------------------------------------------------------------------
# _try_existing_pdf_extraction
# ---------------------------------------------------------------------------


class TestTryExistingPdfExtraction:
    """Test _try_existing_pdf_extraction method."""

    def test_no_document_returns_none(self, tmp_path):
        """No Document record -> returns None."""
        service = _create_service(tmp_path)

        mock_session = Mock()
        mock_query = Mock()
        mock_query.filter_by.return_value = mock_query
        mock_query.first.return_value = None
        mock_session.query.return_value = mock_query

        result = service._try_existing_pdf_extraction(mock_session, Mock(), 1)
        assert result is None
        service.close()

    def test_not_completed_returns_none(self, tmp_path):
        """Document with status != 'completed' -> returns None."""
        service = _create_service(tmp_path)

        doc = Mock()
        doc.status = "pending"

        mock_session = Mock()
        mock_query = Mock()
        mock_query.filter_by.return_value = mock_query
        mock_query.first.return_value = doc
        mock_session.query.return_value = mock_query

        result = service._try_existing_pdf_extraction(mock_session, Mock(), 1)
        assert result is None
        service.close()

    def test_file_missing_returns_none(self, tmp_path):
        """Document is completed but file doesn't exist -> returns None."""
        service = _create_service(tmp_path)

        doc = Mock()
        doc.status = "completed"
        doc.file_path = "pdfs/nonexistent.pdf"

        mock_session = Mock()
        mock_query = Mock()
        mock_query.filter_by.return_value = mock_query
        mock_query.first.return_value = doc
        mock_session.query.return_value = mock_query

        result = service._try_existing_pdf_extraction(mock_session, Mock(), 1)
        assert result is None
        service.close()


# ---------------------------------------------------------------------------
# _fallback_pdf_extraction
# ---------------------------------------------------------------------------


class TestFallbackPdfExtraction:
    """Test _fallback_pdf_extraction method."""

    def test_no_compatible_downloader(self, tmp_path):
        """No compatible downloader returns (False, error_msg)."""
        service = _create_service(tmp_path)

        # Make all downloaders not handle URL
        for dl in service.downloaders:
            dl.can_handle = Mock(return_value=False)

        resource = Mock()
        resource.url = "ftp://weird.com/file"
        resource.title = "Test Title"
        resource.id = 1

        mock_session = Mock()

        success, reason = service._fallback_pdf_extraction(
            mock_session, resource
        )
        assert success is False
        assert "No compatible downloader" in reason
        service.close()

    def test_download_fails(self, tmp_path):
        """Download fails returns (False, error_msg)."""
        service = _create_service(tmp_path)

        mock_downloader = Mock()
        mock_downloader.can_handle.return_value = True
        result_obj = Mock()
        result_obj.is_success = False
        result_obj.content = None
        result_obj.skip_reason = "Server returned 403"
        mock_downloader.download_with_result.return_value = result_obj
        service.downloaders = [mock_downloader]

        resource = Mock()
        resource.url = "https://example.com/paper.pdf"
        resource.title = "Test"
        resource.id = 1

        mock_session = Mock()

        success, reason = service._fallback_pdf_extraction(
            mock_session, resource
        )
        assert success is False
        assert "403" in reason
        service.close()


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


class TestInitialization:
    """Test DownloadService initialization."""

    def test_username_stored(self, tmp_path):
        """Username is stored on the service."""
        service = _create_service(tmp_path, username="alice")
        assert service.username == "alice"
        service.close()

    def test_password_stored(self, tmp_path):
        """Password is stored on the service."""
        service = _create_service(tmp_path, password="secret123")
        assert service.password == "secret123"
        service.close()

    def test_library_root_set(self, tmp_path):
        """Library root is set from settings."""
        service = _create_service(tmp_path)
        assert service.library_root == str(tmp_path)
        service.close()

    def test_downloaders_initialized(self, tmp_path):
        """Multiple downloaders are initialized."""
        service = _create_service(tmp_path)
        assert len(service.downloaders) > 0
        service.close()

    def test_closed_starts_false(self, tmp_path):
        """_closed flag starts as False."""
        service = _create_service(tmp_path)
        assert service._closed is False
        service.close()

    def test_none_storage_path_raises(self, tmp_path):
        """None storage path raises ValueError."""
        mock_settings = Mock()
        mock_settings.get_setting.return_value = None

        with (
            patch(
                "local_deep_research.research_library.services.download_service.get_settings_manager",
                return_value=mock_settings,
            ),
            patch(
                "local_deep_research.research_library.services.download_service.RetryManager",
            ),
            patch(
                "local_deep_research.research_library.services.download_service.get_library_directory",
                return_value=tmp_path,
            ),
        ):
            from local_deep_research.research_library.services.download_service import (
                DownloadService,
            )

            with pytest.raises(ValueError, match="Storage path"):
                DownloadService("user", "pw")


# ---------------------------------------------------------------------------
# _normalize_url
# ---------------------------------------------------------------------------


class TestNormalizeUrl:
    """Test URL normalization."""

    def test_basic_url_normalized(self, tmp_path):
        """Basic URL normalization works."""
        service = _create_service(tmp_path)
        # _normalize_url should at least return a string
        result = service._normalize_url("https://example.com/path")
        assert isinstance(result, str)
        service.close()

    def test_url_with_trailing_slash(self, tmp_path):
        """Trailing slashes handled consistently."""
        service = _create_service(tmp_path)
        r1 = service._normalize_url("https://example.com/path/")
        r2 = service._normalize_url("https://example.com/path")
        # Both should normalize to the same thing
        assert r1 == r2
        service.close()


# ---------------------------------------------------------------------------
# Egress policy gates at the network-fire points (PR #4300 review fix)
# ---------------------------------------------------------------------------


class TestEgressPolicyGatesAtFirePoints:
    """The entry methods (download_resource, download_as_text) do NOT gate
    the URL; the egress check lives at the three network-fire points so no
    caller can route around it. These tests pin that a denied URL never
    reaches a downloader from _download_pdf / _try_api_text_extraction /
    _fallback_pdf_extraction.

    Regression: before the fix these three methods fired
    downloader.download_with_result()/download_text() with no policy check,
    so a PRIVATE_ONLY/STRICT run could exfiltrate to a public URL via the
    download_resource / download_as_text paths.
    """

    def _deny(self, service):
        """Force _check_url_against_policy to deny every URL."""
        patcher = patch.object(
            service,
            "_check_url_against_policy",
            return_value=(False, "scope_mismatch_private_only"),
        )
        return patcher

    def test_download_pdf_denied_url_never_reaches_downloader(self, tmp_path):
        service = _create_service(tmp_path)
        # A downloader that would explode if ever consulted.
        exploding = Mock()
        exploding.can_handle.side_effect = AssertionError(
            "downloader consulted despite policy denial"
        )
        service.downloaders = [exploding]

        resource = Mock()
        resource.url = "https://public.example.com/paper.pdf"

        with self._deny(service):
            success, reason, status = service._download_pdf(
                resource, Mock(), Mock(), None
            )

        assert success is False
        assert reason.startswith("egress_policy_denied:")
        exploding.can_handle.assert_not_called()
        service.close()

    def test_try_api_text_extraction_denied_returns_non_none_tuple(
        self, tmp_path
    ):
        """Must return a non-None tuple on denial so download_as_text
        hard-stops instead of falling through to _fallback_pdf_extraction
        (which would fire a second request)."""
        service = _create_service(tmp_path)
        downloader = Mock()
        downloader.download_with_result.side_effect = AssertionError(
            "download_with_result fired despite policy denial"
        )

        resource = Mock()
        resource.url = "https://public.example.com/paper"
        resource.id = 1
        resource.title = "t"

        with (
            patch.object(service, "_get_downloader", return_value=downloader),
            self._deny(service),
        ):
            result = service._try_api_text_extraction(Mock(), resource)

        assert result is not None, "denial must not fall through to fallback"
        ok, reason = result
        assert ok is False
        assert reason.startswith("egress_policy_denied:")
        downloader.download_with_result.assert_not_called()
        service.close()

    def test_fallback_pdf_extraction_denied_url_never_downloads(self, tmp_path):
        service = _create_service(tmp_path)
        downloader = Mock()
        downloader.download_with_result.side_effect = AssertionError(
            "download_with_result fired despite policy denial"
        )

        resource = Mock()
        resource.url = "https://public.example.com/paper.pdf"
        resource.id = 1
        resource.title = "t"

        with (
            patch.object(service, "_get_downloader", return_value=downloader),
            patch.object(service, "_record_failed_text_extraction"),
            self._deny(service),
        ):
            ok, reason = service._fallback_pdf_extraction(Mock(), resource)

        assert ok is False
        assert reason.startswith("egress_policy_denied:")
        downloader.download_with_result.assert_not_called()
        service.close()


class TestDefaultSnapshotEgressContext:
    """When no settings_snapshot is passed, DownloadService builds one from
    its own settings_manager so egress policy is still enforced. This closes
    the library-download routes hole where DownloadService(username, password)
    was constructed snapshot-less and every download ran ungated.
    """

    def _service_with_snapshot(self, tmp_path, snapshot):
        mock_settings = Mock()
        mock_settings.get_setting.side_effect = lambda key, default=None: {
            "research_library.storage_path": str(tmp_path),
            "research_library.pdf_storage_mode": "none",
        }.get(key, default)
        # The new behavior: __init__ pulls a snapshot from here when none
        # is passed explicitly.
        mock_settings.get_settings_snapshot.return_value = snapshot

        with (
            patch(
                "local_deep_research.research_library.services.download_service.get_settings_manager",
                return_value=mock_settings,
            ),
            patch(
                "local_deep_research.research_library.services.download_service.RetryManager",
            ),
            patch(
                "local_deep_research.research_library.services.download_service.get_library_directory",
                return_value=tmp_path,
            ),
        ):
            from local_deep_research.research_library.services.download_service import (
                DownloadService,
            )

            # No settings_snapshot passed — must self-build from manager.
            return DownloadService("u", "p")

    def test_builds_context_and_denies_public_url_under_private_only(
        self, tmp_path
    ):
        service = self._service_with_snapshot(
            tmp_path, {"policy.egress_scope": "private_only"}
        )
        # Context was built from the self-fetched snapshot.
        assert service._egress_context is not None
        # A public URL is refused under PRIVATE_ONLY without an explicit
        # snapshot argument.
        allowed, reason = service._check_url_against_policy(
            "https://arxiv.org/abs/1"
        )
        assert allowed is False
        service.close()

    def test_non_dict_snapshot_falls_back_to_ungated(self, tmp_path):
        # A test-double manager returning a non-dict must NOT fail closed;
        # it falls back to the pre-policy ungated behavior.
        service = self._service_with_snapshot(tmp_path, Mock())
        assert service._egress_context is None
        allowed, _ = service._check_url_against_policy("https://arxiv.org/x")
        assert allowed is True  # no_context back-compat
        service.close()
