import { Api as GramJs } from '../../../lib/gramjs';
import { RPCError } from '../../../lib/gramjs/errors';
import { generateRandomBigInt } from '../../../lib/gramjs/Helpers';

import type {
  ForwardMessagesParams,
  SendMessageParams,
  ThreadId,
  TranslationTone,
} from '../../../types';
import type {
  ApiAttachment,
  ApiChat,
  ApiComposedMessageWithAI,
  ApiError,
  ApiFormattedText,
  ApiGlobalMessageSearchType,
  ApiInputAiComposeTone,
  ApiInputReplyInfo,
  ApiInputSuggestedPostInfo,
  ApiMessage,
  ApiMessageEntity,
  ApiMessagePoll,
  ApiMessageSearchContext,
  ApiMessageSearchType,
  ApiNewMediaTodo,
  ApiOnProgress,
  ApiPeer,
  ApiReaction,
  ApiSearchPostsFlood,
  ApiSendMessageAction,
  ApiTodoItem,
  ApiTopicWithState,
  ApiUser,
  ApiUserStatus,
  ApiWebPage,
  MediaContent,
} from '../../types';
import {
  MAIN_THREAD_ID,
  MESSAGE_DELETED,
} from '../../types';

import {
  DEBUG,
  GIF_MIME_TYPE,
  MAX_INT_32,
  MENTION_UNREAD_SLICE,
  MESSAGE_ID_REQUIRED_ERROR,
  POLL_UNREAD_SLICE,
  REACTION_UNREAD_SLICE,
  SUPPORTED_PHOTO_CONTENT_TYPES,
  SUPPORTED_VIDEO_CONTENT_TYPES,
} from '../../../config';
import { fetchFile } from '../../../util/files';
import { compact, split } from '../../../util/iteratees';
import { getMessageKey } from '../../../util/keys/messageKey';
import { getServerTime } from '../../../util/serverTime';
import { interpolateArray } from '../../../util/waveform';
import { API_GENERAL_ID_LIMIT, PINNED_MESSAGES_LIMIT } from '../../../limits';
import {
  buildApiChatFromPreview,
  buildApiSendAsPeerId,
  buildApiSponsoredMessageReportResult,
  buildThreadReadState,
} from '../apiBuilders/chats';
import {
  buildApiAiComposeTone, buildApiAiComposeToneExample, buildApiComposedMessageWithAI, buildApiFormattedText,
} from '../apiBuilders/common';
import { buildApiTopicWithState } from '../apiBuilders/forums';
import {
  buildMessageMediaContent, buildMessagePollFromMedia, buildMessageTextContent,
  buildWebPageFromMedia,
} from '../apiBuilders/messageContent';
import {
  buildApiFactCheck,
  buildApiMessage,
  buildApiQuickReply,
  buildApiReportResult,
  buildApiSearchPostsFlood,
  buildApiSponsoredMessage,
  buildApiThreadInfo,
  buildApiThreadInfoFromMessage,
  buildLocalForwardedMessage,
  buildLocalMessage,
  buildPreparedInlineMessage,
  buildUploadingMedia,
  incrementLocalMessageCounter,
} from '../apiBuilders/messages';
import { getApiChatIdFromMtpPeer } from '../apiBuilders/peers';
import { buildApiUser, buildApiUserStatuses } from '../apiBuilders/users';
import {
  buildInputAiComposeTone,
  buildInputChannel,
  buildInputDocument,
  buildInputMediaDocument,
  buildInputPeer,
  buildInputPhoto,
  buildInputPoll,
  buildInputPollFromExisting,
  buildInputReaction,
  buildInputReplyTo,
  buildInputStory,
  buildInputSuggestedPost,
  buildInputTextWithEntities,
  buildInputTodo,
  buildInputUser,
  buildMessageFromUpdate,
  buildMtpMessageEntity,
  buildPeer,
  buildSendMessageAction,
  DEFAULT_PRIMITIVES,
  getEntityTypeById,
} from '../gramjsBuilders';
import {
  buildApiError,
  deserializeBytes,
  resolveMessageApiChatId,
} from '../helpers/misc';
import localDb from '../localDb';
import { sendApiUpdate } from '../updates/apiUpdateEmitter';
import { processMessageAndUpdateThreadInfo } from '../updates/entityProcessor';
import { processAffectedHistory, updateChannelState } from '../updates/updateManager';
import { requestChatUpdate } from './chats';
import { handleGramJsUpdate, invokeRequest, uploadFile } from './client';

const FAST_SEND_TIMEOUT = 1000;
const INPUT_WAVEFORM_LENGTH = 63;

type TranslateTextParams = ({
  text: ApiFormattedText[];
} | {
  chat: ApiChat;
  messageIds: number[];
}) & {
  toLanguageCode: string;
  tone?: TranslationTone;
};

type SearchResults = {
  messages: ApiMessage[];
  topics: ApiTopicWithState[];
  userStatusesById: Record<number, ApiUserStatus>;
  totalCount: number;
  nextOffsetRate?: number;
  nextOffsetPeerId?: string;
  nextOffsetId?: number;
  searchFlood?: ApiSearchPostsFlood;
};

export async function fetchMessages({
  chat,
  threadId,
  offsetId,
  isSavedDialog,
  addOffset,
  limit,
}: {
  chat: ApiChat;
  threadId?: ThreadId;
  offsetId?: number;
  isSavedDialog?: boolean;
  addOffset?: number;
  limit: number;
}) {
  const RequestClass = threadId === MAIN_THREAD_ID
    ? GramJs.messages.GetHistory : isSavedDialog
      ? GramJs.messages.GetSavedHistory : GramJs.messages.GetReplies;
  const isChannel = getEntityTypeById(chat.id) === 'channel';
  let result;

  try {
    result = await invokeRequest(new RequestClass({
      hash: DEFAULT_PRIMITIVES.BIGINT,
      maxId: DEFAULT_PRIMITIVES.INT,
      minId: DEFAULT_PRIMITIVES.INT,
      offsetDate: DEFAULT_PRIMITIVES.INT,
      peer: buildInputPeer(chat.id, chat.accessHash),
      ...(threadId !== MAIN_THREAD_ID && !isSavedDialog && {
        msgId: Number(threadId),
      }),
      // Workaround for local message IDs overflowing some internal `Buffer` range check
      offsetId: offsetId ? Math.min(offsetId, MAX_INT_32) : DEFAULT_PRIMITIVES.INT,
      addOffset: addOffset ?? DEFAULT_PRIMITIVES.INT,
      limit,
    }), {
      shouldThrow: true,
      abortControllerChatId: chat.id,
      abortControllerThreadId: threadId,
    });
  } catch (err: any) {
    if (err.errorMessage === 'CHANNEL_PRIVATE') {
      sendApiUpdate({
        '@type': 'updateChat',
        id: chat.id,
        chat: {
          isRestricted: true,
        },
      });
    }
  }

  if (
    !result
    || result instanceof GramJs.messages.MessagesNotModified
    || !result.messages
  ) {
    return undefined;
  }

  if (isChannel && 'pts' in result) {
    updateChannelState(chat.id, result.pts);
  }

  const messages = result.messages.map(buildApiMessage).filter(Boolean);
  const users = result.users.map(buildApiUser).filter(Boolean);
  const chats = result.chats.map((c) => buildApiChatFromPreview(c)).filter(Boolean);
  const count = 'count' in result ? result.count : messages.length;
  const topics = result.topics.map(buildApiTopicWithState).filter(Boolean);

  return {
    messages,
    users,
    chats,
    count,
    topics,
  };
}

export async function fetchMessage({ chat, messageId }: { chat: ApiChat; messageId: number }) {
  const isChannel = getEntityTypeById(chat.id) === 'channel';

  let result;
  try {
    result = await invokeRequest(
      isChannel
        ? new GramJs.channels.GetMessages({
          channel: buildInputChannel(chat.id, chat.accessHash),
          id: [new GramJs.InputMessageID({ id: messageId })],
        })
        : new GramJs.messages.GetMessages({
          id: [new GramJs.InputMessageID({ id: messageId })],
        }),
      {
        shouldThrow: true,
        abortControllerChatId: chat.id,
      },
    );
  } catch (err: any) {
    const { message, code } = buildApiError(err);

    // When fetching messages for the bot @replies, there may be situations when the user was banned
    // in the comment group or this group was deleted
    if (message !== 'CHANNEL_PRIVATE') {
      sendApiUpdate({
        '@type': 'error',
        error: {
          message,
          code,
          isSlowMode: false,
          hasErrorKey: true,
        },
      });
    }
  }

  if (!result || result instanceof GramJs.messages.MessagesNotModified) {
    return undefined;
  }

  if (isChannel && 'pts' in result) {
    updateChannelState(chat.id, result.pts);
  }

  const mtpMessage = result.messages[0];
  if (!mtpMessage) {
    return undefined;
  }

  if (mtpMessage instanceof GramJs.MessageEmpty) {
    return MESSAGE_DELETED;
  }

  processMessageAndUpdateThreadInfo(mtpMessage);
  const message = buildApiMessage(mtpMessage);

  if (!message) {
    return undefined;
  }

  return { message };
}

export async function fetchMessagesById({ chat, messageIds }: { chat: ApiChat; messageIds: number[] }) {
  const isChannel = getEntityTypeById(chat.id) === 'channel';

  const result = await invokeRequest(
    isChannel
      ? new GramJs.channels.GetMessages({
        channel: buildInputChannel(chat.id, chat.accessHash),
        id: messageIds.map((id) => new GramJs.InputMessageID({ id })),
      })
      : new GramJs.messages.GetMessages({
        id: messageIds.map((id) => new GramJs.InputMessageID({ id })),
      }),
    {
      shouldThrow: true,
    },
  );

  if (!result || result instanceof GramJs.messages.MessagesNotModified) {
    return undefined;
  }

  return result.messages.map(buildApiMessage).filter(Boolean);
}

let mediaQueue = Promise.resolve();

export function sendMessageLocal(
  params: SendMessageParams,
) {
  const {
    chat, lastMessageId, text, entities, replyInfo, suggestedPostInfo, attachment, sticker, story, gif, poll, todo,
    contact, scheduledAt, scheduleRepeatPeriod, groupedId, sendAs, wasDrafted, isInvertedMedia, effectId, isPending,
    messagePriceInStars, dice,
  } = params;

  if (!chat) return undefined;

  const {
    message: localMessage,
    poll: localPoll,
  } = buildLocalMessage({
    chat,
    lastMessageId,
    text,
    entities,
    replyInfo,
    suggestedPostInfo,
    attachment,
    sticker,
    gif,
    poll,
    todo,
    contact,
    groupedId,
    scheduledAt,
    scheduleRepeatPeriod,
    sendAs,
    story,
    isInvertedMedia,
    effectId,
    isPending,
    messagePriceInStars,
    dice,
  });

  sendApiUpdate({
    '@type': localMessage.isScheduled ? 'newScheduledMessage' : 'newMessage',
    id: localMessage.id,
    chatId: chat.id,
    message: localMessage,
    poll: localPoll,
    wasDrafted,
  });

  return Promise.resolve(localMessage);
}

export function sendApiMessage(
  params: SendMessageParams,
  localMessage: ApiMessage,
  onProgress?: ApiOnProgress,
) {
  const {
    chat, text, entities, replyInfo, suggestedPostInfo, suggestedMedia,
    attachment, sticker, story, gif, poll, todo, contact, dice,

    isSilent, scheduledAt, scheduleRepeatPeriod, groupedId, noWebPage, sendAs, shouldUpdateStickerSetOrder,
    isInvertedMedia, effectId, webPageMediaSize, webPageUrl, messagePriceInStars,
  } = params;

  if (!chat) return undefined;

  let isSendCompleted = false;
  const timeout = setTimeout(() => {
    if (isSendCompleted) return;

    sendApiUpdate({
      '@type': localMessage.isScheduled ? 'updateScheduledMessage' : 'updateMessage',
      id: localMessage.id,
      chatId: chat.id,
      message: {
        sendingState: 'messageSendingStatePending',
      },
      isFull: false,
    });
  }, FAST_SEND_TIMEOUT);
  const cancelSendingStatusTimeout = () => {
    isSendCompleted = true;
    clearTimeout(timeout);
  };

  const randomId = generateRandomBigInt();

  if (groupedId) {
    return sendGroupedMedia({
      chat,
      text,
      entities,
      replyInfo,
      suggestedPostInfo,
      attachment: attachment!,
      groupedId,
      isSilent,
      scheduledAt,
      scheduleRepeatPeriod,
      messagePriceInStars,
    }, randomId, localMessage, onProgress, cancelSendingStatusTimeout);
  }

  const messagePromise = (async () => {
    let media: GramJs.TypeInputMedia | undefined;

    if (suggestedPostInfo && suggestedMedia && !attachment) {
      if (suggestedMedia.photo) {
        const inputPhoto = buildInputPhoto(suggestedMedia.photo);
        if (inputPhoto) {
          media = new GramJs.InputMediaPhoto({
            id: inputPhoto,
            spoiler: suggestedMedia.photo.isSpoiler || undefined,
          });
        }
      } else if (suggestedMedia.video) {
        const inputDocument = buildInputDocument(suggestedMedia.video);
        if (inputDocument) {
          media = new GramJs.InputMediaDocument({
            id: inputDocument,
            spoiler: suggestedMedia.video.isSpoiler || undefined,
          });
        }
      } else if (suggestedMedia.document) {
        const document = suggestedMedia.document;
        if (document.id) {
          const localDocument = localDb.documents[document.id];
          if (localDocument) {
            const inputDocument = new GramJs.InputDocument({
              id: localDocument.id,
              accessHash: localDocument.accessHash,
              fileReference: localDocument.fileReference,
            });
            media = new GramJs.InputMediaDocument({
              id: inputDocument,
            });
          }
        }
      }
    }

    if (!media && attachment?.gif) {
      media = buildInputMediaDocument(attachment.gif, attachment.shouldSendAsSpoiler);
    }
    if (!media && attachment) {
      try {
        media = await uploadMedia(localMessage, attachment, onProgress!);
      } catch (err) {
        if (DEBUG) {
          // eslint-disable-next-line no-console
          console.warn(err);
        }

        await mediaQueue;

        return;
      }
    } else if (sticker) {
      media = buildInputMediaDocument(sticker);
    } else if (gif) {
      media = buildInputMediaDocument(gif);
    } else if (poll) {
      try {
        const attachedMedia = poll.attachedMedia
          ? await uploadMedia(localMessage, poll.attachedMedia, onProgress!)
          : undefined;
        const solutionMedia = poll.solutionMedia
          ? await uploadMedia(localMessage, poll.solutionMedia, onProgress!)
          : undefined;

        media = buildInputPoll(poll, randomId, {
          attachedMedia,
          solutionMedia,
        });
      } catch (err) {
        if (DEBUG) {
          // eslint-disable-next-line no-console
          console.warn(err);
        }

        await mediaQueue;

        return;
      }
    } else if (todo) {
      media = buildInputTodo(todo);
    } else if (story) {
      media = buildInputStory(story);
    } else if (webPageUrl && webPageMediaSize) {
      media = new GramJs.InputMediaWebPage({
        url: webPageUrl,
        forceLargeMedia: webPageMediaSize === 'large' ? true : undefined,
        forceSmallMedia: webPageMediaSize === 'small' ? true : undefined,
      });
    } else if (contact) {
      media = new GramJs.InputMediaContact({
        phoneNumber: contact.phoneNumber,
        firstName: contact.firstName,
        lastName: contact.lastName,
        vcard: DEFAULT_PRIMITIVES.STRING,
      });
    } else if (dice) {
      media = new GramJs.InputMediaDice({
        emoticon: dice,
      });
    }

    type SharedKeys<T, U> = {
      [K in keyof T & keyof U]:
      T[K] extends U[K] ? (U[K] extends T[K] ? K : never) : never
    }[keyof T & keyof U];
    type SharedRecord<T, U> = Pick<T, SharedKeys<T, U>>;

    type SendMediaArgs = ConstructorParameters<typeof GramJs.messages.SendMedia>[0];
    type SendMessageArgs = ConstructorParameters<typeof GramJs.messages.SendMessage>[0];

    type SharedArgs = SharedRecord<SendMediaArgs, SendMessageArgs>;

    const args: SharedArgs = {
      clearDraft: true,
      message: text || DEFAULT_PRIMITIVES.STRING,
      entities: entities ? entities.map(buildMtpMessageEntity) : undefined,
      peer: buildInputPeer(chat.id, chat.accessHash),
      randomId,
      replyTo: replyInfo && buildInputReplyTo(replyInfo),
      silent: isSilent || undefined,
      scheduleDate: scheduledAt,
      scheduleRepeatPeriod,
      sendAs: sendAs && buildInputPeer(sendAs.id, sendAs.accessHash),
      updateStickersetsOrder: shouldUpdateStickerSetOrder || undefined,
      invertMedia: isInvertedMedia || undefined,
      effect: effectId ? BigInt(effectId) : undefined,
      allowPaidStars: messagePriceInStars ? BigInt(messagePriceInStars) : undefined,
      suggestedPost: suggestedPostInfo && buildInputSuggestedPost(suggestedPostInfo),
    };

    try {
      let update;
      if (media) {
        update = await invokeRequest(new GramJs.messages.SendMedia({
          ...args,
          media,
        }), {
          shouldThrow: true,
          shouldIgnoreUpdates: true,
        });
      } else {
        update = await invokeRequest(new GramJs.messages.SendMessage({
          ...args,
          noWebpage: noWebPage || undefined,
        }), {
          shouldThrow: true,
          shouldIgnoreUpdates: true,
        });
      }

      cancelSendingStatusTimeout();
      if (update) handleLocalMessageUpdate(localMessage, update);
    } catch (error: any) {
      cancelSendingStatusTimeout();

      if (error.errorMessage === 'PRIVACY_PREMIUM_REQUIRED') {
        sendApiUpdate({ '@type': 'updateRequestUserUpdate', id: chat.id });
      }

      sendApiUpdate({
        '@type': localMessage.isScheduled ? 'updateScheduledMessageSendFailed' : 'updateMessageSendFailed',
        chatId: chat.id,
        localId: localMessage.id,
        error: error.errorMessage,
      });
    }
  })();

  return messagePromise;
}

export async function sendMessage(
  params: SendMessageParams,
  onProgress?: ApiOnProgress,
) {
  const localMessage = params.localMessage || await sendMessageLocal(params);
  return localMessage ? sendApiMessage(params, localMessage, onProgress) : undefined;
}

const groupedUploads: Record<string, {
  counter: number;
  singleMediaByIndex: Record<number, GramJs.InputSingleMedia>;
  localMessages: Record<string, ApiMessage>;
  cancelSendingStatusTimeouts: Record<string, NoneToVoidFunction>;
}> = {};

function sendGroupedMedia(
  {
    chat,
    text = DEFAULT_PRIMITIVES.STRING,
    entities,
    replyInfo,
    suggestedPostInfo,
    attachment,
    groupedId,
    isSilent,
    scheduledAt,
    scheduleRepeatPeriod,
    sendAs,
    messagePriceInStars,
  }: {
    chat: ApiChat;
    text?: string;
    entities?: ApiMessageEntity[];
    replyInfo?: ApiInputReplyInfo;
    suggestedPostInfo?: ApiInputSuggestedPostInfo;
    attachment: ApiAttachment;
    groupedId: string;
    isSilent?: boolean;
    scheduledAt?: number;
    scheduleRepeatPeriod?: number;
    sendAs?: ApiPeer;
    messagePriceInStars?: number;
  },
  randomId: GramJs.long,
  localMessage: ApiMessage,
  onProgress: ApiOnProgress | undefined,
  cancelSendingStatusTimeout: NoneToVoidFunction,
) {
  let groupIndex = -1;
  if (!groupedUploads[groupedId]) {
    groupedUploads[groupedId] = {
      counter: 0,
      singleMediaByIndex: {},
      localMessages: {},
      cancelSendingStatusTimeouts: {},
    };
  }

  groupIndex = groupedUploads[groupedId].counter++;

  const prevMediaQueue = mediaQueue;
  mediaQueue = (async () => {
    let inputMedia: GramJs.TypeInputMedia | undefined;

    if (attachment.gif) {
      inputMedia = buildInputMediaDocument(attachment.gif, attachment.shouldSendAsSpoiler);
    } else {
      let media;
      try {
        media = await uploadMedia(localMessage, attachment, onProgress!);
      } catch (err) {
        if (DEBUG) {
          // eslint-disable-next-line no-console
          console.warn(err);
        }

        groupedUploads[groupedId].counter--;

        await prevMediaQueue;

        return;
      }

      inputMedia = await fetchInputMedia(
        buildInputPeer(chat.id, chat.accessHash),
        media,
      );
    }

    await prevMediaQueue;

    if (!inputMedia) {
      groupedUploads[groupedId].counter--;

      if (DEBUG) {
        // eslint-disable-next-line no-console
        console.warn('Failed to upload grouped media');
      }

      return;
    }

    groupedUploads[groupedId].singleMediaByIndex[groupIndex] = new GramJs.InputSingleMedia({
      media: inputMedia,
      randomId,
      message: text,
      entities: entities ? entities.map(buildMtpMessageEntity) : undefined,
    });
    groupedUploads[groupedId].localMessages[randomId.toString()] = localMessage;
    groupedUploads[groupedId].cancelSendingStatusTimeouts[randomId.toString()] = cancelSendingStatusTimeout;

    if (Object.keys(groupedUploads[groupedId].singleMediaByIndex).length < groupedUploads[groupedId].counter) {
      return;
    }

    const { singleMediaByIndex, localMessages, cancelSendingStatusTimeouts } = groupedUploads[groupedId];
    delete groupedUploads[groupedId];
    const count = Object.values(singleMediaByIndex).length;

    const update = await invokeRequest(new GramJs.messages.SendMultiMedia({
      clearDraft: true,
      peer: buildInputPeer(chat.id, chat.accessHash),
      multiMedia: Object.values(singleMediaByIndex), // Object keys are usually ordered
      replyTo: replyInfo && buildInputReplyTo(replyInfo),
      ...(isSilent && { silent: isSilent }),
      ...(scheduledAt && { scheduleDate: scheduledAt }),
      ...(scheduleRepeatPeriod && { scheduleRepeatPeriod }),
      ...(sendAs && { sendAs: buildInputPeer(sendAs.id, sendAs.accessHash) }),
      ...(messagePriceInStars && { allowPaidStars: BigInt(messagePriceInStars * count) }),
      ...(suggestedPostInfo && { suggestedPost: buildInputSuggestedPost(suggestedPostInfo) }),
    }), {
      shouldIgnoreUpdates: true,
    });

    if (!update) return;

    Object.values(cancelSendingStatusTimeouts).forEach((cancel) => cancel());
    handleMultipleLocalMessagesUpdate(localMessages, update);
  })();

  return mediaQueue;
}

async function fetchInputMedia(
  peer: GramJs.TypeInputPeer,
  uploadedMedia: GramJs.InputMediaUploadedPhoto | GramJs.InputMediaUploadedDocument,
) {
  const messageMedia = await invokeRequest(new GramJs.messages.UploadMedia({
    peer,
    media: uploadedMedia,
  }));
  const isSpoiler = uploadedMedia.spoiler;

  if ((
    messageMedia instanceof GramJs.MessageMediaPhoto
    && messageMedia.photo
    && messageMedia.photo instanceof GramJs.Photo)
  ) {
    const { photo: { id, accessHash, fileReference } } = messageMedia;

    return new GramJs.InputMediaPhoto({
      id: new GramJs.InputPhoto({ id, accessHash, fileReference }),
      spoiler: isSpoiler,
    });
  }

  if ((
    messageMedia instanceof GramJs.MessageMediaDocument
    && messageMedia.document
    && messageMedia.document instanceof GramJs.Document)
  ) {
    const { document: { id, accessHash, fileReference } } = messageMedia;

    return new GramJs.InputMediaDocument({
      id: new GramJs.InputDocument({ id, accessHash, fileReference }),
      spoiler: isSpoiler,
    });
  }

  return undefined;
}

export async function editMessage({
  chat,
  message,
  text,
  entities,
  attachment,
  noWebPage,
}: {
  chat: ApiChat;
  message: ApiMessage;
  text: string;
  entities?: ApiMessageEntity[];
  attachment?: ApiAttachment;
  noWebPage?: boolean;
}, onProgress?: ApiOnProgress) {
  const isScheduled = message.date * 1000 > getServerTime() * 1000;

  const media = attachment && buildUploadingMedia(attachment);

  const isInvertedMedia = text && !attachment?.shouldSendAsFile ? message.isInvertedMedia : undefined;

  const newContent = {
    ...(media || message.content),
    ...(text && {
      text: {
        text,
        entities,
      },
    }),
  };

  const messageUpdate: ApiMessage = {
    ...message,
    content: newContent,
    isInvertedMedia,
  };

  sendApiUpdate({
    '@type': isScheduled ? 'updateScheduledMessage' : 'updateMessage',
    id: message.id,
    chatId: chat.id,
    message: messageUpdate,
    isFull: true,
  });

  try {
    let mediaUpdate: GramJs.TypeInputMedia | undefined;
    if (attachment) {
      mediaUpdate = await uploadMedia(message, attachment, onProgress!);
    }

    const mtpEntities = entities && entities.map(buildMtpMessageEntity);

    await invokeRequest(new GramJs.messages.EditMessage({
      message: text,
      entities: mtpEntities,
      media: mediaUpdate,
      peer: buildInputPeer(chat.id, chat.accessHash),
      id: message.id,
      ...(isScheduled && { scheduleDate: message.date }),
      ...(message.scheduleRepeatPeriod && { scheduleRepeatPeriod: message.scheduleRepeatPeriod }),
      ...(noWebPage && { noWebpage: noWebPage }),
      ...(isInvertedMedia && { invertMedia: isInvertedMedia }),
    }), { shouldThrow: true });
  } catch (err) {
    if (DEBUG) {
      // eslint-disable-next-line no-console
      console.warn(err);
    }

    const apiError = buildApiError(err as Error);

    sendApiUpdate({
      '@type': 'error',
      error: {
        ...apiError,
        hasErrorKey: true,
      },
    });

    // Rollback changes
    sendApiUpdate({
      '@type': isScheduled ? 'updateScheduledMessage' : 'updateMessage',
      id: message.id,
      chatId: chat.id,
      message,
      isFull: true,
    });
  }
}

export async function editTodo({
  chat,
  message,
  todo,
}: {
  chat: ApiChat;
  message: ApiMessage;
  todo: ApiNewMediaTodo;
}) {
  const media = buildInputTodo(todo);
  const isScheduled = message.date * 1000 > getServerTime() * 1000;

  const newContent: MediaContent = {
    ...message.content,
    todo: {
      mediaType: 'todo',
      todo: todo.todo,
    },
  };

  const messageUpdate: ApiMessage = {
    ...message,
    content: newContent,
  };

  sendApiUpdate({
    '@type': isScheduled ? 'updateScheduledMessage' : 'updateMessage',
    id: message.id,
    chatId: chat.id,
    message: messageUpdate,
    isFull: true,
  });

  try {
    await invokeRequest(new GramJs.messages.EditMessage({
      media,
      peer: buildInputPeer(chat.id, chat.accessHash),
      id: message.id,
    }), { shouldThrow: true });
  } catch (err) {
    if (DEBUG) {
      // eslint-disable-next-line no-console
      console.warn(err);
    }

    const apiError = buildApiError(err as Error);

    sendApiUpdate({
      '@type': 'error',
      error: {
        ...apiError,
        hasErrorKey: true,
      },
    });

    // Rollback changes
    sendApiUpdate({
      '@type': 'updateMessage',
      id: message.id,
      chatId: chat.id,
      message,
      isFull: true,
    });
  }
}

export async function appendTodoList({
  chat,
  message,
  items,
}: {
  chat: ApiChat;
  message: ApiMessage;
  items: ApiTodoItem[];
}) {
  const todoItems = items.map((item) => {
    return new GramJs.TodoItem({
      id: item.id,
      title: buildInputTextWithEntities(item.title),
    });
  });

  try {
    await invokeRequest(new GramJs.messages.AppendTodoList({
      peer: buildInputPeer(chat.id, chat.accessHash),
      msgId: message.id,
      list: todoItems,
    }), { shouldThrow: true });
  } catch (err) {
    if (DEBUG) {
      // eslint-disable-next-line no-console
      console.warn(err);
    }

    const apiError = buildApiError(err as Error);

    sendApiUpdate({
      '@type': 'error',
      error: {
        ...apiError,
        hasErrorKey: true,
      },
    });
  }
}

export async function rescheduleMessage({
  chat,
  message,
  scheduledAt,
  scheduleRepeatPeriod,
}: {
  chat: ApiChat;
  message: ApiMessage;
  scheduledAt: number;
  scheduleRepeatPeriod?: number;
}) {
  await invokeRequest(new GramJs.messages.EditMessage({
    peer: buildInputPeer(chat.id, chat.accessHash),
    id: message.id,
    scheduleDate: scheduledAt,
    scheduleRepeatPeriod,
  }));
}

async function uploadMedia(message: ApiMessage, attachment: ApiAttachment, onProgress: ApiOnProgress) {
  const {
    filename, blobUrl, mimeType, quick, voice, audio, previewBlobUrl, shouldSendAsFile, shouldSendAsSpoiler, ttlSeconds,
  } = attachment;

  const patchedOnProgress: ApiOnProgress = (progress) => {
    if (onProgress.isCanceled) {
      patchedOnProgress.isCanceled = true;
    } else {
      onProgress(progress, getMessageKey(message));
    }
  };

  const fetchAndUpload = async (url: string, progressCallback?: (progress: number) => void) => {
    const file = await fetchFile(url, filename);
    return uploadFile(file, progressCallback);
  };

  const isVideo = SUPPORTED_VIDEO_CONTENT_TYPES.has(mimeType);
  const shouldUploadThumb = audio || isVideo || shouldSendAsFile;

  const [inputFile, thumb] = await Promise.all(compact([
    fetchAndUpload(blobUrl, patchedOnProgress),
    shouldUploadThumb && previewBlobUrl && fetchAndUpload(previewBlobUrl),
  ]));

  const attributes: GramJs.TypeDocumentAttribute[] = [new GramJs.DocumentAttributeFilename({ fileName: filename })];
  if (!shouldSendAsFile) {
    if (quick) {
      if (SUPPORTED_PHOTO_CONTENT_TYPES.has(mimeType) && mimeType !== GIF_MIME_TYPE) {
        return new GramJs.InputMediaUploadedPhoto({
          file: inputFile,
          spoiler: shouldSendAsSpoiler,
        });
      }

      if (isVideo) {
        const { width, height, duration } = quick;
        if (duration !== undefined) {
          attributes.push(new GramJs.DocumentAttributeVideo({
            duration,
            w: width,
            h: height,
            supportsStreaming: true,
          }));
        }
      }
    }

    if (audio) {
      const { duration, title, performer } = audio;
      attributes.push(new GramJs.DocumentAttributeAudio({
        duration,
        title,
        performer,
      }));
    }

    if (voice) {
      const { duration, waveform } = voice;
      const { data: inputWaveform } = interpolateArray(waveform, INPUT_WAVEFORM_LENGTH);
      attributes.push(new GramJs.DocumentAttributeAudio({
        voice: true,
        duration,
        waveform: Buffer.from(inputWaveform),
      }));
    }
  }

  return new GramJs.InputMediaUploadedDocument({
    file: inputFile,
    mimeType,
    attributes,
    thumb,
    forceFile: shouldSendAsFile,
    spoiler: shouldSendAsSpoiler,
    ttlSeconds,
  });
}

export async function pinMessage({
  chat, messageId, isUnpin, isOneSide, isSilent,
}: { chat: ApiChat; messageId: number; isUnpin: boolean; isOneSide?: boolean; isSilent?: boolean }) {
  await invokeRequest(new GramJs.messages.UpdatePinnedMessage({
    peer: buildInputPeer(chat.id, chat.accessHash),
    id: messageId,
    ...(isUnpin && { unpin: true }),
    ...(isOneSide && { pmOneside: true }),
    ...(isSilent && { silent: true }),
  }));
}

export async function unpinAllMessages({ chat, threadId }: { chat: ApiChat; threadId?: ThreadId }) {
  const result = await invokeRequest(new GramJs.messages.UnpinAllMessages({
    peer: buildInputPeer(chat.id, chat.accessHash),
    ...(threadId && { topMsgId: Number(threadId) }),
  }));

  if (!result) return;

  processAffectedHistory(chat, result);

  if (result.offset) {
    await unpinAllMessages({ chat, threadId });
  }
}

export async function deleteMessages({
  chat, messageIds, shouldDeleteForAll,
}: {
  chat: ApiChat; messageIds: number[]; shouldDeleteForAll?: boolean;
}) {
  const isChannel = getEntityTypeById(chat.id) === 'channel';

  const result = await invokeRequest(
    isChannel
      ? new GramJs.channels.DeleteMessages({
        channel: buildInputChannel(chat.id, chat.accessHash),
        id: messageIds,
      })
      : new GramJs.messages.DeleteMessages({
        id: messageIds,
        ...(shouldDeleteForAll && { revoke: true }),
      }),
  );

  if (!result) {
    return;
  }

  processAffectedHistory(chat, result);

  sendApiUpdate({
    '@type': 'deleteMessages',
    ids: messageIds,
    ...(isChannel && { chatId: chat.id }),
  });
}

export async function deleteParticipantHistory({
  chat, peer, isRepeat = false,
}: {
  chat: ApiChat; peer: ApiPeer; isRepeat?: boolean;
}) {
  const result = await invokeRequest(
    new GramJs.channels.DeleteParticipantHistory({
      channel: buildInputChannel(chat.id, chat.accessHash),
      participant: buildInputPeer(peer.id, peer.accessHash),
    }),
  );

  if (!result) {
    return;
  }

  processAffectedHistory(chat, result);

  if (!isRepeat) {
    sendApiUpdate({
      '@type': 'deleteParticipantHistory',
      chatId: chat.id,
      peerId: peer.id,
    });
  }

  if (result.offset) {
    await deleteParticipantHistory({ chat, peer, isRepeat: true });
  }
}

export function editChatParticipantRank({
  chat, peer, rank,
}: {
  chat: ApiChat;
  peer: ApiPeer;
  rank: string;
}) {
  const participant = buildInputPeer(peer.id, peer.accessHash);

  return invokeRequest(new GramJs.messages.EditChatParticipantRank({
    peer: buildInputPeer(chat.id, chat.accessHash),
    participant,
    rank,
  }), {
    shouldReturnTrue: true,
  });
}

export function deleteScheduledMessages({
  chat, messageIds,
}: {
  chat: ApiChat; messageIds: number[];
}) {
  invokeRequest(new GramJs.messages.DeleteScheduledMessages({
    peer: buildInputPeer(chat.id, chat.accessHash),
    id: messageIds,
  }));
}

export async function deleteHistory({
  chat, shouldDeleteForAll, maxId,
}: {
  chat: ApiChat; shouldDeleteForAll?: boolean; maxId?: number;
}) {
  const isChannel = getEntityTypeById(chat.id) === 'channel';
  const result = await invokeRequest(
    isChannel
      ? new GramJs.channels.DeleteHistory({
        channel: buildInputChannel(chat.id, chat.accessHash),
        maxId: maxId ?? DEFAULT_PRIMITIVES.INT,
      })
      : new GramJs.messages.DeleteHistory({
        peer: buildInputPeer(chat.id, chat.accessHash),
        maxId: maxId ?? DEFAULT_PRIMITIVES.INT,
        ...(shouldDeleteForAll && { revoke: true }),
        ...(!shouldDeleteForAll && { just_clear: true }),
      }),
  );

  if (!result) {
    return;
  }

  if ('offset' in result) {
    processAffectedHistory(chat, result);

    if (result.offset) {
      await deleteHistory({ chat, shouldDeleteForAll });
      return;
    }
  }

  sendApiUpdate({
    '@type': 'deleteHistory',
    chatId: chat.id,
  });
}

export async function deleteSavedHistory({
  chat,
}: {
  chat: ApiChat;
}) {
  const result = await invokeRequest(new GramJs.messages.DeleteSavedHistory({
    peer: buildInputPeer(chat.id, chat.accessHash),
    maxId: DEFAULT_PRIMITIVES.INT,
  }));

  if (!result) {
    return;
  }

  processAffectedHistory(chat, result);

  if (result.offset) {
    await deleteSavedHistory({ chat });
    return;
  }

  sendApiUpdate({
    '@type': 'deleteSavedHistory',
    chatId: chat.id,
  });
}

export async function toggleSuggestedPostApproval({
  chat,
  messageId,
  reject,
  scheduleDate,
  rejectComment,
}: {
  chat: ApiChat;
  messageId: number;
  reject?: boolean;
  scheduleDate?: number;
  rejectComment?: string;
}) {
  const result = await invokeRequest(new GramJs.messages.ToggleSuggestedPostApproval({
    peer: buildInputPeer(chat.id, chat.accessHash),
    msgId: messageId,
    reject: reject || undefined,
    scheduleDate,
    rejectComment,
  }));

  return result;
}

export async function reportMessages({
  peer, messageIds, description, option,
}: {
  peer: ApiPeer; messageIds: number[]; description: string; option: string;
}) {
  try {
    const result = await invokeRequest(new GramJs.messages.Report({
      peer: buildInputPeer(peer.id, peer.accessHash),
      id: messageIds,
      option: deserializeBytes(option),
      message: description,
    }), { shouldThrow: true });

    if (!result) return undefined;

    return { result: buildApiReportResult(result), error: undefined };
  } catch (err: any) {
    const errorMessage = (err as ApiError).message;

    if (errorMessage === MESSAGE_ID_REQUIRED_ERROR) {
      return {
        result: undefined,
        error: errorMessage,
      };
    }

    throw err;
  }
}

export function reportChannelSpam({
  peer, chat, messageIds,
}: {
  peer: ApiPeer; chat: ApiChat; messageIds: number[];
}) {
  return invokeRequest(new GramJs.channels.ReportSpam({
    participant: buildInputPeer(peer.id, peer.accessHash),
    channel: buildInputChannel(chat.id, chat.accessHash),
    id: messageIds,
  }));
}

export async function sendMessageAction({
  peer, threadId, action,
}: {
  peer: ApiPeer; threadId?: ThreadId; action: ApiSendMessageAction;
}) {
  const mtpAction = buildSendMessageAction(action);
  if (!mtpAction) {
    if (DEBUG) {
      // eslint-disable-next-line no-console
      console.warn('Unsupported message action', action);
    }
    return undefined;
  }

  try {
    const result = await invokeRequest(new GramJs.messages.SetTyping({
      peer: buildInputPeer(peer.id, peer.accessHash),
      topMsgId: Number(threadId),
      action: mtpAction,
    }), {
      shouldThrow: true,
      abortControllerChatId: peer.id,
      abortControllerThreadId: threadId,
    });
    return result;
  } catch (error) {
    // Prevent error from being displayed in UI
  }
  return undefined;
}

export async function markMessageListRead({
  chat, threadId, maxId = 0,
}: {
  chat: ApiChat; threadId: ThreadId; maxId?: number;
}) {
  const isChannel = getEntityTypeById(chat.id) === 'channel';

  if (isChannel && threadId === MAIN_THREAD_ID) {
    await invokeRequest(new GramJs.channels.ReadHistory({
      channel: buildInputChannel(chat.id, chat.accessHash),
      maxId,
    }));
  } else if (threadId !== MAIN_THREAD_ID) {
    await invokeRequest(new GramJs.messages.ReadDiscussion({
      peer: buildInputPeer(chat.id, chat.accessHash),
      msgId: Number(threadId),
      readMaxId: maxId,
    }));
  } else {
    const result = await invokeRequest(new GramJs.messages.ReadHistory({
      peer: buildInputPeer(chat.id, chat.accessHash),
      maxId,
    }));

    if (result) {
      processAffectedHistory(chat, result);
    }
  }

  if (threadId === MAIN_THREAD_ID) {
    void requestChatUpdate({ chat, noLastMessage: true });
  } else if (chat.isForum) {
    sendApiUpdate({
      '@type': 'updateTopic',
      chatId: chat.id,
      topicId: Number(threadId),
    });
  } else {
    sendApiUpdate({
      '@type': 'updateDiscussion',
      chatId: chat.id,
      threadId: Number(threadId),
    });
  }
}

export async function markMessagesRead({
  chat, messageIds,
}: {
  chat: ApiChat; messageIds: number[];
}) {
  const isChannel = getEntityTypeById(chat.id) === 'channel';

  const result = await invokeRequest(
    isChannel
      ? new GramJs.channels.ReadMessageContents({
        channel: buildInputChannel(chat.id, chat.accessHash),
        id: messageIds,
      })
      : new GramJs.messages.ReadMessageContents({
        id: messageIds,
      }),
  );

  if (!result) {
    return;
  }

  if (result !== true) {
    processAffectedHistory(chat, result);
  }

  sendApiUpdate({
    ...(isChannel ? {
      '@type': 'updateChannelMessages',
      channelId: chat.id,
    } : {
      '@type': 'updateCommonBoxMessages',
    }),
    ids: messageIds,
    messageUpdate: {
      hasUnreadMention: false,
      isMediaUnread: false,
    },
  });
}

export async function fetchMessageViews({
  chat, ids, shouldIncrement,
}: {
  chat: ApiChat;
  ids: number[];
  shouldIncrement?: boolean;
}) {
  const chunks = split(ids, API_GENERAL_ID_LIMIT);
  const results = await Promise.all(chunks.map((chunkIds) => (
    invokeRequest(new GramJs.messages.GetMessagesViews({
      peer: buildInputPeer(chat.id, chat.accessHash),
      id: chunkIds,
      increment: Boolean(shouldIncrement),
    }))
  )));

  if (!results || results.some((result) => !result)) return undefined;

  const viewsList = results.flatMap((result) => result!.views);

  const viewsInfo = ids.map((id, index) => {
    const { views, forwards, replies } = viewsList[index];
    return {
      id,
      views,
      forwards,
      threadInfo: replies ? buildApiThreadInfo(chat.id, id, replies) : undefined,
    };
  });

  return {
    viewsInfo,
  };
}

export async function fetchFactChecks({
  chat, ids,
}: {
  chat: ApiChat;
  ids: number[];
}) {
  const chunks = split(ids, API_GENERAL_ID_LIMIT);
  const results = await Promise.all(chunks.map((chunkIds) => (
    invokeRequest(new GramJs.messages.GetFactCheck({
      peer: buildInputPeer(chat.id, chat.accessHash),
      msgId: chunkIds,
    }))
  )));

  if (!results || results.some((result) => !result)) return undefined;

  return results.flatMap((result) => result!).map(buildApiFactCheck);
}

export function fetchPaidReactionPrivacy() {
  return invokeRequest(new GramJs.messages.GetPaidReactionPrivacy(), { shouldReturnTrue: true });
}

export function reportMessagesDelivery({
  chat, messageIds,
}: {
  chat: ApiChat;
  messageIds: number[];
}) {
  return invokeRequest(new GramJs.messages.ReportMessagesDelivery({
    peer: buildInputPeer(chat.id, chat.accessHash),
    id: messageIds,
  }));
}

export async function fetchDiscussionMessage({
  chat, messageId,
}: {
  chat: ApiChat;
  messageId: number;
}) {
  const [result, replies] = await Promise.all([
    invokeRequest(new GramJs.messages.GetDiscussionMessage({
      peer: buildInputPeer(chat.id, chat.accessHash),
      msgId: messageId,
    }), {
      abortControllerChatId: chat.id,
      abortControllerThreadId: messageId,
    }),
    fetchMessages({
      chat,
      threadId: messageId,
      offsetId: 1,
      addOffset: -1,
      limit: 1,
    }),
  ]);

  if (!result || !replies) return undefined;

  const topMessages = result.messages.map(buildApiMessage).filter(Boolean);
  const messages = topMessages.concat(replies.messages);
  const threadId = result.messages[result.messages.length - 1]?.id;

  const chatId = topMessages[0]?.chatId;
  if (!chatId || !threadId) return undefined;

  const { maxId } = result;
  const threadReadState = buildThreadReadState(result);

  const topMessageWithReplies = result.messages.find((message): message is GramJs.Message => (
    message instanceof GramJs.Message && Boolean(message.replies)
  ))!;
  const threadInfo = buildApiThreadInfoFromMessage(topMessageWithReplies);

  return {
    messages,
    topMessages,
    threadId,
    threadReadState,
    threadInfo,
    lastMessageId: maxId,
    chatId: topMessages[0]?.chatId,
    firstMessageId: replies.messages[0]?.id,
  };
}

export async function searchMessagesInChat({
  peer,
  isSavedDialog,
  savedTag,
  type,
  query = DEFAULT_PRIMITIVES.STRING,
  threadId,
  minDate,
  maxDate,
  offsetId,
  addOffset,
  limit,
  fromPeer,
}: {
  peer: ApiPeer;
  isSavedDialog?: boolean;
  savedTag?: ApiReaction;
  type?: ApiMessageSearchType | ApiGlobalMessageSearchType;
  query?: string;
  threadId?: ThreadId;
  offsetId?: number;
  addOffset?: number;
  limit: number;
  minDate?: number;
  maxDate?: number;
  fromPeer?: ApiPeer;
}): Promise<SearchResults | undefined> {
  let filter;
  switch (type) {
    case 'media':
      filter = new GramJs.InputMessagesFilterPhotoVideo();
      break;
    case 'documents':
      filter = new GramJs.InputMessagesFilterDocument();
      break;
    case 'links':
      filter = new GramJs.InputMessagesFilterUrl();
      break;
    case 'audio':
      filter = new GramJs.InputMessagesFilterMusic();
      break;
    case 'voice':
      filter = new GramJs.InputMessagesFilterRoundVoice();
      break;
    case 'profilePhoto':
      filter = new GramJs.InputMessagesFilterChatPhotos();
      break;
    case 'gif':
      filter = new GramJs.InputMessagesFilterGif();
      break;
    case 'text':
    default: {
      filter = new GramJs.InputMessagesFilterEmpty();
    }
  }

  const inputPeer = buildInputPeer(peer.id, peer.accessHash);
  const inputFromPeer = fromPeer ? buildInputPeer(fromPeer.id, fromPeer.accessHash) : undefined;

  const result = await invokeRequest(new GramJs.messages.Search({
    peer: isSavedDialog ? new GramJs.InputPeerSelf() : inputPeer,
    savedPeerId: isSavedDialog ? inputPeer : undefined,
    savedReaction: savedTag && [buildInputReaction(savedTag)],
    topMsgId: threadId !== MAIN_THREAD_ID && !isSavedDialog ? Number(threadId) : undefined,
    filter,
    q: query,
    fromId: inputFromPeer,
    minDate: minDate ?? DEFAULT_PRIMITIVES.INT,
    maxDate: maxDate ?? DEFAULT_PRIMITIVES.INT,
    maxId: DEFAULT_PRIMITIVES.INT,
    minId: DEFAULT_PRIMITIVES.INT,
    hash: DEFAULT_PRIMITIVES.BIGINT,
    offsetId: offsetId ?? DEFAULT_PRIMITIVES.INT,
    addOffset: addOffset ?? DEFAULT_PRIMITIVES.INT,
    limit,
  }), {
    abortControllerChatId: peer.id,
    abortControllerThreadId: threadId,
  });

  if (
    !result
    || result instanceof GramJs.messages.MessagesNotModified
    || !result.messages
  ) {
    return undefined;
  }

  const userStatusesById = buildApiUserStatuses(result.users);
  const messages = result.messages.map(buildApiMessage).filter(Boolean);
  const topics = result.topics.map(buildApiTopicWithState).filter(Boolean);

  let totalCount = messages.length;
  let nextOffsetId: number | undefined;
  if (result instanceof GramJs.messages.MessagesSlice || result instanceof GramJs.messages.ChannelMessages) {
    totalCount = result.count;

    if (messages.length) {
      nextOffsetId = messages[messages.length - 1].id;
    }
  }

  return {
    userStatusesById,
    messages,
    topics,
    totalCount,
    nextOffsetId,
  };
}

export async function searchMessagesGlobal({
  query, offsetRate = 0, offsetPeer, offsetId, limit, type = 'text', minDate, maxDate, context = 'all',
}: {
  query: string;
  offsetRate?: number;
  offsetPeer?: ApiPeer;
  offsetId?: number;
  limit: number;
  type?: ApiGlobalMessageSearchType;
  context?: ApiMessageSearchContext;
  minDate?: number;
  maxDate?: number;
}): Promise<SearchResults | undefined> {
  if (type === 'publicPosts') {
    return searchPublicPosts({
      query,
      offsetRate,
      offsetPeer,
      offsetId,
      limit,
    });
  }

  let filter;
  switch (type) {
    case 'media':
      filter = new GramJs.InputMessagesFilterPhotoVideo();
      break;
    case 'documents':
      filter = new GramJs.InputMessagesFilterDocument();
      break;
    case 'links':
      filter = new GramJs.InputMessagesFilterUrl();
      break;
    case 'audio':
      filter = new GramJs.InputMessagesFilterMusic();
      break;
    case 'voice':
      filter = new GramJs.InputMessagesFilterRoundVoice();
      break;
    case 'text':
    default: {
      if (!query && !(maxDate && minDate)) {
        return undefined;
      }

      filter = new GramJs.InputMessagesFilterEmpty();
    }
  }

  const peer = (offsetPeer && buildInputPeer(offsetPeer.id, offsetPeer.accessHash)) || new GramJs.InputPeerEmpty();

  const result = await invokeRequest(new GramJs.messages.SearchGlobal({
    q: query,
    offsetRate,
    offsetPeer: peer,
    offsetId: offsetId ?? DEFAULT_PRIMITIVES.INT,
    broadcastsOnly: type === 'channels' || context === 'channels' || undefined,
    groupsOnly: context === 'groups' || undefined,
    usersOnly: context === 'users' || undefined,
    limit,
    filter,
    minDate: minDate ?? DEFAULT_PRIMITIVES.INT,
    maxDate: maxDate ?? DEFAULT_PRIMITIVES.INT,
  }));

  if (
    !result
    || result instanceof GramJs.messages.MessagesNotModified
    || !result.messages
  ) {
    return undefined;
  }

  const userStatusesById = buildApiUserStatuses(result.users);
  const messages = result.messages.map(buildApiMessage).filter(Boolean);
  const topics = result.topics.map(buildApiTopicWithState).filter(Boolean);

  let totalCount;
  if (result instanceof GramJs.messages.MessagesSlice || result instanceof GramJs.messages.ChannelMessages) {
    totalCount = result.count;
  } else {
    totalCount = result.messages.length;
  }

  const lastMessage = result.messages[result.messages.length - 1];
  const nextOffsetPeerId = resolveMessageApiChatId(lastMessage);
  const nextOffsetRate = 'nextRate' in result && result.nextRate ? result.nextRate : undefined;
  const nextOffsetId = lastMessage?.id;

  return {
    messages,
    topics,
    userStatusesById,
    totalCount,
    nextOffsetRate,
    nextOffsetPeerId,
    nextOffsetId,
  };
}

export async function searchPublicPosts({
  hashtag, query, offsetRate, offsetPeer, offsetId, limit,
}: {
  hashtag?: string;
  query?: string;
  offsetRate?: number;
  offsetPeer?: ApiPeer;
  offsetId?: number;
  limit?: number;
}): Promise<SearchResults | undefined> {
  const peer = (offsetPeer && buildInputPeer(offsetPeer.id, offsetPeer.accessHash)) || new GramJs.InputPeerEmpty();

  const resultFlood = await checkSearchPostsFlood(query);

  if (!resultFlood) {
    return undefined;
  }

  const result = await invokeRequest(new GramJs.channels.SearchPosts({
    hashtag,
    query,
    offsetRate: offsetRate ?? DEFAULT_PRIMITIVES.INT,
    offsetId: offsetId ?? DEFAULT_PRIMITIVES.INT,
    offsetPeer: peer,
    limit: limit ?? DEFAULT_PRIMITIVES.INT,
    allowPaidStars: BigInt(resultFlood.starsAmount),
  }));

  if (!result || result instanceof GramJs.messages.MessagesNotModified) {
    return undefined;
  }

  const userStatusesById = buildApiUserStatuses(result.users);
  const messages = result.messages.map(buildApiMessage).filter(Boolean);
  const topics = result.topics.map(buildApiTopicWithState).filter(Boolean);

  let totalCount;
  if (result instanceof GramJs.messages.MessagesSlice || result instanceof GramJs.messages.ChannelMessages) {
    totalCount = result.count;
  } else {
    totalCount = result.messages.length;
  }

  const lastMessage = result.messages[result.messages.length - 1];
  const nextOffsetPeerId = resolveMessageApiChatId(lastMessage);
  const nextOffsetRate = 'nextRate' in result && result.nextRate ? result.nextRate : undefined;
  const nextOffsetId = lastMessage?.id;

  const searchFlood = result instanceof GramJs.messages.MessagesSlice && result.searchFlood
    ? buildApiSearchPostsFlood(result.searchFlood, query)
    : undefined;

  return {
    messages,
    topics,
    userStatusesById,
    totalCount,
    nextOffsetRate,
    nextOffsetPeerId,
    nextOffsetId,
    searchFlood,
  };
}

export async function checkSearchPostsFlood(query?: string) {
  const result = await invokeRequest(new GramJs.channels.CheckSearchPostsFlood({ query }));

  if (!result) {
    return undefined;
  }

  return buildApiSearchPostsFlood(result, query);
}

export async function fetchWebPagePreview({
  text,
}: {
  text: ApiFormattedText;
}) {
  const textWithEntities = buildInputTextWithEntities(text);
  const preview = await invokeRequest(new GramJs.messages.GetWebPagePreview({
    message: textWithEntities.text,
    entities: textWithEntities.entities,
  }));

  if (!preview) return undefined;

  return buildWebPageFromMedia(preview.media);
}

export async function sendPollVote({
  chat, messageId, options,
}: {
  chat: ApiChat;
  messageId: number;
  options: string[];
}) {
  const { id, accessHash } = chat;

  await invokeRequest(new GramJs.messages.SendVote({
    peer: buildInputPeer(id, accessHash),
    msgId: messageId,
    options: options.map(deserializeBytes),
  }));
}

export async function appendPollAnswer({
  chat, messageId, text,
}: {
  chat: ApiChat;
  messageId: number;
  text: string;
}) {
  const { id, accessHash } = chat;

  await invokeRequest(new GramJs.messages.AddPollAnswer({
    peer: buildInputPeer(id, accessHash),
    msgId: messageId,
    answer: new GramJs.InputPollAnswer({
      text: buildInputTextWithEntities({ text }),
    }),
  }));
}

export async function toggleTodoCompleted({
  chat, messageId, completedIds, incompletedIds,
}: {
  chat: ApiChat;
  messageId: number;
  completedIds: number[];
  incompletedIds: number[];
}) {
  const { id, accessHash } = chat;

  await invokeRequest(new GramJs.messages.ToggleTodoCompleted({
    peer: buildInputPeer(id, accessHash),
    msgId: messageId,
    completed: completedIds,
    incompleted: incompletedIds,
  }));
}

export async function closePoll({
  chat, messageId, poll,
}: {
  chat: ApiChat;
  messageId: number;
  poll: ApiMessagePoll;
}) {
  const { id, accessHash } = chat;

  await invokeRequest(new GramJs.messages.EditMessage({
    peer: buildInputPeer(id, accessHash),
    id: messageId,
    media: buildInputPollFromExisting(poll, true),
  }));
}

export async function loadPollOptionResults({
  chat, messageId, option, offset, limit, shouldResetVoters,
}: {
  chat: ApiChat;
  messageId: number;
  option?: string;
  offset?: string;
  limit?: number;
  shouldResetVoters?: boolean;
}) {
  const { id, accessHash } = chat;

  const result = await invokeRequest(new GramJs.messages.GetPollVotes({
    peer: buildInputPeer(id, accessHash),
    id: messageId,
    limit: limit ?? DEFAULT_PRIMITIVES.INT,
    option: option ? deserializeBytes(option) : undefined,
    offset,
  }));

  if (!result) {
    return undefined;
  }

  const votes = result.votes.map((vote) => ({
    peerId: getApiChatIdFromMtpPeer(vote.peer),
    date: vote.date,
  }));

  return {
    count: result.count,
    votes,
    nextOffset: result.nextOffset,
    shouldResetVoters,
  };
}

export async function fetchExtendedMedia({
  chat, ids,
}: {
  chat: ApiChat;
  ids: number[];
}) {
  await invokeRequest(new GramJs.messages.GetExtendedMedia({
    peer: buildInputPeer(chat.id, chat.accessHash),
    id: ids,
  }));
}

export function forwardMessagesLocal(params: ForwardMessagesParams) {
  const {
    toChat, toThreadId, messages,
    scheduledAt, scheduleRepeatPeriod, sendAs, noAuthors, noCaptions,
    isCurrentUserPremium, wasDrafted, lastMessageId, effectId,
  } = params;

  const messageIds = messages.map(({ id }) => id);
  const localMessages: ApiMessage[] = [];

  messages.forEach((message, index) => {
    const localMessage = buildLocalForwardedMessage({
      toChat,
      toThreadId: Number(toThreadId),
      message,
      scheduledAt,
      scheduleRepeatPeriod,
      noAuthors,
      noCaptions,
      isCurrentUserPremium,
      lastMessageId,
      sendAs,
      effectId: index === 0 ? effectId : undefined,
    });
    localMessages.push(localMessage);

    sendApiUpdate({
      '@type': localMessage.isScheduled ? 'newScheduledMessage' : 'newMessage',
      id: localMessage.id,
      chatId: toChat.id,
      message: localMessage,
      wasDrafted,
    });
  });
  return Promise.resolve({ messageIds, localMessages });
}

export async function forwardApiMessages(params: ForwardMessagesParams) {
  const {
    fromChat, toChat, toThreadId, isSilent,
    scheduledAt, scheduleRepeatPeriod, sendAs, withMyScore, noAuthors, noCaptions,
    forwardedLocalMessagesSlice, messagePriceInStars, effectId,
  } = params;

  if (!forwardedLocalMessagesSlice) return;

  const {
    messageIds, localMessages,
  } = forwardedLocalMessagesSlice;

  const priceInStars = messagePriceInStars ? messagePriceInStars * messageIds.length : undefined;

  const randomIds = messageIds.map(() => generateRandomBigInt());
  try {
    const update = await invokeRequest(new GramJs.messages.ForwardMessages({
      fromPeer: buildInputPeer(fromChat.id, fromChat.accessHash),
      toPeer: buildInputPeer(toChat.id, toChat.accessHash),
      randomId: randomIds,
      id: messageIds,
      withMyScore: withMyScore || undefined,
      silent: isSilent || undefined,
      dropAuthor: noAuthors || undefined,
      dropMediaCaptions: noCaptions || undefined,
      ...(toThreadId && { topMsgId: Number(toThreadId) }),
      ...(scheduledAt && { scheduleDate: scheduledAt }),
      ...(scheduleRepeatPeriod && { scheduleRepeatPeriod }),
      ...(sendAs && { sendAs: buildInputPeer(sendAs.id, sendAs.accessHash) }),
      ...(priceInStars && { allowPaidStars: BigInt(priceInStars) }),
      effect: effectId ? BigInt(effectId) : undefined,
    }), {
      shouldThrow: true,
      shouldIgnoreUpdates: true,
    });
    const messagesForUpdate: Record<string, ApiMessage> = {};
    localMessages.forEach((message, index) => {
      messagesForUpdate[randomIds[index].toString()] = message;
    });
    if (update) handleMultipleLocalMessagesUpdate(messagesForUpdate, update);
  } catch (error: any) {
    Object.values(localMessages).forEach((localMessage) => {
      sendApiUpdate({
        '@type': localMessage.isScheduled ? 'updateScheduledMessageSendFailed' : 'updateMessageSendFailed',
        chatId: toChat.id,
        localId: localMessage.id,
        error: error.errorMessage,
      });
    });
  }
}

export async function forwardMessages(params: ForwardMessagesParams) {
  if (params.forwardedLocalMessagesSlice) {
    await forwardApiMessages(params);
  } else {
    const newParams = {
      ...params,
      forwardedLocalMessagesSlice: await forwardMessagesLocal(params),
    };
    await forwardApiMessages(newParams);
  }
}

export async function findFirstMessageIdAfterDate({
  chat,
  timestamp,
}: {
  chat: ApiChat;
  timestamp: number;
}) {
  const result = await invokeRequest(new GramJs.messages.GetHistory({
    peer: buildInputPeer(chat.id, chat.accessHash),
    offsetDate: timestamp,
    addOffset: -1,
    limit: 1,
    offsetId: DEFAULT_PRIMITIVES.INT,
    maxId: DEFAULT_PRIMITIVES.INT,
    minId: DEFAULT_PRIMITIVES.INT,
    hash: DEFAULT_PRIMITIVES.BIGINT,
  }));

  if (
    !result
    || result instanceof GramJs.messages.MessagesNotModified
    || !result.messages || !result.messages.length
  ) {
    return undefined;
  }

  return result.messages[0].id;
}

export async function fetchScheduledHistory({ chat }: { chat: ApiChat }) {
  const { id, accessHash } = chat;

  const result = await invokeRequest(new GramJs.messages.GetScheduledHistory({
    peer: buildInputPeer(id, accessHash),
    hash: DEFAULT_PRIMITIVES.BIGINT,
  }), {
    abortControllerChatId: id,
  });

  if (
    !result
    || result instanceof GramJs.messages.MessagesNotModified
    || !result.messages
  ) {
    return undefined;
  }

  const messages = result.messages.map(buildApiMessage).filter(Boolean);

  return {
    messages,
  };
}

export async function sendScheduledMessages({ chat, ids }: { chat: ApiChat; ids: number[] }) {
  const { id, accessHash } = chat;

  await invokeRequest(new GramJs.messages.SendScheduledMessages({
    peer: buildInputPeer(id, accessHash),
    id: ids,
  }));
}

export async function fetchPinnedMessages({ chat, threadId }: { chat: ApiChat; threadId: ThreadId }) {
  const result = await invokeRequest(new GramJs.messages.Search(
    {
      peer: buildInputPeer(chat.id, chat.accessHash),
      filter: new GramJs.InputMessagesFilterPinned(),
      q: DEFAULT_PRIMITIVES.STRING,
      limit: PINNED_MESSAGES_LIMIT,
      topMsgId: Number(threadId),
      minDate: DEFAULT_PRIMITIVES.INT,
      maxDate: DEFAULT_PRIMITIVES.INT,
      offsetId: DEFAULT_PRIMITIVES.INT,
      addOffset: DEFAULT_PRIMITIVES.INT,
      maxId: DEFAULT_PRIMITIVES.INT,
      minId: DEFAULT_PRIMITIVES.INT,
      hash: DEFAULT_PRIMITIVES.BIGINT,
    },
  ), {
    abortControllerChatId: chat.id,
    abortControllerThreadId: threadId,
  });

  if (
    !result
    || result instanceof GramJs.messages.MessagesNotModified
    || !result.messages
  ) {
    return undefined;
  }

  const messages = result.messages.map(buildApiMessage).filter(Boolean);

  return {
    messages,
  };
}

export async function fetchSeenBy({ chat, messageId }: { chat: ApiChat; messageId: number }) {
  const result = await invokeRequest(new GramJs.messages.GetMessageReadParticipants({
    peer: buildInputPeer(chat.id, chat.accessHash),
    msgId: messageId,
  }));

  return result
    ? result.reduce((acc, readDate) => {
      acc[readDate.userId.toString()] = readDate.date;

      return acc;
    }, {} as Record<string, number>)
    : undefined;
}

export async function fetchSendAs({
  chat,
  isForPaidReactions,
}: {
  isForPaidReactions?: true;
  chat: ApiChat;
}) {
  const result = await invokeRequest(new GramJs.channels.GetSendAs({
    forPaidReactions: isForPaidReactions,
    peer: buildInputPeer(chat.id, chat.accessHash),
  }), {
    shouldIgnoreErrors: true,
    abortControllerChatId: chat.id,
  });

  if (!result) {
    return undefined;
  }

  return result.peers.map(buildApiSendAsPeerId);
}

export function saveDefaultSendAs({
  sendAs, chat,
}: {
  sendAs: ApiPeer; chat: ApiChat;
}) {
  return invokeRequest(new GramJs.messages.SaveDefaultSendAs({
    peer: buildInputPeer(chat.id, chat.accessHash),
    sendAs: buildInputPeer(sendAs.id, sendAs.accessHash),
  }));
}

export async function fetchSponsoredMessages({ peer }: { peer: ApiPeer }) {
  const result = await invokeRequest(new GramJs.messages.GetSponsoredMessages({
    peer: buildInputPeer(peer.id, peer.accessHash),
  }));

  if (!result || result instanceof GramJs.messages.SponsoredMessagesEmpty || !result.messages.length) {
    return undefined;
  }

  const messages = result.messages
    .map((message) => buildApiSponsoredMessage(message, peer.id))
    .filter(Boolean);

  return {
    messages,
  };
}

export async function viewSponsoredMessage({ random }: { random: string }) {
  await invokeRequest(new GramJs.messages.ViewSponsoredMessage({
    randomId: deserializeBytes(random),
  }));
}

export function clickSponsoredMessage({
  random,
  isMedia,
  isFullscreen,
}: {
  random: string;
  isMedia?: boolean;
  isFullscreen?: boolean;
}) {
  return invokeRequest(new GramJs.messages.ClickSponsoredMessage({
    media: isMedia || undefined,
    fullscreen: isFullscreen || undefined,
    randomId: deserializeBytes(random),
  }));
}

export async function reportSponsoredMessage({
  randomId,
  option,
}: {
  randomId: string;
  option: string;
}) {
  try {
    const result = await invokeRequest(new GramJs.messages.ReportSponsoredMessage({
      randomId: deserializeBytes(randomId),
      option: deserializeBytes(option),
    }), {
      shouldThrow: true,
    });

    if (!result) {
      return undefined;
    }

    return buildApiSponsoredMessageReportResult(result);
  } catch (err: unknown) {
    if (err instanceof RPCError && err.errorMessage === 'PREMIUM_ACCOUNT_REQUIRED') {
      return {
        type: 'premiumRequired' as const,
      };
    }
    return undefined;
  }
}

export async function readAllMentions({
  chat,
  threadId,
}: {
  chat: ApiChat;
  threadId?: ThreadId;
}) {
  const result = await invokeRequest(new GramJs.messages.ReadMentions({
    peer: buildInputPeer(chat.id, chat.accessHash),
    topMsgId: threadId ? Number(threadId) : undefined,
  }));

  if (!result) return;

  processAffectedHistory(chat, result);

  if (result.offset) {
    await readAllMentions({ chat, threadId });
  }
}

export async function readAllReactions({
  chat,
  threadId,
}: {
  chat: ApiChat;
  threadId?: ThreadId;
}) {
  const result = await invokeRequest(new GramJs.messages.ReadReactions({
    peer: buildInputPeer(chat.id, chat.accessHash),
    topMsgId: threadId ? Number(threadId) : undefined,
  }));

  if (!result) return;

  processAffectedHistory(chat, result);

  if (result.offset) {
    await readAllReactions({ chat, threadId });
  }
}

export async function readAllPollVotes({
  chat,
  threadId,
}: {
  chat: ApiChat;
  threadId?: ThreadId;
}) {
  const result = await invokeRequest(new GramJs.messages.ReadPollVotes({
    peer: buildInputPeer(chat.id, chat.accessHash),
    topMsgId: threadId ? Number(threadId) : undefined,
  }));

  if (!result) return;

  processAffectedHistory(chat, result);

  if (result.offset) {
    await readAllPollVotes({ chat, threadId });
  }
}

export async function fetchUnreadMentions({
  chat, threadId, offsetId, addOffset, maxId, minId,
}: {
  chat: ApiChat;
  threadId?: ThreadId;
  offsetId?: number;
  addOffset?: number;
  maxId?: number;
  minId?: number;
}) {
  const result = await invokeRequest(new GramJs.messages.GetUnreadMentions({
    peer: buildInputPeer(chat.id, chat.accessHash),
    topMsgId: threadId ? Number(threadId) : undefined,
    limit: MENTION_UNREAD_SLICE,
    offsetId: offsetId ?? DEFAULT_PRIMITIVES.INT,
    addOffset: addOffset ?? DEFAULT_PRIMITIVES.INT,
    maxId: maxId ?? DEFAULT_PRIMITIVES.INT,
    minId: minId ?? DEFAULT_PRIMITIVES.INT,
  }));

  if (
    !result
    || result instanceof GramJs.messages.MessagesNotModified
    || !result.messages
  ) {
    return undefined;
  }

  const totalCount = 'count' in result ? result.count : result.messages.length;
  const messages = result.messages.map(buildApiMessage).filter(Boolean);
  const topics = result.topics.map(buildApiTopicWithState).filter(Boolean);

  return {
    totalCount,
    messages,
    topics,
  };
}

export async function fetchUnreadReactions({
  chat, threadId, offsetId, addOffset, maxId, minId,
}: {
  chat: ApiChat;
  threadId?: ThreadId;
  offsetId?: number;
  addOffset?: number;
  maxId?: number;
  minId?: number;
}) {
  const result = await invokeRequest(new GramJs.messages.GetUnreadReactions({
    peer: buildInputPeer(chat.id, chat.accessHash),
    topMsgId: threadId ? Number(threadId) : undefined,
    limit: REACTION_UNREAD_SLICE,
    offsetId: offsetId ?? DEFAULT_PRIMITIVES.INT,
    addOffset: addOffset ?? DEFAULT_PRIMITIVES.INT,
    maxId: maxId ?? DEFAULT_PRIMITIVES.INT,
    minId: minId ?? DEFAULT_PRIMITIVES.INT,
  }));

  if (
    !result
    || result instanceof GramJs.messages.MessagesNotModified
    || !result.messages
  ) {
    return undefined;
  }

  const totalCount = 'count' in result ? result.count : result.messages.length;
  const messages = result.messages.map(buildApiMessage).filter(Boolean);
  const topics = result.topics.map(buildApiTopicWithState).filter(Boolean);

  return {
    totalCount,
    messages,
    topics,
  };
}

export async function fetchUnreadPollVotes({
  chat, threadId, offsetId, addOffset, maxId, minId,
}: {
  chat: ApiChat;
  threadId?: ThreadId;
  offsetId?: number;
  addOffset?: number;
  maxId?: number;
  minId?: number;
}) {
  const result = await invokeRequest(new GramJs.messages.GetUnreadPollVotes({
    peer: buildInputPeer(chat.id, chat.accessHash),
    topMsgId: threadId ? Number(threadId) : undefined,
    limit: POLL_UNREAD_SLICE,
    offsetId: offsetId ?? DEFAULT_PRIMITIVES.INT,
    addOffset: addOffset ?? DEFAULT_PRIMITIVES.INT,
    maxId: maxId ?? DEFAULT_PRIMITIVES.INT,
    minId: minId ?? DEFAULT_PRIMITIVES.INT,
  }));

  if (
    !result
    || result instanceof GramJs.messages.MessagesNotModified
    || !result.messages
  ) {
    return undefined;
  }

  const totalCount = 'count' in result ? result.count : result.messages.length;
  const messages = result.messages.map(buildApiMessage).filter(Boolean);
  const topics = result.topics.map(buildApiTopicWithState).filter(Boolean);

  return {
    totalCount,
    messages,
    topics,
  };
}

export async function transcribeAudio({
  chat, messageId,
}: {
  chat: ApiChat; messageId: number;
}) {
  const result = await invokeRequest(new GramJs.messages.TranscribeAudio({
    msgId: messageId,
    peer: buildInputPeer(chat.id, chat.accessHash),
  }));

  if (!result) return undefined;

  sendApiUpdate({
    '@type': 'updateTranscribedAudio',
    isPending: result.pending,
    transcriptionId: result.transcriptionId.toString(),
    text: result.text,
  });

  return result.transcriptionId.toString();
}

export async function translateText(params: TranslateTextParams) {
  let result;
  const isMessageTranslation = 'chat' in params;
  const { toLanguageCode, tone } = params;
  const apiTone = tone === 'neutral' ? undefined : tone;

  if (isMessageTranslation) {
    const { chat, messageIds } = params;

    result = await invokeRequest(new GramJs.messages.TranslateText({
      peer: buildInputPeer(chat.id, chat.accessHash),
      id: messageIds,
      toLang: toLanguageCode,
      tone: apiTone,
    }));
  } else {
    const { text } = params;
    result = await invokeRequest(new GramJs.messages.TranslateText({
      text: text.map((t) => buildInputTextWithEntities(t)),
      toLang: toLanguageCode,
      tone: apiTone,
    }));
  }

  if (!result) {
    if (isMessageTranslation) {
      sendApiUpdate({
        '@type': 'failedMessageTranslations',
        chatId: params.chat.id,
        messageIds: params.messageIds,
        toLanguageCode: params.toLanguageCode,
        tone,
      });
    }
    return undefined;
  }

  const formattedText = result.result.map((r) => buildApiFormattedText(r));

  if (isMessageTranslation) {
    sendApiUpdate({
      '@type': 'updateMessageTranslations',
      chatId: params.chat.id,
      messageIds: params.messageIds,
      translations: formattedText,
      toLanguageCode: params.toLanguageCode,
      tone,
    });
  }

  return formattedText;
}

export async function fetchMessageSummary({
  chat, id, toLanguageCode, tone,
}: {
  chat: ApiChat; id: number; toLanguageCode?: string; tone?: string;
}) {
  const result = await invokeRequest(new GramJs.messages.SummarizeText({
    peer: buildInputPeer(chat.id, chat.accessHash),
    id,
    toLang: toLanguageCode,
    tone,
  }));

  if (!result) return undefined;

  return buildApiFormattedText(result);
}

function handleMultipleLocalMessagesUpdate(
  localMessages: Record<string, ApiMessage>, update: GramJs.TypeUpdates,
) {
  if (!('updates' in update)) {
    handleGramJsUpdate(update);
    return;
  }

  const updateMessageIds = update.updates.filter((u): u is GramJs.UpdateMessageID => (
    u instanceof GramJs.UpdateMessageID
  ));

  // Server can return `UpdateNewScheduledMessage` that we currently process as video that requires processing
  updateMessageIds.forEach((updateMessageId) => {
    const updateNewScheduledMessage = update.updates
      .find((scheduledUpdate): scheduledUpdate is GramJs.UpdateNewScheduledMessage => {
        if (!(scheduledUpdate instanceof GramJs.UpdateNewScheduledMessage)) return false;
        return scheduledUpdate.message.id === updateMessageId.id;
      });

    const localMessage = localMessages[updateMessageId.randomId.toString()];
    handleLocalMessageUpdate(localMessage, updateMessageId, updateNewScheduledMessage);
  });

  const otherUpdates = update.updates.filter((u) => {
    if (u instanceof GramJs.UpdateMessageID) return false;
    if (u instanceof GramJs.UpdateNewScheduledMessage) return false;
    return true;
  });

  // Illegal monkey patching. Easier than creating mock update object
  update.updates = otherUpdates;

  handleGramJsUpdate(update);
}

function handleLocalMessageUpdate(
  localMessage: ApiMessage,
  update: GramJs.TypeUpdates | GramJs.UpdateMessageID,
  scheduledMessageUpdate?: GramJs.UpdateNewScheduledMessage,
) {
  let messageUpdate;
  if (update instanceof GramJs.UpdateShortSentMessage || update instanceof GramJs.UpdateMessageID) {
    messageUpdate = update;
  } else if ('updates' in update) {
    messageUpdate = update.updates.find((u): u is GramJs.UpdateMessageID => u instanceof GramJs.UpdateMessageID);
    scheduledMessageUpdate = update.updates.find((u): u is GramJs.UpdateNewScheduledMessage => (
      u instanceof GramJs.UpdateNewScheduledMessage
    ));
  }

  if (!messageUpdate) {
    handleGramJsUpdate(update);
    return;
  }

  let newContent: MediaContent | undefined;
  let poll: ApiMessagePoll | undefined;
  let webPage: ApiWebPage | undefined;
  if (messageUpdate instanceof GramJs.UpdateShortSentMessage) {
    if (localMessage.content.text && messageUpdate.entities) {
      newContent = {
        text: buildMessageTextContent(localMessage.content.text.text, messageUpdate.entities),
      };
    }
    if (messageUpdate.media) {
      newContent = {
        ...newContent,
        ...buildMessageMediaContent(messageUpdate.media, {
          peerId: buildPeer(localMessage.chatId), id: messageUpdate.id,
        }),
      };
      poll = buildMessagePollFromMedia(messageUpdate.media);
      webPage = buildWebPageFromMedia(messageUpdate.media);
    }

    const mtpMessage = buildMessageFromUpdate(messageUpdate.id, localMessage.chatId, messageUpdate);
    processMessageAndUpdateThreadInfo(mtpMessage);
  }

  const newScheduledMessage = scheduledMessageUpdate?.message && buildApiMessage(scheduledMessageUpdate.message);

  // Edge case for "Send When Online"
  const isSentBefore = 'date' in messageUpdate && messageUpdate.date < getServerTime();

  if (newScheduledMessage?.isVideoProcessingPending) {
    sendApiUpdate({
      '@type': 'updateVideoProcessingPending',
      chatId: localMessage.chatId,
      localId: localMessage.id,
      newScheduledMessageId: newScheduledMessage?.id,
    });
  } else {
    const updatedMessage: ApiMessage = {
      ...localMessage,
      ...(newContent && {
        content: {
          ...localMessage.content,
          ...newContent,
        },
      }),
      id: messageUpdate.id,
      sendingState: undefined,
      ...('date' in messageUpdate && { date: messageUpdate.date }),
    };

    sendApiUpdate({
      '@type': localMessage.isScheduled && !isSentBefore
        ? 'updateScheduledMessageSendSucceeded'
        : 'updateMessageSendSucceeded',
      chatId: localMessage.chatId,
      localId: localMessage.id,
      message: updatedMessage,
      poll,
      webPage,
    });
  }

  handleGramJsUpdate(update);
}

export async function fetchOutboxReadDate({ chat, messageId }: { chat: ApiChat; messageId: number }) {
  const { id, accessHash } = chat;
  const peer = buildInputPeer(id, accessHash);

  const result = await invokeRequest(new GramJs.messages.GetOutboxReadDate({
    peer,
    msgId: messageId,
  }), { shouldThrow: true });

  if (!result) return undefined;

  return { date: result.date };
}

export async function fetchQuickReplies() {
  const result = await invokeRequest(new GramJs.messages.GetQuickReplies({
    hash: DEFAULT_PRIMITIVES.BIGINT,
  }));
  if (!result || result instanceof GramJs.messages.QuickRepliesNotModified) return undefined;

  const messages = result.messages.map(buildApiMessage).filter(Boolean);

  const quickReplies = result.quickReplies.map(buildApiQuickReply);

  return {
    messages,
    quickReplies,
  };
}

export async function sendQuickReply({
  chat,
  shortcutId,
}: {
  chat: ApiChat;
  shortcutId: number;
}) {
  // Remove this request when the client fully supports quick replies and caches them
  const messages = await invokeRequest(new GramJs.messages.GetQuickReplyMessages({
    shortcutId,
    hash: DEFAULT_PRIMITIVES.BIGINT,
  }));
  if (!messages || messages instanceof GramJs.messages.MessagesNotModified) return;

  const ids = messages.messages.map((m) => m.id);
  const randomIds = ids.map(() => generateRandomBigInt());

  const result = await invokeRequest(new GramJs.messages.SendQuickReplyMessages({
    peer: buildInputPeer(chat.id, chat.accessHash),
    shortcutId,
    id: ids,
    randomId: randomIds,
  }), {
    shouldIgnoreUpdates: true,
  });

  if (!result) return;

  // Hack to prevent client from thinking that those messages were local
  if ('updates' in result) {
    const filteredUpdates = result.updates
      .filter((u): u is GramJs.UpdateMessageID => !(u instanceof GramJs.UpdateMessageID));
    result.updates = filteredUpdates;
  }

  handleGramJsUpdate(result);
}

export async function exportMessageLink({
  id, chat, shouldIncludeThread, shouldIncludeGrouped,
}: {
  id: number;
  chat: ApiChat;
  shouldIncludeThread?: boolean;
  shouldIncludeGrouped?: boolean;
}) {
  const result = await invokeRequest(new GramJs.channels.ExportMessageLink({
    channel: buildInputChannel(chat.id, chat.accessHash),
    id,
    thread: shouldIncludeThread || undefined,
    grouped: shouldIncludeGrouped || undefined,
  }));

  return result?.link;
}

export async function fetchPreparedInlineMessage({
  bot, id,
}: {
  bot: ApiUser;
  id: string;
}) {
  const result = await invokeRequest(new GramJs.messages.GetPreparedInlineMessage({
    bot: buildInputUser(bot.id, bot.accessHash),
    id,
  }));
  if (!result) return undefined;

  return buildPreparedInlineMessage(result);
}

export function incrementLocalMessagesCounter() {
  incrementLocalMessageCounter();
}

export async function composeMessageWithAI({
  text,
  shouldProofread,
  isEmojify,
  translateToLang,
  tone,
}: {
  text: ApiFormattedText;
  shouldProofread?: boolean;
  isEmojify?: boolean;
  translateToLang?: string;
  tone?: ApiInputAiComposeTone;
}): Promise<{ result?: ApiComposedMessageWithAI; error?: 'floodPremium' | 'aiError' | 'generic' }> {
  try {
    const result = await invokeRequest(new GramJs.messages.ComposeMessageWithAI({
      text: buildInputTextWithEntities(text),
      proofread: shouldProofread || undefined,
      emojify: isEmojify || undefined,
      translateToLang,
      tone: tone ? buildInputAiComposeTone(tone) : undefined,
    }), { shouldThrow: true });

    if (!result) return { error: 'generic' };

    return { result: buildApiComposedMessageWithAI(result) };
  } catch (err) {
    if (err instanceof RPCError) {
      if (err.errorMessage === 'AICOMPOSE_FLOOD_PREMIUM') {
        return { error: 'floodPremium' };
      }
      if (err.errorMessage === 'AICOMPOSE_ERROR_OCCURED') {
        return { error: 'aiError' };
      }
    }
    return { error: 'generic' };
  }
}

export async function fetchAiComposeTones({
  hash,
}: {
  hash?: string;
}) {
  const result = await invokeRequest(new GramJs.aicompose.GetTones({
    hash: hash ? BigInt(hash) : DEFAULT_PRIMITIVES.BIGINT,
  }));

  if (!result || result instanceof GramJs.aicompose.TonesNotModified) {
    return undefined;
  }

  return {
    tones: result.tones.map(buildApiAiComposeTone),
    hash: result.hash.toString(),
  };
}

export async function createAiTone({
  title,
  emojiId,
  prompt,
  shouldDisplayAuthor,
}: {
  title: string;
  emojiId: string;
  prompt: string;
  shouldDisplayAuthor?: boolean;
}) {
  const result = await invokeRequest(new GramJs.aicompose.CreateTone({
    title,
    prompt,
    emojiId: BigInt(emojiId),
    displayAuthor: shouldDisplayAuthor || undefined,
  }));

  if (!result) return undefined;

  return buildApiAiComposeTone(result);
}

export async function deleteAiTone({
  tone,
}: {
  tone: ApiInputAiComposeTone;
}) {
  return invokeRequest(new GramJs.aicompose.DeleteTone({
    tone: buildInputAiComposeTone(tone),
  }));
}

export async function updateAiTone({
  tone,
  title,
  emojiId,
  prompt,
  shouldDisplayAuthor,
}: {
  tone: ApiInputAiComposeTone;
  title?: string;
  emojiId?: string;
  prompt?: string;
  shouldDisplayAuthor?: boolean;
}) {
  const result = await invokeRequest(new GramJs.aicompose.UpdateTone({
    tone: buildInputAiComposeTone(tone),
    title,
    prompt,
    emojiId: emojiId ? BigInt(emojiId) : undefined,
    displayAuthor: shouldDisplayAuthor,
  }));

  if (!result) return undefined;

  return buildApiAiComposeTone(result);
}

export async function fetchAiTone({
  tone,
}: {
  tone: ApiInputAiComposeTone;
}) {
  const result = await invokeRequest(new GramJs.aicompose.GetTone({
    tone: buildInputAiComposeTone(tone),
  }));

  if (!result || !('tones' in result)) {
    return undefined;
  }

  return {
    tones: result.tones.map(buildApiAiComposeTone),
  };
}

export async function fetchAiToneExample({
  tone,
  num,
}: {
  tone: ApiInputAiComposeTone;
  num: number;
}) {
  const result = await invokeRequest(new GramJs.aicompose.GetToneExample({
    tone: buildInputAiComposeTone(tone),
    num,
  }));

  if (!result) return undefined;

  return buildApiAiComposeToneExample(result);
}

export async function saveAiTone({
  tone,
  unsave,
}: {
  tone: ApiInputAiComposeTone;
  unsave?: boolean;
}) {
  return invokeRequest(new GramJs.aicompose.SaveTone({
    tone: buildInputAiComposeTone(tone),
    unsave: Boolean(unsave),
  }));
}
