"""
Comprehensive coverage tests for rag_routes.py.

Exercises route handlers, helper functions, and edge cases with
precise assertions on response bodies, status codes, and mock interactions.
"""

from contextlib import contextmanager
from datetime import datetime, UTC
from io import BytesIO
from unittest.mock import MagicMock, Mock, patch

import pytest
from flask import Flask, jsonify

from local_deep_research.constants import (
    DEFAULT_LOCAL_SEARCH_TEXT_SEPARATORS_JSON,
)
from local_deep_research.web.auth.routes import auth_bp
from local_deep_research.research_library.routes.rag_routes import rag_bp

# Module path shorthand for patching
_ROUTES = "local_deep_research.research_library.routes.rag_routes"
_FACTORY = "local_deep_research.research_library.services.rag_service_factory"

# Source module paths for function-local imports
_DB_CTX = "local_deep_research.database.session_context"
_DB_INIT = "local_deep_research.database.library_init"
_DB_THREAD = "local_deep_research.database.thread_local_session"
_DB_PASS = "local_deep_research.database.session_passwords"
_DOC_LOADERS = "local_deep_research.document_loaders"
_EMBEDDINGS = "local_deep_research.embeddings.embeddings_config"
_TEXT_PROC = "local_deep_research.text_processing"
_DEL_SVC = (
    "local_deep_research.research_library.deletion.services.collection_deletion"
)


# ---------------------------------------------------------------------------
# Test infrastructure
# ---------------------------------------------------------------------------


def _create_app():
    """Minimal Flask app with rag blueprint registered."""
    app = Flask(__name__)
    app.config["SECRET_KEY"] = "test-secret"
    app.config["WTF_CSRF_ENABLED"] = False
    app.config["TESTING"] = True
    app.register_blueprint(auth_bp)
    app.register_blueprint(rag_bp)

    @app.errorhandler(500)
    def _handle_500(error):
        return jsonify({"error": "Internal server error"}), 500

    return app


def _mock_db_manager():
    """Mock db_manager so login_required passes."""
    mock_db = Mock()
    mock_db.is_user_connected.return_value = True
    mock_db.connections = {"testuser": True}
    mock_db.has_encryption = False
    return mock_db


def _build_mock_query(
    all_result=None,
    first_result=None,
    count_result=0,
):
    """Build a chainable mock query."""
    q = Mock()
    q.all.return_value = all_result if all_result is not None else []
    q.first.return_value = first_result
    q.count.return_value = count_result
    q.filter_by.return_value = q
    q.filter.return_value = q
    q.order_by.return_value = q
    q.outerjoin.return_value = q
    q.join.return_value = q
    q.limit.return_value = q
    q.offset.return_value = q
    q.delete.return_value = 0
    q.update.return_value = 0
    return q


def _make_settings_mock(overrides=None):
    """Create a mock settings manager with defaults."""
    mock_sm = Mock()
    defaults = {
        "local_search_embedding_model": "all-MiniLM-L6-v2",
        "local_search_embedding_provider": "sentence_transformers",
        "local_search_chunk_size": 1000,
        "local_search_chunk_overlap": 200,
        "local_search_splitter_type": "recursive",
        "local_search_text_separators": DEFAULT_LOCAL_SEARCH_TEXT_SEPARATORS_JSON,
        "local_search_distance_metric": "cosine",
        "local_search_normalize_vectors": True,
        "local_search_index_type": "flat",
        "research_library.upload_pdf_storage": "none",
        "research_library.storage_path": "/tmp/test_lib",
        "rag.indexing_batch_size": 15,
        "research_library.auto_index_enabled": True,
    }
    if overrides:
        defaults.update(overrides)
    mock_sm.get_setting.side_effect = lambda k, d=None: defaults.get(k, d)
    mock_sm.get_bool_setting.side_effect = lambda k, d=None: defaults.get(k, d)
    mock_sm.get_all_settings.return_value = {}
    mock_sm.set_setting = Mock()
    mock_sm.get_settings_snapshot.return_value = {}
    return mock_sm


def _make_db_session():
    """Create a standard mock db session."""
    db_session = Mock()
    db_session.query = Mock(return_value=_build_mock_query())
    db_session.commit = Mock()
    db_session.add = Mock()
    db_session.flush = Mock()
    db_session.expire_all = Mock()
    return db_session


@contextmanager
def _auth_client(
    app,
    mock_db_session=None,
    settings_overrides=None,
    extra_patches=None,
):
    """
    Context manager providing an authenticated test client with mocking.
    """
    mock_db = _mock_db_manager()
    db_session = mock_db_session or _make_db_session()
    mock_sm = _make_settings_mock(settings_overrides)

    @contextmanager
    def fake_get_user_db_session(*a, **kw):
        yield db_session

    patches = [
        patch("local_deep_research.web.auth.decorators.db_manager", mock_db),
        # Patch at source for function-local imports
        patch(
            f"{_DB_CTX}.get_user_db_session",
            side_effect=fake_get_user_db_session,
        ),
        patch(f"{_ROUTES}.get_settings_manager", return_value=mock_sm),
        patch(
            "local_deep_research.utilities.db_utils.get_settings_manager",
            return_value=mock_sm,
        ),
        # Disable rate limiter
        patch(f"{_ROUTES}.limiter", Mock(exempt=lambda f: f)),
        patch(f"{_ROUTES}.upload_rate_limit_user", lambda f: f),
        patch(f"{_ROUTES}.upload_rate_limit_ip", lambda f: f),
    ]
    if extra_patches:
        patches.extend(extra_patches)

    started = []
    try:
        for p in patches:
            started.append(p.start())
        with app.test_client() as client:
            with client.session_transaction() as sess:
                sess["username"] = "testuser"
                sess["session_id"] = "test-session-id"
            yield (
                client,
                {
                    "db_session": db_session,
                    "settings": mock_sm,
                },
            )
    finally:
        for p in patches:
            p.stop()


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


@pytest.fixture
def app():
    return _create_app()


# ---------------------------------------------------------------------------
# Tests: get_supported_formats
# ---------------------------------------------------------------------------


class TestGetSupportedFormats:
    def test_returns_sorted_extensions(self, app):
        with _auth_client(
            app,
            extra_patches=[
                patch(
                    f"{_DOC_LOADERS}.get_supported_extensions",
                    return_value=[".pdf", ".txt", ".md"],
                ),
            ],
        ) as (client, ctx):
            resp = client.get("/library/api/config/supported-formats")
            assert resp.status_code == 200
            data = resp.get_json()
            assert data["extensions"] == [".md", ".pdf", ".txt"]
            assert data["count"] == 3
            assert ".md,.pdf,.txt" == data["accept_string"]


# ---------------------------------------------------------------------------
# Tests: Page routes
# ---------------------------------------------------------------------------


class TestPageRoutes:
    def test_embedding_settings_page(self, app):
        with _auth_client(
            app,
            extra_patches=[
                patch(
                    f"{_ROUTES}.render_template", return_value="<html>ok</html>"
                ),
            ],
        ) as (client, ctx):
            resp = client.get("/library/embedding-settings")
            assert resp.status_code == 200

    def test_collections_page(self, app):
        with _auth_client(
            app,
            extra_patches=[
                patch(
                    f"{_ROUTES}.render_template", return_value="<html>ok</html>"
                ),
            ],
        ) as (client, ctx):
            resp = client.get("/library/collections")
            assert resp.status_code == 200

    def test_collection_details_page(self, app):
        with _auth_client(
            app,
            extra_patches=[
                patch(
                    f"{_ROUTES}.render_template", return_value="<html>ok</html>"
                ),
            ],
        ) as (client, ctx):
            resp = client.get("/library/collections/coll-123")
            assert resp.status_code == 200

    def test_collection_upload_page_default_storage(self, app):
        with _auth_client(
            app,
            extra_patches=[
                patch(
                    f"{_ROUTES}.render_template", return_value="<html>ok</html>"
                ),
            ],
        ) as (client, ctx):
            resp = client.get("/library/collections/coll-123/upload")
            assert resp.status_code == 200

    def test_collection_upload_page_invalid_storage_falls_to_none(self, app):
        with _auth_client(
            app,
            settings_overrides={
                "research_library.upload_pdf_storage": "filesystem"
            },
            extra_patches=[
                patch(
                    f"{_ROUTES}.render_template", return_value="<html>ok</html>"
                ),
            ],
        ) as (client, ctx):
            resp = client.get("/library/collections/coll-123/upload")
            assert resp.status_code == 200

    def test_collection_create_page(self, app):
        with _auth_client(
            app,
            extra_patches=[
                patch(
                    f"{_ROUTES}.render_template", return_value="<html>ok</html>"
                ),
            ],
        ) as (client, ctx):
            resp = client.get("/library/collections/create")
            assert resp.status_code == 200

    def test_view_document_chunks_not_found(self, app):
        db_session = _make_db_session()
        q = _build_mock_query(first_result=None)
        db_session.query = Mock(return_value=q)

        with _auth_client(app, mock_db_session=db_session) as (client, ctx):
            resp = client.get("/library/document/doc-123/chunks")
            assert resp.status_code == 404

    def test_view_document_chunks_found(self, app):
        mock_doc = Mock()
        mock_doc.id = "doc-123"
        mock_doc.title = "Test Doc"

        mock_chunk = Mock()
        mock_chunk.id = "chunk-1"
        mock_chunk.source_id = "doc-123"
        mock_chunk.collection_name = "collection_coll-1"
        mock_chunk.chunk_index = 0
        mock_chunk.chunk_text = "Hello world"
        mock_chunk.word_count = 2
        mock_chunk.start_char = 0
        mock_chunk.end_char = 11
        mock_chunk.embedding_model = "test-model"
        mock_chunk.embedding_model_type = Mock(value="sentence_transformers")
        mock_chunk.embedding_dimension = 384
        mock_chunk.created_at = datetime(2024, 1, 1, tzinfo=UTC)

        mock_collection = Mock()
        mock_collection.name = "Test Collection"

        db_session = _make_db_session()
        call_count = [0]

        def query_side_effect(model, *args):
            call_count[0] += 1
            q = _build_mock_query()
            if call_count[0] == 1:
                q.first.return_value = mock_doc
            elif call_count[0] == 2:
                q2 = _build_mock_query()
                q.filter.return_value = q2
                q2.order_by.return_value = q2
                q2.all.return_value = [mock_chunk]
            elif call_count[0] == 3:
                q.first.return_value = mock_collection
            return q

        db_session.query = Mock(side_effect=query_side_effect)

        with _auth_client(
            app,
            mock_db_session=db_session,
            extra_patches=[
                patch(
                    f"{_ROUTES}.render_template", return_value="<html>ok</html>"
                ),
            ],
        ) as (client, ctx):
            resp = client.get("/library/document/doc-123/chunks")
            assert resp.status_code == 200


# ---------------------------------------------------------------------------
# Tests: GET /api/rag/settings
# ---------------------------------------------------------------------------


class TestGetCurrentSettings:
    def test_success(self, app):
        with _auth_client(app) as (client, ctx):
            resp = client.get("/library/api/rag/settings")
            assert resp.status_code == 200
            data = resp.get_json()
            assert data["success"] is True
            assert "settings" in data
            assert data["settings"]["embedding_model"] == "all-MiniLM-L6-v2"

    def test_error_handling(self, app):
        mock_resp = Mock()
        mock_resp.status_code = 500
        mock_resp.get_json.return_value = {"success": False}

        with _auth_client(app) as (client, ctx):
            ctx["settings"].get_setting.side_effect = RuntimeError("boom")
            resp = client.get("/library/api/rag/settings")
            assert resp.status_code == 500


# ---------------------------------------------------------------------------
# Tests: POST /api/rag/test-embedding
# ---------------------------------------------------------------------------


class TestTestEmbedding:
    def test_missing_provider_model(self, app):
        with _auth_client(app) as (client, ctx):
            resp = client.post(
                "/library/api/rag/test-embedding",
                json={"provider": "", "model": ""},
            )
            assert resp.status_code == 400

    def test_no_json_body(self, app):
        with _auth_client(app) as (client, ctx):
            resp = client.post(
                "/library/api/rag/test-embedding",
                data="not json",
                content_type="text/plain",
            )
            assert resp.status_code == 400

    def test_success(self, app):
        # get_embedding_function returns a callable; that callable returns list of embeddings
        inner_func = Mock(return_value=[[0.1, 0.2, 0.3]])
        mock_get_ef = Mock(return_value=inner_func)
        with _auth_client(
            app,
            extra_patches=[
                patch(f"{_EMBEDDINGS}.get_embedding_function", mock_get_ef),
            ],
        ) as (client, ctx):
            resp = client.post(
                "/library/api/rag/test-embedding",
                json={
                    "provider": "sentence_transformers",
                    "model": "test-model",
                },
            )
            assert resp.status_code == 200
            data = resp.get_json()
            assert data["success"] is True
            assert data["dimension"] == 3

    def test_builtin_runtime_error_is_not_flagged_as_ldr_bug(self, app):
        """A bare builtin (RuntimeError/KeyError/TypeError/...) is NOT an
        LDR bug: ``type(exc).__module__`` is where the class is defined,
        not where it was raised, so a builtin can just as easily escape
        provider/langchain code. It must fall through to a verbatim
        message, not be labeled 'report it on GitHub'. Only exceptions
        whose class lives under ``local_deep_research`` are internal.
        See #4208 and the follow-up polish."""
        with _auth_client(
            app,
            extra_patches=[
                patch(
                    f"{_EMBEDDINGS}.get_embedding_function",
                    side_effect=RuntimeError("does not support embedding"),
                ),
            ],
        ) as (client, ctx):
            resp = client.post(
                "/library/api/rag/test-embedding",
                json={"provider": "ollama", "model": "llama3"},
            )
            assert resp.status_code == 500
            data = resp.get_json()
            assert data["success"] is False
            assert "internal LDR error" not in data["error"]
            assert "report it on GitHub" not in data["error"]
            # The real cause is still surfaced verbatim.
            assert "does not support embedding" in data["error"]

    def test_builtin_error_message_is_surfaced_verbatim(self, app):
        """A builtin error's original message is shown verbatim, with no
        'try a dedicated embedding model' guessing."""
        with _auth_client(
            app,
            extra_patches=[
                patch(
                    f"{_EMBEDDINGS}.get_embedding_function",
                    side_effect=RuntimeError("network timeout"),
                ),
            ],
        ) as (client, ctx):
            resp = client.post(
                "/library/api/rag/test-embedding",
                json={"provider": "ollama", "model": "embed-model"},
            )
            assert resp.status_code == 500
            data = resp.get_json()
            assert data["success"] is False
            # No more "try a dedicated embedding model" guessing.
            assert "dedicated embedding model" not in data["error"]
            assert "network timeout" in data["error"]


# ---------------------------------------------------------------------------
# Tests: GET /api/rag/models
# ---------------------------------------------------------------------------


class TestGetAvailableModels:
    def test_success_with_available_provider(self, app):
        mock_provider_class = Mock()
        mock_provider_class.is_available.return_value = True
        mock_provider_class.get_available_models.return_value = [
            {"value": "model-1", "label": "Model 1", "is_embedding": True}
        ]
        mock_classes = {"sentence_transformers": mock_provider_class}

        with _auth_client(
            app,
            extra_patches=[
                patch(
                    f"{_EMBEDDINGS}._get_provider_classes",
                    return_value=mock_classes,
                ),
            ],
        ) as (client, ctx):
            resp = client.get("/library/api/rag/models")
            assert resp.status_code == 200
            data = resp.get_json()
            assert data["success"] is True
            assert len(data["provider_options"]) == 1
            assert (
                data["providers"]["sentence_transformers"][0]["is_embedding"]
                is True
            )

    def test_unavailable_provider(self, app):
        mock_provider_class = Mock()
        mock_provider_class.is_available.return_value = False
        mock_classes = {"ollama": mock_provider_class}

        with _auth_client(
            app,
            extra_patches=[
                patch(
                    f"{_EMBEDDINGS}._get_provider_classes",
                    return_value=mock_classes,
                ),
            ],
        ) as (client, ctx):
            resp = client.get("/library/api/rag/models")
            assert resp.status_code == 200
            data = resp.get_json()
            assert data["providers"]["ollama"] == []

    def test_error_handling(self, app):
        with _auth_client(
            app,
            extra_patches=[
                patch(
                    f"{_EMBEDDINGS}._get_provider_classes",
                    side_effect=RuntimeError("boom"),
                ),
            ],
        ) as (client, ctx):
            resp = client.get("/library/api/rag/models")
            assert resp.status_code == 500


# ---------------------------------------------------------------------------
# Tests: GET /api/rag/info
# ---------------------------------------------------------------------------


class TestGetIndexInfo:
    def test_with_index(self, app):
        mock_rag = MagicMock()
        # rag_routes now uses ``with get_rag_service(...) as rag_service:``
        # MagicMock's default __enter__ returns a child mock, so route body
        # would see a different object. Pin __enter__ to self.
        mock_rag.__enter__.return_value = mock_rag
        mock_rag.get_current_index_info.return_value = {"total_chunks": 10}

        with _auth_client(
            app,
            extra_patches=[
                patch(f"{_ROUTES}.get_rag_service", return_value=mock_rag),
                patch(
                    f"{_DB_INIT}.get_default_library_id",
                    return_value="default-lib",
                ),
            ],
        ) as (client, ctx):
            resp = client.get("/library/api/rag/info")
            assert resp.status_code == 200
            data = resp.get_json()
            assert data["success"] is True
            assert data["info"]["total_chunks"] == 10

    def test_no_index(self, app):
        mock_rag = MagicMock()
        # rag_routes now uses ``with get_rag_service(...) as rag_service:``
        # MagicMock's default __enter__ returns a child mock, so route body
        # would see a different object. Pin __enter__ to self.
        mock_rag.__enter__.return_value = mock_rag
        mock_rag.get_current_index_info.return_value = None

        with _auth_client(
            app,
            extra_patches=[
                patch(f"{_ROUTES}.get_rag_service", return_value=mock_rag),
                patch(
                    f"{_DB_INIT}.get_default_library_id",
                    return_value="default-lib",
                ),
            ],
        ) as (client, ctx):
            resp = client.get("/library/api/rag/info")
            assert resp.status_code == 200
            data = resp.get_json()
            assert data["info"] is None

    def test_with_collection_id(self, app):
        mock_rag = MagicMock()
        # rag_routes now uses ``with get_rag_service(...) as rag_service:``
        # MagicMock's default __enter__ returns a child mock, so route body
        # would see a different object. Pin __enter__ to self.
        mock_rag.__enter__.return_value = mock_rag
        mock_rag.get_current_index_info.return_value = {"total_chunks": 5}

        with _auth_client(
            app,
            extra_patches=[
                patch(f"{_ROUTES}.get_rag_service", return_value=mock_rag),
            ],
        ) as (client, ctx):
            resp = client.get("/library/api/rag/info?collection_id=coll-1")
            assert resp.status_code == 200
            data = resp.get_json()
            assert data["success"] is True


# ---------------------------------------------------------------------------
# Tests: GET /api/rag/stats
# ---------------------------------------------------------------------------


class TestGetRagStats:
    def test_success(self, app):
        mock_rag = MagicMock()
        # rag_routes now uses ``with get_rag_service(...) as rag_service:``
        # MagicMock's default __enter__ returns a child mock, so route body
        # would see a different object. Pin __enter__ to self.
        mock_rag.__enter__.return_value = mock_rag
        mock_rag.get_rag_stats.return_value = {"indexed": 10, "total": 20}

        with _auth_client(
            app,
            extra_patches=[
                patch(f"{_ROUTES}.get_rag_service", return_value=mock_rag),
                patch(
                    f"{_DB_INIT}.get_default_library_id",
                    return_value="default-lib",
                ),
            ],
        ) as (client, ctx):
            resp = client.get("/library/api/rag/stats")
            assert resp.status_code == 200
            data = resp.get_json()
            assert data["success"] is True
            assert data["stats"]["indexed"] == 10


# ---------------------------------------------------------------------------
# Tests: POST /api/rag/index-document
# ---------------------------------------------------------------------------


class TestIndexDocument:
    def test_missing_text_doc_id(self, app):
        with _auth_client(
            app,
            extra_patches=[
                patch(
                    f"{_DB_INIT}.get_default_library_id",
                    return_value="default-lib",
                ),
            ],
        ) as (client, ctx):
            resp = client.post(
                "/library/api/rag/index-document",
                json={"force_reindex": False},
            )
            assert resp.status_code == 400

    def test_success(self, app):
        mock_rag = MagicMock()
        # rag_routes now uses ``with get_rag_service(...) as rag_service:``
        # MagicMock's default __enter__ returns a child mock, so route body
        # would see a different object. Pin __enter__ to self.
        mock_rag.__enter__.return_value = mock_rag
        mock_rag.index_document.return_value = {
            "status": "success",
            "chunks": 5,
        }

        with _auth_client(
            app,
            extra_patches=[
                patch(f"{_ROUTES}.get_rag_service", return_value=mock_rag),
                patch(
                    f"{_DB_INIT}.get_default_library_id",
                    return_value="default-lib",
                ),
            ],
        ) as (client, ctx):
            resp = client.post(
                "/library/api/rag/index-document",
                json={"text_doc_id": "doc-1"},
            )
            assert resp.status_code == 200
            data = resp.get_json()
            assert data["success"] is True

    def test_error_result(self, app):
        mock_rag = MagicMock()
        # rag_routes now uses ``with get_rag_service(...) as rag_service:``
        # MagicMock's default __enter__ returns a child mock, so route body
        # would see a different object. Pin __enter__ to self.
        mock_rag.__enter__.return_value = mock_rag
        mock_rag.index_document.return_value = {
            "status": "error",
            "error": "No text",
        }

        with _auth_client(
            app,
            extra_patches=[
                patch(f"{_ROUTES}.get_rag_service", return_value=mock_rag),
                patch(
                    f"{_DB_INIT}.get_default_library_id",
                    return_value="default-lib",
                ),
            ],
        ) as (client, ctx):
            resp = client.post(
                "/library/api/rag/index-document",
                json={"text_doc_id": "doc-1"},
            )
            assert resp.status_code == 400

    def test_with_collection_id(self, app):
        mock_rag = MagicMock()
        # rag_routes now uses ``with get_rag_service(...) as rag_service:``
        # MagicMock's default __enter__ returns a child mock, so route body
        # would see a different object. Pin __enter__ to self.
        mock_rag.__enter__.return_value = mock_rag
        mock_rag.index_document.return_value = {"status": "success"}

        with _auth_client(
            app,
            extra_patches=[
                patch(f"{_ROUTES}.get_rag_service", return_value=mock_rag),
            ],
        ) as (client, ctx):
            resp = client.post(
                "/library/api/rag/index-document",
                json={"text_doc_id": "doc-1", "collection_id": "coll-1"},
            )
            assert resp.status_code == 200


# ---------------------------------------------------------------------------
# Tests: POST /api/rag/remove-document
# ---------------------------------------------------------------------------


class TestRemoveDocument:
    def test_missing_text_doc_id(self, app):
        with _auth_client(
            app,
            extra_patches=[
                patch(
                    f"{_DB_INIT}.get_default_library_id",
                    return_value="default-lib",
                ),
            ],
        ) as (client, ctx):
            resp = client.post("/library/api/rag/remove-document", json={})
            assert resp.status_code == 400

    def test_success(self, app):
        mock_rag = MagicMock()
        # rag_routes now uses ``with get_rag_service(...) as rag_service:``
        # MagicMock's default __enter__ returns a child mock, so route body
        # would see a different object. Pin __enter__ to self.
        mock_rag.__enter__.return_value = mock_rag
        mock_rag.remove_document_from_rag.return_value = {"status": "success"}

        with _auth_client(
            app,
            extra_patches=[
                patch(f"{_ROUTES}.get_rag_service", return_value=mock_rag),
                patch(
                    f"{_DB_INIT}.get_default_library_id",
                    return_value="default-lib",
                ),
            ],
        ) as (client, ctx):
            resp = client.post(
                "/library/api/rag/remove-document",
                json={"text_doc_id": "doc-1"},
            )
            assert resp.status_code == 200
            data = resp.get_json()
            assert data["success"] is True

    def test_error_result(self, app):
        mock_rag = MagicMock()
        # rag_routes now uses ``with get_rag_service(...) as rag_service:``
        # MagicMock's default __enter__ returns a child mock, so route body
        # would see a different object. Pin __enter__ to self.
        mock_rag.__enter__.return_value = mock_rag
        mock_rag.remove_document_from_rag.return_value = {
            "status": "error",
            "error": "not found",
        }

        with _auth_client(
            app,
            extra_patches=[
                patch(f"{_ROUTES}.get_rag_service", return_value=mock_rag),
                patch(
                    f"{_DB_INIT}.get_default_library_id",
                    return_value="default-lib",
                ),
            ],
        ) as (client, ctx):
            resp = client.post(
                "/library/api/rag/remove-document",
                json={"text_doc_id": "doc-1"},
            )
            assert resp.status_code == 400


# ---------------------------------------------------------------------------
# Tests: POST /api/rag/index-research
# ---------------------------------------------------------------------------


class TestIndexResearch:
    def test_missing_research_id(self, app):
        with _auth_client(app) as (client, ctx):
            resp = client.post("/library/api/rag/index-research", json={})
            assert resp.status_code == 400

    def test_success(self, app):
        mock_rag = MagicMock()
        # rag_routes now uses ``with get_rag_service(...) as rag_service:``
        # MagicMock's default __enter__ returns a child mock, so route body
        # would see a different object. Pin __enter__ to self.
        mock_rag.__enter__.return_value = mock_rag
        mock_rag.index_research_documents.return_value = [{"status": "success"}]

        with _auth_client(
            app,
            extra_patches=[
                patch(f"{_ROUTES}.get_rag_service", return_value=mock_rag),
            ],
        ) as (client, ctx):
            resp = client.post(
                "/library/api/rag/index-research",
                json={"research_id": "research-1"},
            )
            assert resp.status_code == 200
            data = resp.get_json()
            assert data["success"] is True


# ---------------------------------------------------------------------------
# Tests: POST /api/rag/configure
# ---------------------------------------------------------------------------


class TestConfigureRag:
    def test_missing_params(self, app):
        with _auth_client(app) as (client, ctx):
            resp = client.post(
                "/library/api/rag/configure",
                json={"embedding_model": "test"},
            )
            assert resp.status_code == 400

    def test_success_no_collection(self, app):
        with _auth_client(app) as (client, ctx):
            resp = client.post(
                "/library/api/rag/configure",
                json={
                    "embedding_model": "test-model",
                    "embedding_provider": "sentence_transformers",
                    "chunk_size": 500,
                    "chunk_overlap": 100,
                },
            )
            assert resp.status_code == 200
            data = resp.get_json()
            assert data["success"] is True
            assert "Default embedding settings" in data["message"]

    def test_success_with_collection(self, app):
        mock_rag_service = Mock()
        mock_rag_service.__enter__ = Mock(return_value=mock_rag_service)
        mock_rag_service.__exit__ = Mock(return_value=False)
        mock_rag_index = Mock()
        mock_rag_index.index_hash = "hash123"
        mock_rag_service._get_or_create_rag_index.return_value = mock_rag_index

        with _auth_client(
            app,
            extra_patches=[
                patch(
                    f"{_ROUTES}.LibraryRAGService",
                    return_value=mock_rag_service,
                ),
            ],
        ) as (client, ctx):
            resp = client.post(
                "/library/api/rag/configure",
                json={
                    "embedding_model": "test-model",
                    "embedding_provider": "sentence_transformers",
                    "chunk_size": 500,
                    "chunk_overlap": 100,
                    "collection_id": "coll-1",
                    "text_separators": ["\n\n", "\n"],
                },
            )
            assert resp.status_code == 200
            data = resp.get_json()
            assert data["success"] is True
            assert data["index_hash"] == "hash123"

    def test_text_separators_as_string(self, app):
        """Test configure with text_separators as string (not list)."""
        mock_rag_service = Mock()
        mock_rag_service.__enter__ = Mock(return_value=mock_rag_service)
        mock_rag_service.__exit__ = Mock(return_value=False)
        mock_rag_index = Mock()
        mock_rag_index.index_hash = "hash456"
        mock_rag_service._get_or_create_rag_index.return_value = mock_rag_index

        with _auth_client(
            app,
            extra_patches=[
                patch(
                    f"{_ROUTES}.LibraryRAGService",
                    return_value=mock_rag_service,
                ),
            ],
        ) as (client, ctx):
            resp = client.post(
                "/library/api/rag/configure",
                json={
                    "embedding_model": "test-model",
                    "embedding_provider": "sentence_transformers",
                    "chunk_size": 500,
                    "chunk_overlap": 100,
                    "collection_id": "coll-1",
                    "text_separators": '["\\n"]',
                },
            )
            assert resp.status_code == 200


# ---------------------------------------------------------------------------
# Tests: GET /api/rag/documents
# ---------------------------------------------------------------------------


class TestGetDocuments:
    def test_success_default_params(self, app):
        mock_doc = Mock()
        mock_doc.id = "doc-1"
        mock_doc.title = "Test Doc"
        mock_doc.original_url = "http://example.com"
        mock_doc.created_at = datetime(2024, 1, 1, tzinfo=UTC)

        mock_dc = Mock()
        mock_rag_status = Mock()
        mock_rag_status.chunk_count = 5

        db_session = _make_db_session()
        q = _build_mock_query()
        q.all.return_value = [(mock_doc, mock_dc, mock_rag_status)]
        q.count.return_value = 1
        db_session.query = Mock(return_value=q)

        with _auth_client(
            app,
            mock_db_session=db_session,
            extra_patches=[
                patch(
                    f"{_DB_INIT}.get_default_library_id",
                    return_value="default-lib",
                ),
            ],
        ) as (client, ctx):
            resp = client.get("/library/api/rag/documents")
            assert resp.status_code == 200
            data = resp.get_json()
            assert data["success"] is True
            assert len(data["documents"]) == 1
            assert data["documents"][0]["rag_indexed"] is True
            assert data["pagination"]["page"] == 1

    def test_filter_indexed(self, app):
        db_session = _make_db_session()
        q = _build_mock_query()
        q.all.return_value = []
        q.count.return_value = 0
        db_session.query = Mock(return_value=q)

        with _auth_client(
            app,
            mock_db_session=db_session,
            extra_patches=[
                patch(
                    f"{_DB_INIT}.get_default_library_id",
                    return_value="default-lib",
                ),
            ],
        ) as (client, ctx):
            resp = client.get("/library/api/rag/documents?filter=indexed")
            assert resp.status_code == 200

    def test_filter_unindexed(self, app):
        db_session = _make_db_session()
        q = _build_mock_query()
        q.all.return_value = []
        q.count.return_value = 0
        db_session.query = Mock(return_value=q)

        with _auth_client(
            app,
            mock_db_session=db_session,
            extra_patches=[
                patch(
                    f"{_DB_INIT}.get_default_library_id",
                    return_value="default-lib",
                ),
            ],
        ) as (client, ctx):
            resp = client.get("/library/api/rag/documents?filter=unindexed")
            assert resp.status_code == 200

    def test_with_collection_id_param(self, app):
        db_session = _make_db_session()
        q = _build_mock_query()
        q.all.return_value = []
        q.count.return_value = 0
        db_session.query = Mock(return_value=q)

        with _auth_client(app, mock_db_session=db_session) as (client, ctx):
            resp = client.get("/library/api/rag/documents?collection_id=coll-1")
            assert resp.status_code == 200

    def test_doc_without_created_at(self, app):
        mock_doc = Mock()
        mock_doc.id = "doc-1"
        mock_doc.title = "Test"
        mock_doc.original_url = None
        mock_doc.created_at = None

        db_session = _make_db_session()
        q = _build_mock_query()
        q.all.return_value = [(mock_doc, Mock(), None)]
        q.count.return_value = 1
        db_session.query = Mock(return_value=q)

        with _auth_client(
            app,
            mock_db_session=db_session,
            extra_patches=[
                patch(
                    f"{_DB_INIT}.get_default_library_id",
                    return_value="default-lib",
                ),
            ],
        ) as (client, ctx):
            resp = client.get("/library/api/rag/documents")
            assert resp.status_code == 200
            data = resp.get_json()
            assert data["documents"][0]["rag_indexed"] is False
            assert data["documents"][0]["created_at"] is None


# ---------------------------------------------------------------------------
# Tests: GET /api/collections
# ---------------------------------------------------------------------------


class TestGetCollections:
    def test_success_no_embedding(self, app):
        mock_coll = Mock()
        mock_coll.id = "coll-1"
        mock_coll.name = "Test Collection"
        mock_coll.description = "A test"
        mock_coll.created_at = datetime(2024, 1, 1, tzinfo=UTC)
        mock_coll.collection_type = "user_uploads"
        mock_coll.is_default = False
        mock_coll.document_links = [Mock()]
        mock_coll.linked_folders = []
        mock_coll.embedding_model = None

        db_session = _make_db_session()
        q = _build_mock_query(all_result=[mock_coll])
        db_session.query = Mock(return_value=q)

        with _auth_client(app, mock_db_session=db_session) as (client, ctx):
            resp = client.get("/library/api/collections")
            assert resp.status_code == 200
            data = resp.get_json()
            assert data["success"] is True
            assert len(data["collections"]) == 1
            assert data["collections"][0]["embedding"] is None

    def test_collection_with_embedding(self, app):
        mock_coll = Mock()
        mock_coll.id = "coll-1"
        mock_coll.name = "Embedded"
        mock_coll.description = ""
        mock_coll.created_at = datetime(2024, 1, 1, tzinfo=UTC)
        mock_coll.collection_type = "user_uploads"
        mock_coll.is_default = True
        mock_coll.document_links = []
        mock_coll.linked_folders = []
        mock_coll.embedding_model = "test-model"
        mock_coll.embedding_model_type = Mock(value="sentence_transformers")
        mock_coll.embedding_dimension = 384
        mock_coll.chunk_size = 1000
        mock_coll.chunk_overlap = 200

        db_session = _make_db_session()
        q = _build_mock_query(all_result=[mock_coll])
        db_session.query = Mock(return_value=q)

        with _auth_client(app, mock_db_session=db_session) as (client, ctx):
            resp = client.get("/library/api/collections")
            assert resp.status_code == 200
            data = resp.get_json()
            emb = data["collections"][0]["embedding"]
            assert emb["model"] == "test-model"
            assert emb["dimension"] == 384

    def test_collection_created_at_none(self, app):
        mock_coll = Mock()
        mock_coll.id = "coll-1"
        mock_coll.name = "NoDate"
        mock_coll.description = ""
        mock_coll.created_at = None
        mock_coll.collection_type = "user_uploads"
        mock_coll.is_default = False
        mock_coll.document_links = []
        mock_coll.linked_folders = []
        mock_coll.embedding_model = None

        db_session = _make_db_session()
        q = _build_mock_query(all_result=[mock_coll])
        db_session.query = Mock(return_value=q)

        with _auth_client(app, mock_db_session=db_session) as (client, ctx):
            resp = client.get("/library/api/collections")
            assert resp.status_code == 200
            data = resp.get_json()
            assert data["collections"][0]["created_at"] is None


# ---------------------------------------------------------------------------
# Tests: POST /api/collections
# ---------------------------------------------------------------------------


class TestCreateCollection:
    def test_missing_name(self, app):
        with _auth_client(app) as (client, ctx):
            resp = client.post("/library/api/collections", json={"name": ""})
            assert resp.status_code == 400

    def test_duplicate_name(self, app):
        db_session = _make_db_session()
        q = _build_mock_query(first_result=Mock())
        db_session.query = Mock(return_value=q)

        with _auth_client(app, mock_db_session=db_session) as (client, ctx):
            resp = client.post(
                "/library/api/collections", json={"name": "Existing"}
            )
            assert resp.status_code == 400
            data = resp.get_json()
            assert "already exists" in data["error"]

    def test_success(self, app):
        db_session = _make_db_session()
        q = _build_mock_query(first_result=None)
        db_session.query = Mock(return_value=q)

        mock_collection = Mock()
        mock_collection.id = "new-coll-id"
        mock_collection.name = "New Collection"
        mock_collection.description = "desc"
        mock_collection.created_at = datetime(2024, 1, 1, tzinfo=UTC)
        mock_collection.collection_type = "user_uploads"

        with _auth_client(
            app,
            mock_db_session=db_session,
            extra_patches=[
                patch(f"{_ROUTES}.Collection", return_value=mock_collection),
            ],
        ) as (client, ctx):
            resp = client.post(
                "/library/api/collections",
                json={"name": "New Collection", "description": "desc"},
            )
            assert resp.status_code == 200
            data = resp.get_json()
            assert data["success"] is True


# ---------------------------------------------------------------------------
# Tests: PUT /api/collections/<id>
# ---------------------------------------------------------------------------


class TestUpdateCollection:
    def test_not_found(self, app):
        db_session = _make_db_session()
        q = _build_mock_query(first_result=None)
        db_session.query = Mock(return_value=q)

        with _auth_client(app, mock_db_session=db_session) as (client, ctx):
            resp = client.put(
                "/library/api/collections/coll-1",
                json={"name": "Updated"},
            )
            assert resp.status_code == 404

    def test_name_conflict(self, app):
        mock_coll = Mock()
        mock_coll.id = "coll-1"
        mock_coll.name = "Original"
        mock_coll.description = ""
        mock_coll.created_at = datetime(2024, 1, 1, tzinfo=UTC)
        mock_coll.collection_type = "user_uploads"

        db_session = _make_db_session()
        q = _build_mock_query(first_result=mock_coll)
        filter_q = Mock()
        filter_q.first.return_value = Mock()  # conflict
        q.filter.return_value = filter_q
        db_session.query = Mock(return_value=q)

        with _auth_client(app, mock_db_session=db_session) as (client, ctx):
            resp = client.put(
                "/library/api/collections/coll-1",
                json={"name": "Conflicting"},
            )
            assert resp.status_code == 400

    def test_success(self, app):
        mock_coll = Mock()
        mock_coll.id = "coll-1"
        mock_coll.name = "Original"
        mock_coll.description = ""
        mock_coll.created_at = datetime(2024, 1, 1, tzinfo=UTC)
        mock_coll.collection_type = "user_uploads"

        db_session = _make_db_session()
        q = _build_mock_query(first_result=mock_coll)
        filter_q = Mock()
        filter_q.first.return_value = None  # No conflict
        q.filter.return_value = filter_q
        db_session.query = Mock(return_value=q)

        with _auth_client(app, mock_db_session=db_session) as (client, ctx):
            resp = client.put(
                "/library/api/collections/coll-1",
                json={"name": "Updated Name", "description": "new desc"},
            )
            assert resp.status_code == 200
            data = resp.get_json()
            assert data["success"] is True

    def test_empty_name_skips_rename(self, app):
        """When name is empty string, only update description."""
        mock_coll = Mock()
        mock_coll.id = "coll-1"
        mock_coll.name = "Original"
        mock_coll.description = ""
        mock_coll.created_at = datetime(2024, 1, 1, tzinfo=UTC)
        mock_coll.collection_type = "user_uploads"

        db_session = _make_db_session()
        q = _build_mock_query(first_result=mock_coll)
        db_session.query = Mock(return_value=q)

        with _auth_client(app, mock_db_session=db_session) as (client, ctx):
            resp = client.put(
                "/library/api/collections/coll-1",
                json={"name": "", "description": "updated desc"},
            )
            assert resp.status_code == 200
            # Name should not have been changed
            assert mock_coll.name == "Original"


# ---------------------------------------------------------------------------
# Tests: DELETE /api/collections/<id>  -- moved to delete_routes tests
# (route removed from rag_routes.py, canonical endpoint in delete_routes.py)
# ---------------------------------------------------------------------------


# ---------------------------------------------------------------------------
# Tests: GET /api/collections/<id>/documents
# ---------------------------------------------------------------------------


class TestGetCollectionDocuments:
    def test_collection_not_found(self, app):
        db_session = _make_db_session()
        q = _build_mock_query(first_result=None)
        db_session.query = Mock(return_value=q)

        with _auth_client(app, mock_db_session=db_session) as (client, ctx):
            resp = client.get("/library/api/collections/coll-1/documents")
            assert resp.status_code == 404

    def test_success_with_documents(self, app):
        mock_coll = Mock()
        mock_coll.id = "coll-1"
        mock_coll.name = "Test"
        mock_coll.description = ""
        mock_coll.embedding_model = "test-model"
        mock_coll.embedding_model_type = Mock(value="sentence_transformers")
        mock_coll.embedding_dimension = 384
        mock_coll.chunk_size = 1000
        mock_coll.chunk_overlap = 200
        mock_coll.splitter_type = "recursive"
        mock_coll.distance_metric = "cosine"
        mock_coll.index_type = "flat"
        mock_coll.normalize_vectors = True
        mock_coll.collection_type = "user_uploads"

        mock_link = Mock()
        mock_link.indexed = True
        mock_link.chunk_count = 5
        mock_link.last_indexed_at = datetime(2024, 1, 1, tzinfo=UTC)

        mock_doc = Mock()
        mock_doc.id = "doc-1"
        mock_doc.filename = "test.pdf"
        mock_doc.title = "Test PDF"
        mock_doc.file_type = "pdf"
        mock_doc.file_size = 1024
        mock_doc.created_at = datetime(2024, 1, 1, tzinfo=UTC)
        mock_doc.text_content = "Some text"
        mock_doc.file_path = "/path/to/file.pdf"
        mock_source_type = Mock()
        mock_source_type.name = "user_upload"
        mock_doc.source_type = mock_source_type

        db_session = _make_db_session()
        call_count = [0]

        def query_side_effect(model, *args):
            call_count[0] += 1
            q = _build_mock_query()
            if call_count[0] == 1:
                q.first.return_value = mock_coll
            elif call_count[0] == 2:
                join_q = _build_mock_query()
                q.join.return_value = join_q
                join_q.options.return_value = join_q
                join_q.filter.return_value = join_q
                # 3-tuple: (link, doc, has_text_db) — has_text is now computed
                # at the SQL level instead of read from doc.text_content.
                join_q.all.return_value = [(mock_link, mock_doc, True)]
            elif call_count[0] == 3:
                q.filter.return_value = q
                q.count.return_value = 1
            elif call_count[0] == 4:
                q.first.return_value = None  # No RAG index
            return q

        db_session.query = Mock(side_effect=query_side_effect)

        with _auth_client(app, mock_db_session=db_session) as (client, ctx):
            resp = client.get("/library/api/collections/coll-1/documents")
            assert resp.status_code == 200
            data = resp.get_json()
            assert data["success"] is True
            assert len(data["documents"]) == 1
            assert data["documents"][0]["has_pdf"] is True
            assert data["documents"][0]["in_other_collections"] is True

    def test_no_rag_index(self, app):
        """Test response when no RAG index exists for collection."""
        mock_coll = Mock()
        mock_coll.id = "coll-1"
        mock_coll.name = "Test"
        mock_coll.description = ""
        mock_coll.embedding_model = None
        mock_coll.embedding_model_type = None
        mock_coll.embedding_dimension = None
        mock_coll.chunk_size = None
        mock_coll.chunk_overlap = None
        mock_coll.splitter_type = None
        mock_coll.distance_metric = None
        mock_coll.index_type = None
        mock_coll.normalize_vectors = None
        mock_coll.collection_type = "user_uploads"

        db_session = _make_db_session()
        call_count = [0]

        def query_side_effect(model, *args):
            call_count[0] += 1
            q = _build_mock_query()
            if call_count[0] == 1:
                q.first.return_value = mock_coll
            elif call_count[0] == 2:
                join_q = _build_mock_query()
                q.join.return_value = join_q
                join_q.options.return_value = join_q
                join_q.filter.return_value = join_q
                join_q.all.return_value = []
            elif call_count[0] == 3:
                q.first.return_value = None  # No RAG index
            return q

        db_session.query = Mock(side_effect=query_side_effect)

        with _auth_client(app, mock_db_session=db_session) as (client, ctx):
            resp = client.get("/library/api/collections/coll-1/documents")
            assert resp.status_code == 200
            data = resp.get_json()
            assert data["collection"]["index_file_size"] is None
            assert data["collection"]["index_file_size_bytes"] is None


# ---------------------------------------------------------------------------
# Tests: POST /api/collections/<id>/upload
# ---------------------------------------------------------------------------


class TestUploadToCollection:
    def test_no_files(self, app):
        with _auth_client(app) as (client, ctx):
            resp = client.post(
                "/library/api/collections/coll-1/upload",
                content_type="multipart/form-data",
            )
            assert resp.status_code == 400

    def test_collection_not_found(self, app):
        db_session = _make_db_session()
        q = _build_mock_query(first_result=None)
        db_session.query = Mock(return_value=q)

        with _auth_client(app, mock_db_session=db_session) as (client, ctx):
            data = {"files": (BytesIO(b"test content"), "test.txt")}
            resp = client.post(
                "/library/api/collections/coll-1/upload",
                data=data,
                content_type="multipart/form-data",
            )
            assert resp.status_code == 404

    def test_successful_upload_new_doc(self, app):
        mock_coll = Mock()
        mock_coll.id = "coll-1"

        db_session = _make_db_session()
        call_count = [0]

        def query_side_effect(model):
            call_count[0] += 1
            q = _build_mock_query()
            if call_count[0] == 1:
                q.first.return_value = mock_coll
            elif call_count[0] == 2:
                q.first.return_value = None  # No existing doc by hash
            elif call_count[0] == 3:
                mock_source = Mock()
                mock_source.id = "src-1"
                q.first.return_value = mock_source
            return q

        db_session.query = Mock(side_effect=query_side_effect)

        mock_password_store = Mock()
        mock_password_store.get_session_password.return_value = None

        with _auth_client(
            app,
            mock_db_session=db_session,
            extra_patches=[
                patch(
                    f"{_DOC_LOADERS}.extract_text_from_bytes",
                    return_value="Extracted text",
                ),
                patch(
                    f"{_DOC_LOADERS}.is_extension_supported", return_value=True
                ),
                patch(
                    f"{_TEXT_PROC}.remove_surrogates", side_effect=lambda x: x
                ),
                patch(
                    f"{_DB_PASS}.session_password_store", mock_password_store
                ),
            ],
        ) as (client, ctx):
            data = {"files": (BytesIO(b"test content"), "test.txt")}
            resp = client.post(
                "/library/api/collections/coll-1/upload",
                data=data,
                content_type="multipart/form-data",
            )
            assert resp.status_code == 200
            rdata = resp.get_json()
            assert rdata["success"] is True
            assert rdata["summary"]["successful"] == 1

    def test_successful_odt_upload_real_extraction(self, app):
        """End-to-end: a real ODT uploaded through the HTTP route is parsed by
        the real extraction stack (issue #4414 regression). Nothing in the
        document-loaders path is mocked; only the DB and password store are."""
        pypandoc = pytest.importorskip("pypandoc")
        pytest.importorskip("docx")
        try:
            pypandoc.get_pandoc_version()
        except OSError:
            pytest.skip("pandoc binary not available")

        import tempfile
        from pathlib import Path

        marker = "Otters build dams in the river."
        with tempfile.NamedTemporaryFile(suffix=".odt", delete=False) as tmp:
            odt_path = tmp.name
        try:
            pypandoc.convert_text(
                marker, "odt", format="md", outputfile=odt_path
            )
            odt_bytes = Path(odt_path).read_bytes()
        finally:
            Path(odt_path).unlink(missing_ok=True)

        mock_coll = Mock()
        mock_coll.id = "coll-1"

        db_session = _make_db_session()
        call_count = [0]

        def query_side_effect(model):
            call_count[0] += 1
            q = _build_mock_query()
            if call_count[0] == 1:
                q.first.return_value = mock_coll
            elif call_count[0] == 2:
                q.first.return_value = None  # No existing doc by hash
            elif call_count[0] == 3:
                mock_source = Mock()
                mock_source.id = "src-1"
                q.first.return_value = mock_source
            return q

        db_session.query = Mock(side_effect=query_side_effect)

        mock_password_store = Mock()
        mock_password_store.get_session_password.return_value = None

        # Note: extract_text_from_bytes / is_extension_supported are NOT
        # patched here — the real ODT must flow through the real loaders.
        with _auth_client(
            app,
            mock_db_session=db_session,
            extra_patches=[
                patch(
                    f"{_DB_PASS}.session_password_store", mock_password_store
                ),
            ],
        ) as (client, ctx):
            data = {"files": (BytesIO(odt_bytes), "report.odt")}
            resp = client.post(
                "/library/api/collections/coll-1/upload",
                data=data,
                content_type="multipart/form-data",
            )
            assert resp.status_code == 200
            rdata = resp.get_json()
            assert rdata["success"] is True
            assert rdata["summary"]["successful"] == 1

    def test_upload_existing_doc_not_in_collection(self, app):
        mock_coll = Mock()
        mock_coll.id = "coll-1"

        existing_doc = Mock()
        existing_doc.id = "doc-existing"
        existing_doc.filename = "test.txt"

        db_session = _make_db_session()
        call_count = [0]

        def query_side_effect(model):
            call_count[0] += 1
            q = _build_mock_query()
            if call_count[0] == 1:
                q.first.return_value = mock_coll
            elif call_count[0] == 2:
                q.first.return_value = existing_doc
            elif call_count[0] == 3:
                q.first.return_value = None  # Not in collection
            return q

        db_session.query = Mock(side_effect=query_side_effect)

        mock_password_store = Mock()
        mock_password_store.get_session_password.return_value = None

        with _auth_client(
            app,
            mock_db_session=db_session,
            extra_patches=[
                patch(
                    f"{_DB_PASS}.session_password_store", mock_password_store
                ),
            ],
        ) as (client, ctx):
            data = {"files": (BytesIO(b"test content"), "test.txt")}
            resp = client.post(
                "/library/api/collections/coll-1/upload",
                data=data,
                content_type="multipart/form-data",
            )
            assert resp.status_code == 200
            rdata = resp.get_json()
            assert rdata["uploaded"][0]["status"] == "added_to_collection"

    def test_upload_existing_doc_already_in_collection(self, app):
        mock_coll = Mock()
        mock_coll.id = "coll-1"

        existing_doc = Mock()
        existing_doc.id = "doc-existing"
        existing_doc.filename = "test.txt"

        db_session = _make_db_session()
        call_count = [0]

        def query_side_effect(model):
            call_count[0] += 1
            q = _build_mock_query()
            if call_count[0] == 1:
                q.first.return_value = mock_coll
            elif call_count[0] == 2:
                q.first.return_value = existing_doc
            elif call_count[0] == 3:
                q.first.return_value = Mock()  # Already in collection
            return q

        db_session.query = Mock(side_effect=query_side_effect)

        mock_password_store = Mock()
        mock_password_store.get_session_password.return_value = None

        with _auth_client(
            app,
            mock_db_session=db_session,
            extra_patches=[
                patch(
                    f"{_DB_PASS}.session_password_store", mock_password_store
                ),
            ],
        ) as (client, ctx):
            data = {"files": (BytesIO(b"test content"), "test.txt")}
            resp = client.post(
                "/library/api/collections/coll-1/upload",
                data=data,
                content_type="multipart/form-data",
            )
            assert resp.status_code == 200
            rdata = resp.get_json()
            assert rdata["uploaded"][0]["status"] == "already_in_collection"

    def test_upload_unsupported_format(self, app):
        mock_coll = Mock()
        mock_coll.id = "coll-1"

        db_session = _make_db_session()
        call_count = [0]

        def query_side_effect(model):
            call_count[0] += 1
            q = _build_mock_query()
            if call_count[0] == 1:
                q.first.return_value = mock_coll
            elif call_count[0] == 2:
                q.first.return_value = None
            return q

        db_session.query = Mock(side_effect=query_side_effect)

        mock_password_store = Mock()
        mock_password_store.get_session_password.return_value = None

        with _auth_client(
            app,
            mock_db_session=db_session,
            extra_patches=[
                patch(
                    f"{_DOC_LOADERS}.is_extension_supported", return_value=False
                ),
                patch(
                    f"{_DB_PASS}.session_password_store", mock_password_store
                ),
            ],
        ) as (client, ctx):
            data = {"files": (BytesIO(b"data"), "test.xyz")}
            resp = client.post(
                "/library/api/collections/coll-1/upload",
                data=data,
                content_type="multipart/form-data",
            )
            assert resp.status_code == 200
            rdata = resp.get_json()
            assert len(rdata["errors"]) == 1
            assert "Unsupported" in rdata["errors"][0]["error"]

    def test_upload_no_extracted_text(self, app):
        mock_coll = Mock()
        mock_coll.id = "coll-1"

        db_session = _make_db_session()
        call_count = [0]

        def query_side_effect(model):
            call_count[0] += 1
            q = _build_mock_query()
            if call_count[0] == 1:
                q.first.return_value = mock_coll
            elif call_count[0] == 2:
                q.first.return_value = None
            return q

        db_session.query = Mock(side_effect=query_side_effect)

        mock_password_store = Mock()
        mock_password_store.get_session_password.return_value = None

        with _auth_client(
            app,
            mock_db_session=db_session,
            extra_patches=[
                patch(
                    f"{_DOC_LOADERS}.extract_text_from_bytes", return_value=""
                ),
                patch(
                    f"{_DOC_LOADERS}.is_extension_supported", return_value=True
                ),
                patch(
                    f"{_DB_PASS}.session_password_store", mock_password_store
                ),
            ],
        ) as (client, ctx):
            data = {"files": (BytesIO(b"data"), "empty.txt")}
            resp = client.post(
                "/library/api/collections/coll-1/upload",
                data=data,
                content_type="multipart/form-data",
            )
            assert resp.status_code == 200
            rdata = resp.get_json()
            assert len(rdata["errors"]) == 1


# ---------------------------------------------------------------------------
# Tests: POST /api/collections/<id>/index/start
# ---------------------------------------------------------------------------


class TestStartBackgroundIndex:
    def test_already_in_progress(self, app):
        existing_task = Mock()
        existing_task.task_id = "task-1"
        existing_task.metadata_json = {"collection_id": "coll-1"}

        db_session = _make_db_session()
        q = _build_mock_query(first_result=existing_task)
        db_session.query = Mock(return_value=q)

        mock_password_store = Mock()
        mock_password_store.get_session_password.return_value = "pass"

        with _auth_client(
            app,
            mock_db_session=db_session,
            extra_patches=[
                patch(
                    f"{_DB_PASS}.session_password_store", mock_password_store
                ),
            ],
        ) as (client, ctx):
            resp = client.post(
                "/library/api/collections/coll-1/index/start",
                json={},
            )
            assert resp.status_code == 409

    def test_existing_task_different_collection(self, app):
        """Existing task is for a different collection - should proceed."""
        existing_task = Mock()
        existing_task.task_id = "task-1"
        existing_task.metadata_json = {"collection_id": "other-coll"}

        db_session = _make_db_session()
        q = _build_mock_query(first_result=existing_task)
        db_session.query = Mock(return_value=q)

        mock_password_store = Mock()
        mock_password_store.get_session_password.return_value = "pass"
        mock_thread = Mock()

        with _auth_client(
            app,
            mock_db_session=db_session,
            extra_patches=[
                patch(
                    f"{_DB_PASS}.session_password_store", mock_password_store
                ),
                patch(f"{_ROUTES}.threading.Thread", return_value=mock_thread),
            ],
        ) as (client, ctx):
            resp = client.post(
                "/library/api/collections/coll-1/index/start",
                json={},
            )
            assert resp.status_code == 200

    def test_success_starts_thread(self, app):
        db_session = _make_db_session()
        q = _build_mock_query(first_result=None)
        db_session.query = Mock(return_value=q)

        mock_password_store = Mock()
        mock_password_store.get_session_password.return_value = "pass"
        mock_thread = Mock()

        with _auth_client(
            app,
            mock_db_session=db_session,
            extra_patches=[
                patch(
                    f"{_DB_PASS}.session_password_store", mock_password_store
                ),
                patch(f"{_ROUTES}.threading.Thread", return_value=mock_thread),
            ],
        ) as (client, ctx):
            resp = client.post(
                "/library/api/collections/coll-1/index/start",
                json={"force_reindex": True},
            )
            assert resp.status_code == 200
            data = resp.get_json()
            assert data["success"] is True
            assert "task_id" in data
            mock_thread.start.assert_called_once()


# ---------------------------------------------------------------------------
# Tests: GET /api/collections/<id>/index/status
# ---------------------------------------------------------------------------


class TestGetIndexStatus:
    def test_no_task(self, app):
        db_session = _make_db_session()
        q = _build_mock_query(first_result=None)
        db_session.query = Mock(return_value=q)

        mock_password_store = Mock()
        mock_password_store.get_session_password.return_value = "pass"

        with _auth_client(
            app,
            mock_db_session=db_session,
            extra_patches=[
                patch(
                    f"{_DB_PASS}.session_password_store", mock_password_store
                ),
            ],
        ) as (client, ctx):
            resp = client.get("/library/api/collections/coll-1/index/status")
            assert resp.status_code == 200
            data = resp.get_json()
            assert data["status"] == "idle"

    def test_task_for_different_collection(self, app):
        task = Mock()
        task.metadata_json = {"collection_id": "other-coll"}

        db_session = _make_db_session()
        q = _build_mock_query(first_result=task)
        db_session.query = Mock(return_value=q)

        mock_password_store = Mock()
        mock_password_store.get_session_password.return_value = "pass"

        with _auth_client(
            app,
            mock_db_session=db_session,
            extra_patches=[
                patch(
                    f"{_DB_PASS}.session_password_store", mock_password_store
                ),
            ],
        ) as (client, ctx):
            resp = client.get("/library/api/collections/coll-1/index/status")
            assert resp.status_code == 200
            data = resp.get_json()
            assert data["status"] == "idle"

    def test_task_found(self, app):
        task = Mock()
        task.task_id = "task-1"
        task.metadata_json = {"collection_id": "coll-1"}
        task.status = "processing"
        task.progress_current = 5
        task.progress_total = 10
        task.progress_message = "Indexing 5/10"
        task.error_message = None
        task.created_at = datetime(2024, 1, 1, tzinfo=UTC)
        task.completed_at = None

        db_session = _make_db_session()
        q = _build_mock_query(first_result=task)
        db_session.query = Mock(return_value=q)

        mock_password_store = Mock()
        mock_password_store.get_session_password.return_value = "pass"

        with _auth_client(
            app,
            mock_db_session=db_session,
            extra_patches=[
                patch(
                    f"{_DB_PASS}.session_password_store", mock_password_store
                ),
            ],
        ) as (client, ctx):
            resp = client.get("/library/api/collections/coll-1/index/status")
            assert resp.status_code == 200
            data = resp.get_json()
            assert data["status"] == "processing"
            assert data["progress_current"] == 5

    def test_task_null_metadata_json(self, app):
        """Task with metadata_json=None."""
        task = Mock()
        task.metadata_json = None

        db_session = _make_db_session()
        q = _build_mock_query(first_result=task)
        db_session.query = Mock(return_value=q)

        mock_password_store = Mock()
        mock_password_store.get_session_password.return_value = "pass"

        with _auth_client(
            app,
            mock_db_session=db_session,
            extra_patches=[
                patch(
                    f"{_DB_PASS}.session_password_store", mock_password_store
                ),
            ],
        ) as (client, ctx):
            resp = client.get("/library/api/collections/coll-1/index/status")
            assert resp.status_code == 200
            data = resp.get_json()
            assert data["status"] == "idle"


# ---------------------------------------------------------------------------
# Tests: POST /api/collections/<id>/index/cancel
# ---------------------------------------------------------------------------


class TestCancelIndexing:
    def test_no_active_task(self, app):
        db_session = _make_db_session()
        q = _build_mock_query(first_result=None)
        db_session.query = Mock(return_value=q)

        mock_password_store = Mock()
        mock_password_store.get_session_password.return_value = "pass"

        with _auth_client(
            app,
            mock_db_session=db_session,
            extra_patches=[
                patch(
                    f"{_DB_PASS}.session_password_store", mock_password_store
                ),
            ],
        ) as (client, ctx):
            resp = client.post("/library/api/collections/coll-1/index/cancel")
            assert resp.status_code == 404

    def test_task_for_different_collection(self, app):
        task = Mock()
        task.metadata_json = {"collection_id": "other-coll"}

        db_session = _make_db_session()
        q = _build_mock_query(first_result=task)
        db_session.query = Mock(return_value=q)

        mock_password_store = Mock()
        mock_password_store.get_session_password.return_value = "pass"

        with _auth_client(
            app,
            mock_db_session=db_session,
            extra_patches=[
                patch(
                    f"{_DB_PASS}.session_password_store", mock_password_store
                ),
            ],
        ) as (client, ctx):
            resp = client.post("/library/api/collections/coll-1/index/cancel")
            assert resp.status_code == 404

    def test_success(self, app):
        task = Mock()
        task.task_id = "task-1"
        task.metadata_json = {"collection_id": "coll-1"}
        task.status = "processing"

        db_session = _make_db_session()
        q = _build_mock_query(first_result=task)
        db_session.query = Mock(return_value=q)

        mock_password_store = Mock()
        mock_password_store.get_session_password.return_value = "pass"

        with _auth_client(
            app,
            mock_db_session=db_session,
            extra_patches=[
                patch(
                    f"{_DB_PASS}.session_password_store", mock_password_store
                ),
            ],
        ) as (client, ctx):
            resp = client.post("/library/api/collections/coll-1/index/cancel")
            assert resp.status_code == 200
            data = resp.get_json()
            assert data["success"] is True
            assert task.status == "cancelled"

    def test_null_metadata_json(self, app):
        task = Mock()
        task.metadata_json = None

        db_session = _make_db_session()
        q = _build_mock_query(first_result=task)
        db_session.query = Mock(return_value=q)

        mock_password_store = Mock()
        mock_password_store.get_session_password.return_value = "pass"

        with _auth_client(
            app,
            mock_db_session=db_session,
            extra_patches=[
                patch(
                    f"{_DB_PASS}.session_password_store", mock_password_store
                ),
            ],
        ) as (client, ctx):
            resp = client.post("/library/api/collections/coll-1/index/cancel")
            assert resp.status_code == 404


# ---------------------------------------------------------------------------
# Tests: index_local_library (GET /api/rag/index-local)
# ---------------------------------------------------------------------------


class TestIndexLocalLibrary:
    def test_missing_path(self, app):
        with _auth_client(app) as (client, ctx):
            resp = client.get("/library/api/rag/index-local")
            assert resp.status_code == 400

    def test_invalid_path(self, app):
        with _auth_client(
            app,
            extra_patches=[
                patch(
                    f"{_ROUTES}.PathValidator.validate_local_filesystem_path",
                    side_effect=ValueError("bad path"),
                ),
            ],
        ) as (client, ctx):
            resp = client.get("/library/api/rag/index-local?path=/etc/../root")
            assert resp.status_code == 400

    def test_path_not_exist(self, app):
        mock_path = Mock()
        mock_path.exists.return_value = False

        with _auth_client(
            app,
            extra_patches=[
                patch(
                    f"{_ROUTES}.PathValidator.validate_local_filesystem_path",
                    return_value=mock_path,
                ),
                patch(
                    f"{_ROUTES}.PathValidator.sanitize_for_filesystem_ops",
                    return_value=mock_path,
                ),
            ],
        ) as (client, ctx):
            resp = client.get("/library/api/rag/index-local?path=/nonexistent")
            assert resp.status_code == 400

    def test_path_not_dir(self, app):
        mock_path = Mock()
        mock_path.exists.return_value = True
        mock_path.is_dir.return_value = False

        with _auth_client(
            app,
            extra_patches=[
                patch(
                    f"{_ROUTES}.PathValidator.validate_local_filesystem_path",
                    return_value=mock_path,
                ),
                patch(
                    f"{_ROUTES}.PathValidator.sanitize_for_filesystem_ops",
                    return_value=mock_path,
                ),
            ],
        ) as (client, ctx):
            resp = client.get(
                "/library/api/rag/index-local?path=/some/file.txt"
            )
            assert resp.status_code == 400


# ---------------------------------------------------------------------------
# Tests: helper functions
# ---------------------------------------------------------------------------


class TestUpdateTaskStatus:
    def test_updates_status_completed(self):
        from local_deep_research.research_library.routes.rag_routes import (
            _update_task_status,
        )

        mock_task = Mock()
        mock_task.status = "processing"
        mock_task.completed_at = None

        mock_session = Mock()
        q = _build_mock_query(first_result=mock_task)
        mock_session.query = Mock(return_value=q)

        @contextmanager
        def fake_session(*a, **kw):
            yield mock_session

        with patch(f"{_DB_CTX}.get_user_db_session", side_effect=fake_session):
            _update_task_status(
                "user",
                "pass",
                "task-1",
                status="completed",
                progress_current=10,
                progress_total=10,
                progress_message="Done",
            )
            assert mock_task.status == "completed"
            assert mock_task.completed_at is not None
            assert mock_task.progress_current == 10
            mock_session.commit.assert_called_once()

    def test_task_not_found(self):
        from local_deep_research.research_library.routes.rag_routes import (
            _update_task_status,
        )

        mock_session = Mock()
        q = _build_mock_query(first_result=None)
        mock_session.query = Mock(return_value=q)

        @contextmanager
        def fake_session(*a, **kw):
            yield mock_session

        with patch(f"{_DB_CTX}.get_user_db_session", side_effect=fake_session):
            _update_task_status("user", "pass", "task-1", status="completed")
            mock_session.commit.assert_not_called()

    def test_updates_error_message(self):
        from local_deep_research.research_library.routes.rag_routes import (
            _update_task_status,
        )

        mock_task = Mock()
        mock_task.status = "processing"

        mock_session = Mock()
        q = _build_mock_query(first_result=mock_task)
        mock_session.query = Mock(return_value=q)

        @contextmanager
        def fake_session(*a, **kw):
            yield mock_session

        with patch(f"{_DB_CTX}.get_user_db_session", side_effect=fake_session):
            _update_task_status(
                "user",
                "pass",
                "task-1",
                status="failed",
                error_message="Something went wrong",
            )
            assert mock_task.error_message == "Something went wrong"


class TestIsTaskCancelled:
    def test_cancelled(self):
        from local_deep_research.research_library.routes.rag_routes import (
            _is_task_cancelled,
        )

        mock_task = Mock()
        mock_task.status = "cancelled"

        mock_session = Mock()
        q = _build_mock_query(first_result=mock_task)
        mock_session.query = Mock(return_value=q)

        @contextmanager
        def fake_session(*a, **kw):
            yield mock_session

        with patch(f"{_DB_CTX}.get_user_db_session", side_effect=fake_session):
            assert _is_task_cancelled("user", "pass", "task-1") is True

    def test_not_cancelled(self):
        from local_deep_research.research_library.routes.rag_routes import (
            _is_task_cancelled,
        )

        mock_task = Mock()
        mock_task.status = "processing"

        mock_session = Mock()
        q = _build_mock_query(first_result=mock_task)
        mock_session.query = Mock(return_value=q)

        @contextmanager
        def fake_session(*a, **kw):
            yield mock_session

        with patch(f"{_DB_CTX}.get_user_db_session", side_effect=fake_session):
            assert _is_task_cancelled("user", "pass", "task-1") is False

    def test_no_task(self):
        from local_deep_research.research_library.routes.rag_routes import (
            _is_task_cancelled,
        )

        mock_session = Mock()
        q = _build_mock_query(first_result=None)
        mock_session.query = Mock(return_value=q)

        @contextmanager
        def fake_session(*a, **kw):
            yield mock_session

        with patch(f"{_DB_CTX}.get_user_db_session", side_effect=fake_session):
            assert not _is_task_cancelled("user", "pass", "task-1")

    def test_exception_returns_false(self):
        from local_deep_research.research_library.routes.rag_routes import (
            _is_task_cancelled,
        )

        with patch(
            f"{_DB_CTX}.get_user_db_session",
            side_effect=RuntimeError("db error"),
        ):
            assert _is_task_cancelled("user", "pass", "task-1") is False


class TestTriggerAutoIndex:
    def test_auto_index_enabled(self):
        from local_deep_research.research_library.routes.rag_routes import (
            trigger_auto_index,
        )

        mock_sm = Mock()
        mock_sm.get_bool_setting.return_value = True

        mock_session = Mock()

        @contextmanager
        def fake_session(*a, **kw):
            yield mock_session

        mock_executor = Mock()

        with (
            patch(f"{_DB_CTX}.get_user_db_session", side_effect=fake_session),
            patch(f"{_ROUTES}.SettingsManager", return_value=mock_sm),
            patch(
                f"{_ROUTES}._get_auto_index_executor",
                return_value=mock_executor,
            ),
        ):
            trigger_auto_index(["doc-1", "doc-2"], "coll-1", "user", "pass")
            mock_executor.submit.assert_called_once()


# ---------------------------------------------------------------------------
# Tests: get_rag_service function
# ---------------------------------------------------------------------------


class TestGetRagServiceFunction:
    def test_no_collection_id(self, app):
        from local_deep_research.research_library.routes.rag_routes import (
            get_rag_service,
        )

        mock_sm = _make_settings_mock()

        @contextmanager
        def fake_db_session(*a, **kw):
            yield Mock()

        with (
            patch(f"{_FACTORY}.get_settings_manager", return_value=mock_sm),
            patch(f"{_ROUTES}.session", {"username": "testuser"}),
            patch(
                f"{_FACTORY}.get_user_db_session", side_effect=fake_db_session
            ),
            patch(f"{_FACTORY}.LibraryRAGService") as mock_rag_cls,
        ):
            mock_rag_cls.return_value = Mock()
            get_rag_service()
            mock_rag_cls.assert_called_once()

    def test_with_collection_stored_settings(self, app):
        from local_deep_research.research_library.routes.rag_routes import (
            get_rag_service,
        )

        mock_sm = _make_settings_mock()
        mock_coll = Mock()
        mock_coll.embedding_model = "stored-model"
        mock_coll.embedding_model_type = Mock(value="ollama")
        mock_coll.chunk_size = 500
        mock_coll.chunk_overlap = 100
        mock_coll.splitter_type = "character"
        mock_coll.text_separators = ["\n"]
        mock_coll.distance_metric = "l2"
        mock_coll.normalize_vectors = "true"
        mock_coll.index_type = "ivf"

        mock_session = Mock()
        q = _build_mock_query(first_result=mock_coll)
        mock_session.query = Mock(return_value=q)

        @contextmanager
        def fake_db_session(*a, **kw):
            yield mock_session

        with (
            patch(f"{_FACTORY}.get_settings_manager", return_value=mock_sm),
            patch(f"{_ROUTES}.session", {"username": "testuser"}),
            patch(
                f"{_FACTORY}.get_user_db_session", side_effect=fake_db_session
            ),
            patch(f"{_FACTORY}.LibraryRAGService") as mock_rag_cls,
        ):
            mock_rag_cls.return_value = Mock()
            get_rag_service(collection_id="coll-1")
            call_kwargs = mock_rag_cls.call_args[1]
            assert call_kwargs["embedding_model"] == "stored-model"

    def test_with_collection_no_stored_settings(self, app):
        from local_deep_research.research_library.routes.rag_routes import (
            get_rag_service,
        )

        mock_sm = _make_settings_mock()
        mock_coll = Mock()
        mock_coll.embedding_model = None

        mock_session = Mock()
        q = _build_mock_query(first_result=mock_coll)
        mock_session.query = Mock(return_value=q)

        @contextmanager
        def fake_db_session(*a, **kw):
            yield mock_session

        with (
            patch(f"{_FACTORY}.get_settings_manager", return_value=mock_sm),
            patch(f"{_ROUTES}.session", {"username": "testuser"}),
            patch(
                f"{_FACTORY}.get_user_db_session", side_effect=fake_db_session
            ),
            patch(f"{_FACTORY}.LibraryRAGService") as mock_rag_cls,
        ):
            mock_rag_cls.return_value = Mock()
            get_rag_service(collection_id="coll-1")
            call_kwargs = mock_rag_cls.call_args[1]
            assert call_kwargs["embedding_model"] == "all-MiniLM-L6-v2"

    def test_invalid_text_separators_json(self, app):
        from local_deep_research.research_library.routes.rag_routes import (
            get_rag_service,
        )

        mock_sm = _make_settings_mock(
            {"local_search_text_separators": "not-json"}
        )

        @contextmanager
        def fake_db_session(*a, **kw):
            yield Mock()

        with (
            patch(f"{_FACTORY}.get_settings_manager", return_value=mock_sm),
            patch(f"{_ROUTES}.session", {"username": "testuser"}),
            patch(
                f"{_FACTORY}.get_user_db_session", side_effect=fake_db_session
            ),
            patch(f"{_FACTORY}.LibraryRAGService") as mock_rag_cls,
        ):
            mock_rag_cls.return_value = Mock()
            get_rag_service()
            call_kwargs = mock_rag_cls.call_args[1]
            assert isinstance(call_kwargs["text_separators"], list)

    def test_use_defaults_flag(self, app):
        from local_deep_research.research_library.routes.rag_routes import (
            get_rag_service,
        )

        mock_sm = _make_settings_mock()
        mock_coll = Mock()
        mock_coll.embedding_model = "stored-model"
        mock_coll.embedding_model_type = Mock(value="ollama")

        mock_session = Mock()
        q = _build_mock_query(first_result=mock_coll)
        mock_session.query = Mock(return_value=q)

        @contextmanager
        def fake_db_session(*a, **kw):
            yield mock_session

        with (
            patch(f"{_FACTORY}.get_settings_manager", return_value=mock_sm),
            patch(f"{_ROUTES}.session", {"username": "testuser"}),
            patch(
                f"{_FACTORY}.get_user_db_session", side_effect=fake_db_session
            ),
            patch(f"{_FACTORY}.LibraryRAGService") as mock_rag_cls,
        ):
            mock_rag_cls.return_value = Mock()
            get_rag_service(collection_id="coll-1", use_defaults=True)
            call_kwargs = mock_rag_cls.call_args[1]
            assert call_kwargs["embedding_model"] == "all-MiniLM-L6-v2"

    def test_normalize_vectors_none_uses_default(self, app):
        from local_deep_research.research_library.routes.rag_routes import (
            get_rag_service,
        )

        mock_sm = _make_settings_mock()
        mock_coll = Mock()
        mock_coll.embedding_model = "stored-model"
        mock_coll.embedding_model_type = Mock(value="ollama")
        mock_coll.chunk_size = 500
        mock_coll.chunk_overlap = 100
        mock_coll.splitter_type = "recursive"
        mock_coll.text_separators = None
        mock_coll.distance_metric = None
        mock_coll.normalize_vectors = None
        mock_coll.index_type = None

        mock_session = Mock()
        q = _build_mock_query(first_result=mock_coll)
        mock_session.query = Mock(return_value=q)

        @contextmanager
        def fake_db_session(*a, **kw):
            yield mock_session

        with (
            patch(f"{_FACTORY}.get_settings_manager", return_value=mock_sm),
            patch(f"{_ROUTES}.session", {"username": "testuser"}),
            patch(
                f"{_FACTORY}.get_user_db_session", side_effect=fake_db_session
            ),
            patch(f"{_FACTORY}.LibraryRAGService") as mock_rag_cls,
        ):
            mock_rag_cls.return_value = Mock()
            get_rag_service(collection_id="coll-1")
            call_kwargs = mock_rag_cls.call_args[1]
            assert call_kwargs["normalize_vectors"] is True

    def test_collection_not_found(self, app):
        """When collection_id is given but collection doesn't exist."""
        from local_deep_research.research_library.routes.rag_routes import (
            get_rag_service,
        )

        mock_sm = _make_settings_mock()

        mock_session = Mock()
        q = _build_mock_query(first_result=None)
        mock_session.query = Mock(return_value=q)

        @contextmanager
        def fake_db_session(*a, **kw):
            yield mock_session

        with (
            patch(f"{_FACTORY}.get_settings_manager", return_value=mock_sm),
            patch(f"{_ROUTES}.session", {"username": "testuser"}),
            patch(
                f"{_FACTORY}.get_user_db_session", side_effect=fake_db_session
            ),
            patch(f"{_FACTORY}.LibraryRAGService") as mock_rag_cls,
        ):
            mock_rag_cls.return_value = Mock()
            get_rag_service(collection_id="nonexistent")
            # Should fall through to default settings
            call_kwargs = mock_rag_cls.call_args[1]
            assert call_kwargs["embedding_model"] == "all-MiniLM-L6-v2"


# ---------------------------------------------------------------------------
# Tests: Executor management
# ---------------------------------------------------------------------------


class TestAutoIndexExecutor:
    def test_executor_creation(self):
        from local_deep_research.research_library.routes import rag_routes

        rag_routes._auto_index_executor = None
        executor = rag_routes._get_auto_index_executor()
        assert executor is not None
        rag_routes._shutdown_auto_index_executor()

    def test_executor_reused(self):
        from local_deep_research.research_library.routes import rag_routes

        rag_routes._auto_index_executor = None
        e1 = rag_routes._get_auto_index_executor()
        e2 = rag_routes._get_auto_index_executor()
        assert e1 is e2
        rag_routes._shutdown_auto_index_executor()

    def test_shutdown_handles_none(self):
        from local_deep_research.research_library.routes import rag_routes

        rag_routes._auto_index_executor = None
        rag_routes._shutdown_auto_index_executor()
        assert rag_routes._auto_index_executor is None
