"""
Extended tests for the module whitelist security system.

Tests the whitelist constants, validation functions, error hierarchy,
and security-critical edge cases in local_deep_research.security.module_whitelist.
"""

import pytest
from unittest.mock import MagicMock, patch

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


# ---------------------------------------------------------------------------
# ALLOWED_MODULE_PATHS
# ---------------------------------------------------------------------------


class TestAllowedModulePathsExtended:
    """Extended tests for the ALLOWED_MODULE_PATHS constant."""

    def test_is_frozenset(self):
        """ALLOWED_MODULE_PATHS must be a frozenset (immutable)."""
        assert isinstance(ALLOWED_MODULE_PATHS, frozenset)

    def test_contains_brave_engine(self):
        """Whitelist contains the Brave search engine module."""
        assert ".engines.search_engine_brave" in ALLOWED_MODULE_PATHS

    def test_contains_arxiv_engine(self):
        """Whitelist contains the ArXiv search engine module."""
        assert ".engines.search_engine_arxiv" in ALLOWED_MODULE_PATHS

    def test_all_entries_start_with_dot(self):
        """Every entry must start with '.' enforcing relative-import-only policy."""
        for path in ALLOWED_MODULE_PATHS:
            assert path.startswith("."), (
                f"Path {path!r} violates the relative-import-only policy"
            )

    def test_does_not_contain_os(self):
        """Whitelist must never contain the 'os' module."""
        assert "os" not in ALLOWED_MODULE_PATHS

    def test_does_not_contain_subprocess(self):
        """Whitelist must never contain the 'subprocess' module."""
        assert "subprocess" not in ALLOWED_MODULE_PATHS

    def test_does_not_contain_sys(self):
        """Whitelist must never contain the 'sys' module."""
        assert "sys" not in ALLOWED_MODULE_PATHS

    def test_no_dangerous_stdlib_paths(self):
        """Whitelist must not contain any well-known dangerous stdlib modules."""
        dangerous = {
            "os",
            "sys",
            "subprocess",
            "shutil",
            "ctypes",
            "socket",
            "pickle",
            "shelve",
            "code",
            "codeop",
            "compileall",
            "importlib",
            "builtins",
            "io",
            "signal",
        }
        for module in dangerous:
            assert module not in ALLOWED_MODULE_PATHS, (
                f"Dangerous module {module!r} must not appear in whitelist"
            )

    def test_immutability(self):
        """frozenset must refuse mutation attempts."""
        with pytest.raises(AttributeError):
            ALLOWED_MODULE_PATHS.add("os")  # type: ignore[attr-defined]

        with pytest.raises(AttributeError):
            ALLOWED_MODULE_PATHS.discard(  # type: ignore[attr-defined]
                ".engines.search_engine_brave"
            )


# ---------------------------------------------------------------------------
# ALLOWED_CLASS_NAMES
# ---------------------------------------------------------------------------


class TestAllowedClassNamesExtended:
    """Extended tests for the ALLOWED_CLASS_NAMES constant."""

    def test_is_frozenset(self):
        """ALLOWED_CLASS_NAMES must be a frozenset (immutable)."""
        assert isinstance(ALLOWED_CLASS_NAMES, frozenset)

    def test_contains_brave_search_engine(self):
        """Whitelist contains the BraveSearchEngine class."""
        assert "BraveSearchEngine" in ALLOWED_CLASS_NAMES

    def test_contains_arxiv_search_engine(self):
        """Whitelist contains the ArXivSearchEngine class."""
        assert "ArXivSearchEngine" in ALLOWED_CLASS_NAMES

    def test_does_not_contain_system(self):
        """Whitelist must not contain 'system' (os.system target)."""
        assert "system" not in ALLOWED_CLASS_NAMES

    def test_does_not_contain_exec(self):
        """Whitelist must not contain 'exec' (code execution target)."""
        assert "exec" not in ALLOWED_CLASS_NAMES

    def test_does_not_contain_eval(self):
        """Whitelist must not contain 'eval' (code execution target)."""
        assert "eval" not in ALLOWED_CLASS_NAMES

    def test_no_dangerous_class_names(self):
        """Whitelist must not contain any well-known dangerous class/function names."""
        dangerous = {
            "system",
            "exec",
            "eval",
            "Popen",
            "call",
            "run",
            "loads",
            "load",
            "CDLL",
            "interact",
            "__import__",
            "import_module",
            "compile",
        }
        for name in dangerous:
            assert name not in ALLOWED_CLASS_NAMES, (
                f"Dangerous name {name!r} must not appear in class whitelist"
            )

    def test_immutability(self):
        """frozenset must refuse mutation attempts."""
        with pytest.raises(AttributeError):
            ALLOWED_CLASS_NAMES.add("system")  # type: ignore[attr-defined]


# ---------------------------------------------------------------------------
# ALLOWED_MODULES (legacy alias)
# ---------------------------------------------------------------------------


class TestAllowedModulesAlias:
    """Tests for the ALLOWED_MODULES legacy alias."""

    def test_is_same_object_as_allowed_module_paths(self):
        """ALLOWED_MODULES must be the exact same object as ALLOWED_MODULE_PATHS."""
        assert ALLOWED_MODULES is ALLOWED_MODULE_PATHS


# ---------------------------------------------------------------------------
# SecurityError and ModuleNotAllowedError
# ---------------------------------------------------------------------------


class TestSecurityErrorHierarchy:
    """Tests for the SecurityError and ModuleNotAllowedError exception classes."""

    def test_security_error_is_exception_subclass(self):
        """SecurityError must be a subclass of Exception."""
        assert issubclass(SecurityError, Exception)

    def test_module_not_allowed_error_is_security_error(self):
        """ModuleNotAllowedError must be the same class as SecurityError (legacy alias)."""
        assert ModuleNotAllowedError is SecurityError

    def test_security_error_is_catchable_as_exception(self):
        """SecurityError instances must be catchable as plain Exception."""
        with pytest.raises(Exception):
            raise SecurityError("test")

    def test_module_not_allowed_error_is_catchable_as_security_error(self):
        """ModuleNotAllowedError instances must be catchable as SecurityError."""
        with pytest.raises(SecurityError):
            raise ModuleNotAllowedError("test")

    def test_security_error_preserves_message(self):
        """SecurityError must preserve its error message."""
        err = SecurityError("blocked import attempt")
        assert str(err) == "blocked import attempt"


# ---------------------------------------------------------------------------
# validate_module_import
# ---------------------------------------------------------------------------


class TestValidateModuleImportExtended:
    """Extended tests for the validate_module_import function."""

    # --- happy-path ---

    def test_valid_module_and_valid_class_returns_true(self):
        """Returns True when both module and class are whitelisted."""
        assert (
            validate_module_import(
                ".engines.search_engine_brave", "BraveSearchEngine"
            )
            is True
        )

    # --- single-invalid dimension ---

    def test_invalid_module_valid_class_returns_false(self):
        """Returns False when only the module is not whitelisted."""
        assert (
            validate_module_import(
                ".engines.nonexistent_engine", "BraveSearchEngine"
            )
            is False
        )

    def test_valid_module_invalid_class_returns_false(self):
        """Returns False when only the class is not whitelisted."""
        assert (
            validate_module_import(".engines.search_engine_brave", "EvilClass")
            is False
        )

    # --- both-invalid dimension ---

    def test_both_invalid_returns_false(self):
        """Returns False when neither module nor class is whitelisted."""
        assert validate_module_import(".evil.module", "EvilClass") is False

    # --- empty / None inputs ---

    def test_empty_module_path_returns_false(self):
        """Returns False for an empty string module_path."""
        assert validate_module_import("", "BraveSearchEngine") is False

    def test_empty_class_name_returns_false(self):
        """Returns False for an empty string class_name."""
        assert (
            validate_module_import(".engines.search_engine_brave", "") is False
        )

    def test_none_module_path_returns_false(self):
        """Returns False when module_path is None."""
        assert validate_module_import(None, "BraveSearchEngine") is False

    def test_none_class_name_returns_false(self):
        """Returns False when class_name is None."""
        assert (
            validate_module_import(".engines.search_engine_brave", None)
            is False
        )

    def test_both_none_returns_false(self):
        """Returns False when both arguments are None."""
        assert validate_module_import(None, None) is False

    # --- CRITICAL SECURITY: non-relative paths ---

    def test_non_relative_os_returns_false(self):
        """CRITICAL: bare 'os' module must be rejected as non-relative path."""
        assert validate_module_import("os", "system") is False

    def test_absolute_fully_qualified_path_returns_false(self):
        """Absolute fully-qualified module path must be rejected."""
        assert (
            validate_module_import(
                "local_deep_research.web_search_engines.engines.search_engine_brave",
                "BraveSearchEngine",
            )
            is False
        )

    def test_double_dot_prefix_returns_false(self):
        """Path starting with '..' must be rejected (not in whitelist)."""
        assert validate_module_import("..os", "system") is False

    def test_triple_dot_prefix_returns_false(self):
        """Path starting with '...' must be rejected."""
        assert validate_module_import("...os", "system") is False

    def test_non_relative_subprocess_returns_false(self):
        """Bare 'subprocess' module must be rejected."""
        assert validate_module_import("subprocess", "Popen") is False

    def test_non_relative_sys_returns_false(self):
        """Bare 'sys' module must be rejected."""
        assert validate_module_import("sys", "exit") is False

    # --- path traversal attempts ---

    def test_path_traversal_slash(self):
        """Path traversal using forward slashes must be rejected."""
        assert validate_module_import(".engines/../../../os", "system") is False

    def test_path_traversal_backslash(self):
        """Path traversal using backslashes must be rejected."""
        assert (
            validate_module_import(".engines\\..\\..\\subprocess", "Popen")
            is False
        )

    # --- case sensitivity ---

    def test_case_sensitive_module_path(self):
        """Module paths are case-sensitive -- upper-cased variant must be rejected."""
        assert (
            validate_module_import(
                ".ENGINES.SEARCH_ENGINE_BRAVE", "BraveSearchEngine"
            )
            is False
        )

    def test_case_sensitive_class_name(self):
        """Class names are case-sensitive -- lower-cased variant must be rejected."""
        assert (
            validate_module_import(
                ".engines.search_engine_brave", "bravesearchengine"
            )
            is False
        )

    # --- whitespace / special chars ---

    def test_whitespace_module_path_returns_false(self):
        """A whitespace-only module_path must be rejected (not starting with '.')."""
        assert validate_module_import("   ", "BraveSearchEngine") is False

    def test_module_path_with_trailing_space_returns_false(self):
        """Module path with trailing space must not match whitelist entry."""
        assert (
            validate_module_import(
                ".engines.search_engine_brave ", "BraveSearchEngine"
            )
            is False
        )

    def test_class_name_with_trailing_space_returns_false(self):
        """Class name with trailing space must not match whitelist entry."""
        assert (
            validate_module_import(
                ".engines.search_engine_brave", "BraveSearchEngine "
            )
            is False
        )

    # --- injection-style inputs ---

    def test_semicolon_injection_in_module_path(self):
        """Semicolon-based injection attempt in module path must be rejected."""
        assert validate_module_import(".engines; import os;", "system") is False

    def test_newline_injection_in_module_path(self):
        """Newline injection attempt in module path must be rejected."""
        assert validate_module_import(".engines\nimport os", "system") is False


# ---------------------------------------------------------------------------
# get_safe_module_class
# ---------------------------------------------------------------------------


class TestGetSafeModuleClassExtended:
    """Extended tests for the get_safe_module_class function."""

    def test_raises_security_error_for_non_whitelisted_module(self):
        """Raises SecurityError when the module path is not whitelisted."""
        with pytest.raises(SecurityError):
            get_safe_module_class(".evil.module", "BraveSearchEngine")

    def test_raises_security_error_for_non_whitelisted_class(self):
        """Raises SecurityError when the class name is not whitelisted."""
        with pytest.raises(SecurityError):
            get_safe_module_class(
                ".engines.search_engine_brave", "MaliciousClass"
            )

    def test_raises_security_error_for_absolute_os(self):
        """Raises SecurityError for absolute path 'os'."""
        with pytest.raises(SecurityError):
            get_safe_module_class("os", "system")

    def test_raises_security_error_for_subprocess(self):
        """Raises SecurityError for absolute path 'subprocess'."""
        with pytest.raises(SecurityError):
            get_safe_module_class("subprocess", "Popen")

    def test_error_message_contains_module_path(self):
        """The SecurityError message must include the rejected module path."""
        with pytest.raises(SecurityError, match=r"\.evil\.module"):
            get_safe_module_class(".evil.module", "EvilClass")

    def test_error_message_contains_class_name(self):
        """The SecurityError message must include the rejected class name."""
        with pytest.raises(SecurityError, match="EvilClass"):
            get_safe_module_class(".evil.module", "EvilClass")

    def test_error_message_mentions_whitelist(self):
        """The SecurityError message must mention 'whitelist' for clarity."""
        with pytest.raises(SecurityError, match="whitelist"):
            get_safe_module_class("os", "system")

    # --- default package for relative imports ---

    def test_default_package_used_for_relative_import(self):
        """When no package kwarg is given, the default package is used 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")

                mock_import.assert_called_once_with(
                    ".engines.fake",
                    package="local_deep_research.web_search_engines",
                )

    # --- custom package parameter ---

    def test_custom_package_parameter_is_used(self):
        """When a custom package kwarg is given, it overrides the default."""
        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="my.custom.package",
                )

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

    # --- downstream exception propagation ---

    def test_module_not_found_error_propagates(self):
        """ModuleNotFoundError propagates when a whitelisted module cannot be found."""
        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_attribute_error_propagates(self):
        """AttributeError propagates when the class is missing from the module."""
        with patch(
            "local_deep_research.security.module_whitelist.validate_module_import",
            return_value=True,
        ):
            mock_module = MagicMock(spec=[])
            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",
                    )
