"""
Tests covering uncovered lines in news/flask_api.py.

Targets:
- scheduler_control_required decorator 403 path with session username logging
- _is_job_owned_by_user() fallback via scheduler.user_sessions
- run_subscription_now() subscription lookup, config extraction, response paths
- check_subscriptions_now() overdue query + threading
- trigger_cleanup() APScheduler job scheduling
- update_subscription_folder() dynamic field update + next_refresh recalculation
- get_search_history() / clear_search_history() unauthenticated return paths
- add_search_history() data validation
"""

import pytest
from unittest.mock import MagicMock, patch
from flask import Flask, jsonify


@pytest.fixture
def app():
    """Create a Flask app with the news blueprint registered."""
    app = Flask(__name__)
    app.config["SECRET_KEY"] = "test-secret-key"
    app.config["WTF_CSRF_ENABLED"] = False
    app.config["TESTING"] = True

    from local_deep_research.news.flask_api import news_api_bp

    app.register_blueprint(news_api_bp, url_prefix="/news/api")
    return app


@pytest.fixture
def client(app):
    """Create a test client."""
    return app.test_client()


def _auth_session(client, username="testuser"):
    """Inject a valid session so login_required passes."""
    with client.session_transaction() as sess:
        sess["username"] = username


def _session_ctx_returning(sub):
    """Build a mocked get_user_db_session() context manager whose
    ``query(...).filter(...).first()`` yields ``sub`` (a real NewsSubscription
    or None). run_subscription_now reads the ORM row directly, so tests supply
    a real model instance rather than the trimmed api.get_subscriptions dict.
    """
    session = MagicMock()
    session.query.return_value.filter.return_value.first.return_value = sub
    ctx = MagicMock()
    ctx.__enter__ = MagicMock(return_value=session)
    ctx.__exit__ = MagicMock(return_value=False)
    return ctx


class TestCallStartResearchInternal:
    """Cover _call_start_research_internal: the in-process replacement for the
    old loopback HTTP POST to /research/api/start.

    The loopback could never pass CSRF (only the api_v1 blueprint is exempt),
    so both run_subscription_now and check_overdue_subscriptions were broken.
    Calling the route handler directly fixes that, but the nested request
    context MUST carry the caller's full auth context: start_research() resolves
    the user's DB password from session["session_id"] (-> password store) or
    g.user_password. Propagating only session["username"] makes research fail
    with "session expired" on encrypted databases — this test guards that
    regression.
    """

    def test_propagates_session_and_db_to_research_handler(self, app):
        from local_deep_research.news.flask_api import (
            _call_start_research_internal,
        )

        captured = {}

        def _spy_start_research():
            from flask import g as gg, request as rq
            from flask import session as ss

            captured["session"] = dict(ss)
            captured["db_session"] = getattr(gg, "db_session", None)
            captured["user_password"] = getattr(gg, "user_password", None)
            captured["payload"] = rq.get_json()
            return jsonify({"status": "success", "research_id": "spy_res"})

        sentinel_db = object()

        with app.test_request_context():
            from flask import g, session

            # Authenticated caller context: username AND session_id (the key
            # the DB-password store is keyed by) plus g state.
            session["username"] = "alice"
            session["session_id"] = "sid-abc123"
            g.db_session = sentinel_db
            g.user_password = "hunter2"  # gitleaks:allow

            with patch(
                "local_deep_research.web.routes.research_routes.start_research",
                _spy_start_research,
            ):
                result = _call_start_research_internal({"query": "hello"})

        # Returns the handler's JSON body as a dict.
        assert result == {"status": "success", "research_id": "spy_res"}
        # The request payload reached the handler.
        assert captured["payload"] == {"query": "hello"}
        # Critically: session_id is propagated (not just username), so
        # resolve_user_password() can find the password. The PR this supersedes
        # copied only username and would fail this assertion.
        assert captured["session"]["username"] == "alice"
        assert captured["session"]["session_id"] == "sid-abc123"
        # The handler sees the caller's db session and password. Note these
        # arrive via the shared app-context ``g`` (the nested request context
        # reuses it), so these two assertions document the contract rather than
        # exercise the helper's explicit (defensive) g-copying. The session_id
        # assertion above is the one that fails against the username-only bug.
        assert captured["db_session"] is sentinel_db
        assert captured["user_password"] == "hunter2"  # gitleaks:allow

    def test_handles_tuple_response_with_status_code(self, app):
        """start_research may return (Response, status_code); the helper must
        unwrap the body either way."""
        from local_deep_research.news.flask_api import (
            _call_start_research_internal,
        )

        def _spy_start_research():
            return jsonify({"status": "error", "message": "boom"}), 400

        with app.test_request_context():
            from flask import session

            session["username"] = "alice"
            session["session_id"] = "sid-xyz"

            with patch(
                "local_deep_research.web.routes.research_routes.start_research",
                _spy_start_research,
            ):
                result = _call_start_research_internal({"query": "q"})

        assert result == {"status": "error", "message": "boom"}


class TestSchedulerControlRequiredLogging:
    """Cover the 403 branch that reads session username and remote_addr."""

    def test_403_body_includes_session_username_in_log(self, app):
        """When blocked, the decorator reads session['username'] for logging."""
        from local_deep_research.news.flask_api import (
            scheduler_control_required,
        )

        with app.test_request_context():
            # Put a username in the Flask session so the decorator can read it
            from flask import session as flask_session

            flask_session["username"] = "alice"

            with patch(
                "local_deep_research.news.flask_api.get_env_setting",
                return_value=False,
            ):

                @scheduler_control_required
                def dummy():
                    return jsonify({"ok": True}), 200

                response, status = dummy()
                assert status == 403
                data = response.get_json()
                assert "disabled" in data["error"].lower()


class TestIsJobOwnedByUser:
    """Cover _is_job_owned_by_user fallback through scheduler.user_sessions."""

    def test_primary_match_via_job_args(self):
        """Job is owned when job.args[0] matches the username."""
        from local_deep_research.news.flask_api import _is_job_owned_by_user

        job = MagicMock()
        job.args = ("alice", 42)
        scheduler = MagicMock(spec=[])  # no user_sessions attribute

        assert _is_job_owned_by_user(job, "alice", scheduler) is True

    def test_fallback_match_via_user_sessions(self):
        """Job is owned when its id appears in scheduler.user_sessions."""
        from local_deep_research.news.flask_api import _is_job_owned_by_user

        job = MagicMock()
        job.args = ("other_user",)  # primary check fails
        job.id = "job_123"

        scheduler = MagicMock()
        scheduler.user_sessions = {
            "alice": {"scheduled_jobs": {"job_123", "job_456"}},
        }

        assert _is_job_owned_by_user(job, "alice", scheduler) is True

    def test_no_match_returns_false(self):
        """Returns False when neither primary nor fallback matches."""
        from local_deep_research.news.flask_api import _is_job_owned_by_user

        job = MagicMock()
        job.args = ("other_user",)
        job.id = "job_999"

        scheduler = MagicMock()
        scheduler.user_sessions = {
            "alice": {"scheduled_jobs": {"job_123"}},
        }

        assert _is_job_owned_by_user(job, "alice", scheduler) is False


class TestRunSubscriptionNow:
    """Cover run_subscription_now route: subscription lookup, config, responses."""

    def _setup_auth(self, client):
        _auth_session(client)

    def test_subscription_not_found_returns_404(self, client):
        """Returns 404 when the subscription id is not in the user's DB."""
        self._setup_auth(client)
        with (
            patch(
                "local_deep_research.web.auth.decorators.db_manager",
            ) as mock_db,
            patch(
                "local_deep_research.database.session_context.get_user_db_session",
                return_value=_session_ctx_returning(None),
            ),
            patch(
                "local_deep_research.news.core.utils.get_local_date_string",
                return_value="2026-03-20",
            ),
            patch("local_deep_research.settings.manager.SettingsManager"),
        ):
            mock_db.is_user_connected.return_value = True
            resp = client.post("/news/api/subscriptions/nonexistent/run")
            assert resp.status_code == 404
            assert "not found" in resp.get_json()["error"].lower()

    def test_successful_run_returns_research_id(self, client, app):
        """Successful run returns status=success and a research_id."""
        self._setup_auth(client)

        from local_deep_research.database.models.news import NewsSubscription

        mock_response = {
            "status": "success",
            "research_id": "res_42",
        }

        sub = NewsSubscription(
            id="sub_1",
            query_or_topic="AI news YYYY-MM-DD",
            subscription_type="topic",
            model_provider="OPENAI",
            model="gpt-4",
            search_strategy="news_aggregation",
            name="AI Digest",
            refresh_interval_minutes=60,
        )

        with (
            patch(
                "local_deep_research.web.auth.decorators.db_manager",
            ) as mock_db,
            patch(
                "local_deep_research.database.session_context.get_user_db_session",
                return_value=_session_ctx_returning(sub),
            ),
            patch(
                "local_deep_research.news.flask_api._call_start_research_internal",
                return_value=mock_response,
            ),
            patch(
                "local_deep_research.news.core.utils.get_local_date_string",
                return_value="2026-03-20",
            ),
            patch(
                "local_deep_research.settings.manager.SettingsManager",
            ),
        ):
            mock_db.is_user_connected.return_value = True

            resp = client.post("/news/api/subscriptions/sub_1/run")
            assert resp.status_code == 200
            data = resp.get_json()
            assert data["status"] == "success"
            assert data["research_id"] == "res_42"

    def test_unset_model_config_falls_back_to_user_settings(self, client):
        """A subscription without model_provider/model must NOT force
        "ollama"/"llama3" onto the research request. The values are sent
        unset (None) so start_research falls back to the user's configured
        llm.provider / llm.model. Regression test: hardcoding the defaults
        here overrode the LLM for any subscription created without an
        explicit model.
        """
        self._setup_auth(client)

        from local_deep_research.database.models.news import NewsSubscription

        mock_response = {
            "status": "success",
            "research_id": "res_99",
        }

        # Subscription with no saved model_provider/model.
        sub = NewsSubscription(
            id="sub_1",
            query_or_topic="AI news",
            subscription_type="topic",
            name="AI Digest",
            refresh_interval_minutes=60,
        )

        with (
            patch(
                "local_deep_research.web.auth.decorators.db_manager",
            ) as mock_db,
            patch(
                "local_deep_research.database.session_context.get_user_db_session",
                return_value=_session_ctx_returning(sub),
            ),
            patch(
                "local_deep_research.news.flask_api._call_start_research_internal",
                return_value=mock_response,
            ) as mock_post,
            patch(
                "local_deep_research.news.core.utils.get_local_date_string",
                return_value="2026-03-20",
            ),
            patch(
                "local_deep_research.settings.manager.SettingsManager",
            ),
        ):
            mock_db.is_user_connected.return_value = True

            resp = client.post("/news/api/subscriptions/sub_1/run")
            assert resp.status_code == 200

            sent = mock_post.call_args.args[0]
            # Unset → falsy, so the backend resolves from user settings.
            assert not sent["model_provider"]
            assert not sent["model"]
            assert sent["model_provider"] != "ollama"
            assert sent["model"] != "llama3"

    def test_explicit_model_config_is_passed_through(self, client):
        """When the subscription carries an explicit provider/model, those
        exact values reach the research request unchanged."""
        self._setup_auth(client)

        from local_deep_research.database.models.news import NewsSubscription

        mock_response = {
            "status": "success",
            "research_id": "res_100",
        }

        sub = NewsSubscription(
            id="sub_1",
            query_or_topic="AI news",
            subscription_type="topic",
            model_provider="openai",
            model="gpt-4o",
            name="AI Digest",
            refresh_interval_minutes=60,
        )

        with (
            patch(
                "local_deep_research.web.auth.decorators.db_manager",
            ) as mock_db,
            patch(
                "local_deep_research.database.session_context.get_user_db_session",
                return_value=_session_ctx_returning(sub),
            ),
            patch(
                "local_deep_research.news.flask_api._call_start_research_internal",
                return_value=mock_response,
            ) as mock_post,
            patch(
                "local_deep_research.news.core.utils.get_local_date_string",
                return_value="2026-03-20",
            ),
            patch(
                "local_deep_research.settings.manager.SettingsManager",
            ),
        ):
            mock_db.is_user_connected.return_value = True

            resp = client.post("/news/api/subscriptions/sub_1/run")
            assert resp.status_code == 200

            sent = mock_post.call_args.args[0]
            assert sent["model_provider"] == "openai"
            assert sent["model"] == "gpt-4o"

    def test_failed_response_returns_error(self, client):
        """An error status from start_research surfaces as a 500 with the
        error message (the in-process call has no HTTP status code to relay)."""
        self._setup_auth(client)

        from local_deep_research.database.models.news import NewsSubscription

        mock_response = {
            "status": "error",
            "message": "Service unavailable",
        }

        sub = NewsSubscription(
            id="sub_1",
            query_or_topic="test",
            subscription_type="topic",
            refresh_interval_minutes=60,
        )

        with (
            patch(
                "local_deep_research.web.auth.decorators.db_manager",
            ) as mock_db,
            patch(
                "local_deep_research.database.session_context.get_user_db_session",
                return_value=_session_ctx_returning(sub),
            ),
            patch(
                "local_deep_research.news.flask_api._call_start_research_internal",
                return_value=mock_response,
            ),
            patch(
                "local_deep_research.news.core.utils.get_local_date_string",
                return_value="2026-03-20",
            ),
            patch(
                "local_deep_research.settings.manager.SettingsManager",
            ),
        ):
            mock_db.is_user_connected.return_value = True

            resp = client.post("/news/api/subscriptions/sub_1/run")
            assert resp.status_code == 500
            # The actual start_research message must surface (it reports failures
            # under "message"), not a generic fallback — guards the
            # message-over-error extraction.
            assert resp.get_json()["error"] == "Service unavailable"

    def test_helper_exception_propagates_as_500(self, client):
        """If _call_start_research_internal raises (e.g. start_research blows
        up), run_subscription_now's outer handler returns a 500 rather than
        leaking the traceback. The old safe_post path converted HTTP failures
        to error dicts; the in-process call can raise, so this path matters."""
        self._setup_auth(client)

        from local_deep_research.database.models.news import NewsSubscription

        sub = NewsSubscription(
            id="sub_1",
            query_or_topic="test",
            subscription_type="topic",
            refresh_interval_minutes=60,
        )

        with (
            patch(
                "local_deep_research.web.auth.decorators.db_manager",
            ) as mock_db,
            patch(
                "local_deep_research.database.session_context.get_user_db_session",
                return_value=_session_ctx_returning(sub),
            ),
            patch(
                "local_deep_research.news.flask_api._call_start_research_internal",
                side_effect=RuntimeError("boom"),
            ),
            patch(
                "local_deep_research.news.core.utils.get_local_date_string",
                return_value="2026-03-20",
            ),
            patch("local_deep_research.settings.manager.SettingsManager"),
        ):
            mock_db.is_user_connected.return_value = True

            resp = client.post("/news/api/subscriptions/sub_1/run")
            assert resp.status_code == 500
            assert "error" in resp.get_json()

    def test_successful_run_advances_refresh_schedule(self, client):
        """A successful run advances the subscription's refresh schedule.

        Regression guard for the PR's headline behavior: without the
        advance_refresh_schedule() call, a manually-run subscription that was
        also overdue would be immediately re-run by the scheduler. Deleting the
        advance must fail this test.
        """
        from datetime import datetime, timedelta, timezone

        from local_deep_research.database.models.news import NewsSubscription

        self._setup_auth(client)

        mock_response = {
            "status": "success",
            "research_id": "res_adv",
        }

        sub = NewsSubscription(
            id="sub_1",
            query_or_topic="AI news",
            subscription_type="topic",
            refresh_interval_minutes=60,
            last_refresh=None,
            next_refresh=None,
        )

        with (
            patch(
                "local_deep_research.web.auth.decorators.db_manager",
            ) as mock_db,
            patch(
                "local_deep_research.database.session_context.get_user_db_session",
                return_value=_session_ctx_returning(sub),
            ),
            patch(
                "local_deep_research.news.flask_api._call_start_research_internal",
                return_value=mock_response,
            ),
            patch(
                "local_deep_research.news.core.utils.get_local_date_string",
                return_value="2026-03-20",
            ),
            patch("local_deep_research.settings.manager.SettingsManager"),
        ):
            mock_db.is_user_connected.return_value = True

            before = datetime.now(timezone.utc)
            resp = client.post("/news/api/subscriptions/sub_1/run")
            after = datetime.now(timezone.utc)
            assert resp.status_code == 200

        assert sub.last_refresh is not None, "schedule was not advanced"
        assert before <= sub.last_refresh <= after
        assert sub.next_refresh == sub.last_refresh + timedelta(minutes=60)

    def test_advance_skipped_when_failure_already_reset(self, client):
        """Compare-and-set guard: if a fast-failing run already reset
        next_refresh (worker thread) between the pre-spawn read and the
        post-POST advance, the advance must NOT clobber it and re-hide the
        failed subscription. Regression guard for the race in fix #1.
        """
        from datetime import datetime, timezone

        from local_deep_research.database.models.news import NewsSubscription

        self._setup_auth(client)

        t0 = datetime(2026, 6, 10, 12, 0, tzinfo=timezone.utc)
        # Worker's failure handler reset next_refresh to a different value
        # before we reopen to advance.
        t1 = datetime(2026, 6, 10, 12, 5, tzinfo=timezone.utc)
        sub_read = NewsSubscription(
            id="sub_1",
            query_or_topic="q",
            subscription_type="topic",
            refresh_interval_minutes=60,
            next_refresh=t0,
        )
        sub_after = NewsSubscription(
            id="sub_1",
            query_or_topic="q",
            subscription_type="topic",
            refresh_interval_minutes=60,
            next_refresh=t1,
            last_refresh=None,
        )

        session = MagicMock()
        session.query.return_value.filter.return_value.first.side_effect = [
            sub_read,
            sub_after,
        ]
        ctx = MagicMock()
        ctx.__enter__ = MagicMock(return_value=session)
        ctx.__exit__ = MagicMock(return_value=False)

        mock_response = {
            "status": "success",
            "research_id": "res_x",
        }

        with (
            patch(
                "local_deep_research.web.auth.decorators.db_manager",
            ) as mock_db,
            patch(
                "local_deep_research.database.session_context.get_user_db_session",
                return_value=ctx,
            ),
            patch(
                "local_deep_research.news.flask_api._call_start_research_internal",
                return_value=mock_response,
            ),
            patch(
                "local_deep_research.news.core.utils.get_local_date_string",
                return_value="2026-06-10",
            ),
            patch("local_deep_research.settings.manager.SettingsManager"),
        ):
            mock_db.is_user_connected.return_value = True
            resp = client.post("/news/api/subscriptions/sub_1/run")
            assert resp.status_code == 200

        # CAS skipped the advance: the worker's reset (t1) is preserved.
        assert sub_after.last_refresh is None, "advance clobbered the reset"
        assert sub_after.next_refresh == t1


class TestCheckOverdueSubscriptions:
    """Cover check_overdue_subscriptions: the per-overdue-sub run path
    builds request_data from the ORM object directly."""

    def _make_sub(self, **overrides):
        from types import SimpleNamespace

        defaults = dict(
            id="sub_overdue_1",
            name="Overdue Digest",
            query_or_topic="AI news",
            model_provider=None,
            model=None,
            search_strategy=None,
            search_engine=None,
            custom_endpoint=None,
            refresh_interval_minutes=60,
            last_refresh=None,
            next_refresh=None,
        )
        defaults.update(overrides)
        return SimpleNamespace(**defaults)

    def _run(self, client, sub):
        """POST /check-overdue with the DB query returning [sub]; return the
        request_data dict sent to the research backend."""
        mock_response = {
            "status": "success",
            "research_id": "res_overdue",
        }

        db_mock = MagicMock()
        db_mock.query.return_value.filter.return_value.all.return_value = [sub]

        mock_ctx_mgr = MagicMock()
        mock_ctx_mgr.__enter__ = MagicMock(return_value=db_mock)
        mock_ctx_mgr.__exit__ = MagicMock(return_value=False)

        with (
            patch(
                "local_deep_research.web.auth.decorators.db_manager",
            ) as mock_db,
            patch(
                "local_deep_research.database.session_context.get_user_db_session",
                return_value=mock_ctx_mgr,
            ),
            patch(
                "local_deep_research.news.flask_api._call_start_research_internal",
                return_value=mock_response,
            ) as mock_post,
            patch(
                "local_deep_research.news.core.utils.get_local_date_string",
                return_value="2026-03-20",
            ),
            patch(
                "local_deep_research.settings.manager.SettingsManager",
            ),
        ):
            mock_db.is_user_connected.return_value = True
            resp = client.post("/news/api/check-overdue")
            assert resp.status_code == 200, resp.get_json()
            return mock_post.call_args.args[0]

    def test_unset_model_config_falls_back_to_user_settings(self, client):
        """Overdue sub without provider/model must not force ollama/llama3."""
        _auth_session(client)
        sent = self._run(client, self._make_sub())
        assert not sent["model_provider"]
        assert not sent["model"]
        assert sent["model_provider"] != "ollama"
        assert sent["model"] != "llama3"

    def test_explicit_model_config_is_passed_through(self, client):
        """An overdue sub's explicit provider/model reach the request."""
        _auth_session(client)
        sent = self._run(
            client,
            self._make_sub(model_provider="anthropic", model="claude-3"),
        )
        assert sent["model_provider"] == "anthropic"
        assert sent["model"] == "claude-3"

    def test_successful_overdue_run_advances_schedule(self, client):
        """The overdue sweep advances each run subscription's schedule.

        Regression guard: without advance_refresh_schedule() the same overdue
        subscription would be picked up and re-run on every sweep.
        """
        from datetime import timedelta

        _auth_session(client)
        sub = self._make_sub(refresh_interval_minutes=60)
        self._run(client, sub)

        assert sub.last_refresh is not None, "schedule was not advanced"
        assert sub.next_refresh == sub.last_refresh + timedelta(minutes=60)

    def test_overdue_run_skips_advance_when_reset_during_run(self, client):
        """Compare-and-set in the sweep: if a fast-failing run already reset
        next_refresh (worker thread, observed via db.refresh), the advance is
        skipped so the reset is not clobbered. Mirrors the run-now CAS guard.
        """
        from datetime import datetime, timezone

        _auth_session(client)

        t0 = datetime(2026, 6, 10, 12, 0, tzinfo=timezone.utc)
        t1 = datetime(2026, 6, 10, 12, 5, tzinfo=timezone.utc)  # worker reset
        sub = self._make_sub(next_refresh=t0, last_refresh=None)

        mock_response = {
            "status": "success",
            "research_id": "res_overdue",
        }

        db_mock = MagicMock()
        db_mock.query.return_value.filter.return_value.all.return_value = [sub]

        # Simulate the worker's failure reset becoming visible on db.refresh().
        def _refresh(obj):
            obj.next_refresh = t1

        db_mock.refresh.side_effect = _refresh

        mock_ctx_mgr = MagicMock()
        mock_ctx_mgr.__enter__ = MagicMock(return_value=db_mock)
        mock_ctx_mgr.__exit__ = MagicMock(return_value=False)

        with (
            patch(
                "local_deep_research.web.auth.decorators.db_manager",
            ) as mock_db,
            patch(
                "local_deep_research.database.session_context.get_user_db_session",
                return_value=mock_ctx_mgr,
            ),
            patch(
                "local_deep_research.news.flask_api._call_start_research_internal",
                return_value=mock_response,
            ),
            patch(
                "local_deep_research.news.core.utils.get_local_date_string",
                return_value="2026-03-20",
            ),
            patch("local_deep_research.settings.manager.SettingsManager"),
        ):
            mock_db.is_user_connected.return_value = True
            resp = client.post("/news/api/check-overdue")
            assert resp.status_code == 200, resp.get_json()

        # CAS skipped the advance: the worker's reset (t1) is preserved.
        assert sub.last_refresh is None, "advance clobbered the reset"
        assert sub.next_refresh == t1

    def test_failed_sub_rolls_back_and_sweep_continues(self, client):
        """A failing subscription (error result OR raised exception) must roll
        back the shared session and let the remaining overdue subs run.

        Regression guard: the loop shares one DB session with start_research,
        whose error path does not roll back. Without the per-iteration
        db.rollback() recovery, a PendingRollbackError would propagate while
        re-reading the expired sub and collapse the entire sweep into a 500,
        discarding all partial results.
        """
        _auth_session(client)

        sub_err = self._make_sub(id="sub_err", name="Returns error")
        sub_raise = self._make_sub(id="sub_raise", name="Raises")
        sub_ok = self._make_sub(id="sub_ok", name="Succeeds")

        db_mock = MagicMock()
        db_mock.query.return_value.filter.return_value.all.return_value = [
            sub_err,
            sub_raise,
            sub_ok,
        ]

        mock_ctx_mgr = MagicMock()
        mock_ctx_mgr.__enter__ = MagicMock(return_value=db_mock)
        mock_ctx_mgr.__exit__ = MagicMock(return_value=False)

        with (
            patch(
                "local_deep_research.web.auth.decorators.db_manager",
            ) as mock_db,
            patch(
                "local_deep_research.database.session_context.get_user_db_session",
                return_value=mock_ctx_mgr,
            ),
            patch(
                "local_deep_research.news.flask_api._call_start_research_internal",
                side_effect=[
                    {"status": "error", "message": "kaboom"},
                    RuntimeError("explode"),
                    {"status": "success", "research_id": "rid-ok"},
                ],
            ),
            patch(
                "local_deep_research.news.core.utils.get_local_date_string",
                return_value="2026-03-20",
            ),
            patch("local_deep_research.settings.manager.SettingsManager"),
        ):
            mock_db.is_user_connected.return_value = True
            resp = client.post("/news/api/check-overdue")

        assert resp.status_code == 200, resp.get_json()
        data = resp.get_json()
        assert data["overdue_found"] == 3
        assert data["started"] == 1
        # Both failure branches recovered the session via rollback (>= 2 calls).
        assert db_mock.rollback.call_count >= 2
        by_id = {r["id"]: r for r in data["results"]}
        assert by_id["sub_err"]["error"] == "kaboom"
        assert "error" in by_id["sub_raise"]
        assert by_id["sub_ok"]["research_id"] == "rid-ok"


class TestUpdateSubscriptionFolderStatus:
    """Cover update_subscription_folder: the is_active->status translation
    (so the PUT route keeps status authoritative) and the response
    serialization (NewsSubscription has no to_dict)."""

    def _ctx_for(self, sub):
        """get_user_db_session() context whose query(...).filter_by(...).first()
        yields ``sub`` (this route looks the row up via filter_by)."""
        session = MagicMock()
        session.query.return_value.filter_by.return_value.first.return_value = (
            sub
        )
        ctx = MagicMock()
        ctx.__enter__ = MagicMock(return_value=session)
        ctx.__exit__ = MagicMock(return_value=False)
        return ctx

    def _put(self, client, sub, body):
        with (
            patch(
                "local_deep_research.web.auth.decorators.db_manager",
            ) as mock_db,
            patch(
                "local_deep_research.news.flask_api.get_user_db_session",
                return_value=self._ctx_for(sub),
            ),
        ):
            mock_db.is_user_connected.return_value = True
            return client.put(
                "/news/api/subscription/subscriptions/sub_1", json=body
            )

    def test_is_active_false_translates_to_paused_status(self, client):
        """A {"is_active": false} body must pause via status, not just flip the
        legacy column -- otherwise the status-keyed scheduler keeps running it.
        """
        from local_deep_research.database.models.news import NewsSubscription

        _auth_session(client)
        sub = NewsSubscription(
            id="sub_1",
            query_or_topic="q",
            subscription_type="topic",
            status="active",
            refresh_interval_minutes=60,
        )

        resp = self._put(client, sub, {"is_active": False})

        assert resp.status_code == 200, resp.get_json()
        assert sub.status == "paused"
        body = resp.get_json()
        assert body["status"] == "paused"
        assert body["is_active"] is False

    def test_is_active_true_translates_to_active_status(self, client):
        from local_deep_research.database.models.news import NewsSubscription

        _auth_session(client)
        sub = NewsSubscription(
            id="sub_1",
            query_or_topic="q",
            subscription_type="topic",
            status="paused",
            refresh_interval_minutes=60,
        )

        resp = self._put(client, sub, {"is_active": True})

        assert resp.status_code == 200, resp.get_json()
        assert sub.status == "active"
        assert resp.get_json()["is_active"] is True

    def test_plain_field_update_returns_200_not_500(self, client):
        """A non-status field update must serialize a response without raising
        (NewsSubscription has no to_dict; the route builds the dict explicitly).
        """
        from local_deep_research.database.models.news import NewsSubscription

        _auth_session(client)
        sub = NewsSubscription(
            id="sub_1",
            query_or_topic="q",
            subscription_type="topic",
            status="active",
            refresh_interval_minutes=60,
        )

        resp = self._put(client, sub, {"folder_id": "folder_9"})

        assert resp.status_code == 200, resp.get_json()
        assert sub.folder_id == "folder_9"
        assert resp.get_json()["id"] == "sub_1"


class TestCheckSubscriptionsNow:
    """Cover check_subscriptions_now: DB query for overdue subs + threading."""

    def test_scheduler_not_initialized_returns_503(self, client, app):
        """Returns 503 when news_scheduler is missing from current_app."""
        _auth_session(client)

        with (
            patch(
                "local_deep_research.web.auth.decorators.db_manager",
            ) as mock_db,
            patch(
                "local_deep_research.news.flask_api.get_env_setting",
                return_value=True,
            ),
        ):
            mock_db.is_user_connected.return_value = True
            # Ensure current_app has no background_job_scheduler
            if hasattr(app, "background_job_scheduler"):
                delattr(app, "background_job_scheduler")

            resp = client.post("/news/api/scheduler/check-now")
            assert resp.status_code == 503

    def test_scheduler_not_running_returns_503(self, client, app):
        """Returns 503 when scheduler exists but is_running is False."""
        _auth_session(client)
        app.background_job_scheduler = MagicMock()
        app.background_job_scheduler.is_running = False

        with (
            patch(
                "local_deep_research.web.auth.decorators.db_manager",
            ) as mock_db,
            patch(
                "local_deep_research.news.flask_api.get_env_setting",
                return_value=True,
            ),
        ):
            mock_db.is_user_connected.return_value = True

            resp = client.post("/news/api/scheduler/check-now")
            assert resp.status_code == 503

        # Clean up
        delattr(app, "background_job_scheduler")


class TestTriggerCleanup:
    """Cover trigger_cleanup: scheduler.add_job scheduling."""

    def test_cleanup_not_running_returns_400(self, client):
        """Returns 400 when scheduler is not running."""
        _auth_session(client)
        mock_scheduler = MagicMock()
        mock_scheduler.is_running = False

        with (
            patch(
                "local_deep_research.web.auth.decorators.db_manager",
            ) as mock_db,
            patch(
                "local_deep_research.news.flask_api.get_env_setting",
                return_value=True,
            ),
            patch(
                "local_deep_research.news.flask_api.get_background_job_scheduler",
                return_value=mock_scheduler,
                create=True,
            ),
            patch(
                "local_deep_research.scheduler.background.get_background_job_scheduler",
                return_value=mock_scheduler,
            ),
        ):
            mock_db.is_user_connected.return_value = True

            resp = client.post("/news/api/scheduler/cleanup-now")
            assert resp.status_code == 400
            assert "not running" in resp.get_json()["error"].lower()

    def test_cleanup_triggered_returns_success(self, client):
        """Returns triggered status when scheduler is running."""
        _auth_session(client)
        mock_scheduler = MagicMock()
        mock_scheduler.is_running = True
        mock_scheduler.scheduler = MagicMock()

        with (
            patch(
                "local_deep_research.web.auth.decorators.db_manager",
            ) as mock_db,
            patch(
                "local_deep_research.news.flask_api.get_env_setting",
                return_value=True,
            ),
            patch(
                "local_deep_research.scheduler.background.get_background_job_scheduler",
                return_value=mock_scheduler,
            ),
        ):
            mock_db.is_user_connected.return_value = True

            resp = client.post("/news/api/scheduler/cleanup-now")
            assert resp.status_code == 200
            data = resp.get_json()
            assert data["status"] == "triggered"
            # Verify add_job was called
            mock_scheduler.scheduler.add_job.assert_called_once()


class TestSearchHistoryUnauthenticated:
    """Cover unauthenticated return paths in search history endpoints."""

    def test_get_search_history_unauthenticated_returns_empty(self, client):
        """GET /search-history returns empty list when current_user is None."""
        _auth_session(client)
        with (
            patch(
                "local_deep_research.web.auth.decorators.db_manager",
            ) as mock_db,
            patch(
                "local_deep_research.news.flask_api.current_user",
                return_value=None,
                create=True,
            ),
            patch(
                "local_deep_research.web.auth.decorators.current_user",
                return_value=None,
                create=True,
            ),
        ):
            mock_db.is_user_connected.return_value = True

            resp = client.get("/news/api/search-history")
            assert resp.status_code == 200
            assert resp.get_json()["search_history"] == []

    def test_clear_search_history_unauthenticated_returns_success(self, client):
        """DELETE /search-history returns success when current_user is None."""
        _auth_session(client)
        with (
            patch(
                "local_deep_research.web.auth.decorators.db_manager",
            ) as mock_db,
            patch(
                "local_deep_research.news.flask_api.current_user",
                return_value=None,
                create=True,
            ),
            patch(
                "local_deep_research.web.auth.decorators.current_user",
                return_value=None,
                create=True,
            ),
        ):
            mock_db.is_user_connected.return_value = True

            resp = client.delete("/news/api/search-history")
            assert resp.status_code == 200
            assert resp.get_json()["status"] == "success"

    def test_add_search_history_missing_query_returns_400(self, client):
        """POST /search-history returns 400 when query field is missing."""
        _auth_session(client)
        with (
            patch(
                "local_deep_research.web.auth.decorators.db_manager",
            ) as mock_db,
            patch(
                "local_deep_research.web.auth.decorators.current_user",
                return_value="testuser",
            ),
        ):
            mock_db.is_user_connected.return_value = True

            resp = client.post(
                "/news/api/search-history",
                json={"type": "filter"},
                content_type="application/json",
            )
            assert resp.status_code == 400
            assert "query" in resp.get_json()["error"].lower()

    def test_add_search_history_unauthenticated_returns_401(self, client):
        """POST /search-history returns 401 when current_user is None."""
        _auth_session(client)
        with (
            patch(
                "local_deep_research.web.auth.decorators.db_manager",
            ) as mock_db,
            patch(
                "local_deep_research.news.flask_api.current_user",
                return_value=None,
                create=True,
            ),
            patch(
                "local_deep_research.web.auth.decorators.current_user",
                return_value=None,
                create=True,
            ),
        ):
            mock_db.is_user_connected.return_value = True

            resp = client.post(
                "/news/api/search-history",
                json={"query": "test"},
                content_type="application/json",
            )
            assert resp.status_code == 401
