"""
Tests for PathValidator security module.
"""

import sys
import tempfile
from pathlib import Path

import pytest

from local_deep_research.security.path_validator import PathValidator


class TestValidateSafePath:
    """Tests for PathValidator.validate_safe_path()."""

    @pytest.fixture
    def temp_base_dir(self):
        """Create a temporary base directory for tests."""
        with tempfile.TemporaryDirectory() as tmpdir:
            yield Path(tmpdir)

    def test_valid_relative_path(self, temp_base_dir):
        """Accepts valid relative path."""
        result = PathValidator.validate_safe_path(
            "subdir/file.txt", temp_base_dir
        )
        assert result is not None
        assert str(temp_base_dir) in str(result)

    def test_valid_simple_filename(self, temp_base_dir):
        """Accepts simple filename."""
        result = PathValidator.validate_safe_path("file.txt", temp_base_dir)
        assert result is not None
        assert result.name == "file.txt"

    def test_path_traversal_blocked(self, temp_base_dir):
        """Blocks path traversal attempts."""
        with pytest.raises(ValueError) as exc_info:
            PathValidator.validate_safe_path("../etc/passwd", temp_base_dir)
        assert "traversal" in str(exc_info.value).lower()

    def test_path_traversal_double_dots(self, temp_base_dir):
        """Blocks multiple .. components."""
        with pytest.raises(ValueError) as exc_info:
            PathValidator.validate_safe_path("../../secret.txt", temp_base_dir)
        assert "traversal" in str(exc_info.value).lower()

    def test_path_traversal_mixed(self, temp_base_dir):
        """Blocks mixed path with traversal."""
        with pytest.raises(ValueError) as exc_info:
            PathValidator.validate_safe_path(
                "valid/../../../escape.txt", temp_base_dir
            )
        assert "traversal" in str(exc_info.value).lower()

    def test_empty_path_rejected(self, temp_base_dir):
        """Rejects empty path."""
        with pytest.raises(ValueError) as exc_info:
            PathValidator.validate_safe_path("", temp_base_dir)
        assert "invalid" in str(exc_info.value).lower()

    def test_none_path_rejected(self, temp_base_dir):
        """Rejects None path."""
        with pytest.raises(ValueError):
            PathValidator.validate_safe_path(None, temp_base_dir)

    def test_required_extensions(self, temp_base_dir):
        """Validates required file extensions."""
        result = PathValidator.validate_safe_path(
            "config.json",
            temp_base_dir,
            required_extensions=(".json", ".yaml"),
        )
        assert result.suffix == ".json"

    def test_wrong_extension_rejected(self, temp_base_dir):
        """Rejects file with wrong extension."""
        with pytest.raises(ValueError) as exc_info:
            PathValidator.validate_safe_path(
                "script.py",
                temp_base_dir,
                required_extensions=(".json", ".yaml"),
            )
        assert "allowed" in str(exc_info.value).lower()

    def test_whitespace_stripped(self, temp_base_dir):
        """Strips whitespace from path."""
        result = PathValidator.validate_safe_path("  file.txt  ", temp_base_dir)
        assert result.name == "file.txt"


class TestValidateLocalFilesystemPath:
    """Tests for PathValidator.validate_local_filesystem_path()."""

    @pytest.fixture
    def temp_safe_dir(self):
        """Create a temporary safe directory."""
        with tempfile.TemporaryDirectory() as tmpdir:
            yield Path(tmpdir)

    def test_valid_absolute_path(self, temp_safe_dir):
        """Accepts valid absolute path."""
        test_path = str(temp_safe_dir / "subdir")
        result = PathValidator.validate_local_filesystem_path(test_path)
        assert result is not None

    @pytest.mark.skipif(
        Path.home() == Path("/root"),
        reason="Skipping in Docker/CI where home is /root (restricted directory)",
    )
    def test_home_expansion(self):
        """Expands ~ to home directory."""
        result = PathValidator.validate_local_filesystem_path("~/Documents")
        assert str(result).startswith(str(Path.home()))

    def test_null_byte_rejected(self):
        """Rejects path with null bytes."""
        with pytest.raises(ValueError) as exc_info:
            PathValidator.validate_local_filesystem_path("/path/with\x00null")
        assert "null" in str(exc_info.value).lower()

    def test_control_characters_rejected(self):
        """Rejects path with control characters."""
        with pytest.raises(ValueError) as exc_info:
            PathValidator.validate_local_filesystem_path(
                "/path/with\x01control"
            )
        assert "control" in str(exc_info.value).lower()

    def test_empty_path_rejected(self):
        """Rejects empty path."""
        with pytest.raises(ValueError):
            PathValidator.validate_local_filesystem_path("")

    def test_none_path_rejected(self):
        """Rejects None path."""
        with pytest.raises(ValueError):
            PathValidator.validate_local_filesystem_path(None)

    @pytest.mark.skipif(sys.platform == "win32", reason="Unix-specific test")
    def test_etc_blocked(self):
        """Blocks access to /etc directory."""
        with pytest.raises(ValueError) as exc_info:
            PathValidator.validate_local_filesystem_path("/etc/passwd")
        assert "system" in str(exc_info.value).lower()

    @pytest.mark.skipif(sys.platform == "win32", reason="Unix-specific test")
    def test_proc_blocked(self):
        """Blocks access to /proc directory."""
        with pytest.raises(ValueError) as exc_info:
            PathValidator.validate_local_filesystem_path("/proc/1/status")
        assert "system" in str(exc_info.value).lower()

    @pytest.mark.skipif(sys.platform == "win32", reason="Unix-specific test")
    def test_sys_blocked(self):
        """Blocks access to /sys directory."""
        with pytest.raises(ValueError) as exc_info:
            PathValidator.validate_local_filesystem_path("/sys/class")
        assert "system" in str(exc_info.value).lower()

    @pytest.mark.skipif(sys.platform == "win32", reason="Unix-specific test")
    def test_dev_blocked(self):
        """Blocks access to /dev directory."""
        with pytest.raises(ValueError) as exc_info:
            PathValidator.validate_local_filesystem_path("/dev/null")
        assert "system" in str(exc_info.value).lower()

    @pytest.mark.skipif(sys.platform == "win32", reason="Unix-specific test")
    def test_root_blocked(self):
        """Blocks access to /root directory.

        Also a regression guard for #3090: this must surface as a clean
        ValueError. The removed pre-resolve is_symlink() lstat() raised an
        uncaught PermissionError for /root/.bashrc on non-root hosts (where it
        escaped to a Flask 500); pytest.raises(ValueError) here would not catch
        that, so this test fails loudly if the EACCES leak ever returns.
        """
        with pytest.raises(ValueError) as exc_info:
            PathValidator.validate_local_filesystem_path("/root/.bashrc")
        assert "system" in str(exc_info.value).lower()

    @pytest.mark.skipif(
        sys.platform == "win32",
        reason="symlink creation needs privileges on Windows",
    )
    def test_symlinked_directory_is_permitted(self, temp_safe_dir):
        """Regression for #3090: a symlinked directory is accepted.

        The removed pre-resolve symlink check rejected ALL symlinks, breaking
        legitimate setups (Docker/k8s bind mounts, macOS /tmp -> /private/tmp).
        Containment is still enforced by resolve()+restricted_dirs.
        """
        real = temp_safe_dir / "real"
        real.mkdir()
        link = temp_safe_dir / "link"
        link.symlink_to(real, target_is_directory=True)

        result = PathValidator.validate_local_filesystem_path(
            str(link), restricted_dirs=[]
        )
        assert result is not None

    @pytest.mark.skipif(sys.platform == "win32", reason="Unix-specific test")
    def test_symlink_escaping_to_system_dir_still_blocked(self, temp_safe_dir):
        """A symlink that resolves into a restricted system dir is still blocked:
        resolve() follows it and the restricted-dir check catches the target.
        This is the real symlink control (vs. the removed leaf-only lstat).
        """
        link = temp_safe_dir / "escape"
        link.symlink_to("/etc", target_is_directory=True)

        with pytest.raises(ValueError) as exc_info:
            PathValidator.validate_local_filesystem_path(str(link / "passwd"))
        assert "system" in str(exc_info.value).lower()

    def test_custom_restricted_dirs(self, temp_safe_dir):
        """Respects custom restricted directories."""
        restricted = [temp_safe_dir]
        with pytest.raises(ValueError):
            PathValidator.validate_local_filesystem_path(
                str(temp_safe_dir / "subdir"),
                restricted_dirs=restricted,
            )

    def test_empty_restricted_dirs_allows_all(self, temp_safe_dir):
        """Empty restricted dirs allows all paths."""
        result = PathValidator.validate_local_filesystem_path(
            str(temp_safe_dir),
            restricted_dirs=[],
        )
        assert result is not None

    def test_restricted_dir_block_does_not_log_user_path(self, temp_safe_dir):
        """The restricted-dir block must log WHICH restricted dir was hit, not
        the user's submitted path — a resolved local path can contain a
        username."""
        from loguru import logger

        secret_segment = "alice-private-7f3a2b"
        target = str(temp_safe_dir / secret_segment / "file.txt")

        # The package disables loguru by default (__init__.py); enable it so the
        # error emitted from inside the module reaches our sink.
        logger.enable("local_deep_research")
        logged: list[str] = []
        sink_id = logger.add(
            lambda m: logged.append(m.record["message"]), level="ERROR"
        )
        try:
            with pytest.raises(ValueError):
                PathValidator.validate_local_filesystem_path(
                    target, restricted_dirs=[temp_safe_dir]
                )
        finally:
            logger.remove(sink_id)
            logger.disable("local_deep_research")

        blocked = [m for m in logged if "restricted directory" in m.lower()]
        assert blocked, "expected a restricted-dir block error log"
        assert all(secret_segment not in m for m in blocked)


class TestValidateConfigPath:
    """Tests for PathValidator.validate_config_path()."""

    @pytest.fixture
    def temp_config_dir(self):
        """Create temp config directory with test files."""
        with tempfile.TemporaryDirectory() as tmpdir:
            config_file = Path(tmpdir) / "settings.json"
            config_file.write_text('{"key": "value"}')
            yaml_file = Path(tmpdir) / "config.yaml"
            yaml_file.write_text("key: value")
            yield Path(tmpdir)

    def test_valid_relative_config_path(self, temp_config_dir):
        """Accepts valid relative config path."""
        result = PathValidator.validate_config_path(
            "settings.json",
            config_root=str(temp_config_dir),
        )
        assert result.exists()

    @pytest.mark.skip(
        reason="Absolute paths to temp dirs not supported by current implementation"
    )
    def test_valid_absolute_config_path(self, temp_config_dir):
        """Accepts valid absolute config path."""
        # NOTE: Current implementation of validate_config_path uses safe_join("/", path)
        # for absolute paths, which doesn't work with arbitrary temp directories.
        # This test would need the implementation to be updated to handle
        # arbitrary absolute paths or config_root parameter for absolute paths.
        # audit: PUNCHLIST reviewed 2026-05 — issue resolved by prior PR (recommendation: REVISIT).
        config_file = temp_config_dir / "settings.json"
        result = PathValidator.validate_config_path(
            str(config_file),
            config_root=str(temp_config_dir),
        )
        assert result.exists()

    def test_traversal_blocked(self, temp_config_dir):
        """Blocks path traversal in config path."""
        with pytest.raises(ValueError) as exc_info:
            PathValidator.validate_config_path(
                "../../../etc/passwd",
                config_root=str(temp_config_dir),
            )
        assert "traversal" in str(exc_info.value).lower()

    def test_null_bytes_rejected(self, temp_config_dir):
        """Rejects null bytes in config path."""
        with pytest.raises(ValueError, match="Null bytes"):
            PathValidator.validate_config_path(
                "set\x00tings.json",
                config_root=str(temp_config_dir),
            )

    def test_etc_prefix_blocked(self, temp_config_dir):
        """Blocks paths starting with /etc/."""
        with pytest.raises(ValueError) as exc_info:
            PathValidator.validate_config_path("/etc/passwd")
        assert "restricted" in str(exc_info.value).lower()

    def test_proc_prefix_blocked(self, temp_config_dir):
        """Blocks paths starting with /proc/."""
        with pytest.raises(ValueError) as exc_info:
            PathValidator.validate_config_path("/proc/1/status")
        assert "restricted" in str(exc_info.value).lower()

    def test_wrong_extension_blocked(self, temp_config_dir):
        """Blocks config files with wrong extension."""
        with pytest.raises(ValueError) as exc_info:
            PathValidator.validate_config_path(
                "script.py",
                config_root=str(temp_config_dir),
            )
        assert (
            "allowed" in str(exc_info.value).lower()
            or "type" in str(exc_info.value).lower()
        )

    def test_valid_yaml_extension(self, temp_config_dir):
        """Accepts YAML config files."""
        result = PathValidator.validate_config_path(
            "config.yaml",
            config_root=str(temp_config_dir),
        )
        assert result.suffix == ".yaml"


class TestValidateModelPath:
    """Tests for PathValidator.validate_model_path()."""

    @pytest.fixture
    def temp_model_dir(self):
        """Create temp model directory with test files."""
        with tempfile.TemporaryDirectory() as tmpdir:
            model_file = Path(tmpdir) / "model.gguf"
            model_file.write_text("fake model content")
            yield Path(tmpdir)

    def test_valid_model_path(self, temp_model_dir):
        """Accepts valid model path."""
        result = PathValidator.validate_model_path(
            "model.gguf",
            model_root=str(temp_model_dir),
        )
        assert result.exists()

    def test_model_not_found(self, temp_model_dir):
        """Raises error for non-existent model."""
        with pytest.raises(ValueError) as exc_info:
            PathValidator.validate_model_path(
                "missing.gguf",
                model_root=str(temp_model_dir),
            )
        assert "not found" in str(exc_info.value).lower()

    def test_model_path_is_directory(self, temp_model_dir):
        """Raises error when path is directory."""
        subdir = temp_model_dir / "subdir"
        subdir.mkdir()
        with pytest.raises(ValueError) as exc_info:
            PathValidator.validate_model_path(
                "subdir",
                model_root=str(temp_model_dir),
            )
        assert "not a file" in str(exc_info.value).lower()

    def test_model_traversal_blocked(self, temp_model_dir):
        """Blocks path traversal in model path."""
        with pytest.raises(ValueError):
            PathValidator.validate_model_path(
                "../../../etc/passwd",
                model_root=str(temp_model_dir),
            )


class TestValidateDataPath:
    """Tests for PathValidator.validate_data_path()."""

    @pytest.fixture
    def temp_data_dir(self):
        """Create temp data directory."""
        with tempfile.TemporaryDirectory() as tmpdir:
            yield Path(tmpdir)

    def test_valid_data_path(self, temp_data_dir):
        """Accepts valid data path."""
        result = PathValidator.validate_data_path(
            "data/file.txt",
            str(temp_data_dir),
        )
        assert result is not None

    def test_data_path_traversal_blocked(self, temp_data_dir):
        """Blocks path traversal in data path."""
        with pytest.raises(ValueError):
            PathValidator.validate_data_path(
                "../secret.txt",
                str(temp_data_dir),
            )


class TestPathValidatorConstants:
    """Tests for PathValidator constants."""

    def test_safe_path_pattern_exists(self):
        """SAFE_PATH_PATTERN is defined."""
        assert PathValidator.SAFE_PATH_PATTERN is not None

    def test_config_extensions_defined(self):
        """CONFIG_EXTENSIONS is defined."""
        assert PathValidator.CONFIG_EXTENSIONS is not None
        assert ".json" in PathValidator.CONFIG_EXTENSIONS
        assert ".yaml" in PathValidator.CONFIG_EXTENSIONS
        assert ".yml" in PathValidator.CONFIG_EXTENSIONS


class TestSecurityScenarios:
    """Integration tests for security scenarios."""

    @pytest.fixture
    def temp_base_dir(self):
        """Create a temporary base directory."""
        with tempfile.TemporaryDirectory() as tmpdir:
            yield Path(tmpdir)

    def test_unicode_normalization_attack(self, temp_base_dir):
        """Unicode look-alike traversal is rejected.

        Full-width periods (U+FF0E '．') NFKC-normalize to '.', which a
        downstream consumer might apply silently. PathValidator rejects
        the input before that happens.
        """
        # audit: PUNCHLIST reviewed 2026-05 — issue resolved by prior PR (recommendation: REVISIT).
        with pytest.raises(ValueError, match="unicode traversal"):
            PathValidator.validate_safe_path(
                "．．/etc/passwd",  # Full-width periods
                temp_base_dir,
            )

    def test_url_encoded_traversal(self, temp_base_dir):
        """URL-encoded path traversal is rejected.

        Some downstream consumers urldecode strings before joining them
        into paths; werkzeug.safe_join's literal-'..' check can't see
        through '%2e%2e'. PathValidator catches this earlier.
        """
        # audit: PUNCHLIST reviewed 2026-05 — issue resolved by prior PR (recommendation: REVISIT).
        with pytest.raises(ValueError, match="encoded traversal"):
            PathValidator.validate_safe_path(
                "%2e%2e/etc/passwd",
                temp_base_dir,
            )

    def test_double_encoded_traversal(self, temp_base_dir):
        """Double-encoded path traversal is rejected.

        '%252e%252e' decodes once to '%2e%2e' and again to '..';
        detection runs two levels of unquote() to catch this layered
        encoding.
        """
        # audit: PUNCHLIST reviewed 2026-05 — issue resolved by prior PR (recommendation: REVISIT).
        with pytest.raises(ValueError, match="encoded traversal"):
            PathValidator.validate_safe_path(
                "%252e%252e/etc/passwd",
                temp_base_dir,
            )

    def test_symlink_escape_prevention(self, temp_base_dir):
        """Path resolution prevents symlink escapes."""
        # Create a symlink pointing outside base dir
        symlink_path = temp_base_dir / "escape"
        try:
            symlink_path.symlink_to("/etc")
            # Attempting to access via symlink should work for the symlink itself
            # but not for paths through it
            result = PathValidator.validate_safe_path("escape", temp_base_dir)
            # The result should be within base_dir (the symlink itself)
            assert str(temp_base_dir) in str(result)
        except OSError:
            pytest.skip("Symlink creation not supported")
