// mautrix-telegram - 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"
	"cmp"
	"context"
	"crypto/sha256"
	"encoding/base64"
	"errors"
	"fmt"
	"image"
	"image/jpeg"
	_ "image/jpeg"
	_ "image/png"
	"io"
	"math"
	"math/rand/v2"
	"os"
	"slices"
	"strconv"
	"strings"
	"sync"
	"time"

	"github.com/rs/zerolog"
	"go.mau.fi/util/exsync"
	"go.mau.fi/util/ffmpeg"
	"go.mau.fi/util/jsontime"
	"go.mau.fi/util/variationselector"
	"go.mau.fi/webp"
	"golang.org/x/exp/maps"
	_ "golang.org/x/image/webp"
	"golang.org/x/net/html"
	"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/event"
	"maunium.net/go/mautrix/id"

	"go.mau.fi/mautrix-telegram/pkg/connector/media"
	"go.mau.fi/mautrix-telegram/pkg/gotd/telegram/message"
	"go.mau.fi/mautrix-telegram/pkg/gotd/telegram/uploader"
	"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
	"go.mau.fi/mautrix-telegram/pkg/gotd/tgerr"

	"go.mau.fi/mautrix-telegram/pkg/connector/emojis"
	"go.mau.fi/mautrix-telegram/pkg/connector/humanise"
	"go.mau.fi/mautrix-telegram/pkg/connector/ids"
	"go.mau.fi/mautrix-telegram/pkg/connector/matrixfmt"
	"go.mau.fi/mautrix-telegram/pkg/connector/waveform"
)

var (
	_ bridgev2.EditHandlingNetworkAPI           = (*TelegramClient)(nil)
	_ bridgev2.ReactionHandlingNetworkAPI       = (*TelegramClient)(nil)
	_ bridgev2.RedactionHandlingNetworkAPI      = (*TelegramClient)(nil)
	_ bridgev2.ReadReceiptHandlingNetworkAPI    = (*TelegramClient)(nil)
	_ bridgev2.TypingHandlingNetworkAPI         = (*TelegramClient)(nil)
	_ bridgev2.DisappearTimerChangingNetworkAPI = (*TelegramClient)(nil)
	_ bridgev2.MuteHandlingNetworkAPI           = (*TelegramClient)(nil)
	_ bridgev2.TagHandlingNetworkAPI            = (*TelegramClient)(nil)
	_ bridgev2.ChatViewingNetworkAPI            = (*TelegramClient)(nil)
	_ bridgev2.DeleteChatHandlingNetworkAPI     = (*TelegramClient)(nil)
	_ bridgev2.RoomNameHandlingNetworkAPI       = (*TelegramClient)(nil)
	_ bridgev2.RoomAvatarHandlingNetworkAPI     = (*TelegramClient)(nil)
)

func getMediaFilename(content *event.MessageEventContent) (filename string) {
	if content.FileName != "" {
		filename = content.FileName
	} else {
		filename = content.Body
	}
	if filename == "" {
		return "image.jpg" // Assume it's a JPEG image
	}
	if content.MsgType == event.MsgImage && (!strings.HasSuffix(filename, ".jpg") && !strings.HasSuffix(filename, ".jpeg") && !strings.HasSuffix(filename, ".png")) {
		if content.Info != nil && content.Info.MimeType != "" {
			return filename + "." + strings.TrimPrefix(content.Info.MimeType, "image/")
		}
		return filename + ".jpg" // Assume it's a JPEG
	}
	return filename
}

func (tc *TelegramClient) HandleMatrixViewingChat(ctx context.Context, msg *bridgev2.MatrixViewingChat) error {
	if msg.Portal == nil {
		return nil
	}
	_, _, topicID, _ := ids.ParsePortalID(msg.Portal.PortalKey.ID)
	// TODO sync topic parent space
	meta := msg.Portal.Metadata.(*PortalMetadata)
	if (topicID == 0 && !meta.FullSynced) || meta.LastSync.Add(24*time.Hour).Before(time.Now()) {
		tc.userLogin.QueueRemoteEvent(&simplevent.ChatResync{
			EventMeta: simplevent.EventMeta{
				Type:      bridgev2.RemoteEventChatResync,
				PortalKey: msg.Portal.PortalKey,
			},
			GetChatInfoFunc: tc.GetChatInfo,
		})
	}
	err := tc.maybePollForReactions(ctx, msg.Portal)
	if err != nil {
		return err
	}
	err = tc.pollSponsoredMessage(ctx, msg.Portal)
	if err != nil {
		return err
	}
	return nil
}

func (tc *TelegramClient) pollSponsoredMessage(ctx context.Context, portal *bridgev2.Portal) error {
	if tc.metadata.IsBot {
		return nil
	}
	meta := portal.Metadata.(*PortalMetadata)
	peerType, id, topicID, err := ids.ParsePortalID(portal.ID)
	if err != nil {
		return err
	} else if peerType != ids.PeerTypeChannel || meta.IsSuperGroup || topicID != 0 {
		return nil
	}
	meta.sponsoredMessageLock.Lock()
	defer meta.sponsoredMessageLock.Unlock()
	if time.Since(meta.SponsoredMessagePollTS.Time) < 5*time.Minute {
		return nil
	}
	latestMessage, err := tc.main.Bridge.DB.Message.GetLastNonFakePartAtOrBeforeTime(ctx, portal.PortalKey, time.Now())
	if err != nil {
		return fmt.Errorf("failed to get latest message for portal: %w", err)
	} else if latestMessage != nil && latestMessage.ID == meta.LastMessageOnSponsorFetch {
		meta.SponsoredMessagePollTS = jsontime.UnixNow()
		return nil
	}
	accessHash, err := tc.ScopedStore.GetAccessHash(ctx, ids.PeerTypeChannel, id)
	if err != nil {
		return err
	}
	resp, err := tc.client.API().MessagesGetSponsoredMessages(ctx, &tg.MessagesGetSponsoredMessagesRequest{
		Peer: &tg.InputPeerChannel{ChannelID: id, AccessHash: accessHash},
	})
	if err != nil {
		return fmt.Errorf("failed to get sponsored messages: %w", err)
	}
	meta.SponsoredMessagePollTS = jsontime.UnixNow()
	if latestMessage != nil {
		meta.LastMessageOnSponsorFetch = latestMessage.ID
	}
	msgs, ok := resp.(*tg.MessagesSponsoredMessages)
	if !ok || len(msgs.Messages) == 0 || (len(msgs.Messages) == 1 && bytes.Equal(msgs.Messages[0].RandomID, meta.SponsoredMessageRandomID)) {
		err = portal.Save(ctx)
		if err != nil {
			return fmt.Errorf("failed to save portal after polling sponsored messages: %w", err)
		}
		return nil
	}
	if meta.sponsoredMessageSeen == nil {
		meta.sponsoredMessageSeen = exsync.NewSet[int64]()
	} else {
		meta.sponsoredMessageSeen.Clear()
	}
	msg := msgs.Messages[0]
	if bytes.Equal(msg.RandomID, meta.SponsoredMessageRandomID) && len(msgs.Messages) > 1 {
		msg = msgs.Messages[1]
	}
	meta.SponsoredMessageRandomID = msg.RandomID
	content := tc.parseBodyAndHTML(ctx, msg.Message, msg.Entities)
	content.MsgType = event.MsgNotice
	content.EnsureHasHTML()
	extra := map[string]any{
		"external_url": msg.URL,
		"fi.mau.telegram.sponsored": map[string]any{
			"random_id":       msg.RandomID,
			"url":             msg.URL,
			"button_text":     msg.ButtonText,
			"title":           msg.Title,
			"content":         content.FormattedBody,
			"sponsor_info":    msg.SponsorInfo,
			"additional_info": msg.AdditionalInfo,
			"recommended":     msg.Recommended,
		},
	}
	var fromStr string
	if msg.SponsorInfo != "" {
		fromStr = fmt.Sprintf(" from %s", html.EscapeString(msg.SponsorInfo))
	}
	prefix := "Ad"
	if msg.Recommended {
		prefix = "Recommended"
	}
	content.FormattedBody = fmt.Sprintf(
		`<strong>%s: %s</strong><blockquote>%s</blockquote><p>Sponsored message%s - <a href="%s">%s</a></p>`,
		prefix, html.EscapeString(msg.Title), content.FormattedBody, fromStr, msg.URL, msg.ButtonText,
	)
	sendResp, err := tc.main.Bridge.Bot.SendMessage(ctx, portal.MXID, event.EventMessage, &event.Content{
		Raw:    extra,
		Parsed: content,
	}, &bridgev2.MatrixSendExtra{Timestamp: time.Now()})
	if err != nil {
		return fmt.Errorf("failed to send sponsored message: %w", err)
	}
	oldSponsoredMessageMXID := meta.SponsoredMessageEventID
	if oldSponsoredMessageMXID != "" {
		_, err = tc.main.Bridge.Bot.SendMessage(ctx, portal.MXID, event.EventRedaction, &event.Content{
			Parsed: &event.RedactionEventContent{
				Reason:  "new sponsored message sent",
				Redacts: oldSponsoredMessageMXID,
			},
			Raw: map[string]any{
				"com.beeper.dont_render_redacted_placeholder": true,
			},
		}, &bridgev2.MatrixSendExtra{Timestamp: time.Now()})
		if err != nil {
			zerolog.Ctx(ctx).Warn().Err(err).Msg("Failed to redact old sponsored message after sending new one")
		}
	}
	meta.SponsoredMessageEventID = sendResp.EventID
	zerolog.Ctx(ctx).Debug().
		Stringer("event_id", sendResp.EventID).
		Str("random_id", base64.StdEncoding.EncodeToString(msg.RandomID)).
		Msg("Sent sponsored message to Matrix")
	err = portal.Save(ctx)
	if err != nil {
		return fmt.Errorf("failed to save portal after sending sponsored messages: %w", err)
	}
	return nil
}

func (tc *TelegramClient) transferMediaToTelegram(ctx context.Context, content *event.MessageEventContent, sticker, forceRetry, forceDocument bool) (tg.InputMediaClass, error) {
	var upload tg.InputFileClass
	filename := getMediaFilename(content)
	info := content.GetInfo()
	if sticker {
		if origFile, err := tc.findOriginalStickerDocument(ctx, info.BridgedSticker, forceRetry); err != nil {
			zerolog.Ctx(ctx).Err(err).Msg("Failed to find original sticker document, falling back to reupload")
		} else if origFile != nil {
			return origFile, nil
		}
	}
	err := tc.main.Bridge.Bot.DownloadMediaToFile(ctx, content.URL, content.File, false, func(f *os.File) (err error) {
		uploadFilename := f.Name()
		if sticker && (info.MimeType == "image/png" || info.MimeType == "image/jpeg") {
			tempFile, err := os.CreateTemp("", "telegram-sticker-*.webp")
			if err != nil {
				return err
			}
			defer func() {
				_ = tempFile.Close()
				_ = os.Remove(tempFile.Name())
			}()
			if img, _, err := image.Decode(f); err != nil {
				return fmt.Errorf("failed to decode sticker image: %w", err)
			} else if err := webp.Encode(tempFile, img, nil); err != nil {
				return fmt.Errorf("failed to encode sticker webp image: %w", err)
			}
			uploadFilename = tempFile.Name()
			info.MimeType = "image/webp"
		} else if sticker && (info.MimeType != "video/webm" && info.MimeType != "application/x-tgsticker") {
			uploadFilename, err = ffmpeg.ConvertPath(ctx, uploadFilename, ".webp", []string{}, []string{}, false)
			if err != nil {
				return fmt.Errorf("failed to convert sticker to webm: %w", err)
			}
			defer os.Remove(uploadFilename)
			info.MimeType = "image/webp"
		} else if sticker && info.MimeType == "video/lottie+json" {
			uploadFilename, err = media.CompressGZip(f)
			if err != nil {
				return fmt.Errorf("failed to compress lottie sticker: %w", err)
			}
			defer os.Remove(uploadFilename)
			info.MimeType = "application/x-tgsticker"
		} else if cfg, _, err := image.DecodeConfig(f); err != nil {
			forceDocument = true
		} else if fileInfo, err := f.Stat(); err != nil {
			return err
		} else {
			// Telegram restricts photos in the following ways according to:
			// https://core.telegram.org/tdlib/docs/classtd_1_1td__api_1_1input_message_photo.html#ae1229ec5026a0b29dc398d87211bf572
			//
			// * The photo must be at most 10 MB in size.
			// * The photo's width and height must not exceed 10,000 in total
			// * Width and height ratio must be at most 20.
			//
			// We also have the image_as_file_pixels configuration threshold to
			// prevent Telegram from compressing the file.
			aspectRatio := float64(max(cfg.Height, cfg.Width)) / float64(min(cfg.Height, cfg.Width))
			forceDocument = forceDocument ||
				cfg.Height*cfg.Width > tc.main.Config.ImageAsFilePixels ||
				fileInfo.Size() > int64(10*1024*1024) ||
				aspectRatio > 20 ||
				cfg.Height+cfg.Width > 10000
		}
		if !forceDocument && !sticker && content.MsgType == event.MsgImage {
			_, err = f.Seek(0, io.SeekStart)
			if err != nil {
				return err
			}
			tempFile, err := os.CreateTemp("", "telegram-nonsticker-*.jpeg")
			if err != nil {
				return err
			}
			defer func() {
				_ = tempFile.Close()
				_ = os.Remove(tempFile.Name())
			}()
			if img, _, err := image.Decode(f); err != nil {
				return fmt.Errorf("failed to decode non-sticker webp image: %w", err)
			} else if err := jpeg.Encode(tempFile, img, nil); err != nil {
				return fmt.Errorf("failed to encode non-sticker jpeg image: %w", err)
			}
			uploadFilename = tempFile.Name()
			filename += ".jpeg"
			info.MimeType = "image/jpeg"
		}

		upload, err = uploader.NewUploader(tc.client.API()).FromPath(ctx, uploadFilename, filename)
		return
	})
	if err != nil {
		return nil, fmt.Errorf("failed to download media from Matrix and upload media to Telegram: %w", err)
	}

	if !forceDocument && content.MsgType == event.MsgImage && (info.MimeType == "image/jpeg" || info.MimeType == "image/png") {
		return &tg.InputMediaUploadedPhoto{File: upload}, nil
	}

	var attributes []tg.DocumentAttributeClass
	attributes = append(attributes, &tg.DocumentAttributeFilename{FileName: filename})

	if info.Width != 0 && info.Height != 0 && content.MsgType == event.MsgImage {
		attributes = append(attributes, &tg.DocumentAttributeImageSize{W: info.Width, H: info.Height})
	}

	if info.MauGIF {
		attributes = append(attributes, &tg.DocumentAttributeAnimated{})
	}

	if sticker {
		attributes = append(attributes, &tg.DocumentAttributeSticker{
			Alt:        content.Body,
			Stickerset: &tg.InputStickerSetEmpty{},
		})
	} else if content.MsgType == event.MsgAudio {
		audioAttr := &tg.DocumentAttributeAudio{
			Voice:    content.MSC3245Voice != nil,
			Duration: info.Duration / 1000,
		}
		if content.MSC1767Audio != nil && len(content.MSC1767Audio.Waveform) > 0 {
			audioAttr.Waveform = waveform.Encode(content.MSC1767Audio.Waveform)
		}
		attributes = append(attributes, audioAttr)
	} else if content.MsgType == event.MsgVideo {
		attributes = append(attributes, &tg.DocumentAttributeVideo{
			Duration: float64(info.Duration) / 1000,
			W:        info.Width,
			H:        info.Height,
		})
	}

	return &tg.InputMediaUploadedDocument{
		File:       upload,
		MimeType:   cmp.Or(info.MimeType, "application/octet-stream"),
		Attributes: attributes,
	}, nil
}

func (tc *TelegramClient) humaniseSendError(err error) bridgev2.MessageStatus {
	status := bridgev2.WrapErrorInStatus(err).
		WithErrorReason(event.MessageStatusNetworkError).
		WithMessage(humanise.Error(err))

	switch {
	case tg.IsYouBlockedUser(err),
		tg.IsUserIsBlocked(err),
		tg.IsUserBlocked(err),
		tg.IsUserBannedInChannel(err),
		tg.IsChatAdminRequired(err),
		tg.IsChatRestricted(err),
		tg.IsChatWriteForbidden(err):
		status = status.WithErrorReason(event.MessageStatusNoPermission)
	case tg.IsMessageEmpty(err),
		tg.IsMessageTooLong(err),
		tg.IsEntitiesTooLong(err),
		tg.IsEntityBoundsInvalid(err),
		tg.IsEntityMentionUserInvalid(err):
		status = status.WithErrorReason(event.MessageStatusUnsupported)
	case tg.IsMessageEditTimeExpired(err):
		return status.WithErrorReason(event.MessageStatusUnsupported)
	case tg.IsMessageNotModified(err):
		status = status.WithErrorReason(event.MessageStatusNetworkError)
	default:
		// Return a normal status with the default retriable status
		return status
	}
	return status.WithIsCertain(true).
		WithStatus(event.MessageStatusFail)
}

func (tc *TelegramConnector) GenerateTransactionID(userID id.UserID, roomID id.RoomID, eventType event.Type) networkid.RawTransactionID {
	return networkid.RawTransactionID(strconv.FormatInt(rand.Int64(), 10))
}

func parseRandomID(txnID networkid.RawTransactionID) int64 {
	if txnID != "" {
		if id, err := strconv.ParseInt(string(txnID), 10, 64); err == nil && id > 0 {
			return id
		}
	}
	return rand.Int64()
}

func (tc *TelegramClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.MatrixMessage) (resp *bridgev2.MatrixMessageResponse, err error) {
	if msg.Portal.RoomType == database.RoomTypeSpace {
		return nil, fmt.Errorf("can't send messages to space portals")
	}
	// Handle Matrix events only after initial connection has been established to avoid deadlocking gotd
	err = tc.clientInitialized.Wait(ctx)
	if err != nil {
		return nil, err
	}

	peer, topicID, err := tc.inputPeerForPortalID(ctx, msg.Portal.ID)
	if err != nil {
		return nil, err
	}
	log := zerolog.Ctx(ctx).With().
		Stringer("portal_key", msg.Portal.PortalKey).
		Any("peer_id", peer).
		Logger()
	ctx = log.WithContext(ctx)

	var contentURI id.ContentURIString

	noWebpage := msg.Content.BeeperLinkPreviews != nil && len(msg.Content.BeeperLinkPreviews) == 0

	message, entities := matrixfmt.Parse(ctx, tc.matrixParser, msg.Content, msg.Portal)

	var replyTo tg.InputReplyToClass
	if msg.ReplyTo != nil {
		_, messageID, err := ids.ParseMessageID(msg.ReplyTo.ID)
		if err != nil {
			log.Warn().Msg("failed to parse replied-to message ID")
			return nil, err
		}
		replyTo = &tg.InputReplyToMessage{ReplyToMsgID: messageID}
	}
	if topicID > 0 {
		if replyTo == nil {
			replyTo = &tg.InputReplyToMessage{ReplyToMsgID: topicID}
		} else {
			replyTo.(*tg.InputReplyToMessage).TopMsgID = topicID
		}
	}

	randomID := parseRandomID(msg.InputTransactionID)

	var updates tg.UpdatesClass
	if msg.Event.Type == event.EventSticker {
		mediaReq := &tg.MessagesSendMediaRequest{
			Peer:     peer,
			Message:  message,
			Entities: entities,
			ReplyTo:  replyTo,
			RandomID: randomID,
		}
		mediaReq.Media, err = tc.transferMediaToTelegram(ctx, msg.Content, true, false, false)
		if err != nil {
			return nil, err
		}
		updates, err = tc.client.API().MessagesSendMedia(ctx, mediaReq)
		if tgerr.Is(err, tg.ErrFileReferenceExpired) {
			zerolog.Ctx(ctx).Debug().AnErr("send_error", err).Msg("Trying to refetch sticker pack")
			mediaReq.Media, err = tc.transferMediaToTelegram(ctx, msg.Content, true, true, false)
			if err != nil {
				return nil, err
			}
			updates, err = tc.client.API().MessagesSendMedia(ctx, mediaReq)
		}
	} else {
		switch msg.Content.MsgType {
		case event.MsgText, event.MsgNotice, event.MsgEmote:
			updates, err = tc.client.API().MessagesSendMessage(ctx, &tg.MessagesSendMessageRequest{
				Peer:      peer,
				NoWebpage: noWebpage,
				Message:   message,
				Entities:  entities,
				ReplyTo:   replyTo,
				RandomID:  randomID,
			})
		case event.MsgImage, event.MsgFile, event.MsgAudio, event.MsgVideo:
			var media tg.InputMediaClass
			forceDocument, _ := msg.Event.Content.Raw["fi.mau.telegram.force_document"].(bool)
			media, err = tc.transferMediaToTelegram(ctx, msg.Content, false, false, forceDocument)
			if err != nil {
				return nil, err
			}
			updates, err = tc.client.API().MessagesSendMedia(ctx, &tg.MessagesSendMediaRequest{
				Peer:     peer,
				Message:  message,
				Entities: entities,
				Media:    media,
				ReplyTo:  replyTo,
				RandomID: randomID,
			})
		case event.MsgLocation:
			var uri GeoURI
			uri, err = ParseGeoURI(msg.Content.GeoURI)
			if err != nil {
				return nil, err
			}
			message = ""
			if location, ok := msg.Event.Content.Raw["org.matrix.msc3488.location"].(map[string]any); ok {
				if desc, ok := location["description"].(string); ok {
					message = desc
				}
			}
			updates, err = tc.client.API().MessagesSendMedia(ctx, &tg.MessagesSendMediaRequest{
				Peer:    peer,
				Message: message,
				Media: &tg.InputMediaGeoPoint{
					GeoPoint: &tg.InputGeoPoint{Lat: uri.Lat, Long: uri.Long},
				},
				ReplyTo:  replyTo,
				RandomID: randomID,
			})
		default:
			return nil, fmt.Errorf("unsupported message type %s", msg.Content.MsgType)
		}
	}
	if err != nil {
		log.Err(err).Msg("failed to send message to Telegram")
		return nil, tc.humaniseSendError(err)
	}

	hasher := sha256.New()

	var tgMessageID, tgDate int
	switch sentMessage := updates.(type) {
	case *tg.UpdateShortSentMessage:
		tgMessageID = sentMessage.ID
		tgDate = sentMessage.Date
		hasher.Write([]byte(msg.Content.Body))
		hasher.Write(mediaHashID(ctx, sentMessage.Media))
	case *tg.Updates:
		var realSentMessage *tg.Message
		for _, u := range sentMessage.Updates {
			switch update := u.(type) {
			case *tg.UpdateMessageID:
				tgMessageID = update.ID
				if update.RandomID != randomID {
					log.Warn().
						Int64("update_random_id", update.RandomID).
						Int64("expected_random_id", randomID).
						Msg("Random ID in response does not match sent random ID")
				}
			case *tg.UpdateNewMessage:
				if realSentMessage != nil {
					log.Warn().
						Int("prev_id", realSentMessage.ID).
						Int("new_id", update.Message.GetID()).
						Msg("Multiple messages in send response")
				}
				realSentMessage = update.Message.(*tg.Message)
			case *tg.UpdateNewChannelMessage:
				if realSentMessage != nil {
					log.Warn().
						Int("prev_id", realSentMessage.ID).
						Int("new_id", update.Message.GetID()).
						Msg("Multiple messages in send response")
				}
				realSentMessage = update.Message.(*tg.Message)
			case *tg.UpdateReadChannelInbox, *tg.UpdateReadHistoryInbox, *tg.UpdateReadMonoForumInbox,
				*tg.UpdateReadHistoryOutbox, *tg.UpdateReadChannelOutbox:
				// ignore
			default:
				log.Warn().Type("update_type", update).Msg("Unexpected update type in send message response")
			}
		}
		if realSentMessage != nil {
			tgDate = realSentMessage.Date
			hasher.Write([]byte(realSentMessage.Message))
			hasher.Write(mediaHashID(ctx, realSentMessage.Media))
		} else {
			hasher.Write([]byte(msg.Content.Body))
			tgDate = sentMessage.Date
		}
		if tgMessageID == 0 {
			return nil, fmt.Errorf("couldn't find update message ID update")
		}
	default:
		return nil, fmt.Errorf("unknown update from message response %T", updates)
	}

	messageID := ids.MakeMessageID(msg.Portal.PortalKey, tgMessageID)
	timestamp := time.Unix(int64(tgDate), 0)
	hash := hasher.Sum(nil)
	log.Info().
		Int("tg_message_id", tgMessageID).
		Str("message_id", string(messageID)).
		Time("timestamp", timestamp).
		Str("content_hash", base64.StdEncoding.EncodeToString(hash)).
		Msg("sent message successfully")

	resp = &bridgev2.MatrixMessageResponse{
		DB: &database.Message{
			ID:        messageID,
			SenderID:  tc.userID,
			Timestamp: timestamp,
			Metadata: &MessageMetadata{
				ContentHash: hash,
				ContentURI:  contentURI,
			},
		},
		StreamOrder: int64(tgMessageID),
	}
	return
}

func (tc *TelegramClient) HandleMatrixEdit(ctx context.Context, msg *bridgev2.MatrixEdit) error {
	if msg.Portal.RoomType == database.RoomTypeSpace {
		return fmt.Errorf("can't send messages to space portals")
	}
	log := zerolog.Ctx(ctx).With().
		Str("conversion_direction", "to_telegram").
		Str("handler", "matrix_edit").
		Logger()

	peer, _, err := tc.inputPeerForPortalID(ctx, msg.Portal.ID)
	if err != nil {
		return err
	}

	_, targetID, err := ids.ParseMessageID(msg.EditTarget.ID)
	if err != nil {
		return err
	}

	message, entities := matrixfmt.Parse(ctx, tc.matrixParser, msg.Content, msg.Portal)

	var newContentURI id.ContentURIString
	req := tg.MessagesEditMessageRequest{
		Peer:      peer,
		NoWebpage: msg.Content.BeeperLinkPreviews != nil && len(msg.Content.BeeperLinkPreviews) == 0,
		Message:   message,
		Entities:  entities,
		ID:        targetID,
	}
	if msg.Content.MsgType.IsMedia() {
		newContentURI = msg.Content.URL
		if newContentURI == "" {
			newContentURI = msg.Content.File.URL
		}
		if msg.EditTarget.Metadata.(*MessageMetadata).ContentURI == newContentURI {
			log.Info().Msg("media URI unchanged, skipping re-upload, just editing text")
		} else {
			log.Info().Msg("media URI changed, re-uploading media")
			forceDocument, _ := msg.Event.Content.Raw["fi.mau.telegram.force_document"].(bool)
			req.Media, err = tc.transferMediaToTelegram(ctx, msg.Content, false, false, forceDocument)
			if err != nil {
				return err
			}
		}
	} else if !msg.Content.MsgType.IsText() {
		return fmt.Errorf("editing message type %s is unsupported", msg.Content.MsgType)
	}
	updates, err := tc.client.API().MessagesEditMessage(ctx, &req)
	if err != nil {
		return tc.humaniseSendError(err)
	}

	hasher := sha256.New()

	switch sentMessage := updates.(type) {
	case *tg.UpdateShortSentMessage:
		hasher.Write([]byte(msg.Content.Body))
		hasher.Write(mediaHashID(ctx, sentMessage.Media))
	case *tg.Updates:
		var realSentMessage *tg.Message
		for _, u := range sentMessage.Updates {
			switch update := u.(type) {
			case *tg.UpdateNewMessage:
				if realSentMessage != nil {
					log.Warn().
						Int("prev_id", realSentMessage.ID).
						Int("new_id", update.Message.GetID()).
						Msg("Multiple messages in edit response")
				}
				realSentMessage = update.Message.(*tg.Message)
			case *tg.UpdateNewChannelMessage:
				if realSentMessage != nil {
					log.Warn().
						Int("prev_id", realSentMessage.ID).
						Int("new_id", update.Message.GetID()).
						Msg("Multiple messages in edit response")
				}
				realSentMessage = update.Message.(*tg.Message)
			default:
				log.Warn().Type("update_type", update).Msg("Unexpected update type in edit message response")
			}
		}
		if realSentMessage != nil {
			hasher.Write([]byte(realSentMessage.Message))
			hasher.Write(mediaHashID(ctx, realSentMessage.Media))
		} else {
			hasher.Write([]byte(msg.Content.Body))
		}
	default:
		return fmt.Errorf("unknown update from message response %T", updates)
	}

	metadata := msg.EditTarget.Metadata.(*MessageMetadata)
	metadata.ContentHash = hasher.Sum(nil)
	metadata.ContentURI = newContentURI
	return nil
}

func (tc *TelegramClient) HandleMatrixMessageRemove(ctx context.Context, msg *bridgev2.MatrixMessageRemove) error {
	if msg.Portal.RoomType == database.RoomTypeSpace {
		return fmt.Errorf("can't send messages to space portals")
	} else if dbMsg, err := tc.main.Bridge.DB.Message.GetPartByMXID(ctx, msg.TargetMessage.MXID); err != nil {
		return err
	} else if _, messageID, err := ids.ParseMessageID(dbMsg.ID); err != nil {
		return err
	} else if peer, _, err := tc.inputPeerForPortalID(ctx, msg.Portal.ID); err != nil {
		return err
	} else {
		_, err := message.NewSender(tc.client.API()).
			To(peer).
			Revoke().
			Messages(ctx, messageID)
		return err
	}
}

func (tc *TelegramClient) PreHandleMatrixReaction(ctx context.Context, msg *bridgev2.MatrixReaction) (bridgev2.MatrixReactionPreResponse, error) {
	if msg.Portal.RoomType == database.RoomTypeSpace {
		return bridgev2.MatrixReactionPreResponse{}, fmt.Errorf("can't send messages to space portals")
	}
	log := zerolog.Ctx(ctx).With().
		Str("conversion_direction", "to_telegram").
		Str("handler", "pre_handle_matrix_reaction").
		Str("key", msg.Content.RelatesTo.Key).
		Logger()
	var resp bridgev2.MatrixReactionPreResponse

	var maxReactions int
	maxReactions, err := tc.getReactionLimit(ctx, tc.userID)
	if err != nil {
		return resp, err
	}

	keyNoVariation := variationselector.Remove(msg.Content.RelatesTo.Key)
	emojiID := ids.MakeEmojiIDFromEmoticon(msg.Content.RelatesTo.Key)
	if strings.HasPrefix(msg.Content.RelatesTo.Key, "mxc://") {
		if file, err := tc.main.Store.TelegramFile.GetByMXC(ctx, id.ContentURIString(msg.Content.RelatesTo.Key)); err != nil {
			return resp, err
		} else if file == nil {
			return resp, fmt.Errorf("reaction MXC URI %s does not correspond with any known Telegram files", msg.Content.RelatesTo.Key)
		} else if documentID, err := strconv.ParseInt(string(file.LocationID), 10, 64); err != nil {
			return resp, err
		} else {
			emojiID = ids.MakeEmojiIDFromDocumentID(documentID)
		}
	} else if tc.main.Config.AlwaysCustomEmojiReaction {
		// Always use the unicodemoji reaction if available
		if documentID, ok := emojis.GetEmojiDocumentID(keyNoVariation); ok {
			log.Debug().Msg("Using custom emoji reaction")
			emojiID = ids.MakeEmojiIDFromDocumentID(documentID)
		}
	} else if availableReactions, err := tc.getAvailableReactions(ctx); err != nil {
		return resp, fmt.Errorf("failed to get available reactions: %w", err)
	} else if _, ok := availableReactions[keyNoVariation]; ok {
		log.Debug().Msg("Not using custom emoji reaction since the emoji is available")
	} else if documentID, ok := emojis.GetEmojiDocumentID(keyNoVariation); ok && !tc.metadata.IsBot {
		log.Debug().Msg("Using custom emoji reaction")
		emojiID = ids.MakeEmojiIDFromDocumentID(documentID)
	}

	log.Debug().Str("emoji_id", string(emojiID)).Msg("Pre-handled reaction")

	return bridgev2.MatrixReactionPreResponse{
		SenderID:     tc.userID,
		EmojiID:      emojiID,
		Emoji:        variationselector.FullyQualify(msg.Content.RelatesTo.Key),
		MaxReactions: maxReactions,
	}, nil
}

func (tc *TelegramClient) appendEmojiID(reactionList []tg.ReactionClass, emojiID networkid.EmojiID) ([]tg.ReactionClass, error) {
	if documentID, emoticon, err := ids.ParseEmojiID(emojiID); err != nil {
		return nil, err
	} else if documentID > 0 {
		return append(reactionList, &tg.ReactionCustomEmoji{DocumentID: documentID}), nil
	} else {
		return append(reactionList, &tg.ReactionEmoji{Emoticon: emoticon}), nil
	}
}

func (tc *TelegramClient) HandleMatrixReaction(ctx context.Context, msg *bridgev2.MatrixReaction) (reaction *database.Reaction, err error) {
	peer, _, err := tc.inputPeerForPortalID(ctx, msg.Portal.ID)
	if err != nil {
		return nil, err
	}
	_, targetMessageID, err := ids.ParseMessageID(msg.TargetMessage.ID)
	if err != nil {
		return nil, err
	}

	var newReactions []tg.ReactionClass
	for _, existing := range msg.ExistingReactionsToKeep {
		newReactions, err = tc.appendEmojiID(newReactions, existing.EmojiID)
		if err != nil {
			return nil, err
		}
	}
	newReactions, err = tc.appendEmojiID(newReactions, msg.PreHandleResp.EmojiID)
	if err != nil {
		return nil, err
	}

	_, err = tc.client.API().MessagesSendReaction(ctx, &tg.MessagesSendReactionRequest{
		Peer:        peer,
		AddToRecent: true,
		MsgID:       targetMessageID,
		Reaction:    newReactions,
	})
	if tg.IsReactionInvalid(err) {
		return nil, bridgev2.WrapErrorInStatus(err).
			WithErrorReason(event.MessageStatusUnsupported).
			WithIsCertain(true).
			WithMessage("Unsupported reaction")
	}
	return &database.Reaction{}, err
}

func (tc *TelegramClient) HandleMatrixReactionRemove(ctx context.Context, msg *bridgev2.MatrixReactionRemove) error {
	if msg.Portal.RoomType == database.RoomTypeSpace {
		return fmt.Errorf("can't send messages to space portals")
	}
	peer, _, err := tc.inputPeerForPortalID(ctx, msg.Portal.ID)
	if err != nil {
		return err
	}

	var newReactions []tg.ReactionClass

	if maxReactions, err := tc.getReactionLimit(ctx, tc.userID); err != nil {
		return err
	} else if maxReactions > 1 {
		existing, err := tc.main.Bridge.DB.Reaction.GetAllToMessageBySender(ctx, msg.Portal.Receiver, msg.TargetReaction.MessageID, msg.TargetReaction.SenderID)
		if err != nil {
			return err
		}
		for _, existing := range existing {
			if msg.TargetReaction.EmojiID != existing.EmojiID {
				newReactions, err = tc.appendEmojiID(newReactions, existing.EmojiID)
				if err != nil {
					return err
				}
			}
		}
	}

	_, messageID, err := ids.ParseMessageID(msg.TargetReaction.MessageID)
	if err != nil {
		return err
	}
	_, err = tc.client.API().MessagesSendReaction(ctx, &tg.MessagesSendReactionRequest{
		Peer:        peer,
		AddToRecent: true,
		MsgID:       messageID,
		Reaction:    newReactions,
	})
	return err
}

func (tc *TelegramClient) HandleMatrixReadReceipt(ctx context.Context, msg *bridgev2.MatrixReadReceipt) error {
	if msg.Portal.RoomType == database.RoomTypeSpace {
		return nil
	}
	log := zerolog.Ctx(ctx).With().
		Str("action", "handle_matrix_read_receipt").
		Str("portal_id", string(msg.Portal.ID)).
		Bool("is_supergroup", msg.Portal.Metadata.(*PortalMetadata).IsSuperGroup).
		Logger()
	peerType, portalID, topicID, parseErr := ids.ParsePortalID(msg.Portal.ID)
	if parseErr != nil {
		return parseErr
	}
	if msg.Portal.Metadata.(*PortalMetadata).IsForumGeneral {
		topicID = 1
	}
	inputPeer, _, parseErr := tc.inputPeerForPortalID(ctx, msg.Portal.ID)
	if parseErr != nil {
		return parseErr
	}

	var readMentionsErr, readReactionsErr, readMessagesErr error
	var wg sync.WaitGroup

	isBot := tc.metadata.IsBot

	// Read mentions
	wg.Add(1)
	go func() {
		defer wg.Done()
		if isBot {
			return
		}
		_, readMentionsErr = tc.client.API().MessagesReadMentions(ctx, &tg.MessagesReadMentionsRequest{
			Peer:     inputPeer,
			TopMsgID: topicID,
		})
	}()

	// Read reactions
	wg.Add(1)
	go func() {
		defer wg.Done()
		if isBot {
			return
		}
		_, readMentionsErr = tc.client.API().MessagesReadReactions(ctx, &tg.MessagesReadReactionsRequest{
			Peer:     inputPeer,
			TopMsgID: topicID,
		})
	}()

	// Read messages
	wg.Add(1)
	go func() {
		defer wg.Done()

		if isBot {
			return
		}

		message := msg.ExactMessage
		if message == nil {
			message, readMessagesErr = tc.main.Bridge.DB.Message.GetLastPartAtOrBeforeTime(ctx, msg.Portal.PortalKey, time.Now())
			if readMessagesErr != nil {
				return
			} else if message == nil {
				zerolog.Ctx(ctx).Warn().Msg("no message found to read")
				return
			}
		}
		var maxID int
		_, maxID, readMessagesErr = ids.ParseMessageID(message.ID)
		if readMessagesErr != nil {
			return
		}

		switch peerType {
		case ids.PeerTypeUser, ids.PeerTypeChat:
			_, readMessagesErr = tc.client.API().MessagesReadHistory(ctx, &tg.MessagesReadHistoryRequest{
				Peer:  inputPeer,
				MaxID: maxID,
			})
		case ids.PeerTypeChannel:
			var accessHash int64
			accessHash, readMessagesErr = tc.ScopedStore.GetAccessHash(ctx, ids.PeerTypeChannel, portalID)
			if readMessagesErr != nil {
				return
			}
			_, readMessagesErr = tc.client.API().ChannelsReadHistory(ctx, &tg.ChannelsReadHistoryRequest{
				Channel: &tg.InputChannel{ChannelID: portalID, AccessHash: accessHash},
				MaxID:   maxID,
			})

			meta := msg.Portal.Metadata.(*PortalMetadata)
			randomID := meta.SponsoredMessageRandomID
			if !tc.metadata.IsBot &&
				randomID != nil &&
				time.Since(meta.SponsoredMessagePollTS.Time) < 15*time.Minute &&
				(meta.SponsoredMessageEventID == msg.EventID || msg.Receipt.Timestamp.After(meta.SponsoredMessagePollTS.Time)) &&
				meta.sponsoredMessageSeen.Add(tc.telegramUserID) {
				_, viewSponsoredErr := tc.client.API().MessagesViewSponsoredMessage(ctx, randomID)
				if viewSponsoredErr != nil {
					log.Err(viewSponsoredErr).Msg("Failed to mark sponsored message as viewed after read receipt")
				} else {
					log.Debug().
						Str("random_id", base64.StdEncoding.EncodeToString(randomID)).
						Msg("Marked sponsored message as viewed after read receipt")
				}
			}
		default:
			readMessagesErr = fmt.Errorf("unknown peer type %s", peerType)
		}
	}()

	// Poll for reactions (non-blocking to avoid deadlock when portal event buffer is disabled)
	go func() {
		err := tc.maybePollForReactions(ctx, msg.Portal)
		if err != nil {
			log.Err(err).Msg("failed to poll for reactions after read receipt")
		}
		err = tc.pollSponsoredMessage(ctx, msg.Portal)
		if err != nil {
			log.Err(err).Msg("failed to poll for sponsored message after read receipt")
		}
	}()

	if peerType == ids.PeerTypeChannel && !msg.Portal.Metadata.(*PortalMetadata).FullSynced {
		log.Debug().Msg("Scheduling chat resync on read receipt because channel has never got a full sync")
		go tc.userLogin.QueueRemoteEvent(&simplevent.ChatResync{
			EventMeta: simplevent.EventMeta{
				Type:      bridgev2.RemoteEventChatResync,
				PortalKey: msg.Portal.PortalKey,
			},
			GetChatInfoFunc: tc.GetChatInfo,
		})
	}

	wg.Wait()
	return errors.Join(readMentionsErr, readReactionsErr, readMessagesErr)
}

func (tc *TelegramClient) HandleMatrixTyping(ctx context.Context, msg *bridgev2.MatrixTyping) error {
	if msg.Portal.RoomType == database.RoomTypeSpace {
		return nil
	}
	inputPeer, topicID, err := tc.inputPeerForPortalID(ctx, msg.Portal.ID)
	if err != nil {
		return err
	}
	if msg.Portal.Metadata.(*PortalMetadata).IsForumGeneral {
		topicID = 1
	}
	var action tg.SendMessageActionClass
	switch msg.Type {
	case bridgev2.TypingTypeText:
		action = &tg.SendMessageTypingAction{}
	case bridgev2.TypingTypeRecordingMedia:
		// TODO media types?
		action = &tg.SendMessageRecordVideoAction{}
	case bridgev2.TypingTypeUploadingMedia:
		action = &tg.SendMessageUploadVideoAction{}
	}
	if !msg.IsTyping {
		action = &tg.SendMessageCancelAction{}
	}
	_, err = tc.client.API().MessagesSetTyping(ctx, &tg.MessagesSetTypingRequest{
		Peer:     inputPeer,
		TopMsgID: topicID,
		Action:   action,
	})
	return err
}

func (tc *TelegramClient) HandleMatrixDisappearingTimer(ctx context.Context, msg *bridgev2.MatrixDisappearingTimer) (bool, error) {
	inputPeer, topicID, err := tc.inputPeerForPortalID(ctx, msg.Portal.ID)
	if err != nil {
		return false, err
	} else if topicID > 0 {
		return false, fmt.Errorf("topics can't have their own disappearing timer")
	}
	_, err = tc.client.API().MessagesSetHistoryTTL(ctx, &tg.MessagesSetHistoryTTLRequest{
		Peer:   inputPeer,
		Period: int(msg.Content.Timer.Seconds()),
	})
	if err == nil {
		msg.Portal.Disappear = database.DisappearingSetting{
			Type:  event.DisappearingTypeAfterSend,
			Timer: msg.Content.Timer.Duration,
		}.Normalize()
	}
	return err == nil, err
}

func (tc *TelegramClient) HandleMute(ctx context.Context, msg *bridgev2.MatrixMute) error {
	inputPeer, topicID, err := tc.inputPeerForPortalID(ctx, msg.Portal.ID)
	if err != nil {
		return err
	}

	settings := tg.InputPeerNotifySettings{
		Silent:    msg.Content.IsMuted(),
		MuteUntil: int(max(0, min(msg.Content.GetMutedUntilTime().Unix(), math.MaxInt32))),
	}
	var peer tg.InputNotifyPeerClass
	if topicID > 0 {
		peer = &tg.InputNotifyForumTopic{Peer: inputPeer, TopMsgID: topicID}
	} else {
		peer = &tg.InputNotifyPeer{Peer: inputPeer}
	}

	_, err = tc.client.API().AccountUpdateNotifySettings(ctx, &tg.AccountUpdateNotifySettingsRequest{
		Peer:     peer,
		Settings: settings,
	})
	return err
}

func (tc *TelegramClient) HandleRoomTag(ctx context.Context, msg *bridgev2.MatrixRoomTag) error {
	inputPeer, topicID, err := tc.inputPeerForPortalID(ctx, msg.Portal.ID)
	if err != nil {
		return err
	} else if topicID > 0 {
		return fmt.Errorf("topics can't be pinned for yourself")
	}

	_, err = tc.client.API().MessagesToggleDialogPin(ctx, &tg.MessagesToggleDialogPinRequest{
		Pinned: slices.Contains(maps.Keys(msg.Content.Tags), event.RoomTagFavourite),
		Peer:   &tg.InputDialogPeer{Peer: inputPeer},
	})
	return err
}

func (tc *TelegramClient) HandleMatrixDeleteChat(ctx context.Context, chat *bridgev2.MatrixDeleteChat) error {
	peerType, id, topicID, err := ids.ParsePortalID(chat.Portal.ID)
	if err != nil {
		return err
	}
	switch peerType {
	case ids.PeerTypeUser:
		accessHash, err := tc.ScopedStore.GetAccessHash(ctx, peerType, id)
		if err != nil {
			return err
		}
		_, err = tc.client.API().MessagesDeleteHistory(ctx, &tg.MessagesDeleteHistoryRequest{
			Peer:      &tg.InputPeerUser{UserID: id, AccessHash: accessHash},
			JustClear: !chat.Content.DeleteForEveryone,
			Revoke:    chat.Content.DeleteForEveryone,
			MaxID:     0,
		})
		if err != nil {
			return err
		}
	case ids.PeerTypeChat:
		if !chat.Content.DeleteForEveryone {
			return fmt.Errorf("chats can only be deleted for everyone or left")
		}
		result, err := tc.client.API().MessagesDeleteChat(ctx, id)
		if err != nil {
			return err
		}
		if !result {
			return fmt.Errorf("failed to delete chat %d", id)
		}
		return nil
	case ids.PeerTypeChannel:
		if !chat.Content.DeleteForEveryone {
			return fmt.Errorf("channels can only be deleted for everyone or left")
		}
		accessHash, err := tc.ScopedStore.GetAccessHash(ctx, peerType, id)
		if err != nil {
			return err
		}
		if topicID > 0 {
			_, err = tc.client.API().MessagesDeleteTopicHistory(ctx, &tg.MessagesDeleteTopicHistoryRequest{
				Peer: &tg.InputPeerChannel{
					ChannelID:  id,
					AccessHash: accessHash,
				},
				TopMsgID: topicID,
			})
		} else {
			_, err = tc.client.API().ChannelsDeleteChannel(ctx, &tg.InputChannel{
				ChannelID:  id,
				AccessHash: accessHash,
			})
		}
		if err != nil {
			return err
		}
		return nil
	default:
		return fmt.Errorf("unknown peer type %s", peerType)
	}
	return nil
}

func (tc *TelegramClient) HandleMatrixRoomName(ctx context.Context, msg *bridgev2.MatrixRoomName) (bool, error) {
	peerType, id, topicID, err := ids.ParsePortalID(msg.Portal.ID)
	if err != nil {
		return false, err
	}

	switch peerType {
	case ids.PeerTypeChat:
		_, err = tc.client.API().MessagesEditChatTitle(ctx, &tg.MessagesEditChatTitleRequest{
			ChatID: id,
			Title:  msg.Content.Name,
		})
		if err != nil {
			return false, err
		}
		return true, nil
	case ids.PeerTypeChannel:
		accessHash, err := tc.ScopedStore.GetAccessHash(ctx, peerType, id)
		if err != nil {
			return false, err
		}
		if topicID > 0 {
			_, err = tc.client.API().MessagesEditForumTopic(ctx, &tg.MessagesEditForumTopicRequest{
				Peer: &tg.InputPeerChannel{
					ChannelID:  id,
					AccessHash: accessHash,
				},
				TopicID: topicID,
				Title:   msg.Content.Name,
			})
		} else {
			_, err = tc.client.API().ChannelsEditTitle(ctx, &tg.ChannelsEditTitleRequest{
				Channel: &tg.InputChannel{
					ChannelID:  id,
					AccessHash: accessHash,
				},
				Title: msg.Content.Name,
			})
		}
		if err != nil {
			return false, err
		}
		return true, nil
	default:
		return false, fmt.Errorf("unsupported peer type %s for changing room name", peerType)
	}
}

func (tc *TelegramClient) HandleMatrixRoomAvatar(ctx context.Context, msg *bridgev2.MatrixRoomAvatar) (bool, error) {
	peerType, id, topicID, err := ids.ParsePortalID(msg.Portal.ID)
	if err != nil {
		return false, err
	}

	if peerType == ids.PeerTypeUser {
		return false, fmt.Errorf("changing user avatar is not supported")
	} else if topicID > 0 {
		return false, fmt.Errorf("changing group topic avatar is not supported")
	}

	var photo tg.InputChatPhotoClass
	if msg.Content.URL == "" {
		photo = &tg.InputChatPhotoEmpty{}
	} else {
		data, err := tc.main.Bridge.Bot.DownloadMedia(ctx, msg.Content.URL, nil)
		if err != nil {
			return false, fmt.Errorf("failed to download avatar: %w", err)
		}
		upload, err := uploader.NewUploader(tc.client.API()).FromBytes(ctx, "avatar.jpg", data)
		if err != nil {
			return false, fmt.Errorf("failed to upload avatar: %w", err)
		}
		photo = &tg.InputChatUploadedPhoto{File: upload}
	}

	switch peerType {
	case ids.PeerTypeChat:
		_, err = tc.client.API().MessagesEditChatPhoto(ctx, &tg.MessagesEditChatPhotoRequest{
			ChatID: id,
			Photo:  photo,
		})
		if err != nil {
			return false, err
		}
		// TODO update portal metadata
		return true, nil
	case ids.PeerTypeChannel:
		accessHash, err := tc.ScopedStore.GetAccessHash(ctx, peerType, id)
		if err != nil {
			return false, err
		}
		_, err = tc.client.API().ChannelsEditPhoto(ctx, &tg.ChannelsEditPhotoRequest{
			Channel: &tg.InputChannel{
				ChannelID:  id,
				AccessHash: accessHash,
			},
			Photo: photo,
		})
		if err != nil {
			return false, err
		}
		// TODO update portal metadata
		return true, nil
	default:
		return false, fmt.Errorf("unsupported peer type %s for changing room avatar", peerType)
	}
}
