import { Component } from '@component'
import { Editor } from '@editor'
import { MessageBus } from '@mbus'
import { SelectType, SelectNodeDialog, ConnectionDataDialog } from '@select_node'

export class App {
	constructor() {// {{{
		window.mbus = new MessageBus()
		this.editor = null
		this.typesList = null
		this.scriptsList = null
		this.scriptEditor = null
		this.scriptExecutionList = null

		this.currentNode = null
		this.currentNodeID = null
		this.types = []
		this.currentPage = null
		this.tree = new Tree(document.getElementById('nodes'))

		this.createdScript = null

		const events = [
			'EDITOR_NODE_SAVE',
			'EXECUTIONS_LIST_FETCHED',
			'MENU_ITEM_SELECTED',
			'NODE_CONNECT',
			'NODE_COPY_PATH',
			'NODE_CREATE_DIALOG',
			'NODE_DELETE',
			'NODE_EDIT_NAME',
			'NODE_MOVE',
			'NODE_SELECTED',
			'NODE_HOOKED',
			'SCRIPT_CREATED',
			'SCRIPT_DELETED',
			'SCRIPT_EDIT',
			'SCRIPTS_LIST_FETCHED',
			'SCRIPT_UPDATED',
			'TREE_RELOAD_NODE',
			'TYPE_EDIT',
			'TYPES_LIST_FETCHED',
		]
		for (const eventName of events)
			mbus.subscribe(eventName, event => this.eventHandler(event))

		document.addEventListener('keydown', event => this.keyHandler(event))

		mbus.dispatch('MENU_ITEM_SELECTED', 'node')
	}// }}}

	eventHandler(event) {// {{{
		switch (event.type) {
			case 'MENU_ITEM_SELECTED':
				const item = document.querySelector(`#menu [data-section="${event.detail}"]`)
				this.page(item, event.detail)
				break

			case 'NODE_CONNECT':
				const selectnode = new SelectNodeDialog(selectedNode => {
					this.nodeConnect(this.currentNode, selectedNode)
						.then(() => this.edit(this.currentNode.ID))
				})
				selectnode.render()
				break

			case 'NODE_COPY_PATH':
				const uri = `${location.protocol}//${location.host}/nodes/${event.detail}`
				const img = document.querySelector('#editor-node .copy-path')
				img.classList.add('highlight')
				setTimeout(() => img.classList.remove('highlight'), 150)
				navigator.clipboard.writeText(uri)
				break

			case 'NODE_SELECTED':
				for (const n of document.querySelectorAll('#nodes .node.selected'))
					n.classList.remove('selected')

				if (event.detail !== null)
					for (const n of document.querySelectorAll(`#nodes .node[data-node-id="${event.detail}"]`))
						n.classList?.add('selected')

				this.edit(event.detail)
				break

			case 'NODE_DELETE':
				if (!confirm('Are you sure you want to delete this node?'))
					return
				this.nodeDelete(this.currentNode.ID)
				break

			case 'NODE_HOOKED':
				this.edit(event.detail)
				break

			case 'NODE_MOVE':
				const nodes = this.tree.markedNodes()
				if (!confirm(`Are you sure you want to move ${nodes.length} nodes here?`))
					return
				this.nodesMove(nodes, this.currentNode.ID)
				break

			case 'EDITOR_NODE_SAVE':
				this.nodeUpdate()
				break

			case 'EXECUTIONS_LIST_FETCHED':
				const executions = document.getElementById('script-executions')
				executions.replaceChildren(this.scriptExecutionList.render())
				break

			case 'TYPES_LIST_FETCHED':
				const types = document.getElementById('types')
				types.replaceChildren(this.typesList.render())
				break

			case 'SCRIPTS_LIST_FETCHED':
				const scripts = document.getElementById('scripts')
				scripts.replaceChildren(this.scriptsList.render())

				if (this.createdScript !== null) {
					mbus.dispatch('SCRIPT_EDIT', this.createdScript)
					this.createdScript = null
				}

				break

			case 'SCRIPT_CREATED':
				this.createdScript = event.detail
				this.scriptsList.fetchScripts()
					.catch(err => showError(err))
				break

			case 'SCRIPT_DELETED':
				this.scriptsList.setSelected(null)
				this.scriptsList.fetchScripts().catch(err => showError(err))
				break

			case 'SCRIPT_EDIT':
				const scriptEditor = document.getElementById('editor-script')
				this.scriptEditor.script = event.detail

				this.scriptsList.setSelected(event.detail.ID)

				scriptEditor.replaceChildren(this.scriptEditor.render())
				break

			case 'SCRIPT_UPDATED':
				this.scriptsList.fetchScripts()
					.catch(err => showError(err))
				break

			case 'NODE_CREATE_DIALOG':
				if (this.currentPage !== 'node' || this.currentNode === null)
					return

				new NodeCreateDialog(this.currentNode.ID)
				break

			case 'NODE_EDIT_NAME':
				const newName = prompt('Rename node', this.currentNode.Name)
				if (newName === null)
					return

				this.nodeRename(this.currentNode.ID, newName)
					.then(() => mbus.dispatch('TREE_RELOAD_NODE', { parentNodeID: this.currentNode.ParentID }))
				break

			case 'TREE_RELOAD_NODE':
				this.tree.updateNode(parseInt(event.detail.parentNodeID))
					.then(() => {
						if (event.detail.callback)
							event.detail.callback()
					})
					.catch(err => showError(err))
				break

			case 'TYPE_EDIT':
				const typeID = event.detail
				fetch(`/types/${typeID}`)
					.then(data => data.json())
					.then(json => {
						if (!json.OK) {
							showError(json.Error)
							return
						}

						const editor = new TypeSchemaEditor(json.Type)
						document.getElementById('editor-type-schema').replaceChildren(editor.render())
					})
					.catch(err => showError(err))
				break

			default:
				alert(`Unhandled event: ${event.type}`)
				console.log(event)
		}
	}// }}}
	keyHandler(event) {// {{{
		if (!event.shiftKey || !event.altKey)
			return

		let handled = true
		switch (event.key.toUpperCase()) {
			case 'D':
				mbus.dispatch('NODE_DELETE')
				break

			case 'M':
				mbus.dispatch('NODE_MOVE')
				break

			case 'N':
				mbus.dispatch('NODE_CREATE_DIALOG')
				break

			default:
				handled = false
		}

		if (handled) {
			event.stopPropagation()
			event.preventDefault()
		}
	}// }}}
	page(item, name) {// {{{
		for (const el of document.querySelectorAll('#menu .item'))
			el.classList.remove('selected')
		item.classList.add('selected')

		for (const el of document.querySelectorAll('.page.show'))
			el.classList.remove('show')

		this.currentPage = name

		// This one is special and needs to be hidden separately since the HTML elements are built differently.
		document.getElementById('editor-node').style.display = 'none'

		switch (name) {
			case 'node':
				document.getElementById('nodes').classList.add('show')
				document.getElementById('editor-node').classList.add('show')
				break

			case 'type':
				document.getElementById('types').classList.add('show')
				document.getElementById('editor-type-schema').classList.add('show')

				if (this.typesList === null)
					this.typesList = new TypesList()
				this.typesList.fetchTypes().then(() => {
					mbus.dispatch('TYPES_LIST_FETCHED')
				})
				break

			case 'script':
				document.getElementById('scripts').classList.add('show')
				document.getElementById('editor-script').classList.add('show')

				if (this.scriptsList === null) {
					this.scriptsList = new ScriptsList()
					this.scriptEditor = new ScriptEditor()
				}

				this.scriptsList.fetchScripts()
					.catch(err => showError(err))
				break

			case 'script-execution':
				document.getElementById('script-executions').classList.add('show')

				if (this.scriptExecutionList === null)
					this.scriptExecutionList = new ScriptExecutionList()
				this.scriptExecutionList.fetchExecutions()
					.then(() => {
						mbus.dispatch('EXECUTIONS_LIST_FETCHED')
					})
					.catch(err => showError(err))
				break
		}
	}// }}}

	edit(nodeID) {// {{{
		if (nodeID === null) {
			document.getElementById('editor-node').style.display = 'none'
			this.currentNode = null
			return
		}

		fetch(`/nodes/${nodeID}`)
			.then(data => data.json())
			.then(json => {
				if (!json.OK) {
					showError(json.Error)
					return
				}

				this.currentNode = json.Node

				// The JSON editor is created each time. Could probably be reused.
				const editorEl = document.querySelector('#editor-node .editor')
				this.editor = new Editor(json.Node.TypeSchema)

				if (json.Node.Data['x-new'])
					editorEl.replaceChildren(this.editor.render(null))
				else
					editorEl.replaceChildren(this.editor.render(json.Node.Data))

				// Name is separate from the JSON node.
				const name = document.getElementById('editor-node-name')
				name.innerText = json.Node.Name

				// The editor-node div is hidden from the start as a lot of the elements
				// doesn't make any sense before a node is selected.
				document.getElementById('editor-node').style.display = 'grid'

				const connectedNodes = new ConnectedNodes(json.Node.ConnectedNodes)
				document.getElementById('connected-nodes').replaceChildren(connectedNodes.render())

				const scriptHooks = new ScriptHooks(json.Node.ScriptHooks)
				document.getElementById('script-hooks').replaceChildren(scriptHooks.render())
			})
			.catch(err => showError(err))
	}// }}}
	async nodeRename(nodeID, name) {// {{{
		return new Promise((resolve, reject) => {
			name = name.trim()
			if (name.length === 0) {
				alert('A name must be provided.')
				return
			}

			fetch(`/nodes/rename/${nodeID}`, {
				method: 'POST',
				body: JSON.stringify({
					Name: name,
				}),
			})
				.then(data => data.json())
				.then(json => {
					if (!json.OK) {
						showError(json.Error)
						return
					}

					this.edit(nodeID)
					resolve()
				})
				.catch(err => reject(err))
		})
	}// }}}
	nodeUpdate() {// {{{
		if (this.editor === null)
			return

		const btn = document.querySelector('#editor-node .controls button')
		btn.disabled = true
		const buttonPressed = Date.now()

		const nodeData = this.editor.data()

		fetch(`/nodes/update/${this.currentNode.ID}`, {
			method: 'POST',
			body: JSON.stringify(nodeData),
		})
			.then(data => data.json())
			.then(json => {
				if (!json.OK) {
					showError(json.Error)
					return
				}

				const timePassed = Date.now() - buttonPressed
				if (timePassed < 250)
					setTimeout(() => btn.disabled = false, 250 - timePassed)
				else
					btn.disabled = false
			})
	}// }}}
	nodeDelete(nodeID) {// {{{
		const node = this.tree.treeNodes.get(nodeID)
		const parentID = node.node.ParentID

		fetch(`/nodes/delete/${nodeID}`)
			.then(data => data.json())
			.then(json => {
				if (!json.OK) {
					showError(json.Error)
					return
				}

				this.tree.updateNode(parseInt(parentID))
				mbus.dispatch('NODE_SELECTED', null)
			})
			.catch(err => showError(err))
	}// }}}
	nodesMove(nodes, newParentID) {// {{{
		const req = {
			NewParentID: parseInt(newParentID),
			NodeIDs: nodes.map(n => n.ID),
		}
		fetch(`/nodes/move`, {
			method: 'POST',
			body: JSON.stringify(req),
		})
			.then(data => data.json())
			.then(async json => {
				if (!json.OK) {
					showError(json.Error)
					return
				}

				const updateParents = new Map()
				for (const n of nodes)
					updateParents.set(n.ParentID, true)
				updateParents.set(newParentID, true)

				for (const nodeID of updateParents.keys())
					this.tree.updateNode(nodeID)
			})
			.catch(err => showError(err))
	}// }}}
	async nodeConnect(parentNode, nodeToConnect) {// {{{
		return new Promise((resolve, reject) => {
			const req = {
				ParentNodeID: parentNode.ID,
				ChildNodeID: nodeToConnect.ID,
			}

			fetch(`/nodes/connect`, {
				method: 'POST',
				body: JSON.stringify(req),
			})
				.then(data => data.json())
				.then(json => {
					if (!json.OK) {
						showError(json.Error)
						return
					}
					resolve()
				})
				.catch(err => reject(err))
		})
	}// }}}
	typeSort(a, b) {// {{{
		if (a.Schema['x-group'] === undefined)
			a.Schema['x-group'] = 'No group'

		if (b.Schema['x-group'] === undefined)
			b.Schema['x-group'] = 'No group'

		if (a.Schema['x-group'] < b.Schema['x-group']) return -1
		if (a.Schema['x-group'] > b.Schema['x-group']) return 1

		if ((a.Schema.title || a.Name) < (b.Schema.title || b.Name)) return -1
		if ((a.Schema.title || a.Name) > (b.Schema.title || b.Name)) return 1

		return 0
	}// }}}
	async query(path, data) {// {{{
		return new Promise((resolve, reject) => {
			let request = {}

			if (data !== undefined) {
				request.method = 'POST'
				request.body = JSON.stringify(data)
			}

			fetch(path, request)
				.then(data => data.json())
				.then(json => {
					if (!json.OK) {
						reject(json.Error)
						return
					}
					resolve(json)
				})
				.catch(err => {
					reject(err)
				})
		})
	}// }}}
}

class NodeCreateDialog {
	constructor(parentNodeID) {// {{{
		this.parentNodeID = parentNodeID
		this.dialog = null
		this.types = null
		this.select = null
		this.input = null

		this.createElements()

		this.dialog.showModal()
		//this.select.focus()
	}// }}}
	createElements() {// {{{
		this.dialog = document.createElement('dialog')
		this.dialog.id = 'create-type'
		this.dialog.addEventListener('close', () => this.dialog.remove())
		this.dialog.innerHTML = `
			<div style="padding: 16px">
				<select></select>
				<input type="text" placeholder="Name">
				<button>Create</button>
			</div>
		`

		new SelectType().render()
			.then(select => {
				this.select = select
				this.dialog.querySelector('select').replaceWith(this.select)
				this.select.focus()
			})

		this.dialog.querySelector('button').addEventListener('click', () => this.commit())
		this.select = this.dialog.querySelector('select')
		this.input = this.dialog.querySelector('input')
		this.input.addEventListener('keydown', event => {
			if (event.key === 'Enter')
				this.commit()
		})

		document.body.appendChild(this.dialog)
	}// }}}
	commit() {// {{{
		if (this.input.value.trim().length === 0) {
			alert('Give a name.')
			return
		}

		const req = {
			ParentNodeID: this.parentNodeID,
			TypeID: parseInt(this.select.value),
			Name: this.input.value.trim(),
		}

		fetch('/nodes/create', {
			method: 'POST',
			body: JSON.stringify(req),
		})
			.then(data => data.json())
			.then(json => {
				if (!json.OK) {
					showError(json.Error)
					return
				}
				mbus.dispatch('TREE_RELOAD_NODE', {
					parentNodeID: this.parentNodeID,
					callback: () => mbus.dispatch('NODE_SELECTED', json.NodeID)
				})
				this.dialog.close()
			})
			.catch(err => showError(err))
	}// }}}
}

export class Tree {
	constructor() {// {{{
		this.treeNodes = new Map()

		const events = [
			'NODE_EXPAND',
		]
		for (const e of events)
			mbus.subscribe(e, event => this.eventHandler(event))


		// click on the empty tree list to unmark all nodes.
		const nodesEl = document.getElementById('nodes')
		nodesEl.addEventListener('click', event => {
			// To prevent accidentally removing all node marks,
			// shift is required to be unpressed, since it is required to
			// be pressed when marking nodes.
			if (event.shiftKey)
				return

			const markedElements = document.querySelectorAll('#nodes .node.marked')
			for (const e of markedElements)
				e.classList.remove('marked')
		})

		// Fetch the top node to start
		this.fetchNodes(0)
			.then(node => {
				const top = document.getElementById('nodes')
				const topNode = this.treeNodes.get(0)
				topNode.expanded = true
				top.appendChild(topNode.render())
				//this.updateNode(0)
			})
			.catch(err => showError(err))
	}// }}}
	eventHandler(event) {// {{{
		switch (event.type) {
			case 'NODE_EXPAND':
				this.updateNode(event.detail.node.ID)
				break

			default:
				alert(`Unhandled event: ${event.type}`)
				console.log(event)
		}
	}// }}}
	async fetchNodes(topNode) {// {{{
		return new Promise((resolve, reject) => {
			fetch(`/nodes/tree/${topNode}?depth=1`)
				.then(data => data.json())
				.then(json => {
					if (!json.OK) {
						reject(json.Error)
						return
					}

					// Make sure treenodes are updated with the latest fetched data.
					// The parent node is processed after the children, since the render function needs to have the children already available.
					const nodes = []
					nodes.push(...json.Nodes.Children)
					nodes.push(json.Nodes)

					for (const n of nodes) {
						let treenode = this.treeNodes.get(n.ID)
						if (treenode === undefined) {
							treenode = new TreeNode(this, n)
							treenode.render()
							this.treeNodes.set(n.ID, treenode)
						} else {
							// Since the depth is set to 1, the childrens' children array will be empty.
							// If children have been fetched, these should be kept.
							if (n.NumChildren > 0 && n.Children.length == 0)
								n.Children = treenode.node.Children
							treenode.node = n
						}
					}

					resolve(json.Nodes)
				})
		})
	}// }}}
	updateNode(nodeID) {// {{{
		return new Promise((resolve, reject) => {
			// updateNode retrieves a node and its' immediate children.
			// Node and each child is found in the treeNodes map and the names are updated.
			// If not found, created and added.
			//
			// Newly created nodes are found and added, existing but renamed nodes are modified, and unchanged are left as is.
			this.fetchNodes(nodeID, true)
				.then(node => {
					const thisTreeNode = this.treeNodes.get(nodeID)
					thisTreeNode.render()

					for (const n of thisTreeNode.node.Children)
						this.treeNodes.get(n.ID)?.render()

					resolve()

				})
				.catch(err => { showError(err); reject(err) })
		})
	}// }}}
	sortChildren(children) {// {{{
		children.sort((a, b) => {
			if (a.TypeName < b.TypeName) return -1
			if (a.TypeName > b.TypeName) return 1

			if (a.Name < b.Name) return -1
			if (a.Name > b.Name) return 1

			return 0
		})
	}// }}}
	markedNodes() {// {{{
		const markedElements = document.querySelectorAll('#nodes .node.marked')
		const marked = []
		for (const n of markedElements) {
			const nodeID = n.getAttribute('data-node-id')
			marked.push(this.treeNodes.get(parseInt(nodeID)).node)
		}
		return marked
	}// }}}
}

export class TreeNode {
	constructor(tree, data) {// {{{
		this.tree = tree
		this.node = data
		this.childrenFetched = false
		this.element = null
		this.children = null
		this.nameElement = null
		this.expandEventListenerAdded = false
		this.expanded = this.retrieveExpanded()

		if (this.expanded)
			this.tree.fetchNodes(this.node.ID)
				.then(() => this.render())
	}// }}}
	render() {// {{{
		if (this.element === null) {
			this.element = document.createElement('div')
			this.element.classList.add('node')
			this.element.setAttribute('data-node-id', this.node.ID)

			const nodeHTML = `
				<div class="expand-status"><img /></div>
				<div class="type-icon"><img /></div>
				<div class="name"></div>
				<div class="children"></div>
			`
			this.element.innerHTML = nodeHTML

			this.nameElement = this.element.querySelector('.name')
			this.children = this.element.querySelector('.children')
			this.expandImg = this.element.querySelector('.expand-status img')
			this.expandStatus = this.element.querySelector('.expand-status img')

			this.nameElement.addEventListener('click', event => this.clickNode(event))
		}

		// data.NumChildren is set regardless of having fetched the children or not.
		this.updateExpandImages()

		if (this.node.TypeIcon) {
			const img = this.element.querySelector('.type-icon img')
			img.setAttribute('src', `/images/${_VERSION}/node_modules/@mdi/svg/svg/${this.node.TypeIcon}.svg`)
		}

		this.nameElement.innerText = this.name()

		this.tree.sortChildren(this.node.Children)
		const children = []
		for (const c of this.node.Children)
			children.push(this.tree.treeNodes.get(c.ID).element)

		this.children.replaceChildren(...children)

		return this.element
	}// }}}

	name() {// {{{
		if (this.node.TypeName === 'root_node')
			return 'Start'
		return this.node.Name
	}// }}}
	clickNode(event) {// {{{
		if (!event.shiftKey)
			mbus.dispatch('NODE_SELECTED', this.node.ID)
		else
			this.element.classList.toggle('marked')
		event.stopPropagation()
	}// }}}
	hasChildren() {// {{{
		return this.node.NumChildren > 0
	}// }}}
	updateExpandImages() {// {{{
		if (this.hasChildren()) {
			if (this.expanded)
				this.element.classList.add('expanded')
			else
				this.element.classList.remove('expanded')

			this.expandStatus.setAttribute('src', `/images/${_VERSION}/node_modules/@mdi/svg/svg/${this.expanded ? 'minus-box-outline.svg' : 'plus-box-outline.svg'}`)

			if (!this.expandEventListenerAdded) {
				this.expandStatus.addEventListener('click', () => this.toggleExpand())
				this.expandEventListenerAdded = true
			}
		} else
			this.expandStatus.setAttribute('src', `/images/${_VERSION}/node_modules/@mdi/svg/svg/circle-medium.svg`)
	}// }}}
	toggleExpand(expanded) {// {{{
		if (expanded === undefined)
			this.expanded = !this.expanded
		else
			this.expanded = expanded

		this.storeExpanded()
		this.updateExpandImages()

		if (!this.childrenFetched && this.node.NumChildren > 0 && this.node.Children.length == 0) {
			mbus.dispatch('NODE_EXPAND', this)
		}
	}// }}}
	async fetchChildren() {// {{{
		return new Promise((resolve, reject) => {
			fetch(`/nodes/tree/${this.node.ID}?depth=1`)
				.then(data => data.json())
				.then(json => {
					if (json.OK)
						resolve(json.Nodes)
					else
						reject(json.Error)
				})
				.catch(err => reject(err))
		})
	}// }}}

	storeExpanded() {// {{{
		sessionStorage.setItem(`tree_expand_${this.node.ID}`, this.expanded ? '1' : '0')
	}// }}}
	retrieveExpanded() {// {{{
		return sessionStorage.getItem(`tree_expand_${this.node.ID}`) === '1'
	}// }}}
}

class TypesList extends Component {
	constructor() {// {{{
		super()
		this.types = []
	}// }}}
	async fetchTypes() {// {{{
		return new Promise((resolve, reject) => {
			fetch('/types/')
				.then(data => data.json())
				.then(json => {
					if (!json.OK) {
						showError(json.Error)
						return
					}
					this.types = json.Types
					resolve()
				})
				.catch(err => reject(err))
		})
	}// }}}
	renderComponent() {// {{{
		const div = document.createElement('div')

		const label = document.createElement('div')
		const inner = document.createElement('div')
		label.classList.add('label')
		inner.innerText = 'Types'
		label.appendChild(inner)

		const create = document.createElement('img')
		create.setAttribute('src', `/images/${_VERSION}/node_modules/@mdi/svg/svg/plus-box.svg`)
		create.style.height = '32px'
		create.style.cursor = 'pointer';
		create.addEventListener('click', () => this.createType())

		label.append(create)
		div.appendChild(label)

		this.types.sort(_app.typeSort)

		let prevGroup = null

		for (const t of this.types) {
			if (t.Name == 'root_node')
				continue

			if (t.Schema['x-group'] != prevGroup) {
				prevGroup = t.Schema['x-group']
				const group = document.createElement('div')
				group.classList.add('group')
				group.innerText = t.Schema['x-group']
				div.appendChild(group)
			}

			const tDiv = document.createElement('div')
			tDiv.classList.add('type')
			tDiv.innerHTML = `
				<div class="img"><img src="/images/${_VERSION}/node_modules/@mdi/svg/svg/${t.Schema.icon}.svg" /></div>
				<div class="title">${t.Schema.title || t.Name}</div>
				`

			tDiv.addEventListener('click', () => mbus.dispatch('TYPE_EDIT', t.ID))
			div.appendChild(tDiv)
		}

		return div
	}// }}}
	createType() {// {{{
		const name = prompt("Type name")
		if (name === null)
			return
		fetch(`/types/create`, {
			method: 'POST',
			body: JSON.stringify({ name }),
		})
			.then(data => data.json())
			.then(json => {
				if (!json.OK) {
					showError(json.Error)
					return
				}

				_app.typesList.fetchTypes().then(() => {
					mbus.dispatch('TYPES_LIST_FETCHED')
					mbus.dispatch('TYPE_EDIT', json.Type.ID)
				})
			})
			.catch(err => showError(err))
	}// }}}
}

class TypeSchemaEditor extends Component {
	constructor(dataType) {// {{{
		super()
		this.type = dataType
	}// }}}
	renderComponent() {// {{{
		const tmpl = document.createElement('template')
		tmpl.innerHTML = `
			<div>
				<div style="float: left;" class="label">${this.type.Schema.title}</div>
				<div style="float: right;"><img class="save" src="/images/${_VERSION}/node_modules/@mdi/svg/svg/content-save.svg" /></div>
			</div>
			<div style="clear: both;">
				<input type="text" class="name">
			</div>
			<textarea class="type-schema"></textarea>
		`

		this.textarea = tmpl.content.querySelector('textarea')
		this.textarea.value = JSON.stringify(this.type.Schema, null, 4)
		this.textarea.addEventListener('keydown', event => {
			if (!event.ctrlKey || event.key !== 's')
				return
			event.stopPropagation()
			event.preventDefault()
			this.save()
		})

		this.name = tmpl.content.querySelector('.name')
		this.name.value = this.type.Name

		this.img_save = tmpl.content.querySelector('img.save')
		this.img_save.addEventListener('click', () => this.save())

		return tmpl.content
	}// }}}
	save() {// {{{
		const req = {
			Name: this.name.value,
			Schema: this.textarea.value,
		}

		const start_update = Date.now()
		this.img_save.classList.add('saving')

		fetch(`/types/update/${this.type.ID}`, {
			method: 'POST',
			body: JSON.stringify(req),
		})
			.then(data => data.json())
			.then(json => {
				if (!json.OK) {
					showError(json.Error)
					return
				}

				const time_left = 100 - (Date.now() - start_update)
				setTimeout(() => {
					this.img_save.classList.remove('saving')
					this.refreshTypeUI()
				}, Math.max(time_left, 0))

			})
			.catch(err => showError(err))

	}// }}}
	async refreshTypeUI() {// {{{
		_app.typesList.fetchTypes().then(() => {
			mbus.dispatch('TYPES_LIST_FETCHED')
			mbus.dispatch('TYPE_EDIT', this.type.ID)
		})
	}// }}}
}

class ConnectedNodes {
	constructor(nodes) {// {{{
		this.nodes = nodes
	}// }}}
	render() {// {{{
		const div = document.createElement('template')
		div.innerHTML = `
			<div class="label">
				<div style="white-space: nowrap">Connected nodes</div>
				<img class="add" src="/images/${_VERSION}/node_modules/@mdi/svg/svg/plus-box.svg" />
			</div>
			<div class="connected-nodes"></div>
		`

		div.content.querySelector('.add').addEventListener('click', () => mbus.dispatch('NODE_CONNECT'))

		const types = new Map()
		for (const n of this.nodes) {
			let typeGroup = types.get(n.TypeSchema.title)
			if (typeGroup === undefined) {
				typeGroup = document.createElement('div')
				typeGroup.classList.add('type-group')
				typeGroup.innerHTML = `<div class="type-name">${n.TypeSchema.title}</div>`
				types.set(n.TypeSchema.title, typeGroup)
			}

			typeGroup.appendChild(
				new ConnectedNode(n).render()
			)
		}

		const connectedNodes = div.content.querySelector('.connected-nodes')
		for (const t of Array.from(types.keys()).sort()) {
			connectedNodes.append(types.get(t))
		}

		return div.content
	}// }}}
}

class ConnectedNode {
	constructor(node) {// {{{
		this.node = node
	}// }}}
	render() {// {{{
		const tmpl = document.createElement('template')
		tmpl.innerHTML = `
			<div class="type-icon"><img src="/images/${_VERSION}/node_modules/@mdi/svg/svg/${this.node.TypeIcon}.svg" /></div>
			<div class="node-name">${this.node.Name}</div>
		`

		for (const el of tmpl.content.children) {
			el.addEventListener('click', () => {
				new ConnectionDataDialog(this.node, () => _app.edit(_app.currentNode.ID)).render()
			})
		}
		return tmpl.content
	}// }}}
}

class ScriptHooks extends Component {
	constructor(hooks) {// {{{
		super()
		this.hooks = hooks
		this.scriptGrid = null
	}// }}}
	renderComponent() {// {{{
		const div = document.createElement('div')
		div.innerHTML = `
			<div class="label">
				<div style="white-space: nowrap">Script hooks</div>
				<img class="add" src="/images/${_VERSION}/node_modules/@mdi/svg/svg/plus-box.svg" />
			</div>
			<div class="scripts-grid">
				<div class="header" style="grid-column: 1 / 3;">Script</div>
				<div class="header" style="grid-column: 3 / 5;">SSH</div>
			</div>
		`

		div.querySelector('.add').addEventListener('click', () => {
			const dlg = new ScriptSelectDialog(s => {
				this.hookScript(s)
			})
			dlg.render()
		})

		this.scriptGrid = div.querySelector('.scripts-grid')
		this.renderHooks()

		return div.children
	}// }}}
	hookDeleted(deletedHookID) {// {{{
		this.hooks = this.hooks.filter(h => h.ID !== deletedHookID)
		this.renderHooks()
	}// }}}
	renderHooks() {// {{{
		this.scriptGrid.innerHTML = ''

		let curGroupName = null
		let group = document.createElement('div')
		group.classList.add('script-group')

		for (const hook of this.hooks) {
			if (hook.Script.Group !== curGroupName) {
				const g = document.createElement('div')
				g.classList.add('script-group-title')
				g.innerText = hook.Script.Group

				group = document.createElement('div')
				group.classList.add('script-group')
				group.append(g)
				this.scriptGrid.append(group)
				curGroupName = hook.Script.Group
			}

			const h = new ScriptHook(hook, this)
			group.append(h.render())
		}

		if (group.children.length > 1)
			this.scriptGrid.append(group)
	}// }}}
	hookScript(script) {// {{{
		_app.query(`/nodes/hook`, {
			NodeID: _app.currentNode.ID,
			ScriptID: script.ID,
		})
			.then(() => mbus.dispatch('NODE_HOOKED', _app.currentNode.ID))
			.catch(err => showError(err))
	}// }}}
}

class ScriptHook extends Component {
	constructor(hook, parentList) {// {{{
		super()
		this.hook = hook
		this.parentList = parentList
		this.elementSSH = null
		this.elementSchedule = null
		this.scheduleDisable = false
	}// }}}
	renderComponent() {// {{{
		const tmpl = document.createElement('template')
		tmpl.innerHTML = `
			<div class="script-name">${this.hook.Script.Name}</div>
			<div class="script-ssh"></div>
			<div class="script-schedule"><img src="/images/${_VERSION}/node_modules/@mdi/svg/svg/play-box.svg" /></div>
		`


		this.elementSchedule = tmpl.content.querySelector('.script-schedule')
		this.elementSSH = tmpl.content.querySelector('.script-ssh')
		this.elementSSH.innerText = `[ ${this.hook.SSH} ]`

		tmpl.content.querySelector('.script-name').addEventListener('click', () => this.update())
		tmpl.content.querySelector('.script-ssh').addEventListener('click', () => this.update())
		tmpl.content.querySelector('.script-schedule').addEventListener('click', () => this.run())

		return tmpl.content
	}// }}}
	update() {// {{{
		new ScriptHookDialog(this.hook, this.parentList, () => {
			this.elementSSH.innerText = `[ ${this.hook.SSH} ]`
		}).render()
	}// }}}
	run() {// {{{
		if (this.scheduleDisable)
			return

		this.scheduleDisable = true
		this.elementSchedule.classList.add('disabled')
		const start = Date.now()

		_app.query(`/hooks/schedule/${this.hook.ID}`)
			.catch(err => showError(err))
			.finally(() => {
				const duration = Date.now() - start
				setTimeout(() => {
					this.scheduleDisable = false
					this.elementSchedule.classList.remove('disabled')
				}, 250 - duration)
			})
	}// }}}
}

class ScriptsList extends Component {
	constructor() {// {{{
		super()
		this.scripts = []
		this.selectedID = 0
	}// }}}

	async fetchScripts() {// {{{
		return new Promise((resolve, reject) => {
			fetch('/scripts/')
				.then(data => data.json())
				.then(json => {
					if (!json.OK) {
						reject(json.Error)
						return
					}
					this.scripts = json.Scripts
					mbus.dispatch('SCRIPTS_LIST_FETCHED')
					resolve(this.scripts)
				})
		})
	}// }}}
	renderComponent() {// {{{
		const label = document.createElement('div')
		const inner = document.createElement('div')
		label.classList.add('label')
		inner.innerText = 'Scripts'
		label.appendChild(inner)

		let prevGroup = null
		const elements = []
		const imgAdd = document.createElement('img')
		imgAdd.setAttribute('src', `/images/${_VERSION}/node_modules/@mdi/svg/svg/plus-box.svg`)
		imgAdd.style.height = '32px'
		imgAdd.style.cursor = 'pointer'
		imgAdd.addEventListener('click', () => this.createScript())

		label.append(imgAdd)
		elements.push(label)

		for (const s of this.scripts) {
			if (prevGroup != s.Group) {
				const gEl = document.createElement('div')
				gEl.classList.add('group')
				gEl.innerText = s.Group
				elements.push(gEl)
				prevGroup = s.Group
			}

			const sEl = document.createElement('div')
			sEl.classList.add('script')
			if (s.ID === this.selectedID)
				sEl.classList.add('selected')
			sEl.setAttribute('data-script-id', s.ID)
			sEl.innerHTML = `
				<div><img src="/images/${_VERSION}/node_modules/@mdi/svg/svg/bash.svg"></div>
				<div>${s.Name}</div>
			`

			for (const el of sEl.children)
				el.addEventListener('click', () => mbus.dispatch('SCRIPT_EDIT', s))

			elements.push(sEl)

		}

		return elements
	}// }}}
	setSelected(scriptID) {// {{{
		this.selectedID = scriptID

		const scripts = document.getElementById('scripts')
		for (const el of scripts.querySelectorAll('.selected'))
			el.classList.remove('selected')
		const script = scripts.querySelector(`[data-script-id="${this.selectedID}"]`)
		script?.classList.add('selected')
	}// }}}
	createScript() {// {{{
		const name = prompt('Script name')
		if (name === null)
			return

		if (name.trim() === '') {
			alert("Name can't be empty.")
			return
		}

		const script = {
			Group: 'Uncategorized',
			Name: name,
			Source: "%!/bin/bash\n",
		}
		fetch(`/scripts/update/0`, {
			method: 'POST',
			body: JSON.stringify(script),
		})
			.then(data => data.json())
			.then(json => {
				if (!json.OK) {
					showError(json.Error)
					return
				}

				mbus.dispatch('SCRIPT_CREATED', json.Script)
			})
			.catch(err => showError(err))
	}// }}}
}

class ScriptEditor extends Component {
	constructor() {// {{{
		super()
		this.elements = this.createElements()
		this.script = {
			Group: '',
			Name: '',
			Source: '',
		}
	}// }}}
	createElements() {// {{{
		const div = document.createElement('div')
		div.innerHTML = `
			<div>
				<div class="label">Group</div>
				<div><input class="group" type="text"></div>
			</div>

			<div>
				<div class="label">Name</div>
				<div><input class="name" type="text"></div>
			</div>

			<div>
				<img class="delete" src="/images/${_VERSION}/node_modules/@mdi/svg/svg/trash-can.svg" style="height: 32px; cursor: pointer">
			</div>

			<div style="grid-column: 1 / -1">
				<div class="label">Source</div>
				<div><textarea class="source"></textarea></div>

				<button>Update</button>
			</div>
		`

		this.groupElement = div.querySelector('.group')
		this.nameElement = div.querySelector('.name')
		this.sourceElement = div.querySelector('.source')

		this.button = div.querySelector('button')
		this.button.addEventListener('click', () => this.updateScript())

		div.querySelector('.delete').addEventListener('click', () => this.deleteScript())

		div.addEventListener('keydown', event => this.keyHandler(event))

		return div
	}// }}}
	keyHandler(event) {// {{{
		if (event.key !== 's' || !event.ctrlKey)
			return
		event.stopPropagation()
		event.preventDefault()

		this.updateScript()
	}// }}}
	renderComponent() {// {{{
		this.groupElement.value = this.script.Group
		this.nameElement.value = this.script.Name
		this.sourceElement.value = this.script.Source

		return this.elements
	}// }}}
	updateScript() {// {{{
		this.button.disabled = true
		const start = Date.now()

		const script = {
			Group: this.groupElement.value,
			Name: this.nameElement.value,
			Source: this.sourceElement.value,
		}

		fetch(`/scripts/update/${this.script.ID}`, {
			method: 'POST',
			body: JSON.stringify(script),
		})
			.then(data => data.json())
			.then(json => {
				if (!json.OK) {
					showError(json.Error)
					return
				}
				mbus.dispatch('SCRIPT_UPDATED')
			})
			.finally(() => {
				const timePassed = Date.now() - start
				setTimeout(() => this.button.disabled = false, 250 - timePassed)
			})

	}// }}}
	deleteScript() {// {{{
		if (!confirm('Delete script?'))
			return

		fetch(`/scripts/delete/${this.script.ID}`)
			.then(data => data.json())
			.then(json => {
				if (!json.OK) {
					showError(json.Error)
					return
				}
				this.script.Name = ''
				this.script.Group = ''
				this.script.Source = ''
				this.render()
				mbus.dispatch('SCRIPT_DELETED', this.script.ID)
			})
			.catch(err => showError(err))
	}// }}}
}

class ScriptSelectDialog extends Component {
	constructor(callback) {// {{{
		super()
		this.dlg = document.createElement('dialog')
		this.dlg.id = 'script-select-dialog'
		this.dlg.addEventListener('close', () => this.dlg.remove())
		this.searchFor = null
		this.scripts = null
		this.callback = callback
	}// }}}
	renderComponent() {// {{{
		const div = document.createElement('div')
		div.innerHTML = `
			<div class="header">Search for script</div>
			<div><input class="search-for" type="text" value="%"></div>
			<div><button>Search</button></div>
			<div class="scripts"></div>
		`

		this.searchFor = div.querySelector('.search-for')
		this.scripts = div.querySelector('.scripts')
		const button = div.querySelector('button')
		this.searchFor.addEventListener('keydown', event => {
			if (event.key == 'Enter')
				this.searchScripts()
		})
		button.addEventListener('click', () => this.searchScripts())

		this.dlg.append(...div.children)
		document.body.append(this.dlg)
		this.dlg.showModal()

		return []
	}// }}}
	searchScripts() {// {{{
		fetch('/scripts/search', {
			method: 'POST',
			body: JSON.stringify({
				Search: this.searchFor.value,
			}),
		})
			.then(data => data.json())
			.then(json => {
				if (!json.OK) {
					showError(json.Error)
					return
				}
				this.populateScripts(json.Scripts)
			})
			.catch(err => showError(err))
	}// }}}
	populateScripts(scripts) {// {{{
		this.scripts.innerHTML = ''

		let prevGroup = null
		for (const s of scripts) {
			if (s.Group !== prevGroup) {
				const group = document.createElement('div')
				group.classList.add('group')
				group.innerText = s.Group
				this.scripts.append(group)
				prevGroup = s.Group
			}

			const div = document.createElement('div')
			div.innerText = s.Name
			div.classList.add('script')
			div.addEventListener('click', () => {
				this.dlg.close()
				this.callback(s)
			})
			this.scripts.append(div)
		}
	}// }}}
}

class ScriptHookDialog extends Component {
	constructor(hook, parentList, callback) {// {{{
		super()
		this.hook = hook
		this.callback = callback
		this.parentList = parentList

		this.dlg = document.createElement('dialog')
		this.dlg.id = 'script-hook-dialog'
		this.dlg.addEventListener('close', () => this.dlg.remove())

		this.env = null
		this.ssh = null
		this.schedule_on_child_update = null
	}// }}}
	renderComponent() {// {{{
		const div = document.createElement('div')
		div.innerHTML = `
			<div class="top">
				<div class="header"></div>
				<img src="/images/${_VERSION}/node_modules/@mdi/svg/svg/trash-can.svg">
			</div>

			<div class="label">SSH</div>
			<div><input type="text" class="ssh" style="width: 100%" /></div>

			<div class="label">Schedule automatically</div>
			<div>
				<input type="checkbox" class="schedule-on-child" id="schedule-on-child" />
				<label for="schedule-on-child">on child update</label>
			</div>

			<div class="label">
				Environment
			</div>
			<div style="font-size: 0.9em">A map with keys and values as strings.</div>

			<div><textarea class="env"></textarea></div>
			<div><button>Update</button></div>
		`

		div.querySelector('.header').innerText = `Hook for ${this.hook.Script.Name}`
		div.querySelector('.top img').addEventListener('click', () => this.delete())

		this.ssh = div.querySelector('.ssh')
		this.ssh.value = this.hook.SSH
		this.ssh.addEventListener('keydown', event => {
			if (event.key == 'Enter') {
				event.stopPropagation()
				this.save()
			}
		})

		this.env = div.querySelector('.env')
		this.env.value = JSON.stringify(this.hook.Env, null, "  ")

		this.schedule_on_child_update = div.querySelector('.schedule-on-child')
		this.schedule_on_child_update.checked = this.hook.ScheduleOnChildUpdate

		const button = div.querySelector('button')
		this.env.addEventListener('keydown', event => {
			if (event.ctrlKey && event.key == 's') {
				event.preventDefault()
				event.stopPropagation()
				this.save()
			}
		})
		button.addEventListener('click', () => this.save())

		this.dlg.append(...div.children)
		document.body.append(this.dlg)
		this.dlg.showModal()

		return []
	}// }}}
	save() {// {{{
		if (this.ssh.value.trim() === '') {
			alert('SSH has to be filled in.')
			return
		}

		if (this.env.value.trim() === '')
			this.env.value = '{}'

		try {
			this.hook.Env = JSON.parse(this.env.value)
			this.hook.SSH = this.ssh.value.trim()
			this.hook.ScheduleOnChildUpdate = this.schedule_on_child_update.checked
			window._app.query('/hooks/update', this.hook)
				.then(() => {
					this.callback()
					this.dlg.close()
				})
				.catch(err => showError(err))
		} catch (err) {
			alert(`A JSON error occured:\n\n${err}`)
		}

	}// }}}
	delete() {// {{{
		if (!confirm(`Unhook the '${this.hook.Script.Name}' script?`))
			return

		window._app.query(`/hooks/delete/${this.hook.ID}`)
			.then(() => {
				this.dlg.close()
				this.parentList.hookDeleted(this.hook.ID)
			})
			.catch(err => showError(err))
	}// }}}
}

class ScriptExecutionList extends Component {
	constructor() {// {{{
		super()
		this.executions = []
	}// }}}
	async fetchExecutions() {// {{{
		return new Promise((resolve, reject) => {
			window._app.query('/scriptexecutions/')
				.then(data => {
					this.executions = data.ScriptExecutions
					resolve()
				})
				.catch(err => reject(err))
		})
	}// }}}
	renderComponent() {// {{{
		const tmpl = document.createElement('template')
		tmpl.innerHTML = `
			<div class="label">Script executions</div>
			<div  class="executions">
				<div class="header">ID</div>
				<div class="header">SSH</div>
				<div class="header">Script</div>
				<div class="header">Start</div>
				<div class="header">End</div>
				<div class="header">Script</div>
				<div class="header">Data</div>
				<div class="header">Env</div>
				<div class="header">Out</div>
				<div class="header">Err</div>
				<div class="header">Exitcode</div>
			</div>
		`
		const executions = tmpl.content.querySelector('.executions')

		for (const e of this.executions) {
			const se = new ScriptExecution(e)
			executions.append(se.render())
		}

		return tmpl.content
	}// }}}
}

class ScriptExecution extends Component {
	constructor(execution) {// {{{
		super()
		this.execution = execution
	}// }}}
	formatTime(t) {// {{{
		const d = new Date(t)
		const year = d.getYear() + 1900
		const month = `0${d.getMonth() + 1}`.slice(-2)
		const date = `0${d.getDate()}`.slice(-2)
		const hour = `0${d.getHours()}`.slice(-2)
		const min = `0${d.getMinutes()}`.slice(-2)
		const sec = `0${d.getSeconds()}`.slice(-2)
		return `<span class="date">${year}-${month}-${date}</span> <span class="time">${hour}:${min}:${sec}</span>`
	}// }}}
	icon(name) {// {{{
		return `<img src="/images/${_VERSION}/node_modules/@mdi/svg/svg/${name}.svg" />`
	}// }}}
	renderComponent() {// {{{
		const tmpl = document.createElement('template')

		tmpl.innerHTML = `
			<div class="id">${this.execution.ID}</div>
			<div class="ssh">${this.execution.SSH}</div>
			<div class="name">${this.execution.ScriptName}</div>
			<div class="start">${this.formatTime(this.execution.TimeStart.Time)}</div>
			<div class="end">${this.formatTime(this.execution.TimeEnd.Time)}</div>
			<div class="source">${this.execution.HasSource ? this.icon('bash') : ''}</div>
			<div class="data">${this.execution.HasData ? this.icon('code-json') : ''}</div>
			<div class="env">${this.execution.HasEnv ? this.icon('application-braces-outline') : ''}</div>
			<div class="stdout">${this.execution.HasOutputStdout ? this.icon('text-box-check-outline') : ''}</div>
			<div class="stderr">${this.execution.HasOutputStderr ? this.icon('text-box-remove-outline') : ''}</div>
			<div class="exitcode"><div class="code-${this.execution.ExitCode.Int16 == 0 ? 'ok' : 'error'}">${this.execution.ExitCode.Int16}</div></div>
		`

		const classValues = new Map()
		classValues.set('source', 'Source')
		classValues.set('data', 'Data')
		classValues.set('env', 'Env')
		classValues.set('stdout', 'OutputStdout')
		classValues.set('stderr', 'OutputStderr')

		for (const [cls, value] of classValues) {
			tmpl.content.querySelector(`.${cls}`).addEventListener('click', () => {
				new ScriptExecutionValueDialog(this.execution, value).render()
			})
		}

		return tmpl.content
	}// }}}
}

class ScriptExecutionValueDialog extends Component {
	constructor(execution, valueName) {// {{{
		super()
		this.execution = execution
		this.valueName = valueName

		this.dlg = document.createElement('dialog')
		this.dlg.id = 'script-execution-value-dialog'
		this.dlg.addEventListener('close', () => this.dlg.remove())

		this.value = null
	}// }}}
	getValue(execution) {// {{{
		switch (this.valueName) {
			case 'Source':
				return execution.Source

			case 'Data':
			case 'Env':
				return JSON.stringify(
					JSON.parse(execution[this.valueName])
					,
					null,
					'    '
				)

			case 'OutputStdout':
			case 'OutputStderr':
				return execution[this.valueName]?.String
		}
	}// }}}
	renderComponent() {// {{{
		const div = document.createElement('div')
		div.innerHTML = `
			<div class="top">
				<div class="header">${this.valueName}</div>
				<img class="copy" src="/images/${_VERSION}/node_modules/@mdi/svg/svg/content-copy.svg" />
			</div>
			<div class="label">${this.execution.ID}</div>
			<div class="value"></div>
		`

		this.value = div.querySelector('.value')
		div.querySelector('.copy').addEventListener('click', event=>{
			event.target.classList.add('clicked')
			setTimeout(()=>event.target.classList.remove('clicked'), 250)
			navigator.clipboard.writeText(this.value.innerText)
		})

		window._app.query(`/scriptexecutions/${this.execution.ID}`)
			.then(data => {
				this.value.innerText = this.getValue(data.ScriptExecution)
			})
			.catch(err => showError(err))

		this.dlg.append(...div.children)
		document.body.append(this.dlg)
		this.dlg.showModal()

		return []
	}// }}}
}

// vim: foldmethod=marker
