from __future__ import annotations

import asyncio
import logging
from contextlib import asynccontextmanager
from dataclasses import dataclass, field
from typing import (
    Any,
    AsyncIterator,
    Awaitable,
    Callable,
    Iterable,
    Optional,
    Sequence,
    Union,
)

import httpx

from ...core.circuit_breaker import CircuitBreaker
from ...core.exceptions import CodexError, PermanentError, TransientError
from ...core.logging_utils import log_event
from ...core.retry import retry_transient
from ..chat.collaboration_policy import (
    CollaborationEvaluationContext,
    build_telegram_collaboration_policy,
    evaluate_collaboration_admission,
)
from ..chat.text_chunking import chunk_text
from .api_schemas import (
    TelegramAudioSchema,
    TelegramDocumentSchema,
    TelegramMessageEntitySchema,
    TelegramPhotoSizeSchema,
    TelegramVoiceSchema,
    parse_callback_query_payload,
    parse_message_payload,
    parse_update_payload,
)
from .command_parsing import parse_command_payload
from .constants import TELEGRAM_CALLBACK_DATA_LIMIT, TELEGRAM_MAX_MESSAGE_LENGTH
from .rendering import sanitize_telegram_outbound_text
from .retry import _extract_retry_after_seconds

_RATE_LIMIT_BUFFER_SECONDS = 0.0

INTERRUPT_ALIASES = {
    "^c",
    "ctrl-c",
    "ctrl+c",
    "esc",
    "escape",
    "/stop",
}


def _sanitize_outbound_text_payload(
    text: str, *, parse_mode: Optional[str] = None
) -> str:
    if parse_mode:
        return text
    return sanitize_telegram_outbound_text(text)


def _sanitize_outbound_caption_payload(
    caption: Optional[str], *, parse_mode: Optional[str] = None
) -> Optional[str]:
    if not isinstance(caption, str) or parse_mode:
        return caption
    return sanitize_telegram_outbound_text(caption)


class TelegramAPIError(CodexError):
    """Raised when the Telegram Bot API returns an error."""

    def __init__(
        self,
        message: str,
        *,
        retry_after: Optional[int] = None,
        user_message: Optional[str] = None,
    ) -> None:
        if user_message is None:
            user_message = "Telegram API error. Retrying with backoff..."
        super().__init__(message, user_message=user_message)
        self.retry_after = retry_after


class TelegramTransientError(TelegramAPIError, TransientError):
    """Retryable Telegram API error."""


class TelegramPermanentError(TelegramAPIError, PermanentError):
    """Non-retryable Telegram API error."""

    recoverable = PermanentError.recoverable
    severity = PermanentError.severity


@dataclass(frozen=True)
class TelegramPhotoSize:
    file_id: str
    file_unique_id: Optional[str]
    width: int
    height: int
    file_size: Optional[int]


@dataclass(frozen=True)
class TelegramDocument:
    file_id: str
    file_unique_id: Optional[str]
    file_name: Optional[str]
    mime_type: Optional[str]
    file_size: Optional[int]


@dataclass(frozen=True)
class TelegramAudio:
    file_id: str
    file_unique_id: Optional[str]
    duration: Optional[int]
    file_name: Optional[str]
    mime_type: Optional[str]
    file_size: Optional[int]


@dataclass(frozen=True)
class TelegramVoice:
    file_id: str
    file_unique_id: Optional[str]
    duration: Optional[int]
    mime_type: Optional[str]
    file_size: Optional[int]


@dataclass(frozen=True)
class TelegramMessageEntity:
    type: str
    offset: int
    length: int


@dataclass(frozen=True)
class TelegramForwardOrigin:
    source_label: Optional[str] = None
    message_id: Optional[int] = None
    is_automatic: bool = False


@dataclass(frozen=True)
class TelegramMessage:
    update_id: int
    message_id: int
    chat_id: int
    thread_id: Optional[int]
    from_user_id: Optional[int]
    text: Optional[str]
    date: Optional[int]
    is_topic_message: bool
    is_edited: bool = False
    caption: Optional[str] = None
    entities: tuple[TelegramMessageEntity, ...] = field(default_factory=tuple)
    caption_entities: tuple[TelegramMessageEntity, ...] = field(default_factory=tuple)
    photos: tuple[TelegramPhotoSize, ...] = field(default_factory=tuple)
    document: Optional[TelegramDocument] = None
    audio: Optional[TelegramAudio] = None
    voice: Optional[TelegramVoice] = None
    media_group_id: Optional[str] = None

    # Extra metadata used for trigger gating / UX (optional, depends on update payload).
    chat_type: Optional[str] = None
    forward_origin: Optional[TelegramForwardOrigin] = None
    reply_to_message_id: Optional[int] = None
    reply_to_is_bot: bool = False
    reply_to_username: Optional[str] = None
    reply_to_text: Optional[str] = None
    reply_to_author_label: Optional[str] = None
    chat_title: Optional[str] = None
    thread_title: Optional[str] = None


@dataclass(frozen=True)
class TelegramCallbackQuery:
    update_id: int
    callback_id: str
    from_user_id: Optional[int]
    data: Optional[str]
    message_id: Optional[int]
    chat_id: Optional[int]
    thread_id: Optional[int]


@dataclass(frozen=True)
class TelegramUpdate:
    update_id: int
    message: Optional[TelegramMessage]
    callback: Optional[TelegramCallbackQuery]


@dataclass(frozen=True)
class TelegramCommand:
    name: str
    args: str
    raw: str


@dataclass(frozen=True)
class TelegramAllowlist:
    allowed_chat_ids: set[int]
    allowed_user_ids: set[int]
    require_topic: bool = False


@dataclass(frozen=True)
class InlineButton:
    text: str
    callback_data: str


@dataclass(frozen=True)
class ApprovalCallback:
    decision: str
    request_id: str


@dataclass(frozen=True)
class QuestionOptionCallback:
    request_id: str
    question_index: int
    option_index: int


@dataclass(frozen=True)
class QuestionDoneCallback:
    request_id: str


@dataclass(frozen=True)
class QuestionCustomCallback:
    request_id: str


@dataclass(frozen=True)
class QuestionCancelCallback:
    request_id: str


@dataclass(frozen=True)
class ResumeCallback:
    thread_id: str


@dataclass(frozen=True)
class BindCallback:
    repo_id: str


@dataclass(frozen=True)
class AgentCallback:
    agent: str


@dataclass(frozen=True)
class AgentProfileCallback:
    profile: str


@dataclass(frozen=True)
class ModelCallback:
    model_id: str


@dataclass(frozen=True)
class EffortCallback:
    effort: str


@dataclass(frozen=True)
class UpdateCallback:
    target: str


@dataclass(frozen=True)
class UpdateConfirmCallback:
    decision: str


@dataclass(frozen=True)
class ReviewCommitCallback:
    sha: str


@dataclass(frozen=True)
class CancelCallback:
    kind: str


@dataclass(frozen=True)
class CompactCallback:
    action: str


@dataclass(frozen=True)
class PageCallback:
    kind: str
    page: int


@dataclass(frozen=True)
class FlowCallback:
    action: str
    run_id: Optional[str] = None
    repo_id: Optional[str] = None


@dataclass(frozen=True)
class FlowRunCallback:
    run_id: str


def parse_command(
    text: Optional[str],
    *,
    entities: Optional[Sequence[TelegramMessageEntity]] = None,
    bot_username: Optional[str] = None,
) -> Optional[TelegramCommand]:
    parsed = parse_command_payload(
        text,
        entities=entities,
        bot_username=bot_username,
    )
    if parsed is None:
        return None
    name, args, raw = parsed
    return TelegramCommand(name=name, args=args, raw=raw)


def is_interrupt_alias(text: Optional[str]) -> bool:
    if not text:
        return False
    normalized = text.strip().lower()
    if normalized in INTERRUPT_ALIASES:
        return True
    return normalized == "/interrupt"


def parse_update(update: dict[str, Any]) -> Optional[TelegramUpdate]:
    try:
        schema = parse_update_payload(update)
    except Exception:
        return None
    message = _parse_message(schema.update_id, schema.message, edited=False)
    if message is None:
        message = _parse_message(schema.update_id, schema.edited_message, edited=True)
    callback = _parse_callback(schema.update_id, schema.callback_query)
    if message is None and callback is None:
        return None
    return TelegramUpdate(
        update_id=schema.update_id, message=message, callback=callback
    )


def _parse_message(
    update_id: int, payload: Any, *, edited: bool = False
) -> Optional[TelegramMessage]:
    schema = parse_message_payload(payload)
    if schema is None:
        return None

    chat_id = schema.chat.get("id") if isinstance(schema.chat, dict) else None
    if not isinstance(chat_id, int):
        return None

    chat_type = schema.chat.get("type") if isinstance(schema.chat, dict) else None
    if chat_type is not None and not isinstance(chat_type, str):
        chat_type = None
    chat_title = _extract_chat_title(schema.chat)
    thread_title = _extract_thread_title(payload, schema.reply_to_message)

    reply_to_message_id: Optional[int] = None
    reply_to_is_bot = False
    reply_to_username: Optional[str] = None
    reply_to_text: Optional[str] = None
    reply_to_author_label: Optional[str] = None
    if isinstance(schema.reply_to_message, dict):
        rmid = schema.reply_to_message.get("message_id")
        if isinstance(rmid, int):
            reply_to_message_id = rmid
        reply_text = schema.reply_to_message.get("text")
        if isinstance(reply_text, str) and reply_text.strip():
            reply_to_text = reply_text.strip()
        if reply_to_text is None:
            reply_caption = schema.reply_to_message.get("caption")
            if isinstance(reply_caption, str) and reply_caption.strip():
                reply_to_text = reply_caption.strip()
        reply_from = schema.reply_to_message.get("from")
        if isinstance(reply_from, dict):
            is_bot = reply_from.get("is_bot")
            if isinstance(is_bot, bool):
                reply_to_is_bot = is_bot
            username = reply_from.get("username")
            if isinstance(username, str):
                reply_to_username = username
            for key in ("username", "first_name", "last_name"):
                value = reply_from.get(key)
                if isinstance(value, str) and value.strip():
                    reply_to_author_label = value.strip()
                    break

    from_user_id = (
        schema.from_user.get("id") if isinstance(schema.from_user, dict) else None
    )
    if from_user_id is not None and not isinstance(from_user_id, int):
        from_user_id = None

    entities = _parse_entities(schema.entities)
    caption_entities = _parse_entities(schema.caption_entities)
    photos = _parse_photo_sizes(schema.photo)
    document = _parse_document(schema.document)
    audio = _parse_audio(schema.audio)
    voice = _parse_voice(schema.voice)
    forward_origin = _parse_forward_origin(
        schema.forward_origin,
        is_automatic=schema.is_automatic_forward,
    )

    return TelegramMessage(
        update_id=update_id,
        message_id=schema.message_id,
        chat_id=chat_id,
        thread_id=schema.message_thread_id,
        from_user_id=from_user_id,
        text=schema.text,
        date=schema.date,
        is_topic_message=schema.is_topic_message,
        is_edited=edited,
        caption=schema.caption,
        entities=entities,
        caption_entities=caption_entities,
        photos=photos,
        document=document,
        audio=audio,
        voice=voice,
        media_group_id=schema.media_group_id,
        chat_type=chat_type,
        forward_origin=forward_origin,
        reply_to_message_id=reply_to_message_id,
        reply_to_is_bot=reply_to_is_bot,
        reply_to_username=reply_to_username,
        reply_to_text=reply_to_text,
        reply_to_author_label=reply_to_author_label,
        chat_title=chat_title,
        thread_title=thread_title,
    )


def _parse_callback(update_id: int, payload: Any) -> Optional[TelegramCallbackQuery]:
    schema = parse_callback_query_payload(payload)
    if schema is None:
        return None

    from_user_id = (
        schema.from_user.get("id") if isinstance(schema.from_user, dict) else None
    )
    if from_user_id is not None and not isinstance(from_user_id, int):
        from_user_id = None

    message_id = None
    chat_id = None
    thread_id = None
    if isinstance(schema.message, dict):
        message_id = schema.message.get("message_id")
        chat = schema.message.get("chat")
        if isinstance(chat, dict):
            chat_id = chat.get("id")
        thread_id = schema.message.get("message_thread_id")

    if message_id is not None and not isinstance(message_id, int):
        message_id = None
    if chat_id is not None and not isinstance(chat_id, int):
        chat_id = None
    if thread_id is not None and not isinstance(thread_id, int):
        thread_id = None

    return TelegramCallbackQuery(
        update_id=update_id,
        callback_id=schema.id,
        from_user_id=from_user_id,
        data=schema.data,
        message_id=message_id,
        chat_id=chat_id,
        thread_id=thread_id,
    )


def _extract_chat_title(chat_payload: Any) -> Optional[str]:
    if not isinstance(chat_payload, dict):
        return None
    for key in ("title", "username"):
        raw = chat_payload.get(key)
        if isinstance(raw, str) and raw.strip():
            return raw.strip()
    first_name = chat_payload.get("first_name")
    last_name = chat_payload.get("last_name")
    parts: list[str] = []
    if isinstance(first_name, str) and first_name.strip():
        parts.append(first_name.strip())
    if isinstance(last_name, str) and last_name.strip():
        parts.append(last_name.strip())
    if parts:
        return " ".join(parts)
    return None


def _parse_forward_origin(
    payload: Any, *, is_automatic: bool
) -> Optional[TelegramForwardOrigin]:
    if not isinstance(payload, dict):
        if not is_automatic:
            return None
        return TelegramForwardOrigin(is_automatic=True)

    message_id = payload.get("message_id")
    if not isinstance(message_id, int):
        message_id = None
    return TelegramForwardOrigin(
        source_label=_extract_forward_source_label(payload),
        message_id=message_id,
        is_automatic=is_automatic,
    )


def _extract_forward_source_label(payload: Any) -> Optional[str]:
    if not isinstance(payload, dict):
        return None
    origin_type = payload.get("type")
    if origin_type == "user":
        return _extract_chat_title(payload.get("sender_user"))
    if origin_type == "hidden_user":
        sender_name = payload.get("sender_user_name")
        if isinstance(sender_name, str) and sender_name.strip():
            return sender_name.strip()
        return None
    if origin_type == "chat":
        return _extract_chat_title(payload.get("sender_chat"))
    if origin_type == "channel":
        return _extract_chat_title(payload.get("chat"))
    return None


def _extract_thread_title(message_payload: Any, reply_to_payload: Any) -> Optional[str]:
    # Prefer explicit rename events first, then topic creation from the current
    # message only. Reply metadata often carries the original topic creation
    # payload forever, which becomes stale after a rename.
    for candidate in (message_payload, reply_to_payload):
        title = _extract_forum_topic_name(candidate, keys=("forum_topic_edited",))
        if title is not None:
            return title
    return _extract_forum_topic_name(message_payload, keys=("forum_topic_created",))


def _extract_forum_topic_name(payload: Any, *, keys: tuple[str, ...]) -> Optional[str]:
    if not isinstance(payload, dict):
        return None
    for key in keys:
        raw = payload.get(key)
        if not isinstance(raw, dict):
            continue
        name = raw.get("name")
        if isinstance(name, str) and name.strip():
            return name.strip()
    return None


def _parse_photo_sizes(payload: Any) -> tuple[TelegramPhotoSize, ...]:
    if not isinstance(payload, list):
        return ()
    sizes: list[TelegramPhotoSize] = []
    for item in payload:
        if not isinstance(item, dict):
            continue
        try:
            schema = TelegramPhotoSizeSchema.model_validate(item)
        except Exception:
            continue
        sizes.append(
            TelegramPhotoSize(
                file_id=schema.file_id,
                file_unique_id=schema.file_unique_id,
                width=schema.width,
                height=schema.height,
                file_size=schema.file_size,
            )
        )
    return tuple(sizes)


def _parse_document(payload: Any) -> Optional[TelegramDocument]:
    if not isinstance(payload, dict):
        return None
    try:
        schema = TelegramDocumentSchema.model_validate(payload)
    except Exception:
        return None
    return TelegramDocument(
        file_id=schema.file_id,
        file_unique_id=schema.file_unique_id,
        file_name=schema.file_name,
        mime_type=schema.mime_type,
        file_size=schema.file_size,
    )


def _parse_audio(payload: Any) -> Optional[TelegramAudio]:
    if not isinstance(payload, dict):
        return None
    try:
        schema = TelegramAudioSchema.model_validate(payload)
    except Exception:
        return None
    return TelegramAudio(
        file_id=schema.file_id,
        file_unique_id=schema.file_unique_id,
        duration=schema.duration,
        file_name=schema.file_name,
        mime_type=schema.mime_type,
        file_size=schema.file_size,
    )


def _parse_voice(payload: Any) -> Optional[TelegramVoice]:
    if not isinstance(payload, dict):
        return None
    try:
        schema = TelegramVoiceSchema.model_validate(payload)
    except Exception:
        return None
    return TelegramVoice(
        file_id=schema.file_id,
        file_unique_id=schema.file_unique_id,
        duration=schema.duration,
        mime_type=schema.mime_type,
        file_size=schema.file_size,
    )


def _parse_entities(payload: Any) -> tuple[TelegramMessageEntity, ...]:
    if not isinstance(payload, list):
        return ()
    entities: list[TelegramMessageEntity] = []
    for item in payload:
        if not isinstance(item, dict):
            continue
        try:
            schema = TelegramMessageEntitySchema.model_validate(item)
        except Exception:
            continue
        entities.append(
            TelegramMessageEntity(
                type=schema.type, offset=schema.offset, length=schema.length
            )
        )
    return tuple(entities)


def allowlist_allows(update: TelegramUpdate, allowlist: TelegramAllowlist) -> bool:
    chat_id = None
    user_id = None
    thread_id = None
    if update.message:
        chat_id = update.message.chat_id
        user_id = update.message.from_user_id
        thread_id = update.message.thread_id
    elif update.callback:
        chat_id = update.callback.chat_id
        user_id = update.callback.from_user_id
        thread_id = update.callback.thread_id
    policy = build_telegram_collaboration_policy(
        allowed_chat_ids=allowlist.allowed_chat_ids,
        allowed_user_ids=allowlist.allowed_user_ids,
        require_topics=allowlist.require_topic,
        trigger_mode="all",
    )
    result = evaluate_collaboration_admission(
        policy,
        CollaborationEvaluationContext(
            actor_id=str(user_id) if user_id is not None else None,
            container_id=str(chat_id) if chat_id is not None else None,
            destination_id=str(chat_id) if chat_id is not None else None,
            subdestination_id=str(thread_id) if thread_id is not None else None,
        ),
    )
    return result.command_allowed


def chunk_message(
    text: Optional[str],
    *,
    max_len: int = TELEGRAM_MAX_MESSAGE_LENGTH,
    with_numbering: bool = True,
) -> list[str]:
    if text is None:
        return []
    return chunk_text(text, max_len=max_len, with_numbering=with_numbering)


def build_inline_keyboard(
    rows: Sequence[Sequence[InlineButton]],
) -> dict[str, Any]:
    keyboard: list[list[dict[str, str]]] = []
    for row in rows:
        keyboard.append(
            [
                {"text": button.text, "callback_data": button.callback_data}
                for button in row
            ]
        )
    return {"inline_keyboard": keyboard}


def encode_approval_callback(decision: str, request_id: str) -> str:
    data = f"appr:{decision}:{request_id}"
    _validate_callback_data(data)
    return data


def encode_question_option_callback(
    request_id: str, question_index: int, option_index: int
) -> str:
    data = f"qopt:{question_index}:{option_index}:{request_id}"
    _validate_callback_data(data)
    return data


def encode_question_done_callback(request_id: str) -> str:
    data = f"qdone:{request_id}"
    _validate_callback_data(data)
    return data


def encode_question_custom_callback(request_id: str) -> str:
    data = f"qcustom:{request_id}"
    _validate_callback_data(data)
    return data


def encode_question_cancel_callback(request_id: str) -> str:
    data = f"qcancel:{request_id}"
    _validate_callback_data(data)
    return data


def encode_resume_callback(thread_id: str) -> str:
    data = f"resume:{thread_id}"
    _validate_callback_data(data)
    return data


def encode_bind_callback(repo_id: str) -> str:
    data = f"bind:{repo_id}"
    _validate_callback_data(data)
    return data


def encode_agent_callback(agent: str) -> str:
    data = f"agent:{agent}"
    _validate_callback_data(data)
    return data


def encode_agent_profile_callback(profile: str) -> str:
    data = f"agent_profile:{profile}"
    _validate_callback_data(data)
    return data


def encode_model_callback(model_id: str) -> str:
    data = f"model:{model_id}"
    _validate_callback_data(data)
    return data


def encode_effort_callback(effort: str) -> str:
    data = f"effort:{effort}"
    _validate_callback_data(data)
    return data


def encode_update_callback(target: str) -> str:
    data = f"update:{target}"
    _validate_callback_data(data)
    return data


def encode_update_confirm_callback(decision: str) -> str:
    data = f"update_confirm:{decision}"
    _validate_callback_data(data)
    return data


def encode_review_commit_callback(sha: str) -> str:
    data = f"review_commit:{sha}"
    _validate_callback_data(data)
    return data


def encode_cancel_callback(kind: str) -> str:
    data = f"cancel:{kind}"
    _validate_callback_data(data)
    return data


def encode_page_callback(kind: str, page: int) -> str:
    data = f"page:{kind}:{page}"
    _validate_callback_data(data)
    return data


def encode_flow_callback(
    action: str, run_id: Optional[str] = None, *, repo_id: Optional[str] = None
) -> str:
    action = str(action or "").strip()
    if not action:
        raise ValueError("flow action required")
    repo_id = str(repo_id or "").strip() or None
    if repo_id and not run_id:
        raise ValueError("flow repo callback requires run_id")
    if run_id:
        data = f"flow:{action}:{run_id}"
        if repo_id:
            data = f"{data}:{repo_id}"
    else:
        data = f"flow:{action}"
    _validate_callback_data(data)
    return data


def encode_flow_run_callback(run_id: str) -> str:
    run_id = str(run_id or "").strip()
    if not run_id:
        raise ValueError("flow run id required")
    data = f"flow_run:{run_id}"
    _validate_callback_data(data)
    return data


def parse_callback_data(
    data: Optional[str],
) -> Optional[
    Union[
        ApprovalCallback,
        QuestionOptionCallback,
        QuestionDoneCallback,
        QuestionCustomCallback,
        QuestionCancelCallback,
        ResumeCallback,
        BindCallback,
        AgentCallback,
        AgentProfileCallback,
        ModelCallback,
        EffortCallback,
        UpdateCallback,
        UpdateConfirmCallback,
        ReviewCommitCallback,
        CancelCallback,
        CompactCallback,
        FlowCallback,
        FlowRunCallback,
        PageCallback,
    ]
]:
    # Keep adapter API stable while delegating parsing through the chat codec.
    from .chat_callbacks import parse_callback_data as parse_callback_data_v2

    return parse_callback_data_v2(data)


def build_approval_keyboard(
    request_id: str, *, include_session: bool = False
) -> dict[str, Any]:
    rows: list[list[InlineButton]] = [
        [
            InlineButton("Accept", encode_approval_callback("accept", request_id)),
            InlineButton("Decline", encode_approval_callback("decline", request_id)),
        ],
        [InlineButton("Cancel", encode_approval_callback("cancel", request_id))],
    ]
    if include_session:
        rows[0].insert(
            1,
            InlineButton(
                "Accept session", encode_approval_callback("accept_session", request_id)
            ),
        )
    return build_inline_keyboard(rows)


def build_question_keyboard(
    request_id: str,
    *,
    question_index: int,
    options: Sequence[str],
    multiple: bool = False,
    custom: bool = True,
    selected_indices: set[int] | None = None,
    include_cancel: bool = True,
) -> dict[str, Any]:
    selected = selected_indices or set()
    rows = []
    for index, label in enumerate(options):
        if multiple and index in selected:
            display_label = f"✓ {label}"
        else:
            display_label = label
        rows.append(
            [
                InlineButton(
                    display_label,
                    encode_question_option_callback(request_id, question_index, index),
                )
            ]
        )
    if custom:
        rows.append(
            [
                InlineButton(
                    "Type your own answer", encode_question_custom_callback(request_id)
                )
            ]
        )
    if multiple:
        rows.append([InlineButton("Done", encode_question_done_callback(request_id))])
    if include_cancel:
        rows.append(
            [InlineButton("Cancel", encode_question_cancel_callback(request_id))]
        )
    return build_inline_keyboard(rows)


def build_resume_keyboard(
    options: Sequence[tuple[str, str]],
    *,
    page_button: Optional[tuple[str, str]] = None,
    include_cancel: bool = False,
) -> dict[str, Any]:
    rows = [
        [InlineButton(label, encode_resume_callback(thread_id))]
        for thread_id, label in options
    ]
    if page_button:
        label, callback_data = page_button
        rows.append([InlineButton(label, callback_data)])
    if include_cancel:
        rows.append([InlineButton("Cancel", encode_cancel_callback("resume"))])
    return build_inline_keyboard(rows)


def build_agent_keyboard(
    options: Sequence[tuple[str, str]],
    *,
    page_button: Optional[tuple[str, str]] = None,
    include_cancel: bool = False,
) -> dict[str, Any]:
    rows = [
        [InlineButton(label, encode_agent_callback(agent))] for agent, label in options
    ]
    if page_button:
        label, callback_data = page_button
        rows.append([InlineButton(label, callback_data)])
    if include_cancel:
        rows.append([InlineButton("Cancel", encode_cancel_callback("agent"))])
    return build_inline_keyboard(rows)


def build_agent_profile_keyboard(
    options: Sequence[tuple[str, str]],
    *,
    page_button: Optional[tuple[str, str]] = None,
    include_cancel: bool = False,
) -> dict[str, Any]:
    rows = [
        [InlineButton(label, encode_agent_profile_callback(profile))]
        for profile, label in options
    ]
    if page_button:
        label, callback_data = page_button
        rows.append([InlineButton(label, callback_data)])
    if include_cancel:
        rows.append([InlineButton("Cancel", encode_cancel_callback("agent-profile"))])
    return build_inline_keyboard(rows)


def build_model_keyboard(
    options: Sequence[tuple[str, str]],
    *,
    page_button: Optional[tuple[str, str]] = None,
    include_cancel: bool = False,
) -> dict[str, Any]:
    rows = [
        [InlineButton(label, encode_model_callback(model_id))]
        for model_id, label in options
    ]
    if page_button:
        label, callback_data = page_button
        rows.append([InlineButton(label, callback_data)])
    if include_cancel:
        rows.append([InlineButton("Cancel", encode_cancel_callback("model"))])
    return build_inline_keyboard(rows)


def build_effort_keyboard(
    options: Sequence[tuple[str, str]],
    *,
    include_cancel: bool = False,
) -> dict[str, Any]:
    rows = [
        [InlineButton(label, encode_effort_callback(effort))]
        for effort, label in options
    ]
    if include_cancel:
        rows.append([InlineButton("Cancel", encode_cancel_callback("model"))])
    return build_inline_keyboard(rows)


def build_update_keyboard(
    options: Sequence[tuple[str, str]],
    *,
    include_cancel: bool = False,
) -> dict[str, Any]:
    rows = [
        [InlineButton(label, encode_update_callback(target))]
        for target, label in options
    ]
    if include_cancel:
        rows.append([InlineButton("Cancel", encode_cancel_callback("update"))])
    return build_inline_keyboard(rows)


def build_update_confirm_keyboard() -> dict[str, Any]:
    rows = [
        [
            InlineButton("Yes, continue", encode_update_confirm_callback("yes")),
            InlineButton("Cancel", encode_cancel_callback("update-confirm")),
        ]
    ]
    return build_inline_keyboard(rows)


def build_review_commit_keyboard(
    options: Sequence[tuple[str, str]],
    *,
    page_button: Optional[tuple[str, str]] = None,
    include_cancel: bool = False,
) -> dict[str, Any]:
    rows = [
        [InlineButton(label, encode_review_commit_callback(sha))]
        for sha, label in options
    ]
    if page_button:
        label, callback_data = page_button
        rows.append([InlineButton(label, callback_data)])
    if include_cancel:
        rows.append([InlineButton("Cancel", encode_cancel_callback("review-commit"))])
    return build_inline_keyboard(rows)


def build_bind_keyboard(
    options: Sequence[tuple[str, str]],
    *,
    page_button: Optional[tuple[str, str]] = None,
    include_cancel: bool = False,
) -> dict[str, Any]:
    rows = [
        [InlineButton(label, encode_bind_callback(repo_id))]
        for repo_id, label in options
    ]
    if page_button:
        label, callback_data = page_button
        rows.append([InlineButton(label, callback_data)])
    if include_cancel:
        rows.append([InlineButton("Cancel", encode_cancel_callback("bind"))])
    return build_inline_keyboard(rows)


def build_flow_runs_keyboard(
    options: Sequence[tuple[str, str]],
    *,
    page_button: Optional[tuple[str, str]] = None,
    include_cancel: bool = False,
) -> dict[str, Any]:
    rows = [
        [InlineButton(label, encode_flow_run_callback(run_id))]
        for run_id, label in options
    ]
    if page_button:
        label, callback_data = page_button
        rows.append([InlineButton(label, callback_data)])
    if include_cancel:
        rows.append([InlineButton("Cancel", encode_cancel_callback("flow-runs"))])
    return build_inline_keyboard(rows)


def _validate_callback_data(data: str) -> None:
    if len(data.encode("utf-8")) > TELEGRAM_CALLBACK_DATA_LIMIT:
        raise ValueError("callback_data exceeds Telegram limit")


def next_update_offset(
    updates: Iterable[dict[str, Any]], current: Optional[int]
) -> Optional[int]:
    max_update_id = None
    for update in updates:
        update_id = update.get("update_id")
        if isinstance(update_id, int):
            if max_update_id is None or update_id > max_update_id:
                max_update_id = update_id
    if max_update_id is None:
        return current
    return max_update_id + 1


class TelegramBotClient:
    def __init__(
        self,
        bot_token: str,
        *,
        timeout_seconds: float = 30.0,
        logger: Optional[logging.Logger] = None,
        client: Optional[httpx.AsyncClient] = None,
    ) -> None:
        self._bot_token = bot_token
        self._base_url = "https://api.telegram.org"
        self._file_base_url = f"https://api.telegram.org/file/bot{bot_token}"
        self._logger = logger or logging.getLogger(__name__)
        if client is None:
            self._client = httpx.AsyncClient(timeout=timeout_seconds)
            self._owns_client = True
        else:
            self._client = client
            self._owns_client = False
        self._rate_limit_until: dict[str, float] = {}
        self._rate_limit_lock: Optional[asyncio.Lock] = None
        self._rate_limit_lock_loop: Optional[asyncio.AbstractEventLoop] = None
        self._circuit_breakers: dict[str, CircuitBreaker] = {}

    async def close(self) -> None:
        if self._owns_client:
            await self._client.aclose()

    async def __aenter__(self) -> "TelegramBotClient":
        return self

    async def __aexit__(self, *_exc_info) -> None:
        await self.close()

    async def get_updates(
        self,
        *,
        offset: Optional[int] = None,
        timeout: int = 30,
        allowed_updates: Optional[Sequence[str]] = None,
    ) -> list[dict[str, Any]]:
        log_event(
            self._logger,
            logging.DEBUG,
            "telegram.request",
            method="getUpdates",
            offset=offset,
            timeout=timeout,
            allowed_updates=list(allowed_updates) if allowed_updates else None,
        )
        payload: dict[str, Any] = {"timeout": timeout}
        if offset is not None:
            payload["offset"] = offset
        if allowed_updates:
            payload["allowed_updates"] = list(allowed_updates)
        result = await self._request("getUpdates", payload)
        if not isinstance(result, list):
            return []
        return [item for item in result if isinstance(item, dict)]

    async def send_message(
        self,
        chat_id: Union[int, str],
        text: str,
        *,
        message_thread_id: Optional[int] = None,
        reply_to_message_id: Optional[int] = None,
        reply_markup: Optional[dict[str, Any]] = None,
        parse_mode: Optional[str] = None,
        disable_web_page_preview: bool = True,
    ) -> dict[str, Any]:
        text = _sanitize_outbound_text_payload(text, parse_mode=parse_mode)
        if len(text) > TELEGRAM_MAX_MESSAGE_LENGTH:
            responses = await self.send_message_chunks(
                chat_id,
                text,
                message_thread_id=message_thread_id,
                reply_to_message_id=reply_to_message_id,
                reply_markup=reply_markup,
                parse_mode=parse_mode,
                disable_web_page_preview=disable_web_page_preview,
            )
            return responses[0] if responses else {}
        return await self._send_message_raw(
            chat_id,
            text,
            message_thread_id=message_thread_id,
            reply_to_message_id=reply_to_message_id,
            reply_markup=reply_markup,
            parse_mode=parse_mode,
            disable_web_page_preview=disable_web_page_preview,
        )

    async def _send_message_raw(
        self,
        chat_id: Union[int, str],
        text: str,
        *,
        message_thread_id: Optional[int] = None,
        reply_to_message_id: Optional[int] = None,
        reply_markup: Optional[dict[str, Any]] = None,
        parse_mode: Optional[str] = None,
        disable_web_page_preview: bool = True,
    ) -> dict[str, Any]:
        text = _sanitize_outbound_text_payload(text, parse_mode=parse_mode)
        log_event(
            self._logger,
            logging.INFO,
            "telegram.send_message",
            chat_id=chat_id,
            thread_id=message_thread_id,
            reply_to_message_id=reply_to_message_id,
            text_len=len(text),
            has_markup=reply_markup is not None,
            parse_mode=parse_mode,
            disable_web_page_preview=disable_web_page_preview,
        )
        payload: dict[str, Any] = {
            "chat_id": chat_id,
            "text": text,
            "disable_web_page_preview": disable_web_page_preview,
        }
        if message_thread_id is not None:
            payload["message_thread_id"] = message_thread_id
        if reply_to_message_id is not None:
            payload["reply_to_message_id"] = reply_to_message_id
        if reply_markup is not None:
            payload["reply_markup"] = reply_markup
        if parse_mode is not None:
            payload["parse_mode"] = parse_mode
        result = await self._request("sendMessage", payload)
        return result if isinstance(result, dict) else {}

    async def send_document(
        self,
        chat_id: Union[int, str],
        document: bytes,
        *,
        filename: str,
        message_thread_id: Optional[int] = None,
        reply_to_message_id: Optional[int] = None,
        caption: Optional[str] = None,
        parse_mode: Optional[str] = None,
    ) -> dict[str, Any]:
        caption = _sanitize_outbound_caption_payload(caption, parse_mode=parse_mode)
        log_event(
            self._logger,
            logging.INFO,
            "telegram.send_document",
            chat_id=chat_id,
            thread_id=message_thread_id,
            reply_to_message_id=reply_to_message_id,
            filename=filename,
            bytes_len=len(document),
            parse_mode=parse_mode,
        )
        data: dict[str, Any] = {"chat_id": chat_id}
        if message_thread_id is not None:
            data["message_thread_id"] = message_thread_id
        if reply_to_message_id is not None:
            data["reply_to_message_id"] = reply_to_message_id
        if caption is not None:
            data["caption"] = caption
        if parse_mode is not None:
            data["parse_mode"] = parse_mode
        files = {"document": (filename, document, "text/plain")}
        result = await self._request_multipart("sendDocument", data, files)
        return result if isinstance(result, dict) else {}

    async def send_chat_action(
        self,
        chat_id: Union[int, str],
        *,
        action: str = "typing",
        message_thread_id: Optional[int] = None,
    ) -> bool:
        log_event(
            self._logger,
            logging.DEBUG,
            "telegram.send_chat_action",
            chat_id=chat_id,
            thread_id=message_thread_id,
            action=action,
        )
        payload: dict[str, Any] = {"chat_id": chat_id, "action": action}
        if message_thread_id is not None:
            payload["message_thread_id"] = message_thread_id
        result = await self._request("sendChatAction", payload)
        return bool(result) if isinstance(result, bool) else False

    async def get_me(self) -> dict[str, Any]:
        log_event(self._logger, logging.DEBUG, "telegram.request", method="getMe")
        result = await self._request("getMe", {})
        return result if isinstance(result, dict) else {}

    async def get_file(self, file_id: str) -> dict[str, Any]:
        log_event(self._logger, logging.DEBUG, "telegram.request", method="getFile")
        result = await self._request("getFile", {"file_id": file_id})
        return result if isinstance(result, dict) else {}

    async def get_my_commands(
        self,
        *,
        scope: Optional[dict[str, Any]] = None,
        language_code: Optional[str] = None,
    ) -> list[dict[str, Any]]:
        log_event(
            self._logger,
            logging.DEBUG,
            "telegram.request",
            method="getMyCommands",
            scope=scope,
            language_code=language_code,
        )
        payload: dict[str, Any] = {}
        if scope is not None:
            payload["scope"] = scope
        if language_code is not None:
            payload["language_code"] = language_code
        result = await self._request("getMyCommands", payload)
        if not isinstance(result, list):
            return []
        return [item for item in result if isinstance(item, dict)]

    async def set_my_commands(
        self,
        commands: Sequence[dict[str, str]],
        *,
        scope: Optional[dict[str, Any]] = None,
        language_code: Optional[str] = None,
    ) -> bool:
        log_event(
            self._logger,
            logging.INFO,
            "telegram.set_my_commands",
            command_count=len(commands),
            scope=scope,
            language_code=language_code,
        )
        payload: dict[str, Any] = {"commands": list(commands)}
        if scope is not None:
            payload["scope"] = scope
        if language_code is not None:
            payload["language_code"] = language_code
        result = await self._request("setMyCommands", payload)
        return bool(result) if isinstance(result, bool) else False

    async def download_file(
        self, file_path: str, max_size_bytes: int = 100 * 1024 * 1024
    ) -> bytes:
        safe_path = file_path.lstrip("/")
        url = f"{self._file_base_url}/{safe_path}"
        log_event(
            self._logger, logging.INFO, "telegram.file.download", file_path=file_path
        )
        try:
            response = await self._client.get(url)
            response.raise_for_status()
            content_length = response.headers.get("content-length")
            if content_length:
                try:
                    file_size = int(content_length)
                    if file_size > max_size_bytes:
                        log_event(
                            self._logger,
                            logging.WARNING,
                            "telegram.file.too_large",
                            file_path=file_path,
                            size=file_size,
                            max_size=max_size_bytes,
                        )
                        raise TelegramPermanentError(
                            f"File too large: {file_size} bytes (max {max_size_bytes})",
                            user_message="Telegram file too large.",
                        )
                except ValueError:
                    pass
            if len(response.content) > max_size_bytes:
                log_event(
                    self._logger,
                    logging.WARNING,
                    "telegram.file.too_large",
                    file_path=file_path,
                    size=len(response.content),
                    max_size=max_size_bytes,
                )
                raise TelegramPermanentError(
                    f"File too large: {len(response.content)} bytes (max {max_size_bytes})",
                    user_message="Telegram file too large.",
                )
            return response.content
        except TelegramAPIError:
            raise
        except Exception as exc:
            log_event(
                self._logger,
                logging.WARNING,
                "telegram.file.download_failed",
                file_path=file_path,
                exc=exc,
            )
            raise TelegramTransientError(
                "Telegram file download failed",
                user_message="Telegram file download failed. Retrying...",
            ) from exc

    async def send_message_chunks(
        self,
        chat_id: Union[int, str],
        text: str,
        *,
        message_thread_id: Optional[int] = None,
        reply_to_message_id: Optional[int] = None,
        reply_markup: Optional[dict[str, Any]] = None,
        parse_mode: Optional[str] = None,
        disable_web_page_preview: bool = True,
        max_len: int = TELEGRAM_MAX_MESSAGE_LENGTH,
    ) -> list[dict[str, Any]]:
        text = _sanitize_outbound_text_payload(text, parse_mode=parse_mode)
        chunks = chunk_message(text, max_len=max_len, with_numbering=False)
        if not chunks:
            return []
        responses: list[dict[str, Any]] = []
        log_event(
            self._logger,
            logging.INFO,
            "telegram.send_message.chunks",
            chat_id=chat_id,
            thread_id=message_thread_id,
            reply_to_message_id=reply_to_message_id,
            parts=len(chunks),
            total_len=len(text),
        )
        for idx, chunk in enumerate(chunks):
            response = await self._send_message_raw(
                chat_id,
                chunk,
                message_thread_id=message_thread_id,
                reply_to_message_id=reply_to_message_id if idx == 0 else None,
                reply_markup=reply_markup if idx == 0 else None,
                parse_mode=parse_mode,
                disable_web_page_preview=disable_web_page_preview,
            )
            responses.append(response)
        return responses

    async def edit_message_text(
        self,
        chat_id: Union[int, str],
        message_id: int,
        text: str,
        *,
        message_thread_id: Optional[int] = None,
        reply_markup: Optional[dict[str, Any]] = None,
        parse_mode: Optional[str] = None,
        disable_web_page_preview: bool = True,
    ) -> dict[str, Any]:
        text = _sanitize_outbound_text_payload(text, parse_mode=parse_mode)
        log_event(
            self._logger,
            logging.INFO,
            "telegram.edit_message",
            chat_id=chat_id,
            thread_id=message_thread_id,
            message_id=message_id,
            text_len=len(text),
            has_markup=reply_markup is not None,
            parse_mode=parse_mode,
            disable_web_page_preview=disable_web_page_preview,
        )
        payload: dict[str, Any] = {
            "chat_id": chat_id,
            "message_id": message_id,
            "text": text,
            "disable_web_page_preview": disable_web_page_preview,
        }
        if message_thread_id is not None:
            payload["message_thread_id"] = message_thread_id
        if reply_markup is not None:
            payload["reply_markup"] = reply_markup
        if parse_mode is not None:
            payload["parse_mode"] = parse_mode
        result = await self._request("editMessageText", payload)
        return result if isinstance(result, dict) else {}

    async def delete_message(
        self,
        chat_id: Union[int, str],
        message_id: int,
        *,
        message_thread_id: Optional[int] = None,
    ) -> bool:
        log_event(
            self._logger,
            logging.INFO,
            "telegram.delete_message",
            chat_id=chat_id,
            thread_id=message_thread_id,
            message_id=message_id,
        )
        payload: dict[str, Any] = {"chat_id": chat_id, "message_id": message_id}
        result = await self._request("deleteMessage", payload)
        return bool(result) if isinstance(result, bool) else False

    async def answer_callback_query(
        self,
        callback_query_id: str,
        *,
        chat_id: Optional[int] = None,
        thread_id: Optional[int] = None,
        message_id: Optional[int] = None,
        text: Optional[str] = None,
        show_alert: bool = False,
    ) -> dict[str, Any]:
        log_event(
            self._logger,
            logging.INFO,
            "telegram.answer_callback",
            callback_query_id=callback_query_id,
            chat_id=chat_id,
            thread_id=thread_id,
            message_id=message_id,
            text_len=len(text) if text else 0,
            show_alert=show_alert,
        )
        payload: dict[str, Any] = {"callback_query_id": callback_query_id}
        if text is not None:
            payload["text"] = text
        if show_alert:
            payload["show_alert"] = True
        result = await self._request("answerCallbackQuery", payload)
        return result if isinstance(result, dict) else {}

    async def _request(self, method: str, payload: dict[str, Any]) -> Any:
        url = f"{self._base_url}/bot{self._bot_token}/{method}"

        async def send() -> httpx.Response:
            return await self._client.post(url, json=payload)

        return await self._request_with_retry(method, send)

    async def _request_multipart(
        self, method: str, data: dict[str, Any], files: dict[str, Any]
    ) -> Any:
        url = f"{self._base_url}/bot{self._bot_token}/{method}"

        async def send() -> httpx.Response:
            return await self._client.post(url, data=data, files=files)

        return await self._request_with_retry(method, send)

    @retry_transient(max_attempts=5, base_wait=1.0, max_wait=60.0)
    async def _request_with_retry(
        self, method: str, send: Callable[[], Awaitable[httpx.Response]]
    ) -> Any:
        async with self._resilience_guard(method):
            await self._wait_for_rate_limit(method)
            try:
                payload = await self._send_request_payload(send)
            except httpx.HTTPStatusError as exc:
                raise await self._handle_http_status_error(method, exc) from exc
            except httpx.RequestError as exc:
                raise await self._handle_transport_error(method, exc) from exc
            except Exception as exc:
                raise await self._handle_unexpected_error(method, exc) from exc

            if not isinstance(payload, dict) or not payload.get("ok"):
                raise await self._handle_unsuccessful_payload(method, payload)
            return payload.get("result")

    async def _send_request_payload(
        self, send: Callable[[], Awaitable[httpx.Response]]
    ) -> Any:
        response = await send()
        response.raise_for_status()
        return response.json()

    async def _handle_http_status_error(
        self, method: str, exc: httpx.HTTPStatusError
    ) -> Exception:
        retry_after = _extract_retry_after_seconds(exc)
        status_code = exc.response.status_code
        if retry_after is not None:
            await self._apply_rate_limit(method, retry_after)
        log_event(
            self._logger,
            logging.WARNING,
            "telegram.request.failed",
            method=method,
            status_code=status_code,
            retry_after=retry_after,
            exc=exc,
        )
        if status_code == 429 or retry_after is not None or status_code >= 500:
            return TelegramTransientError(
                "Telegram request failed",
                retry_after=retry_after,
            )
        return TelegramPermanentError(
            "Telegram request failed",
            user_message="Telegram API error.",
        )

    async def _handle_transport_error(
        self, method: str, exc: httpx.RequestError
    ) -> Exception:
        retry_after = _extract_retry_after_seconds(exc)
        if retry_after is not None:
            await self._apply_rate_limit(method, retry_after)
        log_event(
            self._logger,
            logging.WARNING,
            "telegram.request.failed",
            method=method,
            retry_after=retry_after,
            exc=exc,
        )
        return TelegramTransientError(
            "Telegram request failed",
            retry_after=retry_after,
        )

    async def _handle_unexpected_error(self, method: str, exc: Exception) -> Exception:
        retry_after = _extract_retry_after_seconds(exc)
        if retry_after is not None:
            await self._apply_rate_limit(method, retry_after)
        log_event(
            self._logger,
            logging.WARNING,
            "telegram.request.failed",
            method=method,
            retry_after=retry_after,
            exc=exc,
        )
        return TelegramTransientError(
            "Telegram request failed",
            retry_after=retry_after,
        )

    async def _handle_unsuccessful_payload(
        self, method: str, payload: Any
    ) -> Exception:
        description = payload.get("description") if isinstance(payload, dict) else None
        retry_after = self._retry_after_from_payload(payload)
        if isinstance(payload, dict):
            error_code = payload.get("error_code")
        else:
            error_code = None
        if not isinstance(error_code, int) or isinstance(error_code, bool):
            error_code = None
        if retry_after is not None:
            await self._apply_rate_limit(method, retry_after)
        log_event(
            self._logger,
            logging.WARNING,
            "telegram.request.failed",
            method=method,
            error_code=error_code,
            retry_after=retry_after,
            description=description,
        )
        if (
            error_code == 429
            or retry_after is not None
            or (error_code is not None and error_code >= 500)
        ):
            return TelegramTransientError(
                description or "Telegram API returned error",
                retry_after=retry_after,
            )
        if error_code is not None and 400 <= error_code < 500:
            return TelegramPermanentError(
                description or "Telegram API returned error",
                user_message="Telegram API error.",
            )
        return TelegramTransientError(
            description or "Telegram API returned error",
            retry_after=retry_after,
        )

    def _retry_after_from_payload(self, payload: Any) -> Optional[int]:
        if not isinstance(payload, dict):
            return None
        parameters = payload.get("parameters")
        if isinstance(parameters, dict):
            retry_after = parameters.get("retry_after")
            if isinstance(retry_after, int):
                return retry_after
        description = payload.get("description")
        if isinstance(description, str) and description:
            return _extract_retry_after_seconds(Exception(description))
        return None

    def _ensure_rate_limit_lock(self) -> asyncio.Lock:
        loop = asyncio.get_running_loop()
        lock = self._rate_limit_lock
        lock_loop = self._rate_limit_lock_loop
        if (
            lock is None
            or lock_loop is None
            or lock_loop is not loop
            or lock_loop.is_closed()
        ):
            lock = asyncio.Lock()
            self._rate_limit_lock = lock
            self._rate_limit_lock_loop = loop
            self._rate_limit_until = {}
        return lock

    def _resilience_scope(self, method: str) -> str:
        return method

    def _circuit_breaker_scope(self, method: str) -> Optional[str]:
        # Keep polling independent from delivery/edit resilience paths.
        if method == "getUpdates":
            return None
        return self._resilience_scope(method)

    @asynccontextmanager
    async def _resilience_guard(self, method: str) -> AsyncIterator[None]:
        circuit_scope = self._circuit_breaker_scope(method)
        if not circuit_scope:
            yield
            return
        breaker = self._circuit_breakers.get(circuit_scope)
        if breaker is None:
            breaker = CircuitBreaker(
                f"Telegram:{circuit_scope}",
                logger=self._logger,
            )
            self._circuit_breakers[circuit_scope] = breaker
        async with breaker.call():
            yield

    async def _apply_rate_limit(self, method: str, retry_after: int) -> None:
        delay = float(retry_after)
        loop = asyncio.get_running_loop()
        until = loop.time() + delay + _RATE_LIMIT_BUFFER_SECONDS
        scope = self._resilience_scope(method)
        lock = self._ensure_rate_limit_lock()
        async with lock:
            existing = self._rate_limit_until.get(scope)
            if existing is None or until > existing:
                self._rate_limit_until[scope] = until
        log_event(
            self._logger,
            logging.INFO,
            "telegram.rate_limit.hit",
            method=method,
            retry_after=retry_after,
            scope=scope,
        )
        await self._wait_for_rate_limit(method, min_delay=delay)

    async def _wait_for_rate_limit(
        self, method: str, min_delay: Optional[float] = None
    ) -> None:
        scope = self._resilience_scope(method)
        lock = self._ensure_rate_limit_lock()
        async with lock:
            until = self._rate_limit_until.get(scope)
        if until is None:
            return
        loop = asyncio.get_running_loop()
        delay = until - loop.time()
        if min_delay is not None and delay < min_delay:
            delay = min_delay
        if delay <= 0:
            async with lock:
                if self._rate_limit_until.get(scope) == until:
                    self._rate_limit_until.pop(scope, None)
            return
        log_event(
            self._logger,
            logging.INFO,
            "telegram.rate_limit.wait",
            method=method,
            delay_seconds=delay,
            scope=scope,
        )
        await asyncio.sleep(delay)
        async with lock:
            if self._rate_limit_until.get(scope) == until:
                self._rate_limit_until.pop(scope, None)


class TelegramUpdatePoller:
    def __init__(
        self,
        client: TelegramBotClient,
        *,
        allowed_updates: Optional[Sequence[str]] = None,
        offset: Optional[int] = None,
    ) -> None:
        self._client = client
        self._offset: Optional[int] = None
        self._allowed_updates = list(allowed_updates) if allowed_updates else None
        if isinstance(offset, int) and not isinstance(offset, bool):
            self._offset = offset

    @property
    def offset(self) -> Optional[int]:
        return self._offset

    def set_offset(self, offset: Optional[int]) -> None:
        if offset is None:
            return
        if not isinstance(offset, int) or isinstance(offset, bool):
            return
        self._offset = offset

    async def poll(self, *, timeout: int = 30) -> list[TelegramUpdate]:
        updates = await self._client.get_updates(
            offset=self._offset,
            timeout=timeout,
            allowed_updates=self._allowed_updates,
        )
        self._offset = next_update_offset(updates, self._offset)
        parsed: list[TelegramUpdate] = []
        for update in updates:
            parsed_update = parse_update(update)
            if parsed_update is not None:
                parsed.append(parsed_update)
        return parsed
