"""
Coverage gap tests for settings_routes.py targeting remaining uncovered paths.

Covers:
- get_bulk_settings: default keys, custom keys, error handling
- api_get_data_location: platform info, encryption status
- api_test_notification_url: success, missing URL, exception
- api_get_available_models: cache hit, force refresh, provider discovery
"""

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

import pytest
from flask import Flask, jsonify

from local_deep_research.web.auth.routes import auth_bp
from local_deep_research.web.routes.settings_routes import settings_bp

MODULE = "local_deep_research.web.routes.settings_routes"
DECORATOR_MODULE = "local_deep_research.web.utils.route_decorators"


def _create_test_app():
    app = Flask(__name__)
    app.config["SECRET_KEY"] = "test-secret"
    app.config["WTF_CSRF_ENABLED"] = False
    app.register_blueprint(auth_bp)
    app.register_blueprint(settings_bp)

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

    return app


@contextmanager
def _authenticated_client(app):
    mock_db = Mock()
    mock_db.connections = {"testuser": True}
    mock_db.has_encryption = False

    @contextmanager
    def _fake_session(*args, **kwargs):
        yield MagicMock()

    patches = [
        patch("local_deep_research.web.auth.decorators.db_manager", mock_db),
        patch(
            f"{DECORATOR_MODULE}.get_user_db_session", side_effect=_fake_session
        ),
        patch(f"{MODULE}.settings_limit", lambda f: f),
    ]

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


# ===========================================================================
# api_get_all_settings — redaction
# ===========================================================================


class TestApiGetAllSettingsRedaction:
    """Tests for GET /settings/api — verifies password-typed values are
    redacted in the JSON response so env-overridden API keys (and any other
    ``ui_element=='password'`` field) cannot leak via the JSON endpoint.

    The fix wraps the settings dict in
    ``DataSanitizer.redact_settings_snapshot`` before ``jsonify``. No JS
    caller round-trips this endpoint's values back into the form (the
    settings form is server-rendered from the template), so redacting the
    JSON response is safe.
    """

    SECRET = "sk-real-leaked-key"
    SAFE_VALUE = "summary_focus_query"

    def _snapshot_with_secret(self):
        return {
            "llm.openai.api_key": {
                "value": self.SECRET,
                "ui_element": "password",
                "type": "LLM",
            },
            "search.fetch.mode": {
                "value": self.SAFE_VALUE,
                "ui_element": "select",
                "type": "SEARCH",
            },
        }

    def test_password_value_is_redacted(self):
        """The plaintext API key MUST NOT appear in the response."""
        app = _create_test_app()
        mock_sm = Mock()
        mock_sm.get_all_settings.return_value = self._snapshot_with_secret()

        with _authenticated_client(app) as client:
            with patch(
                "local_deep_research.web.utils.route_decorators.SettingsManager",
                return_value=mock_sm,
            ):
                resp = client.get("/settings/api")

        assert resp.status_code == 200
        data = resp.get_json()
        assert data["status"] == "success"
        assert data["settings"]["llm.openai.api_key"]["value"] == "[REDACTED]"
        # And belt-and-braces: the raw secret string must be nowhere in
        # the serialised payload (no encoded copies, no log echoes).
        assert self.SECRET not in resp.get_data(as_text=True)

    def test_non_secret_values_pass_through(self):
        """Non-password settings still come back with their real values."""
        app = _create_test_app()
        mock_sm = Mock()
        mock_sm.get_all_settings.return_value = self._snapshot_with_secret()

        with _authenticated_client(app) as client:
            with patch(
                "local_deep_research.web.utils.route_decorators.SettingsManager",
                return_value=mock_sm,
            ):
                resp = client.get("/settings/api")

        data = resp.get_json()
        assert data["settings"]["search.fetch.mode"]["value"] == self.SAFE_VALUE

    def test_metadata_preserved_for_redacted_entry(self):
        """ui_element/type/etc. survive so the UI can still render a
        password input even though the value is masked."""
        app = _create_test_app()
        mock_sm = Mock()
        mock_sm.get_all_settings.return_value = self._snapshot_with_secret()

        with _authenticated_client(app) as client:
            with patch(
                "local_deep_research.web.utils.route_decorators.SettingsManager",
                return_value=mock_sm,
            ):
                resp = client.get("/settings/api")

        data = resp.get_json()
        entry = data["settings"]["llm.openai.api_key"]
        assert entry["ui_element"] == "password"
        assert entry["type"] == "LLM"


# ===========================================================================
# Empty-password no-op safety net
#
# Companion fix to the /settings/api redaction. The render layer keeps
# password inputs empty so the saved value never enters the HTML — but
# the backend must also enforce that submitting "" for a password
# setting does NOT wipe the stored secret. Otherwise a stale tab, a
# direct cURL, or a buggy frontend can corrupt API keys.
# ===========================================================================


def _make_password_db_setting(key="llm.openai.api_key", value="sk-existing"):
    """A mock Setting model row with ui_element='password'.

    All JSON-serialisable fields get concrete values so ``jsonify`` in the
    GET routes doesn't choke on auto-created MagicMock attributes.
    """
    s = MagicMock()
    s.key = key
    s.value = value
    s.ui_element = "password"
    s.editable = True
    s.visible = True
    s.type = MagicMock(value="LLM")
    s.name = key
    s.description = ""
    s.category = "llm_general"
    s.options = None
    s.min_value = None
    s.max_value = None
    s.step = None
    return s


@contextmanager
def _client_with_db(app, db_setting):
    """Authenticated test client whose DB query for `Setting` returns
    `db_setting`. Overrides the more general `_authenticated_client`
    fixture's MagicMock so the route sees a controllable Setting row.
    """
    mock_db_mgr = Mock()
    mock_db_mgr.connections = {"testuser": True}
    mock_db_mgr.has_encryption = False

    db = MagicMock()
    db.query.return_value.filter.return_value.first.return_value = db_setting

    @contextmanager
    def _fake_session(*args, **kwargs):
        yield db

    patches = [
        patch(
            "local_deep_research.web.auth.decorators.db_manager",
            mock_db_mgr,
        ),
        patch(
            f"{DECORATOR_MODULE}.get_user_db_session",
            side_effect=_fake_session,
        ),
        patch(f"{MODULE}.settings_limit", lambda f: f),
    ]

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


class TestApiUpdateSettingEmptyPasswordNoop:
    """PUT /settings/api/<key> with value='' on a password setting must be
    a no-op (200 + message), not a write."""

    def test_empty_password_returns_200_without_writing(self):
        app = _create_test_app()
        existing = _make_password_db_setting(value="sk-real-existing")

        with _client_with_db(app, existing) as (client, db):
            resp = client.put(
                "/settings/api/llm.openai.api_key",
                json={"value": ""},
            )

        assert resp.status_code == 200
        body = resp.get_json()
        # Idempotent message — the route does NOT return an error
        # because client save indicators would otherwise show a
        # spurious failure when blurring an empty password field.
        assert "unchanged" in body["message"].lower()
        # The route never reached the write step — no commit was
        # called on the DB session, and the existing row's value is
        # untouched.
        db.commit.assert_not_called()
        assert existing.value == "sk-real-existing"

    def test_redacted_sentinel_returns_200_without_writing(self):
        """Submitting the redaction sentinel ("[REDACTED]") for a password
        setting must also be a no-op. GET /settings/api redacts password
        values to this sentinel, so a stale tab / automation round-trip
        would otherwise persist the literal "[REDACTED]" over the real
        secret."""
        from local_deep_research.security.data_sanitizer import DataSanitizer

        app = _create_test_app()
        existing = _make_password_db_setting(value="sk-real-existing")

        with _client_with_db(app, existing) as (client, db):
            resp = client.put(
                "/settings/api/llm.openai.api_key",
                json={"value": DataSanitizer.REDACTION_TEXT},
            )

        assert resp.status_code == 200
        body = resp.get_json()
        assert "unchanged" in body["message"].lower()
        db.commit.assert_not_called()
        assert existing.value == "sk-real-existing"

    def test_non_empty_password_does_not_match_noop_guard(self):
        """Control: the no-op guard must NOT fire for a non-empty value.

        We don't try to drive the full write path here (that exercises
        coercion + validation + DB session machinery and is covered by
        other integration tests). What this control proves is that the
        guard's specific 200-with-"unchanged"-message response does not
        return for a real value — meaning the guard correctly carved out
        only the empty-string case.
        """
        app = _create_test_app()
        existing = _make_password_db_setting(value="sk-old")

        with _client_with_db(app, existing) as (client, _db):
            resp = client.put(
                "/settings/api/llm.openai.api_key",
                json={"value": "sk-new"},
            )

        # The route may continue to error past our guard (write path
        # depends on machinery we haven't mocked) — that's fine. The
        # invariant we're proving: this is NOT the no-op 200 response.
        if resp.status_code == 200:
            body = resp.get_json() or {}
            assert "unchanged" not in (body.get("message") or "").lower()


# ===========================================================================
# api_get_db_setting (GET /settings/api/<key>) — singular endpoint redaction
#
# The bulk endpoint at /settings/api was redacted in PR #3947, but the
# singular companion was deliberately deferred. With the form template no
# longer pre-filling password values (PR #3954) the singular endpoint's
# only remaining role is diagnostic / single-key API access — same threat
# model as the bulk GET, same redaction needed.
# ===========================================================================


class TestApiGetDbSettingRedaction:
    SECRET = "sk-real-leaked"

    def test_password_value_is_redacted_db_branch(self):
        """When the setting exists in the DB and is password-typed, the
        response must replace `value` with [REDACTED]."""
        app = _create_test_app()
        existing = _make_password_db_setting(value=self.SECRET)

        with _client_with_db(app, existing) as (client, _db):
            resp = client.get("/settings/api/llm.openai.api_key")

        assert resp.status_code == 200
        body = resp.get_json()
        assert body["value"] == "[REDACTED]"
        # Metadata still passes through so the front-end can render
        # the right input control.
        assert body["ui_element"] == "password"
        # Belt-and-braces: the secret is nowhere in the serialised payload.
        assert self.SECRET not in resp.get_data(as_text=True)

    def test_non_password_value_passes_through_db_branch(self):
        """Non-password settings stay readable — only `password` is special."""
        app = _create_test_app()
        plain = _make_password_db_setting(
            key="search.fetch.mode", value="summary_focus_query"
        )
        plain.ui_element = "select"  # override the helper's default

        with _client_with_db(app, plain) as (client, _db):
            resp = client.get("/settings/api/search.fetch.mode")

        body = resp.get_json()
        assert body["value"] == "summary_focus_query"

    def test_empty_password_value_stays_empty(self):
        """Empty/None values are not the secret — leave them readable
        so the front-end can tell 'not configured' from 'configured'."""
        app = _create_test_app()
        existing = _make_password_db_setting(value="")

        with _client_with_db(app, existing) as (client, _db):
            resp = client.get("/settings/api/llm.openai.api_key")

        body = resp.get_json()
        assert body["value"] == ""


# ===========================================================================
# get_bulk_settings (GET /settings/api/bulk) — caller-controlled keys[]
#
# Anyone authenticated can ask for arbitrary keys including password-typed
# ones. Default-key list excludes them but a caller can request
# `keys[]=llm.openai.api_key` directly. Apply the same suffix-based
# defense-in-depth used by `redact_settings_snapshot`.
# ===========================================================================


class TestGetBulkSettingsRedaction:
    SECRET = "sk-real-bulk-leak"

    def test_password_key_is_redacted_when_explicitly_requested(self):
        app = _create_test_app()
        with _authenticated_client(app) as client:
            with patch(
                f"{MODULE}._get_setting_from_session",
                return_value=self.SECRET,
            ):
                resp = client.get(
                    "/settings/api/bulk?keys[]=llm.openai.api_key"
                )

        assert resp.status_code == 200
        body = resp.get_json()
        entry = body["settings"]["llm.openai.api_key"]
        assert entry["value"] == "[REDACTED]"
        # `exists` must still be true so callers can tell the key is set
        # — only the value is masked.
        assert entry["exists"] is True
        assert self.SECRET not in resp.get_data(as_text=True)

    def test_non_sensitive_key_passes_through(self):
        """Suffix outside DEFAULT_SENSITIVE_KEYS keeps its real value."""
        app = _create_test_app()
        with _authenticated_client(app) as client:
            with patch(
                f"{MODULE}._get_setting_from_session",
                return_value="searxng",
            ):
                resp = client.get("/settings/api/bulk?keys[]=search.tool")

        body = resp.get_json()
        assert body["settings"]["search.tool"]["value"] == "searxng"

    def test_empty_password_value_stays_empty(self):
        app = _create_test_app()
        with _authenticated_client(app) as client:
            with patch(f"{MODULE}._get_setting_from_session", return_value=""):
                resp = client.get(
                    "/settings/api/bulk?keys[]=llm.lmstudio.api_key"
                )

        body = resp.get_json()
        # Empty value is not the secret — stays empty so the front-end
        # can tell "not configured".
        assert body["settings"]["llm.lmstudio.api_key"]["value"] == ""


# ===========================================================================
# save_settings (POST /save_settings) — JS-disabled form-encoded fallback
#
# PR #3954 added the empty-password no-op guard to save_all_settings
# (JSON path) and api_update_setting. The form-encoded fallback at
# /save_settings was missed. Same guard, same rationale.
# ===========================================================================


class TestSaveSettingsEmptyPasswordNoop:
    def test_empty_password_form_submit_is_noop(self):
        """POST /save_settings with empty form value must NOT call
        settings_manager.set_setting for password-typed settings."""
        app = _create_test_app()
        existing = _make_password_db_setting(value="sk-keep-me")
        # save_settings also fetches all_db_settings into a {key: row}
        # dict, so the mock query must support `.all()` returning [existing].
        sm = MagicMock()
        sm.set_setting.return_value = True

        with _authenticated_client(app) as client:
            with (
                patch(f"{DECORATOR_MODULE}.SettingsManager", return_value=sm),
                patch(
                    f"{MODULE}.coerce_setting_for_write",
                    side_effect=lambda key, value, **kw: value,
                ),
                patch(
                    "local_deep_research.web.auth.decorators.db_manager",
                    Mock(connections={"testuser": True}, has_encryption=False),
                ),
            ):
                # Override the fixture's MagicMock session so query()...all()
                # returns our existing password row.
                with patch(
                    f"{DECORATOR_MODULE}.get_user_db_session"
                ) as get_session:
                    from contextlib import contextmanager

                    @contextmanager
                    def _fake_session(*a, **kw):
                        db = MagicMock()
                        db.query.return_value.all.return_value = [existing]
                        yield db

                    get_session.side_effect = _fake_session

                    # We don't assert on the HTTP response (the form-POST
                    # response varies — flash + redirect on success, or a
                    # 500 if downstream machinery isn't fully mocked). The
                    # invariant we're proving is that set_setting was
                    # never called for the empty password key — see the
                    # call_args_list check below.
                    client.post(
                        "/save_settings",
                        data={"llm.openai.api_key": ""},
                    )

        # Form-POST returns 302 (redirect with flash) on success in this
        # codebase, but what we actually care about is that set_setting
        # was never called for the empty password key.
        for call in sm.set_setting.call_args_list:
            args, kwargs = call
            # set_setting(key, value, commit=False) — first positional is key
            if args and args[0] == "llm.openai.api_key":
                pytest.fail(
                    "save_settings called set_setting for an empty "
                    "password value; the no-op guard did not fire."
                )


# ===========================================================================
# get_bulk_settings
# ===========================================================================


class TestGetBulkSettings:
    """Tests for GET /settings/api/bulk endpoint."""

    def test_returns_defaults_when_no_keys_specified(self):
        app = _create_test_app()
        with _authenticated_client(app) as client:
            with patch(
                f"{MODULE}._get_setting_from_session", return_value="test-val"
            ):
                resp = client.get("/settings/api/bulk")
                assert resp.status_code == 200
                data = resp.get_json()
                assert data["success"] is True
                # Default keys should include llm.provider, llm.model, etc.
                assert "llm.provider" in data["settings"]
                assert "search.tool" in data["settings"]

    def test_returns_specific_keys(self):
        app = _create_test_app()
        with _authenticated_client(app) as client:
            with patch(
                f"{MODULE}._get_setting_from_session", return_value="val"
            ):
                resp = client.get(
                    "/settings/api/bulk?keys[]=custom.key1&keys[]=custom.key2"
                )
                assert resp.status_code == 200
                data = resp.get_json()
                assert "custom.key1" in data["settings"]
                assert "custom.key2" in data["settings"]
                assert data["settings"]["custom.key1"]["value"] == "val"

    def test_returns_exists_false_for_none_value(self):
        app = _create_test_app()
        with _authenticated_client(app) as client:
            with patch(
                f"{MODULE}._get_setting_from_session", return_value=None
            ):
                resp = client.get("/settings/api/bulk?keys[]=missing.key")
                data = resp.get_json()
                assert data["settings"]["missing.key"]["exists"] is False

    def test_handles_per_key_errors(self):
        app = _create_test_app()
        with _authenticated_client(app) as client:
            with patch(
                f"{MODULE}._get_setting_from_session",
                side_effect=RuntimeError("DB error"),
            ):
                resp = client.get("/settings/api/bulk?keys[]=bad.key")
                data = resp.get_json()
                assert data["success"] is True
                assert data["settings"]["bad.key"]["exists"] is False
                assert "error" in data["settings"]["bad.key"]


# ===========================================================================
# api_get_data_location
# ===========================================================================


class TestApiGetDataLocation:
    """Tests for GET /settings/api/data-location endpoint."""

    def test_returns_data_location_info(self):
        app = _create_test_app()
        mock_sm = Mock()
        mock_sm.get_setting.return_value = None  # No custom data dir

        with _authenticated_client(app) as client:
            with (
                patch(f"{MODULE}.get_data_directory", return_value="/data/ldr"),
                patch(
                    f"{MODULE}.get_encrypted_database_path",
                    return_value="/data/ldr/encrypted",
                ),
                patch(f"{MODULE}.db_manager", Mock(has_encryption=True)),
                patch(
                    "local_deep_research.settings.manager.SettingsManager",
                    return_value=mock_sm,
                ),
                patch(f"{MODULE}.get_user_db_session"),
                patch(
                    "local_deep_research.database.sqlcipher_utils.get_sqlcipher_settings",
                    return_value={"kdf_iterations": 256000},
                ),
            ):
                resp = client.get("/settings/api/data-location")
                assert resp.status_code == 200
                data = resp.get_json()
                assert data["data_directory"] == "/data/ldr"
                assert data["is_custom"] is False
                assert data["security_notice"]["encrypted"] is True

    def test_returns_unencrypted_warning(self):
        app = _create_test_app()
        mock_sm = Mock()
        mock_sm.get_setting.return_value = "/custom/dir"

        with _authenticated_client(app) as client:
            with (
                patch(f"{MODULE}.get_data_directory", return_value="/data/ldr"),
                patch(
                    f"{MODULE}.get_encrypted_database_path",
                    return_value="/data/ldr/db",
                ),
                patch(f"{MODULE}.db_manager", Mock(has_encryption=False)),
                patch(
                    "local_deep_research.settings.manager.SettingsManager",
                    return_value=mock_sm,
                ),
                patch(f"{MODULE}.get_user_db_session"),
            ):
                resp = client.get("/settings/api/data-location")
                data = resp.get_json()
                assert data["is_custom"] is True
                assert data["security_notice"]["encrypted"] is False

    def test_exception_returns_500(self):
        app = _create_test_app()
        with _authenticated_client(app) as client:
            with patch(
                f"{MODULE}.get_data_directory", side_effect=RuntimeError("fail")
            ):
                resp = client.get("/settings/api/data-location")
                assert resp.status_code == 500


# ===========================================================================
# api_test_notification_url
# ===========================================================================


class TestApiTestNotificationUrl:
    """Tests for POST /settings/api/notifications/test-url endpoint."""

    def test_success(self):
        app = _create_test_app()
        mock_svc = Mock()
        mock_svc.test_service.return_value = {
            "success": True,
            "message": "Notification sent",
        }

        with _authenticated_client(app) as client:
            with patch(
                "local_deep_research.notifications.service.NotificationService",
                return_value=mock_svc,
            ):
                resp = client.post(
                    "/settings/api/notifications/test-url",
                    json={"service_url": "ntfy://topic"},
                    content_type="application/json",
                )
                assert resp.status_code == 200
                data = resp.get_json()
                assert data["success"] is True

    def test_missing_service_url_returns_400(self):
        app = _create_test_app()
        with _authenticated_client(app) as client:
            resp = client.post(
                "/settings/api/notifications/test-url",
                json={"wrong_key": "value"},
                content_type="application/json",
            )
            assert resp.status_code == 400
            data = resp.get_json()
            assert data["success"] is False

    def test_empty_body_returns_400(self):
        app = _create_test_app()
        with _authenticated_client(app) as client:
            resp = client.post(
                "/settings/api/notifications/test-url",
                json={},
                content_type="application/json",
            )
            assert resp.status_code == 400

    def test_service_exception_text_is_not_leaked_to_client(self):
        """Response boundary must stay generic when test_service raises.

        SendError carries the underlying exception text (service.py
        ``raise SendError(f"Failed to send notification: {str(e)}")``). It is
        not reachable by this endpoint today, but this guards the response
        boundary — the right layer for CWE-209 defence — so that if anything
        in the test-URL flow ever raises with sensitive detail, the endpoint's
        ``except`` keeps returning a generic message instead of echoing it.
        Fails if someone changes the handler to surface ``str(e)``."""
        from local_deep_research.notifications.exceptions import SendError

        secret = "SMTP-PASSWORD-do-not-leak-12345"
        app = _create_test_app()
        mock_svc = Mock()
        mock_svc.test_service.side_effect = SendError(
            f"Failed to send notification: {secret}"
        )

        with _authenticated_client(app) as client:
            with patch(
                "local_deep_research.notifications.service.NotificationService",
                return_value=mock_svc,
            ):
                resp = client.post(
                    "/settings/api/notifications/test-url",
                    json={"service_url": "ntfy://topic"},
                    content_type="application/json",
                )

        assert resp.status_code == 500
        assert secret not in resp.get_data(as_text=True)
        data = resp.get_json()
        assert data["success"] is False
        assert secret not in data["error"]


# ===========================================================================
# api_get_available_models — cache path
# ===========================================================================


class TestApiGetAvailableModels:
    """Tests for GET /settings/api/available-models endpoint."""

    def test_force_refresh_bypasses_cache(self):
        """force_refresh=true skips cache and fetches live."""
        app = _create_test_app()

        @contextmanager
        def _fake_session(*a, **kw):
            yield MagicMock()

        with _authenticated_client(app) as client:
            with (
                patch(
                    f"{MODULE}.get_user_db_session", side_effect=_fake_session
                ),
                patch(
                    "local_deep_research.llm.providers.get_discovered_provider_options",
                    return_value=[],
                ),
                patch(f"{MODULE}.safe_get", return_value=Mock(status_code=404)),
                patch(f"{MODULE}._get_setting_from_session", return_value=None),
            ):
                resp = client.get(
                    "/settings/api/available-models?force_refresh=true"
                )
                assert resp.status_code == 200
                data = resp.get_json()
                assert "providers" in data

    def test_llamacpp_not_duplicated_in_provider_options(self):
        """LlamaCppProvider is auto-discovered, so the route must NOT add a
        second hardcoded LLAMACPP entry (regression for the duplicate
        dropdown entry)."""
        app = _create_test_app()

        @contextmanager
        def _fake_session(*a, **kw):
            yield MagicMock()

        discovered = [
            {"value": "OLLAMA", "label": "Ollama 💻 Local", "is_cloud": False},
            {
                "value": "LLAMACPP",
                "label": "llama.cpp 💻 Local",
                "is_cloud": False,
            },
        ]

        with _authenticated_client(app) as client:
            with (
                patch(
                    f"{MODULE}.get_user_db_session", side_effect=_fake_session
                ),
                patch(
                    "local_deep_research.llm.providers.get_discovered_provider_options",
                    return_value=discovered,
                ),
                patch(f"{MODULE}.safe_get", return_value=Mock(status_code=404)),
                patch(f"{MODULE}._get_setting_from_session", return_value=None),
            ):
                resp = client.get(
                    "/settings/api/available-models?force_refresh=true"
                )
                assert resp.status_code == 200
                data = resp.get_json()
                values = [opt["value"] for opt in data["provider_options"]]
                assert values.count("LLAMACPP") == 1
                # No provider value should be duplicated.
                assert len(values) == len(set(values))

    @staticmethod
    def _discovered(name, models):
        """Build a discovered-provider info whose class returns ``models``."""
        provider_class = MagicMock()
        provider_class.api_key_setting = "x.api_key"
        provider_class.url_setting = None  # falsy -> no base-url fetch
        provider_class.list_models_for_api.return_value = models
        info = MagicMock()
        info.provider_name = name
        info.provider_class = provider_class
        return info

    def _run_models(self, local_only):
        app = _create_test_app()

        @contextmanager
        def _fake_session(*a, **kw):
            yield MagicMock()

        discovered = {
            "OLLAMA": self._discovered(
                "Ollama", [{"value": "llama3", "label": "llama3 (Ollama)"}]
            ),
            "OPENAI": self._discovered(
                "OpenAI", [{"value": "gpt-4", "label": "GPT-4 (OpenAI)"}]
            ),
        }

        with _authenticated_client(app) as client:
            with (
                patch(
                    f"{MODULE}.get_user_db_session", side_effect=_fake_session
                ),
                patch(
                    "local_deep_research.llm.providers.get_discovered_provider_options",
                    return_value=[],
                ),
                patch(
                    "local_deep_research.llm.providers.discover_providers",
                    return_value=discovered,
                ),
                patch(
                    f"{MODULE}._model_list_local_only", return_value=local_only
                ),
                patch(f"{MODULE}._get_setting_from_session", return_value=""),
            ):
                resp = client.get(
                    "/settings/api/available-models?force_refresh=true"
                )
                assert resp.status_code == 200
                return resp.get_json()["providers"]

    def test_non_local_lists_all_discovered_providers(self):
        """Normal posture: discovery is the single path and lists every
        provider (incl. Ollama via its provider class — no hand-rolled block)."""
        providers = self._run_models(local_only=False)
        assert providers["ollama_models"] == [
            {
                "value": "llama3",
                "label": "llama3 (Ollama)",
                "provider": "OLLAMA",
            }
        ]
        assert providers["openai_models"] == [
            {"value": "gpt-4", "label": "GPT-4 (OpenAI)", "provider": "OPENAI"}
        ]

    def test_local_only_lists_only_local_providers(self):
        """Local-only posture: discovery is filtered to LOCAL_PROVIDERS, so
        Ollama is still listed (via its provider class) but the cloud provider
        is not — replacing the removed hand-rolled local-only fallback."""
        providers = self._run_models(local_only=True)
        assert providers["ollama_models"] == [
            {
                "value": "llama3",
                "label": "llama3 (Ollama)",
                "provider": "OLLAMA",
            }
        ]
        # Cloud provider filtered out entirely (key absent, not just empty).
        assert "openai_models" not in providers
