"""Tests for config manager module."""

from unittest.mock import patch
import os
import pytest
from chad.util.config_manager import CONFIG_BASE_KEYS, ConfigManager, validate_config_keys


class TestConfigManager:
    """Test cases for ConfigManager."""

    def test_hash_password(self):
        """Test password hashing."""
        mgr = ConfigManager()
        password = "testpassword123"
        hashed = mgr.hash_password(password)

        assert isinstance(hashed, str)
        assert len(hashed) > 0
        assert hashed != password

    def test_verify_password_correct(self):
        """Test password verification with correct password."""
        mgr = ConfigManager()
        password = "testpassword123"
        hashed = mgr.hash_password(password)

        assert mgr.verify_password(password, hashed) is True

    def test_verify_password_incorrect(self):
        """Test password verification with incorrect password."""
        mgr = ConfigManager()
        password = "testpassword123"
        hashed = mgr.hash_password(password)

        assert mgr.verify_password("wrongpassword", hashed) is False

    def test_encrypt_decrypt_value(self):
        """Test encryption and decryption of values."""
        import base64
        import bcrypt

        mgr = ConfigManager()
        password = "masterpassword"
        salt = base64.urlsafe_b64encode(bcrypt.gensalt()).decode()
        salt_bytes = base64.urlsafe_b64decode(salt.encode())
        value = "sk-test-api-key-12345"

        # Encrypt
        encrypted = mgr.encrypt_value(value, password, salt_bytes)
        assert isinstance(encrypted, str)
        assert encrypted != value

        # Decrypt
        decrypted = mgr.decrypt_value(encrypted, password, salt_bytes)
        assert decrypted == value

    def test_decrypt_with_wrong_password_raises_error(self):
        """Test that decryption with wrong password raises an error."""
        import base64
        import bcrypt

        mgr = ConfigManager()
        password = "masterpassword"
        wrong_password = "wrongpassword"
        salt = base64.urlsafe_b64encode(bcrypt.gensalt()).decode()
        salt_bytes = base64.urlsafe_b64decode(salt.encode())
        value = "sk-test-api-key-12345"

        encrypted = mgr.encrypt_value(value, password, salt_bytes)

        with pytest.raises(Exception):
            mgr.decrypt_value(encrypted, wrong_password, salt_bytes)

    def test_load_config_nonexistent_file(self, tmp_path):
        """Test loading config when file doesn't exist."""
        mgr = ConfigManager(tmp_path / "nonexistent.conf")
        config = mgr.load_config()
        assert config == {}

    def test_save_and_load_config(self, tmp_path):
        """Test saving and loading configuration."""
        config_path = tmp_path / "test.conf"
        mgr = ConfigManager(config_path)

        test_config = {
            "password_hash": "hashed_password",
            "encryption_salt": "test_salt",
            "api_keys": {"anthropic": "encrypted_key"},
        }

        mgr.save_config(test_config)

        # Verify file was created and has correct permissions
        assert config_path.exists()
        expected_mode = "666" if os.name == "nt" else "600"
        assert oct(config_path.stat().st_mode)[-3:] == expected_mode

        # Load and verify
        loaded_config = mgr.load_config()
        assert loaded_config == test_config

    def test_is_first_run_no_config(self, tmp_path):
        """Test is_first_run when no config exists."""
        mgr = ConfigManager(tmp_path / "new.conf")
        assert mgr.is_first_run() is True

    def test_is_first_run_no_password_hash(self, tmp_path):
        """Test is_first_run when config exists but no password hash."""
        config_path = tmp_path / "test.conf"
        mgr = ConfigManager(config_path)
        mgr.save_config({"api_keys": {}})

        assert mgr.is_first_run() is True

    def test_is_first_run_with_password_hash(self, tmp_path):
        """Test is_first_run when password hash exists."""
        config_path = tmp_path / "test.conf"
        mgr = ConfigManager(config_path)
        mgr.save_config({"password_hash": "test_hash"})

        assert mgr.is_first_run() is False

    @patch("getpass.getpass")
    def test_setup_main_password(self, mock_getpass, tmp_path):
        """Test main password setup."""
        config_path = tmp_path / "test.conf"
        mgr = ConfigManager(config_path)

        # Mock password input (password, then confirmation)
        mock_getpass.side_effect = ["testpassword123", "testpassword123"]

        password = mgr.setup_main_password()

        assert password == "testpassword123"
        assert config_path.exists()

        # Verify config was saved correctly
        config = mgr.load_config()
        assert "password_hash" in config
        assert "encryption_salt" in config
        assert "accounts" in config

        # Verify password hash is valid
        assert mgr.verify_password(password, config["password_hash"]) is True

    @patch("getpass.getpass")
    def test_setup_main_password_short_allowed(self, mock_getpass, tmp_path):
        """Test main password setup allows short passwords with warning."""
        mgr = ConfigManager(tmp_path / "test.conf")

        # Mock: short password, then confirmation
        mock_getpass.side_effect = ["short", "short"]

        password = mgr.setup_main_password()
        assert password == "short"

    @patch("getpass.getpass")
    def test_setup_main_password_empty_allowed(self, mock_getpass, tmp_path):
        """Test main password setup allows empty password with warning."""
        mgr = ConfigManager(tmp_path / "test.conf")

        # Mock: empty password, then confirmation
        mock_getpass.side_effect = ["", ""]

        password = mgr.setup_main_password()
        assert password == ""

    @patch("getpass.getpass")
    def test_setup_main_password_mismatch(self, mock_getpass, tmp_path):
        """Test main password setup with mismatched passwords."""
        mgr = ConfigManager(tmp_path / "test.conf")

        # Mock: password, wrong confirmation, then correct pair
        mock_getpass.side_effect = ["testpassword123", "wrongconfirm", "testpassword123", "testpassword123"]

        password = mgr.setup_main_password()
        assert password == "testpassword123"

    @patch("getpass.getpass")
    def test_verify_main_password_success(self, mock_getpass, tmp_path):
        """Test successful main password verification."""
        config_path = tmp_path / "test.conf"
        mgr = ConfigManager(config_path)

        # Setup password first
        password = "testpassword123"
        password_hash = mgr.hash_password(password)
        mgr.save_config({"password_hash": password_hash})

        # Mock getpass to return correct password
        mock_getpass.return_value = password

        verified_password = mgr.verify_main_password()
        assert verified_password == password

    @patch("getpass.getpass")
    def test_verify_main_password_retry(self, mock_getpass, tmp_path):
        """Test main password verification with retry."""
        config_path = tmp_path / "test.conf"
        mgr = ConfigManager(config_path)

        password = "testpassword123"
        password_hash = mgr.hash_password(password)
        mgr.save_config({"password_hash": password_hash})

        # Mock: wrong password (triggers reset prompt), cancel with EOFError, then correct password
        mock_getpass.side_effect = ["wrongpassword", EOFError(), password]

        verified_password = mgr.verify_main_password()
        assert verified_password == password

    @patch("getpass.getpass")
    def test_verify_main_password_retry_after_decline(self, mock_getpass, tmp_path):
        """Test main password verification retries after cancelling reset."""
        config_path = tmp_path / "test.conf"
        mgr = ConfigManager(config_path)

        password = "correct123"
        password_hash = mgr.hash_password(password)
        mgr.save_config({"password_hash": password_hash})

        # Mock: wrong password, cancel reset, wrong password again, cancel reset, then correct password
        mock_getpass.side_effect = ["wrong1", EOFError(), "wrong2", KeyboardInterrupt(), password]

        verified = mgr.verify_main_password()
        assert verified == password

    @patch("getpass.getpass")
    def test_verify_main_password_reset_accepted(self, mock_getpass, tmp_path):
        """Test main password reset when user accepts."""
        config_path = tmp_path / "test.conf"
        mgr = ConfigManager(config_path)

        # Setup old password and accounts
        old_password = "oldpassword"
        password_hash = mgr.hash_password(old_password)
        mgr.save_config(
            {
                "password_hash": password_hash,
                "accounts": {"test-account": {"provider": "anthropic", "key": "encrypted"}},
            }
        )

        # Mock: wrong password (becomes new password candidate), then confirmation
        mock_getpass.side_effect = [
            "newpassword",  # Wrong password (becomes new password)
            "newpassword",  # Confirmation
        ]

        new_password = mgr.verify_main_password()

        assert new_password == "newpassword"
        # Verify old accounts were deleted
        config = mgr.load_config()
        assert "accounts" in config
        assert len(config["accounts"]) == 0

    @patch("getpass.getpass")
    def test_verify_main_password_reset_confirmation_failed_then_retry(self, mock_getpass, tmp_path):
        """Test main password reset with mismatched confirmation, then retry and succeed."""
        config_path = tmp_path / "test.conf"
        mgr = ConfigManager(config_path)

        old_password = "correct123"
        password_hash = mgr.hash_password(old_password)
        mgr.save_config({"password_hash": password_hash})

        # Mock: wrong password, mismatched confirmation, wrong password again, matching confirmation
        mock_getpass.side_effect = [
            "newpass",  # Wrong password (becomes new password candidate)
            "different",  # Mismatched confirmation - loops back
            "newpass2",  # Wrong password again (becomes new password candidate)
            "newpass2",  # Matching confirmation
        ]

        verified = mgr.verify_main_password()
        assert verified == "newpass2"

    def test_store_and_get_account(self, tmp_path):
        """Test storing and retrieving named accounts."""
        config_path = tmp_path / "test.conf"
        mgr = ConfigManager(config_path)

        # Setup main password
        import base64
        import bcrypt

        password = "testpassword"
        password_hash = mgr.hash_password(password)
        salt = base64.urlsafe_b64encode(bcrypt.gensalt()).decode()

        mgr.save_config({"password_hash": password_hash, "encryption_salt": salt, "accounts": {}})

        # Store account
        api_key = "sk-test-key-12345"
        mgr.store_account("work-anthropic", "anthropic", api_key, password)

        # Retrieve account
        account = mgr.get_account("work-anthropic", password)
        assert account is not None
        assert account["provider"] == "anthropic"
        assert account["api_key"] == api_key

    def test_list_accounts(self, tmp_path):
        """Test listing all accounts."""
        config_path = tmp_path / "test.conf"
        mgr = ConfigManager(config_path)

        import base64
        import bcrypt

        password = "testpassword"
        password_hash = mgr.hash_password(password)
        salt = base64.urlsafe_b64encode(bcrypt.gensalt()).decode()

        mgr.save_config({"password_hash": password_hash, "encryption_salt": salt, "accounts": {}})

        # Store multiple accounts
        mgr.store_account("work-anthropic", "anthropic", "key1", password)
        mgr.store_account("personal-openai", "openai", "key2", password)

        # List accounts
        accounts = mgr.list_accounts()
        assert len(accounts) == 2
        assert accounts["work-anthropic"] == "anthropic"
        assert accounts["personal-openai"] == "openai"

    def test_has_account(self, tmp_path):
        """Test checking if account exists."""
        config_path = tmp_path / "test.conf"
        mgr = ConfigManager(config_path)

        import base64
        import bcrypt

        password = "testpassword"
        password_hash = mgr.hash_password(password)
        salt = base64.urlsafe_b64encode(bcrypt.gensalt()).decode()

        mgr.save_config({"password_hash": password_hash, "encryption_salt": salt, "accounts": {}})

        mgr.store_account("my-account", "anthropic", "key", password)

        assert mgr.has_account("my-account") is True
        assert mgr.has_account("nonexistent") is False

    def test_encrypt_value_produces_different_output_each_time(self, tmp_path):
        """Test that encrypt_value produces different output each time due to IV."""
        import bcrypt

        mgr = ConfigManager(tmp_path / "test.conf")
        password = "testpassword"
        salt_bytes = bcrypt.gensalt()
        value = "same-test-value"

        encrypted1 = mgr.encrypt_value(value, password, salt_bytes)
        encrypted2 = mgr.encrypt_value(value, password, salt_bytes)

        # Should produce different encrypted values due to random IV
        assert encrypted1 != encrypted2

        # But both should decrypt to the same value
        decrypted1 = mgr.decrypt_value(encrypted1, password, salt_bytes)
        decrypted2 = mgr.decrypt_value(encrypted2, password, salt_bytes)
        assert decrypted1 == value
        assert decrypted2 == value

    def test_encrypt_decrypt_empty_string(self, tmp_path):
        """Test encryption and decryption of empty string."""
        import bcrypt

        mgr = ConfigManager(tmp_path / "test.conf")
        password = "testpassword"
        salt_bytes = bcrypt.gensalt()
        value = ""

        encrypted = mgr.encrypt_value(value, password, salt_bytes)
        decrypted = mgr.decrypt_value(encrypted, password, salt_bytes)
        assert decrypted == value

    def test_encrypt_decrypt_very_long_value(self, tmp_path):
        """Test encryption and decryption of very long API key."""
        import bcrypt

        mgr = ConfigManager(tmp_path / "test.conf")
        password = "testpassword"
        salt_bytes = bcrypt.gensalt()
        # Create a very long value (10KB)
        value = "sk-" + "x" * 10000

        encrypted = mgr.encrypt_value(value, password, salt_bytes)
        decrypted = mgr.decrypt_value(encrypted, password, salt_bytes)
        assert decrypted == value

    def test_decrypt_value_with_truncated_encrypted_value(self, tmp_path):
        """Test that decrypt_value fails gracefully with corrupted encrypted data."""
        import bcrypt

        mgr = ConfigManager(tmp_path / "test.conf")
        password = "testpassword"
        salt_bytes = bcrypt.gensalt()
        value = "test-api-key"

        encrypted = mgr.encrypt_value(value, password, salt_bytes)
        # Truncate the encrypted value to corrupt it
        corrupted = encrypted[: len(encrypted) // 2]

        with pytest.raises(Exception):  # Should raise some form of decryption error
            mgr.decrypt_value(corrupted, password, salt_bytes)

    def test_get_role_assignment_nonexistent_role(self, tmp_path):
        """Test get_role_assignment for role that was never assigned."""
        config_path = tmp_path / "test.conf"
        mgr = ConfigManager(config_path)

        result = mgr.get_role_assignment("NONEXISTENT_ROLE")
        assert result is None

    def test_list_role_assignments_empty(self, tmp_path):
        """Test list_role_assignments when no roles are assigned."""
        config_path = tmp_path / "test.conf"
        mgr = ConfigManager(config_path)

        assignments = mgr.list_role_assignments()
        assert assignments == {}

    def test_assign_role_overwrites_previous_role(self, tmp_path):
        """assign_role should overwrite an existing assignment for the role."""
        config_path = tmp_path / "test.conf"
        mgr = ConfigManager(config_path)
        password = "testpassword"

        import base64
        import bcrypt

        password_hash = mgr.hash_password(password)
        salt = base64.urlsafe_b64encode(bcrypt.gensalt()).decode()
        mgr.save_config({"password_hash": password_hash, "encryption_salt": salt})

        mgr.store_account("account1", "anthropic", "key1", password, "model1")
        mgr.store_account("account2", "openai", "key2", password, "model2")

        mgr.assign_role("account1", "CODING")
        assert mgr.get_role_assignment("CODING") == "account1"

        mgr.assign_role("account2", "CODING")
        assert mgr.get_role_assignment("CODING") == "account2"

    def test_assign_role_to_account_that_doesnt_exist(self, tmp_path):
        """assign_role should error when the account does not exist."""
        config_path = tmp_path / "test.conf"
        mgr = ConfigManager(config_path)

        with pytest.raises(ValueError):
            mgr.assign_role("nonexistent", "CODING")

    def test_clear_role_nonexistent(self, tmp_path):
        """Clearing a missing role should be a no-op."""
        config_path = tmp_path / "test.conf"
        mgr = ConfigManager(config_path)

        mgr.clear_role("NONEXISTENT_ROLE")
        assert mgr.get_role_assignment("NONEXISTENT_ROLE") is None

    def test_delete_account_cascades_to_role_assignments(self, tmp_path):
        """delete_account should remove related role assignments."""
        config_path = tmp_path / "test.conf"
        mgr = ConfigManager(config_path)
        password = "testpassword"

        import base64
        import bcrypt

        password_hash = mgr.hash_password(password)
        salt = base64.urlsafe_b64encode(bcrypt.gensalt()).decode()
        mgr.save_config({"password_hash": password_hash, "encryption_salt": salt})

        mgr.store_account("test_account", "anthropic", "key", password, "model")
        mgr.assign_role("test_account", "CODING")
        assert mgr.get_role_assignment("CODING") == "test_account"

        mgr.delete_account("test_account")

        assert mgr.get_role_assignment("CODING") is None
        assert "test_account" not in mgr.list_accounts()

    def test_delete_account_nonexistent(self, tmp_path):
        """Deleting a missing account should not error."""
        config_path = tmp_path / "test.conf"
        mgr = ConfigManager(config_path)

        mgr.delete_account("nonexistent")

    def test_save_and_load_preferences(self, tmp_path):
        """Test saving and loading user preferences."""
        config_path = tmp_path / "test.conf"
        mgr = ConfigManager(config_path)

        mgr.save_preferences("/tmp/project-path")
        loaded_prefs = mgr.load_preferences()
        assert loaded_prefs == {"project_path": "/tmp/project-path"}

    def test_load_preferences_nonexistent(self, tmp_path):
        """Test loading preferences when none have been saved."""
        config_path = tmp_path / "test.conf"
        mgr = ConfigManager(config_path)

        preferences = mgr.load_preferences()
        assert preferences is None

    def test_multiple_accounts(self, tmp_path):
        """Test config with multiple accounts."""
        config_path = tmp_path / "test.conf"
        mgr = ConfigManager(config_path)

        import base64
        import bcrypt

        password = "testpassword"
        password_hash = mgr.hash_password(password)
        salt = base64.urlsafe_b64encode(bcrypt.gensalt()).decode()
        salt_bytes = base64.urlsafe_b64decode(salt.encode())

        # Create config with multiple accounts
        account1_key = mgr.encrypt_value("api-key-1", password, salt_bytes)
        account2_key = mgr.encrypt_value("api-key-2", password, salt_bytes)

        mgr.save_config(
            {
                "password_hash": password_hash,
                "encryption_salt": salt,
                "accounts": {
                    "work-anthropic": {"provider": "anthropic", "key": account1_key, "model": "claude"},
                    "personal-openai": {"provider": "openai", "key": account2_key, "model": "gpt-4"},
                },
            }
        )

        accounts = mgr.list_accounts()
        assert "work-anthropic" in accounts
        assert "personal-openai" in accounts
        assert accounts["work-anthropic"] == "anthropic"
        assert accounts["personal-openai"] == "openai"
        assert len(accounts) == 2  # No duplicates

    def test_validate_config_keys_accepts_known_keys(self):
        """validate_config_keys should allow all base keys."""
        config = {key: "value" for key in CONFIG_BASE_KEYS}
        validate_config_keys(config)  # Should not raise

    def test_validate_config_keys_rejects_unknown(self):
        """validate_config_keys should force panel updates for new keys."""
        config = {"password_hash": "hash", "unexpected": True}
        with pytest.raises(ValueError):
            validate_config_keys(config)


class TestVerificationAgent:
    """Tests for verification agent configuration."""

    def test_set_verification_agent_none_marker(self, tmp_path):
        """Setting verification agent to VERIFICATION_NONE stores the marker."""
        config_path = tmp_path / "test.conf"
        mgr = ConfigManager(config_path)

        # Set the verification agent to the VERIFICATION_NONE marker
        mgr.set_verification_agent(mgr.VERIFICATION_NONE)

        # Verify the marker is stored in the config
        config = mgr.load_config()
        assert config.get("verification_agent") == "__verification_none__"

    def test_get_verification_agent_returns_none_marker(self, tmp_path):
        """Getting verification agent returns VERIFICATION_NONE marker when stored."""
        config_path = tmp_path / "test.conf"
        mgr = ConfigManager(config_path)

        # Set the verification agent to the VERIFICATION_NONE marker
        mgr.set_verification_agent(mgr.VERIFICATION_NONE)

        # Verify get returns the marker (not None, not empty string)
        result = mgr.get_verification_agent()
        assert result == mgr.VERIFICATION_NONE
        assert result == "__verification_none__"

    def test_verification_agent_none_marker_persists_across_instances(self, tmp_path):
        """VERIFICATION_NONE marker persists and can be retrieved by a new ConfigManager."""
        config_path = tmp_path / "test.conf"

        # Set verification agent with first instance
        mgr1 = ConfigManager(config_path)
        mgr1.set_verification_agent(mgr1.VERIFICATION_NONE)

        # Create a new instance (simulating restart) and verify
        mgr2 = ConfigManager(config_path)
        result = mgr2.get_verification_agent()

        assert result == mgr2.VERIFICATION_NONE
        assert result == "__verification_none__"

    def test_set_verification_agent_to_none_clears_setting(self, tmp_path):
        """Setting verification agent to None (not the marker) clears the setting."""
        config_path = tmp_path / "test.conf"
        mgr = ConfigManager(config_path)

        # First set to the VERIFICATION_NONE marker
        mgr.set_verification_agent(mgr.VERIFICATION_NONE)
        assert mgr.get_verification_agent() == mgr.VERIFICATION_NONE

        # Then clear by setting to None
        mgr.set_verification_agent(None)

        # Should return None (not the marker)
        result = mgr.get_verification_agent()
        assert result is None

        # Config should not have the key
        config = mgr.load_config()
        assert "verification_agent" not in config


class TestPreferredVerificationModel:
    """Tests for preferred verification model configuration."""

    def test_set_and_get_preferred_verification_model(self, tmp_path):
        """Test setting and getting preferred verification model."""
        config_path = tmp_path / "test.conf"
        mgr = ConfigManager(config_path)

        # Initially should return None
        assert mgr.get_preferred_verification_model() is None

        # Set a model
        mgr.set_preferred_verification_model("claude-3-5-sonnet-20241022")

        # Should return the set value
        result = mgr.get_preferred_verification_model()
        assert result == "claude-3-5-sonnet-20241022"

        # Verify it's stored in config
        config = mgr.load_config()
        assert config.get("preferred_verification_model") == "claude-3-5-sonnet-20241022"

    def test_set_preferred_verification_model_to_none_clears_setting(self, tmp_path):
        """Setting preferred verification model to None clears the setting."""
        config_path = tmp_path / "test.conf"
        mgr = ConfigManager(config_path)

        # First set a model
        mgr.set_preferred_verification_model("gpt-4o")
        assert mgr.get_preferred_verification_model() == "gpt-4o"

        # Clear by setting to None
        mgr.set_preferred_verification_model(None)

        # Should return None
        result = mgr.get_preferred_verification_model()
        assert result is None

        # Config should not have the key
        config = mgr.load_config()
        assert "preferred_verification_model" not in config

    def test_preferred_verification_model_persists_across_instances(self, tmp_path):
        """Preferred verification model persists and can be retrieved by a new ConfigManager."""
        config_path = tmp_path / "test.conf"

        # Set model with first instance
        mgr1 = ConfigManager(config_path)
        mgr1.set_preferred_verification_model("gemini-2.0-flash-exp")

        # Create a new instance (simulating restart) and verify
        mgr2 = ConfigManager(config_path)
        result = mgr2.get_preferred_verification_model()

        assert result == "gemini-2.0-flash-exp"

    def test_preferred_verification_model_independent_of_account_model(self, tmp_path):
        """Preferred verification model is stored separately from account model."""
        import base64
        import bcrypt

        config_path = tmp_path / "test.conf"
        mgr = ConfigManager(config_path)

        # Setup account with password
        password = "testpassword"
        password_hash = mgr.hash_password(password)
        salt = base64.urlsafe_b64encode(bcrypt.gensalt()).decode()
        mgr.save_config({"password_hash": password_hash, "encryption_salt": salt})

        # Store account with a model
        mgr.store_account("test-account", "anthropic", "key", password, "claude-opus-4-20250514")
        mgr.set_verification_agent("test-account")

        # Set a different preferred verification model
        mgr.set_preferred_verification_model("claude-3-5-sonnet-20241022")

        # They should be independent
        assert mgr.get_account_model("test-account") == "claude-opus-4-20250514"
        assert mgr.get_preferred_verification_model() == "claude-3-5-sonnet-20241022"


class TestProjectConfig:
    """Tests for per-project configuration."""

    def test_get_project_config_nonexistent(self, tmp_path):
        """Test getting config for a project that doesn't exist."""
        config_path = tmp_path / "test.conf"
        mgr = ConfigManager(config_path)

        result = mgr.get_project_config("/some/project/path")
        assert result is None

    def test_set_and_get_project_config(self, tmp_path):
        """Test setting and getting project config."""
        config_path = tmp_path / "test.conf"
        mgr = ConfigManager(config_path)

        project_path = tmp_path / "my_project"
        project_path.mkdir()

        project_config = {
            "version": "1.0",
            "project_type": "python",
            "verification": {
                "lint_command": "flake8 .",
                "test_command": "pytest tests/",
                "validated": True,
            },
        }

        mgr.set_project_config(project_path, project_config)

        result = mgr.get_project_config(project_path)
        assert result is not None
        assert result["project_type"] == "python"
        assert result["verification"]["lint_command"] == "flake8 ."

    def test_project_config_normalizes_path(self, tmp_path):
        """Test that project config normalizes paths to absolute."""
        config_path = tmp_path / "test.conf"
        mgr = ConfigManager(config_path)

        project_path = tmp_path / "my_project"
        project_path.mkdir()

        project_config = {"project_type": "javascript"}

        # Save with one form of the path
        mgr.set_project_config(str(project_path), project_config)

        # Retrieve with another form (Path object)
        result = mgr.get_project_config(project_path)
        assert result is not None
        assert result["project_type"] == "javascript"

    def test_delete_project_config(self, tmp_path):
        """Test deleting project config."""
        config_path = tmp_path / "test.conf"
        mgr = ConfigManager(config_path)

        project_path = tmp_path / "my_project"
        project_path.mkdir()

        mgr.set_project_config(project_path, {"project_type": "rust"})
        assert mgr.get_project_config(project_path) is not None

        mgr.delete_project_config(project_path)
        assert mgr.get_project_config(project_path) is None

    def test_delete_project_config_nonexistent(self, tmp_path):
        """Test deleting nonexistent project config is a no-op."""
        config_path = tmp_path / "test.conf"
        mgr = ConfigManager(config_path)

        # Should not raise
        mgr.delete_project_config("/nonexistent/path")

    def test_list_project_configs(self, tmp_path):
        """Test listing all project configs."""
        config_path = tmp_path / "test.conf"
        mgr = ConfigManager(config_path)

        project1 = tmp_path / "project1"
        project1.mkdir()
        project2 = tmp_path / "project2"
        project2.mkdir()

        mgr.set_project_config(project1, {"project_type": "python"})
        mgr.set_project_config(project2, {"project_type": "rust"})

        configs = mgr.list_project_configs()
        assert len(configs) == 2
        assert configs[str(project1.resolve())]["project_type"] == "python"
        assert configs[str(project2.resolve())]["project_type"] == "rust"

    def test_list_project_configs_empty(self, tmp_path):
        """Test listing project configs when none exist."""
        config_path = tmp_path / "test.conf"
        mgr = ConfigManager(config_path)

        configs = mgr.list_project_configs()
        assert configs == {}

    def test_project_config_persists_across_instances(self, tmp_path):
        """Test that project config persists across ConfigManager instances."""
        config_path = tmp_path / "test.conf"
        project_path = tmp_path / "my_project"
        project_path.mkdir()

        # Set with first instance
        mgr1 = ConfigManager(config_path)
        mgr1.set_project_config(project_path, {"project_type": "go"})

        # Get with second instance
        mgr2 = ConfigManager(config_path)
        result = mgr2.get_project_config(project_path)

        assert result is not None
        assert result["project_type"] == "go"


class TestActionSettings:
    """Test cases for unified action settings configuration."""

    def _make_mgr(self, tmp_path, accounts=None):
        config_path = tmp_path / "test.conf"
        mgr = ConfigManager(config_path)
        accts = accounts or {
            "work-claude": {"provider": "anthropic", "key": "xxx"},
            "backup-gpt": {"provider": "openai", "key": "xxx"},
        }
        mgr.save_config({
            "password_hash": mgr.hash_password("test"),
            "encryption_salt": "dGVzdHNhbHQ=",
            "accounts": accts,
        })
        return mgr

    def test_get_default_action_settings(self, tmp_path):
        """Default settings are 3 notify at 90%."""
        mgr = self._make_mgr(tmp_path)
        settings = mgr.get_action_settings()
        assert len(settings) == 3
        for s in settings:
            assert s["threshold"] == 90
            assert s["action"] == "notify"
        events = {s["event"] for s in settings}
        assert events == {"session_usage", "weekly_usage", "context_usage"}

    def test_migration_from_old_config(self, tmp_path):
        """Legacy usage_switch_threshold is migrated at startup."""
        config_path = tmp_path / "test.conf"
        # Write legacy config directly (before ConfigManager init triggers migration)
        import json
        config_path.write_text(json.dumps({
            "accounts": {"a": {"provider": "anthropic", "key": "x"}},
            "usage_switch_threshold": 75,
        }))
        # Creating ConfigManager triggers migration
        mgr = ConfigManager(config_path)
        settings = mgr.get_action_settings()
        assert all(s["threshold"] == 75 for s in settings)
        # Legacy keys are removed
        config = mgr.load_config()
        assert "usage_switch_threshold" not in config
        assert "action_settings" in config

    def test_set_and_get_action_settings(self, tmp_path):
        """Round-trip with all action types."""
        mgr = self._make_mgr(tmp_path)
        settings = [
            {"event": "session_usage", "threshold": 85, "action": "switch_provider", "target_account": "backup-gpt"},
            {"event": "weekly_usage", "threshold": 95, "action": "await_reset"},
            {"event": "context_usage", "threshold": 90, "action": "notify"},
        ]
        mgr.set_action_settings(settings)
        result = mgr.get_action_settings()
        assert result == settings

    def test_await_reset_invalid_for_context(self, tmp_path):
        """await_reset is rejected for context_usage."""
        mgr = self._make_mgr(tmp_path)
        with pytest.raises(ValueError, match="await_reset.*not valid.*context"):
            mgr.set_action_settings([
                {"event": "context_usage", "threshold": 90, "action": "await_reset"},
            ])

    def test_switch_provider_requires_valid_account(self, tmp_path):
        """switch_provider with missing/invalid account is rejected."""
        mgr = self._make_mgr(tmp_path)
        with pytest.raises(ValueError, match="valid target_account"):
            mgr.set_action_settings([
                {"event": "session_usage", "threshold": 90, "action": "switch_provider", "target_account": "nonexistent"},
            ])
        with pytest.raises(ValueError, match="valid target_account"):
            mgr.set_action_settings([
                {"event": "session_usage", "threshold": 90, "action": "switch_provider"},
            ])

    def test_startup_migration_cleans_up_old_keys(self, tmp_path):
        """Legacy keys are removed on ConfigManager startup."""
        config_path = tmp_path / "test.conf"
        import json
        config_path.write_text(json.dumps({
            "accounts": {"a": {"provider": "anthropic", "key": "x"}},
            "provider_fallback_order": ["a"],
            "usage_switch_threshold": 80,
        }))
        mgr = ConfigManager(config_path)
        config = mgr.load_config()
        assert "provider_fallback_order" not in config
        assert "usage_switch_threshold" not in config
        assert "action_settings" in config

    def test_get_action_for_event(self, tmp_path):
        """Convenience method returns correct entry."""
        mgr = self._make_mgr(tmp_path)
        settings = [
            {"event": "session_usage", "threshold": 80, "action": "notify"},
            {"event": "weekly_usage", "threshold": 95, "action": "await_reset"},
        ]
        mgr.set_action_settings(settings)
        assert mgr.get_action_for_event("weekly_usage")["action"] == "await_reset"
        assert mgr.get_action_for_event("context_usage") is None

    def test_settings_persist_across_instances(self, tmp_path):
        """Settings survive ConfigManager reinstantiation."""
        config_path = tmp_path / "test.conf"
        mgr1 = ConfigManager(config_path)
        mgr1.save_config({
            "password_hash": mgr1.hash_password("test"),
            "encryption_salt": "dGVzdHNhbHQ=",
            "accounts": {"a": {"provider": "anthropic", "key": "x"}},
        })
        mgr1.set_action_settings([
            {"event": "session_usage", "threshold": 70, "action": "notify"},
        ])
        mgr2 = ConfigManager(config_path)
        result = mgr2.get_action_settings()
        assert len(result) == 1
        assert result[0]["threshold"] == 70

    def test_duplicate_event_types_allowed(self, tmp_path):
        """Multiple rules for the same event type are allowed."""
        mgr = self._make_mgr(tmp_path)
        settings = [
            {"event": "session_usage", "threshold": 80, "action": "notify"},
            {"event": "session_usage", "threshold": 95, "action": "await_reset"},
        ]
        mgr.set_action_settings(settings)
        assert mgr.get_action_settings() == settings


class TestMockRemainingUsage:
    """Test cases for mock remaining usage (for testing usage-based switching)."""

    def test_get_mock_usage_default(self, tmp_path):
        """Test that default mock usage is 0.5 (50%)."""
        config_path = tmp_path / "test.conf"
        mgr = ConfigManager(config_path)

        assert mgr.get_mock_remaining_usage("any-mock") == 0.5

    def test_set_and_get_mock_usage(self, tmp_path):
        """Test setting and getting mock remaining usage."""
        config_path = tmp_path / "test.conf"
        mgr = ConfigManager(config_path)

        mgr.set_mock_remaining_usage("mock-1", 0.3)
        assert mgr.get_mock_remaining_usage("mock-1") == 0.3

        mgr.set_mock_remaining_usage("mock-2", 0.8)
        assert mgr.get_mock_remaining_usage("mock-2") == 0.8

        # mock-1 should still be 0.3
        assert mgr.get_mock_remaining_usage("mock-1") == 0.3

    def test_mock_usage_validates_range(self, tmp_path):
        """Test that mock usage must be between 0.0 and 1.0."""
        config_path = tmp_path / "test.conf"
        mgr = ConfigManager(config_path)

        with pytest.raises(ValueError, match="must be between 0.0 and 1.0"):
            mgr.set_mock_remaining_usage("mock-1", -0.1)

        with pytest.raises(ValueError, match="must be between 0.0 and 1.0"):
            mgr.set_mock_remaining_usage("mock-1", 1.1)

    def test_mock_usage_persists(self, tmp_path):
        """Test that mock usage persists across instances."""
        config_path = tmp_path / "test.conf"

        mgr1 = ConfigManager(config_path)
        mgr1.set_mock_remaining_usage("test-mock", 0.25)

        mgr2 = ConfigManager(config_path)
        assert mgr2.get_mock_remaining_usage("test-mock") == 0.25


class TestMockRunDuration:
    """Tests for mock run duration configuration."""

    def test_get_mock_run_duration_default(self, tmp_path):
        """Default mock run duration should be 0 seconds."""
        mgr = ConfigManager(tmp_path / "test.conf")
        assert mgr.get_mock_run_duration_seconds("any-mock") == 0

    def test_set_and_get_mock_run_duration(self, tmp_path):
        """Can set and retrieve mock run duration per account."""
        mgr = ConfigManager(tmp_path / "test.conf")

        mgr.set_mock_run_duration_seconds("mock-1", 60)
        assert mgr.get_mock_run_duration_seconds("mock-1") == 60

        mgr.set_mock_run_duration_seconds("mock-2", 15)
        assert mgr.get_mock_run_duration_seconds("mock-2") == 15

        # Ensure account isolation
        assert mgr.get_mock_run_duration_seconds("mock-1") == 60

    def test_set_mock_run_duration_validates_range(self, tmp_path):
        """Mock run duration must be between 0 and 3600 seconds."""
        mgr = ConfigManager(tmp_path / "test.conf")

        with pytest.raises(ValueError, match="must be between 0 and 3600"):
            mgr.set_mock_run_duration_seconds("mock-1", -1)

        with pytest.raises(ValueError, match="must be between 0 and 3600"):
            mgr.set_mock_run_duration_seconds("mock-1", 3601)

    def test_mock_run_duration_persists(self, tmp_path):
        """Mock run duration persists across instances."""
        config_path = tmp_path / "test.conf"

        mgr1 = ConfigManager(config_path)
        mgr1.set_mock_run_duration_seconds("test-mock", 75)

        mgr2 = ConfigManager(config_path)
        assert mgr2.get_mock_run_duration_seconds("test-mock") == 75


class TestVerificationSettings:
    """Test cases for verification settings persistence."""

    def _make_mgr(self, tmp_path):
        config_path = tmp_path / "test.conf"
        return ConfigManager(config_path)

    def test_defaults_are_enabled_and_auto_run(self, tmp_path):
        """Fresh config returns enabled=True, auto_run=True."""
        mgr = self._make_mgr(tmp_path)
        enabled, auto_run = mgr.get_runtime_verification_settings()
        assert enabled is True
        assert auto_run is True

    def test_set_enabled_false_persists(self, tmp_path):
        """Setting enabled=False is persisted to disk."""
        config_path = tmp_path / "test.conf"
        mgr1 = ConfigManager(config_path)
        mgr1.set_runtime_verification_settings(enabled=False)

        mgr2 = ConfigManager(config_path)
        enabled, auto_run = mgr2.get_runtime_verification_settings()
        assert enabled is False
        assert auto_run is True  # unchanged

    def test_set_auto_run_false_persists(self, tmp_path):
        """Setting auto_run=False is persisted to disk."""
        config_path = tmp_path / "test.conf"
        mgr1 = ConfigManager(config_path)
        mgr1.set_runtime_verification_settings(auto_run=False)

        mgr2 = ConfigManager(config_path)
        enabled, auto_run = mgr2.get_runtime_verification_settings()
        assert enabled is True  # unchanged
        assert auto_run is False

    def test_partial_update_preserves_other_field(self, tmp_path):
        """Updating one field does not reset the other."""
        mgr = self._make_mgr(tmp_path)
        mgr.set_runtime_verification_settings(enabled=False, auto_run=False)
        mgr.set_runtime_verification_settings(enabled=True)
        enabled, auto_run = mgr.get_runtime_verification_settings()
        assert enabled is True
        assert auto_run is False

    def test_settings_survive_restart(self, tmp_path):
        """Both fields survive a full ConfigManager reinstantiation."""
        config_path = tmp_path / "test.conf"
        ConfigManager(config_path).set_runtime_verification_settings(enabled=False, auto_run=False)
        enabled, auto_run = ConfigManager(config_path).get_runtime_verification_settings()
        assert enabled is False
        assert auto_run is False


class TestConfigUIParity:
    """Test that the CLI UI exposes all required config options.

    These tests enforce that any user-editable config key is exposed in BOTH UIs.
    If a new config option is added to CONFIG_BASE_KEYS that should be user-editable,
    it must be added to REQUIRED_UI_CONFIG_KEYS and exposed in both UIs.

    IMPORTANT: When adding a new config option:
    1. Add getter/setter to ConfigManager
    2. Add API endpoint in routes/config.py
    3. Add APIClient method in api_client.py
    4. Add UI element in CLI
    5. Add menu option in cli/app.py (CLI)
    6. Add the key to REQUIRED_UI_CONFIG_KEYS below (or INTERNAL_KEYS if not user-editable)

    The test_all_config_keys_categorized test will FAIL if you add a new key
    to CONFIG_BASE_KEYS without categorizing it here. This is intentional -
    it forces you to decide whether the key needs UI exposure.
    """

    # Internal keys that are NOT user-editable via UI
    # These are system-managed or accessed via other UI paths (like account management)
    INTERNAL_KEYS = {
        "password_hash",      # Security - never exposed
        "encryption_salt",    # Security - never exposed
        "accounts",           # Managed via provider cards, not direct config
        "role_assignments",   # Managed via account role dropdowns
        "preferences",        # Container object, individual prefs exposed separately
        "projects",           # Per-project settings, not global config
        "mock_remaining_usage",    # Testing only - per-account mock via provider cards
        "mock_run_duration_seconds",  # Testing only - per-account mock via provider cards
        "mock_session_reset_time",  # Testing only - per-account mock reset time
        "verification_enabled",   # Managed via API/React UI (not CLI config panel)
        "verification_auto_run",  # Managed via API/React UI (not CLI config panel)
    }

    # Config keys that MUST be editable in the CLI UI
    REQUIRED_UI_CONFIG_KEYS = {
        "verification_agent",
        "preferred_verification_model",
        "cleanup_days",
        "action_settings",
        "max_verification_attempts",
        "slack_enabled",
        "slack_bot_token",
        "slack_channel",
    }

    # Keys that are only in web UI (makes sense for web-only settings)
    WEB_ONLY_KEYS = {"ui_mode"}

    def test_required_keys_subset_of_config_base_keys(self):
        """Ensure all required UI keys are valid CONFIG_BASE_KEYS."""
        all_required = self.REQUIRED_UI_CONFIG_KEYS | self.WEB_ONLY_KEYS
        invalid_keys = all_required - CONFIG_BASE_KEYS
        assert not invalid_keys, (
            f"Keys in REQUIRED_UI_CONFIG_KEYS not in CONFIG_BASE_KEYS: {invalid_keys}. "
            f"Either add them to CONFIG_BASE_KEYS or remove from REQUIRED_UI_CONFIG_KEYS."
        )

    def test_all_config_keys_categorized(self):
        """Ensure every CONFIG_BASE_KEY is categorized as internal, required, or web-only.

        This test FAILS if you add a new key to CONFIG_BASE_KEYS without deciding
        whether it needs UI exposure. This forces explicit categorization of all config keys.

        If you add a new config key:
        - Add to INTERNAL_KEYS if it's system-managed (not user-editable)
        - Add to REQUIRED_UI_CONFIG_KEYS if users should edit it in both UIs
        - Add to WEB_ONLY_KEYS if it only makes sense in the web UI
        """
        all_categorized = self.INTERNAL_KEYS | self.REQUIRED_UI_CONFIG_KEYS | self.WEB_ONLY_KEYS
        uncategorized = CONFIG_BASE_KEYS - all_categorized
        assert not uncategorized, (
            f"CONFIG_BASE_KEYS contains uncategorized keys: {uncategorized}. "
            f"Add each key to one of: INTERNAL_KEYS (system-managed), "
            f"REQUIRED_UI_CONFIG_KEYS (both UIs), or WEB_ONLY_KEYS (web UI only). "
            f"See TestConfigUIParity docstring for details."
        )

    # Mapping from config keys to patterns that indicate the key is exposed in UI
    # Some keys use different naming conventions in the UI code
    KEY_PATTERNS = {
        "verification_agent": ["verification_agent", "verification_pref"],
        "preferred_verification_model": ["verification_model", "preferred_verification_model"],
        "cleanup_days": ["cleanup_days", "retention_days", "cleanup_settings", "retention_input"],
        "action_settings": ["action_settings", "action_setting", "action_rule"],
        "max_verification_attempts": ["max_verification_attempts", "verification_attempts"],
        "ui_mode": ["ui_mode"],
        "slack_enabled": ["slack_enabled", "slack_enable", "slack_settings", "slack integration"],
        "slack_bot_token": ["slack_bot_token", "slack_token", "bot_token", "slack_settings"],
        "slack_channel": ["slack_channel", "slack_settings", "channel id"],
    }

    def test_cli_ui_exposes_all_required_keys(self):
        """Verify CLI app.py references all required config keys."""
        import pathlib
        import re

        cli_app_path = pathlib.Path(__file__).parent.parent / "src" / "chad" / "ui" / "cli" / "app.py"
        content = cli_app_path.read_text()

        missing_keys = []

        for key in self.REQUIRED_UI_CONFIG_KEYS:
            # Get patterns for this key, or use the key itself as fallback
            patterns_to_check = self.KEY_PATTERNS.get(key, [key])

            # Check for any of the patterns in various forms
            found = False
            for pattern in patterns_to_check:
                search_patterns = [
                    rf'"{pattern}"',
                    rf"'{pattern}'",
                    rf"get_{pattern}",
                    rf"set_{pattern}",
                    pattern,  # Direct reference
                ]
                if any(re.search(p, content, re.IGNORECASE) for p in search_patterns):
                    found = True
                    break

            if not found:
                missing_keys.append(key)

        assert not missing_keys, (
            f"CLI app.py is missing menu options for config keys: {missing_keys}. "
            f"Add settings menu options for these keys in run_settings_menu()."
        )
