// mautrix - A Matrix-Telegram puppeting bridge.
// Copyright (C) 2025 Sumner Evans
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program.  If not, see <https://www.gnu.org/licenses/>.

package connector

import (
	"bytes"
	"context"
	"errors"
	"fmt"
	"slices"
	"strconv"
	"strings"
	"time"

	"github.com/rs/zerolog"
	"go.mau.fi/util/exfmt"
	"go.mau.fi/util/ptr"
	"golang.org/x/exp/maps"
	"maunium.net/go/mautrix/bridgev2"
	"maunium.net/go/mautrix/bridgev2/database"
	"maunium.net/go/mautrix/bridgev2/networkid"
	"maunium.net/go/mautrix/bridgev2/simplevent"
	"maunium.net/go/mautrix/bridgev2/status"
	"maunium.net/go/mautrix/event"

	"go.mau.fi/mautrix-telegram/pkg/connector/emojis"
	"go.mau.fi/mautrix-telegram/pkg/connector/ids"
	"go.mau.fi/mautrix-telegram/pkg/connector/media"
	"go.mau.fi/mautrix-telegram/pkg/connector/store"
	"go.mau.fi/mautrix-telegram/pkg/connector/tljson"
	"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
	"go.mau.fi/mautrix-telegram/pkg/gotd/tgerr"
)

type IGetMessage interface {
	GetMessage() tg.MessageClass
}

type IGetMessages interface {
	GetMessages() []int
}

func (tc *TelegramClient) selfLeaveChat(ctx context.Context, portalKey networkid.PortalKey, reason error) error {
	peerType, id, _, err := ids.ParsePortalID(portalKey.ID)
	if err != nil {
		return err
	}
	if peerType == ids.PeerTypeChannel {
		tc.updatesManager.RemoveChannel(id, reason)
		topics, err := tc.main.Store.Topic.GetAll(ctx, id)
		if err != nil {
			return err
		}
		for _, topicID := range topics {
			res := tc.main.Bridge.QueueRemoteEvent(tc.userLogin, &simplevent.ChatDelete{
				EventMeta: simplevent.EventMeta{
					Type:      bridgev2.RemoteEventChatDelete,
					PortalKey: tc.makePortalKeyFromID(peerType, id, topicID),
					Sender:    tc.mySender(),
					LogContext: func(c zerolog.Context) zerolog.Context {
						return c.AnErr("self_leave_reason", reason)
					},
				},
				OnlyForMe: true,
			})
			if err = resultToError(res); err != nil {
				return err
			}
		}
	}
	res := tc.main.Bridge.QueueRemoteEvent(tc.userLogin, &simplevent.ChatDelete{
		EventMeta: simplevent.EventMeta{
			Type:      bridgev2.RemoteEventChatDelete,
			PortalKey: portalKey,
			Sender:    tc.mySender(),
			LogContext: func(c zerolog.Context) zerolog.Context {
				return c.AnErr("self_leave_reason", reason)
			},
		},
		OnlyForMe: true,
	})
	if err = resultToError(res); err != nil {
		return err
	}
	if peerType == ids.PeerTypeChannel {
		// This is a no-op if there's no space portal
		res = tc.main.Bridge.QueueRemoteEvent(tc.userLogin, &simplevent.ChatDelete{
			EventMeta: simplevent.EventMeta{
				Type:      bridgev2.RemoteEventChatDelete,
				PortalKey: tc.makePortalKeyFromID(peerType, id, ids.TopicIDSpaceRoom),
				Sender:    tc.mySender(),
				LogContext: func(c zerolog.Context) zerolog.Context {
					return c.AnErr("self_leave_reason", reason)
				},
			},
			OnlyForMe: true,
		})
		if err = resultToError(res); err != nil {
			return err
		}
	}
	return nil
}

func (tc *TelegramClient) onNotChannelMember(ctx context.Context, channelID int64) error {
	return tc.selfLeaveChat(ctx, tc.makePortalKeyFromID(ids.PeerTypeChannel, channelID, 0), fmt.Errorf("startup channel member check failed"))
}

func (tc *TelegramClient) onUpdateChannel(ctx context.Context, e tg.Entities, update *tg.UpdateChannel) error {
	log := zerolog.Ctx(ctx).With().
		Str("handler", "on_update_channel").
		Int64("channel_id", update.ChannelID).
		Logger()

	// TODO resync topic portals?
	portalKey := tc.makePortalKeyFromID(ids.PeerTypeChannel, update.ChannelID, 0)

	// TODO is using the info in entities safe?
	channel, ok := e.Channels[update.ChannelID]
	if !ok {
		log.Debug().Msg("Fetching channel due to UpdateChannel event")
		chats, err := APICallWithOnlyChatUpdates(ctx, tc, func() (tg.MessagesChatsClass, error) {
			if accessHash, err := tc.ScopedStore.GetAccessHash(ctx, ids.PeerTypeChannel, update.ChannelID); err != nil {
				return nil, err
			} else {
				return tc.client.API().ChannelsGetChannels(ctx, []tg.InputChannelClass{
					&tg.InputChannel{ChannelID: update.ChannelID, AccessHash: accessHash},
				})
			}
		})
		if err != nil {
			if tgerr.Is(err, tg.ErrChannelInvalid, tg.ErrChannelPrivate) {
				return tc.selfLeaveChat(ctx, portalKey, fmt.Errorf("error fetching after UpdateChannel: %w", err))
			}
			log.Err(err).Msg("Failed to get channel info after UpdateChannel event")
			return nil
		} else if len(chats.GetChats()) != 1 {
			log.Warn().Int("chat_count", len(chats.GetChats())).Msg("Got more than 1 chat in GetChannels response")
			return nil
		} else if channel, ok = chats.GetChats()[0].(*tg.Channel); !ok {
			log.Error().Type("chat_type", chats.GetChats()[0]).Msg("Expected channel, got something else. Leaving the channel.")
			return tc.selfLeaveChat(ctx, portalKey, fmt.Errorf("channel not returned in getChannels after UpdateChannel"))
		}
	}
	if channel.Left {
		log.Debug().Msg("Update was for a left channel. Leaving the channel.")
		return tc.selfLeaveChat(ctx, portalKey, fmt.Errorf("channel has left=true after UpdateChannel"))
	}
	res := tc.main.Bridge.QueueRemoteEvent(tc.userLogin, &simplevent.ChatResync{
		EventMeta: simplevent.EventMeta{
			Type:         bridgev2.RemoteEventChatResync,
			PortalKey:    portalKey,
			CreatePortal: true,
			LogContext: func(c zerolog.Context) zerolog.Context {
				return c.Str("tg_event", "updateChannel")
			},
		},
		GetChatInfoFunc: func(ctx context.Context, portal *bridgev2.Portal) (*bridgev2.ChatInfo, error) {
			chatInfo, mfm, err := tc.wrapChatInfo(portal.ID, channel)
			if err != nil {
				return nil, err
			}
			if portal.MXID == "" {
				err = tc.fillChannelMembers(ctx, mfm, chatInfo.Members)
				if err != nil {
					return nil, err
				}
			}
			return chatInfo, nil
		},
	})
	return resultToError(res)
}

func (tc *TelegramClient) onUpdateNewMessage(ctx context.Context, entities tg.Entities, update IGetMessage) error {
	log := *zerolog.Ctx(ctx)
	switch msg := update.GetMessage().(type) {
	case *tg.Message:
		var isBroadcastChannel bool
		switch peer := msg.PeerID.(type) {
		case *tg.PeerChannel:
			log = log.With().Int64("channel_id", peer.ChannelID).Logger()
			if c, ok := entities.Channels[peer.ChannelID]; ok && c.Left {
				log.Debug().Msg("Received message in left channel, ignoring")
				return nil
			} else if ok && !c.GetMegagroup() {
				isBroadcastChannel = true
			}
		case *tg.PeerChat:
			log = log.With().Int64("chat_id", peer.ChatID).Logger()
			if c, ok := entities.Chats[peer.ChatID]; ok && c.Left {
				log.Debug().Msg("Received message in left chat, ignoring")
				return nil
			}
		}

		sender := tc.getEventSender(msg, isBroadcastChannel)

		if media, ok := msg.GetMedia(); ok && media.TypeID() == tg.MessageMediaContactTypeID {
			contact := media.(*tg.MessageMediaContact)
			// TODO update the corresponding puppet
			log.Info().Int64("user_id", contact.UserID).Msg("received contact")
		}

		topicID := tc.getTopicID(ctx, msg.PeerID, msg.ReplyTo)
		res := tc.main.Bridge.QueueRemoteEvent(tc.userLogin, &simplevent.Message[*tg.Message]{
			EventMeta: simplevent.EventMeta{
				Type: bridgev2.RemoteEventMessage,
				LogContext: func(c zerolog.Context) zerolog.Context {
					return c.
						Int("message_id", msg.GetID()).
						Str("sender", string(sender.Sender)).
						Str("sender_login", string(sender.SenderLogin)).
						Bool("is_from_me", sender.IsFromMe).
						Stringer("peer_id", msg.PeerID)
				},
				Sender:       sender,
				PortalKey:    tc.makePortalKeyFromPeer(msg.PeerID, topicID),
				CreatePortal: true,
				Timestamp:    time.Unix(int64(msg.Date), 0),
				StreamOrder:  int64(msg.GetID()),
			},
			ID:                 ids.GetMessageIDFromMessage(msg),
			Data:               msg,
			ConvertMessageFunc: tc.convertToMatrix,
		})

		if err := resultToError(res); err != nil {
			return err
		}

		if len(msg.Reactions.Results) > 0 {
			return tc.handleTelegramReactions(ctx, msg.PeerID, topicID, msg.ID, msg.Reactions, "updateNewMessage")
		}
		return nil
	case *tg.MessageService:
		return tc.handleServiceMessage(ctx, msg)

	default:
		log.Warn().
			Type("action_type", msg).
			Msg("ignoring unknown message type")
		return nil
	}
}

func (tc *TelegramClient) getTopicID(ctx context.Context, peerID tg.PeerClass, rawReplyTo tg.MessageReplyHeaderClass) int {
	topicID := rawGetTopicID(rawReplyTo)
	if topicID != 0 {
		channelPeer, _ := peerID.(*tg.PeerChannel)
		err := tc.main.Store.Topic.Add(ctx, channelPeer.GetChannelID(), topicID)
		if err != nil {
			zerolog.Ctx(ctx).Err(err).Msg("Failed to save topic ID")
		}
	}
	return topicID
}

func rawGetTopicID(rawReplyTo tg.MessageReplyHeaderClass) int {
	switch replyTo := rawReplyTo.(type) {
	case *tg.MessageReplyHeader:
		if replyTo.ForumTopic {
			if replyTo.ReplyToTopID != 0 {
				return replyTo.ReplyToTopID
			}
			return replyTo.ReplyToMsgID
		}
	}
	return 0
}

func (tc *TelegramClient) handleServiceMessage(ctx context.Context, msg *tg.MessageService) error {
	log := zerolog.Ctx(ctx)
	sender := tc.getEventSender(msg, false)

	eventMeta := simplevent.EventMeta{
		PortalKey: tc.makePortalKeyFromPeer(msg.PeerID, tc.getTopicID(ctx, msg.PeerID, msg.ReplyTo)),
		Sender:    sender,
		Timestamp: time.Unix(int64(msg.Date), 0),
		LogContext: func(c zerolog.Context) zerolog.Context {
			return c.
				Int("message_id", msg.GetID()).
				Str("sender", string(sender.Sender)).
				Str("sender_login", string(sender.SenderLogin)).
				Bool("is_from_me", sender.IsFromMe).
				Stringer("peer_id", msg.PeerID).
				Type("action_message_type", msg.Action)
		},
		StreamOrder: int64(msg.GetID()),
	}
	switch action := msg.Action.(type) {
	case *tg.MessageActionChatEditTitle:
		res := tc.main.Bridge.QueueRemoteEvent(tc.userLogin, &simplevent.ChatInfoChange{
			EventMeta:      eventMeta.WithType(bridgev2.RemoteEventChatInfoChange),
			ChatInfoChange: &bridgev2.ChatInfoChange{ChatInfo: &bridgev2.ChatInfo{Name: &action.Title}},
		})
		return resultToError(res)
	case *tg.MessageActionChatEditPhoto:
		switch peer := msg.PeerID.(type) {
		case *tg.PeerChat:
			res := tc.main.Bridge.QueueRemoteEvent(tc.userLogin, &simplevent.ChatInfoChange{
				EventMeta: eventMeta.WithType(bridgev2.RemoteEventChatInfoChange),
				ChatInfoChange: &bridgev2.ChatInfoChange{ChatInfo: &bridgev2.ChatInfo{
					Avatar: tc.avatarFromPhoto(ctx, ids.PeerTypeChat, peer.ChatID, action.Photo),
				}},
			})
			return resultToError(res)
		case *tg.PeerChannel:
			res := tc.main.Bridge.QueueRemoteEvent(tc.userLogin, &simplevent.ChatInfoChange{
				EventMeta: eventMeta.WithType(bridgev2.RemoteEventChatInfoChange),
				ChatInfoChange: &bridgev2.ChatInfoChange{ChatInfo: &bridgev2.ChatInfo{
					Avatar: tc.avatarFromPhoto(ctx, ids.PeerTypeChannel, peer.ChannelID, action.Photo),
				}},
			})
			return resultToError(res)
		default:
			return nil
		}

	case *tg.MessageActionChatDeletePhoto:
		res := tc.main.Bridge.QueueRemoteEvent(tc.userLogin, &simplevent.ChatInfoChange{
			EventMeta:      eventMeta.WithType(bridgev2.RemoteEventChatInfoChange),
			ChatInfoChange: &bridgev2.ChatInfoChange{ChatInfo: &bridgev2.ChatInfo{Avatar: &bridgev2.Avatar{Remove: true}}},
		})
		return resultToError(res)
	case *tg.MessageActionChatAddUser:
		memberChanges := &bridgev2.ChatMemberList{
			MemberMap: map[networkid.UserID]bridgev2.ChatMember{},
		}
		for _, userID := range action.Users {
			memberChanges.MemberMap.Set(bridgev2.ChatMember{
				EventSender: tc.senderForUserID(userID),
				Membership:  event.MembershipJoin,
			})
		}
		res := tc.main.Bridge.QueueRemoteEvent(tc.userLogin, &simplevent.ChatInfoChange{
			EventMeta:      eventMeta.WithType(bridgev2.RemoteEventChatInfoChange),
			ChatInfoChange: &bridgev2.ChatInfoChange{MemberChanges: memberChanges},
		})
		return resultToError(res)
	case *tg.MessageActionChatJoinedByLink:
		res := tc.main.Bridge.QueueRemoteEvent(tc.userLogin, &simplevent.ChatInfoChange{
			EventMeta: eventMeta.WithType(bridgev2.RemoteEventChatInfoChange),
			ChatInfoChange: &bridgev2.ChatInfoChange{
				MemberChanges: &bridgev2.ChatMemberList{
					MemberMap: bridgev2.ChatMemberMap{}.Set(bridgev2.ChatMember{
						EventSender: sender,
						Membership:  event.MembershipJoin,
					}),
				},
			},
		})
		return resultToError(res)
	case *tg.MessageActionChatDeleteUser:
		if action.UserID == tc.telegramUserID {
			return tc.selfLeaveChat(ctx, eventMeta.PortalKey, fmt.Errorf("delete user event for chat"))
		}
		res := tc.main.Bridge.QueueRemoteEvent(tc.userLogin, &simplevent.ChatInfoChange{
			EventMeta: eventMeta.WithType(bridgev2.RemoteEventChatInfoChange),
			ChatInfoChange: &bridgev2.ChatInfoChange{
				MemberChanges: &bridgev2.ChatMemberList{
					MemberMap: bridgev2.ChatMemberMap{}.Set(bridgev2.ChatMember{
						EventSender: tc.senderForUserID(action.UserID),
						Membership:  event.MembershipLeave,
					}),
				},
			},
		})
		return resultToError(res)
	case *tg.MessageActionChatCreate:
		res := tc.main.Bridge.QueueRemoteEvent(tc.userLogin, &simplevent.Message[any]{
			EventMeta: eventMeta.WithType(bridgev2.RemoteEventMessage).WithCreatePortal(true),
			ID:        ids.GetMessageIDFromMessage(msg),
			ConvertMessageFunc: func(ctx context.Context, portal *bridgev2.Portal, intent bridgev2.MatrixAPI, data any) (*bridgev2.ConvertedMessage, error) {
				return &bridgev2.ConvertedMessage{
					Parts: []*bridgev2.ConvertedMessagePart{{
						Type: event.EventMessage,
						Content: &event.MessageEventContent{
							MsgType: event.MsgNotice,
							Body:    "Created the group",
						},
					}},
				}, nil
			},
		})
		return resultToError(res)

	case *tg.MessageActionChannelCreate:
		res := tc.main.Bridge.QueueRemoteEvent(tc.userLogin, &simplevent.ChatResync{
			EventMeta: eventMeta.
				WithType(bridgev2.RemoteEventChatResync).
				WithCreatePortal(true),
			GetChatInfoFunc: tc.GetChatInfo,
		})
		if err := resultToError(res); err != nil {
			return err
		}
		res = tc.main.Bridge.QueueRemoteEvent(tc.userLogin, &simplevent.Message[any]{
			EventMeta: eventMeta.WithType(bridgev2.RemoteEventMessage),
			ID:        ids.GetMessageIDFromMessage(msg),
			ConvertMessageFunc: func(ctx context.Context, portal *bridgev2.Portal, intent bridgev2.MatrixAPI, data any) (*bridgev2.ConvertedMessage, error) {
				return &bridgev2.ConvertedMessage{
					Parts: []*bridgev2.ConvertedMessagePart{
						{
							Type:    event.EventMessage,
							Content: &event.MessageEventContent{MsgType: event.MsgNotice, Body: "Created the group"},
						},
					},
				}, nil
			},
		})
		return resultToError(res)
	case *tg.MessageActionSetMessagesTTL:
		setting := database.DisappearingSetting{
			Type:  event.DisappearingTypeAfterSend,
			Timer: time.Duration(action.Period) * time.Second,
		}.Normalize()
		res := tc.main.Bridge.QueueRemoteEvent(tc.userLogin, &simplevent.ChatInfoChange{
			EventMeta: eventMeta.WithType(bridgev2.RemoteEventChatInfoChange),
			ChatInfoChange: &bridgev2.ChatInfoChange{
				ChatInfo: &bridgev2.ChatInfo{
					Disappear: &setting,
				},
			},
		})
		return resultToError(res)
	case *tg.MessageActionPhoneCall:
		var body strings.Builder
		if action.Video {
			body.WriteString("Video call ")
		} else {
			body.WriteString("Call ")
		}
		switch action.Reason.TypeID() {
		case tg.PhoneCallDiscardReasonMissedTypeID:
			body.WriteString("missed")
		case tg.PhoneCallDiscardReasonDisconnectTypeID:
			body.WriteString("disconnected")
		case tg.PhoneCallDiscardReasonHangupTypeID:
			body.WriteString("ended")
		case tg.PhoneCallDiscardReasonBusyTypeID:
			body.WriteString("rejected")
		default:
			log.Warn().Stringer("end_reason", action.Reason).Msg("Unknown call end reason")
			return nil
		}

		if action.Duration > 0 {
			body.WriteString(" (")
			body.WriteString(exfmt.Duration(time.Duration(action.Duration) * time.Second))
			body.WriteString(")")
		}

		res := tc.main.Bridge.QueueRemoteEvent(tc.userLogin, &simplevent.Message[any]{
			EventMeta: eventMeta.WithType(bridgev2.RemoteEventMessage),
			ID:        ids.GetMessageIDFromMessage(msg),
			ConvertMessageFunc: func(ctx context.Context, portal *bridgev2.Portal, intent bridgev2.MatrixAPI, data any) (*bridgev2.ConvertedMessage, error) {
				return &bridgev2.ConvertedMessage{
					Parts: []*bridgev2.ConvertedMessagePart{
						{
							Type:    event.EventMessage,
							Content: &event.MessageEventContent{MsgType: event.MsgNotice, Body: body.String()},
						},
					},
				}, nil
			},
		})
		return resultToError(res)
	case *tg.MessageActionGroupCall:
		var body string
		if action.Duration == 0 {
			body = "Started a video chat"
		} else {
			body = fmt.Sprintf("Ended the video chat (%s)", exfmt.Duration(time.Duration(action.Duration)*time.Second))
		}

		res := tc.main.Bridge.QueueRemoteEvent(tc.userLogin, &simplevent.Message[any]{
			EventMeta: eventMeta.WithType(bridgev2.RemoteEventMessage),
			ID:        ids.GetMessageIDFromMessage(msg),
			ConvertMessageFunc: func(ctx context.Context, portal *bridgev2.Portal, intent bridgev2.MatrixAPI, data any) (*bridgev2.ConvertedMessage, error) {
				return &bridgev2.ConvertedMessage{
					Parts: []*bridgev2.ConvertedMessagePart{{
						Type: event.EventMessage,
						Content: &event.MessageEventContent{
							MsgType: event.MsgNotice,
							Body:    body,
							BeeperActionMessage: &event.BeeperActionMessage{
								Type:     event.BeeperActionMessageCall,
								CallType: event.BeeperActionMessageCallTypeVideo,
							},
						},
					}},
				}, nil
			},
		})
		return resultToError(res)
	case *tg.MessageActionInviteToGroupCall:
		var body, html strings.Builder
		var mentions event.Mentions
		body.WriteString("Invited ")
		html.WriteString("Invited ")
		for i, userID := range action.Users {
			if i > 0 {
				body.WriteString(", ")
			}

			if ghost, err := tc.main.Bridge.GetGhostByID(ctx, ids.MakeUserID(userID)); err != nil {
				return err
			} else {
				var name string
				if username, err := tc.main.Store.Username.Get(ctx, ids.PeerTypeUser, userID); err != nil {
					name = "@" + username
				} else {
					name = ghost.Name
				}

				mentions.UserIDs = append(mentions.UserIDs, ghost.Intent.GetMXID())
				body.WriteString(name)
				html.WriteString(fmt.Sprintf(`<a href="%s">@%s</a>`, ghost.Intent.GetMXID().URI().MatrixToURL(), name))
			}
		}
		body.WriteString(" to the video chat")
		html.WriteString(" to the video chat")
		res := tc.main.Bridge.QueueRemoteEvent(tc.userLogin, &simplevent.Message[any]{
			EventMeta: eventMeta.WithType(bridgev2.RemoteEventMessage),
			ID:        ids.GetMessageIDFromMessage(msg),
			ConvertMessageFunc: func(ctx context.Context, portal *bridgev2.Portal, intent bridgev2.MatrixAPI, data any) (*bridgev2.ConvertedMessage, error) {
				return &bridgev2.ConvertedMessage{
					Parts: []*bridgev2.ConvertedMessagePart{{
						Type: event.EventMessage,
						Content: &event.MessageEventContent{
							MsgType:       event.MsgNotice,
							Body:          body.String(),
							Format:        event.FormatHTML,
							FormattedBody: html.String(),
							Mentions:      &mentions,
						},
					}},
				}, nil
			},
		})
		return resultToError(res)
	case *tg.MessageActionGroupCallScheduled:
		start := time.Unix(int64(action.ScheduleDate), 0)
		res := tc.main.Bridge.QueueRemoteEvent(tc.userLogin, &simplevent.Message[any]{
			EventMeta: eventMeta.
				WithType(bridgev2.RemoteEventMessage).
				WithSender(bridgev2.EventSender{}), // Telegram shows it as not coming from a specific user
			ID: ids.GetMessageIDFromMessage(msg),
			ConvertMessageFunc: func(ctx context.Context, portal *bridgev2.Portal, intent bridgev2.MatrixAPI, data any) (*bridgev2.ConvertedMessage, error) {
				return &bridgev2.ConvertedMessage{
					Parts: []*bridgev2.ConvertedMessagePart{{
						Type: event.EventMessage,
						Content: &event.MessageEventContent{
							MsgType: event.MsgNotice,
							Body:    fmt.Sprintf("Video chat scheduled for %s", start.Format("Jan 2, 15:04")),
						},
					}},
				}, nil
			},
		})
		return resultToError(res)

	case *tg.MessageActionChatMigrateTo:
		log.Debug().
			Str("old_portal_id", string(eventMeta.PortalKey.ID)).
			Int64("channel_id", action.ChannelID).
			Msg("MessageActionChatMigrateTo")
		newPortalKey := tc.makePortalKeyFromID(ids.PeerTypeChannel, action.ChannelID, 0)
		if err := tc.migrateChat(ctx, eventMeta.PortalKey, newPortalKey); err != nil {
			log.Err(err).Msg("Failed to migrate chat to channel")
			return err
		}
		res := tc.main.Bridge.QueueRemoteEvent(tc.userLogin, &simplevent.Message[any]{
			EventMeta: eventMeta.
				WithPortalKey(newPortalKey).
				WithStreamOrder(0).
				WithType(bridgev2.RemoteEventMessage),
			ID: ids.GetMessageIDFromMessage(msg),
			ConvertMessageFunc: func(ctx context.Context, portal *bridgev2.Portal, intent bridgev2.MatrixAPI, data any) (*bridgev2.ConvertedMessage, error) {
				return &bridgev2.ConvertedMessage{
					Parts: []*bridgev2.ConvertedMessagePart{{
						Type: event.EventMessage,
						Content: &event.MessageEventContent{
							MsgType: event.MsgNotice,
							Body:    "Upgraded this group to a supergroup",
						},
					}},
				}, nil
			},
		})
		return resultToError(res)

	case *tg.MessageActionTopicCreate:
		channelPeer, _ := msg.PeerID.(*tg.PeerChannel)
		err := tc.main.Store.Topic.Add(ctx, channelPeer.GetChannelID(), msg.ID)
		if err != nil {
			return fmt.Errorf("failed to store new topic: %w", err)
		}
		res := tc.main.Bridge.QueueRemoteEvent(tc.userLogin, &simplevent.ChatResync{
			EventMeta: eventMeta.
				WithPortalKey(tc.makePortalKeyFromPeer(msg.PeerID, msg.ID)).
				WithType(bridgev2.RemoteEventChatResync).
				WithCreatePortal(true),
			GetChatInfoFunc: tc.GetChatInfo,
		})
		return resultToError(res)
	case *tg.MessageActionTopicEdit:
		// TODO specific changes?
		res := tc.main.Bridge.QueueRemoteEvent(tc.userLogin, &simplevent.ChatResync{
			EventMeta: eventMeta.
				WithPortalKey(tc.makePortalKeyFromPeer(msg.PeerID, msg.ID)).
				WithType(bridgev2.RemoteEventChatResync).
				WithCreatePortal(true),
			GetChatInfoFunc: tc.GetChatInfo,
		})
		return resultToError(res)

	default:
		log.Warn().
			Type("action_type", action).
			Msg("ignoring unknown action type")
		return nil
	}
}

func (tc *TelegramClient) migrateChat(ctx context.Context, oldPortalKey, newPortalKey networkid.PortalKey) error {
	if tc.main.Config.AlwaysTombstoneOnSupergroupMigration {
		newPortal, err := tc.main.Bridge.GetPortalByKey(ctx, newPortalKey)
		if err != nil {
			return fmt.Errorf("failed to get new portal for chat migration: %w", err)
		}
		info, err := tc.GetChatInfo(ctx, newPortal)
		if err != nil {
			return fmt.Errorf("failed to get chat info for new portal: %w", err)
		}
		err = newPortal.CreateMatrixRoom(ctx, tc.userLogin, info)
		if err != nil {
			return fmt.Errorf("failed to create Matrix room for new portal: %w", err)
		}
	}

	result, portal, err := tc.main.Bridge.ReIDPortal(ctx, oldPortalKey, newPortalKey)
	if err != nil {
		return fmt.Errorf("failed to re-ID portal: %w", err)
	} else if result == bridgev2.ReIDResultSourceReIDd || result == bridgev2.ReIDResultTargetDeletedAndSourceReIDd {
		// If the source portal is re-ID'd, we need to sync metadata and participants.
		// If the source is deleted, then it doesn't matter, any existing target will already be correct
		info, err := tc.GetChatInfo(ctx, portal)
		if err != nil {
			zerolog.Ctx(ctx).Err(err).Msg("Failed to get chat info after re-ID")
			if tgerr.Is(err, tg.ErrChannelPrivate) {
				go func() {
					select {
					case <-time.After(5 * time.Second):
					case <-ctx.Done():
						return
					}
					zerolog.Ctx(ctx).Debug().Msg("Retrying GetChatInfo after re-ID")
					info, err := tc.GetChatInfo(ctx, portal)
					if err != nil {
						zerolog.Ctx(ctx).Err(err).Msg("Failed to get chat info after re-ID retry")
					} else {
						portal.UpdateInfo(ctx, info, tc.userLogin, nil, time.Time{})
					}
				}()
			}
		} else {
			portal.UpdateInfo(ctx, info, tc.userLogin, nil, time.Time{})
		}
	}
	return nil
}

func (tc *TelegramClient) getEventSender(msg interface {
	GetOut() bool
	GetFromID() (tg.PeerClass, bool)
	GetPeerID() tg.PeerClass
}, isBroadcastChannel bool) bridgev2.EventSender {
	if isBroadcastChannel && msg.GetPeerID().TypeID() == tg.PeerChannelTypeID {
		// Always send as the channel in broadcast channels. We set a
		// per-message profile to indicate the actual user it was from.
		return tc.getPeerSender(msg.GetPeerID())
	}

	if msg.GetOut() {
		return tc.mySender()
	}

	peer, ok := msg.GetFromID()
	if !ok {
		peer = msg.GetPeerID()
	}
	return tc.getPeerSender(peer)
}

func (tc *TelegramClient) getPeerSender(peer tg.PeerClass) bridgev2.EventSender {
	switch from := peer.(type) {
	case *tg.PeerUser:
		return tc.senderForUserID(from.UserID)
	case *tg.PeerChannel:
		return bridgev2.EventSender{
			Sender: ids.MakeChannelUserID(from.ChannelID),
		}
	default:
		panic(fmt.Sprintf("couldn't determine sender (peer: %+v)", peer))
	}
}

func (tc *TelegramClient) onUserName(ctx context.Context, e tg.Entities, update *tg.UpdateUserName) error {
	ghost, err := tc.main.Bridge.GetGhostByID(ctx, ids.MakeUserID(update.UserID))
	if err != nil {
		return err
	}
	meta := ghost.Metadata.(*GhostMetadata)

	var userInfo bridgev2.UserInfo

	var firstUsername string
	if len(update.Usernames) > 0 {
		for _, ident := range ghost.Identifiers {
			if !strings.HasPrefix(ident, "telegram:") {
				userInfo.Identifiers = append(userInfo.Identifiers, ident)
			}
		}

		for i, username := range update.Usernames {
			if i == 0 {
				firstUsername = username.Username
			}
			userInfo.Identifiers = append(userInfo.Identifiers, fmt.Sprintf("telegram:%s", username.Username))
		}

		slices.Sort(userInfo.Identifiers)
		userInfo.Identifiers = slices.Compact(userInfo.Identifiers)
	}

	name := tc.main.Config.FormatDisplayname(update.FirstName, update.LastName, firstUsername, false, update.UserID)
	userInfo.Name = &name
	if meta.ContactSource != 0 && meta.ContactSource != tc.telegramUserID && !tc.main.Config.ContactNames {
		// TODO fetch full info to accurately detect if the user is a contact or not
		userInfo.Name = nil
	}

	ghost.UpdateInfo(ctx, &userInfo)
	if ghost.ID == tc.userID {
		var firstUsername string
		if len(update.Usernames) > 0 {
			firstUsername = update.Usernames[0].Username
		}
		tc.updateRemoteProfile(ctx, &tg.User{
			Self:      true,
			ID:        update.UserID,
			FirstName: update.FirstName,
			LastName:  update.LastName,
			Username:  firstUsername,
			Usernames: update.Usernames,
		}, ghost)
	}

	return nil
}

func (tc *TelegramClient) onDeleteMessages(ctx context.Context, channelID int64, update IGetMessages) error {
	for _, messageID := range update.GetMessages() {
		wrappedMessageID := ids.MakeMessageID(channelID, messageID)
		var portalKey networkid.PortalKey
		var ok bool
		if portalKey, ok = tc.recentMessageRooms.Get(wrappedMessageID); ok {
			// key found in cache
		} else if parts, err := tc.main.Bridge.DB.Message.GetAllPartsByID(ctx, tc.loginID, wrappedMessageID); err != nil {
			return err
		} else if len(parts) > 0 {
			portalKey = parts[0].Room
		} else if channelID != 0 {
			// This won't work for topics, but should work for any other channels
			portalKey = tc.makePortalKeyFromPeer(&tg.PeerChannel{ChannelID: channelID}, 0)
		} else {
			zerolog.Ctx(ctx).Debug().
				Int("message_id", messageID).
				Msg("Ignoring delete of unknown message")
			continue
		}
		res := tc.main.Bridge.QueueRemoteEvent(tc.userLogin, &simplevent.MessageRemove{
			EventMeta: simplevent.EventMeta{
				Type:      bridgev2.RemoteEventMessageRemove,
				PortalKey: portalKey,
			},
			TargetMessage: wrappedMessageID,
		})
		if err := resultToError(res); err != nil {
			return err
		}
	}
	return nil
}

func (tc *TelegramClient) updateGhost(ctx context.Context, userID int64, user *tg.User) (*bridgev2.UserInfo, error) {
	ghost, err := tc.main.Bridge.GetGhostByID(ctx, ids.MakeUserID(userID))
	if err != nil {
		return nil, err
	}
	userInfo, err := tc.wrapUserInfo(ctx, user, ghost)
	if err != nil {
		return nil, err
	}
	ghost.UpdateInfo(ctx, userInfo)

	if !user.Min && ghost.ID == tc.userID && tc.updateRemoteProfile(ctx, user, ghost) {
		tc.userLogin.BridgeState.Send(status.BridgeState{StateEvent: status.StateConnected})
	}

	return userInfo, nil
}

func (tc *TelegramClient) updateChannel(ctx context.Context, channel *tg.Channel) (*bridgev2.UserInfo, error) {
	// TODO resync portal metadata?
	userInfo, err := tc.wrapChannelGhostInfo(ctx, channel)
	if err != nil {
		return nil, err
	}
	ghost, err := tc.main.Bridge.GetGhostByID(ctx, ids.MakeChannelUserID(channel.ID))
	if err != nil {
		return nil, err
	}
	ghost.UpdateInfo(ctx, userInfo)
	return userInfo, nil
}

const updateHandlerStuck status.BridgeStateErrorCode = "tg-update-handler-stuck"

func (tc *TelegramClient) onUpdateWrapper(ctx context.Context, e tg.Entities, upd tg.UpdateClass) error {
	doneChan := make(chan error, 1)
	go func() {
		doneChan <- tc.onUpdate(ctx, e, upd)
	}()
	ticker := time.NewTicker(10 * time.Second)
	defer ticker.Stop()
	startedAt := time.Now()
	bridgeStateUpdated := false
	for {
		select {
		case <-ticker.C:
			zerolog.Ctx(ctx).Warn().
				Time("started_at", startedAt).
				Msg("Telegram update handling is taking long")
			if time.Since(startedAt) > 3*time.Minute && !bridgeStateUpdated {
				bridgeStateUpdated = true
				tc.userLogin.BridgeState.Send(status.BridgeState{
					StateEvent: status.StateUnknownError,
					Error:      updateHandlerStuck,
					Message:    "Processing messages from Telegram is stuck",
				})
			}
		case err := <-doneChan:
			if bridgeStateUpdated && tc.userLogin.BridgeState.GetPrevUnsent().Error == updateHandlerStuck {
				tc.userLogin.BridgeState.Send(status.BridgeState{
					StateEvent: status.StateConnected,
					Info: map[string]any{
						"update_reason": "finished processing slow update",
					},
				})
			}
			return err
		}
	}
}

func (tc *TelegramClient) onUpdate(ctx context.Context, e tg.Entities, upd tg.UpdateClass) error {
	zerolog.Ctx(ctx).Trace().Stringer("update", upd).Msg("Raw update")
	for userID, user := range e.Users {
		zerolog.Ctx(ctx).Trace().Stringer("user", user).Msg("Raw user info in update")
		if _, err := tc.updateGhost(ctx, userID, user); err != nil {
			return err
		}
	}
	for chatID, chat := range e.Chats {
		zerolog.Ctx(ctx).Trace().Stringer("chat", chat).Msg("Raw chat info in update")
		if chat.GetLeft() {
			// TODO don't ignore errors
			tc.selfLeaveChat(ctx, tc.makePortalKeyFromID(ids.PeerTypeChat, chatID, 0), fmt.Errorf("left flag in entity update"))
		}
	}
	for _, channel := range e.Channels {
		zerolog.Ctx(ctx).Trace().Stringer("channel", channel).Msg("Raw channel info in update")
		if channel.GetLeft() {
			tc.selfLeaveChat(ctx, tc.makePortalKeyFromID(ids.PeerTypeChannel, channel.ID, 0), fmt.Errorf("left flag in entity update"))
		}
		if _, err := tc.updateChannel(ctx, channel); err != nil {
			return err
		}
	}
	switch update := upd.(type) {
	case *tg.UpdateNewMessage:
		return tc.onUpdateNewMessage(ctx, e, update)
	case *tg.UpdateNewChannelMessage:
		return tc.onUpdateNewMessage(ctx, e, update)
	case *tg.UpdateChannel:
		return tc.onUpdateChannel(ctx, e, update)
	case *tg.UpdateUserName:
		return tc.onUserName(ctx, e, update)
	case *tg.UpdateDeleteMessages:
		return tc.onDeleteMessages(ctx, 0, update)
	case *tg.UpdateDeleteChannelMessages:
		return tc.onDeleteMessages(ctx, update.ChannelID, update)
	case *tg.UpdateEditMessage:
		return tc.onMessageEdit(ctx, update)
	case *tg.UpdateEditChannelMessage:
		return tc.onMessageEdit(ctx, update)
	case *tg.UpdateMessageReactions:
		return tc.onMessageReactions(ctx, update)
	case *tg.UpdateUserTyping:
		return tc.handleTyping(tc.makePortalKeyFromID(ids.PeerTypeUser, update.UserID, 0), tc.senderForUserID(update.UserID), update.Action)
	case *tg.UpdateChatUserTyping:
		if update.FromID.TypeID() != tg.PeerUserTypeID {
			zerolog.Ctx(ctx).Warn().Str("from_id_type", update.FromID.TypeName()).Msg("unsupported from_id type")
			return nil
		}
		return tc.handleTyping(tc.makePortalKeyFromID(ids.PeerTypeChat, update.ChatID, 0), tc.getPeerSender(update.FromID), update.Action)
	case *tg.UpdateChannelUserTyping:
		return tc.handleTyping(tc.makePortalKeyFromID(ids.PeerTypeChannel, update.ChannelID, update.TopMsgID), tc.getPeerSender(update.FromID), update.Action)
	case *tg.UpdateReadHistoryOutbox:
		return tc.updateReadReceipt(ctx, e, update)
	case *tg.UpdateReadHistoryInbox:
		return tc.onOwnReadReceipt(tc.makePortalKeyFromPeer(update.Peer, update.TopMsgID), update.MaxID)
	case *tg.UpdateReadChannelInbox:
		return tc.onOwnReadReceipt(tc.makePortalKeyFromID(ids.PeerTypeChannel, update.ChannelID, 0), update.MaxID)
	case *tg.UpdateNotifySettings:
		return tc.onNotifySettings(ctx, e, update)
	case *tg.UpdatePinnedDialogs:
		return tc.onPinnedDialogs(ctx, e, update)
	case *tg.UpdateChatDefaultBannedRights:
		return tc.onChatDefaultBannedRights(ctx, e, update)
	case *tg.UpdatePeerBlocked:
		return tc.onPeerBlocked(ctx, e, update)
	case *tg.UpdateChat:
		return tc.onChat(ctx, e, update)
	case *tg.UpdatePhoneCall:
		return tc.onPhoneCall(ctx, e, update)
	case *tg.UpdateUserStatus:
		// ignored
		return nil
	default:
		zerolog.Ctx(ctx).Debug().Type("update_type", update).Msg("Unhandled update type")
		return nil
	}
}

func (tc *TelegramClient) onMessageReactions(ctx context.Context, update *tg.UpdateMessageReactions) error {
	return tc.handleTelegramReactions(ctx, update.Peer, update.TopMsgID, update.MsgID, update.Reactions, "updateMessageReactions")
}

func (tc *TelegramClient) onMessageEdit(ctx context.Context, update IGetMessage) error {
	msg, ok := update.GetMessage().(*tg.Message)
	if !ok {
		zerolog.Ctx(ctx).Warn().
			Str("type_name", update.GetMessage().TypeName()).
			Msg("edit message is not *tg.Message")
		return nil
	}

	topicID := tc.getTopicID(ctx, msg.PeerID, msg.ReplyTo)
	// Channels don't use edits to signal reactions, and when sending the first reaction they send a no-op edit
	// with an empty reactions list, which would confuse the handle method. Therefore, just don't sync reactions
	// on channel message edits.
	if _, isChannel := msg.PeerID.(*tg.PeerChannel); !isChannel {
		err := tc.handleTelegramReactions(ctx, msg.PeerID, topicID, msg.ID, msg.Reactions, "updateMessageEdit")
		if err != nil {
			zerolog.Ctx(ctx).Err(err).Msg("Failed to handle reactions on edited message")
		}
	}

	portalKey := tc.makePortalKeyFromPeer(msg.PeerID, topicID)
	portal, err := tc.main.Bridge.GetPortalByKey(ctx, portalKey)
	if err != nil {
		return err
	}
	sender := tc.getEventSender(msg, !portal.Metadata.(*PortalMetadata).IsSuperGroup)

	// Check if this edit was a data export request acceptance message
	if sender.Sender == networkid.UserID("777000") {
		if strings.Contains(msg.Message, "Data export request") && strings.Contains(msg.Message, "Accepted") {
			zerolog.Ctx(ctx).Info().
				Int("message_id", msg.ID).
				Msg("Received an edit to message that looks like the data export was accepted, marking takeout as retriable")
			tc.takeoutAccepted.Set()
		}
		// TODO detect takeout being rejected too
	}

	res := tc.main.Bridge.QueueRemoteEvent(tc.userLogin, &simplevent.Message[*tg.Message]{
		EventMeta: simplevent.EventMeta{
			Type: bridgev2.RemoteEventEdit,
			LogContext: func(c zerolog.Context) zerolog.Context {
				return c.
					Str("action", "edit_message").
					Str("conversion_direction", "to_matrix").
					Int("message_id", msg.ID)
			},
			Sender:    sender,
			PortalKey: portalKey,
			Timestamp: time.Unix(int64(msg.EditDate), 0),
		},
		ID:            ids.GetMessageIDFromMessage(msg),
		TargetMessage: ids.GetMessageIDFromMessage(msg),
		Data:          msg,
		ConvertEditFunc: func(ctx context.Context, portal *bridgev2.Portal, intent bridgev2.MatrixAPI, existing []*database.Message, data *tg.Message) (*bridgev2.ConvertedEdit, error) {
			log := zerolog.Ctx(ctx)
			converted, err := tc.convertToMatrix(ctx, portal, intent, msg)
			if err != nil {
				return nil, err
			}
			convertedPart := converted.Parts[0]

			existingPart := existing[0]
			if len(existing) > 1 {
				log.Warn().Msg("Multiple parts found, using the first one that has a nonzero timestamp")
				for _, e := range existing {
					if !e.Timestamp.IsZero() {
						existingPart = e
						break
					}
				}
			}

			if bytes.Equal(existingPart.Metadata.(*MessageMetadata).ContentHash, convertedPart.DBMetadata.(*MessageMetadata).ContentHash) {
				return nil, fmt.Errorf("%w (content hash didn't change)", bridgev2.ErrIgnoringRemoteEvent)
			}
			editPart := convertedPart.ToEditPart(existingPart)
			if data.EditHide {
				if editPart.TopLevelExtra == nil {
					editPart.TopLevelExtra = make(map[string]any)
				}
				editPart.TopLevelExtra["com.beeper.dont_render_edited"] = true
			}
			return &bridgev2.ConvertedEdit{
				ModifiedParts: []*bridgev2.ConvertedEditPart{editPart},
			}, nil
		},
	})
	return resultToError(res)
}

func (tc *TelegramClient) handleTyping(portal networkid.PortalKey, sender bridgev2.EventSender, action tg.SendMessageActionClass) error {
	if sender.IsFromMe || (sender.Sender == tc.userID && sender.SenderLogin == tc.userLogin.ID) {
		return nil
	}
	timeout := time.Duration(6) * time.Second
	var typingType bridgev2.TypingType
	switch action.(type) {
	case *tg.SendMessageTypingAction:
		typingType = bridgev2.TypingTypeText
	case *tg.SendMessageRecordAudioAction, *tg.SendMessageRecordRoundAction, *tg.SendMessageRecordVideoAction:
		typingType = bridgev2.TypingTypeRecordingMedia
	case *tg.SendMessageUploadAudioAction, *tg.SendMessageUploadDocumentAction, *tg.SendMessageUploadPhotoAction, *tg.SendMessageUploadRoundAction, *tg.SendMessageUploadVideoAction:
		typingType = bridgev2.TypingTypeUploadingMedia
	case *tg.SendMessageCancelAction:
		timeout = 0
	default:
		timeout = 0
	}
	res := tc.main.Bridge.QueueRemoteEvent(tc.userLogin, &simplevent.Typing{
		EventMeta: simplevent.EventMeta{
			Type:      bridgev2.RemoteEventTyping,
			PortalKey: portal,
			Sender:    sender,
			LogContext: func(c zerolog.Context) zerolog.Context {
				return c.Str("tg_event", "update*Typing")
			},
		},
		Timeout: timeout,
		Type:    typingType,
	})
	return resultToError(res)
}

func (tc *TelegramClient) updateReadReceipt(ctx context.Context, e tg.Entities, update *tg.UpdateReadHistoryOutbox) error {
	user, ok := update.Peer.(*tg.PeerUser)
	if !ok {
		// Read receipts from other users are meaningless in chats/channels
		// (they only say "someone read the message" and not who)
		return nil
	}
	res := tc.main.Bridge.QueueRemoteEvent(tc.userLogin, &simplevent.Receipt{
		EventMeta: simplevent.EventMeta{
			Type:      bridgev2.RemoteEventReadReceipt,
			PortalKey: tc.makePortalKeyFromPeer(update.Peer, 0),
			Sender: bridgev2.EventSender{
				SenderLogin: ids.MakeUserLoginID(user.UserID),
				Sender:      ids.MakeUserID(user.UserID),
			},
			LogContext: func(c zerolog.Context) zerolog.Context {
				return c.Str("tg_event", "updateReadHistoryOutbox")
			},
		},
		LastTarget:          ids.MakeMessageID(update.Peer, update.MaxID),
		ReadUpToStreamOrder: int64(update.MaxID),
	})
	return resultToError(res)
}

func (tc *TelegramClient) onOwnReadReceipt(portalKey networkid.PortalKey, maxID int) error {
	res := tc.main.Bridge.QueueRemoteEvent(tc.userLogin, &simplevent.Receipt{
		EventMeta: simplevent.EventMeta{
			Type:      bridgev2.RemoteEventReadReceipt,
			PortalKey: portalKey,
			Sender:    tc.mySender(),
			LogContext: func(c zerolog.Context) zerolog.Context {
				return c.Str("tg_event", "updateRead*Inbox")
			},
		},
		LastTarget:          ids.MakeMessageID(portalKey, maxID),
		ReadUpToStreamOrder: int64(maxID),
	})
	return resultToError(res)
}

func (tc *TelegramClient) inputPeerForPortalID(ctx context.Context, portalID networkid.PortalID) (tg.InputPeerClass, int, error) {
	peerType, id, topicID, err := ids.ParsePortalID(portalID)
	if err != nil {
		return nil, 0, err
	}
	switch peerType {
	case ids.PeerTypeUser:
		if accessHash, err := tc.ScopedStore.GetAccessHash(ctx, ids.PeerTypeUser, id); err != nil {
			return nil, 0, fmt.Errorf("failed to get user access hash for %d: %w", id, err)
		} else {
			return &tg.InputPeerUser{UserID: id, AccessHash: accessHash}, 0, nil
		}
	case ids.PeerTypeChat:
		return &tg.InputPeerChat{ChatID: id}, 0, nil
	case ids.PeerTypeChannel:
		if accessHash, err := tc.ScopedStore.GetAccessHash(ctx, ids.PeerTypeChannel, id); err != nil {
			return nil, 0, err
		} else {
			return &tg.InputPeerChannel{ChannelID: id, AccessHash: accessHash}, topicID, nil
		}
	default:
		panic("invalid peer type")
	}
}

func (tc *TelegramClient) getAppConfigCached(ctx context.Context) (map[string]any, error) {
	if tc.metadata.IsBot {
		return nil, nil
	}
	tc.appConfigLock.Lock()
	defer tc.appConfigLock.Unlock()
	if tc.appConfig == nil {
		cfg, err := tc.client.API().HelpGetAppConfig(ctx, tc.appConfigHash)
		if err != nil {
			return nil, err
		}
		appConfig, ok := cfg.(*tg.HelpAppConfig)
		if !ok {
			return nil, fmt.Errorf("failed to get app config: unexpected type %T", appConfig)
		}
		parsedConfig, err := tljson.Parse(appConfig.Config)
		if err != nil {
			return nil, err
		}
		tc.appConfig, ok = parsedConfig.(map[string]any)
		if !ok {
			return nil, fmt.Errorf("failed to parse app config: unexpected type %T", tc.appConfig)
		}
		tc.appConfigHash = appConfig.Hash
	}
	return tc.appConfig, nil
}

func (tc *TelegramClient) getAvailableReactionsForCapability(ctx context.Context) ([]string, bool) {
	_, err := tc.getAvailableReactions(ctx)
	if err != nil {
		zerolog.Ctx(ctx).Warn().Err(err).Msg("Failed to get available reactions for capability listing")
	}
	return tc.availableReactionsList, tc.isPremiumCache.Load()
}

func (tc *TelegramClient) getAvailableReactions(ctx context.Context) (map[string]struct{}, error) {
	if tc.metadata.IsBot {
		return nil, nil
	} else if !tc.IsLoggedIn() {
		return nil, errors.New("you must be logged in to get available reactions")
	}

	log := zerolog.Ctx(ctx).With().Str("handler", "get_available_reactions").Logger()
	tc.availableReactionsLock.Lock()
	defer tc.availableReactionsLock.Unlock()
	if tc.availableReactions == nil || time.Since(tc.availableReactionsFetched) > 12*time.Hour {
		cfg, err := tc.client.API().MessagesGetAvailableReactions(ctx, tc.availableReactionsHash)
		if err != nil {
			return nil, err
		}
		tc.availableReactionsFetched = time.Now()
		switch v := cfg.(type) {
		case *tg.MessagesAvailableReactions:
			availableReactions, ok := cfg.(*tg.MessagesAvailableReactions)
			if !ok {
				return nil, fmt.Errorf("failed to get app config: unexpected type %T", availableReactions)
			}

			log.Debug().Msg("Fetched new available reactions")

			myGhost, err := tc.main.Bridge.GetGhostByID(ctx, tc.userID)
			if err != nil {
				log.Err(err).Msg("failed to get own ghost")
			}
			tc.availableReactions = make(map[string]struct{}, len(availableReactions.Reactions))
			for _, reaction := range availableReactions.Reactions {
				if !reaction.Inactive && (myGhost.Metadata.(*GhostMetadata).IsPremium || !reaction.Premium) {
					tc.availableReactions[reaction.Reaction] = struct{}{}
				}
			}

			tc.availableReactionsHash = availableReactions.Hash
			if myGhost.Metadata.(*GhostMetadata).IsPremium {
				// All reactions are allowed via the unicodemojipack feature
				tc.availableReactionsList = nil
				tc.isPremiumCache.Store(true)
			} else {
				tc.availableReactionsList = maps.Keys(tc.availableReactions)
				tc.isPremiumCache.Store(false)
				slices.Sort(tc.availableReactionsList)
			}
		case *tg.MessagesAvailableReactionsNotModified:
			log.Debug().Msg("Available reactions not modified")
		default:
			log.Error().Type("reaction_type", v).Msg("failed to get available reactions: unexpected type")
		}
	}
	return tc.availableReactions, nil
}

func (tc *TelegramClient) transferEmojisToMatrix(ctx context.Context, customEmojiIDs []int64) (result map[networkid.EmojiID]emojis.EmojiInfo, err error) {
	result, customEmojiIDs = emojis.ConvertKnownEmojis(customEmojiIDs)

	if len(customEmojiIDs) == 0 {
		return
	}

	if tc.main.useDirectMedia {
		for _, emojiID := range customEmojiIDs {
			mediaID, err := ids.DirectMediaInfo{
				PeerType: ids.FakePeerTypeEmoji,
				UserID:   tc.telegramUserID,
				ID:       emojiID,
			}.AsMediaID()
			if err != nil {
				return nil, err
			}
			if mxcURI, err := tc.main.Bridge.Matrix.GenerateContentURI(ctx, mediaID); err != nil {
				return nil, err
			} else {
				result[ids.MakeEmojiIDFromDocumentID(emojiID)] = emojis.EmojiInfo{EmojiURI: mxcURI, DocumentID: emojiID}
			}
		}

		return result, nil
	}

	missingCustomEmojiIDs := customEmojiIDs[:0]
	for _, emojiID := range customEmojiIDs {
		file, err := tc.main.Store.TelegramFile.GetByLocationID(ctx, store.TelegramFileLocationID(strconv.FormatInt(emojiID, 10)))
		if err != nil {
			return nil, fmt.Errorf("failed to get file for custom emoji %d: %w", emojiID, err)
		} else if file != nil {
			result[ids.MakeEmojiIDFromDocumentID(emojiID)] = emojis.EmojiInfo{EmojiURI: file.MXC, DocumentID: emojiID}
		} else {
			missingCustomEmojiIDs = append(missingCustomEmojiIDs, emojiID)
		}
	}
	if len(missingCustomEmojiIDs) == 0 {
		return
	}

	customEmojiDocuments, err := tc.client.API().MessagesGetCustomEmojiDocuments(ctx, missingCustomEmojiIDs)
	if err != nil {
		return nil, err
	}

	for _, customEmojiDocument := range customEmojiDocuments {
		mxcURI, _, _, err := media.NewTransferer(tc.client.API()).
			WithStickerConfig(tc.main.Config.AnimatedSticker).
			WithForceWebmStickerConvert(true).
			WithDocument(customEmojiDocument, false).
			Transfer(ctx, tc.main.Store, tc.main.Bridge.Bot)
		if err != nil {
			return nil, err
		}
		result[ids.MakeEmojiIDFromDocumentID(customEmojiDocument.GetID())] = emojis.EmojiInfo{EmojiURI: mxcURI, DocumentID: customEmojiDocument.GetID()}
	}
	return
}

func (tc *TelegramClient) onNotifySettings(ctx context.Context, e tg.Entities, update *tg.UpdateNotifySettings) error {
	var portalKey networkid.PortalKey
	switch typedPeer := update.Peer.(type) {
	case *tg.NotifyPeer:
		portalKey = tc.makePortalKeyFromPeer(typedPeer.Peer, 0)
	case *tg.NotifyForumTopic:
		portalKey = tc.makePortalKeyFromPeer(typedPeer.Peer, typedPeer.TopMsgID)
	default:
		zerolog.Ctx(ctx).Debug().
			Type("peer_type", update.Peer).
			Any("peer", update.Peer).
			Msg("Ignoring unsupported notify settings peer type")
		return nil
	}

	var mutedUntil *time.Time
	if mu, ok := update.NotifySettings.GetMuteUntil(); ok {
		mutedUntil = ptr.Ptr(time.Unix(int64(mu), 0))
	} else {
		mutedUntil = &bridgev2.Unmuted
	}

	res := tc.main.Bridge.QueueRemoteEvent(tc.userLogin, &simplevent.ChatInfoChange{
		ChatInfoChange: &bridgev2.ChatInfoChange{
			ChatInfo: &bridgev2.ChatInfo{
				UserLocal: &bridgev2.UserLocalPortalInfo{
					MutedUntil: mutedUntil,
				},
			},
		},
		EventMeta: simplevent.EventMeta{
			Type:      bridgev2.RemoteEventChatInfoChange,
			PortalKey: portalKey,
			LogContext: func(c zerolog.Context) zerolog.Context {
				return c.
					Str("tg_event", "updateNotifySettings").
					Time("muted_until", *mutedUntil)
			},
		},
	})
	return resultToError(res)
}

func (tc *TelegramClient) onPinnedDialogs(ctx context.Context, e tg.Entities, msg *tg.UpdatePinnedDialogs) error {
	needsUnpinning := map[networkid.PortalKey]struct{}{}
	for _, portalID := range tc.metadata.PinnedDialogs {
		pt, id, _, err := ids.ParsePortalID(portalID)
		if err != nil {
			return err
		}
		needsUnpinning[tc.makePortalKeyFromID(pt, id, 0)] = struct{}{}
	}
	tc.metadata.PinnedDialogs = nil

	for _, d := range msg.Order {
		dialog, ok := d.(*tg.DialogPeer)
		if !ok {
			continue
		}
		portalKey := tc.makePortalKeyFromPeer(dialog.Peer, 0)
		delete(needsUnpinning, portalKey)
		tc.metadata.PinnedDialogs = append(tc.metadata.PinnedDialogs, portalKey.ID)

		res := tc.main.Bridge.QueueRemoteEvent(tc.userLogin, &simplevent.ChatInfoChange{
			ChatInfoChange: &bridgev2.ChatInfoChange{
				ChatInfo: &bridgev2.ChatInfo{
					UserLocal: &bridgev2.UserLocalPortalInfo{
						Tag: ptr.Ptr(event.RoomTagFavourite),
					},
				},
			},
			EventMeta: simplevent.EventMeta{
				Type:      bridgev2.RemoteEventChatInfoChange,
				PortalKey: portalKey,
				LogContext: func(c zerolog.Context) zerolog.Context {
					return c.
						Str("tg_event", "updatePinnedDialogs").
						Bool("pinned", true)
				},
			},
		})
		if err := resultToError(res); err != nil {
			return err
		}
	}

	var empty event.RoomTag
	for portalKey := range needsUnpinning {
		res := tc.main.Bridge.QueueRemoteEvent(tc.userLogin, &simplevent.ChatInfoChange{
			ChatInfoChange: &bridgev2.ChatInfoChange{
				ChatInfo: &bridgev2.ChatInfo{
					UserLocal: &bridgev2.UserLocalPortalInfo{
						Tag: &empty,
					},
				},
			},
			EventMeta: simplevent.EventMeta{
				Type:      bridgev2.RemoteEventChatInfoChange,
				PortalKey: portalKey,
				LogContext: func(c zerolog.Context) zerolog.Context {
					return c.
						Str("tg_event", "updatePinnedDialogs").
						Bool("pinned", false)
				},
			},
		})
		if err := resultToError(res); err != nil {
			return err
		}
	}

	return tc.userLogin.Save(ctx)
}

func (tc *TelegramClient) onChatDefaultBannedRights(ctx context.Context, entities tg.Entities, update *tg.UpdateChatDefaultBannedRights) error {
	// TODO update all topic portals
	res := tc.main.Bridge.QueueRemoteEvent(tc.userLogin, &simplevent.ChatInfoChange{
		ChatInfoChange: &bridgev2.ChatInfoChange{
			ChatInfo: &bridgev2.ChatInfo{
				Members: &bridgev2.ChatMemberList{
					PowerLevels: tc.getPowerLevelOverridesFromBannedRights(entities.Chats[0], update.DefaultBannedRights),
				},
			},
		},
		EventMeta: simplevent.EventMeta{
			Type:      bridgev2.RemoteEventChatInfoChange,
			PortalKey: tc.makePortalKeyFromPeer(update.Peer, 0),
			LogContext: func(c zerolog.Context) zerolog.Context {
				return c.Str("tg_event", "updateChatDefaultBannedRights")
			},
		},
	})
	return resultToError(res)
}

func (tc *TelegramClient) onPeerBlocked(ctx context.Context, e tg.Entities, update *tg.UpdatePeerBlocked) error {
	// TODO fix this after adding storage for block status (getDMPowerLevels also needs updating)
	if true {
		return nil
	}
	var userID networkid.UserID
	if peer, ok := update.PeerID.(*tg.PeerUser); ok {
		userID = ids.MakeUserID(peer.UserID)
	} else {
		zerolog.Ctx(ctx).Warn().Type("peer_type", update.PeerID).Msg("Unexpected peer type in peer blocked update")
		return nil
	}

	// Update the ghost
	ghost, err := tc.main.Bridge.GetGhostByID(ctx, userID)
	if err != nil {
		return err
	}

	// Find portals that are DMs with the user
	res := tc.main.Bridge.QueueRemoteEvent(tc.userLogin, &simplevent.ChatResync{
		ChatInfo: &bridgev2.ChatInfo{
			Members: &bridgev2.ChatMemberList{
				PowerLevels: tc.getDMPowerLevels(ghost),
			},
			CanBackfill: true,
		},
		EventMeta: simplevent.EventMeta{
			Type:      bridgev2.RemoteEventChatResync,
			PortalKey: tc.makePortalKeyFromPeer(update.PeerID, 0),
			LogContext: func(c zerolog.Context) zerolog.Context {
				return c.Str("tg_event", "updatePeerBlocked")
			},
		},
	})
	return resultToError(res)
}

func (tc *TelegramClient) onChat(ctx context.Context, e tg.Entities, update *tg.UpdateChat) error {
	return nil
}

func (tc *TelegramClient) onPhoneCall(ctx context.Context, e tg.Entities, update *tg.UpdatePhoneCall) error {
	log := zerolog.Ctx(ctx).With().Str("action", "on_phone_call").Logger()
	call, ok := update.PhoneCall.(*tg.PhoneCallRequested)
	if !ok {
		log.Info().Type("type", update.PhoneCall).Msg("Unhandled phone call update class")
		return nil
	} else if call.ParticipantID != tc.telegramUserID {
		log.Warn().Msg("Received phone call for user that is not us")
		return nil
	}

	var callType event.BeeperActionMessageCallType
	var body strings.Builder
	body.WriteString("Started a ")
	if call.Video {
		callType = event.BeeperActionMessageCallTypeVideo
		body.WriteString("video call")
	} else {
		callType = event.BeeperActionMessageCallTypeVoice
		body.WriteString("call")
	}
	res := tc.main.Bridge.QueueRemoteEvent(tc.userLogin, &simplevent.Message[any]{
		EventMeta: simplevent.EventMeta{
			Type:         bridgev2.RemoteEventMessage,
			PortalKey:    tc.makePortalKeyFromID(ids.PeerTypeUser, call.AdminID, 0),
			CreatePortal: true,
			Sender:       tc.senderForUserID(call.AdminID),
			LogContext: func(c zerolog.Context) zerolog.Context {
				return c.Str("tg_event", "updatePhoneCall")
			},
		},
		ID: networkid.MessageID(fmt.Sprintf("requested-%d", call.ID)),
		ConvertMessageFunc: func(ctx context.Context, portal *bridgev2.Portal, intent bridgev2.MatrixAPI, data any) (*bridgev2.ConvertedMessage, error) {
			return &bridgev2.ConvertedMessage{
				Parts: []*bridgev2.ConvertedMessagePart{
					{
						Type: event.EventMessage,
						Content: &event.MessageEventContent{
							MsgType: event.MsgNotice,
							Body:    body.String(),
							BeeperActionMessage: &event.BeeperActionMessage{
								Type:     event.BeeperActionMessageCall,
								CallType: callType,
							},
						},
					},
				},
			}, nil
		},
	})
	return resultToError(res)
}
