"""Tests for ConversationHandler — multi-step conversation state machine.

Tests cover: entry points, state transitions, END termination,
fallbacks, per_user/per_chat key generation, allow_reentry,
conversation_timeout cleanup, and simultaneous conversations.
"""

import time

import pytest

from dexfather.conversation import END, TIMEOUT, ConversationHandler
from dexfather.handlers import CommandHandler, MessageHandler
from dexfather.testing import MockBot, TestClient
from dexfather.types import Chat, Message, Update, User


# ── State constants ──────────────────────────────────────────────
NAME = 0
AGE = 1
CONFIRM = 2


# ── Helper: build a simple 2-state conversation handler ──────────


def _make_update(text, chat_id=1, user_id=1):
    """Create an Update with a text message."""
    return Update(
        update_id=1,
        message=Message(
            message_id=1,
            chat=Chat(id=chat_id, type="private"),
            from_user=User(id=user_id),
            text=text,
            date=0,
        ),
    )


# ── Entry point triggers conversation start ──────────────────────


@pytest.mark.asyncio
async def test_entry_point_triggers_conversation():
    """An entry point handler match starts the conversation and sets state."""
    bot = MockBot()
    started = []

    async def ask_name(update, b):
        started.append(True)
        return NAME  # Transition to NAME state

    async def process_name(update, b):
        return END

    conv = ConversationHandler(
        entry_points=[CommandHandler("start", ask_name)],
        states={NAME: [MessageHandler(process_name)]},
        fallbacks=[],
    )
    bot.add_handler(conv)

    # Dispatch /start to trigger entry
    update = _make_update("/start")
    await bot._dispatch(update)

    assert started == [True]
    key = (1, 1)  # (chat_id, user_id) default per_chat=True, per_user=True
    assert conv._conversations[key] == NAME


# ── State transitions work correctly ─────────────────────────────


@pytest.mark.asyncio
async def test_state_transitions():
    """Handler returning a new state transitions the conversation."""
    bot = MockBot()
    steps = []

    async def ask_name(update, b):
        steps.append("entry")
        return NAME

    async def process_name(update, b):
        steps.append("name")
        return AGE

    async def process_age(update, b):
        steps.append("age")
        return END

    conv = ConversationHandler(
        entry_points=[CommandHandler("start", ask_name)],
        states={
            NAME: [MessageHandler(process_name)],
            AGE: [MessageHandler(process_age)],
        },
        fallbacks=[],
    )
    bot.add_handler(conv)

    # Step 1: /start -> NAME
    await bot._dispatch(_make_update("/start"))
    assert steps == ["entry"]
    assert conv._conversations.get((1, 1)) == NAME

    # Step 2: text -> AGE
    await bot._dispatch(_make_update("Alice"))
    assert steps == ["entry", "name"]
    assert conv._conversations.get((1, 1)) == AGE

    # Step 3: text -> END (conversation removed)
    await bot._dispatch(_make_update("25"))
    assert steps == ["entry", "name", "age"]
    assert (1, 1) not in conv._conversations


# ── END constant terminates conversation ─────────────────────────


@pytest.mark.asyncio
async def test_end_terminates_conversation():
    """Returning END from a handler removes the conversation from state."""
    bot = MockBot()

    async def start(update, b):
        return NAME

    async def done(update, b):
        return END

    conv = ConversationHandler(
        entry_points=[CommandHandler("start", start)],
        states={NAME: [MessageHandler(done)]},
        fallbacks=[],
    )
    bot.add_handler(conv)

    await bot._dispatch(_make_update("/start"))
    assert (1, 1) in conv._conversations

    await bot._dispatch(_make_update("anything"))
    assert (1, 1) not in conv._conversations


# ── Fallback handlers work ───────────────────────────────────────


@pytest.mark.asyncio
async def test_fallback_handlers():
    """Fallback handler fires when no state handler matches."""
    bot = MockBot()
    fallback_called = []

    async def start(update, b):
        return NAME

    async def process_name(update, b):
        # Only matches regex "^[A-Z]" — won't match "/cancel"
        return AGE

    async def cancel(update, b):
        fallback_called.append(True)
        return END

    conv = ConversationHandler(
        entry_points=[CommandHandler("start", start)],
        states={
            NAME: [MessageHandler(process_name, pattern=r"^[A-Z]")],
        },
        fallbacks=[CommandHandler("cancel", cancel)],
    )
    bot.add_handler(conv)

    # Start conversation
    await bot._dispatch(_make_update("/start"))
    assert (1, 1) in conv._conversations

    # /cancel triggers fallback, not NAME handler
    await bot._dispatch(_make_update("/cancel"))
    assert fallback_called == [True]
    assert (1, 1) not in conv._conversations


# ── per_user / per_chat key generation ───────────────────────────


@pytest.mark.asyncio
async def test_per_user_per_chat_key():
    """Default per_user=True per_chat=True generates (chat_id, user_id) key."""
    conv = ConversationHandler(
        entry_points=[],
        states={},
        fallbacks=[],
        per_user=True,
        per_chat=True,
    )
    update = _make_update("test", chat_id=10, user_id=20)
    key = conv._get_key(update)
    assert key == (10, 20)


@pytest.mark.asyncio
async def test_per_chat_only_key():
    """per_user=False, per_chat=True generates (chat_id,) key."""
    conv = ConversationHandler(
        entry_points=[],
        states={},
        fallbacks=[],
        per_user=False,
        per_chat=True,
    )
    update = _make_update("test", chat_id=10, user_id=20)
    key = conv._get_key(update)
    assert key == (10,)


@pytest.mark.asyncio
async def test_per_user_only_key():
    """per_user=True, per_chat=False generates (user_id,) key."""
    conv = ConversationHandler(
        entry_points=[],
        states={},
        fallbacks=[],
        per_user=True,
        per_chat=False,
    )
    update = _make_update("test", chat_id=10, user_id=20)
    key = conv._get_key(update)
    assert key == (20,)


# ── allow_reentry works ─────────────────────────────────────────


@pytest.mark.asyncio
async def test_allow_reentry():
    """With allow_reentry=True, entry points can restart an active conversation."""
    bot = MockBot()
    entry_calls = []

    async def start(update, b):
        entry_calls.append(1)
        return NAME

    async def process_name(update, b):
        return AGE

    conv = ConversationHandler(
        entry_points=[CommandHandler("start", start)],
        states={
            NAME: [MessageHandler(process_name)],
            AGE: [MessageHandler(process_name)],
        },
        fallbacks=[],
        allow_reentry=True,
    )
    bot.add_handler(conv)

    # Start conversation
    await bot._dispatch(_make_update("/start"))
    assert len(entry_calls) == 1
    assert conv._conversations[(1, 1)] == NAME

    # Move to AGE
    await bot._dispatch(_make_update("Alice"))
    assert conv._conversations[(1, 1)] == AGE

    # Re-enter with /start while in AGE state — restarts to NAME
    await bot._dispatch(_make_update("/start"))
    assert len(entry_calls) == 2
    assert conv._conversations[(1, 1)] == NAME


@pytest.mark.asyncio
async def test_allow_reentry_disabled():
    """Without allow_reentry, /start during active conversation does not re-trigger entry."""
    bot = MockBot()
    entry_calls = []

    async def start(update, b):
        entry_calls.append(1)
        return NAME

    async def process_name(update, b):
        return AGE

    conv = ConversationHandler(
        entry_points=[CommandHandler("start", start)],
        states={
            NAME: [MessageHandler(process_name)],
            AGE: [],
        },
        fallbacks=[],
        allow_reentry=False,
    )
    bot.add_handler(conv)

    await bot._dispatch(_make_update("/start"))
    assert len(entry_calls) == 1

    await bot._dispatch(_make_update("Alice"))
    # Now in AGE state

    # /start should NOT re-enter — AGE has no handlers for it
    await bot._dispatch(_make_update("/start"))
    assert len(entry_calls) == 1  # Still only called once


# ── conversation_timeout cleans up state ─────────────────────────


@pytest.mark.asyncio
async def test_conversation_timeout():
    """Expired conversations are cleaned up on next update."""
    bot = MockBot()

    async def start(update, b):
        return NAME

    async def process_name(update, b):
        return END

    conv = ConversationHandler(
        entry_points=[CommandHandler("start", start)],
        states={NAME: [MessageHandler(process_name)]},
        fallbacks=[],
        conversation_timeout=0.01,  # 10ms timeout
    )
    bot.add_handler(conv)

    # Start conversation
    await bot._dispatch(_make_update("/start"))
    assert (1, 1) in conv._conversations

    # Manually expire: set timeout timestamp to the past
    conv._timeouts[(1, 1)] = time.time() - 1.0

    # Next update should trigger TIMEOUT cleanup
    result = await conv.handle(_make_update("Alice"), bot)
    assert result == TIMEOUT
    assert (1, 1) not in conv._conversations


# ── Multiple simultaneous conversations tracked independently ────


@pytest.mark.asyncio
async def test_multiple_simultaneous_conversations():
    """Different users can have independent conversation states."""
    bot = MockBot()
    user_states = {}

    async def start(update, b):
        return NAME

    async def process_name(update, b):
        uid = update.effective_user.id
        user_states[uid] = "name_processed"
        return AGE

    async def process_age(update, b):
        uid = update.effective_user.id
        user_states[uid] = "age_processed"
        return END

    conv = ConversationHandler(
        entry_points=[CommandHandler("start", start)],
        states={
            NAME: [MessageHandler(process_name)],
            AGE: [MessageHandler(process_age)],
        },
        fallbacks=[],
    )
    bot.add_handler(conv)

    # User 1 starts
    await bot._dispatch(_make_update("/start", chat_id=1, user_id=1))
    assert conv._conversations[(1, 1)] == NAME

    # User 2 starts independently
    await bot._dispatch(_make_update("/start", chat_id=2, user_id=2))
    assert conv._conversations[(2, 2)] == NAME
    assert conv._conversations[(1, 1)] == NAME  # User 1 still in NAME

    # User 1 advances to AGE
    await bot._dispatch(_make_update("Alice", chat_id=1, user_id=1))
    assert conv._conversations[(1, 1)] == AGE
    assert conv._conversations[(2, 2)] == NAME  # User 2 still in NAME

    # User 2 advances to AGE
    await bot._dispatch(_make_update("Bob", chat_id=2, user_id=2))
    assert conv._conversations[(2, 2)] == AGE
    assert conv._conversations[(1, 1)] == AGE  # User 1 still in AGE

    # User 1 finishes
    await bot._dispatch(_make_update("30", chat_id=1, user_id=1))
    assert (1, 1) not in conv._conversations
    assert conv._conversations[(2, 2)] == AGE  # User 2 still active

    # User 2 finishes
    await bot._dispatch(_make_update("25", chat_id=2, user_id=2))
    assert (2, 2) not in conv._conversations

    assert user_states == {1: "age_processed", 2: "age_processed"}


# ── Persistent handler requires name ─────────────────────────────


def test_persistent_requires_name():
    """ConversationHandler with persistent=True but no name raises ValueError."""
    with pytest.raises(ValueError, match="requires a name"):
        ConversationHandler(
            entry_points=[],
            states={},
            fallbacks=[],
            persistent=True,
            name=None,
        )


# ── Handler returning None does not change state ─────────────────


@pytest.mark.asyncio
async def test_handler_returning_none_keeps_state():
    """If a state handler returns None, the conversation state is unchanged."""
    bot = MockBot()

    async def start(update, b):
        return NAME

    async def ask_again(update, b):
        # Return None to stay in the same state
        return None

    async def accept_name(update, b):
        return END

    conv = ConversationHandler(
        entry_points=[CommandHandler("start", start)],
        states={
            NAME: [
                MessageHandler(ask_again, pattern=r"^\d+$"),  # numbers -> ask again
                MessageHandler(accept_name),  # anything else -> accept
            ],
        },
        fallbacks=[],
    )
    bot.add_handler(conv)

    await bot._dispatch(_make_update("/start"))
    assert conv._conversations[(1, 1)] == NAME

    # Send a number — handler returns None, state stays NAME
    await bot._dispatch(_make_update("123"))
    assert conv._conversations[(1, 1)] == NAME

    # Send a name — handler returns END
    await bot._dispatch(_make_update("Alice"))
    assert (1, 1) not in conv._conversations


# ── check() matches both active conversations and entry points ───


@pytest.mark.asyncio
async def test_check_matches_entry_and_active():
    """ConversationHandler.check() returns True for entry matches and active convos."""

    async def start(update, b):
        return NAME

    conv = ConversationHandler(
        entry_points=[CommandHandler("start", start)],
        states={NAME: [MessageHandler(lambda u, b: END)]},
        fallbacks=[],
    )

    # Entry point match
    assert conv.check(_make_update("/start")) is True

    # Non-matching update
    assert conv.check(_make_update("random text")) is False

    # Active conversation
    conv._conversations[(1, 1)] = NAME
    assert conv.check(_make_update("random text")) is True
