/*
 * main.swift: Cocoa frontend for xC
 *
 * This is in effect a port of xW.cpp to macOS frameworks.
 *
 * Copyright (c) 2023, Přemysl Eric Janouch <p@janouch.name>
 *
 * Permission to use, copy, modify, and/or distribute this software for any
 * purpose with or without fee is hereby granted.
 *
 * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
 * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
 * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY
 * SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
 * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION
 * OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN
 * CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
 *
 */

import AppKit
import Network
import os

let projectName = "xM"

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

class RelayRPC {
	var connection: NWConnection?

	// Callbacks:

	var onConnected: (() -> Void)?
	var onFailed: ((String) -> Void)?
	var onEvent: ((RelayEventMessage) -> Void)?

	// Command processing:

	typealias Callback = (String, RelayResponseData?) -> ()

	/// Outgoing message counter
	var commandSeq: UInt32 = 0
	/// Callbacks for messages processed by the server
	var commandCallbacks = Dictionary<UInt32, Callback>()

	func resetState() {
		self.connection = nil

		// TODO(p): Consider cancelling all of them from here.
		self.commandCallbacks.removeAll()
	}

	func connect(host: String, port: String) -> Bool {
		let nwHost = NWEndpoint.Host(host)
		let nwPort = NWEndpoint.Port(port)
		if nwPort == nil {
			return false
		}

		// TODO(p): Consider how to behave after failure.
		self.resetState()

		self.connection = NWConnection(host: nwHost, port: nwPort!, using: .tcp)
		self.connection!.stateUpdateHandler = onStateChange(to:)
		self.receiveFrame()
		// We directly update the UI from callbacks, avoid threading.
		self.connection!.start(queue: .main)
		return true
	}

	func fail(message: String) {
		// We cannot pass a message along with cancellation state transition.
		self.onFailed?(message)
		self.connection!.cancel()
	}

	func onStateChange(to: NWConnection.State) {
		switch to {
		case .waiting(let error):
			// This is most likely fatal, despite what the documentation says.
			self.fail(message: error.localizedDescription)
		case .ready:
			self.onConnected?()
		case .failed(let error):
			self.onFailed?(error.localizedDescription)
		default:
			return
		}
	}

	func processCallbacks(
			commandSeq: UInt32, error: String, response: RelayResponseData?) {
		if let callback = self.commandCallbacks[commandSeq] {
			callback(error, response)
		} else if !error.isEmpty {
			Logger().warning("Unawaited response: \(error)")
		} else {
			Logger().warning("Unawaited response")
		}

		self.commandCallbacks.removeValue(forKey: commandSeq)

		// We don't particularly care about wraparound issues.
		for (seq, callback) in self.commandCallbacks where seq < commandSeq {
			callback("No response.", nil)
			self.commandCallbacks.removeValue(forKey: seq)
		}
	}

	func process(message: RelayEventMessage) {
		switch message.data {
		case let data as RelayEventDataError:
			self.processCallbacks(
				commandSeq: data.commandSeq, error: data.error, response: nil)
		case let data as RelayEventDataResponse:
			self.processCallbacks(
				commandSeq: data.commandSeq, error: "", response: data.data)
		default:
			self.onEvent?(message)
		}
	}

	func receiveMessage(length: Int) {
		self.connection!.receive(
			minimumIncompleteLength: length, maximumLength: length) {
			(content, context, isComplete, error) in
			guard let content = content else {
				// TODO(p): Do we need to bring it down explicitly on error?
				if isComplete {
					self.fail(message: "Connection closed.")
				}
				return
			}

			var r = RelayReader(data: content)
			do {
				self.process(message: try RelayEventMessage(from: &r))
				self.receiveFrame()
			} catch {
				self.fail(message: "Deserialization failed.")
			}
		}
	}

	func receiveFrame() {
		let length = MemoryLayout<UInt32>.size
		self.connection!.receive(
			minimumIncompleteLength: length, maximumLength: length) {
			(content, context, isComplete, error) in
			guard let content = content else {
				// TODO(p): Do we need to bring it down explicitly on error?
				if isComplete {
					self.fail(message: "Connection closed.")
				}
				return
			}

			var r = RelayReader(data: content)
			do {
				let len: UInt32 = try r.read()
				if let len = Int(exactly: len) {
					self.receiveMessage(length: len)
				} else {
					self.fail(message: "Frame length overflow.")
				}
			} catch {
				self.fail(message: "Deserialization failed.")
			}
		}
	}

	func send(data: RelayCommandData, callback: Callback? = nil) {
		self.commandSeq += 1
		let m = RelayCommandMessage(commandSeq: self.commandSeq, data: data)
		if let callback = callback {
			self.commandCallbacks[m.commandSeq] = callback
		}

		var w = RelayWriter()
		m.encode(to: &w)
		var prefix = RelayWriter()
		prefix.append(UInt32(w.data.count))

		self.connection!.batch() {
			self.connection!.send(content: prefix.data,
				completion: .contentProcessed({ error in }))
			self.connection!.send(content: w.data,
				completion: .contentProcessed({ error in }))
		}
	}
}

// --- State -------------------------------------------------------------------

class Server {
	var state: RelayServerState = .disconnected
	var user: String = ""
	var userModes: String = ""
}

struct BufferLine {
	var leaked: Bool = false

	var isUnimportant: Bool = false
	var isHighlight: Bool = false
	var rendition: RelayRendition = .bare
	var when: UInt64 = 0
	var text = NSAttributedString()
}

class Buffer {
	var bufferName: String = ""
	var hideUnimportant: Bool = false
	var kind: RelayBufferKind = .global
	var serverName: String = ""
	var lines: Array<BufferLine> = []

	// Channel:

	var topic = NSAttributedString()
	var modes: String = ""

	// Stats:

	var newMessages: UInt32 = 0
	var newUnimportantMessages: UInt32 = 0
	var highlighted: Bool = false

	// Input:

	var input: String = ""
	var inputSelection: NSRange? = nil
	var history: Array<String> = []
	var historyAt: Int = 0
}

var relayRPC = RelayRPC()

var relayBuffers: Array<Buffer> = []
var relayBufferCurrent: String = ""
var relayBufferLast: String = ""

var relayServers: Dictionary<String, Server> = [:]

// --- Buffers -----------------------------------------------------------------

func bufferBy(name: String) -> Buffer? {
	return relayBuffers.first(where: { b in b.bufferName == name })
}

func bufferActivate(name: String) {
	let activate = RelayCommandDataBufferActivate(bufferName: name)
	relayRPC.send(data: activate)
}

func bufferToggleUnimportant(name: String) {
	let toggle = RelayCommandDataBufferToggleUnimportant(bufferName: name)
	relayRPC.send(data: toggle)
}

// --- GUI ---------------------------------------------------------------------

let app = NSApplication.shared

let menu = NSMenu()
app.mainMenu = menu

let applicationMenuItem = NSMenuItem()
menu.addItem(applicationMenuItem)
let applicationMenu = NSMenu()
applicationMenuItem.submenu = applicationMenu
applicationMenu.addItem(NSMenuItem(title: "Quit " + projectName,
	action: #selector(app.terminate), keyEquivalent: "q"))

let bufferMenuItem = NSMenuItem()
menu.addItem(bufferMenuItem)
let bufferMenu = NSMenu(title: "Buffer")
bufferMenuItem.submenu = bufferMenu

let uiStackView = NSStackView()
uiStackView.orientation = .vertical
uiStackView.spacing = 0
uiStackView.alignment = .leading

func pushWithMargins(view: NSView) {
	let box = NSStackView()
	box.orientation = .horizontal
	box.edgeInsets = NSEdgeInsetsMake(4, 8, 4, 8)
	box.addArrangedSubview(view)
	uiStackView.addArrangedSubview(box)
}

// TODO(p): Consider replacing with NSTextView,
// to avoid font changes when selected.
let uiTopic = NSTextField(wrappingLabelWithString: "")
uiTopic.isEditable = false
uiTopic.isBezeled = false
uiTopic.drawsBackground = false
// Otherwise clicking it removes string attributes.
uiTopic.allowsEditingTextAttributes = true
pushWithMargins(view: uiTopic)

let uiSeparator1 = NSBox()
uiSeparator1.boxType = .separator
uiStackView.addArrangedSubview(uiSeparator1)

let uiBufferList = NSTableView()
uiBufferList.addTableColumn(
	NSTableColumn(identifier: NSUserInterfaceItemIdentifier("Buffer name")))
uiBufferList.headerView = nil
uiBufferList.style = .plain
uiBufferList.rowSizeStyle = .default
uiBufferList.allowsEmptySelection = false

let uiBufferListScroll = NSScrollView()
uiBufferListScroll.setFrameSize(CGSize(width: 1, height: 1))
uiBufferListScroll.documentView = uiBufferList
uiBufferListScroll.hasVerticalScroller = true
uiBufferListScroll.verticalScroller?.refusesFirstResponder = true

let uiBuffer = NSTextView()
uiBuffer.isEditable = false
uiBuffer.focusRingType = .default
// Otherwise it skips a lot when scrolling.
uiBuffer.layoutManager?.allowsNonContiguousLayout = false
uiBuffer.autoresizingMask = [.width, .height]

let uiBufferScroll = NSScrollView()
uiBufferScroll.setFrameSize(CGSize(width: 2, height: 1))
uiBufferScroll.documentView = uiBuffer
uiBufferScroll.hasVerticalScroller = true
uiBufferScroll.verticalScroller?.refusesFirstResponder = true

let uiSplitView = NSSplitView()
uiSplitView.isVertical = true
uiSplitView.dividerStyle = .thin
uiSplitView.addArrangedSubview(uiBufferListScroll)
uiSplitView.addArrangedSubview(uiBufferScroll)
uiStackView.addArrangedSubview(uiSplitView)

NSLayoutConstraint.activate([
	uiSplitView.leadingAnchor.constraint(
		equalTo: uiStackView.leadingAnchor),
	uiSplitView.trailingAnchor.constraint(
		equalTo: uiStackView.trailingAnchor),
])

let uiSeparator2 = NSBox()
uiSeparator2.boxType = .separator
uiStackView.addArrangedSubview(uiSeparator2)

let uiStatus = NSTextField()
uiStatus.isEditable = false
uiStatus.isBezeled = false
uiStatus.drawsBackground = false
uiStatus.stringValue = "Connecting..."
pushWithMargins(view: uiStatus)

let uiSeparator3 = NSBox()
uiSeparator3.boxType = .separator
uiStackView.addArrangedSubview(uiSeparator3)

let uiBottomStackView = NSStackView()
uiBottomStackView.orientation = .horizontal
pushWithMargins(view: uiBottomStackView)

let uiPrompt = NSTextField()
uiPrompt.isEditable = false
uiPrompt.isBezeled = false
uiPrompt.drawsBackground = false
uiPrompt.isHidden = true
uiBottomStackView.addArrangedSubview(uiPrompt)

let uiInput = NSTextField()
uiBottomStackView.addArrangedSubview(uiInput)

let uiWindow = NSWindow(
	contentRect: NSMakeRect(0, 0, 640, 480),
	styleMask: [.titled, .closable, .resizable],
	backing: .buffered, defer: true)
uiWindow.title = projectName
uiWindow.contentView = uiStackView

// --- Current buffer ----------------------------------------------------------

func bufferAtBottom() -> Bool {
	// TODO(p): Actually implement.
	// Consider uiBufferScroll.verticalScroller.floatValue.
	return true
}

func bufferScrollToBottom() {
	uiBuffer.scrollToEndOfDocument(nil)
}

// --- UI state refresh --------------------------------------------------------

func refreshIcon() {
	// TODO(p): Perhaps adjust NSApplication.applicationIconImage.
}

func refreshTopic(topic: NSAttributedString) {
	uiTopic.attributedStringValue = topic
}

func refreshBufferList() {
	uiBufferList.reloadData()
}

func serverStateToString(state: RelayServerState) -> String {
	switch state {
	case .disconnected:
		return "disconnected"
	case .connecting:
		return "connecting"
	case .connected:
		return "connected"
	case .registered:
		return "registered"
	case .disconnecting:
		return "disconnecting"
	}
}

func refreshPrompt() {
	var prompt = String()
	if let b = bufferBy(name: relayBufferCurrent) {
		if let server = relayServers[b.serverName] {
			prompt = server.user
			if !server.userModes.isEmpty {
				prompt += "(\(server.userModes))"
			}
			if prompt.isEmpty {
				prompt = "(\(serverStateToString(state: server.state)))"
			}
		}
	}

	if prompt.isEmpty {
		uiPrompt.isHidden = true
	} else {
		uiPrompt.isHidden = false
		uiPrompt.stringValue = prompt
	}
}

func refreshStatus() {
	var status = relayBufferCurrent
	if let b = bufferBy(name: relayBufferCurrent) {
		if !b.modes.isEmpty {
			status += "(+\(b.modes))"
		}
		if b.hideUnimportant {
			status += "<H>"
		}
	} else {
		status = "Synchronizing..."
	}

	// XXX: This indicator should probably be on the right side of the window.
	if !bufferAtBottom() {
		status += " ^"
	}

	uiStatus.stringValue = status
}

// --- Buffer output -----------------------------------------------------------

func convertColor(color: Int16) -> NSColor {
	let base16: [UInt16] = [
		0x000, 0x800, 0x080, 0x880, 0x008, 0x808, 0x088, 0xccc,
		0x888, 0xf00, 0x0f0, 0xff0, 0x00f, 0xf0f, 0x0ff, 0xfff,
	]
	if color < 16 {
		let r = CGFloat(0xf & (base16[Int(color)] >> 8)) / 0xf
		let g = CGFloat(0xf & (base16[Int(color)] >> 4)) / 0xf
		let b = CGFloat(0xf & (base16[Int(color)] >> 0)) / 0xf
		return NSColor(red: r, green: g, blue: b, alpha: 1)
	}
	if color >= 216 {
		let g = CGFloat(8 + (color - 216) * 10) / 0xff
		return NSColor(white: g, alpha: 1)
	}

	let i = color - 16
	let r = (i / 36)
	let g = (i / 6) % 6
	let b = (i % 6)
	let rr = r > 0 ? CGFloat(55 + 40 * r) / 0xff : 0
	let gg = g > 0 ? CGFloat(55 + 40 * g) / 0xff : 0
	let bb = b > 0 ? CGFloat(55 + 40 * b) / 0xff : 0
	return NSColor(red: rr, green: gg, blue: bb, alpha: 1)
}

func convertItemFormatting(item: RelayItemData,
		attrs: inout [NSAttributedString.Key : Any], inverse: inout Bool) {
	switch item {
	case is RelayItemDataReset:
		attrs.removeAll()
		inverse = false
	case is RelayItemDataFlipBold:
		// TODO(p): Need to select a font: applyFontTraits(_:range:)?
		break
	case is RelayItemDataFlipItalic:
		// TODO(p): Need to select a font: applyFontTraits(_:range:)?
		break
	case is RelayItemDataFlipUnderline:
		if attrs[.underlineStyle] != nil {
			attrs.removeValue(forKey: .underlineStyle)
		} else {
			attrs[.underlineStyle] = NSUnderlineStyle.single.rawValue
		}
	case is RelayItemDataFlipCrossedOut:
		if attrs[.strikethroughStyle] != nil {
			attrs.removeValue(forKey: .strikethroughStyle)
		} else {
			attrs[.strikethroughStyle] = NSUnderlineStyle.single.rawValue
		}
	case is RelayItemDataFlipInverse:
		inverse = !inverse
	case is RelayItemDataFlipMonospace:
		// TODO(p): Need to select a font: applyFontTraits(_:range:)?
		break
	case let data as RelayItemDataFgColor:
		if data.color < 0 {
			attrs.removeValue(forKey: .foregroundColor)
		} else {
			attrs[.foregroundColor] = convertColor(color: data.color)
		}
	case let data as RelayItemDataBgColor:
		if data.color < 0 {
			attrs.removeValue(forKey: .backgroundColor)
		} else {
			attrs[.backgroundColor] = convertColor(color: data.color)
		}
	default:
		return
	}
}

func convertItems(items: [RelayItemData]) -> NSAttributedString {
	let result = NSMutableAttributedString()
	var attrs = [NSAttributedString.Key : Any]()
	var inverse = false
	for item in items {
		guard let text = item as? RelayItemDataText else {
			convertItemFormatting(item: item, attrs: &attrs, inverse: &inverse)
			continue
		}

		// TODO(p): Handle inverse text.
		result.append(NSAttributedString(string: text.text, attributes: attrs))
	}
	if let detector = try? NSDataDetector(types:
		NSTextCheckingResult.CheckingType.link.rawValue) {
		for m in detector.matches(
			in: result.string, range: NSMakeRange(0, result.length)) {
			guard let url = m.url else {
				continue
			}
			let raw = (result.string as NSString).substring(with: m.range)
			if raw.contains("://") {
				result.addAttribute(.link, value: url, range: m.range)
			}
		}
	}
	return result
}

func convertBufferLine(line: RelayEventDataBufferLine) -> BufferLine {
	var bl = BufferLine()
	bl.isUnimportant = line.isUnimportant
	bl.isHighlight = line.isHighlight
	bl.rendition = line.rendition
	bl.when = line.when
	bl.text = convertItems(items: line.items)
	return bl
}

func bufferPrintLine(line: BufferLine) {
	guard let ts = uiBuffer.textStorage else {
		return
	}

	let current = Date(timeIntervalSince1970: Double(line.when / 1000))

	// TODO(p): Print date changes.
	if ts.length != 0 {
		ts.append(NSAttributedString(string: "\n"))
	}

	let formatter = DateFormatter()
	formatter.dateFormat = "HH:mm:ss"
	ts.append(NSAttributedString(
		string: formatter.string(from: current),
		attributes: [
			.foregroundColor: NSColor(white: 0xbb / 0xff, alpha: 1),
			.backgroundColor: NSColor(white: 0xf8 / 0xff, alpha: 1),
			.font: NSFont.monospacedDigitSystemFont(
				ofSize: uiBuffer.font!.pointSize, weight: .regular)
		]))

	ts.append(NSAttributedString(string: " "))

	let prefix = NSMutableAttributedString()
	var foreground: NSColor? = nil
	switch line.rendition {
	case .bare:
		break
	case .indent:
		prefix.mutableString.append("    ")
	case .status:
		prefix.mutableString.append(" -  ")
	case .error:
		prefix.mutableString.append("=!= ")
		foreground = NSColor.red
	case .join:
		prefix.mutableString.append("--> ")
		foreground = NSColor(red: 0, green: 0.5, blue: 0, alpha: 1)
	case .part:
		prefix.mutableString.append("<-- ")
		foreground = NSColor(red: 0.5, green: 0, blue: 0, alpha: 1)
	case .action:
		prefix.mutableString.append(" *  ")
		foreground = NSColor(red: 0.5, green: 0, blue: 0, alpha: 1)
	}
	if let color = foreground {
		prefix.addAttribute(.foregroundColor, value: color,
			range: NSMakeRange(0, prefix.length))
	}

	// FIXME: Fixed pitch doesn't actually work.
	prefix.applyFontTraits(.boldFontMask,
		range: NSMakeRange(0, prefix.length))
	prefix.applyFontTraits(.fixedPitchFontMask,
		range: NSMakeRange(0, prefix.length))

	if line.leaked {
		let deattributed = NSMutableAttributedString(attributedString: prefix)
		deattributed.append(line.text)
		let whole = NSMakeRange(0, deattributed.length)
		deattributed.removeAttribute(.backgroundColor, range: whole)
		deattributed.addAttributes(
			[.foregroundColor: NSColor(white: 0.5, alpha: 1)], range: whole)
		ts.append(deattributed)
	} else {
		ts.append(prefix)
		ts.append(line.text)
	}
}

func bufferPrintSeparator() {
	guard let ts = uiBuffer.textStorage else {
		return
	}
	if ts.length != 0 {
		ts.append(NSAttributedString(string: "\n"))
	}

	// TODO(p): Figure out if we can't add an actual horizontal line.
	ts.append(NSAttributedString(
		string: "---",
		attributes: [.foregroundColor: NSColor.controlAccentColor]))
}

func refreshBuffer(b: Buffer) {
	// TODO(p): See if we can pause updating.
	// If not, consider updating textStorage atomically.
	if let ts = uiBuffer.textStorage {
		ts.setAttributedString(NSAttributedString())
	}

	var i: Int = 0
	let markBefore = b.lines.count
		- Int(b.newMessages) - Int(b.newUnimportantMessages)
	for line in b.lines {
		if i == markBefore {
			bufferPrintSeparator()
		}
		if !line.isUnimportant || !b.hideUnimportant {
			bufferPrintLine(line: line)
		}

		i += 1
	}

	// TODO(p): Output any trailing date change.
	// FIXME: When the topic wraps, this doesn't scroll correctly.
	bufferScrollToBottom()
}

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

relayRPC.onConnected = {
	let hello = RelayCommandDataHello(version: UInt32(relayVersion))
	relayRPC.send(data: hello)
}

relayRPC.onFailed = { error in
	let alert = NSAlert()
	alert.messageText = "Relay connection failed"
	alert.informativeText = error
	alert.addButton(withTitle: "OK")
	alert.runModal()
	exit(EXIT_FAILURE)
}

func onBufferLine(b: Buffer, m: RelayEventDataBufferLine) {
	// Initial sync: skip all other processing, let highlights be.
	guard let bc = bufferBy(name: relayBufferCurrent) else {
		b.lines.append(convertBufferLine(line: m))
		return
	}

	let display = (!m.isUnimportant || !bc.hideUnimportant) &&
		(b.bufferName == relayBufferCurrent || m.leakToActive)
	let toBottom = display &&
		bufferAtBottom()
	// TODO(p): Once the log view is implemented, replace the "true" here.
	let visible = display &&
		toBottom &&
		!uiWindow.isMiniaturized &&
		true
	let separate = display &&
		!visible && bc.newMessages == 0 && bc.newUnimportantMessages == 0

	var line = convertBufferLine(line: m)
	if !(visible || m.leakToActive) ||
		b.newMessages != 0 || b.newUnimportantMessages != 0 {
		if line.isUnimportant || m.leakToActive {
			b.newUnimportantMessages += 1
		} else {
			b.newMessages += 1
		}
	}

	b.lines.append(line)
	if m.leakToActive {
		line.leaked = true
		bc.lines.append(line)
		if !visible || bc.newMessages != 0 || bc.newUnimportantMessages != 0 {
			if line.isUnimportant {
				bc.newUnimportantMessages += 1
			} else {
				bc.newMessages += 1
			}
		}
	}

	if separate {
		bufferPrintSeparator()
	}
	if display {
		bufferPrintLine(line: line)
	}
	if toBottom {
		bufferScrollToBottom()
	}

	if line.isHighlight || (!visible && !line.isUnimportant &&
		b.kind == .privateMessage) {
		NSSound.beep()

		if !visible {
			b.highlighted = true
			refreshIcon()
		}
	}

	refreshBufferList()
}

relayRPC.onEvent = { message in
	Logger().debug("Processing message \(message.eventSeq)")

	switch message.data {
	case _ as RelayEventDataPing:
		let pong = RelayCommandDataPingResponse(eventSeq: message.eventSeq)
		relayRPC.send(data: pong)

	case let data as RelayEventDataBufferLine:
		guard let b = bufferBy(name: data.bufferName) else {
			break
		}

		onBufferLine(b: b, m: data)

	case let data as RelayEventDataBufferUpdate:
		let b: Buffer
		if let buffer = bufferBy(name: data.bufferName) {
			b = buffer
		} else {
			b = Buffer()
			b.bufferName = data.bufferName
			relayBuffers.append(b)
			refreshBufferList()
		}

		let hidingToggled = b.hideUnimportant != data.hideUnimportant
		b.hideUnimportant = data.hideUnimportant
		b.kind = data.context.kind
		b.serverName.removeAll()
		switch data.context {
		case let context as RelayBufferContextServer:
			b.serverName = context.serverName
		case let context as RelayBufferContextChannel:
			b.serverName = context.serverName
			b.modes = context.modes
			b.topic = convertItems(items: context.topic)
		case let context as RelayBufferContextPrivateMessage:
			b.serverName = context.serverName
		default:
			break
		}

		if b.bufferName == relayBufferCurrent {
			refreshTopic(topic: b.topic)
			refreshStatus()

			if hidingToggled {
				refreshBuffer(b: b)
			}
		}

	case let data as RelayEventDataBufferStats:
		guard let b = bufferBy(name: data.bufferName) else {
			break
		}

		b.newMessages = data.newMessages
		b.newUnimportantMessages = data.newUnimportantMessages
		b.highlighted = data.highlighted

		refreshIcon()

	case let data as RelayEventDataBufferRename:
		guard let b = bufferBy(name: data.bufferName) else {
			break
		}

		b.bufferName = data.new

		if b.bufferName == relayBufferCurrent {
			relayBufferCurrent = data.new
			refreshStatus()
		}
		refreshBufferList()
		if b.bufferName == relayBufferLast {
			relayBufferLast = data.new
		}

	case let data as RelayEventDataBufferRemove:
		guard let b = bufferBy(name: data.bufferName) else {
			break
		}

		relayBuffers.removeAll(where: { $0 === b })
		refreshBufferList()

		refreshIcon()

	case let data as RelayEventDataBufferActivate:
		let old = bufferBy(name: relayBufferCurrent)
		relayBufferLast = relayBufferCurrent
		relayBufferCurrent = data.bufferName
		guard let b = bufferBy(name: data.bufferName) else {
			break
		}

		if let old = old {
			old.newMessages = 0
			old.newUnimportantMessages = 0
			old.highlighted = false

			old.input = uiInput.stringValue
			// As in the textShouldBeginEditing delegate method.
			if let editor = uiInput.currentEditor() {
				old.inputSelection = editor.selectedRange
			}

			old.historyAt = old.history.count
		}

		// TODO(p): Disable log display, once implemented.
		if !uiWindow.isMiniaturized {
			b.highlighted = false
		}

		if let i = relayBuffers.firstIndex(where: { $0 === b }) {
			uiBufferList.selectRowIndexes(
				IndexSet(integer: i), byExtendingSelection: false)
		}

		refreshIcon()
		refreshTopic(topic: b.topic)
		refreshBuffer(b: b)
		refreshPrompt()
		refreshStatus()

		uiInput.stringValue = b.input
		uiWindow.makeFirstResponder(uiInput)
		if let editor = uiInput.currentEditor() {
			// As in the textShouldEndEditing delegate method.
			if let selection = b.inputSelection {
				editor.selectedRange = selection
			} else {
				editor.selectedRange = NSMakeRange(editor.string.count, 0)
			}
		}

	case let data as RelayEventDataBufferInput:
		guard let b = bufferBy(name: data.bufferName) else {
			break
		}
		if b.historyAt == b.history.count {
			b.historyAt += 1
		}

		b.history.append(data.text)

	case let data as RelayEventDataBufferClear:
		guard let b = bufferBy(name: data.bufferName) else {
			break
		}

		b.lines.removeAll()
		if b.bufferName == relayBufferCurrent {
			refreshBuffer(b: b)
		}

	case let data as RelayEventDataServerUpdate:
		let s: Server
		if let server = relayServers[data.serverName] {
			s = server
		} else {
			s = Server()
			relayServers[data.serverName] = s
		}

		s.state = data.data.state
		s.user.removeAll()
		s.userModes.removeAll()
		if let registered = data.data as? RelayServerDataRegistered {
			s.user = registered.user
			s.userModes = registered.userModes
		}

		refreshPrompt()

	case let data as RelayEventDataServerRename:
		relayServers[data.new] = relayServers[data.serverName]
		relayServers.removeValue(forKey: data.serverName)

	case let data as RelayEventDataServerRemove:
		relayServers.removeValue(forKey: data.serverName)

	default:
		return
	}
}

// --- Input line --------------------------------------------------------------

struct InputStamp: Equatable {
	let input: String
	let selection: NSRange

	init?() {
		guard let textView = uiInput.currentEditor() as? NSTextView else {
			return nil
		}

		self.input = textView.string
		self.selection = textView.selectedRange()
	}
}

class InputDelegate: NSObject, NSTextFieldDelegate {
	func inputSubmit() -> Bool {
		guard let b = bufferBy(name: relayBufferCurrent) else {
			return false
		}

		let input = RelayCommandDataBufferInput(
			bufferName: b.bufferName, text: uiInput.stringValue)

		// Buffer.history[Buffer.history.count] is virtual,
		// and is represented either by edit contents when it's currently
		// being edited, or by Buffer.input in all other cases.
		b.history.append(uiInput.stringValue)
		b.historyAt = b.history.count
		uiInput.stringValue = ""

		relayRPC.send(data: input)
		return true
	}

	func inputComplete(state: InputStamp,
			error: String, data: RelayResponseDataBufferComplete?) {
		guard let data = data else {
			NSSound.beep()
			Logger().warning("\(error)")
			return
		}

		guard let textView = uiInput.currentEditor() as? NSTextView else {
			return
		}
		guard let preceding =
				String(state.input.utf8.prefix(Int(data.start))) else {
			return
		}

		if var insert = data.completions.first {
			if data.completions.count == 1 {
				insert += " "
			}

			textView.insertText(insert, replacementRange: NSMakeRange(
				preceding.count, NSMaxRange(state.selection) - preceding.count))
		}

		if data.completions.count != 1 {
			NSSound.beep()
		}

		// TODO(p): Show all completion options.
		// Cocoa text completion isn't useful, because it searches for word
		// boundaries on its own (otherwise see NSControlTextEditingDelegate).
	}

	func inputComplete(textView: NSTextView) -> Bool {
		// TODO(p): Also add an increasing counter to the stamp.
		guard let state = InputStamp() else {
			return false
		}
		if state.selection.length != 0 {
			return false
		}

		let prefix = state.input.prefix(state.selection.location)
		let complete = RelayCommandDataBufferComplete(
			bufferName: relayBufferCurrent,
			text: state.input,
			position: UInt32(prefix.utf8.count))
		relayRPC.send(data: complete) { (error, data) in
			if state != InputStamp() {
				return
			}

			self.inputComplete(state: state, error: error,
				data: data as? RelayResponseDataBufferComplete)
		}
		return true
	}

	func inputUp(textView: NSTextView) -> Bool {
		guard let b = bufferBy(name: relayBufferCurrent) else {
			return false
		}
		if b.historyAt < 1 {
			return false
		}

		if b.historyAt == b.history.count {
			b.input = uiInput.stringValue
		}
		b.historyAt -= 1
		uiInput.stringValue = b.history[b.historyAt]
		textView.selectedRange = NSMakeRange(textView.string.count, 0)
		return true
	}

	func inputDown(textView: NSTextView) -> Bool {
		guard let b = bufferBy(name: relayBufferCurrent) else {
			return false
		}
		if b.historyAt >= b.history.count {
			return false
		}

		b.historyAt += 1
		if b.historyAt == b.history.count {
			uiInput.stringValue = b.input
		} else {
			uiInput.stringValue = b.history[b.historyAt]
		}
		textView.selectedRange = NSMakeRange(textView.string.count, 0)
		return true
	}

	func control(_ control: NSControl,
		    textShouldBeginEditing fieldEditor: NSText) -> Bool {
		guard let b = bufferBy(name: relayBufferCurrent) else {
			return false
		}
		if let selection = b.inputSelection {
			fieldEditor.selectedRange = selection
		} else {
			fieldEditor.selectedRange = NSMakeRange(fieldEditor.string.count, 0)
		}
		return true
	}

	func control(_ control: NSControl,
		    textShouldEndEditing fieldEditor: NSText) -> Bool {
		guard let b = bufferBy(name: relayBufferCurrent) else {
			return true
		}
		b.inputSelection = fieldEditor.selectedRange
		return true
	}

	func control(_ control: NSControl, textView: NSTextView,
			doCommandBy commandSelector: Selector) -> Bool {
		// TODO(p): Emacs-style cursor movement shortcuts.
		// TODO(p): Once the log is implemented, scroll that if visible.
		var success = true
		switch commandSelector {
		case #selector(NSStandardKeyBindingResponding.insertTab(_:)):
			success = self.inputComplete(textView: textView)
		case #selector(NSStandardKeyBindingResponding.insertNewline(_:)):
			success = self.inputSubmit()
		case #selector(NSStandardKeyBindingResponding.scrollPageUp(_:)):
			uiBuffer.doCommand(by: commandSelector)
		case #selector(NSStandardKeyBindingResponding.scrollPageDown(_:)):
			uiBuffer.doCommand(by: commandSelector)
		case #selector(NSStandardKeyBindingResponding.moveUp(_:)):
			success = self.inputUp(textView: textView)
		case #selector(NSStandardKeyBindingResponding.moveDown(_:)):
			success = self.inputDown(textView: textView)
		default:
			return false
		}
		if !success {
			NSSound.beep()
		}
		return true
	}
}

// --- General UI --------------------------------------------------------------

class BufferListDataSource: NSObject, NSTableViewDataSource {
	func numberOfRows(in tableView: NSTableView) -> Int {
		return relayBuffers.count
	}

	func tableView(_ tableView: NSTableView,
			objectValueFor tableColumn: NSTableColumn?, row: Int) -> Any? {
		let b = relayBuffers[row]
		let result = NSMutableAttributedString(string: b.bufferName)
		if b.bufferName != relayBufferCurrent && b.newMessages != 0 {
			result.mutableString.append(" (\(b.newMessages))")
			result.applyFontTraits(.boldFontMask,
				range: NSMakeRange(0, result.length))
		}
		if b.highlighted {
			result.addAttribute(.foregroundColor,
				value: NSColor.controlAccentColor,
				range: NSMakeRange(0, result.length))
		}
		return result
	}
}

class BufferListDelegate: NSObject, NSTableViewDelegate {
	func tableView(_ tableView: NSTableView,
			shouldEdit: NSTableColumn?, row: Int) -> Bool {
		return false
	}

	func tableView(_ tableView: NSTableView,
			shouldSelectRow row: Int) -> Bool {
		// The framework would select a row during synchronization.
		if !relayBufferCurrent.isEmpty && row < relayBuffers.count {
			bufferActivate(name: relayBuffers[row].bufferName)
		}
		return false
	}
}

class ApplicationDelegate: NSObject, NSApplicationDelegate {
	func applicationDidFinishLaunching(_ notification: Notification) {
		// We need to call them from here,
		// or the menu wouldn't be clickable right after activation.
		app.setActivationPolicy(.regular)
		app.activate(ignoringOtherApps: true)
	}
}

class WindowDelegate: NSObject, NSWindowDelegate {
	func windowWillClose(_ notification: Notification) {
		app.terminate(nil)
	}

	func windowDidDeminiaturize(_ notification: Notification) {
		guard let b = bufferBy(name: relayBufferCurrent) else {
			return
		}

		b.highlighted = false
		refreshIcon()
		refreshBufferList()
	}

	// Buffer indexes rotated to start after the current buffer.
	func rotatedBuffers() -> Array<Buffer> {
		guard let i = relayBuffers.firstIndex(
			where: { $0.bufferName == relayBufferCurrent }) else {
			return relayBuffers
		}
		let start = i + 1
		return Array<Buffer>(relayBuffers[start...] + relayBuffers[..<start])
	}

	@objc func actionPreviousBuffer() {
		let rotated = self.rotatedBuffers()
		if rotated.count > 1 {
			bufferActivate(name: rotated[rotated.count - 2].bufferName)
		}
	}

	@objc func actionNextBuffer() {
		if let following = self.rotatedBuffers().first {
			bufferActivate(name: following.bufferName)
		}
	}

	@objc func actionSwitchBuffer() {
		if !relayBufferLast.isEmpty {
			bufferActivate(name: relayBufferLast)
		}
	}

	@objc func actionGotoHighlight() {
		for b in rotatedBuffers() {
			if b.highlighted {
				bufferActivate(name: b.bufferName)
				return
			}
		}
	}

	@objc func actionGotoActivity() {
		for b in rotatedBuffers() {
			if b.newMessages != 0 {
				bufferActivate(name: b.bufferName)
				return
			}
		}
	}

	@objc func actionToggleUnimportant() {
		if let b = bufferBy(name: relayBufferCurrent) {
			bufferToggleUnimportant(name: b.bufferName)
		}
	}
}

// --- Accelerators ------------------------------------------------------------

func pushAccelerator(_ title: String, _ action: Selector,
	_ keyEquivalent: String, _ modifiers: NSEvent.ModifierFlags,
	hidden: Bool = false) {
	let item = NSMenuItem(
		title: title, action: action, keyEquivalent: keyEquivalent)
	item.keyEquivalentModifierMask = modifiers

	// isAlternate doesn't really cover our needs.
	if hidden {
		item.isHidden = true
		item.allowsKeyEquivalentWhenHidden = true
	}
	bufferMenu.addItem(item)
}

pushAccelerator("Previous buffer",
	#selector(WindowDelegate.actionPreviousBuffer),
	"p", [.control])
pushAccelerator("Next buffer",
	#selector(WindowDelegate.actionNextBuffer),
	"n", [.control])
pushAccelerator("Previous buffer",
	#selector(WindowDelegate.actionPreviousBuffer),
	String(UnicodeScalar(NSF5FunctionKey)!), [], hidden: true)
pushAccelerator("Next buffer",
	#selector(WindowDelegate.actionNextBuffer),
	String(UnicodeScalar(NSF6FunctionKey)!), [], hidden: true)
pushAccelerator("Previous buffer",
	#selector(WindowDelegate.actionPreviousBuffer),
	String(UnicodeScalar(NSPageUpFunctionKey)!), [.control], hidden: true)
pushAccelerator("Next buffer",
	#selector(WindowDelegate.actionNextBuffer),
	String(UnicodeScalar(NSPageDownFunctionKey)!), [.control], hidden: true)

// Let's add macOS browser shortcuts for good measure.
pushAccelerator("Previous buffer",
	#selector(WindowDelegate.actionPreviousBuffer),
	"[", [.shift, .command], hidden: true)
pushAccelerator("Next buffer",
	#selector(WindowDelegate.actionNextBuffer),
	"]", [.shift, .command], hidden: true)

pushAccelerator("Switch buffer",
	#selector(WindowDelegate.actionSwitchBuffer),
	"\t", [.control])

// TODO(p): Remove .command, and ignore these with the right Option key.
bufferMenu.addItem(NSMenuItem.separator())
pushAccelerator("Go to highlight",
	#selector(WindowDelegate.actionGotoHighlight),
	"!", [.command, .option])
pushAccelerator("Go to activity",
	#selector(WindowDelegate.actionGotoActivity),
	"a", [.command, .option])

bufferMenu.addItem(NSMenuItem.separator())
pushAccelerator("Toggle unimportant",
	#selector(WindowDelegate.actionToggleUnimportant),
	"H", [.command, .option])

// --- Delegation setup --------------------------------------------------------

let uiInputDelegate = InputDelegate()
uiInput.delegate = uiInputDelegate
let uiBufferListDataSource = BufferListDataSource()
uiBufferList.dataSource = uiBufferListDataSource
let uiBufferListDelegate = BufferListDelegate()
uiBufferList.delegate = uiBufferListDelegate

let appDelegate = ApplicationDelegate()
app.delegate = appDelegate
let uiWindowDelegate = WindowDelegate()
uiWindow.delegate = uiWindowDelegate

// --- Startup -----------------------------------------------------------------

// TODO(p): Ideally, we would show a dialog to enter this information.
let defaults = UserDefaults.standard
var relayHost: String? = defaults.string(forKey: "relayHost")
var relayPort: String? = defaults.string(forKey: "relayPort")

if CommandLine.arguments.count >= 3 {
	relayHost = CommandLine.arguments[1]
	relayPort = CommandLine.arguments[2]
}

if relayHost == nil || relayPort == nil {
	CFUserNotificationDisplayAlert(
		0, kCFUserNotificationStopAlertLevel, nil, nil, nil,
		"\(projectName): Usage error" as CFString,
		("The relay address and port either need to be stored " +
		"in your user defaults, or passed on the command line.") as CFString,
		nil, nil, nil, nil)
	exit(EXIT_FAILURE)
}

if !relayRPC.connect(host: relayHost!, port: relayPort!) {
	CFUserNotificationDisplayAlert(
		0, kCFUserNotificationStopAlertLevel, nil, nil, nil,
		"\(projectName): Usage error" as CFString,
		"Invalid relay address." as CFString,
		nil, nil, nil, nil)
	exit(EXIT_FAILURE)
}

uiWindow.center()
uiWindow.makeFirstResponder(uiInput)
uiWindow.makeKeyAndOrderFront(nil)
app.run()