from __future__ import annotations

import pytest

from app.agent.tool_specs import build_openai_tools, resolve_allowed_tools
from app.generation.llm_client import ToolCallResult, ToolCompletionResult
from app.models.agent import AgentTurnRequest, TurnMessage
from app.services.agent_turn_service import AgentTurnService


class FakeLLMClient:
    def __init__(self, completion: ToolCompletionResult | list[ToolCompletionResult]):
        if isinstance(completion, list):
            self._completions = list(completion)
        else:
            self._completions = [completion]
        self.last_messages: list[dict] | None = None
        self.last_tools: list[dict] | None = None
        self.last_model: str | None = None
        self.last_tool_choice: str | dict | None = None
        self.call_count = 0

    async def complete_with_tools(self, messages, tools, *, model=None, tool_choice="auto"):
        self.last_messages = messages
        self.last_tools = tools
        self.last_model = model
        self.last_tool_choice = tool_choice
        self.call_count += 1
        index = min(self.call_count - 1, len(self._completions) - 1)
        return self._completions[index]


@pytest.mark.asyncio
async def test_run_turn_maps_tool_calls():
    fake = FakeLLMClient(
        ToolCompletionResult(
            text="Reading README.md…",
            tool_calls=[
                ToolCallResult(
                    id="call-1",
                    name="read_file",
                    arguments={"path": "README.md"},
                )
            ],
            model="gpt-4o",
            finish_reason="tool_calls",
            latency_ms=10,
        )
    )
    service = AgentTurnService(llm_client=fake)
    body = AgentTurnRequest(
        task_id="task-1",
        messages=[TurnMessage(role="user", content="summarise README.md")],
        local_context_summary={
            "model": "claude-sonnet-4-6",
            "active_file": "README.md",
            "workspace_root": "/tmp/workspace",
        },
    )

    response = await service.run_turn(body)

    assert response.done is False
    assert response.finish_reason == "tool_calls"
    # Tool-status narration is stripped from chat deltas on tool turns.
    assert response.text_deltas == []
    assert len(response.tool_intents) == 1
    assert response.tool_intents[0].tool_call_id == "call-1"
    assert response.tool_intents[0].tool_name == "read_file"
    assert response.tool_intents[0].arguments == {"path": "README.md"}
    # Model is passed through to LLMRouter (no Claude→gpt-4o remap).
    assert fake.last_model == "claude-sonnet-4-6"


@pytest.mark.asyncio
async def test_run_turn_stop_path_sets_done_and_summary():
    fake = FakeLLMClient(
        ToolCompletionResult(
            text="Here is the summary.",
            tool_calls=[],
            model="gpt-4o",
            finish_reason="stop",
            latency_ms=12,
        )
    )
    service = AgentTurnService(llm_client=fake)
    body = AgentTurnRequest(
        task_id="task-2",
        messages=[TurnMessage(role="user", content="summarise README.md")],
        local_context_summary={"model": "gpt-4o"},
    )

    response = await service.run_turn(body)

    assert response.done is True
    assert response.finish_reason == "stop"
    assert response.summary == "Here is the summary."
    assert response.tool_intents == []


def test_resolve_allowed_tools_defaults():
    assert resolve_allowed_tools() == [
        "read_file",
        "search_text",
        "propose_patch",
        "run_command",
        "web_search",
    ]


def test_run_command_tool_spec_and_allowlists():
    default_tools = resolve_allowed_tools()
    wildcard_tools = resolve_allowed_tools(agent_allowed_tools=["*"])
    explicit_tools = resolve_allowed_tools(agent_allowed_tools=["run_command"])

    assert "run_command" in default_tools
    assert "run_command" in wildcard_tools
    assert explicit_tools == ["run_command"]

    tools = build_openai_tools(["run_command"])
    assert len(tools) == 1
    spec = tools[0]["function"]
    assert spec["name"] == "run_command"
    assert spec["parameters"]["required"] == ["command"]
    assert set(spec["parameters"]["properties"]) == {
        "command",
        "cwd",
        "timeout_seconds",
    }


def test_build_openai_tools_filters_unknown():
    tools = build_openai_tools(["read_file", "unknown_tool"])
    assert len(tools) == 1
    assert tools[0]["function"]["name"] == "read_file"


@pytest.mark.asyncio
async def test_run_turn_injects_edit_intent_reminder():
    fake = FakeLLMClient(
        ToolCompletionResult(
            text="",
            tool_calls=[],
            model="gpt-4o",
            finish_reason="stop",
            latency_ms=5,
        )
    )
    service = AgentTurnService(llm_client=fake)
    body = AgentTurnRequest(
        task_id="task-edit",
        messages=[
            TurnMessage(
                role="user",
                content="create an api endpoint to get the status",
            )
        ],
        local_context_summary={"model": "gpt-4o"},
    )

    await service.run_turn(body)

    assert fake.last_messages is not None
    assert fake.call_count == 2
    reminder_messages = [
        m["content"]
        for m in fake.last_messages
        if m.get("role") == "user" and "do not only paste code" in m.get("content", "").lower()
    ]
    assert len(reminder_messages) == 1
    assert "leave message content empty" in reminder_messages[0].lower()
    assert any(
        "stopped without calling propose_patch" in m.get("content", "").lower()
        for m in fake.last_messages
        if m.get("role") == "user"
    )


@pytest.mark.asyncio
async def test_run_turn_injects_discover_intent_reminder():
    fake = FakeLLMClient(
        ToolCompletionResult(
            text="",
            tool_calls=[],
            model="gpt-4o",
            finish_reason="stop",
            latency_ms=5,
        )
    )
    service = AgentTurnService(llm_client=fake)
    body = AgentTurnRequest(
        task_id="task-discover",
        messages=[TurnMessage(role="user", content="list all the routes in this repo")],
        local_context_summary={"model": "gpt-4o"},
    )

    await service.run_turn(body)

    assert fake.last_messages is not None
    reminder_messages = [
        m["content"]
        for m in fake.last_messages
        if m.get("role") == "user" and "discovery/explanation" in m.get("content", "").lower()
    ]
    assert len(reminder_messages) == 1
    assert "empty content is not allowed" in reminder_messages[0].lower()


@pytest.mark.asyncio
async def test_run_turn_synthesizes_narration_when_tool_content_empty():
    fake = FakeLLMClient(
        ToolCompletionResult(
            text="",
            tool_calls=[
                ToolCallResult(
                    id="call-1",
                    name="read_file",
                    arguments={"path": "src/app.js"},
                ),
                ToolCallResult(
                    id="call-2",
                    name="search_text",
                    arguments={"pattern": "router.get", "path": "src"},
                ),
            ],
            model="gpt-4o",
            finish_reason="tool_calls",
            latency_ms=8,
        )
    )
    service = AgentTurnService(llm_client=fake)
    body = AgentTurnRequest(
        task_id="task-empty-tools",
        messages=[TurnMessage(role="user", content="create an endpoint to list all the routes")],
        local_context_summary={"model": "gpt-4o"},
    )

    response = await service.run_turn(body)

    assert response.done is False
    assert len(response.tool_intents) == 2
    assert response.text_deltas
    joined = " ".join(response.text_deltas)
    assert "Reading src/app.js" in joined
    assert "Searching for 'router.get'" in joined


@pytest.mark.asyncio
async def test_run_turn_synthesizes_propose_patch_narration():
    fake = FakeLLMClient(
        ToolCompletionResult(
            text="   ",
            tool_calls=[
                ToolCallResult(
                    id="call-patch",
                    name="propose_patch",
                    arguments={
                        "summary": "Add GET /api/routes",
                        "file_changes": [{"path": "src/app.js", "old_text": "a", "new_text": "b"}],
                    },
                )
            ],
            model="gpt-4o",
            finish_reason="tool_calls",
            latency_ms=9,
        )
    )
    service = AgentTurnService(llm_client=fake)
    body = AgentTurnRequest(
        task_id="task-patch",
        messages=[TurnMessage(role="user", content="create an endpoint to list all the routes")],
        local_context_summary={"model": "gpt-4o"},
    )

    response = await service.run_turn(body)

    assert response.text_deltas == [
        "Proposing changes to src/app.js: Add GET /api/routes."
    ]


@pytest.mark.asyncio
async def test_run_turn_empty_stop_retries_for_answer():
    fake = FakeLLMClient(
        [
            ToolCompletionResult(
                text="",
                tool_calls=[],
                model="gpt-4o",
                finish_reason="stop",
                latency_ms=5,
            ),
            ToolCompletionResult(
                text="The README describes a sample API gateway.",
                tool_calls=[],
                model="gpt-4o",
                finish_reason="stop",
                latency_ms=5,
            ),
        ]
    )
    service = AgentTurnService(llm_client=fake)
    body = AgentTurnRequest(
        task_id="task-empty",
        messages=[TurnMessage(role="user", content="what did you find?")],
        local_context_summary={"model": "gpt-4o"},
        tool_observations=[{"tool": "read_file", "success": True, "path": "README.md"}],
    )

    response = await service.run_turn(body)

    assert fake.call_count == 2
    assert fake.last_tool_choice == "none"
    assert response.done is True
    assert response.summary == "The README describes a sample API gateway."
    assert response.text_deltas == ["The README describes a sample API gateway."]


@pytest.mark.asyncio
async def test_run_turn_narration_only_stop_retries_for_answer():
    fake = FakeLLMClient(
        [
            ToolCompletionResult(
                text="Reading README.md.",
                tool_calls=[],
                model="gpt-4o",
                finish_reason="stop",
                latency_ms=5,
            ),
            ToolCompletionResult(
                text="This service exposes a FastAPI health endpoint.",
                tool_calls=[],
                model="gpt-4o",
                finish_reason="stop",
                latency_ms=5,
            ),
        ]
    )
    service = AgentTurnService(llm_client=fake)
    body = AgentTurnRequest(
        task_id="task-narration",
        messages=[TurnMessage(role="user", content="explain this app")],
        local_context_summary={"model": "gpt-4o"},
        tool_observations=[{"tool": "read_file", "success": True, "path": "README.md"}],
    )

    response = await service.run_turn(body)

    assert fake.call_count == 2
    assert response.summary == "This service exposes a FastAPI health endpoint."


@pytest.mark.asyncio
async def test_edit_forces_patch_after_enough_reads():
    fake = FakeLLMClient(
        [
            ToolCompletionResult(
                text="Reading more files.",
                tool_calls=[
                    ToolCallResult(
                        id="call-read",
                        name="read_file",
                        arguments={"path": "src/app.js"},
                    )
                ],
                model="gpt-4o",
                finish_reason="tool_calls",
                latency_ms=5,
            ),
            ToolCompletionResult(
                text="Proposing the health endpoint.",
                tool_calls=[
                    ToolCallResult(
                        id="call-patch",
                        name="propose_patch",
                        arguments={
                            "summary": "Add health endpoint",
                            "file_changes": [
                                {
                                    "path": "src/app.js",
                                    "old_text": "app.listen",
                                    "new_text": "app.get('/health', ...)\napp.listen",
                                }
                            ],
                        },
                    )
                ],
                model="gpt-4o",
                finish_reason="tool_calls",
                latency_ms=5,
            ),
        ]
    )
    service = AgentTurnService(llm_client=fake)
    body = AgentTurnRequest(
        task_id="task-edit-force",
        messages=[TurnMessage(role="user", content="add a health endpoint")],
        local_context_summary={"model": "gpt-4o"},
        tool_observations=[
            {"tool": "read_file", "success": True, "path": "package.json"},
            {"tool": "search_text", "success": True, "pattern": "listen"},
        ],
    )

    response = await service.run_turn(body)

    assert fake.call_count == 2
    assert isinstance(fake.last_tool_choice, dict)
    assert fake.last_tool_choice["function"]["name"] == "propose_patch"
    assert response.tool_intents[0].tool_name == "propose_patch"


def test_format_tool_observations_hints_on_empty_search():
    service = AgentTurnService()
    text = service._format_tool_observations(
        [
            {
                "tool": "search_text",
                "success": True,
                "match_count": 0,
                "pattern": "@app.route",
            }
        ]
    )
    assert "broaden path" in text.lower()
    assert "fastapi" in text.lower()


def test_detect_intent_edit_vs_discover():
    service = AgentTurnService()
    assert service._detect_intent("create an endpoint to list all the routes") == "edit"
    assert service._detect_intent("list all the routes in this repo") == "discover"
    assert service._detect_intent("hello") == "general"
    assert service._detect_intent("give me a report on what does this app does?") == "discover"
    assert service._detect_intent("what does this app do?") == "discover"
    assert service._detect_intent("write a report on the architecture") == "discover"
    assert service._detect_intent("add a health endpoint") == "edit"
    # Create-new-file must stay edit even when the goal also says summarise/what the repo does.
    assert (
        service._detect_intent("create a new file to summarise what the repo does")
        == "edit"
    )
    assert service._detect_intent("create a new file summarizing the repository") == "edit"


def test_create_file_goal_reminder_mentions_empty_old_text():
    service = AgentTurnService()
    reminder = service._intent_reminder(
        [TurnMessage(role="user", content="create a new file to summarise what the repo does")],
        ["read_file", "search_text", "propose_patch"],
        has_tool_observations=True,
        failed_propose_patches=1,
    )
    lowered = reminder.lower()
    assert "new" in lowered
    assert "old_text" in lowered
    assert "readme" in lowered


@pytest.mark.asyncio
async def test_discover_intent_hides_propose_patch_tool():
    fake = FakeLLMClient(
        ToolCompletionResult(
            text="This app is a sample API gateway.",
            tool_calls=[],
            model="gpt-4o",
            finish_reason="stop",
            latency_ms=5,
        )
    )
    service = AgentTurnService(llm_client=fake)
    body = AgentTurnRequest(
        task_id="t-report",
        messages=[
            TurnMessage(
                role="user",
                content="give me a report on what does this app does?",
            )
        ],
        local_context_summary={"workspace_root": "/tmp/workspace"},
    )
    response = await service.run_turn(body)
    tool_names = [t["function"]["name"] for t in (fake.last_tools or [])]
    assert "propose_patch" not in tool_names
    assert "read_file" in tool_names
    assert response.done is True
    assert response.text_deltas == ["This app is a sample API gateway."]

    assert service._detect_intent("What is HTML") == "general"
    assert service._detect_intent("what is the auth flow in this repo") == "discover"
    assert service._detect_intent("explain how FastAPI routing works") == "discover"
    assert service._detect_intent("tell me about Kubernetes") == "general"


class FakeWebSearchClient:
    def __init__(self):
        self.calls: list[dict] = []

    async def search(self, *, query: str, max_results: int = 5):
        self.calls.append({"query": query, "max_results": max_results})
        return {
            "tool": "web_search",
            "query": query,
            "provider": "mock",
            "success": True,
            "result_count": 1,
            "results": [
                {
                    "title": "HTML",
                    "url": "https://en.wikipedia.org/wiki/HTML",
                    "snippet": "HyperText Markup Language",
                }
            ],
            "output": "1. HTML\n   https://en.wikipedia.org/wiki/HTML\n   HyperText Markup Language\n",
        }


@pytest.mark.asyncio
async def test_run_turn_executes_web_search_in_cloud_for_general_knowledge():
    fake = FakeLLMClient(
        [
            ToolCompletionResult(
                text="Searching the web for HTML…",
                tool_calls=[
                    ToolCallResult(
                        id="call-web",
                        name="web_search",
                        arguments={"query": "What is HTML"},
                    )
                ],
                model="gpt-4o",
                finish_reason="tool_calls",
                latency_ms=8,
            ),
            ToolCompletionResult(
                text="HTML is the standard markup language for web pages.",
                tool_calls=[],
                model="gpt-4o",
                finish_reason="stop",
                latency_ms=9,
            ),
        ]
    )
    web_search = FakeWebSearchClient()
    service = AgentTurnService(llm_client=fake, web_search_client=web_search)
    body = AgentTurnRequest(
        task_id="task-general",
        messages=[TurnMessage(role="user", content="What is HTML")],
        local_context_summary={"model": "gpt-4o"},
    )

    response = await service.run_turn(body)

    assert fake.call_count == 2
    assert web_search.calls == [{"query": "What is HTML", "max_results": 5}]
    assert response.done is True
    assert response.tool_intents == []
    assert "HTML is the standard markup language" in response.summary
    cloud_obs = [
        m["content"]
        for m in fake.last_messages
        if m.get("role") == "user" and "lucos cloud" in m.get("content", "").lower()
    ]
    assert cloud_obs
    assert "hypertext markup language" in cloud_obs[0].lower()
    # web_search must not be returned to the daemon as a tool intent
    assert all(intent.tool_name != "web_search" for intent in response.tool_intents)


def test_web_search_tool_spec_in_defaults_and_wildcard():
    default_tools = resolve_allowed_tools()
    wildcard_tools = resolve_allowed_tools(agent_allowed_tools=["*"])
    assert "web_search" in default_tools
    assert "web_search" in wildcard_tools

    tools = build_openai_tools(["web_search"])
    assert len(tools) == 1
    spec = tools[0]["function"]
    assert spec["name"] == "web_search"
    assert spec["parameters"]["required"] == ["query"]
    assert "max_results" in spec["parameters"]["properties"]


def test_format_tool_observations_hints_on_empty_web_search():
    service = AgentTurnService()
    text = service._format_tool_observations(
        [
            {
                "tool": "web_search",
                "success": True,
                "result_count": 0,
                "query": "What is HTML",
            }
        ]
    )
    assert "web_search returned 0 results" in text.lower()
    assert "general knowledge" in text.lower()


@pytest.mark.asyncio
async def test_edit_stop_without_patch_retries_and_keeps_loop_open():
    fake = FakeLLMClient(
        [
            ToolCompletionResult(
                text="Here is some chat-only code:\n```js\napp.get('/routes')\n```",
                tool_calls=[],
                model="gpt-4o",
                finish_reason="stop",
                latency_ms=5,
            ),
            ToolCompletionResult(
                text="",
                tool_calls=[],
                model="gpt-4o",
                finish_reason="stop",
                latency_ms=5,
            ),
        ]
    )
    service = AgentTurnService(llm_client=fake)
    body = AgentTurnRequest(
        task_id="task-edit-gate",
        messages=[
            TurnMessage(role="user", content="create an endpoint to list all the routes")
        ],
        local_context_summary={"model": "gpt-4o"},
        budget={"turn_index": 2, "max_turns": 8},
        tool_observations=[
            {
                "tool": "read_file",
                "success": True,
                "path": "src/app.js",
                "content": "const express = require('express');",
            }
        ],
    )

    response = await service.run_turn(body)

    assert fake.call_count == 2
    assert fake.last_tool_choice == {
        "type": "function",
        "function": {"name": "propose_patch"},
    }
    assert response.done is False
    assert response.finish_reason == "edit_requires_patch"
    assert response.tool_intents == []
    assert response.text_deltas
    assert "propose a patch" in response.text_deltas[0].lower()


@pytest.mark.asyncio
async def test_edit_stop_allows_ask_when_feature_already_exists():
    ask = (
        "I found an existing GET /db-status endpoint in src/index.ts. "
        "Do you want me to create a new one, or update the existing route?"
    )
    fake = FakeLLMClient(
        ToolCompletionResult(
            text=ask,
            tool_calls=[],
            model="gpt-4o",
            finish_reason="stop",
            latency_ms=5,
        )
    )
    service = AgentTurnService(llm_client=fake)
    body = AgentTurnRequest(
        task_id="task-already-exists",
        messages=[
            TurnMessage(role="user", content="create an endpoint to give db-status")
        ],
        local_context_summary={"model": "gpt-4o"},
        budget={"turn_index": 2, "max_turns": 8},
        tool_observations=[
            {
                "tool": "read_file",
                "success": True,
                "path": "src/index.ts",
                "content": "app.get('/db-status', ...)",
            }
        ],
    )

    response = await service.run_turn(body)

    assert fake.call_count == 1
    assert response.done is True
    assert response.finish_reason == "stop"
    assert response.tool_intents == []
    assert "existing" in response.text_deltas[0].lower()
    assert "do you want" in response.text_deltas[0].lower()


def test_asks_user_about_existing_feature_detection():
    service = AgentTurnService(llm_client=FakeLLMClient(
        ToolCompletionResult(text="", tool_calls=[], model="gpt-4o", finish_reason="stop", latency_ms=1)
    ))
    assert service._asks_user_about_existing_feature(
        "GET /db-status already exists in src/index.ts. Do you want a new one?"
    )
    assert not service._asks_user_about_existing_feature(
        "I will add a GET /db-status endpoint next."
    )


def test_failed_patch_summary_asks_about_existing():
    service = AgentTurnService(llm_client=FakeLLMClient(
        ToolCompletionResult(text="", tool_calls=[], model="gpt-4o", finish_reason="stop", latency_ms=1)
    ))
    summary = service._failed_patch_summary(
        [
            {
                "tool": "propose_patch",
                "success": False,
                "error": "patch preflight failed: old_text mismatch",
            }
        ]
    )
    assert "already exists" in summary.lower()
    assert "new/different" in summary.lower() or "update" in summary.lower()


@pytest.mark.asyncio
async def test_edit_stop_retry_can_emit_propose_patch():
    fake = FakeLLMClient(
        [
            ToolCompletionResult(
                text="Sure, add this route in chat.",
                tool_calls=[],
                model="gpt-4o",
                finish_reason="stop",
                latency_ms=5,
            ),
            ToolCompletionResult(
                text="",
                tool_calls=[
                    ToolCallResult(
                        id="call-patch",
                        name="propose_patch",
                        arguments={
                            "summary": "Add GET /api/routes",
                            "file_changes": [
                                {
                                    "path": "src/app.js",
                                    "old_text": "a",
                                    "new_text": "b",
                                }
                            ],
                        },
                    )
                ],
                model="gpt-4o",
                finish_reason="tool_calls",
                latency_ms=8,
            ),
        ]
    )
    service = AgentTurnService(llm_client=fake)
    body = AgentTurnRequest(
        task_id="task-edit-retry-patch",
        messages=[
            TurnMessage(role="user", content="create an endpoint to list all the routes")
        ],
        local_context_summary={"model": "gpt-4o"},
        tool_observations=[
            {"tool": "read_file", "success": True, "path": "src/app.js"}
        ],
    )

    response = await service.run_turn(body)

    assert fake.call_count == 2
    assert response.done is False
    assert len(response.tool_intents) == 1
    assert response.tool_intents[0].tool_name == "propose_patch"


@pytest.mark.asyncio
async def test_edit_stop_allows_done_on_final_turn():
    fake = FakeLLMClient(
        ToolCompletionResult(
            text="I could not propose a patch.",
            tool_calls=[],
            model="gpt-4o",
            finish_reason="stop",
            latency_ms=5,
        )
    )
    service = AgentTurnService(llm_client=fake)
    body = AgentTurnRequest(
        task_id="task-edit-last",
        messages=[
            TurnMessage(role="user", content="create an endpoint to list all the routes")
        ],
        local_context_summary={"model": "gpt-4o"},
        budget={"turn_index": 7, "max_turns": 8},
    )

    response = await service.run_turn(body)

    # Still retries once, but final mapped stop may remain done on last budget turn.
    assert fake.call_count == 2
    assert response.done is True
    assert response.summary == "I could not propose a patch."


@pytest.mark.asyncio
async def test_discover_stop_still_completes_without_patch():
    fake = FakeLLMClient(
        ToolCompletionResult(
            text="Routes are registered in src/app.js.",
            tool_calls=[],
            model="gpt-4o",
            finish_reason="stop",
            latency_ms=5,
        )
    )
    service = AgentTurnService(llm_client=fake)
    body = AgentTurnRequest(
        task_id="task-discover-ok",
        messages=[TurnMessage(role="user", content="list all the routes in this repo")],
        local_context_summary={"model": "gpt-4o"},
    )

    response = await service.run_turn(body)

    assert fake.call_count == 1
    assert response.done is True
    assert response.summary == "Routes are registered in src/app.js."


@pytest.mark.asyncio
async def test_after_successful_patch_stops_without_reproposing():
    fake = FakeLLMClient(
        ToolCompletionResult(
            text="",
            tool_calls=[
                ToolCallResult(
                    id="call-again",
                    name="propose_patch",
                    arguments={
                        "summary": "Add routes again",
                        "file_changes": [
                            {"path": "server.js", "old_text": "a", "new_text": "b"}
                        ],
                    },
                )
            ],
            model="gpt-4o",
            finish_reason="tool_calls",
            latency_ms=5,
        )
    )
    service = AgentTurnService(llm_client=fake)
    body = AgentTurnRequest(
        task_id="task-no-repropose",
        messages=[
            TurnMessage(
                role="user",
                content="create an endpoint to list all the routes",
            )
        ],
        local_context_summary={"model": "gpt-4o"},
        tool_observations=[
            {
                "tool": "propose_patch",
                "success": True,
                "patch_id": "patch-1",
                "files_changed": ["server.js"],
                "summary": "Add GET /api/routes",
            }
        ],
    )

    response = await service.run_turn(body)

    assert response.done is True
    assert response.tool_intents == []
    assert "propose_patch" not in " ".join(
        t.get("function", {}).get("name", "") for t in (fake.last_tools or [])
    )
    assert "Add GET /api/routes" in (response.summary or response.text_deltas[0])
    reminder = " ".join(
        m["content"]
        for m in (fake.last_messages or [])
        if m.get("role") == "user"
    ).lower()
    assert "do not call propose_patch again" in reminder


_CALLBACK_GOAL = (
    "create a small module for call back request - where user will enter their phone "
    "number and click request call back. backend should able to save the request, and send mail."
)


def test_required_goal_aspects_detects_ui_persist_email():
    service = AgentTurnService(llm_client=FakeLLMClient(
        ToolCompletionResult(text="", tool_calls=[], model="gpt-4o", finish_reason="stop", latency_ms=1)
    ))
    aspects = service._required_goal_aspects(_CALLBACK_GOAL)
    assert aspects == {"ui", "persist", "email"}


def test_patch_paths_cover_aspects():
    service = AgentTurnService(llm_client=FakeLLMClient(
        ToolCompletionResult(text="", tool_calls=[], model="gpt-4o", finish_reason="stop", latency_ms=1)
    ))
    api_only = service._aspects_covered_by_patch_text(
        "app/Controllers/Api/CallbackRequestController.php app/Models/CallbackRequestModel.php "
        "app/Config/Routes.php"
    )
    assert api_only == {"persist"}
    full = service._aspects_covered_by_patch_text(
        "app/Views/callback_form.php app/Controllers/Api/CallbackRequestController.php "
        "app/Libraries/CallbackMailer.php"
    )
    assert full == {"ui", "persist", "email"}


@pytest.mark.asyncio
async def test_incomplete_coverage_keeps_propose_patch_and_retries():
    fake = FakeLLMClient(
        [
            ToolCompletionResult(
                text="I have implemented the callback module.",
                tool_calls=[],
                model="gpt-4o",
                finish_reason="stop",
                latency_ms=5,
            ),
            ToolCompletionResult(
                text="Adding the form and email sender.",
                tool_calls=[
                    ToolCallResult(
                        id="call-expand",
                        name="propose_patch",
                        arguments={
                            "summary": "Add callback form view and mailer",
                            "file_changes": [
                                {
                                    "path": "app/Views/callback_form.php",
                                    "old_text": "",
                                    "new_text": "<form></form>",
                                },
                                {
                                    "path": "app/Libraries/CallbackMailer.php",
                                    "old_text": "",
                                    "new_text": "<?php class CallbackMailer {}",
                                },
                            ],
                        },
                    )
                ],
                model="gpt-4o",
                finish_reason="tool_calls",
                latency_ms=5,
            ),
        ]
    )
    service = AgentTurnService(llm_client=fake)
    body = AgentTurnRequest(
        task_id="task-coverage-incomplete",
        messages=[TurnMessage(role="user", content=_CALLBACK_GOAL)],
        local_context_summary={"model": "gpt-4o"},
        tool_observations=[
            {
                "tool": "propose_patch",
                "success": True,
                "patch_id": "patch-1",
                "files_changed": [
                    "app/Controllers/Api/CallbackRequestController.php",
                    "app/Config/Routes.php",
                    "app/Models/CallbackRequestModel.php",
                ],
                "summary": "Add CallbackRequestController for handling callback requests.",
            }
        ],
    )

    response = await service.run_turn(body)

    assert fake.call_count == 2
    assert fake.last_tool_choice == {"type": "function", "function": {"name": "propose_patch"}}
    tool_names = [t.get("function", {}).get("name", "") for t in (fake.last_tools or [])]
    assert "propose_patch" in tool_names
    assert response.done is False
    assert response.tool_intents
    assert response.tool_intents[0].tool_name == "propose_patch"
    reminder = " ".join(
        m["content"]
        for m in (fake.last_messages or [])
        if m.get("role") == "user"
    ).lower()
    assert "email" in reminder or "ui/form" in reminder or "missing" in reminder


@pytest.mark.asyncio
async def test_complete_coverage_still_forces_stop_after_patch():
    fake = FakeLLMClient(
        ToolCompletionResult(
            text="Ready for review.",
            tool_calls=[
                ToolCallResult(
                    id="call-again",
                    name="propose_patch",
                    arguments={
                        "summary": "Tweak again",
                        "file_changes": [
                            {"path": "app/Views/callback_form.php", "old_text": "a", "new_text": "b"}
                        ],
                    },
                )
            ],
            model="gpt-4o",
            finish_reason="tool_calls",
            latency_ms=5,
        )
    )
    service = AgentTurnService(llm_client=fake)
    body = AgentTurnRequest(
        task_id="task-coverage-complete",
        messages=[TurnMessage(role="user", content=_CALLBACK_GOAL)],
        local_context_summary={"model": "gpt-4o"},
        tool_observations=[
            {
                "tool": "propose_patch",
                "success": True,
                "patch_id": "patch-full",
                "files_changed": [
                    "app/Views/callback_form.php",
                    "app/Controllers/Api/CallbackRequestController.php",
                    "app/Libraries/CallbackMailer.php",
                ],
                "summary": "Form, save API, and email for callback requests.",
            }
        ],
    )

    response = await service.run_turn(body)

    assert response.done is True
    assert response.tool_intents == []
    assert "propose_patch" not in " ".join(
        t.get("function", {}).get("name", "") for t in (fake.last_tools or [])
    )
    reminder = " ".join(
        m["content"]
        for m in (fake.last_messages or [])
        if m.get("role") == "user"
    ).lower()
    assert "do not call propose_patch again" in reminder


@pytest.mark.asyncio
async def test_discover_intent_skips_coverage_gate():
    fake = FakeLLMClient(
        ToolCompletionResult(
            text="Auth middleware lives in app/Middleware/Auth.php.",
            tool_calls=[],
            model="gpt-4o",
            finish_reason="stop",
            latency_ms=5,
        )
    )
    service = AgentTurnService(llm_client=fake)
    body = AgentTurnRequest(
        task_id="task-discover-coverage",
        messages=[TurnMessage(role="user", content="where is auth middleware defined?")],
        local_context_summary={"model": "gpt-4o"},
        tool_observations=[
            {"tool": "read_file", "success": True, "path": "app/Middleware/Auth.php"},
        ],
    )

    response = await service.run_turn(body)

    assert response.done is True
    assert "missing" not in (response.summary or "").lower()
    assert fake.call_count == 1
    assert "propose_patch" not in " ".join(
        t.get("function", {}).get("name", "") for t in (fake.last_tools or [])
    )


@pytest.mark.asyncio
async def test_coverage_retry_cap_finalizes_after_second_patch():
    fake = FakeLLMClient(
        ToolCompletionResult(
            text="Still working.",
            tool_calls=[
                ToolCallResult(
                    id="call-third",
                    name="propose_patch",
                    arguments={
                        "summary": "Yet another patch",
                        "file_changes": [
                            {"path": "app/Models/CallbackRequestModel.php", "old_text": "a", "new_text": "b"}
                        ],
                    },
                )
            ],
            model="gpt-4o",
            finish_reason="tool_calls",
            latency_ms=5,
        )
    )
    service = AgentTurnService(llm_client=fake)
    body = AgentTurnRequest(
        task_id="task-coverage-cap",
        messages=[TurnMessage(role="user", content=_CALLBACK_GOAL)],
        local_context_summary={"model": "gpt-4o"},
        tool_observations=[
            {
                "tool": "propose_patch",
                "success": True,
                "patch_id": "patch-1",
                "files_changed": [
                    "app/Controllers/Api/CallbackRequestController.php",
                    "app/Models/CallbackRequestModel.php",
                ],
                "summary": "API save only",
            },
            {
                "tool": "propose_patch",
                "success": True,
                "patch_id": "patch-2",
                "files_changed": ["app/Config/Routes.php"],
                "summary": "Routes only",
            },
        ],
    )

    response = await service.run_turn(body)

    assert response.done is True
    assert response.tool_intents == []
    assert "propose_patch" not in " ".join(
        t.get("function", {}).get("name", "") for t in (fake.last_tools or [])
    )
    summary = (response.summary or response.text_deltas[0] or "").lower()
    assert "missing" in summary
    assert "email" in summary or "ui/form" in summary


_OVERVIEW_GOAL = "explain the features of this project by scanning it"

_STRUCTURED_OVERVIEW = """## Purpose
This workspace hosts a sample API gateway used for local demos.

## Main features
- Health and status endpoints for readiness checks
- Callback request intake with persistence
- Optional email notifications for operators

## Key modules
- `app/Controllers` HTTP handlers
- `app/Models` persistence layer
- `app/Config/Routes.php` route registration

## How to run
Install dependencies, configure mail settings, then start the PHP built-in server from the project root.
"""


def test_broad_overview_goal_detection():
    service = AgentTurnService(llm_client=FakeLLMClient(
        ToolCompletionResult(text="", tool_calls=[], model="gpt-4o", finish_reason="stop", latency_ms=1)
    ))
    assert service._is_broad_overview_goal(_OVERVIEW_GOAL)
    assert service._is_broad_overview_goal("what does this app do?")
    assert not service._is_broad_overview_goal("where is auth middleware defined?")


@pytest.mark.asyncio
async def test_shallow_overview_triggers_deepen_retry():
    fake = FakeLLMClient(
        [
            ToolCompletionResult(
                text="This project is a small PHP app.",
                tool_calls=[],
                model="gpt-4o",
                finish_reason="stop",
                latency_ms=5,
            ),
            ToolCompletionResult(
                text="Searching for routes and controllers.",
                tool_calls=[
                    ToolCallResult(
                        id="call-search",
                        name="search_text",
                        arguments={"pattern": "Router|Controller", "path": "app"},
                    )
                ],
                model="gpt-4o",
                finish_reason="tool_calls",
                latency_ms=5,
            ),
        ]
    )
    service = AgentTurnService(llm_client=fake)
    body = AgentTurnRequest(
        task_id="task-overview-shallow",
        messages=[TurnMessage(role="user", content=_OVERVIEW_GOAL)],
        local_context_summary={"model": "gpt-4o"},
        tool_observations=[
            {"tool": "read_file", "success": True, "path": "README.md"},
        ],
    )

    response = await service.run_turn(body)

    assert fake.call_count == 2
    assert fake.last_tool_choice == {"type": "function", "function": {"name": "search_text"}}
    assert response.done is False
    assert response.tool_intents
    assert response.tool_intents[0].tool_name == "search_text"
    nudge = " ".join(
        m["content"] for m in (fake.last_messages or []) if m.get("role") == "user"
    ).lower()
    assert "overview" in nudge or "thin" in nudge or "readme" in nudge


@pytest.mark.asyncio
async def test_well_explored_overview_accepts_structured_answer():
    fake = FakeLLMClient(
        ToolCompletionResult(
            text=_STRUCTURED_OVERVIEW,
            tool_calls=[],
            model="gpt-4o",
            finish_reason="stop",
            latency_ms=5,
        )
    )
    service = AgentTurnService(llm_client=fake)
    body = AgentTurnRequest(
        task_id="task-overview-complete",
        messages=[TurnMessage(role="user", content=_OVERVIEW_GOAL)],
        local_context_summary={"model": "gpt-4o"},
        tool_observations=[
            {"tool": "search_text", "success": True, "pattern": "Router", "path": "app", "match_count": 4},
            {"tool": "read_file", "success": True, "path": "README.md"},
            {"tool": "read_file", "success": True, "path": "app/Config/Routes.php"},
            {"tool": "read_file", "success": True, "path": "app/Controllers/Api/BaseApiController.php"},
        ],
    )

    response = await service.run_turn(body)

    assert fake.call_count == 1
    assert response.done is True
    assert response.tool_intents == []
    assert "Main features" in (response.summary or response.text_deltas[0])


@pytest.mark.asyncio
async def test_narrow_discover_skips_overview_deepen():
    fake = FakeLLMClient(
        ToolCompletionResult(
            text="Auth middleware lives in app/Middleware/Auth.php.",
            tool_calls=[],
            model="gpt-4o",
            finish_reason="stop",
            latency_ms=5,
        )
    )
    service = AgentTurnService(llm_client=fake)
    body = AgentTurnRequest(
        task_id="task-narrow-discover",
        messages=[TurnMessage(role="user", content="where is auth middleware defined?")],
        local_context_summary={"model": "gpt-4o"},
        tool_observations=[
            {"tool": "read_file", "success": True, "path": "app/Middleware/Auth.php"},
        ],
    )

    response = await service.run_turn(body)

    assert fake.call_count == 1
    assert response.done is True
    assert "Auth middleware" in (response.summary or response.text_deltas[0])


@pytest.mark.asyncio
async def test_overview_deepen_cap_finalizes_after_one_retry():
    short = "Still just a short sketch of the app."
    fake = FakeLLMClient(
        [
            ToolCompletionResult(
                text=short,
                tool_calls=[],
                model="gpt-4o",
                finish_reason="stop",
                latency_ms=5,
            ),
            ToolCompletionResult(
                text=short,
                tool_calls=[],
                model="gpt-4o",
                finish_reason="stop",
                latency_ms=5,
            ),
        ]
    )
    service = AgentTurnService(llm_client=fake)
    body = AgentTurnRequest(
        task_id="task-overview-cap",
        messages=[TurnMessage(role="user", content=_OVERVIEW_GOAL)],
        local_context_summary={"model": "gpt-4o"},
        tool_observations=[
            {"tool": "read_file", "success": True, "path": "README.md"},
            {"tool": "read_file", "success": True, "path": "docs/intro.md"},
        ],
    )

    response = await service.run_turn(body)

    assert fake.call_count == 2
    assert response.done is True
    assert response.tool_intents == []
    assert short in (response.summary or response.text_deltas[0])


_IMPLEMENT_GOAL = "implement a calculate_total function that sums line items"


def test_implement_goal_and_stub_detection():
    service = AgentTurnService(llm_client=FakeLLMClient(
        ToolCompletionResult(text="", tool_calls=[], model="gpt-4o", finish_reason="stop", latency_ms=1)
    ))
    assert service._is_implement_function_goal(_IMPLEMENT_GOAL)
    assert not service._is_implement_function_goal("rename calculate_total to sum_total")
    assert service._is_stub_like_text("def calculate_total(items):\n    pass\n")
    assert service._is_stub_like_text("def calculate_total(items):\n    raise NotImplementedError\n")
    assert not service._is_stub_like_text(
        "def calculate_total(items):\n    total = 0\n    for item in items:\n        total += item.amount\n    return total\n"
    )


@pytest.mark.asyncio
async def test_stub_propose_patch_triggers_body_retry():
    stub_args = {
        "summary": "Add calculate_total",
        "file_changes": [
            {
                "path": "app/totals.py",
                "old_text": "",
                "new_text": "def calculate_total(items):\n    pass\n",
            }
        ],
    }
    full_args = {
        "summary": "Implement calculate_total",
        "file_changes": [
            {
                "path": "app/totals.py",
                "old_text": "",
                "new_text": (
                    "def calculate_total(items):\n"
                    "    total = 0\n"
                    "    for item in items:\n"
                    "        total += item.amount\n"
                    "    return total\n"
                ),
            }
        ],
    }
    fake = FakeLLMClient(
        [
            ToolCompletionResult(
                text="Adding calculate_total.",
                tool_calls=[ToolCallResult(id="call-stub", name="propose_patch", arguments=stub_args)],
                model="gpt-4o",
                finish_reason="tool_calls",
                latency_ms=5,
            ),
            ToolCompletionResult(
                text="Implementing calculate_total fully.",
                tool_calls=[ToolCallResult(id="call-full", name="propose_patch", arguments=full_args)],
                model="gpt-4o",
                finish_reason="tool_calls",
                latency_ms=5,
            ),
        ]
    )
    service = AgentTurnService(llm_client=fake)
    body = AgentTurnRequest(
        task_id="task-stub-retry",
        messages=[TurnMessage(role="user", content=_IMPLEMENT_GOAL)],
        local_context_summary={"model": "gpt-4o"},
        tool_observations=[
            {"tool": "read_file", "success": True, "path": "app/totals.py"},
        ],
    )

    response = await service.run_turn(body)

    assert fake.call_count == 2
    assert fake.last_tool_choice == {"type": "function", "function": {"name": "propose_patch"}}
    assert response.done is False
    assert response.tool_intents
    assert response.tool_intents[0].tool_call_id == "call-full"
    assert "pass" not in (response.tool_intents[0].arguments.get("file_changes")[0]["new_text"])


@pytest.mark.asyncio
async def test_complete_function_body_does_not_retry():
    full_args = {
        "summary": "Implement calculate_total",
        "file_changes": [
            {
                "path": "app/totals.py",
                "old_text": "def calculate_total(items):\n    pass\n",
                "new_text": (
                    "def calculate_total(items):\n"
                    "    total = 0\n"
                    "    for item in items:\n"
                    "        total += item.amount\n"
                    "    return total\n"
                ),
            }
        ],
    }
    fake = FakeLLMClient(
        ToolCompletionResult(
            text="Implemented calculate_total.",
            tool_calls=[ToolCallResult(id="call-full", name="propose_patch", arguments=full_args)],
            model="gpt-4o",
            finish_reason="tool_calls",
            latency_ms=5,
        )
    )
    service = AgentTurnService(llm_client=fake)
    body = AgentTurnRequest(
        task_id="task-full-body",
        messages=[TurnMessage(role="user", content=_IMPLEMENT_GOAL)],
        local_context_summary={"model": "gpt-4o"},
        tool_observations=[
            {"tool": "read_file", "success": True, "path": "app/totals.py"},
        ],
    )

    response = await service.run_turn(body)

    assert fake.call_count == 1
    assert response.tool_intents
    assert response.tool_intents[0].tool_call_id == "call-full"


@pytest.mark.asyncio
async def test_rename_goal_skips_stub_gate():
    fake = FakeLLMClient(
        ToolCompletionResult(
            text="Renaming the function.",
            tool_calls=[
                ToolCallResult(
                    id="call-rename",
                    name="propose_patch",
                    arguments={
                        "summary": "Rename calculate_total",
                        "file_changes": [
                            {
                                "path": "app/totals.py",
                                "old_text": "def calculate_total(",
                                "new_text": "def sum_total(",
                            }
                        ],
                    },
                )
            ],
            model="gpt-4o",
            finish_reason="tool_calls",
            latency_ms=5,
        )
    )
    service = AgentTurnService(llm_client=fake)
    body = AgentTurnRequest(
        task_id="task-rename",
        messages=[TurnMessage(role="user", content="rename calculate_total to sum_total")],
        local_context_summary={"model": "gpt-4o"},
        tool_observations=[
            {"tool": "read_file", "success": True, "path": "app/totals.py"},
        ],
    )

    response = await service.run_turn(body)

    assert fake.call_count == 1
    assert response.tool_intents
    assert response.tool_intents[0].tool_call_id == "call-rename"


@pytest.mark.asyncio
async def test_stub_observation_keeps_propose_patch_then_caps():
    fake = FakeLLMClient(
        ToolCompletionResult(
            text="Still a stub.",
            tool_calls=[
                ToolCallResult(
                    id="call-stub-2",
                    name="propose_patch",
                    arguments={
                        "summary": "Stub again",
                        "file_changes": [
                            {
                                "path": "app/totals.py",
                                "old_text": "",
                                "new_text": "def calculate_total(items):\n    TODO\n",
                            }
                        ],
                    },
                )
            ],
            model="gpt-4o",
            finish_reason="tool_calls",
            latency_ms=5,
        )
    )
    service = AgentTurnService(llm_client=fake)
    body = AgentTurnRequest(
        task_id="task-stub-cap",
        messages=[TurnMessage(role="user", content=_IMPLEMENT_GOAL)],
        local_context_summary={"model": "gpt-4o"},
        tool_observations=[
            {
                "tool": "propose_patch",
                "success": True,
                "patch_id": "patch-1",
                "files_changed": ["app/totals.py"],
                "summary": "Add calculate_total stub",
                "new_texts": ["def calculate_total(items):\n    pass\n"],
            },
            {
                "tool": "propose_patch",
                "success": True,
                "patch_id": "patch-2",
                "files_changed": ["app/totals.py"],
                "summary": "Still stub",
                "new_texts": ["def calculate_total(items):\n    raise NotImplementedError\n"],
            },
        ],
    )

    response = await service.run_turn(body)

    assert response.done is True
    assert response.tool_intents == []
    assert "propose_patch" not in " ".join(
        t.get("function", {}).get("name", "") for t in (fake.last_tools or [])
    )
    summary = (response.summary or response.text_deltas[0] or "").lower()
    assert "stub" in summary or "signature" in summary


def test_expected_stack_language_from_context():
    service = AgentTurnService(llm_client=FakeLLMClient(
        ToolCompletionResult(text="", tool_calls=[], model="gpt-4o", finish_reason="stop", latency_ms=1)
    ))
    body = AgentTurnRequest(
        task_id="stack-ctx",
        messages=[TurnMessage(role="user", content="add a helper")],
        local_context_summary={"tech_stack_language": "php", "tech_stack_evidence": "composer.json"},
    )
    assert service._expected_stack_language(body) == "php"
    assert "php" in service._stack_guidance(body).lower()


def test_detect_code_language_signals():
    service = AgentTurnService(llm_client=FakeLLMClient(
        ToolCompletionResult(text="", tool_calls=[], model="gpt-4o", finish_reason="stop", latency_ms=1)
    ))
    assert service._detect_code_language("<?php\nnamespace App;\nfunction foo() { return 1; }\n") == "php"
    assert service._detect_code_language("def foo():\n    import os\n    return 1\n") == "python"


@pytest.mark.asyncio
async def test_stack_mismatch_python_into_php_retries():
    fake = FakeLLMClient(
        [
            ToolCompletionResult(
                text="Adding helper.",
                tool_calls=[
                    ToolCallResult(
                        id="call-py",
                        name="propose_patch",
                        arguments={
                            "summary": "Add helper",
                            "file_changes": [
                                {
                                    "path": "app/Helpers/format.py",
                                    "old_text": "",
                                    "new_text": "def format_name(name):\n    import re\n    return name.strip()\n",
                                }
                            ],
                        },
                    )
                ],
                model="gpt-4o",
                finish_reason="tool_calls",
                latency_ms=5,
            ),
            ToolCompletionResult(
                text="Adding PHP helper.",
                tool_calls=[
                    ToolCallResult(
                        id="call-php",
                        name="propose_patch",
                        arguments={
                            "summary": "Add helper",
                            "file_changes": [
                                {
                                    "path": "app/Helpers/FormatHelper.php",
                                    "old_text": "",
                                    "new_text": "<?php\nnamespace App\\Helpers;\n\nclass FormatHelper {\n    public static function formatName(string $name): string {\n        return trim($name);\n    }\n}\n",
                                }
                            ],
                        },
                    )
                ],
                model="gpt-4o",
                finish_reason="tool_calls",
                latency_ms=5,
            ),
        ]
    )
    service = AgentTurnService(llm_client=fake)
    body = AgentTurnRequest(
        task_id="task-stack-mismatch",
        messages=[TurnMessage(role="user", content="create a helper to format names")],
        local_context_summary={
            "model": "gpt-4o",
            "tech_stack_language": "php",
            "tech_stack_framework": "php",
            "tech_stack_evidence": "composer.json",
        },
        tool_observations=[
            {"tool": "read_file", "success": True, "path": "app/Controllers/Home.php"},
        ],
    )

    response = await service.run_turn(body)

    assert fake.call_count == 2
    assert fake.last_tool_choice == {"type": "function", "function": {"name": "propose_patch"}}
    assert response.tool_intents
    assert response.tool_intents[0].tool_call_id == "call-php"
    assert response.tool_intents[0].arguments["file_changes"][0]["path"].endswith(".php")
