"""
Validation and save-path coverage tests for settings_routes.py.

Targeted functions / branches:
- validate_setting: checkbox non-boolean, number below/above min/max, select invalid,
  select dynamic setting bypass
- save_settings (POST fallback): empty form, commit failure rollback, blocked setting
- api_get_all_settings: category query param filtering
- api_get_db_setting: 404 for missing key
- coerce_setting_for_write: various ui_element types
- api_update_setting: string-to-int and string-to-bool coercion via PUT
"""

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

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

# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------

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


# ---------------------------------------------------------------------------
# Helpers (mirror the pattern in test_settings_routes_deep_coverage.py)
# ---------------------------------------------------------------------------


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,
):
    """Build a mock Setting ORM object."""
    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


def _create_test_app():
    """Create a minimal Flask app with only the settings blueprint."""
    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):
    """Provide an authenticated test client with mocked auth and settings_limit."""
    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()


# ---------------------------------------------------------------------------
# 1. validate_setting: checkbox with non-boolean value returns False
# ---------------------------------------------------------------------------


class TestValidateCheckboxNonBoolean:
    """validate_setting returns False when a checkbox value cannot be converted
    to bool by get_typed_setting_value (i.e. the type converter itself returns
    a non-bool because the ui_element mapping is bypassed).

    parse_boolean always returns bool for any input, so the only way to reach
    the ``not isinstance(value, bool)`` branch is to have get_typed_setting_value
    return a non-bool — which happens when we mock it to do so.
    """

    def test_validate_checkbox_non_boolean(self):
        from local_deep_research.web.routes.settings_routes import (
            validate_setting,
        )

        setting = _make_setting(key="app.flag", ui_element="checkbox")

        # Patch get_typed_setting_value so that it returns a non-bool value,
        # exercising the ``not isinstance(value, bool)`` guard in validate_setting.
        with patch(
            f"{MODULE}.get_typed_setting_value", return_value="still-a-string"
        ):
            is_valid, message = validate_setting(setting, "not_a_bool")

        assert is_valid is False
        assert message is not None
        assert "boolean" in message.lower()


# ---------------------------------------------------------------------------
# 2. validate_setting: number below min_value
# ---------------------------------------------------------------------------


class TestValidateNumberBelowMin:
    """validate_setting returns False when a numeric value is below min_value."""

    def test_validate_number_below_min(self):
        from local_deep_research.web.routes.settings_routes import (
            validate_setting,
        )

        setting = _make_setting(
            key="search.iterations",
            ui_element="number",
            min_value=1,
            max_value=100,
        )

        is_valid, message = validate_setting(setting, 0)

        assert is_valid is False
        assert message is not None
        assert "1" in message  # min_value is 1


# ---------------------------------------------------------------------------
# 3. validate_setting: number above max_value
# ---------------------------------------------------------------------------


class TestValidateNumberAboveMax:
    """validate_setting returns False when a numeric value exceeds max_value."""

    def test_validate_number_above_max(self):
        from local_deep_research.web.routes.settings_routes import (
            validate_setting,
        )

        setting = _make_setting(
            key="search.iterations",
            ui_element="number",
            min_value=1,
            max_value=10,
        )

        is_valid, message = validate_setting(setting, 99)

        assert is_valid is False
        assert message is not None
        assert "10" in message  # max_value is 10


# ---------------------------------------------------------------------------
# 4. validate_setting: select with invalid option value
# ---------------------------------------------------------------------------


class TestValidateSelectInvalidOption:
    """validate_setting returns False for a select value not in allowed options."""

    def test_validate_select_invalid_option(self):
        from local_deep_research.web.routes.settings_routes import (
            validate_setting,
        )

        setting = _make_setting(
            key="app.theme",
            ui_element="select",
            options=[
                {"value": "dark"},
                {"value": "light"},
            ],
        )

        is_valid, message = validate_setting(setting, "neon")

        assert is_valid is False
        assert message is not None
        assert "dark" in message or "light" in message


# ---------------------------------------------------------------------------
# 5. validate_setting: select on DYNAMIC_SETTINGS key skips option validation
# ---------------------------------------------------------------------------


class TestValidateSelectDynamicSettingSkips:
    """DYNAMIC_SETTINGS keys bypass option-list validation for select elements."""

    def test_validate_select_dynamic_setting_skips(self):
        from local_deep_research.web.routes.settings_routes import (
            DYNAMIC_SETTINGS,
            validate_setting,
        )

        # Use a real DYNAMIC_SETTINGS key
        assert "llm.provider" in DYNAMIC_SETTINGS

        setting = _make_setting(
            key="llm.provider",
            ui_element="select",
            # options present but should be ignored for this key
            options=[{"value": "openai"}],
        )

        # "any_arbitrary_provider" is not in options, but because the key is
        # in DYNAMIC_SETTINGS the validation must pass.
        is_valid, message = validate_setting(setting, "any_arbitrary_provider")

        assert is_valid is True
        assert message is None


# ---------------------------------------------------------------------------
# 6. save_settings POST: empty form data → redirect with flash success (0 updates)
# ---------------------------------------------------------------------------


class TestSaveSettingsPostNoFormData:
    """save_settings POST with no form fields still commits and redirects."""

    def test_save_settings_post_no_form_data(self):
        app = _create_test_app()

        mock_settings_manager = MagicMock()
        mock_settings_manager.set_setting.return_value = True
        mock_db_session = MagicMock()
        mock_db_session.query.return_value.all.return_value = []

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

        with (
            patch(
                "local_deep_research.web.auth.decorators.db_manager"
            ) as mock_db,
            patch(
                f"{DECORATOR_MODULE}.get_user_db_session",
                side_effect=_fake_session,
            ),
            patch(f"{MODULE}.settings_limit", lambda f: f),
            patch(
                f"{DECORATOR_MODULE}.SettingsManager",
                return_value=mock_settings_manager,
            ),
        ):
            mock_db.connections = {"testuser": True}
            mock_db.has_encryption = False

            with app.test_client() as client:
                with client.session_transaction() as sess:
                    sess["username"] = "testuser"
                    sess["session_id"] = "test-session-id"

                # POST with empty form (only csrf_token which gets stripped)
                response = client.post(
                    f"{SETTINGS_PREFIX}/save_settings",
                    data={"csrf_token": "dummy"},
                    follow_redirects=False,
                )

        # Should redirect back to settings page
        assert response.status_code in (302, 200)


# ---------------------------------------------------------------------------
# 7. save_settings POST: db commit raises → rollback called
# ---------------------------------------------------------------------------


class TestSaveSettingsPostCommitFailureRollback:
    """save_settings POST calls rollback when db_session.commit() raises."""

    def test_save_settings_post_commit_failure_rollback(self):
        app = _create_test_app()

        mock_settings_manager = MagicMock()
        mock_settings_manager.set_setting.return_value = True
        mock_db_session = MagicMock()
        mock_db_session.query.return_value.all.return_value = []
        mock_db_session.commit.side_effect = RuntimeError("disk full")

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

        with (
            patch(
                "local_deep_research.web.auth.decorators.db_manager"
            ) as mock_db,
            patch(
                f"{DECORATOR_MODULE}.get_user_db_session",
                side_effect=_fake_session,
            ),
            patch(f"{MODULE}.settings_limit", lambda f: f),
            patch(
                f"{DECORATOR_MODULE}.SettingsManager",
                return_value=mock_settings_manager,
            ),
        ):
            mock_db.connections = {"testuser": True}
            mock_db.has_encryption = False

            with app.test_client() as client:
                with client.session_transaction() as sess:
                    sess["username"] = "testuser"
                    sess["session_id"] = "test-session-id"

                response = client.post(
                    f"{SETTINGS_PREFIX}/save_settings",
                    data={"app.theme": "dark"},
                    follow_redirects=False,
                )

        # rollback must have been called
        mock_db_session.rollback.assert_called_once()
        # Still redirects (error flash)
        assert response.status_code in (302, 200)


# ---------------------------------------------------------------------------
# 9. api_get_all_settings: category query param filters results
# ---------------------------------------------------------------------------


class TestApiGetSettingsFilteredByCategory:
    """GET /settings/api?category=<cat> returns only settings in that category."""

    def test_api_get_settings_filtered_by_category(self):
        app = _create_test_app()

        llm_setting = _make_setting(
            key="llm.model",
            value="gpt-4",
            category="llm_general",
        )
        search_setting = _make_setting(
            key="search.tool",
            value="searxng",
            category="search_general",
        )

        mock_settings_manager = MagicMock()
        # get_all_settings returns a flat dict of key→value
        mock_settings_manager.get_all_settings.return_value = {
            "llm.model": "gpt-4",
            "search.tool": "searxng",
        }

        mock_db_session = MagicMock()
        # query(Setting).all() used to build category_keys
        mock_db_session.query.return_value.all.return_value = [
            llm_setting,
            search_setting,
        ]

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

        with (
            patch(
                "local_deep_research.web.auth.decorators.db_manager"
            ) as mock_db,
            patch(
                f"{DECORATOR_MODULE}.get_user_db_session",
                side_effect=_fake_session,
            ),
            patch(f"{MODULE}.settings_limit", lambda f: f),
            patch(
                f"{DECORATOR_MODULE}.SettingsManager",
                return_value=mock_settings_manager,
            ),
        ):
            mock_db.connections = {"testuser": True}
            mock_db.has_encryption = False

            with app.test_client() as client:
                with client.session_transaction() as sess:
                    sess["username"] = "testuser"
                    sess["session_id"] = "test-session-id"

                response = client.get(
                    f"{SETTINGS_PREFIX}/api?category=llm_general"
                )

        # The endpoint should succeed
        assert response.status_code == 200
        data = response.get_json()
        assert data["status"] == "success"
        # Only llm_general keys should be present
        settings = data["settings"]
        assert "llm.model" in settings
        assert "search.tool" not in settings


# ---------------------------------------------------------------------------
# 10. api_get_db_setting: returns 404 for unknown key
# ---------------------------------------------------------------------------


class TestApiGetDbSettingNotFound:
    """GET /settings/api/<key> returns 404 when the key is absent from the DB."""

    def test_api_get_db_setting_not_found(self):
        app = _create_test_app()

        mock_db_session = MagicMock()
        # Simulate no setting found: query(...).filter(...).first() → None
        mock_db_session.query.return_value.filter.return_value.first.return_value = None

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

        with (
            patch(
                "local_deep_research.web.auth.decorators.db_manager"
            ) as mock_db,
            patch(
                f"{DECORATOR_MODULE}.get_user_db_session",
                side_effect=_fake_session,
            ),
            patch(f"{MODULE}.settings_limit", lambda f: f),
        ):
            mock_db.connections = {"testuser": True}
            mock_db.has_encryption = False

            with app.test_client() as client:
                with client.session_transaction() as sess:
                    sess["username"] = "testuser"
                    sess["session_id"] = "test-session-id"

                response = client.get(
                    f"{SETTINGS_PREFIX}/api/nonexistent.setting.key"
                )

        assert response.status_code == 404
        data = response.get_json()
        assert "error" in data
        assert "nonexistent.setting.key" in data["error"]


# ---------------------------------------------------------------------------
# 11. coerce_setting_for_write: various ui_element type coercions
# ---------------------------------------------------------------------------


class TestCoerceSettingForWriteVariousTypes:
    """coerce_setting_for_write delegates to get_typed_setting_value correctly."""

    def test_text_returns_string(self):
        from local_deep_research.web.routes.settings_routes import (
            coerce_setting_for_write,
        )

        result = coerce_setting_for_write("app.name", 42, "text")
        assert result == "42"

    def test_number_converts_string_to_int_or_float(self):
        from local_deep_research.web.routes.settings_routes import (
            coerce_setting_for_write,
        )

        result = coerce_setting_for_write("search.iterations", "5", "number")
        assert result == 5
        assert isinstance(result, (int, float))

    def test_checkbox_converts_string_true_to_bool(self):
        from local_deep_research.web.routes.settings_routes import (
            coerce_setting_for_write,
        )

        result = coerce_setting_for_write("app.flag", "true", "checkbox")
        assert result is True

    def test_checkbox_converts_string_false_to_bool(self):
        from local_deep_research.web.routes.settings_routes import (
            coerce_setting_for_write,
        )

        result = coerce_setting_for_write("app.flag", "false", "checkbox")
        assert result is False

    def test_select_returns_string(self):
        from local_deep_research.web.routes.settings_routes import (
            coerce_setting_for_write,
        )

        result = coerce_setting_for_write("app.theme", "dark", "select")
        assert result == "dark"

    def test_unknown_ui_element_returns_none(self):
        from local_deep_research.web.routes.settings_routes import (
            coerce_setting_for_write,
        )

        # get_typed_setting_value returns default (None) for unknown ui_element
        result = coerce_setting_for_write("foo.bar", "value", "unknown_widget")
        assert result is None


# ---------------------------------------------------------------------------
# 12. api_update_setting: string-to-int and string-to-bool coercion via PUT
# ---------------------------------------------------------------------------


class TestApiUpdateSettingTypeCoercion:
    """PUT /settings/api/<key> coerces incoming strings to the correct type."""

    def _put_setting(self, key, payload_value, ui_element):
        """Helper that performs a PUT and returns (response, mock_set_setting)."""
        app = _create_test_app()

        db_setting = _make_setting(
            key=key,
            value="old",
            ui_element=ui_element,
            editable=True,
        )
        mock_db_session = MagicMock()
        mock_db_session.query.return_value.filter.return_value.first.return_value = db_setting

        captured = {}

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

        def _fake_set_setting(k, v, db_session=None):
            captured["key"] = k
            captured["value"] = v
            return True

        with (
            patch(
                "local_deep_research.web.auth.decorators.db_manager"
            ) as mock_db,
            patch(
                f"{DECORATOR_MODULE}.get_user_db_session",
                side_effect=_fake_session,
            ),
            patch(f"{MODULE}.settings_limit", lambda f: f),
            patch(f"{MODULE}.set_setting", side_effect=_fake_set_setting),
            patch(f"{MODULE}.validate_setting", return_value=(True, None)),
        ):
            mock_db.connections = {"testuser": True}
            mock_db.has_encryption = False

            with app.test_client() as client:
                with client.session_transaction() as sess:
                    sess["username"] = "testuser"
                    sess["session_id"] = "test-session-id"

                response = client.put(
                    f"{SETTINGS_PREFIX}/api/{key}",
                    json={"value": payload_value},
                    content_type="application/json",
                )

        return response, captured

    def test_string_to_int_coercion(self):
        """PUT with string "7" on a number setting stores integer 7."""
        response, captured = self._put_setting(
            "search.iterations", "7", "number"
        )

        assert response.status_code == 200
        # set_setting must have been called with the coerced int, not the raw string
        assert "value" in captured
        stored_value = captured["value"]
        assert stored_value == 7
        assert isinstance(stored_value, (int, float))

    def test_string_to_bool_coercion(self):
        """PUT with string "true" on a checkbox setting stores boolean True."""
        response, captured = self._put_setting("app.flag", "true", "checkbox")

        assert response.status_code == 200
        assert "value" in captured
        stored_value = captured["value"]
        assert stored_value is True
