"""Tests for security module."""

from pathlib import Path

import pytest

from openzim_mcp.config import OpenZimMcpConfig
from openzim_mcp.exceptions import OpenZimMcpSecurityError, OpenZimMcpValidationError
from openzim_mcp.security import PathValidator, sanitize_input
from openzim_mcp.server import OpenZimMcpServer


class TestPathValidator:
    """Test PathValidator class."""

    def test_init_valid_directory(self, temp_dir: Path):
        """Test initialization with valid directory."""
        validator = PathValidator([str(temp_dir)])
        assert len(validator.allowed_directories) == 1
        assert validator.allowed_directories[0] == temp_dir.resolve()

    def test_init_nonexistent_directory(self):
        """Test initialization with non-existent directory."""
        with pytest.raises(OpenZimMcpValidationError, match="Directory does not exist"):
            PathValidator(["/nonexistent/directory"])

    def test_init_file_instead_of_directory(self, temp_dir: Path):
        """Test initialization with file instead of directory."""
        test_file = temp_dir / "test.txt"
        test_file.write_text("test")

        with pytest.raises(OpenZimMcpValidationError, match="Path is not a directory"):
            PathValidator([str(test_file)])

    def test_validate_path_within_allowed(
        self, path_validator: PathValidator, temp_dir: Path
    ):
        """Test validating path within allowed directory."""
        test_file = temp_dir / "test.zim"
        test_file.write_text("test content")

        result = path_validator.validate_path(str(test_file))
        assert result == test_file.resolve()

    def test_validate_path_outside_allowed(self, path_validator: PathValidator):
        """Test validating path outside allowed directory."""
        with pytest.raises(OpenZimMcpSecurityError, match="Access denied"):
            path_validator.validate_path("/etc/passwd")

    def test_validate_path_traversal_attack(
        self, path_validator: PathValidator, temp_dir: Path
    ):
        """Test protection against path traversal attacks."""
        with pytest.raises(OpenZimMcpSecurityError, match="suspicious pattern"):
            path_validator.validate_path(str(temp_dir / "../../../etc/passwd"))

    def test_validate_path_embedded_dotdot_caught_by_regex(
        self, path_validator: PathValidator, temp_dir: Path
    ):
        r"""Embedded ``..`` (no surrounding slash) must trip the regex layer.

        Regression for finding M1: the original ``suspicious_patterns``
        list required ``..`` to be either at the start (``^\.\.``), at
        the end (``\.\.$``), or followed by a path separator
        (``\.\./`` / ``\.\.\\``). Strings like ``foo..bar`` —
        ``..`` embedded between non-separator characters — slipped past
        the regex layer entirely and only the ``is_relative_to`` final
        gate prevented escape. We now reject any ``..`` substring.
        """
        attack = f"{temp_dir}/sub..foo/bar"
        with pytest.raises(OpenZimMcpSecurityError, match="suspicious pattern"):
            path_validator.validate_path(attack)

    def test_validate_zim_file_valid(
        self, path_validator: PathValidator, temp_dir: Path
    ):
        """Test validating valid ZIM file."""
        zim_file = temp_dir / "test.zim"
        zim_file.write_text("test content")

        result = path_validator.validate_zim_file(zim_file)
        assert result == zim_file.resolve()

    def test_validate_zim_file_wrong_extension(
        self, path_validator: PathValidator, temp_dir: Path
    ):
        """Test validating file with wrong extension."""
        txt_file = temp_dir / "test.txt"
        txt_file.write_text("test content")

        with pytest.raises(OpenZimMcpValidationError, match="File is not a ZIM file"):
            path_validator.validate_zim_file(txt_file)

    def test_validate_zim_file_nonexistent(
        self, path_validator: PathValidator, temp_dir: Path
    ):
        """Test validating non-existent file."""
        nonexistent_file = temp_dir / "nonexistent.zim"

        with pytest.raises(OpenZimMcpValidationError, match="File does not exist"):
            path_validator.validate_zim_file(nonexistent_file)

    def test_validate_zim_file_returns_resolved_path(
        self, path_validator: PathValidator, temp_dir: Path
    ):
        """Returned path must be the symlink-resolved path.

        Otherwise callers open through the original symlink rather than the
        inode whose containment was just verified, leaving the TOCTOU window
        between validate and open open.
        """
        target = temp_dir / "real.zim"
        target.write_text("test content")

        link = temp_dir / "link.zim"
        link.symlink_to(target)

        result = path_validator.validate_zim_file(link)
        assert result == target.resolve()
        assert result != link


class TestSanitizeInput:
    """Test sanitize_input function."""

    def test_sanitize_normal_string(self):
        """Test sanitizing normal string."""
        result = sanitize_input("Hello, World!")
        assert result == "Hello, World!"

    def test_sanitize_string_with_control_chars(self):
        """Test sanitizing string with control characters."""
        result = sanitize_input("Hello\x00\x01World\x7f")
        assert result == "HelloWorld"

    def test_sanitize_string_too_long(self):
        """Test sanitizing string that's too long."""
        long_string = "a" * 1001
        with pytest.raises(OpenZimMcpValidationError, match="Input too long"):
            sanitize_input(long_string, max_length=1000)

    def test_normalize_path_empty_string(self, temp_dir: Path):
        """Test _normalize_path with empty string."""
        validator = PathValidator([str(temp_dir)])
        with pytest.raises(
            OpenZimMcpValidationError, match="Path must be a non-empty string"
        ):
            validator._normalize_path("")

    def test_normalize_path_none_input(self, temp_dir: Path):
        """Test _normalize_path with None input."""
        validator = PathValidator([str(temp_dir)])
        with pytest.raises(
            OpenZimMcpValidationError, match="Path must be a non-empty string"
        ):
            validator._normalize_path(None)  # type: ignore[arg-type]  # NOSONAR

    def test_normalize_path_with_home_directory(self, temp_dir: Path):
        """Test _normalize_path with home directory expansion."""
        validator = PathValidator([str(temp_dir)])
        # Test with ~ path (this should trigger line 80)
        import os
        import platform

        # Handle both Unix (HOME) and Windows (USERPROFILE) environment variables
        if platform.system() == "Windows":
            env_var = "USERPROFILE"
        else:
            env_var = "HOME"

        original_home = os.environ.get(env_var)
        try:
            os.environ[env_var] = str(temp_dir)
            result = validator._normalize_path("~/test")
            # Use Path.resolve() for proper cross-platform path comparison
            assert Path(result).resolve().is_relative_to(temp_dir.resolve())
        finally:
            if original_home:
                os.environ[env_var] = original_home
            elif env_var in os.environ:
                del os.environ[env_var]

    def test_validate_path_with_os_error(self, temp_dir: Path):
        """Test validate_path when OSError occurs during path resolution."""
        validator = PathValidator([str(temp_dir)])

        # Mock Path.resolve to raise OSError
        from unittest.mock import patch

        with (
            patch("pathlib.Path.resolve", side_effect=OSError("Test error")),
            pytest.raises(OpenZimMcpValidationError, match="Invalid path"),
        ):
            validator.validate_path("valid_path")

    def test_is_path_within_directory_exception_handling(self, temp_dir: Path):
        """Test _is_path_within_directory exception handling."""
        validator = PathValidator([str(temp_dir)])

        # Create a mock Path that raises an exception
        from unittest.mock import MagicMock

        mock_path = MagicMock()
        mock_path.is_relative_to.side_effect = OSError("Test error")
        mock_path.relative_to.side_effect = OSError("Test error")

        # This should return False when exceptions occur (line 140-141)
        result = validator._is_path_within_directory(mock_path, temp_dir)
        assert result is False

    def test_sanitize_non_string_input(self):
        """Test sanitizing non-string input."""
        with pytest.raises(OpenZimMcpValidationError, match="Input must be a string"):
            sanitize_input(123)  # type: ignore[arg-type]  # NOSONAR

    def test_sanitize_preserves_newlines_and_tabs(self):
        """Test that sanitization preserves newlines and tabs."""
        result = sanitize_input("Hello\nWorld\tTest")
        assert result == "Hello\nWorld\tTest"


class TestErrorMessageRedaction:
    """Tests for path redaction in error messages returned to MCP clients."""

    def test_security_error_message_does_not_leak_resolved_path(
        self, test_config: OpenZimMcpConfig
    ):
        """Verify the canonical path embedded in OpenZimMcpSecurityError is redacted.

        Returning the full path verbatim leaks the host's allowed-dirs
        layout exactly when a traversal attempt is rejected, so the
        directory portion must be redacted before reaching the client.
        """
        server = OpenZimMcpServer(test_config)
        err = OpenZimMcpSecurityError(
            "Access denied - Path is outside allowed directories: "
            "/opt/secret/data/wikipedia.zim"
        )

        msg = server._create_enhanced_error_message(
            "get_zim_entry", err, "../etc/passwd"
        )

        # Directory portion must not leak
        assert "/opt/secret/data" not in msg, msg
        assert "/opt/secret" not in msg, msg
        # But filename can be retained (sanitize_path_for_error keeps the
        # filename so the operator still has a debugging signal)
        assert "wikipedia.zim" in msg or "[REDACTED" in msg, msg

    def test_windows_absolute_path_is_redacted(self, test_config: OpenZimMcpConfig):
        r"""Verify Windows-style absolute paths are redacted from error messages.

        ``C:\foo\bar`` style paths embedded in an exception message must
        also be redacted before being returned to the client, even when
        the host OS is POSIX.
        """
        server = OpenZimMcpServer(test_config)
        err = OpenZimMcpSecurityError(
            "Access denied - Path is outside allowed directories: "
            "C:\\Secret\\Data\\wikipedia.zim"
        )

        msg = server._create_enhanced_error_message(
            "get_zim_entry", err, "..\\etc\\passwd"
        )

        assert "C:\\Secret\\Data" not in msg, msg
        assert "Secret\\Data" not in msg, msg
        assert "wikipedia.zim" in msg or "[REDACTED" in msg, msg


@pytest.mark.parametrize(
    "leaked",
    [
        "/opt/zims/wikipedia.zim",
        "/mnt/storage/foo.zim",
        "/srv/data/file.zim",
        "/media/usb/data.zim",
        "E:\\zims\\foo.zim",
        "Z:\\share\\bar.zim",
    ],
)
def test_sanitize_context_for_error_redacts_unusual_paths(leaked):
    """sanitize_context_for_error must redact paths in unusual mount points."""
    from openzim_mcp.security import sanitize_context_for_error

    out = sanitize_context_for_error(leaked)
    assert leaked not in out, out


@pytest.mark.parametrize(
    "win_path",
    [
        "C:\\Secret\\Data\\wikipedia.zim",
        "Z:\\share\\foo.zim",
    ],
)
def test_sanitize_path_for_error_handles_windows_paths_on_posix(win_path):
    """sanitize_path_for_error must split on backslash regardless of host OS."""
    from openzim_mcp.security import sanitize_path_for_error

    out = sanitize_path_for_error(win_path)
    # Directory should not appear; basename can survive
    assert "Secret\\Data" not in out, out
    assert "share\\" not in out, out


@pytest.mark.parametrize(
    "wrapped",
    [
        "(/opt/foo.zim)",
        "[/opt/foo.zim]",
        '"/opt/foo.zim"',
        "'/opt/foo.zim'",
        "<file>/opt/foo.zim</file>",
        "file=/opt/foo.zim",
    ],
)
def test_redact_handles_wrapped_absolute_paths(wrapped):
    """redact_paths_in_message must catch paths wrapped by punctuation."""
    from openzim_mcp.security import redact_paths_in_message

    out = redact_paths_in_message(wrapped)
    # Directory portion must not leak
    assert "/opt/foo" not in out, out
    # The basename ".zim" should still be visible
    # (sanitize_path_for_error keeps the filename)
    assert "foo.zim" in out, out


@pytest.mark.parametrize(
    "encoded",
    [
        "%2Fopt%2Fzims%2Ffoo.zim",
        "context=%2Fmnt%2Fdata%2Fbar.zim",
    ],
)
def test_sanitize_context_redacts_url_encoded_paths(encoded):
    """sanitize_context_for_error must decode + redact percent-encoded paths."""
    from openzim_mcp.security import sanitize_context_for_error

    out = sanitize_context_for_error(encoded)
    assert "/opt/zims" not in out and "%2Fopt%2Fzims" not in out
    assert "/mnt/data" not in out and "%2Fmnt%2Fdata" not in out


def test_redact_handles_whitespace_prefixed_path():
    """Whitespace-prefixed absolute paths must still be redacted (regression)."""
    from openzim_mcp.security import redact_paths_in_message

    out = redact_paths_in_message("hello world /tmp/x.zim")
    assert "/tmp/x" not in out, out
    assert "x.zim" in out, out


def test_redact_handles_bol_path():
    """Path at start of string (no preceding character) must be redacted."""
    from openzim_mcp.security import redact_paths_in_message

    out = redact_paths_in_message("/opt/data/baz.zim is missing")
    assert "/opt/data" not in out, out
    assert "baz.zim" in out, out


def test_redact_does_not_match_relative_path_after_basename():
    """``test.zim/A/Article`` must not have its ``/A/Article`` suffix redacted.

    The ``/`` after ``test.zim`` is preceded by ``m`` (a path-continuation
    character) and so the absolute-path regex must not fire here.
    """
    from openzim_mcp.security import redact_paths_in_message

    text = "see test.zim/A/Article for details"
    out = redact_paths_in_message(text)
    # The mid-token "/A/Article" suffix must remain intact
    assert "test.zim/A/Article" in out, out
