"""Unit tests for file search middleware."""

import os
from pathlib import Path
from typing import Any

import pytest
from langchain_core.tools import StructuredTool

from langchain.agents.middleware.file_search import (
    FilesystemFileSearchMiddleware,
    _expand_include_patterns,
    _is_valid_include_pattern,
    _is_within_root,
    _match_include_pattern,
)


class TestFilesystemGrepSearch:
    """Tests for filesystem-backed grep search."""

    def test_grep_invalid_include_pattern(self, tmp_path: Path) -> None:
        """Return error when include glob cannot be parsed."""
        (tmp_path / "example.py").write_text("print('hello')\n", encoding="utf-8")

        middleware = FilesystemFileSearchMiddleware(root_path=str(tmp_path), use_ripgrep=False)

        assert isinstance(middleware.grep_search, StructuredTool)
        assert middleware.grep_search.func is not None
        result = middleware.grep_search.func(pattern="print", include="*.{py")

        assert result == "Invalid include pattern"

    def test_ripgrep_command_uses_literal_pattern(
        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """Ensure ripgrep receives pattern after `--` to avoid option parsing."""
        (tmp_path / "example.py").write_text("print('hello')\n", encoding="utf-8")

        middleware = FilesystemFileSearchMiddleware(root_path=str(tmp_path), use_ripgrep=True)

        captured: dict[str, list[str]] = {}

        class DummyResult:
            stdout = ""

        def fake_run(*args: Any, **_kwargs: Any) -> DummyResult:
            cmd = args[0]
            captured["cmd"] = cmd
            return DummyResult()

        monkeypatch.setattr("langchain.agents.middleware.file_search.subprocess.run", fake_run)

        middleware._ripgrep_search("--pattern", "/", None)

        assert "cmd" in captured
        cmd = captured["cmd"]
        assert cmd[:2] == ["rg", "--json"]
        assert "--" in cmd
        separator_index = cmd.index("--")
        assert cmd[separator_index + 1] == "--pattern"

    def test_grep_basic_search_python_fallback(self, tmp_path: Path) -> None:
        """Test basic grep search using Python fallback."""
        (tmp_path / "file1.py").write_text("def hello():\n    pass\n", encoding="utf-8")
        (tmp_path / "file2.py").write_text("def world():\n    pass\n", encoding="utf-8")
        (tmp_path / "file3.txt").write_text("hello world\n", encoding="utf-8")

        middleware = FilesystemFileSearchMiddleware(root_path=str(tmp_path), use_ripgrep=False)

        assert isinstance(middleware.grep_search, StructuredTool)
        assert middleware.grep_search.func is not None
        result = middleware.grep_search.func(pattern="hello")

        assert "/file1.py" in result
        assert "/file3.txt" in result
        assert "/file2.py" not in result

    def test_grep_with_include_filter(self, tmp_path: Path) -> None:
        """Test grep search with include pattern filter."""
        (tmp_path / "file1.py").write_text("def hello():\n    pass\n", encoding="utf-8")
        (tmp_path / "file2.txt").write_text("hello world\n", encoding="utf-8")

        middleware = FilesystemFileSearchMiddleware(root_path=str(tmp_path), use_ripgrep=False)

        assert isinstance(middleware.grep_search, StructuredTool)
        assert middleware.grep_search.func is not None
        result = middleware.grep_search.func(pattern="hello", include="*.py")

        assert "/file1.py" in result
        assert "/file2.txt" not in result

    def test_grep_output_mode_content(self, tmp_path: Path) -> None:
        """Test grep search with content output mode."""
        (tmp_path / "test.py").write_text("line1\nhello\nline3\n", encoding="utf-8")

        middleware = FilesystemFileSearchMiddleware(root_path=str(tmp_path), use_ripgrep=False)

        assert isinstance(middleware.grep_search, StructuredTool)
        assert middleware.grep_search.func is not None
        result = middleware.grep_search.func(pattern="hello", output_mode="content")

        assert "/test.py:2:hello" in result

    def test_grep_output_mode_count(self, tmp_path: Path) -> None:
        """Test grep search with count output mode."""
        (tmp_path / "test.py").write_text("hello\nhello\nworld\n", encoding="utf-8")

        middleware = FilesystemFileSearchMiddleware(root_path=str(tmp_path), use_ripgrep=False)

        assert isinstance(middleware.grep_search, StructuredTool)
        assert middleware.grep_search.func is not None
        result = middleware.grep_search.func(pattern="hello", output_mode="count")

        assert "/test.py:2" in result

    def test_grep_invalid_regex_pattern(self, tmp_path: Path) -> None:
        """Test grep search with invalid regex pattern."""
        (tmp_path / "test.py").write_text("hello\n", encoding="utf-8")

        middleware = FilesystemFileSearchMiddleware(root_path=str(tmp_path), use_ripgrep=False)

        assert isinstance(middleware.grep_search, StructuredTool)
        assert middleware.grep_search.func is not None
        result = middleware.grep_search.func(pattern="[invalid")

        assert "Invalid regex pattern" in result

    def test_grep_no_matches(self, tmp_path: Path) -> None:
        """Test grep search with no matches."""
        (tmp_path / "test.py").write_text("hello\n", encoding="utf-8")

        middleware = FilesystemFileSearchMiddleware(root_path=str(tmp_path), use_ripgrep=False)

        assert isinstance(middleware.grep_search, StructuredTool)
        assert middleware.grep_search.func is not None
        result = middleware.grep_search.func(pattern="notfound")

        assert result == "No matches found"


class TestFilesystemGlobSearch:
    """Tests for filesystem-backed glob search."""

    def test_glob_basic_pattern(self, tmp_path: Path) -> None:
        """Test basic glob pattern matching."""
        (tmp_path / "file1.py").write_text("content", encoding="utf-8")
        (tmp_path / "file2.py").write_text("content", encoding="utf-8")
        (tmp_path / "file3.txt").write_text("content", encoding="utf-8")

        middleware = FilesystemFileSearchMiddleware(root_path=str(tmp_path))

        assert isinstance(middleware.glob_search, StructuredTool)
        assert middleware.glob_search.func is not None
        result = middleware.glob_search.func(pattern="*.py")

        assert "/file1.py" in result
        assert "/file2.py" in result
        assert "/file3.txt" not in result

    def test_glob_recursive_pattern(self, tmp_path: Path) -> None:
        """Test recursive glob pattern matching."""
        (tmp_path / "src").mkdir()
        (tmp_path / "src" / "test.py").write_text("content", encoding="utf-8")
        (tmp_path / "src" / "nested").mkdir()
        (tmp_path / "src" / "nested" / "deep.py").write_text("content", encoding="utf-8")

        middleware = FilesystemFileSearchMiddleware(root_path=str(tmp_path))

        assert isinstance(middleware.glob_search, StructuredTool)
        assert middleware.glob_search.func is not None
        result = middleware.glob_search.func(pattern="**/*.py")

        assert "/src/test.py" in result
        assert "/src/nested/deep.py" in result

    def test_glob_with_subdirectory_path(self, tmp_path: Path) -> None:
        """Test glob search starting from subdirectory."""
        (tmp_path / "src").mkdir()
        (tmp_path / "src" / "file1.py").write_text("content", encoding="utf-8")
        (tmp_path / "other").mkdir()
        (tmp_path / "other" / "file2.py").write_text("content", encoding="utf-8")

        middleware = FilesystemFileSearchMiddleware(root_path=str(tmp_path))

        assert isinstance(middleware.glob_search, StructuredTool)
        assert middleware.glob_search.func is not None
        result = middleware.glob_search.func(pattern="*.py", path="/src")

        assert "/src/file1.py" in result
        assert "/other/file2.py" not in result

    def test_glob_no_matches(self, tmp_path: Path) -> None:
        """Test glob search with no matches."""
        (tmp_path / "file.txt").write_text("content", encoding="utf-8")

        middleware = FilesystemFileSearchMiddleware(root_path=str(tmp_path))

        assert isinstance(middleware.glob_search, StructuredTool)
        assert middleware.glob_search.func is not None
        result = middleware.glob_search.func(pattern="*.py")

        assert result == "No files found"

    def test_glob_invalid_path(self, tmp_path: Path) -> None:
        """Test glob search with non-existent path."""
        middleware = FilesystemFileSearchMiddleware(root_path=str(tmp_path))

        assert isinstance(middleware.glob_search, StructuredTool)
        assert middleware.glob_search.func is not None
        result = middleware.glob_search.func(pattern="*.py", path="/nonexistent")

        assert result == "No files found"

    def test_glob_results_sorted_by_mtime_desc(self, tmp_path: Path) -> None:
        """Results must be ordered newest-first, as documented."""
        # Create files in alphabetical order opposite to mtime order so the
        # default Path.glob() ordering cannot coincidentally pass the assertion.
        oldest = tmp_path / "a_oldest.txt"
        middle = tmp_path / "b_middle.txt"
        newest = tmp_path / "c_newest.txt"
        for p in (oldest, middle, newest):
            p.write_text("x", encoding="utf-8")

        # Explicit mtimes (seconds since epoch) — deterministic, no sleeps.
        os.utime(oldest, (1_000_000, 1_000_000))
        os.utime(middle, (2_000_000, 2_000_000))
        os.utime(newest, (3_000_000, 3_000_000))

        middleware = FilesystemFileSearchMiddleware(root_path=str(tmp_path))

        assert isinstance(middleware.glob_search, StructuredTool)
        assert middleware.glob_search.func is not None
        result = middleware.glob_search.func(pattern="*.txt")

        assert result.splitlines() == [
            "/c_newest.txt",
            "/b_middle.txt",
            "/a_oldest.txt",
        ]


class TestPathTraversalSecurity:
    """Security tests for path traversal protection."""

    def test_path_traversal_with_double_dots(self, tmp_path: Path) -> None:
        """Test that path traversal with .. is blocked."""
        (tmp_path / "allowed").mkdir()
        (tmp_path / "allowed" / "file.txt").write_text("content", encoding="utf-8")

        # Create file outside root
        parent = tmp_path.parent
        (parent / "secret.txt").write_text("secret", encoding="utf-8")

        middleware = FilesystemFileSearchMiddleware(root_path=str(tmp_path / "allowed"))

        # Try to escape with ..
        assert isinstance(middleware.glob_search, StructuredTool)
        assert middleware.glob_search.func is not None
        result = middleware.glob_search.func(pattern="*.txt", path="/../")

        assert result == "No files found"
        assert "secret" not in result

    def test_path_traversal_with_absolute_path(self, tmp_path: Path) -> None:
        """Test that absolute paths outside root are blocked."""
        (tmp_path / "allowed").mkdir()

        # Create file outside root
        (tmp_path / "secret.txt").write_text("secret", encoding="utf-8")

        middleware = FilesystemFileSearchMiddleware(root_path=str(tmp_path / "allowed"))

        # Try to access with absolute path
        assert isinstance(middleware.glob_search, StructuredTool)
        assert middleware.glob_search.func is not None
        result = middleware.glob_search.func(pattern="*.txt", path=str(tmp_path))

        assert result == "No files found"

    def test_path_traversal_with_symlink(self, tmp_path: Path) -> None:
        """Test that symlinks outside root are blocked."""
        (tmp_path / "allowed").mkdir()
        (tmp_path / "secret.txt").write_text("secret", encoding="utf-8")

        # Create symlink from allowed dir to parent
        try:
            (tmp_path / "allowed" / "link").symlink_to(tmp_path)
        except OSError:
            pytest.skip("Symlink creation not supported")

        middleware = FilesystemFileSearchMiddleware(root_path=str(tmp_path / "allowed"))

        # Try to access via symlink
        assert isinstance(middleware.glob_search, StructuredTool)
        assert middleware.glob_search.func is not None
        result = middleware.glob_search.func(pattern="*.txt", path="/link")

        assert result == "No files found"

    def test_validate_path_blocks_tilde(self, tmp_path: Path) -> None:
        """Test that tilde paths are handled safely."""
        middleware = FilesystemFileSearchMiddleware(root_path=str(tmp_path))

        assert isinstance(middleware.glob_search, StructuredTool)
        assert middleware.glob_search.func is not None
        result = middleware.glob_search.func(pattern="*.txt", path="~/")

        assert result == "No files found"

    def test_grep_path_traversal_protection(self, tmp_path: Path) -> None:
        """Test that grep also protects against path traversal."""
        (tmp_path / "allowed").mkdir()
        (tmp_path / "secret.txt").write_text("secret content", encoding="utf-8")

        middleware = FilesystemFileSearchMiddleware(
            root_path=str(tmp_path / "allowed"), use_ripgrep=False
        )

        # Try to search outside root
        assert isinstance(middleware.grep_search, StructuredTool)
        assert middleware.grep_search.func is not None
        result = middleware.grep_search.func(pattern="secret", path="/../")

        assert result == "No matches found"
        assert "secret" not in result


class TestGlobPatternTraversalSecurity:
    """Security tests for the glob `pattern` argument (not just the base `path`)."""

    def test_glob_pattern_with_double_dots_rejected(self, tmp_path: Path) -> None:
        """A glob pattern containing `..` must not escape the root."""
        root = tmp_path / "root"
        root.mkdir()
        (root / "inside.txt").write_text("inside", encoding="utf-8")
        # Secret lives outside the root.
        (tmp_path / "passwd").write_text("secret", encoding="utf-8")

        middleware = FilesystemFileSearchMiddleware(root_path=str(root))

        assert isinstance(middleware.glob_search, StructuredTool)
        assert middleware.glob_search.func is not None
        result = middleware.glob_search.func(pattern="../passwd")

        assert result == "No files found"
        assert "secret" not in result
        assert "passwd" not in result

    def test_glob_pattern_absolute_rejected(self, tmp_path: Path) -> None:
        """A glob pattern that is an absolute path must be rejected."""
        root = tmp_path / "root"
        root.mkdir()
        (root / "inside.txt").write_text("inside", encoding="utf-8")
        secret = tmp_path / "secret.txt"
        secret.write_text("secret", encoding="utf-8")

        middleware = FilesystemFileSearchMiddleware(root_path=str(root))

        assert isinstance(middleware.glob_search, StructuredTool)
        assert middleware.glob_search.func is not None
        result = middleware.glob_search.func(pattern=str(secret))

        assert result == "No files found"

    def test_glob_pattern_tilde_rejected(self, tmp_path: Path) -> None:
        """A glob pattern containing `~` must be rejected."""
        root = tmp_path / "root"
        root.mkdir()
        (root / "inside.txt").write_text("inside", encoding="utf-8")

        middleware = FilesystemFileSearchMiddleware(root_path=str(root))

        assert isinstance(middleware.glob_search, StructuredTool)
        assert middleware.glob_search.func is not None
        result = middleware.glob_search.func(pattern="~/secret")

        assert result == "No files found"

    def test_glob_normal_pattern_still_works(self, tmp_path: Path) -> None:
        """A normal in-root pattern must still return expected results."""
        root = tmp_path / "root"
        root.mkdir()
        (root / "keep.py").write_text("content", encoding="utf-8")
        (root / "skip.txt").write_text("content", encoding="utf-8")

        middleware = FilesystemFileSearchMiddleware(root_path=str(root))

        assert isinstance(middleware.glob_search, StructuredTool)
        assert middleware.glob_search.func is not None
        result = middleware.glob_search.func(pattern="*.py")

        assert "/keep.py" in result
        assert "/skip.txt" not in result

    def test_glob_does_not_follow_inside_symlink_to_outside(self, tmp_path: Path) -> None:
        """A symlink inside the root pointing outside must not be enumerated by glob."""
        root = tmp_path / "root"
        root.mkdir()
        outside = tmp_path / "outside.txt"
        outside.write_text("secret-outside", encoding="utf-8")

        try:
            (root / "link.txt").symlink_to(outside)
        except OSError:
            pytest.skip("Symlink creation not supported")

        middleware = FilesystemFileSearchMiddleware(root_path=str(root))

        assert isinstance(middleware.glob_search, StructuredTool)
        assert middleware.glob_search.func is not None
        result = middleware.glob_search.func(pattern="*.txt")

        # The symlinked entry resolves outside root, so it must be excluded.
        assert "/link.txt" not in result
        assert result == "No files found"


class TestGrepSymlinkContainment:
    """Security tests that grep does not surface out-of-root files via symlinks."""

    def test_python_grep_does_not_read_inside_symlink_to_outside(self, tmp_path: Path) -> None:
        """Python-fallback grep must not read a symlink inside root pointing outside."""
        root = tmp_path / "root"
        root.mkdir()
        (root / "inside.txt").write_text("nothing-here\n", encoding="utf-8")
        outside = tmp_path / "outside.txt"
        outside.write_text("TOPSECRET\n", encoding="utf-8")

        try:
            (root / "link.txt").symlink_to(outside)
        except OSError:
            pytest.skip("Symlink creation not supported")

        middleware = FilesystemFileSearchMiddleware(root_path=str(root), use_ripgrep=False)

        assert isinstance(middleware.grep_search, StructuredTool)
        assert middleware.grep_search.func is not None
        result = middleware.grep_search.func(pattern="TOPSECRET", output_mode="content")

        assert "TOPSECRET" not in result
        assert "/link.txt" not in result
        assert result == "No matches found"

    def test_python_grep_normal_search_still_works(self, tmp_path: Path) -> None:
        """A normal in-root grep must still return expected results."""
        root = tmp_path / "root"
        root.mkdir()
        (root / "a.py").write_text("needle here\n", encoding="utf-8")
        (root / "b.py").write_text("nothing\n", encoding="utf-8")

        middleware = FilesystemFileSearchMiddleware(root_path=str(root), use_ripgrep=False)

        assert isinstance(middleware.grep_search, StructuredTool)
        assert middleware.grep_search.func is not None
        result = middleware.grep_search.func(pattern="needle")

        assert "/a.py" in result
        assert "/b.py" not in result


class TestIsWithinRoot:
    """Tests for the private `_is_within_root` helper."""

    def test_in_root_path_is_within(self, tmp_path: Path) -> None:
        """A file inside the root is reported as within."""
        root = tmp_path / "root"
        root.mkdir()
        candidate = root / "file.txt"
        candidate.write_text("x", encoding="utf-8")

        assert _is_within_root(candidate, root) is True

    def test_outside_path_is_not_within(self, tmp_path: Path) -> None:
        """A file outside the root is reported as not within."""
        root = tmp_path / "root"
        root.mkdir()
        outside = tmp_path / "outside.txt"
        outside.write_text("x", encoding="utf-8")

        assert _is_within_root(outside, root) is False

    def test_symlink_escaping_root_is_not_within(self, tmp_path: Path) -> None:
        """A symlink inside the root that resolves outside is not within."""
        root = tmp_path / "root"
        root.mkdir()
        outside = tmp_path / "outside.txt"
        outside.write_text("x", encoding="utf-8")
        try:
            link = root / "link.txt"
            link.symlink_to(outside)
        except OSError:
            pytest.skip("Symlink creation not supported")

        assert _is_within_root(link, root) is False


class TestExpandIncludePatterns:
    """Tests for _expand_include_patterns helper function."""

    def test_expand_patterns_basic_brace_expansion(self) -> None:
        """Test basic brace expansion with multiple options."""
        result = _expand_include_patterns("*.{py,txt}")
        assert result == ["*.py", "*.txt"]

    def test_expand_patterns_nested_braces(self) -> None:
        """Test nested brace expansion."""
        result = _expand_include_patterns("test.{a,b}.{c,d}")
        assert result is not None
        assert len(result) == 4
        assert "test.a.c" in result
        assert "test.b.d" in result

    @pytest.mark.parametrize(
        "pattern",
        [
            "*.py}",  # closing brace without opening
            "*.{}",  # empty braces
            "*.{py",  # unclosed brace
        ],
    )
    def test_expand_patterns_invalid_braces(self, pattern: str) -> None:
        """Test patterns with invalid brace syntax return None."""
        result = _expand_include_patterns(pattern)
        assert result is None


class TestValidateIncludePattern:
    """Tests for _is_valid_include_pattern helper function."""

    @pytest.mark.parametrize(
        "pattern",
        [
            "",  # empty pattern
            "*.py\x00",  # null byte
            "*.py\n",  # newline
        ],
    )
    def test_validate_invalid_patterns(self, pattern: str) -> None:
        """Test that invalid patterns are rejected."""
        assert not _is_valid_include_pattern(pattern)


class TestMatchIncludePattern:
    """Tests for _match_include_pattern helper function."""

    def test_match_pattern_with_braces(self) -> None:
        """Test matching with brace expansion."""
        assert _match_include_pattern("test.py", "*.{py,txt}")
        assert _match_include_pattern("test.txt", "*.{py,txt}")
        assert not _match_include_pattern("test.md", "*.{py,txt}")

    def test_match_pattern_invalid_expansion(self) -> None:
        """Test matching with pattern that cannot be expanded returns False."""
        assert not _match_include_pattern("test.py", "*.{}")


class TestGrepEdgeCases:
    """Tests for edge cases in grep search."""

    def test_grep_with_special_chars_in_pattern(self, tmp_path: Path) -> None:
        """Test grep with special characters in pattern."""
        (tmp_path / "test.py").write_text("def test():\n    pass\n", encoding="utf-8")

        middleware = FilesystemFileSearchMiddleware(root_path=str(tmp_path), use_ripgrep=False)

        assert isinstance(middleware.grep_search, StructuredTool)
        assert middleware.grep_search.func is not None
        result = middleware.grep_search.func(pattern="def.*:")

        assert "/test.py" in result

    def test_grep_case_insensitive(self, tmp_path: Path) -> None:
        """Test grep with case-insensitive search."""
        (tmp_path / "test.py").write_text("HELLO world\n", encoding="utf-8")

        middleware = FilesystemFileSearchMiddleware(root_path=str(tmp_path), use_ripgrep=False)

        assert isinstance(middleware.grep_search, StructuredTool)
        assert middleware.grep_search.func is not None
        result = middleware.grep_search.func(pattern="(?i)hello")

        assert "/test.py" in result

    def test_grep_with_large_file_skipping(self, tmp_path: Path) -> None:
        """Test that grep skips files larger than max_file_size_mb."""
        # Create a file larger than 1MB
        large_content = "x" * (2 * 1024 * 1024)  # 2MB
        (tmp_path / "large.txt").write_text(large_content, encoding="utf-8")
        (tmp_path / "small.txt").write_text("x", encoding="utf-8")

        middleware = FilesystemFileSearchMiddleware(
            root_path=str(tmp_path),
            use_ripgrep=False,
            max_file_size_mb=1,  # 1MB limit
        )

        assert isinstance(middleware.grep_search, StructuredTool)
        assert middleware.grep_search.func is not None
        result = middleware.grep_search.func(pattern="x")

        # Large file should be skipped
        assert "/small.txt" in result
