"""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`, `usage`, and `performance`.

Run exclusively:
    pytest -m live

Skip during normal CI:
    pytest tests/ -m "not live"

Outputs are saved to tests/gen/engine/live/run/{provider}_{safe_model}_{scenario}_{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

Scenarios
---------
ONLY_SYSTEM      — system message only (edge case; lenient assertions)
ONLY_USER        — single user message
SYSTEM_AND_USER  — system prompt + user message; asserts system prompt is respected
TOOL_CALL        — model must issue a tool call

TOOL_CALL_RESULT is tested separately via test_chat_tool_call_result /
test_stream_tool_call_result, which run a real TOOL_CALL first to capture
the provider-specific tool call IDs and any reasoning content, then replay
the full multi-turn conversation to avoid format mismatches.
"""

from __future__ import annotations

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

# 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,
    Tool,
    ToolCall,
)

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

ENGINES: list[tuple[str, str]] = [
    ("anthropic", "claude-haiku-4-5"),
    ("deepseek", "deepseek-chat"),
    ("google", "gemini-3.1-flash-lite-preview"),
    ("moonshot", "kimi-k2.5"),
    ("openai", "gpt-5-nano"),
    ("xai", "grok-4-1-fast"),
]

# ---------------------------------------------------------------------------
# Tools
# ---------------------------------------------------------------------------

TOOLS: list[Tool] = [
    Tool(
        name="update_chat",
        description="Update the current chat title.",
        parameters={
            "type": "object",
            "properties": {
                "title": {"type": "string", "description": "The new chat title."},
            },
            "required": ["title"],
        },
        fn=lambda title: json.dumps({"before": "New Chat", "after": title}),
    ),
]

_TOOL_FNS: dict[str, Callable] = {t.name: t.fn for t in TOOLS if t.fn}


def _execute_tool(name: str, arguments: dict) -> str:
    fn = _TOOL_FNS.get(name)
    return fn(**arguments) if fn else json.dumps({"error": f"unknown tool: {name}"})


# ---------------------------------------------------------------------------
# Scenarios (simple parametrized cases)
# ---------------------------------------------------------------------------

SCENARIOS: dict[str, list[Message]] = {
    "ONLY_SYSTEM": [
        Message(role=Role.SYSTEM, content="Respond with 'ok'."),
    ],
    "ONLY_USER": [
        Message(role=Role.USER, content="Respond with 'ok'."),
    ],
    "SYSTEM_AND_USER": [
        Message(role=Role.SYSTEM, content="Start all your answers with <START>."),
        Message(role=Role.USER, content="Respond with 'ok'."),
    ],
    "TOOL_CALL": [
        Message(role=Role.USER, content="Update this chat name to 'Test Chat'."),
    ],
}

_TOOL_SCENARIOS = {"TOOL_CALL"}

SCENARIO_NAMES = list(SCENARIOS.keys())

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_base_fields(r: Response, *, streaming: bool) -> None:
    """Assert provider identity and absence of error — common to all scenarios."""
    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
    assert r.error is None, f"unexpected error: {r.error}"
    assert r.stop_reason is not None, "stop_reason must be set on the final response"


def _assert_usage_cost_perf(r: Response) -> None:
    """Assert usage, cost, and performance are fully populated."""
    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"

    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"

    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_final_response(r: Response, *, streaming: bool, scenario_name: str) -> None:
    """Assert the terminal Response for a given scenario.

    For streaming the final chunk carries only metadata (stop_reason, usage,
    cost, performance) — content and tool_calls live in earlier chunks and are
    asserted separately in each streaming test.
    """
    if scenario_name == "ONLY_SYSTEM":
        # Not all providers accept a system-only message list; only verify
        # the engine returned a valid Response object without crashing.
        assert r.provider, "provider must be non-empty"
        assert r.model, "model must be non-empty"
        return

    _assert_base_fields(r, streaming=streaming)
    _assert_usage_cost_perf(r)

    if scenario_name in ("ONLY_USER", "SYSTEM_AND_USER"):
        assert (
            r.stop_reason == StopReason.END_TURN
        ), f"expected END_TURN, got {r.stop_reason}"
        if not streaming:
            assert r.content, "content must be non-empty for a text reply"
            assert r.tool_calls is None, "tool_calls must be None when no tools given"

    elif scenario_name == "TOOL_CALL":
        assert (
            r.stop_reason == StopReason.TOOL_USE
        ), f"expected TOOL_USE, got {r.stop_reason}"
        if not streaming:
            assert r.tool_calls, "tool_calls must be populated for TOOL_CALL scenario"
            call = r.tool_calls[0]
            assert call.name == "update_chat", f"unexpected tool name: {call.name!r}"
            assert (
                call.arguments.get("title") == "Test Chat"
            ), f"unexpected title argument: {call.arguments!r}"

    elif scenario_name == "TOOL_CALL_RESULT":
        assert (
            r.stop_reason == StopReason.END_TURN
        ), f"expected END_TURN, got {r.stop_reason}"
        if not streaming:
            assert r.content, "content must be non-empty after tool result"


def _assert_content_chunk(r: Response) -> None:
    """Assert a streaming intermediate chunk (no stop_reason, no terminal fields)."""
    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"
    assert r.cost is None
    assert r.usage is None
    assert r.performance is None


def _collect_stream(chunks: list[Response]) -> tuple[str, str, list[ToolCall]]:
    """Return (accumulated_content, accumulated_reasoning, all_tool_calls) from chunks."""
    content = "".join(
        c.content or "" for c in chunks if c.stop_reason is None and not c.synthetic
    )
    reasoning = "".join(
        c.reasoning_content or ""
        for c in chunks
        if c.stop_reason is None and not c.synthetic
    )
    tool_calls = [tc for c in chunks if c.tool_calls for tc in c.tool_calls]
    return content, reasoning, tool_calls


# ---------------------------------------------------------------------------
# Tests — non-streaming (chat) parametrized by scenario
# ---------------------------------------------------------------------------


@pytest.mark.live
@pytest.mark.parametrize("scenario_name", SCENARIO_NAMES)
@pytest.mark.parametrize("provider,model", ENGINES)
async def test_chat_scenario(provider: str, model: str, scenario_name: str) -> None:
    """chat() must return a correctly populated Response for every scenario."""
    if not os.environ.get(_key_for(provider)):
        pytest.skip(f"{_key_for(provider)} not set")

    messages = SCENARIOS[scenario_name]
    tools = TOOLS if scenario_name in _TOOL_SCENARIOS else None

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

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

    _assert_final_response(response, streaming=False, scenario_name=scenario_name)

    # Extra content assertion for SYSTEM_AND_USER (chat mode only)
    if scenario_name == "SYSTEM_AND_USER":
        assert (
            "<START>" in (response.content or "").upper()
        ), f"system prompt not respected — content: {response.content!r}"


# ---------------------------------------------------------------------------
# Tests — streaming parametrized by scenario
# ---------------------------------------------------------------------------


@pytest.mark.live
@pytest.mark.parametrize("scenario_name", SCENARIO_NAMES)
@pytest.mark.parametrize("provider,model", ENGINES)
async def test_stream_scenario(provider: str, model: str, scenario_name: str) -> None:
    """stream() must yield intermediate chunks followed by a terminal 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")

    messages = SCENARIOS[scenario_name]
    tools = TOOLS if scenario_name in _TOOL_SCENARIOS else None

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

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

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

    # Every chunk must carry provider + model + streaming=True
    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"

    final = chunks[-1]

    if scenario_name == "ONLY_SYSTEM":
        assert final.provider
        assert final.model
        return

    # Intermediate chunks must not carry terminal fields
    intermediate = [c for c in chunks if c.stop_reason is None and not c.synthetic]
    for chunk in intermediate:
        _assert_content_chunk(chunk)

    # Terminal chunk assertions (content/tool_calls live in intermediate chunks)
    _assert_final_response(final, streaming=True, scenario_name=scenario_name)

    # Accumulated assertions — content/tool_calls must appear somewhere in the stream
    accumulated_content, _, all_tool_calls = _collect_stream(chunks)

    if scenario_name in ("ONLY_USER", "SYSTEM_AND_USER"):
        assert accumulated_content, "accumulated content must be non-empty"

    if scenario_name == "SYSTEM_AND_USER":
        assert (
            "<START>" in accumulated_content.upper()
        ), f"system prompt not respected — accumulated: {accumulated_content!r}"

    if scenario_name == "TOOL_CALL":
        assert all_tool_calls, "no tool calls found across stream chunks"
        call = all_tool_calls[0]
        assert call.name == "update_chat", f"unexpected tool name: {call.name!r}"
        assert (
            call.arguments.get("title") == "Test Chat"
        ), f"unexpected title argument: {call.arguments!r}"


# ---------------------------------------------------------------------------
# Tests — TOOL_CALL_RESULT (dynamic: builds on real TOOL_CALL output)
#
# Using static hardcoded messages for the assistant turn fails because:
#  - providers use different tool call ID formats (e.g. Google uses the
#    function name; moonshot appends ":0"; OpenAI generates its own IDs)
#  - thinking-enabled models (kimi-k2.5) require reasoning_content in
#    assistant turns
#
# These tests run a real TOOL_CALL first to capture the provider-specific
# response, then replay the full multi-turn conversation.
# ---------------------------------------------------------------------------


@pytest.mark.live
@pytest.mark.parametrize("provider,model", ENGINES)
async def test_chat_tool_call_result(provider: str, model: str) -> None:
    """Full tool-use loop via chat(): TOOL_CALL → execute → text reply."""
    if not os.environ.get(_key_for(provider)):
        pytest.skip(f"{_key_for(provider)} not set")

    engine = get_engine(provider, model)
    user_messages: list[Message] = list(SCENARIOS["TOOL_CALL"])

    # Step 1 — get the tool call
    tc_response = await engine.chat(user_messages, tools=TOOLS)
    assert (
        tc_response.stop_reason == StopReason.TOOL_USE
    ), f"expected TOOL_USE from step 1, got {tc_response.stop_reason}"
    assert tc_response.tool_calls, "expected tool_calls in step 1 response"

    # Step 2 — build the assistant turn using the real response fields so that
    # provider-specific IDs and reasoning content are preserved
    assistant_msg = Message(
        role=Role.ASSISTANT,
        content=tc_response.content,
        reasoning_content=tc_response.reasoning_content,
        reasoning_signature=tc_response.reasoning_signature,
        tool_calls=tc_response.tool_calls,
    )

    tool_msgs = [
        Message(
            role=Role.TOOL,
            content=_execute_tool(tc.name, tc.arguments),
            tool_call_id=tc.id,
        )
        for tc in tc_response.tool_calls
    ]

    # Step 3 — run the final turn
    final_messages = user_messages + [assistant_msg] + tool_msgs
    final_response = await engine.chat(final_messages, tools=TOOLS)

    _save(
        {
            "tool_call_response": _to_dict(tc_response),
            "final_response": _to_dict(final_response),
        },
        f"{provider}_{model}_TOOL_CALL_RESULT_chat",
    )

    _assert_final_response(
        final_response, streaming=False, scenario_name="TOOL_CALL_RESULT"
    )


@pytest.mark.live
@pytest.mark.parametrize("provider,model", ENGINES)
async def test_stream_tool_call_result(provider: str, model: str) -> None:
    """Full tool-use loop via stream(): TOOL_CALL → execute → text reply."""
    if not os.environ.get(_key_for(provider)):
        pytest.skip(f"{_key_for(provider)} not set")

    engine = get_engine(provider, model)
    user_messages: list[Message] = list(SCENARIOS["TOOL_CALL"])

    # Step 1 — stream the tool call
    tc_chunks: list[Response] = [
        c async for c in engine.stream(user_messages, tools=TOOLS)
    ]
    tc_final = tc_chunks[-1]
    assert (
        tc_final.stop_reason == StopReason.TOOL_USE
    ), f"expected TOOL_USE from step 1 stream, got {tc_final.stop_reason}"

    tc_content, tc_reasoning, all_tool_calls = _collect_stream(tc_chunks)
    # reasoning_signature may arrive on any chunk
    tc_reasoning_sig = next(
        (c.reasoning_signature for c in tc_chunks if c.reasoning_signature), None
    )

    assert all_tool_calls, "no tool calls found across step 1 stream chunks"

    # Step 2 — build the assistant turn from accumulated stream data
    assistant_msg = Message(
        role=Role.ASSISTANT,
        content=tc_content or None,
        reasoning_content=tc_reasoning or None,
        reasoning_signature=tc_reasoning_sig,
        tool_calls=all_tool_calls,
    )

    tool_msgs = [
        Message(
            role=Role.TOOL,
            content=_execute_tool(tc.name, tc.arguments),
            tool_call_id=tc.id,
        )
        for tc in all_tool_calls
    ]

    # Step 3 — stream the final turn
    final_messages = user_messages + [assistant_msg] + tool_msgs
    final_chunks: list[Response] = [
        c async for c in engine.stream(final_messages, tools=TOOLS)
    ]
    final = final_chunks[-1]

    _save(
        {
            "step1_chunks": [_to_dict(c) for c in tc_chunks],
            "step2_chunks": [_to_dict(c) for c in final_chunks],
        },
        f"{provider}_{model}_TOOL_CALL_RESULT_stream",
    )

    assert final_chunks, "step 2 stream must yield at least one chunk"

    for i, chunk in enumerate(final_chunks):
        assert chunk.provider == provider, f"step2 chunk[{i}].provider mismatch"
        assert chunk.model == model, f"step2 chunk[{i}].model mismatch"
        assert chunk.streaming is True

    intermediate = [
        c for c in final_chunks if c.stop_reason is None and not c.synthetic
    ]
    for chunk in intermediate:
        _assert_content_chunk(chunk)

    _assert_final_response(final, streaming=True, scenario_name="TOOL_CALL_RESULT")

    # Accumulated content must be non-empty for the final reply
    accumulated_content, _, _ = _collect_stream(final_chunks)
    assert (
        accumulated_content
    ), "accumulated content must be non-empty after tool result"


# ---------------------------------------------------------------------------
# Cross-provider conversational continuity
#
# Models the real use case: each provider completes its ENTIRE turn (including
# any tool calls within it), appends a final assistant message to the shared
# history, then hands off to the next provider.  The next provider sees the
# full history — including tool call IDs, reasoning fields, and
# thought_signatures generated by the previous provider — and must accept it
# without any translation.
#
# Turn structure:
#   user message
#   → provider N: (tool call → tool result)* → final text reply
#   → assistant message appended to history
#   → user sends another message
#   → provider N+1 picks up
# ---------------------------------------------------------------------------


async def _run_full_turn(
    provider: str,
    model: str,
    history: list[Message],
) -> tuple[list[Message], Response]:
    """Run one complete agentic turn for a provider.

    Loops until END_TURN (or MAX_TOKENS), executing tool calls inline.
    Returns the updated history (with all intermediate + final messages
    appended) and the terminal Response.
    """
    messages = list(history)
    engine = get_engine(provider, model)

    for _ in range(10):
        response = await engine.chat(messages, tools=TOOLS)

        if response.stop_reason == StopReason.TOOL_USE and response.tool_calls:
            assistant_msg = Message(
                role=Role.ASSISTANT,
                content=response.content,
                reasoning_content=response.reasoning_content,
                reasoning_signature=response.reasoning_signature,
                tool_calls=response.tool_calls,
            )
            tool_msgs = [
                Message(
                    role=Role.TOOL,
                    content=_execute_tool(tc.name, tc.arguments),
                    tool_call_id=tc.id,
                )
                for tc in response.tool_calls
            ]
            messages += [assistant_msg] + tool_msgs
        else:
            # END_TURN or MAX_TOKENS — turn is complete
            messages.append(Message(role=Role.ASSISTANT, content=response.content))
            return messages, response

    raise AssertionError(
        f"{provider}/{model}: turn did not finish within 10 iterations"
    )


# (provider_a, model_a, provider_b, model_b)
CROSS_PROVIDER_PAIRS: list[tuple[str, str, str, str]] = [
    ("openai", "gpt-5-nano", "anthropic", "claude-haiku-4-5"),
    ("anthropic", "claude-haiku-4-5", "openai", "gpt-5-nano"),
    ("openai", "gpt-5-nano", "google", "gemini-3.1-flash-lite-preview"),
    ("google", "gemini-3.1-flash-lite-preview", "openai", "gpt-5-nano"),
    ("anthropic", "claude-haiku-4-5", "google", "gemini-3.1-flash-lite-preview"),
    ("google", "gemini-3.1-flash-lite-preview", "anthropic", "claude-haiku-4-5"),
]


def _provider_chat_name(provider: str) -> str:
    return f"{provider.capitalize()} Chat"


def _assert_tool_was_called(history: list[Message], expected_title: str) -> None:
    """Verify that the most recent tool message in history carries the expected title."""
    tool_msgs = [m for m in history if m.role == Role.TOOL]
    assert tool_msgs, "no tool messages found in history"
    last_result = json.loads(tool_msgs[-1].content or "{}")
    assert (
        last_result.get("after") == expected_title
    ), f"expected tool to set title to {expected_title!r}, got {last_result!r}"


@pytest.mark.live
@pytest.mark.parametrize("pa,ma,pb,mb", CROSS_PROVIDER_PAIRS)
async def test_chat_cross_provider_turn_switch(
    pa: str, ma: str, pb: str, mb: str
) -> None:
    """Two providers each complete a full turn with a tool call in the same chat.

    Turn 1 — Provider A renames the chat to '{pa} Chat' via tool call.
    Turn 2 — Provider B (receiving A's full history) renames it to '{pb} Chat'.

    Verifies that the conversation history — including provider-specific tool
    call IDs and reasoning fields — is fully portable across providers.
    """
    for key in (_key_for(pa), _key_for(pb)):
        if not os.environ.get(key):
            pytest.skip(f"{key} not set")

    name_a = _provider_chat_name(pa)
    name_b = _provider_chat_name(pb)

    history: list[Message] = [
        Message(role=Role.USER, content=f"Rename this chat to '{name_a}'."),
    ]

    # Turn 1 — Provider A: tool call to rename, then text reply
    history, r1 = await _run_full_turn(pa, ma, history)
    assert (
        r1.stop_reason == StopReason.END_TURN
    ), f"{pa}: expected END_TURN, got {r1.stop_reason}"
    assert r1.content, f"{pa}: content must be non-empty"
    _assert_tool_was_called(history, name_a)
    _assert_usage_cost_perf(r1)

    # Turn 2 — Provider B: receives A's full history, renames to its own name
    history.append(Message(role=Role.USER, content=f"Now rename it to '{name_b}'."))
    history, r2 = await _run_full_turn(pb, mb, history)
    assert (
        r2.stop_reason == StopReason.END_TURN
    ), f"{pb}: expected END_TURN, got {r2.stop_reason}"
    assert r2.content, f"{pb}: content must be non-empty"
    _assert_tool_was_called(history, name_b)
    _assert_usage_cost_perf(r2)

    _save(
        {
            f"turn1_{pa}": _to_dict(r1),
            f"turn2_{pb}": _to_dict(r2),
            "history_length": len(history),
        },
        f"{pa}_{ma}_to_{pb}_{mb}_turn_switch",
    )


@pytest.mark.live
async def test_chat_three_provider_chain() -> None:
    """Three providers each complete a full turn with a tool call in one chat.

    Turn 1 — OpenAI:    renames chat to 'OpenAI Chat'
    Turn 2 — Anthropic: renames chat to 'Anthropic Chat' (sees OpenAI's history)
    Turn 3 — Google:    renames chat to 'Google Chat' (sees full history)

    Each provider must call the update_chat tool with the correct title and
    accept the accumulated history built by all preceding providers.
    """
    for provider in ("openai", "anthropic", "google"):
        if not os.environ.get(_key_for(provider)):
            pytest.skip(f"{_key_for(provider)} not set")

    history: list[Message] = [
        Message(role=Role.USER, content="Rename this chat to 'OpenAI Chat'."),
    ]

    # Turn 1 — OpenAI
    history, r1 = await _run_full_turn("openai", "gpt-5-nano", history)
    assert r1.stop_reason == StopReason.END_TURN, f"turn1/openai: {r1.stop_reason}"
    assert r1.content
    _assert_tool_was_called(history, "OpenAI Chat")
    _assert_usage_cost_perf(r1)

    # Turn 2 — Anthropic (receives OpenAI's full history including tool messages)
    history.append(
        Message(role=Role.USER, content="Now rename it to 'Anthropic Chat'.")
    )
    history, r2 = await _run_full_turn("anthropic", "claude-haiku-4-5", history)
    assert r2.stop_reason == StopReason.END_TURN, f"turn2/anthropic: {r2.stop_reason}"
    assert r2.content
    _assert_tool_was_called(history, "Anthropic Chat")
    _assert_usage_cost_perf(r2)

    # Turn 3 — Google (receives the combined OpenAI + Anthropic history)
    history.append(Message(role=Role.USER, content="Now rename it to 'Google Chat'."))
    history, r3 = await _run_full_turn(
        "google", "gemini-3.1-flash-lite-preview", history
    )
    assert r3.stop_reason == StopReason.END_TURN, f"turn3/google: {r3.stop_reason}"
    assert r3.content
    _assert_tool_was_called(history, "Google Chat")
    _assert_usage_cost_perf(r3)

    _save(
        {
            "turn1_openai": _to_dict(r1),
            "turn2_anthropic": _to_dict(r2),
            "turn3_google": _to_dict(r3),
            "history_length": len(history),
        },
        "three_provider_chain_chat",
    )
