/*
 * xW.cpp: Win32 frontend for xC
 *
 * Copyright (c) 2023 - 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 "xW-resources.h"
#include "config.h"

#include <winsock2.h>
#include <ws2tcpip.h>

#define WIN32_LEAN_AND_MEAN
#include <windows.h>
#include <commctrl.h>
#include <richedit.h>
#undef ERROR
#undef REGISTERED

#include <algorithm>
#include <clocale>
#include <ctime>
#include <functional>
#include <map>
#include <string>

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

struct BufferLineItem {
	CHARFORMAT2 format = {};
	std::wstring 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 {
	std::wstring buffer_name;
	bool hide_unimportant = {};
	Relay::BufferKind kind = {};
	std::wstring server_name;
	std::vector<BufferLine> lines;

	// Channel:

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

	// Stats:

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

	// Input:

	std::wstring input;
	DWORD input_start = {};
	DWORD input_end = {};
	std::vector<std::wstring> history;
	size_t history_at = {};
};

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

struct {
	HWND hwndMain;                      ///< Main program window
	HWND hwndTopic;                     ///< static: channel topic
	HWND hwndBufferList;                ///< listbox: buffer list
	HWND hwndBuffer;                    ///< richedit: buffer backlog
	HWND hwndBufferLog;                 ///< edit: buffer log
	HWND hwndPrompt;                    ///< static: user name, etc.
	HWND hwndStatus;                    ///< static: buffer name, etc.
	HWND hwndInput;                     ///< edit: user input

	HWND hwndLastFocused;               ///< For Alt+Tab, e.g.
	HANDLE date_change_timer;           ///< Waitable timer for day changes

	HICON hicon;                        ///< Normal program icon
	HICON hiconHighlighted;             ///< Highlighted program icon

	HFONT hfont;                        ///< Normal variant of the UI font
	HFONT hfontBold;                    ///< Bold variant of the UI font

	LOGFONT fontlog;                    ///< UI font characteristics
	LONG font_height;                   ///< UI font height in pixels

	// Networking:

	std::wstring host;                  ///< Host as given by user
	std::wstring port;                  ///< Port/service as given by user

	addrinfoW *addresses;               ///< GetAddrInfo() result
	addrinfoW *addresses_iterator;      ///< Currently processed address
	SOCKET socket;                      ///< Relay socket
	WSAEVENT socket_event;              ///< Relay socket event
	HANDLE flush_event;                 ///< Write buffer has new data
	std::vector<uint8_t> write_buffer;  ///< Write buffer
	std::vector<uint8_t> read_buffer;   ///< Read buffer

	// Relay protocol:

	uint32_t command_seq;               ///< Outgoing message counter

	std::map<uint32_t, Callback> command_callbacks;

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

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

static void
show_error_message(const wchar_t *message)
{
	MessageBox(g.hwndMain, message, NULL, MB_ICONERROR | MB_OK | MB_APPLMODAL);
}

static std::wstring
format_error_message(int err)
{
	wchar_t *message = NULL;
	if (!FormatMessageW(FORMAT_MESSAGE_ALLOCATE_BUFFER |
			FORMAT_MESSAGE_FROM_SYSTEM | FORMAT_MESSAGE_IGNORE_INSERTS,
			NULL, err, 0, (LPWSTR) &message, 0, NULL))
		return std::to_wstring(err);

	std::wstring copy = message;
	LocalFree(message);
	return copy;
}

static std::wstring
window_get_text(HWND hWnd)
{
	int length = GetWindowTextLength(hWnd);
	std::wstring buffer(length, {});
	GetWindowText(hWnd, buffer.data(), length + 1);
	return buffer;
}

static void
beep()
{
	if (!PlaySound(MAKEINTRESOURCE(IDR_BEEP),
			GetModuleHandle(NULL), SND_ASYNC | SND_RESOURCE))
		Beep(800, 100);
}

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

static bool
relay_try_read(std::wstring &error)
{
	auto &r = g.read_buffer;
	char buffer[8192] = {};
	int err = {};
	while (true) {
		int n = recv(g.socket, buffer, sizeof buffer, 0);
		if (!n) {
			error = L"Server closed the connection.";
			return false;
		} else if (n != SOCKET_ERROR) {
			r.insert(r.end(), buffer, buffer + n);
		} else if ((err = WSAGetLastError()) != WSAEWOULDBLOCK) {
			error = format_error_message(err);
			return false;
		} else {
			break;
		}
	}
	return true;
}

static bool
relay_try_write(std::wstring &error)
{
	ResetEvent(g.flush_event);

	auto &w = g.write_buffer;
	int err = {};
	while (!w.empty()) {
		int n = send(g.socket,
			reinterpret_cast<const char *>(w.data()), w.size(), 0);
		if (n != SOCKET_ERROR) {
			w.erase(w.begin(), w.begin() + n);
		} else if ((err = WSAGetLastError()) != WSAEWOULDBLOCK) {
			error = format_error_message(err);
			return false;
		} else {
			break;
		}
	}
	return true;
}

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);

	uint32_t len = htonl(w.data.size());
	uint8_t *prefix = reinterpret_cast<uint8_t *>(&len);
	g.write_buffer.insert(g.write_buffer.end(), prefix, prefix + sizeof len);
	g.write_buffer.insert(g.write_buffer.end(), w.data.begin(), w.data.end());

	// There doesn't seem to be a way to cause FD_WRITE without first
	// unsuccessfully trying to send some data, but we don't want to
	// handle any errors at this level.
	SetEvent(g.flush_event);
}

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

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

static bool
buffer_at_bottom()
{
	// It is created with this style, and should retain it indefinitely,
	// however this check works. It is necessary because when richedit
	// hides its scrollbar, it does not bother resetting its values.
	if (!(GetWindowLong(g.hwndBuffer, GWL_STYLE) & WS_VSCROLL))
		return true;

	SCROLLINFO si = {};
	si.cbSize = sizeof si;
	si.fMask = SIF_ALL;
	GetScrollInfo(g.hwndBuffer, SB_VERT, &si);
	return si.nPos + (int) si.nPage >= si.nMax;
}

static void
buffer_scroll_to_bottom()
{
	SendMessage(g.hwndBuffer, EM_SCROLL, SB_BOTTOM, 0);
}

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

static void
refresh_icon()
{
	HICON icon = g.hicon;
	for (const auto &b : g.buffers)
		if (b.highlighted)
			icon = g.hiconHighlighted;

	// XXX: This may not change the taskbar icon.
	SendMessage(g.hwndMain, WM_SETICON, ICON_SMALL, (LPARAM) icon);
	SendMessage(g.hwndMain, WM_SETICON, ICON_BIG, (LPARAM) icon);
}

static void
richedit_replacesel(HWND hWnd, const CHARFORMAT2 *cf, const wchar_t *text)
{
	SendMessage(hWnd, EM_SETCHARFORMAT, SCF_SELECTION, (LPARAM) cf);
	SendMessage(hWnd, EM_REPLACESEL, FALSE, (LPARAM) text);
}

static void
refresh_topic(const std::vector<BufferLineItem> &topic)
{
	SetWindowText(g.hwndTopic, L"");
	for (const auto &it : topic)
		richedit_replacesel(g.hwndTopic, &it.format, it.text.c_str());
}

static void
refresh_buffer_list()
{
	InvalidateRect(g.hwndBufferList, NULL, TRUE);
}

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

static void
refresh_prompt()
{
	std::wstring prompt;
	auto b = buffer_by_name(g.buffer_current);
	if (!b) {
		prompt = L"Synchronizing...";
	} else if (auto server = g.servers.find(b->server_name);
			server != g.servers.end()) {
		prompt = server->second.user;
		if (!server->second.user_modes.empty())
			prompt += L"(" + server->second.user_modes + L")";
		if (prompt.empty())
			prompt = L"(" + server_state_to_string(server->second.state) + L")";
	}
	SetWindowText(g.hwndPrompt, prompt.c_str());
}

static void
refresh_status()
{
	std::wstring status;
	if (!buffer_at_bottom())
		status += L"🡇 ";

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

	// Buffer scrolling would cause a ton of flickering redraws.
	if (window_get_text(g.hwndStatus) != status)
		SetWindowText(g.hwndStatus, status.c_str());
}

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() &&
		!IsIconic(g.hwndMain) && !IsWindowVisible(g.hwndBufferLog)) {
		b->highlighted = false;
		refresh_icon();
		refresh_buffer_list();
	}
}

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

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

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

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

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

	std::wstring filtered;
	for (auto wch : log) {
		if (wch == L'\n')
			filtered += L"\r\n";
		else
			filtered += wch;
	}

	SetWindowText(g.hwndBufferLog, filtered.c_str());
	ShowWindow(g.hwndBuffer, SW_HIDE);
	ShowWindow(g.hwndBufferLog, SW_SHOW);
}

static void
buffer_toggle_log()
{
	if (IsWindowVisible(g.hwndBufferLog)) {
		ShowWindow(g.hwndBufferLog, SW_HIDE);
		ShowWindow(g.hwndBuffer, SW_SHOW);
		SetWindowText(g.hwndBufferLog, L"");

		recheck_highlighted();
		return;
	}

	auto log = new Relay::CommandData_BufferLog();
	log->buffer_name = g.buffer_current;
	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));
	});
}

// --- Rich Edit formatting ----------------------------------------------------

static COLORREF
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 RGB(r * 0x11, g * 0x11, b * 0x11);
	}
	if (color >= 216) {
		uint8_t g = 8 + (color - 216) * 10;
		return RGB(g, g, g);
	}

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

static CHARFORMAT2
default_charformat()
{
	// Everything we leave out will be kept as it was.
	// So, e.g., there is no way to "unset" a monospace font.
	CHARFORMAT2 reset = {};
	reset.cbSize = sizeof reset;
	reset.dwMask = CFM_BOLD | CFM_ITALIC | CFM_UNDERLINE | CFM_STRIKEOUT |
		CFM_COLOR | CFM_BACKCOLOR | CFM_FACE | CFM_LINK;
	reset.dwEffects = CFE_AUTOCOLOR | CFE_AUTOBACKCOLOR;
	lstrcpyn(reset.szFaceName, g.fontlog.lfFaceName, sizeof reset.szFaceName);
	return reset;
}

static void
convert_item_formatting(Relay::ItemData *item, CHARFORMAT2 &cf, bool &inverse)
{
	if (dynamic_cast<Relay::ItemData_Reset *>(item)) {
		cf = default_charformat();
		inverse = false;
	} else if (dynamic_cast<Relay::ItemData_FlipBold *>(item)) {
		cf.dwEffects ^= CFE_BOLD;
	} else if (dynamic_cast<Relay::ItemData_FlipItalic *>(item)) {
		cf.dwEffects ^= CFE_ITALIC;
	} else if (dynamic_cast<Relay::ItemData_FlipUnderline *>(item)) {
		cf.dwEffects ^= CFE_UNDERLINE;
	} else if (dynamic_cast<Relay::ItemData_FlipCrossedOut *>(item)) {
		cf.dwEffects ^= CFE_STRIKEOUT;
	} else if (dynamic_cast<Relay::ItemData_FlipInverse *>(item)) {
		inverse = !inverse;
	} else if (dynamic_cast<Relay::ItemData_FlipMonospace *>(item)) {
		auto reset = default_charformat();
		const auto face = !lstrcmp(cf.szFaceName, reset.szFaceName)
			? L"Courier New"
			: reset.szFaceName;
		lstrcpyn(cf.szFaceName, face, sizeof cf.szFaceName);
	} else if (auto data = dynamic_cast<Relay::ItemData_FgColor *>(item)) {
		if (data->color < 0) {
			cf.dwEffects |= CFE_AUTOCOLOR;
		} else {
			cf.dwEffects &= ~CFE_AUTOCOLOR;
			cf.crTextColor = convert_color(data->color);
		}
	} else if (auto data = dynamic_cast<Relay::ItemData_BgColor *>(item)) {
		if (data->color < 0) {
			cf.dwEffects |= CFE_AUTOBACKCOLOR;
		} else {
			cf.dwEffects &= ~CFE_AUTOBACKCOLOR;
			cf.crBackColor = convert_color(data->color);
		}
	}
}

static std::vector<BufferLineItem>
convert_items(const std::vector<std::unique_ptr<Relay::ItemData>> &items)
{
	CHARFORMAT2 cf = default_charformat();
	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;
		}

		BufferLineItem item = {};
		item.format = cf;
		item.text = text->text;
		if (inverse) {
			std::swap(item.format.crTextColor, item.format.crBackColor);
			item.format.dwEffects &= ~(CFE_AUTOCOLOR | CFE_AUTOBACKCOLOR);
			if (cf.dwEffects & CFE_AUTOCOLOR)
				item.format.crBackColor = GetSysColor(COLOR_WINDOWTEXT);
			if (cf.dwEffects & CFE_AUTOBACKCOLOR)
				item.format.crTextColor = GetSysColor(COLOR_WINDOW);
		}
		result.push_back(std::move(item));
	}
	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 tm &last, const tm &current)
{
	if (last.tm_year == current.tm_year &&
		last.tm_mon == current.tm_mon &&
		last.tm_mday == current.tm_mday)
		return;

	wchar_t buffer[64] = {};
	wcsftime(buffer, sizeof buffer, &L"\n%x"[sameline], &current);
	sameline = false;

	CHARFORMAT2 cf = default_charformat();
	cf.dwEffects |= CFE_BOLD;
	richedit_replacesel(g.hwndBuffer, &cf, buffer);
}

static LONG
buffer_reset_selection()
{
	CHARRANGE cr = {};
	cr.cpMin = cr.cpMax = GetWindowTextLength(g.hwndBuffer);
	SendMessage(g.hwndBuffer, EM_EXSETSEL, 0, (LPARAM) &cr);
	return cr.cpMin;
}

static struct tm
buffer_localtime(time_t time)
{
	// This isn't critical, so let it fail quietly.
	struct tm result = {};
	(void) localtime_s(&result, &time);
	return result;
}

static void
buffer_print_and_watch_trailing_date_changes()
{
	time_t current_unix = time(NULL);
	tm current = buffer_localtime(current_unix);
	auto b = buffer_by_name(g.buffer_current);
	if (b && !b->lines.empty()) {
		tm last = buffer_localtime(b->lines.back().when / 1000);
		bool sameline = !buffer_reset_selection();
		buffer_print_date_change(sameline, last, current);
	}

	current.tm_sec = current.tm_min = current.tm_hour = 0;
	current.tm_mday++;
	current.tm_isdst = -1;
	const time_t midnight = mktime(&current);
	if (midnight == (time_t) -1 || midnight < current_unix)
		return;

	// Note that after printing the first trailing update,
	// follow-up updates may be duplicated if timer events arrive too early.
	LARGE_INTEGER li = {};
	li.QuadPart = (midnight - current_unix + 1) * -10000000LL;
	SetWaitableTimer(g.date_change_timer, &li, 0, NULL, NULL, FALSE);
}

static void
buffer_print_line(std::vector<BufferLine>::const_iterator begin,
	std::vector<BufferLine>::const_iterator line)
{
	tm current = buffer_localtime(line->when / 1000);
	tm last = buffer_localtime(
		line == begin ? time(NULL) : (line - 1)->when / 1000);

	// The Rich Edit control makes the window cursor transparent
	// each time you add an independent newline character. Avoid that.
	// (Sadly, this also makes Windows 7 end lines with a bogus space that
	// has the CHARFORMAT2 of what we flush that newline together with.)
	bool sameline = !buffer_reset_selection();
	buffer_print_date_change(sameline, last, current);

	wchar_t buffer[64] = {};
	wcsftime(buffer, sizeof buffer, &L"\n%H:%M:%S"[sameline], &current);

	CHARFORMAT2 cf = default_charformat();
	cf.dwEffects &= ~(CFE_AUTOCOLOR | CFE_AUTOBACKCOLOR);
	cf.crTextColor = RGB(0xbb, 0xbb, 0xbb);
	cf.crBackColor = RGB(0xf8, 0xf8, 0xf8);
	richedit_replacesel(g.hwndBuffer, &cf, buffer);
	cf = default_charformat();
	richedit_replacesel(g.hwndBuffer, &cf, L" ");

	// Tabstops won't quite help us here, since we need it centred.
	std::wstring prefix;
	CHARFORMAT2 pcf = default_charformat();
	lstrcpyn(pcf.szFaceName, L"Courier New", sizeof pcf.szFaceName);
	// This looks better, but it may trigger a repaint bug in richedit.
#if 1
	pcf.dwEffects |= CFE_BOLD;
#endif
	switch (line->rendition) {
	break; case Relay::Rendition::BARE:
	break; case Relay::Rendition::INDENT:
		prefix = L"    ";
	break; case Relay::Rendition::STATUS:
		prefix = L" -  ";
	break; case Relay::Rendition::ERROR:
		prefix = L"=!= ";
		pcf.dwEffects &= ~CFE_AUTOCOLOR;
		pcf.crTextColor = RGB(0xff, 0, 0);
	break; case Relay::Rendition::JOIN:
		prefix = L"——> ";
		pcf.dwEffects &= ~CFE_AUTOCOLOR;
		pcf.crTextColor = RGB(0, 0x88, 0);
	break; case Relay::Rendition::PART:
		prefix = L"<—— ";
		pcf.dwEffects &= ~CFE_AUTOCOLOR;
		pcf.crTextColor = RGB(0x88, 0, 0);
	break; case Relay::Rendition::ACTION:
		prefix = L" *  ";
		pcf.dwEffects &= ~CFE_AUTOCOLOR;
		pcf.crTextColor = RGB(0x88, 0, 0);
	}

	if (line->leaked) {
		pcf.dwEffects &= ~CFE_AUTOCOLOR;
		pcf.crTextColor = GetSysColor(COLOR_GRAYTEXT);
		if (!prefix.empty())
			richedit_replacesel(g.hwndBuffer, &pcf, prefix.c_str());

		for (auto it : line->items) {
			it.format.dwEffects &= ~CFE_AUTOCOLOR;
			it.format.crTextColor = GetSysColor(COLOR_GRAYTEXT);
			it.format.dwEffects |= CFE_AUTOBACKCOLOR;
			richedit_replacesel(g.hwndBuffer, &it.format, it.text.c_str());
		}
	} else {
		if (!prefix.empty())
			richedit_replacesel(g.hwndBuffer, &pcf, prefix.c_str());
		for (const auto &it : line->items)
			richedit_replacesel(g.hwndBuffer, &it.format, it.text.c_str());
	}
}

static void
buffer_print_separator()
{
	bool sameline = !buffer_reset_selection();

	CHARFORMAT2 format = default_charformat();
	format.dwEffects &= ~CFE_AUTOCOLOR;
	format.crTextColor = RGB(0xff, 0x5f, 0x00);
	richedit_replacesel(g.hwndBuffer, &format, &L"\n---"[sameline]);
}

static void
refresh_buffer(const Buffer &b)
{
	HCURSOR oldCursor = SetCursor(LoadCursor(NULL, IDC_WAIT));
	SendMessage(g.hwndBuffer, WM_SETREDRAW, (WPARAM) FALSE, 0);
	SetWindowText(g.hwndBuffer, L"");

	// PFM_OFFSET could also be used, but the result isn't very nice.
	//
	// PFM_BORDER is not implemented, at most we can try to construct
	// an OLE object the width of the screen and see how it clips
	// (this is a lot of code).
	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();
	// We will get a scroll event, so no need to recheck_highlighted() here.

	SendMessage(g.hwndBuffer, WM_SETREDRAW, (WPARAM) TRUE, 0);
	InvalidateRect(g.hwndBuffer, NULL, TRUE);
	SetCursor(oldCursor);
}

// --- 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);
	// XXX: It would be great if it didn't autoscroll when focused.
	bool to_bottom = display &&
		(buffer_at_bottom() || GetFocus() == g.hwndBuffer);
	bool visible = display &&
		to_bottom &&
		!IsIconic(g.hwndMain) &&
		!IsWindowVisible(g.hwndBufferLog);
	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 = data.buffer_name;
			SendMessage(g.hwndBufferList, LB_ADDSTRING, 0, 0);
		}

		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 = context->server_name;
		if (auto context = dynamic_cast<Relay::BufferContext_Channel *>(
				data.context.get())) {
			b->server_name = context->server_name;
			b->modes = context->modes;
			b->topic = convert_items(context->topic);
		}
		if (auto context = dynamic_cast<Relay::BufferContext_PrivateMessage *>(
				data.context.get()))
			b->server_name = 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();
		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;

		b->buffer_name = data.new_;

		if (data.buffer_name == g.buffer_current) {
			g.buffer_current = data.new_;
			refresh_status();
		}
		refresh_buffer_list();
		if (data.buffer_name == g.buffer_last)
			g.buffer_last = data.new_;
		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();
		SendMessage(g.hwndBufferList, LB_DELETESTRING, index, 0);
		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 = 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 = window_get_text(g.hwndInput);
			SendMessage(g.hwndInput, EM_GETSEL,
				(WPARAM) &old->input_start, (LPARAM) &old->input_end);

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

		if (IsWindowVisible(g.hwndBufferLog))
			buffer_toggle_log();
		if (!IsIconic(g.hwndMain))
			b->highlighted = false;
		SendMessage(g.hwndBufferList, LB_SETCURSEL, b - g.buffers.data(), 0);

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

		SetWindowText(g.hwndInput, b->input.c_str());
		SendMessage(g.hwndInput, EM_SETSEL,
			(WPARAM) b->input_start, (LPARAM) b->input_end);
		SetFocus(g.hwndInput);
		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(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);
		if (!g.servers.count(data.server_name))
			g.servers.emplace(data.server_name, Server());

		auto &server = g.servers.at(data.server_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 = registered->user;
			server.user_modes = registered->user_modes;
		}

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

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

static bool
relay_process_buffer(std::wstring &error)
{
	auto &b = g.read_buffer;
	size_t offset = 0;
	while (true) {
		LibertyXDR::Reader r;
		r.data = b.data() + offset;
		r.length = b.size() - offset;

		uint32_t frame_len = 0;
		if (!r.read(frame_len))
			break;

		r.length = std::min<size_t>(r.length, frame_len);
		if (r.length < frame_len)
			break;

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

		relay_process_message(m);
		offset += sizeof frame_len + frame_len;
	}

	b.erase(b.begin(), b.begin() + offset);
	return true;
}

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

static void
relay_destroy_socket()
{
	closesocket(g.socket);
	g.socket = INVALID_SOCKET;
	WSACloseEvent(g.socket_event);
	g.socket_event = NULL;

	g.read_buffer.clear();
	g.write_buffer.clear();
}

static bool
relay_connect_step(std::wstring& error)
{
	addrinfoW *&p = g.addresses_iterator;
	while (p) {
		g.socket = socket(p->ai_family, p->ai_socktype, p->ai_protocol);
		if (g.socket != INVALID_SOCKET)
			break;
		p = p->ai_next;
	}
	if (!p) {
		error = L"Failed to create a socket.";
		return false;
	}

	g.socket_event = WSACreateEvent();
	if (WSAEventSelect(g.socket, g.socket_event,
			FD_CONNECT | FD_READ | FD_WRITE | FD_CLOSE))
		error = format_error_message(WSAGetLastError());
	else if (!connect(g.socket, p->ai_addr, (int) p->ai_addrlen))
		error = L"Connection succeeded unexpectedly early.";
	else if (int err = WSAGetLastError(); err != WSAEWOULDBLOCK)
		error = format_error_message(err);
	else
		return true;

	relay_destroy_socket();
	return false;
}

static bool
relay_process_connect_event(int err, std::wstring &error)
{
	addrinfoW *&p = g.addresses_iterator;
	if (err) {
		relay_destroy_socket();
		if (!(p = p->ai_next)) {
			error = L"Connection failed.";
			return false;
		}
		return relay_connect_step(error);
	}

	g.read_buffer.clear();
	g.write_buffer.clear();

	auto hello = new Relay::CommandData_Hello();
	hello->version = Relay::VERSION;
	relay_send(hello);
	// The message will be flushed at the upcoming FD_WRITE notification.
	return true;
}

static bool
relay_process_socket_event(int event, int err, std::wstring &error)
{
	if (err) {
		relay_destroy_socket();
		error = format_error_message(err);
		return false;
	}

	switch (event) {
	case FD_READ:
		if (!relay_try_read(error) ||
			!relay_process_buffer(error) ||
			!relay_try_write(error))
			return false;
		break;
	case FD_WRITE:
		if (!relay_try_write(error))
			return false;
		break;
	case FD_CLOSE:
		// Handling this seems excessive, since we also get EOF while reading.
		// But we may not receive an FD_READ notification for it.
		error = L"Connection closed.";
		return false;
	}
	return true;
}

static bool
relay_process_socket_events(std::wstring &error)
{
	WSANETWORKEVENTS wne = {};
	if (WSAEnumNetworkEvents(g.socket, g.socket_event, &wne)) {
		error = format_error_message(WSAGetLastError());
		return false;
	}

	// TODO(p): Offer reconnecting.
	// TODO(p): Consider disabling UI controls while disconnected.
	if (wne.lNetworkEvents & FD_CONNECT &&
		!relay_process_connect_event(wne.iErrorCode[FD_CONNECT_BIT], error))
		return false;

	for (auto bit : {FD_READ_BIT, FD_WRITE_BIT, FD_CLOSE_BIT})
		if ((wne.lNetworkEvents & (1 << bit)) &&
			!relay_process_socket_event((1 << bit), wne.iErrorCode[bit], error))
			return false;
	return true;
}

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

static void
input_set_contents(const std::wstring &input)
{
	SetWindowText(g.hwndInput, input.c_str());
	if (input.size())
		SendMessage(g.hwndInput, EM_SETSEL, input.size(), input.size());
}

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;
	input->text = window_get_text(g.hwndInput);

	// 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(input->text);
	b->history_at = b->history.size();
	input_set_contents({});

	relay_send(input);
	return true;
}

struct InputStamp {
	DWORD start = {};
	DWORD end = {};
	std::wstring input;
};

static InputStamp
input_stamp()
{
	DWORD start = {}, end = {};
	SendMessage(g.hwndInput, EM_GETSEL, (WPARAM) &start, (LPARAM) &end);
	return {start, end, window_get_text(g.hwndInput)};
}

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

	std::string utf8;
	if (!LibertyXDR::wstring_to_utf8(state.input.substr(0, state.start), utf8))
		return;
	std::wstring preceding;
	if (!LibertyXDR::utf8_to_wstring(
			reinterpret_cast<const uint8_t *>(utf8.c_str()), response->start,
			preceding))
		return;

	if (response->completions.size() > 0) {
		auto insert = response->completions.at(0);
		if (response->completions.size() == 1)
			insert += L" ";

		SendMessage(g.hwndInput, EM_SETSEL, preceding.length(), state.end);
		SendMessage(g.hwndInput, EM_REPLACESEL, TRUE, (LPARAM) insert.c_str());
	}

	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;

	std::string utf8;
	if (!LibertyXDR::wstring_to_utf8(state.input.substr(0, state.start), utf8))
		return false;

	auto complete = new Relay::CommandData_BufferComplete();
	complete->buffer_name = g.buffer_current;
	complete->text = state.input;
	complete->position = utf8.length();
	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 = window_get_text(g.hwndInput);
	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;
}

static boolean
input_wants(const MSG *message)
{
	switch (message->message) {
	case WM_KEYDOWN:
		// Shift-Tab can go to the dialog manager.
		return message->wParam == VK_RETURN ||
			(message->wParam == VK_TAB && !(GetKeyState(VK_SHIFT) & 0x8000));
	case WM_SYSCHAR:
		switch (message->wParam) {
		case 'p': return true;
		case 'n': return true;
		}
	}
	return false;
}

static LRESULT CALLBACK
input_proc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam,
	UINT_PTR uIdSubclass, [[maybe_unused]] DWORD_PTR dwRefData)
{
	switch (uMsg) {
	case WM_GETDLGCODE:
	{
		// https://devblogs.microsoft.com/oldnewthing/20070627-00/?p=26243
		LRESULT lResult = DefSubclassProc(hWnd, uMsg, wParam, lParam);
		if (lParam && input_wants((MSG *) lParam))
			lResult |= DLGC_WANTMESSAGE;
		return lResult;
	}
	case WM_SYSCHAR:
		// TODO(p): Emacs-style cursor movement shortcuts.
		switch (wParam) {
		case 'p':
			if (input_up())
				return 0;
			break;
		case 'n':
			if (input_down())
				return 0;
			break;
		}
		break;
	case WM_KEYDOWN:
	{
		HWND scrollable = IsWindowVisible(g.hwndBufferLog)
			? g.hwndBufferLog
			: g.hwndBuffer;

		switch (wParam) {
		case VK_UP:
			if (input_up())
				return 0;
			break;
		case VK_DOWN:
			if (input_down())
				return 0;
			break;
		case VK_PRIOR:
			SendMessage(scrollable, EM_SCROLL, SB_PAGEUP, 0);
			return 0;
		case VK_NEXT:
			SendMessage(scrollable, EM_SCROLL, SB_PAGEDOWN, 0);
			return 0;
		}
		break;
	}
	case WM_CHAR:
	{
		// This could be implemented more precisely, but it will do.
		relay_send(new Relay::CommandData_Active());

		switch (wParam) {
		case VK_RETURN:
			if (!input_submit())
				break;
			return 0;
		case VK_TAB:
			if (!input_complete())
				break;
			return 0;
		}
		break;
	}
	case WM_NCDESTROY:
		// https://devblogs.microsoft.com/oldnewthing/20031111-00/?p=41883
		RemoveWindowSubclass(hWnd, input_proc, uIdSubclass);
		break;
	}
	return DefSubclassProc(hWnd, uMsg, wParam, lParam);
}

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

static LRESULT CALLBACK
bufferlist_proc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam,
	UINT_PTR uIdSubclass, [[maybe_unused]] DWORD_PTR dwRefData)
{
	switch (uMsg) {
	case WM_MBUTTONUP:
	{
		POINT p = {LOWORD(lParam), HIWORD(lParam)};
		ClientToScreen(hWnd, &p);
		int index = LBItemFromPt(hWnd, p, FALSE);
		if (wParam || index < 0 || (size_t) index > g.buffers.size())
			break;

		auto input = new Relay::CommandData_BufferInput();
		input->buffer_name = g.buffer_current;
		input->text = L"/buffer close " + g.buffers.at(index).buffer_name;
		relay_send(input);
		return 0;
	}
	case WM_NCDESTROY:
		// https://devblogs.microsoft.com/oldnewthing/20031111-00/?p=41883
		RemoveWindowSubclass(hWnd, bufferlist_proc, uIdSubclass);
		break;
	}
	return DefSubclassProc(hWnd, uMsg, wParam, lParam);
}

static LRESULT CALLBACK
richedit_proc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam,
	UINT_PTR uIdSubclass, [[maybe_unused]] DWORD_PTR dwRefData)
{
	switch (uMsg) {
	case WM_GETDLGCODE:
	{
		// https://devblogs.microsoft.com/oldnewthing/20070627-00/?p=26243
		LRESULT lResult = DefSubclassProc(hWnd, uMsg, wParam, lParam);
		lResult &= ~DLGC_WANTTAB;
		if (lParam &&
			((MSG *) lParam)->message == WM_KEYDOWN &&
			((MSG *) lParam)->wParam == VK_TAB)
			lResult &= ~DLGC_WANTMESSAGE;
		return lResult;
	}
	case WM_VSCROLL:
	{
		// Dragging the scrollbar doesn't result in EN_VSCROLL.
		LRESULT lResult = DefSubclassProc(hWnd, uMsg, wParam, lParam);
		recheck_highlighted();
		refresh_status();
		return lResult;
	}
	case WM_NCDESTROY:
		// https://devblogs.microsoft.com/oldnewthing/20031111-00/?p=41883
		RemoveWindowSubclass(hWnd, richedit_proc, uIdSubclass);
		break;
	}
	return DefSubclassProc(hWnd, uMsg, wParam, lParam);
}

static LRESULT CALLBACK
log_proc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam,
	UINT_PTR uIdSubclass, [[maybe_unused]] DWORD_PTR dwRefData)
{
	switch (uMsg) {
	case WM_GETDLGCODE:
	{
		// https://devblogs.microsoft.com/oldnewthing/20070627-00/?p=26243
		// https://devblogs.microsoft.com/oldnewthing/20031114-00/?p=41823
		LRESULT lResult = DefSubclassProc(hWnd, uMsg, wParam, lParam);
		lResult &= ~(DLGC_WANTTAB | DLGC_HASSETSEL);
		if (lParam &&
			((MSG *) lParam)->message == WM_KEYDOWN &&
			((MSG *) lParam)->wParam == VK_TAB)
			lResult &= ~DLGC_WANTMESSAGE;
		return lResult;
	}
	case WM_NCDESTROY:
		// https://devblogs.microsoft.com/oldnewthing/20031111-00/?p=41883
		RemoveWindowSubclass(hWnd, log_proc, uIdSubclass);
		break;
	}
	return DefSubclassProc(hWnd, uMsg, wParam, lParam);
}

static void
process_resize(UINT w, UINT h)
{
	// Font height, control height (accounts for padding and borders)
	int fh = g.font_height, ch = fh + 10;

	int top = 0;
	MoveWindow(g.hwndTopic, 5, 5, w - 10, fh, FALSE);
	top += ch;

	int bottom = 0;
	MoveWindow(g.hwndInput, 3, h - ch - 3, w - 6, ch, FALSE);
	bottom += ch + 3;
	MoveWindow(g.hwndPrompt, 5, h - bottom - fh - 5, w / 2 - 5, fh, FALSE);
	MoveWindow(g.hwndStatus, w / 2, h - bottom - fh - 5, w / 2 - 5, fh, FALSE);
	bottom += ch;

	bool to_bottom = buffer_at_bottom();
	MoveWindow(g.hwndBufferList, 3, top, 150, h - top - bottom, FALSE);
	MoveWindow(g.hwndBuffer, 156, top, w - 159, h - top - bottom, FALSE);
	MoveWindow(g.hwndBufferLog, 156, top, w - 159, h - top - bottom, FALSE);
	if (to_bottom) {
		buffer_scroll_to_bottom();
	} else {
		recheck_highlighted();
		refresh_status();
	}

	InvalidateRect(g.hwndMain, NULL, TRUE);
}

static void
process_bufferlist_drawitem(PDRAWITEMSTRUCT dis)
{
	// Just always redraw it entirely, disregarding dis->itemAction.
	COLORREF foreground = GetSysColor(dis->itemState & ODS_SELECTED
		? COLOR_HIGHLIGHTTEXT : COLOR_WINDOWTEXT);
	COLORREF background = GetSysColor(dis->itemState & ODS_SELECTED
		? COLOR_HIGHLIGHT : COLOR_WINDOW);
	if (dis->itemState & ODS_DISABLED)
		foreground = GetSysColor(COLOR_GRAYTEXT);

	HFONT oldFont = NULL;
	std::wstring text;
	if (dis->itemID != (UINT) -1 && dis->itemID < g.buffers.size()) {
		const Buffer& b = g.buffers.at(dis->itemID);
		text = b.buffer_name;
		if (b.buffer_name != g.buffer_current && b.new_messages) {
			text += L" (" + std::to_wstring(b.new_messages) + L")";
			oldFont = (HFONT) SelectObject(dis->hDC, g.hfontBold);
		}
		if (b.highlighted)
			foreground = RGB(0xff, 0x5f, 0x00);
	}

	COLORREF oldForeground = SetTextColor(dis->hDC, foreground);
	COLORREF oldBackground = SetBkColor(dis->hDC, background);

	// Old Windows hardcoded two pixels, and so will we.
	ExtTextOut(dis->hDC, dis->rcItem.left + 2, dis->rcItem.top,
		ETO_CLIPPED | ETO_OPAQUE, &dis->rcItem,
		text.c_str(), text.length(), NULL);
	if (oldFont)
		SelectObject(dis->hDC, oldFont);

	SetTextColor(dis->hDC, oldForeground);
	SetBkColor(dis->hDC, oldBackground);

	if (dis->itemState & ODS_FOCUS)
		DrawFocusRect(dis->hDC, &dis->rcItem);
}

static void
process_bufferlist_notification(WORD code)
{
	if (code == LBN_SELCHANGE) {
		auto i = (size_t) SendMessage(g.hwndBufferList, LB_GETCURSEL, 0, 0);
		if (i < g.buffers.size())
			buffer_activate(g.buffers.at(i).buffer_name);
	}
}

static void
process_accelerator(WORD id)
{
	// Buffer indexes rotated to start after the current buffer.
	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();

	auto b = buffer_by_name(g.buffer_current);
	switch (id) {
	case ID_PREVIOUS_BUFFER:
		if (rotated.size() > 0) {
			size_t i = (rotated.back() ? rotated.back() : g.buffers.size()) - 1;
			buffer_activate(g.buffers[i].buffer_name);
		}
		return;
	case ID_NEXT_BUFFER:
		if (rotated.size() > 0)
			buffer_activate(g.buffers[rotated.front()].buffer_name);
		return;
	case ID_SWITCH_BUFFER:
		if (!g.buffer_last.empty())
			buffer_activate(g.buffer_last);
		return;
	case ID_GOTO_HIGHLIGHT:
		for (auto i : rotated)
			if (g.buffers[i].highlighted) {
				buffer_activate(g.buffers[i].buffer_name);
				break;
			}
		return;
	case ID_GOTO_ACTIVITY:
		for (auto i : rotated)
			if (g.buffers[i].new_messages) {
				buffer_activate(g.buffers[i].buffer_name);
				break;
			}
		return;
	case ID_TOGGLE_UNIMPORTANT:
		if (b)
			buffer_toggle_unimportant(b->buffer_name);
		return;
	case ID_DISPLAY_FULL_LOG:
		if (b)
			buffer_toggle_log();
		return;
	}
}

static LRESULT CALLBACK
window_proc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam)
{
	switch (uMsg) {
	case WM_DESTROY:
		PostQuitMessage(0);
		return 0;
	case WM_SIZE:
		process_resize(LOWORD(lParam), HIWORD(lParam));
		return 0;
	case WM_TIMECHANGE:
		_tzset();
		if (auto b = buffer_by_name(g.buffer_current))
			refresh_buffer(*b);
		return 0;
	case WM_SYSCOLORCHANGE:
		// The topic would flicker with WS_EX_TRANSPARENT.
		// The buffer only changed its text colour, not background.
		SendMessage(g.hwndTopic,
			EM_SETBKGNDCOLOR, 0, GetSysColor(COLOR_3DFACE));
		SendMessage(g.hwndBuffer,
			EM_SETBKGNDCOLOR, 1, 0);

		// XXX: This is incomplete, we'd have to run convert_items() again;
		// essentially only COLOR_GRAYTEXT is reloaded in here.
		if (auto b = buffer_by_name(g.buffer_current))
			refresh_buffer(*b);

		// Pass it to all child windows, through DefWindowProc().
		break;
	case WM_ACTIVATE:
		if (LOWORD(wParam) == WA_INACTIVE)
			g.hwndLastFocused = GetFocus();
		else if (g.hwndLastFocused)
			SetFocus(g.hwndLastFocused);
		return 0;
	case WM_MEASUREITEM:
	{
		auto mis = (PMEASUREITEMSTRUCT) lParam;
		mis->itemHeight = g.font_height;
		return TRUE;
	}
	case WM_DRAWITEM:
	{
		auto dis = (PDRAWITEMSTRUCT) lParam;
		if (dis->hwndItem == g.hwndBufferList)
			process_bufferlist_drawitem(dis);
		return TRUE;
	}
	case WM_SYSCOMMAND:
	{
		// We're not deiconified yet, so duplicate recheck_highlighted().
		auto b = buffer_by_name(g.buffer_current);
		if (wParam == SC_RESTORE && b && b->highlighted && buffer_at_bottom() &&
			!IsWindowVisible(g.hwndBufferLog)) {
			b->highlighted = false;
			refresh_icon();
		}
		// Here we absolutely must pass to DefWindowProc().
		break;
	}
	case WM_COMMAND:
		if (!lParam) {
			process_accelerator(LOWORD(wParam));
		} else if (lParam == (LPARAM) g.hwndBufferList) {
			process_bufferlist_notification(HIWORD(wParam));
		} else if (lParam == (LPARAM) g.hwndBuffer &&
				HIWORD(wParam) == EN_VSCROLL) {
			recheck_highlighted();
			refresh_status();
		}
		return 0;
	case WM_NOTIFY:
		switch (((LPNMHDR) lParam)->code) {
		case EN_LINK:
		{
			auto link = (ENLINK *) lParam;
			if (link->msg == WM_LBUTTONUP) {
				TEXTRANGE tr = {};
				tr.chrg = link->chrg;
				tr.lpstrText = new wchar_t[tr.chrg.cpMax - tr.chrg.cpMin + 1]();
				SendMessage(
					link->nmhdr.hwndFrom, EM_GETTEXTRANGE, 0, (LPARAM) &tr);
				ShellExecute(
					NULL, L"open", tr.lpstrText, NULL, NULL, SW_SHOWNORMAL);
				delete[] tr.lpstrText;
			}
			break;
		}
		}
	case DM_GETDEFID:
	case DM_SETDEFID:
		break;
	}
	return DefWindowProc(hWnd, uMsg, wParam, lParam);
}

static INT_PTR CALLBACK
connect_proc(
	HWND hDlg, UINT uMsg, WPARAM wParam, [[maybe_unused]] LPARAM lParam)
{
	switch (uMsg) {
	case WM_INITDIALOG:
		return TRUE;
	case WM_COMMAND:
		switch (LOWORD(wParam)) {
		case IDOK:
		case IDCANCEL:
			g.host = window_get_text(GetDlgItem(hDlg, IDC_HOST));
			g.port = window_get_text(GetDlgItem(hDlg, IDC_PORT));
			EndDialog(hDlg, LOWORD(wParam));
			return TRUE;
		}
	}
	return FALSE;
}

static void
get_font()
{
	// To enable the "Make text bigger" scaling functionality.
	NONCLIENTMETRICS ncm = {};
	ncm.cbSize = sizeof ncm;
	if (SystemParametersInfo(SPI_GETNONCLIENTMETRICS, sizeof ncm, &ncm, 0)) {
		g.hfont = CreateFontIndirect(&ncm.lfMessageFont);

		LOGFONT bold = g.fontlog = ncm.lfMessageFont;
		bold.lfWeight = FW_BOLD;
		g.hfontBold = CreateFontIndirect(&bold);
	}

	if (!g.hfont)
		g.hfont = g.hfontBold = (HFONT) GetStockObject(DEFAULT_GUI_FONT);

	// There doesn't seem to be a better way than through a drawing context.
	HDC hdc = GetDC(NULL);
	HFONT oldFont = (HFONT) SelectObject(hdc, g.hfont);
	TEXTMETRIC tm = {};
	GetTextMetrics(hdc, &tm);
	SelectObject(hdc, oldFont);
	ReleaseDC(NULL, hdc);

	g.font_height = tm.tmHeight;
}

static BOOL CALLBACK
set_font(HWND child, LPARAM font)
{
	SendMessage(child, WM_SETFONT, font, (LPARAM) TRUE);
	return TRUE;
}

static bool
process_messages(HACCEL accelerators)
{
	MSG message = {};
	while (PeekMessage(&message, NULL, 0, 0, TRUE)) {
		if (message.message == WM_QUIT)
			return false;
		if (TranslateAccelerator(g.hwndMain, accelerators, &message))
			continue;

		// https://devblogs.microsoft.com/oldnewthing/20031021-00/?p=42083
		if (IsDialogMessage(g.hwndMain, &message))
			continue;

		TranslateMessage(&message);
		DispatchMessage(&message);
	}
	return true;
}

int WINAPI
wWinMain(HINSTANCE hInstance, [[maybe_unused]] HINSTANCE hPrevInstance,
	PWSTR pCmdLine, int nCmdShow)
{
	setlocale(LC_ALL, "");

	WSADATA wd = {};
	int err = {};
	if ((err = WSAStartup(MAKEWORD(2, 2), &wd))) {
		show_error_message(format_error_message(err).c_str());
		return 1;
	}

	INITCOMMONCONTROLSEX icc = {};
	icc.dwICC = 0;
	icc.dwSize = sizeof icc;
	(void) InitCommonControlsEx(&icc);

	// TODO(p): The control doesn't seem to support visual styles at all,
	// try to figure out how to immitate it with WM_NCPAINT,
	// GetThemeBackgroundContentRect(), DrawThemeBackground(), etc.,
	// and remember to handle the case of visual styles being disabled,
	// perhaps by using DrawEdge() → InflateRect(-CXBORDER, -CYBORDER).
	// This is by no means simple.
	//
	// Example implementation: https://web.archive.org/web/20210707175627
	// /http://www.codeguru.com/cpp/w-d/dislog/miscellaneous/article.php/c8729
	// /XP-Theme-Support-for-Rich-Edit-and-Custom-Controls.htm
	if (!LoadLibrary(L"Msftedit.dll")) {
		show_error_message(format_error_message(GetLastError()).c_str());
		return 1;
	}

	// WINE calls WM_MEASUREITEM as soon as the listbox is created.
	// TODO(p): Watch for WM_SETTINGCHANGE/SPI_SETNONCLIENTMETRICS,
	// reset all fonts in all widgets, and the topic background colour.
	get_font();

	g.hicon =
		LoadIcon(hInstance, MAKEINTRESOURCE(IDI_ICON));
	g.hiconHighlighted =
		LoadIcon(hInstance, MAKEINTRESOURCE(IDI_HIGHLIGHTED));

	WNDCLASSEX wc = {};
	wc.cbSize = sizeof wc;
	wc.lpfnWndProc = window_proc;
	wc.hInstance = hInstance;
	wc.hIcon = g.hicon;
	wc.hCursor = LoadCursor(NULL, IDC_ARROW);
	wc.hbrBackground = GetSysColorBrush(COLOR_3DFACE);
	wc.lpszClassName = TEXT(PROJECT_NAME);
	if (!RegisterClassEx(&wc))
		return 1;

	g.hwndMain = CreateWindowEx(
		WS_EX_CONTROLPARENT, wc.lpszClassName, TEXT(PROJECT_NAME),
		WS_OVERLAPPEDWINDOW,
		CW_USEDEFAULT, CW_USEDEFAULT, 600, 400, NULL, NULL, hInstance, NULL);

	// We're lucky to not need much user user interface,
	// because Win32 is in many aspects quite difficult to work with.
	HMENU id = 0;
	g.hwndTopic = CreateWindowEx(0, MSFTEDIT_CLASS, L"",
		WS_VISIBLE | WS_CHILD | WS_TABSTOP |
		ES_AUTOHSCROLL | ES_READONLY,
		0, 0, 100, 20, g.hwndMain, ++id, hInstance, NULL);
	g.hwndBufferList = CreateWindowEx(WS_EX_CLIENTEDGE, WC_LISTBOX, L"",
		WS_VISIBLE | WS_CHILD | WS_TABSTOP | WS_VSCROLL |
		LBS_NOINTEGRALHEIGHT | LBS_NOTIFY | LBS_NODATA | LBS_OWNERDRAWFIXED,
		0, 20, 50, 40, g.hwndMain, ++id, hInstance, NULL);
	g.hwndBuffer = CreateWindowEx(WS_EX_CLIENTEDGE, MSFTEDIT_CLASS, L"",
		WS_VISIBLE | WS_CHILD | WS_TABSTOP | WS_VSCROLL |
		ES_MULTILINE | ES_READONLY | ES_SAVESEL,
		50, 20, 50, 40, g.hwndMain, ++id, hInstance, NULL);
	g.hwndBufferLog = CreateWindowEx(WS_EX_CLIENTEDGE, WC_EDIT, L"",
		WS_CHILD | WS_TABSTOP | WS_VSCROLL |
		ES_MULTILINE | ES_READONLY,
		50, 20, 50, 40, g.hwndMain, ++id, hInstance, NULL);
	g.hwndPrompt = CreateWindowEx(0, WC_STATIC, L"Connecting...",
		WS_VISIBLE | WS_CHILD,
		0, 60, 50, 20, g.hwndMain, ++id, hInstance, NULL);
	g.hwndStatus = CreateWindowEx(0, WC_STATIC, L"",
		WS_VISIBLE | WS_CHILD | ES_RIGHT,
		50, 60, 50, 20, g.hwndMain, ++id, hInstance, NULL);
	g.hwndInput = CreateWindowEx(WS_EX_CLIENTEDGE, WC_EDIT, L"",
		WS_VISIBLE | WS_CHILD | WS_TABSTOP |
		ES_AUTOHSCROLL,
		0, 80, 100, 20, g.hwndMain, ++id, hInstance, NULL);

	SendMessage(g.hwndTopic, EM_SETBKGNDCOLOR, 0, GetSysColor(COLOR_3DFACE));
	// The 1 probably means AURL_ENABLEEA only in later versions.
	SendMessage(g.hwndTopic, EM_AUTOURLDETECT, 1, 0);
	SendMessage(g.hwndTopic, EM_SETEVENTMASK, 0, ENM_LINK);
	SendMessage(g.hwndTopic, EM_SETUNDOLIMIT, 0, 0);
	SetWindowSubclass(g.hwndTopic, richedit_proc, 0, 0);
	SendMessage(g.hwndBuffer, EM_AUTOURLDETECT, 1, 0);
	SendMessage(g.hwndBuffer, EM_SETEVENTMASK, 0, ENM_LINK | ENM_SCROLL);
	SendMessage(g.hwndBuffer, EM_SETUNDOLIMIT, 0, 0);
	SetWindowSubclass(g.hwndBufferList, bufferlist_proc, 0, 0);
	SetWindowSubclass(g.hwndBuffer, richedit_proc, 0, 0);
	SetWindowSubclass(g.hwndBufferLog, log_proc, 0, 0);

	RECT client_rect = {};
	if (GetClientRect(g.hwndMain, &client_rect)) {
		process_resize(client_rect.right - client_rect.left,
			client_rect.bottom - client_rect.top);
	}

	EnumChildWindows(g.hwndMain, (WNDENUMPROC) set_font, (LPARAM) g.hfont);
	SendMessage(g.hwndPrompt, WM_SETFONT, (WPARAM) g.hfontBold, (LPARAM) TRUE);
	SetFocus(g.hwndInput);
	SetWindowSubclass(g.hwndInput, input_proc, 0, 0);
	ShowWindow(g.hwndMain, nCmdShow);

	HACCEL accelerators =
		LoadAccelerators(hInstance, MAKEINTRESOURCE(IDA_ACCELERATORS));
	if (!accelerators) {
		show_error_message(format_error_message(GetLastError()).c_str());
		return 1;
	}

	int argc = 0;
	LPWSTR *argv = CommandLineToArgvW(pCmdLine, &argc);
	if (argc >= 2) {
		g.host = argv[0];
		g.port = argv[1];
	} else if (DialogBox(hInstance, MAKEINTRESOURCE(IDD_CONNECT),
			g.hwndMain, connect_proc) != IDOK) {
		return 0;
	}
	LocalFree(argv);

	// We have a few suboptimal asynchronous options:
	// a) WSAAsyncGetHostByName() requires us to distinguish hostnames
	//    from IP literals manually,
	// b) GetAddrInfoEx() only supports asynchronous operation since Windows 8,
	// c) run this from a thread.
	addrinfoW hints = {};
	hints.ai_family = AF_UNSPEC;
	hints.ai_socktype = SOCK_STREAM;
	hints.ai_protocol = IPPROTO_TCP;
	err = GetAddrInfo(g.host.c_str(), g.port.c_str(), &hints, &g.addresses);
	if (err) {
		show_error_message(format_error_message(err).c_str());
		return 1;
	}

	std::wstring error;
	g.addresses_iterator = g.addresses;
	if (!relay_connect_step(error)) {
		show_error_message(error.c_str());
		return 1;
	}

	if (!(g.date_change_timer = CreateWaitableTimer(NULL, FALSE, NULL)) ||
			!(g.flush_event = CreateEvent(NULL, FALSE, FALSE, NULL))) {
		show_error_message(format_error_message(GetLastError()).c_str());
		return 1;
	}

	while (process_messages(accelerators)) {
		HANDLE handles[] = {g.date_change_timer, g.flush_event, g.socket_event};
		DWORD count = 3 - !handles[2];
		DWORD result = MsgWaitForMultipleObjects(
			count, handles, FALSE, INFINITE, QS_ALLINPUT);
		if (result == WAIT_FAILED) {
			show_error_message(format_error_message(GetLastError()).c_str());
			return 1;
		}
		if (result >= WAIT_OBJECT_0 + count)
			continue;

		auto signalled = handles[result];
		if (signalled == g.date_change_timer) {
			bool to_bottom = buffer_at_bottom();
			buffer_print_and_watch_trailing_date_changes();
			if (to_bottom)
				buffer_scroll_to_bottom();
		}
		if (signalled == g.flush_event && !relay_try_write(error)) {
			show_error_message(error.c_str());
			return 1;
		}
		if (signalled == g.socket_event && !relay_process_socket_events(error)) {
			show_error_message(error.c_str());
			return 1;
		}
	}
	FreeAddrInfo(g.addresses);
	WSACleanup();
	CloseHandle(g.date_change_timer);
	CloseHandle(g.flush_event);
	return 0;
}