"""Tests for built-in file and command tools."""

import asyncio
import os
from pathlib import Path
import sys
import time

import pytest

from yier_agents.src.tool import ToolContext
from yier_agents.src.tools import (
    BackgroundCommandManager,
    ListFilesTool,
    ReadFileTool,
    ReplaceInFileTool,
    RunCommandTool,
    SearchFilesTool,
    create_list_background_commands_tool,
    WriteFileTool,
    create_read_background_command_tool,
    create_list_files_tool,
    create_read_file_tool,
    create_replace_in_file_tool,
    create_run_command_tool,
    create_search_files_tool,
    create_send_background_command_input_tool,
    create_start_background_command_tool,
    create_stop_background_command_tool,
    create_wait_background_command_tool,
    create_write_file_tool,
)


def _normalize_newlines(text: str) -> str:
    """Normalize platform-specific newlines for assertions."""
    return text.replace("\r\n", "\n")


@pytest.fixture
def tool_context():
    """Create a test tool context."""
    return ToolContext(
        session_id="test-session",
        message_id="test-message",
        call_id="test-call",
    )


@pytest.mark.asyncio
async def test_read_file_tool_reads_with_line_numbers(tmp_path, tool_context):
    """Test read_file returns numbered lines."""
    file_path = tmp_path / "demo.txt"
    file_path.write_text("alpha\nbeta\ngamma\n", encoding="utf-8")

    params = ReadFileTool.parameters(path=str(file_path), start_line=2, end_line=3)
    result = await ReadFileTool.execute(params, tool_context)

    assert "File:" in result.content
    assert "2: beta" in result.content
    assert "3: gamma" in result.content
    assert result.metadata["start_line"] == 2
    assert result.raw["kind"] == "file_read"
    assert result.raw["path"] == str(file_path)
    assert result.raw["lines"] == [
        {"number": 2, "text": "beta"},
        {"number": 3, "text": "gamma"},
    ]


@pytest.mark.asyncio
async def test_list_files_tool_lists_directory(tmp_path, tool_context):
    """Test list_files returns files and directories."""
    (tmp_path / "scripts").mkdir()
    (tmp_path / "scripts" / "demo.py").write_text("print('hi')\n", encoding="utf-8")

    params = ListFilesTool.parameters(path=str(tmp_path), recursive=True)
    result = await ListFilesTool.execute(params, tool_context)

    assert "[dir] scripts" in result.content
    assert "[file] scripts/demo.py" in result.content
    assert result.metadata["count"] >= 2
    assert result.raw["kind"] == "file_list"
    assert any(entry["path"] == "scripts" for entry in result.raw["entries"])
    assert any(entry["path"] == "scripts/demo.py" for entry in result.raw["entries"])


@pytest.mark.asyncio
async def test_run_command_tool_executes_command(tmp_path, tool_context):
    """Test run_command captures stdout and exit code."""
    script_path = tmp_path / "demo.py"
    script_path.write_text("print('hello from command tool')\n", encoding="utf-8")

    params = RunCommandTool.parameters(
        command=f"python3 {script_path.name}",
        cwd=str(tmp_path),
    )
    result = await RunCommandTool.execute(params, tool_context)

    assert "Command:" in result.content
    assert "[stdout]" in result.content
    assert "hello from command tool" in result.content
    assert result.metadata["exit_code"] == 0
    assert result.raw["kind"] == "shell_command"
    assert result.raw["request"]["command"] == f"python3 {script_path.name}"
    assert result.raw["request"]["cwd"] == str(tmp_path)
    assert _normalize_newlines(result.raw["streams"]["stdout"]["text"]) == (
        "hello from command tool\n"
    )
    assert result.raw["streams"]["stderr"]["text"] == ""
    assert [event["type"] for event in result.raw["events"]] == [
        "started",
        "stdout",
        "state_changed",
        "exit",
    ]


@pytest.mark.asyncio
async def test_workspace_tool_factory_restricts_paths(tmp_path, tool_context):
    """Test scoped file tool blocks access outside allowed roots."""
    allowed_dir = tmp_path / "allowed"
    allowed_dir.mkdir()
    blocked_dir = tmp_path / "blocked"
    blocked_dir.mkdir()
    blocked_file = blocked_dir / "secret.txt"
    blocked_file.write_text("secret\n", encoding="utf-8")

    read_tool = create_read_file_tool([allowed_dir])

    params = read_tool.parameters(path=str(blocked_file))
    with pytest.raises(PermissionError):
        await read_tool.execute(params, tool_context)


@pytest.mark.asyncio
async def test_run_command_tool_rejects_shell_operators(tmp_path, tool_context):
    """Test run_command blocks chained shell commands."""
    run_tool = create_run_command_tool([tmp_path])

    params = run_tool.parameters(command="python3 demo.py && echo done", cwd=str(tmp_path))
    with pytest.raises(ValueError):
        await run_tool.execute(params, tool_context)


@pytest.mark.asyncio
async def test_run_command_tool_allows_pipes_in_shell_mode(tmp_path, tool_context):
    """Test run_command can opt into shell features like pipelines."""
    run_tool = create_run_command_tool(
        [tmp_path],
        allow_shell=True,
    )
    command = "printf 'alpha\\nbeta\\n' | grep beta"
    if os.name == "nt":
        command = "(echo alpha & echo beta) | findstr beta"

    params = run_tool.parameters(
        command=command,
        cwd=str(tmp_path),
    )
    result = await run_tool.execute(params, tool_context)

    assert "[stdout]" in result.content
    assert "beta" in result.content
    assert result.metadata["allow_shell"] is True
    assert result.raw["request"]["allow_shell"] is True


@pytest.mark.asyncio
async def test_background_command_tools_start_list_and_wait(tmp_path, tool_context):
    """Test long-running commands can continue in the background."""
    script_path = tmp_path / "background_demo.py"
    script_path.write_text(
        "\n".join(
            [
                "import sys",
                "import time",
                "print('started', flush=True)",
                "time.sleep(0.1)",
                "print('finished', flush=True)",
            ]
        ),
        encoding="utf-8",
    )
    command = f'"{sys.executable}" "{script_path.name}"'
    manager = BackgroundCommandManager([tmp_path], default_root=tmp_path)

    start_tool = create_start_background_command_tool(manager)
    list_tool = create_list_background_commands_tool(manager)
    wait_tool = create_wait_background_command_tool(manager)

    started = await start_tool.execute(
        start_tool.parameters(command=command, cwd=str(tmp_path)),
        tool_context,
    )
    session_id = started.metadata["session_id"]

    listed = await list_tool.execute(
        list_tool.parameters(include_completed=True),
        tool_context,
    )
    assert session_id in listed.content

    waited = await wait_tool.execute(
        wait_tool.parameters(
            session_id=session_id,
            timeout_seconds=2,
        ),
        tool_context,
    )
    assert "finished" in waited.content
    assert waited.metadata["running"] is False
    assert waited.raw["kind"] == "background_command"
    assert waited.raw["process"]["session_id"] == session_id
    assert waited.raw["process"]["state"] == "completed"
    assert waited.raw["streams"]["stdout"]["text"].startswith("started")
    assert waited.raw["latest_event_index"] is not None
    assert [event["type"] for event in waited.raw["events"]] == [
        "started",
        "stdout",
        "stdout",
        "state_changed",
        "exit",
    ]


@pytest.mark.asyncio
async def test_background_command_tools_stop_running_process(tmp_path, tool_context):
    """Test a running background process can be stopped."""
    script_path = tmp_path / "sleeper.py"
    script_path.write_text(
        "\n".join(
            [
                "import time",
                "time.sleep(30)",
            ]
        ),
        encoding="utf-8",
    )
    command = f'"{sys.executable}" "{script_path.name}"'
    manager = BackgroundCommandManager([tmp_path], default_root=tmp_path)

    start_tool = create_start_background_command_tool(manager)
    stop_tool = create_stop_background_command_tool(manager)

    started = await start_tool.execute(
        start_tool.parameters(command=command, cwd=str(tmp_path)),
        tool_context,
    )
    stopped = await stop_tool.execute(
        stop_tool.parameters(session_id=started.metadata["session_id"]),
        tool_context,
    )

    assert "Stopped background command" in stopped.content
    assert stopped.metadata["state"] == "stopped"
    assert stopped.raw["process"]["state"] == "stopped"
    assert stopped.raw["events"][-1]["type"] == "exit"


@pytest.mark.asyncio
async def test_background_command_tools_support_stdin(tmp_path, tool_context):
    """Test stdin can be sent to a background command."""
    script_path = tmp_path / "echo_input.py"
    script_path.write_text(
        "\n".join(
            [
                "import sys",
                "line = sys.stdin.readline().strip()",
                "print(f'received:{line}', flush=True)",
            ]
        ),
        encoding="utf-8",
    )
    command = f'"{sys.executable}" "{script_path.name}"'
    manager = BackgroundCommandManager([tmp_path], default_root=tmp_path)

    start_tool = create_start_background_command_tool(manager)
    input_tool = create_send_background_command_input_tool(manager)
    read_tool = create_read_background_command_tool(manager)
    wait_tool = create_wait_background_command_tool(manager)

    started = await start_tool.execute(
        start_tool.parameters(command=command, cwd=str(tmp_path)),
        tool_context,
    )
    session_id = started.metadata["session_id"]

    await input_tool.execute(
        input_tool.parameters(
            session_id=session_id,
            text="hello",
            append_newline=True,
        ),
        tool_context,
    )
    await wait_tool.execute(
        wait_tool.parameters(session_id=session_id, timeout_seconds=2),
        tool_context,
    )
    output = await read_tool.execute(
        read_tool.parameters(session_id=session_id),
        tool_context,
    )

    assert "received:hello" in output.content
    assert any(event["type"] == "stdin" for event in output.raw["events"])
    assert _normalize_newlines(output.raw["streams"]["stdout"]["text"]) == (
        "received:hello\n"
    )


@pytest.mark.asyncio
async def test_background_command_raw_events_support_incremental_reads(tmp_path, tool_context):
    """Test background command raw payload supports incremental event polling."""
    script_path = tmp_path / "incremental.py"
    script_path.write_text(
        "\n".join(
            [
                "import time",
                "print('first', flush=True)",
                "time.sleep(0.2)",
                "print('second', flush=True)",
            ]
        ),
        encoding="utf-8",
    )
    command = f'"{sys.executable}" "{script_path.name}"'
    manager = BackgroundCommandManager([tmp_path], default_root=tmp_path)

    start_tool = create_start_background_command_tool(manager)
    read_tool = create_read_background_command_tool(manager)
    wait_tool = create_wait_background_command_tool(manager)

    started = await start_tool.execute(
        start_tool.parameters(command=command, cwd=str(tmp_path)),
        tool_context,
    )
    session_id = started.metadata["session_id"]

    deadline = time.monotonic() + 1
    first_read = None
    while time.monotonic() < deadline:
        first_read = await read_tool.execute(
            read_tool.parameters(session_id=session_id),
            tool_context,
        )
        if any(event["type"] == "stdout" for event in first_read.raw["events"]):
            break
        await asyncio.sleep(0.05)

    assert first_read is not None
    first_latest_index = first_read.raw["latest_event_index"]
    assert any(event["type"] == "stdout" for event in first_read.raw["events"])

    waited = await wait_tool.execute(
        wait_tool.parameters(
            session_id=session_id,
            timeout_seconds=2,
            after_event_index=first_latest_index,
        ),
        tool_context,
    )

    assert waited.raw["latest_event_index"] >= first_latest_index
    assert all(
        event["index"] > first_latest_index for event in waited.raw["events"]
    )
    assert any(
        _normalize_newlines(str(event.get("text", ""))) == "second\n"
        for event in waited.raw["events"]
    )


@pytest.mark.asyncio
async def test_list_files_tool_factory_respects_allowed_roots(tmp_path, tool_context):
    """Test scoped list_files tool can read within allowed roots."""
    allowed_dir = tmp_path / "allowed"
    allowed_dir.mkdir()
    (allowed_dir / "demo.txt").write_text("demo\n", encoding="utf-8")

    list_tool = create_list_files_tool([allowed_dir])
    params = list_tool.parameters(path=str(allowed_dir))
    result = await list_tool.execute(params, tool_context)

    assert "demo.txt" in result.content
    assert result.raw["kind"] == "file_list"


@pytest.mark.asyncio
async def test_read_file_tool_uses_default_root_for_relative_paths(tmp_path, tool_context):
    """Test relative file paths resolve against the configured default root."""
    workspace_root = tmp_path / "workspace"
    workspace_root.mkdir()
    target_file = workspace_root / "notes.txt"
    target_file.write_text("hello\n", encoding="utf-8")

    read_tool = create_read_file_tool([workspace_root], default_root=workspace_root)
    params = read_tool.parameters(path="notes.txt")
    result = await read_tool.execute(params, tool_context)

    assert "1: hello" in result.content
    assert result.raw["kind"] == "file_read"


@pytest.mark.asyncio
async def test_write_file_tool_creates_and_writes_file(tmp_path, tool_context):
    """Test write_file creates a file and writes content."""
    target_file = tmp_path / "notes" / "demo.txt"

    params = WriteFileTool.parameters(path=str(target_file), content="hello\nworld\n")
    result = await WriteFileTool.execute(params, tool_context)

    assert "Wrote file:" in result.content
    assert target_file.read_text(encoding="utf-8") == "hello\nworld\n"
    assert result.raw["kind"] == "file_write"
    assert result.raw["path"] == str(target_file)
    assert result.raw["chars_written"] == len("hello\nworld\n")


@pytest.mark.asyncio
async def test_search_files_tool_finds_matches(tmp_path, tool_context):
    """Test search_files returns grep-like results."""
    script_dir = tmp_path / "scripts"
    script_dir.mkdir()
    script_file = script_dir / "demo.py"
    script_file.write_text("print('hello')\nprint('world')\n", encoding="utf-8")

    params = SearchFilesTool.parameters(pattern="world", path=str(tmp_path))
    result = await SearchFilesTool.execute(params, tool_context)

    assert "Search results for 'world'" in result.content
    assert "scripts/demo.py:2: print('world')" in result.content
    assert result.raw["kind"] == "file_search"
    assert result.raw["matches"] == [
        {
            "path": "scripts/demo.py",
            "line_number": 2,
            "text": "print('world')",
        }
    ]


@pytest.mark.asyncio
async def test_write_file_tool_factory_restricts_paths(tmp_path, tool_context):
    """Test scoped write_file blocks writes outside allowed roots."""
    allowed_dir = tmp_path / "allowed"
    allowed_dir.mkdir()
    blocked_file = tmp_path / "blocked" / "secret.txt"

    write_tool = create_write_file_tool([allowed_dir])
    params = write_tool.parameters(path=str(blocked_file), content="secret")

    with pytest.raises(PermissionError):
        await write_tool.execute(params, tool_context)


@pytest.mark.asyncio
async def test_search_files_tool_factory_restricts_paths(tmp_path, tool_context):
    """Test scoped search_files blocks reads outside allowed roots."""
    allowed_dir = tmp_path / "allowed"
    allowed_dir.mkdir()
    blocked_dir = tmp_path / "blocked"
    blocked_dir.mkdir()

    search_tool = create_search_files_tool([allowed_dir])
    params = search_tool.parameters(pattern="x", path=str(blocked_dir))

    with pytest.raises(PermissionError):
        await search_tool.execute(params, tool_context)


@pytest.mark.asyncio
async def test_replace_in_file_tool_replaces_exact_text(tmp_path, tool_context):
    """Test replace_in_file updates one exact match."""
    target_file = tmp_path / "demo.txt"
    target_file.write_text("hello world\n", encoding="utf-8")

    params = ReplaceInFileTool.parameters(
        path=str(target_file),
        old_text="world",
        new_text="agent",
    )
    result = await ReplaceInFileTool.execute(params, tool_context)

    assert "Updated file:" in result.content
    assert target_file.read_text(encoding="utf-8") == "hello agent\n"
    assert result.metadata["replaced_count"] == 1
    assert result.raw["kind"] == "file_replace"
    assert result.raw["old_text"] == "world"
    assert result.raw["new_text"] == "agent"


@pytest.mark.asyncio
async def test_replace_in_file_tool_rejects_ambiguous_match(tmp_path, tool_context):
    """Test replace_in_file blocks ambiguous edits by default."""
    target_file = tmp_path / "demo.txt"
    target_file.write_text("x\nx\n", encoding="utf-8")

    params = ReplaceInFileTool.parameters(
        path=str(target_file),
        old_text="x",
        new_text="y",
    )

    with pytest.raises(ValueError):
        await ReplaceInFileTool.execute(params, tool_context)


@pytest.mark.asyncio
async def test_replace_in_file_tool_factory_restricts_paths(tmp_path, tool_context):
    """Test scoped replace_in_file blocks edits outside allowed roots."""
    allowed_dir = tmp_path / "allowed"
    allowed_dir.mkdir()
    blocked_file = tmp_path / "blocked.txt"
    blocked_file.write_text("secret\n", encoding="utf-8")

    replace_tool = create_replace_in_file_tool([allowed_dir])
    params = replace_tool.parameters(
        path=str(blocked_file),
        old_text="secret",
        new_text="public",
    )

    with pytest.raises(PermissionError):
        await replace_tool.execute(params, tool_context)
