"""
Tests for ResearchHistoryIndexer service.
"""

import math
import uuid
from contextlib import contextmanager
from unittest.mock import patch

import pytest

from local_deep_research.database.models.library import (
    Collection,
    Document,
    DocumentCollection,
    SourceType,
)
from local_deep_research.database.models.research import (
    ResearchHistory,
    ResearchResource,
)
from local_deep_research.research_library.search.services.research_history_indexer import (
    ResearchHistoryIndexer,
)

# Module path for patching imports within the indexer
INDEXER_MODULE = "local_deep_research.research_library.search.services.research_history_indexer"


@pytest.fixture
def research_source_types(library_session):
    """Create source types needed for research history indexing."""
    report_type = SourceType(
        id=str(uuid.uuid4()),
        name="research_report",
        display_name="Research Report",
        description="Research report documents",
        icon="fas fa-file-alt",
    )
    source_type = SourceType(
        id=str(uuid.uuid4()),
        name="research_source",
        display_name="Research Source",
        description="Research source documents",
        icon="fas fa-link",
    )
    library_session.add_all([report_type, source_type])
    library_session.commit()
    return report_type, source_type


@pytest.fixture
def research_collection(library_session):
    """Create a Research History collection."""
    collection = Collection(
        id=str(uuid.uuid4()),
        name="Research History",
        description="Auto-indexed research history",
        is_default=False,
        collection_type="research_history",
    )
    library_session.add(collection)
    library_session.commit()
    return collection


@pytest.fixture
def sample_research(library_session):
    """Create a sample completed research entry."""
    research = ResearchHistory(
        id=str(uuid.uuid4()),
        query="What is quantum computing?",
        mode="detailed_report",
        status="completed",
        created_at="2025-01-15T10:00:00",
        report_content="# Quantum Computing\n\nQuantum computing uses qubits...",
        title="Quantum Computing Overview",
    )
    library_session.add(research)
    library_session.commit()
    return research


@pytest.fixture
def sample_resource(library_session, sample_research):
    """Create a sample research resource with content."""
    resource = ResearchResource(
        research_id=sample_research.id,
        url="https://example.com/quantum",
        title="Quantum Computing Primer",
        source_type="web_search",
        content_preview="A" * 200,  # Must exceed MIN_SOURCE_CONTENT_LENGTH
        created_at="2025-01-15T10:00:00",
    )
    library_session.add(resource)
    library_session.commit()
    return resource


@pytest.fixture
def mock_session_ctx(library_session):
    """Mock get_user_db_session at the indexer's import location."""

    @contextmanager
    def _mock_session(*args, **kwargs):
        yield library_session

    with patch(f"{INDEXER_MODULE}.get_user_db_session", _mock_session):
        yield library_session


@pytest.fixture
def indexer(mock_session_ctx):
    """Create an indexer with mocked DB session."""
    return ResearchHistoryIndexer("testuser", db_password=None)


class TestGetOrCreateCollection:
    def test_creates_collection(self, indexer):
        """get_or_create_collection should return a valid collection ID."""
        with patch(
            f"{INDEXER_MODULE}.ensure_research_history_collection",
            return_value=str(uuid.uuid4()),
        ) as mock_ensure:
            collection_id = indexer.get_or_create_collection()
            assert collection_id is not None
            mock_ensure.assert_called_once_with("testuser", None)


class TestIndexResearch:
    def test_index_research_not_found(self, indexer):
        """Should return error for non-existent research ID."""
        with patch.object(
            indexer, "get_or_create_collection", return_value=str(uuid.uuid4())
        ):
            result = indexer.index_research("nonexistent-id")
            assert result["status"] == "error"
            assert "not found" in result["error"].lower()

    def test_index_research_creates_documents(
        self,
        indexer,
        mock_session_ctx,
        sample_research,
        sample_resource,
        research_source_types,
        research_collection,
    ):
        """Should create a Document entry for the research report."""
        with patch.object(
            indexer,
            "get_or_create_collection",
            return_value=research_collection.id,
        ):
            result = indexer.index_research(sample_research.id)

            assert result["status"] == "success"
            assert result["documents_added"] == 1  # report only, no sources

            # Verify document was created with correct research_id
            docs = (
                mock_session_ctx.query(Document)
                .filter(Document.research_id == sample_research.id)
                .all()
            )
            assert len(docs) == 1

            # Verify report document has correct source type
            report_doc = next(
                (
                    d
                    for d in docs
                    if d.source_type_id == research_source_types[0].id
                ),
                None,
            )
            assert report_doc is not None

    def test_index_research_retries_unindexed_documents(
        self,
        indexer,
        mock_session_ctx,
        sample_research,
        research_source_types,
        research_collection,
    ):
        """Second call should retry when DocumentCollection.indexed is False."""
        with patch.object(
            indexer,
            "get_or_create_collection",
            return_value=research_collection.id,
        ):
            # First call: create documents
            result1 = indexer.index_research(sample_research.id)
            assert result1["status"] == "success"

            # Verify DocumentCollection rows exist but are NOT marked indexed
            doc_collections = (
                mock_session_ctx.query(DocumentCollection)
                .filter(
                    DocumentCollection.collection_id == research_collection.id
                )
                .all()
            )
            assert len(doc_collections) > 0
            for dc in doc_collections:
                assert dc.indexed is False

            # Second call: documents exist but indexed=False, so retry needed
            result2 = indexer.index_research(sample_research.id)
            assert result2["status"] == "success", (
                "Should retry for unindexed docs, not return 'skipped'"
            )

    def test_index_research_idempotent(
        self,
        indexer,
        mock_session_ctx,
        sample_research,
        research_source_types,
        research_collection,
    ):
        """Calling index_research twice should not duplicate documents."""
        with patch.object(
            indexer,
            "get_or_create_collection",
            return_value=research_collection.id,
        ):
            result1 = indexer.index_research(sample_research.id)
            assert result1["status"] == "success"

            result2 = indexer.index_research(sample_research.id)
            assert result2["status"] == "success"

            # Should still have only 1 report document
            report_docs = (
                mock_session_ctx.query(Document)
                .filter(
                    Document.research_id == sample_research.id,
                    Document.source_type_id == research_source_types[0].id,
                )
                .all()
            )
            assert len(report_docs) == 1


class TestHashCollisionReuse:
    def test_identical_content_reuses_document(
        self,
        indexer,
        mock_session_ctx,
        research_source_types,
        research_collection,
    ):
        """Two research entries with identical report_content should share one Document."""
        shared_content = (
            "# Shared Report\n\nIdentical content for both entries."
        )

        research1 = ResearchHistory(
            id=str(uuid.uuid4()),
            query="First query",
            mode="detailed_report",
            status="completed",
            created_at="2025-01-15T10:00:00",
            report_content=shared_content,
            title="Research One",
        )
        research2 = ResearchHistory(
            id=str(uuid.uuid4()),
            query="Second query",
            mode="detailed_report",
            status="completed",
            created_at="2025-01-16T10:00:00",
            report_content=shared_content,
            title="Research Two",
        )
        mock_session_ctx.add_all([research1, research2])
        mock_session_ctx.commit()

        with patch.object(
            indexer,
            "get_or_create_collection",
            return_value=research_collection.id,
        ):
            result1 = indexer.index_research(research1.id)
            assert result1["status"] == "success"

            result2 = indexer.index_research(research2.id)
            assert result2["status"] == "success"

            # Only 1 Document should exist (same content hash)
            docs = mock_session_ctx.query(Document).all()
            assert len(docs) == 1


class TestConvertAllResearch:
    def test_converts_unconverted_research(
        self,
        indexer,
        mock_session_ctx,
        research_source_types,
        research_collection,
    ):
        """convert_all_research should create Documents for all completed entries."""
        r1 = ResearchHistory(
            id=str(uuid.uuid4()),
            query="Convert query one",
            mode="detailed_report",
            status="completed",
            created_at="2025-03-01T10:00:00",
            report_content="# Convert Report One\n\nContent for r1.",
            title="Convert One",
        )
        r2 = ResearchHistory(
            id=str(uuid.uuid4()),
            query="Convert query two",
            mode="detailed_report",
            status="completed",
            created_at="2025-03-02T10:00:00",
            report_content="# Convert Report Two\n\nContent for r2.",
            title="Convert Two",
        )
        mock_session_ctx.add_all([r1, r2])
        mock_session_ctx.commit()

        with patch.object(
            indexer,
            "get_or_create_collection",
            return_value=research_collection.id,
        ):
            result = indexer.convert_all_research(force=False)

        assert result["converted"] == 2
        assert result["skipped"] == 0
        assert result["failed"] == 0
        assert result["collection_id"] == research_collection.id

        # Verify Documents were created
        docs = mock_session_ctx.query(Document).all()
        assert len(docs) == 2

    def test_batches_large_history_without_loading_all_bodies(
        self,
        indexer,
        mock_session_ctx,
        research_source_types,
        research_collection,
    ):
        """convert_all_research must enumerate candidates by ID and page
        through full rows in CONVERT_BATCH_SIZE chunks, so it never loads
        every report body at once (#4560). Asserts both multi-batch
        correctness (5 entries, batch size 2 -> 2+2+1 all convert) and that
        the candidate fetch projects ResearchHistory.id rather than the full
        entity (a revert to query(ResearchHistory) would never do this)."""
        session = mock_session_ctx  # fixture yields the real library_session
        for i in range(5):
            session.add(
                ResearchHistory(
                    id=str(uuid.uuid4()),
                    query=f"Batch query {i}",
                    mode="detailed_report",
                    status="completed",
                    created_at=f"2025-04-0{i + 1}T10:00:00",
                    report_content=f"# Batch Report {i}\n\nBody {i}.",
                    title=f"Batch {i}",
                )
            )
        session.commit()

        real_query = session.query
        query_calls = []

        def spy_query(*args, **kwargs):
            query_calls.append(args)
            return real_query(*args, **kwargs)

        with (
            patch.object(
                indexer,
                "get_or_create_collection",
                return_value=research_collection.id,
            ),
            patch.object(ResearchHistoryIndexer, "CONVERT_BATCH_SIZE", 2),
            patch.object(session, "query", side_effect=spy_query),
        ):
            result = indexer.convert_all_research(force=False)

        assert result["converted"] == 5
        assert result["skipped"] == 0
        assert result["failed"] == 0
        assert len(session.query(Document).all()) == 5

        # (1) The candidate enumeration must project the id column, not the
        # full ResearchHistory entity (identity check — column __eq__ builds a
        # clause, so `in`/`==` are unsafe). Exactly one such fetch.
        id_fetches = [
            a for a in query_calls if len(a) == 1 and a[0] is ResearchHistory.id
        ]
        assert len(id_fetches) == 1, (
            "candidate fetch must project ResearchHistory.id exactly once"
        )

        # (2) Full report rows must be loaded in bounded batches, not all at
        # once. convert_all_research issues one full-entity query(ResearchHistory)
        # for the eligibility count plus one per batch. With 5 entries and
        # CONVERT_BATCH_SIZE=2 that is 1 + ceil(5/2) = 4. A regression that
        # collapses the loop into a single query(ResearchHistory).filter(
        # id.in_(all_ids)).all() would issue only 1 + 1 = 2 and fail here.
        expected_full_entity_queries = 1 + math.ceil(5 / 2)
        full_entity_queries = [
            a for a in query_calls if len(a) == 1 and a[0] is ResearchHistory
        ]
        assert len(full_entity_queries) == expected_full_entity_queries, (
            f"expected {expected_full_entity_queries} full-entity "
            f"ResearchHistory queries (1 count + 3 batches), "
            f"got {len(full_entity_queries)}"
        )

    def test_skips_already_converted_when_force_false(
        self,
        indexer,
        mock_session_ctx,
        sample_research,
        research_source_types,
        research_collection,
    ):
        """When force=False, entries that already have a report Document are excluded."""
        # First pass — convert sample_research
        with patch.object(
            indexer,
            "get_or_create_collection",
            return_value=research_collection.id,
        ):
            result1 = indexer.convert_all_research(force=False)

        assert result1["converted"] == 1

        # Add a second research entry
        r2 = ResearchHistory(
            id=str(uuid.uuid4()),
            query="New query",
            mode="detailed_report",
            status="completed",
            created_at="2025-04-01T10:00:00",
            report_content="# New report content that is unique.",
            title="New Research",
        )
        mock_session_ctx.add(r2)
        mock_session_ctx.commit()

        # Second pass — only the new entry should be converted
        with patch.object(
            indexer,
            "get_or_create_collection",
            return_value=research_collection.id,
        ):
            result2 = indexer.convert_all_research(force=False)

        assert result2["converted"] == 1  # only r2
        assert result2["failed"] == 0

        # Total Documents: 1 (sample_research report) + 1 (r2 report) = 2
        all_docs = mock_session_ctx.query(Document).all()
        assert len(all_docs) == 2

    def test_force_true_reconverts_all(
        self,
        indexer,
        mock_session_ctx,
        sample_research,
        research_source_types,
        research_collection,
    ):
        """When force=True, all entries are processed even if already converted."""
        with patch.object(
            indexer,
            "get_or_create_collection",
            return_value=research_collection.id,
        ):
            result1 = indexer.convert_all_research(force=False)
        assert result1["converted"] == 1

        with patch.object(
            indexer,
            "get_or_create_collection",
            return_value=research_collection.id,
        ):
            result2 = indexer.convert_all_research(force=True)

        # The entry is re-processed (document reuse via hash — no duplicate created)
        assert result2["converted"] == 1
        assert result2["failed"] == 0

    def test_returns_early_when_source_type_missing(
        self,
        indexer,
        mock_session_ctx,
        research_collection,
    ):
        """If research_report SourceType doesn't exist, return early with zeros."""
        # No source types seeded in this test
        with patch.object(
            indexer,
            "get_or_create_collection",
            return_value=research_collection.id,
        ):
            result = indexer.convert_all_research(force=False)

        assert result["converted"] == 0
        assert result["skipped"] == 0
        assert result["failed"] == 0
        assert result["collection_id"] == research_collection.id

    def test_converts_report_only_no_sources(
        self,
        indexer,
        mock_session_ctx,
        sample_research,
        sample_resource,
        research_source_types,
        research_collection,
    ):
        """Only research reports are converted, not individual sources."""
        with patch.object(
            indexer,
            "get_or_create_collection",
            return_value=research_collection.id,
        ):
            result = indexer.convert_all_research(force=False)

        assert result["converted"] == 1
        # Only 1 report document, no source documents
        all_docs = mock_session_ctx.query(Document).all()
        assert len(all_docs) == 1
