<template>
	<div class="editor">
		<div class="clear"></div>

		<vue-easymde
			:configs="config"
			@change="bubble"
			@update:modelValue="handleInput"
			class="content"
			v-if="isEditActive"
			v-model="text"/>

		<div class="preview content" v-html="preview" v-if="isPreviewActive && text !== ''">
		</div>

		<p class="has-text-centered has-text-grey is-italic my-5" v-if="showPreviewText">
			{{ emptyText }}
			<template v-if="isEditEnabled">
				<ButtonLink @click="toggleEdit" class="d-print-none">{{ $t('input.editor.edit') }}</ButtonLink>.
			</template>
		</p>

		<ul class="actions d-print-none" v-if="bottomActions.length > 0">
			<li v-if="isEditEnabled && !showPreviewText && showSave">
				<BaseButton v-if="showEditButton" @click="toggleEdit">{{ $t('input.editor.edit') }}</BaseButton>
				<BaseButton v-else-if="isEditActive" @click="toggleEdit" class="done-edit">{{ $t('misc.save') }}</BaseButton>
			</li>
			<li v-for="(action, k) in bottomActions" :key="k">
				<BaseButton @click="action.action">{{ action.title }}</BaseButton>
			</li>
		</ul>
		<template v-else-if="isEditEnabled && showSave">
			<ul v-if="showEditButton" class="actions d-print-none">
				<li>
					<BaseButton @click="toggleEdit">{{ $t('input.editor.edit') }}</BaseButton>
				</li>
			</ul>
			<x-button
				v-else-if="isEditActive"
				@click="toggleEdit"
				variant="secondary"
				:shadow="false"
				v-cy="'saveEditor'">
				{{ $t('misc.save') }}
			</x-button>
		</template>
	</div>
</template>

<script lang="ts">
import {defineComponent} from 'vue'

import VueEasymde from './vue-easymde.vue'
import {marked} from 'marked'
import DOMPurify from 'dompurify'
import hljs from 'highlight.js/lib/common'

import {createEasyMDEConfig} from './editorConfig'

import AttachmentModel from '../../models/attachment'
import AttachmentService from '../../services/attachment'
import {findCheckboxesInText} from '../../helpers/checklistFromText'
import {createRandomID} from '@/helpers/randomId'

import BaseButton from '@/components/base/BaseButton.vue'
import ButtonLink from '@/components/misc/ButtonLink.vue'

export default defineComponent({
	name: 'editor',
	components: {
		VueEasymde,
		BaseButton,
		ButtonLink,
	},
	props: {
		modelValue: {
			type: String,
			default: '',
		},
		placeholder: {
			type: String,
			default: '',
		},
		uploadEnabled: {
			type: Boolean,
			default: false,
		},
		uploadCallback: {
			type: Function,
		},
		hasPreview: {
			type: Boolean,
			default: true,
		},
		previewIsDefault: {
			type: Boolean,
			default: true,
		},
		isEditEnabled: {
			default: true,
		},
		bottomActions: {
			default: () => [],
		},
		emptyText: {
			type: String,
			default: '',
		},
		showSave: {
			type: Boolean,
			default: false,
		},
	},
	emits: ['update:modelValue', 'change'],
	computed: {
		showPreviewText() {
			return this.isPreviewActive && this.text === '' && this.emptyText !== ''
		},
		showEditButton() {
			return !this.isEditActive && this.text !== ''
		},
	},
	data() {
		return {
			text: '',
			changeTimeout: null,
			isEditActive: false,
			isPreviewActive: true,

			preview: '',
			attachmentService: null,
			loadedAttachments: {},
			config: createEasyMDEConfig({
				placeholder: this.placeholder,
				uploadImage: this.uploadEnabled,
				imageUploadFunction: this.uploadCallback,
			}),
			checkboxId: createRandomID(),
		}
	},
	watch: {
		modelValue(modelValue) {
			this.text = modelValue
			this.$nextTick(this.renderPreview)
		},
		text(newVal, oldVal) {
			// Only bubble the new value if it actually changed, but not if the component just got mounted and the text changed from the outside.
			if (oldVal === '' && this.text === this.modelValue) {
				return
			}
			this.bubble()
		},
	},
	mounted() {
		if (this.modelValue !== '') {
			this.text = this.modelValue
		}

		if (this.previewIsDefault && this.hasPreview) {
			this.$nextTick(this.renderPreview)
			return
		}

		this.isPreviewActive = false
		this.isEditActive = true
	},
	methods: {
		// This gets triggered when only pasting content into the editor.
		// A change event would not get generated by that, an input event does.
		// Therefore, we're using this handler to catch paste events.
		// But because this also gets triggered when typing into the editor, we give
		// it a higher timeout to make the timouts cancel each other in that case so
		// that in the end, only one change event is triggered to the outside per change.
		handleInput(val) {
			// Don't bubble if the text is up to date
			if (val === this.text) {
				return
			}

			this.text = val
			this.bubble(1000)
		},
		bubble(timeout = 500) {
			if (this.changeTimeout !== null) {
				clearTimeout(this.changeTimeout)
			}

			this.changeTimeout = setTimeout(() => {
				this.$emit('update:modelValue', this.text)
				this.$emit('change', this.text)
			}, timeout)
		},
		replaceAt(str, index, replacement) {
			return str.substr(0, index) + replacement + str.substr(index + replacement.length)
		},
		findNthIndex(str, n) {
			const checkboxes = findCheckboxesInText(str)
			return checkboxes[n]
		},
		renderPreview() {
			const renderer = new marked.Renderer()
			const linkRenderer = renderer.link

			let checkboxNum = -1
			marked.use({
				renderer: {
					image: (src, title, text) => {

						title = title ? ` title="${title}` : ''

						// If the url starts with the api url, the image is likely an attachment and
						// we'll need to download and parse it properly.
						if (src.substr(0, window.API_URL.length + 7) === `${window.API_URL}/tasks/`) {
							return `<img data-src="${src}" alt="${text}" ${title} class="attachment-image"/>`
						}

						return `<img src="${src}" alt="${text}" ${title}/>`
					},
					checkbox: (checked) => {
						if (checked) {
							checked = ' checked="checked"'
						}

						checkboxNum++
						return `<input type="checkbox" data-checkbox-num="${checkboxNum}" ${checked} class="text-checkbox-${this.checkboxId}"/>`
					},
					link: (href, title, text) => {
						const isLocal = href.startsWith(`${location.protocol}//${location.hostname}`)
						const html = linkRenderer.call(renderer, href, title, text)
						return isLocal ? html : html.replace(/^<a /, '<a target="_blank" rel="noreferrer noopener nofollow" ')
					},
				},
				highlight: function (code, language) {
					const validLanguage = hljs.getLanguage(language) ? language : 'plaintext'
					return hljs.highlight(code, {language: validLanguage}).value
				},
			})

			this.preview = DOMPurify.sanitize(marked(this.text), {ADD_ATTR: ['target']})

			// Since the render function is synchronous, we can't do async http requests in it.
			// Therefore, we can't resolve the blob url at (markdown) compile time.
			// To work around this, we modify the url after rendering it in the vue component.
			// We're doing the whole thing in the next tick to ensure the image elements are available in the
			// dom tree. If we're calling this right after setting this.preview it could be the images were
			// not already made available.
			// Some docs at https://stackoverflow.com/q/62865160/10924593
			this.$nextTick(async () => {
				const attachmentImage = document.getElementsByClassName('attachment-image')
				if (attachmentImage) {
					for (const img of attachmentImage) {
						// The url is something like /tasks/<id>/attachments/<id>
						const parts = img.dataset.src.substr(window.API_URL.length + 1).split('/')
						const taskId = parseInt(parts[1])
						const attachmentId = parseInt(parts[3])
						const cacheKey = `${taskId}-${attachmentId}`

						if (typeof this.loadedAttachments[cacheKey] !== 'undefined') {
							img.src = this.loadedAttachments[cacheKey]
							continue
						}

						const attachment = new AttachmentModel({taskId: taskId, id: attachmentId})

						if (this.attachmentService === null) {
							this.attachmentService = new AttachmentService()
						}

						const url = await this.attachmentService.getBlobUrl(attachment)
						img.src = url
						this.loadedAttachments[cacheKey] = url
					}
				}

				const textCheckbox = document.getElementsByClassName(`text-checkbox-${this.checkboxId}`)
				if (textCheckbox) {
					for (const check of textCheckbox) {
						check.removeEventListener('change', this.handleCheckboxClick)
						check.addEventListener('change', this.handleCheckboxClick)
						check.parentElement.classList.add('has-checkbox')
					}
				}
			})
		},
		handleCheckboxClick(e) {
			// Find the original markdown checkbox this is targeting
			const checked = e.target.checked
			const numMarkdownCheck = parseInt(e.target.dataset.checkboxNum)

			const index = this.findNthIndex(this.text, numMarkdownCheck)
			if (index < 0 || typeof index === 'undefined') {
				console.debug('no index found')
				return
			}
			console.debug(index, this.text.substr(index, 9))

			const listPrefix = this.text.substr(index, 1)

			if (checked) {
				this.text = this.replaceAt(this.text, index, `${listPrefix} [x] `)
			} else {
				this.text = this.replaceAt(this.text, index, `${listPrefix} [ ] `)
			}
			this.bubble()
			this.renderPreview()
		},
		toggleEdit() {
			if (this.isEditActive) {
				this.isPreviewActive = true
				this.isEditActive = false
				this.renderPreview()
				this.bubble(0) // save instantly
			} else {
				this.isPreviewActive = false
				this.isEditActive = true
			}
		},
	},
})
</script>

<style lang="scss">
@import 'codemirror/lib/codemirror.css';
@import 'highlight.js/scss/base16/equilibrium-gray-light';

.editor {
	.clear {
		clear: both;
	}

	.preview.content {
		margin-bottom: .5rem;
		overflow-wrap: anywhere; // Safari does not understand "break-word" so we put that first to make sure it at least is able to show it somewhat properly there.
		overflow-wrap: break-word;

		ul li {
			input[type="checkbox"] {
				margin-right: .5rem;
			}

			&.has-checkbox {
				margin-left: -1.25rem;
				list-style: none;
			}
		}
	}
}

.CodeMirror {
	padding: .5rem;
	border: 1px solid var(--grey-200) !important;
	background: var(--white);

	&-lines pre {
		margin: 0 !important;
	}

	&-placeholder {
		color: var(--grey-400) !important;
		font-style: italic;
	}

	&-cursor {
		border-color: var(--grey-700);
	}
}

.editor-preview {
	padding: 0;

	&-side {
		padding: .5rem;
	}
}

.editor-toolbar {
	background: var(--grey-50);
	border: 1px solid var(--grey-200);
	border-bottom: none;

	button {
		color: var(--grey-700);

		&.active {
			background: var(--grey-200);
		}

		svg {
			vertical-align: middle;

			&, rect {
				width: 20px;
				height: 20px;
			}
		}

		&::after {
			position: absolute;
			top: 24px;
			margin-left: -3px;
		}

		&:hover {
			background: var(--grey-200);
			border-color: var(--grey-300);
		}
	}

	i.separator {
		border-color: var(--grey-200) !important;
	}
}

pre.CodeMirror-line {
	margin-bottom: 0 !important;
	color: var(--grey-700) !important;
}

.cm-header {
	font-family: $vikunja-font;
	font-weight: 400;
}

ul.actions {
	font-size: .8rem;
	margin: 0;

	li {
		display: inline-block;

		&::after {
			content: '·';
			padding: 0 .25rem;
		}

		&:last-child:after {
			content: '';
		}
	}

	&, a {
		color: var(--grey-500);

		&.done-edit {
			color: var(--primary);
		}
	}

	a:hover {
		text-decoration: underline;
	}
}

.vue-easymde.content {
	margin-bottom: 0 !important;
}
</style>
