/**
 * @file text_render.cpp
 *
 * Text rendering.
 */
#include "text_render.hpp"

#include <algorithm>
#include <array>
#include <cstddef>
#include <cstdint>
#include <unordered_map>
#include <utility>
#include <variant>

#include <fmt/core.h>

#include "DiabloUI/ui_flags.hpp"
#include "engine/clx_sprite.hpp"
#include "engine/load_cel.hpp"
#include "engine/load_clx.hpp"
#include "engine/load_file.hpp"
#include "engine/load_pcx.hpp"
#include "engine/point.hpp"
#include "engine/rectangle.hpp"
#include "engine/render/clx_render.hpp"
#include "engine/render/primitive_render.hpp"
#include "engine/surface.hpp"
#include "utils/algorithm/container.hpp"
#include "utils/language.h"
#include "utils/log.hpp"
#include "utils/stdcompat/optional.hpp"
#include "utils/stdcompat/string_view.hpp"
#include "utils/str_cat.hpp"
#include "utils/utf8.hpp"

namespace devilution {

OptionalOwnedClxSpriteList pSPentSpn2Cels;

namespace {

constexpr char32_t ZWSP = U'\u200B'; // Zero-width space

struct OwnedFontStack {
	OptionalOwnedClxSpriteList baseFont;
	OptionalOwnedClxSpriteList overrideFont;
};

struct FontStack {
	OptionalClxSpriteList baseFont;
	OptionalClxSpriteList overrideFont;

	FontStack() = default;

	explicit FontStack(const OwnedFontStack &owned)
	{
		if (owned.baseFont.has_value()) baseFont.emplace(*owned.baseFont);
		if (owned.overrideFont.has_value()) overrideFont.emplace(*owned.overrideFont);
	}

	[[nodiscard]] bool has_value() const // NOLINT(readability-identifier-naming)
	{
		return baseFont.has_value() || overrideFont.has_value();
	}

	[[nodiscard]] ClxSprite glyph(size_t i) const
	{
		if (overrideFont.has_value()) {
			ClxSprite overrideGlyph = (*overrideFont)[i];
			if (overrideGlyph.width() != 0) return overrideGlyph;
		}
		return (*baseFont)[i];
	}
};

std::unordered_map<uint32_t, OwnedFontStack> Fonts;

std::array<int, 6> FontSizes = { 12, 24, 30, 42, 46, 22 };
constexpr std::array<int, 6> LineHeights = { 12, 26, 38, 42, 50, 22 };
constexpr int SmallFontTallLineHeight = 16;
std::array<int, 6> BaseLineOffset = { -3, -2, -3, -6, -7, 3 };

std::array<const char *, 19> ColorTranslations = {
	"fonts\\goldui.trn",
	"fonts\\grayui.trn",
	"fonts\\golduis.trn",
	"fonts\\grayuis.trn",

	nullptr, // ColorDialogWhite
	nullptr, // ColorDialogRed
	"fonts\\yellow.trn",

	nullptr,
	"fonts\\black.trn",

	"fonts\\white.trn",
	"fonts\\whitegold.trn",
	"fonts\\red.trn",
	"fonts\\blue.trn",
	"fonts\\orange.trn",

	"fonts\\buttonface.trn",
	"fonts\\buttonpushed.trn",
	"fonts\\gamedialogwhite.trn",
	"fonts\\gamedialogyellow.trn",
	"fonts\\gamedialogred.trn",
};

std::array<std::optional<std::array<uint8_t, 256>>, 19> ColorTranslationsData;

text_color GetColorFromFlags(UiFlags flags)
{
	if (HasAnyOf(flags, UiFlags::ColorWhite))
		return ColorWhite;
	if (HasAnyOf(flags, UiFlags::ColorBlue))
		return ColorBlue;
	if (HasAnyOf(flags, UiFlags::ColorOrange))
		return ColorOrange;
	if (HasAnyOf(flags, UiFlags::ColorRed))
		return ColorRed;
	if (HasAnyOf(flags, UiFlags::ColorBlack))
		return ColorBlack;
	if (HasAnyOf(flags, UiFlags::ColorGold))
		return ColorGold;
	if (HasAnyOf(flags, UiFlags::ColorUiGold))
		return ColorUiGold;
	if (HasAnyOf(flags, UiFlags::ColorUiSilver))
		return ColorUiSilver;
	if (HasAnyOf(flags, UiFlags::ColorUiGoldDark))
		return ColorUiGoldDark;
	if (HasAnyOf(flags, UiFlags::ColorUiSilverDark))
		return ColorUiSilverDark;
	if (HasAnyOf(flags, UiFlags::ColorDialogWhite))
		return gbRunGame ? ColorInGameDialogWhite : ColorDialogWhite;
	if (HasAnyOf(flags, UiFlags::ColorDialogYellow))
		return ColorInGameDialogYellow;
	if (HasAnyOf(flags, UiFlags::ColorDialogRed))
		return ColorInGameDialogRed;
	if (HasAnyOf(flags, UiFlags::ColorYellow))
		return ColorYellow;
	if (HasAnyOf(flags, UiFlags::ColorButtonface))
		return ColorButtonface;
	if (HasAnyOf(flags, UiFlags::ColorButtonpushed))
		return ColorButtonpushed;

	return ColorWhitegold;
}

uint16_t GetUnicodeRow(char32_t codePoint)
{
	return static_cast<uint32_t>(codePoint) >> 8;
}

bool IsCJK(uint16_t row)
{
	return row >= 0x30 && row <= 0x9f;
}

bool IsHangul(uint16_t row)
{
	return row >= 0xac && row <= 0xd7;
}

bool IsSmallFontTallRow(uint16_t row)
{
	return IsCJK(row) || IsHangul(row);
}

void GetFontPath(GameFontTables size, uint16_t row, string_view ext, char *out)
{
	*BufCopy(out, "fonts\\", FontSizes[size], "-", AsHexPad2(row), ext) = '\0';
}

void GetFontPath(string_view language_code, GameFontTables size, uint16_t row, string_view ext, char *out)
{
	*BufCopy(out, "fonts\\", language_code, "\\", FontSizes[size], "-", AsHexPad2(row), ext) = '\0';
}

uint32_t GetFontId(GameFontTables size, uint16_t row)
{
	return (size << 16) | row;
}

FontStack LoadFont(GameFontTables size, text_color color, uint16_t row)
{
	if (ColorTranslations[color] != nullptr && !ColorTranslationsData[color]) {
		ColorTranslationsData[color].emplace();
		LoadFileInMem(ColorTranslations[color], *ColorTranslationsData[color]);
	}

	const uint32_t fontId = GetFontId(size, row);
	auto hotFont = Fonts.find(fontId);
	if (hotFont != Fonts.end()) {
		return FontStack(hotFont->second);
	}

	OwnedFontStack &font = Fonts[fontId];
	char path[32];

	// Load language-specific glyphs:
	const string_view languageCode = GetLanguageCode();
	const string_view lang = languageCode.substr(0, 2);
	if (lang == "zh" || lang == "ja" || lang == "ko"
	    || (lang == "tr" && row == 0)) {
		GetFontPath(languageCode, size, row, ".clx", &path[0]);
		font.overrideFont = LoadOptionalClx(path);
	}

	// Load the base glyphs:
	GetFontPath(size, row, ".clx", &path[0]);
	font.baseFont = LoadOptionalClx(path);

#ifndef UNPACKED_MPQS
	if (!font.baseFont.has_value()) {
		// Could be an old devilutionx.mpq or fonts.mpq with PCX instead of CLX.
		//
		// We'll show an error elsewhere (in `CheckArchivesUpToDate`) and we need to load
		// the font files to display it.
		char pcxPath[32];
		GetFontPath(size, row, "", &pcxPath[0]);
		font.baseFont = LoadPcxSpriteList(pcxPath, /*numFramesOrFrameHeight=*/256, /*transparentColor=*/1);
	}
#endif

	if (!font.baseFont.has_value()) {
		LogError("Error loading font: {}", path);
	}

	return FontStack(font);
}

class CurrentFont {
public:
	FontStack fontStack;

	[[nodiscard]] ClxSprite glyph(size_t i) const
	{
		return fontStack.glyph(i);
	}

	bool load(GameFontTables size, text_color color, char32_t next)
	{
		const uint32_t unicodeRow = GetUnicodeRow(next);
		if (unicodeRow == currentUnicodeRow_ && hasAttemptedLoad_) {
			return true;
		}

		fontStack = LoadFont(size, color, unicodeRow);
		hasAttemptedLoad_ = true;
		currentUnicodeRow_ = unicodeRow;

		return fontStack.has_value();
	}

	void clear()
	{
		hasAttemptedLoad_ = false;
	}

private:
	bool hasAttemptedLoad_ = false;
	uint32_t currentUnicodeRow_ = 0;
};

void DrawFont(const Surface &out, Point position, ClxSprite glyph, text_color color, bool outline)
{
	if (outline) {
		ClxDrawOutlineSkipColorZero(out, 0, { position.x, position.y + glyph.height() - 1 }, glyph);
	}
	if (ColorTranslationsData[color]) {
		RenderClxSpriteWithTRN(out, glyph, position, ColorTranslationsData[color]->data());
	} else {
		RenderClxSprite(out, glyph, position);
	}
}

bool IsFullWidthPunct(char32_t c)
{
	return IsAnyOf(c, U'，', U'、', U'。', U'？', U'！');
}

bool IsBreakAllowed(char32_t codepoint, char32_t nextCodepoint)
{
	return IsFullWidthPunct(codepoint) && !IsFullWidthPunct(nextCodepoint);
}

std::size_t CountNewlines(string_view fmt, const DrawStringFormatArg *args, std::size_t argsLen)
{
	std::size_t result = c_count(fmt, '\n');
	for (std::size_t i = 0; i < argsLen; ++i) {
		if (std::holds_alternative<string_view>(args[i].value()))
			result += c_count(args[i].GetFormatted(), '\n');
	}
	return result;
}

class FmtArgParser {
public:
	FmtArgParser(string_view fmt,
	    DrawStringFormatArg *args,
	    size_t len,
	    size_t offset = 0)
	    : fmt_(fmt)
	    , args_(args)
	    , len_(len)
	    , next_(offset)
	{
	}

	std::optional<std::size_t> operator()(string_view &rest)
	{
		std::optional<std::size_t> result;
		if (rest[0] != '{')
			return result;

		const std::size_t closingBracePos = rest.find('}', 1);
		if (closingBracePos == string_view::npos) {
			LogError("Unclosed format argument: {}", fmt_);
			return result;
		}

		std::size_t fmtLen;
		bool positional;
		if (closingBracePos == 2 && rest[1] >= '0' && rest[1] <= '9') {
			result = rest[1] - '0';
			fmtLen = 3;
			positional = true;
		} else {
			result = next_++;
			fmtLen = closingBracePos + 1;
			positional = false;
		}
		if (!result) {
			LogError("Unsupported format argument: {}", rest);
		} else if (*result >= len_) {
			LogError("Not enough format arguments, {} given for: {}", len_, fmt_);
			result = std::nullopt;
		} else {
			if (!args_[*result].HasFormatted()) {
				const auto fmtStr = positional ? "{}" : string_view(rest.data(), fmtLen);
				args_[*result].SetFormatted(fmt::format(fmt::runtime(fmtStr), std::get<int>(args_[*result].value())));
			}
			rest.remove_prefix(fmtLen);
		}
		return result;
	}

	size_t offset() const
	{
		return next_;
	}

private:
	string_view fmt_;
	DrawStringFormatArg *args_;
	std::size_t len_;
	std::size_t next_;
};

bool ContainsSmallFontTallCodepoints(string_view text)
{
	while (!text.empty()) {
		const char32_t next = ConsumeFirstUtf8CodePoint(&text);
		if (next == Utf8DecodeError)
			break;
		if (next == ZWSP)
			continue;
		if (IsSmallFontTallRow(GetUnicodeRow(next)))
			return true;
	}
	return false;
}

int GetLineHeight(string_view fmt, DrawStringFormatArg *args, std::size_t argsLen, GameFontTables fontIndex)
{
	constexpr std::array<int, 6> LineHeights = { 12, 26, 38, 42, 50, 22 };
	if (fontIndex == GameFont12 && IsSmallFontTall()) {
		FmtArgParser fmtArgParser { fmt, args, argsLen };
		string_view rest = fmt;
		while (!rest.empty()) {
			const std::optional<std::size_t> fmtArgPos = fmtArgParser(rest);
			if (fmtArgPos) {
				if (ContainsSmallFontTallCodepoints(args[*fmtArgPos].GetFormatted())) {
					return SmallFontTallLineHeight;
				}
				continue;
			}
			const char32_t cp = ConsumeFirstUtf8CodePoint(&rest);
			if (cp == Utf8DecodeError) break;
			if (cp == ZWSP) continue;
			if (IsSmallFontTallRow(GetUnicodeRow(cp))) return SmallFontTallLineHeight;
		}
	}
	return LineHeights[fontIndex];
}

Surface ClipSurface(const Surface &out, Rectangle rect)
{
	if (rect.size.height == 0) {
		return out.subregion(0, 0, std::min(rect.position.x + rect.size.width, out.w()), out.h());
	}
	return out.subregion(0, 0,
	    std::min(rect.position.x + rect.size.width, out.w()),
	    std::min(rect.position.y + rect.size.height, out.h()));
}

int AdjustSpacingToFitHorizontally(int &lineWidth, int maxSpacing, int charactersInLine, int availableWidth)
{
	if (lineWidth <= availableWidth || charactersInLine < 2)
		return maxSpacing;

	const int overhang = lineWidth - availableWidth;
	const int spacingRedux = (overhang + charactersInLine - 2) / (charactersInLine - 1);
	lineWidth -= spacingRedux * (charactersInLine - 1);
	return maxSpacing - spacingRedux;
}

void MaybeWrap(Point &characterPosition, int characterWidth, int rightMargin, int initialX, int lineHeight)
{
	if (characterPosition.x + characterWidth > rightMargin) {
		characterPosition.x = initialX;
		characterPosition.y += lineHeight;
	}
}

int GetLineStartX(UiFlags flags, const Rectangle &rect, int lineWidth)
{
	if (HasAnyOf(flags, UiFlags::AlignCenter)) {
		return std::max(rect.position.x, rect.position.x + (rect.size.width - lineWidth) / 2);
	}
	if (HasAnyOf(flags, UiFlags::AlignRight))
		return rect.position.x + rect.size.width - lineWidth;
	return rect.position.x;
}

uint32_t DoDrawString(const Surface &out, string_view text, Rectangle rect, Point &characterPosition,
    int lineWidth, int charactersInLine, int rightMargin, int bottomMargin, GameFontTables size, text_color color, bool outline,
    TextRenderOptions &opts)
{
	CurrentFont currentFont;
	int curSpacing = opts.spacing;
	if (HasAnyOf(opts.flags, UiFlags::KerningFitSpacing)) {
		curSpacing = AdjustSpacingToFitHorizontally(lineWidth, opts.spacing, charactersInLine, rect.size.width);
		if (curSpacing != opts.spacing && HasAnyOf(opts.flags, UiFlags::AlignCenter | UiFlags::AlignRight)) {
			const int adjustedLineWidth = GetLineWidth(text, size, curSpacing, &charactersInLine);
			characterPosition.x = GetLineStartX(opts.flags, rect, adjustedLineWidth);
		}
	}

	char32_t next;
	string_view remaining = text;
	size_t cpLen;

	const auto maybeDrawCursor = [&]() {
		if (opts.cursorPosition == static_cast<int>(text.size() - remaining.size())) {
			Point position = characterPosition;
			MaybeWrap(position, 2, rightMargin, position.x, opts.lineHeight);
			if (GetAnimationFrame(2, 500) != 0) {
				FontStack baseFont = LoadFont(size, color, 0);
				if (baseFont.has_value()) {
					DrawFont(out, position, baseFont.glyph('|'), color, outline);
				}
			}
			if (opts.renderedCursorPositionOut != nullptr) {
				*opts.renderedCursorPositionOut = position;
			}
		}
	};

	for (; !remaining.empty() && remaining[0] != '\0'
	     && (next = DecodeFirstUtf8CodePoint(remaining, &cpLen)) != Utf8DecodeError;
	     remaining.remove_prefix(cpLen)) {
		if (next == ZWSP)
			continue;

		if (!currentFont.load(size, color, next)) {
			next = U'?';
			if (!currentFont.load(size, color, next)) {
				app_fatal("Missing fonts");
			}
		}

		const uint8_t frame = next & 0xFF;
		const uint16_t width = currentFont.glyph(frame).width();
		if (next == U'\n' || characterPosition.x + width > rightMargin) {
			if (next == '\n')
				maybeDrawCursor();
			const int nextLineY = characterPosition.y + opts.lineHeight;
			if (nextLineY >= bottomMargin)
				break;
			characterPosition.y = nextLineY;

			if (HasAnyOf(opts.flags, UiFlags::KerningFitSpacing)) {
				int nextLineWidth = GetLineWidth(remaining.substr(cpLen), size, opts.spacing, &charactersInLine);
				curSpacing = AdjustSpacingToFitHorizontally(nextLineWidth, opts.spacing, charactersInLine, rect.size.width);
			}

			if (HasAnyOf(opts.flags, UiFlags::AlignCenter | UiFlags::AlignRight)) {
				lineWidth = width;
				if (remaining.size() > cpLen)
					lineWidth += curSpacing + GetLineWidth(remaining.substr(cpLen), size, curSpacing);
			}
			characterPosition.x = GetLineStartX(opts.flags, rect, lineWidth);

			if (next == U'\n')
				continue;
		}

		const ClxSprite glyph = currentFont.glyph(frame);
		const auto byteIndex = static_cast<int>(text.size() - remaining.size());

		// Draw highlight
		if (byteIndex >= opts.highlightRange.begin && byteIndex < opts.highlightRange.end) {
			const bool lastInRange = static_cast<int>(byteIndex + cpLen) == opts.highlightRange.end;
			FillRect(out, characterPosition.x, characterPosition.y,
			    glyph.width() + (lastInRange ? 0 : curSpacing), glyph.height(),
			    opts.highlightColor);
		}

		DrawFont(out, characterPosition, glyph, color, outline);
		maybeDrawCursor();
		characterPosition.x += width + curSpacing;
	}
	maybeDrawCursor();
	return static_cast<uint32_t>(remaining.data() - text.data());
}

} // namespace

void LoadSmallSelectionSpinner()
{
	pSPentSpn2Cels = LoadCel("data\\pentspn2", 12);
}

void UnloadFonts()
{
	Fonts.clear();
}

int GetLineWidth(string_view text, GameFontTables size, int spacing, int *charactersInLine)
{
	int lineWidth = 0;
	CurrentFont currentFont;
	uint32_t codepoints = 0;
	char32_t next;
	while (!text.empty()) {
		next = ConsumeFirstUtf8CodePoint(&text);
		if (next == Utf8DecodeError)
			break;
		if (next == ZWSP)
			continue;

		if (next == U'\n')
			break;

		if (!currentFont.load(size, text_color::ColorDialogWhite, next)) {
			next = U'?';
			if (!currentFont.load(size, text_color::ColorDialogWhite, next)) {
				app_fatal("Missing fonts");
			}
		}

		const uint8_t frame = next & 0xFF;
		lineWidth += currentFont.glyph(frame).width() + spacing;
		++codepoints;
	}
	if (charactersInLine != nullptr)
		*charactersInLine = codepoints;

	return lineWidth != 0 ? (lineWidth - spacing) : 0;
}

bool IsConsumed(string_view s) { return s.empty() || s[0] == '\0'; };

int GetLineWidth(string_view fmt, DrawStringFormatArg *args, std::size_t argsLen, size_t argsOffset, GameFontTables size, int spacing, int *charactersInLine,
    std::optional<size_t> firstArgOffset)
{
	int lineWidth = 0;
	CurrentFont currentFont;

	uint32_t codepoints = 0;
	char32_t prev = U'\0';
	char32_t next;
	string_view remaining = fmt;
	FmtArgParser fmtArgParser { fmt, args, argsLen, argsOffset };
	size_t cpLen;

	// The current formatted argument value being processed.
	string_view curFormatted;

	// The string that we're currently processing: either `remaining` or `curFormatted`.
	string_view *str;

	if (firstArgOffset.has_value()) {
		curFormatted = args[argsOffset - 1].GetFormatted().substr(*firstArgOffset);
	}

	for (; !(IsConsumed(curFormatted) && IsConsumed(remaining));
	     str->remove_prefix(cpLen), prev = next) {
		const bool isProcessingFormatArgValue = !IsConsumed(curFormatted);
		str = isProcessingFormatArgValue ? &curFormatted : &remaining;
		next = DecodeFirstUtf8CodePoint(*str, &cpLen);
		if (next == Utf8DecodeError) break;

		// {{ and }} escapes in fmt.
		if (!isProcessingFormatArgValue && (prev == U'{' || prev == U'}') && prev == next) continue;
		// ZWSP are line-breaking opportunities that can otherwise be skipped for rendering as they have 0-width.
		if (next == ZWSP) continue;
		if (next == U'\n') break;

		if (!isProcessingFormatArgValue) {
			const std::optional<std::size_t> fmtArgPos = fmtArgParser(*str);
			if (fmtArgPos.has_value()) {
				// `fmtArgParser` has already consumed `*str`. Ensure the loop doesn't consume any more.
				cpLen = 0;
				// The loop assigns `prev = next`.
				// We reset it to U'\0' to ensure that {{ and }} escapes are not processed accross
				// the boundary of the format string and a formatted value.
				next = U'\0';
				currentFont.clear();
				const DrawStringFormatArg &arg = args[*fmtArgPos];
				curFormatted = arg.GetFormatted();
				continue;
			}
		}

		if (!currentFont.load(size, text_color::ColorDialogWhite, next)) {
			next = U'?';
			if (!currentFont.load(size, text_color::ColorDialogWhite, next)) {
				app_fatal("Missing fonts");
			}
		}

		const uint8_t frame = next & 0xFF;
		lineWidth += currentFont.glyph(frame).width() + spacing;
		++codepoints;
	}
	if (charactersInLine != nullptr)
		*charactersInLine = codepoints;

	return lineWidth != 0 ? (lineWidth - spacing) : 0;
}

int GetLineHeight(string_view text, GameFontTables fontIndex)
{
	if (fontIndex == GameFont12 && IsSmallFontTall() && ContainsSmallFontTallCodepoints(text)) {
		return SmallFontTallLineHeight;
	}
	return LineHeights[fontIndex];
}

std::string WordWrapString(string_view text, unsigned width, GameFontTables size, int spacing)
{
	std::string output;
	if (text.empty() || text[0] == '\0')
		return output;

	output.reserve(text.size());
	const char *begin = text.data();
	const char *processedEnd = text.data();
	string_view::size_type lastBreakablePos = string_view::npos;
	std::size_t lastBreakableLen = 0;
	unsigned lineWidth = 0;
	CurrentFont currentFont;

	char32_t codepoint = U'\0'; // the current codepoint
	char32_t nextCodepoint;     // the next codepoint
	std::size_t nextCodepointLen;
	string_view remaining = text;
	nextCodepoint = DecodeFirstUtf8CodePoint(remaining, &nextCodepointLen);
	do {
		codepoint = nextCodepoint;
		const std::size_t codepointLen = nextCodepointLen;
		if (codepoint == Utf8DecodeError)
			break;
		remaining.remove_prefix(codepointLen);
		nextCodepoint = !remaining.empty() ? DecodeFirstUtf8CodePoint(remaining, &nextCodepointLen) : U'\0';

		if (codepoint == U'\n') { // Existing line break, scan next line
			lastBreakablePos = string_view::npos;
			lineWidth = 0;
			output.append(processedEnd, remaining.data());
			processedEnd = remaining.data();
			continue;
		}

		if (codepoint != ZWSP) {
			const uint8_t frame = codepoint & 0xFF;
			if (!currentFont.load(size, text_color::ColorDialogWhite, codepoint)) {
				codepoint = U'?';
				if (!currentFont.load(size, text_color::ColorDialogWhite, codepoint)) {
					app_fatal("Missing fonts");
				}
			}

			lineWidth += currentFont.glyph(frame).width() + spacing;
		}

		if (IsBreakableWhitespace(codepoint)) {
			lastBreakablePos = remaining.data() - begin - codepointLen;
			lastBreakableLen = codepointLen;
			continue;
		}

		if (lineWidth - spacing <= width) {
			if (IsBreakAllowed(codepoint, nextCodepoint)) {
				lastBreakablePos = remaining.data() - begin;
				lastBreakableLen = 0;
			}

			continue; // String is still within the limit, continue to the next symbol
		}

		if (lastBreakablePos == string_view::npos) { // Single word longer than width
			lastBreakablePos = remaining.data() - begin - codepointLen;
			lastBreakableLen = 0;
		}

		// Break line and continue to next line
		const char *end = &text[lastBreakablePos];
		output.append(processedEnd, end);
		output += '\n';

		// Restart from the beginning of the new line.
		remaining = text.substr(lastBreakablePos + lastBreakableLen);
		processedEnd = remaining.data();
		lastBreakablePos = string_view::npos;
		lineWidth = 0;
		nextCodepoint = !remaining.empty() ? DecodeFirstUtf8CodePoint(remaining, &nextCodepointLen) : U'\0';
	} while (!remaining.empty() && remaining[0] != '\0');
	output.append(processedEnd, remaining.data());
	return output;
}

/**
 * @todo replace Rectangle with cropped Surface
 */
uint32_t DrawString(const Surface &out, string_view text, const Rectangle &rect, TextRenderOptions opts)
{
	const GameFontTables size = GetFontSizeFromUiFlags(opts.flags);
	const text_color color = GetColorFromFlags(opts.flags);

	int charactersInLine = 0;
	int lineWidth = 0;
	if (HasAnyOf(opts.flags, (UiFlags::AlignCenter | UiFlags::AlignRight | UiFlags::KerningFitSpacing)))
		lineWidth = GetLineWidth(text, size, opts.spacing, &charactersInLine);

	Point characterPosition { GetLineStartX(opts.flags, rect, lineWidth), rect.position.y };
	const int initialX = characterPosition.x;

	const int rightMargin = rect.position.x + rect.size.width;
	const int bottomMargin = rect.size.height != 0 ? std::min(rect.position.y + rect.size.height + BaseLineOffset[size], out.h()) : out.h();

	if (opts.lineHeight == -1)
		opts.lineHeight = GetLineHeight(text, size);

	if (HasAnyOf(opts.flags, UiFlags::VerticalCenter)) {
		const int textHeight = static_cast<int>((c_count(text, '\n') + 1) * opts.lineHeight);
		characterPosition.y += std::max(0, (rect.size.height - textHeight) / 2);
	}

	characterPosition.y += BaseLineOffset[size];

	const bool outlined = HasAnyOf(opts.flags, UiFlags::Outlined);

	const Surface clippedOut = ClipSurface(out, rect);

	// Only draw the PentaCursor if the cursor is not at the end.
	if (HasAnyOf(opts.flags, UiFlags::PentaCursor) && static_cast<size_t>(opts.cursorPosition) == text.size()) {
		opts.cursorPosition = -1;
	}

	const uint32_t bytesDrawn = DoDrawString(clippedOut, text, rect, characterPosition,
	    lineWidth, charactersInLine, rightMargin, bottomMargin, size, color, outlined, opts);

	if (HasAnyOf(opts.flags, UiFlags::PentaCursor)) {
		const ClxSprite sprite = (*pSPentSpn2Cels)[PentSpn2Spin()];
		MaybeWrap(characterPosition, sprite.width(), rightMargin, initialX, opts.lineHeight);
		ClxDraw(clippedOut, characterPosition + Displacement { 0, opts.lineHeight - BaseLineOffset[size] }, sprite);
	}

	return bytesDrawn;
}

void DrawStringWithColors(const Surface &out, string_view fmt, DrawStringFormatArg *args, std::size_t argsLen, const Rectangle &rect, TextRenderOptions opts)
{
	const GameFontTables size = GetFontSizeFromUiFlags(opts.flags);
	const text_color color = GetColorFromFlags(opts.flags);

	int charactersInLine = 0;
	int lineWidth = 0;
	if (HasAnyOf(opts.flags, (UiFlags::AlignCenter | UiFlags::AlignRight | UiFlags::KerningFitSpacing)))
		lineWidth = GetLineWidth(fmt, args, argsLen, 0, size, opts.spacing, &charactersInLine);

	Point characterPosition { GetLineStartX(opts.flags, rect, lineWidth), rect.position.y };
	const int initialX = characterPosition.x;

	const int rightMargin = rect.position.x + rect.size.width;
	const int bottomMargin = rect.size.height != 0 ? std::min(rect.position.y + rect.size.height + BaseLineOffset[size], out.h()) : out.h();

	if (opts.lineHeight == -1)
		opts.lineHeight = GetLineHeight(fmt, args, argsLen, size);

	if (HasAnyOf(opts.flags, UiFlags::VerticalCenter)) {
		const int textHeight = static_cast<int>((CountNewlines(fmt, args, argsLen) + 1) * opts.lineHeight);
		characterPosition.y += std::max(0, (rect.size.height - textHeight) / 2);
	}

	characterPosition.y += BaseLineOffset[size];

	const bool outlined = HasAnyOf(opts.flags, UiFlags::Outlined);

	const Surface clippedOut = ClipSurface(out, rect);

	CurrentFont currentFont;
	const int originalSpacing = opts.spacing;
	if (HasAnyOf(opts.flags, UiFlags::KerningFitSpacing)) {
		opts.spacing = AdjustSpacingToFitHorizontally(lineWidth, originalSpacing, charactersInLine, rect.size.width);
		if (opts.spacing != originalSpacing && HasAnyOf(opts.flags, UiFlags::AlignCenter | UiFlags::AlignRight)) {
			const int adjustedLineWidth = GetLineWidth(fmt, args, argsLen, 0, size, opts.spacing, &charactersInLine);
			characterPosition.x = GetLineStartX(opts.flags, rect, adjustedLineWidth);
		}
	}

	char32_t prev = U'\0';
	char32_t next;
	string_view remaining = fmt;
	FmtArgParser fmtArgParser { fmt, args, argsLen };
	size_t cpLen;

	// The current formatted argument value being processed.
	string_view curFormatted;
	text_color curFormattedColor;

	// The string that we're currently processing: either `remaining` or `curFormatted`.
	string_view *str;

	for (; !(IsConsumed(curFormatted) && IsConsumed(remaining));
	     str->remove_prefix(cpLen), prev = next) {
		const bool isProcessingFormatArgValue = !IsConsumed(curFormatted);
		str = isProcessingFormatArgValue ? &curFormatted : &remaining;
		next = DecodeFirstUtf8CodePoint(*str, &cpLen);
		if (next == Utf8DecodeError) break;

		// {{ and }} escapes in fmt.
		if (!isProcessingFormatArgValue && (prev == U'{' || prev == U'}') && prev == next) continue;
		// ZWSP are line-breaking opportunities that can otherwise be skipped for rendering as they have 0-width.
		if (next == ZWSP) continue;

		if (!isProcessingFormatArgValue) {
			const std::optional<std::size_t> fmtArgPos = fmtArgParser(*str);
			if (fmtArgPos.has_value()) {
				// `fmtArgParser` has already consumed `*str`. Ensure the loop doesn't consume any more.
				cpLen = 0;
				// The loop assigns `prev = next`.
				// We reset it to U'\0' to ensure that {{ and }} escapes are not processed accross
				// the boundary of the format string and a formatted value.
				next = U'\0';
				currentFont.clear();
				const DrawStringFormatArg &arg = args[*fmtArgPos];
				curFormatted = arg.GetFormatted();
				curFormattedColor = GetColorFromFlags(arg.GetFlags());
				continue;
			}
		}

		const text_color curColor = isProcessingFormatArgValue ? curFormattedColor : color;
		if (!currentFont.load(size, curColor, next)) {
			next = U'?';
			if (!currentFont.load(size, curColor, next)) {
				app_fatal("Missing fonts");
			}
		}

		const uint8_t frame = next & 0xFF;
		const uint16_t width = currentFont.glyph(frame).width();
		if (next == U'\n' || characterPosition.x + width > rightMargin) {
			const int nextLineY = characterPosition.y + opts.lineHeight;
			if (nextLineY >= bottomMargin)
				break;
			characterPosition.y = nextLineY;

			if (HasAnyOf(opts.flags, UiFlags::KerningFitSpacing)) {
				int nextLineWidth = isProcessingFormatArgValue
				    ? GetLineWidth(remaining, args, argsLen, fmtArgParser.offset(), size, originalSpacing, &charactersInLine,
				          /*firstArgOffset=*/args[fmtArgParser.offset() - 1].GetFormatted().size() - (curFormatted.size() - cpLen))
				    : GetLineWidth(remaining.substr(cpLen), args, argsLen, fmtArgParser.offset(), size, originalSpacing, &charactersInLine);
				opts.spacing = AdjustSpacingToFitHorizontally(nextLineWidth, originalSpacing, charactersInLine, rect.size.width);
			}

			if (HasAnyOf(opts.flags, UiFlags::AlignCenter | UiFlags::AlignRight)) {
				lineWidth = width;
				if (str->size() > cpLen) {
					lineWidth += opts.spacing
					    + (isProcessingFormatArgValue
					            ? GetLineWidth(remaining, args, argsLen, fmtArgParser.offset(), size, opts.spacing, &charactersInLine,
					                  /*firstArgOffset=*/args[fmtArgParser.offset() - 1].GetFormatted().size() - (curFormatted.size() - cpLen))
					            : GetLineWidth(remaining.substr(cpLen), args, argsLen, fmtArgParser.offset(), size, opts.spacing, &charactersInLine));
				}
			}
			characterPosition.x = GetLineStartX(opts.flags, rect, lineWidth);

			if (next == U'\n')
				continue;
		}

		DrawFont(clippedOut, characterPosition, currentFont.glyph(frame), curColor, outlined);
		characterPosition.x += width + opts.spacing;
	}

	if (HasAnyOf(opts.flags, UiFlags::PentaCursor)) {
		const ClxSprite sprite = (*pSPentSpn2Cels)[PentSpn2Spin()];
		MaybeWrap(characterPosition, sprite.width(), rightMargin, initialX, opts.lineHeight);
		ClxDraw(clippedOut, characterPosition + Displacement { 0, opts.lineHeight - BaseLineOffset[size] }, sprite);
	}
}

uint8_t PentSpn2Spin()
{
	return GetAnimationFrame(8, 50);
}

bool IsBreakableWhitespace(char32_t c)
{
	return IsAnyOf(c, U' ', U'　', ZWSP);
}

} // namespace devilution
