"""Coverage tests for the start_research route in research_routes.py.

Targeted branches:
- Date placeholder replacement (YYYY-MM-DD in query)
- Settings loaded from DB defaults when not in request
- Missing query -> 400
- Missing model (no DB default) -> 400
- OPENAI_ENDPOINT provider without custom_endpoint -> 400
- active_count >= max_concurrent -> research queued
- Race condition: final_count > max -> requeue
- No g.db_session -> fallback temporary session for settings snapshot
- Settings snapshot exception -> 500
- Password retrieved from temp_auth_store
- Full happy path: thread spawned -> 200
- UserActiveResearch record created
"""

import threading
import uuid
from contextlib import contextmanager
from datetime import UTC, datetime
from unittest.mock import MagicMock, patch

import pytest
from flask import Flask, g

MODULE = "local_deep_research.web.routes.research_routes"

# SettingsManager is imported locally inside start_research at two paths;
# patch both so whichever import runs first picks up the mock.
_SM_MANAGER = "local_deep_research.settings.manager.SettingsManager"
_SM_SETTINGS = "local_deep_research.settings.SettingsManager"

_GET_USER_DB = f"{MODULE}.get_user_db_session"

# Symbols imported locally (inside the function body) — patch at source path.
_SESSION_PW_STORE = (
    "local_deep_research.database.session_passwords.session_password_store"
)
_GET_METRICS_SESSION = (
    "local_deep_research.database.thread_local_session.get_metrics_session"
)
_TEMP_AUTH_STORE = "local_deep_research.database.temp_auth.temp_auth_store"
_SAVE_STRATEGY = (
    "local_deep_research.web.services.research_service.save_research_strategy"
)
_QUEUE_PROCESSOR = "local_deep_research.web.queue.processor_v2.queue_processor"


# ---------------------------------------------------------------------------
# Helpers  (same patterns as test_research_routes_deep_coverage.py)
# ---------------------------------------------------------------------------


def _uid():
    return uuid.uuid4().hex[:8]


def _mock_db_session():
    """Create a MagicMock that works as a SQLAlchemy session."""
    return MagicMock()


@contextmanager
def _ctx(session):
    """Context manager wrapping a mock session."""
    yield session


def _ctx_factory(session):
    """Return a callable that always yields session (for side_effect use)."""

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

    return _factory


def _make_settings_manager(provider="OLLAMA", model="llama3", **extra):
    """Return a SettingsManager mock whose get_setting uses a lookup table."""
    sm = MagicMock()
    lookup = {
        "llm.provider": provider,
        "llm.model": model,
        "llm.ollama.url": "http://localhost:11434",
        "llm.openai_endpoint.url": extra.get("openai_url", None),
        "search.tool": "searxng",
        "search.iterations": 5,
        "search.questions_per_iteration": 5,
        "search.search_strategy": "source-based",
        "app.max_concurrent_researches": extra.get("max_concurrent", 3),
    }

    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


def _configure_ms_for_active(ms, active_count=0, max_pos=0):
    """Set up the mock db session for typical start_research usage."""
    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()
    )


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


@pytest.fixture()
def app():
    """Create a minimal Flask app with the research blueprint, auth bypassed.

    has_encryption=False avoids the early password check that returns 401
    when no password is available for encrypted databases.
    """
    flask_app = Flask(__name__)
    flask_app.secret_key = "test-secret-key"
    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 now reads has_encryption via the shared
        # resolve_user_password helper, which imports db_manager from
        # encrypted_db. Patch it at the source so the real password-
        # resolution chain (incl. temp_auth) still runs; has_encryption=False
        # keeps the guard from 401-ing when no password is configured.
        patch(
            "local_deep_research.database.encrypted_db.db_manager"
        ) as mock_enc_db,
    ):
        mock_db.is_user_connected.return_value = True
        mock_enc_db.has_encryption = False
        yield flask_app


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


@pytest.fixture(autouse=True)
def _inject_session(app):
    """Inject authenticated session for every request."""

    @app.before_request
    def _set_sess():
        from flask import session

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


# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------


class TestStartResearchDatePlaceholder:
    """YYYY-MM-DD in the query should be replaced with today's date."""

    def test_start_research_date_placeholder_replacement(self, client, app):
        ms = _mock_db_session()
        _configure_ms_for_active(ms, active_count=0)
        sm = _make_settings_manager()

        fake_thread = MagicMock(spec=threading.Thread)
        fake_thread.ident = 12345
        captured = {}

        def fake_spawn(research_id, query, *args, **kwargs):
            captured["query"] = query
            return fake_thread

        # Inject g.db_session inside the real request context via before_request
        @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}.start_research_process", side_effect=fake_spawn),
            patch(_SAVE_STRATEGY),
            patch(f"{MODULE}.log_settings"),
            patch(_SESSION_PW_STORE) as mock_sps,
            patch(f"{MODULE}.ResearchHistory"),
            patch(f"{MODULE}.UserActiveResearch"),
        ):
            mock_sps.get_session_password.return_value = "pw"

            resp = client.post(
                "/api/start_research",
                json={
                    "query": "What happened on YYYY-MM-DD?",
                    "model": "llama3",
                },
                content_type="application/json",
            )

        today = datetime.now(UTC).strftime("%Y-%m-%d")
        assert resp.status_code == 200
        data = resp.get_json()
        assert data["status"] == "success"
        # The thread was spawned with the replaced query — no placeholder remaining
        assert "YYYY-MM-DD" not in captured.get("query", "YYYY-MM-DD")
        assert today in captured.get("query", "")


class TestStartResearchSettingsFromDb:
    """When no model/provider in request, settings are loaded from DB."""

    def test_start_research_settings_from_db_defaults(self, client, app):
        ms = _mock_db_session()
        _configure_ms_for_active(ms, active_count=0)
        sm = _make_settings_manager(provider="OLLAMA", model="mistral")

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

        @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}.start_research_process", return_value=fake_thread),
            patch(_SAVE_STRATEGY),
            patch(f"{MODULE}.log_settings"),
            patch(_SESSION_PW_STORE) as mock_sps,
            patch(f"{MODULE}.ResearchHistory"),
            patch(f"{MODULE}.UserActiveResearch"),
        ):
            mock_sps.get_session_password.return_value = "pw"

            resp = client.post(
                "/api/start_research",
                # No model_provider or model — both should come from DB
                json={"query": "Tell me about AI"},
                content_type="application/json",
            )

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


class TestStartResearchMissingQuery:
    """POST with no query field should return 400."""

    def test_start_research_missing_query(self, client):
        ms = _mock_db_session()
        sm = _make_settings_manager(model="llama3")

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

        assert resp.status_code == 400
        data = resp.get_json()
        assert data["status"] == "error"
        assert "query" in data["message"].lower()


class TestStartResearchMissingModel:
    """POST with no model and DB has no default -> 400."""

    def test_start_research_missing_model(self, client):
        ms = _mock_db_session()
        # DB returns None for llm.model
        sm = _make_settings_manager(model=None)

        with (
            patch(_GET_USER_DB, side_effect=_ctx_factory(ms)),
            patch(_SM_MANAGER, return_value=sm),
            patch(_SM_SETTINGS, return_value=sm),
        ):
            resp = client.post(
                "/api/start_research",
                json={"query": "What is gravity?"},
                content_type="application/json",
            )

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


class TestStartResearchOpenAINoEndpoint:
    """provider=OPENAI_ENDPOINT but no custom_endpoint -> 400."""

    def test_start_research_openai_no_custom_endpoint(self, client):
        ms = _mock_db_session()
        sm = _make_settings_manager(
            provider="OPENAI_ENDPOINT",
            model="gpt-4",
            openai_url=None,  # no URL in DB either
        )

        with (
            patch(_GET_USER_DB, side_effect=_ctx_factory(ms)),
            patch(_SM_MANAGER, return_value=sm),
            patch(_SM_SETTINGS, return_value=sm),
        ):
            resp = client.post(
                "/api/start_research",
                json={
                    "query": "Tell me about the universe",
                    "model_provider": "OPENAI_ENDPOINT",
                    "model": "gpt-4",
                    # no custom_endpoint key
                },
                content_type="application/json",
            )

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


class TestStartResearchCustomEndpointSSRF:
    """The custom_endpoint URL is later handed to the OpenAI client (httpx)
    with no SafeSession wrapping, so the route layer is the only place to
    reject cloud-metadata / link-local targets before the request goes out.
    Private IPs and localhost pass because local LLMs live there.
    """

    _AWS_METADATA = (
        "http://169.254.169.254/latest/meta-data/iam/security-credentials/"
    )

    def test_metadata_endpoint_rejected(self, client):
        ms = _mock_db_session()
        sm = _make_settings_manager(model="gpt-4")

        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}.start_research_process") as mock_spawn,
        ):
            resp = client.post(
                "/api/start_research",
                json={
                    "query": "anything",
                    "model": "gpt-4",
                    "custom_endpoint": self._AWS_METADATA,
                },
                content_type="application/json",
            )

        assert resp.status_code == 400
        data = resp.get_json()
        assert data["status"] == "error"
        mock_spawn.assert_not_called()

    def test_garbage_url_rejected(self, client):
        ms = _mock_db_session()
        sm = _make_settings_manager(model="gpt-4")

        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}.start_research_process") as mock_spawn,
        ):
            resp = client.post(
                "/api/start_research",
                json={
                    "query": "anything",
                    "model": "gpt-4",
                    "custom_endpoint": "not-a-url",
                },
                content_type="application/json",
            )

        assert resp.status_code == 400
        mock_spawn.assert_not_called()

    def test_localhost_endpoint_accepted(self, client, app):
        # Local LLM providers (Ollama / LM Studio / vLLM) live on localhost;
        # validation must not reject them. Mirrors the happy-path setup in
        # TestStartResearchSettingsFromDb so we exercise the full path past
        # the SSRF check, not just the validation itself.
        ms = _mock_db_session()
        _configure_ms_for_active(ms, active_count=0, max_pos=0)
        sm = _make_settings_manager(model="gpt-4")
        fake_thread = MagicMock(spec=threading.Thread)
        fake_thread.ident = 99

        @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}.start_research_process", return_value=fake_thread),
            patch(_SAVE_STRATEGY),
            patch(f"{MODULE}.log_settings"),
            patch(_SESSION_PW_STORE) as mock_sps,
            patch(f"{MODULE}.ResearchHistory"),
            patch(f"{MODULE}.UserActiveResearch"),
        ):
            mock_sps.get_session_password.return_value = "pw"

            resp = client.post(
                "/api/start_research",
                json={
                    "query": "anything",
                    "model": "gpt-4",
                    "custom_endpoint": "http://localhost:11434/v1",
                },
                content_type="application/json",
            )

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


class TestStartResearchShouldQueue:
    """active_count >= max_concurrent -> research gets queued."""

    def test_start_research_should_queue(self, client, app):
        ms = _mock_db_session()
        # active_count = 3 == max_concurrent = 3 -> should queue
        _configure_ms_for_active(ms, active_count=3, max_pos=2)
        sm = _make_settings_manager(model="llama3", max_concurrent=3)
        mock_qp = MagicMock()

        @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}.ResearchHistory"),
            patch(f"{MODULE}.QueuedResearch"),
            patch(_QUEUE_PROCESSOR, mock_qp),
            patch(_SESSION_PW_STORE) as mock_sps,
        ):
            mock_sps.get_session_password.return_value = "pw"

            resp = client.post(
                "/api/start_research",
                json={"query": "Queued research topic", "model": "llama3"},
                content_type="application/json",
            )

        assert resp.status_code == 200
        data = resp.get_json()
        assert data["status"] == "queued"
        assert "research_id" in data
        assert "queue_position" in data


class TestStartResearchRaceConditionRequeue:
    """final_count > max triggers requeue after initial active record creation."""

    def test_start_research_race_condition_requeue(self, client, app):
        ms = _mock_db_session()

        call_count = {"n": 0}

        def count_side_effect():
            call_count["n"] += 1
            # First call (active_count check): below max -> don't queue initially
            # Second call (final recheck after commit): above max -> requeue
            if call_count["n"] == 1:
                return 2
            return 4

        ms.query.return_value.filter_by.return_value.count.side_effect = (
            count_side_effect
        )
        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(model="llama3", max_concurrent=3)
        mock_qp = MagicMock()

        @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}.ResearchHistory"),
            patch(f"{MODULE}.UserActiveResearch"),
            patch(f"{MODULE}.QueuedResearch"),
            patch(_QUEUE_PROCESSOR, mock_qp),
            patch(_SESSION_PW_STORE) as mock_sps,
        ):
            mock_sps.get_session_password.return_value = "pw"

            resp = client.post(
                "/api/start_research",
                json={"query": "Race condition test", "model": "llama3"},
                content_type="application/json",
            )

        assert resp.status_code == 200
        data = resp.get_json()
        # After race condition detection, research should be queued
        assert data["status"] == "queued"


class TestStartResearchNoGDbSession:
    """When g has no db_session, a fallback temp session is used for snapshot."""

    def test_start_research_no_g_db_session(self, client):
        # g.db_session is NOT set — the code falls into the else branch for
        # settings snapshot. The fallback uses session_password_store to get a
        # password, then get_metrics_session to build a temporary session.
        ms = _mock_db_session()
        _configure_ms_for_active(ms, active_count=0)
        sm = _make_settings_manager(model="llama3")
        temp_session = MagicMock()
        fake_thread = MagicMock(spec=threading.Thread)
        fake_thread.ident = 42

        with (
            patch(_GET_USER_DB, side_effect=_ctx_factory(ms)),
            patch(_SM_MANAGER, return_value=sm),
            patch(_SM_SETTINGS, return_value=sm),
            patch(_SESSION_PW_STORE) as mock_sps,
            patch(_GET_METRICS_SESSION, return_value=temp_session),
            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"),
        ):
            mock_sps.get_session_password.return_value = "pw"

            # Do NOT register a before_request that sets g.db_session.
            # The test client request context will not have it set.
            resp = client.post(
                "/api/start_research",
                json={"query": "No g.db_session path", "model": "llama3"},
                content_type="application/json",
            )

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


class TestStartResearchSnapshotFailure:
    """If get_all_settings raises an exception -> 500."""

    def test_start_research_settings_snapshot_failure(self, client, app):
        ms = _mock_db_session()
        ms.query.return_value.filter_by.return_value.count.return_value = 0
        sm = _make_settings_manager(model="llama3")
        # Make get_all_settings blow up to force the snapshot to fail
        sm.get_all_settings.side_effect = RuntimeError("DB exploded")

        # Inject g.db_session so the snapshot uses the g-branch path, which
        # calls get_all_settings and will trigger the exception.
        @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),
        ):
            resp = client.post(
                "/api/start_research",
                json={"query": "Snapshot failure test", "model": "llama3"},
                content_type="application/json",
            )

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


class TestStartResearchPasswordFromTempAuth:
    """Last-resort password retrieval path via temp_auth_store."""

    def test_start_research_password_from_temp_auth(self, client, app):
        ms = _mock_db_session()
        _configure_ms_for_active(ms, active_count=0)
        sm = _make_settings_manager(model="llama3")

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

        mock_temp_auth = MagicMock()
        # peek_auth returns (username, password) tuple
        mock_temp_auth.peek_auth.return_value = ("testuser", "secret_pw")

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

        # Also inject temp_auth_token into the Flask session
        @app.before_request
        def _set_temp_auth_token():
            from flask import session as s

            s["temp_auth_token"] = "tok-abc"

        with (
            patch(_GET_USER_DB, side_effect=_ctx_factory(ms)),
            patch(_SM_MANAGER, return_value=sm),
            patch(_SM_SETTINGS, return_value=sm),
            # session_password_store returns None -> fall through to temp_auth
            patch(_SESSION_PW_STORE) as mock_sps,
            patch(_TEMP_AUTH_STORE, mock_temp_auth),
            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"),
        ):
            mock_sps.get_session_password.return_value = None

            resp = client.post(
                "/api/start_research",
                json={"query": "Temp auth password path", "model": "llama3"},
                content_type="application/json",
            )

        assert resp.status_code == 200
        data = resp.get_json()
        assert data["status"] == "success"
        # peek_auth should have been called with the injected token
        mock_temp_auth.peek_auth.assert_called_once_with("tok-abc")


class TestStartResearchThreadSpawn:
    """Spawn path: success returns 200; failure cleans up and returns 500."""

    def test_start_research_thread_spawn_failure_cleans_up(self, client, app):
        """If start_research_process raises, the orphan UserActiveResearch row
        must be deleted and ResearchHistory.status set to FAILED before we
        respond with 500 — matches the queue processor's terminal-failure
        contract added in #3481."""
        ms = _mock_db_session()
        _configure_ms_for_active(ms, active_count=0)
        sm = _make_settings_manager(model="llama3")

        stale_active = MagicMock()
        research_row = MagicMock()
        cleanup_session = MagicMock()

        def cleanup_query_factory(model):
            q = MagicMock()
            q.filter_by.return_value = q

            class _UAR:
                pass

            class _RH:
                pass

            if getattr(model, "__name__", "") == "UserActiveResearch":
                q.first.return_value = stale_active
            elif getattr(model, "__name__", "") == "ResearchHistory":
                q.first.return_value = research_row
            else:
                q.first.return_value = MagicMock()
            return q

        cleanup_session.query.side_effect = cleanup_query_factory

        def cleanup_ctx(*args, **kwargs):
            return _ctx(cleanup_session)

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

        with (
            patch(_GET_USER_DB, side_effect=cleanup_ctx),
            patch(_SM_MANAGER, return_value=sm),
            patch(_SM_SETTINGS, return_value=sm),
            patch(
                f"{MODULE}.start_research_process",
                side_effect=RuntimeError("boom"),
            ),
            patch(_SAVE_STRATEGY),
            patch(f"{MODULE}.log_settings"),
            patch(_SESSION_PW_STORE) as mock_sps,
            patch(f"{MODULE}.ResearchHistory") as mock_rh_cls,
            patch(f"{MODULE}.UserActiveResearch") as mock_uar_cls,
        ):
            mock_sps.get_session_password.return_value = "pw"
            mock_rh_cls.__name__ = "ResearchHistory"
            mock_uar_cls.__name__ = "UserActiveResearch"

            resp = client.post(
                "/api/start_research",
                json={
                    "query": "Spawn failure test",
                    "model": "llama3",
                    "mode": "deep",
                },
                content_type="application/json",
            )

        assert resp.status_code == 500
        data = resp.get_json()
        assert data["status"] == "error"
        # Orphan UserActiveResearch row deleted, ResearchHistory marked FAILED.
        cleanup_session.delete.assert_called_with(stale_active)
        from local_deep_research.constants import ResearchStatus

        assert research_row.status == ResearchStatus.FAILED
        cleanup_session.commit.assert_called()

    def test_start_research_duplicate_error_leaves_state_intact(
        self, client, app
    ):
        """If start_research_process raises DuplicateResearchError a live
        thread already owns this research_id. The route must return 409
        without deleting the UserActiveResearch row or marking
        ResearchHistory FAILED — that state belongs to the live thread."""
        from local_deep_research.exceptions import DuplicateResearchError

        ms = _mock_db_session()
        _configure_ms_for_active(ms, active_count=0)
        sm = _make_settings_manager(model="llama3")

        cleanup_session = MagicMock()

        def cleanup_ctx(*args, **kwargs):
            return _ctx(cleanup_session)

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

        with (
            patch(_GET_USER_DB, side_effect=cleanup_ctx),
            patch(_SM_MANAGER, return_value=sm),
            patch(_SM_SETTINGS, return_value=sm),
            patch(
                f"{MODULE}.start_research_process",
                side_effect=DuplicateResearchError(
                    "research already has a live thread"
                ),
            ),
            patch(_SAVE_STRATEGY),
            patch(f"{MODULE}.log_settings"),
            patch(_SESSION_PW_STORE) as mock_sps,
            patch(f"{MODULE}.ResearchHistory"),
            patch(f"{MODULE}.UserActiveResearch"),
        ):
            mock_sps.get_session_password.return_value = "pw"

            resp = client.post(
                "/api/start_research",
                json={
                    "query": "Duplicate live thread test",
                    "model": "llama3",
                    "mode": "deep",
                },
                content_type="application/json",
            )

        # 409 Conflict — not 500, and no cleanup session opened for
        # the spawn-failure cleanup branch.
        assert resp.status_code == 409
        data = resp.get_json()
        assert data["status"] == "error"
        # Critical invariants: no delete call on any session, and the
        # cleanup session in particular must never be touched — if the
        # spawn-failure branch had run, it would have called
        # cleanup_session.delete + cleanup_session.commit.
        cleanup_session.delete.assert_not_called()
        cleanup_session.commit.assert_not_called()

    def test_start_research_thread_spawn_success(self, client, app):
        ms = _mock_db_session()
        _configure_ms_for_active(ms, active_count=0)
        sm = _make_settings_manager(model="llama3")

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

        spawn_calls = []

        def fake_spawn(research_id, query, mode, run_fn, **kwargs):
            spawn_calls.append(
                {"research_id": research_id, "query": query, "mode": mode}
            )
            return 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}.start_research_process", side_effect=fake_spawn),
            patch(_SAVE_STRATEGY),
            patch(f"{MODULE}.log_settings"),
            patch(_SESSION_PW_STORE) as mock_sps,
            patch(f"{MODULE}.ResearchHistory"),
            patch(f"{MODULE}.UserActiveResearch"),
        ):
            mock_sps.get_session_password.return_value = "pw"

            resp = client.post(
                "/api/start_research",
                json={
                    "query": "Thread spawn test",
                    "model": "llama3",
                    "mode": "deep",
                },
                content_type="application/json",
            )

        assert resp.status_code == 200
        data = resp.get_json()
        assert data["status"] == "success"
        assert "research_id" in data
        assert len(spawn_calls) == 1
        assert spawn_calls[0]["query"] == "Thread spawn test"
        assert spawn_calls[0]["mode"] == "deep"


class TestStartResearchActiveResearchTracking:
    """UserActiveResearch record should be created on the happy path."""

    def test_start_research_active_research_tracking(self, client, app):
        ms = _mock_db_session()
        _configure_ms_for_active(ms, active_count=0)
        sm = _make_settings_manager(model="llama3")

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

        active_research_kwargs = []

        def capture_uar(**kwargs):
            active_research_kwargs.append(kwargs)
            return MagicMock()

        @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}.start_research_process", return_value=fake_thread),
            patch(_SAVE_STRATEGY),
            patch(f"{MODULE}.log_settings"),
            patch(_SESSION_PW_STORE) as mock_sps,
            patch(f"{MODULE}.ResearchHistory"),
            patch(f"{MODULE}.UserActiveResearch", side_effect=capture_uar),
        ):
            mock_sps.get_session_password.return_value = "pw"

            resp = client.post(
                "/api/start_research",
                json={"query": "Active tracking test", "model": "llama3"},
                content_type="application/json",
            )

        assert resp.status_code == 200
        # UserActiveResearch should have been instantiated at least once
        assert len(active_research_kwargs) >= 1
        # Verify key fields were passed
        uar_kw = active_research_kwargs[0]
        assert uar_kw.get("username") == "testuser"
        assert "research_id" in uar_kw
        assert "status" in uar_kw
