'use strict';

const EventEmitter = require('events');
const puppeteer = require('puppeteer');

const Util = require('./util/Util');
const InterfaceController = require('./util/InterfaceController');
const {
    WhatsWebURL,
    DefaultOptions,
    Events,
    WAState,
    MessageTypes,
} = require('./util/Constants');
const { ExposeAuthStore } = require('./util/Injected/AuthStore/AuthStore');
const { LoadUtils } = require('./util/Injected/Utils');
const ChatFactory = require('./factories/ChatFactory');
const ContactFactory = require('./factories/ContactFactory');
const WebCacheFactory = require('./webCache/WebCacheFactory');
const {
    ClientInfo,
    Message,
    MessageMedia,
    Contact,
    Location,
    Poll,
    PollVote,
    GroupNotification,
    Label,
    Call,
    Buttons,
    List,
    Reaction,
    Broadcast,
    ScheduledEvent,
} = require('./structures');
const NoAuth = require('./authStrategies/NoAuth');
const { exposeFunctionIfAbsent } = require('./util/Puppeteer');

/**
 * Starting point for interacting with the WhatsApp Web API
 * @extends {EventEmitter}
 * @param {object} options - Client options
 * @param {AuthStrategy} options.authStrategy - Determines how to save and restore sessions. If not set, NoAuth will be used.
 * @param {string} options.webVersion - The version of WhatsApp Web to use. Use options.webVersionCache to configure how the version is retrieved.
 * @param {object} options.webVersionCache - Determines how to retrieve the WhatsApp Web version. Defaults to a local cache (LocalWebCache) that falls back to latest if the requested version is not found.
 * @param {number} options.authTimeoutMs - Timeout for authentication selector in puppeteer
 * @param {function} options.evalOnNewDoc - function to eval on new doc
 * @param {object} options.puppeteer - Puppeteer launch options. View docs here: https://github.com/puppeteer/puppeteer/
 * @param {number} options.qrMaxRetries - How many times should the qrcode be refreshed before giving up
 * @param {number} options.takeoverOnConflict - If another whatsapp web session is detected (another browser), take over the session in the current browser
 * @param {number} options.takeoverTimeoutMs - How much time to wait before taking over the session
 * @param {string} options.userAgent - User agent to use in puppeteer
 * @param {string} options.ffmpegPath - Ffmpeg path to use when formatting videos to webp while sending stickers
 * @param {boolean} options.bypassCSP - Sets bypassing of page's Content-Security-Policy.
 * @param {string} options.deviceName - Sets the device name of a current linked device., i.e.: 'TEST'.
 * @param {string} options.browserName - Sets the browser name of a current linked device, i.e.: 'Firefox'.
 * @param {object} options.proxyAuthentication - Proxy Authentication object.
 *
 * @fires Client#qr
 * @fires Client#authenticated
 * @fires Client#auth_failure
 * @fires Client#ready
 * @fires Client#message
 * @fires Client#message_ack
 * @fires Client#message_create
 * @fires Client#message_revoke_me
 * @fires Client#message_revoke_everyone
 * @fires Client#message_ciphertext
 * @fires Client#message_edit
 * @fires Client#media_uploaded
 * @fires Client#group_join
 * @fires Client#group_leave
 * @fires Client#group_update
 * @fires Client#disconnected
 * @fires Client#change_state
 * @fires Client#contact_changed
 * @fires Client#group_admin_changed
 * @fires Client#group_membership_request
 * @fires Client#vote_update
 */
class Client extends EventEmitter {
    constructor(options = {}) {
        super();

        this.options = Util.mergeDefault(DefaultOptions, options);

        if (!this.options.authStrategy) {
            this.authStrategy = new NoAuth();
        } else {
            this.authStrategy = this.options.authStrategy;
        }

        this.authStrategy.setup(this);

        /**
         * @type {puppeteer.Browser}
         */
        this.pupBrowser = null;
        /**
         * @type {puppeteer.Page}
         */
        this.pupPage = null;

        this.currentIndexHtml = null;
        this.lastLoggedOut = false;

        Util.setFfmpegPath(this.options.ffmpegPath);
    }

    /**
     * Injection logic
     * Private function
     */
    async inject() {
        if (
            this.options.authTimeoutMs === undefined ||
            this.options.authTimeoutMs == 0
        ) {
            this.options.authTimeoutMs = 30000;
        }
        let start = Date.now();
        let timeout = this.options.authTimeoutMs;
        let res = false;
        while (start > Date.now() - timeout) {
            res = await this.pupPage.evaluate(
                'window.Debug?.VERSION != undefined',
            );
            if (res) {
                break;
            }
            await new Promise((r) => setTimeout(r, 200));
        }
        if (!res) {
            throw 'auth timeout';
        }
        await this.setDeviceName(
            this.options.deviceName,
            this.options.browserName,
        );
        const pairWithPhoneNumber = this.options.pairWithPhoneNumber;
        const version = await this.getWWebVersion();

        await this.pupPage.evaluate(ExposeAuthStore);

        const needAuthentication = await this.pupPage.evaluate(async () => {
            let state = window.require('WAWebSocketModel').Socket.state;

            if (
                state === 'OPENING' ||
                state === 'UNLAUNCHED' ||
                state === 'PAIRING'
            ) {
                // wait till state changes
                await new Promise((r) => {
                    window
                        .require('WAWebSocketModel')
                        .Socket.on(
                            'change:state',
                            function waitTillInit(_AppState, state) {
                                if (
                                    state !== 'OPENING' &&
                                    state !== 'UNLAUNCHED' &&
                                    state !== 'PAIRING'
                                ) {
                                    window
                                        .require('WAWebSocketModel')
                                        .Socket.off(
                                            'change:state',
                                            waitTillInit,
                                        );
                                    r();
                                }
                            },
                        );
                });
            }
            state = window.require('WAWebSocketModel').Socket.state;
            return state == 'UNPAIRED' || state == 'UNPAIRED_IDLE';
        });

        if (needAuthentication) {
            const { failed, failureEventPayload, restart } =
                await this.authStrategy.onAuthenticationNeeded();

            if (failed) {
                /**
                 * Emitted when there has been an error while trying to restore an existing session
                 * @event Client#auth_failure
                 * @param {string} message
                 */
                this.emit(Events.AUTHENTICATION_FAILURE, failureEventPayload);
                await this.destroy();
                if (restart) {
                    // session restore failed so try again but without session to force new authentication
                    return this.initialize();
                }
                return;
            }

            // Register qr/code events
            if (pairWithPhoneNumber.phoneNumber) {
                await exposeFunctionIfAbsent(
                    this.pupPage,
                    'onCodeReceivedEvent',
                    async (code) => {
                        /**
                         * Emitted when a pairing code is received
                         * @event Client#code
                         * @param {string} code Code
                         * @returns {string} Code that was just received
                         */
                        this.emit(Events.CODE_RECEIVED, code);
                        return code;
                    },
                );
                this.requestPairingCode(
                    pairWithPhoneNumber.phoneNumber,
                    pairWithPhoneNumber.showNotification,
                    pairWithPhoneNumber.intervalMs,
                );
            } else {
                let qrRetries = 0;
                await exposeFunctionIfAbsent(
                    this.pupPage,
                    'onQRChangedEvent',
                    async (qr) => {
                        /**
                         * Emitted when a QR code is received
                         * @event Client#qr
                         * @param {string} qr QR Code
                         */
                        this.emit(Events.QR_RECEIVED, qr);
                        if (this.options.qrMaxRetries > 0) {
                            qrRetries++;
                            if (qrRetries > this.options.qrMaxRetries) {
                                this.emit(
                                    Events.DISCONNECTED,
                                    'Max qrcode retries reached',
                                );
                                await this.destroy();
                            }
                        }
                    },
                );

                await this.pupPage.evaluate(async () => {
                    const registrationInfo =
                        await window.AuthStore.RegistrationUtils.waSignalStore.getRegistrationInfo();
                    const noiseKeyPair =
                        await window.AuthStore.RegistrationUtils.waNoiseInfo.get();
                    const staticKeyB64 = window.AuthStore.Base64Tools.encodeB64(
                        noiseKeyPair.staticKeyPair.pubKey,
                    );
                    const identityKeyB64 =
                        window.AuthStore.Base64Tools.encodeB64(
                            registrationInfo.identityKeyPair.pubKey,
                        );
                    const platform =
                        window.AuthStore.RegistrationUtils.DEVICE_PLATFORM;
                    const getQR = (ref) =>
                        ref +
                        ',' +
                        staticKeyB64 +
                        ',' +
                        identityKeyB64 +
                        ',' +
                        window
                            .require('WAWebUserPrefsMultiDevice')
                            .getADVSecretKey() +
                        ',' +
                        platform;
                    window.onQRChangedEvent(getQR(window.AuthStore.Conn.ref)); // initial qr
                    window.AuthStore.Conn.on('change:ref', (_, ref) => {
                        window.onQRChangedEvent(getQR(ref));
                    }); // future QR changes
                });
            }
        }

        await exposeFunctionIfAbsent(
            this.pupPage,
            'onAuthAppStateChangedEvent',
            async (state) => {
                if (
                    state == 'UNPAIRED_IDLE' &&
                    !pairWithPhoneNumber.phoneNumber
                ) {
                    // refresh qr code
                    window.require('WAWebCmd').Cmd.refreshQR();
                }
            },
        );

        await exposeFunctionIfAbsent(
            this.pupPage,
            'onAppStateHasSyncedEvent',
            async () => {
                const authEventPayload =
                    await this.authStrategy.getAuthEventPayload();
                /**
                 * Emitted when authentication is successful
                 * @event Client#authenticated
                 */
                this.emit(Events.AUTHENTICATED, authEventPayload);

                const injected = await this.pupPage.evaluate(async () => {
                    return typeof window.WWebJS !== 'undefined';
                });

                if (!injected) {
                    if (
                        this.options.webVersionCache.type === 'local' &&
                        this.currentIndexHtml
                    ) {
                        const { type: webCacheType, ...webCacheOptions } =
                            this.options.webVersionCache;
                        const webCache = WebCacheFactory.createWebCache(
                            webCacheType,
                            webCacheOptions,
                        );

                        await webCache.persist(this.currentIndexHtml, version);
                    }

                    // Load util functions (serializers, helper functions)
                    await this.pupPage.evaluate(LoadUtils);

                    let start = Date.now();
                    let res = false;
                    while (start > Date.now() - 30000) {
                        // Check window.WWebJS Injection
                        res = await this.pupPage.evaluate(
                            'window.WWebJS != undefined',
                        );
                        if (res) {
                            break;
                        }
                        await new Promise((r) => setTimeout(r, 200));
                    }
                    if (!res) {
                        throw 'ready timeout';
                    }

                    /**
                     * Current connection information
                     * @type {ClientInfo}
                     */
                    this.info = new ClientInfo(
                        this,
                        await this.pupPage.evaluate(() => {
                            return {
                                ...window
                                    .require('WAWebConnModel')
                                    .Conn.serialize(),
                                wid:
                                    window
                                        .require('WAWebUserPrefsMeUser')
                                        .getMaybeMePnUser() ||
                                    window
                                        .require('WAWebUserPrefsMeUser')
                                        .getMaybeMeLidUser(),
                            };
                        }),
                    );

                    this.interface = new InterfaceController(this);

                    await this.attachEventListeners();
                }
                /**
                 * Emitted when the client has initialized and is ready to receive messages.
                 * @event Client#ready
                 */
                this.emit(Events.READY);
                this.authStrategy.afterAuthReady();
            },
        );
        let lastPercent = null;
        await exposeFunctionIfAbsent(
            this.pupPage,
            'onOfflineProgressUpdateEvent',
            async (percent) => {
                if (lastPercent !== percent) {
                    lastPercent = percent;
                    this.emit(Events.LOADING_SCREEN, percent, 'WhatsApp'); // Message is hardcoded as "WhatsApp" for now
                }
            },
        );
        await exposeFunctionIfAbsent(
            this.pupPage,
            'onLogoutEvent',
            async () => {
                this.lastLoggedOut = true;
                await this.pupPage
                    .waitForNavigation({ waitUntil: 'load', timeout: 5000 })
                    .catch((_) => _);
            },
        );
        await this.pupPage.evaluate(() => {
            window
                .require('WAWebSocketModel')
                .Socket.on('change:state', (_AppState, state) => {
                    window.onAuthAppStateChangedEvent(state);
                });
            window
                .require('WAWebSocketModel')
                .Socket.on('change:hasSynced', () => {
                    window.onAppStateHasSyncedEvent();
                });
            const Cmd = window.require('WAWebCmd').Cmd;
            Cmd.on('offline_progress_update_from_bridge', () => {
                window.onOfflineProgressUpdateEvent(
                    window.AuthStore.OfflineMessageHandler.getOfflineDeliveryProgress(),
                );
            });
            Cmd.on('logout', async () => {
                await window.onLogoutEvent();
            });
            Cmd.on('logout_from_bridge', async () => {
                await window.onLogoutEvent();
            });
        });
    }

    /**
     * Sets up events and requirements, kicks off authentication request
     */
    async initialize() {
        let /**
             * @type {puppeteer.Browser}
             */
            browser,
            /**
             * @type {puppeteer.Page}
             */
            page;

        browser = null;
        page = null;

        await this.authStrategy.beforeBrowserInitialized();

        const puppeteerOpts = this.options.puppeteer;
        if (
            puppeteerOpts &&
            (puppeteerOpts.browserWSEndpoint || puppeteerOpts.browserURL)
        ) {
            browser = await puppeteer.connect(puppeteerOpts);
            page = await browser.newPage();
        } else {
            const browserArgs = [...(puppeteerOpts.args || [])];
            if (
                this.options.userAgent !== false &&
                !browserArgs.find((arg) => arg.includes('--user-agent'))
            ) {
                browserArgs.push(`--user-agent=${this.options.userAgent}`);
            }
            // navigator.webdriver fix
            browserArgs.push('--disable-blink-features=AutomationControlled');

            browser = await puppeteer.launch({
                ...puppeteerOpts,
                args: browserArgs,
            });
            page = (await browser.pages())[0];
        }

        if (this.options.proxyAuthentication !== undefined) {
            await page.authenticate(this.options.proxyAuthentication);
        }
        if (this.options.userAgent !== false) {
            await page.setUserAgent(this.options.userAgent);
        }
        if (this.options.bypassCSP) await page.setBypassCSP(true);

        this.pupBrowser = browser;
        this.pupPage = page;

        await this.authStrategy.afterBrowserInitialized();
        await this.initWebVersionCache();

        if (this.options.evalOnNewDoc !== undefined) {
            await page.evaluateOnNewDocument(this.options.evalOnNewDoc);
        }

        await page.goto(WhatsWebURL, {
            waitUntil: 'load',
            timeout: 0,
            referer: 'https://whatsapp.com/',
        });

        await this.inject();

        this.pupPage.on('framenavigated', async (frame) => {
            if (frame.url().includes('post_logout=1') || this.lastLoggedOut) {
                this.emit(Events.DISCONNECTED, 'LOGOUT');
                await this.authStrategy.logout();
                await this.authStrategy.beforeBrowserInitialized();
                await this.authStrategy.afterBrowserInitialized();
                this.lastLoggedOut = false;
            }
            await this.inject();
        });
    }

    /**
     * Request authentication via pairing code instead of QR code
     * @param {string} phoneNumber - Phone number in international, symbol-free format (e.g. 12025550108 for US, 551155501234 for Brazil)
     * @param {boolean} [showNotification = true] - Show notification to pair on phone number
     * @param {number} [intervalMs = 180000] - The interval in milliseconds on how frequent to generate pairing code (WhatsApp default to 3 minutes)
     * @returns {Promise<string>} - Returns a pairing code in format "ABCDEFGH"
     */
    async requestPairingCode(
        phoneNumber,
        showNotification = true,
        intervalMs = 180000,
    ) {
        await exposeFunctionIfAbsent(
            this.pupPage,
            'onCodeReceivedEvent',
            async (code) => {
                this.emit(Events.CODE_RECEIVED, code);
                return code;
            },
        );
        return await this.pupPage.evaluate(
            async (phoneNumber, showNotification, intervalMs) => {
                const getCode = async () => {
                    while (!window.AuthStore.PairingCodeLinkUtils) {
                        await new Promise((resolve) =>
                            setTimeout(resolve, 250),
                        );
                    }
                    window.AuthStore.PairingCodeLinkUtils.setPairingType(
                        'ALT_DEVICE_LINKING',
                    );
                    await window.AuthStore.PairingCodeLinkUtils.initializeAltDeviceLinking();
                    return window.AuthStore.PairingCodeLinkUtils.startAltLinkingFlow(
                        phoneNumber,
                        showNotification,
                    );
                };
                if (window.codeInterval) {
                    clearInterval(window.codeInterval); // remove existing interval
                }
                window.codeInterval = setInterval(async () => {
                    const state =
                        window.require('WAWebSocketModel').Socket.state;
                    if (state != 'UNPAIRED' && state != 'UNPAIRED_IDLE') {
                        clearInterval(window.codeInterval);
                        return;
                    }
                    window.onCodeReceivedEvent(await getCode());
                }, intervalMs);
                return window.onCodeReceivedEvent(await getCode());
            },
            phoneNumber,
            showNotification,
            intervalMs,
        );
    }

    /**
     * Cancels an active pairing code session and returns to QR code mode
     */
    async cancelPairingCode() {
        await this.pupPage.evaluate(async () => {
            if (window.codeInterval) {
                clearInterval(window.codeInterval);
                window.codeInterval = undefined;
            }
            window.require('WAWebLaunchSocketUtils').refreshQR();
            await window
                .require('WAWebAltDeviceLinkingApi')
                .initializeQRLinking();
        });
    }

    /**
     * Attach event listeners to WA Web
     * Private function
     * @property {boolean} reinject is this a reinject?
     */
    async attachEventListeners() {
        await exposeFunctionIfAbsent(
            this.pupPage,
            'onAddMessageEvent',
            (msg) => {
                if (msg.type === 'gp2') {
                    const notification = new GroupNotification(this, msg);
                    if (
                        ['add', 'invite', 'linked_group_join'].includes(
                            msg.subtype,
                        )
                    ) {
                        /**
                         * Emitted when a user joins the chat via invite link or is added by an admin.
                         * @event Client#group_join
                         * @param {GroupNotification} notification GroupNotification with more information about the action
                         */
                        this.emit(Events.GROUP_JOIN, notification);
                    } else if (
                        msg.subtype === 'remove' ||
                        msg.subtype === 'leave'
                    ) {
                        /**
                         * Emitted when a user leaves the chat or is removed by an admin.
                         * @event Client#group_leave
                         * @param {GroupNotification} notification GroupNotification with more information about the action
                         */
                        this.emit(Events.GROUP_LEAVE, notification);
                    } else if (
                        msg.subtype === 'promote' ||
                        msg.subtype === 'demote'
                    ) {
                        /**
                         * Emitted when a current user is promoted to an admin or demoted to a regular user.
                         * @event Client#group_admin_changed
                         * @param {GroupNotification} notification GroupNotification with more information about the action
                         */
                        this.emit(Events.GROUP_ADMIN_CHANGED, notification);
                    } else if (msg.subtype === 'membership_approval_request') {
                        /**
                         * Emitted when some user requested to join the group
                         * that has the membership approval mode turned on
                         * @event Client#group_membership_request
                         * @param {GroupNotification} notification GroupNotification with more information about the action
                         * @param {string} notification.chatId The group ID the request was made for
                         * @param {string} notification.author The user ID that made a request
                         * @param {number} notification.timestamp The timestamp the request was made at
                         */
                        this.emit(
                            Events.GROUP_MEMBERSHIP_REQUEST,
                            notification,
                        );
                    } else {
                        /**
                         * Emitted when group settings are updated, such as subject, description or picture.
                         * @event Client#group_update
                         * @param {GroupNotification} notification GroupNotification with more information about the action
                         */
                        this.emit(Events.GROUP_UPDATE, notification);
                    }
                    return;
                }

                const message = new Message(this, msg);

                /**
                 * Emitted when a new message is created, which may include the current user's own messages.
                 * @event Client#message_create
                 * @param {Message} message The message that was created
                 */
                this.emit(Events.MESSAGE_CREATE, message);

                if (msg.id.fromMe) return;

                /**
                 * Emitted when a new message is received.
                 * @event Client#message
                 * @param {Message} message The message that was received
                 */
                this.emit(Events.MESSAGE_RECEIVED, message);
            },
        );

        let last_message;

        await exposeFunctionIfAbsent(
            this.pupPage,
            'onChangeMessageTypeEvent',
            (msg) => {
                if (msg.type === 'revoked') {
                    const message = new Message(this, msg);
                    let revoked_msg;
                    if (last_message && msg.id.id === last_message.id.id) {
                        revoked_msg = new Message(this, last_message);

                        if (message.protocolMessageKey)
                            revoked_msg.id = { ...message.protocolMessageKey };
                    }

                    /**
                     * Emitted when a message is deleted for everyone in the chat.
                     * @event Client#message_revoke_everyone
                     * @param {Message} message The message that was revoked, in its current state. It will not contain the original message's data.
                     * @param {?Message} revoked_msg The message that was revoked, before it was revoked. It will contain the message's original data.
                     * Note that due to the way this data is captured, it may be possible that this param will be undefined.
                     */
                    this.emit(
                        Events.MESSAGE_REVOKED_EVERYONE,
                        message,
                        revoked_msg,
                    );
                }
            },
        );

        await exposeFunctionIfAbsent(
            this.pupPage,
            'onChangeMessageEvent',
            (msg) => {
                if (msg.type !== 'revoked') {
                    last_message = msg;
                }

                /**
                 * The event notification that is received when one of
                 * the group participants changes their phone number.
                 */
                const isParticipant =
                    msg.type === 'gp2' && msg.subtype === 'modify';

                /**
                 * The event notification that is received when one of
                 * the contacts changes their phone number.
                 */
                const isContact =
                    msg.type === 'notification_template' &&
                    msg.subtype === 'change_number';

                if (isParticipant || isContact) {
                    /** @type {GroupNotification} object does not provide enough information about this event, so a @type {Message} object is used. */
                    const message = new Message(this, msg);

                    const newId = isParticipant ? msg.recipients[0] : msg.to;
                    const oldId = isParticipant
                        ? msg.author
                        : msg.templateParams.find((id) => id !== newId);

                    /**
                     * Emitted when a contact or a group participant changes their phone number.
                     * @event Client#contact_changed
                     * @param {Message} message Message with more information about the event.
                     * @param {String} oldId The user's id (an old one) who changed their phone number
                     * and who triggered the notification.
                     * @param {String} newId The user's new id after the change.
                     * @param {Boolean} isContact Indicates if a contact or a group participant changed their phone number.
                     */
                    this.emit(
                        Events.CONTACT_CHANGED,
                        message,
                        oldId,
                        newId,
                        isContact,
                    );
                }
            },
        );

        await exposeFunctionIfAbsent(
            this.pupPage,
            'onRemoveMessageEvent',
            (msg) => {
                if (!msg.isNewMsg) return;

                const message = new Message(this, msg);

                /**
                 * Emitted when a message is deleted by the current user.
                 * @event Client#message_revoke_me
                 * @param {Message} message The message that was revoked
                 */
                this.emit(Events.MESSAGE_REVOKED_ME, message);
            },
        );

        await exposeFunctionIfAbsent(
            this.pupPage,
            'onMessageAckEvent',
            (msg, ack) => {
                const message = new Message(this, msg);

                /**
                 * Emitted when an ack event occurrs on message type.
                 * @event Client#message_ack
                 * @param {Message} message The message that was affected
                 * @param {MessageAck} ack The new ACK value
                 */
                this.emit(Events.MESSAGE_ACK, message, ack);
            },
        );

        await exposeFunctionIfAbsent(
            this.pupPage,
            'onChatUnreadCountEvent',
            async (data) => {
                const chat = await this.getChatById(data.id);

                /**
                 * Emitted when the chat unread count changes
                 */
                this.emit(Events.UNREAD_COUNT, chat);
            },
        );

        await exposeFunctionIfAbsent(
            this.pupPage,
            'onMessageMediaUploadedEvent',
            (msg) => {
                const message = new Message(this, msg);

                /**
                 * Emitted when media has been uploaded for a message sent by the client.
                 * @event Client#media_uploaded
                 * @param {Message} message The message with media that was uploaded
                 */
                this.emit(Events.MEDIA_UPLOADED, message);
            },
        );

        await exposeFunctionIfAbsent(
            this.pupPage,
            'onAppStateChangedEvent',
            async (state) => {
                /**
                 * Emitted when the connection state changes
                 * @event Client#change_state
                 * @param {WAState} state the new connection state
                 */
                this.emit(Events.STATE_CHANGED, state);

                const ACCEPTED_STATES = [
                    WAState.CONNECTED,
                    WAState.OPENING,
                    WAState.PAIRING,
                    WAState.TIMEOUT,
                ];

                if (this.options.takeoverOnConflict) {
                    ACCEPTED_STATES.push(WAState.CONFLICT);

                    if (state === WAState.CONFLICT) {
                        setTimeout(() => {
                            this.pupPage.evaluate(() =>
                                window
                                    .require('WAWebSocketModel')
                                    .Socket.takeover(),
                            );
                        }, this.options.takeoverTimeoutMs);
                    }
                }

                if (!ACCEPTED_STATES.includes(state)) {
                    /**
                     * Emitted when the client has been disconnected
                     * @event Client#disconnected
                     * @param {WAState|"LOGOUT"} reason reason that caused the disconnect
                     */
                    await this.authStrategy.disconnect();
                    this.emit(Events.DISCONNECTED, state);
                    this.destroy();
                }
            },
        );

        await exposeFunctionIfAbsent(
            this.pupPage,
            'onBatteryStateChangedEvent',
            (state) => {
                const { battery, plugged } = state;

                if (battery === undefined) return;

                /**
                 * Emitted when the battery percentage for the attached device changes. Will not be sent if using multi-device.
                 * @event Client#change_battery
                 * @param {object} batteryInfo
                 * @param {number} batteryInfo.battery - The current battery percentage
                 * @param {boolean} batteryInfo.plugged - Indicates if the phone is plugged in (true) or not (false)
                 * @deprecated
                 */
                this.emit(Events.BATTERY_CHANGED, { battery, plugged });
            },
        );

        await exposeFunctionIfAbsent(this.pupPage, 'onIncomingCall', (call) => {
            /**
             * Emitted when a call is received
             * @event Client#incoming_call
             * @param {object} call
             * @param {number} call.id - Call id
             * @param {string} call.peerJid - Who called
             * @param {boolean} call.isVideo - if is video
             * @param {boolean} call.isGroup - if is group
             * @param {boolean} call.canHandleLocally - if we can handle in waweb
             * @param {boolean} call.outgoing - if is outgoing
             * @param {boolean} call.webClientShouldHandle - If Waweb should handle
             * @param {object} call.participants - Participants
             */
            const cll = new Call(this, call);
            this.emit(Events.INCOMING_CALL, cll);
        });

        await exposeFunctionIfAbsent(
            this.pupPage,
            'onReaction',
            (reactions) => {
                for (const reaction of reactions) {
                    /**
                     * Emitted when a reaction is sent, received, updated or removed
                     * @event Client#message_reaction
                     * @param {object} reaction
                     * @param {object} reaction.id - Reaction id
                     * @param {number} reaction.orphan - Orphan
                     * @param {?string} reaction.orphanReason - Orphan reason
                     * @param {number} reaction.timestamp - Timestamp
                     * @param {string} reaction.reaction - Reaction
                     * @param {boolean} reaction.read - Read
                     * @param {object} reaction.msgId - Parent message id
                     * @param {string} reaction.senderId - Sender id
                     * @param {?number} reaction.ack - Ack
                     */

                    this.emit(
                        Events.MESSAGE_REACTION,
                        new Reaction(this, reaction),
                    );
                }
            },
        );

        await exposeFunctionIfAbsent(
            this.pupPage,
            'onRemoveChatEvent',
            async (chat) => {
                const _chat = await this.getChatById(chat.id);

                /**
                 * Emitted when a chat is removed
                 * @event Client#chat_removed
                 * @param {Chat} chat
                 */
                this.emit(Events.CHAT_REMOVED, _chat);
            },
        );

        await exposeFunctionIfAbsent(
            this.pupPage,
            'onArchiveChatEvent',
            async (chat, currState, prevState) => {
                const _chat = await this.getChatById(chat.id);

                /**
                 * Emitted when a chat is archived/unarchived
                 * @event Client#chat_archived
                 * @param {Chat} chat
                 * @param {boolean} currState
                 * @param {boolean} prevState
                 */
                this.emit(Events.CHAT_ARCHIVED, _chat, currState, prevState);
            },
        );

        await exposeFunctionIfAbsent(
            this.pupPage,
            'onEditMessageEvent',
            (msg, newBody, prevBody) => {
                if (msg.type === 'revoked') {
                    return;
                }
                /**
                 * Emitted when messages are edited
                 * @event Client#message_edit
                 * @param {Message} message
                 * @param {string} newBody
                 * @param {string} prevBody
                 */
                this.emit(
                    Events.MESSAGE_EDIT,
                    new Message(this, msg),
                    newBody,
                    prevBody,
                );
            },
        );

        await exposeFunctionIfAbsent(
            this.pupPage,
            'onAddMessageCiphertextEvent',
            (msg) => {
                /**
                 * Emitted when a message is received as ciphertext (not yet decrypted)
                 * @event Client#message_ciphertext
                 * @param {Message} message
                 */
                this.emit(Events.MESSAGE_CIPHERTEXT, new Message(this, msg));
            },
        );

        await exposeFunctionIfAbsent(
            this.pupPage,
            'onCiphertextFailedEvent',
            (msg) => {
                /**
                 * Emitted when a ciphertext message failed to decrypt after recovery attempt
                 * @event Client#message_ciphertext_failed
                 * @param {Message} message
                 */
                this.emit(
                    Events.MESSAGE_CIPHERTEXT_FAILED,
                    new Message(this, msg),
                );
            },
        );

        await exposeFunctionIfAbsent(
            this.pupPage,
            'onPollVoteEvent',
            (votes) => {
                for (const vote of votes) {
                    /**
                     * Emitted when some poll option is selected or deselected,
                     * shows a user's current selected option(s) on the poll
                     * @event Client#vote_update
                     */
                    this.emit(Events.VOTE_UPDATE, new PollVote(this, vote));
                }
            },
        );

        await this.pupPage.evaluate(() => {
            const { Msg, Chat } = window.require('WAWebCollections');
            const AppState = window.require('WAWebSocketModel').Socket;

            // Enable placeholder message resend (recovery for ciphertext messages)
            const gatingUtils = window.require('WAWebSyncGatingUtils');
            gatingUtils.isPlaceholderMessageResendEnabled = () => true;

            // Safe lookup helper mapping inside page execution thread
            const getSafeMessageModel = (msg) => {
                try {
                    return window.WWebJS.getMessageModel(msg);
                } catch (e) {
                    return null;
                }
            };

            Msg.on('change', (msg) => {
                const model = getSafeMessageModel(msg);
                if (model) window.onChangeMessageEvent(model);
            });
            Msg.on('change:type', (msg) => {
                const model = getSafeMessageModel(msg);
                if (model) window.onChangeMessageTypeEvent(model);
            });
            Msg.on('change:ack', (msg, ack) => {
                const model = getSafeMessageModel(msg);
                if (model) window.onMessageAckEvent(model, ack);
            });
            Msg.on('change:isUnsentMedia', (msg, unsent) => {
                if (msg.id.fromMe && !unsent) {
                    const model = getSafeMessageModel(msg);
                    if (model) window.onMessageMediaUploadedEvent(model);
                }
            });
            Msg.on('remove', (msg) => {
                if (msg.isNewMsg) {
                    const model = getSafeMessageModel(msg);
                    if (model) window.onRemoveMessageEvent(model);
                }
            });
            Msg.on('change:body change:caption', (msg, newBody, prevBody) => {
                const model = getSafeMessageModel(msg);
                if (model) window.onEditMessageEvent(model, newBody, prevBody);
            });
            AppState.on('change:state', (_AppState, state) => {
                window.onAppStateChangedEvent(state);
            });
            window
                .require('WAWebConnModel')
                .Conn.on('change:battery', (state) => {
                    window.onBatteryStateChangedEvent(state);
                });
            const WAWebCallCollection = window.require('WAWebCallCollection');
            if (
                WAWebCallCollection &&
                typeof WAWebCallCollection.on === 'function'
            ) {
                const mapKey = Object.keys(WAWebCallCollection).find(
                    (k) => WAWebCallCollection[k] instanceof Map,
                );
                const internalCallMap = WAWebCallCollection[mapKey];
                const originalMapSet =
                    internalCallMap.set.bind(internalCallMap);

                internalCallMap.set = function (key, value) {
                    window.onIncomingCall({
                        id: value.id,
                        peerJid: value.peerJid,
                        isVideo: value.isVideo,
                        isGroup: value.isGroup,
                        canHandleLocally: value.canHandleLocally,
                        outgoing: value.outgoing,
                        webClientShouldHandle: value.webClientShouldHandle,
                        participants: value.participants,
                    });
                    return originalMapSet(key, value);
                };
            }
            Chat.on('remove', async (chat) => {
                window.onRemoveChatEvent(
                    await window.WWebJS.getChatModel(chat),
                );
            });
            Chat.on('change:archive', async (chat, currState, prevState) => {
                window.onArchiveChatEvent(
                    await window.WWebJS.getChatModel(chat),
                    currState,
                    prevState,
                );
            });
            const pendingResend = new Set();
            let resendFlush = null;

            function requestResend(msg) {
                pendingResend.add(msg);
                if (resendFlush) return;
                resendFlush = setTimeout(() => {
                    resendFlush = null;
                    const msgs = [...pendingResend];
                    pendingResend.add(msg);
                    if (msgs.length === 0) return;
                    window
                        .require(
                            'WAWebNonMessageDataRequestPlaceholderMessageResendUtils',
                        )
                        .handlePlaceholderMsgsSeen(msgs, true);
                }, 5000);
            }

            Msg.on('add', (msg) => {
                if (!msg.isNewMsg) return;

                if (msg.type !== 'ciphertext') {
                    const model = getSafeMessageModel(msg);
                    if (model) window.onAddMessageEvent(model);
                    return;
                }

                const cipherModel = getSafeMessageModel(msg);
                if (cipherModel) window.onAddMessageCiphertextEvent(cipherModel);

                if (msg.subtype && msg.subtype.endsWith('_unavailable_fanout'))
                    return;

                requestResend(msg);

                const failTimer = setTimeout(() => {
                    if (msg.type !== 'ciphertext') return;
                    const model = getSafeMessageModel(msg);
                    if (model) window.onCiphertextFailedEvent(model);
                }, 15000);

                msg.once('change:type', (_msg) => {
                    clearTimeout(failTimer);
                    pendingResend.delete(_msg);
                    if (_msg.type === 'revoked') return;
                    const model = getSafeMessageModel(_msg);
                    if (model) window.onAddMessageEvent(model);
                });
            });
            Chat.on('change:unreadCount', (chat) => {
                window.onChatUnreadCountEvent(chat);
            });

            window.WWebJS.injectToFunction(
                {
                    module: 'WAWebAddonReactionTableMode',
                    function: 'reactionTableMode.bulkUpsert',
                },
                (module, origFunction, ...args) => {
                    window.onReaction(
                        args[0].map((reaction) => {
                            const msgKey = reaction.id;
                            const parentMsgKey = reaction.reactionParentKey;
                            const timestamp = reaction.reactionTimestamp / 1000;
                            const sender = reaction.author ?? reaction.from;
                            const senderUserJid = sender._serialized;

                            return {
                                ...reaction,
                                msgKey,
                                parentMsgKey,
                                senderUserJid,
                                timestamp,
                            };
                        }),
                    );

                    return origFunction.apply(module, args);
                },
            );

            window.WWebJS.injectToFunction(
                {
                    module: 'WAWebAddonPollVoteTableMode',
                    function: 'pollVoteTableMode.bulkUpsert',
                },
                async (module, origFunction, ...args) => {
                    const votes = await Promise.all(
                        args[0].map(async (vote) => {
                            const msgKey = vote.id;
                            const parentMsgKey = vote.pollUpdateParentKey;
                            const timestamp = vote.t / 1000;
                            const sender = vote.author ?? vote.from;
                            const senderUserJid = sender._serialized;

                            let parentMessage = Msg.get(
                                parentMsgKey._serialized,
                            );
                            if (!parentMessage) {
                                const fetched = await Msg.getMessagesById([
                                    parentMsgKey._serialized,
                                ]);
                                parentMessage = fetched?.messages?.[0] || null;
                            }

                            return {
                                ...vote,
                                msgKey,
                                sender,
                                parentMsgKey,
                                senderUserJid,
                                timestamp,
                                parentMessage,
                            };
                        }),
                    );

                    window.onPollVoteEvent(votes);

                    return origFunction.apply(module, args);
                },
            );
        });
    }

    async initWebVersionCache() {
        const { type: webCacheType, ...webCacheOptions } =
            this.options.webVersionCache;
        const webCache = WebCacheFactory.createWebCache(
            webCacheType,
            webCacheOptions,
        );

        const requestedVersion = this.options.webVersion;
        const versionContent = await webCache.resolve(requestedVersion);

        if (versionContent) {
            await this.pupPage.setRequestInterception(true);
            this.pupPage.on('request', async (req) => {
                if (req.url() === WhatsWebURL) {
                    req.respond({
                        status: 200,
                        contentType: 'text/html',
                        body: versionContent,
                    });
                } else {
                    req.continue();
                }
            });
        } else {
            this.pupPage.on('response', async (res) => {
                if (res.ok() && res.url() === WhatsWebURL) {
                    const indexHtml = await res.text();
                    this.currentIndexHtml = indexHtml;
                }
            });
        }
    }

    /**
     * Closes the client
     */
    async destroy() {
        const browser = this.pupBrowser;
        const isConnected = browser?.isConnected?.();
        if (isConnected) {
            await browser.close();
        }
        await this.authStrategy.destroy();
    }

    /**
     * Logs out the client, closing the current session
     */
    async logout() {
        await this.pupPage.evaluate(() => {
            return window.require('WAWebSocketModel').Socket.logout();
        });
        await this.pupBrowser.close();
        let maxDelay = 0;
        while (this.pupBrowser.isConnected() && maxDelay < 10) {
            // waits a maximum of 1 second before calling the AuthStrategy
            await new Promise((resolve) => setTimeout(resolve, 100));
            maxDelay++;
        }
        await this.authStrategy.logout();
    }

    /**
     * Returns the version of WhatsApp Web currently being run
     * @returns {Promise<string>}
     */
    async getWWebVersion() {
        return await this.pupPage.evaluate(() => {
            return window.Debug.VERSION;
        });
    }

    async setDeviceName(deviceName, browserName) {
        (deviceName || browserName) &&
            (await this.pupPage.evaluate(
                (deviceName, browserName) => {
                    const func = window.require('WAWebMiscBrowserUtils').info;
                    window.require('WAWebMiscBrowserUtils').info = () => {
                        return {
                            ...func(),
                            ...(deviceName ? { os: deviceName } : {}),
                            ...(browserName ? { name: browserName } : {}),
                        };
                    };
                },
                deviceName,
                browserName,
            ));
    }

    /**
     * Mark as seen for the Chat
     * @param {string} chatId
     * @returns {Promise<boolean>} result
     * */
    async sendSeen(chatId) {
        return await this.pupPage.evaluate(async (chatId) => {
            return window.WWebJS.sendSeen(chatId);
        }, chatId);
    }

    /**
     * An object representing mentions of groups
     * @typedef {Object} GroupMention
     * @property {string} subject - The name of a group to mention (can be custom)
     * @property {string} id - The group ID, e.g.: 'XXXXXXXXXX@g.us'
     */

    /**
     * Message options.
     * @typedef {Object} MessageSendOptions
     * @property {boolean} [linkPreview=true] - Show links preview. Has no effect on multi-device accounts.
     * @property {boolean} [sendAudioAsVoice=false] - Send audio as voice message with a generated waveform
     * @property {boolean} [sendVideoAsGif=false] - Send video as gif
     * @property {boolean} [sendMediaAsSticker=false] - Send media as a sticker
     * @property {boolean} [sendMediaAsDocument=false] - Send media as a document
     * @property {boolean} [sendMediaAsHd=false] - Send image as quality HD
     * @property {boolean} [isViewOnce=false] - Send photo/video as a view once message
     * @property {boolean} [parseVCards=true] - Automatically parse vCards and send them as contacts
     * @property {string} [caption] - Image or video caption
     * @property {string} [quotedMessageId] - Id of the message that is being quoted (or replied to)
     * @property {GroupMention[]} [groupMentions] - An array of object that handle group mentions
     * @property {string[]} [mentions] - User IDs to mention in the message
     * @property {boolean} [sendSeen=true] - Mark the conversation as seen after sending the message
     * @property {string} [invokedBotWid=undefined] - Bot Wid when doing a bot mention like @Meta AI
     * @property {string} [stickerAuthor=undefined] - Sets the author of the sticker, (if sendMediaAsSticker is true).
     * @property {string} [stickerName=undefined] - Sets the name of the sticker, (if sendMediaAsSticker is true).
     * @property {string[]} [stickerCategories=undefined] - Sets the categories of the sticker, (if sendMediaAsSticker is true). Provide emoji char array, can be null.
     * @property {boolean} [ignoreQuoteErrors = true] - Should the bot send a quoted message without the quoted message if it fails to get the quote?
     * @property {boolean} [waitUntilMsgSent = false] - Should the bot wait for the message send result?
     * @property {MessageMedia} [media] - Media to be sent
     * @property {any} [extra] - Extra options
     */

    /**
     * Send a message to a specific chatId
     * @param {string} chatId
     * @param {string|MessageMedia|Location|Poll|Contact|Array<Contact>|Buttons|List} content
     * @param {MessageSendOptions} [options] - Options used when sending the message
     *
     * @returns {Promise<Message>} Message that was just sent
     */
    async sendMessage(chatId, content, options = {}) {
        const isChannel = /@\w*newsletter\b/.test(chatId);
        const isStatus = /@\w*broadcast\b/.test(chatId);
        if (
            isChannel &&
            [
                options.sendMediaAsDocument,
                options.quotedMessageId,
                options.parseVCards,
                options.isViewOnce,
                content instanceof Location,
                content instanceof Contact,
                content instanceof Buttons,
                content instanceof List,
                Array.isArray(content) &&
                    content.length > 0 &&
                    content[0] instanceof Contact,
            ].includes(true)
        ) {
            console.warn(
                'The message type is currently not supported for sending in channels,\nthe supported message types are: text, image, sticker, gif, video, voice and poll.',
            );
            return null;
        } else if (
            isStatus &&
            [
                options.sendMediaAsDocument,
                options.quotedMessageId,
                options.parseVCards,
                options.isViewOnce,
                options.sendMediaAsSticker,
                content instanceof Location,
                content instanceof Contact,
                content instanceof Poll,
                content instanceof Buttons,
                content instanceof List,
                Array.isArray(content) &&
                    content.length > 0 &&
                    content[0] instanceof Contact,
            ].includes(true)
        ) {
            console.warn(
                'The message type is currently not supported for sending in status broadcast,\nthe supported message types are: text, image, gif, audio and video.',
            );
            return null;
        }
        if (options.mentions) {
            !Array.isArray(options.mentions) &&
                (options.mentions = [options.mentions]);
            if (
                options.mentions.some(
                    (possiblyContact) => possiblyContact instanceof Contact,
                )
            ) {
                console.warn(
                    'Mentions with an array of Contact are now deprecated. See more at https://github.com/wwebjs/whatsapp-web.js/pull/2166.',
                );
                options.mentions = options.mentions.map(
                    (a) => a.id._serialized,
                );
            }
        }
        options.groupMentions &&
            !Array.isArray(options.groupMentions) &&
            (options.groupMentions = [options.groupMentions]);
        let internalOptions = {
            linkPreview: options.linkPreview === false ? undefined : true,
            sendAudioAsVoice: options.sendAudioAsVoice,
            sendVideoAsGif: options.sendVideoAsGif,
            sendMediaAsSticker: options.sendMediaAsSticker,
            sendMediaAsDocument: options.sendMediaAsDocument,
            sendMediaAsHd: options.sendMediaAsHd,
            caption: options.caption,
            isCaptionByUser: options.caption ? true : false,
            quotedMessageId: options.quotedMessageId,
            parseVCards: options.parseVCards !== false,
            mentionedJidList: options.mentions || [],
            groupMentions: options.groupMentions,
            invokedBotWid: options.invokedBotWid,
            ignoreQuoteErrors: options.ignoreQuoteErrors !== false,
            waitUntilMsgSent: options.waitUntilMsgSent || false,
            extraOptions: options.extra,
        };
        const sendSeen = options.sendSeen !== false;
        if (content instanceof MessageMedia) {
            internalOptions.media = content;
            ((internalOptions.isViewOnce = options.isViewOnce), (content = ''));
        } else if (options.media instanceof MessageMedia) {
            internalOptions.media = options.media;
            internalOptions.caption = content;
            ((internalOptions.isViewOnce = options.isViewOnce), (content = ''));
        } else if (content instanceof Location) {
            internalOptions.location = content;
            content = '';
        } else if (content instanceof Poll) {
            internalOptions.poll = content;
            content = '';
        } else if (content instanceof ScheduledEvent) {
            internalOptions.event = content;
            content = '';
        } else if (content instanceof Contact) {
            internalOptions.contactCard = content.id._serialized;
            content = '';
        } else if (
            Array.isArray(content) &&
            content.length > 0 &&
            content[0] instanceof Contact
        ) {
            internalOptions.contactCardList = content.map(
                (contact) => contact.id._serialized,
            );
            content = '';
        } else if (content instanceof Buttons) {
            console.warn(
                'Buttons are now deprecated. See more at https://www.youtube.com/watch?v=hv1R1rLeVVE.',
            );
            if (content.type !== 'chat') {
                internalOptions.attachment = content.body;
            }
            internalOptions.buttons = content;
            content = '';
        } else if (content instanceof List) {
            console.warn(
                'Lists are now deprecated. See more at https://www.youtube.com/watch?v=hv1R1rLeVVE.',
            );
            internalOptions.list = content;
            content = '';
        }
        if (internalOptions.sendMediaAsSticker && internalOptions.media) {
            internalOptions.media = await Util.formatToWebpSticker(
                internalOptions.media,
                {
                    name: options.stickerName,
                    author: options.stickerAuthor,
                    categories: options.stickerCategories,
                },
                this.pupPage,
            );
        }
        const sentMsg = await this.pupPage.evaluate(
            async (chatId, content, options, sendSeen) => {
                const chat = await window.WWebJS.getChat(chatId, {
                    getAsModel: false,
                });
                if (!chat) return null;
                if (sendSeen) {
                    await window.WWebJS.sendSeen(chatId);
                }
                const msg = await window.WWebJS.sendMessage(
                    chat,
                    content,
                    options,
                );
                return msg.serialize();
            },
            chatId,
            content,
            internalOptions,
            sendSeen,
        );
        return new Message(this, sentMsg);
    }

    /**
     * Get a Chat by ID
     * @param {string} chatId
     * @returns {Promise<Chat>}
     */
    async getChatById(chatId) {
        let chat = await this.pupPage.evaluate(async (chatId) => {
            return await window.WWebJS.getChat(chatId);
        }, chatId);

        if (!chat) return null;

        return ChatFactory.create(this, chat);
    }

    /**
     * Get all Chats
     * @returns {Promise<Array<Chat>>}
     */
    async getChats() {
        let chats = await this.pupPage.evaluate(async () => {
            return await window.WWebJS.getChats();
        });

        return chats.map((chat) => ChatFactory.create(this, chat));
    }

    /**
     * Get a Contact by ID
     * @param {string} contactId
     * @returns {Promise<Contact>}
     */
    async getContactById(contactId) {
        let contact = await this.pupPage.evaluate(async (contactId) => {
            return window.WWebJS.getContact(contactId);
        }, contactId);

        return ContactFactory.create(this, contact);
    }

    /**
     * Get all Contacts
     * @returns {Promise<Array<Contact>>}
     */
    async getContacts() {
        let contacts = await this.pupPage.evaluate(() => {
            return window.WWebJS.getContacts();
        });

        return contacts.map((contact) => ContactFactory.create(this, contact));
    }

    async getMessageById(messageId) {
        const msg = await this.pupPage.evaluate(async (messageId) => {
            return await window.WWebJS.getMessageById(messageId);
        }, messageId);

        if (msg) return new Message(this, msg);
        return null;
    }

    /**
     * Returns an object with the community info
     * @param {string} communityId community ID
     * @returns {Promise<object>} community info
     */
    async getCommunityLabels(communityId) {
        return this.pupPage.evaluate(async (communityId) => {
            return await window.WWebJS.getCommunityLabels(communityId);
        }, communityId);
    }

    /**
     * Sync chat history conversation
     * @param {string} chatId chat ID
     * @return {Promise<boolean>} True if operation completed successfully, false otherwise.
     */
    async syncHistory(chatId) {
        return this.pupPage.evaluate(async (chatId) => {
            return await window.WWebJS.syncHistory(chatId);
        }, chatId);
    }

    /**
     * Add or edit a customer note
     * @see https://faq.whatsapp.com/1433099287594476
     * @param {string} chatId chat ID
     * @param {string} note The note to add
     * @returns {Promise<void>}
     */
    async addOrEditCustomerNote(chatId, note) {
        return this.pupPage.evaluate(
            async (chatId, note) => {
                return await window.WWebJS.addOrEditCustomerNote(chatId, note);
            },
            chatId,
            note,
        );
    }

    /**
     * Get a customer note
     * @see https://faq.whatsapp.com/1433099287594476
     * @param {string} chatId chat ID
     * @returns {Promise<{
     * chatId: string,
     * content: string,
     * createdAt: number,
     * id: string,
     * modifiedAt: number,
     * type: string
     * }>}
     */
    async getCustomerNote(chatId) {
        return this.pupPage.evaluate(async (chatId) => {
            return await window.WWebJS.getCustomerNote(chatId);
        }, chatId);
    }

    /**
     * Returns an object with the common groups with a user
     * @param {string} userId user ID
     * @returns {Promise<string[]>} array of common groups serialized ID
     */
    async getCommonGroups(userId) {
        return this.pupPage.evaluate(async (userId) => {
            return await window.WWebJS.getCommonGroups(userId);
        }, userId);
    }

    /**
     * Create a community
     * @param {string} name community name
     * @param {string} description community description
     * @returns {Promise<object>} community creation info
     */
    async createCommunity(name, description) {
        return this.pupPage.evaluate(
            async (name, description) => {
                return await window.WWebJS.createCommunity(name, description);
            },
            name,
            description,
        );
    }

    /**
     * Deactivate a community
     * @param {string} communityId community ID
     * @returns {Promise<void>}
     */
    async deactivateCommunity(communityId) {
        return this.pupPage.evaluate(async (communityId) => {
            return await window.WWebJS.deactivateCommunity(communityId);
        }, communityId);
    }

    /**
     * Get a Broadcast by ID
     * @param {string} broadcastId
     * @returns {Promise<Broadcast>}
     */
    async getBroadcastById(broadcastId) {
        let broadcast = await this.pupPage.evaluate(async (broadcastId) => {
            return await window.WWebJS.getBroadcast(broadcastId);
        }, broadcastId);

        if (broadcast) return new Broadcast(this, broadcast);
        return null;
    }

    /**
     * Get all Broadcasts
     * @returns {Promise<Array<Broadcast>>}
     */
    async getBroadcasts() {
        let broadcasts = await this.pupPage.evaluate(async () => {
            return await window.WWebJS.getBroadcasts();
        });

        return broadcasts.map((broadcast) => new Broadcast(this, broadcast));
    }

    /**
     * Create a new Group
     * @param {string} title Group title
     * @param {Array<Contact|string>} participants At least one contact instance or contact id is required
     * @returns {Promise<GroupCreationResponse>} Object with the creation results and group id
     */
    async createGroup(title, participants) {
        let rawParticipants = participants.map((p) =>
            p instanceof Contact ? p.id._serialized : p,
        );
        return await this.pupPage.evaluate(
            async (title, rawParticipants) => {
                return await window.WWebJS.createGroup(title, rawParticipants);
            },
            title,
            rawParticipants,
        );
    }

    /**
     * Create a new Channel
     * @param {string} title Channel title
     * @param {string} description Channel description
     * @returns {Promise<ChannelCreationResponse>} Object with the creation results and channel id
     */
    async createChannel(title, description) {
        return await this.pupPage.evaluate(async (title, description) => {
            return await window.WWebJS.createChannel(title, description);
        }, title, description);
    }

    /**
     * Get all labels
     * @returns {Promise<Array<Label>>}
     */
    async getLabels() {
        let labels = await this.pupPage.evaluate(async () => {
            return Object.values(await window.WWebJS.getLabels());
        });

        return labels.map((label) => new Label(this, label));
    }

    /**
     * Get label by ID
     * @param {string} labelId
     * @returns {Promise<Label>}
     */
    async getLabelById(labelId) {
        let label = await this.pupPage.evaluate(async (labelId) => {
            return window.WWebJS.getLabel(labelId);
        }, labelId);

        return new Label(this, label);
    }

    /**
     * Get all Chats labeled with the label ID
     * @param {string} labelId
     * @returns {Promise<Array<Chat>>}
     */
    async getChatsByLabelId(labelId) {
        let chatIds = await this.pupPage.evaluate(async (labelId) => {
            const label = window.require('WAWebLabelCollection').LabelCollection.get(labelId);
            const labelItemCollection = window.require('WAWebLabelItemCollection').LabelItemCollection;
            await labelItemCollection.getLabelItems(label);
            const items = labelItemCollection.filter((m) => m.labelId === labelId && m.id.endsWith('@g.us') || m.id.endsWith('@c.us'));
            return items.map((m) => m.id);
        }, labelId);

        return Promise.all(chatIds.map((id) => this.getChatById(id)));
    }

    /**
     * Get all Labels assigned to a chat
     * @param {string} chatId
     * @returns {Promise<Array<Label>>}
     */
    async getChatLabels(chatId) {
        let labels = await this.pupPage.evaluate(async (chatId) => {
            return window.WWebJS.getChatLabels(chatId);
        }, chatId);

        return labels.map((label) => new Label(this, label));
    }

    /**
     * Add or remove labels to a chat or chats
     * @param {Array<number|string>} labelIds
     * @param {Array<string>} chatIds
     * @returns {Promise<void>}
     */
    async addOrRemoveLabels(labelIds, chatIds) {
        return this.pupPage.evaluate(
            async (labelIds, chatIds) => {
                return window.WWebJS.addOrRemoveLabels(labelIds, chatIds);
            },
            labelIds,
            chatIds,
        );
    }

    /**
     * Get instances of all pinned messages in a chat
     * @param {string} chatId
     * @returns {Promise<Array<Message>>}
     */
    async getPinnedMessages(chatId) {
        const pinnedMessages = await this.pupPage.evaluate(async (chatId) => {
            return window.WWebJS.getPinnedMessages(chatId);
        }, chatId);

        return pinnedMessages.map((m) => new Message(this, m));
    }

    /**
     * Actions may include archive, unarchive, pin, unpin, mute, unmute, read, unread, delete, clear, pin_msg, unpin_msg
     * @typedef {Object} ChatActionOptions
     * @property {string} action - Action to perform
     * @property {string} [chatId] - Chat ID to perform the action on (not required for pin_msg, unpin_msg)
     * @property {?Date} [muteDuration] - Duration to mute the chat (only required for mute)
     * @property {string} [msgId] - Message ID to pin/unpin (only required for pin_msg, unpin_msg)
     * @property {number} [pinDuration] - Duration to pin the message in seconds (only required for pin_msg)
     */

    /**
     * Perform an action on a chat or message
     * @param {ChatActionOptions} options Options used when performing the action
     * @returns {Promise<any>}
     */
    async chatAction(options = {}) {
        return this.pupPage.evaluate(async (options) => {
            if (options.action === 'archive') {
                const chat = window.require('WAWebCollections').Chat.get(options.chatId);
                return window.require('WAWebArchiveChatAction').archiveChat(chat, true);
            } else if (options.action === 'unarchive') {
                const chat = window.require('WAWebCollections').Chat.get(options.chatId);
                return window.require('WAWebArchiveChatAction').archiveChat(chat, false);
            } else if (options.action === 'pin') {
                const chat = window.require('WAWebCollections').Chat.get(options.chatId);
                return window.require('WAWebPinChatAction').pinChat(chat, true);
            } else if (options.action === 'unpin') {
                const chat = window.require('WAWebCollections').Chat.get(options.chatId);
                return window.require('WAWebPinChatAction').pinChat(chat, false);
            } else if (options.action === 'mute') {
                const chat = window.require('WAWebCollections').Chat.get(options.chatId);
                const muteDuration = options.muteDuration ? options.muteDuration / 1000 : -1;
                return window.require('WAWebMuteChatAction').muteChat(chat, muteDuration, false);
            } else if (options.action === 'unmute') {
                const chat = window.require('WAWebCollections').Chat.get(options.chatId);
                return window.require('WAWebMuteChatAction').unmuteChat(chat, false);
            } else if (options.action === 'read') {
                const chat = window.require('WAWebCollections').Chat.get(options.chatId);
                return window.require('WAWebReadChatAction').markChatAsRead(chat, true, false);
            } else if (options.action === 'unread') {
                const chat = window.require('WAWebCollections').Chat.get(options.chatId);
                return window.require('WAWebReadChatAction').markChatAsUnread(chat, false);
            } else if (options.action === 'delete') {
                const chat = window.require('WAWebCollections').Chat.get(options.chatId);
                return window.require('WAWebDeleteChatAction').deleteChat(chat);
            } else if (options.action === 'clear') {
                const chat = window.require('WAWebCollections').Chat.get(options.chatId);
                return window.require('WAWebClearChatAction').clearChat(chat);
            } else if (options.action === 'pin_msg') {
                const msg = window.require('WAWebCollections').Msg.get(options.msgId);
                return window.require('WAWebPinMessageAction').pinMessage(msg, options.pinDuration);
            } else if (options.action === 'unpin_msg') {
                const msg = window.require('WAWebCollections').Msg.get(options.msgId);
                return window.require('WAWebPinMessageAction').unpinMessage(msg);
            }
        }, options);
    }

    /**
     * Force archive a chat
     * @param {string} chatId
     * @returns {Promise<boolean>}
     */
    async archiveChat(chatId) {
        return this.chatAction({ action: 'archive', chatId });
    }

    /**
     * Force unarchive a chat
     * @param {string} chatId
     * @returns {Promise<boolean>}
     */
    async unarchiveChat(chatId) {
        return this.chatAction({ action: 'unarchive', chatId });
    }

    /**
     * Force pin a chat
     * @param {string} chatId
     * @returns {Promise<boolean>}
     */
    async pinChat(chatId) {
        return this.chatAction({ action: 'pin', chatId });
    }

    /**
     * Force unpin a chat
     * @param {string} chatId
     * @returns {Promise<boolean>}
     */
    async unpinChat(chatId) {
        return this.chatAction({ action: 'unpin', chatId });
    }

    /**
     * Mute a chat
     * @param {string} chatId
     * @param {?Date} muteDuration Duration to mute the chat, don't provide a value to mute forever
     * @returns {Promise<{isMuted: boolean, muteExpiration: number}>}
     */
    async muteChat(chatId, muteDuration) {
        await this.chatAction({ action: 'mute', chatId, muteDuration });
        const chat = await this.getChatById(chatId);
        return { isMuted: chat.isMuted, muteExpiration: chat.muteExpiration };
    }

    /**
     * Unmute a chat
     * @param {string} chatId
     * @returns {Promise<{isMuted: boolean, muteExpiration: number}>}
     */
    async unmuteChat(chatId) {
        await this.chatAction({ action: 'unmute', chatId });
        const chat = await this.getChatById(chatId);
        return { isMuted: chat.isMuted, muteExpiration: chat.muteExpiration };
    }

    /**
     * Mark chat as unread
     * @param {string} chatId
     * @returns {Promise<void>}
     */
    async markChatUnread(chatId) {
        return this.chatAction({ action: 'unread', chatId });
    }

    /**
     * Returns the status text of a contact
     * @param {string} contactId
     * @returns {Promise<object>}
     */
    async getStatus(contactId) {
        let status = await this.pupPage.evaluate(async (contactId) => {
            return window.WWebJS.getStatus(contactId);
        }, contactId);

        return status;
    }

    /**
     * Sets the current user's profile status text
     * @param {string} status New status text
     */
    async setStatus(status) {
        await this.pupPage.evaluate(async (status) => {
            return window.WWebJS.setStatus(status);
        }, status);
    }

    /**
     * Sets the current user's profile name
     * @param {string} name New name
     * @returns {Promise<boolean>}
     */
    async setDisplayName(name) {
        return this.pupPage.evaluate(async (name) => {
            return window.WWebJS.setDisplayName(name);
        }, name);
    }

    /**
     * Gets the profile picture of a contact/group
     * @param {string} contactId
     * @returns {Promise<string>} Profile picture URL or undefined if not set
     */
    async getProfilePicUrl(contactId) {
        return await this.pupPage.evaluate(async (contactId) => {
            return window.WWebJS.getProfilePicUrl(contactId);
        }, contactId);
    }

    /**
     * Gets the profile privacy settings
     * @returns {Promise<object>} privacy settings
     */
    async getPrivacySettings() {
        return await this.pupPage.evaluate(async () => {
            return window.WWebJS.getPrivacySettings();
        });
    }

    /**
     * Update profile picture
     * @param {MessageMedia} media
     * @param {string} [type=chat] picture type 'chat' | 'community'
     * @param {string} [id] chat or community id, default is me
     * @returns {Promise<boolean>} true if success
     */
    async setProfilePicture(media, type = 'chat', id) {
        id = id ?? this.info.wid._serialized;
        return await this.pupPage.evaluate(
            async (id, media, type) => {
                return window.WWebJS.setProfilePic(id, media, type);
            },
            id,
            media,
            type,
        );
    }

    /**
     * Delete profile picture
     * @param {string} [type=chat] picture type 'chat' | 'community'
     * @param {string} [id] chat or community id, default is me
     * @returns {Promise<boolean>} true if success
     */
    async deleteProfilePicture(type = 'chat', id) {
        id = id ?? this.info.wid._serialized;
        return await this.pupPage.evaluate(
            async (id, type) => {
                return window.WWebJS.deleteProfilePic(id, type);
            },
            id,
            type,
        );
    }

    /**
     * Update query duration
     * @param {string} type privacy type 'last_seen' | 'profile_pic' | 'status' | 'read_receipts' | 'group_add' | 'online'
     * @param {string} value privacy value 'all' | 'contacts' | 'contact_blacklist' | 'none' | 'everyone' (for online)
     * @returns {Promise<boolean>} true if success
     */
    async setPrivacySettings(type, value) {
        return await this.pupPage.evaluate(
            async (type, value) => {
                return window.WWebJS.setPrivacySettings(type, value);
            },
            type,
            value,
        );
    }

    /**
     * Blocks a contact
     * @param {string} contactId
     * @returns {Promise<Contact>} The blocked contact
     */
    async blockContact(contactId) {
        let contact = await this.pupPage.evaluate(async (contactId) => {
            return window.WWebJS.blockContact(contactId);
        }, contactId);

        return ContactFactory.create(this, contact);
    }

    /**
     * Unblocks a contact
     * @param {string} contactId
     * @returns {Promise<Contact>} The unblocked contact
     */
    async unblockContact(contactId) {
        let contact = await this.pupPage.evaluate(async (contactId) => {
            return window.WWebJS.unblockContact(contactId);
        }, contactId);

        return ContactFactory.create(this, contact);
    }

    /**
     * Checks if a user is registered on WhatsApp
     * @param {string} id User ID to check, without the domain (e.g. 12025550108)
     * @returns {Promise<boolean>} Whether the user is registered
     */
    async isRegisteredUser(id) {
        return await this.pupPage.evaluate(async (id) => {
            return window.WWebJS.isRegisteredUser(id);
        }, id);
    }

    /**
     * Gets the registered WhatsApp ID for a phone number
     * @param {string} id User ID to check, without the domain (e.g. 12025550108)
     * @returns {Promise<object|null>} Object containing serialized ID or null if not registered
     */
    async getNumberId(id) {
        return await this.pupPage.evaluate(async (id) => {
            return window.WWebJS.getNumberId(id);
        }, id);
    }

    /**
     * Gets formatted JID format from phone number user parameter
     * @param {string} userId user ID string
     * @returns {Promise<string|null>} Formatted serialized string
     */
    async getFormattedUserJid(userId) {
        return await this.pupPage.evaluate(async (userId) => {
            let serialized = await window.WWebJS.getNumberId(userId);
            if (!serialized) return null;

            serialized.chatId = window
                .require('WAWebJidToWid')
                .userJidToUserWid(serialized.chatJid)._serialized;
            delete serialized.chatJid;

            return serialized;
        }, userId);
    }

    /**
     * Get Poll Votes
     * @param {string} messageId
     * @return {Promise<Array<PollVote>>}
     */
    async getPollVotes(messageId) {
        const msg = await this.getMessageById(messageId);
        if (!msg) return [];
        if (msg.type != MessageTypes.POLL_CREATION)
            throw 'Invalid usage! Can only be used with a pollCreation message';

        const pollVotes = await this.pupPage.evaluate(async (msg) => {
            const msgKey = window
                .require('WAWebMsgKey')
                .fromString(msg.id._serialized);
            let pollVotes = await window
                .require('WAWebPollsVotesSchema')
                .getTable()
                .equals(['parentMsgKey'], msgKey.toString());

            return pollVotes.map((item) => {
                const typedArray = new Uint8Array(item.selectedOptionLocalIds);
                return {
                    ...item,
                    selectedOptionLocalIds: Array.from(typedArray),
                };
            });
        }, msg);

        return pollVotes.map(
            (pollVote) =>
                new PollVote(this.client, { ...pollVote, parentMessage: msg }),
        );
    }
}

module.exports = Client;