from __future__ import annotations

import shutil

import pytest

from tests.mock.utils import collect_result
from vibe.core.tools.base import BaseToolState, ToolError
from vibe.core.tools.builtins.grep import Grep, GrepArgs, GrepBackend, GrepToolConfig


@pytest.fixture
def grep(tmp_path, monkeypatch):
    monkeypatch.chdir(tmp_path)
    config = GrepToolConfig()
    return Grep(config_getter=lambda: config, state=BaseToolState())


@pytest.fixture
def grep_gnu_only(tmp_path, monkeypatch):
    monkeypatch.chdir(tmp_path)
    original_which = shutil.which

    def mock_which(cmd):
        if cmd == "rg":
            return None
        return original_which(cmd)

    monkeypatch.setattr("shutil.which", mock_which)
    config = GrepToolConfig()
    return Grep(config_getter=lambda: config, state=BaseToolState())


def test_detects_ripgrep_when_available(grep):
    if shutil.which("rg"):
        assert grep._detect_backend() == GrepBackend.RIPGREP


def test_falls_back_to_gnu_grep(grep, monkeypatch):
    original_which = shutil.which

    def mock_which(cmd):
        if cmd == "rg":
            return None
        return original_which(cmd)

    monkeypatch.setattr("shutil.which", mock_which)

    if shutil.which("grep"):
        assert grep._detect_backend() == GrepBackend.GNU_GREP


def test_raises_error_if_no_grep_available(grep, monkeypatch):
    monkeypatch.setattr("shutil.which", lambda cmd: None)

    with pytest.raises(ToolError) as err:
        grep._detect_backend()

    assert "Neither ripgrep (rg) nor grep is installed" in str(err.value)


@pytest.mark.asyncio
async def test_finds_pattern_in_file(grep, tmp_path):
    (tmp_path / "test.py").write_text("def hello():\n    print('world')\n")

    result = await collect_result(grep.run(GrepArgs(pattern="hello")))

    assert result.match_count == 1
    assert "hello" in result.matches
    assert "test.py" in result.matches
    assert not result.was_truncated


@pytest.mark.asyncio
async def test_finds_multiple_matches(grep, tmp_path):
    (tmp_path / "test.py").write_text("foo\nbar\nfoo\nbaz\nfoo\n")

    result = await collect_result(grep.run(GrepArgs(pattern="foo")))

    assert result.match_count == 3
    assert result.matches.count("foo") == 3
    assert not result.was_truncated


@pytest.mark.asyncio
async def test_returns_empty_on_no_matches(grep, tmp_path):
    (tmp_path / "test.py").write_text("def hello():\n    pass\n")

    result = await collect_result(grep.run(GrepArgs(pattern="nonexistent")))

    assert result.match_count == 0
    assert result.matches == ""
    assert not result.was_truncated


@pytest.mark.asyncio
async def test_preserves_accents_when_matching_latin1_encoded_file(grep, tmp_path):
    (tmp_path / "menu.txt").write_bytes("café au lait\nthé glacé\n".encode("latin-1"))

    result = await collect_result(
        grep.run(GrepArgs(pattern="caf"))  # typos:disable-line
    )

    assert result.match_count == 1
    assert "\ufffd" not in result.matches
    assert "café au lait" in result.matches


@pytest.mark.asyncio
async def test_fails_with_empty_pattern(grep):
    with pytest.raises(ToolError) as err:
        await collect_result(grep.run(GrepArgs(pattern="")))

    assert "Empty search pattern" in str(err.value)


@pytest.mark.asyncio
async def test_fails_with_nonexistent_path(grep):
    with pytest.raises(ToolError) as err:
        await collect_result(grep.run(GrepArgs(pattern="test", path="nonexistent")))

    assert "Path does not exist" in str(err.value)


@pytest.mark.asyncio
async def test_searches_in_specific_path(grep, tmp_path):
    subdir = tmp_path / "subdir"
    subdir.mkdir()
    (subdir / "test.py").write_text("match here\n")
    (tmp_path / "other.py").write_text("match here too\n")

    result = await collect_result(grep.run(GrepArgs(pattern="match", path="subdir")))

    assert result.match_count == 1
    assert "subdir" in result.matches and "test.py" in result.matches
    assert "other.py" not in result.matches


@pytest.mark.asyncio
async def test_truncates_to_max_matches(grep, tmp_path):
    (tmp_path / "test.py").write_text("\n".join(f"line {i}" for i in range(200)))

    result = await collect_result(grep.run(GrepArgs(pattern="line", max_matches=50)))

    assert result.match_count == 50
    assert result.was_truncated


@pytest.mark.asyncio
async def test_truncates_to_max_output_bytes(grep, tmp_path, monkeypatch):
    monkeypatch.chdir(tmp_path)
    config = GrepToolConfig(max_output_bytes=100)
    grep_tool = Grep(config_getter=lambda: config, state=BaseToolState())
    (tmp_path / "test.py").write_text("\n".join("x" * 100 for _ in range(10)))

    result = await collect_result(grep_tool.run(GrepArgs(pattern="x")))

    assert len(result.matches) <= 100
    assert result.was_truncated


@pytest.mark.asyncio
async def test_respects_default_ignore_patterns(grep, tmp_path):
    (tmp_path / "included.py").write_text("match\n")
    node_modules = tmp_path / "node_modules"
    node_modules.mkdir()
    (node_modules / "excluded.js").write_text("match\n")

    result = await collect_result(grep.run(GrepArgs(pattern="match")))

    assert "included.py" in result.matches
    assert "excluded.js" not in result.matches


@pytest.mark.asyncio
async def test_respects_vibeignore_file(grep, tmp_path):
    (tmp_path / ".vibeignore").write_text("custom_dir/\n*.tmp\n")
    custom_dir = tmp_path / "custom_dir"
    custom_dir.mkdir()
    (custom_dir / "excluded.py").write_text("match\n")
    (tmp_path / "excluded.tmp").write_text("match\n")
    (tmp_path / "included.py").write_text("match\n")

    result = await collect_result(grep.run(GrepArgs(pattern="match")))

    assert "included.py" in result.matches
    assert "excluded.py" not in result.matches
    assert "excluded.tmp" not in result.matches


@pytest.mark.asyncio
async def test_ignores_comments_in_vibeignore(grep, tmp_path):
    (tmp_path / ".vibeignore").write_text("# comment\npattern/\n# another comment\n")
    (tmp_path / "file.py").write_text("match\n")

    result = await collect_result(grep.run(GrepArgs(pattern="match")))

    assert result.match_count >= 1


@pytest.mark.asyncio
async def test_uses_effective_workdir(tmp_path, monkeypatch):
    monkeypatch.chdir(tmp_path)
    config = GrepToolConfig()
    grep_tool = Grep(config_getter=lambda: config, state=BaseToolState())
    (tmp_path / "test.py").write_text("match\n")

    result = await collect_result(grep_tool.run(GrepArgs(pattern="match", path=".")))

    assert result.match_count == 1
    assert "test.py" in result.matches


@pytest.mark.asyncio
async def test_single_file_match_includes_filename_in_output(grep, tmp_path):
    # Without --with-filename / -H, rg and grep omit the filename when
    # searching a single file, causing GrepMatch.from_output_line to
    # misinterpret the line number as a path. See VIBE-2772.
    (tmp_path / "only.py").write_text("hit one\nnope\nhit two\n")

    result = await collect_result(grep.run(GrepArgs(pattern="hit", path="only.py")))

    assert result.match_count == 2
    for parsed in result.parsed_matches:
        assert parsed.path.endswith("only.py")
        assert parsed.line is not None


@pytest.mark.skipif(not shutil.which("grep"), reason="GNU grep not available")
class TestGnuGrepBackend:
    @pytest.mark.asyncio
    async def test_finds_pattern_in_file(self, grep_gnu_only, tmp_path):
        (tmp_path / "test.py").write_text("def hello():\n    print('world')\n")

        result = await collect_result(grep_gnu_only.run(GrepArgs(pattern="hello")))

        assert result.match_count == 1
        assert "hello" in result.matches
        assert "test.py" in result.matches

    @pytest.mark.asyncio
    async def test_finds_multiple_matches(self, grep_gnu_only, tmp_path):
        (tmp_path / "test.py").write_text("foo\nbar\nfoo\nbaz\nfoo\n")

        result = await collect_result(grep_gnu_only.run(GrepArgs(pattern="foo")))

        assert result.match_count == 3
        assert result.matches.count("foo") == 3

    @pytest.mark.asyncio
    async def test_returns_empty_on_no_matches(self, grep_gnu_only, tmp_path):
        (tmp_path / "test.py").write_text("def hello():\n    pass\n")

        result = await collect_result(
            grep_gnu_only.run(GrepArgs(pattern="nonexistent"))
        )

        assert result.match_count == 0
        assert result.matches == ""

    @pytest.mark.asyncio
    async def test_case_insensitive_for_lowercase_pattern(
        self, grep_gnu_only, tmp_path
    ):
        (tmp_path / "test.py").write_text("Hello\nHELLO\nhello\n")

        result = await collect_result(grep_gnu_only.run(GrepArgs(pattern="hello")))

        assert result.match_count == 3

    @pytest.mark.asyncio
    async def test_case_sensitive_for_mixed_case_pattern(self, grep_gnu_only, tmp_path):
        (tmp_path / "test.py").write_text("Hello\nHELLO\nhello\n")

        result = await collect_result(grep_gnu_only.run(GrepArgs(pattern="Hello")))

        assert result.match_count == 1

    @pytest.mark.asyncio
    async def test_respects_exclude_patterns(self, grep_gnu_only, tmp_path):
        (tmp_path / "included.py").write_text("match\n")
        node_modules = tmp_path / "node_modules"
        node_modules.mkdir()
        (node_modules / "excluded.js").write_text("match\n")

        result = await collect_result(grep_gnu_only.run(GrepArgs(pattern="match")))

        assert "included.py" in result.matches
        assert "excluded.js" not in result.matches

    @pytest.mark.asyncio
    async def test_searches_in_specific_path(self, grep_gnu_only, tmp_path):
        subdir = tmp_path / "subdir"
        subdir.mkdir()
        (subdir / "test.py").write_text("match here\n")
        (tmp_path / "other.py").write_text("match here too\n")

        result = await collect_result(
            grep_gnu_only.run(GrepArgs(pattern="match", path="subdir"))
        )

        assert result.match_count == 1
        assert "other.py" not in result.matches

    @pytest.mark.asyncio
    async def test_respects_vibeignore_file(self, grep_gnu_only, tmp_path):
        (tmp_path / ".vibeignore").write_text("custom_dir/\n*.tmp\n")
        custom_dir = tmp_path / "custom_dir"
        custom_dir.mkdir()
        (custom_dir / "excluded.py").write_text("match\n")
        (tmp_path / "excluded.tmp").write_text("match\n")
        (tmp_path / "included.py").write_text("match\n")

        result = await collect_result(grep_gnu_only.run(GrepArgs(pattern="match")))

        assert "included.py" in result.matches
        assert "excluded.py" not in result.matches
        assert "excluded.tmp" not in result.matches

    @pytest.mark.asyncio
    async def test_truncates_to_max_matches(self, grep_gnu_only, tmp_path):
        (tmp_path / "test.py").write_text("\n".join(f"line {i}" for i in range(200)))

        result = await collect_result(
            grep_gnu_only.run(GrepArgs(pattern="line", max_matches=50))
        )

        assert result.match_count == 50
        assert result.was_truncated


@pytest.mark.skipif(not shutil.which("rg"), reason="ripgrep not available")
class TestRipgrepBackend:
    @pytest.mark.asyncio
    async def test_smart_case_lowercase_pattern(self, grep, tmp_path):
        (tmp_path / "test.py").write_text("Hello\nHELLO\nhello\n")

        result = await collect_result(grep.run(GrepArgs(pattern="hello")))

        assert result.match_count == 3

    @pytest.mark.asyncio
    async def test_smart_case_mixed_case_pattern(self, grep, tmp_path):
        (tmp_path / "test.py").write_text("Hello\nHELLO\nhello\n")

        result = await collect_result(grep.run(GrepArgs(pattern="Hello")))

        assert result.match_count == 1

    @pytest.mark.asyncio
    async def test_searches_ignored_files_when_use_default_ignore_false(
        self, grep, tmp_path
    ):
        (tmp_path / ".ignore").write_text("ignored_by_rg/\n")

        ignored_dir = tmp_path / "ignored_by_rg"
        ignored_dir.mkdir()
        (ignored_dir / "file.py").write_text("match\n")
        (tmp_path / "included.py").write_text("match\n")

        result_with_ignore = await collect_result(grep.run(GrepArgs(pattern="match")))
        assert "included.py" in result_with_ignore.matches
        assert "ignored_by_rg" not in result_with_ignore.matches

        result_without_ignore = await collect_result(
            grep.run(GrepArgs(pattern="match", use_default_ignore=False))
        )
        assert "included.py" in result_without_ignore.matches
        assert "ignored_by_rg/file.py" in result_without_ignore.matches
