"""
Tests for LibraryService CRUD operations — happy paths and edge cases.

Covers:
- delete_document: successful deletion, no URL, missing file, tracker reset
- get_document_by_id: found with all fields, file_path sentinels, word count, blob check
- get_library_stats: value assertions, size conversion, None handling
- sync_library_with_filesystem: empty docs, found/missing files, mixed docs
- mark_for_redownload: empty list, non-existent doc, successful mark
"""

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

from local_deep_research.constants import (
    FILE_PATH_METADATA_ONLY,
    FILE_PATH_TEXT_ONLY,
)


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


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

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


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

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

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


# ============== delete_document ==============


class TestDeleteDocumentHappyPaths:
    """Tests for delete_document when document IS found."""

    def test_successful_deletion_with_file(self, mocker):
        """Doc found, tracker with file, file exists: unlink + cascade + True."""
        service = _make_service()
        mock_session = MagicMock()

        mock_doc = Mock()
        mock_doc.original_url = "https://arxiv.org/abs/2301.00001"

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

        # Session query routing
        mock_doc_query = MagicMock()
        mock_doc_query.get.return_value = mock_doc
        mock_tracker_query = MagicMock()
        mock_tracker_query.filter_by.return_value.first.return_value = (
            mock_tracker
        )

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

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

        # Mock file path resolution
        mock_path = MagicMock()
        mock_path.is_file.return_value = True
        mocker.patch(
            "local_deep_research.research_library.services.library_service.get_absolute_path_from_settings",
            return_value=mock_path,
        )

        mocker.patch(
            "local_deep_research.research_library.deletion.utils.cascade_helper.CascadeHelper.delete_document_completely"
        )

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

        assert result is True
        mock_path.unlink.assert_called_once()
        assert mock_tracker.is_downloaded is False
        assert mock_tracker.file_path is None

    def test_doc_found_no_original_url(self, mocker):
        """Doc found but original_url is None — no tracker lookup, still returns True."""
        service = _make_service()
        mock_session = MagicMock()

        mock_doc = Mock()
        mock_doc.original_url = None

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

        mocker.patch(
            "local_deep_research.research_library.deletion.utils.cascade_helper.CascadeHelper.delete_document_completely"
        )

        result = service.delete_document("doc-123")
        assert result is True

    def test_tracker_found_but_file_missing(self, mocker):
        """Tracker has file_path but file doesn't exist — no unlink, still True."""
        service = _make_service()
        mock_session = MagicMock()

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

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

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

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

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

        mock_path = MagicMock()
        mock_path.is_file.return_value = False
        mocker.patch(
            "local_deep_research.research_library.services.library_service.get_absolute_path_from_settings",
            return_value=mock_path,
        )
        mocker.patch(
            "local_deep_research.research_library.deletion.utils.cascade_helper.CascadeHelper.delete_document_completely"
        )

        result = service.delete_document("doc-123")
        assert result is True
        mock_path.unlink.assert_not_called()

    def test_tracker_has_no_file_path(self, mocker):
        """Tracker exists but file_path is None — no file deletion."""
        service = _make_service()
        mock_session = MagicMock()

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

        mock_tracker = Mock()
        mock_tracker.file_path = None

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

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

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

        mocker.patch(
            "local_deep_research.research_library.deletion.utils.cascade_helper.CascadeHelper.delete_document_completely"
        )

        result = service.delete_document("doc-123")
        assert result is True
        assert mock_tracker.is_downloaded is False

    def test_file_unlink_raises_exception_continues(self, mocker):
        """File unlink fails with OSError — continues with DB cleanup, returns True."""
        service = _make_service()
        mock_session = MagicMock()

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

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

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

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

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

        mock_path = MagicMock()
        mock_path.is_file.return_value = True
        mock_path.unlink.side_effect = OSError("Permission denied")
        mocker.patch(
            "local_deep_research.research_library.services.library_service.get_absolute_path_from_settings",
            return_value=mock_path,
        )
        mocker.patch(
            "local_deep_research.research_library.deletion.utils.cascade_helper.CascadeHelper.delete_document_completely"
        )

        result = service.delete_document("doc-123")
        assert result is True

    def test_tracker_reset_fields(self, mocker):
        """Verify tracker fields are reset after deletion."""
        service = _make_service()
        mock_session = MagicMock()

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

        mock_tracker = Mock()
        mock_tracker.file_path = "pdfs/test.pdf"
        mock_tracker.is_downloaded = True

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

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

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

        mock_path = MagicMock()
        mock_path.is_file.return_value = False
        mocker.patch(
            "local_deep_research.research_library.services.library_service.get_absolute_path_from_settings",
            return_value=mock_path,
        )
        mocker.patch(
            "local_deep_research.research_library.deletion.utils.cascade_helper.CascadeHelper.delete_document_completely"
        )

        service.delete_document("doc-123")

        assert mock_tracker.is_downloaded is False
        assert mock_tracker.file_path is None


# ============== get_document_by_id ==============


class TestGetDocumentByIdHappyPaths:
    """Tests for get_document_by_id when document IS found."""

    def _setup_found_doc(self, mocker, **doc_overrides):
        """Helper to set up a found document with query mocks."""
        service = _make_service()
        mock_session = MagicMock()

        mock_doc = Mock()
        mock_doc.id = "doc-123"
        mock_doc.resource_id = "res-1"
        mock_doc.research_id = "research-1"
        mock_doc.title = "Test Paper"
        mock_doc.original_url = "https://arxiv.org/abs/2301.00001"
        mock_doc.file_path = doc_overrides.get("file_path", "pdfs/test.pdf")
        mock_doc.filename = "test.pdf"
        mock_doc.file_size = 1024
        mock_doc.file_type = "pdf"
        mock_doc.mime_type = "application/pdf"
        mock_doc.text_content = doc_overrides.get(
            "text_content", "one two three"
        )
        mock_doc.status = "completed"
        mock_doc.processed_at = None
        mock_doc.favorite = False
        mock_doc.tags = []
        mock_doc.storage_mode = doc_overrides.get("storage_mode", "filesystem")

        mock_resource = Mock()
        mock_resource.title = "Resource Title"
        mock_resource.url = "https://arxiv.org/abs/2301.00001"

        mock_research = Mock()
        mock_research.query = "quantum computing"
        mock_research.created_at = "2024-01-01T00:00:00"

        # Main query result
        mock_main_query = MagicMock()
        mock_main_query.outerjoin.return_value = mock_main_query
        mock_main_query.filter.return_value = mock_main_query
        mock_main_query.first.return_value = (
            mock_doc,
            mock_resource,
            mock_research,
        )

        # Doc collections query
        mock_dc = Mock()
        mock_dc.indexed = True
        mock_dc.chunk_count = 10
        mock_coll = Mock()
        mock_coll.id = "coll-1"
        mock_coll.name = "Library"

        mock_coll_query = MagicMock()
        mock_coll_query.join.return_value = mock_coll_query
        mock_coll_query.filter.return_value = mock_coll_query
        mock_coll_query.all.return_value = [(mock_dc, mock_coll)]

        call_count = {"n": 0}

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

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

        mocker.patch(
            "local_deep_research.research_library.services.library_service.get_absolute_path_from_settings",
            return_value=None,
        )

        return service, mock_doc, mock_session

    def test_document_found_returns_dict_with_enriched_fields(self, mocker):
        """Found document returns dict with all expected keys."""
        service, _, _ = self._setup_found_doc(mocker)
        result = service.get_document_by_id("doc-123")

        assert result is not None
        assert result["id"] == "doc-123"
        assert result["document_title"] == "Test Paper"
        assert "has_pdf" in result
        assert "word_count" in result
        assert "collections" in result

    def test_file_path_metadata_only_has_pdf_false(self, mocker):
        """file_path='metadata_only' means has_pdf is False."""
        service, _, _ = self._setup_found_doc(
            mocker, file_path=FILE_PATH_METADATA_ONLY
        )
        result = service.get_document_by_id("doc-123")

        assert result["has_pdf"] is False

    def test_file_path_text_only_has_pdf_false(self, mocker):
        """file_path='text_only_not_stored' means has_pdf is False."""
        service, _, _ = self._setup_found_doc(
            mocker, file_path=FILE_PATH_TEXT_ONLY
        )
        result = service.get_document_by_id("doc-123")

        assert result["has_pdf"] is False

    def test_word_count_from_text_content(self, mocker):
        """Word count computed from text_content.split()."""
        service, _, _ = self._setup_found_doc(
            mocker, text_content="one two three four five"
        )
        result = service.get_document_by_id("doc-123")

        assert result["word_count"] == 5

    def test_word_count_none_text_content(self, mocker):
        """text_content=None results in word_count=0."""
        service, _, _ = self._setup_found_doc(mocker, text_content=None)
        result = service.get_document_by_id("doc-123")

        assert result["word_count"] == 0

    def test_has_pdf_via_database_storage_mode(self, mocker):
        """storage_mode='database' + no file_path checks _has_blob_in_db."""
        service, mock_doc, mock_session = self._setup_found_doc(
            mocker, file_path=FILE_PATH_METADATA_ONLY, storage_mode="database"
        )
        # Mock _has_blob_in_db to return True
        mocker.patch.object(service, "_has_blob_in_db", return_value=True)

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

        assert result["has_pdf"] is True
        service._has_blob_in_db.assert_called_once()


# ============== get_library_stats ==============


class TestGetLibraryStatsValues:
    """Value assertions for get_library_stats (not just isinstance checks)."""

    def _setup_stats(self, mocker, size_total=None, size_avg=None):
        """Set up mocked session for stats queries."""
        service = _make_service()
        mock_session = MagicMock()

        # We need to track query calls to route them
        call_count = {"n": 0}

        def query_side_effect(*args):
            call_count["n"] += 1
            mock_q = MagicMock()
            if call_count["n"] == 1:
                # total docs count
                mock_q.count.return_value = 42
            elif call_count["n"] == 2:
                # pdf count
                mock_q.filter_by.return_value.count.return_value = 10
            elif call_count["n"] == 3:
                # size stats
                mock_q.first.return_value = (size_total, size_avg)
            elif call_count["n"] == 4:
                # research count (scalar)
                mock_q.scalar.return_value = 5
            elif call_count["n"] == 5:
                # domain subquery
                mock_q.subquery.return_value = MagicMock()
            elif call_count["n"] == 6:
                # domain count from subquery
                mock_q.select_from.return_value.scalar.return_value = 3
            elif call_count["n"] == 7:
                # pending downloads
                mock_q.filter_by.return_value.count.return_value = 2
            return mock_q

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

        mocker.patch.object(
            service, "_get_storage_path", return_value="/test/path"
        )

        return service

    def test_all_expected_keys_present(self, mocker):
        """Result contains all 9 expected keys."""
        service = self._setup_stats(mocker, size_total=1048576, size_avg=524288)
        result = service.get_library_stats()

        expected_keys = {
            "total_documents",
            "total_pdfs",
            "total_size_bytes",
            "total_size_mb",
            "average_size_mb",
            "research_sessions",
            "unique_domains",
            "pending_downloads",
            "storage_path",
        }
        assert set(result.keys()) == expected_keys

    def test_size_mb_conversion(self, mocker):
        """1048576 bytes = 1.0 MB, 524288 bytes avg = 0.5 MB."""
        service = self._setup_stats(mocker, size_total=1048576, size_avg=524288)
        result = service.get_library_stats()

        assert result["total_size_mb"] == 1.0
        assert result["average_size_mb"] == 0.5

    def test_zero_documents_size_handling(self, mocker):
        """When size is None/0, total_size_mb and average_size_mb are 0."""
        service = self._setup_stats(mocker, size_total=None, size_avg=None)
        result = service.get_library_stats()

        assert result["total_size_mb"] == 0
        assert result["average_size_mb"] == 0

    def test_total_size_none_returns_zero_mb(self, mocker):
        """total_size=None → total_size_mb=0 via 'if total_size' guard."""
        service = self._setup_stats(mocker, size_total=None, size_avg=100)
        result = service.get_library_stats()

        assert result["total_size_mb"] == 0

    def test_storage_path_present(self, mocker):
        """storage_path field is populated from _get_storage_path."""
        service = self._setup_stats(mocker, size_total=0, size_avg=0)
        result = service.get_library_stats()

        assert result["storage_path"] == "/test/path"


# ============== sync_library_with_filesystem ==============


class TestSyncLibraryWithFilesystem:
    """Tests for sync_library_with_filesystem logic."""

    def test_empty_documents_all_counts_zero(self, mocker):
        """No completed documents → all stats zero."""
        service = _make_service()
        mock_session = MagicMock()
        mock_session.query.return_value.filter_by.return_value.filter.return_value.all.return_value = []
        _mock_session_cm(mocker, mock_session)

        result = service.sync_library_with_filesystem()

        assert result["total_documents"] == 0
        assert result["files_found"] == 0
        assert result["files_missing"] == 0

    def test_doc_with_tracker_file_exists(self, mocker):
        """Doc with tracker + existing file → files_found incremented."""
        service = _make_service()
        mock_session = MagicMock()

        mock_doc = Mock()
        mock_doc.id = "doc-1"
        mock_doc.title = "Test"
        mock_doc.original_url = "https://example.com/doc.pdf"

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

        mock_session.query.return_value.filter_by.return_value.filter.return_value.all.return_value = [
            mock_doc
        ]

        # Second query call for tracker
        tracker_query = MagicMock()
        tracker_query.filter_by.return_value.first.return_value = mock_tracker

        call_count = {"n": 0}

        def query_router(model):
            call_count["n"] += 1
            if call_count["n"] == 1:
                # Document query
                q = MagicMock()
                q.filter_by.return_value.filter.return_value.all.return_value = [
                    mock_doc
                ]
                return q
            return tracker_query

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

        mock_path = MagicMock()
        mock_path.is_file.return_value = True
        mocker.patch(
            "local_deep_research.research_library.services.library_service.get_absolute_path_from_settings",
            return_value=mock_path,
        )

        result = service.sync_library_with_filesystem()

        assert result["files_found"] == 1

    def test_doc_with_tracker_file_missing(self, mocker):
        """Doc with tracker but missing file → files_missing + cascade delete."""
        service = _make_service()
        mock_session = MagicMock()

        mock_doc = Mock()
        mock_doc.id = "doc-1"
        mock_doc.title = "Test"
        mock_doc.original_url = "https://example.com/doc.pdf"

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

        call_count = {"n": 0}

        def query_router(model):
            call_count["n"] += 1
            if call_count["n"] == 1:
                q = MagicMock()
                q.filter_by.return_value.filter.return_value.all.return_value = [
                    mock_doc
                ]
                return q
            tq = MagicMock()
            tq.filter_by.return_value.first.return_value = mock_tracker
            return tq

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

        mock_path = MagicMock()
        mock_path.is_file.return_value = False
        mocker.patch(
            "local_deep_research.research_library.services.library_service.get_absolute_path_from_settings",
            return_value=mock_path,
        )
        mocker.patch(
            "local_deep_research.research_library.deletion.utils.cascade_helper.CascadeHelper.delete_document_completely"
        )

        result = service.sync_library_with_filesystem()

        assert result["files_missing"] == 1
        assert result["trackers_updated"] == 1

    def test_doc_without_tracker(self, mocker):
        """Doc with no tracker → files_missing + cascade delete."""
        service = _make_service()
        mock_session = MagicMock()

        mock_doc = Mock()
        mock_doc.id = "doc-1"
        mock_doc.title = "Test"
        mock_doc.original_url = "https://example.com/doc.pdf"

        call_count = {"n": 0}

        def query_router(model):
            call_count["n"] += 1
            if call_count["n"] == 1:
                q = MagicMock()
                q.filter_by.return_value.filter.return_value.all.return_value = [
                    mock_doc
                ]
                return q
            tq = MagicMock()
            tq.filter_by.return_value.first.return_value = None
            return tq

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

        mocker.patch(
            "local_deep_research.research_library.deletion.utils.cascade_helper.CascadeHelper.delete_document_completely"
        )

        result = service.sync_library_with_filesystem()

        assert result["files_missing"] == 1

    def test_multiple_mixed_docs(self, mocker):
        """2 found + 1 missing → correct totals."""
        service = _make_service()
        mock_session = MagicMock()

        docs = []
        for i in range(3):
            d = Mock()
            d.id = f"doc-{i}"
            d.title = f"Doc {i}"
            d.original_url = f"https://example.com/doc{i}.pdf"
            docs.append(d)

        trackers = [Mock(file_path=f"pdfs/doc{i}.pdf") for i in range(3)]

        call_count = {"n": 0}

        def query_router(model):
            call_count["n"] += 1
            if call_count["n"] == 1:
                q = MagicMock()
                q.filter_by.return_value.filter.return_value.all.return_value = docs
                return q
            idx = call_count["n"] - 2
            tq = MagicMock()
            if idx < 3:
                tq.filter_by.return_value.first.return_value = trackers[idx]
            else:
                tq.filter_by.return_value.first.return_value = None
            return tq

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

        # First 2 files exist, third missing
        paths = [MagicMock(), MagicMock(), MagicMock()]
        paths[0].is_file.return_value = True
        paths[1].is_file.return_value = True
        paths[2].is_file.return_value = False

        path_call_count = {"n": 0}

        def path_resolver(fp):
            idx = path_call_count["n"]
            path_call_count["n"] += 1
            if idx < 3:
                return paths[idx]
            return MagicMock()

        mocker.patch(
            "local_deep_research.research_library.services.library_service.get_absolute_path_from_settings",
            side_effect=path_resolver,
        )
        mocker.patch(
            "local_deep_research.research_library.deletion.utils.cascade_helper.CascadeHelper.delete_document_completely"
        )

        result = service.sync_library_with_filesystem()

        assert result["total_documents"] == 3
        assert result["files_found"] == 2
        assert result["files_missing"] == 1


# ============== mark_for_redownload ==============


class TestMarkForRedownload:
    """Tests for mark_for_redownload method."""

    def test_empty_document_ids_returns_zero(self, mocker):
        """Empty list returns 0."""
        service = _make_service()
        mock_session = MagicMock()
        _mock_session_cm(mocker, mock_session)

        result = service.mark_for_redownload([])

        assert result == 0

    def test_nonexistent_doc_id_not_counted(self, mocker):
        """Non-existent doc_id is skipped, count stays 0."""
        service = _make_service()
        mock_session = MagicMock()
        mock_session.query.return_value.get.return_value = None
        _mock_session_cm(mocker, mock_session)

        result = service.mark_for_redownload(["nonexistent-id"])

        assert result == 0

    def test_successful_mark_changes_status_and_resets_tracker(self, mocker):
        """Found doc: status→PENDING, tracker reset."""
        from local_deep_research.database.models.library import DocumentStatus

        service = _make_service()
        mock_session = MagicMock()

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

        mock_tracker = Mock()
        mock_tracker.is_downloaded = True
        mock_tracker.file_path = "/path/to/file.pdf"

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

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

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

        result = service.mark_for_redownload(["doc-123"])

        assert result == 1
        assert mock_doc.status == DocumentStatus.PENDING
        assert mock_tracker.is_downloaded is False
        assert mock_tracker.file_path is None
