// Copyright (c) 2022, Přemysl Eric Janouch <p@janouch.name>
// SPDX-License-Identifier: 0BSD
import * as Relay from './proto.js'

// ---- RPC --------------------------------------------------------------------

class RelayRpc extends EventTarget {
	constructor(url) {
		super()
		this.url = url
		this.commandSeq = 0
	}

	connect() {
		// We can't close the connection immediately, as that queues a task.
		if (this.ws !== undefined)
			throw "Already connecting or connected"

		return new Promise((resolve, reject) => {
			let ws = this.ws = new WebSocket(this.url)
			ws.onopen = event => {
				this._initialize()
				resolve()
			}
			// It's going to be code 1006 with no further info.
			ws.onclose = event => {
				this.ws = undefined
				reject()
			}
		})
	}

	_initialize() {
		this.ws.binaryType = 'arraybuffer'
		this.ws.onopen = undefined
		this.ws.onmessage = event => {
			this._process(event.data)
		}
		this.ws.onerror = event => {
			this.dispatchEvent(new CustomEvent('error'))
		}
		this.ws.onclose = event => {
			let message = "Connection closed: " +
				event.reason + " (" + event.code + ")"
			for (const seq in this.promised)
				this.promised[seq].reject(message)

			this.ws = undefined
			this.dispatchEvent(new CustomEvent('close', {
				detail: {message, code: event.code, reason: event.reason},
			}))

			// Now connect() can be called again.
		}

		this.promised = {}
	}

	_process(data) {
		if (typeof data === 'string')
			throw "JSON messages not supported"

		const r = new Relay.Reader(data)
		while (!r.empty)
			this._processOne(Relay.EventMessage.deserialize(r))
	}

	_processOne(message) {
		let e = message.data
		switch (e.event) {
		case Relay.Event.Error:
			if (this.promised[e.commandSeq] !== undefined)
				this.promised[e.commandSeq].reject(e.error)
			else
				console.error("Unawaited error")
			break
		case Relay.Event.Response:
			if (this.promised[e.commandSeq] !== undefined)
				this.promised[e.commandSeq].resolve(e.data)
			else
				console.error("Unawaited response")
			break
		default:
			e.eventSeq = message.eventSeq
			this.dispatchEvent(new CustomEvent('event', {detail: e}))
			return
		}

		delete this.promised[e.commandSeq]
		for (const seq in this.promised) {
			// We don't particularly care about wraparound issues.
			if (seq >= e.commandSeq)
				continue

			this.promised[seq].reject("No response")
			delete this.promised[seq]
		}
	}

	send(params) {
		if (this.ws === undefined)
			throw "Not connected"
		if (typeof params !== 'object')
			throw "Method parameters must be an object"

		// Left shifts in Javascript convert to a 32-bit signed number.
		let seq = ++this.commandSeq
		if ((seq << 0) != seq)
			seq = this.commandSeq = 0

		this.ws.send(JSON.stringify({commandSeq: seq, data: params}))
		return new Promise((resolve, reject) => {
			this.promised[seq] = {resolve, reject}
		})
	}
}

// ---- Utilities --------------------------------------------------------------

function utf8Encode(s) { return new TextEncoder().encode(s) }
function utf8Decode(s) { return new TextDecoder().decode(s) }

function hasShortcutModifiers(event) {
	return (event.altKey || event.escapePrefix) &&
		!event.metaKey && !event.ctrlKey
}

const audioContext = new AudioContext()

function beep() {
	let gain = audioContext.createGain()
	gain.gain.value = 0.5
	gain.connect(audioContext.destination)

	let oscillator = audioContext.createOscillator()
	oscillator.type = "triangle"
	oscillator.frequency.value = 800
	oscillator.connect(gain)
	oscillator.start(audioContext.currentTime)
	oscillator.stop(audioContext.currentTime + 0.1)
}

let iconLink = undefined
let iconState = undefined

function updateIcon(highlighted) {
	if (iconState === highlighted)
		return

	iconState = highlighted
	let canvas = document.createElement('canvas')
	canvas.width = 32
	canvas.height = 32

	let ctx = canvas.getContext('2d')
	ctx.arc(16, 16, 12, 0, 2 * Math.PI)
	ctx.fillStyle = '#000'
	if (highlighted === true)
		ctx.fillStyle = '#ff5f00'
	if (highlighted === false)
		ctx.fillStyle = '#ccc'
	ctx.fill()

	if (iconLink === undefined) {
		iconLink = document.createElement('link')
		iconLink.type = 'image/png'
		iconLink.rel = 'icon'
		document.getElementsByTagName('head')[0].appendChild(iconLink)
	}

	iconLink.href = canvas.toDataURL();
}

// ---- Event processing -------------------------------------------------------

let rpc = new RelayRpc(proxy)
let rpcEventHandlers = new Map()

let buffers = new Map()
let bufferLast = undefined
let bufferCurrent = undefined
let bufferLog = undefined
let bufferAutoscroll = true

let servers = new Map()

function bufferResetStats(b) {
	b.newMessages = 0
	b.newUnimportantMessages = 0
	b.highlighted = false
}

function bufferActivate(name) {
	rpc.send({command: 'BufferActivate', bufferName: name})
}

function bufferToggleUnimportant(name) {
	rpc.send({command: 'BufferToggleUnimportant', bufferName: name})
}

function bufferToggleLog() {
	if (bufferLog) {
		setTimeout(() =>
			document.getElementById('input')?.focus())

		bufferLog = undefined
		m.redraw()
		return
	}

	let name = bufferCurrent
	rpc.send({
		command: 'BufferLog',
		bufferName: name,
	}).then(resp => {
		if (bufferCurrent !== name)
			return

		bufferLog = utf8Decode(resp.log)
		m.redraw()
	})
}

let connecting = true
rpc.connect().then(result => {
	buffers.clear()
	bufferLast = undefined
	bufferCurrent = undefined
	bufferLog = undefined
	bufferAutoscroll = true

	servers.clear()

	rpc.send({command: 'Hello', version: Relay.version})
	connecting = false
	m.redraw()
}).catch(error => {
	connecting = false
	m.redraw()
})

rpc.addEventListener('close', event => {
	m.redraw()
})

rpc.addEventListener('event', event => {
	const handler = rpcEventHandlers.get(event.detail.event)
	if (handler !== undefined) {
		handler(event.detail)
		if (bufferCurrent !== undefined ||
			event.detail.event !== Relay.Event.BufferLine)
			m.redraw()
	}
})

rpcEventHandlers['Ping'] = e => {
	rpc.send({command: 'PingResponse', eventSeq: e.eventSeq})
}

// ~~~ Buffer events ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

rpcEventHandlers.set(Relay.Event.BufferUpdate, e => {
	let b = buffers.get(e.bufferName)
	if (b === undefined) {
		buffers.set(e.bufferName, (b = {
			lines: [],
			history: [],
			historyAt: 0,
		}))
		bufferResetStats(b)
	}

	b.hideUnimportant = e.hideUnimportant
	b.kind = e.context.kind
	b.server = servers.get(e.context.serverName)
})

rpcEventHandlers.set(Relay.Event.BufferStats, e => {
	let b = buffers.get(e.bufferName)
	if (b === undefined)
		return

	b.newMessages = e.newMessages,
	b.newUnimportantMessages = e.newUnimportantMessages
	b.highlighted = e.highlighted
})

rpcEventHandlers.set(Relay.Event.BufferRename, e => {
	buffers.set(e.new, buffers.get(e.bufferName))
	buffers.delete(e.bufferName)
})

rpcEventHandlers.set(Relay.Event.BufferRemove, e => {
	buffers.delete(e.bufferName)
	if (e.bufferName === bufferLast)
		bufferLast = undefined
})

rpcEventHandlers.set(Relay.Event.BufferActivate, e => {
	let old = buffers.get(bufferCurrent)
	if (old !== undefined)
		bufferResetStats(old)

	bufferLast = bufferCurrent
	let b = buffers.get(e.bufferName)
	bufferCurrent = e.bufferName
	bufferLog = undefined
	bufferAutoscroll = true
	if (b !== undefined && document.visibilityState !== 'hidden')
		b.highlighted = false

	let textarea = document.getElementById('input')
	if (textarea === null)
		return

	textarea.focus()
	if (old !== undefined) {
		old.input = textarea.value
		old.inputStart = textarea.selectionStart
		old.inputEnd = textarea.selectionEnd
		old.inputDirection = textarea.selectionDirection
		// Note that we effectively overwrite the newest line
		// with the current textarea contents, and jump there.
		old.historyAt = old.history.length
	}

	textarea.value = ''
	if (b !== undefined && b.input !== undefined) {
		textarea.value = b.input
		textarea.setSelectionRange(b.inputStart, b.inputEnd, b.inputDirection)
	}
})

rpcEventHandlers.set(Relay.Event.BufferLine, e => {
	let b = buffers.get(e.bufferName), line = {...e}
	delete line.event
	delete line.eventSeq
	delete line.leakToActive
	if (b === undefined)
		return

	// Initial sync: skip all other processing, let highlights be.
	if (bufferCurrent === undefined) {
		b.lines.push(line)
		return
	}

	let visible = document.visibilityState !== 'hidden' &&
		bufferLog === undefined &&
		(e.bufferName == bufferCurrent || e.leakToActive)
	b.lines.push({...line})
	if (!(visible || e.leakToActive) ||
			b.newMessages || b.newUnimportantMessages) {
		if (line.isUnimportant)
			b.newUnimportantMessages++
		else
			b.newMessages++
	}

	if (e.leakToActive) {
		let bc = buffers.get(bufferCurrent)
		bc.lines.push({...line, leaked: true})
		if (!visible || bc.newMessages || bc.newUnimportantMessages) {
			if (line.isUnimportant)
				bc.newUnimportantMessages++
			else
				bc.newMessages++
		}
	}

	if (line.isHighlight || (!visible && !line.isUnimportant &&
			b.kind === Relay.BufferKind.PrivateMessage)) {
		beep()
		if (!visible)
			b.highlighted = true
	}
})

rpcEventHandlers.set(Relay.Event.BufferClear, e => {
	let b = buffers.get(e.bufferName)
	if (b !== undefined)
		b.lines.length = 0
})

// ~~~ Server events ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

rpcEventHandlers.set(Relay.Event.ServerUpdate, e => {
	let s = servers.get(e.serverName)
	if (s === undefined)
		servers.set(e.serverName, (s = {}))
	s.state = e.state
})

rpcEventHandlers.set(Relay.Event.ServerRename, e => {
	servers.set(e.new, servers.get(e.serverName))
	servers.delete(e.serverName)
})

rpcEventHandlers.set(Relay.Event.ServerRemove, e => {
	servers.delete(e.serverName)
})

// --- Colours -----------------------------------------------------------------

let palette = [
	'#000', '#800', '#080', '#880', '#008', '#808', '#088', '#ccc',
	'#888', '#f00', '#0f0', '#ff0', '#00f', '#f0f', '#0ff', '#fff',
]
palette.length = 256
for (let i = 0; i < 216; i++) {
	let r = i / 36 >> 0, g = (i / 6 >> 0) % 6, b = i % 6
	r = !r ? '00' : (55 + 40 * r).toString(16)
	g = !g ? '00' : (55 + 40 * g).toString(16)
	b = !b ? '00' : (55 + 40 * b).toString(16)
	palette[16 + i] = `#${r}${g}${b}`
}
for (let i = 0; i < 24; i++) {
	let g = ('0' + (8 + i * 10).toString(16)).slice(-2)
	palette[232 + i] = `#${g}${g}${g}`
}

// ---- UI ---------------------------------------------------------------------

let linkRE = [
	/https?:\/\//,
	/([^\[\](){}<>"'\s]|\([^\[\](){}<>"'\s]*\))+/,
	/[^\[\](){}<>"'\s,.:]/,
].map(r => r.source).join('')

let Toolbar = {
	toggleAutoscroll: () => {
		bufferAutoscroll = !bufferAutoscroll
	},

	view: vnode => {
		return m('.toolbar', {}, [
			m('button', {onclick: Toolbar.toggleAutoscroll},
				bufferAutoscroll ? 'Scroll lock' : 'Scroll unlock'),
			m('button', {onclick: event => bufferToggleLog()},
				bufferLog === undefined ? 'Show log' : 'Hide log'),
		])
	},
}

let BufferList = {
	view: vnode => {
		let highlighted = false
		let items = Array.from(buffers, ([name, b]) => {
			let classes = [], displayName = name
			if (name == bufferCurrent) {
				classes.push('current')
			} else if (b.newMessages) {
				classes.push('activity')
				displayName += ` (${b.newMessages})`
			}
			if (b.highlighted) {
				classes.push('highlighted')
				highlighted = true
			}
			return m('.item', {
				onclick: event => bufferActivate(name),
				class: classes.join(' '),
			}, displayName)
		})

		updateIcon(rpc.ws === undefined ? null : highlighted)
		return m('.list', {}, items)
	},
}

let Content = {
	applyColor: (fg, bg, inverse) => {
		if (inverse)
			[fg, bg] = [bg >= 0 ? bg : 15, fg >= 0 ? fg : 0]

		let style = {}
		if (fg >= 0)
			style.color = palette[fg]
		if (bg >= 0)
			style.backgroundColor = palette[bg]
		if (style)
			return style
	},

	linkify: (text, attrs) => {
		let re = new RegExp(linkRE, 'g'), a = [], end = 0, match
		while ((match = re.exec(text)) !== null) {
			if (end < match.index)
				a.push(m('span', attrs, text.substring(end, match.index)))
			a.push(m('a[target=_blank]', {href: match[0], ...attrs}, match[0]))
			end = re.lastIndex
		}
		if (end < text.length)
			a.push(m('span', attrs, text.substring(end)))
		return a
	},

	makeMark: line => {
		switch (line.rendition) {
		case Relay.Rendition.Indent: return m('span.mark',        {}, '')
		case Relay.Rendition.Status: return m('span.mark',        {}, '–')
		case Relay.Rendition.Error:  return m('span.mark.error',  {}, '⚠')
		case Relay.Rendition.Join:   return m('span.mark.join',   {}, '→')
		case Relay.Rendition.Part:   return m('span.mark.part',   {}, '←')
		case Relay.Rendition.Action: return m('span.mark.action', {}, '✶')
		}
	},

	view: vnode => {
		let line = vnode.children[0]
		let classes = new Set()
		let flip = c => {
			if (classes.has(c))
				classes.delete(c)
			else
				classes.add(c)
		}

		let fg = -1, bg = -1, inverse = false
		return m('.content', vnode.attrs, [
			Content.makeMark(line),
			line.items.flatMap(item => {
				switch (item.kind) {
				case Relay.Item.Text:
					return Content.linkify(item.text, {
						class: Array.from(classes.keys()).join(' '),
						style: Content.applyColor(fg, bg, inverse),
					})
				case Relay.Item.Reset:
					classes.clear()
					fg = bg = -1
					inverse = false
					break
				case Relay.Item.FgColor:
					fg = item.color
					break
				case Relay.Item.BgColor:
					bg = item.color
					break
				case Relay.Item.FlipInverse:
					inverse = !inverse
					break
				case Relay.Item.FlipBold:
					flip('b')
					break
				case Relay.Item.FlipItalic:
					flip('i')
					break
				case Relay.Item.FlipUnderline:
					flip('u')
					break
				case Relay.Item.FlipCrossedOut:
					flip('s')
					break
				case Relay.Item.FlipMonospace:
					flip('m')
					break
				}
			}),
		])
	},
}

let Buffer = {
	controller: new AbortController(),

	onbeforeremove: vnode => {
		Buffer.controller.abort()
	},

	onupdate: vnode => {
		if (bufferAutoscroll)
			vnode.dom.scrollTop = vnode.dom.scrollHeight
	},

	oncreate: vnode => {
		Buffer.onupdate(vnode)
		window.addEventListener('resize', event => Buffer.onupdate(vnode),
			{signal: Buffer.controller.signal})
	},

	view: vnode => {
		let lines = []
		let b = buffers.get(bufferCurrent)
		if (b === undefined)
			return m('.buffer')

		let lastDateMark = undefined
		let squashing = false
		let markBefore = b.lines.length
			- b.newMessages - b.newUnimportantMessages
		b.lines.forEach((line, i) => {
			if (i == markBefore)
				lines.push(m('.unread'))

			if (!line.isUnimportant || !b.hideUnimportant) {
				squashing = false
			} else if (squashing) {
				return
			} else {
				squashing = true
			}

			let date = new Date(line.when)
			let dateMark = date.toLocaleDateString()
			if (dateMark !== lastDateMark) {
				lines.push(m('.date', {}, dateMark))
				lastDateMark = dateMark
			}
			if (squashing) {
				lines.push(m('.time.hidden'))
				lines.push(m('.content'))
				return
			}

			let attrs = {}
			if (line.leaked)
				attrs.class = 'leaked'

			lines.push(m('.time', {...attrs}, date.toLocaleTimeString()))
			lines.push(m(Content, {...attrs}, line))
		})

		let dateMark = new Date().toLocaleDateString()
		if (dateMark !== lastDateMark && lastDateMark !== undefined)
			lines.push(m('.date', {}, dateMark))
		return m('.buffer', {}, lines)
	},
}

let Log = {
	oncreate: vnode => {
		vnode.dom.scrollTop = vnode.dom.scrollHeight
		vnode.dom.focus()
	},

	linkify: text => {
		let re = new RegExp(linkRE, 'g'), a = [], end = 0, match
		while ((match = re.exec(text)) !== null) {
			if (end < match.index)
				a.push(text.substring(end, match.index))
			a.push(m('a[target=_blank]', {href: match[0]}, match[0]))
			end = re.lastIndex
		}
		if (end < text.length)
			a.push(text.substring(end))
		return a
	},

	view: vnode => {
		return m(".log", {}, Log.linkify(bufferLog))
	},
}

let Completions = {
	entries: [],

	reset: list => {
		Completions.entries = list || []
		m.redraw()
	},

	view: vnode => {
		if (!Completions.entries.length)
			return
		return m('.completions', {},
			Completions.entries.map(option => m('.completion', {}, option)))
	},
}

let BufferContainer = {
	view: vnode => {
		return m('.buffer-container', {}, [
			m('.filler'),
			bufferLog !== undefined ? m(Log) : m(Buffer),
			m(Completions),
		])
	},
}

let Status = {
	view: vnode => {
		let b = buffers.get(bufferCurrent)
		if (b === undefined)
			return m('.status', {}, 'Synchronizing...')

		let status = `${bufferCurrent}`
		if (b.hideUnimportant)
			status += `<H>`
		return m('.status', {}, status)
	},
}

let Prompt = {
	view: vnode => {
		// This should be handled differently, so don't mind the lookup.
		let b = buffers.get(bufferCurrent)
		if (b === undefined || b.server === undefined)
			return

		let state = b.server.state
		for (const s in Relay.ServerState)
			if (Relay.ServerState[s] == b.server.state) {
				state = s
				break
			}
		return m('.prompt', {}, `(${state})`)
	},
}

let Input = {
	counter: 0,
	stamp: textarea => {
		return [Input.counter,
			textarea.selectionStart, textarea.selectionEnd, textarea.value]
	},

	complete: (b, textarea) => {
		if (textarea.selectionStart !== textarea.selectionEnd)
			return false

		// Cancel any previous autocomplete, and ensure applicability.
		Input.counter++
		let state = Input.stamp(textarea)
		rpc.send({
			command: 'BufferComplete',
			bufferName: bufferCurrent,
			text: textarea.value,
			position: utf8Encode(
				textarea.value.slice(0, textarea.selectionEnd)).length,
		}).then(resp => {
			if (!Input.stamp(textarea).every((v, k) => v === state[k]))
				return

			let preceding = utf8Encode(textarea.value).slice(0, resp.start)
			let start = utf8Decode(preceding).length
			if (resp.completions.length > 0) {
				textarea.setRangeText(resp.completions[0],
					start, textarea.selectionEnd, 'end')
			}

			if (resp.completions.length == 1) {
				textarea.setRangeText(' ',
					textarea.selectionStart, textarea.selectionEnd, 'end')
			} else {
				beep()
			}

			if (resp.completions.length > 1)
				Completions.reset(resp.completions.slice(1))
		})
		return true
	},

	submit: (b, textarea) => {
		rpc.send({
			command: 'BufferInput',
			bufferName: bufferCurrent,
			text: textarea.value,
		})

		// b.history[b.history.length] is virtual, and is represented
		// either by textarea contents when it's currently being edited,
		// or by b.input in all other cases.
		b.history.push(textarea.value)
		b.historyAt = b.history.length
		textarea.value = ''
		return true
	},

	backward: textarea => {
		if (textarea.selectionStart !== textarea.selectionEnd)
			return false

		let point = textarea.selectionStart
		if (point < 1)
			return false
		while (point && /\s/.test(textarea.value.charAt(--point))) {}
		while (point-- && !/\s/.test(textarea.value.charAt(point))) {}
		point++
		textarea.setSelectionRange(point, point)
		return true
	},

	forward: textarea => {
		if (textarea.selectionStart !== textarea.selectionEnd)
			return false

		let point = textarea.selectionStart, len = textarea.value.length
		if (point + 1 > len)
			return false
		while (point < len && /\s/.test(textarea.value.charAt(point))) point++
		while (point < len && !/\s/.test(textarea.value.charAt(point))) point++
		textarea.setSelectionRange(point, point)
		return true
	},

	modifyWord: (textarea, cb) => {
		let start = textarea.selectionStart
		let end = textarea.selectionEnd
		if (start === end) {
			let len = textarea.value.length
			while (start < len && /\s/.test(textarea.value.charAt(start)))
				start++;
			end = start
			while (end < len && !/\s/.test(textarea.value.charAt(end)))
				end++;
		}
		if (start === end)
			return false

		const text = textarea.value, modified = cb(text.substring(start, end))
		textarea.value = text.slice(0, start) + modified + text.slice(end)
		end = start + modified.length
		textarea.setSelectionRange(end, end)
		return true
	},

	downcase: textarea => {
		return Input.modifyWord(textarea, text => text.toLowerCase())
	},

	upcase: textarea => {
		return Input.modifyWord(textarea, text => text.toUpperCase())
	},

	capitalize: textarea => {
		return Input.modifyWord(textarea, text => {
			const cps = Array.from(text.toLowerCase())
			return cps[0].toUpperCase() + cps.slice(1).join('')
		})
	},

	first: (b, textarea) => {
		if (b.historyAt <= 0)
			return false

		if (b.historyAt == b.history.length)
			b.input = textarea.value
		textarea.value = b.history[(b.historyAt = 0)]
		return true
	},

	last: (b, textarea) => {
		if (b.historyAt >= b.history.length)
			return false

		b.historyAt = b.history.length
		textarea.value = b.input
		return true
	},

	previous: (b, textarea) => {
		if (b.historyAt <= 0)
			return false

		if (b.historyAt == b.history.length)
			b.input = textarea.value
		textarea.value = b.history[--b.historyAt]
		return true
	},

	next: (b, textarea) => {
		if (b.historyAt >= b.history.length)
			return false

		if (++b.historyAt == b.history.length)
			textarea.value = b.input
		else
			textarea.value = b.history[b.historyAt]
		return true
	},

	onKeyDown: event => {
		// TODO: And perhaps on other actions, too.
		rpc.send({command: 'Active'})

		let b = buffers.get(bufferCurrent)
		if (b === undefined)
			return

		let textarea = event.currentTarget
		let handled = false
		let success = true
		if (hasShortcutModifiers(event)) {
			handled = true
			switch (event.key) {
			case 'b': success = Input.backward(textarea);    break
			case 'f': success = Input.forward(textarea);     break
			case 'l': success = Input.downcase(textarea);    break
			case 'u': success = Input.upcase(textarea);      break
			case 'c': success = Input.capitalize(textarea);  break
			case '<': success = Input.first(b, textarea);    break
			case '>': success = Input.last(b, textarea);     break
			case 'p': success = Input.previous(b, textarea); break
			case 'n': success = Input.next(b, textarea);     break
			default:  handled = false
			}
		} else if (!event.altKey && !event.ctrlKey && !event.metaKey &&
				!event.shiftKey) {
			handled = true
			switch (event.keyCode) {
			case 9:   success = Input.complete(b, textarea); break
			case 13:  success = Input.submit(b, textarea);   break
			default:  handled = false
			}
		}
		if (!success)
			beep()
		if (handled)
			event.preventDefault()
	},

	view: vnode => {
		return m('textarea#input', {
			rows: 1,
			onkeydown: Input.onKeyDown,
			oninput: event => Completions.reset(),
			// Sadly only supported in Firefox as of writing.
			onselectionchange: event => Completions.reset(),
			// The list of completions is scrollable without receiving focus.
			onblur: event => Completions.reset(),
		})
	},
}

let Main = {
	view: vnode => {
		let overlay = undefined
		if (connecting)
			overlay = m('.overlay', {}, "Connecting...")
		else if (rpc.ws === undefined)
			overlay = m('.overlay', {}, [
				m('', {}, "Disconnected"),
				m('', {}, m('small', {}, "Reload page to reconnect.")),
			])

		return m('.xP', {}, [
			overlay,
			m('.title', {}, [`xP`, m(Toolbar)]),
			m('.middle', {}, [m(BufferList), m(BufferContainer)]),
			m(Status),
			m('.input', {}, [m(Prompt), m(Input)]),
		])
	},
}

window.addEventListener('load', () => m.mount(document.body, Main))

document.addEventListener('visibilitychange', event => {
	let b = buffers.get(bufferCurrent)
	if (b !== undefined && document.visibilityState !== 'hidden') {
		b.highlighted = false
		m.redraw()
	}
})

// On macOS, the Alt/Option key transforms characters, which basically breaks
// all event.altKey shortcuts, so implement Escape prefixing on that system.
// This method of detection only works with Blink browsers, as of writing.
let lastWasEscape = false
document.addEventListener('keydown', event => {
	event.escapePrefix = lastWasEscape
	if (lastWasEscape) {
		lastWasEscape = false
	} else if (event.code == 'Escape' &&
			navigator.userAgentData?.platform === 'macOS') {
		event.preventDefault()
		event.stopPropagation()
		lastWasEscape = true
		return
	}

	if (rpc.ws == undefined || !hasShortcutModifiers(event))
		return

	// Rotate names so that the current buffer comes first.
	let names = [...buffers.keys()]
	names.push.apply(names,
		names.splice(0, names.findIndex(name => name == bufferCurrent)))

	switch (event.key) {
	case 'h':
		bufferToggleLog()
		break
	case 'H':
		if (bufferCurrent !== undefined)
			bufferToggleUnimportant(bufferCurrent)
		break
	case 'a':
		for (const name of names.slice(1))
			if (buffers.get(name).newMessages) {
				bufferActivate(name)
				break
			}
		break
	case '!':
		for (const name of names.slice(1))
			if (buffers.get(name).highlighted) {
				bufferActivate(name)
				break
			}
		break
	case 'Tab':
		if (bufferLast !== undefined)
			bufferActivate(bufferLast)
		break
	case 'PageUp':
		if (names.length > 1)
			bufferActivate(names.at(-1))
		break
	case 'PageDown':
		if (names.length > 1)
			bufferActivate(names.at(+1))
		break
	default:
		return
	}

	event.preventDefault()
}, true)