"""
Comprehensive coverage tests for settings_routes.py.

Targets uncovered code paths including:
- save_all_settings (complex branching: corrupted values, setting types, new settings)
- save_settings (POST fallback)
- api_get_all_settings (category filtering)
- api_get_db_setting / api_update_setting / api_delete_setting
- api_import_settings / api_get_categories / api_get_types / api_get_ui_elements
- fix_corrupted_settings
- api_get_available_models (cache, Ollama, OpenAI, Anthropic, auto-discovery)
- api_get_available_search_engines
- api_get/update search favorites, toggle favorite
- get_bulk_settings
- api_get_data_location
- api_test_notification_url
- check_ollama_status
- _get_engine_icon_and_category
- legacy redirect routes
- open_file_location
- inject_csrf_token

Note: rate-limiting endpoints (/api/rate-limiting/status, /reset, /cleanup)
are covered in the dedicated test_settings_routes_rate_limiting.py, not here,
to keep one canonical home per endpoint and avoid the duplicate-test drift that
previously occurred for /api/rate-limiting/cleanup (#4735).
"""

from unittest.mock import patch, MagicMock

import pytest

# ---------------------------------------------------------------------------
# Helper: build a mock Setting ORM object
# ---------------------------------------------------------------------------


def _make_setting(
    key="test.key",
    value="val",
    ui_element="text",
    name="Test Key",
    description="desc",
    category="general",
    setting_type="app",
    editable=True,
    visible=True,
    options=None,
    min_value=None,
    max_value=None,
    step=None,
    updated_at=None,
):
    s = MagicMock()
    s.key = key
    s.value = value
    s.ui_element = ui_element
    s.name = name
    s.description = description
    s.category = category
    s.type = setting_type
    s.editable = editable
    s.visible = visible
    s.options = options
    s.min_value = min_value
    s.max_value = max_value
    s.step = step
    s.updated_at = updated_at
    return s


# ---------------------------------------------------------------------------
# _get_engine_icon_and_category (pure function, no Flask context needed)
# ---------------------------------------------------------------------------


class TestGetEngineIconAndCategory:
    """Tests for the _get_engine_icon_and_category helper."""

    def _call(self, engine_data, engine_class=None):
        from local_deep_research.web.routes.settings_routes import (
            _get_engine_icon_and_category,
        )

        return _get_engine_icon_and_category(engine_data, engine_class)

    def test_local_engine(self):
        icon, cat = self._call({"is_local": True, "is_scientific": True})
        assert cat == "Local RAG"

    def test_scientific_engine(self):
        icon, cat = self._call({"is_scientific": True})
        assert cat == "Scientific"

    def test_news_engine(self):
        icon, cat = self._call({"is_news": True})
        assert cat == "News"

    def test_code_engine(self):
        icon, cat = self._call({"is_code": True})
        assert cat == "Code"

    def test_generic_engine(self):
        icon, cat = self._call({"is_generic": True})
        assert cat == "Web Search"

    def test_default_engine(self):
        icon, cat = self._call({})
        assert cat == "Search"

    def test_engine_class_attributes(self):
        cls = MagicMock()
        cls.is_scientific = False
        cls.is_generic = False
        cls.is_local = False
        cls.is_news = True
        cls.is_code = False
        icon, cat = self._call({}, engine_class=cls)
        assert cat == "News"

    def test_engine_class_code(self):
        cls = MagicMock()
        cls.is_scientific = False
        cls.is_generic = False
        cls.is_local = False
        cls.is_news = False
        cls.is_code = True
        icon, cat = self._call({}, engine_class=cls)
        assert cat == "Code"

    def test_priority_local_over_scientific(self):
        """Local takes priority over scientific."""
        icon, cat = self._call(
            {"is_local": True, "is_scientific": True, "is_news": True}
        )
        assert cat == "Local RAG"


# =========================================================================
# Flask route tests — use the app/authenticated_client fixtures from conftest
# =========================================================================

SETTINGS_PREFIX = "/settings"
ROUTES_MODULE = "local_deep_research.web.routes.settings_routes"
DECORATOR_MODULE = "local_deep_research.web.utils.route_decorators"


class TestSettingsPage:
    """GET /settings/"""

    def test_requires_auth(self, client):
        resp = client.get(f"{SETTINGS_PREFIX}/")
        assert resp.status_code in [302, 401]

    @patch(f"{ROUTES_MODULE}.render_template_with_defaults", return_value="ok")
    def test_returns_page(self, mock_render, authenticated_client):
        resp = authenticated_client.get(f"{SETTINGS_PREFIX}/")
        assert resp.status_code == 200


class TestLegacyRedirects:
    """Legacy redirect routes.

    Note: research_routes also registers these same paths under /settings/,
    so the settings_routes versions are shadowed in the Flask app.
    We test the functions directly instead.
    """

    def test_main_config_redirects(self, app):
        from local_deep_research.web.routes.settings_routes import (
            main_config_page,
        )

        with app.test_request_context():
            from flask import session

            session["username"] = "test"
            resp = main_config_page()
            assert resp.status_code == 302

    def test_collections_config_redirects(self, app):
        from local_deep_research.web.routes.settings_routes import (
            collections_config_page,
        )

        with app.test_request_context():
            from flask import session

            session["username"] = "test"
            resp = collections_config_page()
            assert resp.status_code == 302

    def test_api_keys_config_redirects(self, app):
        from local_deep_research.web.routes.settings_routes import (
            api_keys_config_page,
        )

        with app.test_request_context():
            from flask import session

            session["username"] = "test"
            resp = api_keys_config_page()
            assert resp.status_code == 302

    def test_search_engines_config_redirects(self, app):
        from local_deep_research.web.routes.settings_routes import (
            search_engines_config_page,
        )

        with app.test_request_context():
            from flask import session

            session["username"] = "test"
            resp = search_engines_config_page()
            assert resp.status_code == 302


class TestOpenFileLocation:
    """POST /settings/open_file_location"""

    def test_disabled(self, authenticated_client):
        resp = authenticated_client.post(
            f"{SETTINGS_PREFIX}/open_file_location"
        )
        assert resp.status_code == 403
        data = resp.get_json()
        assert data["status"] == "error"


class TestApiGetTypes:
    """GET /settings/api/types"""

    def test_returns_types(self, authenticated_client):
        resp = authenticated_client.get(f"{SETTINGS_PREFIX}/api/types")
        assert resp.status_code == 200
        data = resp.get_json()
        assert "types" in data


class TestApiGetUiElements:
    """GET /settings/api/ui_elements"""

    def test_returns_ui_elements(self, authenticated_client):
        resp = authenticated_client.get(f"{SETTINGS_PREFIX}/api/ui_elements")
        assert resp.status_code == 200
        data = resp.get_json()
        assert "ui_elements" in data
        assert "text" in data["ui_elements"]
        assert "checkbox" in data["ui_elements"]


class TestApiGetCategories:
    """GET /settings/api/categories"""

    def test_returns_categories(self, authenticated_client):
        resp = authenticated_client.get(f"{SETTINGS_PREFIX}/api/categories")
        assert resp.status_code == 200
        data = resp.get_json()
        assert "categories" in data


class TestApiImportSettings:
    """POST /settings/api/import"""

    def test_import_success(self, authenticated_client):
        resp = authenticated_client.post(f"{SETTINGS_PREFIX}/api/import")
        assert resp.status_code == 200
        data = resp.get_json()
        assert (
            "imported" in data.get("message", "").lower()
            or "success" in data.get("message", "").lower()
        )


class TestResetToDefaults:
    """POST /settings/reset_to_defaults"""

    def test_reset_success(self, authenticated_client):
        resp = authenticated_client.post(f"{SETTINGS_PREFIX}/reset_to_defaults")
        assert resp.status_code == 200
        data = resp.get_json()
        assert data["status"] == "success"

    @patch(f"{DECORATOR_MODULE}.SettingsManager")
    @patch(f"{DECORATOR_MODULE}.get_user_db_session")
    def test_reset_error(self, mock_ctx, mock_sm, authenticated_client):
        mock_ctx.side_effect = RuntimeError("db fail")
        resp = authenticated_client.post(f"{SETTINGS_PREFIX}/reset_to_defaults")
        assert resp.status_code == 500
        assert resp.get_json()["error"] == "Database session unavailable"


class TestApiGetAllSettings:
    """GET /settings/api"""

    def test_returns_settings(self, authenticated_client):
        resp = authenticated_client.get(f"{SETTINGS_PREFIX}/api")
        assert resp.status_code == 200
        data = resp.get_json()
        assert data["status"] == "success"
        assert "settings" in data

    def test_filter_by_category(self, authenticated_client):
        resp = authenticated_client.get(
            f"{SETTINGS_PREFIX}/api?category=llm_general"
        )
        assert resp.status_code == 200
        data = resp.get_json()
        assert data["status"] == "success"


class TestApiGetDbSetting:
    """GET /settings/api/<key>"""

    def test_setting_found(self, authenticated_client):
        resp = authenticated_client.get(f"{SETTINGS_PREFIX}/api/llm.model")
        # May return 200 or 404 depending on whether setting exists in test db
        assert resp.status_code in [200, 404]

    def test_setting_not_found(self, authenticated_client):
        resp = authenticated_client.get(
            f"{SETTINGS_PREFIX}/api/nonexistent.key.xyz"
        )
        assert resp.status_code == 404
        data = resp.get_json()
        assert "error" in data


class TestApiUpdateSetting:
    """PUT /settings/api/<key>"""

    def test_no_json_body_returns_400(self, authenticated_client):
        resp = authenticated_client.put(
            f"{SETTINGS_PREFIX}/api/llm.model",
            data="not json",
            content_type="text/plain",
        )
        assert resp.status_code == 400

    def test_no_value_returns_400(self, authenticated_client):
        resp = authenticated_client.put(
            f"{SETTINGS_PREFIX}/api/llm.model",
            json={"no_value_key": "x"},
        )
        assert resp.status_code == 400

    def test_non_editable_setting_returns_403(self, authenticated_client):
        """Setting exists but is not editable."""
        setting = _make_setting(key="locked.setting", editable=False)
        with patch(f"{DECORATOR_MODULE}.get_user_db_session") as mock_ctx:
            mock_session = MagicMock()
            mock_session.query.return_value.filter.return_value.first.return_value = setting
            mock_ctx.return_value.__enter__ = MagicMock(
                return_value=mock_session
            )
            mock_ctx.return_value.__exit__ = MagicMock(return_value=False)
            resp = authenticated_client.put(
                f"{SETTINGS_PREFIX}/api/locked.setting",
                json={"value": "new"},
            )
        assert resp.status_code == 403

    @patch(f"{ROUTES_MODULE}.calculate_warnings", return_value=[])
    @patch(f"{ROUTES_MODULE}.set_setting", return_value=True)
    @patch(f"{ROUTES_MODULE}.coerce_setting_for_write", return_value="openai")
    @patch(f"{ROUTES_MODULE}.validate_setting", return_value=(True, None))
    def test_update_warning_affecting_key(
        self,
        mock_validate,
        mock_coerce,
        mock_set,
        mock_warnings,
        authenticated_client,
    ):
        """Updating a warning-affecting key includes warnings in response."""
        setting = _make_setting(
            key="llm.provider", value="ollama", editable=True
        )
        with patch(f"{DECORATOR_MODULE}.get_user_db_session") as mock_ctx:
            mock_session = MagicMock()
            mock_session.query.return_value.filter.return_value.first.return_value = setting
            mock_ctx.return_value.__enter__ = MagicMock(
                return_value=mock_session
            )
            mock_ctx.return_value.__exit__ = MagicMock(return_value=False)
            resp = authenticated_client.put(
                f"{SETTINGS_PREFIX}/api/llm.provider",
                json={"value": "openai"},
            )
        assert resp.status_code == 200
        data = resp.get_json()
        assert "warnings" in data

    @patch(f"{ROUTES_MODULE}.create_or_update_setting")
    def test_create_new_setting_via_put(
        self, mock_create, authenticated_client
    ):
        """PUT creates a new setting when key doesn't exist."""
        mock_new = _make_setting(key="llm.new_setting", value="v")
        mock_new.type = MagicMock()
        mock_new.type.value = "app"
        mock_create.return_value = mock_new

        with patch(f"{DECORATOR_MODULE}.get_user_db_session") as mock_ctx:
            mock_session = MagicMock()
            mock_session.query.return_value.filter.return_value.first.return_value = None
            mock_ctx.return_value.__enter__ = MagicMock(
                return_value=mock_session
            )
            mock_ctx.return_value.__exit__ = MagicMock(return_value=False)
            resp = authenticated_client.put(
                f"{SETTINGS_PREFIX}/api/llm.new_setting",
                json={"value": "hello", "type": "app"},
            )
        assert resp.status_code == 201

    @patch(f"{ROUTES_MODULE}.create_or_update_setting", return_value=None)
    def test_create_new_setting_fails(self, mock_create, authenticated_client):
        with patch(f"{DECORATOR_MODULE}.get_user_db_session") as mock_ctx:
            mock_session = MagicMock()
            mock_session.query.return_value.filter.return_value.first.return_value = None
            mock_ctx.return_value.__enter__ = MagicMock(
                return_value=mock_session
            )
            mock_ctx.return_value.__exit__ = MagicMock(return_value=False)
            resp = authenticated_client.put(
                f"{SETTINGS_PREFIX}/api/llm.new_fail",
                json={"value": "hello"},
            )
        assert resp.status_code == 500

    @patch(f"{ROUTES_MODULE}.set_setting", return_value=False)
    @patch(f"{ROUTES_MODULE}.coerce_setting_for_write", return_value="v")
    @patch(f"{ROUTES_MODULE}.validate_setting", return_value=(True, None))
    def test_update_fails_returns_500(
        self, mock_v, mock_c, mock_s, authenticated_client
    ):
        setting = _make_setting(key="test.x", editable=True)
        with patch(f"{DECORATOR_MODULE}.get_user_db_session") as mock_ctx:
            mock_session = MagicMock()
            mock_session.query.return_value.filter.return_value.first.return_value = setting
            mock_ctx.return_value.__enter__ = MagicMock(
                return_value=mock_session
            )
            mock_ctx.return_value.__exit__ = MagicMock(return_value=False)
            resp = authenticated_client.put(
                f"{SETTINGS_PREFIX}/api/test.x",
                json={"value": "v"},
            )
        assert resp.status_code == 500

    @patch(f"{ROUTES_MODULE}.coerce_setting_for_write", return_value="bad")
    @patch(
        f"{ROUTES_MODULE}.validate_setting", return_value=(False, "bad value")
    )
    def test_validation_failure_returns_400(
        self, mock_v, mock_c, authenticated_client
    ):
        setting = _make_setting(key="test.x", editable=True)
        with patch(f"{DECORATOR_MODULE}.get_user_db_session") as mock_ctx:
            mock_session = MagicMock()
            mock_session.query.return_value.filter.return_value.first.return_value = setting
            mock_ctx.return_value.__enter__ = MagicMock(
                return_value=mock_session
            )
            mock_ctx.return_value.__exit__ = MagicMock(return_value=False)
            resp = authenticated_client.put(
                f"{SETTINGS_PREFIX}/api/test.x",
                json={"value": "bad"},
            )
        assert resp.status_code == 400


class TestApiDeleteSetting:
    """DELETE /settings/api/<key>"""

    def test_not_found(self, authenticated_client):
        resp = authenticated_client.delete(
            f"{SETTINGS_PREFIX}/api/nonexistent.xyz.abc"
        )
        assert resp.status_code == 404

    @patch(f"{DECORATOR_MODULE}.SettingsManager")
    def test_non_editable(self, mock_sm, authenticated_client):
        setting = _make_setting(key="locked", editable=False)
        with patch(f"{DECORATOR_MODULE}.get_user_db_session") as mock_ctx:
            mock_session = MagicMock()
            mock_session.query.return_value.filter.return_value.first.return_value = setting
            mock_ctx.return_value.__enter__ = MagicMock(
                return_value=mock_session
            )
            mock_ctx.return_value.__exit__ = MagicMock(return_value=False)
            resp = authenticated_client.delete(f"{SETTINGS_PREFIX}/api/locked")
        assert resp.status_code == 403

    @patch(f"{DECORATOR_MODULE}.SettingsManager")
    def test_delete_success(self, mock_sm_cls, authenticated_client):
        setting = _make_setting(key="del.me", editable=True)
        mock_sm_instance = MagicMock()
        mock_sm_instance.delete_setting.return_value = True
        mock_sm_cls.return_value = mock_sm_instance
        with patch(f"{DECORATOR_MODULE}.get_user_db_session") as mock_ctx:
            mock_session = MagicMock()
            mock_session.query.return_value.filter.return_value.first.return_value = setting
            mock_ctx.return_value.__enter__ = MagicMock(
                return_value=mock_session
            )
            mock_ctx.return_value.__exit__ = MagicMock(return_value=False)
            resp = authenticated_client.delete(f"{SETTINGS_PREFIX}/api/del.me")
        assert resp.status_code == 200

    @patch(f"{DECORATOR_MODULE}.SettingsManager")
    def test_delete_fails(self, mock_sm_cls, authenticated_client):
        setting = _make_setting(key="del.fail", editable=True)
        mock_sm_instance = MagicMock()
        mock_sm_instance.delete_setting.return_value = False
        mock_sm_cls.return_value = mock_sm_instance
        with patch(f"{DECORATOR_MODULE}.get_user_db_session") as mock_ctx:
            mock_session = MagicMock()
            mock_session.query.return_value.filter.return_value.first.return_value = setting
            mock_ctx.return_value.__enter__ = MagicMock(
                return_value=mock_session
            )
            mock_ctx.return_value.__exit__ = MagicMock(return_value=False)
            resp = authenticated_client.delete(
                f"{SETTINGS_PREFIX}/api/del.fail"
            )
        assert resp.status_code == 500


class TestSaveAllSettings:
    """POST /settings/save_all_settings"""

    def test_no_json_body(self, authenticated_client):
        resp = authenticated_client.post(
            f"{SETTINGS_PREFIX}/save_all_settings",
            data="not json",
            content_type="text/plain",
        )
        assert resp.status_code == 400

    @patch(f"{ROUTES_MODULE}.set_setting", return_value=True)
    @patch(
        f"{ROUTES_MODULE}.coerce_setting_for_write",
        side_effect=lambda key, value, ui_element: value,
    )
    @patch(f"{ROUTES_MODULE}.validate_setting", return_value=(True, None))
    def test_update_existing_setting(
        self, mock_v, mock_c, mock_s, authenticated_client
    ):
        """Update an existing editable setting."""
        setting = _make_setting(
            key="llm.temperature", value=0.7, ui_element="number", editable=True
        )
        setting.type = "llm"

        all_settings_after = [setting]

        with patch(f"{DECORATOR_MODULE}.get_user_db_session") as mock_ctx:
            mock_session = MagicMock()
            # First call: fetch all settings; second call: return all for response
            mock_session.query.return_value.all.side_effect = [
                [setting],  # all_db_settings
                all_settings_after,  # response settings
            ]
            mock_ctx.return_value.__enter__ = MagicMock(
                return_value=mock_session
            )
            mock_ctx.return_value.__exit__ = MagicMock(return_value=False)
            resp = authenticated_client.post(
                f"{SETTINGS_PREFIX}/save_all_settings",
                json={"llm.temperature": 0.5},
            )
        assert resp.status_code == 200
        data = resp.get_json()
        assert data["status"] == "success"

    @patch(f"{ROUTES_MODULE}.set_setting", return_value=True)
    @patch(
        f"{ROUTES_MODULE}.coerce_setting_for_write",
        side_effect=lambda key, value, ui_element: value,
    )
    @patch(f"{ROUTES_MODULE}.validate_setting", return_value=(True, None))
    @patch(f"{ROUTES_MODULE}.parse_boolean", return_value=True)
    def test_checkbox_conversion(
        self, mock_pb, mock_v, mock_c, mock_s, authenticated_client
    ):
        """Checkbox string value gets converted to bool."""
        setting = _make_setting(
            key="search.safe_search",
            value=False,
            ui_element="checkbox",
            editable=True,
        )
        setting.type = "search"

        with patch(f"{DECORATOR_MODULE}.get_user_db_session") as mock_ctx:
            mock_session = MagicMock()
            mock_session.query.return_value.all.side_effect = [
                [setting],
                [setting],
            ]
            mock_ctx.return_value.__enter__ = MagicMock(
                return_value=mock_session
            )
            mock_ctx.return_value.__exit__ = MagicMock(return_value=False)
            resp = authenticated_client.post(
                f"{SETTINGS_PREFIX}/save_all_settings",
                json={"search.safe_search": "true"},
            )
        assert resp.status_code == 200

    def test_corrupted_value_object_object(self, authenticated_client):
        """[object Object] gets corrected."""
        setting = _make_setting(
            key="llm.model", value="gpt-4", ui_element="text", editable=True
        )
        setting.type = "llm"

        with patch(f"{DECORATOR_MODULE}.get_user_db_session") as mock_ctx:
            mock_session = MagicMock()
            mock_session.query.return_value.all.side_effect = [
                [setting],
                [setting],
            ]
            mock_ctx.return_value.__enter__ = MagicMock(
                return_value=mock_session
            )
            mock_ctx.return_value.__exit__ = MagicMock(return_value=False)

            with patch(
                f"{ROUTES_MODULE}.coerce_setting_for_write",
                return_value="gpt-3.5-turbo",
            ):
                with patch(
                    f"{ROUTES_MODULE}.validate_setting",
                    return_value=(True, None),
                ):
                    with patch(
                        f"{ROUTES_MODULE}.set_setting", return_value=True
                    ):
                        resp = authenticated_client.post(
                            f"{SETTINGS_PREFIX}/save_all_settings",
                            json={"llm.model": "[object Object]"},
                        )
        assert resp.status_code == 200

    def test_corrupted_report_value(self, authenticated_client):
        """Corrupted report value gets set to empty dict."""
        setting = _make_setting(
            key="report.structure", value={}, ui_element="json", editable=True
        )
        setting.type = "report"

        with patch(f"{DECORATOR_MODULE}.get_user_db_session") as mock_ctx:
            mock_session = MagicMock()
            mock_session.query.return_value.all.side_effect = [
                [setting],
                [setting],
            ]
            mock_ctx.return_value.__enter__ = MagicMock(
                return_value=mock_session
            )
            mock_ctx.return_value.__exit__ = MagicMock(return_value=False)

            with patch(
                f"{ROUTES_MODULE}.coerce_setting_for_write", return_value={}
            ):
                with patch(
                    f"{ROUTES_MODULE}.validate_setting",
                    return_value=(True, None),
                ):
                    with patch(
                        f"{ROUTES_MODULE}.set_setting", return_value=True
                    ):
                        resp = authenticated_client.post(
                            f"{SETTINGS_PREFIX}/save_all_settings",
                            json={"report.structure": "{}"},
                        )
        assert resp.status_code == 200

    @patch(f"{ROUTES_MODULE}.create_or_update_setting")
    def test_create_new_setting(self, mock_create, authenticated_client):
        """Creating a new setting when key not in DB."""
        mock_new = _make_setting(key="llm.new_param", value="v")
        mock_new.type = "llm"
        mock_create.return_value = mock_new

        with patch(f"{DECORATOR_MODULE}.get_user_db_session") as mock_ctx:
            mock_session = MagicMock()
            mock_session.query.return_value.all.side_effect = [
                [],  # no existing settings
                [],  # response
            ]
            mock_ctx.return_value.__enter__ = MagicMock(
                return_value=mock_session
            )
            mock_ctx.return_value.__exit__ = MagicMock(return_value=False)
            resp = authenticated_client.post(
                f"{SETTINGS_PREFIX}/save_all_settings",
                json={"llm.new_param": "value"},
            )
        assert resp.status_code == 200

    @patch(f"{ROUTES_MODULE}.create_or_update_setting", return_value=None)
    def test_create_new_setting_fails(self, mock_create, authenticated_client):
        """Creating a new setting that fails produces validation error."""
        with patch(f"{DECORATOR_MODULE}.get_user_db_session") as mock_ctx:
            mock_session = MagicMock()
            mock_session.query.return_value.all.side_effect = [
                [],
                [],
            ]
            mock_ctx.return_value.__enter__ = MagicMock(
                return_value=mock_session
            )
            mock_ctx.return_value.__exit__ = MagicMock(return_value=False)
            resp = authenticated_client.post(
                f"{SETTINGS_PREFIX}/save_all_settings",
                json={"llm.new_fail": "value"},
            )
        assert resp.status_code == 400

    def test_non_editable_skipped(self, authenticated_client):
        """Non-editable settings are filtered out."""
        setting = _make_setting(
            key="app.locked", editable=False, ui_element="text"
        )
        setting.type = "app"

        with patch(f"{DECORATOR_MODULE}.get_user_db_session") as mock_ctx:
            mock_session = MagicMock()
            mock_session.query.return_value.all.side_effect = [
                [setting],
                [setting],
            ]
            mock_ctx.return_value.__enter__ = MagicMock(
                return_value=mock_session
            )
            mock_ctx.return_value.__exit__ = MagicMock(return_value=False)
            resp = authenticated_client.post(
                f"{SETTINGS_PREFIX}/save_all_settings",
                json={"app.locked": "new_val"},
            )
        assert resp.status_code == 200
        data = resp.get_json()
        assert data["status"] == "success"

    @patch(
        f"{ROUTES_MODULE}.calculate_warnings", return_value=[{"msg": "warn"}]
    )
    @patch(f"{ROUTES_MODULE}.set_setting", return_value=True)
    @patch(
        f"{ROUTES_MODULE}.coerce_setting_for_write",
        side_effect=lambda key, value, ui_element: value,
    )
    @patch(f"{ROUTES_MODULE}.validate_setting", return_value=(True, None))
    def test_warning_affecting_keys(
        self, mock_v, mock_c, mock_s, mock_w, authenticated_client
    ):
        """Updating warning-affecting key includes warnings."""
        setting = _make_setting(
            key="llm.provider", value="ollama", ui_element="text", editable=True
        )
        setting.type = "llm"

        with patch(f"{DECORATOR_MODULE}.get_user_db_session") as mock_ctx:
            mock_session = MagicMock()
            mock_session.query.return_value.all.side_effect = [
                [setting],
                [setting],
            ]
            mock_ctx.return_value.__enter__ = MagicMock(
                return_value=mock_session
            )
            mock_ctx.return_value.__exit__ = MagicMock(return_value=False)
            resp = authenticated_client.post(
                f"{SETTINGS_PREFIX}/save_all_settings",
                json={"llm.provider": "openai"},
            )
        assert resp.status_code == 200
        data = resp.get_json()
        assert "warnings" in data

    @pytest.mark.parametrize(
        "egress_key",
        [
            "policy.egress_scope",
            "llm.require_local_endpoint",
            "embeddings.require_local",
        ],
    )
    @patch(
        f"{ROUTES_MODULE}.calculate_warnings", return_value=[{"msg": "warn"}]
    )
    @patch(f"{ROUTES_MODULE}.set_setting", return_value=True)
    @patch(
        f"{ROUTES_MODULE}.coerce_setting_for_write",
        side_effect=lambda key, value, ui_element: value,
    )
    @patch(f"{ROUTES_MODULE}.validate_setting", return_value=(True, None))
    def test_egress_keys_trigger_warnings_in_bulk_path(
        self, mock_v, mock_c, mock_s, mock_w, egress_key, authenticated_client
    ):
        """Regression test for #4463: the bulk ``save_all_settings`` path must
        recalculate warnings when an egress-policy key changes.

        These three keys were originally present only in
        ``api_update_setting``'s list, not the bulk path's — so a bulk save
        that changed them silently skipped warning recalculation. Both paths
        now share ``WARNING_AFFECTING_KEYS``; this pins the bulk behavior.
        """
        setting = _make_setting(
            key=egress_key, value="false", ui_element="text", editable=True
        )

        with patch(f"{DECORATOR_MODULE}.get_user_db_session") as mock_ctx:
            mock_session = MagicMock()
            mock_session.query.return_value.all.side_effect = [
                [setting],
                [setting],
            ]
            mock_ctx.return_value.__enter__ = MagicMock(
                return_value=mock_session
            )
            mock_ctx.return_value.__exit__ = MagicMock(return_value=False)
            resp = authenticated_client.post(
                f"{SETTINGS_PREFIX}/save_all_settings",
                json={egress_key: "true"},
            )

        assert resp.status_code == 200
        data = resp.get_json()
        assert "warnings" in data, (
            f"bulk save of {egress_key!r} did not recalculate warnings — it "
            f"must be in WARNING_AFFECTING_KEYS (regression of #4463)"
        )

    def test_exception_returns_500(self, authenticated_client):
        """Generic exception during session setup returns 500 JSON."""
        with patch(f"{DECORATOR_MODULE}.get_user_db_session") as mock_ctx:
            mock_session = MagicMock()
            mock_session.query.side_effect = RuntimeError("boom")
            mock_ctx.return_value.__enter__ = MagicMock(
                return_value=mock_session
            )
            mock_ctx.return_value.__exit__ = MagicMock(return_value=False)
            resp = authenticated_client.post(
                f"{SETTINGS_PREFIX}/save_all_settings",
                json={"llm.model": "x"},
            )
            assert resp.status_code == 500

    def test_setting_type_categorization(self, authenticated_client):
        """Test different key prefixes for setting type categorization."""
        # search setting with parameters category
        setting = _make_setting(
            key="search.iterations", value=3, ui_element="number", editable=True
        )
        setting.type = "search"

        with patch(f"{DECORATOR_MODULE}.get_user_db_session") as mock_ctx:
            mock_session = MagicMock()
            mock_session.query.return_value.all.side_effect = [
                [setting],
                [setting],
            ]
            mock_ctx.return_value.__enter__ = MagicMock(
                return_value=mock_session
            )
            mock_ctx.return_value.__exit__ = MagicMock(return_value=False)

            with patch(
                f"{ROUTES_MODULE}.coerce_setting_for_write", return_value=5
            ):
                with patch(
                    f"{ROUTES_MODULE}.validate_setting",
                    return_value=(True, None),
                ):
                    with patch(
                        f"{ROUTES_MODULE}.set_setting", return_value=True
                    ):
                        resp = authenticated_client.post(
                            f"{SETTINGS_PREFIX}/save_all_settings",
                            json={"search.iterations": 5},
                        )
        assert resp.status_code == 200

    def test_corrupted_search_tool(self, authenticated_client):
        """Corrupted search.tool value gets corrected to 'searxng'."""
        setting = _make_setting(
            key="search.tool", value="searxng", ui_element="text", editable=True
        )
        setting.type = "search"

        with patch(f"{DECORATOR_MODULE}.get_user_db_session") as mock_ctx:
            mock_session = MagicMock()
            mock_session.query.return_value.all.side_effect = [
                [setting],
                [setting],
            ]
            mock_ctx.return_value.__enter__ = MagicMock(
                return_value=mock_session
            )
            mock_ctx.return_value.__exit__ = MagicMock(return_value=False)
            with patch(
                f"{ROUTES_MODULE}.coerce_setting_for_write",
                return_value="searxng",
            ):
                with patch(
                    f"{ROUTES_MODULE}.validate_setting",
                    return_value=(True, None),
                ):
                    with patch(
                        f"{ROUTES_MODULE}.set_setting", return_value=True
                    ):
                        resp = authenticated_client.post(
                            f"{SETTINGS_PREFIX}/save_all_settings",
                            json={"search.tool": "[object Object]"},
                        )
        assert resp.status_code == 200

    def test_corrupted_app_theme(self, authenticated_client):
        """Corrupted app.theme gets corrected to 'dark'."""
        setting = _make_setting(
            key="app.theme", value="dark", ui_element="text", editable=True
        )
        setting.type = "app"

        with patch(f"{DECORATOR_MODULE}.get_user_db_session") as mock_ctx:
            mock_session = MagicMock()
            mock_session.query.return_value.all.side_effect = [
                [setting],
                [setting],
            ]
            mock_ctx.return_value.__enter__ = MagicMock(
                return_value=mock_session
            )
            mock_ctx.return_value.__exit__ = MagicMock(return_value=False)
            with patch(
                f"{ROUTES_MODULE}.coerce_setting_for_write", return_value="dark"
            ):
                with patch(
                    f"{ROUTES_MODULE}.validate_setting",
                    return_value=(True, None),
                ):
                    with patch(
                        f"{ROUTES_MODULE}.set_setting", return_value=True
                    ):
                        resp = authenticated_client.post(
                            f"{SETTINGS_PREFIX}/save_all_settings",
                            json={"app.theme": "{}"},
                        )
        assert resp.status_code == 200


class TestSaveSettings:
    """POST /settings/save_settings (traditional form POST)."""

    def test_blocked_keys_flash(self, authenticated_client):
        resp = authenticated_client.post(
            f"{SETTINGS_PREFIX}/save_settings",
            data={"evil.module_path": "bad"},
        )
        assert resp.status_code == 302  # redirect

    def test_successful_save(self, authenticated_client):
        resp = authenticated_client.post(
            f"{SETTINGS_PREFIX}/save_settings",
            data={"llm.temperature": "0.5"},
        )
        assert resp.status_code == 302

    def test_exception_flashes_error(self, authenticated_client):
        with patch(f"{DECORATOR_MODULE}.get_user_db_session") as mock_ctx:
            mock_ctx.side_effect = RuntimeError("db fail")
            resp = authenticated_client.post(
                f"{SETTINGS_PREFIX}/save_settings",
                data={"llm.model": "gpt-4"},
            )
            assert resp.status_code == 500
            assert resp.get_json()["error"] == "Database session unavailable"


class TestFixCorruptedSettings:
    """POST /settings/fix_corrupted_settings"""

    def test_no_corruption(self, authenticated_client):
        """No corrupted or duplicate settings."""
        setting = _make_setting(key="llm.model", value="gpt-4")

        with patch(f"{DECORATOR_MODULE}.get_user_db_session") as mock_ctx:
            mock_session = MagicMock()
            # duplicate_keys query
            mock_session.query.return_value.group_by.return_value.having.return_value.all.return_value = []
            # all_settings query
            mock_session.query.return_value.all.return_value = [setting]
            mock_ctx.return_value.__enter__ = MagicMock(
                return_value=mock_session
            )
            mock_ctx.return_value.__exit__ = MagicMock(return_value=False)
            resp = authenticated_client.post(
                f"{SETTINGS_PREFIX}/fix_corrupted_settings"
            )
        assert resp.status_code == 200
        data = resp.get_json()
        assert data["status"] == "success"

    def test_fixes_corrupted_llm_model(self, authenticated_client):
        """Corrupted llm.model gets fixed to default."""
        setting = _make_setting(key="llm.model", value="[object Object]")

        with patch(f"{DECORATOR_MODULE}.get_user_db_session") as mock_ctx:
            mock_session = MagicMock()
            mock_session.query.return_value.group_by.return_value.having.return_value.all.return_value = []
            mock_session.query.return_value.all.return_value = [setting]
            mock_ctx.return_value.__enter__ = MagicMock(
                return_value=mock_session
            )
            mock_ctx.return_value.__exit__ = MagicMock(return_value=False)
            resp = authenticated_client.post(
                f"{SETTINGS_PREFIX}/fix_corrupted_settings"
            )
        assert resp.status_code == 200
        data = resp.get_json()
        assert "llm.model" in data["fixed_settings"]

    def test_fixes_corrupted_search_settings(self, authenticated_client):
        """Corrupted search settings get fixed."""
        settings = [
            _make_setting(key="search.tool", value="null"),
            _make_setting(key="search.max_results", value="undefined"),
            _make_setting(key="search.region", value=None),
        ]

        with patch(f"{DECORATOR_MODULE}.get_user_db_session") as mock_ctx:
            mock_session = MagicMock()
            mock_session.query.return_value.group_by.return_value.having.return_value.all.return_value = []
            mock_session.query.return_value.all.return_value = settings
            mock_ctx.return_value.__enter__ = MagicMock(
                return_value=mock_session
            )
            mock_ctx.return_value.__exit__ = MagicMock(return_value=False)
            resp = authenticated_client.post(
                f"{SETTINGS_PREFIX}/fix_corrupted_settings"
            )
        assert resp.status_code == 200

    def test_fixes_corrupted_app_settings(self, authenticated_client):
        """Corrupted app settings get fixed."""
        settings = [
            _make_setting(key="app.theme", value="{}"),
            _make_setting(key="app.host", value=None),
            _make_setting(key="app.port", value="null"),
            _make_setting(key="app.debug", value="undefined"),
        ]

        with patch(f"{DECORATOR_MODULE}.get_user_db_session") as mock_ctx:
            mock_session = MagicMock()
            mock_session.query.return_value.group_by.return_value.having.return_value.all.return_value = []
            mock_session.query.return_value.all.return_value = settings
            mock_ctx.return_value.__enter__ = MagicMock(
                return_value=mock_session
            )
            mock_ctx.return_value.__exit__ = MagicMock(return_value=False)
            resp = authenticated_client.post(
                f"{SETTINGS_PREFIX}/fix_corrupted_settings"
            )
        assert resp.status_code == 200

    def test_removes_duplicates(self, authenticated_client):
        """Duplicate settings get removed."""
        dup_setting1 = _make_setting(key="llm.model", value="gpt-4")
        dup_setting2 = _make_setting(key="llm.model", value="gpt-3.5")

        with patch(f"{DECORATOR_MODULE}.get_user_db_session") as mock_ctx:
            mock_session = MagicMock()
            # duplicate_keys query returns one duplicate key
            mock_session.query.return_value.group_by.return_value.having.return_value.all.return_value = [
                ("llm.model",)
            ]
            # Settings for duplicate key ordered by updated_at desc
            mock_session.query.return_value.filter.return_value.order_by.return_value.all.return_value = [
                dup_setting1,
                dup_setting2,
            ]
            # all_settings (no corruption)
            mock_session.query.return_value.all.return_value = [dup_setting1]
            mock_ctx.return_value.__enter__ = MagicMock(
                return_value=mock_session
            )
            mock_ctx.return_value.__exit__ = MagicMock(return_value=False)
            resp = authenticated_client.post(
                f"{SETTINGS_PREFIX}/fix_corrupted_settings"
            )
        assert resp.status_code == 200

    def test_exception_returns_500(self, authenticated_client):
        with patch(f"{DECORATOR_MODULE}.get_user_db_session") as mock_ctx:
            mock_session = MagicMock()
            mock_session.query.side_effect = RuntimeError("db fail")
            mock_ctx.return_value.__enter__ = MagicMock(
                return_value=mock_session
            )
            mock_ctx.return_value.__exit__ = MagicMock(return_value=False)
            resp = authenticated_client.post(
                f"{SETTINGS_PREFIX}/fix_corrupted_settings"
            )
        assert resp.status_code == 500


class TestApiGetWarnings:
    """GET /settings/api/warnings"""

    @patch(f"{ROUTES_MODULE}.calculate_warnings", return_value=[])
    def test_success(self, mock_w, authenticated_client):
        resp = authenticated_client.get(f"{SETTINGS_PREFIX}/api/warnings")
        assert resp.status_code == 200
        data = resp.get_json()
        assert "warnings" in data

    @patch(
        f"{ROUTES_MODULE}.calculate_warnings", side_effect=RuntimeError("fail")
    )
    def test_error(self, mock_w, authenticated_client):
        resp = authenticated_client.get(f"{SETTINGS_PREFIX}/api/warnings")
        assert resp.status_code == 500


class TestCheckOllamaStatus:
    """GET /settings/api/ollama-status"""

    @patch(f"{ROUTES_MODULE}.safe_get")
    @patch(
        f"{ROUTES_MODULE}._get_setting_from_session",
        return_value="http://localhost:11434",
    )
    def test_running(self, mock_setting, mock_get, authenticated_client):
        mock_resp = MagicMock()
        mock_resp.status_code = 200
        mock_resp.json.return_value = {"version": "0.1.0"}
        mock_get.return_value = mock_resp
        resp = authenticated_client.get(f"{SETTINGS_PREFIX}/api/ollama-status")
        assert resp.status_code == 200
        data = resp.get_json()
        assert data["running"] is True

    @patch(f"{ROUTES_MODULE}.safe_get")
    @patch(
        f"{ROUTES_MODULE}._get_setting_from_session",
        return_value="http://localhost:11434",
    )
    def test_not_running(self, mock_setting, mock_get, authenticated_client):
        mock_resp = MagicMock()
        mock_resp.status_code = 503
        mock_get.return_value = mock_resp
        resp = authenticated_client.get(f"{SETTINGS_PREFIX}/api/ollama-status")
        assert resp.status_code == 200
        data = resp.get_json()
        assert data["running"] is False

    @patch(f"{ROUTES_MODULE}.safe_get")
    @patch(
        f"{ROUTES_MODULE}._get_setting_from_session",
        return_value="http://localhost:11434",
    )
    def test_connection_error(
        self, mock_setting, mock_get, authenticated_client
    ):
        import requests as req_lib

        mock_get.side_effect = req_lib.exceptions.ConnectionError("refused")
        resp = authenticated_client.get(f"{SETTINGS_PREFIX}/api/ollama-status")
        assert resp.status_code == 200
        data = resp.get_json()
        assert data["running"] is False


class TestGetBulkSettings:
    """GET /settings/api/bulk"""

    def test_default_keys(self, authenticated_client):
        resp = authenticated_client.get(f"{SETTINGS_PREFIX}/api/bulk")
        assert resp.status_code == 200
        data = resp.get_json()
        assert data["success"] is True
        assert "settings" in data

    def test_custom_keys(self, authenticated_client):
        resp = authenticated_client.get(
            f"{SETTINGS_PREFIX}/api/bulk?keys[]=llm.model&keys[]=search.tool"
        )
        assert resp.status_code == 200
        data = resp.get_json()
        assert data["success"] is True

    @patch(
        f"{ROUTES_MODULE}._get_setting_from_session",
        side_effect=RuntimeError("boom"),
    )
    def test_individual_key_error(self, mock_get, authenticated_client):
        resp = authenticated_client.get(
            f"{SETTINGS_PREFIX}/api/bulk?keys[]=bad.key"
        )
        assert resp.status_code == 200
        data = resp.get_json()
        assert data["settings"]["bad.key"]["exists"] is False
        assert "error" in data["settings"]["bad.key"]


class TestApiGetSearchFavorites:
    """GET /settings/api/search-favorites"""

    def test_returns_favorites(self, authenticated_client):
        resp = authenticated_client.get(
            f"{SETTINGS_PREFIX}/api/search-favorites"
        )
        assert resp.status_code == 200
        data = resp.get_json()
        assert "favorites" in data


class TestApiUpdateSearchFavorites:
    """PUT /settings/api/search-favorites"""

    def test_no_favorites_key(self, authenticated_client):
        resp = authenticated_client.put(
            f"{SETTINGS_PREFIX}/api/search-favorites",
            json={"not_favorites": []},
        )
        assert resp.status_code == 400

    def test_favorites_not_list(self, authenticated_client):
        resp = authenticated_client.put(
            f"{SETTINGS_PREFIX}/api/search-favorites",
            json={"favorites": "not_a_list"},
        )
        assert resp.status_code == 400

    @patch(f"{DECORATOR_MODULE}.SettingsManager")
    def test_update_success(self, mock_sm_cls, authenticated_client):
        mock_sm = MagicMock()
        mock_sm.set_setting.return_value = True
        mock_sm_cls.return_value = mock_sm
        with patch(f"{DECORATOR_MODULE}.get_user_db_session") as mock_ctx:
            mock_session = MagicMock()
            mock_ctx.return_value.__enter__ = MagicMock(
                return_value=mock_session
            )
            mock_ctx.return_value.__exit__ = MagicMock(return_value=False)
            resp = authenticated_client.put(
                f"{SETTINGS_PREFIX}/api/search-favorites",
                json={"favorites": ["google", "bing"]},
            )
        assert resp.status_code == 200

    @patch(f"{DECORATOR_MODULE}.SettingsManager")
    def test_update_fails(self, mock_sm_cls, authenticated_client):
        mock_sm = MagicMock()
        mock_sm.set_setting.return_value = False
        mock_sm_cls.return_value = mock_sm
        with patch(f"{DECORATOR_MODULE}.get_user_db_session") as mock_ctx:
            mock_session = MagicMock()
            mock_ctx.return_value.__enter__ = MagicMock(
                return_value=mock_session
            )
            mock_ctx.return_value.__exit__ = MagicMock(return_value=False)
            resp = authenticated_client.put(
                f"{SETTINGS_PREFIX}/api/search-favorites",
                json={"favorites": ["google"]},
            )
        assert resp.status_code == 500


class TestApiToggleSearchFavorite:
    """POST /settings/api/search-favorites/toggle"""

    def test_no_engine_id(self, authenticated_client):
        resp = authenticated_client.post(
            f"{SETTINGS_PREFIX}/api/search-favorites/toggle",
            json={"not_engine_id": "x"},
        )
        assert resp.status_code == 400

    @patch(f"{DECORATOR_MODULE}.SettingsManager")
    def test_add_favorite(self, mock_sm_cls, authenticated_client):
        mock_sm = MagicMock()
        mock_sm.get_setting.return_value = []
        mock_sm.set_setting.return_value = True
        mock_sm_cls.return_value = mock_sm
        with patch(f"{DECORATOR_MODULE}.get_user_db_session") as mock_ctx:
            mock_session = MagicMock()
            mock_ctx.return_value.__enter__ = MagicMock(
                return_value=mock_session
            )
            mock_ctx.return_value.__exit__ = MagicMock(return_value=False)
            resp = authenticated_client.post(
                f"{SETTINGS_PREFIX}/api/search-favorites/toggle",
                json={"engine_id": "google"},
            )
        assert resp.status_code == 200
        data = resp.get_json()
        assert data["is_favorite"] is True

    @patch(f"{DECORATOR_MODULE}.SettingsManager")
    def test_remove_favorite(self, mock_sm_cls, authenticated_client):
        mock_sm = MagicMock()
        mock_sm.get_setting.return_value = ["google"]
        mock_sm.set_setting.return_value = True
        mock_sm_cls.return_value = mock_sm
        with patch(f"{DECORATOR_MODULE}.get_user_db_session") as mock_ctx:
            mock_session = MagicMock()
            mock_ctx.return_value.__enter__ = MagicMock(
                return_value=mock_session
            )
            mock_ctx.return_value.__exit__ = MagicMock(return_value=False)
            resp = authenticated_client.post(
                f"{SETTINGS_PREFIX}/api/search-favorites/toggle",
                json={"engine_id": "google"},
            )
        assert resp.status_code == 200
        data = resp.get_json()
        assert data["is_favorite"] is False

    @patch(f"{DECORATOR_MODULE}.SettingsManager")
    def test_toggle_fails(self, mock_sm_cls, authenticated_client):
        mock_sm = MagicMock()
        mock_sm.get_setting.return_value = []
        mock_sm.set_setting.return_value = False
        mock_sm_cls.return_value = mock_sm
        with patch(f"{DECORATOR_MODULE}.get_user_db_session") as mock_ctx:
            mock_session = MagicMock()
            mock_ctx.return_value.__enter__ = MagicMock(
                return_value=mock_session
            )
            mock_ctx.return_value.__exit__ = MagicMock(return_value=False)
            resp = authenticated_client.post(
                f"{SETTINGS_PREFIX}/api/search-favorites/toggle",
                json={"engine_id": "google"},
            )
        assert resp.status_code == 500

    @patch(f"{DECORATOR_MODULE}.SettingsManager")
    def test_favorites_not_list_resets(self, mock_sm_cls, authenticated_client):
        """If favorites is not a list, it gets reset to empty list."""
        mock_sm = MagicMock()
        mock_sm.get_setting.return_value = "not_a_list"
        mock_sm.set_setting.return_value = True
        mock_sm_cls.return_value = mock_sm
        with patch(f"{DECORATOR_MODULE}.get_user_db_session") as mock_ctx:
            mock_session = MagicMock()
            mock_ctx.return_value.__enter__ = MagicMock(
                return_value=mock_session
            )
            mock_ctx.return_value.__exit__ = MagicMock(return_value=False)
            resp = authenticated_client.post(
                f"{SETTINGS_PREFIX}/api/search-favorites/toggle",
                json={"engine_id": "google"},
            )
        assert resp.status_code == 200
        data = resp.get_json()
        assert data["is_favorite"] is True


class TestApiTestNotificationUrl:
    """POST /settings/api/notifications/test-url"""

    def test_no_body(self, authenticated_client):
        resp = authenticated_client.post(
            f"{SETTINGS_PREFIX}/api/notifications/test-url",
            json={},
        )
        assert resp.status_code == 400

    @patch(f"{ROUTES_MODULE}.NotificationService", create=True)
    def test_success(self, mock_ns_cls, authenticated_client):
        mock_ns = MagicMock()
        mock_ns.test_service.return_value = {
            "success": True,
            "message": "ok",
            "error": "",
        }
        # Patch the actual import inside the function
        with patch(
            "local_deep_research.notifications.service.NotificationService",
            return_value=mock_ns,
        ):
            with patch(
                f"{ROUTES_MODULE}.NotificationService", mock_ns_cls, create=True
            ):
                # The function imports NotificationService inside, so we need to patch that import
                resp = authenticated_client.post(
                    f"{SETTINGS_PREFIX}/api/notifications/test-url",
                    json={"service_url": "http://example.com"},
                )
        # Just check it doesn't crash - could be 200 or 500 depending on import path
        assert resp.status_code in [200, 500]


class TestApiGetDataLocation:
    """GET /settings/api/data-location"""

    @patch(f"{ROUTES_MODULE}.db_manager")
    @patch(
        f"{ROUTES_MODULE}.get_encrypted_database_path",
        return_value="/tmp/test.db",
    )
    @patch(f"{ROUTES_MODULE}.get_data_directory", return_value="/tmp/data")
    def test_success(
        self, mock_dir, mock_db_path, mock_dbm, authenticated_client
    ):
        mock_dbm.has_encryption = False
        with patch(f"{DECORATOR_MODULE}.SettingsManager") as mock_sm_cls:
            mock_sm = MagicMock()
            mock_sm.get_setting.return_value = None
            mock_sm_cls.return_value = mock_sm
            with patch(f"{ROUTES_MODULE}.platform") as mock_platform:
                mock_platform.system.return_value = "Linux"
                resp = authenticated_client.get(
                    f"{SETTINGS_PREFIX}/api/data-location"
                )
        assert resp.status_code == 200
        data = resp.get_json()
        assert "data_directory" in data
        assert data["security_notice"]["encrypted"] is False

    @patch(f"{ROUTES_MODULE}.db_manager")
    @patch(
        f"{ROUTES_MODULE}.get_encrypted_database_path",
        return_value="/tmp/test.db",
    )
    @patch(f"{ROUTES_MODULE}.get_data_directory", return_value="/tmp/data")
    def test_with_encryption(
        self, mock_dir, mock_db_path, mock_dbm, authenticated_client
    ):
        mock_dbm.has_encryption = True
        with patch(
            "local_deep_research.settings.manager.SettingsManager"
        ) as mock_sm_cls:
            mock_sm = MagicMock()
            mock_sm.get_setting.return_value = "/custom/dir"
            mock_sm_cls.return_value = mock_sm
            with patch(f"{ROUTES_MODULE}.platform") as mock_platform:
                mock_platform.system.return_value = "Darwin"
                with patch(
                    "local_deep_research.database.sqlcipher_utils.get_sqlcipher_settings",
                    return_value={"cipher": "aes-256"},
                ):
                    resp = authenticated_client.get(
                        f"{SETTINGS_PREFIX}/api/data-location"
                    )
        assert resp.status_code == 200
        data = resp.get_json()
        assert data["security_notice"]["encrypted"] is True
        assert data["platform"] == "macOS"


# NOTE: /api/rate-limiting/cleanup is covered by the canonical DB-backed tests
# in test_settings_routes_rate_limiting.py::TestApiCleanupRateLimiting (auth,
# default/custom-window delete + commit, 500 path, and days validation). The
# duplicate class that used to live here patched the removed get_tracker() path
# (#4735) and drifted stale; removed to avoid two copies diverging again.


class TestSaveAllSettingsNewSettingTypes:
    """Test new setting creation with different value types in save_all_settings."""

    @patch(f"{ROUTES_MODULE}.create_or_update_setting")
    def test_new_bool_setting(self, mock_create, authenticated_client):
        """Bool value creates checkbox UI element."""
        mock_new = _make_setting(key="app.new_flag")
        mock_new.type = "app"
        mock_create.return_value = mock_new

        with patch(f"{DECORATOR_MODULE}.get_user_db_session") as mock_ctx:
            mock_session = MagicMock()
            mock_session.query.return_value.all.side_effect = [[], []]
            mock_ctx.return_value.__enter__ = MagicMock(
                return_value=mock_session
            )
            mock_ctx.return_value.__exit__ = MagicMock(return_value=False)
            resp = authenticated_client.post(
                f"{SETTINGS_PREFIX}/save_all_settings",
                json={"app.new_flag": True},
            )
        assert resp.status_code == 200
        # Check that create was called with checkbox ui_element
        call_args = mock_create.call_args[0][0]
        assert call_args["ui_element"] == "checkbox"

    @patch(f"{ROUTES_MODULE}.create_or_update_setting")
    def test_new_number_setting(self, mock_create, authenticated_client):
        """Numeric value creates number UI element."""
        mock_new = _make_setting(key="search.new_count")
        mock_new.type = "search"
        mock_create.return_value = mock_new

        with patch(f"{DECORATOR_MODULE}.get_user_db_session") as mock_ctx:
            mock_session = MagicMock()
            mock_session.query.return_value.all.side_effect = [[], []]
            mock_ctx.return_value.__enter__ = MagicMock(
                return_value=mock_session
            )
            mock_ctx.return_value.__exit__ = MagicMock(return_value=False)
            resp = authenticated_client.post(
                f"{SETTINGS_PREFIX}/save_all_settings",
                json={"search.new_count": 42},
            )
        assert resp.status_code == 200
        call_args = mock_create.call_args[0][0]
        assert call_args["ui_element"] == "number"

    @patch(f"{ROUTES_MODULE}.create_or_update_setting")
    def test_new_dict_setting(self, mock_create, authenticated_client):
        """Dict value creates textarea UI element."""
        mock_new = _make_setting(key="report.new_struct")
        mock_new.type = "report"
        mock_create.return_value = mock_new

        with patch(f"{DECORATOR_MODULE}.get_user_db_session") as mock_ctx:
            mock_session = MagicMock()
            mock_session.query.return_value.all.side_effect = [[], []]
            mock_ctx.return_value.__enter__ = MagicMock(
                return_value=mock_session
            )
            mock_ctx.return_value.__exit__ = MagicMock(return_value=False)
            resp = authenticated_client.post(
                f"{SETTINGS_PREFIX}/save_all_settings",
                json={"report.new_struct": {"key": "val"}},
            )
        assert resp.status_code == 200
        call_args = mock_create.call_args[0][0]
        assert call_args["ui_element"] == "textarea"

    @patch(f"{ROUTES_MODULE}.create_or_update_setting")
    def test_new_database_setting(self, mock_create, authenticated_client):
        """Database-prefixed key gets correct type."""
        mock_new = _make_setting(key="database.new_param")
        mock_new.type = "database"
        mock_create.return_value = mock_new

        with patch(f"{DECORATOR_MODULE}.get_user_db_session") as mock_ctx:
            mock_session = MagicMock()
            mock_session.query.return_value.all.side_effect = [[], []]
            mock_ctx.return_value.__enter__ = MagicMock(
                return_value=mock_session
            )
            mock_ctx.return_value.__exit__ = MagicMock(return_value=False)
            resp = authenticated_client.post(
                f"{SETTINGS_PREFIX}/save_all_settings",
                json={"database.new_param": "value"},
            )
        assert resp.status_code == 200


class TestSaveAllSettingsSuccessMessages:
    """Test success message variations in save_all_settings."""

    @patch(f"{ROUTES_MODULE}.set_setting", return_value=True)
    @patch(
        f"{ROUTES_MODULE}.coerce_setting_for_write",
        side_effect=lambda key, value, ui_element: value,
    )
    @patch(f"{ROUTES_MODULE}.validate_setting", return_value=(True, None))
    def test_single_bool_update_message(
        self, mock_v, mock_c, mock_s, authenticated_client
    ):
        """Single boolean update uses enabled/disabled language."""
        setting = _make_setting(
            key="search.safe_search",
            value=True,
            ui_element="checkbox",
            editable=True,
        )
        setting.type = "search"

        with patch(f"{DECORATOR_MODULE}.get_user_db_session") as mock_ctx:
            mock_session = MagicMock()
            mock_session.query.return_value.all.side_effect = [
                [setting],
                [setting],
            ]
            mock_ctx.return_value.__enter__ = MagicMock(
                return_value=mock_session
            )
            mock_ctx.return_value.__exit__ = MagicMock(return_value=False)
            resp = authenticated_client.post(
                f"{SETTINGS_PREFIX}/save_all_settings",
                json={"search.safe_search": True},
            )
        assert resp.status_code == 200
        data = resp.get_json()
        msg = data["message"]
        assert "enabled" in msg or "disabled" in msg or "updated" in msg

    @patch(f"{ROUTES_MODULE}.set_setting", return_value=True)
    @patch(
        f"{ROUTES_MODULE}.coerce_setting_for_write",
        side_effect=lambda key, value, ui_element: value,
    )
    @patch(f"{ROUTES_MODULE}.validate_setting", return_value=(True, None))
    def test_multiple_updates_message(
        self, mock_v, mock_c, mock_s, authenticated_client
    ):
        """Multiple updates use count message."""
        s1 = _make_setting(
            key="llm.model", value="gpt-4", ui_element="text", editable=True
        )
        s1.type = "llm"
        s2 = _make_setting(
            key="llm.temperature", value=0.7, ui_element="number", editable=True
        )
        s2.type = "llm"

        with patch(f"{DECORATOR_MODULE}.get_user_db_session") as mock_ctx:
            mock_session = MagicMock()
            mock_session.query.return_value.all.side_effect = [
                [s1, s2],
                [s1, s2],
            ]
            mock_ctx.return_value.__enter__ = MagicMock(
                return_value=mock_session
            )
            mock_ctx.return_value.__exit__ = MagicMock(return_value=False)
            resp = authenticated_client.post(
                f"{SETTINGS_PREFIX}/save_all_settings",
                json={"llm.model": "gpt-3.5-turbo", "llm.temperature": 0.5},
            )
        assert resp.status_code == 200
        data = resp.get_json()
        assert "2 updated" in data["message"]


class TestSaveAllSettingsValidationErrors:
    """Test validation error paths in save_all_settings."""

    @patch(f"{ROUTES_MODULE}.coerce_setting_for_write", return_value="bad")
    @patch(
        f"{ROUTES_MODULE}.validate_setting",
        return_value=(False, "Value must be a number"),
    )
    def test_validation_error_returned(
        self, mock_v, mock_c, authenticated_client
    ):
        setting = _make_setting(
            key="llm.temperature", value=0.7, ui_element="number", editable=True
        )
        setting.type = "llm"

        with patch(f"{DECORATOR_MODULE}.get_user_db_session") as mock_ctx:
            mock_session = MagicMock()
            mock_session.query.return_value.all.return_value = [setting]
            mock_ctx.return_value.__enter__ = MagicMock(
                return_value=mock_session
            )
            mock_ctx.return_value.__exit__ = MagicMock(return_value=False)
            resp = authenticated_client.post(
                f"{SETTINGS_PREFIX}/save_all_settings",
                json={"llm.temperature": "bad"},
            )
        assert resp.status_code == 400
        data = resp.get_json()
        assert data["status"] == "error"
        assert len(data["errors"]) == 1


class TestSaveAllSettingsSkipsEmptyKeys:
    """Test that empty/corrupted keys are skipped."""

    @patch(f"{ROUTES_MODULE}.set_setting", return_value=True)
    @patch(
        f"{ROUTES_MODULE}.coerce_setting_for_write",
        side_effect=lambda key, value, ui_element: value,
    )
    @patch(f"{ROUTES_MODULE}.validate_setting", return_value=(True, None))
    def test_empty_key_skipped(
        self, mock_v, mock_c, mock_s, authenticated_client
    ):
        setting = _make_setting(
            key="llm.model", value="gpt-4", ui_element="text", editable=True
        )
        setting.type = "llm"

        with patch(f"{DECORATOR_MODULE}.get_user_db_session") as mock_ctx:
            mock_session = MagicMock()
            mock_session.query.return_value.all.side_effect = [
                [setting],
                [setting],
            ]
            mock_ctx.return_value.__enter__ = MagicMock(
                return_value=mock_session
            )
            mock_ctx.return_value.__exit__ = MagicMock(return_value=False)
            resp = authenticated_client.post(
                f"{SETTINGS_PREFIX}/save_all_settings",
                json={"": "value", "llm.model": "gpt-4"},
            )
        assert resp.status_code == 200


class TestFixCorruptedSettingsReportFallback:
    """Test report. key corruption fallback to empty dict."""

    def test_report_key_no_default_gets_empty_dict(self, authenticated_client):
        setting = _make_setting(key="report.unknown_key", value=None)

        with patch(f"{DECORATOR_MODULE}.get_user_db_session") as mock_ctx:
            mock_session = MagicMock()
            mock_session.query.return_value.group_by.return_value.having.return_value.all.return_value = []
            mock_session.query.return_value.all.return_value = [setting]
            mock_ctx.return_value.__enter__ = MagicMock(
                return_value=mock_session
            )
            mock_ctx.return_value.__exit__ = MagicMock(return_value=False)
            resp = authenticated_client.post(
                f"{SETTINGS_PREFIX}/fix_corrupted_settings"
            )
        assert resp.status_code == 200
        data = resp.get_json()
        assert "report.unknown_key" in data["fixed_settings"]
