"""Tests for the Telegram listener."""

import asyncio
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch

import pytest

from fliiq.runtime.llm.providers import LLMConfig, LLMProvider
from fliiq.runtime.telegram.listener import (
    TelegramListener,
    _MODEL_RESET_SENTINEL,
    _agent_sent_telegram,
    _parse_model_directive,
    _split_message,
    parse_allowed_chat_ids,
)


@pytest.fixture
def listener(tmp_path):
    """Create a TelegramListener with a fake token."""
    mock_llm_config = MagicMock()
    return TelegramListener(
        bot_token="fake-token",
        llm_config=mock_llm_config,
        project_root=tmp_path,
    )


def test_split_message_short():
    assert _split_message("Hello") == ["Hello"]


def test_split_message_long():
    text = "a" * 5000
    chunks = _split_message(text)
    assert len(chunks) == 2
    assert len(chunks[0]) == 4096
    assert len(chunks[1]) == 904
    assert "".join(chunks) == text


def test_split_message_at_newline():
    text = "a" * 4000 + "\n" + "b" * 200
    chunks = _split_message(text)
    assert len(chunks) == 2
    assert chunks[0] == "a" * 4000
    assert chunks[1] == "b" * 200


def test_parse_allowed_chat_ids_empty(monkeypatch):
    monkeypatch.delenv("TELEGRAM_ALLOWED_CHAT_IDS", raising=False)
    assert parse_allowed_chat_ids() is None


def test_parse_allowed_chat_ids_valid(monkeypatch):
    monkeypatch.setenv("TELEGRAM_ALLOWED_CHAT_IDS", "123, 456, 789")
    result = parse_allowed_chat_ids()
    assert result == {123, 456, 789}


def test_parse_allowed_chat_ids_invalid(monkeypatch):
    monkeypatch.setenv("TELEGRAM_ALLOWED_CHAT_IDS", "abc, 123")
    result = parse_allowed_chat_ids()
    assert result is None


async def test_poll_updates_empty(listener):
    mock_response = MagicMock()
    mock_response.status_code = 200
    mock_response.json.return_value = {"ok": True, "result": []}

    mock_client = AsyncMock()
    mock_client.get.return_value = mock_response

    updates = await listener._poll_updates(mock_client)
    assert updates == []
    assert listener._offset == 0


async def test_poll_updates_with_message(listener):
    mock_response = MagicMock()
    mock_response.status_code = 200
    mock_response.json.return_value = {
        "ok": True,
        "result": [
            {
                "update_id": 100,
                "message": {
                    "chat": {"id": 42},
                    "text": "Hello Fliiq",
                },
            }
        ],
    }

    mock_client = AsyncMock()
    mock_client.get.return_value = mock_response

    updates = await listener._poll_updates(mock_client)
    assert len(updates) == 1
    assert updates[0]["message"]["text"] == "Hello Fliiq"
    assert listener._offset == 101


async def test_poll_updates_error(listener):
    mock_response = MagicMock()
    mock_response.status_code = 500
    mock_response.json.return_value = {}

    mock_client = AsyncMock()
    mock_client.get.return_value = mock_response

    updates = await listener._poll_updates(mock_client)
    assert updates == []


async def test_send_message_success(listener):
    mock_response = MagicMock()
    mock_response.status_code = 200

    with patch("httpx.AsyncClient") as mock_client_cls:
        mock_client = AsyncMock()
        mock_client.post.return_value = mock_response
        mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client)
        mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False)
        await listener.send_message(42, "Hello")

    mock_client.post.assert_called_once()
    call_kwargs = mock_client.post.call_args
    assert call_kwargs[1]["json"]["chat_id"] == 42
    assert call_kwargs[1]["json"]["text"] == "Hello"


async def test_send_message_long_text(listener):
    long_text = "a" * 5000
    mock_response = MagicMock()
    mock_response.status_code = 200

    with patch("httpx.AsyncClient") as mock_client_cls:
        mock_client = AsyncMock()
        mock_client.post.return_value = mock_response
        mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client)
        mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False)
        await listener.send_message(42, long_text)

    assert mock_client.post.call_count == 2


async def test_allowed_chat_ids_filter(tmp_path):
    mock_llm_config = MagicMock()
    listener = TelegramListener(
        bot_token="fake-token",
        llm_config=mock_llm_config,
        project_root=tmp_path,
        allowed_chat_ids={42},
    )

    # Mock poll returning a message from blocked chat_id
    mock_poll_response = MagicMock()
    mock_poll_response.status_code = 200
    mock_poll_response.json.return_value = {
        "ok": True,
        "result": [
            {
                "update_id": 100,
                "message": {
                    "chat": {"id": 999},  # Not in allowed list
                    "text": "Blocked user",
                },
            }
        ],
    }

    # Mock a second poll that returns empty (to break the loop)
    mock_empty_response = MagicMock()
    mock_empty_response.status_code = 200
    mock_empty_response.json.return_value = {"ok": True, "result": []}

    call_count = 0

    async def _mock_get(*args, **kwargs):
        nonlocal call_count
        call_count += 1
        if call_count == 1:
            return mock_poll_response
        # Stop the listener after first poll
        await listener.stop()
        return mock_empty_response

    with patch("httpx.AsyncClient") as mock_client_cls:
        mock_client = AsyncMock()
        mock_client.get.side_effect = _mock_get
        mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client)
        mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False)

        # Patch _handle_message to track if it was called
        with patch.object(listener, "_handle_message", new_callable=AsyncMock) as mock_handle:
            await listener.run()
            mock_handle.assert_not_called()  # Blocked chat_id → not handled


async def test_offset_tracking(listener):
    # First poll returns update_id 100
    resp1 = MagicMock()
    resp1.status_code = 200
    resp1.json.return_value = {
        "ok": True,
        "result": [{"update_id": 100, "message": {"chat": {"id": 42}, "text": "Hi"}}],
    }

    mock_client = AsyncMock()
    mock_client.get.return_value = resp1

    await listener._poll_updates(mock_client)
    assert listener._offset == 101

    # Second poll should use offset=101
    resp2 = MagicMock()
    resp2.status_code = 200
    resp2.json.return_value = {
        "ok": True,
        "result": [{"update_id": 101, "message": {"chat": {"id": 42}, "text": "Again"}}],
    }
    mock_client.get.return_value = resp2

    await listener._poll_updates(mock_client)
    assert listener._offset == 102

    # Verify the second call used offset=101
    second_call = mock_client.get.call_args_list[1]
    assert second_call[1]["params"]["offset"] == 101


# ── Voice message tests ──────────────────────────────────────────────


async def test_download_voice(listener):
    """Voice file is downloaded from Telegram and saved to temp OGG."""
    mock_get_file = MagicMock()
    mock_get_file.status_code = 200
    mock_get_file.json.return_value = {"result": {"file_path": "voice/file_123.ogg"}}

    mock_download = MagicMock()
    mock_download.status_code = 200
    mock_download.content = b"\x00\x01\x02" * 100

    with patch("httpx.AsyncClient") as mock_client_cls:
        mock_client = AsyncMock()
        mock_client.get.side_effect = [mock_get_file, mock_download]
        mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client)
        mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False)

        ogg_path = await listener._download_voice("file_id_123")

    assert ogg_path.exists()
    assert ogg_path.suffix == ".ogg"
    assert ogg_path.read_bytes() == b"\x00\x01\x02" * 100
    ogg_path.unlink()


async def test_download_voice_get_file_error(listener):
    """getFile API failure raises ValueError."""
    mock_resp = MagicMock()
    mock_resp.status_code = 400
    mock_resp.text = "Bad Request"

    with patch("httpx.AsyncClient") as mock_client_cls:
        mock_client = AsyncMock()
        mock_client.get.return_value = mock_resp
        mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client)
        mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False)

        with pytest.raises(ValueError, match="getFile failed"):
            await listener._download_voice("bad_file_id")


async def test_transcribe_voice(listener, tmp_path):
    """Voice transcription calls STT handler and returns text."""
    ogg = tmp_path / "voice.ogg"
    ogg.write_bytes(b"\x00" * 50)

    with patch(
        "fliiq.runtime.telegram.listener.TelegramListener._transcribe_voice",
        new_callable=AsyncMock,
        return_value="Hello from voice",
    ):
        # Call the actual method with a patched stt_handler
        mock_stt = AsyncMock(return_value={"text": "Hello from voice"})
        with patch("fliiq.data.skills.core.speech_to_text.main.handler", mock_stt):
            text = await listener._transcribe_voice(ogg)

    assert text == "Hello from voice"


async def test_voice_message_in_run_loop(tmp_path):
    """Voice message in run loop triggers download → transcribe → handle with is_voice=True."""
    mock_llm_config = MagicMock()
    listener = TelegramListener(
        bot_token="fake-token",
        llm_config=mock_llm_config,
        project_root=tmp_path,
    )

    # Update with a voice message (no text field)
    voice_update = {
        "update_id": 200,
        "message": {
            "chat": {"id": 42},
            "voice": {"file_id": "voice_file_abc", "duration": 3},
        },
    }
    mock_poll_response = MagicMock()
    mock_poll_response.status_code = 200
    mock_poll_response.json.return_value = {"ok": True, "result": [voice_update]}

    mock_empty_response = MagicMock()
    mock_empty_response.status_code = 200
    mock_empty_response.json.return_value = {"ok": True, "result": []}

    call_count = 0

    async def _mock_get(*args, **kwargs):
        nonlocal call_count
        call_count += 1
        if call_count == 1:
            return mock_poll_response
        await listener.stop()
        return mock_empty_response

    ogg_path = tmp_path / "voice.ogg"
    ogg_path.write_bytes(b"\x00" * 50)

    with patch("httpx.AsyncClient") as mock_client_cls:
        mock_client = AsyncMock()
        mock_client.get.side_effect = _mock_get
        mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client)
        mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False)

        with (
            patch.object(listener, "_download_voice", new_callable=AsyncMock, return_value=ogg_path) as mock_dl,
            patch.object(listener, "_transcribe_voice", new_callable=AsyncMock, return_value="test transcription") as mock_stt,
            patch.object(listener, "_handle_message", new_callable=AsyncMock) as mock_handle,
        ):
            await listener.run()

            mock_dl.assert_called_once_with("voice_file_abc")
            mock_stt.assert_called_once_with(ogg_path)
            mock_handle.assert_called_once_with(
                42, "test transcription", is_voice=True, message_thread_id=None,
            )


async def test_voice_transcription_failure_sends_error(tmp_path):
    """If STT fails, user gets an error message — not silence."""
    mock_llm_config = MagicMock()
    listener = TelegramListener(
        bot_token="fake-token",
        llm_config=mock_llm_config,
        project_root=tmp_path,
    )

    voice_update = {
        "update_id": 300,
        "message": {
            "chat": {"id": 42},
            "voice": {"file_id": "voice_file_err", "duration": 2},
        },
    }
    mock_poll_response = MagicMock()
    mock_poll_response.status_code = 200
    mock_poll_response.json.return_value = {"ok": True, "result": [voice_update]}

    mock_empty_response = MagicMock()
    mock_empty_response.status_code = 200
    mock_empty_response.json.return_value = {"ok": True, "result": []}

    call_count = 0

    async def _mock_get(*args, **kwargs):
        nonlocal call_count
        call_count += 1
        if call_count == 1:
            return mock_poll_response
        await listener.stop()
        return mock_empty_response

    ogg_path = tmp_path / "voice.ogg"
    ogg_path.write_bytes(b"\x00" * 50)

    with patch("httpx.AsyncClient") as mock_client_cls:
        mock_client = AsyncMock()
        mock_client.get.side_effect = _mock_get
        mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client)
        mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False)

        with (
            patch.object(listener, "_download_voice", new_callable=AsyncMock, return_value=ogg_path),
            patch.object(listener, "_transcribe_voice", new_callable=AsyncMock, side_effect=ValueError("OPENAI_API_KEY not set")),
            patch.object(listener, "send_message", new_callable=AsyncMock) as mock_send,
            patch.object(listener, "_handle_message", new_callable=AsyncMock) as mock_handle,
        ):
            await listener.run()

            # Error message sent to user
            mock_send.assert_called_once()
            error_text = mock_send.call_args[0][1]
            assert "Could not process voice message" in error_text

            # Agent loop NOT called
            mock_handle.assert_not_called()


async def test_voice_reply_failure_nonfatal(listener):
    """If TTS/sendVoice fails, it's logged but doesn't crash."""
    with patch.object(
        listener,
        "_synthesize_voice",
        new_callable=AsyncMock,
        side_effect=ValueError("OPENAI_API_KEY not set"),
    ):
        # Should not raise — best-effort
        await listener._send_voice_reply(42, "Hello")


async def test_handle_message_voice_tag(listener):
    """Voice messages get type='voice' in the external_message tag."""
    with (
        patch.object(listener, "_get_or_create_session", new_callable=AsyncMock) as mock_session,
        patch("fliiq.runtime.telegram.listener.agent_loop", new_callable=AsyncMock) as mock_loop,
        patch.object(listener, "send_message", new_callable=AsyncMock),
        patch.object(listener, "_send_voice_reply", new_callable=AsyncMock) as mock_voice_reply,
    ):
        mock_result = MagicMock()
        mock_result.messages = []
        mock_result.final_text = "Response text"
        mock_loop.return_value = mock_result

        mock_resources = MagicMock()
        mock_resources.soul = ""
        mock_resources.user_soul = ""
        mock_resources.skill_info = None
        mock_resources.memory_context = ""
        mock_resources.user_profile = ""
        mock_resources.fliiq_email = ""
        mock_session.return_value = {"messages": [], "resources": mock_resources}

        with patch("fliiq.runtime.telegram.listener.assemble_agent_prompt", return_value="system"):
            await listener._handle_message(42, "Hello from voice", is_voice=True)

        # Verify the tagged message includes type="voice"
        call_args = mock_loop.call_args
        user_msg = call_args[1]["messages"][0]["content"]
        assert 'type="voice"' in user_msg
        assert "Hello from voice" in user_msg

        # Voice reply should be triggered
        mock_voice_reply.assert_called_once_with(42, "Response text", None)


async def test_synthesize_voice(listener, monkeypatch):
    """OpenAI TTS API is called with correct params and returns opus file."""
    monkeypatch.setenv("OPENAI_API_KEY", "fake-key")
    monkeypatch.setenv("FLIIQ_VOICE_ID", "echo")

    mock_response = MagicMock()
    mock_response.status_code = 200
    mock_response.content = b"\x00\x01\x02" * 100  # fake opus data

    with patch("httpx.AsyncClient") as mock_client_cls:
        mock_client = AsyncMock()
        mock_client.post.return_value = mock_response
        mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client)
        mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False)

        audio_path = await listener._synthesize_voice("Hello world")

    assert audio_path.exists()
    assert audio_path.suffix == ".ogg"

    # Verify API call payload
    call_args = mock_client.post.call_args
    payload = call_args[1]["json"]
    assert payload["voice"] == "echo"
    assert payload["model"] == "tts-1"
    assert payload["response_format"] == "opus"
    assert payload["input"] == "Hello world"

    audio_path.unlink()


async def test_synthesize_voice_default_voice(listener, monkeypatch):
    """Without FLIIQ_VOICE_ID, defaults to 'onyx'."""
    monkeypatch.setenv("OPENAI_API_KEY", "fake-key")
    monkeypatch.delenv("FLIIQ_VOICE_ID", raising=False)

    mock_response = MagicMock()
    mock_response.status_code = 200
    mock_response.content = b"\x00" * 50

    with patch("httpx.AsyncClient") as mock_client_cls:
        mock_client = AsyncMock()
        mock_client.post.return_value = mock_response
        mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client)
        mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False)

        audio_path = await listener._synthesize_voice("Test")

    payload = mock_client.post.call_args[1]["json"]
    assert payload["voice"] == "onyx"
    audio_path.unlink()


# ── Forum Topics / message_thread_id tests ─────────────────────────


async def test_session_key_with_thread_id(listener):
    """Different thread_ids create separate sessions."""
    with (
        patch(
            "fliiq.runtime.telegram.listener.setup_agent_resources",
            new_callable=AsyncMock,
        ) as mock_setup,
    ):
        mock_resources = MagicMock()
        mock_setup.return_value = mock_resources

        s1 = await listener._get_or_create_session(42, None)
        s2 = await listener._get_or_create_session(42, 101)
        s3 = await listener._get_or_create_session(42, 102)

        assert s1 is not s2
        assert s2 is not s3
        assert len(listener._sessions) == 3
        assert (42, None) in listener._sessions
        assert (42, 101) in listener._sessions
        assert (42, 102) in listener._sessions


async def test_send_message_with_thread_id(listener):
    """send_message includes message_thread_id in payload when set."""
    mock_response = MagicMock()
    mock_response.status_code = 200

    with patch("httpx.AsyncClient") as mock_client_cls:
        mock_client = AsyncMock()
        mock_client.post.return_value = mock_response
        mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client)
        mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False)
        await listener.send_message(42, "Hello", message_thread_id=101)

    call_kwargs = mock_client.post.call_args
    payload = call_kwargs[1]["json"]
    assert payload["chat_id"] == 42
    assert payload["text"] == "Hello"
    assert payload["message_thread_id"] == 101


async def test_send_message_without_thread_id(listener):
    """send_message omits message_thread_id when None."""
    mock_response = MagicMock()
    mock_response.status_code = 200

    with patch("httpx.AsyncClient") as mock_client_cls:
        mock_client = AsyncMock()
        mock_client.post.return_value = mock_response
        mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client)
        mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False)
        await listener.send_message(42, "Hello")

    call_kwargs = mock_client.post.call_args
    payload = call_kwargs[1]["json"]
    assert "message_thread_id" not in payload


async def test_handle_message_with_thread_id(listener):
    """message_thread_id is included in external_message tag and routed correctly."""
    with (
        patch.object(listener, "_get_or_create_session", new_callable=AsyncMock) as mock_session,
        patch("fliiq.runtime.telegram.listener.agent_loop", new_callable=AsyncMock) as mock_loop,
        patch.object(listener, "send_message", new_callable=AsyncMock) as mock_send,
        patch.object(listener, "_send_chat_action", new_callable=AsyncMock),
    ):
        mock_result = MagicMock()
        mock_result.messages = []
        mock_result.final_text = "Response text"
        mock_result.iterations = 0
        mock_loop.return_value = mock_result

        mock_resources = MagicMock()
        mock_resources.soul = ""
        mock_resources.user_soul = ""
        mock_resources.skill_info = None
        mock_resources.memory_context = ""
        mock_resources.user_profile = ""
        mock_resources.fliiq_email = ""
        mock_session.return_value = {"messages": [], "resources": mock_resources}

        with patch("fliiq.runtime.telegram.listener.assemble_agent_prompt", return_value="system"):
            await listener._handle_message(42, "Hi topic", message_thread_id=101)

        # Session created with thread_id
        mock_session.assert_called_once_with(42, 101)

        # Tagged message includes thread_id
        user_msg = mock_loop.call_args[1]["messages"][0]["content"]
        assert 'message_thread_id="101"' in user_msg

        # Reply sent to same thread
        mock_send.assert_called_once_with(42, "Response text", 101)


async def test_thread_id_extracted_from_update(tmp_path):
    """run() extracts message_thread_id from inbound updates."""
    mock_llm_config = MagicMock()
    listener = TelegramListener(
        bot_token="fake-token",
        llm_config=mock_llm_config,
        project_root=tmp_path,
    )

    topic_update = {
        "update_id": 500,
        "message": {
            "chat": {"id": 42},
            "text": "Hello from topic",
            "message_thread_id": 201,
            "is_topic_message": True,
        },
    }
    mock_poll_response = MagicMock()
    mock_poll_response.status_code = 200
    mock_poll_response.json.return_value = {"ok": True, "result": [topic_update]}

    mock_empty_response = MagicMock()
    mock_empty_response.status_code = 200
    mock_empty_response.json.return_value = {"ok": True, "result": []}

    call_count = 0

    async def _mock_get(*args, **kwargs):
        nonlocal call_count
        call_count += 1
        if call_count == 1:
            return mock_poll_response
        await listener.stop()
        return mock_empty_response

    with patch("httpx.AsyncClient") as mock_client_cls:
        mock_client = AsyncMock()
        mock_client.get.side_effect = _mock_get
        mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client)
        mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False)

        with patch.object(listener, "_handle_message", new_callable=AsyncMock) as mock_handle:
            await listener.run()

            mock_handle.assert_called_once_with(
                42, "Hello from topic", is_voice=False, message_thread_id=201,
            )


# ── Model override directive parsing ────────────────────────────────


def test_parse_model_directive_slash_no_prompt():
    model, remaining = _parse_model_directive("/model opus-4.6")
    assert model == "opus-4.6"
    assert remaining == ""


def test_parse_model_directive_slash_with_prompt():
    model, remaining = _parse_model_directive("/model opus-4.6 summarize this doc")
    assert model == "opus-4.6"
    assert remaining == "summarize this doc"


def test_parse_model_directive_slash_multiline_prompt():
    model, remaining = _parse_model_directive("/model opus-4.6 line 1\nline 2\nline 3")
    assert model == "opus-4.6"
    assert remaining == "line 1\nline 2\nline 3"


def test_parse_model_directive_reset():
    model, remaining = _parse_model_directive("/model reset")
    assert model == _MODEL_RESET_SENTINEL
    assert remaining == ""


def test_parse_model_directive_default():
    model, remaining = _parse_model_directive("/model default")
    assert model == _MODEL_RESET_SENTINEL
    assert remaining == ""


def test_parse_model_directive_natural():
    model, remaining = _parse_model_directive("use opus-4.6 summarize this")
    assert model == "opus-4.6"
    assert remaining == "summarize this"


def test_parse_model_directive_natural_the_model():
    model, remaining = _parse_model_directive("use the model opus-4.6 summarize this")
    assert model == "opus-4.6"
    assert remaining == "summarize this"


def test_parse_model_directive_no_match():
    model, remaining = _parse_model_directive("hello world")
    assert model is None
    assert remaining == "hello world"


def test_parse_model_directive_case_insensitive():
    model, remaining = _parse_model_directive("/Model Opus-4.6 do stuff")
    assert model == "Opus-4.6"
    assert remaining == "do stuff"

    model2, remaining2 = _parse_model_directive("USE opus-4.6 do stuff")
    assert model2 == "opus-4.6"
    assert remaining2 == "do stuff"


def test_parse_model_directive_natural_no_remaining():
    """Bare 'use opus-4.6' with no prompt — should NOT match (ambiguous)."""
    model, remaining = _parse_model_directive("use opus-4.6")
    assert model is None
    assert remaining == "use opus-4.6"


# ── Model override integration tests ────────────────────────────────


async def test_handle_message_model_set_only(listener):
    """/model opus-4.6 (no prompt) sends confirmation, no agent_loop call."""
    with (
        patch.object(listener, "_get_or_create_session", new_callable=AsyncMock) as mock_session,
        patch("fliiq.runtime.telegram.listener.agent_loop", new_callable=AsyncMock) as mock_loop,
        patch.object(listener, "send_message", new_callable=AsyncMock) as mock_send,
        patch("fliiq.runtime.telegram.listener.resolve_model_alias", return_value="claude-opus-4-6"),
    ):
        session_dict = {"messages": [], "resources": MagicMock(), "model_override": None, "model_override_display": None}
        mock_session.return_value = session_dict

        await listener._handle_message(42, "/model opus-4.6")

        mock_loop.assert_not_called()
        mock_send.assert_called_once()
        sent_text = mock_send.call_args[0][1]
        assert "claude-opus-4-6" in sent_text
        assert session_dict["model_override"] == "claude-opus-4-6"
        assert session_dict["model_override_display"] == "opus-4.6"


async def test_handle_message_model_reset(listener):
    """/model reset clears override and sends confirmation."""
    with (
        patch.object(listener, "_get_or_create_session", new_callable=AsyncMock) as mock_session,
        patch("fliiq.runtime.telegram.listener.agent_loop", new_callable=AsyncMock) as mock_loop,
        patch.object(listener, "send_message", new_callable=AsyncMock) as mock_send,
    ):
        session_dict = {
            "messages": [], "resources": MagicMock(),
            "model_override": "claude-opus-4-6", "model_override_display": "opus-4.6",
        }
        mock_session.return_value = session_dict

        await listener._handle_message(42, "/model reset")

        mock_loop.assert_not_called()
        mock_send.assert_called_once()
        assert "reset to default" in mock_send.call_args[0][1].lower()
        assert session_dict["model_override"] is None
        assert session_dict["model_override_display"] is None


async def test_handle_message_model_override_creates_llm(tmp_path):
    """When override is active, create_llm is called with overridden config."""
    real_config = LLMConfig(provider=LLMProvider.ANTHROPIC, api_key="fake-key", model="claude-sonnet-4-6")
    listener = TelegramListener(
        bot_token="fake-token",
        llm_config=real_config,
        project_root=tmp_path,
    )

    with (
        patch.object(listener, "_get_or_create_session", new_callable=AsyncMock) as mock_session,
        patch("fliiq.runtime.telegram.listener.agent_loop", new_callable=AsyncMock) as mock_loop,
        patch.object(listener, "send_message", new_callable=AsyncMock),
        patch.object(listener, "_send_chat_action", new_callable=AsyncMock),
        patch("fliiq.runtime.telegram.listener.assemble_agent_prompt", return_value="system"),
        patch("fliiq.runtime.telegram.listener.create_llm") as mock_create_llm,
    ):
        mock_result = MagicMock()
        mock_result.messages = []
        mock_result.final_text = "Done"
        mock_result.iterations = 0
        mock_loop.return_value = mock_result

        mock_override_llm = MagicMock()
        mock_create_llm.return_value = mock_override_llm

        mock_resources = MagicMock()
        mock_resources.soul = ""
        mock_resources.user_soul = ""
        mock_resources.skill_info = None
        mock_resources.memory_context = ""
        mock_resources.user_profile = ""
        mock_resources.fliiq_email = ""

        session_dict = {
            "messages": [], "resources": mock_resources,
            "model_override": "claude-opus-4-6", "model_override_display": "opus-4.6",
        }
        mock_session.return_value = session_dict

        await listener._handle_message(42, "do something")

        mock_create_llm.assert_called_once()
        override_config = mock_create_llm.call_args[0][0]
        assert override_config.model == "claude-opus-4-6"
        assert override_config.provider == LLMProvider.ANTHROPIC
        assert override_config.api_key == "fake-key"
        assert mock_loop.call_args[1]["llm"] is mock_override_llm


async def test_handle_message_model_indicator_in_response(listener):
    """Response is prefixed with [model-display-name] when override is active."""
    with (
        patch.object(listener, "_get_or_create_session", new_callable=AsyncMock) as mock_session,
        patch("fliiq.runtime.telegram.listener.agent_loop", new_callable=AsyncMock) as mock_loop,
        patch.object(listener, "send_message", new_callable=AsyncMock) as mock_send,
        patch.object(listener, "_send_chat_action", new_callable=AsyncMock),
        patch("fliiq.runtime.telegram.listener.assemble_agent_prompt", return_value="system"),
        patch("fliiq.runtime.telegram.listener.create_llm", return_value=MagicMock()),
    ):
        mock_result = MagicMock()
        mock_result.messages = []
        mock_result.final_text = "Here is your answer"
        mock_result.iterations = 0
        mock_loop.return_value = mock_result

        mock_resources = MagicMock()
        mock_resources.soul = ""
        mock_resources.user_soul = ""
        mock_resources.skill_info = None
        mock_resources.memory_context = ""
        mock_resources.user_profile = ""
        mock_resources.fliiq_email = ""

        session_dict = {
            "messages": [], "resources": mock_resources,
            "model_override": "claude-opus-4-6", "model_override_display": "opus-4.6",
        }
        mock_session.return_value = session_dict

        await listener._handle_message(42, "tell me something")

        sent_text = mock_send.call_args[0][1]
        assert sent_text.startswith("[opus-4.6] ")
        assert "Here is your answer" in sent_text


async def test_handle_message_no_indicator_without_override(listener):
    """Response has no model prefix when using default model."""
    with (
        patch.object(listener, "_get_or_create_session", new_callable=AsyncMock) as mock_session,
        patch("fliiq.runtime.telegram.listener.agent_loop", new_callable=AsyncMock) as mock_loop,
        patch.object(listener, "send_message", new_callable=AsyncMock) as mock_send,
        patch.object(listener, "_send_chat_action", new_callable=AsyncMock),
        patch("fliiq.runtime.telegram.listener.assemble_agent_prompt", return_value="system"),
    ):
        mock_result = MagicMock()
        mock_result.messages = []
        mock_result.final_text = "Default response"
        mock_result.iterations = 0
        mock_loop.return_value = mock_result

        mock_resources = MagicMock()
        mock_resources.soul = ""
        mock_resources.user_soul = ""
        mock_resources.skill_info = None
        mock_resources.memory_context = ""
        mock_resources.user_profile = ""
        mock_resources.fliiq_email = ""

        session_dict = {
            "messages": [], "resources": mock_resources,
            "model_override": None, "model_override_display": None,
        }
        mock_session.return_value = session_dict

        await listener._handle_message(42, "normal message")

        sent_text = mock_send.call_args[0][1]
        assert sent_text == "Default response"
        assert not sent_text.startswith("[")


async def test_handle_message_model_sticky(listener):
    """Model override persists across messages in the same session."""
    with (
        patch.object(listener, "_get_or_create_session", new_callable=AsyncMock) as mock_session,
        patch("fliiq.runtime.telegram.listener.agent_loop", new_callable=AsyncMock) as mock_loop,
        patch.object(listener, "send_message", new_callable=AsyncMock) as mock_send,
        patch.object(listener, "_send_chat_action", new_callable=AsyncMock),
        patch("fliiq.runtime.telegram.listener.assemble_agent_prompt", return_value="system"),
        patch("fliiq.runtime.telegram.listener.create_llm", return_value=MagicMock()) as mock_create_llm,
        patch("fliiq.runtime.telegram.listener.resolve_model_alias", return_value="claude-opus-4-6"),
    ):
        mock_result = MagicMock()
        mock_result.messages = []
        mock_result.final_text = "Response"
        mock_result.iterations = 0
        mock_loop.return_value = mock_result

        mock_resources = MagicMock()
        mock_resources.soul = ""
        mock_resources.user_soul = ""
        mock_resources.skill_info = None
        mock_resources.memory_context = ""
        mock_resources.user_profile = ""
        mock_resources.fliiq_email = ""

        # Shared session dict — simulates sticky state
        session_dict = {
            "messages": [], "resources": mock_resources,
            "model_override": None, "model_override_display": None,
        }
        mock_session.return_value = session_dict

        # Message 1: set model with prompt
        await listener._handle_message(42, "/model opus-4.6 first task")
        assert session_dict["model_override"] == "claude-opus-4-6"

        # Message 2: no model directive — should still use override
        mock_create_llm.reset_mock()
        await listener._handle_message(42, "follow up question")
        mock_create_llm.assert_called_once()  # Still creates override LLM
        sent_text = mock_send.call_args[0][1]
        assert sent_text.startswith("[opus-4.6]")


# ── Double-send prevention tests ────────────────────────────────────


def test_agent_sent_telegram_detected():
    """Detects send_telegram tool call matching the chat_id."""
    messages = [
        {"role": "assistant", "content": [
            {"type": "tool_use", "name": "send_telegram", "id": "t1",
             "input": {"chat_id": 42, "text": "Hello"}},
        ]},
    ]
    assert _agent_sent_telegram(messages, 42) is True


def test_agent_sent_telegram_different_chat():
    """send_telegram to a different chat_id is not a match."""
    messages = [
        {"role": "assistant", "content": [
            {"type": "tool_use", "name": "send_telegram", "id": "t1",
             "input": {"chat_id": 999, "text": "Hello"}},
        ]},
    ]
    assert _agent_sent_telegram(messages, 42) is False


def test_agent_sent_telegram_no_tools():
    """No tool calls → returns False."""
    messages = [
        {"role": "assistant", "content": "Just a text response"},
    ]
    assert _agent_sent_telegram(messages, 42) is False


def test_agent_sent_telegram_empty():
    assert _agent_sent_telegram([], 42) is False


async def test_handle_message_skips_reply_when_agent_sent(listener):
    """Listener does NOT auto-reply when agent already called send_telegram."""
    with (
        patch.object(listener, "_get_or_create_session", new_callable=AsyncMock) as mock_session,
        patch("fliiq.runtime.telegram.listener.agent_loop", new_callable=AsyncMock) as mock_loop,
        patch.object(listener, "send_message", new_callable=AsyncMock) as mock_send,
        patch.object(listener, "_send_chat_action", new_callable=AsyncMock),
        patch("fliiq.runtime.telegram.listener.assemble_agent_prompt", return_value="system"),
    ):
        # Agent result includes a send_telegram tool call to chat_id 42
        mock_result = MagicMock()
        mock_result.messages = [
            {"role": "assistant", "content": [
                {"type": "tool_use", "name": "send_telegram", "id": "t1",
                 "input": {"chat_id": 42, "text": "I already replied"}},
            ]},
        ]
        mock_result.final_text = "Sent the response via telegram"
        mock_result.iterations = 1
        mock_loop.return_value = mock_result

        mock_resources = MagicMock()
        mock_resources.soul = ""
        mock_resources.user_soul = ""
        mock_resources.skill_info = None
        mock_resources.memory_context = ""
        mock_resources.user_profile = ""
        mock_resources.fliiq_email = ""

        session_dict = {
            "messages": [], "resources": mock_resources,
            "model_override": None, "model_override_display": None,
        }
        mock_session.return_value = session_dict

        await listener._handle_message(42, "what model are you?")

        # send_message should NOT be called — agent already sent via tool
        mock_send.assert_not_called()
