"""
Comprehensive tests for SettingsManager.

Tests cover:
- Thread safety mechanisms
- Settings locking behavior
- get_setting functionality with various scenarios
- set_setting operations
- Import/export functionality
- Version management
- Static helper methods
"""

import os
import threading
import pytest
from unittest.mock import MagicMock, patch, PropertyMock

from local_deep_research.settings.manager import (
    SettingsManager,
    get_typed_setting_value,
    check_env_setting,
    parse_boolean,
    _parse_number,
    _parse_multiselect,
    _filter_setting_columns,
)


class TestSettingsManagerThreadSafety:
    """Tests for thread safety mechanisms in SettingsManager."""

    @pytest.fixture(autouse=True)
    def clean_env(self):
        """Clean environment before each test."""
        original_env = {
            k: v for k, v in os.environ.items() if k.startswith("LDR_")
        }
        for key in list(os.environ.keys()):
            if key.startswith("LDR_"):
                os.environ.pop(key, None)
        yield
        for key in list(os.environ.keys()):
            if key.startswith("LDR_"):
                os.environ.pop(key, None)
        for key, value in original_env.items():
            os.environ[key] = value

    def test_check_thread_safety_same_thread_passes(self):
        """Test that thread safety check passes when used in creation thread."""
        mock_session = MagicMock()
        mock_session.query.return_value.count.return_value = 1

        manager = SettingsManager(db_session=mock_session)

        # Should not raise when used in same thread
        manager._check_thread_safety()

    def test_check_thread_safety_different_thread_raises(self):
        """Test that thread safety check raises RuntimeError when used across threads."""
        mock_session = MagicMock()
        mock_session.query.return_value.count.return_value = 1

        manager = SettingsManager(db_session=mock_session)

        exception_raised = None

        def use_in_different_thread():
            nonlocal exception_raised
            try:
                manager._check_thread_safety()
            except RuntimeError as e:
                exception_raised = e

        thread = threading.Thread(target=use_in_different_thread)
        thread.start()
        thread.join()

        assert exception_raised is not None
        assert "thread-safe" in str(exception_raised).lower()

    def test_check_thread_safety_no_session_skips_check(self):
        """Test that thread safety check is skipped without DB session."""
        manager = SettingsManager(db_session=None)

        # Should not raise even if called from different thread
        # because there's no db_session
        def use_in_different_thread():
            manager._check_thread_safety()  # Should not raise

        thread = threading.Thread(target=use_in_different_thread)
        thread.start()
        thread.join()

    def test_settings_manager_thread_id_tracking(self):
        """Test that SettingsManager tracks creation thread ID."""
        mock_session = MagicMock()
        mock_session.query.return_value.count.return_value = 1

        manager = SettingsManager(db_session=mock_session)

        assert hasattr(manager, "_creation_thread_id")
        assert manager._creation_thread_id == threading.get_ident()

    def test_concurrent_access_from_multiple_threads(self):
        """Test that concurrent access from multiple threads raises errors."""
        mock_session = MagicMock()
        mock_session.query.return_value.count.return_value = 1

        manager = SettingsManager(db_session=mock_session)

        errors = []

        def access_from_thread():
            try:
                manager._check_thread_safety()
            except RuntimeError as e:
                errors.append(e)

        threads = [
            threading.Thread(target=access_from_thread) for _ in range(3)
        ]
        for t in threads:
            t.start()
        for t in threads:
            t.join()

        # All 3 threads should have raised errors
        assert len(errors) == 3


class TestSettingsManagerLocking:
    """Tests for settings locking behavior."""

    @pytest.fixture(autouse=True)
    def clean_env(self):
        """Clean environment before each test."""
        original_env = {
            k: v for k, v in os.environ.items() if k.startswith("LDR_")
        }
        for key in list(os.environ.keys()):
            if key.startswith("LDR_"):
                os.environ.pop(key, None)
        yield
        for key in list(os.environ.keys()):
            if key.startswith("LDR_"):
                os.environ.pop(key, None)
        for key, value in original_env.items():
            os.environ[key] = value

    def test_settings_locked_property_returns_false_when_unlocked(self):
        """Test settings_locked returns False by default."""
        manager = SettingsManager(db_session=None)

        # Manually set the private attribute to test
        manager._SettingsManager__settings_locked = False

        assert manager.settings_locked is False

    def test_settings_locked_property_returns_true_when_locked(self):
        """Test settings_locked returns True when app.lock_settings is True."""
        manager = SettingsManager(db_session=None)

        # Manually set the private attribute
        manager._SettingsManager__settings_locked = True

        assert manager.settings_locked is True

    def test_settings_locked_cached_after_first_check(self):
        """Test that settings_locked value is cached after first evaluation."""
        manager = SettingsManager(db_session=None)

        # Initially None
        assert manager._SettingsManager__settings_locked is None

        # After accessing, should be set
        with patch.object(manager, "get_setting", return_value=False):
            _ = manager.settings_locked

        # Now should be cached
        assert manager._SettingsManager__settings_locked is False

    def test_set_setting_blocked_when_locked(self):
        """Test that set_setting returns False when settings are locked."""
        mock_session = MagicMock()
        mock_session.query.return_value.count.return_value = 1

        manager = SettingsManager(db_session=mock_session)
        manager._SettingsManager__settings_locked = True

        result = manager.set_setting("test.key", "value")

        assert result is False

    def test_create_or_update_setting_blocked_when_locked(self):
        """Test that create_or_update_setting returns None when settings are locked."""
        mock_session = MagicMock()
        mock_session.query.return_value.count.return_value = 1

        manager = SettingsManager(db_session=mock_session)
        manager._SettingsManager__settings_locked = True

        result = manager.create_or_update_setting(
            {"key": "test", "value": "val"}
        )

        assert result is None

    def test_settings_locked_exception_handling(self):
        """Test that settings_locked returns False on error."""
        manager = SettingsManager(db_session=None)

        # Force an exception during get_setting
        with patch.object(
            manager, "get_setting", side_effect=Exception("Test error")
        ):
            # Reset to force re-evaluation
            manager._SettingsManager__settings_locked = None

            result = manager.settings_locked

        assert result is False


class TestSettingsManagerGetSetting:
    """Tests for get_setting functionality."""

    @pytest.fixture(autouse=True)
    def clean_env(self):
        """Clean environment before each test."""
        original_env = {
            k: v for k, v in os.environ.items() if k.startswith("LDR_")
        }
        for key in list(os.environ.keys()):
            if key.startswith("LDR_"):
                os.environ.pop(key, None)
        yield
        for key in list(os.environ.keys()):
            if key.startswith("LDR_"):
                os.environ.pop(key, None)
        for key, value in original_env.items():
            os.environ[key] = value

    def test_get_setting_returns_default_when_not_found(self):
        """Test that get_setting returns default when key not found."""
        manager = SettingsManager(db_session=None)

        result = manager.get_setting("nonexistent.key", default="fallback")

        assert result == "fallback"

    def test_get_setting_env_override_takes_priority(self):
        """Test that environment variable overrides DB value."""
        os.environ["LDR_APP_DEBUG"] = "true"

        mock_session = MagicMock()
        mock_setting = MagicMock()
        mock_setting.key = "app.debug"
        mock_setting.value = False
        mock_setting.ui_element = "checkbox"
        mock_session.query.return_value.count.return_value = 1
        mock_session.query.return_value.filter.return_value.all.return_value = [
            mock_setting
        ]

        manager = SettingsManager(db_session=mock_session)

        result = manager.get_setting("app.debug", check_env=True)

        # Environment variable should override
        assert result is True

    def test_get_setting_env_only_setting_from_env(self):
        """Test that env-only settings are read from environment."""
        os.environ["LDR_TESTING_TEST_MODE"] = "true"

        manager = SettingsManager(db_session=None)

        result = manager.get_setting("testing.test_mode")

        assert result is True

    def test_get_setting_nested_key_pattern(self):
        """Test that nested key pattern returns dict of settings."""
        mock_session = MagicMock()
        mock_session.query.return_value.count.return_value = 1

        # Mock multiple settings matching pattern
        mock_settings = [
            MagicMock(key="llm.provider", value="openai", ui_element="select"),
            MagicMock(key="llm.temperature", value=0.7, ui_element="number"),
        ]
        mock_session.query.return_value.filter.return_value.all.return_value = (
            mock_settings
        )

        manager = SettingsManager(db_session=mock_session)

        result = manager.get_setting("llm")

        assert isinstance(result, dict)
        assert "provider" in result
        assert "temperature" in result

    def test_get_setting_exact_key_match(self):
        """Test that exact key match returns single value."""
        mock_session = MagicMock()
        mock_session.query.return_value.count.return_value = 1

        mock_setting = MagicMock()
        mock_setting.key = "app.debug"
        mock_setting.value = True
        mock_setting.ui_element = "checkbox"
        mock_session.query.return_value.filter.return_value.all.return_value = [
            mock_setting
        ]

        manager = SettingsManager(db_session=mock_session)

        result = manager.get_setting("app.debug")

        assert result is True

    def test_get_setting_with_empty_string_default(self):
        """Test get_setting with empty string as default."""
        manager = SettingsManager(db_session=None)

        result = manager.get_setting("nonexistent.key", default="")

        assert result == ""

    def test_get_setting_with_none_default(self):
        """Test get_setting with None as default."""
        manager = SettingsManager(db_session=None)

        result = manager.get_setting("nonexistent.key", default=None)

        assert result is None

    def test_get_setting_check_env_false_ignores_env(self):
        """get_setting with check_env=False ignores env var and returns DB value."""
        os.environ["LDR_APP_DEBUG"] = "true"

        mock_session = MagicMock()
        mock_setting = MagicMock()
        mock_setting.key = "app.debug"
        mock_setting.value = False
        mock_setting.ui_element = "checkbox"
        mock_session.query.return_value.count.return_value = 1
        mock_session.query.return_value.filter.return_value.all.return_value = [
            mock_setting
        ]

        manager = SettingsManager(db_session=mock_session)

        result = manager.get_setting("app.debug", check_env=False)

        # Should return DB value (False), NOT the env var "true"
        assert result is False

    def test_get_setting_sqlalchemy_error_handling(self):
        """Test that SQLAlchemy errors are handled and return default."""
        from sqlalchemy.exc import SQLAlchemyError

        mock_session = MagicMock()
        mock_session.query.return_value.count.return_value = 1
        mock_session.query.return_value.filter.return_value.all.side_effect = (
            SQLAlchemyError("DB error")
        )

        manager = SettingsManager(db_session=mock_session)

        result = manager.get_setting("app.debug", default="fallback")

        assert result == "fallback"

    def test_get_setting_auto_initializes_empty_db(self):
        """Test that _ensure_settings_initialized is called for empty DB."""
        mock_session = MagicMock()
        mock_session.query.return_value.count.return_value = 0

        with patch.object(
            SettingsManager, "load_from_defaults_file"
        ) as mock_load:
            SettingsManager(db_session=mock_session)

            mock_load.assert_called_once()


class TestSettingsManagerSetSetting:
    """Tests for set_setting functionality."""

    @pytest.fixture(autouse=True)
    def clean_env(self):
        """Clean environment before each test."""
        original_env = {
            k: v for k, v in os.environ.items() if k.startswith("LDR_")
        }
        for key in list(os.environ.keys()):
            if key.startswith("LDR_"):
                os.environ.pop(key, None)
        yield
        for key in list(os.environ.keys()):
            if key.startswith("LDR_"):
                os.environ.pop(key, None)
        for key, value in original_env.items():
            os.environ[key] = value

    def test_set_setting_creates_new_setting(self):
        """Test that set_setting creates new setting when not exists."""
        mock_session = MagicMock()
        mock_session.query.return_value.count.return_value = 1
        mock_session.query.return_value.filter.return_value.first.return_value = None

        manager = SettingsManager(db_session=mock_session)
        manager._SettingsManager__settings_locked = False

        with patch.object(manager, "_emit_settings_changed"):
            result = manager.set_setting("new.key", "new_value")

        assert result is True
        mock_session.add.assert_called_once()

    def test_set_setting_updates_existing_setting(self):
        """Test that set_setting updates existing setting."""
        mock_session = MagicMock()
        mock_session.query.return_value.count.return_value = 1

        mock_setting = MagicMock()
        mock_setting.editable = True
        mock_session.query.return_value.filter.return_value.first.return_value = mock_setting

        manager = SettingsManager(db_session=mock_session)
        manager._SettingsManager__settings_locked = False

        with patch.object(manager, "_emit_settings_changed"):
            result = manager.set_setting("existing.key", "updated_value")

        assert result is True
        assert mock_setting.value == "updated_value"

    def test_set_setting_preserves_type(self):
        """Test that set_setting preserves the type of the value."""
        mock_session = MagicMock()
        mock_session.query.return_value.count.return_value = 1

        mock_setting = MagicMock()
        mock_setting.editable = True
        mock_session.query.return_value.filter.return_value.first.return_value = mock_setting

        manager = SettingsManager(db_session=mock_session)
        manager._SettingsManager__settings_locked = False

        with patch.object(manager, "_emit_settings_changed"):
            manager.set_setting("test.int", 42)

        assert mock_setting.value == 42

    def test_set_setting_self_heals_chat_prefix_type_and_category(self):
        """A legacy chat.* row written before the chat dispatch landed could
        have type=APP and a stale (or NULL) category. set_setting() now
        re-points BOTH type and category to the inferred values for the
        prefix; verify this happens on update.
        """
        from local_deep_research.database.models import SettingType

        mock_session = MagicMock()
        mock_session.query.return_value.count.return_value = 1

        mock_setting = MagicMock()
        mock_setting.editable = True
        mock_setting.type = SettingType.APP
        mock_setting.category = None
        mock_session.query.return_value.filter.return_value.first.return_value = mock_setting

        manager = SettingsManager(db_session=mock_session)
        manager._SettingsManager__settings_locked = False

        with patch.object(manager, "_emit_settings_changed"):
            result = manager.set_setting("chat.title_llm_timeout_seconds", 30)

        assert result is True
        assert mock_setting.type == SettingType.CHAT
        assert mock_setting.category == "chat"

    def test_set_setting_emits_websocket_event(self):
        """Test that set_setting emits WebSocket event on commit."""
        mock_session = MagicMock()
        mock_session.query.return_value.count.return_value = 1
        mock_session.query.return_value.filter.return_value.first.return_value = None

        manager = SettingsManager(db_session=mock_session)
        manager._SettingsManager__settings_locked = False

        with patch.object(manager, "_emit_settings_changed") as mock_emit:
            manager.set_setting("test.key", "value", commit=True)

            mock_emit.assert_called_once_with(["test.key"])

    def test_set_setting_rollback_on_error(self):
        """Test that set_setting rolls back on error."""
        from sqlalchemy.exc import SQLAlchemyError

        mock_session = MagicMock()
        mock_session.query.return_value.count.return_value = 1
        mock_session.query.return_value.filter.return_value.first.side_effect = SQLAlchemyError()

        manager = SettingsManager(db_session=mock_session)
        manager._SettingsManager__settings_locked = False

        result = manager.set_setting("test.key", "value")

        assert result is False
        mock_session.rollback.assert_called_once()

    def test_set_setting_no_db_session_returns_false(self):
        """Test that set_setting returns False without DB session."""
        manager = SettingsManager(db_session=None)

        result = manager.set_setting("test.key", "value")

        assert result is False

    def test_set_setting_non_editable_returns_false(self):
        """Test that set_setting returns False for non-editable settings."""
        mock_session = MagicMock()
        mock_session.query.return_value.count.return_value = 1

        mock_setting = MagicMock()
        mock_setting.editable = False
        mock_session.query.return_value.filter.return_value.first.return_value = mock_setting

        manager = SettingsManager(db_session=mock_session)
        manager._SettingsManager__settings_locked = False

        result = manager.set_setting("readonly.key", "value")

        assert result is False


class TestSettingsManagerImportExport:
    """Tests for import/export functionality."""

    @pytest.fixture(autouse=True)
    def clean_env(self):
        """Clean environment before each test."""
        original_env = {
            k: v for k, v in os.environ.items() if k.startswith("LDR_")
        }
        for key in list(os.environ.keys()):
            if key.startswith("LDR_"):
                os.environ.pop(key, None)
        yield
        for key in list(os.environ.keys()):
            if key.startswith("LDR_"):
                os.environ.pop(key, None)
        for key, value in original_env.items():
            os.environ[key] = value

    def test_import_settings_with_overwrite_true(self):
        """Test that import_settings overwrites existing values when overwrite=True."""
        mock_session = MagicMock()
        mock_session.query.return_value.count.return_value = 1

        manager = SettingsManager(db_session=mock_session)

        with patch.object(manager, "get_setting", return_value="old_value"):
            with patch.object(manager, "delete_setting"):
                with patch.object(manager, "_emit_settings_changed"):
                    manager.import_settings(
                        {"test.key": {"value": "new_value", "type": "APP"}},
                        overwrite=True,
                    )

        # Should have added the new value (delete + add)
        mock_session.add.assert_called()

    def test_import_settings_with_overwrite_false(self):
        """Test that import_settings preserves existing values when overwrite=False."""
        mock_session = MagicMock()
        mock_session.query.return_value.count.return_value = 1

        manager = SettingsManager(db_session=mock_session)

        with patch.object(
            manager, "get_setting", return_value="existing_value"
        ):
            with patch.object(manager, "delete_setting"):
                with patch.object(manager, "_emit_settings_changed"):
                    manager.import_settings(
                        {"test.key": {"value": "new_value", "type": "APP"}},
                        overwrite=False,
                    )

        # The value should be preserved (existing_value)
        mock_session.add.assert_called()

    def test_import_settings_with_delete_extra_true(self):
        """Test that import_settings deletes extra settings when delete_extra=True."""
        mock_session = MagicMock()
        mock_session.query.return_value.count.return_value = 1

        manager = SettingsManager(db_session=mock_session)

        # Mock get_all_settings to return extra key
        extra_settings = {
            "test.key": {"value": "v1"},
            "extra.key": {"value": "v2"},
        }

        with patch.object(manager, "get_setting", return_value=None):
            with patch.object(manager, "delete_setting") as mock_delete:
                with patch.object(
                    manager, "get_all_settings", return_value=extra_settings
                ):
                    with patch.object(manager, "_emit_settings_changed"):
                        manager.import_settings(
                            {"test.key": {"value": "v1", "type": "APP"}},
                            delete_extra=True,
                        )

        # Should delete the extra.key
        delete_calls = [
            call
            for call in mock_delete.call_args_list
            if call[0][0] == "extra.key"
        ]
        assert len(delete_calls) > 0

    def test_import_settings_type_detection_from_key(self):
        """Test that import_settings detects type from key prefix."""
        mock_session = MagicMock()
        mock_session.query.return_value.count.return_value = 1

        manager = SettingsManager(db_session=mock_session)

        with patch.object(manager, "get_setting", return_value=None):
            with patch.object(manager, "delete_setting"):
                with patch.object(manager, "_emit_settings_changed"):
                    manager.import_settings(
                        {
                            "llm.test": {"value": "v1", "type": "LLM"},
                            "search.test": {"value": "v2", "type": "SEARCH"},
                            "report.test": {"value": "v3", "type": "REPORT"},
                        }
                    )

        # All should be added
        assert mock_session.add.call_count == 3

    def test_get_all_settings_merges_defaults(self):
        """Test that get_all_settings merges defaults with DB values."""
        mock_session = MagicMock()
        mock_session.query.return_value.count.return_value = 1
        mock_session.query.return_value.all.return_value = []

        manager = SettingsManager(db_session=mock_session)

        # Mock default_settings
        with patch.object(
            SettingsManager,
            "default_settings",
            new_callable=PropertyMock,
            return_value={"default.key": {"value": "default"}},
        ):
            result = manager.get_all_settings()

        assert "default.key" in result

    def test_get_all_settings_marks_env_non_editable(self):
        """Test that settings overridden by env vars are marked non-editable."""
        os.environ["LDR_APP_DEBUG"] = "true"

        mock_session = MagicMock()
        mock_session.query.return_value.count.return_value = 1

        mock_setting = MagicMock()
        mock_setting.key = "app.debug"
        mock_setting.value = False
        mock_setting.type = MagicMock(name="APP")
        mock_setting.name = "Debug"
        mock_setting.description = "Debug mode"
        mock_setting.category = "app"
        mock_setting.ui_element = "checkbox"
        mock_setting.options = None
        mock_setting.min_value = None
        mock_setting.max_value = None
        mock_setting.step = None
        mock_setting.visible = True
        mock_setting.editable = True
        mock_session.query.return_value.all.return_value = [mock_setting]

        manager = SettingsManager(db_session=mock_session)
        manager._SettingsManager__settings_locked = False

        with patch.object(
            SettingsManager,
            "default_settings",
            new_callable=PropertyMock,
            return_value={},
        ):
            result = manager.get_all_settings()

        assert result["app.debug"]["editable"] is False

    def test_get_all_settings_locked_marks_all_non_editable(self):
        """get_all_settings with settings_locked=True marks all settings as non-editable."""
        mock_session = MagicMock()
        mock_session.query.return_value.count.return_value = 1

        mock_setting = MagicMock()
        mock_setting.key = "app.debug"
        mock_setting.value = True
        mock_setting.type = MagicMock(name="APP")
        mock_setting.name = "Debug"
        mock_setting.description = "Debug mode"
        mock_setting.category = "app"
        mock_setting.ui_element = "checkbox"
        mock_setting.options = None
        mock_setting.min_value = None
        mock_setting.max_value = None
        mock_setting.step = None
        mock_setting.visible = True
        mock_setting.editable = True  # Originally editable
        mock_session.query.return_value.all.return_value = [mock_setting]

        manager = SettingsManager(db_session=mock_session)
        manager._SettingsManager__settings_locked = True  # Lock settings

        with patch.object(
            SettingsManager,
            "default_settings",
            new_callable=PropertyMock,
            return_value={},
        ):
            result = manager.get_all_settings()

        # Even though the DB setting is editable=True, the lock overrides it
        assert result["app.debug"]["editable"] is False

    def test_get_settings_snapshot_flat_dict(self):
        """Test that get_settings_snapshot returns flat key-value dict."""
        mock_session = MagicMock()
        mock_session.query.return_value.count.return_value = 1

        manager = SettingsManager(db_session=mock_session)

        with patch.object(
            manager,
            "get_all_settings",
            return_value={
                "key1": {"value": "v1"},
                "key2": {"value": 42},
            },
        ):
            result = manager.get_settings_snapshot()

        assert result == {"key1": "v1", "key2": 42}

    def test_load_from_defaults_file(self):
        """Test that load_from_defaults_file calls import_settings."""
        mock_session = MagicMock()
        mock_session.query.return_value.count.return_value = 1

        manager = SettingsManager(db_session=mock_session)

        with patch.object(manager, "import_settings") as mock_import:
            with patch.object(
                SettingsManager,
                "default_settings",
                new_callable=PropertyMock,
                return_value={"test": {"value": "v"}},
            ):
                manager.load_from_defaults_file()

        mock_import.assert_called_once()


class TestSettingsManagerVersioning:
    """Tests for version management."""

    @pytest.fixture(autouse=True)
    def clean_env(self):
        """Clean environment before each test."""
        original_env = {
            k: v for k, v in os.environ.items() if k.startswith("LDR_")
        }
        yield
        for key, value in original_env.items():
            os.environ[key] = value

    def test_db_version_matches_package_true(self):
        """Test db_version_matches_package returns True when versions match."""
        mock_session = MagicMock()
        mock_session.query.return_value.count.return_value = 1

        manager = SettingsManager(db_session=mock_session)

        from local_deep_research.__version__ import __version__ as pkg_version

        with patch.object(manager, "get_setting", return_value=pkg_version):
            result = manager.db_version_matches_package()

        assert result is True

    def test_db_version_matches_package_false(self):
        """Test db_version_matches_package returns False when versions differ."""
        mock_session = MagicMock()
        mock_session.query.return_value.count.return_value = 1

        manager = SettingsManager(db_session=mock_session)

        with patch.object(manager, "get_setting", return_value="0.0.0"):
            result = manager.db_version_matches_package()

        assert result is False

    def test_update_db_version(self):
        """Test that update_db_version saves package version."""
        mock_session = MagicMock()
        mock_session.query.return_value.count.return_value = 1

        manager = SettingsManager(db_session=mock_session)

        with patch.object(manager, "delete_setting"):
            manager.update_db_version()

        mock_session.add.assert_called_once()
        mock_session.commit.assert_called_once()

    def test_update_db_version_commit_false(self):
        """update_db_version(commit=False) must stage the version row but
        NOT call session.commit() — callers that bundle writes into a
        single atomic transaction rely on this.
        """
        mock_session = MagicMock()
        mock_session.query.return_value.count.return_value = 1

        manager = SettingsManager(db_session=mock_session)

        with patch.object(manager, "delete_setting"):
            manager.update_db_version(commit=False)

        mock_session.add.assert_called_once()
        mock_session.commit.assert_not_called()


class TestSettingsManagerStaticMethods:
    """Tests for static helper methods."""

    def test_get_bootstrap_env_vars(self):
        """Test get_bootstrap_env_vars returns bootstrap variables."""
        result = SettingsManager.get_bootstrap_env_vars()

        assert isinstance(result, dict)
        assert "LDR_BOOTSTRAP_ENCRYPTION_KEY" in result
        assert "LDR_BOOTSTRAP_DATA_DIR" in result

    def test_is_bootstrap_env_var_true(self):
        """Test is_bootstrap_env_var returns True for bootstrap vars."""
        assert SettingsManager.is_bootstrap_env_var(
            "LDR_BOOTSTRAP_ENCRYPTION_KEY"
        )
        assert SettingsManager.is_bootstrap_env_var(
            "LDR_DB_CONFIG_CACHE_SIZE_MB"
        )

    def test_is_bootstrap_env_var_false(self):
        """Test is_bootstrap_env_var returns False for non-bootstrap vars."""
        assert not SettingsManager.is_bootstrap_env_var("LDR_TESTING_TEST_MODE")
        assert not SettingsManager.is_bootstrap_env_var("RANDOM_VAR")

    def test_is_env_only_setting_true(self):
        """Test is_env_only_setting returns True for env-only settings."""
        assert SettingsManager.is_env_only_setting("testing.test_mode")
        assert SettingsManager.is_env_only_setting("bootstrap.encryption_key")

    def test_is_env_only_setting_false(self):
        """Test is_env_only_setting returns False for DB settings."""
        assert not SettingsManager.is_env_only_setting("app.debug")
        assert not SettingsManager.is_env_only_setting("llm.provider")

    def test_get_env_var_for_setting(self):
        """Test get_env_var_for_setting returns correct env var name."""
        assert (
            SettingsManager.get_env_var_for_setting("app.host")
            == "LDR_APP_HOST"
        )
        assert (
            SettingsManager.get_env_var_for_setting("llm.provider")
            == "LDR_LLM_PROVIDER"
        )

    def test_get_setting_key_for_env_var(self):
        """Test get_setting_key_for_env_var returns correct setting key."""
        assert (
            SettingsManager.get_setting_key_for_env_var("LDR_APP_HOST")
            == "app.host"
        )
        assert (
            SettingsManager.get_setting_key_for_env_var("LDR_LLM_PROVIDER")
            == "llm.provider"
        )

    def test_get_setting_key_for_env_var_non_ldr(self):
        """Test get_setting_key_for_env_var returns None for non-LDR vars."""
        assert SettingsManager.get_setting_key_for_env_var("PATH") is None
        assert SettingsManager.get_setting_key_for_env_var("HOME") is None


class TestHelperFunctions:
    """Tests for module-level helper functions."""

    @pytest.fixture(autouse=True)
    def clean_env(self):
        """Clean environment before each test."""
        original_env = {
            k: v for k, v in os.environ.items() if k.startswith("LDR_")
        }
        for key in list(os.environ.keys()):
            if key.startswith("LDR_"):
                os.environ.pop(key, None)
        yield
        for key in list(os.environ.keys()):
            if key.startswith("LDR_"):
                os.environ.pop(key, None)
        for key, value in original_env.items():
            os.environ[key] = value

    def test_parse_number_int(self):
        """Test _parse_number returns int for whole numbers."""
        assert _parse_number("42") == 42
        assert isinstance(_parse_number("42"), int)

    def test_parse_number_float(self):
        """Test _parse_number returns float for decimals."""
        assert _parse_number("3.14") == 3.14
        assert isinstance(_parse_number("3.14"), float)

    def test_parse_number_float_as_int(self):
        """Test _parse_number returns int for float with .0."""
        assert _parse_number("42.0") == 42
        assert isinstance(_parse_number("42.0"), int)

    def test_check_env_setting_returns_value(self):
        """Test check_env_setting returns env var value."""
        os.environ["LDR_APP_DEBUG"] = "true"

        result = check_env_setting("app.debug")

        assert result == "true"

    def test_check_env_setting_returns_none_when_not_set(self):
        """Test check_env_setting returns None when not set."""
        result = check_env_setting("nonexistent.key")

        assert result is None

    def test_parse_boolean_empty_string(self):
        """parse_boolean returns False for empty string."""
        assert parse_boolean("") is False

    def test_parse_boolean_integer(self):
        """parse_boolean converts integers using Python bool()."""
        assert parse_boolean(1) is True
        assert parse_boolean(0) is False

    def test_parse_boolean_whitespace(self):
        """parse_boolean treats whitespace-only string as falsy (stripped to empty)."""
        assert parse_boolean("  ") is False

    def test_get_typed_setting_value_unknown_ui_element(self):
        """Test get_typed_setting_value returns default for unknown UI element."""
        result = get_typed_setting_value(
            key="test",
            value="val",
            ui_element="unknown_element",
            default="fallback",
        )

        assert result == "fallback"

    def test_get_typed_setting_value_json_passthrough(self):
        """Test get_typed_setting_value passes JSON through unchanged."""
        json_value = {"key": "value", "list": [1, 2, 3]}

        result = get_typed_setting_value(
            key="test", value=json_value, ui_element="json", default=None
        )

        assert result == json_value

    def test_get_typed_setting_value_invalid_number(self):
        """Test get_typed_setting_value returns default for invalid number."""
        result = get_typed_setting_value(
            key="test", value="not_a_number", ui_element="number", default=99
        )

        assert result == 99

    def test_get_typed_setting_value_select_returns_string(self):
        """Test get_typed_setting_value returns string for select."""
        result = get_typed_setting_value(
            key="test", value="option1", ui_element="select", default=None
        )

        assert result == "option1"
        assert isinstance(result, str)


class TestDeleteSetting:
    """Tests for delete_setting functionality."""

    def test_delete_setting_success(self):
        """Test that delete_setting returns True on success."""
        mock_session = MagicMock()
        mock_session.query.return_value.count.return_value = 1
        mock_session.query.return_value.filter.return_value.delete.return_value = 1

        manager = SettingsManager(db_session=mock_session)

        result = manager.delete_setting("test.key")

        assert result is True
        mock_session.commit.assert_called()

    def test_delete_setting_not_found(self):
        """Test that delete_setting returns False when key not found."""
        mock_session = MagicMock()
        mock_session.query.return_value.count.return_value = 1
        mock_session.query.return_value.filter.return_value.delete.return_value = 0

        manager = SettingsManager(db_session=mock_session)

        result = manager.delete_setting("nonexistent.key")

        assert result is False

    def test_delete_setting_no_session(self):
        """Test that delete_setting returns False without DB session."""
        manager = SettingsManager(db_session=None)

        result = manager.delete_setting("test.key")

        assert result is False

    def test_delete_setting_rollback_on_error(self):
        """Test that delete_setting rolls back on error."""
        from sqlalchemy.exc import SQLAlchemyError

        mock_session = MagicMock()
        mock_session.query.return_value.count.return_value = 1
        mock_session.query.return_value.filter.return_value.delete.side_effect = SQLAlchemyError()

        manager = SettingsManager(db_session=mock_session)

        result = manager.delete_setting("test.key")

        assert result is False
        mock_session.rollback.assert_called_once()


class TestGetSettingEnvFallbackWhenNotInDb:
    """Tests for Bug 1 fix: get_setting() checks env vars when setting not in DB."""

    @pytest.fixture(autouse=True)
    def clean_env(self):
        """Clean environment before each test."""
        original_env = {
            k: v for k, v in os.environ.items() if k.startswith("LDR_")
        }
        for key in list(os.environ.keys()):
            if key.startswith("LDR_"):
                os.environ.pop(key, None)
        yield
        for key in list(os.environ.keys()):
            if key.startswith("LDR_"):
                os.environ.pop(key, None)
        for key, value in original_env.items():
            os.environ[key] = value

    def test_env_var_number_when_not_in_db(self):
        """Env var override returns typed value when setting not in DB.

        Bug 1: Previously, get_setting() would return the raw default
        without checking env vars when the setting was not in the database.
        """
        os.environ["LDR_LLM_TEMPERATURE"] = "0.5"

        mock_session = MagicMock()
        mock_session.query.return_value.count.return_value = 1
        # Setting not found in DB
        mock_session.query.return_value.filter.return_value.all.return_value = []

        manager = SettingsManager(db_session=mock_session)

        result = manager.get_setting("llm.temperature", 0.7, check_env=True)

        # Should pick up the env var value, type-converted via ui_element
        # The exact type depends on whether llm.temperature is in default_settings
        assert result is not None
        # Should not be the raw default 0.7
        assert result != 0.7 or str(result) == "0.5" or result == 0.5

    def test_env_var_checkbox_when_not_in_db(self):
        """Env var override for checkbox returns typed bool when setting not in DB."""
        os.environ["LDR_APP_DEBUG"] = "true"

        mock_session = MagicMock()
        mock_session.query.return_value.count.return_value = 1
        # Setting not found in DB
        mock_session.query.return_value.filter.return_value.all.return_value = []

        manager = SettingsManager(db_session=mock_session)

        result = manager.get_setting("app.debug", False, check_env=True)

        # Should pick up the env var, not return the default False
        assert result is not False

    def test_default_when_not_in_db_and_no_env(self):
        """Returns default when setting not in DB and no env var set."""
        mock_session = MagicMock()
        mock_session.query.return_value.count.return_value = 1
        mock_session.query.return_value.filter.return_value.all.return_value = []

        manager = SettingsManager(db_session=mock_session)

        result = manager.get_setting(
            "nonexistent.setting", "my_default", check_env=True
        )

        assert result == "my_default"


class TestGetAllSettingsEnvTypeConversion:
    """Tests for Bug 2 fix: get_all_settings() type-converts env overrides."""

    @pytest.fixture(autouse=True)
    def clean_env(self):
        """Clean environment before each test."""
        original_env = {
            k: v for k, v in os.environ.items() if k.startswith("LDR_")
        }
        for key in list(os.environ.keys()):
            if key.startswith("LDR_"):
                os.environ.pop(key, None)
        yield
        for key in list(os.environ.keys()):
            if key.startswith("LDR_"):
                os.environ.pop(key, None)
        for key, value in original_env.items():
            os.environ[key] = value

    def test_env_override_boolean_is_converted(self):
        """Env override 'true' is converted to bool True, not stored as string.

        Bug 2: Previously, get_all_settings() stored the raw string from
        check_env_setting() without type conversion. "true" stayed as "true"
        instead of becoming True.
        """
        os.environ["LDR_APP_DEBUG"] = "true"

        mock_session = MagicMock()
        mock_session.query.return_value.count.return_value = 1

        mock_setting = MagicMock()
        mock_setting.key = "app.debug"
        mock_setting.value = False
        mock_setting.type = MagicMock(name="APP")
        mock_setting.name = "Debug"
        mock_setting.description = "Debug mode"
        mock_setting.category = "app"
        mock_setting.ui_element = "checkbox"
        mock_setting.options = None
        mock_setting.min_value = None
        mock_setting.max_value = None
        mock_setting.step = None
        mock_setting.visible = True
        mock_setting.editable = True
        mock_session.query.return_value.all.return_value = [mock_setting]

        manager = SettingsManager(db_session=mock_session)
        manager._SettingsManager__settings_locked = False

        with patch.object(
            SettingsManager,
            "default_settings",
            new_callable=PropertyMock,
            return_value={},
        ):
            result = manager.get_all_settings()

        # The value should be typed (bool True), not raw string "true"
        assert result["app.debug"]["value"] is True
        assert result["app.debug"]["editable"] is False

    def test_env_override_number_is_converted(self):
        """Env override '8080' is converted to int 8080, not stored as string.

        Bug 2: Previously, get_all_settings() would store "8080" as the
        raw string instead of converting to the proper type.
        """
        os.environ["LDR_APP_PORT"] = "8080"

        mock_session = MagicMock()
        mock_session.query.return_value.count.return_value = 1

        mock_setting = MagicMock()
        mock_setting.key = "app.port"
        mock_setting.value = 5000
        mock_setting.type = MagicMock(name="APP")
        mock_setting.name = "Port"
        mock_setting.description = "Server port"
        mock_setting.category = "app"
        mock_setting.ui_element = "number"
        mock_setting.options = None
        mock_setting.min_value = None
        mock_setting.max_value = None
        mock_setting.step = None
        mock_setting.visible = True
        mock_setting.editable = True
        mock_session.query.return_value.all.return_value = [mock_setting]

        manager = SettingsManager(db_session=mock_session)
        manager._SettingsManager__settings_locked = False

        with patch.object(
            SettingsManager,
            "default_settings",
            new_callable=PropertyMock,
            return_value={},
        ):
            result = manager.get_all_settings()

        # The value should be typed (int 8080), not raw string "8080"
        assert result["app.port"]["value"] == 8080
        assert isinstance(result["app.port"]["value"], int)
        assert result["app.port"]["editable"] is False

    def test_env_override_text_is_string(self):
        """Env override for text setting stays as string."""
        os.environ["LDR_APP_HOST"] = "example.com"

        mock_session = MagicMock()
        mock_session.query.return_value.count.return_value = 1

        mock_setting = MagicMock()
        mock_setting.key = "app.host"
        mock_setting.value = "localhost"
        mock_setting.type = MagicMock(name="APP")
        mock_setting.name = "Host"
        mock_setting.description = "Server host"
        mock_setting.category = "app"
        mock_setting.ui_element = "text"
        mock_setting.options = None
        mock_setting.min_value = None
        mock_setting.max_value = None
        mock_setting.step = None
        mock_setting.visible = True
        mock_setting.editable = True
        mock_session.query.return_value.all.return_value = [mock_setting]

        manager = SettingsManager(db_session=mock_session)
        manager._SettingsManager__settings_locked = False

        with patch.object(
            SettingsManager,
            "default_settings",
            new_callable=PropertyMock,
            return_value={},
        ):
            result = manager.get_all_settings()

        assert result["app.host"]["value"] == "example.com"
        assert isinstance(result["app.host"]["value"], str)
        assert result["app.host"]["editable"] is False


class TestGetBoolSettingMethod:
    """Tests for get_bool_setting() method on the unified SettingsManager."""

    def test_get_bool_setting_exists(self):
        """Verify get_bool_setting method exists on SettingsManager."""
        manager = SettingsManager(db_session=None)
        assert hasattr(manager, "get_bool_setting")
        assert callable(manager.get_bool_setting)

    def test_get_bool_setting_returns_bool(self):
        """get_bool_setting returns a boolean type."""
        manager = SettingsManager(db_session=None)

        with patch.object(manager, "get_setting", return_value="true"):
            result = manager.get_bool_setting("test.key")
            assert result is True
            assert isinstance(result, bool)

    def test_get_bool_setting_converts_false_string(self):
        """get_bool_setting converts 'false' to False."""
        manager = SettingsManager(db_session=None)

        with patch.object(manager, "get_setting", return_value="false"):
            result = manager.get_bool_setting("test.key")
            assert result is False

    def test_get_bool_setting_default(self):
        """get_bool_setting returns default when setting not found."""
        manager = SettingsManager(db_session=None)

        with patch.object(manager, "get_setting", return_value=None):
            result = manager.get_bool_setting("test.key", default=True)
            assert result is True

    def test_get_bool_setting_integer_zero(self):
        """get_bool_setting converts integer 0 to False."""
        manager = SettingsManager(db_session=None)

        with patch.object(manager, "get_setting", return_value=0):
            result = manager.get_bool_setting("test.key")
            assert result is False
            assert isinstance(result, bool)

    def test_get_bool_setting_integer_one(self):
        """get_bool_setting converts integer 1 to True."""
        manager = SettingsManager(db_session=None)

        with patch.object(manager, "get_setting", return_value=1):
            result = manager.get_bool_setting("test.key")
            assert result is True
            assert isinstance(result, bool)


class TestCreateOrUpdateSetting:
    """Tests for create_or_update_setting method."""

    @pytest.fixture(autouse=True)
    def clean_env(self):
        """Clean environment before each test."""
        original_env = {
            k: v for k, v in os.environ.items() if k.startswith("LDR_")
        }
        for key in list(os.environ.keys()):
            if key.startswith("LDR_"):
                os.environ.pop(key, None)
        yield
        for key in list(os.environ.keys()):
            if key.startswith("LDR_"):
                os.environ.pop(key, None)
        for key, value in original_env.items():
            os.environ[key] = value

    def test_create_new_setting(self):
        """create_or_update_setting creates a new setting when none exists."""
        mock_session = MagicMock()
        mock_session.query.return_value.count.return_value = 1
        # No existing setting found
        mock_session.query.return_value.filter.return_value.first.return_value = None

        manager = SettingsManager(db_session=mock_session)
        manager._SettingsManager__settings_locked = False

        setting_dict = {
            "key": "app.new_feature",
            "value": "enabled",
            "type": "app",
            "name": "New Feature",
        }

        with patch.object(manager, "_emit_settings_changed"):
            result = manager.create_or_update_setting(setting_dict)

        assert result is not None
        mock_session.add.assert_called_once()
        mock_session.commit.assert_called_once()

    def test_update_existing_editable_setting(self):
        """create_or_update_setting updates fields of an existing editable setting."""
        mock_session = MagicMock()
        mock_session.query.return_value.count.return_value = 1

        mock_db_setting = MagicMock()
        mock_db_setting.editable = True
        mock_session.query.return_value.filter.return_value.first.return_value = mock_db_setting

        manager = SettingsManager(db_session=mock_session)
        manager._SettingsManager__settings_locked = False

        setting_dict = {
            "key": "app.existing",
            "value": "updated",
            "type": "app",
            "name": "Existing Setting",
        }

        with patch.object(manager, "_emit_settings_changed"):
            result = manager.create_or_update_setting(setting_dict)

        assert result is mock_db_setting
        assert mock_db_setting.value == "updated"
        assert mock_db_setting.name == "Existing Setting"
        # Should NOT call session.add for update (only for create)
        mock_session.add.assert_not_called()

    def test_update_non_editable_returns_none(self):
        """Bug 3 fix: create_or_update_setting returns None for non-editable settings."""
        mock_session = MagicMock()
        mock_session.query.return_value.count.return_value = 1

        mock_db_setting = MagicMock()
        mock_db_setting.editable = False
        mock_session.query.return_value.filter.return_value.first.return_value = mock_db_setting

        manager = SettingsManager(db_session=mock_session)
        manager._SettingsManager__settings_locked = False

        setting_dict = {
            "key": "app.locked_setting",
            "value": "should_not_update",
            "type": "app",
            "name": "Locked",
        }

        result = manager.create_or_update_setting(setting_dict)

        assert result is None

    def test_create_from_dict_llm_type(self):
        """Dict with llm. key prefix uses LLMSetting model."""
        mock_session = MagicMock()
        mock_session.query.return_value.count.return_value = 1
        mock_session.query.return_value.filter.return_value.first.return_value = None

        manager = SettingsManager(db_session=mock_session)
        manager._SettingsManager__settings_locked = False

        setting_dict = {
            "key": "llm.temperature",
            "value": 0.7,
            "name": "Temperature",
        }

        with patch.object(manager, "_emit_settings_changed"):
            result = manager.create_or_update_setting(setting_dict)

        assert result is not None
        mock_session.add.assert_called_once()

    def test_create_from_dict_search_type(self):
        """Dict with search. key prefix uses SearchSetting model."""
        mock_session = MagicMock()
        mock_session.query.return_value.count.return_value = 1
        mock_session.query.return_value.filter.return_value.first.return_value = None

        manager = SettingsManager(db_session=mock_session)
        manager._SettingsManager__settings_locked = False

        setting_dict = {
            "key": "search.max_results",
            "value": 10,
            "name": "Max Results",
        }

        with patch.object(manager, "_emit_settings_changed"):
            result = manager.create_or_update_setting(setting_dict)

        assert result is not None
        mock_session.add.assert_called_once()

    def test_create_from_dict_report_type(self):
        """Dict with report. key prefix uses ReportSetting model."""
        mock_session = MagicMock()
        mock_session.query.return_value.count.return_value = 1
        mock_session.query.return_value.filter.return_value.first.return_value = None

        manager = SettingsManager(db_session=mock_session)
        manager._SettingsManager__settings_locked = False

        setting_dict = {
            "key": "report.format",
            "value": "pdf",
            "name": "Report Format",
        }

        with patch.object(manager, "_emit_settings_changed"):
            result = manager.create_or_update_setting(setting_dict)

        assert result is not None
        mock_session.add.assert_called_once()

    def test_no_db_session_returns_none(self):
        """create_or_update_setting returns None without a DB session."""
        manager = SettingsManager(db_session=None)

        result = manager.create_or_update_setting(
            {"key": "test", "value": "val", "type": "app", "name": "Test"}
        )

        assert result is None

    def test_sqlalchemy_error_rolls_back(self):
        """create_or_update_setting rolls back on SQLAlchemy error."""
        from sqlalchemy.exc import SQLAlchemyError

        mock_session = MagicMock()
        mock_session.query.return_value.count.return_value = 1
        mock_session.query.return_value.filter.return_value.first.side_effect = SQLAlchemyError(
            "DB error"
        )

        manager = SettingsManager(db_session=mock_session)
        manager._SettingsManager__settings_locked = False

        setting_dict = {
            "key": "app.broken",
            "value": "val",
            "type": "app",
            "name": "Broken",
        }

        result = manager.create_or_update_setting(setting_dict)

        assert result is None
        mock_session.rollback.assert_called_once()


class TestDefaultSettingsProperty:
    """Tests for the default_settings property."""

    def test_loads_multiple_json_files(self):
        """default_settings loads and merges multiple JSON files."""
        import tempfile
        from pathlib import Path

        with tempfile.TemporaryDirectory() as tmpdir:
            # Create two JSON files
            file1 = Path(tmpdir) / "settings1.json"
            file1.write_text(
                '{"app.debug": {"value": true, "ui_element": "checkbox"}}'
            )
            file2 = Path(tmpdir) / "settings2.json"
            file2.write_text(
                '{"llm.model": {"value": "gpt-4", "ui_element": "select"}}'
            )

            manager = SettingsManager(db_session=None)

            with patch(
                "local_deep_research.settings.manager.defaults.__file__",
                str(Path(tmpdir) / "__init__.py"),
            ):
                result = manager.default_settings

        assert "app.debug" in result
        assert "llm.model" in result
        assert result["app.debug"]["value"] is True
        assert result["llm.model"]["value"] == "gpt-4"

    def test_handles_json_decode_error(self):
        """default_settings skips files with invalid JSON and loads the rest."""
        import tempfile
        from pathlib import Path

        with tempfile.TemporaryDirectory() as tmpdir:
            good_file = Path(tmpdir) / "good.json"
            good_file.write_text('{"app.host": {"value": "localhost"}}')
            bad_file = Path(tmpdir) / "bad.json"
            bad_file.write_text("{invalid json!!!")

            manager = SettingsManager(db_session=None)

            with patch(
                "local_deep_research.settings.manager.defaults.__file__",
                str(Path(tmpdir) / "__init__.py"),
            ):
                result = manager.default_settings

        assert "app.host" in result
        assert result["app.host"]["value"] == "localhost"

    def test_key_conflicts_logged(self):
        """default_settings warns when keys conflict between files."""
        import tempfile
        from pathlib import Path

        with tempfile.TemporaryDirectory() as tmpdir:
            # Both files define the same key - second (alphabetically) wins
            file_a = Path(tmpdir) / "a_settings.json"
            file_a.write_text('{"app.debug": {"value": false}}')
            file_b = Path(tmpdir) / "b_settings.json"
            file_b.write_text('{"app.debug": {"value": true}}')

            manager = SettingsManager(db_session=None)

            with patch(
                "local_deep_research.settings.manager.defaults.__file__",
                str(Path(tmpdir) / "__init__.py"),
            ):
                result = manager.default_settings

        # Second file (alphabetically sorted) wins
        assert result["app.debug"]["value"] is True

    def test_empty_defaults_returns_empty(self):
        """default_settings returns empty dict when no JSON files exist."""
        import tempfile
        from pathlib import Path

        with tempfile.TemporaryDirectory() as tmpdir:
            manager = SettingsManager(db_session=None)

            with patch(
                "local_deep_research.settings.manager.defaults.__file__",
                str(Path(tmpdir) / "__init__.py"),
            ):
                result = manager.default_settings

        assert result == {}


class TestEnsureSettingsInitialized:
    """Tests for _ensure_settings_initialized behavior."""

    @pytest.fixture(autouse=True)
    def clean_env(self):
        """Clean environment before each test."""
        original_env = {
            k: v for k, v in os.environ.items() if k.startswith("LDR_")
        }
        for key in list(os.environ.keys()):
            if key.startswith("LDR_"):
                os.environ.pop(key, None)
        yield
        for key in list(os.environ.keys()):
            if key.startswith("LDR_"):
                os.environ.pop(key, None)
        for key, value in original_env.items():
            os.environ[key] = value

    def test_skips_load_when_db_has_settings(self):
        """_ensure_settings_initialized does NOT load defaults when DB already has settings."""
        mock_session = MagicMock()
        mock_session.query.return_value.count.return_value = 42

        with patch.object(
            SettingsManager, "load_from_defaults_file"
        ) as mock_load:
            SettingsManager(db_session=mock_session)

            mock_load.assert_not_called()

    def test_loads_defaults_when_db_empty(self):
        """_ensure_settings_initialized loads defaults when DB has 0 settings."""
        mock_session = MagicMock()
        mock_session.query.return_value.count.return_value = 0

        with patch.object(
            SettingsManager, "load_from_defaults_file"
        ) as mock_load:
            SettingsManager(db_session=mock_session)

            mock_load.assert_called_once()


class TestNewUiElementTypes:
    """Tests for new UI element types: textarea, multiselect, range."""

    def test_textarea_returns_string(self):
        """get_typed_setting_value with textarea returns string."""
        result = get_typed_setting_value(
            key="app.custom_css",
            value="body { color: red; }",
            ui_element="textarea",
        )

        assert result == "body { color: red; }"
        assert isinstance(result, str)

    def test_multiselect_passthrough(self):
        """get_typed_setting_value with multiselect passes through the value."""
        result = get_typed_setting_value(
            key="search.engines",
            value=["google", "bing"],
            ui_element="multiselect",
        )

        assert result == ["google", "bing"]

    def test_range_returns_number(self):
        """get_typed_setting_value with range parses number like number element."""
        result = get_typed_setting_value(
            key="llm.temperature",
            value="3.14",
            ui_element="range",
        )

        assert result == 3.14
        assert isinstance(result, float)

    def test_range_returns_int_for_whole_number(self):
        """get_typed_setting_value with range returns int for whole numbers."""
        result = get_typed_setting_value(
            key="search.max_results",
            value="10",
            ui_element="range",
        )

        assert result == 10
        assert isinstance(result, int)


class TestEmitSettingsChanged:
    """Tests for _emit_settings_changed error resilience."""

    @pytest.fixture(autouse=True)
    def clean_env(self):
        """Clean environment before each test."""
        original_env = {
            k: v for k, v in os.environ.items() if k.startswith("LDR_")
        }
        for key in list(os.environ.keys()):
            if key.startswith("LDR_"):
                os.environ.pop(key, None)
        yield
        for key in list(os.environ.keys()):
            if key.startswith("LDR_"):
                os.environ.pop(key, None)
        for key, value in original_env.items():
            os.environ[key] = value

    def test_emit_handles_socket_not_initialized(self):
        """_emit_settings_changed handles SocketIOService raising ValueError."""
        mock_session = MagicMock()
        mock_session.query.return_value.count.return_value = 1

        manager = SettingsManager(db_session=mock_session)

        with patch(
            "local_deep_research.settings.manager.SocketIOService",
            side_effect=ValueError("SocketIO not initialized"),
            create=True,
        ):
            # Should not raise
            manager._emit_settings_changed(["test.key"])

    def test_emit_handles_import_error(self):
        """_emit_settings_changed handles import failure gracefully."""
        mock_session = MagicMock()
        mock_session.query.return_value.count.return_value = 1

        manager = SettingsManager(db_session=mock_session)

        with patch(
            "local_deep_research.settings.manager.SocketIOService",
            side_effect=ImportError("No module"),
            create=True,
        ):
            # Should not raise - caught by the outer except Exception
            manager._emit_settings_changed(["test.key"])

    def test_emit_handles_generic_exception(self):
        """_emit_settings_changed handles any exception without breaking settings save."""
        mock_session = MagicMock()
        mock_session.query.return_value.count.return_value = 1

        manager = SettingsManager(db_session=mock_session)

        with patch(
            "local_deep_research.settings.manager.SocketIOService",
            side_effect=RuntimeError("Unexpected error"),
            create=True,
        ):
            # Should not raise - the method catches all exceptions
            manager._emit_settings_changed(["test.key"])


class TestParseMultiselect:
    """Tests for _parse_multiselect type conversion."""

    def test_list_passthrough(self):
        """Lists from SQLAlchemy JSON column pass through unchanged."""
        value = ["markdown", "latex", "quarto"]
        assert _parse_multiselect(value) == ["markdown", "latex", "quarto"]

    def test_empty_list_passthrough(self):
        """Empty list passes through unchanged."""
        assert _parse_multiselect([]) == []

    def test_json_array_string(self):
        """JSON array strings (from env vars) are parsed correctly."""
        assert _parse_multiselect('["markdown", "latex"]') == [
            "markdown",
            "latex",
        ]

    def test_comma_separated_string(self):
        """Comma-separated strings (from env vars) are parsed correctly."""
        assert _parse_multiselect("markdown,latex,quarto") == [
            "markdown",
            "latex",
            "quarto",
        ]

    def test_comma_separated_with_spaces(self):
        """Comma-separated strings with spaces are trimmed."""
        assert _parse_multiselect("markdown, latex, quarto") == [
            "markdown",
            "latex",
            "quarto",
        ]

    def test_single_value_string(self):
        """Single value string returns a one-element list."""
        assert _parse_multiselect("markdown") == ["markdown"]

    def test_empty_string(self):
        """Empty string returns empty list."""
        assert _parse_multiselect("") == []

    def test_invalid_json_falls_back_to_comma_split(self):
        """Invalid JSON starting with [ falls back to comma splitting."""
        assert _parse_multiselect("[broken,json") == ["[broken", "json"]

    def test_whitespace_only_items_filtered(self):
        """Whitespace-only items between commas are filtered out."""
        assert _parse_multiselect("markdown,,latex, ,quarto") == [
            "markdown",
            "latex",
            "quarto",
        ]


class TestMultiselectEnvVarOverride:
    """Tests for multiselect env var overrides through get_typed_setting_value."""

    @pytest.fixture(autouse=True)
    def clean_env(self):
        """Clean environment before each test."""
        original_env = {
            k: v for k, v in os.environ.items() if k.startswith("LDR_")
        }
        for key in list(os.environ.keys()):
            if key.startswith("LDR_"):
                os.environ.pop(key, None)
        yield
        for key in list(os.environ.keys()):
            if key.startswith("LDR_"):
                os.environ.pop(key, None)
        for key, value in original_env.items():
            os.environ[key] = value

    def test_multiselect_env_var_json_array(self):
        """Multiselect env var with JSON array is parsed to list."""
        os.environ["LDR_REPORT_EXPORT_FORMATS"] = '["markdown", "latex"]'
        result = get_typed_setting_value(
            "report.export_formats",
            None,
            "multiselect",
            default=["markdown"],
        )
        assert result == ["markdown", "latex"]

    def test_multiselect_env_var_comma_separated(self):
        """Multiselect env var with comma-separated values is parsed to list."""
        os.environ["LDR_REPORT_EXPORT_FORMATS"] = "markdown,latex,quarto"
        result = get_typed_setting_value(
            "report.export_formats",
            None,
            "multiselect",
            default=["markdown"],
        )
        assert result == ["markdown", "latex", "quarto"]

    def test_multiselect_db_value_list(self):
        """Multiselect DB value (already a list) passes through."""
        result = get_typed_setting_value(
            "report.export_formats",
            ["markdown", "latex"],
            "multiselect",
            default=[],
            check_env=False,
        )
        assert result == ["markdown", "latex"]


class TestEnvVarWithoutDefaultsWarning:
    """Tests for warning when env var override has no type information."""

    @pytest.fixture(autouse=True)
    def clean_env(self):
        """Clean environment before each test."""
        original_env = {
            k: v for k, v in os.environ.items() if k.startswith("LDR_")
        }
        for key in list(os.environ.keys()):
            if key.startswith("LDR_"):
                os.environ.pop(key, None)
        yield
        for key in list(os.environ.keys()):
            if key.startswith("LDR_"):
                os.environ.pop(key, None)
        for key, value in original_env.items():
            os.environ[key] = value

    def test_env_var_without_defaults_logs_warning(self):
        """Env var override for unknown setting logs a warning."""
        os.environ["LDR_UNKNOWN_SETTING"] = "some_value"

        mock_session = MagicMock()
        mock_session.query.return_value.count.return_value = 1
        # No DB results for this key
        mock_session.query.return_value.filter.return_value.all.return_value = []

        manager = SettingsManager(db_session=mock_session)

        with patch(
            "local_deep_research.settings.manager.logger"
        ) as mock_logger:
            result = manager.get_setting("unknown.setting", "default_val")

        assert result == "some_value"
        mock_logger.warning.assert_called()
        warning_msg = mock_logger.warning.call_args[0][0]
        assert "not in defaults" in warning_msg

    def test_env_var_with_defaults_no_warning(self):
        """Env var override for known setting does not log a warning."""
        os.environ["LDR_SOME_KNOWN_KEY"] = "overridden"

        mock_session = MagicMock()
        mock_session.query.return_value.count.return_value = 1
        # No DB results
        mock_session.query.return_value.filter.return_value.all.return_value = []

        manager = SettingsManager(db_session=mock_session)

        with (
            patch.object(
                type(manager),
                "default_settings",
                new_callable=PropertyMock,
                return_value={
                    "some.known.key": {
                        "ui_element": "text",
                        "value": "original",
                    }
                },
            ),
            patch("local_deep_research.settings.manager.logger") as mock_logger,
        ):
            result = manager.get_setting("some.known.key", "default_val")

        # Should return the typed value, not log a "not in defaults" warning
        assert result == "overridden"
        for call in mock_logger.warning.call_args_list:
            assert "not in defaults" not in call[0][0]


class TestGetAllSettingsDbErrorPropagation:
    """get_all_settings must propagate DB errors instead of silently
    returning defaults-only results (issue #2079)."""

    @pytest.fixture(autouse=True)
    def clean_env(self):
        original_env = {
            k: v for k, v in os.environ.items() if k.startswith("LDR_")
        }
        for key in list(os.environ.keys()):
            if key.startswith("LDR_"):
                os.environ.pop(key, None)
        yield
        for key in list(os.environ.keys()):
            if key.startswith("LDR_"):
                os.environ.pop(key, None)
        for key, value in original_env.items():
            os.environ[key] = value

    def test_db_error_returns_defaults_gracefully(self):
        """SQLAlchemyError from __query_settings is caught; defaults are returned."""
        from sqlalchemy.exc import SQLAlchemyError

        mock_session = MagicMock()
        mock_session.query.return_value.count.return_value = 1
        mock_session.query.return_value.all.side_effect = SQLAlchemyError(
            "connection lost"
        )

        manager = SettingsManager(db_session=mock_session)

        with patch.object(
            type(manager),
            "default_settings",
            new_callable=PropertyMock,
            return_value={"app.key": {"value": "default"}},
        ):
            # get_all_settings gracefully degrades: catches DB errors,
            # logs them, and returns defaults only
            result = manager.get_all_settings()
            assert isinstance(result, dict)
            assert "app.key" in result


class TestFilterSettingColumns:
    """Tests for _filter_setting_columns column filtering helper."""

    def test_preserves_valid_columns(self):
        """Valid keys like value, name, ui_element, type pass through unchanged."""
        data = {
            "value": "test_val",
            "name": "Test Name",
            "ui_element": "text",
            "type": "APP",
        }
        result = _filter_setting_columns(data)
        assert result == data

    def test_filters_out_invalid_columns(self):
        """Unknown keys like future_flag, internal_id are removed."""
        data = {"future_flag": True, "internal_id": 999}
        result = _filter_setting_columns(data)
        assert result == {}

    def test_mixed_valid_and_invalid_keys(self):
        """Only valid keys kept, invalid ones dropped, values preserved."""
        data = {
            "key": "app.debug",
            "value": True,
            "name": "Debug",
            "unknown_field": "should_be_removed",
            "another_bad": 42,
        }
        result = _filter_setting_columns(data)
        assert result == {"key": "app.debug", "value": True, "name": "Debug"}

    def test_empty_dict(self):
        """Empty dict returns empty dict."""
        assert _filter_setting_columns({}) == {}

    def test_all_invalid_keys(self):
        """Dict with only unknown keys returns empty dict."""
        data = {"foo": 1, "bar": 2, "baz": 3}
        assert _filter_setting_columns(data) == {}

    def test_preserves_none_values(self):
        """Valid keys with None values are kept (not confused with invalid)."""
        data = {"value": None, "description": None, "options": None}
        result = _filter_setting_columns(data)
        assert result == data
        assert all(v is None for v in result.values())

    def test_preserves_various_value_types(self):
        """Handles str, bool, int, float, list, dict, None values correctly."""
        data = {
            "key": "test.key",
            "value": 3.14,
            "name": "Test",
            "visible": True,
            "options": ["a", "b"],
            "description": None,
            "min_value": 0,
            "max_value": 100,
        }
        result = _filter_setting_columns(data)
        assert result == data

    def test_does_not_mutate_input(self):
        """Original dict is unchanged after filtering."""
        data = {
            "key": "test",
            "value": "val",
            "unknown_extra": "should_drop",
        }
        original = data.copy()
        _filter_setting_columns(data)
        assert data == original

    def test_valid_columns_match_setting_model(self):
        """Sanity check: the function recognizes all known Setting model columns."""
        expected_columns = {
            "id",
            "key",
            "value",
            "type",
            "name",
            "description",
            "category",
            "ui_element",
            "options",
            "min_value",
            "max_value",
            "step",
            "visible",
            "editable",
            "env_var",
            "created_at",
            "updated_at",
        }
        # Build a dict with all expected columns as keys
        data = {col: f"test_{col}" for col in expected_columns}
        result = _filter_setting_columns(data)
        # All expected columns should survive filtering
        assert set(result.keys()) == expected_columns
