"""Unit tests for helper functions extracted from start_research().

Tests _extract_research_params() and _queue_research() in isolation,
plus integration tests for the encrypted-DB password gate and error paths.
"""

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

import pytest
from flask import Flask, g

MODULE = "local_deep_research.web.routes.research_routes"
_QP = "local_deep_research.web.queue.processor_v2.queue_processor"
_GET_USER_DB = f"{MODULE}.get_user_db_session"
_SM_MANAGER = "local_deep_research.settings.manager.SettingsManager"
_SM_SETTINGS = "local_deep_research.settings.SettingsManager"
_SAVE_STRATEGY = (
    "local_deep_research.web.services.research_service.save_research_strategy"
)


# ---------------------------------------------------------------------------
# Fixtures & helpers
# ---------------------------------------------------------------------------


@pytest.fixture()
def app():
    flask_app = Flask(__name__)
    flask_app.secret_key = "test-secret"
    flask_app.config["TESTING"] = True

    from local_deep_research.web.routes.research_routes import research_bp

    flask_app.register_blueprint(research_bp)
    with (
        patch("local_deep_research.web.auth.decorators.db_manager") as mock_db,
        # The password guard delegates to resolve_user_password; default it
        # to "not expired" so the general tests are not blocked. The two
        # dedicated guard tests below override this.
        patch(f"{MODULE}.resolve_user_password", return_value=(None, False)),
    ):
        mock_db.is_user_connected.return_value = True
        yield flask_app


@pytest.fixture()
def client(app):
    return app.test_client()


def _make_settings_manager(overrides=None):
    """Return a SettingsManager mock with configurable lookup table."""
    sm = MagicMock()
    lookup = {
        "llm.provider": "OLLAMA",
        "llm.model": "llama3",
        "llm.openai_endpoint.url": None,
        "llm.ollama.url": "http://localhost:11434",
        "search.tool": "searxng",
        "search.iterations": 5,
        "search.questions_per_iteration": 5,
        "search.search_strategy": "source-based",
        "app.max_concurrent_researches": 3,
    }
    if overrides:
        lookup.update(overrides)

    def _get(key, default=None):
        return lookup.get(key, default)

    sm.get_setting.side_effect = _get
    sm.get_all_settings.return_value = {"setting_key": "setting_val"}
    return sm


@contextmanager
def _ctx(session):
    yield session


def _ctx_factory(session):
    def _factory(*args, **kwargs):
        return _ctx(session)

    return _factory


def _mock_db_session(active_count=0, max_pos=0):
    ms = MagicMock()
    ms.query.return_value.filter_by.return_value.count.return_value = (
        active_count
    )
    ms.query.return_value.filter_by.return_value.scalar.return_value = max_pos
    ms.query.return_value.filter_by.return_value.first.return_value = (
        MagicMock()
    )
    return ms


def _happy_path_patches(ms, sm, fake_thread):
    """Return a list of patch context managers for a happy-path integration test."""
    return [
        patch(_GET_USER_DB, side_effect=_ctx_factory(ms)),
        patch(_SM_MANAGER, return_value=sm),
        patch(_SM_SETTINGS, return_value=sm),
        # The route derives user_password from the resolve_user_password
        # guard, so feed the run's password through it (not the now-unused
        # direct get_user_password call).
        patch(f"{MODULE}.resolve_user_password", return_value=("pw", False)),
        patch(f"{MODULE}.start_research_process", return_value=fake_thread),
        patch(_SAVE_STRATEGY),
        patch(f"{MODULE}.log_settings"),
        patch(f"{MODULE}.ResearchHistory"),
        patch(f"{MODULE}.UserActiveResearch"),
    ]


# ---------------------------------------------------------------------------
# _extract_research_params tests
# ---------------------------------------------------------------------------


class TestExtractResearchParams:
    """Tests for _extract_research_params()."""

    def _call(self, data, settings_manager):
        from local_deep_research.web.routes.research_routes import (
            _extract_research_params,
        )

        return _extract_research_params(data, settings_manager)

    def test_request_value_overrides_db_setting(self):
        """Values in request data take precedence over DB settings."""
        sm = _make_settings_manager()
        data = {"model_provider": "OPENAI", "model": "gpt-4"}
        result = self._call(data, sm)
        assert result["model_provider"] == "openai"
        assert result["model"] == "gpt-4"

    def test_falls_back_to_db_defaults(self):
        """Empty request uses settings_manager values."""
        sm = _make_settings_manager(
            {"llm.provider": "ANTHROPIC", "llm.model": "claude"}
        )
        result = self._call({}, sm)
        assert result["model_provider"] == "anthropic"
        assert result["model"] == "claude"
        assert result["search_engine"] == "searxng"
        assert result["iterations"] == 5
        assert result["questions_per_iteration"] == 5
        assert result["strategy"] == "source-based"

    def test_custom_endpoint_only_for_openai_endpoint(self):
        """custom_endpoint is only fetched from DB when provider is OPENAI_ENDPOINT."""
        sm = _make_settings_manager(
            {"llm.openai_endpoint.url": "http://custom.api"}
        )
        # OLLAMA provider — should not fetch custom_endpoint
        result = self._call({"model_provider": "OLLAMA"}, sm)
        assert result["custom_endpoint"] is None

        # OPENAI_ENDPOINT provider — should fetch from DB
        result = self._call({"model_provider": "OPENAI_ENDPOINT"}, sm)
        assert result["custom_endpoint"] == "http://custom.api"

    def test_search_tool_alias(self):
        """search_tool key is accepted as alias for search_engine."""
        sm = _make_settings_manager()
        data = {"search_tool": "tavily", "model_provider": "OLLAMA"}
        result = self._call(data, sm)
        assert result["search_engine"] == "tavily"

    def test_ollama_url_uses_constant_as_default(self):
        """When DB has no ollama.url setting, the DEFAULT_OLLAMA_URL constant is used."""
        sm = _make_settings_manager()
        original_side_effect = sm.get_setting.side_effect

        def _get_without_ollama(key, default=None):
            if key == "llm.ollama.url":
                return default  # simulate key not in DB
            return original_side_effect(key, default)

        sm.get_setting.side_effect = _get_without_ollama
        result = self._call({"model_provider": "OLLAMA"}, sm)
        assert result["ollama_url"] == "http://localhost:11434"

    def test_iterations_none_falls_back_to_settings(self):
        """When iterations not in request, uses settings value."""
        sm = _make_settings_manager({"search.iterations": 10})
        result = self._call({}, sm)
        assert result["iterations"] == 10

    def test_max_results_and_time_period_passthrough(self):
        """max_results and time_period are taken directly from request data."""
        sm = _make_settings_manager()
        data = {"max_results": 20, "time_period": "7d"}
        result = self._call(data, sm)
        assert result["max_results"] == 20
        assert result["time_period"] == "7d"

    def test_max_results_none_when_not_provided(self):
        """max_results defaults to None when not in request."""
        sm = _make_settings_manager()
        result = self._call({}, sm)
        assert result["max_results"] is None
        assert result["time_period"] is None

    def test_returns_all_expected_keys(self):
        """Result dict contains all expected keys."""
        sm = _make_settings_manager()
        result = self._call({}, sm)
        expected_keys = {
            "model_provider",
            "model",
            "custom_endpoint",
            "ollama_url",
            "search_engine",
            "max_results",
            "time_period",
            "iterations",
            "questions_per_iteration",
            "strategy",
            # Per-research egress policy overrides (N11).
            "policy_egress_scope",
            "llm_require_local_endpoint",
            "embeddings_require_local",
        }
        assert set(result.keys()) == expected_keys

    def test_zero_iterations_preserved(self):
        """iterations=0 in request is preserved, not overridden by DB default."""
        sm = _make_settings_manager({"search.iterations": 5})
        result = self._call({"iterations": 0}, sm)
        assert result["iterations"] == 0

    def test_empty_string_model_provider_falls_back(self):
        """Empty string model_provider falls back to DB setting."""
        sm = _make_settings_manager({"llm.provider": "ANTHROPIC"})
        result = self._call({"model_provider": ""}, sm)
        assert result["model_provider"] == "anthropic"

    def test_search_engine_takes_precedence_over_search_tool(self):
        """When both search_engine and search_tool are provided, search_engine wins."""
        sm = _make_settings_manager()
        data = {"search_engine": "google", "search_tool": "tavily"}
        result = self._call(data, sm)
        assert result["search_engine"] == "google"

    def test_ollama_url_not_fetched_for_non_ollama_provider(self):
        """ollama_url is None when provider is not OLLAMA."""
        sm = _make_settings_manager()
        result = self._call({"model_provider": "OPENAI"}, sm)
        assert result["ollama_url"] is None

    def test_zero_questions_per_iteration_preserved(self):
        """questions_per_iteration=0 is preserved, not overridden by DB default."""
        sm = _make_settings_manager({"search.questions_per_iteration": 5})
        result = self._call({"questions_per_iteration": 0}, sm)
        assert result["questions_per_iteration"] == 0

    def test_strategy_empty_string_falls_back(self):
        """Empty string strategy falls back to DB setting (uses truthiness check)."""
        sm = _make_settings_manager({"search.search_strategy": "comprehensive"})
        result = self._call({"strategy": ""}, sm)
        assert result["strategy"] == "comprehensive"


# ---------------------------------------------------------------------------
# _queue_research tests
# ---------------------------------------------------------------------------


class TestQueueResearch:
    """Tests for _queue_research()."""

    def _make_db_session(self, max_position=0):
        ms = MagicMock()
        ms.query.return_value.filter_by.return_value.scalar.return_value = (
            max_position
        )
        return ms

    def _make_params(self):
        return {
            "model_provider": "ollama",
            "model": "llama3",
            "custom_endpoint": None,
            "ollama_url": "http://localhost:11434",
            "search_engine": "searxng",
            "max_results": None,
            "time_period": None,
            "iterations": 5,
            "questions_per_iteration": 5,
            "strategy": "source-based",
        }

    def _call(self, app, db_session, **kwargs):
        from local_deep_research.web.routes.research_routes import (
            _queue_research,
        )

        defaults = {
            "db_session": db_session,
            "username": "testuser",
            "research_id": "r-123",
            "query": "test query",
            "mode": "quick",
            "research_settings": {"test": True},
            "params": self._make_params(),
            "session_id": "sid-1",
        }
        defaults.update(kwargs)
        with app.test_request_context():
            return _queue_research(**defaults)

    @patch(_QP)
    def test_creates_record_at_correct_position(self, mock_qp, app):
        """Queue position is max_position + 1, with correct record fields."""
        ms = self._make_db_session(max_position=2)
        self._call(app, ms)

        add_call = ms.add.call_args[0][0]
        assert add_call.position == 3
        assert add_call.username == "testuser"
        assert add_call.research_id == "r-123"
        assert add_call.query == "test query"
        assert add_call.mode == "quick"
        assert ms.commit.called

    @patch(_QP)
    def test_notifies_processor_with_all_params(self, mock_qp, app):
        """notify_research_queued receives ALL expected kwargs."""
        ms = self._make_db_session()
        self._call(app, ms)

        mock_qp.notify_research_queued.assert_called_once()
        args, kwargs = mock_qp.notify_research_queued.call_args
        # Positional args
        assert args == ("testuser", "r-123")
        # All keyword args
        assert kwargs["session_id"] == "sid-1"
        assert kwargs["query"] == "test query"
        assert kwargs["mode"] == "quick"
        assert kwargs["settings_snapshot"] == {"test": True}
        assert kwargs["model_provider"] == "ollama"
        assert kwargs["model"] == "llama3"
        assert kwargs["custom_endpoint"] is None
        assert kwargs["search_engine"] == "searxng"
        assert kwargs["max_results"] is None
        assert kwargs["time_period"] is None
        assert kwargs["iterations"] == 5
        assert kwargs["questions_per_iteration"] == 5
        assert kwargs["strategy"] == "source-based"

    @patch(_QP)
    def test_default_message(self, mock_qp, app):
        """Default message includes queue position."""
        ms = self._make_db_session(max_position=0)
        resp = self._call(app, ms)

        data = resp.get_json()
        assert (
            data["message"]
            == "Your research has been queued. Position in queue: 1"
        )
        assert data["queue_position"] == 1

    @patch(_QP)
    def test_race_condition_message(self, mock_qp, app):
        """Race condition reason is included in message."""
        ms = self._make_db_session(max_position=1)
        resp = self._call(app, ms, reason="due to concurrent limit")

        data = resp.get_json()
        assert "due to concurrent limit" in data["message"]
        assert "Position in queue: 2" in data["message"]

    @patch(_QP)
    def test_empty_queue_starts_at_position_one(self, mock_qp, app):
        """When queue is empty (max returns None/0), position starts at 1."""
        ms = self._make_db_session(max_position=0)
        resp = self._call(app, ms)

        data = resp.get_json()
        assert data["queue_position"] == 1

    @patch(_QP)
    def test_scalar_returns_none_position_starts_at_one(self, mock_qp, app):
        """When scalar() returns None (empty table), `or 0` fallback gives position 1."""
        ms = self._make_db_session()
        ms.query.return_value.filter_by.return_value.scalar.return_value = None
        resp = self._call(app, ms)

        data = resp.get_json()
        assert data["queue_position"] == 1

    @patch(_QP)
    def test_sets_status_when_research_provided(self, mock_qp, app):
        """When research object is passed, its status is set to QUEUED before commit."""
        from local_deep_research.constants import ResearchStatus

        ms = self._make_db_session()
        mock_research = MagicMock()
        self._call(app, ms, research=mock_research)

        assert mock_research.status == ResearchStatus.QUEUED
        assert ms.commit.called

    @patch(_QP)
    def test_skips_status_when_no_research(self, mock_qp, app):
        """Without research param, no status assignment happens (normal queue path)."""
        ms = self._make_db_session()
        resp = self._call(app, ms)

        # Verify the call completed successfully (no AttributeError on None.status)
        data = resp.get_json()
        assert data["queue_position"] == 1
        assert ms.commit.called


# ---------------------------------------------------------------------------
# Integration tests: encrypted-DB password gate
# ---------------------------------------------------------------------------


class TestStartResearchEncryptedDbGate:
    """Tests for the early password gate that returns 401 on encrypted DB."""

    @pytest.fixture(autouse=True)
    def _inject_session(self, app):
        @app.before_request
        def _set_sess():
            from flask import session

            session["username"] = "testuser"
            session["session_id"] = "sid-1"

    def test_encrypted_db_no_password_returns_401(self, client, app):
        """Route returns 401 (before DB writes) when the guard reports the
        session expired (encrypted DB, no password)."""
        ms = _mock_db_session(active_count=0)
        sm = _make_settings_manager()

        @app.before_request
        def _inject_g():
            g.db_session = ms

        with (
            patch(_GET_USER_DB, side_effect=_ctx_factory(ms)),
            patch(_SM_MANAGER, return_value=sm),
            patch(_SM_SETTINGS, return_value=sm),
            patch(f"{MODULE}.resolve_user_password", return_value=(None, True)),
        ):
            resp = client.post(
                "/api/start_research",
                json={"query": "test query", "model": "llama3"},
                content_type="application/json",
            )

        assert resp.status_code == 401
        data = resp.get_json()
        assert "session has expired" in data["message"].lower()

    def test_unencrypted_db_no_password_continues(
        self, client, app
    ):  # DevSkim: ignore DS101155 - testing DB encryption flag, not TLS certificates
        """When has_encryption=False and no password, research proceeds (200)."""
        ms = _mock_db_session(active_count=0)
        sm = _make_settings_manager()

        fake_thread = MagicMock(spec=threading.Thread)
        fake_thread.ident = 42

        @app.before_request
        def _inject_g():
            g.db_session = ms

        with (
            patch(_GET_USER_DB, side_effect=_ctx_factory(ms)),
            patch(_SM_MANAGER, return_value=sm),
            patch(_SM_SETTINGS, return_value=sm),
            patch(
                f"{MODULE}.resolve_user_password", return_value=(None, False)
            ),
            patch(
                f"{MODULE}.start_research_process", return_value=fake_thread
            ) as mock_start,
            patch(_SAVE_STRATEGY),
            patch(f"{MODULE}.log_settings"),
            patch(f"{MODULE}.ResearchHistory"),
            patch(f"{MODULE}.UserActiveResearch"),
        ):
            resp = client.post(
                "/api/start_research",
                json={"query": "test query", "model": "llama3"},
                content_type="application/json",
            )

        assert resp.status_code == 200
        mock_start.assert_called_once()
        assert mock_start.call_args[1]["user_password"] is None


# ---------------------------------------------------------------------------
# Integration tests: error-handling paths in start_research
# ---------------------------------------------------------------------------


class TestStartResearchErrorPaths:
    """Tests for exception handling paths in start_research()."""

    @pytest.fixture(autouse=True)
    def _inject_session(self, app):
        @app.before_request
        def _set_sess():
            from flask import session

            session["username"] = "testuser"
            session["session_id"] = "sid-1"

    def _make_fake_thread(self):
        fake_thread = MagicMock(spec=threading.Thread)
        fake_thread.ident = 42
        return fake_thread

    def test_active_count_query_exception_defaults_to_not_queuing(
        self, client, app
    ):
        """When active count query raises, should_queue defaults to False."""
        ms = MagicMock()
        # Make the count query raise an exception
        ms.query.return_value.filter_by.return_value.count.side_effect = (
            RuntimeError("DB error")
        )
        ms.query.return_value.filter_by.return_value.scalar.return_value = 0
        ms.query.return_value.filter_by.return_value.first.return_value = (
            MagicMock()
        )
        sm = _make_settings_manager()
        fake_thread = self._make_fake_thread()

        @app.before_request
        def _inject_g():
            g.db_session = ms

        with (
            patch(_GET_USER_DB, side_effect=_ctx_factory(ms)),
            patch(_SM_MANAGER, return_value=sm),
            patch(_SM_SETTINGS, return_value=sm),
            patch(f"{MODULE}.get_user_password", return_value="pw"),
            patch(f"{MODULE}.start_research_process", return_value=fake_thread),
            patch(_SAVE_STRATEGY),
            patch(f"{MODULE}.log_settings"),
            patch(f"{MODULE}.ResearchHistory"),
            patch(f"{MODULE}.UserActiveResearch"),
        ):
            resp = client.post(
                "/api/start_research",
                json={"query": "test query", "model": "llama3"},
                content_type="application/json",
            )

        # Research should still start (not queued)
        assert resp.status_code == 200
        assert resp.get_json()["status"] == "success"

    def test_save_research_strategy_failure_continues(self, client, app):
        """save_research_strategy exception is caught; research still starts."""
        ms = _mock_db_session(active_count=0)
        sm = _make_settings_manager()
        fake_thread = self._make_fake_thread()

        @app.before_request
        def _inject_g():
            g.db_session = ms

        with (
            patch(_GET_USER_DB, side_effect=_ctx_factory(ms)),
            patch(_SM_MANAGER, return_value=sm),
            patch(_SM_SETTINGS, return_value=sm),
            patch(f"{MODULE}.get_user_password", return_value="pw"),
            patch(
                f"{MODULE}.start_research_process", return_value=fake_thread
            ) as mock_start,
            patch(_SAVE_STRATEGY, side_effect=RuntimeError("strategy error")),
            patch(f"{MODULE}.log_settings"),
            patch(f"{MODULE}.ResearchHistory"),
            patch(f"{MODULE}.UserActiveResearch"),
        ):
            resp = client.post(
                "/api/start_research",
                json={"query": "test query", "model": "llama3"},
                content_type="application/json",
            )

        assert resp.status_code == 200
        # Thread was still started despite strategy save failure
        mock_start.assert_called_once()

    def test_thread_id_update_failure_continues(self, client, app):
        """Thread ID update exception is caught; research still returns 200."""
        ms = _mock_db_session(active_count=0)
        sm = _make_settings_manager()
        fake_thread = self._make_fake_thread()

        @app.before_request
        def _inject_g():
            g.db_session = ms

        # Make get_user_db_session work for param extraction but fail for
        # the post-spawn thread ID update
        call_count = [0]
        original_factory = _ctx_factory(ms)

        def _factory_that_fails_on_third(*args, **kwargs):
            call_count[0] += 1
            if call_count[0] >= 3:
                raise RuntimeError("thread ID update failed")
            return original_factory(*args, **kwargs)

        with (
            patch(_GET_USER_DB, side_effect=_factory_that_fails_on_third),
            patch(_SM_MANAGER, return_value=sm),
            patch(_SM_SETTINGS, return_value=sm),
            patch(f"{MODULE}.get_user_password", return_value="pw"),
            patch(f"{MODULE}.start_research_process", return_value=fake_thread),
            patch(_SAVE_STRATEGY),
            patch(f"{MODULE}.log_settings"),
            patch(f"{MODULE}.ResearchHistory"),
            patch(f"{MODULE}.UserActiveResearch"),
        ):
            resp = client.post(
                "/api/start_research",
                json={"query": "test query", "model": "llama3"},
                content_type="application/json",
            )

        assert resp.status_code == 200
        assert resp.get_json()["status"] == "success"

    def test_research_creation_exception_returns_500(self, client, app):
        """When ResearchHistory creation raises, return 500."""
        ms = _mock_db_session(active_count=0)
        sm = _make_settings_manager()

        @app.before_request
        def _inject_g():
            g.db_session = ms

        with (
            patch(_GET_USER_DB, side_effect=_ctx_factory(ms)),
            patch(_SM_MANAGER, return_value=sm),
            patch(_SM_SETTINGS, return_value=sm),
            patch(f"{MODULE}.get_user_password", return_value="pw"),
            patch(
                f"{MODULE}.ResearchHistory",
                side_effect=RuntimeError("creation failed"),
            ),
        ):
            resp = client.post(
                "/api/start_research",
                json={"query": "test query", "model": "llama3"},
                content_type="application/json",
            )

        assert resp.status_code == 500
        data = resp.get_json()
        assert "failed to create" in data["message"].lower()

    def test_recheck_active_count_exception_continues(self, client, app):
        """When race-condition recheck query raises, research still starts."""
        ms = _mock_db_session(active_count=0)
        sm = _make_settings_manager()
        fake_thread = self._make_fake_thread()

        # Make count() return 0 first (initial check), then raise on recheck
        count_calls = [0]
        original_count = 0

        def _count_side_effect():
            count_calls[0] += 1
            if count_calls[0] <= 1:
                return original_count
            raise RuntimeError("recheck failed")

        ms.query.return_value.filter_by.return_value.count.side_effect = (
            _count_side_effect
        )

        @app.before_request
        def _inject_g():
            g.db_session = ms

        with (
            patch(_GET_USER_DB, side_effect=_ctx_factory(ms)),
            patch(_SM_MANAGER, return_value=sm),
            patch(_SM_SETTINGS, return_value=sm),
            patch(f"{MODULE}.get_user_password", return_value="pw"),
            patch(f"{MODULE}.start_research_process", return_value=fake_thread),
            patch(_SAVE_STRATEGY),
            patch(f"{MODULE}.log_settings"),
            patch(f"{MODULE}.ResearchHistory"),
            patch(f"{MODULE}.UserActiveResearch"),
        ):
            resp = client.post(
                "/api/start_research",
                json={"query": "test query", "model": "llama3"},
                content_type="application/json",
            )

        # Research continues despite recheck failure
        assert resp.status_code == 200
        assert resp.get_json()["status"] == "success"


# ---------------------------------------------------------------------------
# Integration tests: validation gates in start_research
# ---------------------------------------------------------------------------


class TestStartResearchValidation:
    """Tests for request validation in start_research()."""

    @pytest.fixture(autouse=True)
    def _inject_session(self, app):
        @app.before_request
        def _set_sess():
            from flask import session

            session["username"] = "testuser"
            session["session_id"] = "sid-1"

    def test_missing_query_returns_400(self, client, app):
        """Empty/missing query returns 400 before any DB writes."""
        ms = _mock_db_session(active_count=0)
        sm = _make_settings_manager()

        @app.before_request
        def _inject_g():
            g.db_session = ms

        with (
            patch(_GET_USER_DB, side_effect=_ctx_factory(ms)),
            patch(_SM_MANAGER, return_value=sm),
            patch(_SM_SETTINGS, return_value=sm),
            patch(f"{MODULE}.get_user_password", return_value="pw"),
        ):
            resp = client.post(
                "/api/start_research",
                json={"model": "llama3"},
                content_type="application/json",
            )

        assert resp.status_code == 400
        assert "required" in resp.get_json()["message"].lower()

    def test_openai_endpoint_without_custom_endpoint_returns_400(
        self, client, app
    ):
        """OPENAI_ENDPOINT provider without custom_endpoint returns 400."""
        ms = _mock_db_session(active_count=0)
        sm = _make_settings_manager({"llm.openai_endpoint.url": None})

        @app.before_request
        def _inject_g():
            g.db_session = ms

        with (
            patch(_GET_USER_DB, side_effect=_ctx_factory(ms)),
            patch(_SM_MANAGER, return_value=sm),
            patch(_SM_SETTINGS, return_value=sm),
            patch(f"{MODULE}.get_user_password", return_value="pw"),
        ):
            resp = client.post(
                "/api/start_research",
                json={
                    "query": "test",
                    "model_provider": "OPENAI_ENDPOINT",
                    "model": "gpt-4",
                },
                content_type="application/json",
            )

        assert resp.status_code == 400
        assert "custom endpoint" in resp.get_json()["message"].lower()

    def test_missing_model_returns_400(self, client, app):
        """No model configured anywhere returns 400."""
        ms = _mock_db_session(active_count=0)
        sm = _make_settings_manager({"llm.model": None})

        @app.before_request
        def _inject_g():
            g.db_session = ms

        with (
            patch(_GET_USER_DB, side_effect=_ctx_factory(ms)),
            patch(_SM_MANAGER, return_value=sm),
            patch(_SM_SETTINGS, return_value=sm),
            patch(f"{MODULE}.get_user_password", return_value="pw"),
        ):
            resp = client.post(
                "/api/start_research",
                json={"query": "test", "model_provider": "OLLAMA"},
                content_type="application/json",
            )

        assert resp.status_code == 400
        assert "model" in resp.get_json()["message"].lower()

    def test_settings_snapshot_failure_returns_500(self, client, app):
        """When settings snapshot capture fails, return 500."""
        ms = _mock_db_session(active_count=0)
        sm = _make_settings_manager()

        @app.before_request
        def _inject_g():
            g.db_session = ms

        # Make get_all_settings raise to trigger the snapshot failure path
        sm.get_all_settings.side_effect = RuntimeError("snapshot failed")

        with (
            patch(_GET_USER_DB, side_effect=_ctx_factory(ms)),
            patch(_SM_MANAGER, return_value=sm),
            patch(_SM_SETTINGS, return_value=sm),
            patch(f"{MODULE}.get_user_password", return_value="pw"),
        ):
            resp = client.post(
                "/api/start_research",
                json={"query": "test", "model": "llama3"},
                content_type="application/json",
            )

        assert resp.status_code == 500
        assert "settings" in resp.get_json()["message"].lower()


# ---------------------------------------------------------------------------
# Integration tests: happy-path param pass-through
# ---------------------------------------------------------------------------


class TestStartResearchHappyPath:
    """Tests verifying correct param flow through the happy path."""

    @pytest.fixture(autouse=True)
    def _inject_session(self, app):
        @app.before_request
        def _set_sess():
            from flask import session

            session["username"] = "testuser"
            session["session_id"] = "sid-1"

    def test_thread_receives_all_params(self, client, app):
        """start_research_process is called with all extracted params."""
        ms = _mock_db_session(active_count=0)
        sm = _make_settings_manager()
        fake_thread = MagicMock(spec=threading.Thread)
        fake_thread.ident = 42

        @app.before_request
        def _inject_g():
            g.db_session = ms

        patches = _happy_path_patches(ms, sm, fake_thread)
        with (
            patches[0],
            patches[1],
            patches[2],
            patches[3],
            patches[4] as mock_start,
            patches[5],
            patches[6],
            patches[7],
            patches[8],
        ):
            resp = client.post(
                "/api/start_research",
                json={
                    "query": "test query",
                    "model_provider": "OLLAMA",
                    "model": "llama3",
                    "search_engine": "tavily",
                    "max_results": 15,
                    "time_period": "30d",
                    "iterations": 3,
                    "questions_per_iteration": 2,
                    "strategy": "comprehensive",
                },
                content_type="application/json",
            )

        assert resp.status_code == 200
        mock_start.assert_called_once()
        kwargs = mock_start.call_args[1]
        assert kwargs["username"] == "testuser"
        assert kwargs["user_password"] == "pw"
        assert kwargs["model_provider"] == "ollama"
        assert kwargs["model"] == "llama3"
        assert kwargs["search_engine"] == "tavily"
        assert kwargs["max_results"] == 15
        assert kwargs["time_period"] == "30d"
        assert kwargs["iterations"] == 3
        assert kwargs["questions_per_iteration"] == 2
        assert kwargs["strategy"] == "comprehensive"

    def test_queued_research_has_queued_status(self, client, app):
        """When should_queue=True, ResearchHistory is created with QUEUED status."""
        ms = _mock_db_session(active_count=5)  # exceeds default max of 3
        sm = _make_settings_manager()

        @app.before_request
        def _inject_g():
            g.db_session = ms

        with (
            patch(_GET_USER_DB, side_effect=_ctx_factory(ms)),
            patch(_SM_MANAGER, return_value=sm),
            patch(_SM_SETTINGS, return_value=sm),
            patch(f"{MODULE}.get_user_password", return_value="pw"),
            patch(f"{MODULE}.ResearchHistory") as mock_rh,
            patch(_QP),
        ):
            resp = client.post(
                "/api/start_research",
                json={"query": "test query", "model": "llama3"},
                content_type="application/json",
            )

        assert resp.status_code == 200
        # Verify ResearchHistory was created with QUEUED status
        from local_deep_research.constants import ResearchStatus

        create_kwargs = mock_rh.call_args[1]
        assert create_kwargs["status"] == ResearchStatus.QUEUED

    def test_non_queued_research_has_in_progress_status(self, client, app):
        """When should_queue=False, ResearchHistory is created with IN_PROGRESS status."""
        ms = _mock_db_session(active_count=0)
        sm = _make_settings_manager()
        fake_thread = MagicMock(spec=threading.Thread)
        fake_thread.ident = 42

        @app.before_request
        def _inject_g():
            g.db_session = ms

        patches = _happy_path_patches(ms, sm, fake_thread)
        with (
            patches[0],
            patches[1],
            patches[2],
            patches[3],
            patches[4],
            patches[5],
            patches[6],
            patches[7] as mock_rh,
            patches[8],
        ):
            resp = client.post(
                "/api/start_research",
                json={"query": "test query", "model": "llama3"},
                content_type="application/json",
            )

        assert resp.status_code == 200
        from local_deep_research.constants import ResearchStatus

        create_kwargs = mock_rh.call_args[1]
        assert create_kwargs["status"] == ResearchStatus.IN_PROGRESS

    def test_custom_endpoint_from_request_data(self):
        """custom_endpoint provided in request data is used directly."""
        sm = _make_settings_manager()
        from local_deep_research.web.routes.research_routes import (
            _extract_research_params,
        )

        result = _extract_research_params(
            {
                "model_provider": "OPENAI_ENDPOINT",
                "custom_endpoint": "http://my-api.com/v1",
            },
            sm,
        )
        assert result["custom_endpoint"] == "http://my-api.com/v1"
        # DB lookup should not have overridden the request value
        assert result["model_provider"] == "openai_endpoint"

    def test_empty_model_falls_back_to_db(self):
        """Empty string model falls back to DB setting (truthiness check)."""
        sm = _make_settings_manager({"llm.model": "deepseek-r1"})
        from local_deep_research.web.routes.research_routes import (
            _extract_research_params,
        )

        result = _extract_research_params({"model": ""}, sm)
        assert result["model"] == "deepseek-r1"
