"""End-to-end tests for the full agent loop.

Tests the complete flow:
  POST /api/chat/prompt → SSE stream → tool execution → multi-step → done

Requires a valid OPENYAK_OPENROUTER_API_KEY.
"""

from __future__ import annotations

import asyncio
import json
from pathlib import Path

import pytest
import pytest_asyncio
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine

from app.agent.agent import AgentRegistry
from app.config import Settings
from app.main import _register_builtin_tools
from app.models.base import Base
from app.provider.openrouter import OpenRouterProvider
from app.provider.registry import ProviderRegistry
from app.schemas.chat import PromptRequest
from app.session.processor import run_generation
from app.streaming.events import (
    AGENT_ERROR,
    DONE,
    STEP_FINISH,
    STEP_START,
    TEXT_DELTA,
    TOOL_ERROR,
    TOOL_RESULT,
    TOOL_START,
)
from app.streaming.manager import GenerationJob, StreamManager
from app.tool.registry import ToolRegistry
from app.utils.id import generate_ulid


# ---------------------------------------------------------------------------
# Fixtures specific to E2E
# ---------------------------------------------------------------------------


@pytest_asyncio.fixture
async def e2e_session_factory():
    """In-memory DB for E2E tests."""
    engine = create_async_engine("sqlite+aiosqlite://", echo=False)
    async with engine.begin() as conn:
        await conn.run_sync(Base.metadata.create_all)
    factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
    yield factory
    await engine.dispose()


@pytest.fixture
def e2e_tool_registry() -> ToolRegistry:
    tr = ToolRegistry()
    _register_builtin_tools(tr)
    return tr


@pytest.fixture
def e2e_agent_registry() -> AgentRegistry:
    return AgentRegistry()


@pytest_asyncio.fixture
async def e2e_provider_registry(api_key: str) -> ProviderRegistry:
    registry = ProviderRegistry()
    provider = OpenRouterProvider(api_key, enable_reasoning=False)
    registry.register(provider)
    await registry.refresh_models()
    return registry


# ---------------------------------------------------------------------------
# Helper
# ---------------------------------------------------------------------------

async def run_prompt(
    text: str,
    *,
    model: str = "z-ai/glm-4.7-flash",
    agent: str = "build",
    session_factory,
    provider_registry: ProviderRegistry,
    agent_registry: AgentRegistry,
    tool_registry: ToolRegistry,
    session_id: str | None = None,
    timeout: float = 60.0,
) -> tuple[GenerationJob, list[dict]]:
    """Run a prompt through the full agent loop and collect all SSE events."""
    sid = session_id or generate_ulid()
    stream_id = generate_ulid()
    job = GenerationJob(stream_id=stream_id, session_id=sid)

    request = PromptRequest(
        session_id=sid,
        text=text,
        model=model,
        agent=agent,
    )

    await asyncio.wait_for(
        run_generation(
            job,
            request,
            session_factory=session_factory,
            provider_registry=provider_registry,
            agent_registry=agent_registry,
            tool_registry=tool_registry,
        ),
        timeout=timeout,
    )

    events = [{"event": e.event, "data": e.data, "id": e.id} for e in job.events]
    return job, events


# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------


class TestSimpleChat:
    """Basic chat without tool calls."""

    @pytest.mark.asyncio
    async def test_simple_response(
        self, e2e_session_factory, e2e_provider_registry,
        e2e_agent_registry, e2e_tool_registry,
    ):
        """Model responds with text, no tools."""
        job, events = await run_prompt(
            "Say exactly the word: pineapple. Nothing else.",
            session_factory=e2e_session_factory,
            provider_registry=e2e_provider_registry,
            agent_registry=e2e_agent_registry,
            tool_registry=e2e_tool_registry,
        )

        event_types = [e["event"] for e in events]
        assert STEP_START in event_types, f"Missing step_start. Events: {event_types}"
        assert TEXT_DELTA in event_types, f"Missing text_delta. Events: {event_types}"
        assert STEP_FINISH in event_types, f"Missing step_finish. Events: {event_types}"
        assert DONE in event_types, f"Missing done. Events: {event_types}"
        assert AGENT_ERROR not in event_types, f"Unexpected error: {[e for e in events if e['event'] == AGENT_ERROR]}"

        # Check text content
        text = "".join(
            e["data"].get("text", "") for e in events if e["event"] == TEXT_DELTA
        )
        assert "pineapple" in text.lower()

        # Job should be complete
        assert job.completed


class TestToolExecution:
    """Agent calls tools and processes results."""

    @pytest.mark.asyncio
    async def test_read_tool_call(
        self, e2e_session_factory, e2e_provider_registry,
        e2e_agent_registry, e2e_tool_registry, tmp_path: Path,
    ):
        """Ask agent to read a file → should call read tool → return content."""
        # Create a test file
        test_file = tmp_path / "test_data.txt"
        test_file.write_text("SECRET_CONTENT_12345")

        job, events = await run_prompt(
            f"Read the file at {test_file} using the read tool, then reply with its exact contents.",
            session_factory=e2e_session_factory,
            provider_registry=e2e_provider_registry,
            agent_registry=e2e_agent_registry,
            tool_registry=e2e_tool_registry,
            timeout=90.0,
        )

        event_types = [e["event"] for e in events]
        assert DONE in event_types, f"Missing done. Events: {event_types}"
        assert AGENT_ERROR not in event_types, f"Error: {[e for e in events if e['event'] == AGENT_ERROR]}"

        # Should have tool_start and tool_result
        assert TOOL_START in event_types, f"Missing tool_start — model didn't call any tool. Events: {event_types}"
        assert TOOL_RESULT in event_types, f"Missing tool_result. Events: {event_types}"

        # Find the read tool call
        tool_starts = [e for e in events if e["event"] == TOOL_START]
        assert any(e["data"].get("tool") == "read" for e in tool_starts), \
            f"Expected read tool call, got: {[e['data'].get('tool') for e in tool_starts]}"

        # Verify the content was retrieved — check both text output and tool results
        # (some models may not echo content in text but the tool pipeline still works)
        text = "".join(
            e["data"].get("text", "") for e in events if e["event"] == TEXT_DELTA
        )
        tool_outputs = " ".join(
            e["data"].get("output", "") for e in events if e["event"] == TOOL_RESULT
        )
        content_found = (
            "SECRET_CONTENT_12345" in text or "12345" in text
            or "SECRET_CONTENT_12345" in tool_outputs
        )
        assert content_found, \
            f"Content not found in text or tool results. Text: {text[:300]}, Tool outputs: {tool_outputs[:300]}"

    @pytest.mark.asyncio
    async def test_glob_tool_call(
        self, e2e_session_factory, e2e_provider_registry,
        e2e_agent_registry, e2e_tool_registry, tmp_path: Path,
    ):
        """Ask agent to find files → should call glob tool."""
        (tmp_path / "alpha.py").write_text("# alpha")
        (tmp_path / "beta.py").write_text("# beta")
        (tmp_path / "gamma.txt").write_text("not python")

        job, events = await run_prompt(
            f"Use the glob tool to find all .py files in {tmp_path}. List them.",
            session_factory=e2e_session_factory,
            provider_registry=e2e_provider_registry,
            agent_registry=e2e_agent_registry,
            tool_registry=e2e_tool_registry,
            timeout=90.0,
        )

        event_types = [e["event"] for e in events]
        assert DONE in event_types
        assert AGENT_ERROR not in event_types

        # Should call glob
        tool_starts = [e for e in events if e["event"] == TOOL_START]
        tool_names = [e["data"].get("tool") for e in tool_starts]
        assert "glob" in tool_names, f"Expected glob tool, got: {tool_names}"

        # Text should mention the py files
        text = "".join(
            e["data"].get("text", "") for e in events if e["event"] == TEXT_DELTA
        )
        assert "alpha" in text.lower() or "beta" in text.lower()


class TestMultiStepAgent:
    """Agent makes multiple tool calls in sequence."""

    @pytest.mark.asyncio
    async def test_multi_step(
        self, e2e_session_factory, e2e_provider_registry,
        e2e_agent_registry, e2e_tool_registry, tmp_path: Path,
    ):
        """Ask agent to find and then read a file → multiple steps."""
        sub = tmp_path / "project"
        sub.mkdir()
        (sub / "main.py").write_text("print('MULTI_STEP_MAGIC')")

        job, events = await run_prompt(
            f"First use glob to find .py files in {sub}, then use read to read the first one you find. Tell me what it prints.",
            session_factory=e2e_session_factory,
            provider_registry=e2e_provider_registry,
            agent_registry=e2e_agent_registry,
            tool_registry=e2e_tool_registry,
            timeout=120.0,
        )

        event_types = [e["event"] for e in events]
        assert DONE in event_types
        assert AGENT_ERROR not in event_types

        # Should have at least 2 steps (step_start appears multiple times)
        step_starts = [e for e in events if e["event"] == STEP_START]
        assert len(step_starts) >= 2, \
            f"Expected multi-step (>=2), got {len(step_starts)} steps"

        # Should have called at least 2 tools
        tool_starts = [e for e in events if e["event"] == TOOL_START]
        assert len(tool_starts) >= 2, \
            f"Expected >=2 tool calls, got {len(tool_starts)}"

        # Final text should reference the content
        text = "".join(
            e["data"].get("text", "") for e in events if e["event"] == TEXT_DELTA
        )
        assert "MULTI_STEP_MAGIC" in text or "multi_step_magic" in text.lower(), \
            f"Agent didn't find the content. Text: {text[:500]}"


class TestDoomLoopDetection:
    """Verify loop detection blocks infinite tool calling."""

    @pytest.mark.asyncio
    async def test_doom_loop_blocked(
        self, e2e_session_factory, e2e_provider_registry,
        e2e_agent_registry, e2e_tool_registry,
    ):
        """Prompt that could cause repeated identical calls → should be caught."""
        from app.session.loop_detection import LoopDetector

        detector = LoopDetector(warn_threshold=2, hard_limit=3)
        assert detector.check("s", "search", {"q": "x"}).action == "allow"
        assert detector.check("s", "search", {"q": "x"}).action == "warn"
        assert detector.check("s", "search", {"q": "x"}).action == "block"


class TestStreamManager:
    """Stream manager and job lifecycle tests."""

    def test_create_and_get_job(self):
        sm = StreamManager()
        job = sm.create_job("stream-1", "session-1")
        assert sm.get_job("stream-1") is job
        assert sm.get_job("nonexistent") is None

    def test_active_jobs(self):
        sm = StreamManager()
        j1 = sm.create_job("s1", "sess1")
        j2 = sm.create_job("s2", "sess2")
        assert len(sm.active_jobs()) == 2

        j1.complete()
        active = sm.active_jobs()
        assert len(active) == 1
        assert active[0]["stream_id"] == "s2"

    def test_event_replay(self):
        from app.streaming.events import SSEEvent, TEXT_DELTA
        job = GenerationJob("s1", "sess1")

        job.publish(SSEEvent(TEXT_DELTA, {"text": "hello"}))
        job.publish(SSEEvent(TEXT_DELTA, {"text": " world"}))

        # Subscribe with replay from event 1
        queue = job.subscribe(last_event_id=1)
        # Should only get event 2 (id=2)
        assert not queue.empty()
        event = queue.get_nowait()
        assert event.data["text"] == " world"

    def test_subscribe_after_complete(self):
        from app.streaming.events import SSEEvent, TEXT_DELTA, DONE
        job = GenerationJob("s1", "sess1")
        job.publish(SSEEvent(TEXT_DELTA, {"text": "hi"}))
        job.publish(SSEEvent(DONE, {}))
        job.complete()

        # Subscribing after completion replays all + gets None sentinel
        queue = job.subscribe()
        events = []
        while not queue.empty():
            e = queue.get_nowait()
            events.append(e)
        assert events[-1] is None  # sentinel
        assert len(events) == 3  # text + done + None

    @pytest.mark.asyncio
    async def test_abort(self):
        job = GenerationJob("s1", "sess1")
        assert not job.abort_event.is_set()
        job.abort()
        assert job.abort_event.is_set()


class TestConversationContinuity:
    """Test multi-turn conversation within same session."""

    @pytest.mark.asyncio
    async def test_second_turn_has_context(
        self, e2e_session_factory, e2e_provider_registry,
        e2e_agent_registry, e2e_tool_registry,
    ):
        """Second message in same session should see history."""
        session_id = generate_ulid()

        # Turn 1: establish a fact
        await run_prompt(
            "Remember this number: 42. Just acknowledge.",
            session_id=session_id,
            session_factory=e2e_session_factory,
            provider_registry=e2e_provider_registry,
            agent_registry=e2e_agent_registry,
            tool_registry=e2e_tool_registry,
        )

        # Turn 2: ask about the fact
        _, events = await run_prompt(
            "What number did I just tell you to remember?",
            session_id=session_id,
            session_factory=e2e_session_factory,
            provider_registry=e2e_provider_registry,
            agent_registry=e2e_agent_registry,
            tool_registry=e2e_tool_registry,
        )

        text = "".join(
            e["data"].get("text", "") for e in events if e["event"] == TEXT_DELTA
        )
        assert "42" in text, f"Model didn't recall the number. Text: {text[:300]}"
