import json
from pathlib import Path
from typing import Optional

import pytest

from codex_autorunner.agents.opencode.runtime import (
    OpenCodeTurnOutput,
    extract_session_id,
)
from codex_autorunner.core.ports.run_event import Completed, OutputDelta, TokenUsage
from codex_autorunner.core.sse import SSEEvent
from codex_autorunner.integrations.agents import (
    opencode_backend as opencode_backend_module,
)
from codex_autorunner.integrations.agents.opencode_backend import OpenCodeBackend


class _FakeOpenCodeClient:
    def __init__(self, events: list[SSEEvent]):
        self._events = events

    async def stream_events(
        self, *, directory=None, ready_event=None, paths=None, session_id=None
    ):
        if ready_event is not None:
            ready_event.set()
        for event in self._events:
            yield event

    async def session_status(self, directory=None):
        return {"status": {"type": "idle"}}

    async def providers(self, directory=None):
        return {}

    async def respond_permission(self, request_id: str, reply: str):
        return None

    async def reply_question(self, request_id: str, answers):
        return None

    async def reject_question(self, request_id: str):
        return None

    async def prompt_async(self, *args, **kwargs):
        return {}

    async def send_command(self, *args, **kwargs):
        return None


@pytest.mark.anyio
async def test_opencode_streaming_coalesces_text_deltas(tmp_path: Path) -> None:
    session_id = "s-test"
    deltas = ["Hello ", "world", " from ", "test", "."]
    events = [
        SSEEvent(
            event="message.part.updated",
            data='{"sessionID":"s-test","properties":{"delta":{"text":"Hello "},'
            '"part":{"type":"text","text":"Hello "}}}',
        ),
        SSEEvent(
            event="message.part.updated",
            data='{"sessionID":"s-test","properties":{"delta":{"text":"world"},'
            '"part":{"type":"text","text":"Hello world"}}}',
        ),
        SSEEvent(
            event="message.part.updated",
            data='{"sessionID":"s-test","properties":{"delta":{"text":" from "},'
            '"part":{"type":"text","text":"Hello world from "}}}',
        ),
        SSEEvent(
            event="message.part.updated",
            data='{"sessionID":"s-test","properties":{"delta":{"text":"test"},'
            '"part":{"type":"text","text":"Hello world from test"}}}',
        ),
        SSEEvent(
            event="message.part.updated",
            data='{"sessionID":"s-test","properties":{"delta":{"text":"."},'
            '"part":{"type":"text","text":"Hello world from test."}}}',
        ),
        SSEEvent(event="session.idle", data='{"sessionID":"s-test"}'),
    ]

    backend = OpenCodeBackend(workspace_root=tmp_path, supervisor=None)
    backend._client = _FakeOpenCodeClient(events)  # type: ignore

    assistant_chunks: list[str] = []
    final_message: Optional[str] = None

    async for event in backend.run_turn_events(session_id, "Ping"):
        if isinstance(event, OutputDelta) and event.delta_type == "assistant_stream":
            assistant_chunks.append(event.content)
        if isinstance(event, Completed):
            final_message = event.final_message

    assert final_message == "".join(deltas)
    assert "".join(assistant_chunks) == "".join(deltas)
    # Regression: avoid emitting one OutputDelta per tiny delta
    assert len(assistant_chunks) <= len(deltas) // 2


@pytest.mark.anyio
async def test_opencode_streaming_real_events_ignore_user_prompt(
    tmp_path: Path,
) -> None:
    fixture_path = Path(__file__).parent / "fixtures" / "opencode_stream_real.json"
    raw_events = json.loads(fixture_path.read_text())
    sse_events = [
        SSEEvent(event=entry["event"], data=entry["data"]) for entry in raw_events
    ]
    session_id = next(
        sid
        for sid in (
            extract_session_id(json.loads(entry["data"])) for entry in raw_events
        )
        if sid
    )

    backend = OpenCodeBackend(workspace_root=tmp_path, supervisor=None)
    backend._client = _FakeOpenCodeClient(sse_events)  # type: ignore

    assistant_chunks: list[str] = []
    final_message: Optional[str] = None

    async for event in backend.run_turn_events(session_id, "Ping"):
        if isinstance(event, OutputDelta) and event.delta_type == "assistant_stream":
            assistant_chunks.append(event.content)
        if isinstance(event, Completed):
            final_message = event.final_message

    assert final_message is not None
    assert not final_message.startswith("Write a concise two-sentence")
    assert final_message.startswith("Debugging")
    assert "".join(assistant_chunks).strip() == final_message


@pytest.mark.anyio
async def test_opencode_backend_emits_single_canonical_usage_and_persists(
    tmp_path: Path,
) -> None:
    session_id = "s-usage"
    events = [
        SSEEvent(
            event="message.part.updated",
            data=(
                '{"sessionID":"s-usage","properties":{"part":{"type":"step-finish",'
                '"tokens":{"input":10,"output":5}}}}'
            ),
        ),
        SSEEvent(
            event="message.updated",
            data=(
                '{"sessionID":"s-usage","properties":{"info":{"tokens":'
                '{"input":10,"output":5,"cache":{"read":2}}}}}'
            ),
        ),
        SSEEvent(event="session.idle", data='{"sessionID":"s-usage"}'),
    ]

    backend = OpenCodeBackend(workspace_root=tmp_path, supervisor=None)
    backend._client = _FakeOpenCodeClient(events)  # type: ignore

    run_events = [event async for event in backend.run_turn_events(session_id, "Ping")]
    usage_events = [event for event in run_events if isinstance(event, TokenUsage)]
    assert len(usage_events) == 1
    assert usage_events[0].usage["totalTokens"] == 17
    assert usage_events[0].usage["cachedInputTokens"] == 2

    persisted_path = (
        tmp_path / ".codex-autorunner" / "usage" / "opencode_turn_usage.jsonl"
    )
    lines = persisted_path.read_text(encoding="utf-8").splitlines()
    assert len(lines) == 1
    payload = json.loads(lines[0])
    assert payload["session_id"] == "s-usage"
    assert payload["usage"]["total_tokens"] == 17


@pytest.mark.anyio
@pytest.mark.parametrize(
    ("session_stall_timeout_seconds", "expected_first_event_timeout"),
    [
        (None, 60.0),
        (15.0, 15.0),
        (120.0, 60.0),
    ],
)
async def test_opencode_backend_passes_first_event_timeout_to_collector(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
    session_stall_timeout_seconds: Optional[float],
    expected_first_event_timeout: float,
) -> None:
    captured: dict[str, float] = {}

    async def _fake_collect(client, **kwargs):
        ready_event = kwargs.get("ready_event")
        if ready_event is not None:
            ready_event.set()
        captured["first_event_timeout_seconds"] = kwargs["first_event_timeout_seconds"]
        return OpenCodeTurnOutput(text="ok")

    monkeypatch.setattr(
        opencode_backend_module,
        "collect_opencode_output",
        _fake_collect,
    )

    backend = OpenCodeBackend(
        workspace_root=tmp_path,
        supervisor=None,
        session_stall_timeout_seconds=session_stall_timeout_seconds,
    )
    backend._client = _FakeOpenCodeClient([])  # type: ignore

    run_events = [event async for event in backend.run_turn_events("s-pass", "Ping")]

    assert captured["first_event_timeout_seconds"] == expected_first_event_timeout
    assert any(
        isinstance(event, Completed) and event.final_message == "ok"
        for event in run_events
    )
