"""
Module Whitelist Tests

Tests for the dynamic import security module that prevents arbitrary code execution
through user-controlled configuration values.

Security model:
- Only allows relative imports (starting with ".")
- Validates module paths against a strict whitelist of trusted search engine modules
- Validates class names against a strict whitelist of legitimate search engine classes
- Blocks dangerous modules like os, subprocess, sys, etc.
- Prevents path traversal attacks in module paths
"""

import pytest
from unittest.mock import MagicMock, patch

from local_deep_research.security.module_whitelist import (
    ALLOWED_CLASS_NAMES,
    ALLOWED_MODULE_PATHS,
    ModuleNotAllowedError,
    SecurityError,
    get_safe_module_class,
    validate_module_import,
)


class TestAllowedModulePaths:
    """Test the ALLOWED_MODULE_PATHS constant."""

    def test_constants_are_relative_paths(self):
        """All allowed module paths should start with '.' (relative imports)."""
        for module_path in ALLOWED_MODULE_PATHS:
            assert module_path.startswith("."), (
                f"Module path '{module_path}' should be a relative import "
                f"(starting with '.'). Absolute imports are not allowed for security."
            )

    def test_whitelist_is_frozen_set(self):
        """ALLOWED_MODULE_PATHS should be a frozenset (immutable)."""
        assert isinstance(ALLOWED_MODULE_PATHS, frozenset)

    def test_whitelist_contains_expected_engines(self):
        """Whitelist should contain expected search engine modules."""
        expected_engines = [
            ".engines.search_engine_brave",
            ".engines.search_engine_ddg",
            ".engines.search_engine_tavily",
            ".engines.search_engine_wikipedia",
        ]
        for engine in expected_engines:
            assert engine in ALLOWED_MODULE_PATHS, (
                f"Expected engine '{engine}' not found in whitelist"
            )


class TestAllowedClassNames:
    """Test the ALLOWED_CLASS_NAMES constant."""

    def test_class_names_whitelist_is_frozen_set(self):
        """ALLOWED_CLASS_NAMES should be a frozenset (immutable)."""
        assert isinstance(ALLOWED_CLASS_NAMES, frozenset)

    def test_whitelist_contains_expected_classes(self):
        """Whitelist should contain expected search engine classes."""
        expected_classes = [
            "BraveSearchEngine",
            "DuckDuckGoSearchEngine",
            "TavilySearchEngine",
            "WikipediaSearchEngine",
            "BaseSearchEngine",
        ]
        for cls_name in expected_classes:
            assert cls_name in ALLOWED_CLASS_NAMES, (
                f"Expected class '{cls_name}' not found in whitelist"
            )


class TestLegacyAliases:
    """Test backward compatibility aliases."""

    def test_module_not_allowed_error_is_security_error(self):
        """ModuleNotAllowedError should be an alias for SecurityError."""
        assert ModuleNotAllowedError is SecurityError


class TestValidateModuleImport:
    """Test the validate_module_import function."""

    def test_valid_module_and_class(self):
        """Should return True for valid whitelisted module and class."""
        result = validate_module_import(
            ".engines.search_engine_brave", "BraveSearchEngine"
        )
        assert result is True

    def test_valid_base_search_engine(self):
        """Should return True for the base search engine module."""
        result = validate_module_import(
            ".search_engine_base", "BaseSearchEngine"
        )
        assert result is True

    def test_rejects_absolute_path(self):
        """Should reject absolute module paths."""
        # Absolute path attempt
        result = validate_module_import(
            "local_deep_research.web_search_engines.engines.search_engine_brave",
            "BraveSearchEngine",
        )
        assert result is False

    def test_rejects_subprocess_module(self):
        """Should reject dangerous subprocess module."""
        result = validate_module_import("subprocess", "Popen")
        assert result is False

    def test_rejects_os_module(self):
        """Should reject dangerous os module."""
        result = validate_module_import("os", "system")
        assert result is False

    def test_rejects_sys_module(self):
        """Should reject dangerous sys module."""
        result = validate_module_import("sys", "exit")
        assert result is False

    def test_case_sensitivity(self):
        """Module and class names should be case-sensitive."""
        # Lowercase - should fail
        result = validate_module_import(
            ".engines.search_engine_brave", "bravesearchengine"
        )
        assert result is False

        # Uppercase - should fail
        result = validate_module_import(
            ".ENGINES.SEARCH_ENGINE_BRAVE", "BraveSearchEngine"
        )
        assert result is False

    def test_empty_module_path(self):
        """Should reject empty module path."""
        result = validate_module_import("", "BraveSearchEngine")
        assert result is False

    def test_empty_class_name(self):
        """Should reject empty class name."""
        result = validate_module_import(".engines.search_engine_brave", "")
        assert result is False

    def test_none_module_path(self):
        """Should reject None module path."""
        result = validate_module_import(None, "BraveSearchEngine")
        assert result is False

    def test_none_class_name(self):
        """Should reject None class name."""
        result = validate_module_import(".engines.search_engine_brave", None)
        assert result is False

    def test_valid_module_invalid_class(self):
        """Should reject valid module with invalid class name."""
        result = validate_module_import(
            ".engines.search_engine_brave", "MaliciousClass"
        )
        assert result is False

    def test_invalid_module_valid_class(self):
        """Should reject invalid module with valid class name."""
        result = validate_module_import(
            ".engines.nonexistent_engine", "BraveSearchEngine"
        )
        assert result is False


class TestGetSafeModuleClass:
    """Test the get_safe_module_class function."""

    def test_loads_valid_search_engine(self):
        """Should successfully load a valid whitelisted search engine class."""
        # Use a module that exists and should be loadable
        cls = get_safe_module_class(".search_engine_base", "BaseSearchEngine")
        assert cls is not None
        assert cls.__name__ == "BaseSearchEngine"

    def test_raises_security_error_for_invalid_module(self):
        """Should raise SecurityError for non-whitelisted module."""
        with pytest.raises(SecurityError) as exc_info:
            get_safe_module_class("os", "system")

        assert "not in the security whitelist" in str(exc_info.value)

    def test_rejects_absolute_import_from_own_package(self):
        """Should reject absolute paths — normalization was removed."""
        with pytest.raises(SecurityError):
            get_safe_module_class(
                "local_deep_research.web_search_engines.search_engine_base",
                "BaseSearchEngine",
            )

    def test_raises_security_error_for_foreign_absolute_import(self):
        """Should raise SecurityError for absolute import paths outside our package."""
        with pytest.raises(SecurityError):
            get_safe_module_class(
                "local_deep_research.security.module_whitelist",
                "SecurityError",
            )

    def test_handles_module_not_found(self):
        """Should raise ModuleNotFoundError if whitelisted module doesn't exist."""
        # First, temporarily add a fake module to the whitelist for testing
        # We can't actually test this without modifying the whitelist, so we mock
        with patch(
            "local_deep_research.security.module_whitelist.validate_module_import",
            return_value=True,
        ):
            with patch(
                "local_deep_research.security.module_whitelist.importlib.import_module",
                side_effect=ModuleNotFoundError("No module named 'fake'"),
            ):
                with pytest.raises(ModuleNotFoundError):
                    get_safe_module_class(".engines.fake_module", "FakeEngine")

    def test_handles_attribute_not_found(self):
        """Should raise AttributeError if class doesn't exist in module."""
        with patch(
            "local_deep_research.security.module_whitelist.validate_module_import",
            return_value=True,
        ):
            mock_module = MagicMock(spec=[])  # Module without the class
            with patch(
                "local_deep_research.security.module_whitelist.importlib.import_module",
                return_value=mock_module,
            ):
                with pytest.raises(AttributeError):
                    get_safe_module_class(
                        ".engines.search_engine_brave",
                        "NonExistentClass",
                    )

    def test_uses_default_package_for_relative_imports(self):
        """Should use default package when none provided for relative imports."""
        with patch(
            "local_deep_research.security.module_whitelist.validate_module_import",
            return_value=True,
        ):
            with patch(
                "local_deep_research.security.module_whitelist.importlib.import_module"
            ) as mock_import:
                mock_module = MagicMock()
                mock_module.FakeEngine = type("FakeEngine", (), {})
                mock_import.return_value = mock_module

                get_safe_module_class(".engines.fake", "FakeEngine")

                # Check that default package was used
                mock_import.assert_called_once_with(
                    ".engines.fake",
                    package="local_deep_research.web_search_engines",
                )

    def test_uses_custom_package_when_provided(self):
        """Should use custom package when explicitly provided."""
        with patch(
            "local_deep_research.security.module_whitelist.validate_module_import",
            return_value=True,
        ):
            with patch(
                "local_deep_research.security.module_whitelist.importlib.import_module"
            ) as mock_import:
                mock_module = MagicMock()
                mock_module.FakeEngine = type("FakeEngine", (), {})
                mock_import.return_value = mock_module

                get_safe_module_class(
                    ".engines.fake",
                    "FakeEngine",
                    package="custom.package",
                )

                mock_import.assert_called_once_with(
                    ".engines.fake",
                    package="custom.package",
                )

    def test_rejects_collection_engine_absolute_path(self):
        """Absolute paths are rejected — normalization was removed."""
        with pytest.raises(SecurityError):
            get_safe_module_class(
                "local_deep_research.web_search_engines.engines.search_engine_collection",
                "CollectionSearchEngine",
            )

    def test_rejects_exact_prefix_as_absolute(self):
        """Exact prefix without module suffix is rejected as absolute path."""
        with pytest.raises(SecurityError):
            get_safe_module_class(
                "local_deep_research.web_search_engines",
                "BaseSearchEngine",
            )

    def test_prefix_boundary_rejects_similar_prefix(self):
        """Prefix that extends the package name without a dot should NOT be normalized.

        'local_deep_research.web_search_enginesXXX.evil' should not match
        the normalization prefix 'local_deep_research.web_search_engines.'.
        """
        with pytest.raises(SecurityError):
            get_safe_module_class(
                "local_deep_research.web_search_enginesXXX.evil",
                "BaseSearchEngine",
            )

    def test_rejects_absolute_path_from_own_package_without_normalization(self):
        """Absolute paths are rejected — registry injection means normalization is unnecessary."""
        with pytest.raises(SecurityError):
            get_safe_module_class(
                "local_deep_research.web_search_engines.engines.search_engine_searxng",
                "SearXNGSearchEngine",
            )


class TestSecurityScenarios:
    """Test specific security attack scenarios."""

    def test_blocks_os_system_import(self):
        """Should block attempts to import os.system for command execution."""
        with pytest.raises(SecurityError):
            get_safe_module_class("os", "system")

    def test_blocks_subprocess_import(self):
        """Should block attempts to import subprocess for command execution."""
        with pytest.raises(SecurityError):
            get_safe_module_class("subprocess", "Popen")

        with pytest.raises(SecurityError):
            get_safe_module_class("subprocess", "call")

        with pytest.raises(SecurityError):
            get_safe_module_class("subprocess", "run")

    def test_path_traversal_in_module_path(self):
        """Should block path traversal attempts in module paths."""
        # Attempt to break out of expected package
        malicious_paths = [
            "..os",
            "....subprocess",
            ".engines/../../../os",
            ".engines/..\\..\\subprocess",  # Windows-style
            ".engines%2F..%2F..%2Fos",  # URL-encoded
        ]

        for path in malicious_paths:
            result = validate_module_import(path, "system")
            assert result is False, (
                f"Path traversal attempt '{path}' should be blocked"
            )

    def test_blocks_builtins_module(self):
        """Should block attempts to import builtins."""
        with pytest.raises(SecurityError):
            get_safe_module_class("builtins", "eval")

        with pytest.raises(SecurityError):
            get_safe_module_class("builtins", "__import__")

    def test_blocks_importlib_module(self):
        """Should block attempts to import importlib directly."""
        with pytest.raises(SecurityError):
            get_safe_module_class("importlib", "import_module")

    def test_blocks_code_module(self):
        """Should block attempts to import code execution modules."""
        with pytest.raises(SecurityError):
            get_safe_module_class("code", "interact")

    def test_blocks_pickle_module(self):
        """Should block attempts to import pickle (deserialization attacks)."""
        with pytest.raises(SecurityError):
            get_safe_module_class("pickle", "loads")

    def test_blocks_ctypes_module(self):
        """Should block attempts to import ctypes (memory manipulation)."""
        with pytest.raises(SecurityError):
            get_safe_module_class("ctypes", "CDLL")

    def test_blocks_socket_module(self):
        """Should block attempts to import socket (network access)."""
        with pytest.raises(SecurityError):
            get_safe_module_class("socket", "socket")

    def test_sql_injection_style_attack(self):
        """Should block SQL-injection-style attacks in module names."""
        malicious_inputs = [
            (".engines; import os;", "system"),
            ('.engines"; import os; "', "system"),
            (".engines' OR '1'='1", "BraveSearchEngine"),
        ]

        for module_path, class_name in malicious_inputs:
            result = validate_module_import(module_path, class_name)
            assert result is False, (
                f"Injection attempt '{module_path}' should be blocked"
            )

    def test_unicode_bypass_attempt(self):
        """Should handle unicode normalization bypass attempts."""
        # These are unlikely to work but good to test
        unicode_attempts = [
            "\u006fs",  # 'o' followed by 's' -> 'os'
            "o\u0073",  # 'o' followed by 's' -> 'os'
        ]

        for module_path in unicode_attempts:
            # Even if they normalize to 'os', should still be blocked
            result = validate_module_import(module_path, "system")
            assert result is False


class TestSecurityErrorException:
    """Test the SecurityError exception class."""

    def test_security_error_is_exception(self):
        """SecurityError should be an Exception."""
        assert issubclass(SecurityError, Exception)

    def test_security_error_message(self):
        """SecurityError should contain helpful error message."""
        error = SecurityError("Test error message")
        assert "Test error message" in str(error)

    def test_security_error_can_be_raised_and_caught(self):
        """SecurityError should be raisable and catchable."""
        with pytest.raises(SecurityError) as exc_info:
            raise SecurityError("blocked import")

        assert "blocked import" in str(exc_info.value)


class TestDocumentation:
    """Documentation tests explaining the security model."""

    @pytest.mark.skip(reason="documentation/placeholder test - not implemented")
    def test_security_model_documentation(self):
        """
        Document the module whitelist security model.

        WHY THIS EXISTS:
        - Dynamic imports from user configuration can lead to arbitrary code execution
        - Attackers could inject malicious module paths to run arbitrary code
        - Example: config.module = "os"; config.class = "system" -> command execution

        THE WHITELIST APPROACH:
        - Only explicitly approved modules can be loaded
        - Only explicitly approved class names can be instantiated
        - All imports must be relative (starting with '.') for additional safety
        - Relative imports are anchored to 'local_deep_research.web_search_engines'

        DEFENSE IN DEPTH:
        - Whitelist validation before any import attempt
        - Relative import requirement prevents direct access to dangerous modules
        - Class name whitelist prevents instantiation of dangerous classes
        - Logging of blocked attempts for security auditing

        SECURITY MODEL:
        | Input Type              | Action     | Reason                              |
        |-------------------------|------------|-------------------------------------|
        | Relative + listed       | ALLOWED    | Trusted search engine module        |
        | Absolute (any)          | BLOCKED    | Could access dangerous modules      |
        | Unlisted module         | BLOCKED    | Not in trusted whitelist            |
        | Unlisted class          | BLOCKED    | Could be dangerous class            |
        | Empty/None              | BLOCKED    | Invalid input                       |
        """
        # audit: PUNCHLIST reviewed 2026-05 — issue resolved by prior PR (recommendation: DELETE).
        assert True  # Documentation test
