/*
 * Copyright (c) 2010-2021 Tim Düsterhus.
 *
 * Use of this software is governed by the Business Source License
 * included in the LICENSE file.
 *
 * Change Date: 2026-09-17
 *
 * On the date above, in accordance with the Business Source
 * License, use of this software will be governed by version 2
 * or later of the General Public License.
 */

define([
	'../Helper',
	'WoltLabSuite/Core/Date/Util',
	'WoltLabSuite/Core/Dom/Change/Listener',
	'WoltLabSuite/Core/Language',
	'WoltLabSuite/Core/User',
	'WoltLabSuite/Core/Dom/Traverse',
	'../DataStructure/EventEmitter',
	'../DataStructure/RedBlackTree/Tree',
], function (
	Helper,
	DateUtil,
	DomChangeListener,
	Language,
	User,
	DOMTraverse,
	EventEmitter,
	Tree
) {
	'use strict'

	const enableAutoscroll = Symbol('enableAutoscroll')

	const DEPENDENCIES = []
	class MessageStream {
		constructor() {
			this.stream = elById('chatMessageStream')
			this.scrollContainer = elBySel('.scrollContainer', this.stream)

			this[enableAutoscroll] = true
			this.lastScrollPosition = undefined
			this.nodeMap = new WeakMap()
			this.positions = new Tree()
		}

		get enableAutoscroll() {
			return this[enableAutoscroll]
		}

		set enableAutoscroll(value) {
			this[enableAutoscroll] = value

			if (this[enableAutoscroll]) {
				this.scrollToBottom()
			}
		}

		bootstrap() {
			this.scrollContainer.addEventListener('copy', this.onCopy.bind(this))
			this.scrollContainer.addEventListener(
				'scroll',
				Helper.throttle(this.onScroll, 100, this),
				{ passive: true }
			)
		}

		getDateMarker(date) {
			const dateMarker = elCreate('li')
			dateMarker.classList.add('dateMarker')
			const time = elCreate('time')
			time.innerText = DateUtil.formatDate(date)
			time.setAttribute('datetime', DateUtil.format(date, 'Y-m-d'))
			dateMarker.appendChild(time)

			return dateMarker
		}

		onDifferentDays(a, b) {
			return DateUtil.format(a, 'Y-m-d') !== DateUtil.format(b, 'Y-m-d')
		}

		ingest(messages) {
			let scrollTopBefore = this.enableAutoscroll
				? 0
				: this.scrollContainer.scrollTop
			let prependedHeight = 0

			const ul = elBySel('ul', this.scrollContainer)
			const first = ul.firstElementChild

			const ingested = messages.map(
				function (item) {
					let currentScrollHeight = 0

					const li = elCreate('li')

					// Allow messages types to not render a messages
					// This can be used for status messages like ChatUpdate
					let fragment
					if ((fragment = item.getMessageType().render(item)) === false) return

					if (
						fragment.querySelector(
							`.userMention[data-user-id="${User.userId}"]`
						)
					)
						li.classList.add('mentioned')

					li.appendChild(fragment)

					li.classList.add('chatMessageBoundary')
					li.setAttribute('id', `message-${item.messageID}`)
					li.dataset.objectType = item.objectType
					li.dataset.userId = item.userID
					if (item.isOwnMessage()) li.classList.add('own')
					if (item.isDeleted) li.classList.add('tombstone')

					const position = this.positions.insert(item.messageID)
					if (position[1] !== undefined) {
						const sibling = elById(`message-${position[1]}`)
						if (!sibling) throw new Error('Unreachable')

						let nodeBefore, nodeAfter
						let dateMarkerBetween = false
						if (position[0] === 'LEFT') {
							nodeAfter = sibling
							nodeBefore = sibling.previousElementSibling

							if (nodeBefore && nodeBefore.classList.contains('dateMarker')) {
								elRemove(nodeBefore)
								nodeBefore = sibling.previousElementSibling
							}
						} else if (position[0] === 'RIGHT') {
							nodeBefore = sibling
							nodeAfter = sibling.nextElementSibling

							if (nodeAfter && nodeAfter.classList.contains('dateMarker')) {
								elRemove(nodeAfter)
								nodeAfter = sibling.nextElementSibling
							}
						} else {
							throw new Error('Unreachable')
						}

						const messageBefore = this.nodeMap.get(nodeBefore)
						if (nodeBefore && !messageBefore) throw new Error('Unreachable')
						const messageAfter = this.nodeMap.get(nodeAfter)
						if (nodeAfter && !messageAfter) throw new Error('Unreachable')

						if (!this.enableAutoscroll && nodeAfter)
							currentScrollHeight = this.scrollContainer.scrollHeight

						let context = nodeAfter
						if (nodeAfter) nodeAfter.classList.remove('first')
						if (messageBefore) {
							if (this.onDifferentDays(messageBefore.date, item.date)) {
								const dateMarker = this.getDateMarker(item.date)
								ul.insertBefore(dateMarker, nodeAfter)
								li.classList.add('first')
							} else {
								if (
									messageBefore.objectType !== item.objectType ||
									!item.getMessageType().joinable(messageBefore, item)
								) {
									li.classList.add('first')
								}
							}
						} else {
							li.classList.add('first')
						}
						if (messageAfter) {
							if (this.onDifferentDays(messageAfter.date, item.date)) {
								const dateMarker = this.getDateMarker(messageAfter.date)
								ul.insertBefore(dateMarker, nodeAfter)
								context = dateMarker
								nodeAfter.classList.add('first')
							} else {
								if (
									messageAfter.objectType !== item.objectType ||
									!item.getMessageType().joinable(item, messageAfter)
								) {
									nodeAfter.classList.add('first')
								}
							}
						}

						ul.insertBefore(li, context)

						if (!this.enableAutoscroll && nodeAfter) {
							prependedHeight +=
								this.scrollContainer.scrollHeight - currentScrollHeight
						}
					} else {
						li.classList.add('first')
						ul.insertBefore(li, null)
					}

					this.nodeMap.set(li, item)

					return { node: li, message: item }
				}.bind(this)
			)

			if (ingested.some((item) => item != null)) {
				if (this.enableAutoscroll) {
					this.scrollToBottom()
				} else {
					this.stream.classList.add('activity')
					this.scrollContainer.scrollTop = scrollTopBefore + prependedHeight
				}
			}

			DomChangeListener.trigger()

			this.emit('ingested', ingested)
		}

		scrollToBottom() {
			this.scrollContainer.scrollTop = this.scrollContainer.scrollHeight
			this.stream.classList.remove('activity')
		}

		onScroll() {
			const { scrollTop, scrollHeight, clientHeight } = this.scrollContainer
			const distanceFromTop = scrollTop
			const distanceFromBottom = scrollHeight - scrollTop - clientHeight

			let direction = 'down'

			if (
				this.lastScrollPosition != null &&
				scrollTop < this.lastScrollPosition
			) {
				direction = 'up'
			}

			if (direction === 'up') {
				if (distanceFromBottom > 7) {
					this.emit('scrollUp')
				}

				if (distanceFromTop <= 7) {
					this.emit('reachedTop')
				} else if (distanceFromTop <= 300) {
					this.emit('nearTop')
				}
			} else if (direction === 'down') {
				if (distanceFromTop > 7) {
					this.emit('scrollDown')
				}

				if (distanceFromBottom <= 7) {
					this.scrollToBottom()
					this.emit('reachedBottom')
				} else if (distanceFromBottom <= 300) {
					this.emit('nearBottom')
				}
			}

			this.lastScrollPosition = scrollTop
		}

		onCopy(event) {
			const selection = window.getSelection()

			// Similar to selecting nothing
			if (selection.isCollapsed) return

			// Get the first and last node in the selection
			let originalStart, start, end, originalEnd
			start = originalStart = selection.getRangeAt(0).startContainer
			end = originalEnd = selection.getRangeAt(
				selection.rangeCount - 1
			).endContainer

			const startOffset = selection.getRangeAt(0).startOffset
			const endOffset = selection.getRangeAt(selection.rangeCount - 1).endOffset

			// The Traverse module needs nodes of the Element type, the selected elements could be of type Text
			while (!(start instanceof Element) && start.parentNode)
				start = start.parentNode
			while (!(end instanceof Element) && end.parentNode) end = end.parentNode

			if (!start || !end)
				throw new Error('Unexpected error, no element nodes in selection')

			// Try to find the starting li element in the selection
			if (!start.id || start.id.indexOf('message-') !== 0) {
				start = DOMTraverse.parentBySel(start, "li[id^='message']", this.stream)
			}

			// Try to find the ending li element in the selection
			if (!end.id || end.id.indexOf('message-') !== 0) {
				end = DOMTraverse.parentBySel(end, "li[id^='message']", this.stream)
			}

			// Do not select a message if we selected only a new line
			if (
				originalStart instanceof Text &&
				originalStart.textContent.substring(startOffset) === ''
			) {
				start = DOMTraverse.next(start)
			}

			// The selection went outside of the stream container, end at the last li element
			if (end === null) {
				end = elBySel('ul > li:last-child', this.stream)
			}

			// Discard the selection, we selected only whitespace between two messages
			if (start === end && endOffset === 0) return

			// Do not include the ending message if there is no visible selection
			if (start !== end && endOffset === 0) {
				end = DOMTraverse.prev(end)
			}

			const elements = []
			let next = start

			do {
				elements.push(next)

				if (next === end) break
			} while ((next = DOMTraverse.next(next)))

			// Only apply our custom formatting when selecting multiple or whole messages
			if (elements.length === 1) {
				const range = document.createRange()
				range.setStart(originalStart, startOffset)
				range.setEnd(originalEnd, endOffset)

				if (
					!Helper.rangeSpansTextContent(
						range,
						start.querySelector('.chatMessage')
					)
				)
					return
			}

			try {
				event.clipboardData.setData(
					'text/plain',
					elements
						.map((el, index, arr) => {
							const message = this.nodeMap.get(el)

							if (el.classList.contains('dateMarker'))
								return `== ${el.textContent.trim()} ==`

							if (!message) return

							if (el.classList.contains('tombstone')) {
								return `[${message.formattedTime}] ${Language.get(
									'chat.messageType.be.bastelstu.chat.messageType.tombstone.message'
								)}`
							}

							const elem = elBySel('.chatMessage', el)

							let body
							if (
								typeof (body = message
									.getMessageType()
									.renderPlainText(message)) === 'undefined' ||
								body === false
							) {
								body = Helper.getTextContent(elem)
									.replace(/\t+/g, '\t') // collapse multiple tabs
									.replace(/ +/g, ' ') // collapse multiple spaces
									.replace(/([\t ]*\n){2,}/g, '\n') // collapse line consisting of tabs, spaces and newlines
									.replace(/^[\t ]+|[\t ]+$/gm, '') // remove leading and trailing whitespace per line
							}

							return `[${message.formattedTime}] <${
								message.username
							}> ${body.trim()}`
						})
						.filter((x) => x)
						.join('\n')
				)

				event.preventDefault()
			} catch (e) {
				console.error('Unable to use the clipboard API')
				console.error(e)
			}
		}
	}
	EventEmitter(MessageStream.prototype)
	MessageStream.DEPENDENCIES = DEPENDENCIES

	return MessageStream
})