"""Live integration tests for all provider engines.

These tests call real provider APIs and verify that every field of the final
Response is populated correctly — including `cost`, which was the subject of a
recent bug fix.

Run exclusively:
    pytest -m live

Skip during normal CI (add ``-m "not live"`` to addopts or just run by path):
    pytest tests/ -m "not live"

Outputs are saved to tests/gen/engine/live/run/{provider}_{safe_model}_{mode}.json
so you can inspect the raw payloads after a run.

API keys are read from environment variables:
    ANTHROPIC_API_KEY, OPENAI_API_KEY, GOOGLE_API_KEY, XAI_API_KEY
"""

from __future__ import annotations

# standard
import json
import os
from dataclasses import asdict
from enum import Enum
from pathlib import Path

# third party
import pytest

# custom
from sunwaee.modules.gen.engine.factory import get_engine
from sunwaee.modules.gen.engine.types import Message, Response, Role, StopReason

# ---------------------------------------------------------------------------
# Config
# ---------------------------------------------------------------------------

# Cheapest/fastest model per provider — keeps live-test costs minimal.
ENGINES: list[tuple[str, str]] = [
    ("anthropic", "claude-haiku-4-5"),
    ("openai", "gpt-5-nano"),
    ("google", "gemini-3.1-flash-lite-preview"),
    ("moonshot", "kimi-k2.5"),
    ("xai", "grok-4-1-fast"),
]

MESSAGES: list[Message] = [Message(role=Role.USER, content="Respond with 'ok'.")]

RUN_DIR = Path(__file__).parent / "run"

# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


def _to_dict(obj):  # type: ignore[return]
    """Recursively convert dataclasses / Enums to plain JSON-serialisable types."""
    if hasattr(obj, "__dataclass_fields__"):
        return {k: _to_dict(v) for k, v in asdict(obj).items()}
    if isinstance(obj, list):
        return [_to_dict(i) for i in obj]
    if isinstance(obj, Enum):
        return obj.value
    return obj


def _save(data: dict, name: str) -> None:
    RUN_DIR.mkdir(parents=True, exist_ok=True)
    safe = name.replace("/", "-")
    (RUN_DIR / f"{safe}.json").write_text(json.dumps(data, indent=2))


def _key_for(provider: str) -> str:
    return f"{provider.upper()}_API_KEY"


def _assert_final_response(r: Response, *, streaming: bool) -> None:
    """Assert every field that must be populated on a terminal Response.

    Called on:
    - the Response returned by ``chat()``
    - the last chunk yielded by ``stream()`` (the one carrying stop_reason)
    """
    # identity
    assert r.provider, "provider must be non-empty"
    assert r.model, "model must be non-empty"
    assert r.streaming is streaming, f"expected streaming={streaming}"
    assert r.synthetic is False

    # no error
    assert r.error is None, f"unexpected error: {r.error}"

    # stop reason
    assert r.stop_reason is not None, "stop_reason must be set on the final response"
    assert (
        r.stop_reason == StopReason.END_TURN
    ), f"expected END_TURN, got {r.stop_reason}"

    # usage
    assert r.usage is not None, "usage must be set"
    assert r.usage.input_tokens > 0, "input_tokens must be > 0"
    assert r.usage.output_tokens > 0, "output_tokens must be > 0"
    assert r.usage.total_tokens > 0, "total_tokens must be > 0"

    # performance
    assert r.performance is not None, "performance must be set"
    assert r.performance.total_duration > 0, "total_duration must be > 0"
    assert r.performance.throughput > 0, "throughput must be > 0"

    # cost  ← the field we just fixed
    assert r.cost is not None, "cost must be set — engine did not call compute_cost()"
    assert r.cost.input >= 0, "cost.input must be >= 0"
    assert r.cost.output >= 0, "cost.output must be >= 0"
    assert r.cost.total > 0, "cost.total must be > 0"


def _assert_content_chunk(r: Response) -> None:
    """Assert a streaming content chunk (not the terminal summary)."""
    assert r.provider, "provider must be non-empty"
    assert r.model, "model must be non-empty"
    assert r.streaming is True
    assert r.stop_reason is None, "intermediate chunks must not carry stop_reason"
    # cost / usage / performance are None on intermediate chunks — that is expected
    assert r.cost is None
    assert r.usage is None
    assert r.performance is None


# ---------------------------------------------------------------------------
# Tests — non-streaming (chat)
# ---------------------------------------------------------------------------


@pytest.mark.live
@pytest.mark.parametrize("provider,model", ENGINES)
async def test_chat_response_complete(provider: str, model: str) -> None:
    """chat() must return a Response with all fields populated."""
    if not os.environ.get(_key_for(provider)):
        pytest.skip(f"{_key_for(provider)} not set")

    engine = get_engine(provider, model)
    response = await engine.chat(MESSAGES)

    _save(
        _to_dict(response),
        f"{provider}_{model}_chat",
    )

    _assert_final_response(response, streaming=False)

    # content must be non-None for a simple text reply
    assert response.content is not None, "content must be set for a text reply"
    assert response.tool_calls is None, "tool_calls must be None when no tools given"


# ---------------------------------------------------------------------------
# Tests — streaming
# ---------------------------------------------------------------------------


@pytest.mark.live
@pytest.mark.parametrize("provider,model", ENGINES)
async def test_stream_chunks_and_final_complete(provider: str, model: str) -> None:
    """stream() must yield content chunks followed by a terminal summary chunk
    that carries stop_reason, usage, performance, and cost.
    """
    if not os.environ.get(_key_for(provider)):
        pytest.skip(f"{_key_for(provider)} not set")

    engine = get_engine(provider, model)
    chunks: list[Response] = [c async for c in engine.stream(MESSAGES)]

    _save(
        {"chunks": [_to_dict(c) for c in chunks]},
        f"{provider}_{model}_stream",
    )

    assert chunks, "stream must yield at least one chunk"

    # Every chunk must carry provider + model
    for i, chunk in enumerate(chunks):
        assert chunk.provider == provider, f"chunk[{i}].provider mismatch"
        assert chunk.model == model, f"chunk[{i}].model mismatch"
        assert chunk.streaming is True, f"chunk[{i}].streaming must be True"

    # Separate content chunks from the terminal summary
    content_chunks = [c for c in chunks if c.stop_reason is None and not c.synthetic]
    final = chunks[-1]

    # There must be at least one content or reasoning chunk before the summary
    assert content_chunks, "stream must yield at least one content chunk"

    # Intermediate chunks must not carry terminal fields
    for chunk in content_chunks:
        _assert_content_chunk(chunk)

    # Terminal summary must have all fields
    _assert_final_response(final, streaming=True)

    # For a simple text reply the accumulated content must be non-empty
    accumulated_content = "".join(c.content or "" for c in chunks)
    assert accumulated_content, "accumulated content must be non-empty"
