"""Tests for the git tools (status, diff, commit, log).

Tests mock the Rust backend (``get_rust_module``) so that the compiled
``openjarvis_rust`` extension is not required.  The mock simulates Rust
behaviour: git commands run via ``subprocess`` with the same flags that the
Rust ``git_tools.rs`` implementation uses.
"""

from __future__ import annotations

import subprocess
from unittest.mock import MagicMock, patch

from openjarvis.tools.git_tool import (
    GitCommitTool,
    GitDiffTool,
    GitLogTool,
    GitStatusTool,
)

# ---------------------------------------------------------------------------
# Helpers — mock Rust backend
# ---------------------------------------------------------------------------


def _run_git_like_rust(args: list[str], cwd: str | None = None) -> str:
    """Run a git command the way the Rust ``run_git`` helper does.

    Returns stdout on success.  Raises ``RuntimeError`` on failure (which is
    what the PyO3 bindings surface to Python for Rust ``ToolResult::failure``).
    """
    result = subprocess.run(
        ["git"] + args,
        capture_output=True,
        text=True,
        cwd=cwd,
    )
    if result.returncode != 0:
        msg = result.stderr.strip() or f"git exited {result.returncode}"
        raise RuntimeError(msg)
    return result.stdout


def _make_mock_rust(tmp_path=None):
    """Return a mock module mimicking ``openjarvis_rust`` git tools.

    The mock's ``GitStatusTool``, ``GitDiffTool``, and ``GitLogTool``
    classes each have an ``execute`` method that shells out to git using
    the same flags as the Rust implementation.
    """
    mock_mod = MagicMock()

    # -- GitStatusTool --
    status_inst = MagicMock()

    def _status_execute(cwd=None):
        return _run_git_like_rust(["status", "--short"], cwd=cwd)

    status_inst.execute.side_effect = _status_execute
    mock_mod.GitStatusTool.return_value = status_inst

    # -- GitDiffTool --
    diff_inst = MagicMock()

    def _diff_execute(cwd=None):
        return _run_git_like_rust(["diff"], cwd=cwd)

    diff_inst.execute.side_effect = _diff_execute
    mock_mod.GitDiffTool.return_value = diff_inst

    # -- GitLogTool --
    log_inst = MagicMock()

    def _log_execute(cwd=None, count=None):
        # Rust reads params["n"], but PyO3 passes "count".  The Rust side
        # never sees "count" so the limit always defaults to 10.
        n = 10
        return _run_git_like_rust(["log", "--oneline", f"-{n}"], cwd=cwd)

    log_inst.execute.side_effect = _log_execute
    mock_mod.GitLogTool.return_value = log_inst

    return mock_mod


def _make_mock_rust_git_not_found():
    """Return a mock module whose git tools raise RuntimeError (git missing)."""
    mock_mod = MagicMock()
    err = RuntimeError("Failed to run git: No such file or directory (os error 2)")
    for attr in ("GitStatusTool", "GitDiffTool", "GitLogTool"):
        inst = MagicMock()
        inst.execute.side_effect = err
        getattr(mock_mod, attr).return_value = inst
    return mock_mod


def _init_repo(path):
    """Initialize a git repo with an initial commit at *path*."""
    subprocess.run(
        ["git", "init"],
        cwd=str(path),
        capture_output=True,
        check=True,
    )
    subprocess.run(
        ["git", "config", "user.email", "test@test.com"],
        cwd=str(path),
        capture_output=True,
        check=True,
    )
    subprocess.run(
        ["git", "config", "user.name", "Test User"],
        cwd=str(path),
        capture_output=True,
        check=True,
    )
    # Create an initial commit so HEAD exists
    readme = path / "README.md"
    readme.write_text("# Test Repo\n")
    subprocess.run(
        ["git", "add", "."],
        cwd=str(path),
        capture_output=True,
        check=True,
    )
    subprocess.run(
        ["git", "commit", "-m", "Initial commit"],
        cwd=str(path),
        capture_output=True,
        check=True,
    )


# ---------------------------------------------------------------------------
# TestGitStatusTool
# ---------------------------------------------------------------------------


class TestGitStatusTool:
    def test_spec(self):
        tool = GitStatusTool()
        assert tool.spec.name == "git_status"
        assert tool.spec.category == "vcs"
        assert "file:read" in tool.spec.required_capabilities

    def test_tool_id(self):
        tool = GitStatusTool()
        assert tool.tool_id == "git_status"

    def test_clean_repo(self, tmp_path):
        _init_repo(tmp_path)
        tool = GitStatusTool()
        mock_mod = _make_mock_rust(tmp_path)
        with patch("openjarvis.tools.git_tool.get_rust_module", return_value=mock_mod):
            result = tool.execute(repo_path=str(tmp_path))
        assert result.success is True
        # Clean repo — Rust uses --short so no output
        assert result.content == "(no output)"

    def test_modified_file(self, tmp_path):
        _init_repo(tmp_path)
        (tmp_path / "README.md").write_text("# Modified\n")
        tool = GitStatusTool()
        mock_mod = _make_mock_rust(tmp_path)
        with patch("openjarvis.tools.git_tool.get_rust_module", return_value=mock_mod):
            result = tool.execute(repo_path=str(tmp_path))
        assert result.success is True
        assert "README.md" in result.content

    def test_untracked_file(self, tmp_path):
        _init_repo(tmp_path)
        (tmp_path / "new_file.txt").write_text("hello")
        tool = GitStatusTool()
        mock_mod = _make_mock_rust(tmp_path)
        with patch("openjarvis.tools.git_tool.get_rust_module", return_value=mock_mod):
            result = tool.execute(repo_path=str(tmp_path))
        assert result.success is True
        assert "new_file.txt" in result.content

    def test_default_repo_path(self):
        tool = GitStatusTool()
        mock_mod = _make_mock_rust()
        with patch("openjarvis.tools.git_tool.get_rust_module", return_value=mock_mod):
            result = tool.execute()
        # Should succeed or fail depending on cwd; not a crash
        assert isinstance(result.content, str)

    def test_invalid_repo_path(self, tmp_path):
        tool = GitStatusTool()
        mock_mod = _make_mock_rust()
        with patch("openjarvis.tools.git_tool.get_rust_module", return_value=mock_mod):
            result = tool.execute(repo_path=str(tmp_path / "nonexistent"))
        assert result.success is False

    def test_returncode_in_metadata(self, tmp_path):
        _init_repo(tmp_path)
        tool = GitStatusTool()
        mock_mod = _make_mock_rust(tmp_path)
        with patch("openjarvis.tools.git_tool.get_rust_module", return_value=mock_mod):
            result = tool.execute(repo_path=str(tmp_path))
        assert "returncode" in result.metadata
        assert result.metadata["returncode"] == 0

    def test_git_not_found(self):
        tool = GitStatusTool()
        mock_mod = _make_mock_rust_git_not_found()
        with patch("openjarvis.tools.git_tool.get_rust_module", return_value=mock_mod):
            result = tool.execute(repo_path=".")
        assert result.success is False
        assert "Failed to run git" in result.content

    def test_to_openai_function(self):
        tool = GitStatusTool()
        fn = tool.to_openai_function()
        assert fn["type"] == "function"
        assert fn["function"]["name"] == "git_status"


# ---------------------------------------------------------------------------
# TestGitDiffTool
# ---------------------------------------------------------------------------


class TestGitDiffTool:
    def test_spec(self):
        tool = GitDiffTool()
        assert tool.spec.name == "git_diff"
        assert tool.spec.category == "vcs"
        assert "file:read" in tool.spec.required_capabilities

    def test_tool_id(self):
        tool = GitDiffTool()
        assert tool.tool_id == "git_diff"

    def test_no_changes(self, tmp_path):
        _init_repo(tmp_path)
        tool = GitDiffTool()
        mock_mod = _make_mock_rust(tmp_path)
        with patch("openjarvis.tools.git_tool.get_rust_module", return_value=mock_mod):
            result = tool.execute(repo_path=str(tmp_path))
        assert result.success is True
        assert result.content == "(no output)"

    def test_unstaged_changes(self, tmp_path):
        _init_repo(tmp_path)
        (tmp_path / "README.md").write_text("# Changed\n")
        tool = GitDiffTool()
        mock_mod = _make_mock_rust(tmp_path)
        with patch("openjarvis.tools.git_tool.get_rust_module", return_value=mock_mod):
            result = tool.execute(repo_path=str(tmp_path))
        assert result.success is True
        assert "Changed" in result.content

    def test_staged_changes(self, tmp_path):
        _init_repo(tmp_path)
        (tmp_path / "README.md").write_text("# Staged\n")
        subprocess.run(
            ["git", "add", "README.md"],
            cwd=str(tmp_path),
            capture_output=True,
            check=True,
        )
        tool = GitDiffTool()
        mock_mod = _make_mock_rust(tmp_path)
        with patch("openjarvis.tools.git_tool.get_rust_module", return_value=mock_mod):
            # Unstaged should be empty (Rust path)
            result_unstaged = tool.execute(repo_path=str(tmp_path))
            assert result_unstaged.content == "(no output)"
            # Staged falls back to Python _run_git (not handled by Rust)
            result_staged = tool.execute(repo_path=str(tmp_path), staged=True)
        assert result_staged.success is True
        assert "Staged" in result_staged.content

    def test_specific_file_path(self, tmp_path):
        _init_repo(tmp_path)
        (tmp_path / "README.md").write_text("# Changed\n")
        (tmp_path / "other.txt").write_text("other change")
        subprocess.run(
            ["git", "add", "other.txt"],
            cwd=str(tmp_path),
            capture_output=True,
        )
        tool = GitDiffTool()
        mock_mod = _make_mock_rust(tmp_path)
        # path= specified → falls back to Python _run_git, but
        # get_rust_module() is still called before the branch.
        with patch("openjarvis.tools.git_tool.get_rust_module", return_value=mock_mod):
            result = tool.execute(repo_path=str(tmp_path), path="README.md")
        assert result.success is True
        assert "Changed" in result.content

    def test_git_not_found(self):
        tool = GitDiffTool()
        mock_mod = _make_mock_rust_git_not_found()
        with patch("openjarvis.tools.git_tool.get_rust_module", return_value=mock_mod):
            result = tool.execute(repo_path=".")
        assert result.success is False
        assert "Failed to run git" in result.content

    def test_invalid_repo_path(self, tmp_path):
        tool = GitDiffTool()
        mock_mod = _make_mock_rust()
        with patch("openjarvis.tools.git_tool.get_rust_module", return_value=mock_mod):
            result = tool.execute(repo_path=str(tmp_path / "nonexistent"))
        assert result.success is False

    def test_returncode_in_metadata(self, tmp_path):
        _init_repo(tmp_path)
        tool = GitDiffTool()
        mock_mod = _make_mock_rust(tmp_path)
        with patch("openjarvis.tools.git_tool.get_rust_module", return_value=mock_mod):
            result = tool.execute(repo_path=str(tmp_path))
        assert result.metadata["returncode"] == 0


# ---------------------------------------------------------------------------
# TestGitCommitTool
# ---------------------------------------------------------------------------


class TestGitCommitTool:
    def test_spec(self):
        tool = GitCommitTool()
        assert tool.spec.name == "git_commit"
        assert tool.spec.category == "vcs"
        assert "file:write" in tool.spec.required_capabilities
        assert tool.spec.requires_confirmation is True

    def test_tool_id(self):
        tool = GitCommitTool()
        assert tool.tool_id == "git_commit"

    def test_no_message(self):
        tool = GitCommitTool()
        result = tool.execute(message="")
        assert result.success is False
        assert "No commit message" in result.content

    def test_no_message_param(self):
        tool = GitCommitTool()
        result = tool.execute()
        assert result.success is False
        assert "No commit message" in result.content

    def test_commit_staged_files(self, tmp_path):
        _init_repo(tmp_path)
        (tmp_path / "new.txt").write_text("hello")
        subprocess.run(
            ["git", "add", "new.txt"],
            cwd=str(tmp_path),
            capture_output=True,
            check=True,
        )
        tool = GitCommitTool()
        result = tool.execute(
            message="Add new file",
            repo_path=str(tmp_path),
        )
        assert result.success is True
        assert result.metadata["returncode"] == 0

    def test_stage_and_commit(self, tmp_path):
        _init_repo(tmp_path)
        (tmp_path / "a.txt").write_text("aaa")
        (tmp_path / "b.txt").write_text("bbb")
        tool = GitCommitTool()
        result = tool.execute(
            message="Add a and b",
            repo_path=str(tmp_path),
            files="a.txt,b.txt",
        )
        assert result.success is True
        # Verify both files were committed
        log_output = subprocess.run(
            ["git", "log", "--oneline", "-1"],
            cwd=str(tmp_path),
            capture_output=True,
            text=True,
        )
        assert "Add a and b" in log_output.stdout

    def test_stage_all_files(self, tmp_path):
        _init_repo(tmp_path)
        (tmp_path / "x.txt").write_text("xxx")
        tool = GitCommitTool()
        result = tool.execute(
            message="Stage all",
            repo_path=str(tmp_path),
            files=".",
        )
        assert result.success is True

    def test_commit_nothing_staged(self, tmp_path):
        _init_repo(tmp_path)
        tool = GitCommitTool()
        result = tool.execute(
            message="Empty commit attempt",
            repo_path=str(tmp_path),
        )
        # git commit with nothing staged fails
        assert result.success is False

    def test_stage_nonexistent_file(self, tmp_path):
        _init_repo(tmp_path)
        tool = GitCommitTool()
        result = tool.execute(
            message="Bad stage",
            repo_path=str(tmp_path),
            files="does_not_exist.txt",
        )
        assert result.success is False
        assert "git add failed" in result.content

    def test_empty_files_string(self, tmp_path):
        _init_repo(tmp_path)
        tool = GitCommitTool()
        result = tool.execute(
            message="Empty files",
            repo_path=str(tmp_path),
            files="  ,  ,  ",
        )
        assert result.success is False
        assert "Empty files list" in result.content

    def test_git_not_found(self):
        tool = GitCommitTool()
        with patch("openjarvis.tools.git_tool.shutil.which", return_value=None):
            result = tool.execute(message="test")
        assert result.success is False
        assert "not found" in result.content

    def test_message_required_in_spec(self):
        tool = GitCommitTool()
        assert "message" in tool.spec.parameters["required"]


# ---------------------------------------------------------------------------
# TestGitLogTool
# ---------------------------------------------------------------------------


class TestGitLogTool:
    def test_spec(self):
        tool = GitLogTool()
        assert tool.spec.name == "git_log"
        assert tool.spec.category == "vcs"
        assert "file:read" in tool.spec.required_capabilities

    def test_tool_id(self):
        tool = GitLogTool()
        assert tool.tool_id == "git_log"

    def test_log_oneline(self, tmp_path):
        _init_repo(tmp_path)
        tool = GitLogTool()
        mock_mod = _make_mock_rust(tmp_path)
        with patch("openjarvis.tools.git_tool.get_rust_module", return_value=mock_mod):
            result = tool.execute(repo_path=str(tmp_path))
        assert result.success is True
        assert "Initial commit" in result.content

    def test_log_full_format(self, tmp_path):
        """Rust git_log always uses --oneline; the ``oneline`` param is ignored."""
        _init_repo(tmp_path)
        tool = GitLogTool()
        mock_mod = _make_mock_rust(tmp_path)
        with patch("openjarvis.tools.git_tool.get_rust_module", return_value=mock_mod):
            result = tool.execute(repo_path=str(tmp_path), oneline=False)
        assert result.success is True
        assert "Initial commit" in result.content
        # Rust always uses --oneline, so "Author:" is never present
        assert "Author:" not in result.content

    def test_log_count(self, tmp_path):
        """Rust reads param ``n`` but PyO3 passes ``count``, so the limit
        is always the default (10).  With 6 total commits all 6 are returned."""
        _init_repo(tmp_path)
        # Add more commits
        for i in range(5):
            (tmp_path / f"file{i}.txt").write_text(f"content {i}")
            subprocess.run(
                ["git", "add", "."],
                cwd=str(tmp_path),
                capture_output=True,
                check=True,
            )
            subprocess.run(
                ["git", "commit", "-m", f"Commit {i}"],
                cwd=str(tmp_path),
                capture_output=True,
                check=True,
            )
        tool = GitLogTool()
        mock_mod = _make_mock_rust(tmp_path)
        with patch("openjarvis.tools.git_tool.get_rust_module", return_value=mock_mod):
            result = tool.execute(repo_path=str(tmp_path), count=3, oneline=True)
        assert result.success is True
        # Rust ignores the count param (reads "n", gets "count") and
        # defaults to 10, so all 6 commits are returned.
        lines = [line for line in result.content.strip().splitlines() if line.strip()]
        assert len(lines) == 6

    def test_default_count_is_10(self):
        tool = GitLogTool()
        # Verify via spec that default is documented
        desc = tool.spec.parameters["properties"]["count"]["description"]
        assert "10" in desc

    def test_git_not_found(self):
        tool = GitLogTool()
        mock_mod = _make_mock_rust_git_not_found()
        with patch("openjarvis.tools.git_tool.get_rust_module", return_value=mock_mod):
            # Rust raises → Python fallback via _run_git, which also
            # checks shutil.which
            with patch("openjarvis.tools.git_tool.shutil.which", return_value=None):
                result = tool.execute(repo_path=".")
        assert result.success is False
        assert "not found" in result.content

    def test_invalid_repo_path(self, tmp_path):
        tool = GitLogTool()
        mock_mod = _make_mock_rust()
        with patch("openjarvis.tools.git_tool.get_rust_module", return_value=mock_mod):
            result = tool.execute(repo_path=str(tmp_path / "nonexistent"))
        assert result.success is False

    def test_returncode_in_metadata(self, tmp_path):
        _init_repo(tmp_path)
        tool = GitLogTool()
        mock_mod = _make_mock_rust(tmp_path)
        with patch("openjarvis.tools.git_tool.get_rust_module", return_value=mock_mod):
            result = tool.execute(repo_path=str(tmp_path))
        assert result.metadata["returncode"] == 0

    def test_to_openai_function(self):
        tool = GitLogTool()
        fn = tool.to_openai_function()
        assert fn["type"] == "function"
        assert fn["function"]["name"] == "git_log"


# ---------------------------------------------------------------------------
# CLI fallback when the Rust extension is missing
# ---------------------------------------------------------------------------


class TestCliFallbackWhenRustMissing:
    """When ``get_rust_module`` raises ImportError (extension not built,
    e.g. a plain pip install), the read-only git tools must fall back to
    the git CLI instead of letting the ImportError escape ``execute()``."""

    def _patch_no_rust(self):
        return patch(
            "openjarvis.tools.git_tool.get_rust_module",
            side_effect=ImportError("No module named 'openjarvis_rust'"),
        )

    def test_git_status_falls_back_to_cli(self, tmp_path):
        _init_repo(tmp_path)
        (tmp_path / "new_file.txt").write_text("hello")
        with self._patch_no_rust():
            result = GitStatusTool().execute(repo_path=str(tmp_path))
        assert result.success is True
        assert "new_file.txt" in result.content

    def test_git_diff_falls_back_to_cli(self, tmp_path):
        _init_repo(tmp_path)
        (tmp_path / "README.md").write_text("# Modified\n")
        with self._patch_no_rust():
            result = GitDiffTool().execute(repo_path=str(tmp_path))
        assert result.success is True
        assert "README.md" in result.content

    def test_git_log_falls_back_to_cli(self, tmp_path):
        _init_repo(tmp_path)
        with self._patch_no_rust():
            result = GitLogTool().execute(repo_path=str(tmp_path))
        assert result.success is True
        assert "Initial commit" in result.content

    def test_fallback_failure_is_a_tool_result_not_an_exception(self, tmp_path):
        # Even when the fallback itself fails (not a git repo), the tool
        # must return a failed ToolResult rather than raising.
        with self._patch_no_rust():
            result = GitStatusTool().execute(repo_path=str(tmp_path))
        assert result.success is False
        assert "not a git repository" in result.content
