import { BoxModel, TLDefaultHorizontalAlignStyle } from '@tldraw/tlschema'
import { Editor } from '../Editor'

const fixNewLines = /\r?\n|\r/g

function normalizeTextForDom(text: string) {
	return text
		.replace(fixNewLines, '\n')
		.split('\n')
		.map((x) => x || ' ')
		.join('\n')
}

const textAlignmentsForLtr = {
	start: 'left',
	'start-legacy': 'left',
	middle: 'center',
	'middle-legacy': 'center',
	end: 'right',
	'end-legacy': 'right',
}

/** @public */
export interface TLMeasureTextSpanOpts {
	overflow: 'wrap' | 'truncate-ellipsis' | 'truncate-clip'
	width: number
	height: number
	padding: number
	fontSize: number
	fontWeight: string
	fontFamily: string
	fontStyle: string
	lineHeight: number
	textAlign: TLDefaultHorizontalAlignStyle
}

const spaceCharacterRegex = /\s/

/** @public */
export class TextManager {
	private baseElem: HTMLDivElement

	constructor(public editor: Editor) {
		this.baseElem = document.createElement('div')
		this.baseElem.classList.add('tl-text')
		this.baseElem.classList.add('tl-text-measure')
		this.baseElem.tabIndex = -1
	}

	measureText(
		textToMeasure: string,
		opts: {
			fontStyle: string
			fontWeight: string
			fontFamily: string
			fontSize: number
			lineHeight: number
			/**
			 * When maxWidth is a number, the text will be wrapped to that maxWidth. When maxWidth
			 * is null, the text will be measured without wrapping, but explicit line breaks and
			 * space are preserved.
			 */
			maxWidth: null | number
			minWidth?: null | number
			padding: string
			disableOverflowWrapBreaking?: boolean
		}
	): BoxModel & { scrollWidth: number } {
		const div = document.createElement('div')
		div.textContent = normalizeTextForDom(textToMeasure)
		return this.measureHtml(div.innerHTML, opts)
	}

	measureHtml(
		html: string,
		opts: {
			fontStyle: string
			fontWeight: string
			fontFamily: string
			fontSize: number
			lineHeight: number
			/**
			 * When maxWidth is a number, the text will be wrapped to that maxWidth. When maxWidth
			 * is null, the text will be measured without wrapping, but explicit line breaks and
			 * space are preserved.
			 */
			maxWidth: null | number
			minWidth?: null | number
			padding: string
			disableOverflowWrapBreaking?: boolean
		}
	): BoxModel & { scrollWidth: number } {
		// Duplicate our base element; we don't need to clone deep
		const wrapperElm = this.baseElem.cloneNode() as HTMLDivElement
		this.editor.getContainer().appendChild(wrapperElm)
		wrapperElm.innerHTML = html
		this.baseElem.insertAdjacentElement('afterend', wrapperElm)

		wrapperElm.setAttribute('dir', 'auto')
		// N.B. This property, while discouraged ("intended for Document Type Definition (DTD) designers")
		// is necessary for ensuring correct mixed RTL/LTR behavior when exporting SVGs.
		wrapperElm.style.setProperty('unicode-bidi', 'plaintext')
		wrapperElm.style.setProperty('font-family', opts.fontFamily)
		wrapperElm.style.setProperty('font-style', opts.fontStyle)
		wrapperElm.style.setProperty('font-weight', opts.fontWeight)
		wrapperElm.style.setProperty('font-size', opts.fontSize + 'px')
		wrapperElm.style.setProperty('line-height', opts.lineHeight * opts.fontSize + 'px')
		wrapperElm.style.setProperty('max-width', opts.maxWidth === null ? null : opts.maxWidth + 'px')
		wrapperElm.style.setProperty('min-width', opts.minWidth === null ? null : opts.minWidth + 'px')
		wrapperElm.style.setProperty('padding', opts.padding)
		wrapperElm.style.setProperty(
			'overflow-wrap',
			opts.disableOverflowWrapBreaking ? 'normal' : 'break-word'
		)

		const scrollWidth = wrapperElm.scrollWidth
		const rect = wrapperElm.getBoundingClientRect()
		wrapperElm.remove()

		return {
			x: 0,
			y: 0,
			w: rect.width,
			h: rect.height,
			scrollWidth,
		}
	}

	/**
	 * Given an html element, measure the position of each span of unbroken
	 * word/white-space characters within any text nodes it contains.
	 */
	measureElementTextNodeSpans(
		element: HTMLElement,
		{ shouldTruncateToFirstLine = false }: { shouldTruncateToFirstLine?: boolean } = {}
	): { spans: { box: BoxModel; text: string }[]; didTruncate: boolean } {
		const spans = []

		// Measurements of individual spans are relative to the containing element
		const elmBounds = element.getBoundingClientRect()
		const offsetX = -elmBounds.left
		const offsetY = -elmBounds.top

		// we measure by creating a range that spans each character in the elements text node
		const range = new Range()
		const textNode = element.childNodes[0]
		let idx = 0

		let currentSpan = null
		let prevCharWasSpaceCharacter = null
		let prevCharTop = 0
		let prevCharLeftForRTLTest = 0
		let didTruncate = false
		for (const childNode of element.childNodes) {
			if (childNode.nodeType !== Node.TEXT_NODE) continue

			for (const char of childNode.textContent ?? '') {
				// place the range around the characters we're interested in
				range.setStart(textNode, idx)
				range.setEnd(textNode, idx + char.length)
				// measure the range. some browsers return multiple rects for the
				// first char in a new line - one for the line break, and one for
				// the character itself. we're only interested in the character.
				const rects = range.getClientRects()
				const rect = rects[rects.length - 1]!

				// calculate the position of the character relative to the element
				const top = rect.top + offsetY
				const left = rect.left + offsetX
				const right = rect.right + offsetX
				const isRTL = left < prevCharLeftForRTLTest

				const isSpaceCharacter = spaceCharacterRegex.test(char)
				if (
					// If we're at a word boundary...
					isSpaceCharacter !== prevCharWasSpaceCharacter ||
					// ...or we're on a different line...
					top !== prevCharTop ||
					// ...or we're at the start of the text and haven't created a span yet...
					!currentSpan
				) {
					// ...then we're at a span boundary!

					if (currentSpan) {
						// if we're truncating to a single line & we just finished the first line, stop there
						if (shouldTruncateToFirstLine && top !== prevCharTop) {
							didTruncate = true
							break
						}
						// otherwise add the span to the list ready to start a new one
						spans.push(currentSpan)
					}

					// start a new span
					currentSpan = {
						box: { x: left, y: top, w: rect.width, h: rect.height },
						text: char,
					}
					prevCharLeftForRTLTest = left
				} else {
					// Looks like we're in RTL mode, so we need to adjust the left position.
					if (isRTL) {
						currentSpan.box.x = left
					}

					// otherwise we just need to extend the current span with the next character
					currentSpan.box.w = isRTL ? currentSpan.box.w + rect.width : right - currentSpan.box.x
					currentSpan.text += char
				}

				if (char === '\n') {
					prevCharLeftForRTLTest = 0
				}

				prevCharWasSpaceCharacter = isSpaceCharacter
				prevCharTop = top
				idx += char.length
			}
		}

		// Add the last span
		if (currentSpan) {
			spans.push(currentSpan)
		}

		return { spans, didTruncate }
	}

	/**
	 * Measure text into individual spans. Spans are created by rendering the
	 * text, then dividing it up according to line breaks and word boundaries.
	 *
	 * It works by having the browser render the text, then measuring the
	 * position of each character. You can use this to replicate the text-layout
	 * algorithm of the current browser in e.g. an SVG export.
	 */
	measureTextSpans(
		textToMeasure: string,
		opts: TLMeasureTextSpanOpts
	): { text: string; box: BoxModel }[] {
		if (textToMeasure === '') return []

		const elm = this.baseElem.cloneNode() as HTMLDivElement
		this.editor.getContainer().appendChild(elm)

		const elementWidth = Math.ceil(opts.width - opts.padding * 2)
		elm.setAttribute('dir', 'auto')
		// N.B. This property, while discouraged ("intended for Document Type Definition (DTD) designers")
		// is necessary for ensuring correct mixed RTL/LTR behavior when exporting SVGs.
		elm.style.setProperty('unicode-bidi', 'plaintext')
		elm.style.setProperty('width', `${elementWidth}px`)
		elm.style.setProperty('height', 'min-content')
		elm.style.setProperty('font-size', `${opts.fontSize}px`)
		elm.style.setProperty('font-family', opts.fontFamily)
		elm.style.setProperty('font-weight', opts.fontWeight)
		elm.style.setProperty('line-height', `${opts.lineHeight * opts.fontSize}px`)
		elm.style.setProperty('text-align', textAlignmentsForLtr[opts.textAlign])
		elm.style.setProperty('font-style', opts.fontStyle)

		const shouldTruncateToFirstLine =
			opts.overflow === 'truncate-ellipsis' || opts.overflow === 'truncate-clip'

		if (shouldTruncateToFirstLine) {
			elm.style.setProperty('overflow-wrap', 'anywhere')
			elm.style.setProperty('word-break', 'break-all')
		}

		const normalizedText = normalizeTextForDom(textToMeasure)

		// Render the text into the measurement element:
		elm.textContent = normalizedText

		// actually measure the text:
		const { spans, didTruncate } = this.measureElementTextNodeSpans(elm, {
			shouldTruncateToFirstLine,
		})

		if (opts.overflow === 'truncate-ellipsis' && didTruncate) {
			// we need to measure the ellipsis to know how much space it takes up
			elm.textContent = '…'
			const ellipsisWidth = Math.ceil(this.measureElementTextNodeSpans(elm).spans[0].box.w)

			// then, we need to subtract that space from the width we have and measure again:
			elm.style.setProperty('width', `${elementWidth - ellipsisWidth}px`)
			elm.textContent = normalizedText
			const truncatedSpans = this.measureElementTextNodeSpans(elm, {
				shouldTruncateToFirstLine: true,
			}).spans

			// Finally, we add in our ellipsis at the end of the last span. We
			// have to do this after measuring, not before, because adding the
			// ellipsis changes how whitespace might be getting collapsed by the
			// browser.
			const lastSpan = truncatedSpans[truncatedSpans.length - 1]!
			truncatedSpans.push({
				text: '…',
				box: {
					x: Math.min(lastSpan.box.x + lastSpan.box.w, opts.width - opts.padding - ellipsisWidth),
					y: lastSpan.box.y,
					w: ellipsisWidth,
					h: lastSpan.box.h,
				},
			})
			return truncatedSpans
		}

		elm.remove()

		return spans
	}
}
