// Package bot wraps the Telegram Bot API client (github.com/go-telegram/bot): it
// runs the long-poll update loop — replying to /start (with an optional deep-link
// payload), to /support with the support-desk info message, and to any other message
// with a Mini App launch button — and sends the
// notification and admin messages the connector requests. The bot token lives only
// in this process.
package bot

import (
	"context"
	"net/http"
	"net/url"
	"strconv"
	"strings"
	"time"

	tgbot "github.com/go-telegram/bot"
	"github.com/go-telegram/bot/models"
	"go.uber.org/zap"
	"golang.org/x/time/rate"

	"scrabble/platform/telegram/internal/health"
	"scrabble/platform/telegram/internal/outbox"
	"scrabble/platform/telegram/internal/support"
)

// botPollTimeout is the getUpdates long-poll timeout. It matches the go-telegram/bot default; we set
// it explicitly only because wiring the health observer (WithHTTPClient) also sets the poll timeout.
const botPollTimeout = time.Minute

// Config configures the bot wrapper.
type Config struct {
	// Token is the Bot API token.
	Token string
	// APIBaseURL overrides the Bot API host ("" uses https://api.telegram.org).
	APIBaseURL string
	// TestEnv routes requests to the Bot API test environment.
	TestEnv bool
	// MiniAppURL is the base URL of the Mini App launch button.
	MiniAppURL string
	// SendRatePerSecond caps outbound sends (Notify and SendText) to respect the
	// Telegram Bot API flood limits; 0 disables the limiter. The burst equals the
	// per-second rate.
	SendRatePerSecond int
	// ChatID is the moderated discussion chat the bot gates write access in; 0
	// disables chat gating (and the chat_member long-poll subscription). Gating needs
	// the bot to be an administrator there with the restrict-members right. Its public
	// @username is also resolved at startup for the /start welcome's discussion link.
	ChatID int64
	// GameChannelID is the game channel whose public @username the /start welcome links
	// to (resolved from this id via getChat at startup); 0 omits that follow link.
	GameChannelID int64
	// SupportChatID is the private forum supergroup the bot relays direct user messages
	// into (one topic per user) and reads operator replies from; 0 disables the support
	// relay. The bot must be an administrator there with the manage-topics and
	// delete-messages rights.
	SupportChatID int64
	// SupportStore persists the support relay's state (topic mapping, block list,
	// relayed message ids); required when SupportChatID is set, ignored otherwise.
	SupportStore *support.Store
	// AcceptPayments enables the Telegram Stars rail: the bot then subscribes to
	// pre_checkout_query updates and handles pre_checkout / successful_payment. The runtime
	// dependencies (the validator, forwarder and outbox) are wired with SetPaymentHandlers.
	AcceptPayments bool
	// Health, when set, observes every Bot API request for health metrics (reported to the gateway
	// over the bot-link) and honours a 429's Retry-After. Nil disables the observation.
	Health *health.Reporter
}

// EligibilityResolver answers whether the Telegram user identified by externalID
// (the decimal user id) may write in the moderated chat: registered and neither
// admin-suspended nor chat-muted. The bot calls it when a user joins the chat. It is
// late-bound (SetEligibilityResolver) because it is backed by the bot-link client,
// which is built after the bot.
type EligibilityResolver func(ctx context.Context, externalID string) (eligible bool, err error)

// Bot wraps a Telegram Bot API client and the Mini App launch URL.
type Bot struct {
	api        *tgbot.Bot
	miniAppURL string
	log        *zap.Logger
	// limiter throttles outbound sends to stay under the Bot API flood limits; nil
	// disables throttling.
	limiter *rate.Limiter
	// chatID is the moderated discussion chat (0 disables gating).
	chatID int64
	// channelID is the game channel (0 omits its welcome follow link).
	channelID int64
	// channelUsername and chatUsername are the public @usernames (without the leading
	// @) of the game channel and the discussion chat, resolved once at startup
	// (resolveWelcomeHandles) for the /start welcome's follow links; "" when unresolved.
	channelUsername string
	chatUsername    string
	// botID is the bot's own Telegram user id (resolved at startup); it skips the
	// chat_member updates the bot's own restrict actions generate — the grant loop guard —
	// and the bot's own relayed copies in the support chat.
	botID int64
	// eligibility resolves a joining user's chat write eligibility; nil leaves a
	// joiner muted (fail-closed) until it is wired.
	eligibility EligibilityResolver
	// supportChatID is the support relay's forum supergroup (0 disables the relay).
	supportChatID int64
	// support persists the support relay's per-user state; nil when the relay is off.
	support *support.Store
	// supportLocks serialises per-user topic creation so a burst of a new user's
	// messages opens exactly one topic.
	supportLocks *keyedMutex
	// admins caches the support chat's administrator ids (who may reply and act).
	admins *adminCache
	// precheck validates a Stars pre_checkout order and forward delivers a completed payment; both
	// are late-bound (SetPaymentHandlers) over the bot-link, which is built after the bot. outbox
	// durably records completed payments before they are forwarded. All nil when the Stars rail is off.
	precheck PreCheckoutValidator
	forward  PaymentForwarder
	outbox   *outbox.Store
}

// New builds the bot wrapper, registering the /start handler and a default handler
// that both reply with a Mini App launch button. It does not start polling; call
// Run for that.
func New(cfg Config, log *zap.Logger) (*Bot, error) {
	if log == nil {
		log = zap.NewNop()
	}
	t := &Bot{
		miniAppURL:    cfg.MiniAppURL,
		log:           log,
		chatID:        cfg.ChatID,
		channelID:     cfg.GameChannelID,
		supportChatID: cfg.SupportChatID,
		support:       cfg.SupportStore,
	}
	if cfg.SendRatePerSecond > 0 {
		t.limiter = rate.NewLimiter(rate.Limit(cfg.SendRatePerSecond), cfg.SendRatePerSecond)
	}

	opts := []tgbot.Option{
		tgbot.WithDefaultHandler(t.handleUpdate),
		tgbot.WithMessageTextHandler("/start", tgbot.MatchTypePrefix, t.handleStart),
		tgbot.WithMessageTextHandler("/support", tgbot.MatchTypePrefix, t.handleSupport),
	}
	if t.supportEnabled() {
		t.supportLocks = newKeyedMutex()
		t.admins = &adminCache{}
		// The info-card buttons are callback_query updates; route them to the support
		// callback handler by their "sup:" data prefix.
		opts = append(opts, tgbot.WithCallbackQueryDataHandler(supportCallbackPrefix, tgbot.MatchTypePrefix, t.handleSupportCallback))
	}
	// Allowed updates default to "all except chat_member" (which already includes
	// pre_checkout_query and message-borne successful_payment). Specify an explicit set only when we
	// need chat_member (moderated chat) — and then re-add callback_query and, for the Stars rail,
	// pre_checkout_query, which the explicit set would otherwise drop.
	if cfg.ChatID != 0 {
		allowed := tgbot.AllowedUpdates{
			models.AllowedUpdateMessage,
			models.AllowedUpdateMyChatMember,
			models.AllowedUpdateChatMember,
		}
		if t.supportEnabled() {
			allowed = append(allowed, models.AllowedUpdateCallbackQuery)
		}
		if cfg.AcceptPayments {
			allowed = append(allowed, models.AllowedUpdatePreCheckoutQuery)
		}
		opts = append(opts, tgbot.WithAllowedUpdates(allowed))
	}
	if cfg.TestEnv {
		// Route to the Bot API test environment (.../bot<token>/test/METHOD).
		opts = append(opts, tgbot.UseTestEnvironment())
	}
	if cfg.APIBaseURL != "" {
		opts = append(opts, tgbot.WithServerURL(cfg.APIBaseURL))
	}
	if cfg.Health != nil {
		// Observe every Bot API request centrally for health metrics and the 429 back-off. The
		// client timeout leaves slack over the long-poll hold (botPollTimeout - 1s server-side).
		base := &http.Client{Timeout: botPollTimeout + 10*time.Second}
		opts = append(opts, tgbot.WithHTTPClient(botPollTimeout, cfg.Health.Wrap(base)))
	}
	api, err := tgbot.New(cfg.Token, opts...)
	if err != nil {
		return nil, err
	}
	t.api = api
	return t, nil
}

// Run sets the bot commands and the Mini App menu button, then blocks on the
// long-poll update loop until ctx is cancelled.
func (t *Bot) Run(ctx context.Context) {
	// Command menu: the default (fallback) list is English; a Russian-scoped list
	// localises the labels for ru clients. A language scope replaces the whole list, so
	// it repeats /start with its own Russian label.
	if _, err := t.api.SetMyCommands(ctx, &tgbot.SetMyCommandsParams{
		Commands: []models.BotCommand{
			{Command: "start", Description: "Play Scrabble"},
			{Command: "support", Description: "Contact the administration"},
		},
	}); err != nil {
		t.log.Warn("set commands failed", zap.Error(err))
	}
	if _, err := t.api.SetMyCommands(ctx, &tgbot.SetMyCommandsParams{
		LanguageCode: "ru",
		Commands: []models.BotCommand{
			{Command: "start", Description: "Играть в «Эрудита»"},
			{Command: "support", Description: "Связаться с администрацией"},
		},
	}); err != nil {
		t.log.Warn("set ru commands failed", zap.Error(err))
	}
	if _, err := t.api.SetChatMenuButton(ctx, &tgbot.SetChatMenuButtonParams{
		MenuButton: models.MenuButtonWebApp{
			Type:   models.MenuButtonTypeWebApp,
			Text:   "Play",
			WebApp: models.WebAppInfo{URL: t.miniAppURL},
		},
	}); err != nil {
		t.log.Warn("set menu button failed", zap.Error(err))
	}
	if t.chatID != 0 {
		t.logChatAdminStatus(ctx)
	}
	if t.supportEnabled() {
		t.initSupport(ctx)
	}
	t.resolveWelcomeHandles(ctx)
	t.api.Start(ctx)
}

// resolveWelcomeHandles resolves, once at startup, the public @usernames of the game
// channel and the discussion chat from their configured ids (getChat), caching them for
// the /start welcome's follow links. It runs before the update loop, so the handles are
// set before any /start is handled; a chat that is unset, private (no public username)
// or unreadable simply leaves its handle empty and the welcome omits that follow link.
func (t *Bot) resolveWelcomeHandles(ctx context.Context) {
	t.channelUsername = t.resolveUsername(ctx, t.channelID, "game channel")
	t.chatUsername = t.resolveUsername(ctx, t.chatID, "discussion chat")
}

// resolveUsername returns the public @username (without the leading @) of the chat with
// the given id, or "" when id is 0, the chat has no public username, or getChat fails —
// logging the reason, since a missing handle silently drops a welcome follow link.
func (t *Bot) resolveUsername(ctx context.Context, id int64, label string) string {
	if id == 0 {
		return ""
	}
	chat, err := t.api.GetChat(ctx, &tgbot.GetChatParams{ChatID: id})
	if err != nil {
		t.log.Warn("welcome: getChat failed; follow link omitted",
			zap.String("chat", label), zap.Int64("id", id), zap.Error(err))
		return ""
	}
	if chat.Username == "" {
		t.log.Warn("welcome: chat has no public @username; follow link omitted",
			zap.String("chat", label), zap.Int64("id", id))
		return ""
	}
	t.log.Info("welcome: resolved follow link",
		zap.String("chat", label), zap.String("username", chat.Username))
	return chat.Username
}

// logChatAdminStatus checks, at startup, whether the bot can actually gate the
// moderated chat — it must be an administrator there with the restrict-members
// ("Ban users") right, or Telegram delivers no chat_member updates and restricts
// fail. It logs a prominent warning when the prerequisite is missing (the common
// misconfiguration), so the cause is visible without reproducing a join.
func (t *Bot) logChatAdminStatus(ctx context.Context) {
	me, err := t.api.GetMe(ctx)
	if err != nil {
		t.log.Warn("chat self-check: getMe failed", zap.Error(err))
		return
	}
	t.botID = me.ID
	m, err := t.api.GetChatMember(ctx, &tgbot.GetChatMemberParams{ChatID: t.chatID, UserID: me.ID})
	if err != nil {
		t.log.Warn("chat gating self-check failed: the bot cannot read the chat — is it added and is TELEGRAM_CHAT_ID the discussion group id?",
			zap.Int64("chat_id", t.chatID), zap.Error(err))
		return
	}
	canRestrict := m.Type == models.ChatMemberTypeAdministrator && m.Administrator.CanRestrictMembers
	if !canRestrict {
		t.log.Warn(`chat gating WILL NOT WORK: the bot must be an administrator with the restrict-members ("Ban users") right`,
			zap.Int64("chat_id", t.chatID), zap.String("bot_status", string(m.Type)))
		return
	}
	t.log.Info("chat gating ready: bot is an admin with the restrict-members right", zap.Int64("chat_id", t.chatID))
	// The same moderated chat also gets the linked channel's auto-forwarded posts un-pinned,
	// which needs the pin-messages right; warn (separately from gating) when it is missing.
	if !m.Administrator.CanPinMessages {
		t.log.Warn("auto-unpin of linked channel posts WILL NOT WORK: the bot lacks the pin-messages right",
			zap.Int64("chat_id", t.chatID))
	}
}

// Notify sends a notification message with a Mini App launch button that opens the
// app at startParam (empty opens the lobby).
func (t *Bot) Notify(ctx context.Context, chatID int64, text, buttonText, startParam string) error {
	if err := t.throttle(ctx); err != nil {
		return err
	}
	_, err := t.api.SendMessage(ctx, &tgbot.SendMessageParams{
		ChatID:      chatID,
		Text:        text,
		ReplyMarkup: t.launchMarkup(buttonText, startParam),
	})
	return err
}

// SendText sends a plain text message with no markup (admin use).
func (t *Bot) SendText(ctx context.Context, chatID int64, text string) error {
	if err := t.throttle(ctx); err != nil {
		return err
	}
	_, err := t.api.SendMessage(ctx, &tgbot.SendMessageParams{ChatID: chatID, Text: text})
	return err
}

// throttle blocks until the rate limiter admits one send, or ctx is cancelled. It
// is a no-op when no limiter is configured.
func (t *Bot) throttle(ctx context.Context) error {
	if t.limiter == nil {
		return nil
	}
	return t.limiter.Wait(ctx)
}

// handleStart replies to /start (with an optional deep-link payload) and to any
// other message with a Mini App launch button.
func (t *Bot) handleStart(ctx context.Context, api *tgbot.Bot, update *models.Update) {
	if update.Message == nil {
		return
	}
	// Reply only in a private chat: the Mini App launch button is an inline web_app
	// button, which Telegram permits only in private chats — replying to a group message
	// (the bot is an admin in the moderated chat and now receives its messages) fails with
	// BUTTON_TYPE_INVALID. In the group the bot only manages permissions, it never chats.
	if update.Message.Chat.Type != models.ChatTypePrivate {
		return
	}
	// The sender's Telegram language rides on the message itself (Message.from.language_code
	// in the Bot API — there is no separate user-update event); fall back to English when it
	// is absent.
	lang := ""
	if update.Message.From != nil {
		lang = update.Message.From.LanguageCode
	}
	text, button := startText(lang, t.channelUsername, t.chatUsername)
	startParam := startPayload(update.Message.Text)
	if _, err := api.SendMessage(ctx, &tgbot.SendMessageParams{
		ChatID:      update.Message.Chat.ID,
		Text:        text,
		ReplyMarkup: t.launchMarkup(button, startParam),
	}); err != nil {
		t.log.Warn("reply to start failed", zap.Error(err))
	}
}

// launchMarkup builds the single-button inline keyboard that opens the Mini App at
// startParam.
func (t *Bot) launchMarkup(buttonText, startParam string) *models.InlineKeyboardMarkup {
	return &models.InlineKeyboardMarkup{
		InlineKeyboard: [][]models.InlineKeyboardButton{{
			{Text: buttonText, WebApp: &models.WebAppInfo{URL: t.launchURL(startParam)}},
		}},
	}
}

// launchURL appends the deep-link start parameter to the Mini App URL as a startapp
// query parameter; an empty parameter returns the base URL unchanged.
func (t *Bot) launchURL(startParam string) string {
	if startParam == "" {
		return t.miniAppURL
	}
	u, err := url.Parse(t.miniAppURL)
	if err != nil {
		return t.miniAppURL
	}
	q := u.Query()
	q.Set("startapp", startParam)
	u.RawQuery = q.Encode()
	return u.String()
}

// startPayload extracts the deep-link payload from a "/start <payload>" command;
// any other text yields an empty payload (open the lobby).
func startPayload(text string) string {
	const cmd = "/start"
	if !strings.HasPrefix(text, cmd) {
		return ""
	}
	return strings.TrimSpace(strings.TrimPrefix(text, cmd))
}

// handleUpdate is the default-handler dispatcher: a chat-member change in the
// moderated chat drives the write-access gate; anything else is treated as a message
// and gets the Mini App launch reply.
func (t *Bot) handleUpdate(ctx context.Context, api *tgbot.Bot, update *models.Update) {
	if update.ChatMember != nil {
		t.handleChatMember(ctx, update.ChatMember)
		return
	}
	// Telegram Stars: the pre_checkout gate (validated against the backend) and the completed
	// payment (persisted to the outbox and forwarded) — before the support relay, so a payment
	// message is never mistaken for a support DM or given a launch reply.
	if update.PreCheckoutQuery != nil {
		t.handlePreCheckout(ctx, update.PreCheckoutQuery)
		return
	}
	if update.Message != nil && update.Message.SuccessfulPayment != nil {
		t.handleSuccessfulPayment(ctx, update.Message)
		return
	}
	// A channel post automatically forwarded into the moderated discussion chat is
	// auto-pinned by Telegram (a non-disableable behaviour). Unpin that one message so only
	// deliberate pins remain; it targets this exact message id, so a pin set by a human admin
	// — or by the bot for another message — is never touched.
	if m := update.Message; m != nil && m.IsAutomaticForward && t.chatID != 0 && m.Chat.ID == t.chatID {
		t.handleLinkedChannelPin(ctx, m)
		return
	}
	// Support relay (when enabled): a non-/start message — /start has its own handler —
	// is either an operator's reply in the support chat or a user's direct message to
	// relay. Everything else falls through to the Mini App launch reply.
	if t.supportEnabled() && update.Message != nil {
		switch {
		case update.Message.Chat.ID == t.supportChatID:
			t.handleSupportGroupMessage(ctx, update.Message)
			return
		case update.Message.Chat.Type == models.ChatTypePrivate:
			t.handleSupportUserMessage(ctx, update.Message)
			return
		}
	}
	t.handleStart(ctx, api, update)
}

// handleLinkedChannelPin removes Telegram's automatic pin from a linked channel's post
// that was auto-forwarded into the moderated discussion chat. It unpins only that exact
// message (by id), so a pin set by a human admin — or by the bot for another message — is
// left untouched. It needs the bot to hold the pin-messages right in the chat; a missing
// right surfaces as a warning (the unpin then no-ops), not a crash.
func (t *Bot) handleLinkedChannelPin(ctx context.Context, m *models.Message) {
	if _, err := t.api.UnpinChatMessage(ctx, &tgbot.UnpinChatMessageParams{
		ChatID:    m.Chat.ID,
		MessageID: m.ID,
	}); err != nil {
		t.log.Warn("could not unpin an auto-forwarded channel post; does the bot have the pin-messages right?",
			zap.Int64("chat_id", m.Chat.ID), zap.Int("message_id", m.ID), zap.Error(err))
		return
	}
	t.log.Debug("unpinned an auto-forwarded channel post",
		zap.Int64("chat_id", m.Chat.ID), zap.Int("message_id", m.ID))
}

// SetEligibilityResolver wires the chat-eligibility resolver after construction (the
// bot-link client backing it is built after the bot).
func (t *Bot) SetEligibilityResolver(resolve EligibilityResolver) {
	t.eligibility = resolve
}

// handleChatMember keeps a chat member's write access in sync with their eligibility.
// The chat allows sending by default, so the bot mutes an ineligible member (not
// registered, or admin-suspended, or chat_muted) and restores an eligible one it had
// muted; an eligible member that can already send is left untouched. It acts only when
// the current state differs from the desired one, so it is idempotent and does not
// re-act on its own change; a resolve failure makes no change.
func (t *Bot) handleChatMember(ctx context.Context, cm *models.ChatMemberUpdated) {
	user := chatMemberUser(cm.NewChatMember)
	var uid int64
	if user != nil {
		uid = user.ID
	}
	// Log every chat_member update the bot receives: the one place to see whether
	// Telegram delivers joins, for which chat, the transition, who performed it, and the
	// new member's send/membership state.
	canSend, isMember := restrictedSendState(cm.NewChatMember)
	t.log.Debug("chat_member update",
		zap.Int64("chat_id", cm.Chat.ID),
		zap.Int64("configured_chat_id", t.chatID),
		zap.Int64("user_id", uid),
		zap.Int64("actor_id", cm.From.ID),
		zap.String("old_status", string(cm.OldChatMember.Type)),
		zap.String("new_status", string(cm.NewChatMember.Type)),
		zap.Bool("new_can_send", canSend),
		zap.Bool("new_is_member", isMember))

	if t.chatID == 0 || cm.Chat.ID != t.chatID {
		return
	}
	if user == nil || user.IsBot {
		return
	}
	// Loop guard: the bot's own restrict re-fires a chat_member update whose performer is
	// the bot; skip those so a grant never re-triggers itself.
	if t.botID != 0 && cm.From.ID == t.botID {
		return
	}
	// The chat allows sending by default and the bot only restricts: Telegram intersects
	// the chat default with the per-user permission, so a per-user grant cannot exceed a
	// deny-by-default — the gate must mute the ineligible, not grant the eligible.
	// Determine whether the user is in the chat and can currently send: a plain member
	// follows the permissive default; a restricted member can send only with
	// CanSendMessages, and only while a member.
	var inChat, currentlyCanSend bool
	switch cm.NewChatMember.Type {
	case models.ChatMemberTypeMember:
		inChat, currentlyCanSend = true, true
	case models.ChatMemberTypeRestricted:
		inChat, currentlyCanSend = isMember, canSend
	default:
		return // left / kicked / administrator / owner — not a member to gate
	}
	if !inChat {
		return
	}
	if t.eligibility == nil {
		t.log.Warn("chat access: eligibility resolver not wired", zap.Int64("user_id", uid))
		return
	}
	eligible, err := t.eligibility(ctx, strconv.FormatInt(user.ID, 10))
	if err != nil {
		t.log.Warn("chat access eligibility failed", zap.Int64("user_id", user.ID), zap.Error(err))
		return
	}
	t.log.Debug("chat access evaluated",
		zap.Int64("user_id", user.ID), zap.Bool("eligible", eligible), zap.Bool("can_send", currentlyCanSend))
	// Desired: an eligible user may send, an ineligible one may not. Act only when the
	// current state differs — idempotent, a no-op for the common eligible member, and it
	// keeps the bot from re-acting on its own change.
	if eligible == currentlyCanSend {
		return
	}
	if err := t.setChatWrite(ctx, user.ID, eligible); err != nil {
		t.log.Warn("set chat write failed",
			zap.Int64("user_id", user.ID), zap.Bool("can_send", eligible), zap.Error(err))
		return
	}
	t.log.Info("chat access applied", zap.Int64("user_id", user.ID), zap.Bool("can_send", eligible))
}

// ApplyChatGate applies a chat-gate command (an admin block/unblock or chat_muted
// change relayed by the gateway): it sets the user's write access, but only when they
// are currently in the chat. Bots cannot list members, so it probes the single user
// with getChatMember and is a no-op when they are absent (left/kicked) or an
// administrator (who cannot be restricted). It reports whether a restriction was
// applied.
func (t *Bot) ApplyChatGate(ctx context.Context, userID int64, allow bool) (bool, error) {
	if t.chatID == 0 {
		return false, nil
	}
	member, err := t.api.GetChatMember(ctx, &tgbot.GetChatMemberParams{ChatID: t.chatID, UserID: userID})
	if err != nil {
		return false, err
	}
	switch member.Type {
	case models.ChatMemberTypeMember, models.ChatMemberTypeRestricted:
		if err := t.setChatWrite(ctx, userID, allow); err != nil {
			return false, err
		}
		t.log.Info("chat gate applied", zap.Int64("user_id", userID), zap.Bool("allow", allow))
		return true, nil
	default:
		t.log.Debug("chat gate: user not in chat, skipped", zap.Int64("user_id", userID), zap.String("status", string(member.Type)))
		return false, nil // absent, or an admin/owner who cannot be restricted
	}
}

// setChatWrite restricts the user in the moderated chat to either the full send
// permission set (allow) or none (mute); the non-send permissions stay at their
// default-deny either way.
func (t *Bot) setChatWrite(ctx context.Context, userID int64, allow bool) error {
	perms := models.ChatPermissions{}
	if allow {
		perms = chatWritePerms()
	}
	_, err := t.api.RestrictChatMember(ctx, &tgbot.RestrictChatMemberParams{
		ChatID:      t.chatID,
		UserID:      userID,
		Permissions: &perms,
	})
	return err
}

// chatWritePerms grants a member the ability to send every kind of message; the
// non-send permissions stay denied.
func chatWritePerms() models.ChatPermissions {
	return models.ChatPermissions{
		CanSendMessages:       true,
		CanSendAudios:         true,
		CanSendDocuments:      true,
		CanSendPhotos:         true,
		CanSendVideos:         true,
		CanSendVideoNotes:     true,
		CanSendVoiceNotes:     true,
		CanSendPolls:          true,
		CanSendOtherMessages:  true,
		CanAddWebPagePreviews: true,
	}
}

// restrictedSendState returns a restricted member's text-send permission and whether
// they are currently a member of the chat; (false, false) for any non-restricted
// status (the fields exist only on the restricted variant).
func restrictedSendState(m models.ChatMember) (canSend, isMember bool) {
	if m.Type == models.ChatMemberTypeRestricted && m.Restricted != nil {
		return m.Restricted.CanSendMessages, m.Restricted.IsMember
	}
	return false, false
}

// chatMemberUser returns the user a ChatMember refers to across the union variants,
// or nil for an unrecognised type.
func chatMemberUser(m models.ChatMember) *models.User {
	switch m.Type {
	case models.ChatMemberTypeOwner:
		return m.Owner.User
	case models.ChatMemberTypeAdministrator:
		return &m.Administrator.User
	case models.ChatMemberTypeMember:
		return m.Member.User
	case models.ChatMemberTypeRestricted:
		return m.Restricted.User
	case models.ChatMemberTypeLeft:
		return m.Left.User
	case models.ChatMemberTypeBanned:
		return m.Banned.User
	}
	return nil
}
