/*
 * xT.cpp: Qt frontend for xC
 *
 * Copyright (c) 2024, 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.
 *
 */

#include "xC-proto.cpp"
#include "config.h"

#include <cstdint>
#include <functional>
#include <map>
#include <string>

#include <QtEndian>
#include <QtDebug>

#include <QDateTime>
#include <QRegularExpression>

#include <QApplication>
#include <QFontDatabase>
#include <QFormLayout>
#include <QHBoxLayout>
#include <QKeyEvent>
#include <QLabel>
#include <QLineEdit>
#include <QListWidget>
#include <QMainWindow>
#include <QMessageBox>
#include <QPushButton>
#include <QScrollBar>
#include <QShortcut>
#include <QSplitter>
#include <QStackedWidget>
#include <QTextBrowser>
#include <QTextBlock>
#include <QTextEdit>
#include <QTimer>
#include <QToolButton>
#include <QVBoxLayout>
#include <QWindow>

#include <QSoundEffect>
#include <QTcpSocket>

struct Server {
	Relay::ServerState state = {};
	QString user;
	QString user_modes;
};

struct BufferLineItem {
	QTextCharFormat format = {};
	QString text;
};

struct BufferLine {
	/// Leaked from another buffer, but temporarily staying in another one.
	bool leaked = {};

	bool is_unimportant = {};
	bool is_highlight = {};
	Relay::Rendition rendition = {};
	uint64_t when = {};
	std::vector<BufferLineItem> items;
};

struct Buffer {
	QString buffer_name;
	bool hide_unimportant = {};
	Relay::BufferKind kind = {};
	QString server_name;
	std::vector<BufferLine> lines;

	// Channel:

	std::vector<BufferLineItem> topic;
	QString modes;

	// Stats:

	uint32_t new_messages = {};
	uint32_t new_unimportant_messages = {};
	bool highlighted = {};

	// Input:

	// The input is stored as rich text.
	QString input;
	int input_start = {};
	int input_end = {};
	std::vector<QString> history;
	size_t history_at = {};
};

using Callback = std::function<
	void(std::wstring error, const Relay::ResponseData *response)>;

struct {
	QMainWindow *wMain;                 ///< Main program window
	QLabel *wTopic;                     ///< Channel topic
	QListWidget *wBufferList;           ///< Buffer list
	QStackedWidget *wStack;             ///< Buffer backlog/log stack
	QTextBrowser *wBuffer;              ///< Buffer backlog
	QTextBrowser *wLog;                 ///< Buffer log
	QLabel *wPrompt;                    ///< User name, etc.
	QToolButton *wButtonB;              ///< Toggle bold formatting
	QToolButton *wButtonI;              ///< Toggle italic formatting
	QToolButton *wButtonU;              ///< Toggle underlined formatting
	QLabel *wStatus;                    ///< Buffer name, etc.
	QToolButton *wButtonLog;            ///< Buffer log toggle button
	QToolButton *wButtonDown;           ///< Scroll indicator
	QTextEdit *wInput;                  ///< User input

	QTimer *date_change_timer;          ///< Timer for day changes

	QLineEdit *wConnectHost;            ///< Connection dialog: host
	QLineEdit *wConnectPort;            ///< Connection dialog: port
	QDialog *wConnectDialog;            ///< Connection details dialog

	// Networking:

	QString host;                       ///< Host as given by user
	QString port;                       ///< Post/service as given by user

	QTcpSocket *socket;                 ///< Buffered relay socket

	// Relay protocol:

	uint32_t command_seq;               ///< Outgoing message counter

	std::map<uint32_t, Callback> command_callbacks;

	std::vector<Buffer> buffers;        ///< List of all buffers
	QString buffer_current;             ///< Current buffer name or ""
	QString buffer_last;                ///< Previous buffer name or ""

	std::map<QString, Server> servers;
} g;

static void
show_error_message(const QString &message)
{
	QMessageBox::critical(g.wMain, "Error", message, QMessageBox::Ok);
}

static void
beep()
{
	// We don't want to reuse the same instance.
	auto *se = new QSoundEffect(g.wMain);
	QObject::connect(se, &QSoundEffect::playingChanged, [=] {
		if (!se->isPlaying())
			se->deleteLater();
	});
	QObject::connect(se, &QSoundEffect::statusChanged, [=] {
		if (se->status() == QSoundEffect::Error)
			se->deleteLater();
	});

	se->setSource(QUrl("qrc:/beep.wav"));
	se->setLoopCount(1);
	se->setVolume(0.5);
	se->play();
}

// --- Networking --------------------------------------------------------------

static void
relay_send(Relay::CommandData *data, Callback callback = {})
{
	Relay::CommandMessage m = {};
	m.command_seq = ++g.command_seq;
	m.data.reset(data);
	LibertyXDR::Writer w;
	m.serialize(w);

	if (callback)
		g.command_callbacks[m.command_seq] = std::move(callback);

	auto len = qToBigEndian<uint32_t>(w.data.size());
	auto prefix = reinterpret_cast<const char *>(&len);
	auto mdata = reinterpret_cast<const char *>(w.data.data());
	if (g.socket->write(prefix, sizeof len) < 0 ||
		g.socket->write(mdata, w.data.size()) < 0) {
		g.socket->abort();
	}
}

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

static Buffer *
buffer_by_name(const QString &name)
{
	for (auto &b : g.buffers)
		if (b.buffer_name == name)
			return &b;
	return nullptr;
}

static Buffer *
buffer_by_name(const std::wstring &name)
{
	// The C++ LibertyXDR backend unfortunately targets Win32.
	return buffer_by_name(QString::fromStdWString(name));
}

static bool
buffer_at_bottom()
{
	auto sb = g.wBuffer->verticalScrollBar();
	return sb->value() == sb->maximum();
}

static void
buffer_scroll_to_bottom()
{
	auto sb = g.wBuffer->verticalScrollBar();
	sb->setValue(sb->maximum());
}

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

static void
refresh_icon()
{
	// This blocks Linux themes, but oh well.
	QIcon icon(":/xT.png");
	for (const auto &b : g.buffers)
		if (b.highlighted)
			icon = QIcon(":/xT-highlighted.png");

	g.wMain->setWindowIcon(icon);
}

static void
textedit_replacesel(
	QTextEdit *e, const QTextCharFormat &cf, const QString &text)
{
	auto cursor = e->textCursor();
	if (cf.fontFixedPitch()) {
		auto fixed = QFontDatabase::systemFont(QFontDatabase::FixedFont);
		auto adjusted = cf;
		// For some reason, setting the families to empty also works.
		adjusted.setFontFamilies(fixed.families());
		cursor.setCharFormat(adjusted);
	} else {
		cursor.setCharFormat(cf);
	}
	cursor.insertText(text);
}

static void
refresh_topic(const std::vector<BufferLineItem> &topic)
{
	QTextDocument doc;
	QTextCursor cursor(&doc);
	for (const auto &it : topic) {
		cursor.setCharFormat(it.format);
		cursor.insertText(it.text);
	}
	g.wTopic->setText(doc.toHtml());
}

static void
refresh_buffer_list_item(QListWidgetItem *item, const Buffer &b)
{
	auto text = b.buffer_name;
	QFont font;
	QBrush color;
	if (b.buffer_name != g.buffer_current && b.new_messages) {
		text += " (" + QString::number(b.new_messages) + ")";
		font.setBold(true);
	}
	if (b.highlighted)
		color = QColor(0xff, 0x5f, 0x00);

	item->setForeground(color);
	item->setText(text);
	item->setFont(font);
}

static void
refresh_buffer_list()
{
	for (size_t i = 0; i < g.buffers.size(); i++)
		refresh_buffer_list_item(g.wBufferList->item(i), g.buffers.at(i));
}

static QString
server_state_to_string(Relay::ServerState state)
{
	switch (state) {
	case Relay::ServerState::DISCONNECTED:  return "disconnected";
	case Relay::ServerState::CONNECTING:    return "connecting";
	case Relay::ServerState::CONNECTED:     return "connected";
	case Relay::ServerState::REGISTERED:    return "registered";
	case Relay::ServerState::DISCONNECTING: return "disconnecting";
	}
	return {};
}

static void
refresh_prompt()
{
	QString prompt;
	auto b = buffer_by_name(g.buffer_current);
	if (!b) {
		prompt = "Synchronizing...";
	} else if (auto server = g.servers.find(b->server_name);
			server != g.servers.end()) {
		prompt = server->second.user;
		if (!server->second.user_modes.isEmpty())
			prompt += "(" + server->second.user_modes + ")";
		if (prompt.isEmpty())
			prompt = "(" + server_state_to_string(server->second.state) + ")";
	}
	g.wPrompt->setText(prompt);
}

static void
refresh_status()
{
	g.wButtonDown->setEnabled(!buffer_at_bottom());

	QString status = g.buffer_current;
	if (auto b = buffer_by_name(g.buffer_current)) {
		if (!b->modes.isEmpty())
			status += "(+" + b->modes + ")";
		if (b->hide_unimportant)
			status += "<H>";
	}

	// Buffer scrolling would cause a ton of flickering redraws.
	if (g.wStatus->text() != status)
		g.wStatus->setText(status);
}

static void
recheck_highlighted()
{
	// Corresponds to the logic toggling the bool on.
	auto b = buffer_by_name(g.buffer_current);
	if (b && b->highlighted && buffer_at_bottom() &&
		!g.wMain->isMinimized() && !g.wLog->isVisible()) {
		b->highlighted = false;
		refresh_icon();
		refresh_buffer_list();
	}
}

// --- Buffer actions ----------------------------------------------------------

static void
buffer_activate(const QString &name)
{
	auto activate = new Relay::CommandData_BufferActivate();
	activate->buffer_name = name.toStdWString();
	relay_send(activate);
}

static void
buffer_toggle_unimportant(const QString &name)
{
	auto toggle = new Relay::CommandData_BufferToggleUnimportant();
	toggle->buffer_name = name.toStdWString();
	relay_send(toggle);
}

// FIXME: This works on the wrong level, we should take a vector and output
// a filtered vector--we must disregard individual items during URL matching.
static void
convert_links(const QTextCharFormat &format, const QString &text,
	std::vector<BufferLineItem> &result)
{
	static QRegularExpression link_re(
		R"(https?://)"
		R"((?:[^\[\](){}<>"'\s]|\([^\[\](){}<>"'\s]*\))+)"
		R"((?:[^\[\](){}<>"'\s,.:]|\([^\[\](){}<>"'\s]*\)))");

	qsizetype end = 0;
	for (const QRegularExpressionMatch &m : link_re.globalMatch(text)) {
		if (end < m.capturedStart()) {
			result.emplace_back(BufferLineItem{
				format, text.sliced(end, m.capturedStart() - end)});
		}

		BufferLineItem item{format, m.captured()};
		item.format.setAnchor(true);
		item.format.setAnchorHref(m.captured());
		result.emplace_back(std::move(item));

		end = m.capturedEnd();
	}
	if (!end)
		result.emplace_back(BufferLineItem{format, text});
	else if (end < text.length())
		result.emplace_back(BufferLineItem{format, text.sliced(end)});
}

static void
buffer_toggle_log(
	const std::wstring &error, const Relay::ResponseData_BufferLog *response)
{
	if (!response) {
		show_error_message(QString::fromStdWString(error));
		return;
	}

	std::wstring log;
	if (!LibertyXDR::utf8_to_wstring(
			response->log.data(), response->log.size(), log)) {
		show_error_message("Invalid encoding.");
		return;
	}

	std::vector<BufferLineItem> linkified;
	convert_links({}, QString::fromStdWString(log), linkified);

	g.wButtonLog->setChecked(true);
	g.wLog->setText({});
	for (const auto &it : linkified)
		textedit_replacesel(g.wLog, it.format, it.text);
	g.wStack->setCurrentWidget(g.wLog);

	// This triggers a relayout of some kind.
	auto cursor = g.wLog->textCursor();
	cursor.movePosition(QTextCursor::End);
	g.wLog->setTextCursor(cursor);

	auto sb = g.wLog->verticalScrollBar();
	sb->setValue(sb->maximum());
}

static void
buffer_toggle_log()
{
	if (g.wLog->isVisible()) {
		g.wStack->setCurrentWidget(g.wBuffer);
		g.wLog->setText("");
		g.wButtonLog->setChecked(false);

		recheck_highlighted();
		return;
	}

	auto log = new Relay::CommandData_BufferLog();
	log->buffer_name = g.buffer_current.toStdWString();
	relay_send(log, [name = g.buffer_current](auto error, auto response) {
		if (g.buffer_current != name)
			return;
		buffer_toggle_log(error,
			dynamic_cast<const Relay::ResponseData_BufferLog *>(response));
	});
}

// --- QTextEdit formatting ----------------------------------------------------

static QString
rich_text_to_irc(QTextEdit *textEdit)
{
	QString irc;
	for (auto block = textEdit->document()->begin();
		block.isValid(); block = block.next()) {
		for (auto it = block.begin(); it != block.end(); ++it) {
			auto fragment = it.fragment();
			if (!fragment.isValid())
				continue;

			// TODO(p): Colours.
			QString toggles;
			auto format = fragment.charFormat();
			if (format.fontWeight() >= QFont::Bold)
				toggles += "\x02";
			if (format.fontFixedPitch())
				toggles += "\x11";
			if (format.fontItalic())
				toggles += "\x1d";
			if (format.fontStrikeOut())
				toggles += "\x1e";
			if (format.fontUnderline())
				toggles += "\x1f";
			irc += toggles + fragment.text() + toggles;
		}
		if (block.next().isValid())
			irc += "\n";
	}
	return irc;
}

static QString
irc_to_rich_text(const QString &irc)
{
	QTextDocument doc;
	QTextCursor cursor(&doc);
	QTextCharFormat cf;
	bool bold = false, monospace = false, italic = false, crossed = false,
		underline = false;

	QString current;
	auto apply = [&]() {
		if (!current.isEmpty()) {
			cursor.insertText(current, cf);
			current.clear();
		}
	};

	for (int i = 0; i < irc.length(); ++i) {
		switch (irc[i].unicode()) {
		case '\x02':
			apply();
			bold = !bold;
			cf.setFontWeight(bold ? QFont::Bold : QFont::Normal);
			break;
		case '\x03':
			// TODO(p): Decode colours, see xC.
			break;
		case '\x11':
			apply();
			cf.setFontFixedPitch((monospace = !monospace));
			break;
		case '\x1d':
			apply();
			cf.setFontItalic((italic = !italic));
			break;
		case '\x1e':
			apply();
			cf.setFontItalic((crossed = !crossed));
			break;
		case '\x1f':
			apply();
			cf.setFontUnderline((underline = !underline));
			break;
		case '\x0f':
			apply();
			bold = monospace = italic = crossed = underline = false;
			cf = QTextCharFormat();
			break;
		default:
			current += irc[i];
		}
	}
	apply();
	return doc.toHtml();
}

// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

static QBrush
convert_color(int16_t color)
{
	static const uint16_t base16[] = {
		0x000, 0x800, 0x080, 0x880, 0x008, 0x808, 0x088, 0xccc,
		0x888, 0xf00, 0x0f0, 0xff0, 0x00f, 0xf0f, 0x0ff, 0xfff,
	};
	if (color < 16) {
		uint8_t r = 0xf & (base16[color] >> 8);
		uint8_t g = 0xf & (base16[color] >> 4);
		uint8_t b = 0xf & (base16[color]);
		return QColor(r * 0x11, g * 0x11, b * 0x11);
	}
	if (color >= 216) {
		uint8_t g = 8 + (color - 216) * 10;
		return QColor(g, g, g);
	}

	uint8_t i = color - 16, r = i / 36 >> 0, g = (i / 6 >> 0) % 6, b = i % 6;
	return QColor(
		!r ? 0 : 55 + 40 * r,
		!g ? 0 : 55 + 40 * g,
		!b ? 0 : 55 + 40 * b);
}

static void
convert_item_formatting(
	Relay::ItemData *item, QTextCharFormat &cf, bool &inverse)
{
	if (dynamic_cast<Relay::ItemData_Reset *>(item)) {
		cf = QTextCharFormat();
		inverse = false;
	} else if (dynamic_cast<Relay::ItemData_FlipBold *>(item)) {
		if (cf.fontWeight() <= QFont::Normal)
			cf.setFontWeight(QFont::Bold);
		else
			cf.setFontWeight(QFont::Normal);
	} else if (dynamic_cast<Relay::ItemData_FlipItalic *>(item)) {
		cf.setFontItalic(!cf.fontItalic());
	} else if (dynamic_cast<Relay::ItemData_FlipUnderline *>(item)) {
		cf.setFontUnderline(!cf.fontUnderline());
	} else if (dynamic_cast<Relay::ItemData_FlipCrossedOut *>(item)) {
		cf.setFontStrikeOut(!cf.fontStrikeOut());
	} else if (dynamic_cast<Relay::ItemData_FlipInverse *>(item)) {
		inverse = !inverse;
	} else if (dynamic_cast<Relay::ItemData_FlipMonospace *>(item)) {
		cf.setFontFixedPitch(!cf.fontFixedPitch());
	} else if (auto data = dynamic_cast<Relay::ItemData_FgColor *>(item)) {
		if (data->color < 0) {
			cf.clearForeground();
		} else {
			cf.setForeground(convert_color(data->color));
		}
	} else if (auto data = dynamic_cast<Relay::ItemData_BgColor *>(item)) {
		if (data->color < 0) {
			cf.clearBackground();
		} else {
			cf.setBackground(convert_color(data->color));
		}
	}
}

static std::vector<BufferLineItem>
convert_items(const std::vector<std::unique_ptr<Relay::ItemData>> &items)
{
	QTextCharFormat cf;
	std::vector<BufferLineItem> result;
	bool inverse = false;
	for (const auto &it : items) {
		auto text = dynamic_cast<Relay::ItemData_Text *>(it.get());
		if (!text) {
			convert_item_formatting(it.get(), cf, inverse);
			continue;
		}

		auto item_format = cf;
		auto item_text = QString::fromStdWString(text->text);
		if (inverse) {
			auto fg = item_format.foreground();
			auto bg = item_format.background();
			item_format.setBackground(fg);
			item_format.setForeground(bg);
		}
		convert_links(item_format, item_text, result);
	}
	return result;
}

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

static BufferLine
convert_buffer_line(Relay::EventData_BufferLine &line)
{
	BufferLine self = {};
	self.items = convert_items(line.items);
	self.is_unimportant = line.is_unimportant;
	self.is_highlight = line.is_highlight;
	self.rendition = line.rendition;
	self.when = line.when;
	return self;
}

static void
buffer_print_date_change(
	bool &sameline, const QDateTime &last, const QDateTime &current)
{
	if (last.date() == current.date())
		return;

	auto timestamp = current.toString(&"\n"[sameline] +
		QLocale::system().dateFormat(QLocale::ShortFormat));
	sameline = false;

	QTextCharFormat cf;
	cf.setFontWeight(QFont::Bold);
	textedit_replacesel(g.wBuffer, cf, timestamp);
}

static bool
buffer_reset_selection()
{
	auto sb = g.wBuffer->verticalScrollBar();
	auto value = sb->value();
	g.wBuffer->moveCursor(QTextCursor::End);
	sb->setValue(value);
	return g.wBuffer->textCursor().atBlockStart();
}

static void
buffer_print_and_watch_trailing_date_changes()
{
	auto current = QDateTime::currentDateTime();
	auto b = buffer_by_name(g.buffer_current);
	if (b && !b->lines.empty()) {
		auto last = QDateTime::fromMSecsSinceEpoch(b->lines.back().when);
		bool sameline = buffer_reset_selection();
		buffer_print_date_change(sameline, last, current);
	}

	QDateTime midnight(current.date().addDays(1), {});
	if (midnight < current)
		return;

	// Note that after printing the first trailing update,
	// follow-up updates may be duplicated if timer events arrive too early.
	g.date_change_timer->start(current.msecsTo(midnight) + 1);
}

static void
buffer_print_line(std::vector<BufferLine>::const_iterator begin,
	std::vector<BufferLine>::const_iterator line)
{
	auto current = QDateTime::fromMSecsSinceEpoch(line->when);
	auto last = line == begin ? QDateTime::currentDateTime()
		: QDateTime::fromMSecsSinceEpoch((line - 1)->when);

	bool sameline = buffer_reset_selection();
	buffer_print_date_change(sameline, last, current);

	auto timestamp = current.toString(&"\nHH:mm:ss"[sameline]);
	sameline = false;

	QTextCharFormat cf;
	cf.setForeground(QColor(0xbb, 0xbb, 0xbb));
	cf.setBackground(QColor(0xf8, 0xf8, 0xf8));
	textedit_replacesel(g.wBuffer, cf, timestamp);
	cf = QTextCharFormat();
	textedit_replacesel(g.wBuffer, cf, " ");

	// Tabstops won't quite help us here, since we need it centred.
	QString prefix;
	QTextCharFormat pcf;
	pcf.setFontFixedPitch(true);
	pcf.setFontWeight(QFont::Bold);
	switch (line->rendition) {
	break; case Relay::Rendition::BARE:
	break; case Relay::Rendition::INDENT:
		prefix = "    ";
	break; case Relay::Rendition::STATUS:
		prefix = " -  ";
	break; case Relay::Rendition::ERROR:
		prefix = "=!= ";
		pcf.setForeground(QColor(0xff, 0, 0));
	break; case Relay::Rendition::JOIN:
		prefix = "——> ";
		pcf.setForeground(QColor(0, 0x88, 0));
	break; case Relay::Rendition::PART:
		prefix = "<—— ";
		pcf.setForeground(QColor(0x88, 0, 0));
	break; case Relay::Rendition::ACTION:
		prefix = " *  ";
		pcf.setForeground(QColor(0x88, 0, 0));
	}

	if (line->leaked) {
		auto color = g.wBuffer->palette().color(
			QPalette::Disabled, QPalette::Text);
		pcf.setForeground(color);
		if (!prefix.isEmpty()) {
			textedit_replacesel(g.wBuffer, pcf, prefix);
		}

		for (auto it : line->items) {
			it.format.setForeground(color);
			it.format.clearBackground();
			textedit_replacesel(g.wBuffer, it.format, it.text);
		}
	} else {
		if (!prefix.isEmpty())
			textedit_replacesel(g.wBuffer, pcf, prefix);
		for (const auto &it : line->items)
			textedit_replacesel(g.wBuffer, it.format, it.text);
	}
}

static void
buffer_print_separator()
{
	buffer_reset_selection();

	QTextFrameFormat ff;
	ff.setBackground(QColor(0xff, 0x5f, 0x00));
	ff.setHeight(1);
	// FIXME: When the current frame was empty, this seems to add a newline.
	g.wBuffer->textCursor().insertFrame(ff);
}

static void
refresh_buffer(const Buffer &b)
{
	g.wBuffer->clear();

	size_t i = 0, mark_before = b.lines.size() -
		b.new_messages - b.new_unimportant_messages;
	for (auto line = b.lines.begin(); line != b.lines.end(); ++line) {
		if (i == mark_before)
			buffer_print_separator();
		if (!line->is_unimportant || !b.hide_unimportant)
			buffer_print_line(b.lines.begin(), line);

		i++;
	}

	buffer_print_and_watch_trailing_date_changes();
	buffer_scroll_to_bottom();
	// TODO(p): recheck_highlighted() here, or do we handle enough signals?
}

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

static void
relay_process_buffer_line(Buffer &b, Relay::EventData_BufferLine &m)
{
	// Initial sync: skip all other processing, let highlights be.
	auto bc = buffer_by_name(g.buffer_current);
	if (!bc) {
		b.lines.push_back(convert_buffer_line(m));
		return;
	}

	// Retained mode is complicated.
	bool display = (!m.is_unimportant || !bc->hide_unimportant) &&
		(b.buffer_name == g.buffer_current || m.leak_to_active);
	bool to_bottom = display && buffer_at_bottom();
	bool visible = display &&
		to_bottom &&
		!g.wMain->isMinimized() &&
		!g.wLog->isVisible();
	bool separate = display &&
		!visible && !bc->new_messages && !bc->new_unimportant_messages;

	auto line = b.lines.insert(b.lines.end(), convert_buffer_line(m));
	if (!(visible || m.leak_to_active) ||
		b.new_messages || b.new_unimportant_messages) {
		if (line->is_unimportant || m.leak_to_active)
			b.new_unimportant_messages++;
		else
			b.new_messages++;
	}

	if (m.leak_to_active) {
		auto line = bc->lines.insert(bc->lines.end(), convert_buffer_line(m));
		line->leaked = true;
		if (!visible || bc->new_messages || bc->new_unimportant_messages) {
			if (line->is_unimportant)
				bc->new_unimportant_messages++;
			else
				bc->new_messages++;
		}
	}
	if (separate)
		buffer_print_separator();
	if (display)
		buffer_print_line(bc->lines.begin(), bc->lines.end() - 1);
	if (to_bottom)
		buffer_scroll_to_bottom();

	if (line->is_highlight || (!visible && !line->is_unimportant &&
		b.kind == Relay::BufferKind::PRIVATE_MESSAGE)) {
		beep();

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

	refresh_buffer_list();
}

static void
relay_process_callbacks(uint32_t command_seq,
	const std::wstring& error, const Relay::ResponseData *response)
{
	auto &callbacks = g.command_callbacks;
	auto handler = callbacks.find(command_seq);
	if (handler == callbacks.end()) {
		// TODO(p): Warn about an unawaited response.
	} else {
		if (handler->second)
			handler->second(error, response);
		callbacks.erase(handler);
	}

	// We don't particularly care about wraparound issues.
	while (!callbacks.empty() && callbacks.begin()->first <= command_seq) {
		auto front = callbacks.begin();
		if (front->second)
			front->second(L"No response", nullptr);
		callbacks.erase(front);
	}
}

static void
relay_process_message(const Relay::EventMessage &m)
{
	switch (m.data->event) {
	case Relay::Event::ERROR:
	{
		auto data = dynamic_cast<Relay::EventData_Error *>(m.data.get());
		relay_process_callbacks(data->command_seq, data->error, nullptr);
		break;
	}
	case Relay::Event::RESPONSE:
	{
		auto data = dynamic_cast<Relay::EventData_Response *>(m.data.get());
		relay_process_callbacks(data->command_seq, {}, data->data.get());
		break;
	}

	case Relay::Event::PING:
	{
		auto pong = new Relay::CommandData_PingResponse();
		pong->event_seq = m.event_seq;
		relay_send(pong);
		break;
	}

	case Relay::Event::BUFFER_LINE:
	{
		auto &data = dynamic_cast<Relay::EventData_BufferLine &>(*m.data);
		auto b = buffer_by_name(data.buffer_name);
		if (!b)
			break;

		relay_process_buffer_line(*b, data);
		break;
	}
	case Relay::Event::BUFFER_UPDATE:
	{
		auto &data = dynamic_cast<Relay::EventData_BufferUpdate &>(*m.data);
		auto b = buffer_by_name(data.buffer_name);
		if (!b) {
			b = &*g.buffers.insert(g.buffers.end(), Buffer());
			b->buffer_name = QString::fromStdWString(data.buffer_name);

			auto item = new QListWidgetItem;
			refresh_buffer_list_item(item, *b);
			g.wBufferList->addItem(item);
		}

		bool hiding_toggled = b->hide_unimportant != data.hide_unimportant;
		b->hide_unimportant = data.hide_unimportant;
		b->kind = data.context->kind;
		b->server_name.clear();
		if (auto context = dynamic_cast<Relay::BufferContext_Server *>(
				data.context.get()))
			b->server_name = QString::fromStdWString(context->server_name);
		if (auto context = dynamic_cast<Relay::BufferContext_Channel *>(
				data.context.get())) {
			b->server_name = QString::fromStdWString(context->server_name);
			b->modes = QString::fromStdWString(context->modes);
			b->topic = convert_items(context->topic);
		}
		if (auto context = dynamic_cast<Relay::BufferContext_PrivateMessage *>(
				data.context.get()))
			b->server_name = QString::fromStdWString(context->server_name);

		if (b->buffer_name == g.buffer_current) {
			refresh_topic(b->topic);
			refresh_status();

			if (hiding_toggled)
				refresh_buffer(*b);
		}
		break;
	}
	case Relay::Event::BUFFER_STATS:
	{
		auto &data = dynamic_cast<Relay::EventData_BufferStats &>(*m.data);
		auto b = buffer_by_name(data.buffer_name);
		if (!b)
			break;

		b->new_messages = data.new_messages;
		b->new_unimportant_messages = data.new_unimportant_messages;
		b->highlighted = data.highlighted;

		refresh_icon();
		refresh_buffer_list();
		break;
	}
	case Relay::Event::BUFFER_RENAME:
	{
		auto &data = dynamic_cast<Relay::EventData_BufferRename &>(*m.data);
		auto b = buffer_by_name(data.buffer_name);
		if (!b)
			break;

		auto original = b->buffer_name;
		b->buffer_name = QString::fromStdWString(data.new_);

		if (original == g.buffer_current) {
			g.buffer_current = b->buffer_name;
			refresh_status();
		}
		refresh_buffer_list();
		if (original == g.buffer_last)
			g.buffer_last = b->buffer_name;
		break;
	}
	case Relay::Event::BUFFER_REMOVE:
	{
		auto &data = dynamic_cast<Relay::EventData_BufferRemove &>(*m.data);
		auto b = buffer_by_name(data.buffer_name);
		if (!b)
			break;

		int index = b - g.buffers.data();
		delete g.wBufferList->takeItem(index);
		g.buffers.erase(g.buffers.begin() + index);

		refresh_icon();
		break;
	}
	case Relay::Event::BUFFER_ACTIVATE:
	{
		auto &data = dynamic_cast<Relay::EventData_BufferActivate &>(*m.data);
		Buffer *old = buffer_by_name(g.buffer_current);
		g.buffer_last = g.buffer_current;
		g.buffer_current = QString::fromStdWString(data.buffer_name);
		auto b = buffer_by_name(data.buffer_name);
		if (!b)
			break;

		if (old) {
			old->new_messages = 0;
			old->new_unimportant_messages = 0;
			old->highlighted = false;

			old->input = g.wInput->toHtml();
			old->input_start = g.wInput->textCursor().selectionStart();
			old->input_end = g.wInput->textCursor().selectionEnd();

			// Note that we effectively overwrite the newest line
			// with the current textarea contents, and jump there.
			old->history_at = old->history.size();
		}

		if (g.wLog->isVisible())
			buffer_toggle_log();
		if (!g.wMain->isMinimized())
			b->highlighted = false;
		auto item = g.wBufferList->item(b - g.buffers.data());
		refresh_buffer_list_item(item, *b);
		g.wBufferList->setCurrentItem(item);

		refresh_icon();
		refresh_topic(b->topic);
		refresh_buffer(*b);
		refresh_prompt();
		refresh_status();

		g.wInput->setHtml(b->input);
		g.wInput->textCursor().setPosition(b->input_start);
		g.wInput->textCursor().setPosition(
			b->input_end, QTextCursor::KeepAnchor);
		g.wInput->setFocus();
		break;
	}
	case Relay::Event::BUFFER_INPUT:
	{
		auto &data = dynamic_cast<Relay::EventData_BufferInput &>(*m.data);
		auto b = buffer_by_name(data.buffer_name);
		if (!b)
			break;

		if (b->history_at == b->history.size())
			b->history_at++;
		b->history.push_back(
			irc_to_rich_text(QString::fromStdWString(data.text)));
		break;
	}
	case Relay::Event::BUFFER_CLEAR:
	{
		auto &data = dynamic_cast<Relay::EventData_BufferClear &>(*m.data);
		auto b = buffer_by_name(data.buffer_name);
		if (!b)
			break;

		b->lines.clear();
		if (b->buffer_name == g.buffer_current)
			refresh_buffer(*b);
		break;
	}

	case Relay::Event::SERVER_UPDATE:
	{
		auto &data = dynamic_cast<Relay::EventData_ServerUpdate &>(*m.data);
		auto name = QString::fromStdWString(data.server_name);
		if (!g.servers.count(name))
			g.servers.emplace(name, Server());

		auto &server = g.servers.at(name);
		server.state = data.data->state;

		server.user.clear();
		server.user_modes.clear();
		if (auto registered = dynamic_cast<Relay::ServerData_Registered *>(
				data.data.get())) {
			server.user = QString::fromStdWString(registered->user);
			server.user_modes = QString::fromStdWString(registered->user_modes);
		}

		refresh_prompt();
		break;
	}
	case Relay::Event::SERVER_RENAME:
	{
		auto &data = dynamic_cast<Relay::EventData_ServerRename &>(*m.data);
		auto original = QString::fromStdWString(data.server_name);
		g.servers.insert_or_assign(
			QString::fromStdWString(data.new_), g.servers.at(original));
		g.servers.erase(original);
		break;
	}
	case Relay::Event::SERVER_REMOVE:
	{
		auto &data = dynamic_cast<Relay::EventData_ServerRemove &>(*m.data);
		auto name = QString::fromStdWString(data.server_name);
		g.servers.erase(name);
		break;
	}
	}
}

// --- Networking --------------------------------------------------------------

static void
relay_show_dialog()
{
	g.wConnectHost->setText(g.host);
	g.wConnectPort->setText(g.port);
	g.wConnectDialog->move(
		g.wMain->frameGeometry().center() - g.wConnectDialog->rect().center());
	switch (g.wConnectDialog->exec()) {
	case QDialog::Accepted:
		g.host = g.wConnectHost->text();
		g.port = g.wConnectPort->text();
		g.socket->connectToHost(g.host, g.port.toUShort());
		break;
	case QDialog::Rejected:
		QCoreApplication::exit();
	}
}

static void
relay_process_error([[maybe_unused]] QAbstractSocket::SocketError err)
{
	show_error_message(g.socket->errorString());
	g.socket->abort();
	QTimer::singleShot(0, relay_show_dialog);
}

static void
relay_process_connected()
{
	g.command_seq = 0;
	g.command_callbacks.clear();

	g.buffers.clear();
	g.buffer_current.clear();
	g.buffer_last.clear();
	g.servers.clear();

	refresh_icon();
	refresh_topic({});
	g.wBufferList->clear();
	g.wBuffer->clear();
	refresh_prompt();
	refresh_status();

	auto hello = new Relay::CommandData_Hello();
	hello->version = Relay::VERSION;
	relay_send(hello);
}

static bool
relay_process_buffer(QString &error)
{
	// How I wish I could access the internal read buffer directly.
	auto s = g.socket;
	union {
		uint32_t frame_len = 0;
		char buf[sizeof frame_len];
	};
	while (s->peek(buf, sizeof buf) == sizeof buf) {
		frame_len = qFromBigEndian(frame_len);
		if (s->bytesAvailable() < qint64(sizeof frame_len + frame_len))
			break;

		s->skip(sizeof frame_len);
		auto b = s->read(frame_len);
		LibertyXDR::Reader r;
		r.data = reinterpret_cast<const uint8_t *>(b.data());
		r.length = b.size();

		Relay::EventMessage m = {};
		if (!m.deserialize(r) || r.length) {
			error = "Deserialization failed.";
			return false;
		}

		relay_process_message(m);
	}
	return true;
}

static void
relay_process_ready()
{
	QString err;
	if (!relay_process_buffer(err)) {
		show_error_message(err);
		g.socket->abort();
		QTimer::singleShot(0, relay_show_dialog);
	}
}

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

static void
input_set_contents(const QString &input)
{
	g.wInput->setHtml(input);

	auto cursor = g.wInput->textCursor();
	cursor.movePosition(QTextCursor::End);
	g.wInput->setTextCursor(cursor);
	g.wInput->ensureCursorVisible();
}

static bool
input_submit()
{
	auto b = buffer_by_name(g.buffer_current);
	if (!b)
		return false;

	auto input = new Relay::CommandData_BufferInput();
	input->buffer_name = b->buffer_name.toStdWString();
	input->text = rich_text_to_irc(g.wInput).toStdWString();

	// Buffer::history[Buffer::history.size()] 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.push_back(g.wInput->toHtml());
	b->history_at = b->history.size();
	input_set_contents("");

	relay_send(input);
	return true;
}

struct InputStamp {
	int start = {};
	int end = {};
	QString input;
};

static InputStamp
input_stamp()
{
	// Hopefully, the selection markers match the plain text characters.
	auto start = g.wInput->textCursor().selectionStart();
	auto end = g.wInput->textCursor().selectionEnd();
	return {start, end, g.wInput->toPlainText()};
}

static void
input_complete(const InputStamp &state, const std::wstring &error,
	const Relay::ResponseData_BufferComplete *response)
{
	if (!response) {
		show_error_message(QString::fromStdWString(error));
		return;
	}

	auto utf8 = state.input.sliced(0, state.start).toUtf8();
	auto preceding = QString(utf8.sliced(0, response->start));
	if (response->completions.size() > 0) {
		auto insert = response->completions.at(0);
		if (response->completions.size() == 1)
			insert += L" ";

		auto cursor = g.wInput->textCursor();
		cursor.setPosition(preceding.length());
		cursor.setPosition(state.end, QTextCursor::KeepAnchor);
		cursor.insertHtml(irc_to_rich_text(QString::fromStdWString(insert)));
	}

	if (response->completions.size() != 1)
		beep();

	// TODO(p): Show all completion options.
}

static bool
input_complete()
{
	// TODO(p): Also add an increasing counter to the stamp.
	auto state = input_stamp();
	if (state.start != state.end)
		return false;

	auto utf8 = state.input.sliced(0, state.start).toUtf8();
	auto complete = new Relay::CommandData_BufferComplete();
	complete->buffer_name = g.buffer_current.toStdWString();
	complete->text = state.input.toStdWString();
	complete->position = utf8.size();
	relay_send(complete, [state](auto error, auto response) {
		auto stamp = input_stamp();
		if (std::make_tuple(stamp.start, stamp.end, stamp.input) !=
			std::make_tuple(state.start, state.end, state.input))
			return;
		input_complete(stamp, error,
			dynamic_cast<const Relay::ResponseData_BufferComplete *>(response));
	});
	return true;
}

static bool
input_up()
{
	auto b = buffer_by_name(g.buffer_current);
	if (!b || b->history_at < 1)
		return false;

	if (b->history_at == b->history.size())
		b->input = g.wInput->toHtml();
	input_set_contents(b->history.at(--b->history_at));
	return true;
}

static bool
input_down()
{
	auto b = buffer_by_name(g.buffer_current);
	if (!b || b->history_at >= b->history.size())
		return false;

	input_set_contents(++b->history_at == b->history.size()
		? b->input
		: b->history.at(b->history_at));
	return true;
}

class InputEdit : public QTextEdit {
	Q_OBJECT

public:
	explicit InputEdit(QWidget *parent = nullptr) : QTextEdit(parent) {}

	void keyPressEvent(QKeyEvent *event) override
	{
		auto scrollable = g.wLog->isVisible()
			? g.wLog->verticalScrollBar()
			: g.wBuffer->verticalScrollBar();

		QKeyCombination combo(
			event->modifiers() & ~Qt::KeypadModifier, Qt::Key(event->key()));
		switch (combo.toCombined()) {
		case Qt::Key_Return:
		case Qt::Key_Enter:
			input_submit();
			break;
		case QKeyCombination(Qt::ShiftModifier, Qt::Key_Return).toCombined():
		case QKeyCombination(Qt::ShiftModifier, Qt::Key_Enter).toCombined():
			// Qt amazingly inserts U+2028 LINE SEPARATOR instead.
			this->textCursor().insertText("\n");
			break;
		case Qt::Key_Tab:
			input_complete();
			break;
		case QKeyCombination(Qt::AltModifier, Qt::Key_P).toCombined():
		case Qt::Key_Up:
			input_up();
			break;
		case QKeyCombination(Qt::AltModifier, Qt::Key_N).toCombined():
		case Qt::Key_Down:
			input_down();
			break;
		case Qt::Key_PageUp:
			scrollable->setValue(scrollable->value() - scrollable->pageStep());
			break;
		case Qt::Key_PageDown:
			scrollable->setValue(scrollable->value() + scrollable->pageStep());
			break;

		default:
			QTextEdit::keyPressEvent(event);
			return;
		}
		event->accept();
	}
};

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

class BufferEdit : public QTextBrowser {
	Q_OBJECT

public:
	explicit BufferEdit(QWidget *parent = nullptr) : QTextBrowser(parent) {}

	void resizeEvent(QResizeEvent *event) override
	{
		bool to_bottom = buffer_at_bottom();
		QTextBrowser::resizeEvent(event);
		if (to_bottom) {
			buffer_scroll_to_bottom();
		} else {
			recheck_highlighted();
			refresh_status();
		}
	}
};

static void
build_main_window()
{
	g.wMain = new QMainWindow;
	refresh_icon();

	auto central = new QWidget(g.wMain);
	auto vbox = new QVBoxLayout(central);
	vbox->setContentsMargins(4, 4, 4, 4);

	g.wTopic = new QLabel(central);
	g.wTopic->setTextFormat(Qt::RichText);
	vbox->addWidget(g.wTopic);

	auto splitter = new QSplitter(Qt::Horizontal, central);
	splitter->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);

	g.wBufferList = new QListWidget(splitter);
	g.wBufferList->setSizePolicy(
		QSizePolicy::Preferred, QSizePolicy::Expanding);
	QObject::connect(g.wBufferList, &QListWidget::currentRowChanged,
		[](int row) {
			if (row >= 0 && (size_t) row < g.buffers.size())
				buffer_activate(g.buffers.at(row).buffer_name);
		});

	g.wStack = new QStackedWidget(splitter);

	g.wBuffer = new BufferEdit(g.wStack);
	g.wBuffer->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
	g.wBuffer->setReadOnly(true);
	g.wBuffer->setTextInteractionFlags(
		Qt::TextSelectableByMouse | Qt::TextSelectableByKeyboard |
		Qt::LinksAccessibleByMouse | Qt::LinksAccessibleByKeyboard);
	g.wBuffer->setOpenExternalLinks(true);
	QObject::connect(g.wBuffer->verticalScrollBar(), &QScrollBar::valueChanged,
		[]([[maybe_unused]] int value) {
			recheck_highlighted();
			refresh_status();
		});
	QObject::connect(g.wBuffer->verticalScrollBar(), &QScrollBar::rangeChanged,
		[]([[maybe_unused]] int min, [[maybe_unused]] int max) {
			recheck_highlighted();
			refresh_status();
		});

	g.wLog = new QTextBrowser(g.wStack);
	g.wLog->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
	g.wLog->setReadOnly(true);
	g.wLog->setTextInteractionFlags(
		Qt::TextSelectableByMouse | Qt::TextSelectableByKeyboard |
		Qt::LinksAccessibleByMouse | Qt::LinksAccessibleByKeyboard);
	g.wLog->setOpenExternalLinks(true);

	g.wStack->addWidget(g.wBuffer);
	g.wStack->addWidget(g.wLog);

	splitter->addWidget(g.wBufferList);
	splitter->setStretchFactor(0, 1);
	splitter->addWidget(g.wStack);
	splitter->setStretchFactor(1, 2);
	vbox->addWidget(splitter);

	auto hbox = new QHBoxLayout();
	g.wPrompt = new QLabel(central);
	hbox->addWidget(g.wPrompt);

	g.wButtonB = new QToolButton(central);
	g.wButtonB->setText("&B");
	g.wButtonB->setCheckable(true);
	hbox->addWidget(g.wButtonB);
	g.wButtonI = new QToolButton(central);
	g.wButtonI->setText("&I");
	g.wButtonI->setCheckable(true);
	hbox->addWidget(g.wButtonI);
	g.wButtonU = new QToolButton(central);
	g.wButtonU->setText("&U");
	g.wButtonU->setCheckable(true);
	hbox->addWidget(g.wButtonU);

	g.wStatus = new QLabel(central);
	g.wStatus->setAlignment(
		Qt::AlignRight | Qt::AlignTrailing | Qt::AlignVCenter);
	hbox->addWidget(g.wStatus);

	g.wButtonLog = new QToolButton(central);
	g.wButtonLog->setText("&Log");
	g.wButtonLog->setCheckable(true);
	QObject::connect(g.wButtonLog, &QToolButton::clicked,
		[]([[maybe_unused]] bool checked) { buffer_toggle_log(); });
	hbox->addWidget(g.wButtonLog);

	g.wButtonDown = new QToolButton(central);
	g.wButtonDown->setIcon(
		QApplication::style()->standardIcon(QStyle::SP_ArrowDown));
	g.wButtonDown->setToolButtonStyle(Qt::ToolButtonIconOnly);
	QObject::connect(g.wButtonDown, &QToolButton::clicked,
		[]([[maybe_unused]] bool checked) { buffer_scroll_to_bottom(); });
	hbox->addWidget(g.wButtonDown);
	vbox->addLayout(hbox);

	g.wInput = new InputEdit(central);
	g.wInput->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Minimum);
	g.wInput->setMaximumHeight(50);
	vbox->addWidget(g.wInput);

	// TODO(p): Figure out why this is not reliable.
	QObject::connect(g.wInput, &QTextEdit::currentCharFormatChanged,
		[](const QTextCharFormat &format) {
			g.wButtonB->setChecked(format.fontWeight() >= QFont::Bold);
			g.wButtonI->setChecked(format.fontItalic());
			g.wButtonU->setChecked(format.fontUnderline());
		});

	QObject::connect(g.wButtonB, &QToolButton::clicked,
		[](bool checked) {
			auto cursor = g.wInput->textCursor();
			auto format = cursor.charFormat();
			format.setFontWeight(checked ? QFont::Bold : QFont::Normal);
			cursor.mergeCharFormat(format);
			g.wInput->setTextCursor(cursor);
		});
	QObject::connect(g.wButtonI, &QToolButton::clicked,
		[](bool checked) {
			auto cursor = g.wInput->textCursor();
			auto format = cursor.charFormat();
			format.setFontItalic(checked);
			cursor.mergeCharFormat(format);
			g.wInput->setTextCursor(cursor);
		});
	QObject::connect(g.wButtonU, &QToolButton::clicked,
		[](bool checked) {
			auto cursor = g.wInput->textCursor();
			auto format = cursor.charFormat();
			format.setFontUnderline(checked);
			cursor.mergeCharFormat(format);
			g.wInput->setTextCursor(cursor);
		});

	central->setLayout(vbox);
	g.wMain->setCentralWidget(central);
	g.wMain->show();
}

static void
build_connect_dialog()
{
	auto dialog = g.wConnectDialog = new QDialog(g.wMain);
	dialog->setModal(true);
	dialog->setWindowTitle("Connect to relay");

	auto layout = new QFormLayout();
	g.wConnectHost = new QLineEdit(dialog);
	layout->addRow("&Host:", g.wConnectHost);
	g.wConnectPort = new QLineEdit(dialog);
	auto validator = new QIntValidator(0, 0xffff, g.wConnectDialog);
	g.wConnectPort->setValidator(validator);
	layout->addRow("&Port:", g.wConnectPort);

	auto buttons = new QDialogButtonBox(dialog);
	buttons->addButton(new QPushButton("&Connect", buttons),
		QDialogButtonBox::AcceptRole);
	buttons->addButton(new QPushButton("&Exit", buttons),
		QDialogButtonBox::RejectRole);
	QObject::connect(buttons, &QDialogButtonBox::accepted,
		dialog, &QDialog::accept);
	QObject::connect(buttons, &QDialogButtonBox::rejected,
		dialog, &QDialog::reject);

	auto vbox = new QVBoxLayout();
	vbox->addLayout(layout);
	vbox->addWidget(buttons);
	dialog->setLayout(vbox);
}

// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

static std::vector<size_t>
rotated_buffers()
{
	std::vector<size_t> rotated(g.buffers.size());
	size_t start = 0;
	for (auto it = g.buffers.begin(); it != g.buffers.end(); ++it)
		if (it->buffer_name == g.buffer_current) {
			start = it - g.buffers.begin();
			break;
		}
	for (auto &index : rotated)
		index = ++start % g.buffers.size();
	return rotated;
}

static void
bind_shortcuts()
{
	auto previous_buffer = [] {
		auto rotated = rotated_buffers();
		if (rotated.size() > 0) {
			size_t i = (rotated.back() ? rotated.back() : g.buffers.size()) - 1;
			buffer_activate(g.buffers[i].buffer_name);
		}
	};
	auto next_buffer = [] {
		auto rotated = rotated_buffers();
		if (rotated.size() > 0)
			buffer_activate(g.buffers[rotated.front()].buffer_name);
	};
	auto switch_buffer = [] {
		if (auto b = buffer_by_name(g.buffer_last))
			buffer_activate(b->buffer_name);
	};
	auto goto_highlight = [] {
		for (auto i : rotated_buffers())
			if (g.buffers[i].highlighted) {
				buffer_activate(g.buffers[i].buffer_name);
				break;
			}
	};
	auto goto_activity = [] {
		for (auto i : rotated_buffers())
			if (g.buffers[i].new_messages) {
				buffer_activate(g.buffers[i].buffer_name);
				break;
			}
	};
	auto toggle_unimportant = [] {
		if (auto b = buffer_by_name(g.buffer_current))
			buffer_toggle_unimportant(b->buffer_name);
	};

	new QShortcut(QKeyCombination(Qt::ControlModifier, Qt::Key_Tab),
		g.wMain, switch_buffer);
	new QShortcut(QKeyCombination(Qt::AltModifier, Qt::Key_Tab),
		g.wMain, switch_buffer);

	new QShortcut(QKeyCombination(Qt::NoModifier, Qt::Key_F5),
		g.wMain, previous_buffer);
	new QShortcut(QKeyCombination(Qt::AltModifier, Qt::Key_PageUp),
		g.wMain, previous_buffer);
	new QShortcut(QKeyCombination(Qt::ControlModifier, Qt::Key_PageUp),
		g.wMain, previous_buffer);
	new QShortcut(QKeyCombination(Qt::NoModifier, Qt::Key_F6),
		g.wMain, next_buffer);
	new QShortcut(QKeyCombination(Qt::AltModifier, Qt::Key_PageDown),
		g.wMain, next_buffer);
	new QShortcut(QKeyCombination(Qt::ControlModifier, Qt::Key_PageDown),
		g.wMain, next_buffer);

	new QShortcut(QKeyCombination(Qt::AltModifier, Qt::Key_A),
		g.wMain, goto_activity);
	new QShortcut(QKeyCombination(Qt::AltModifier, Qt::Key_Exclam),
		g.wMain, goto_highlight);
	new QShortcut(QKeyCombination(Qt::AltModifier, Qt::Key_H),
		g.wMain, toggle_unimportant);
}

int
main(int argc, char *argv[])
{
	QApplication app(argc, argv);
	auto args = app.arguments();
	if (args.size() != 1 && args.size() != 3) {
		QMessageBox::critical(nullptr, "Error", "Usage: xT [HOST PORT]",
			QMessageBox::Close);
		return 1;
	}

	build_main_window();
	build_connect_dialog();
	bind_shortcuts();

	g.date_change_timer = new QTimer(g.wMain);
	g.date_change_timer->setSingleShot(true);
	QObject::connect(g.date_change_timer, &QTimer::timeout, [] {
		bool to_bottom = buffer_at_bottom();
		buffer_print_and_watch_trailing_date_changes();
		if (to_bottom)
			buffer_scroll_to_bottom();
	});

	g.socket = new QTcpSocket(g.wMain);
	QObject::connect(g.socket, &QTcpSocket::errorOccurred,
		relay_process_error);
	QObject::connect(g.socket, &QTcpSocket::connected,
		relay_process_connected);
	QObject::connect(g.socket, &QTcpSocket::readyRead,
		relay_process_ready);
	if (args.size() == 3) {
		g.host = args[1];
		g.port = args[2];
		g.socket->connectToHost(g.host, g.port.toUShort());
	} else {
		// Allow it to center on its parent, which must be realized.
		while (!g.wMain->windowHandle()->isExposed())
			app.processEvents();
		QTimer::singleShot(0, relay_show_dialog);
	}

	int result = app.exec();
	delete g.wMain;
	return result;
}

// Normally, QObjects should be placed in header files, which we don't do.
#include "xT.moc"