"""Tests for the tool-use loop (Builder/UI Builder stages)."""

from __future__ import annotations

import json
import sys
from pathlib import Path
from unittest.mock import AsyncMock

import pytest

from productteam.tool_loop import (
    BUILDER_TOOLS,
    _MAX_HISTORY_EXCHANGES,
    _SHELL_FEATURE_RE,
    _build_subprocess_env,
    _check_write_restricted,
    _execute_tool,
    _truncate_messages,
    _validate_command,
    _validate_path,
    run_tool_loop,
)


# ---------------------------------------------------------------------------
# Path validation tests
# ---------------------------------------------------------------------------


def test_validate_path_relative_ok(tmp_path):
    """Valid relative path returns a resolved Path."""
    result = _validate_path("src/main.py", tmp_path)
    assert isinstance(result, Path)


def test_validate_path_traversal_rejected(tmp_path):
    """Path with .. is rejected."""
    result = _validate_path("../../../etc/passwd", tmp_path)
    assert isinstance(result, str)
    assert "traversal" in result.lower()


def test_validate_path_absolute_rejected(tmp_path):
    """Absolute path is rejected."""
    result = _validate_path("/etc/passwd", tmp_path)
    assert isinstance(result, str)
    # Error message varies by platform (Absolute or escapes)
    assert "not allowed" in result.lower() or "escapes" in result.lower()


def test_validate_path_within_project(tmp_path):
    """Path that resolves within project is accepted."""
    (tmp_path / "src").mkdir()
    result = _validate_path("src", tmp_path)
    assert isinstance(result, Path)


def test_validate_path_shared_prefix_sibling_rejected(tmp_path):
    """A sibling directory sharing a text prefix must be rejected.

    Regression: str.startswith('/tmp/proj') passes for '/tmp/proj2'.
    """
    import tempfile, os
    # Create two siblings: proj and proj2
    parent = Path(tempfile.mkdtemp())
    proj = parent / "proj"
    proj2 = parent / "proj2"
    proj.mkdir()
    proj2.mkdir()
    target = proj2 / "secret.txt"
    target.write_text("secret")

    # Attempt to reach proj2/secret.txt from proj's sandbox
    # Use a relative path that resolves to the sibling
    # Since _validate_path checks .. explicitly, we test via symlink
    result = _validate_path("../proj2/secret.txt", proj)
    assert isinstance(result, str), "Shared-prefix sibling should be rejected"

    # Cleanup
    import shutil
    shutil.rmtree(parent)


def test_validate_path_symlink_escape_rejected(tmp_path):
    """A path that resolves outside the project (e.g. via symlink) must be rejected.

    Mocks Path.resolve() to simulate symlink behavior without requiring
    OS symlink privileges (which need admin on Windows).
    """
    from unittest.mock import patch

    project = tmp_path / "project"
    project.mkdir()
    outside_target = tmp_path / "outside" / "secret.txt"

    # Simulate: (project / "escape_link/secret.txt").resolve() -> outside_target
    # This is exactly what happens when escape_link is a symlink to ../outside
    original_resolve = Path.resolve

    def fake_resolve(self, strict=False):
        if "escape_link" in str(self):
            return outside_target
        return original_resolve(self, strict=strict)

    with patch.object(Path, "resolve", fake_resolve):
        result = _validate_path("escape_link/secret.txt", project)

    assert isinstance(result, str), "Symlink escape should be rejected"
    assert "escapes" in result.lower()


# ---------------------------------------------------------------------------
# Command validation tests
# ---------------------------------------------------------------------------


def test_validate_command_normal():
    """Normal command passes validation."""
    assert _validate_command("pytest tests/ -v") is None


def test_validate_command_ssh_blocked():
    """Command accessing .ssh is blocked."""
    result = _validate_command("cat ~/.ssh/id_rsa")
    assert result is not None
    assert ".ssh" in result


def test_validate_command_aws_blocked():
    """Command accessing .aws is blocked."""
    result = _validate_command("cat ~/.aws/credentials")
    assert result is not None
    assert ".aws" in result


def test_validate_command_bashrc_blocked():
    """Command accessing .bashrc is blocked."""
    result = _validate_command("source ~/.bashrc")
    assert result is not None


def test_validate_command_env_cred_blocked():
    """Command reading credential env vars is blocked."""
    result = _validate_command("printenv ANTHROPIC_API_KEY")
    assert result is not None


def test_validate_command_env_pipe_blocked():
    """env | grep pattern is blocked."""
    assert _validate_command("env | grep KEY") is not None
    assert _validate_command("env|grep SECRET") is not None


def test_validate_command_proc_environ_blocked():
    """Reading /proc/self/environ is blocked."""
    result = _validate_command("cat /proc/self/environ")
    assert result is not None


def test_validate_command_export_dump_blocked():
    """export | grep pattern is blocked."""
    assert _validate_command("export | grep TOKEN") is not None


def test_validate_command_cred_keyword_in_test_file_allowed():
    """File names containing 'api_key' are allowed (e.g. test_api_key.py)."""
    # Normal file operation — no env-reading command
    assert _validate_command("python test_api_key.py") is None


def test_validate_command_poetry_env_allowed():
    """poetry env use is a legitimate command and should not be blocked."""
    assert _validate_command("poetry env use python3.11") is None


def test_validate_command_echo_env_var_blocked():
    """echo $API_KEY is blocked."""
    result = _validate_command("echo $API_KEY")
    assert result is not None


def test_validate_command_blocks_powershell_env_access():
    """PowerShell $env:SECRET is blocked."""
    result = _validate_command("echo $env:ANTHROPIC_API_KEY")
    assert result is not None
    assert "environment" in result.lower()


def test_validate_command_blocks_get_childitem_env():
    """PowerShell Get-ChildItem Env: is blocked."""
    result = _validate_command("Get-ChildItem Env:")
    assert result is not None
    assert "environment" in result.lower()


def test_validate_command_blocks_dotnet_env_access():
    """.NET [System.Environment]::GetEnvironmentVariable is blocked."""
    result = _validate_command("[System.Environment]::GetEnvironmentVariable('SECRET')")
    assert result is not None
    assert "environment" in result.lower()


# ---------------------------------------------------------------------------
# Tool execution tests
# ---------------------------------------------------------------------------


def test_execute_read_file(tmp_path):
    """read_file returns file content."""
    (tmp_path / "test.txt").write_text("hello world")
    result = _execute_tool("read_file", {"path": "test.txt"}, tmp_path)
    assert result == "hello world"


def test_execute_read_file_not_found(tmp_path):
    """read_file returns error for missing file."""
    result = _execute_tool("read_file", {"path": "nonexistent.txt"}, tmp_path)
    data = json.loads(result)
    assert "error" in data


def test_execute_read_file_large_file_truncated(tmp_path):
    """read_file truncates files larger than 100KB."""
    large_content = "x" * (200 * 1024)  # 200KB
    (tmp_path / "big.txt").write_text(large_content)
    result = _execute_tool("read_file", {"path": "big.txt"}, tmp_path)
    assert "[TRUNCATED:" in result
    assert "200KB" not in result  # should not contain the full file
    # Should contain approximately 100KB of content
    assert len(result) < 110 * 1024


def test_execute_read_file_traversal_blocked(tmp_path):
    """read_file rejects path traversal."""
    result = _execute_tool("read_file", {"path": "../../etc/passwd"}, tmp_path)
    data = json.loads(result)
    assert "error" in data


def test_execute_write_file(tmp_path):
    """write_file creates file with content."""
    result = _execute_tool(
        "write_file",
        {"path": "output.txt", "content": "test content"},
        tmp_path,
    )
    data = json.loads(result)
    assert data.get("success") is True
    assert (tmp_path / "output.txt").read_text() == "test content"


def test_execute_write_file_creates_dirs(tmp_path):
    """write_file creates parent directories."""
    _execute_tool(
        "write_file",
        {"path": "deep/nested/file.py", "content": "# code"},
        tmp_path,
    )
    assert (tmp_path / "deep" / "nested" / "file.py").exists()


def test_execute_write_file_traversal_blocked(tmp_path):
    """write_file rejects path traversal."""
    result = _execute_tool(
        "write_file",
        {"path": "../escape.txt", "content": "bad"},
        tmp_path,
    )
    data = json.loads(result)
    assert "error" in data


def test_execute_run_bash(tmp_path):
    """run_bash executes command and returns output."""
    cmd = "python -c \"print('hello')\"" if sys.platform == "win32" else "echo hello"
    result = _execute_tool("run_bash", {"command": cmd}, tmp_path)
    data = json.loads(result)
    if "error" in data and "OS error" in data["error"]:
        # Windows subprocess handle error under pytest capture — verify
        # structured error response instead of crash (production fix)
        assert isinstance(data["error"], str)
    else:
        assert "hello" in data.get("stdout", "")
        assert data.get("exit_code") == 0


def test_execute_run_bash_timeout(tmp_path):
    """run_bash returns timeout error for long commands."""
    cmd = "python -c \"import time; time.sleep(10)\"" if sys.platform == "win32" else "sleep 10"
    result = _execute_tool(
        "run_bash",
        {"command": cmd, "timeout_seconds": 1},
        tmp_path,
    )
    data = json.loads(result)
    if "error" in data and "OS error" in data["error"]:
        # Windows subprocess handle error — verify structured response
        assert isinstance(data["error"], str)
    else:
        assert "timed out" in data.get("error", "").lower()


def test_execute_run_bash_forbidden_path(tmp_path):
    """run_bash blocks commands accessing forbidden paths."""
    result = _execute_tool(
        "run_bash",
        {"command": "cat ~/.ssh/id_rsa"},
        tmp_path,
    )
    data = json.loads(result)
    assert "error" in data


# ---------------------------------------------------------------------------
# Subprocess environment isolation tests (P0 #2)
# ---------------------------------------------------------------------------


def test_build_subprocess_env_strips_api_keys(tmp_path):
    """Sensitive env vars like API keys must NOT appear in subprocess env."""
    import os
    # Inject fake sensitive vars into the current process env
    sensitive_vars = [
        "ANTHROPIC_API_KEY", "OPENAI_API_KEY", "AWS_SECRET_ACCESS_KEY",
        "GITHUB_TOKEN", "DATABASE_URL", "SECRET_KEY",
    ]
    old_values = {}
    for var in sensitive_vars:
        old_values[var] = os.environ.get(var)
        os.environ[var] = "test-secret-value"

    try:
        env = _build_subprocess_env(tmp_path)
        for var in sensitive_vars:
            assert var not in env, f"{var} leaked into subprocess environment"
    finally:
        # Restore original values
        for var in sensitive_vars:
            if old_values[var] is None:
                os.environ.pop(var, None)
            else:
                os.environ[var] = old_values[var]


def test_build_subprocess_env_includes_safe_vars(tmp_path):
    """Safe vars like PATH must be present in subprocess env."""
    env = _build_subprocess_env(tmp_path)
    assert "PATH" in env
    assert "PRODUCTTEAM_SANDBOXED" in env
    assert env["PRODUCTTEAM_SANDBOXED"] == "1"


def test_build_subprocess_env_includes_python_on_path(tmp_path):
    """Python executable directory must be on PATH."""
    import sys
    env = _build_subprocess_env(tmp_path)
    python_dir = str(Path(sys.executable).parent)
    assert python_dir in env["PATH"]


def test_execute_list_dir(tmp_path):
    """list_dir returns directory contents."""
    (tmp_path / "file1.txt").write_text("a")
    (tmp_path / "subdir").mkdir()
    result = _execute_tool("list_dir", {"path": "."}, tmp_path)
    assert "[FILE] file1.txt" in result
    assert "[DIR] subdir" in result


def test_execute_list_dir_not_found(tmp_path):
    """list_dir returns error for missing directory."""
    result = _execute_tool("list_dir", {"path": "nonexistent"}, tmp_path)
    data = json.loads(result)
    assert "error" in data


def test_execute_unknown_tool(tmp_path):
    """Unknown tool returns error."""
    result = _execute_tool("unknown_tool", {}, tmp_path)
    data = json.loads(result)
    assert "error" in data


# ---------------------------------------------------------------------------
# Tool loop tests
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_tool_loop_text_only_response(tmp_path):
    """Loop terminates on text-only response (no tool calls)."""
    mock_provider = AsyncMock()
    mock_provider.complete_with_tools = AsyncMock(return_value={
        "role": "assistant",
        "content": [{"type": "text", "text": "Build complete. Ready for review."}],
        "stop_reason": "end_turn",
    })

    result = await run_tool_loop(
        provider=mock_provider,
        system_prompt="You are a builder.",
        initial_user_message="Build something.",
        project_dir=tmp_path,
    )

    assert result.status == "complete"
    assert "Ready for review" in result.final_text
    assert result.tool_call_count == 0


@pytest.mark.asyncio
async def test_tool_loop_one_tool_call(tmp_path):
    """Loop executes one tool call then completes."""
    (tmp_path / "existing.txt").write_text("file content")

    # First call: tool_use, second call: text-only
    mock_provider = AsyncMock()
    mock_provider.complete_with_tools = AsyncMock(side_effect=[
        {
            "role": "assistant",
            "content": [{
                "type": "tool_use",
                "id": "toolu_001",
                "name": "read_file",
                "input": {"path": "existing.txt"},
            }],
            "stop_reason": "tool_use",
        },
        {
            "role": "assistant",
            "content": [{"type": "text", "text": "Done."}],
            "stop_reason": "end_turn",
        },
    ])

    result = await run_tool_loop(
        provider=mock_provider,
        system_prompt="Builder",
        initial_user_message="Read the file.",
        project_dir=tmp_path,
    )

    assert result.status == "complete"
    assert result.tool_call_count == 1


@pytest.mark.asyncio
async def test_tool_loop_write_file(tmp_path):
    """Loop writes a file via write_file tool."""
    mock_provider = AsyncMock()
    mock_provider.complete_with_tools = AsyncMock(side_effect=[
        {
            "role": "assistant",
            "content": [{
                "type": "tool_use",
                "id": "toolu_001",
                "name": "write_file",
                "input": {"path": "hello.py", "content": "print('hello')"},
            }],
            "stop_reason": "tool_use",
        },
        {
            "role": "assistant",
            "content": [{"type": "text", "text": "File written."}],
            "stop_reason": "end_turn",
        },
    ])

    result = await run_tool_loop(
        provider=mock_provider,
        system_prompt="Builder",
        initial_user_message="Write a file.",
        project_dir=tmp_path,
    )

    assert result.status == "complete"
    assert (tmp_path / "hello.py").read_text() == "print('hello')"


@pytest.mark.asyncio
async def test_tool_loop_max_calls_exceeded(tmp_path):
    """Loop returns max_calls when limit is exceeded."""
    mock_provider = AsyncMock()
    # Always return a tool call
    mock_provider.complete_with_tools = AsyncMock(return_value={
        "role": "assistant",
        "content": [{
            "type": "tool_use",
            "id": "toolu_001",
            "name": "list_dir",
            "input": {"path": "."},
        }],
        "stop_reason": "tool_use",
    })

    result = await run_tool_loop(
        provider=mock_provider,
        system_prompt="Builder",
        initial_user_message="Keep going.",
        project_dir=tmp_path,
        max_tool_calls=3,
    )

    # Should hit either max_calls or stuck (loop detection)
    assert result.status in ("max_calls", "stuck")
    assert result.tool_call_count >= 3


@pytest.mark.asyncio
async def test_tool_loop_infinite_loop_detection(tmp_path):
    """Loop detects identical tool calls 5 times in a row (default window)."""
    call_count = 0

    async def mock_complete_with_tools(*args, **kwargs):
        nonlocal call_count
        call_count += 1
        return {
            "role": "assistant",
            "content": [{
                "type": "tool_use",
                "id": f"toolu_{call_count:03d}",
                "name": "read_file",
                "input": {"path": "same_file.txt"},
            }],
            "stop_reason": "tool_use",
        }

    mock_provider = AsyncMock()
    mock_provider.complete_with_tools = mock_complete_with_tools

    # Create the file so read doesn't error
    (tmp_path / "same_file.txt").write_text("content")

    result = await run_tool_loop(
        provider=mock_provider,
        system_prompt="Builder",
        initial_user_message="Read the same file over and over.",
        project_dir=tmp_path,
        max_tool_calls=50,
    )

    assert result.status == "stuck"
    assert "Loop detected" in result.final_text
    assert result.tool_call_count == 5  # Window is now 5, not 3


def test_builder_tools_count():
    """Exactly 4 builder tools defined."""
    assert len(BUILDER_TOOLS) == 4
    names = {t["name"] for t in BUILDER_TOOLS}
    assert names == {"read_file", "write_file", "run_bash", "list_dir"}


# ---------------------------------------------------------------------------
# v2.4.0 Security hardening tests
# ---------------------------------------------------------------------------


# --- shell=False (Fix 2) ---

class TestShellFeatureDetection:
    """Tests for _SHELL_FEATURE_RE pattern matching."""

    def test_simple_command_no_shell(self):
        """Simple commands should not match shell features."""
        assert not _SHELL_FEATURE_RE.search("python -c 'print(1)'")
        assert not _SHELL_FEATURE_RE.search("pytest tests/ -v")
        assert not _SHELL_FEATURE_RE.search("pip install requests")

    def test_pipe_needs_shell(self):
        """Pipe character triggers shell=True."""
        assert _SHELL_FEATURE_RE.search("cat file.txt | grep error")

    def test_redirect_needs_shell(self):
        """Redirect operators trigger shell=True."""
        assert _SHELL_FEATURE_RE.search("echo hello > output.txt")
        assert _SHELL_FEATURE_RE.search("cat < input.txt")

    def test_chain_needs_shell(self):
        """&& and || trigger shell=True."""
        assert _SHELL_FEATURE_RE.search("mkdir build && cd build")
        assert _SHELL_FEATURE_RE.search("make || echo failed")

    def test_semicolon_needs_shell(self):
        """Semicolon triggers shell=True."""
        assert _SHELL_FEATURE_RE.search("echo a; echo b")

    def test_backtick_needs_shell(self):
        """Backtick substitution triggers shell=True."""
        assert _SHELL_FEATURE_RE.search("echo `date`")

    def test_dollar_paren_needs_shell(self):
        """$() substitution triggers shell=True."""
        assert _SHELL_FEATURE_RE.search("echo $(whoami)")


def test_run_bash_simple_command_uses_shell_false(tmp_path):
    """Simple command runs with shell=False (no shell metacharacters)."""
    cmd = "python -c \"print('hello')\""
    result = _execute_tool("run_bash", {"command": cmd}, tmp_path)
    data = json.loads(result)
    if "error" in data and "OS error" in data["error"]:
        # Windows subprocess handle error under pytest — acceptable
        assert isinstance(data["error"], str)
    else:
        assert "hello" in data.get("stdout", "")


def test_run_bash_pipe_falls_back_to_shell(tmp_path):
    """Command with pipe falls back to shell=True."""
    if sys.platform == "win32":
        cmd = "echo hello_pipe | python -c \"import sys; print(sys.stdin.read().strip())\""
    else:
        cmd = "echo hello_pipe | cat"
    result = _execute_tool("run_bash", {"command": cmd}, tmp_path)
    data = json.loads(result)
    if "error" in data and "OS error" in data["error"]:
        assert isinstance(data["error"], str)
    else:
        assert "hello_pipe" in data.get("stdout", "")


# --- Write restrictions (Fix 3) ---

class TestWriteRestricted:
    """Tests for _check_write_restricted path blocking."""

    def test_claude_dir_blocked(self):
        assert _check_write_restricted(".claude/skills/foo.md") is not None

    def test_claude_root_blocked(self):
        assert _check_write_restricted(".claude/anything") is not None

    def test_productteam_dir_blocked(self):
        assert _check_write_restricted(".productteam/state.json") is not None

    def test_productteam_config_blocked(self):
        assert _check_write_restricted(".productteam/config.toml") is not None

    def test_productteam_sprints_allowed(self):
        """Planner needs .productteam/sprints/ — must be allowed."""
        assert _check_write_restricted(".productteam/sprints/sprint-1.json") is None

    def test_productteam_sprints_subdir_allowed(self):
        assert _check_write_restricted(".productteam/sprints/deep/file.md") is None

    def test_normal_path_allowed(self):
        assert _check_write_restricted("src/main.py") is None

    def test_readme_allowed(self):
        assert _check_write_restricted("README.md") is None


def test_execute_write_file_claude_dir_blocked(tmp_path):
    """write_file tool blocks writes to .claude/ directory."""
    result = _execute_tool(
        "write_file",
        {"path": ".claude/skills/evil.md", "content": "pwned"},
        tmp_path,
    )
    data = json.loads(result)
    assert "error" in data
    assert ".claude" in data["error"]
    # File must not exist
    assert not (tmp_path / ".claude" / "skills" / "evil.md").exists()


def test_execute_write_file_productteam_state_blocked(tmp_path):
    """write_file tool blocks writes to .productteam/state.json."""
    result = _execute_tool(
        "write_file",
        {"path": ".productteam/state.json", "content": "{}"},
        tmp_path,
    )
    data = json.loads(result)
    assert "error" in data
    assert ".productteam" in data["error"]


def test_execute_write_file_productteam_sprints_allowed(tmp_path):
    """write_file tool allows writes to .productteam/sprints/."""
    result = _execute_tool(
        "write_file",
        {"path": ".productteam/sprints/sprint-1.json", "content": "{}"},
        tmp_path,
    )
    data = json.loads(result)
    assert data.get("success") is True
    assert (tmp_path / ".productteam" / "sprints" / "sprint-1.json").exists()


# ---------------------------------------------------------------------------
# Conversation history truncation tests
# ---------------------------------------------------------------------------


def test_truncate_messages_preserves_first_message():
    """Initial task message is always in the truncated result."""
    messages = [{"role": "user", "content": "INITIAL TASK"}]
    for i in range(30):
        messages.append({"role": "assistant", "content": [{"type": "tool_use", "id": f"t{i}"}]})
        messages.append({"role": "user", "content": [{"type": "tool_result", "content": f"result {i}"}]})

    truncated = _truncate_messages(messages)
    assert truncated[0] == {"role": "user", "content": "INITIAL TASK"}


def test_truncate_messages_limits_history():
    """After truncation, only first + last N exchange pairs remain."""
    messages = [{"role": "user", "content": "task"}]
    for i in range(30):
        messages.append({"role": "assistant", "content": f"call {i}"})
        messages.append({"role": "user", "content": f"result {i}"})

    truncated = _truncate_messages(messages)
    # Should be: 1 (initial) + MAX_HISTORY_EXCHANGES * 2 (exchanges)
    assert len(truncated) == 1 + _MAX_HISTORY_EXCHANGES * 2


@pytest.mark.asyncio
async def test_token_counts_in_tool_loop_result(tmp_path):
    """ToolLoopResult accumulates token counts from provider responses."""
    mock_provider = AsyncMock()
    mock_provider.complete_with_tools = AsyncMock(return_value={
        "role": "assistant",
        "content": [{"type": "text", "text": "Done."}],
        "stop_reason": "end_turn",
        "usage": {"input_tokens": 1000, "output_tokens": 50},
    })

    result = await run_tool_loop(
        provider=mock_provider,
        system_prompt="You are a builder.",
        initial_user_message="Build something.",
        project_dir=tmp_path,
    )

    assert result.input_tokens == 1000
    assert result.output_tokens == 50


def test_truncate_messages_no_op_when_short():
    """Short histories are returned unchanged."""
    messages = [
        {"role": "user", "content": "task"},
        {"role": "assistant", "content": "call 1"},
        {"role": "user", "content": "result 1"},
    ]
    truncated = _truncate_messages(messages)
    assert truncated == messages


# ---------------------------------------------------------------------------
# v2.5.2 Loop detection window tests
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_loop_detection_window_default_is_5(tmp_path):
    """Default loop detection window is 5 consecutive identical calls."""
    call_count = 0

    async def always_same_call(*args, **kwargs):
        nonlocal call_count
        call_count += 1
        return {
            "role": "assistant",
            "content": [{
                "type": "tool_use",
                "id": f"toolu_{call_count:03d}",
                "name": "list_dir",
                "input": {"path": "."},
            }],
            "stop_reason": "tool_use",
        }

    (tmp_path / "dummy.txt").write_text("x")
    mock_provider = AsyncMock()
    mock_provider.complete_with_tools = always_same_call

    result = await run_tool_loop(
        provider=mock_provider,
        system_prompt="test",
        initial_user_message="go",
        project_dir=tmp_path,
        max_tool_calls=50,
    )

    assert result.status == "stuck"
    assert result.tool_call_count == 5  # Triggered after 5, not 3


@pytest.mark.asyncio
async def test_loop_detection_window_configurable(tmp_path):
    """loop_detection_window parameter controls when stuck is triggered."""
    call_count = 0

    async def always_same_call(*args, **kwargs):
        nonlocal call_count
        call_count += 1
        return {
            "role": "assistant",
            "content": [{
                "type": "tool_use",
                "id": f"toolu_{call_count:03d}",
                "name": "read_file",
                "input": {"path": "test.txt"},
            }],
            "stop_reason": "tool_use",
        }

    (tmp_path / "test.txt").write_text("content")
    mock_provider = AsyncMock()
    mock_provider.complete_with_tools = always_same_call

    result = await run_tool_loop(
        provider=mock_provider,
        system_prompt="test",
        initial_user_message="go",
        project_dir=tmp_path,
        max_tool_calls=50,
        loop_detection_window=8,
    )

    assert result.status == "stuck"
    assert result.tool_call_count == 8  # Triggered at 8, not 5


@pytest.mark.asyncio
async def test_tool_loop_result_has_cache_fields(tmp_path):
    """ToolLoopResult includes cache_creation and cache_read token fields."""
    mock_provider = AsyncMock()
    mock_provider.complete_with_tools = AsyncMock(return_value={
        "role": "assistant",
        "content": [{"type": "text", "text": "Done."}],
        "stop_reason": "end_turn",
        "usage": {
            "input_tokens": 1000,
            "output_tokens": 50,
            "cache_creation_input_tokens": 800,
            "cache_read_input_tokens": 200,
        },
    })

    result = await run_tool_loop(
        provider=mock_provider,
        system_prompt="You are a builder.",
        initial_user_message="Build something.",
        project_dir=tmp_path,
    )

    assert result.input_tokens == 1000
    assert result.output_tokens == 50
    assert result.cache_creation_input_tokens == 800
    assert result.cache_read_input_tokens == 200
