import { Boom } from '@hapi/boom'
import axios from 'axios'
import { randomBytes } from 'crypto'
import { promises as fs } from 'fs'
import { Logger } from 'pino'
import { type Transform } from 'stream'
import { proto } from '../../WAProto'
import { MEDIA_KEYS, URL_REGEX, WA_DEFAULT_EPHEMERAL } from '../Defaults'
import {
	AnyMediaMessageContent,
	AnyMessageContent,
	DownloadableMessage,
	MediaGenerationOptions,
	MediaType,
	MessageContentGenerationOptions,
	MessageGenerationOptions,
	MessageGenerationOptionsFromContent,
	MessageType,
	MessageUserReceipt,
	WAMediaUpload,
	WAMessage,
	WAMessageContent,
	WAMessageStatus,
	WAProto,
	WATextMessage,
} from '../Types'
import { isJidGroup, isJidNewsLetter, isJidStatusBroadcast, jidNormalizedUser } from '../WABinary'
import { sha256 } from './crypto'
import { generateMessageID, getKeyAuthor, unixTimestampSeconds } from './generics'
import { downloadContentFromMessage, encryptedStream, generateThumbnail, getAudioDuration, getAudioWaveform, MediaDownloadOptions, prepareStream } from './messages-media'

type MediaUploadData = {
	media: WAMediaUpload
	caption?: string
	ptt?: boolean
	ptv?: boolean
	seconds?: number
	gifPlayback?: boolean
	fileName?: string
	jpegThumbnail?: string
	mimetype?: string
	width?: number
	height?: number
	waveform?: Uint8Array
	backgroundArgb?: number
}

const MIMETYPE_MAP: { [T in MediaType]?: string } = {
	image: 'image/jpeg',
	video: 'video/mp4',
	document: 'application/pdf',
	audio: 'audio/ogg; codecs=opus',
	sticker: 'image/webp',
	'product-catalog-image': 'image/jpeg',
}

const MessageTypeProto = {
	'image': WAProto.Message.ImageMessage,
	'video': WAProto.Message.VideoMessage,
	'audio': WAProto.Message.AudioMessage,
	'sticker': WAProto.Message.StickerMessage,
   	'document': WAProto.Message.DocumentMessage,
} as const

const ButtonType = proto.Message.ButtonsMessage.HeaderType

/**
 * Uses a regex to test whether the string contains a URL, and returns the URL if it does.
 * @param text eg. hello https://google.com
 * @returns the URL, eg. https://google.com
 */
export const extractUrlFromText = (text: string) => text.match(URL_REGEX)?.[0]

export const generateLinkPreviewIfRequired = async(text: string, getUrlInfo: MessageGenerationOptions['getUrlInfo'], logger: MessageGenerationOptions['logger']) => {
	const url = extractUrlFromText(text)
	if(!!getUrlInfo && url) {
		try {
			const urlInfo = await getUrlInfo(url)
			return urlInfo
		} catch(error) { // ignore if fails
			logger?.warn({ trace: error.stack }, 'url generation failed')
		}
	}
}

const assertColor = async(color) => {
	let assertedColor
	if(typeof color === 'number') {
		assertedColor = color > 0 ? color : 0xffffffff + Number(color) + 1
	} else {
		let hex = color.trim().replace('#', '')
		if(hex.length <= 6) {
			hex = 'FF' + hex.padStart(6, '0')
		}

		assertedColor = parseInt(hex, 16)
		return assertedColor
	}
}

export const prepareWAMessageMedia = async(
	message: AnyMediaMessageContent,
	options: MediaGenerationOptions
) => {
	const logger = options.logger

	let mediaType: typeof MEDIA_KEYS[number] | undefined
	for(const key of MEDIA_KEYS) {
		if(key in message) {
			mediaType = key
		}
	}

	if(!mediaType) {
		throw new Boom('Invalid media type', { statusCode: 400 })
	}

	const uploadData: MediaUploadData = {
		...message,
		media: message[mediaType]
	}
	delete uploadData[mediaType]
	// check if cacheable + generate cache key
	const cacheableKey = typeof uploadData.media === 'object' &&
			('url' in uploadData.media) &&
			!!uploadData.media.url &&
			!!options.mediaCache && (
	// generate the key
		mediaType + ':' + uploadData.media.url!.toString()
	)

	if(mediaType === 'document' && !uploadData.fileName) {
		uploadData.fileName = 'file'
	}

	if(!uploadData.mimetype) {
		uploadData.mimetype = MIMETYPE_MAP[mediaType]
	}

	// check for cache hit
	if(cacheableKey) {
		const mediaBuff = options.mediaCache!.get<Buffer>(cacheableKey)
		if(mediaBuff) {
			logger?.debug({ cacheableKey }, 'got media cache hit')

			const obj = WAProto.Message.decode(mediaBuff)
			const key = `${mediaType}Message`

			Object.assign(obj[key], { ...uploadData, media: undefined })

			return obj
		}
	}

	const requiresDurationComputation = mediaType === 'audio' && typeof uploadData.seconds === 'undefined'
	const requiresThumbnailComputation = (mediaType === 'image' || mediaType === 'video') &&
										(typeof uploadData['jpegThumbnail'] === 'undefined')
	const requiresWaveformProcessing = mediaType === 'audio' && uploadData.ptt === true
	const requiresAudioBackground = options.backgroundColor && mediaType === 'audio' && uploadData.ptt === true
	const requiresOriginalForSomeProcessing = requiresDurationComputation || requiresThumbnailComputation
	const {
		mediaKey,
		encWriteStream,
		bodyPath,
		fileEncSha256,
		fileSha256,
		fileLength,
		didSaveToTmpPath,
	} = await (options.newsletter ? prepareStream : encryptedStream)(
		uploadData.media,
		options.mediaTypeOverride || mediaType,
		{
			logger,
			saveOriginalFileIfRequired: requiresOriginalForSomeProcessing,
			opts: options.options
		}
	)
	 // url safe Base64 encode the SHA256 hash of the body
	const fileEncSha256B64 = (options.newsletter ? fileSha256 : fileEncSha256 ?? fileSha256).toString('base64')
	const [{ mediaUrl, directPath, handle }] = await Promise.all([
		(async() => {
			const result = await options.upload(
				encWriteStream,
				{ fileEncSha256B64, mediaType, timeoutMs: options.mediaUploadTimeoutMs }
			)
			logger?.debug({ mediaType, cacheableKey }, 'uploaded media')
			return result
		})(),
		(async() => {
			try {
				if(requiresThumbnailComputation) {
					const {
						thumbnail,
						originalImageDimensions
					} = await generateThumbnail(bodyPath!, mediaType as 'image' | 'video', options)
					uploadData.jpegThumbnail = thumbnail
					if(!uploadData.width && originalImageDimensions) {
						uploadData.width = originalImageDimensions.width
						uploadData.height = originalImageDimensions.height
						logger?.debug('set dimensions')
					}

					logger?.debug('generated thumbnail')
				}

				if(requiresDurationComputation) {
					uploadData.seconds = await getAudioDuration(bodyPath!)
					logger?.debug('computed audio duration')
				}

				if(requiresWaveformProcessing) {
					uploadData.waveform = await getAudioWaveform(bodyPath!, logger)
					logger?.debug('processed waveform')
				}

				if(requiresWaveformProcessing) {
					uploadData.waveform = await getAudioWaveform(bodyPath!, logger)
					logger?.debug('processed waveform')
				}

				if(requiresAudioBackground) {
					uploadData.backgroundArgb = await assertColor(options.backgroundColor)
					logger?.debug('computed backgroundColor audio status')
				}
			} catch(error) {
				logger?.warn({ trace: error.stack }, 'failed to obtain extra info')
			}
		})(),
	])
		.finally(
			async() => {
				if (!Buffer.isBuffer(encWriteStream)) {
					encWriteStream.destroy()
				}

				// remove tmp files
				if(didSaveToTmpPath && bodyPath) {
					await fs.unlink(bodyPath)
					logger?.debug('removed tmp files')
				}
			}
		)

	const obj = WAProto.Message.fromObject({
		[`${mediaType}Message`]: MessageTypeProto[mediaType].fromObject(
			{
				url:  handle ? undefined : mediaUrl,
				directPath,
				mediaKey: mediaKey,
				fileEncSha256: fileEncSha256,
				fileSha256,
				fileLength,
				mediaKeyTimestamp: handle ? undefined : unixTimestampSeconds(),
				...uploadData,
				media: undefined
			}
		)
	})

	if(uploadData.ptv) {
		obj.ptvMessage = obj.videoMessage
		delete obj.videoMessage
	}

	if(cacheableKey) {
		logger?.debug({ cacheableKey }, 'set cache')
		options.mediaCache!.set(cacheableKey, WAProto.Message.encode(obj).finish())
	}

	return obj
}

export const prepareDisappearingMessageSettingContent = (ephemeralExpiration?: number) => {
	ephemeralExpiration = ephemeralExpiration || 0
	const content: WAMessageContent = {
		ephemeralMessage: {
			message: {
				protocolMessage: {
					type: WAProto.Message.ProtocolMessage.Type.EPHEMERAL_SETTING,
					ephemeralExpiration
				}
			}
		}
	}
	return WAProto.Message.fromObject(content)
}

/**
 * Generate forwarded message content like WA does
 * @param message the message to forward
 * @param options.forceForward will show the message as forwarded even if it is from you
 */
export const generateForwardMessageContent = (
	message: WAMessage,
	forceForward?: boolean
) => {
	let content = message.message
	if(!content) {
		throw new Boom('no content in message', { statusCode: 400 })
	}

	// hacky copy
	content = normalizeMessageContent(content)
	content = proto.Message.decode(proto.Message.encode(content!).finish())

	let key = Object.keys(content)[0] as MessageType

	let score = content[key].contextInfo?.forwardingScore || 0
	score += message.key.fromMe && !forceForward ? 0 : 1
	if(key === 'conversation') {
		content.extendedTextMessage = { text: content[key] }
		delete content.conversation

		key = 'extendedTextMessage'
	}

	if(score > 0) {
		content[key].contextInfo = { forwardingScore: score, isForwarded: true }
	} else {
		content[key].contextInfo = {}
	}

	return content
}

export const generateWAMessageContent = async(
	message: AnyMessageContent,
	options: MessageContentGenerationOptions
) => {
	let m: WAMessageContent = {}
	if('text' in message) {
		const extContent = { text: message.text } as WATextMessage

		let urlInfo = message.linkPreview
		if(typeof urlInfo === 'undefined') {
			urlInfo = await generateLinkPreviewIfRequired(message.text, options.getUrlInfo, options.logger)
		}

		if(urlInfo) {
			extContent.canonicalUrl = urlInfo['canonical-url']
			extContent.matchedText = urlInfo['matched-text']
			extContent.jpegThumbnail = urlInfo.jpegThumbnail
			extContent.description = urlInfo.description
			extContent.title = urlInfo.title
			extContent.previewType = 0

			const img = urlInfo.highQualityThumbnail
			if(img) {
				extContent.thumbnailDirectPath = img.directPath
				extContent.mediaKey = img.mediaKey
				extContent.mediaKeyTimestamp = img.mediaKeyTimestamp
				extContent.thumbnailWidth = img.width
				extContent.thumbnailHeight = img.height
				extContent.thumbnailSha256 = img.fileSha256
				extContent.thumbnailEncSha256 = img.fileEncSha256
			}
		}

		if(options.backgroundColor) {
			extContent.backgroundArgb = await assertColor(options.backgroundColor)
		}

		if(options.font) {
			extContent.font = options.font
		}

		m.extendedTextMessage = extContent
    } else if('contacts' in message) {
		const contactLen = message.contacts.contacts.length
		if(!contactLen) {
			throw new Boom('require atleast 1 contact', { statusCode: 400 })
		}

		if(contactLen === 1) {
			m.contactMessage = WAProto.Message.ContactMessage.fromObject(message.contacts.contacts[0])
        } else {
			m.contactsArrayMessage = WAProto.Message.ContactsArrayMessage.fromObject(message.contacts)
		}
   } else if('location' in message) {
		m.locationMessage = WAProto.Message.LocationMessage.fromObject(message.location)
   } else if('react' in message) {
		if(!message.react.senderTimestampMs) {
			message.react.senderTimestampMs = Date.now()
		}

		m.reactionMessage = WAProto.Message.ReactionMessage.fromObject(message.react)
   } else if('delete' in message) {
		m.protocolMessage = {
			key: message.delete,
			type: WAProto.Message.ProtocolMessage.Type.REVOKE
		}
   } else if('forward' in message) {
		m = generateForwardMessageContent(
			message.forward,
			message.force
		)
   } else if('disappearingMessagesInChat' in message) {
		const exp = typeof message.disappearingMessagesInChat === 'boolean' ?
			(message.disappearingMessagesInChat ? WA_DEFAULT_EPHEMERAL : 0) :
			message.disappearingMessagesInChat
		m = prepareDisappearingMessageSettingContent(exp)
   } else if('groupInvite' in message) {
        m.groupInviteMessage = {};
        m.groupInviteMessage.inviteCode = message.groupInvite.inviteCode;
        m.groupInviteMessage.inviteExpiration = message.groupInvite.inviteExpiration;
        m.groupInviteMessage.caption = message.groupInvite.text;
        m.groupInviteMessage.groupJid = message.groupInvite.jid;
        m.groupInviteMessage.groupName = message.groupInvite.subject;
        //TODO: use built-in interface and get disappearing mode info etc.
        //TODO: cache / use store!?
        if(options.getProfilePicUrl) {
			const pfpUrl = await options.getProfilePicUrl(message.groupInvite.jid, 'preview')
			if(pfpUrl) {
				const resp = await axios.get(pfpUrl, { responseType: 'arraybuffer' })
				if(resp.status === 200) {
					m.groupInviteMessage.jpegThumbnail = resp.data
				}
			}
		}
   } else if('pin' in message) {
        m.pinInChatMessage = {};
        m.messageContextInfo = {};
        m.pinInChatMessage.key = message.pin;
        m.pinInChatMessage.type = message.type;
        m.pinInChatMessage.senderTimestampMs = Date.now();
        m.messageContextInfo.messageAddOnDurationInSecs = message.type === 1 ? message.time || 86400 : 0;
   } else if('keep' in message) {
        m.keepInChatMessage = {};
        m.keepInChatMessage.key = message.keep;
        m.keepInChatMessage.keepType = message.type;
        m.keepInChatMessage.timestampMs = Date.now();
   } else if('call' in message) {
      m = { 
        scheduledCallCreationMessage: {
           scheduledTimestampMs: message.call.time ?? Date.now(),
           callType: message.call.type ?? 1, 
           title: message.call.title
        }
      }
   } else if ('paymentInvite' in message) {
     	m.paymentInviteMessage = {
   	       serviceType: message.paymentInvite.type, 
           expiryTimestamp: message.paymentInvite.expiry
        }
   } else if('buttonReply' in message) {
		switch (message.type) {
		case 'template':
			m.templateButtonReplyMessage = {
				selectedDisplayText: message.buttonReply.displayText,
				selectedId: message.buttonReply.id,
				selectedIndex: message.buttonReply.index,
			}
			break
		case 'plain':
			m.buttonsResponseMessage = {
				selectedButtonId: message.buttonReply.id,
				selectedDisplayText: message.buttonReply.displayText,
				type: proto.Message.ButtonsResponseMessage.Type.DISPLAY_TEXT,
			}
			break
		}
   } else if('product' in message) {
		const { imageMessage } = await prepareWAMessageMedia(
			{ image: message?.product?.productImage },
			options
		)
		m.productMessage = WAProto.Message.ProductMessage.fromObject({
			...message,
			product: {
				...message.product,
				productImage: imageMessage,
			}
		})
   } else if ('order' in message) {
      m.orderMessage = WAProto.Message.OrderMessage.fromObject({
            orderId: message.order.id,
            thumbnail: message.order.thumbnail,
            itemCount: message.order.itemCount,
            status: message.order.status,
            surface: message.order.surface,
            orderTitle: message.order.title,
            message: message.order.text,
            sellerJid: message.order.seller,
            token: message.order.token,
            totalAmount1000: message.order.amount,
            totalCurrencyCode: message.order.currency
        }) 
   } else if('listReply' in message) {
		m.listResponseMessage = { ...message.listReply }
   } else if('poll' in message) {
		message.poll.selectableCount ||= 0
		message.poll.toAnnouncementGroup ||= false

		if(!Array.isArray(message.poll.values)) {
			throw new Boom('Invalid poll values', { statusCode: 400 })
		}

		if(
			message.poll.selectableCount < 0
			|| message.poll.selectableCount > message.poll.values.length
		) {
			throw new Boom(
				`poll.selectableCount in poll should be >= 0 and <= ${message.poll.values.length}`,
				{ statusCode: 400 }
			)
		}

		m.messageContextInfo = {
			// encKey
			messageSecret: message.poll.messageSecret || randomBytes(32),
		}

		const pollCreationMessage = {
			name: message.poll.name,
			selectableOptionsCount: message.poll.selectableCount,
			options: message.poll.values.map(optionName => ({ optionName })),
		}

		if(message.poll.toAnnouncementGroup) {
			// poll v2 is for community announcement groups (single select and multiple)
			m.pollCreationMessageV2 = pollCreationMessage
		} else {
			if(message.poll.selectableCount > 0) {
				//poll v3 is for single select polls
				m.pollCreationMessageV3 = pollCreationMessage
			} else {
				// poll v3 for multiple choice polls
				m.pollCreationMessage = pollCreationMessage
			}
		}
   } else if('event' in message) {
      m.messageContextInfo = {
         messageSecret: message.event.messageSecret || randomBytes(32), 
      }
      m.eventMessage = { ...message.event }
   } else if('inviteAdmin' in message) {
        m.newsletterAdminInviteMessage = {};
        m.newsletterAdminInviteMessage.inviteExpiration = message.inviteAdmin.inviteExpiration;
        m.newsletterAdminInviteMessage.caption = message.inviteAdmin.text;
        m.newsletterAdminInviteMessage.newsletterJid = message.inviteAdmin.jid;
        m.newsletterAdminInviteMessage.newsletterName = message.inviteAdmin.subject;
        m.newsletterAdminInviteMessage.jpegThumbnail = message.inviteAdmin.thumbnail;
   } else if ('requestPayment' in message) {  
       const sticker = message?.requestPayment?.sticker ?
          await prepareWAMessageMedia(
	       { sticker: message?.requestPayment?.sticker, ...options },
		   options
	      )
	      : null	
	   let notes = {}
	   if(message?.requestPayment?.sticker) {
	      notes = {
	          stickerMessage: {
	             ...sticker?.stickerMessage,
	             contextInfo: message?.requestPayment?.contextInfo
	          }
	      }
	   } else if(message.requestPayment.note) {
	      notes = {
	          extendedTextMessage: {
		          text: message.requestPayment.note,
		          contextInfo: message?.requestPayment?.contextInfo,
		      }
	      }
	   } else {
	      throw new Boom('Invalid media type', { statusCode: 400 })
	   }
       m.requestPaymentMessage = WAProto.Message.RequestPaymentMessage.fromObject({
	       expiryTimestamp: message.requestPayment.expiry,
           amount1000: message.requestPayment.amount,
           currencyCodeIso4217: message.requestPayment.currency,
           requestFrom: message.requestPayment.from,
		   noteMessage: { ...notes },
           background: message.requestPayment.background ?? null,
       })
   } else if('sharePhoneNumber' in message) {
		m.protocolMessage = {
			type: proto.Message.ProtocolMessage.Type.SHARE_PHONE_NUMBER
		}
	} else if('requestPhoneNumber' in message) {
		m.requestPhoneNumberMessage = {}
	} else {
		m = await prepareWAMessageMedia(
			message,
			options
		)
	}

	if('buttons' in message && !!message.buttons) {
		const buttonsMessage: proto.Message.IButtonsMessage = {
			buttons: message.buttons!.map(b => ({ ...b, type: proto.Message.ButtonsMessage.Button.Type.RESPONSE }))
		}
		if('text' in message) {
			buttonsMessage.contentText = message.text
			buttonsMessage.headerType = ButtonType.EMPTY
		} else {
			if('caption' in message) {
				buttonsMessage.contentText = message.caption
			}

			const type = Object.keys(m)[0].replace('Message', '').toUpperCase()
			buttonsMessage.headerType = ButtonType[type]

			Object.assign(buttonsMessage, m)
		}

		if('footer' in message && !!message.footer) {
			buttonsMessage.footerText = message.footer
		}
		
        if('title' in message && !!message.title) {
        	buttonsMessage.text = message.title,
			buttonsMessage.headerType = ButtonType.TEXT
        }
        
        if('contextInfo' in message && !!message.contextInfo) {
        	buttonsMessage.contextInfo = message.contextInfo
        }
        
        if('mentions' in message && !!message.mentions) {
        	buttonsMessage.contextInfo = { mentionedJid: message.mentions }
        }

		m = { buttonsMessage }
	} else if('templateButtons' in message && !!message.templateButtons) {
		const msg: proto.Message.TemplateMessage.IHydratedFourRowTemplate = {
			hydratedButtons: message.hasOwnProperty("templateButtons") ? message.templateButtons : message.templateButtons
		}

		if('text' in message) {
			msg.hydratedContentText = message.text
		} else {

			if('caption' in message) {
				msg.hydratedContentText = message.caption
			}

			Object.assign(msg, m)
		}

		if('footer' in message && !!message.footer) {
			msg.hydratedFooterText = message.footer
		}

		m = {
			templateMessage: {
				fourRowTemplate: msg,
				hydratedTemplate: msg
			}
		}
    }
	
	if('interactiveButtons' in message && !!message.interactiveButtons) {
	   const interactiveMessage: proto.Message.IInteractiveMessage = {
	      nativeFlowMessage: WAProto.Message.InteractiveMessage.NativeFlowMessage.fromObject({ 
	         buttons: message.interactiveButtons,
	      })
	   }
	   
	   if('text' in message) {
	       body: interactiveMessage.body = { 
	           text: message.text
	       }
	   } else {
	   
	      if('caption' in message) {
	          body: interactiveMessage.body = {
	              text: message.caption
	          }
	      }	            
	   }
	   
	   if('footer' in message && !!message.footer) {
		   footer: interactiveMessage.footer = {
		      text: message.footer
		   }
	   }
	   
	   if('title' in message && !!message.title) {
	       header: interactiveMessage.header = {
	          title: message.title,
	          subtitle: message.subtitle,
	          hasMediaAttachment: message?.media ?? false,
	       }
	       		  
		  Object.assign(interactiveMessage.header, m)	
		  
	   }
	   
       if('contextInfo' in message && !!message.contextInfo) {
        	interactiveMessage.contextInfo = message.contextInfo
       }
        
       if('mentions' in message && !!message.mentions) {
        	interactiveMessage.contextInfo = { mentionedJid: message.mentions }
       }
       
	   m = { interactiveMessage }
	}
	
	if('shop' in message && !!message.shop) {
	    const interactiveMessage: proto.Message.IInteractiveMessage = {
	      shopStorefrontMessage: WAProto.Message.InteractiveMessage.ShopMessage.fromObject({ 
	         surface: message.shop,
	         id: message.id
	      })
	   }
	   
	   if('text' in message) {
	       body: interactiveMessage.body = { 
	           text: message.text
	       }
	   } else {
	   
	      if('caption' in message) {
	          body: interactiveMessage.body = {
	              text: message.caption
	          }
	      }
	   }
	   
	   if('footer' in message && !!message.footer) {
		   footer: interactiveMessage.footer = {
		      text: message.footer
		   }
	   }
	   
	   if('title' in message && !!message.title) {
	       header: interactiveMessage.header = {
	          title: message.title,
	          subtitle: message.subtitle,
	          hasMediaAttachment: message?.media ?? false,
	       }
	       		  
		  Object.assign(interactiveMessage.header, m)	
	   
	   }
	   
       if('contextInfo' in message && !!message.contextInfo) {
        	interactiveMessage.contextInfo = message.contextInfo
       }
        
       if('mentions' in message && !!message.mentions) {
        	interactiveMessage.contextInfo = { mentionedJid: message.mentions }
       }
       
	   m = { interactiveMessage }
   }

   if('sections' in message && !!message.sections) {
	    const listMessage: proto.Message.IListMessage = {
			sections: message.sections,
			buttonText: message.buttonText,
			title: message.title,
			footerText: message.footer,
			description: message.text,
			listType: message.hasOwnProperty("listType") ? message.listType : proto.Message.ListMessage.ListType.PRODUCT_LIST
		}

		m = { listMessage }
	}

	if('viewOnce' in message && !!message.viewOnce) {
		m = { viewOnceMessage: { message: m } }
	}
	
    if('viewOnceV2' in message && !!message.viewOnceV2) {
        m = { viewOnceMessageV2: { message: m } };
    }
    
    if('viewOnceV2Extension' in message && !!message.viewOnceV2Extension) {
        m = { viewOnceMessageV2Extension: { message: m } };
    }
    
    if('ephemeral' in message && !!message.ephemeral) {
    	m = { ephemeralMessage: { message: m } };
    }
    
    if('lottie' in message && !!message.lottie) {
    	m = { lottieStickerMessage: { message: m } };
    }

	if('mentions' in message && message.mentions?.length) {
		const [messageType] = Object.keys(m)
		m[messageType].contextInfo = m[messageType] || { }
		m[messageType].contextInfo.mentionedJid = message.mentions
	}

	if('edit' in message) {
		m = {
			protocolMessage: {
				key: message.edit,
				editedMessage: m,
				timestampMs: Date.now(),
				type: WAProto.Message.ProtocolMessage.Type.MESSAGE_EDIT
			}
		}
	}

	if('contextInfo' in message && !!message.contextInfo) {
		const [messageType] = Object.keys(m)
		m[messageType] = m[messageType] || {}
		m[messageType].contextInfo = message.contextInfo
	}

	return WAProto.Message.fromObject(m)
}

export const generateWAMessageFromContent = (
	jid: string,
	message: WAMessageContent,
	options: MessageGenerationOptionsFromContent
) => {
	// set timestamp to now
	// if not specified
	if(!options.timestamp) {
		options.timestamp = new Date()
	}

	const innerMessage = normalizeMessageContent(message)!
	const key: string = getContentType(innerMessage)!
	const timestamp = unixTimestampSeconds(options.timestamp)
	const { quoted, userJid } = options

	if(quoted && !isJidNewsLetter(jid)) {
		const participant = quoted.key.fromMe ? userJid : (quoted.participant || quoted.key.participant || quoted.key.remoteJid)

		let quotedMsg = normalizeMessageContent(quoted.message)!
		const msgType = getContentType(quotedMsg)!
		// strip any redundant properties
		quotedMsg = proto.Message.fromObject({ [msgType]: quotedMsg[msgType] })

		const quotedContent = quotedMsg[msgType]
		if(typeof quotedContent === 'object' && quotedContent && 'contextInfo' in quotedContent) {
			delete quotedContent.contextInfo
		}

		const contextInfo: proto.IContextInfo = innerMessage[key].contextInfo || { }
		contextInfo.participant = jidNormalizedUser(participant!)
		contextInfo.stanzaId = quoted.key.id
		contextInfo.quotedMessage = quotedMsg

		// if a participant is quoted, then it must be a group
		// hence, remoteJid of group must also be entered
		if(jid !== quoted.key.remoteJid) {
			contextInfo.remoteJid = quoted.key.remoteJid
		}

		innerMessage[key].contextInfo = contextInfo
	}

	if(
		// if we want to send a disappearing message
		!!options?.ephemeralExpiration &&
		// and it's not a protocol message -- delete, toggle disappear message
		key !== 'protocolMessage' &&
		// already not converted to disappearing message
		key !== 'ephemeralMessage' &&
		// newsletter not accept disappearing messages
		!isJidNewsLetter(jid)
	) {
		innerMessage[key].contextInfo = {
			...(innerMessage[key].contextInfo || {}),
			expiration: options.ephemeralExpiration || WA_DEFAULT_EPHEMERAL,
			//ephemeralSettingTimestamp: options.ephemeralOptions.eph_setting_ts?.toString()
		}
	}

	message = WAProto.Message.fromObject(message)

	const messageJSON = {
		key: {
			remoteJid: jid,
			fromMe: true,
			id: options?.messageId || generateMessageID(),
		},
		message: message,
		messageTimestamp: timestamp,
		messageStubParameters: [],
		participant: isJidGroup(jid) || isJidStatusBroadcast(jid) ? userJid : undefined,
		status: WAMessageStatus.PENDING
	}
	return WAProto.WebMessageInfo.fromObject(messageJSON)
}

export const generateWAMessage = async(
	jid: string,
	content: AnyMessageContent,
	options: MessageGenerationOptions,
) => {
	// ensure msg ID is with every log
	options.logger = options?.logger?.child({ msgId: options.messageId })
	return generateWAMessageFromContent(
		jid,
		await generateWAMessageContent(
			content,
			{ newsletter: isJidNewsLetter(jid!), ...options }
		),
		options
	)
}

/** Get the key to access the true type of content */
export const getContentType = (content: WAProto.IMessage | undefined) => {
	if(content) {
		const keys = Object.keys(content)
		const key = keys.find(k => (k === 'conversation' || k.includes('Message')) && k !== 'senderKeyDistributionMessage')
		return key as keyof typeof content
	}
}

/**
 * Normalizes ephemeral, view once messages to regular message content
 * Eg. image messages in ephemeral messages, in view once messages etc.
 * @param content
 * @returns
 */
export const normalizeMessageContent = (content: WAMessageContent | null | undefined): WAMessageContent | undefined => {
	 if(!content) {
		 return undefined
	 }

	 // set max iterations to prevent an infinite loop
	 for(let i = 0;i < 5;i++) {
		 const inner = getFutureProofMessage(content)
		 if(!inner) {
			 break
		 }

		 content = inner.message
	 }

	 return content!

	 function getFutureProofMessage(message: typeof content) {
		 return (
			 message?.ephemeralMessage
			 || message?.viewOnceMessage
			 || message?.documentWithCaptionMessage
			 || message?.viewOnceMessageV2
			 || message?.viewOnceMessageV2Extension
			 || message?.editedMessage
		 )
	 }
}

/**
 * Extract the true message content from a message
 * Eg. extracts the inner message from a disappearing message/view once message
 */
export const extractMessageContent = (content: WAMessageContent | undefined | null): WAMessageContent | undefined => {
	const extractFromTemplateMessage = (msg: proto.Message.TemplateMessage.IHydratedFourRowTemplate | proto.Message.IButtonsMessage) => {
		if(msg.imageMessage) {
			return { imageMessage: msg.imageMessage }
		} else if(msg.documentMessage) {
			return { documentMessage: msg.documentMessage }
		} else if(msg.videoMessage) {
			return { videoMessage: msg.videoMessage }
		} else if(msg.locationMessage) {
			return { locationMessage: msg.locationMessage }
		} else {
			return {
				conversation:
					'contentText' in msg
						? msg.contentText
						: ('hydratedContentText' in msg ? msg.hydratedContentText : '')
			}
		}
	}

	content = normalizeMessageContent(content)

	if(content?.buttonsMessage) {
	  return extractFromTemplateMessage(content.buttonsMessage!)
	}

	if(content?.templateMessage?.hydratedFourRowTemplate) {
		return extractFromTemplateMessage(content?.templateMessage?.hydratedFourRowTemplate)
	}

	if(content?.templateMessage?.hydratedTemplate) {
		return extractFromTemplateMessage(content?.templateMessage?.hydratedTemplate)
	}

	if(content?.templateMessage?.fourRowTemplate) {
		return extractFromTemplateMessage(content?.templateMessage?.fourRowTemplate)
	}

	return content
}

/**
 * Returns the device predicted by message ID
 */
export const getDevice = (id: string) => /^3A.{18}$/.test(id) ? 'ios' : /^3E.{20}$/.test(id) ? 'web' : /^(.{21}|.{32})$/.test(id) ? 'android' : /^.{18}$/.test(id) ? 'desktop' : 'unknown'

/** Upserts a receipt in the message */
export const updateMessageWithReceipt = (msg: Pick<WAMessage, 'userReceipt'>, receipt: MessageUserReceipt) => {
	msg.userReceipt = msg.userReceipt || []
	const recp = msg.userReceipt.find(m => m.userJid === receipt.userJid)
	if(recp) {
		Object.assign(recp, receipt)
	} else {
		msg.userReceipt.push(receipt)
	}
}

/** Update the message with a new reaction */
export const updateMessageWithReaction = (msg: Pick<WAMessage, 'reactions'>, reaction: proto.IReaction) => {
	const authorID = getKeyAuthor(reaction.key)

	const reactions = (msg.reactions || [])
		.filter(r => getKeyAuthor(r.key) !== authorID)
	if(reaction.text) {
		reactions.push(reaction)
	}

	msg.reactions = reactions
}

/** Update the message with a new poll update */
export const updateMessageWithPollUpdate = (
	msg: Pick<WAMessage, 'pollUpdates'>,
	update: proto.IPollUpdate
) => {
	const authorID = getKeyAuthor(update.pollUpdateMessageKey)

	const reactions = (msg.pollUpdates || [])
		.filter(r => getKeyAuthor(r.pollUpdateMessageKey) !== authorID)
	if(update.vote?.selectedOptions?.length) {
		reactions.push(update)
	}

	msg.pollUpdates = reactions
}

type VoteAggregation = {
	name: string
	voters: string[]
}

/**
 * Aggregates all poll updates in a poll.
 * @param msg the poll creation message
 * @param meId your jid
 * @returns A list of options & their voters
 */
export function getAggregateVotesInPollMessage(
	{ message, pollUpdates }: Pick<WAMessage, 'pollUpdates' | 'message'>,
	meId?: string
) {
	const opts = message?.pollCreationMessage?.options || message?.pollCreationMessageV2?.options || message?.pollCreationMessageV3?.options || []
	const voteHashMap = opts.reduce((acc, opt) => {
		const hash = sha256(Buffer.from(opt.optionName || '')).toString()
		acc[hash] = {
			name: opt.optionName || '',
			voters: []
		}
		return acc
	}, {} as { [_: string]: VoteAggregation })

	for(const update of pollUpdates || []) {
		const { vote } = update
		if(!vote) {
			continue
		}

		for(const option of vote.selectedOptions || []) {
			const hash = option.toString()
			let data = voteHashMap[hash]
			if(!data) {
				voteHashMap[hash] = {
					name: 'Unknown',
					voters: []
				}
				data = voteHashMap[hash]
			}

			voteHashMap[hash].voters.push(
				getKeyAuthor(update.pollUpdateMessageKey, meId)
			)
		}
	}

	return Object.values(voteHashMap)
}

/** Given a list of message keys, aggregates them by chat & sender. Useful for sending read receipts in bulk */
export const aggregateMessageKeysNotFromMe = (keys: proto.IMessageKey[]) => {
	const keyMap: { [id: string]: { jid: string, participant: string | undefined, messageIds: string[] } } = { }
	for(const { remoteJid, id, participant, fromMe } of keys) {
		if(!fromMe) {
			const uqKey = `${remoteJid}:${participant || ''}`
			if(!keyMap[uqKey]) {
				keyMap[uqKey] = {
					jid: remoteJid!,
					participant: participant!,
					messageIds: []
				}
			}

			keyMap[uqKey].messageIds.push(id!)
		}
	}

	return Object.values(keyMap)
}

type DownloadMediaMessageContext = {
	reuploadRequest: (msg: WAMessage) => Promise<WAMessage>
	logger: Logger
}

const REUPLOAD_REQUIRED_STATUS = [410, 404]

/**
 * Downloads the given message. Throws an error if it's not a media message
 */
export const downloadMediaMessage = async<Type extends 'buffer' | 'stream'>(
	message: WAMessage,
	type: Type,
	options: MediaDownloadOptions,
	ctx?: DownloadMediaMessageContext
) => {
	const result = await downloadMsg()
		.catch(async(error) => {
			if(ctx) {
				if(axios.isAxiosError(error)) {
					// check if the message requires a reupload
					if(REUPLOAD_REQUIRED_STATUS.includes(error.response?.status!)) {
						ctx.logger.info({ key: message.key }, 'sending reupload media request...')
						// request reupload
						message = await ctx.reuploadRequest(message)
						const result = await downloadMsg()
						return result
					}
				}
			}

			throw error
		})

	return result as Type extends 'buffer' ? Buffer : Transform

	async function downloadMsg() {
		const mContent = extractMessageContent(message.message)
		if(!mContent) {
			throw new Boom('No message present', { statusCode: 400, data: message })
		}

		const contentType = getContentType(mContent)
		let mediaType = contentType?.replace('Message', '') as MediaType
		const media = mContent[contentType!]

		if(!media || typeof media !== 'object' || (!('url' in media) && !('thumbnailDirectPath' in media))) {
			throw new Boom(`"${contentType}" message is not a media message`)
		}

		let download: DownloadableMessage
		if('thumbnailDirectPath' in media && !('url' in media)) {
			download = {
				directPath: media.thumbnailDirectPath,
				mediaKey: media.mediaKey
			}
			mediaType = 'thumbnail-link'
		} else {
			download = media
		}

		const stream = await downloadContentFromMessage(download, mediaType, options)
		if(type === 'buffer') {
			const bufferArray: Buffer[] = []
			for await (const chunk of stream) {
				bufferArray.push(chunk)
			}

			return Buffer.concat(bufferArray)
		}

		return stream
	}
}

/** Checks whether the given message is a media message; if it is returns the inner content */
export const assertMediaContent = (content: proto.IMessage | null | undefined) => {
	content = extractMessageContent(content)
	const mediaContent = content?.documentMessage
		|| content?.imageMessage
		|| content?.videoMessage
		|| content?.audioMessage
		|| content?.stickerMessage
	if(!mediaContent) {
		throw new Boom(
			'given message is not a media message',
			{ statusCode: 400, data: content }
		)
	}

	return mediaContent
}