"""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,
    _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)


# ---------------------------------------------------------------------------
# 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


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
