/***************************************************************************
 *   Copyright (C) 2008-2013 by Heiko Koehn - KoehnHeiko@googlemail.com    *
 *   Copyright (C) 2014 by Ahmed Charles - acharles@outlook.com            *
 *   Copyright (C) 2014-2018, 2020, 2022-2024, 2026 by Stephen Lyons       *
 *                                               - slysven@virginmedia.com *
 *                                                                         *
 *   This program is free software; you can redistribute it and/or modify  *
 *   it under the terms of the GNU General Public License as published by  *
 *   the Free Software Foundation; either version 2 of the License, or     *
 *   (at your option) any later version.                                   *
 *                                                                         *
 *   This program is distributed in the hope that it will be useful,       *
 *   but WITHOUT ANY WARRANTY; without even the implied warranty of        *
 *   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the         *
 *   GNU General Public License for more details.                          *
 *                                                                         *
 *   You should have received a copy of the GNU General Public License     *
 *   along with this program; if not, write to the                         *
 *   Free Software Foundation, Inc.,                                       *
 *   59 Temple Place - Suite 330, Boston, MA  02111-1307, USA.             *
 ***************************************************************************/


#include "TBuffer.h"

#include "Host.h"
#include "mudlet.h"
#include "TConsole.h"
#include "TEvent.h"
#include "THyperlinkCompactManager.h"
#include "THyperlinkVisibilityManager.h"
#include "THyperlinkSelectionManager.h"
#include "TStringUtils.h"
#include "TTextEdit.h"
#include "TTextProperties.h"
#include "widechar_width.h"
#include "TEncodingHelper.h"
#include "SentryWrapper.h"

#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonParseError>
#include <QJsonValue>
#include <QRegularExpression>
#include <QTextBoundaryFinder>
#include <QTime>
#include <QTimer>
#include <QUrlQuery>

namespace {

// Maximum length for an OSC sequence before aborting (defense against malformed sequences)
constexpr size_t MAX_OSC_SEQUENCE_LENGTH = 4096;

// Helper to interpret JSON values as boolean
// Accepts both boolean true and numeric non-zero values (servers may send 1 instead of true)
bool jsonBoolValue(const QJsonValue& val)
{
    return val.toBool() || (val.isDouble() && val.toDouble() != 0);
}

} // anonymous namespace

TChar::TChar(const QColor& foreground, const QColor& background, const TChar::AttributeFlags flags, const int linkIndex)
: mFgColor(foreground)
, mBgColor(background)
, mFlags(flags)
, mLinkIndex(linkIndex)
{
}

TChar::TChar(TConsole* pC)
: mFgColor(pC ? pC->mFormatCurrent.foreground() : QColorConstants::White)
, mBgColor(pC ? pC->mFormatCurrent.background() : QColorConstants::Black)
, mFlags(pC ? pC->mFormatCurrent.allDisplayAttributes() : AttributeFlag::None)
{
}

// Note: this operator compares ALL aspects of 'this' against 'other' which may
// not be wanted in every case:
bool TChar::operator==(const TChar& other)
{
    if (mIsSelected != other.mIsSelected) {
        return false;
    }
    if (mLinkIndex != other.mLinkIndex) {
        return false;
    }
    if (mFgColor != other.mFgColor) {
        return false;
    }
    if (mBgColor != other.mBgColor) {
        return false;
    }
    if (mFlags != other.mFlags) {
        return false;
    }
    return true;
}

// Copy constructor - because it is resetting the mIsSelected flag it is NOT a
// default copy constructor:
TChar::TChar(const TChar& copy)
: mFgColor(copy.mFgColor)
, mBgColor(copy.mBgColor)
, mFlags(copy.mFlags)
, mIsSelected(false)
, mLinkIndex(copy.mLinkIndex)
{
}

quint8 TChar::alternateFont() const
{
    // As this is the most likely case check it first:
    if (!(mFlags & AltFontMask)) {
        return 0;
    }

    if (mFlags & AltFont9) {
        return 9;
    }
    if (mFlags & AltFont8) {
        return 8;
    }
    if (mFlags & AltFont7) {
        return 7;
    }
    if (mFlags & AltFont6) {
        return 6;
    }
    if (mFlags & AltFont5) {
        return 5;
    }
    if (mFlags & AltFont4) {
        return 4;
    }
    if (mFlags & AltFont3) {
        return 3;
    }
    if (mFlags & AltFont2) {
        return 2;
    }
    return 1;
}

// Store for text and attributes (such as character color) to be drawn on screen
// Contents are rendered by a TTextEdit
TBuffer::TBuffer(Host* pH, TConsole* pConsole)
: mpConsole(pConsole)
, mBlack(pH->mBlack)
, mLightBlack(pH->mLightBlack)
, mRed(pH->mRed)
, mLightRed(pH->mLightRed)
, mLightGreen(pH->mLightGreen)
, mGreen(pH->mGreen)
, mLightBlue(pH->mLightBlue)
, mBlue(pH->mBlue)
, mLightYellow(pH->mLightYellow)
, mYellow(pH->mYellow)
, mLightCyan(pH->mLightCyan)
, mCyan(pH->mCyan)
, mLightMagenta(pH->mLightMagenta)
, mMagenta(pH->mMagenta)
, mLightWhite(pH->mLightWhite)
, mWhite(pH->mWhite)
, mForeGroundColor(pH->mFgColor)
, mForeGroundColorLight(pH->mFgColor)
, mBackGroundColor(pH->mBgColor)
, mpHost(pH)
, mTagWatchdog(std::make_unique<QTimer>())
{
    mTagWatchdog->setSingleShot(true);
    QObject::connect(mTagWatchdog.get(), &QTimer::timeout, [this]() {
        processMxpWatchdogCallback();
    });
    // All additions to the buffer must use append()/appendLine() to preserve formatting via TChar.
    // Direct modification of the `buffer` vector may bypass formatting and should be avoided.
    clear();

#ifdef QT_DEBUG
    // Validate the encoding tables in case there has been an edit which breaks
    // things:
    for (const auto& table : csmEncodingTable.getEncodings()) {
        Q_ASSERT_X(table.size() == 128, "TBuffer", "Mis-sized encoding look-up table.");
    }
#endif
}

TBuffer::~TBuffer()
{
    if (mTagWatchdog) {
        mTagWatchdog->stop();
    }
}

TBuffer::TBuffer(const TBuffer& other)
: bufferLine(other.bufferLine)
, buffer(other.buffer)
, lineBuffer(other.lineBuffer)
, timeBuffer(other.timeBuffer)
, promptBuffer(other.promptBuffer)
, mLinkStore(other.mLinkStore)
, mLinesLimit(other.mLinesLimit)
, mBatchDeleteSize(other.mBatchDeleteSize)
, mWrapAt(other.mWrapAt)
, mWrapIndent(other.mWrapIndent)
, mWrapHangingIndent(other.mWrapHangingIndent)
, mCursorY(other.mCursorY)
, mEchoingText(other.mEchoingText)
, mpConsole(other.mpConsole)
, mGotESC(other.mGotESC)
, mGotCSI(other.mGotCSI)
, mGotOSC(other.mGotOSC)
, mIsDefaultColor(other.mIsDefaultColor)
, mBlack(other.mBlack)
, mLightBlack(other.mLightBlack)
, mRed(other.mRed)
, mLightRed(other.mLightRed)
, mLightGreen(other.mLightGreen)
, mGreen(other.mGreen)
, mLightBlue(other.mLightBlue)
, mBlue(other.mBlue)
, mLightYellow(other.mLightYellow)
, mYellow(other.mYellow)
, mLightCyan(other.mLightCyan)
, mCyan(other.mCyan)
, mLightMagenta(other.mLightMagenta)
, mMagenta(other.mMagenta)
, mLightWhite(other.mLightWhite)
, mWhite(other.mWhite)
, mForeGroundColor(other.mForeGroundColor)
, mForeGroundColorLight(other.mForeGroundColorLight)
, mBackGroundColor(other.mBackGroundColor)
, mpHost(other.mpHost)
, mBold(other.mBold)
, mItalics(other.mItalics)
, mOverline(other.mOverline)
, mReverse(other.mReverse)
, mStrikeOut(other.mStrikeOut)
, mUnderline(other.mUnderline)
, mUnderlineWavy(other.mUnderlineWavy)
, mUnderlineDotted(other.mUnderlineDotted)
, mUnderlineDashed(other.mUnderlineDashed)
, mBlink(other.mBlink)
, mFastBlink(other.mFastBlink)
, mConcealed(other.mConcealed)
, mAltFont(other.mAltFont)
, mMudLine(other.mMudLine)
, mMudBuffer(other.mMudBuffer)
, mIncompleteSequenceBytes(other.mIncompleteSequenceBytes)
, lastLoggedFromLine(other.lastLoggedFromLine)
, lastloggedToLine(other.lastloggedToLine)
, lastTextToLog(other.lastTextToLog)
, mEncoding(other.mEncoding)
, mCurrentHyperlinkCommand(other.mCurrentHyperlinkCommand)
, mCurrentHyperlinkHint(other.mCurrentHyperlinkHint)
, mCurrentHyperlinkLinkId(other.mCurrentHyperlinkLinkId)
, mHyperlinkActive(other.mHyperlinkActive)
, mWatchdogPhase(other.mWatchdogPhase)
, mTagWatchdog(std::make_unique<QTimer>())
, mWatchdogTagSnapshot(other.mWatchdogTagSnapshot)
, mCurrentHyperlinkStyling(other.mCurrentHyperlinkStyling)
, mCurrentHyperlinkMenu(other.mCurrentHyperlinkMenu)
, mLinkStates(other.mLinkStates)
, mVisitedLinks(other.mVisitedLinks)
, mLinkOriginalBackgrounds(other.mLinkOriginalBackgrounds)
, mLinkOriginalCharacters(other.mLinkOriginalCharacters)
, mCurrentHoveredLinkIndex(other.mCurrentHoveredLinkIndex)
, mCurrentActiveLinkIndex(other.mCurrentActiveLinkIndex)
, mCurrentFocusedLinkIndex(other.mCurrentFocusedLinkIndex)
{
    mTagWatchdog->setSingleShot(true);
    QObject::connect(mTagWatchdog.get(), &QTimer::timeout, [this]() {
        processMxpWatchdogCallback();
    });
}

TBuffer& TBuffer::operator=(const TBuffer& other)
{
    if (this != &other) {
        bufferLine = other.bufferLine;
        buffer = other.buffer;
        lineBuffer = other.lineBuffer;
        timeBuffer = other.timeBuffer;
        promptBuffer = other.promptBuffer;
        mLinkStore = other.mLinkStore;
        mLinesLimit = other.mLinesLimit;
        mBatchDeleteSize = other.mBatchDeleteSize;
        mWrapAt = other.mWrapAt;
        mWrapIndent = other.mWrapIndent;
        mWrapHangingIndent = other.mWrapHangingIndent;
        mCursorY = other.mCursorY;
        mEchoingText = other.mEchoingText;
        mpConsole = other.mpConsole;
        mGotESC = other.mGotESC;
        mGotCSI = other.mGotCSI;
        mGotOSC = other.mGotOSC;
        mIsDefaultColor = other.mIsDefaultColor;
        mBlack = other.mBlack;
        mLightBlack = other.mLightBlack;
        mRed = other.mRed;
        mLightRed = other.mLightRed;
        mLightGreen = other.mLightGreen;
        mGreen = other.mGreen;
        mLightBlue = other.mLightBlue;
        mBlue = other.mBlue;
        mLightYellow = other.mLightYellow;
        mYellow = other.mYellow;
        mLightCyan = other.mLightCyan;
        mCyan = other.mCyan;
        mLightMagenta = other.mLightMagenta;
        mMagenta = other.mMagenta;
        mLightWhite = other.mLightWhite;
        mWhite = other.mWhite;
        mForeGroundColor = other.mForeGroundColor;
        mForeGroundColorLight = other.mForeGroundColorLight;
        mBackGroundColor = other.mBackGroundColor;
        mpHost = other.mpHost;
        mBold = other.mBold;
        mItalics = other.mItalics;
        mOverline = other.mOverline;
        mReverse = other.mReverse;
        mStrikeOut = other.mStrikeOut;
        mUnderline = other.mUnderline;
        mUnderlineWavy = other.mUnderlineWavy;
        mUnderlineDotted = other.mUnderlineDotted;
        mUnderlineDashed = other.mUnderlineDashed;
        mBlink = other.mBlink;
        mFastBlink = other.mFastBlink;
        mConcealed = other.mConcealed;
        mAltFont = other.mAltFont;
        mMudLine = other.mMudLine;
        mMudBuffer = other.mMudBuffer;
        mIncompleteSequenceBytes = other.mIncompleteSequenceBytes;
        lastLoggedFromLine = other.lastLoggedFromLine;
        lastloggedToLine = other.lastloggedToLine;
        lastTextToLog = other.lastTextToLog;
        mEncoding = other.mEncoding;
        mCurrentHyperlinkCommand = other.mCurrentHyperlinkCommand;
        mCurrentHyperlinkHint = other.mCurrentHyperlinkHint;
        mCurrentHyperlinkLinkId = other.mCurrentHyperlinkLinkId;
        mHyperlinkActive = other.mHyperlinkActive;
        mWatchdogPhase = other.mWatchdogPhase;
        mWatchdogTagSnapshot = other.mWatchdogTagSnapshot;
        mCurrentHyperlinkStyling = other.mCurrentHyperlinkStyling;
        mCurrentHyperlinkMenu = other.mCurrentHyperlinkMenu;
        mLinkStates = other.mLinkStates;
        mVisitedLinks = other.mVisitedLinks;
        mLinkOriginalBackgrounds = other.mLinkOriginalBackgrounds;
        mLinkOriginalCharacters = other.mLinkOriginalCharacters;
        mCurrentHoveredLinkIndex = other.mCurrentHoveredLinkIndex;
        mCurrentActiveLinkIndex = other.mCurrentActiveLinkIndex;
        mCurrentFocusedLinkIndex = other.mCurrentFocusedLinkIndex;

        mTagWatchdog = std::make_unique<QTimer>();
        mTagWatchdog->setSingleShot(true);
        QObject::connect(mTagWatchdog.get(), &QTimer::timeout, [this]() {
            processMxpWatchdogCallback();
        });
    }
    return *this;
}

// user-defined literal to represent megabytes
// C++ standard requires unsigned long long parameter for integer literal operators
auto operator""_MB(unsigned long long const x) -> int64_t // NOLINT(runtime/int)
{
    return 1024LL * 1024LL * x;
}

void TBuffer::setBufferSize(int requestedLinesLimit, int batch)
{
    if (requestedLinesLimit < 100) {
        requestedLinesLimit = 100;
    }
    if (batch >= requestedLinesLimit) {
        batch = requestedLinesLimit / 10;
    }
    // clip the maximum to something reasonable that the machine can handle
    auto max = getMaxBufferSize();
    if (requestedLinesLimit > max) {
        qWarning().nospace() << "setBufferSize(): " << requestedLinesLimit << "lines for buffer requested but your computer can only handle " << max << ", clipping it";
        mLinesLimit = max;
    } else {
        mLinesLimit = requestedLinesLimit;
    }

    mBatchDeleteSize = batch;
}

// naive calculation to get a reasonable limit for a maximum buffer size
int TBuffer::getMaxBufferSize()
{
    const int64_t physicalMemoryTotal = mudlet::self()->getPhysicalMemoryTotal();
    // Mudlet is 32bit mainly on Windows, see where the practical limit for a process 2GB:
    // https://docs.microsoft.com/en-us/windows/win32/memory/memory-limits-for-windows-releases#memory-and-address-space-limits
    // 64bit: set to 80% of what is available to us, swap not included
    const int64_t maxProcessMemoryBytes = (QSysInfo::WordSize == 32) ? 1600_MB : (physicalMemoryTotal * 0.80);
    auto maxLines = (maxProcessMemoryBytes / TCHAR_IN_BYTES) / mpHost->mWrapAt;
    // now we've calculated how many lines can we fit in 80% of memory, ignoring memory use for other things like triggers/aliases, Lua scripts, etc
    // so shave that down by 20%
    maxLines = (maxLines / 100) * 80;

    return maxLines;
}

void TBuffer::updateColors()
{
    Host* pH = mpHost;
    if (!pH) {
        qWarning() << "TBuffer::updateColors() ERROR - Called when mpHost pointer is nullptr";
        return;
    }

    mBlack = pH->mBlack;
    mLightBlack = pH->mLightBlack;
    mRed = pH->mRed;
    mLightRed = pH->mLightRed;
    mLightGreen = pH->mLightGreen;
    mGreen = pH->mGreen;
    mLightBlue = pH->mLightBlue;
    mBlue = pH->mBlue;
    mLightYellow = pH->mLightYellow;
    mYellow = pH->mYellow;
    mLightCyan = pH->mLightCyan;
    mCyan = pH->mCyan;
    mLightMagenta = pH->mLightMagenta;
    mMagenta = pH->mMagenta;
    mLightWhite = pH->mLightWhite;
    mWhite = pH->mWhite;
    mForeGroundColor = pH->mFgColor;
    mForeGroundColorLight = pH->mFgColor;
    mBackGroundColor = pH->mBgColor;
}

QPoint TBuffer::getEndPos()
{
    int x = 0;
    int y = 0;
    if (!buffer.empty()) {
        y = buffer.size() - 1;
        if (!buffer.at(y).empty()) {
            x = buffer.at(y).size() - 1;
        }
    }
    QPoint P_end(x, y);
    return P_end;
}

// If buffer is empty zero is now returned and that is also returned if it only
// contains ONE line
int TBuffer::getLastLineNumber()
{
    if (static_cast<int>(buffer.size()) > 0) {
        return static_cast<int>(buffer.size()) - 1;
    } else {
        return 0; //-1;
    }
}

void TBuffer::addLink(bool trigMode, const QString& text, QStringList& command, QStringList& hint, const TChar& format, const QVector<int>& luaReference)
{
    const int id = mLinkStore.addLinks(command, hint, mpHost, luaReference);

    if (!trigMode) {
        append(text, 0, text.length(), format.mFgColor, format.mBgColor, format.mFlags, id);
    } else {
        appendLine(text, 0, text.length(), format.mFgColor, format.mBgColor, format.mFlags, id);
    }
}

/* ANSI color codes: sequence = "ESCAPE + [ code_1; ... ; code_n m"
      -----------------------------------------
      0 reset
      1 intensity bold on
      2 intensity faint on
      3 italics on
      4 underline on
      5 blink on slow
      6 blink on fast
      7 inverse on
      9 strikethrough on
      10 ? TODO
      22 intensity normal (not bold, not faint)
      23 italics off
      24 underline off
      25 blink off
      26 RESERVED (for proportional spacing)
      27 inverse off
      29 strikethrough off
      30 fg black
      31 fg red
      32 fg green
      33 fg yellow
      34 fg blue
      35 fg magenta
      36 fg cyan
      37 fg white
      39 fg default
      40 bg black
      41 bg red
      42 bg green
      43 bg yellow
      44 bg blue
      45 bg magenta
      46 bg cyan
      47 bg white
      49 bg default
      50 RESERVED (for proportional spacing)
      51 framed on
      52 encircled on
      53 overlined on
      54 framed / encircled off
      55 overlined off

      Notes for code 38/48:
      38:0 implementation defined (48:0 is NOT allowed)

      38:1 transparent foreground
      48:1 transparent background

      sequences for 24(32 for '4')-bit Color support:
      38:2:???:0-255:0-255:0-255:XXX:0-255:0-1 (direct) RGB space foreground color
      48:2:???:0-255:0-255:0-255:XXX:0-255:0-1 (direct) RGB space background color
      38:3:???:0-255:0-255:0-255:XXX:0-255:0-1 (direct) CMY space foreground color
      48:3:???:0-255:0-255:0-255:XXX:0-255:0-1 (direct) CMY space background color
      38:4:???:0-255:0-255:0-255:0-255:0-255:0-1 (direct) CMYK space foreground color
      48:4:???:0-255:0-255:0-255:0-255:0-255:0-1 (direct) CMYK space background color
      The third parameter is the color space id but this is expected to be the
      "default" value which is an empty string. The seventh parameter may be used
      to specify a tolerance value (an integer) and parameter eight may be used
      to specify a colour space associated with the tolerance (0 for CIELUV,
      1 for CIELAB).

      sequences for (indexed) 256 Color support:
      38:5:0-256 (indexed) foreground color
      48:5:0-256 (indexed) background color:
          0x00-0x07:   0 -   7 standard colors (as in ESC [ 30–37 m)
          0x08-0x0F:   8 -  15 high intensity colors (as in ESC [ 90–97 m)
          0x10-0xE7:  16 - 231 6 × 6 × 6 = 216 colors: 16 + 36 × r + 6 × g + b (0 ≤ r, g, b ≤ 5)
          0xE8-0xFF: 232 - 255 grayscale from black to white in 24 steps

      Also note that for the 38 and 48 codes the parameter elements SHOULD be
      separated by ':' but some interpretations erroneously use ';'.  Also
      "empty" parameter elements represent a default value and that empty
      elements at the end can be omitted.
 */

void TBuffer::translateToPlainText(std::string& incoming, const bool isFromServer)
{
    // What can appear in a CSI Parameter String (Ps) byte or at least for it
    // to be something we can handle:
    const QByteArray cParameter = QByteArrayLiteral("0123456789;:");
    // What can appear in the initial position of a CSI Parameter String (Ps) byte:
    const QByteArray cParameterInitial = QByteArrayLiteral("0123456789;:<=>?");
    // What can appear in a CSI Intermediate byte (includes a quote character in
    // the middle of the text here which has to be escaped with a backslash):
    const QByteArray cIntermediate = QByteArrayLiteral(" !\"#$%&'()*+,-./");
    // What can appear in a CSI final byte position - (includes a backslash
    // which has to be doubled to include it in here):
    const QByteArray cFinal = QByteArrayLiteral("@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~");

    // As well as enabling the prepending of left-over bytes from last packet
    // from the MUD server this may help in high frequency interactions to
    // protect this process from the supplied string being modified
    // asynchronously by the QNetwork code that runs in another thread:
    std::string localBuffer;

    Host* pHost = mpHost;
    if (!pHost) {
        qWarning() << "TBuffer::translateToPlainText(...) ERROR: Cannot access Host instance at this time - data has been lost.";
        return; // We really have a problem
    }

    // Check this each packet
    const QByteArray usedEncoding = mpHost->mTelnet.getEncoding();
    if (mEncoding != usedEncoding) {
        encodingChanged(usedEncoding);
        // Will have to dump any stored bytes as they will be in the old
        // encoding and the following code block to prepend them is used for
        // both bytes that are held over as part of a multi-byte encoding that
        // was incomplete at the end of the last packet AND ALSO for ANSI code
        // sequences that were not complete at the end of the last packet:
        if (!mIncompleteSequenceBytes.empty()) {
#if defined(DEBUG_SGR_PROCESSING) || defined(DEBUG_OSC_PROCESSING) || defined(DEBUG_UTF8_PROCESSING) || defined(DEBUG_GB_PROCESSING) || defined(DEBUG_BIG5_PROCESSING)
            qDebug() << "TBuffer::translateToPlainText(...) WARNING - Dumping residual bytes that were carried over from previous packet onto incoming data - the encoding has changed and they may no "
                        "longer be usable!";
#endif
            mIncompleteSequenceBytes.clear();
        }
    }

    if (isFromServer && !mIncompleteSequenceBytes.empty()) {
#if defined(DEBUG_SGR_PROCESSING) || defined(DEBUG_OSC_PROCESSING) || defined(DEBUG_UTF8_PROCESSING) || defined(DEBUG_GB_PROCESSING) || defined(DEBUG_BIG5_PROCESSING)
        qDebug() << "TBuffer::translateToPlainText(...) Prepending residual bytes onto incoming data!";
#endif
        localBuffer = mIncompleteSequenceBytes + incoming;
        mIncompleteSequenceBytes.clear();
    } else {
        localBuffer = incoming;
    }

    crashIfRequested();

    // Fixup table for our own, substitute QTextCodecs:
    QByteArray encodingTableToUse{mEncoding};
    if (mEncoding == "M_CP437") {
        encodingTableToUse = "CP437";
    } else if (mEncoding == "M_CP667") {
        encodingTableToUse = "CP667";
    } else if (mEncoding == "M_CP737") {
        encodingTableToUse = "CP737";
    } else if (mEncoding == "M_CP869") {
        encodingTableToUse = "CP869";
    } else if (mEncoding == "M_MEDIEVIA") {
        encodingTableToUse = "MEDIEVIA";
    }

    const QVector<QChar> encodingLookupTable = csmEncodingTable.getLookupTable(encodingTableToUse);
    // If the encoding is "ASCII", "ISO 8859-1", "UTF-8", "GBK", "GB18030",
    // "BIG5", "BIG5-HKSCS" or "EUC-KR" (which are not in the table)
    // encodingLookupTable will be empty otherwise the 128 values in the
    // returned table will be used for all the text data that gets through the
    // following ANSI code and other out-of-band data processing - doing this
    // means that a (fast) lookup in the QVector can be done as opposed to a
    // repeated switch(...) and branch to one of a series of decoding methods
    // each with another up to 128 value switch()

    size_t localBufferLength = localBuffer.length();
    size_t localBufferPosition = 0;
    if (!localBufferLength) {
        return;
    }

    // If we are resolving/interpolating an MXP entity, the interpolated text
    // ends at localBuffer[endOfMXPEntity - 1]. This variable used to avoid an
    // (infinite) recursion like <!EN E "foobar&E;>&E;
    // Recursively interpolating a predefined entity like <!EN E "foobar&frac12;>&E;
    // will work though.
    size_t endOfMXPEntity = 0;

    // A similar index which points behind the name of a literal entity name like
    // &unknown; which does not exist and will be printed literal, w/o
    // any MXP interpretation. Again, this avoid endless recursion trying to
    // resolve an unsolvable entity. We need the hassle in both cases, as the
    // the resolved values may be in a character encoding that must be decoded by
    // Mudlet.
    size_t endOfLiteralEntity = 0;

    while (true) {
        if (localBufferPosition >= localBufferLength) {
            return;
        }

        char& ch = localBuffer[localBufferPosition];
        if (ch == '\033') {
            if (!mGotOSC) {
                // The terminator for an OSC is the String Terminator but that
                // is the ESC character followed by (the single character)
                // '\\' so must not respond to an ESC here - though the code
                // arrangement should avoid looping around this loop while
                // seeking this character pair anyhow...

                // If MXP is building a tag when we see ESC, the tag is invalid
                // because ANSI escape sequences cannot be part of MXP tags.
                // Output the partial tag as literal text before processing ESC.
                if (mpHost->mMxpProcessor.isEnabled() && mpHost->mMxpProcessor.getMxpTagBuilder().isInsideTag()) {
                    TChar::AttributeFlags attributeFlags = computeCurrentAttributeFlags();
                    attributeFlags &= ~(TChar::FastBlink | TChar::Concealed | TChar::AltFontMask);
                    TChar c((!mIsDefaultColor && mBold) ? mForeGroundColorLight : mForeGroundColor, mBackGroundColor, attributeFlags);

                    const QString literalText = mpHost->mMxpProcessor.abortCurrentTag();
                    mMudLine.append(literalText);
                    for (int i = 0; i < literalText.length(); ++i) {
                        mMudBuffer.push_back(c);
                    }

                    mTagWatchdog->stop();
                    mWatchdogPhase = WatchdogPhase::None;
                }

                mGotESC = true;
                ++localBufferPosition;
                continue;
            }
        }

        if (mGotESC && (ch == '[' || ch == ']')) {
            mGotESC = false;
            mGotCSI = (ch == '[');
            mGotOSC = (ch == ']');
            ++localBufferPosition;
            continue;
        }

        if (mGotCSI) {
            // Lookahead and try and see what we are processing
            // At the start of a CSI sequence the only valid character is one of:
            // "0-9:;<=>?" if it is one of "0-9:;" then it is a
            // "parameter-string" ELSE if it is one of '<', '=', '>' or '?' it
            // IS a private/experimental and not covered by the ECMA-48
            // specifications..
            // After the first character the remaining characters of the
            // parameter string will be in the range "0-9:;" only
            size_t const spanStart = localBufferPosition;
            size_t spanEnd = spanStart;
            while (spanEnd < localBufferLength
                   && ((((spanStart < spanEnd) && cParameterInitial.indexOf(localBuffer[spanEnd]) >= 0)) || ((spanStart == spanEnd) && cParameter.indexOf(localBuffer[spanEnd]) >= 0))) {
                ++spanEnd;
            }

            // Test whether the first byte is within the usable subset of the
            // allowed value - or not:
            if (cParameter.indexOf(localBuffer[spanStart]) == -1 && cParameterInitial.indexOf(localBuffer[spanStart]) >= 0) {
                // Oh dear, the CSI parameter string sequence begins with one of
                // the reserved characters ('<', '=', '>' or '?') which we
                // can/do not handle

                qDebug().noquote().nospace() << "TBuffer::translateToPlainText(...) INFO - detected a private/reserved CSI sequence beginning with \"CSI"
                                             << localBuffer.substr(spanStart, spanEnd - spanStart).c_str() << "\" which Mudlet cannot interpret.";
                // So skip over it as far as we can - will still possibly have
                // garbage beyond the end which will still be shown...
                localBufferPosition += 1 + spanEnd - spanStart;
                mGotCSI = false;
                // Go around while loop again:
                continue;
            }

            if (spanEnd >= localBufferLength || cParameter.indexOf(localBuffer[spanEnd]) >= 0) {
                // We have gone to the end of the buffer OR the last character
                // in the buffer is still within a CSI sequence - therefore we
                // have got a split between data packets and are not in a
                // position to process the current line further...

                mIncompleteSequenceBytes = localBuffer.substr(spanStart);
                return;
            }

            // Now we can take a peek at what the next character is, it could
            // be an optional (and we are not expecting this) "intermediate
            // byte" which is space or one of "!"#$%&'()*+,-./" or a "final
            // byte" which is what determines what on earth the CSI is for, it
            // should be in the (ASCII) range '@' to '~' and the end of that
            // range 'p' to '~' is for "private" or "experimental" use.

            if (cIntermediate.indexOf(localBuffer[spanEnd]) >= 0) {
                // We do not handle any sequences with intermediate bytes
                // Report it and then ignore it, try and find out what the byte
                // afterwards is as it might help to debug things
                if (spanEnd + 1 < localBufferLength) {
                    // Yeah there is another byte we can report as the final byte
                    qDebug().noquote().nospace() << "TBuffer::translateToPlainText(...) INFO - detected a CSI sequence with an 'intermediate' byte ('" << localBuffer[spanEnd]
                                                 << "') and a 'final' byte ('" << localBuffer[spanEnd + 1] << "') which Mudlet cannot interpret.";
                } else {
                    qDebug().noquote().nospace() << "TBuffer::translateToPlainText(...) INFO - detected a CSI sequence with an 'intermediate' byte ('" << localBuffer[spanEnd]
                                                 << "') which Mudlet cannot interpret.";
                }
                // So skip over it as far as we can - will still be possible to
                // have garbage beyond the end which will still be shown...
                localBufferPosition += 1 + spanEnd - spanStart;
                mGotCSI = false;
                // Go around while loop again:
                continue;
            }

            if (cFinal.indexOf(localBuffer[spanEnd]) >= 0) {
                // We have a valid CSI sequence - but is it one we handle?
                // We currently only handle the 'm' for SGR and the 'z' for
                // Zuggsoft's MXP protocol:
                const quint8 modeChar = static_cast<unsigned char>(localBuffer[spanEnd]);
                switch (modeChar) {
                case static_cast<quint8>('m'):
                    // We have a complete SGR sequence:
#if defined(DEBUG_SGR_PROCESSING)
                    qDebug().nospace().noquote() << "    Consider the SGR sequence: \"" << localBuffer.substr(localBufferPosition, spanEnd - spanStart).c_str() << "\"";
#endif
                    decodeSGR(QString(localBuffer.substr(localBufferPosition, spanEnd - spanStart).c_str()));
                    break;

                case static_cast<quint8>('z'):
                    // We have a control sequence for MXP
#if defined(DEBUG_MXP_PROCESSING)
                    qDebug().nospace().noquote() << "    Consider the MXP control sequence: \"" << localBuffer.substr(localBufferPosition, spanEnd - spanStart).c_str() << "\"";
#endif
                    if (isFromServer && (mpHost->mTelnet.isMXPEnabled() || mpHost->getForceMXPProcessorOn())) {
                        mGotCSI = false;

                        const QString code = QString(localBuffer.substr(localBufferPosition, spanEnd - spanStart).c_str());
                        mpHost->mMxpProcessor.setMode(code);
                    }

                    break;

                case static_cast<quint8>('C'): {
                    // A workaround for the ONE cursor movement command we CAN
                    // emulate - the CUF Cursor forward one:
                    // Needed for mud.durismud.com see forum message topic:
                    // https://forums.mudlet.org/viewtopic.php?f=9&t=22887
                    const int dataLength = spanEnd - spanStart;
                    const QByteArray temp = QByteArray::fromRawData(localBuffer.substr(localBufferPosition, dataLength).c_str(), dataLength);
                    bool isOk = false;
                    const int spacesNeeded = temp.toInt(&isOk);
                    if (isOk && spacesNeeded > 0) {
                        // Note: we are using the background color for the
                        // foreground color as well so that we are transparent:
                        const TChar c(mBackGroundColor, mBackGroundColor, computeCurrentAttributeFlags());
                        for (int spaceCount = 0; spaceCount < spacesNeeded; ++spaceCount) {
                            mMudLine.append(QChar::Space);
                            mMudBuffer.push_back(c);
                        }
                        // For debugging:
                        //                        qDebug().noquote().nospace() << "TBuffer::translateToPlainText(...) INFO - CUF (cursor forward) sequence of form CSI" << temp << "C received, converting into " << spacesNeeded << " spaces.";
                    } else {
                        qDebug().noquote().nospace() << "TBuffer::translateToPlainText(...) INFO - Unhandled sequence of form CSI..." << temp
                                                     << "C received, that is supposed to be a CUF (cursor forward) sequence but doesn't make sense, Mudlet will ignore it.";
                    }

                } break;

                case static_cast<quint8>('J'): {
                    /*
                     * Also seen in output from mud.durismud.com see 'C' case above:
                     * Is ED 'Erase Display' command and has three variants:
                     * * 0 (or omitted): clear from cursor to end of screen
                     *   - which is a NOP for us!
                     * * 1: clear from cursor to beginning of screen
                     *   - which is a NWIH for us!
                     * * 2: clear entire screen and delete all lines saved in
                     *   scrollback buffer - which is again a NWIH for us...!
                     */
                    const int dataLength = spanEnd - spanStart;
                    const QByteArray temp = QByteArray::fromRawData(localBuffer.substr(localBufferPosition, dataLength).c_str(), dataLength);
                    bool isOk = false;
                    const int argValue = temp.toInt(&isOk);
                    if (isOk) {
                        if (argValue >= 0 && argValue < 3) {
                            qDebug().noquote().nospace() << "TBuffer::translateToPlainText(...) INFO - ED (erase in display) sequence of form CSI" << temp
                                                         << "J received,\nrejecting as incompatible with Mudlet.";
                        } else {
                            qDebug().noquote().nospace() << "TBuffer::translateToPlainText(...) INFO - Invalid ED (erase in display) sequence of form CSI" << temp
                                                         << "J received,\nwhich Mudlet will ignore.";
                        }
                    } else {
                        qDebug().noquote().nospace() << "TBuffer::translateToPlainText(...) INFO - Unhandled sequence of form CSI..." << temp
                                                     << "J received, that is supposed to\nbe a ED (erase in display) sequence but it doesn't make sense, Mudlet will ignore it.";
                    }
                } break;

                default: // Unhandled other (valid) CSI final byte sequences will end up here
                    qDebug().noquote().nospace() << "TBuffer::translateToPlainText(...) INFO - Unhandled sequence of form CSI..." << localBuffer[spanEnd] << " received, Mudlet will ignore it.";

                } // End of switch(modeChar) {}
            } else {
                qDebug().noquote().nospace() << "TBuffer::translateToPlainText(...) INFO - detected an invalid CSI sequence beginning with \"CSI"
                                             << localBuffer.substr(spanStart, spanEnd - spanStart).c_str() << " which Mudlet will ignore.";
            } // End of (isAValidFinalByte) {}

            mGotCSI = false;
            localBufferPosition += 1 + spanEnd - spanStart;
            // Go around while loop again:
            continue;

        } // End of if (mGotCSI)

        if (mGotOSC) {
            // Lookahead and find end of sequence - valid terminators are:
            // - ST (String Terminator): ESC followed by '\' (backslash)
            // - BEL (0x07): Common alternative used by xterm and many MUDs

            // Valid characters inside an OSC are: a "command string" or a
            // "character string".
            // A "command string" is a sequence of bit combinations in the range
            // <BS><TAB><LF><VT><FF><CR> and ASCII printables from Space to '~'
            // A "character string" is a sequence of any character except Start
            // of String (SOS) or String Terminator (ST) and the latter is ESC
            // followed by '\\' (a single \ BTW) in the 7-bit code case (the
            // former is encoded as ESC followed by 'X'):
            size_t const spanStart = localBufferPosition;
            size_t spanEnd = spanStart;
            // It is safe to look at spanEnd-1 even at the starting position
            // because we already know that the localBuffer extends backwards
            // that far (it will be the ']' character!)
            // Loop until we find ST (ESC \), BEL, exceed length limit, or run out of data
            while (spanEnd < localBufferLength && (spanEnd - spanStart) < MAX_OSC_SEQUENCE_LENGTH && localBuffer[spanEnd] != '\x07'
                   && !((spanEnd > 0 && localBuffer[spanEnd - 1] == '\033') && localBuffer[spanEnd] == '\\')) {
                ++spanEnd;
            }

            // Determine what terminated the loop
            const bool foundBEL = (spanEnd < localBufferLength && localBuffer[spanEnd] == '\x07');
            const bool foundST = (spanEnd < localBufferLength && spanEnd > 0 && localBuffer[spanEnd - 1] == '\033' && localBuffer[spanEnd] == '\\');
            const bool exceededLength = ((spanEnd - spanStart) >= MAX_OSC_SEQUENCE_LENGTH);

            if (foundBEL) {
                // BEL terminator - sequence content excludes the BEL
                decodeOSC(QString(localBuffer.substr(spanStart, spanEnd - spanStart).c_str()));
                mGotOSC = false;
                localBufferPosition = spanEnd + 1; // Skip past BEL
                continue;
            } else if (foundST) {
                // ST terminator (ESC \) - sequence content excludes the ESC before backslash
                decodeOSC(QString(localBuffer.substr(spanStart, spanEnd - spanStart - 1).c_str()));
                mGotOSC = false;
                localBufferPosition = spanEnd + 1; // Skip past backslash
                continue;
            } else if (exceededLength) {
                // Length limit exceeded - scan forward for a terminator to skip the malformed sequence
                qWarning().noquote().nospace() << "TBuffer::translateToPlainText(...) WARNING - OSC sequence exceeded " << MAX_OSC_SEQUENCE_LENGTH
                                               << " bytes without terminator, scanning for terminator to recover.";
                // Continue scanning from where we left off to find a terminator
                while (spanEnd < localBufferLength && localBuffer[spanEnd] != '\x07' && !((spanEnd > 0 && localBuffer[spanEnd - 1] == '\033') && localBuffer[spanEnd] == '\\')) {
                    ++spanEnd;
                }
                // Check if we found a terminator
                if (spanEnd < localBufferLength && localBuffer[spanEnd] == '\x07') {
                    // Found BEL - skip to after it
                    mGotOSC = false;
                    localBufferPosition = spanEnd + 1;
                    continue;
                } else if (spanEnd < localBufferLength && spanEnd > 0 && localBuffer[spanEnd - 1] == '\033' && localBuffer[spanEnd] == '\\') {
                    // Found ST - skip to after it
                    mGotOSC = false;
                    localBufferPosition = spanEnd + 1;
                    continue;
                } else {
                    // No terminator found yet - wait for more data
                    // Store from current position to avoid re-scanning the entire sequence
                    mIncompleteSequenceBytes = localBuffer.substr(spanEnd);
                    return;
                }
            } else {
                // Incomplete sequence - not enough data yet, wait for more
                // (This is safe because we're under the length limit)
                mIncompleteSequenceBytes = localBuffer.substr(spanStart);
                return;
            }
        }

        // We are outside of a CSI or OSC sequence if we get to here:

        if (localBufferPosition >= endOfLiteralEntity && mpHost->mMxpProcessor.isEnabled()) {
            if (mpHost->mTelnet.isMXPEnabled() || mpHost->getForceMXPProcessorOn()) {
                if (mpHost->mMxpProcessor.mode() != MXP_MODE_LOCKED) {
                    // The comparison signals to the processor, if custom entities may be resolved
                    // (countermeasure against infinite recursion)
                    TMxpProcessingResult const result = mpHost->mMxpProcessor.processMxpInput(ch, localBufferPosition >= endOfMXPEntity);
                    if (!mTagWatchdog->isActive() && mpHost->mMxpProcessor.getMxpTagBuilder().isInsideTag() && !mpHost->mMxpProcessor.getMxpTagBuilder().getRawTagContent().empty()) {
                        mWatchdogPhase = WatchdogPhase::Phase1_Snapshot;
                        mTagWatchdog->start(MAX_TAG_TIMEOUT_MS);
                    }

                    switch (result) {
                    case HANDLER_NEXT_CHAR:
                        localBufferPosition++;
                        continue;
                    case HANDLER_COMMIT_LINE: // BR tag or &newline;
                        ch = '\n';
                        goto COMMIT_LINE;
                    case HANDLER_INSERT_ENTITY_CUST:
                        // custom entity value set with <!EN>, recurse except for other custom entities
                        [[fallthrough]];
                    case HANDLER_INSERT_ENTITY_LIT: {
                        // Unknown entity name like &unknown; push back into buffer for codeset interpretation,
                        // but no MXP parsing.

                        // We replace the already processed text with the entity value into the buffer and restart
                        // processing it for charset encoding but with limited MXP handling
                        size_t valueLength = mpHost->mMxpProcessor.getEntityValue().length();
                        localBuffer.replace(0, localBufferPosition + 1, mpHost->mMxpProcessor.getEntityValue().toLatin1());

                        if (result == HANDLER_INSERT_ENTITY_LIT) {
                            if (localBufferPosition < endOfMXPEntity) {
                                // This is a special case, our unknown entity might actually be a custom one
                                // inside a custom one which we refused to resolve to avoid an endless recursion.
                                // So we carefully adjust the end marker s.t. custom entities are not reenabled
                                // too early
                                endOfMXPEntity -= localBufferPosition + 1 - valueLength;
                                endOfLiteralEntity = valueLength;
                            } else {
                                endOfMXPEntity = valueLength;
                            }
                            endOfLiteralEntity = valueLength;
                        } else {
                            // HANDLER_INSERT_ENTITY_CUST
                            endOfMXPEntity = valueLength;
                            endOfLiteralEntity = 0;
                        }

                        // Now restart the loop to parse the newly inserted text
                        localBufferLength = localBuffer.length();
                        localBufferPosition = 0;
                        continue;
                    }
                    case HANDLER_INSERT_ENTITY_SYS: {
                        // System entities are literal QString / UTF values which we just 'print'
                        // There is no further MXP or Codeset evaluation

                        TChar::AttributeFlags attributeFlags = computeCurrentAttributeFlags();
                        attributeFlags &= ~(TChar::FastBlink | TChar::Concealed | TChar::AltFontMask);
                        TChar c((!mIsDefaultColor && mBold) ? mForeGroundColorLight : mForeGroundColor, mBackGroundColor, attributeFlags);

                        size_t valueLength = mpHost->mMxpProcessor.getEntityValue().length();
                        mMudLine.append(mpHost->mMxpProcessor.getEntityValue());
                        // We also need to set the color attributes for the special character
                        while (valueLength--) {
                            mMudBuffer.push_back(c);
                        }
                        // We already handled the input, go to the next character
                        localBufferPosition++;
                        continue;
                    }
                    case HANDLER_INSERT_AND_REPROCESS: {
                        // Insert text like HANDLER_INSERT_ENTITY_SYS, but don't increment position.
                        // Used for error recovery: output the rejected/incomplete tag as literal
                        // text, then reprocess the current character (which may be a newline,
                        // an ANSI escape, or the character after an unrecognized tag name)

                        TChar::AttributeFlags attributeFlags = computeCurrentAttributeFlags();
                        attributeFlags &= ~(TChar::FastBlink | TChar::Concealed | TChar::AltFontMask);
                        TChar c((!mIsDefaultColor && mBold) ? mForeGroundColorLight : mForeGroundColor, mBackGroundColor, attributeFlags);

                        size_t valueLength = mpHost->mMxpProcessor.getEntityValue().length();
                        mMudLine.append(mpHost->mMxpProcessor.getEntityValue());
                        while (valueLength--) {
                            mMudBuffer.push_back(c);
                        }
                        // Do NOT increment position - the current character needs to be reprocessed
                        continue;
                    }
                    default:
                        //HANDLER_FALL_THROUGH -> do nothing
                        assert(localBuffer[localBufferPosition] == ch);
                    }
                } else if (mpHost->mMxpProcessor.getMxpTagBuilder().isInsideTag()) {
                    // Mode is LOCKED but we're inside a tag that started in a different mode.
                    // We need to continue feeding characters to the tag builder to complete the tag.
                    TMxpProcessingResult const result = mpHost->mMxpProcessor.processMxpInput(ch, localBufferPosition >= endOfMXPEntity);

                    switch (result) {
                    case HANDLER_NEXT_CHAR:
                        localBufferPosition++;
                        continue;
                    case HANDLER_INSERT_ENTITY_SYS: {
                        // Tag was not handled, output as text
                        TChar::AttributeFlags attributeFlags = computeCurrentAttributeFlags();
                        attributeFlags &= ~(TChar::FastBlink | TChar::Concealed | TChar::AltFontMask);
                        TChar c((!mIsDefaultColor && mBold) ? mForeGroundColorLight : mForeGroundColor, mBackGroundColor, attributeFlags);

                        size_t valueLength = mpHost->mMxpProcessor.getEntityValue().length();
                        mMudLine.append(mpHost->mMxpProcessor.getEntityValue());
                        while (valueLength--) {
                            mMudBuffer.push_back(c);
                        }
                        localBufferPosition++;
                        continue;
                    }
                    case HANDLER_INSERT_AND_REPROCESS: {
                        TChar::AttributeFlags attributeFlags = computeCurrentAttributeFlags();
                        attributeFlags &= ~(TChar::FastBlink | TChar::Concealed | TChar::AltFontMask);
                        TChar c((!mIsDefaultColor && mBold) ? mForeGroundColorLight : mForeGroundColor, mBackGroundColor, attributeFlags);

                        size_t valueLength = mpHost->mMxpProcessor.getEntityValue().length();
                        mMudLine.append(mpHost->mMxpProcessor.getEntityValue());
                        while (valueLength--) {
                            mMudBuffer.push_back(c);
                        }
                        continue;
                    }
                    default:
                        localBufferPosition++;
                        continue;
                    }
                } else {
                    mpHost->mMxpProcessor.processRawInput(ch);
                }
            }

            if (CHAR_IS_COMMIT_CHAR(ch)) {
                // after a newline (but not a <br>) return to default mode
                // BUT only if we're not in the middle of parsing a tag (tags can span lines)
                if (!mpHost->mMxpProcessor.getMxpTagBuilder().isInsideTag()) {
                    mpHost->mMxpProcessor.resetToDefaultMode();
                }
            }
        }

    COMMIT_LINE:
        if (commitLine(ch, localBufferPosition)) {
            continue;
        }
        // PLACEMARKER: Incoming text decoding
        // Used to double up the TChars for Utf-8 byte sequences that produce
        // a surrogate pair (non-BMP):
        bool isTwoTCharsNeeded = false;

        if (!encodingLookupTable.isEmpty()) {
            auto index = static_cast<quint8>(ch);
            if (index < 128) {
                mMudLine.append(QChar::fromLatin1(ch));
            } else {
                mMudLine.append(encodingLookupTable.at(index - 128));
            }
        } else if (mEncoding == "ISO 8859-1") {
            mMudLine.append(QString(QChar::fromLatin1(ch)));
        } else if (mEncoding == "GBK") {
            if (!processGBSequence(localBuffer, isFromServer, false, localBufferLength, localBufferPosition, isTwoTCharsNeeded)) {
                // We have run out of bytes and we have stored the unprocessed
                // ones but we need to bail out NOW!
                return;
            }
        } else if (mEncoding == "GB18030") {
            if (!processGBSequence(localBuffer, isFromServer, true, localBufferLength, localBufferPosition, isTwoTCharsNeeded)) {
                // We have run out of bytes and we have stored the unprocessed
                // ones but we need to bail out NOW!
                return;
            }
        } else if (mEncoding == "EUC-KR") {
            if (!processEUC_KRSequence(localBuffer, isFromServer, localBufferLength, localBufferPosition, isTwoTCharsNeeded)) {
                // We have run out of bytes and we have stored the unprocessed
                // ones but we need to bail out NOW!
                return;
            }
        } else if (mEncoding == "BIG5" || mEncoding == "BIG5-HKSCS") {
            if (!processBig5Sequence(localBuffer, isFromServer, localBufferLength, localBufferPosition, isTwoTCharsNeeded)) {
                // We have run out of bytes and we have stored the unprocessed
                // ones but we need to bail out NOW!
                return;
            }
        } else if (mEncoding == "UTF-8") {
            if (!processUtf8Sequence(localBuffer, isFromServer, localBufferLength, localBufferPosition, isTwoTCharsNeeded)) {
                // We have run out of bytes and we have stored the unprocessed
                // ones but we need to bail out NOW!
                return;
            }
        } else {
            // Default - no encoding case - reject anything that has MS Bit set
            // as that isn't ASCII which is what no encoding specifies!
            if (ch & 0x80) {
                // Was going to ignore this byte, not add a TChar instance
                // either and move on:
                // ++localBufferPosition;
                // continue;
                // but instead insert the "Replacement Character Marker"
                mMudLine.append(QChar::ReplacementCharacter);
            } else {
                mMudLine.append(ch);
            }
        }

        TChar c((!mIsDefaultColor && mBold) ? mForeGroundColorLight : mForeGroundColor, mBackGroundColor, computeCurrentAttributeFlags());

        if (mHyperlinkActive) {
            c.mLinkIndex = mCurrentHyperlinkLinkId;

            // Store the original ANSI-formatted character before applying JSON styling
            // This is needed for ANSI base restoration when pseudo-classes are inactive
            if (!mLinkOriginalCharacters.contains(mCurrentHyperlinkLinkId)) {
                mLinkOriginalCharacters[mCurrentHyperlinkLinkId] = c;
#if defined(DEBUG_OSC_PROCESSING)
                qDebug().nospace().noquote() << "TBuffer::translateToPlainText(): Stored original character for link " << mCurrentHyperlinkLinkId << " with ANSI colors: fg=" << c.mFgColor.name()
                                             << " bg=" << c.mBgColor.name() << " flags=" << c.mFlags;
#endif
            }

            // Apply base styling first (if any)
            if (mCurrentHyperlinkStyling.hasForegroundColor) {
                c.mFgColor = mCurrentHyperlinkStyling.foregroundColor;
            }

            if (mCurrentHyperlinkStyling.hasBackgroundColor) {
                c.mBgColor = mCurrentHyperlinkStyling.backgroundColor;
            }

            // For preset-only links, base styling may be empty but pseudo-class styling exists
            // Apply effective styling with :link pseudo-class to ensure preset colors show
            Mudlet::HyperlinkStyling effectiveStyling = getEffectiveHyperlinkStyling(mCurrentHyperlinkLinkId);
            if (effectiveStyling.hasCustomStyling) {
                if (effectiveStyling.hasForegroundColor) {
                    c.mFgColor = effectiveStyling.foregroundColor;
                }
                if (effectiveStyling.hasBackgroundColor) {
                    c.mBgColor = effectiveStyling.backgroundColor;
                }
                if (effectiveStyling.isBold) {
                    c.mFlags |= TChar::Bold;
                }
                if (effectiveStyling.isItalic) {
                    c.mFlags |= TChar::Italic;
                }
                if (effectiveStyling.isUnderlined) {
                    c.mFlags |= TChar::Underline;
                    switch (effectiveStyling.underlineStyle) {
                    case Mudlet::HyperlinkStyling::UnderlineWavy:
                        c.mFlags |= TChar::UnderlineWavy;
                        break;
                    case Mudlet::HyperlinkStyling::UnderlineDotted:
                        c.mFlags |= TChar::UnderlineDotted;
                        break;
                    case Mudlet::HyperlinkStyling::UnderlineDashed:
                        c.mFlags |= TChar::UnderlineDashed;
                        break;
                    case Mudlet::HyperlinkStyling::UnderlineSolid:
                    case Mudlet::HyperlinkStyling::UnderlineNone:
                    default:
                        break;
                    }
                }
                if (effectiveStyling.isOverlined) {
                    c.mFlags |= TChar::Overline;
                }
                if (effectiveStyling.isStrikeOut) {
                    c.mFlags |= TChar::StrikeOut;
                }
            }

            // Only re-apply base styling if effective pseudo-class styling is not present
            // This prevents base decoration flags from overriding pseudo-class cascade decisions
            if (!effectiveStyling.hasCustomStyling) {
                if (mCurrentHyperlinkStyling.isUnderlined) {
                    c.mFlags |= TChar::Underline;

                    switch (mCurrentHyperlinkStyling.underlineStyle) {
                    case Mudlet::HyperlinkStyling::UnderlineWavy:
                        c.mFlags |= TChar::UnderlineWavy;
                        break;
                    case Mudlet::HyperlinkStyling::UnderlineDotted:
                        c.mFlags |= TChar::UnderlineDotted;
                        break;
                    case Mudlet::HyperlinkStyling::UnderlineDashed:
                        c.mFlags |= TChar::UnderlineDashed;
                        break;
                    case Mudlet::HyperlinkStyling::UnderlineSolid:
                    case Mudlet::HyperlinkStyling::UnderlineNone:
                    default:
                        break;
                    }
                }

                if (mCurrentHyperlinkStyling.isOverlined) {
                    c.mFlags |= TChar::Overline;
                }

                if (mCurrentHyperlinkStyling.isStrikeOut) {
                    c.mFlags |= TChar::StrikeOut;
                }

                if (mCurrentHyperlinkStyling.isBold) {
                    c.mFlags |= TChar::Bold;
                }

                if (mCurrentHyperlinkStyling.isItalic) {
                    c.mFlags |= TChar::Italic;
                }
            }

            // Only apply underline if explicitly set in styling (respects OSC 8 default of no underline)
            // Note: This differs from other Mudlet hyperlinks which default to underlined
        }

        if (mpHost->mMxpClient.isInLinkMode()) {
            c.mLinkIndex = mLinkStore.getCurrentLinkID();
            c.mFlags |= TChar::Underline;
        }

        if (mpHost->mMxpClient.hasFgColor()) {
            c.mFgColor = mpHost->mMxpClient.getFgColor();
        }

        if (mpHost->mMxpClient.hasBgColor()) {
            c.mBgColor = mpHost->mMxpClient.getBgColor();
        }

        if (isTwoTCharsNeeded) {
            // CHECK: Do we need to duplicate stuff for mMXP_LINK_MODE - yes I think we do:
            mMudBuffer.push_back(c);
            mMudBuffer.push_back(c);
            if (mHyperlinkActive) {
                // Capture the column position when the first character of the hyperlink is added
                if (mCurrentHyperlinkText.isEmpty()) {
                    mCurrentHyperlinkStartColumn = static_cast<int>(mMudBuffer.size()) - 2; // -2 because we just added 2 chars
                }
                mCurrentHyperlinkText += QString(QChar(ch));
                mCurrentHyperlinkText += QString(QChar(ch));
            }
        } else {
            mMudBuffer.push_back(c);
            if (mHyperlinkActive) {
                // Capture the column position when the first character of the hyperlink is added
                if (mCurrentHyperlinkText.isEmpty()) {
                    mCurrentHyperlinkStartColumn = static_cast<int>(mMudBuffer.size()) - 1; // -1 because we just added 1 char
                }
                mCurrentHyperlinkText += QString(QChar(ch));
            }
        }

        ++localBufferPosition;
    }
}

void TBuffer::flushPendingDestinationContent()
{
    if (!mpHost || mMudLine.isEmpty()) {
        return;
    }

    if (mpHost->mMxpFrameManager.hasActiveDestination()) {
        TConsole* destConsole = mpHost->mMxpFrameManager.getCurrentDestinationConsole();

        if (destConsole && destConsole != mpHost->mpConsole) {
            destConsole->printFormatted(mMudLine, mMudBuffer, mLinkStore);
            mMudLine.clear();
            mMudBuffer.clear();
        }
    }
}

void TBuffer::resetCurrentTextFormat()
{
    if (!mpHost) {
        return;
    }

    // Reset to default colors and attributes - equivalent to SGR 0
    mIsDefaultColor = true;
    mForeGroundColor = mpHost->mFgColor;
    mForeGroundColorLight = mpHost->mFgColor;
    mBackGroundColor = mpHost->mBgColor;
    mBold = false;
    mItalics = false;
    mOverline = false;
    mReverse = false;
    mStrikeOut = false;
    mUnderline = false;
    mUnderlineWavy = false;
    mUnderlineDotted = false;
    mUnderlineDashed = false;
    mBlink = false;
    mFastBlink = false;
    mConcealed = false;
    mAltFont = 0;
}

bool TBuffer::commitLine(char ch, size_t& localBufferPosition)
{
    if (CHAR_IS_COMMIT_CHAR(ch)) {
        // DE: MUD Zeilen werden immer am Zeilenanfang geschrieben
        // EN: MUD lines are always written at the beginning of the line

        // FIXME: This is the point where we should renormalise the new text
        // data - of course there is the theoretical chance that the new
        // text would alter the prior contents but as that is on a separate
        // line there should not be any changes to text before a line feed
        // which sort of seems to be implied by the current value of ch:

        // Check if there's an active MXP DEST - route to destination frame
        if (mpHost->mMxpFrameManager.hasActiveDestination()) {
            TConsole* destConsole = mpHost->mMxpFrameManager.getCurrentDestinationConsole();
            if (destConsole && destConsole != mpHost->mpConsole) {
                if (!mMudLine.isEmpty()) {
                    destConsole->printFormatted(mMudLine, mMudBuffer, mLinkStore);
                }
                mMudLine.clear();
                mMudBuffer.clear();
                ++localBufferPosition;
                // Skip creating lines in main console while destination is active
                return true;
            }
        }

        // Qt struggles to report blank lines on Windows to screen readers, this is a workaround
        // https://bugreports.qt.io/browse/QTBUG-105035
        if (Q_UNLIKELY(mMudLine.isEmpty())) {
            if (mpHost->mBlankLineBehaviour == Host::BlankLineBehaviour::Hide) {
                localBufferPosition++;
                return true;
            } else if (mpHost->mBlankLineBehaviour == Host::BlankLineBehaviour::ReplaceWithSpace) {
                // Note: we are using the background color for the
                // foreground color as well so that we are transparent:
                const TChar c(mBackGroundColor, mBackGroundColor, computeCurrentAttributeFlags());
                mMudLine.append(QChar::Space);
                mMudBuffer.push_back(c);
            }
        }

        if (static_cast<size_t>(mMudLine.size()) != mMudBuffer.size()) {
            qWarning() << "TBuffer::translateToPlainText(...) WARNING: mismatch in new text "
                          "data character and attribute data items!";
        }
        if (!lineBuffer.back().isEmpty()) {
            if (!mMudLine.isEmpty()) {
                lineBuffer << mMudLine;
            } else {
                if (ch == '\r') {
                    ++localBufferPosition;
                    return true; //empty timer posting
                }
                lineBuffer << QString();
            }
            buffer.push_back(mMudBuffer);
            timeBuffer << QTime::currentTime().toString(mudlet::smTimeStampFormat);
            if (ch == '\xff') {
                promptBuffer.append(true);
            } else {
                promptBuffer.append(false);
            }
        } else {
            if (!mMudLine.isEmpty()) {
                lineBuffer.back().append(mMudLine);
            } else {
                if (ch == '\r') {
                    ++localBufferPosition;
                    return true; //empty timer posting
                }
                lineBuffer.back().append(QString());
            }
            buffer.back() = mMudBuffer;
            timeBuffer.back() = QTime::currentTime().toString(mudlet::smTimeStampFormat);
            if (ch == '\xff') {
                promptBuffer.back() = true;
            } else {
                promptBuffer.back() = false;
            }
        }
        mMudLine.clear();
        mMudBuffer.clear();
        const int line = lineBuffer.size() - 1;
        if (!mSkipTriggerProcessing) {
            mpHost->mpConsole->runTriggers(line);
        }

        // Only use of TBuffer::wrap(), breaks up new text
        // NOTE: it MAY have been clobbered by the trigger engine!
        // If deleteLine() was called in a trigger, 'line' may now be past the end
        // of the buffer; clamp to the last valid line so that any text inserted
        // via echo() into the preceding line (which may contain embedded '\n'
        // characters from insertInLine) is still wrapped correctly.
        const int lastValidLine = static_cast<int>(lineBuffer.size()) - 1;
        const int wrapStartLine = (lastValidLine >= 0) ? std::min(line, lastValidLine) : 0;
        const int addedLines = wrapLine(wrapStartLine, mWrapAt, mWrapIndent, mWrapHangingIndent);

        // Start a new, but empty line in the various buffers
        log(lineBuffer.size() - 1, lineBuffer.size() - 1);

        ++localBufferPosition;
        // Suppress new empty line IFF echoes already created a new empty line
        // i.e. add newline if no added lines or the lastline isn't empty
        if (addedLines == 0 || !lineBuffer.back().isEmpty()) {
            std::deque<TChar> const newLine;
            buffer.push_back(newLine);
            lineBuffer.push_back(QString());
            timeBuffer.push_back(QString());
            promptBuffer << false;
        }

        if (static_cast<int>(buffer.size()) > mLinesLimit) {
            // Whilst we also include a call to TConsole::handleLinesOverflowEvent(...)
            // in all other methods where the following is used (because
            // both need to monitor the number of lines of text in the
            // buffer) the event that the former may be required to
            // generate is NOT used for the TMainConsole case whereas this
            // (translateToPlainText(...)) method is ONLY for that one:
            shrinkBuffer();
        }

        applyPendingSelectionStyling();

        return true;
    }
    return false;
}

void TBuffer::processMxpWatchdogCallback()
{
    if (!mpHost) {
        mWatchdogPhase = WatchdogPhase::None;
        return;
    }

    TMxpNodeBuilder& tagBuilder = mpHost->mMxpProcessor.getMxpTagBuilder();
    std::string currentTagContent = tagBuilder.getRawTagContent();
    bool isMxpParserFrozen = !currentTagContent.empty() && currentTagContent.starts_with(mWatchdogTagSnapshot) && tagBuilder.isInsideTag();

    if (mWatchdogPhase == WatchdogPhase::Phase1_Snapshot) {
        mWatchdogTagSnapshot = currentTagContent;
        mWatchdogPhase = WatchdogPhase::Phase2_Unfreeze;
        mTagWatchdog->start(MAX_TAG_TIMEOUT_MS);
    } else if (mWatchdogPhase == WatchdogPhase::Phase2_Unfreeze) {
        if (isMxpParserFrozen && mpConsole) {
            mpHost->mMxpProcessor.setLastEntityValue(QString::fromStdString('<' + currentTagContent));
            const TChar style(mForeGroundColor, mBackGroundColor, computeCurrentAttributeFlags());
            QPointer<Host> hostGuard = mpHost;
            QPointer<TConsole> consoleGuard = mpConsole;
            QTimer::singleShot(0, mpConsole, [this, style, hostGuard, consoleGuard]() {
                if (!hostGuard || !consoleGuard) {
                    return;
                }
                QString lastEntityValue = hostGuard->mMxpProcessor.getEntityValue();
                size_t unusedBufferPosition = 0;

                mMudLine.append(lastEntityValue);
                for (qsizetype i = 0; i < lastEntityValue.size(); ++i) {
                    mMudBuffer.push_back(style);
                }
                commitLine('\r', unusedBufferPosition);
                hostGuard->mMxpProcessor.getMxpTagBuilder().reset();
                hostGuard->mpConsole->finalize();
            });
        }
        mWatchdogPhase = WatchdogPhase::None;
    }
}

TChar::AttributeFlags TBuffer::computeCurrentAttributeFlags() const
{
    return ((mIsDefaultColor ? mBold || mpHost->mMxpClient.bold() : false) ? TChar::Bold : TChar::None) | (mItalics || mpHost->mMxpClient.italic() ? TChar::Italic : TChar::None)
           | (mOverline ? TChar::Overline : TChar::None) | (mReverse ? TChar::Reverse : TChar::None) | (mStrikeOut || mpHost->mMxpClient.strikeOut() ? TChar::StrikeOut : TChar::None)
           | (mUnderline || mpHost->mMxpClient.underline() ? TChar::Underline : TChar::None) | (mUnderlineWavy ? TChar::UnderlineWavy : TChar::None)
           | (mUnderlineDotted ? TChar::UnderlineDotted : TChar::None) | (mUnderlineDashed ? TChar::UnderlineDashed : TChar::None)
           | (mFastBlink ? TChar::FastBlink : (mBlink ? TChar::Blink : TChar::None)) | (TChar::alternateFontFlag(mAltFont)) | (mConcealed ? TChar::Concealed : TChar::None);
}

void TBuffer::decodeSGR38(const QStringList& parameters, bool isColonSeparated)
{
#if defined(DEBUG_SGR_PROCESSING)
    qDebug() << "    TBuffer::decodeSGR38(" << parameters << "," << isColonSeparated << ") INFO - called";
#endif
    if (parameters.at(1) == QLatin1String("5")) {
        int tag = 0;
        if (parameters.count() > 2) {
            bool isOk = false;
            tag = parameters.at(2).toInt(&isOk);
#if defined(DEBUG_SGR_PROCESSING)
            if (!isOk) {
                if (isColonSeparated) {
                    qDebug().noquote().nospace() << "TBuffer::decodeSGR38(...) ERROR - failed to parse color index parameter element (the third part) in a SGR...;38:5:" << parameters.at(2)
                                                 << ":...;...m sequence treating it as a zero!";
                } else {
                    qDebug().noquote().nospace() << "TBuffer::decodeSGR38(...) ERROR - failed to parse color index parameter string (the third part) in a SGR...;38;5;" << parameters.at(2)
                                                 << ";...m sequence treating it as a zero!";
                }
            }
#endif
        } else {
            // Missing last parameter - so it is treated as a zero
#if defined(DEBUG_SGR_PROCESSING)
            if (isColonSeparated) {
                qDebug().noquote().nospace() << "TBuffer::decodeSGR38(...) ERROR - missing color index parameter element (the third part) in a SGR...;38:5;...m sequence treating it as a zero!";
            } else {
                qDebug().noquote().nospace() << "TBuffer::decodeSGR38(...) ERROR - missing color index parameter string (the third part) in a SGR...;38;5;;m sequence treating it as a zero!";
            }
#endif
        }

        if (tag < 16) {
            if (tag >= 8) {
                tag -= 8;
                mBold = true;
            } else {
                mBold = false;
            }
            mIsDefaultColor = false;

            switch (tag) {
            case 0:
                mForeGroundColor = mBlack;
                mForeGroundColorLight = mLightBlack;
                break;
            case 1:
                mForeGroundColor = mRed;
                mForeGroundColorLight = mLightRed;
                break;
            case 2:
                mForeGroundColor = mGreen;
                mForeGroundColorLight = mLightGreen;
                break;
            case 3:
                mForeGroundColor = mYellow;
                mForeGroundColorLight = mLightYellow;
                break;
            case 4:
                mForeGroundColor = mBlue;
                mForeGroundColorLight = mLightBlue;
                break;
            case 5:
                mForeGroundColor = mMagenta;
                mForeGroundColorLight = mLightMagenta;
                break;
            case 6:
                mForeGroundColor = mCyan;
                mForeGroundColorLight = mLightCyan;
                break;
            case 7:
                mForeGroundColor = mWhite;
                mForeGroundColorLight = mLightWhite;
                break;
            }

        } else if (tag < 232) {
            // because color 1-15 behave like normal ANSI colors
            tag -= 16;
            // 6x6x6 RGB color space
            quint8 const r = tag / 36;
            quint8 const g = (tag - (r * 36)) / 6;
            quint8 const b = (tag - (r * 36)) - (g * 6);
            // Adjusted from previously linear gradient for the blocks.
            // To match the common terminal palettes, the values are
            // scaled as follows:
            // 0: 0, 1: 95, 2:135, 3:175, 4:215, 5:255
            mForeGroundColor = QColor(r == 0 ? 0 : (r - 1) * 40 + 95, g == 0 ? 0 : (g - 1) * 40 + 95, b == 0 ? 0 : (b - 1) * 40 + 95);
            mForeGroundColorLight = mForeGroundColor;

        } else {
            const int value = (tag - 232) * 10 + 8;
            mForeGroundColor = QColor(value, value, value);
            mForeGroundColorLight = mForeGroundColor;
        }

    } else if (parameters.at(1) == QLatin1String("2")) {
        if (parameters.count() >= 6) {
            // Have enough for all three colour
            // components
            mForeGroundColor = QColor(qBound(0, parameters.at(3).toInt(), 255), qBound(0, parameters.at(4).toInt(), 255), qBound(0, parameters.at(5).toInt(), 255));
        } else if (parameters.count() >= 5) {
            // Have enough for two colour
            // components, but blue component is
            // zero
            mForeGroundColor = QColor(qBound(0, parameters.at(3).toInt(), 255), qBound(0, parameters.at(4).toInt(), 255), 0);
        } else if (parameters.count() >= 4) {
            // Have enough for one colour component,
            // but green and blue components are
            // zero
            mForeGroundColor = QColor(qBound(0, parameters.at(3).toInt(), 255), 0, 0);
        } else {
            // No codes left for any colour
            // components so colour must be black,
            // as all of red, green and blue
            // components are zero
            mForeGroundColor = Qt::black;
        }

        if (parameters.count() >= 3 && !parameters.at(2).isEmpty()) {
            if (!isColonSeparated) {
#if !defined(DEBUG_SGR_PROCESSING)
                qDebug() << "Unhandled color space identifier in a SGR...;38;2;" << parameters.at(2)
                         << ";...m sequence - if 16M colors items are missing blue elements you may have checked the \"Expect Color Space Id in SGR...(3|4)8;2;....m codes\" option on the Special "
                            "Options tab of the preferences when it is not needed!";
#else
                qDebug().noquote().nospace() << "TBuffer::decodeSGR38(...) WARNING - unhandled color space identifier in a SGR...;38;2;" << parameters.at(2)
                                             << ";...m sequence treating it as the default (empty) case!";
            } else {
                qDebug().noquote().nospace() << "TBuffer::decodeSGR38(...) WARNING - unhandled color space identifier in a SGR...;38:2:" << parameters.at(2)
                                             << ":...;...m sequence treating it as the default (empty) case!";
#endif
            }
        }
        mForeGroundColorLight = mForeGroundColor;

    } else if (parameters.at(1) == QLatin1String("4") || parameters.at(1) == QLatin1String("3") || parameters.at(1) == QLatin1String("1") || parameters.at(1) == QLatin1String("0")) {
#if defined(DEBUG_SGR_PROCESSING)
        if (isColonSeparated) {
            qDebug().noquote().nospace() << "TBuffer::decodeSGR38(...) WARNING - unhandled SGR code: SGR...;38:" << parameters.at(1) << ":...;...m ignoring sequence!";
        } else {
            qDebug().noquote().nospace() << "TBuffer::decodeSGR38(...) WARNING - unhandled SGR code: SGR...;38;" << parameters.at(1) << ";...m ignoring sequence!";
        }
#endif

    } else {
#if defined(DEBUG_SGR_PROCESSING)
        if (isColonSeparated) {
            qDebug().noquote().nospace() << "TBuffer::decodeSGR38(...) WARNING - unexpected SGR code: SGR...;38:" << parameters.at(1) << ":...;...m ignoring sequence!";
        } else {
            qDebug().noquote().nospace() << "TBuffer::decodeSGR38(...) WARNING - unexpected SGR code: SGR...;38;" << parameters.at(1) << ";...m ignoring sequence!";
        }
#endif
    }
}

void TBuffer::decodeSGR48(const QStringList& parameters, bool isColonSeparated)
{
#if defined(DEBUG_SGR_PROCESSING)
    qDebug() << "    TBuffer::decodeSGR48(" << parameters << "," << isColonSeparated << ") INFO - called";
#endif
    bool useLightColor = false;

    if (parameters.at(1) == QLatin1String("5")) {
        int tag = 0;
        if (parameters.count() > 2) {
            bool isOk = false;
            tag = parameters.at(2).toInt(&isOk);
#if defined(DEBUG_SGR_PROCESSING)
            if (!isOk) {
                if (isColonSeparated) {
                    qDebug().noquote().nospace() << "TBuffer::decodeSGR48(...) ERROR - failed to parse color index parameter element (the third part) in a SGR...;48:5:" << parameters.at(2)
                                                 << ":...;...m sequence treating it as a zero!";
                } else {
                    qDebug().noquote().nospace() << "TBuffer::decodeSGR48(...) ERROR - failed to parse color index parameter string (the third part) in a SGR...;48;5;" << parameters.at(2)
                                                 << ";...m sequence treating it as a zero!";
                }
            }
#endif
        } else {
            // Missing last parameter - so it is treated as a zero
#if defined(DEBUG_SGR_PROCESSING)
            if (isColonSeparated) {
                qDebug().noquote().nospace() << "TBuffer::decodeSGR48(...) ERROR - missing color index parameter element (the third part) in a SGR...;48:5;...m sequence treating it as a zero!";
            } else {
                qDebug().noquote().nospace() << "TBuffer::decodeSGR48(...) ERROR - missing color index parameter string (the third part) in a SGR...;48;5;;m sequence treating it as a zero!";
            }
#endif
        }

        if (tag < 16) {
            if (tag >= 8) {
                tag -= 8;
                useLightColor = true;
            } else {
                useLightColor = false;
            }
            mIsDefaultColor = false;
            QColor bgColorLight;

            switch (tag) {
            case 0:
                mBackGroundColor = mBlack;
                bgColorLight = mLightBlack;
                break;
            case 1:
                mBackGroundColor = mRed;
                bgColorLight = mLightRed;
                break;
            case 2:
                mBackGroundColor = mGreen;
                bgColorLight = mLightGreen;
                break;
            case 3:
                mBackGroundColor = mYellow;
                bgColorLight = mLightYellow;
                break;
            case 4:
                mBackGroundColor = mBlue;
                bgColorLight = mLightBlue;
                break;
            case 5:
                mBackGroundColor = mMagenta;
                bgColorLight = mLightMagenta;
                break;
            case 6:
                mBackGroundColor = mCyan;
                bgColorLight = mLightCyan;
                break;
            case 7:
                mBackGroundColor = mWhite;
                bgColorLight = mLightWhite;
                break;
            }
            if (useLightColor) {
                mBackGroundColor = bgColorLight;
            }

        } else if (tag < 232) {
            // because color 1-15 behave like normal ANSI colors
            tag -= 16;
            // 6x6x6 RGB color space
            quint8 const r = tag / 36;
            quint8 const g = (tag - (r * 36)) / 6;
            quint8 const b = (tag - (r * 36)) - (g * 6);
            // Adjusted from previously linear gradient for the blocks.
            // To match the common terminal palettes, the values are
            // scaled as follows:
            // 0: 0, 1: 95, 2:135, 3:175, 4:215, 5:255
            mBackGroundColor = QColor(r == 0 ? 0 : (r - 1) * 40 + 95, g == 0 ? 0 : (g - 1) * 40 + 95, b == 0 ? 0 : (b - 1) * 40 + 95);

        } else {
            const int value = (tag - 232) * 10 + 8;
            mBackGroundColor = QColor(value, value, value);
        }

    } else if (parameters.at(1) == QLatin1String("2")) {
        if (parameters.count() >= 6) {
            // Have enough for all three colour
            // components
            mBackGroundColor = QColor(qBound(0, parameters.at(3).toInt(), 255), qBound(0, parameters.at(4).toInt(), 255), qBound(0, parameters.at(5).toInt(), 255));

        } else if (parameters.count() >= 5) {
            // Have enough for two colour
            // components, but blue component is
            // zero
            mBackGroundColor = QColor(qBound(0, parameters.at(3).toInt(), 255), qBound(0, parameters.at(4).toInt(), 255), 0);

        } else if (parameters.count() >= 4) {
            // Have enough for one colour component,
            // but green and blue components are
            // zero
            mBackGroundColor = QColor(qBound(0, parameters.at(3).toInt(), 255), 0, 0);

        } else {
            // No codes left for any colour
            // components so colour must be black,
            // as all of red, green and blue
            // components are zero
            mBackGroundColor = Qt::black;
        }

        if (parameters.count() >= 3 && !parameters.at(2).isEmpty()) {
            if (!isColonSeparated) {
#if !defined(DEBUG_SGR_PROCESSING)
                qDebug() << "Unhandled color space identifier in a SGR...;48;2;" << parameters.at(2)
                         << ";...m sequence - if 16M colors items are missing blue elements you may have checked the \"Expect Color Space Id in SGR...(3|4)8;2;....m codes\" option on the Special "
                            "Options tab of the preferences when it is not needed!";
#else
                qDebug().noquote().nospace() << "TBuffer::decodeSGR48(...) WARNING - unhandled color space identifier in a SGR...;48;2;" << parameters.at(2)
                                             << ";...m sequence treating it as the default (empty) case!";
            } else {
                qDebug().noquote().nospace() << "TBuffer::decodeSGR48(...) WARNING - unhandled color space identifier in a SGR...;48:2:" << parameters.at(2)
                                             << ":...;...m sequence treating it as the default (empty) case!";
#endif
            }
        }

    } else if (parameters.at(1) == QLatin1String("4") || parameters.at(1) == QLatin1String("3") || parameters.at(1) == QLatin1String("1") || parameters.at(1) == QLatin1String("0")) {
#if defined(DEBUG_SGR_PROCESSING)
        if (isColonSeparated) {
            qDebug().noquote().nospace() << "TBuffer::decodeSGR48(...) WARNING - unhandled SGR code: SGR...;48:" << parameters.at(1) << ":...;...m ignoring sequence!";
        } else {
            qDebug().noquote().nospace() << "TBuffer::decodeSGR48(...) WARNING - unhandled SGR code: SGR...;48;" << parameters.at(1) << ";...m ignoring sequence!";
        }
#endif

    } else {
#if defined(DEBUG_SGR_PROCESSING)
        if (isColonSeparated) {
            qDebug().noquote().nospace() << "TBuffer::decodeSGR48(...) WARNING - unexpected SGR code: SGR...;48:" << parameters.at(1) << ":...;...m ignoring sequence!";
        } else {
            qDebug().noquote().nospace() << "TBuffer::decodeSGR48(...) WARNING - unexpected SGR code: SGR...;48;" << parameters.at(1) << ";...m ignoring sequence!";
        }
#endif
    }
}

void TBuffer::decodeSGR(const QString& sequence)
{
    Host* pHost = mpHost;
    if (!pHost) {
        qWarning() << "TBuffer::decodeSGR(...) ERROR - Called when mpHost pointer is nullptr";
        return;
    }

    const bool haveColorSpaceId = pHost->getHaveColorSpaceId();

    const QStringList parameterStrings = sequence.split(QChar(';'));
    for (int paraIndex = 0, total = parameterStrings.count(); paraIndex < total; ++paraIndex) {
        const QString allParameterElements = parameterStrings.at(paraIndex);
        if (allParameterElements.contains(QLatin1String(":"))) {
            /******************************************************************
             * Parameter string with colon separated Parameter (sub) elements *
             ******************************************************************/
            // We have colon separated parameter elements, so we must have at least 2 members
            const QStringList parameterElements(allParameterElements.split(QChar(':')));
            if (parameterElements.at(0) == QLatin1String("38")) {
                if (parameterElements.count() >= 2) {
                    decodeSGR38(parameterElements, true);

                } else {
                    // We only have a single element in this parameterString,
                    // so we will need to steal the needed number from the
                    // remainder - this is falling back to using a semicolon
                    // separated list rather than a colon separated one
                    if (paraIndex + 1 >= total) {
                        // Oh dear we are out of parameters to examine, so bail
                        // out:
                        return;
                    }

                    // Okay we have one more parameter at least - so examine it
                    // and grab the needed number of arguments:
                    QStringList madeElements;
                    madeElements << parameterStrings.at(paraIndex);     // "38"
                    madeElements << parameterStrings.at(paraIndex + 1); // "2" or "5" hopefully
                    bool isOk = false;
                    const int sgr38_type = madeElements.at(1).toInt(&isOk);
                    if (madeElements.at(1).isEmpty() || !isOk || sgr38_type == 0) {
                        // Oh dear that parameter is empty or equivalent to zero
                        // so we cannot do anything more
                        return;
                    }

                    switch (sgr38_type) {
                    case 5: // Needs just one more number
                        if (paraIndex + 2 < total) {
                            // We have the parameter needed
                            madeElements << parameterStrings.at(paraIndex + 2);
                        }
                        decodeSGR38(madeElements, false);
                        // Move the index to consume the used values
                        paraIndex += 2;
                        break;
                    case 4: // Not handled but we still should skip its arguments
                        // Uses four or five depending on whether there is
                        // the colour space id first
                        // Move the index to consume the used values
                        paraIndex += (haveColorSpaceId ? 6 : 5);
                        break;
                    case 3: // Not handled but we still should skip its arguments
                        // Move the index to consume the used values
                        paraIndex += (haveColorSpaceId ? 5 : 4);
                        break;
                    case 2: // Need three or four depending on whether there is
                        // the colour space id first
                        if (haveColorSpaceId) {
                            if (paraIndex + 2 < total) {
                                // We have the color space id
                                madeElements << parameterStrings.at(paraIndex + 2);
                            }
                            if (paraIndex + 3 < total) {
                                // We have the red component
                                madeElements << parameterStrings.at(paraIndex + 3);
                            }
                            if (paraIndex + 4 < total) {
                                // We have the green component
                                madeElements << parameterStrings.at(paraIndex + 4);
                            }
                            if (paraIndex + 5 < total) {
                                // We have the blue component
                                madeElements << parameterStrings.at(paraIndex + 5);
                            }
                        } else {
                            // Fake an empty colour space id
                            madeElements << QString();
                            if (paraIndex + 2 < total) {
                                // We have the red component
                                madeElements << parameterStrings.at(paraIndex + 2);
                            }
                            if (paraIndex + 3 < total) {
                                // We have the green component
                                madeElements << parameterStrings.at(paraIndex + 3);
                            }
                            if (paraIndex + 4 < total) {
                                // We have the blue component
                                madeElements << parameterStrings.at(paraIndex + 4);
                            }
                        }

                        decodeSGR38(madeElements, false);
                        // Move the index to consume the used values
                        paraIndex += (haveColorSpaceId ? 5 : 4);
                        break;
                    case 1: // This uses no extra arguments and, as it means
                        // transparent, is no use to us
                        [[fallthrough]];
                    default:
                        break;
                    }
                }
                // End of if (parameterElements.at(0) == QLatin1String("38"))
            } else if (parameterElements.at(0) == QLatin1String("48")) {
                if (parameterElements.count() >= 2) {
                    decodeSGR48(parameterElements, true);

                } else {
                    // We only have a single element in this parameterString,
                    // so we will need to steal the needed number from the
                    // remainder - this is falling back to using a semicolon
                    // separated list rather than a colon separated one
                    if (paraIndex + 1 >= total) {
                        // Oh dear we are out of parameters to examine, so bail
                        // out:
                        return;
                    }

                    // Okay we have one more parameter at least - so examine it
                    // and grab the needed number of arguments:
                    QStringList madeElements;
                    madeElements << parameterStrings.at(paraIndex);
                    madeElements << parameterStrings.at(paraIndex + 1);
                    bool isOk = false;
                    const int sgr48_type = madeElements.at(1).toInt(&isOk);
                    if (madeElements.at(1).isEmpty() || !isOk || sgr48_type == 0) {
                        // Oh dear that parameter is empty or equivalent to zero
                        // so we cannot do anything more
                        return;
                    }

                    switch (sgr48_type) {
                    case 5: // Needs one more number
                        if (paraIndex + 2 < total) {
                            // We have the parameter needed
                            madeElements << parameterStrings.at(paraIndex + 2);
                        }
                        // Move the index to consume the used values
                        decodeSGR48(madeElements, false);
                        paraIndex += 2;
                        break;
                    case 4: // Not handled but we still should skip its arguments
                        // Uses four or five depending on whether there is
                        // the colour space id first
                        // Move the index to consume the used values
                        paraIndex += (haveColorSpaceId ? 6 : 5);
                        break;
                    case 3: // Not handled but we still should skip its arguments
                        // Move the index to consume the used values
                        paraIndex += (haveColorSpaceId ? 5 : 4);
                        break;
                    case 2: // Need three or four depending on whether there is
                        // the colour space id first
                        if (haveColorSpaceId) {
                            if (paraIndex + 2 < total) {
                                // We have the color space id
                                madeElements << parameterStrings.at(paraIndex + 2);
                            }
                            if (paraIndex + 3 < total) {
                                // We have the red component
                                madeElements << parameterStrings.at(paraIndex + 3);
                            }
                            if (paraIndex + 4 < total) {
                                // We have the green component
                                madeElements << parameterStrings.at(paraIndex + 4);
                            }
                            if (paraIndex + 5 < total) {
                                // We have the blue component
                                madeElements << parameterStrings.at(paraIndex + 5);
                            }
                        } else {
                            // Fake an empty colour space id
                            madeElements << QString();
                            if (paraIndex + 2 < total) {
                                // We have the red component
                                madeElements << parameterStrings.at(paraIndex + 2);
                            }
                            if (paraIndex + 3 < total) {
                                // We have the green component
                                madeElements << parameterStrings.at(paraIndex + 3);
                            }
                            if (paraIndex + 4 < total) {
                                // We have the blue component
                                madeElements << parameterStrings.at(paraIndex + 4);
                            }
                        }

                        // Move the index to consume the used values
                        decodeSGR48(madeElements, false);
                        paraIndex += (haveColorSpaceId ? 5 : 4);
                        break;
                    case 1: // This uses no extra arguments and, as it means
                        // transparent, is no use to us
                        [[fallthrough]];
                    default:
                        break;
                    }
                }
                // End of if (parameterElements.at(0) == QLatin1String("48"))
            } else if (parameterElements.at(0) == QLatin1String("4")) {
                // New way of controlling underline
                bool isOk = false;
                const int value = parameterElements.at(1).toInt(&isOk);
                if (!isOk) {
                    // missing value
                    qDebug().noquote().nospace() << "TBuffer::decodeSGR(\"" << sequence
                                                 << "\") ERROR - failed to detect underline parameter element (the second part) in a SGR...;4:?;..m sequence assuming it is a zero!";
                }
                switch (value) {
                case 0: // Underline off
                    mUnderline = false;
                    mUnderlineWavy = false;
                    mUnderlineDotted = false;
                    mUnderlineDashed = false;
                    break;
                case 1: // Underline on (solid)
                    mUnderline = true;
                    mUnderlineWavy = false;
                    mUnderlineDotted = false;
                    mUnderlineDashed = false;
                    break;
                case 2: // Dashed underline
                    mUnderline = true;
                    mUnderlineWavy = false;
                    mUnderlineDotted = false;
                    mUnderlineDashed = true;
                    break;
                case 3: // Dotted underline
                    mUnderline = true;
                    mUnderlineWavy = false;
                    mUnderlineDotted = true;
                    mUnderlineDashed = false;
                    break;
                case 4: // Wavy underline
                    mUnderline = true;
                    mUnderlineWavy = true;
                    mUnderlineDotted = false;
                    mUnderlineDashed = false;
                    break;
                default: // Something unexpected
                    qDebug().noquote().nospace() << "TBuffer::decodeSGR(\"" << sequence
                                                 << "\") ERROR - unexpected underline parameter element (the second part) in a SGR...;4:" << parameterElements.at(1)
                                                 << ";../m sequence treating it as a zero!";
                    mUnderline = false;
                    mUnderlineWavy = false;
                    mUnderlineDotted = false;
                    mUnderlineDashed = false;
                    break;
                }
            } else if (parameterElements.at(0) == QLatin1String("3")) {
                // New way of controlling italics
                bool isOk = false;
                const int value = parameterElements.at(1).toInt(&isOk);
                if (!isOk) {
                    // missing value
                    qDebug().noquote().nospace() << "TBuffer::decodeSGR(\"" << sequence
                                                 << "\") ERROR - failed to detect italic parameter element (the second part) in a SGR...;3:?;../m sequence assuming it is a zero!";
                }
                switch (value) {
                case 0: // Italics/Slant off
                    mItalics = false;
                    break;
                case 1: // Italics on
                    mItalics = true;
                    break;
                case 2: // Slant on - not supported, treat as italics
                    qDebug().noquote().nospace() << "TBuffer::decodeSGR(\"" << sequence
                                                 << "\") ERROR - unsupported italic parameter element (the second part) in a SGR...;3:" << parameterElements.at(1)
                                                 << ";../m sequence treating it as a one!";
                    mUnderline = true;
                    break;
                default: // Something unexpected
                    qDebug().noquote().nospace() << "TBuffer::decodeSGR(\"" << sequence << "\") ERROR - unexpected italic parameter element (the second part) in a SGR...;3:" << parameterElements.at(1)
                                                 << ";../m sequence treating it as a zero!";
                    mUnderline = false;
                    break;
                }
            } else {
                qDebug().noquote().nospace() << "TBuffer::decodeSGR(\"" << sequence << "\") ERROR - parameter string with an unexpected initial parameter element in a SGR...;"
                                             << parameterElements.at(0) << ":" << parameterElements.at(1) << "...;.../m sequence, ignoring it!";
            }
        } else {
            /******************************************************************
             *             Parameter string with no sub-elements              *
             ******************************************************************/
            // We do not have a colon separated string so we must just have a
            // number:
            bool isOk = false;
            int tag = 0;
            if (!allParameterElements.isEmpty()) {
                tag = allParameterElements.toInt(&isOk);
            } else {
                // Allow for an empty parameter to be treated as valid and equal to 0:
                isOk = true;
            }
            if (isOk) {
                switch (tag) {
                case 0:
                    mIsDefaultColor = true;
                    mForeGroundColor = pHost->mFgColor;
                    mBackGroundColor = pHost->mBgColor;
                    mBold = false;
                    mItalics = false;
                    mOverline = false;
                    mReverse = false;
                    mStrikeOut = false;
                    mUnderline = false;
                    mUnderlineWavy = false;
                    mUnderlineDotted = false;
                    mUnderlineDashed = false;
                    mBlink = false;
                    mFastBlink = false;
                    mConcealed = false;
                    mAltFont = 0;
                    break;
                case 1:
                    mBold = true;
                    break;
                case 2:
                    // Technically this should be faint (i.e. decreased
                    // intensity compared to normal and 22 should be
                    // the reset to "normal" intensity):
                    mBold = false;
                    break;
                case 3:
                    // There is a proposal by the "VTE" terminal
                    // emulator to use a (sub)parameter entry to
                    // distinguish between italics and slanted text by
                    // using ESC[...;3:1;...m and ESC[...;3:2;...m
                    // respectively - that is handled above in the colon
                    // sub-string separated part:
                    mItalics = true;
                    break;
                case 4:
                    // There is a implementation by some terminal
                    // emulators ("Kitty" and "VTE") to use a
                    // (sub)parameter entry of 3 for a wavy underline
                    // {presumably 2 would be a double underline and 1
                    // the normal single underline) by sending e.g.:
                    // ESC[...;4:3;...m - that is handled above in the colon
                    // sub-string separated part:
                    mUnderline = true;
                    break;
                case 5:
                    mBlink = true;
                    mFastBlink = false;
                    break; //slow-blinking, display as italics instead for the moment
                case 6:
                    mBlink = false;
                    mFastBlink = true;
                    break; //fast blinking, display as italics instead for the moment
                case 7:
                    mReverse = true;
                    break;
                case 8: // Concealed characters (set foreground to be the same as background?)
                    mConcealed = true;
                    break;
                case 9:
                    mStrikeOut = true;
                    break;
                case 10: //default font
                    mAltFont = 0;
                    break;
                case 11: // 11 to 19 are alternate fonts, what and where those
                         // are set is not so well specified
                    mAltFont = 1;
                    break;
                case 12:
                    mAltFont = 2;
                    break;
                case 13:
                    mAltFont = 3;
                    break;
                case 14:
                    mAltFont = 4;
                    break;
                case 15:
                    mAltFont = 5;
                    break;
                case 16:
                    mAltFont = 6;
                    break;
                case 17:
                    mAltFont = 7;
                    break;
                case 18:
                    mAltFont = 8;
                    break;
                case 19:
                    mAltFont = 9;
                    break;
                // case 21: // Double underline according to specs
                //    break;
                case 22:
                    mBold = false;
                    break;
                case 23:
                    mItalics = false;
                    break;
                case 24:
                    mUnderline = false;
                    mUnderlineWavy = false;
                    mUnderlineDotted = false;
                    mUnderlineDashed = false;
                    break;
                case 25:
                    mBlink = false;
                    mFastBlink = false;
                    break; // blink off
                case 27:
                    mReverse = false;
                    break;
                case 28: // Revealed characters (undoes the effect of "8")
                    mConcealed = false;
                    break;
                case 29:
                    mStrikeOut = false;
                    break;
                case 30:
                    mForeGroundColor = mBlack;
                    mForeGroundColorLight = mLightBlack;
                    mIsDefaultColor = false;
                    break;
                case 31:
                    mForeGroundColor = mRed;
                    mForeGroundColorLight = mLightRed;
                    mIsDefaultColor = false;
                    break;
                case 32:
                    mForeGroundColor = mGreen;
                    mForeGroundColorLight = mLightGreen;
                    mIsDefaultColor = false;
                    break;
                case 33:
                    mForeGroundColor = mYellow;
                    mForeGroundColorLight = mLightYellow;
                    mIsDefaultColor = false;
                    break;
                case 34:
                    mForeGroundColor = mBlue;
                    mForeGroundColorLight = mLightBlue;
                    mIsDefaultColor = false;
                    break;
                case 35:
                    mForeGroundColor = mMagenta;
                    mForeGroundColorLight = mLightMagenta;
                    mIsDefaultColor = false;
                    break;
                case 36:
                    mForeGroundColor = mCyan;
                    mForeGroundColorLight = mLightCyan;
                    mIsDefaultColor = false;
                    break;
                case 37:
                    mForeGroundColor = mWhite;
                    mForeGroundColorLight = mLightWhite;
                    mIsDefaultColor = false;
                    break;
                case 38: {
                    // We only have single elements so we will need to steal the
                    // needed number from the remainder:
                    if (paraIndex + 1 >= total) {
                        // Oh dear we are out of parameters to examine, so bail
                        // out:
                        return;
                    }

                    // Okay we have one more parameter at least - so examine it
                    // and grab the needed number of arguments:
                    QStringList madeElements;
                    madeElements << parameterStrings.at(paraIndex);
                    madeElements << parameterStrings.at(paraIndex + 1);
                    bool isOk = false;
                    const int sgr38_type = madeElements.at(1).toInt(&isOk);
                    if (madeElements.at(1).isEmpty() || !isOk || sgr38_type == 0) {
                        // Oh dear that parameter is empty or equivalent to zero
                        // so we cannot do anything more
                        return;
                    }

                    switch (sgr38_type) {
                    case 5: // Needs one more number
                        if (paraIndex + 2 < total) {
                            // We have the parameter needed
                            madeElements << parameterStrings.at(paraIndex + 2);
                        }
                        // Move the index to consume the used values
                        decodeSGR38(madeElements, false);
                        paraIndex += 2;
                        break;
                    case 4: // Not handled but we still should skip its arguments
                        // Uses four or five depending on whether there is
                        // the colour space id first
                        // Move the index to consume the used values
                        paraIndex += (haveColorSpaceId ? 6 : 5);
                        break;
                    case 3: // Not handled but we still should skip its arguments
                        // Move the index to consume the used values
                        paraIndex += (haveColorSpaceId ? 5 : 4);
                        break;
                    case 2: // Need three or four depending on whether there is
                        // the colour space id first
                        if (haveColorSpaceId) {
                            if (paraIndex + 2 < total) {
                                // We have the color space id
                                madeElements << parameterStrings.at(paraIndex + 2);
                            }
                            if (paraIndex + 3 < total) {
                                // We have the red component
                                madeElements << parameterStrings.at(paraIndex + 3);
                            }
                            if (paraIndex + 4 < total) {
                                // We have the green component
                                madeElements << parameterStrings.at(paraIndex + 4);
                            }
                            if (paraIndex + 5 < total) {
                                // We have the blue component
                                madeElements << parameterStrings.at(paraIndex + 5);
                            }
                        } else {
                            // Fake an empty colour space id
                            madeElements << QString();
                            if (paraIndex + 2 < total) {
                                // We have the red component
                                madeElements << parameterStrings.at(paraIndex + 2);
                            }
                            if (paraIndex + 3 < total) {
                                // We have the green component
                                madeElements << parameterStrings.at(paraIndex + 3);
                            }
                            if (paraIndex + 4 < total) {
                                // We have the blue component
                                madeElements << parameterStrings.at(paraIndex + 4);
                            }
                        }

                        // Move the index to consume the used values LESS
                        // the one that the for loop will handle - even if it
                        // goes past end
                        decodeSGR38(madeElements, false);
                        paraIndex += (haveColorSpaceId ? 5 : 4);
                        break;
                    case 1: // This uses no extra arguments and, as it means
                        // transparent, is no use to us
                        [[fallthrough]];
                    default:
                        break;
                    }
                } break;
                case 39: //default foreground color
                    mForeGroundColor = pHost->mFgColor;
                    break;
                case 40:
                    mBackGroundColor = mBlack;
                    break;
                case 41:
                    mBackGroundColor = mRed;
                    break;
                case 42:
                    mBackGroundColor = mGreen;
                    break;
                case 43:
                    mBackGroundColor = mYellow;
                    break;
                case 44:
                    mBackGroundColor = mBlue;
                    break;
                case 45:
                    mBackGroundColor = mMagenta;
                    break;
                case 46:
                    mBackGroundColor = mCyan;
                    break;
                case 47:
                    mBackGroundColor = mWhite;
                    break;
                case 48: {
                    // We only have single elements so we will need to steal the
                    // needed number from the remainder:
                    if (paraIndex + 1 >= total) {
                        // Oh dear we are out of parameters to examine, so bail
                        // out:
                        return;
                    }

                    // Okay we have one more parameter at least - so examine it
                    // and grab the needed number of arguments:
                    QStringList madeElements;
                    madeElements << parameterStrings.at(paraIndex);
                    madeElements << parameterStrings.at(paraIndex + 1);
                    bool isOk = false;
                    const int sgr48_type = madeElements.at(1).toInt(&isOk);
                    if (madeElements.at(1).isEmpty() || !isOk || sgr48_type == 0) {
                        // Oh dear that parameter is empty or equivalent to zero
                        // so we cannot do anything more
                        return;
                    }

                    switch (sgr48_type) {
                    case 5: // Needs one more number
                        if (paraIndex + 2 < total) {
                            // We have the parameter needed
                            madeElements << parameterStrings.at(paraIndex + 2);
                        }
                        // Move the index to consume the used values
                        decodeSGR48(madeElements, false);
                        paraIndex += 2;
                        break;
                    case 4: // Not handled but we still should skip its arguments
                        // Uses four or five depending on whether there is
                        // the colour space id first
                        // Move the index to consume the used values
                        paraIndex += (haveColorSpaceId ? 6 : 5);
                        break;
                    case 3: // Not handled but we still should skip its arguments
                        // Move the index to consume the used values
                        paraIndex += (haveColorSpaceId ? 5 : 4);
                        break;
                    case 2: // Need three or four depending on whether there is
                        // the colour space id first
                        if (haveColorSpaceId) {
                            if (paraIndex + 2 < total) {
                                // We have the color space id
                                madeElements << parameterStrings.at(paraIndex + 2);
                            }
                            if (paraIndex + 3 < total) {
                                // We have the red component
                                madeElements << parameterStrings.at(paraIndex + 3);
                            }
                            if (paraIndex + 4 < total) {
                                // We have the green component
                                madeElements << parameterStrings.at(paraIndex + 4);
                            }
                            if (paraIndex + 5 < total) {
                                // We have the blue component
                                madeElements << parameterStrings.at(paraIndex + 5);
                            }
                        } else {
                            // Fake an empty colour space id
                            madeElements << QString();
                            if (paraIndex + 2 < total) {
                                // We have the red component
                                madeElements << parameterStrings.at(paraIndex + 2);
                            }
                            if (paraIndex + 3 < total) {
                                // We have the green component
                                madeElements << parameterStrings.at(paraIndex + 3);
                            }
                            if (paraIndex + 4 < total) {
                                // We have the blue component
                                madeElements << parameterStrings.at(paraIndex + 4);
                            }
                        }

                        // Move the index to consume the used values
                        decodeSGR48(madeElements, false);
                        paraIndex += (haveColorSpaceId ? 5 : 4);
                        break;
                    case 1: // This uses no extra arguments and, as it means
                        // transparent, is no use to us
                        [[fallthrough]];
                    default:
                        break;
                    }
                } break;
                case 49: // default background color
                    mBackGroundColor = pHost->mBgColor;
                    break;
                // case 51: // Framed
                //    break;
                // case 52: // Encircled
                //    break;
                case 53:
                    mOverline = true;
                    break;
                // case 54: // Not framed, not encircled
                //    break;
                case 55:
                    mOverline = false;
                    break;
                // 56 to 59 reserved for future standardization
                // case 60: // ideogram underline or right side line
                //    break;
                // case 61: // ideogram double underline or double right side line
                //    break;
                // case 62: // ideogram overline or left side line
                //    break;
                // case 63: // ideogram double overline or double left side line
                //    break;
                // case 64: // ideogram stress marking
                //    break;
                // case 65: // cancels the effects of 60 to 64
                //    break;
                case 90:
                    mForeGroundColor = mLightBlack;
                    mForeGroundColorLight = mLightBlack;
                    mIsDefaultColor = false;
                    break;
                case 91:
                    mForeGroundColor = mLightRed;
                    mForeGroundColorLight = mLightRed;
                    mIsDefaultColor = false;
                    break;
                case 92:
                    mForeGroundColor = mLightGreen;
                    mForeGroundColorLight = mLightGreen;
                    mIsDefaultColor = false;
                    break;
                case 93:
                    mForeGroundColor = mLightYellow;
                    mForeGroundColorLight = mLightYellow;
                    mIsDefaultColor = false;
                    break;
                case 94:
                    mForeGroundColor = mLightBlue;
                    mForeGroundColorLight = mLightBlue;
                    mIsDefaultColor = false;
                    break;
                case 95:
                    mForeGroundColor = mLightMagenta;
                    mForeGroundColorLight = mLightMagenta;
                    mIsDefaultColor = false;
                    break;
                case 96:
                    mForeGroundColor = mLightCyan;
                    mForeGroundColorLight = mLightCyan;
                    mIsDefaultColor = false;
                    break;
                case 97:
                    mForeGroundColor = mLightWhite;
                    mForeGroundColorLight = mLightWhite;
                    mIsDefaultColor = false;
                    break;
                case 100:
                    mBackGroundColor = mLightBlack;
                    break;
                case 101:
                    mBackGroundColor = mLightRed;
                    break;
                case 102:
                    mBackGroundColor = mLightGreen;
                    break;
                case 103:
                    mBackGroundColor = mLightYellow;
                    break;
                case 104:
                    mBackGroundColor = mLightBlue;
                    break;
                case 105:
                    mBackGroundColor = mLightMagenta;
                    break;
                case 106:
                    mBackGroundColor = mLightCyan;
                    break;
                case 107:
                    mBackGroundColor = mLightWhite;
                    break;
                default:
                    qDebug().noquote().nospace() << "TBuffer::translateToPlainText(...) INFO - Unhandled single SGR code sequence CSI " << tag << " m received, Mudlet will ignore it.";
                }
            }
        }
    }
}

void TBuffer::decodeOSC(const QString& sequence)
{
    if (sequence.isEmpty()) {
        qWarning() << "TBuffer::decodeOSC(...) WARNING - Received empty OSC sequence, ignoring.";
        return;
    }

    Host* pHost = mpHost;
    if (!pHost) {
        qWarning() << "TBuffer::decodeOSC(...) ERROR - Called when mpHost pointer is nullptr";
        return;
    }

    const bool serverMayRedefineDefaultColors = pHost->getMayRedefineColors();
#if defined(DEBUG_OSC_PROCESSING)
    // Only log sequences that aren't just OSC 8 terminators to reduce noise
    if (!(sequence.startsWith("8;;") && sequence.length() == 3)) {
        qDebug().nospace().noquote() << "    Consider the OSC sequence: \"" << sequence << "\"";
    }
#endif
    const uint16_t character = sequence.at(0).unicode();
    switch (character) {
    case static_cast<quint8>('P'):
        if (serverMayRedefineDefaultColors) {
            if (sequence.size() == 8) {
                // Should be a 8 byte Hex number in form PIRRGGBB - including the 'P'
                bool isOk = false;
                // Uses mid(...) rather than at(...) because we want the return to
                // be a (single character) QString and not a QChar so we can use
                // QString::toUInt(...):
                quint8 const colorNumber = sequence.mid(1, 1).toUInt(&isOk, 16);
                quint8 rr = 0;
                if (isOk) {
                    rr = sequence.mid(2, 2).toUInt(&isOk, 16);
                }
                quint8 gg = 0;
                if (isOk) {
                    gg = sequence.mid(4, 2).toUInt(&isOk, 16);
                }
                quint8 bb = 0;
                if (isOk) {
                    bb = sequence.mid(6, 2).toUInt(&isOk, 16);
                }
                if (isOk) {
                    bool isValid = true;
                    switch (colorNumber) {
                    case 0: // Black
                        pHost->mBlack = QColor(rr, gg, bb);
                        break;
                    case 1: // Red
                        pHost->mRed = QColor(rr, gg, bb);
                        break;
                    case 2: // Green
                        pHost->mGreen = QColor(rr, gg, bb);
                        break;
                    case 3: // Yellow
                        pHost->mYellow = QColor(rr, gg, bb);
                        break;
                    case 4: // Blue
                        pHost->mBlue = QColor(rr, gg, bb);
                        break;
                    case 5: // Magenta
                        pHost->mMagenta = QColor(rr, gg, bb);
                        break;
                    case 6: // Cyan
                        pHost->mCyan = QColor(rr, gg, bb);
                        break;
                    case 7: // Light gray
                        pHost->mWhite = QColor(rr, gg, bb);
                        break;
                    case 8: // Dark gray
                        pHost->mLightBlack = QColor(rr, gg, bb);
                        break;
                    case 9: // Light Red
                        pHost->mLightRed = QColor(rr, gg, bb);
                        break;
                    case 10: // Light Green
                        pHost->mLightGreen = QColor(rr, gg, bb);
                        break;
                    case 11: // Light Yellow
                        pHost->mLightYellow = QColor(rr, gg, bb);
                        break;
                    case 12: // Light Blue
                        pHost->mLightBlue = QColor(rr, gg, bb);
                        break;
                    case 13: // Light Magenta
                        pHost->mLightMagenta = QColor(rr, gg, bb);
                        break;
                    case 14: // Light Cyan
                        pHost->mLightCyan = QColor(rr, gg, bb);
                        break;
                    case 15: // Light gray
                        pHost->mLightWhite = QColor(rr, gg, bb);
                        break;
                    default:
                        isValid = false;
                    }
                    if (isValid) {
                        // This will refresh the "main" console as it is only this
                        // class instance associated with that one that is to be
                        // changed by this method:
                        if (pHost->mpConsole) {
                            pHost->mpConsole->changeColors();
                        }
                        // Also need to update the Lua sub-system's "color_table"
                        pHost->updateAnsi16ColorsInTable();
                    }

                } else {
#if defined(DEBUG_OSC_PROCESSING)
                    qDebug().noquote().nospace() << "TBuffer::decodeOSC(\"" << sequence
                                                 << "\") ERROR - Unable to parse this as a <OSC>P<I><RR><GG><BB><ST> string to redefined one of the 16 ANSI colors.";
#endif
                }
            } else {
#if defined(DEBUG_OSC_PROCESSING)
                qDebug().noquote().nospace() << "TBuffer::decodeOSC(\"" << sequence
                                             << "\") ERROR - Wrong length of string, unable to decode this as a <OSC>P<I><RR><GG><BB><ST> string to redefined one of the 16 ANSI colors.";
#endif
            }
        }
        break;
    case static_cast<quint8>('R'):
        if (serverMayRedefineDefaultColors) {
            resetColors();
        }
        break;
    case static_cast<quint8>('8'): {
        QStringView rest = QStringView(sequence).mid(1); // skip selector "8"
        int firstSemi = rest.indexOf(';');

        if (firstSemi == -1) {
            qWarning() << "OSC 8: Missing first semicolon";
            return;
        }

        int secondSemi = rest.indexOf(';', firstSemi + 1);

        if (secondSemi == -1) {
            qWarning() << "OSC 8: Missing second semicolon";
            return;
        }

        QString param = rest.left(firstSemi).toString();

        // Parameters are available but not used by Mudlet currently
#if defined(DEBUG_OSC_PROCESSING)
        if (!param.isEmpty()) {
            qDebug().noquote().nospace() << "[OSC 8] Params provided (not used by Mudlet but shown for debugging): \"" << param << "\"";
        }
#endif
        QString rawUrl = rest.mid(secondSemi + 1).toString();

#if defined(DEBUG_OSC_PROCESSING)
        if (!rawUrl.isEmpty()) {
            qDebug().noquote().nospace() << "[OSC] Received OSC 8 sequence with URL (length=" << rawUrl.length() << "): " << (rawUrl.length() > 80 ? rawUrl.left(80) + "..." : rawUrl);
        }
#endif

        // OSC 8 ;; closes the hyperlink
        if ((param.isEmpty() && rawUrl.isEmpty())) {
#if defined(DEBUG_OSC_PROCESSING)
            qDebug().noquote() << "[OSC] Hyperlink terminator - closing active hyperlink";
#endif

            // Apply initial selection/disabled state styling when link closes (from selection branch)
            // OR apply :link pseudo-class styling for preset-only links (from compact branch)
            if (mCurrentHyperlinkLinkId > 0 && mCurrentHyperlinkStyling.selection.hasSelectionSettings) {
#if defined(DEBUG_OSC_PROCESSING)
                qDebug() << "[OSC] Queuing initial selection styling for link" << mCurrentHyperlinkLinkId << "selected:" << mCurrentHyperlinkStyling.selection.selected
                         << "disabled:" << mCurrentHyperlinkStyling.selection.disabled << "selectedStyle.hasCustomStyling:" << mCurrentHyperlinkStyling.selectedStyle.hasCustomStyling;
#endif
                if (mCurrentHyperlinkStyling.selection.selected) {
                    setLinkState(mCurrentHyperlinkLinkId, Mudlet::HyperlinkStyling::StateSelected);
                    mPendingSelectionStyling.insert(mCurrentHyperlinkLinkId);
                } else if (mCurrentHyperlinkStyling.selection.disabled) {
                    setLinkState(mCurrentHyperlinkLinkId, Mudlet::HyperlinkStyling::StateDisabled);
                    mPendingSelectionStyling.insert(mCurrentHyperlinkLinkId);
                }
            } else if (mCurrentHyperlinkLinkId > 0) {
                // Set initial :link pseudo-class state for regular links
                setLinkState(mCurrentHyperlinkLinkId, Mudlet::HyperlinkStyling::StateDefault);
                // DON'T call updateLinkCharacters() here - the link text hasn't been added to
                // the buffer yet! It gets added later during COMMIT_LINE. Calling it now would
                // scan the entire buffer looking for characters that don't exist yet, causing
                // severe performance degradation with many links.
                // The :link styling will be applied when characters are created in COMMIT_LINE.
            }

            // For spoilers, capture original text BEFORE any visibility concealment
            // This ensures spoiler reveal works even when combined with visibility actions
            if (mCurrentHyperlinkLinkId > 0 && mCurrentHyperlinkStyling.isSpoiler) {
                int currentColumn = mMudLine.length();
                int linkLength = currentColumn - mCurrentHyperlinkStartColumn;

                if (linkLength > 0) {
                    // Store the original text before any replacements
                    QString originalText = mMudLine.mid(mCurrentHyperlinkStartColumn, linkLength);
                    mLinkOriginalText[mCurrentHyperlinkLinkId] = originalText;

#if defined(DEBUG_OSC_PROCESSING)
                    qDebug() << "[OSC] Spoiler link" << mCurrentHyperlinkLinkId << "- storing original text:" << originalText << "and replacing with spaces, length:" << linkLength;
#endif
                    // Replace spoiler text with spaces to hide it
                    QString spaces(linkLength, ' ');
                    mMudLine.replace(mCurrentHyperlinkStartColumn, linkLength, spaces);
                }
            }

            // Register with visibility manager if visibility settings exist
            // Visibility currently only supports single-line hyperlinks
            // Multi-line links will not have visibility management applied
            if (mCurrentHyperlinkLinkId > 0 && mCurrentHyperlinkStyling.visibility.hasVisibilitySettings && mpConsole && mCurrentHyperlinkStartLine == static_cast<int>(lineBuffer.size()) - 1) {
                int currentColumn = mMudLine.length();
                int linkLength = currentColumn - mCurrentHyperlinkStartColumn;

                if (linkLength > 0) {
                    // Only register if we have a valid link range
                    QString linkText = mMudLine.mid(mCurrentHyperlinkStartColumn, linkLength);

#if defined(DEBUG_OSC_PROCESSING)
                    qDebug() << "[OSC] Registering hyperlink" << mCurrentHyperlinkLinkId << "line:" << mCurrentHyperlinkStartLine << "col:" << mCurrentHyperlinkStartColumn << "length:" << linkLength
                             << "text:" << linkText;
#endif
                    bool shouldStartConcealed = mpConsole->getHyperlinkVisibilityManager().registerHyperlink(
                            mCurrentHyperlinkLinkId, mCurrentHyperlinkStartLine, mCurrentHyperlinkStartColumn, linkLength, linkText, mCurrentHyperlinkStyling);

                    // If link should start concealed, replace its text with spaces in mMudLine
                    // Skip if spoiler already did this to avoid double-replacement
                    if (shouldStartConcealed && !mCurrentHyperlinkStyling.isSpoiler) {
#if defined(DEBUG_OSC_PROCESSING)
                        qDebug() << "[OSC] Link starts concealed - replacing text with spaces";
#endif
                        // CRITICAL: Maintain exact character length to preserve buffer consistency
                        // Even though emojis have different visual widths, we must keep the same
                        // character count to avoid disrupting buffer indices and causing crashes.
                        QString spaces(linkLength, ' ');
                        mMudLine.replace(mCurrentHyperlinkStartColumn, linkLength, spaces);
                    }
                } else {
#if defined(DEBUG_OSC_PROCESSING)
                    qDebug() << "[OSC] Skipping registration for hyperlink with invalid length:" << linkLength;
#endif
                }
            } else if (mCurrentHyperlinkLinkId > 0 && mCurrentHyperlinkStyling.visibility.hasVisibilitySettings && mCurrentHyperlinkStartLine != static_cast<int>(lineBuffer.size()) - 1) {
#if defined(DEBUG_OSC_PROCESSING)
                qDebug() << "[OSC] Skipping visibility registration for multi-line hyperlink" << "(visibility only applies to single-line links)" << "- started on line" << mCurrentHyperlinkStartLine
                         << "ending on line" << static_cast<int>(lineBuffer.size()) - 1;
#endif
            }

            mCurrentHyperlinkCommand.clear();
            mCurrentHyperlinkHint.clear();
            mCurrentHyperlinkLinkId = 0;
            mHyperlinkActive = false;
            // Reset enhanced styling
            mCurrentHyperlinkStyling = Mudlet::HyperlinkStyling();
            mCurrentHyperlinkMenu.clear();
            // Reset visibility tracking
            mCurrentHyperlinkStartLine = 0;
            mCurrentHyperlinkStartColumn = 0;
            mCurrentHyperlinkText.clear();
            break;
        }

        if (!rawUrl.isEmpty()) {
            if (rawUrl.length() > 8192) {
                qWarning() << "TBuffer::decodeOSC(...) - Rejected hyperlink: URL too long:" << rawUrl;
                return;
            }

            if (rawUrl.startsWith(qsl("preset:"))) {
#if defined(DEBUG_OSC_PROCESSING)
                qDebug() << "[OSC] Detected preset: URL:" << rawUrl;
#endif
                // Extract preset name (between "preset:" and "?")
                int queryStart = rawUrl.indexOf('?');
                QString presetName = (queryStart > 7) ? rawUrl.mid(7, queryStart - 7) : rawUrl.mid(7);

                // Extract config parameter manually to avoid QUrl parsing issues with custom schemes
                QString configParam;
                if (queryStart != -1) {
                    QString queryString = rawUrl.mid(queryStart + 1);
                    QUrlQuery query(queryString);
                    configParam = query.queryItemValue(qsl("config"), QUrl::FullyDecoded);
                }
#if defined(DEBUG_OSC_PROCESSING)
                qDebug() << "[OSC] Preset name extracted:" << presetName;
                qDebug() << "[OSC] Config param length:" << configParam.length();
                qDebug() << "[OSC] Config param preview:" << (configParam.length() > 100 ? configParam.left(100) + "..." : configParam);
#endif

                if (!presetName.isEmpty() && !configParam.isEmpty() && mpConsole) {
                    // Parse the JSON configuration
                    QJsonParseError parseError;
                    QJsonDocument doc = QJsonDocument::fromJson(configParam.toUtf8(), &parseError);

                    if (parseError.error == QJsonParseError::NoError && doc.isObject()) {
                        mpConsole->getHyperlinkCompactManager().registerPreset(presetName, doc.object());
#if defined(DEBUG_OSC_PROCESSING)
                        qDebug() << "[OSC] Successfully registered preset:" << presetName;
#endif
                    } else {
                        qWarning().noquote().nospace() << "TBuffer::decodeOSC(...) - Failed to parse preset JSON for \"" << presetName << "\": " << parseError.errorString();
#if defined(DEBUG_OSC_PROCESSING)
                        qDebug() << "[OSC] Failed JSON:" << configParam;
#endif
                    }
                } else {
#if defined(DEBUG_OSC_PROCESSING)
                    qDebug() << "[OSC] Preset registration skipped - presetName empty:" << presetName.isEmpty() << "configParam empty:" << configParam.isEmpty()
                             << "mpConsole:" << (mpConsole != nullptr) << "mpHyperlinkCompactManager:" << (mpConsole != nullptr);
#endif
                }
                // Preset definitions don't create visible hyperlinks
                return;
            }

            // Parse query parameters for enhanced functionality
#if defined(DEBUG_OSC_PROCESSING)
            qDebug() << "[OSC] Raw URL for parameter parsing:" << rawUrl;
#endif
            // Reset styling to defaults before parsing
            mCurrentHyperlinkStyling = Mudlet::HyperlinkStyling();
            QMap<QString, QString> queryParams;
            parseUriQueryParameters(rawUrl, mCurrentHyperlinkStyling, queryParams);

#if defined(DEBUG_OSC_PROCESSING)
            qDebug() << "[OSC] Styling parsed directly from JSON (isUnderlined=" << mCurrentHyperlinkStyling.isUnderlined << ")";
#endif

            // Extract menu parameters
            if (queryParams.contains(qsl("menu"))) {
                QString menuString = queryParams.value(qsl("menu"));
                mCurrentHyperlinkMenu = menuString.split('|', Qt::SkipEmptyParts);
#if defined(DEBUG_OSC_PROCESSING)
                qDebug() << "[OSC] Menu parameter found:" << menuString;
                qDebug() << "[OSC] Menu items parsed:" << mCurrentHyperlinkMenu;
                qDebug() << "[OSC] Menu items count:" << mCurrentHyperlinkMenu.size();
#endif
            } else {
                mCurrentHyperlinkMenu.clear();
#if defined(DEBUG_OSC_PROCESSING)
                qDebug() << "[OSC] No menu parameter found";
#endif
            }

            // Extract custom tooltip parameter
            QString customTooltip;

            if (queryParams.contains(qsl("tooltip"))) {
                customTooltip = queryParams.value(qsl("tooltip"));
            }

            // Note: title is now parsed directly into mCurrentHyperlinkStyling by parseJsonHyperlinkConfig

            QString baseUrl = rawUrl;

            if (rawUrl.startsWith(qsl("http://")) || rawUrl.startsWith(qsl("https://")) || rawUrl.startsWith(qsl("ftp://"))) {
                int queryStart = baseUrl.indexOf('?');
                if (queryStart != -1) {
                    // Strip reserved parameters when corresponding OSC 8 features are enabled
                    // (currently always true; will reflect NEW-ENVIRON negotiation once fully implemented)
                    const bool stripConfig = mpHost->shouldStripOscHyperlinkConfigParam();
                    const bool stripPreset = mpHost->shouldStripOscHyperlinkPresetParam();

                    // Compare keys against literal strings only; percent-encoded key names
                    // (e.g. %63%6F%6E%66%69%67 for "config") are intentionally not stripped
                    const QStringList rawPairs = baseUrl.mid(queryStart + 1).split('&');
                    QStringList kept;
                    for (const auto& pair : rawPairs) {
                        // Find the separator between key and value - use earliest of '=' or '%3D' (percent-encoded =)
                        int literalEq = pair.indexOf('=');
                        int encodedEq = pair.indexOf(qsl("%3D"), 0, Qt::CaseInsensitive);
                        int eqPos = -1;
                        if (literalEq >= 0 && encodedEq >= 0) {
                            eqPos = qMin(literalEq, encodedEq);
                        } else if (literalEq >= 0) {
                            eqPos = literalEq;
                        } else {
                            eqPos = encodedEq;
                        }
                        const auto key = eqPos >= 0 ? pair.left(eqPos) : pair;
                        if ((stripConfig && key == qsl("config")) || (stripPreset && key == qsl("preset"))) {
                            continue;
                        }
                        kept.append(pair);
                    }

                    baseUrl = baseUrl.left(queryStart);
                    if (!kept.isEmpty()) {
                        baseUrl += '?' + kept.join('&');
                    }
                }
            } else {
                // For send: and prompt: commands, remove all query parameters
                int queryStart = baseUrl.indexOf('?');
                if (queryStart != -1) {
                    baseUrl = baseUrl.left(queryStart);
                }
            }

            QStringList command;
            QStringList hint;

            if (baseUrl.startsWith(qsl("send:"))) {
                QString innerCommand = QUrl::fromPercentEncoding(baseUrl.mid(5).toUtf8());
                command = {qsl("send([[%1]], false)").arg(innerCommand)};
                hint = {qsl("%1: %2").arg(QObject::tr("Send"), innerCommand)};
            } else if (baseUrl.startsWith(qsl("prompt:"))) {
                QString innerCommand = QUrl::fromPercentEncoding(baseUrl.mid(7).toUtf8());
                command = {qsl("sendCmdLine([[%1]])").arg(innerCommand)};
                hint = {qsl("%1: %2").arg(QObject::tr("Prompt"), innerCommand)};
            } else {
                QUrl qurl(baseUrl);
                QString scheme = qurl.scheme().toLower();

                if (scheme == qsl("http") || scheme == qsl("https") || scheme == qsl("ftp")) {
                    command = {qsl("openUrl([[%1]])").arg(baseUrl)};
                    hint = {qsl("%1: %2").arg(QObject::tr("Open browser to"), baseUrl)};
                } else {
                    qWarning().noquote().nospace() << "TBuffer::decodeOSC(...) - Ignored untrusted or unsupported URI scheme: \"" << scheme << "\"";
                    return;
                }
            }

            // Add standalone tooltip support (for links without menus)
            if (!customTooltip.isEmpty() && (mCurrentHyperlinkMenu.isEmpty() || mCurrentHyperlinkMenu.size() < 2)) {
                // Replace the default hint with the custom tooltip
                hint = {customTooltip};
#if defined(DEBUG_OSC_PROCESSING)
                qDebug() << "[OSC] Added standalone tooltip:" << customTooltip;
#endif
            }

            // Handle menu functionality by extending commands and hints
            if (!mCurrentHyperlinkMenu.isEmpty()) {
#if defined(DEBUG_OSC_PROCESSING)
                qDebug() << "[OSC] Building menu commands from" << mCurrentHyperlinkMenu.size() << "menu items";
#endif
                QStringList menuCommands;
                QStringList menuHints;

                // Add menu items in pairs (label, command)
                // The first menu item becomes the primary left-click action (index 0)
                // All items (including first) appear in the right-click menu (index 1+)
                for (int i = 0; i < mCurrentHyperlinkMenu.size() - 1; i += 2) {
                    QString menuLabel = mCurrentHyperlinkMenu[i];
                    QString menuCommand = mCurrentHyperlinkMenu[i + 1];

                    // Determine command type based on prefix
                    if (menuCommand.startsWith(qsl("send:"))) {
                        QString innerCommand = QUrl::fromPercentEncoding(menuCommand.mid(5).toUtf8());
                        menuCommands.append(qsl("send([[%1]], false)").arg(innerCommand));
                        menuHints.append(menuLabel);
                    } else if (menuCommand.startsWith(qsl("prompt:"))) {
                        QString innerCommand = QUrl::fromPercentEncoding(menuCommand.mid(7).toUtf8());
                        menuCommands.append(qsl("sendCmdLine([[%1]])").arg(innerCommand));
                        menuHints.append(menuLabel);
                    } else if (menuCommand == qsl("-")) {
                        // Special case: "-" creates a menu separator
                        menuCommands.append(QString());
                        menuHints.append(QString());
                    } else {
                        // Treat as direct command
                        menuCommands.append(qsl("send([[%1]], false)").arg(menuCommand));
                        menuHints.append(menuLabel);
                    }
                }

                // Set the tooltip for the link (what shows on hover)
                // Use custom tooltip if provided, otherwise show "Right-click for menu" message
                QString linkTooltip;
                if (!customTooltip.isEmpty()) {
                    // Custom tooltip explicitly provided
                    linkTooltip = customTooltip;
                } else {
                    // Default tooltip for menu links
                    linkTooltip = QObject::tr("Right-click for menu");
                }

                // For menus, we need tooltip as first hint, then menu labels
                // This ensures tooltip.size() > commands.size() so only first hint shows in tooltip
                // But menu labels are available for right-click menu display
                QStringList finalHints;
                finalHints.append(linkTooltip); // First: tooltip for hover
                finalHints.append(menuHints);   // Rest: menu labels for right-click menu

                command = menuCommands;
                hint = finalHints;
#if defined(DEBUG_OSC_PROCESSING)
                qDebug() << "[OSC] Final menu commands:" << command;
                qDebug() << "[OSC] Final menu hints:" << hint;
#endif
            }

            mCurrentHyperlinkCommand = command;
            mCurrentHyperlinkHint = hint;
            mCurrentHyperlinkLinkId = mLinkStore.addLinks(command, hint, mpHost, QVector<int>());

            // Store the styling for this link so it can be retrieved later
            mLinkStore.setStyling(mCurrentHyperlinkLinkId, mCurrentHyperlinkStyling);

            // Store the original background color for this link so we can restore it later
            // when the link styling doesn't specify a background
            mLinkOriginalBackgrounds[mCurrentHyperlinkLinkId] = mBackGroundColor;

            // Initialize selection state if this link has selection settings
            if (mCurrentHyperlinkStyling.selection.hasSelectionSettings && mpConsole) {
                const QString& group = mCurrentHyperlinkStyling.selection.group;
                const QString& value = mCurrentHyperlinkStyling.selection.value;

                // Configure group exclusivity mode
                mpConsole->getHyperlinkSelectionManager().setGroupExclusive(group, mCurrentHyperlinkStyling.selection.exclusive);

                // Register the link with the selection manager
                mpConsole->getHyperlinkSelectionManager().setSelected(group, value, mCurrentHyperlinkStyling.selection.selected);

                // Update link selection state (visual styling will be applied when link closes)
                setLinkSelected(mCurrentHyperlinkLinkId, mCurrentHyperlinkStyling.selection.selected);
                if (mCurrentHyperlinkStyling.selection.selected) {
                    setLinkState(mCurrentHyperlinkLinkId, Mudlet::HyperlinkStyling::StateSelected);
                } else if (mCurrentHyperlinkStyling.selection.disabled) {
                    setLinkState(mCurrentHyperlinkLinkId, Mudlet::HyperlinkStyling::StateDisabled);
                }
#if defined(DEBUG_OSC_PROCESSING)
                qDebug() << "[OSC] Link" << mCurrentHyperlinkLinkId << "initialized with selection state:" << "group=" << group << "value=" << value
                         << "selected=" << mCurrentHyperlinkStyling.selection.selected << "disabled=" << mCurrentHyperlinkStyling.selection.disabled;
#endif
            }

#if defined(DEBUG_OSC_PROCESSING)
            qDebug().noquote() << "[OSC] Hyperlink activated:" << rawUrl.left(50) + (rawUrl.length() > 50 ? "..." : "");
#endif
            mHyperlinkActive = true;

            // Record start position for visibility manager registration
            // Use mMudLine.length() since that's where link text will be added
            // (mMudLine is the current line being built, lineBuffer contains completed lines)
            mCurrentHyperlinkStartLine = static_cast<int>(lineBuffer.size()) - 1;
            mCurrentHyperlinkStartColumn = mMudLine.length();
            mCurrentHyperlinkText.clear();

#if defined(DEBUG_OSC_PROCESSING)
            qDebug() << "[OSC] Hyperlink start position: line" << mCurrentHyperlinkStartLine << "column" << mCurrentHyperlinkStartColumn;
#endif
        }
        break;
    }
    default:
        qDebug().noquote().nospace() << "TBuffer::decodeOSC(\"" << sequence << "\") ERROR - Unhandled <OSC>?...<ST> code, Mudlet will ignore it.";
    }
}

QString TBuffer::appendQueryParameters(const QString& uri, const QMap<QString, QString>& parameters)
{
    if (parameters.isEmpty()) {
        return uri;
    }

    QString result = uri;
    bool hasExistingParams = uri.contains(qsl("?"));
    QString separator = hasExistingParams ? qsl("&") : qsl("?");

    QStringList paramStrings;
    for (auto it = parameters.constBegin(); it != parameters.constEnd(); ++it) {
        QString key = QUrl::toPercentEncoding(it.key());
        QString value = QUrl::toPercentEncoding(it.value());
        paramStrings.append(qsl("%1=%2").arg(key, value));
    }

    if (!paramStrings.isEmpty()) {
        result += separator + paramStrings.join(qsl("&"));
    }

    return result;
}

bool TBuffer::parseUriQueryParameters(const QString& uri, Mudlet::HyperlinkStyling& styling, QMap<QString, QString>& parameters)
{
    parameters.clear();

#if defined(DEBUG_OSC_PROCESSING)
    qDebug() << "[OSC] parseUriQueryParameters called with uri:" << uri;
#endif

    // Find the query string part after '?'
    int queryStart = uri.indexOf('?');
    if (queryStart == -1) {
#if defined(DEBUG_OSC_PROCESSING)
        qDebug() << "[OSC] No query parameters found in URI";
#endif
        return true; // No query parameters is not an error
    }

    QString queryString = uri.mid(queryStart + 1);
#if defined(DEBUG_OSC_PROCESSING)
    qDebug() << "[OSC] Query string:" << queryString;
#endif

    // Decode the query string first, since the server percent-encodes the entire URL
    QString decodedQueryString = QUrl::fromPercentEncoding(queryString.toUtf8());
#if defined(DEBUG_OSC_PROCESSING)
    qDebug() << "[OSC] Decoded query string:" << decodedQueryString;
#endif

    // Extract config= and preset= from the query string.
    // The config value is JSON which may contain '&' characters inside strings,
    // so we use brace-depth counting to find where the JSON object ends rather
    // than naively splitting on '&'.
    QString configJson;
    QString presetName;
    QString remaining = decodedQueryString;

    while (!remaining.isEmpty()) {
        // Find config={...} using brace matching
        if (remaining.startsWith(qsl("config={"))) {
            int braceStart = 7; // length of "config="
            int depth = 0;
            bool inString = false;
            bool escaped = false;
            int end = -1;

            for (int i = braceStart; i < remaining.size(); ++i) {
                QChar ch = remaining.at(i);
                if (escaped) {
                    escaped = false;
                    continue;
                }
                if (ch == '\\') {
                    escaped = true;
                    continue;
                }
                if (ch == '"') {
                    inString = !inString;
                    continue;
                }
                if (!inString) {
                    if (ch == '{') {
                        ++depth;
                    } else if (ch == '}') {
                        --depth;
                        if (depth == 0) {
                            end = i + 1;
                            break;
                        }
                    }
                }
            }

            if (end > braceStart) {
                configJson = remaining.mid(braceStart, end - braceStart);
                // Skip past the JSON and any trailing '&'
                remaining = (end < remaining.size() && remaining.at(end) == '&') ? remaining.mid(end + 1) : remaining.mid(end);
            } else {
                // Malformed JSON - take the rest as config and let the parser handle it
                configJson = remaining.mid(braceStart);
                remaining.clear();
            }
            continue;
        }

        // Find the next '&' for non-config parameters
        int ampPos = remaining.indexOf('&');
        QString param = (ampPos == -1) ? remaining : remaining.left(ampPos);
        remaining = (ampPos == -1) ? QString() : remaining.mid(ampPos + 1);

        int eqPos = param.indexOf('=');
        if (eqPos == -1) {
            continue;
        }

        QString key = param.left(eqPos);
        QString value = param.mid(eqPos + 1);

        if (key == qsl("preset")) {
            presetName = value;
        }
    }

#if defined(DEBUG_OSC_PROCESSING)
    if (!configJson.isEmpty()) {
        qDebug() << "[OSC] Extracted config JSON:" << configJson;
    }
    if (!presetName.isEmpty()) {
        qDebug() << "[OSC] Found preset:" << presetName;
    }
#endif

    if (!presetName.isEmpty() || !configJson.isEmpty()) {
        QJsonObject baseConfig;

        if (!presetName.isEmpty() && mpConsole) {
            baseConfig = mpConsole->getHyperlinkCompactManager().getPreset(presetName);
#if defined(DEBUG_OSC_PROCESSING)
            if (!baseConfig.isEmpty()) {
                qDebug() << "[OSC] Resolved preset" << presetName;
            } else {
                qDebug() << "[OSC] Preset" << presetName << "not found or empty";
            }
#endif
        } else {
#if defined(DEBUG_OSC_PROCESSING)
            if (!presetName.isEmpty()) {
                qDebug() << "[OSC] Cannot resolve preset - missing console or manager";
            }
#endif
        }

        // Merge with override config if present
        if (!configJson.isEmpty()) {
            QJsonParseError parseError;
            QJsonDocument overrideDoc = QJsonDocument::fromJson(configJson.toUtf8(), &parseError);

            if (parseError.error == QJsonParseError::NoError && overrideDoc.isObject()) {
                QJsonObject overrideConfig = overrideDoc.object();

                if (!baseConfig.isEmpty() && mpConsole) {
                    // Deep merge: override takes precedence
                    baseConfig = mpConsole->getHyperlinkCompactManager().mergeConfigs(baseConfig, overrideConfig);
#if defined(DEBUG_OSC_PROCESSING)
                    qDebug() << "[OSC] Merged preset with override config";
#endif
                } else {
                    baseConfig = overrideConfig;
                }
            }
        }

        // Parse the final merged config
        if (!baseConfig.isEmpty()) {
            QJsonDocument finalDoc(baseConfig);
            QString finalJson = QString::fromUtf8(finalDoc.toJson(QJsonDocument::Compact));
            return parseJsonHyperlinkConfig(finalJson, parameters, styling);
        }
    }

#if defined(DEBUG_OSC_PROCESSING)
    qDebug() << "[OSC] Parsed JSON parameters:" << parameters;
#endif

    return true;
}

QJsonObject TBuffer::expandJsonShorthands(const QJsonObject& obj)
{
    if (!mpConsole) {
        return obj; // No manager available, return unchanged
    }

    QJsonObject result;

    for (auto it = obj.begin(); it != obj.end(); ++it) {
        QString originalKey = it.key();
        QJsonValue value = it.value();

        QMap<QString, QString> singleKeyMap;
        singleKeyMap.insert(originalKey, qsl("placeholder")); // Value doesn't matter for key expansion
        QMap<QString, QString> expandedMap = mpConsole->getHyperlinkCompactManager().expandShorthand(singleKeyMap);

        // Guard against empty or multi-key expanded maps to prevent assertion/UB
        QString resultKey;
        if (expandedMap.isEmpty()) {
            // No expansion occurred - use original key
            resultKey = originalKey;
        } else if (expandedMap.size() == 1) {
            // Normal case - single expanded key
            resultKey = expandedMap.firstKey();
        } else {
            // Unexpected: multiple keys from single input - use first deterministically and log
            resultKey = expandedMap.firstKey();
#if defined(DEBUG_OSC_PROCESSING)
            qWarning() << "[OSC] expandShorthand returned multiple keys for single input:" << originalKey << "-> expanded to:" << expandedMap.keys() << "using first key:" << resultKey;
#endif
        }

        // Recursively expand nested objects
        QJsonValue resultValue = value.isObject() ? expandJsonShorthands(value.toObject()) : value;

        // Handle duplicate keys by merging objects (e.g., both "style" and "s" -> "style")
        if (result.contains(resultKey) && result[resultKey].isObject() && resultValue.isObject()) {
            // Merge the objects using the compact manager's deep merge functionality
            QJsonObject existing = result[resultKey].toObject();
            QJsonObject toAdd = resultValue.toObject();

            // When both shorthand and full names exist, shorthand takes precedence
            result[resultKey] = mpConsole->getHyperlinkCompactManager().mergeConfigs(toAdd, existing);
        } else {
            result[resultKey] = resultValue;
        }
    }

    return result;
}

bool TBuffer::parseJsonHyperlinkConfig(const QString& jsonString, QMap<QString, QString>& parameters, Mudlet::HyperlinkStyling& styling, QString* errorDetails)
{
#if defined(DEBUG_OSC_PROCESSING)
    qDebug() << "[OSC] parseJsonHyperlinkConfig called with jsonString:" << jsonString;
#endif

    QJsonParseError parseError;
    QJsonDocument doc = QJsonDocument::fromJson(jsonString.toUtf8(), &parseError);

    if (parseError.error != QJsonParseError::NoError) {
#if defined(DEBUG_OSC_PROCESSING)
        qDebug() << "[OSC] JSON parse error:" << parseError.errorString();
#endif
        return false;
    }

    if (!doc.isObject()) {
#if defined(DEBUG_OSC_PROCESSING)
        qDebug() << "[OSC] JSON root is not an object";
#endif
        return false;
    }

    QJsonObject root = doc.object();

    // Expand shorthands in the JSON object (recursive)
    root = expandJsonShorthands(root);

#if defined(DEBUG_OSC_PROCESSING)
    qDebug() << "[OSC] After expansion, root contains:" << root.keys();
    qDebug() << "[OSC] Has 'menu':" << root.contains(qsl("menu"));
    qDebug() << "[OSC] Has 'tooltip':" << root.contains(qsl("tooltip"));
#endif

    if (root.contains(qsl("style")) && root[qsl("style")].isObject()) {
        QJsonObject styleObj = root[qsl("style")].toObject();
        parseJsonStyleToHyperlinkStyling(styleObj, styling);
#if defined(DEBUG_OSC_PROCESSING)
        qDebug() << "[OSC] Style parsed directly from JSON";
#endif
    }

    if (root.contains(qsl("menu")) && root[qsl("menu")].isArray()) {
        QJsonArray menuArray = root[qsl("menu")].toArray();
        QString menuString = jsonMenuArrayToString(menuArray);
        if (!menuString.isEmpty()) {
            parameters.insert(qsl("menu"), menuString);
            // Enable custom styling for links with menus
            styling.hasCustomStyling = true;
#if defined(DEBUG_OSC_PROCESSING)
            qDebug() << "[OSC] Menu parameter added:" << menuString;
#endif
        }
    }

    if (root.contains(qsl("tooltip")) && root[qsl("tooltip")].isString()) {
        parameters.insert(qsl("tooltip"), root[qsl("tooltip")].toString());
        // Enable custom styling for links with tooltips
        styling.hasCustomStyling = true;
#if defined(DEBUG_OSC_PROCESSING)
        qDebug() << "[OSC] Tooltip parameter added:" << root[qsl("tooltip")].toString();
#endif
    }

    if (root.contains(qsl("title"))) {
        QJsonValue titleValue = root[qsl("title")];
        if (titleValue.isString()) {
            styling.menuTitle = titleValue.toString();
#if defined(DEBUG_OSC_PROCESSING)
            qDebug() << "[OSC] Title parameter added:" << titleValue.toString();
#endif
        } else if (titleValue.isObject()) {
            QJsonObject titleObj = titleValue.toObject();
            if (titleObj.contains(qsl("text")) && titleObj[qsl("text")].isString()) {
                styling.menuTitle = titleObj[qsl("text")].toString();
            }
            if (titleObj.contains(qsl("style")) && titleObj[qsl("style")].isObject()) {
                parseJsonStateStyle(titleObj[qsl("style")].toObject(), styling.menuTitleStyle);
            }
#if defined(DEBUG_OSC_PROCESSING)
            qDebug() << "[OSC] Title object parsed - text:" << styling.menuTitle << "hasColor:" << styling.menuTitleStyle.hasForegroundColor;
#endif
        }
    }

    if (root.contains(qsl("selection")) && root[qsl("selection")].isObject()) {
        QJsonObject selectionObj = root[qsl("selection")].toObject();
        parseJsonSelectionConfig(selectionObj, styling.selection);
#if defined(DEBUG_OSC_PROCESSING)
        qDebug() << "[OSC] Selection config parsed from JSON";
#endif
    }

    if (root.contains(qsl("visibility")) && root[qsl("visibility")].isObject()) {
        QJsonObject visibilityObj = root[qsl("visibility")].toObject();
        if (!parseVisibilityFromJson(visibilityObj, styling.visibility)) {
            qWarning() << "TBuffer::parseJsonHyperlinkConfig: Failed to parse visibility settings (continuing with other config)";
            // Non-fatal: visibility settings ignored but other config parts still apply
        }
#if defined(DEBUG_OSC_PROCESSING)
        qDebug() << "[OSC] Visibility config parsed from JSON";
#endif
    }

    if (root.contains(qsl("spoiler"))) {
        styling.isSpoiler = jsonBoolValue(root[qsl("spoiler")]);
        if (styling.isSpoiler) {
            styling.hasCustomStyling = true;

            if (!styling.hasBackgroundColor) {
                QColor spoilerBackground = mBackGroundColor;

                qreal luminance = 0.2126 * spoilerBackground.redF() + 0.7152 * spoilerBackground.greenF() + 0.0722 * spoilerBackground.blueF();

                QColor originalBackground = spoilerBackground;

                if (luminance < 0.5) {
                    if (luminance < 0.1) {
                        spoilerBackground = QColor(40, 40, 40);
                    } else {
                        spoilerBackground = spoilerBackground.lighter(140);
                    }
                } else {
                    spoilerBackground = spoilerBackground.darker(140);
                }

                styling.backgroundColor = spoilerBackground;
                styling.hasBackgroundColor = true;

#if defined(DEBUG_OSC_PROCESSING)
                qDebug() << "[OSC] EARLY SPOILER BACKGROUND: Generated" << spoilerBackground.name() << "from original" << originalBackground.name() << "luminance:" << luminance;
#endif
            }
        }
#if defined(DEBUG_OSC_PROCESSING)
        qDebug() << "[OSC] Spoiler config parsed from JSON:" << styling.isSpoiler;
#endif
    }

    if (root.contains(qsl("disabled"))) {
        styling.selection.disabled = jsonBoolValue(root[qsl("disabled")]);
        if (styling.selection.disabled) {
            styling.hasCustomStyling = true;
        }
#if defined(DEBUG_OSC_PROCESSING)
        qDebug() << "[OSC] Disabled config parsed from JSON:" << styling.selection.disabled;
#endif
    }

    if (styling.isSpoiler) {
        if (!parameters.contains(qsl("tooltip"))) {
            parameters.insert(qsl("tooltip"), QObject::tr("Click to reveal"));
            styling.hasCustomStyling = true;
#if defined(DEBUG_OSC_PROCESSING)
            qDebug() << "[OSC] Auto-generated spoiler tooltip: Click to reveal for link - disabled:" << styling.selection.disabled;
#endif
        }
#if defined(DEBUG_OSC_PROCESSING)
        // NOLINTNEXTLINE(readability/braces) - else is conditional on DEBUG_OSC_PROCESSING
        else {
            qDebug() << "[OSC] Spoiler link has explicit tooltip:" << parameters.value(qsl("tooltip"));
        }
#endif
    }

#if defined(DEBUG_OSC_PROCESSING)
    qDebug() << "[OSC] JSON converted to parameters:" << parameters;
#endif

    return true;
}

QString TBuffer::jsonMenuArrayToString(const QJsonArray& menuArray)
{
    QStringList menuItems;

    for (const QJsonValue& item : menuArray) {
        if (item.isString() && item.toString() == qsl("-")) {
            // Separator
            menuItems << qsl("Separator|-");
        } else if (item.isObject()) {
            QJsonObject menuObj = item.toObject();
            // Each menu object should have one key-value pair: label -> command
            for (auto it = menuObj.begin(); it != menuObj.end(); ++it) {
                if (it.value().isString()) {
                    menuItems << it.key() + qsl("|") + it.value().toString();
                }
            }
        }
    }

    return menuItems.join(qsl("|"));
}

void TBuffer::parseJsonStateStyle(const QJsonObject& stateObj, Mudlet::HyperlinkStyling::StateStyle& stateStyle)
{
    bool hasAnyCustomStyling = false;

    if (stateObj.contains(qsl("color")) && stateObj[qsl("color")].isString()) {
        QColor color = parseColorValue(stateObj[qsl("color")].toString());
        if (color.isValid()) {
            stateStyle.foregroundColor = color;
            stateStyle.hasForegroundColor = true;
            hasAnyCustomStyling = true;
        }
    }

    if (stateObj.contains(qsl("bg")) && stateObj[qsl("bg")].isString()) {
        QColor color = parseColorValue(stateObj[qsl("bg")].toString());
        if (color.isValid()) {
            stateStyle.backgroundColor = color;
            stateStyle.hasBackgroundColor = true;
            hasAnyCustomStyling = true;
        }
    }

    // Also support full CSS property name
    if (stateObj.contains(qsl("background-color")) && stateObj[qsl("background-color")].isString()) {
        QColor color = parseColorValue(stateObj[qsl("background-color")].toString());
        if (color.isValid()) {
            stateStyle.backgroundColor = color;
            stateStyle.hasBackgroundColor = true;
            hasAnyCustomStyling = true;
        }
    }

    if (stateObj.contains(qsl("bold"))) {
        stateStyle.isBold = jsonBoolValue(stateObj[qsl("bold")]);
        hasAnyCustomStyling = true;
    }

    if (stateObj.contains(qsl("italic"))) {
        stateStyle.isItalic = jsonBoolValue(stateObj[qsl("italic")]);
        hasAnyCustomStyling = true;
    }

    if (stateObj.contains(qsl("underline"))) {
        QJsonValue underlineVal = stateObj[qsl("underline")];
        if (underlineVal.isBool() || underlineVal.isDouble()) {
            stateStyle.isUnderlined = jsonBoolValue(underlineVal);
            if (stateStyle.isUnderlined) {
                stateStyle.underlineStyle = Mudlet::HyperlinkStyling::UnderlineSolid;
            }
            hasAnyCustomStyling = true;
        } else if (underlineVal.isString()) {
            stateStyle.isUnderlined = true;
            QString style = underlineVal.toString().toLower();
            if (style == qsl("wavy")) {
                stateStyle.underlineStyle = Mudlet::HyperlinkStyling::UnderlineWavy;
            } else if (style == qsl("dotted")) {
                stateStyle.underlineStyle = Mudlet::HyperlinkStyling::UnderlineDotted;
            } else if (style == qsl("dashed")) {
                stateStyle.underlineStyle = Mudlet::HyperlinkStyling::UnderlineDashed;
            } else {
                stateStyle.underlineStyle = Mudlet::HyperlinkStyling::UnderlineSolid;
            }
            hasAnyCustomStyling = true;
        }
    }

    if (stateObj.contains(qsl("overline"))) {
        QJsonValue overlineVal = stateObj[qsl("overline")];
        if (overlineVal.isBool() || overlineVal.isDouble()) {
            stateStyle.isOverlined = jsonBoolValue(overlineVal);
            hasAnyCustomStyling = true;
        } else if (overlineVal.isString()) {
            stateStyle.isOverlined = true;
            hasAnyCustomStyling = true;
        }
    }

    if (stateObj.contains(qsl("strikethrough"))) {
        QJsonValue strikeVal = stateObj[qsl("strikethrough")];
        if (strikeVal.isBool() || strikeVal.isDouble()) {
            stateStyle.isStrikeOut = jsonBoolValue(strikeVal);
            hasAnyCustomStyling = true;
        } else if (strikeVal.isString()) {
            stateStyle.isStrikeOut = true;
            hasAnyCustomStyling = true;
        }
    }

    if (stateObj.contains(qsl("text-decoration-color")) && stateObj[qsl("text-decoration-color")].isString()) {
        QColor decorationColor = parseColorValue(stateObj[qsl("text-decoration-color")].toString());
        if (decorationColor.isValid()) {
            stateStyle.underlineColor = decorationColor;
            stateStyle.hasUnderlineColor = true;
            stateStyle.overlineColor = decorationColor;
            stateStyle.hasOverlineColor = true;
            stateStyle.strikeoutColor = decorationColor;
            stateStyle.hasStrikeoutColor = true;
            hasAnyCustomStyling = true;
        }
    }

    stateStyle.hasCustomStyling = hasAnyCustomStyling;
}

void TBuffer::parseJsonSelectionConfig(const QJsonObject& selectionObj, Mudlet::HyperlinkStyling::SelectionSettings& settings)
{
    if (selectionObj.contains(qsl("group")) && selectionObj[qsl("group")].isString()) {
        settings.group = selectionObj[qsl("group")].toString();
        settings.hasSelectionSettings = true;
    }

    if (selectionObj.contains(qsl("value")) && selectionObj[qsl("value")].isString()) {
        settings.value = selectionObj[qsl("value")].toString();
    }

    if (selectionObj.contains(qsl("toggle"))) {
        settings.toggle = jsonBoolValue(selectionObj[qsl("toggle")]);
    }

    if (selectionObj.contains(qsl("selected"))) {
        settings.selected = jsonBoolValue(selectionObj[qsl("selected")]);
    }

    if (selectionObj.contains(qsl("exclusive"))) {
        settings.exclusive = jsonBoolValue(selectionObj[qsl("exclusive")]);
    }
}

bool TBuffer::parseVisibilityFromJson(const QJsonObject& visibilityObj, Mudlet::HyperlinkStyling::VisibilitySettings& settings)
{
    if (!visibilityObj.contains(qsl("action"))) {
        return true; // No action specified = no visibility settings
    }

    // Parse action field (can be string or array of strings)
    QJsonValue actionValue = visibilityObj[qsl("action")];
#if defined(DEBUG_OSC_PROCESSING)
    qWarning() << "[OSC] Visibility action value:" << actionValue << "isString:" << actionValue.isString();
#endif
    if (actionValue.isString()) {
        QString actionStr = actionValue.toString().toLower();
#if defined(DEBUG_OSC_PROCESSING)
        qWarning() << "[OSC] Action string:" << actionStr;
#endif
        if (actionStr == qsl("conceal")) {
            settings.action = Mudlet::HyperlinkStyling::VisibilitySettings::Action::Conceal;
            settings.isConcealed = false; // Start visible, will be concealed later
        } else if (actionStr == qsl("reveal")) {
#if defined(DEBUG_OSC_PROCESSING)
            qWarning() << "[OSC] FOUND REVEAL ACTION - setting isConcealed=true";
#endif
            settings.action = Mudlet::HyperlinkStyling::VisibilitySettings::Action::Reveal;
            settings.isConcealed = true; // Start concealed, will be revealed later
#if defined(DEBUG_OSC_PROCESSING)
            qWarning() << "[OSC] Setting reveal action: isConcealed=true, action=Reveal";
#endif
        } else if (actionStr == qsl("revealthenconceal") || actionStr == qsl("reveal-then-conceal")) {
            settings.action = Mudlet::HyperlinkStyling::VisibilitySettings::Action::RevealThenConceal;
            settings.isConcealed = true; // Start concealed, reveal after delay, then conceal on click
        } else {
            qWarning() << "TBuffer::parseVisibilityFromJson: Invalid action string:" << actionStr << "(ignoring visibility settings)";
            settings.hasVisibilitySettings = false;
            return true; // Non-fatal: allow other config parts to apply
        }
    } else if (actionValue.isArray()) {
        QJsonArray actionArray = actionValue.toArray();
        if (actionArray.size() == 2 && actionArray[0].isString() && actionArray[0].toString().toLower() == qsl("reveal") && actionArray[1].isString()
            && actionArray[1].toString().toLower() == qsl("conceal")) {
            settings.action = Mudlet::HyperlinkStyling::VisibilitySettings::Action::RevealThenConceal;
            settings.isConcealed = true; // Start concealed, reveal after delay, then conceal on click
        } else {
            qWarning() << "TBuffer::parseVisibilityFromJson: Invalid action array (expected [\"reveal\", \"conceal\"]) (ignoring visibility settings)";
            settings.hasVisibilitySettings = false;
            return true; // Non-fatal: allow other config parts to apply
        }
    } else {
        qWarning() << "TBuffer::parseVisibilityFromJson: Action must be string or array (ignoring visibility settings)";
        settings.hasVisibilitySettings = false;
        return true; // Non-fatal: allow other config parts to apply
    }

    settings.hasVisibilitySettings = true;

    // Parse delay (optional)
    if (visibilityObj.contains(qsl("delay"))) {
        QJsonValue delayValue = visibilityObj[qsl("delay")];
        if (delayValue.isDouble()) {
            double delayDouble = delayValue.toDouble();
            if (delayDouble < 0) {
                qWarning() << "TBuffer::parseVisibilityFromJson: Delay cannot be negative:" << delayDouble << "(using 0)";
                settings.delayMs = 0;
            } else if (delayDouble > Mudlet::HyperlinkStyling::VisibilitySettings::MaxDelayMs) {
                qWarning() << "TBuffer::parseVisibilityFromJson: Delay exceeds maximum (" << Mudlet::HyperlinkStyling::VisibilitySettings::MaxDelayMs << "ms):" << delayDouble
                           << "(clamping to maximum)";
                settings.delayMs = Mudlet::HyperlinkStyling::VisibilitySettings::MaxDelayMs;
            } else {
                settings.delayMs = static_cast<quint32>(delayDouble);
            }
        }
    }

    // Parse wholeline (optional)
    if (visibilityObj.contains(qsl("wholeline"))) {
        settings.deletesEntireLine = jsonBoolValue(visibilityObj[qsl("wholeline")]);
    }

    // Parse expire triggers (optional)
    if (visibilityObj.contains(qsl("expire")) && visibilityObj[qsl("expire")].isObject()) {
        QJsonObject expireObj = visibilityObj[qsl("expire")].toObject();

        if (expireObj.contains(qsl("input"))) {
            settings.expireOnInput = jsonBoolValue(expireObj[qsl("input")]);
        }

        if (expireObj.contains(qsl("prompt"))) {
            settings.expireOnPrompt = jsonBoolValue(expireObj[qsl("prompt")]);
        }

        if (expireObj.contains(qsl("output"))) {
            settings.expireOnOutput = jsonBoolValue(expireObj[qsl("output")]);
        }

        if (expireObj.contains(qsl("outputDelay"))) {
            QJsonValue outputDelayValue = expireObj[qsl("outputDelay")];
            if (outputDelayValue.isDouble()) {
                double delayDouble = outputDelayValue.toDouble();
                if (delayDouble < 0) {
                    qWarning() << "TBuffer::parseVisibilityFromJson: outputDelay cannot be negative:" << delayDouble << "(using 0)";
                    settings.outputDelayMs = 0;
                } else if (delayDouble > Mudlet::HyperlinkStyling::VisibilitySettings::MaxDelayMs) {
                    qWarning() << "TBuffer::parseVisibilityFromJson: outputDelay exceeds maximum (" << Mudlet::HyperlinkStyling::VisibilitySettings::MaxDelayMs << "ms):" << delayDouble
                               << "(clamping to maximum)";
                    settings.outputDelayMs = Mudlet::HyperlinkStyling::VisibilitySettings::MaxDelayMs;
                } else {
                    settings.outputDelayMs = static_cast<quint32>(delayDouble);
                }
            }
        }
    }

    return true;
}

bool TBuffer::parseVisibilitySettings(const QString& jsonString, Mudlet::HyperlinkStyling::VisibilitySettings& settings, QString* errorDetails)
{
    QJsonParseError parseError;
    QJsonDocument doc = QJsonDocument::fromJson(jsonString.toUtf8(), &parseError);

    if (parseError.error != QJsonParseError::NoError) {
        if (errorDetails) {
            *errorDetails = qsl("JSON parse error: %1").arg(parseError.errorString());
        }
        return false;
    }

    if (!doc.isObject()) {
        if (errorDetails) {
            *errorDetails = qsl("Root must be a JSON object");
        }
        return false;
    }

    QJsonObject root = doc.object();
    if (!root.contains(qsl("visibility"))) {
        // No visibility settings = success
        return true;
    }

    if (!root[qsl("visibility")].isObject()) {
        if (errorDetails) {
            *errorDetails = qsl("'visibility' field must be an object");
        }
        return false;
    }

    bool success = parseVisibilityFromJson(root[qsl("visibility")].toObject(), settings);
    if (!success) {
        if (errorDetails) {
            if (errorDetails->isEmpty()) {
                *errorDetails = qsl("Failed to parse visibility settings (non-fatal)");
            }
            // When errorDetails is provided, treat as warning only
            return true;
        }
    }

    return success;
}

void TBuffer::clearLinkIndices(int lineNumber, int startColumn, int length)
{
    if (lineNumber < 0 || lineNumber >= static_cast<int>(buffer.size())) {
#if defined(DEBUG_OSC_PROCESSING)
        qDebug() << "[OSC] clearLinkIndices: invalid line number" << lineNumber << "buffer size:" << buffer.size();
#endif
        return;
    }

    if (length <= 0) {
#if defined(DEBUG_OSC_PROCESSING)
        qDebug() << "[OSC] clearLinkIndices: non-positive length" << length << "- returning early";
#endif
        return;
    }

    std::deque<TChar>& line = buffer[lineNumber];

    // Extra safety: ensure we don't go out of bounds
    if (line.empty()) {
#if defined(DEBUG_OSC_PROCESSING)
        qDebug() << "[OSC] clearLinkIndices: line" << lineNumber << "is empty";
#endif
        return;
    }

    startColumn = std::max(0, std::min(startColumn, static_cast<int>(line.size())));

    int endColumn = std::min(static_cast<int>(line.size()), startColumn + std::max(0, length));

#if defined(DEBUG_OSC_PROCESSING)
    qDebug() << "[OSC] clearLinkIndices: line" << lineNumber << "startCol" << startColumn << "length" << length << "endCol" << endColumn << "lineSize" << line.size();
#endif

    for (int col = startColumn; col < endColumn; ++col) {
        // Additional bounds check inside the loop
        if (col >= static_cast<int>(line.size())) {
            break;
        }
        int oldLinkIndex = line[col].mLinkIndex;
        line[col].mLinkIndex = 0;
#if defined(DEBUG_OSC_PROCESSING)
        if (oldLinkIndex != 0) {
            qDebug() << "[OSC] clearLinkIndices: cleared column" << col << "from linkIndex" << oldLinkIndex << "to 0";
        }
#endif
    }
}

void TBuffer::restoreLinkIndices(int lineNumber, int startColumn, int length, int linkId)
{
    if (lineNumber < 0 || lineNumber >= static_cast<int>(buffer.size())) {
        return;
    }

    if (length <= 0) {
#if defined(DEBUG_OSC_PROCESSING)
        qDebug() << "[OSC] restoreLinkIndices: non-positive length" << length << "- returning early";
#endif
        return;
    }

    std::deque<TChar>& line = buffer[lineNumber];

    // Extra safety: ensure we don't go out of bounds
    if (line.empty()) {
#if defined(DEBUG_OSC_PROCESSING)
        qDebug() << "[OSC] restoreLinkIndices: line" << lineNumber << "is empty";
#endif
        return;
    }

    startColumn = std::max(0, std::min(startColumn, static_cast<int>(line.size())));

    int endColumn = std::min(static_cast<int>(line.size()), startColumn + std::max(0, length));

    for (int col = startColumn; col < endColumn; ++col) {
        // Additional bounds check inside the loop
        if (col >= static_cast<int>(line.size())) {
            break;
        }
        line[col].mLinkIndex = linkId;
    }

#if defined(DEBUG_OSC_PROCESSING)
    qDebug() << "[OSC] Link" << linkId << "restored";
#endif
}

void TBuffer::parseJsonStyleToHyperlinkStyling(const QJsonObject& styleObj, Mudlet::HyperlinkStyling& styling)
{
#if defined(DEBUG_OSC_PROCESSING)
    qDebug() << "[OSC] parseJsonStyleToHyperlinkStyling called with JSON object";
#endif

    // Parse base style properties directly into the default state
    Mudlet::HyperlinkStyling::StateStyle baseStyle;
    parseJsonStateStyle(styleObj, baseStyle);

    // Apply base style to the main styling object
    if (baseStyle.hasForegroundColor) {
        styling.foregroundColor = baseStyle.foregroundColor;
        styling.hasForegroundColor = true;
    }

    if (baseStyle.hasBackgroundColor) {
        styling.backgroundColor = baseStyle.backgroundColor;
        styling.hasBackgroundColor = true;
    }

    styling.isBold = baseStyle.isBold;
    styling.isItalic = baseStyle.isItalic;
    styling.isUnderlined = baseStyle.isUnderlined;
    styling.underlineStyle = baseStyle.underlineStyle;
    styling.isOverlined = baseStyle.isOverlined;

    // CRITICAL: Set custom styling flags if any base properties were found
    if (baseStyle.hasCustomStyling) {
        styling.hasCustomStyling = true;
        styling.hasBaseCustomStyling = true;
    }
    styling.isStrikeOut = baseStyle.isStrikeOut;

    if (baseStyle.hasUnderlineColor) {
        styling.underlineColor = baseStyle.underlineColor;
        styling.hasUnderlineColor = true;
    }

    if (baseStyle.hasOverlineColor) {
        styling.overlineColor = baseStyle.overlineColor;
        styling.hasOverlineColor = true;
    }

    if (baseStyle.hasStrikeoutColor) {
        styling.strikeoutColor = baseStyle.strikeoutColor;
        styling.hasStrikeoutColor = true;
    }

    // Parse pseudo-class states
    if (styleObj.contains(qsl("link")) && styleObj[qsl("link")].isObject()) {
        parseJsonStateStyle(styleObj[qsl("link")].toObject(), styling.linkStyle);
    }

    if (styleObj.contains(qsl("visited")) && styleObj[qsl("visited")].isObject()) {
        parseJsonStateStyle(styleObj[qsl("visited")].toObject(), styling.visitedStyle);
    }

    if (styleObj.contains(qsl("hover")) && styleObj[qsl("hover")].isObject()) {
        parseJsonStateStyle(styleObj[qsl("hover")].toObject(), styling.hoverStyle);
    }

    if (styleObj.contains(qsl("active")) && styleObj[qsl("active")].isObject()) {
        parseJsonStateStyle(styleObj[qsl("active")].toObject(), styling.activeStyle);
    }

    if (styleObj.contains(qsl("focus")) && styleObj[qsl("focus")].isObject()) {
        parseJsonStateStyle(styleObj[qsl("focus")].toObject(), styling.focusStyle);
    }

    if (styleObj.contains(qsl("focus-visible")) && styleObj[qsl("focus-visible")].isObject()) {
        parseJsonStateStyle(styleObj[qsl("focus-visible")].toObject(), styling.focusVisibleStyle);
    }

    if (styleObj.contains(qsl("any-link")) && styleObj[qsl("any-link")].isObject()) {
        parseJsonStateStyle(styleObj[qsl("any-link")].toObject(), styling.anyLinkStyle);
    }

    if (styleObj.contains(qsl("selected")) && styleObj[qsl("selected")].isObject()) {
        parseJsonStateStyle(styleObj[qsl("selected")].toObject(), styling.selectedStyle);
    }

    if (styleObj.contains(qsl("disabled")) && styleObj[qsl("disabled")].isObject()) {
        parseJsonStateStyle(styleObj[qsl("disabled")].toObject(), styling.disabledStyle);
    }

    // Update hasCustomStyling flag if any pseudo-class states have custom styling
    if (styling.linkStyle.hasCustomStyling || styling.visitedStyle.hasCustomStyling || styling.hoverStyle.hasCustomStyling || styling.activeStyle.hasCustomStyling
        || styling.focusStyle.hasCustomStyling || styling.focusVisibleStyle.hasCustomStyling || styling.anyLinkStyle.hasCustomStyling || styling.selectedStyle.hasCustomStyling
        || styling.disabledStyle.hasCustomStyling) {
        styling.hasCustomStyling = true;
    }

    applyAccessibilityEnhancements(styling);
}

QColor TBuffer::parseColorValue(const QString& value)
{
    QString cleanValue = value.trimmed().toLower();

    // Handle hex colors: #rrggbb (6-digit) or #rgb (3-digit shorthand)
    // Qt's QColor constructor automatically expands 3-digit format (e.g., #f00 -> #ff0000)
    if (cleanValue.startsWith('#')) {
        QColor color(cleanValue);

        if (color.isValid()) {
            return color;
        }
    }

    // Handle named colors
    QColor namedColor(cleanValue);

    if (namedColor.isValid()) {
        return namedColor;
    }

    // Handle rgb() format: rgb(255, 0, 0) or rgb(100%, 0%, 0%)
    // Supports both integer (0-255) and percentage (0%-100%) values
    // Spaces after commas are optional: rgb(255,0,0) works too
    if (cleanValue.startsWith("rgb(") && cleanValue.endsWith(')')) {
        QString rgbContent = cleanValue.mid(4, cleanValue.length() - 5);
        QStringList components = rgbContent.split(',');

        if (components.size() == 3) {
            auto parseComponent = [](const QString& comp, bool& ok) -> int {
                QString trimmed = comp.trimmed();

                if (trimmed.endsWith('%')) {
                    // Percentage value: convert 0-100% to 0-255
                    trimmed.chop(1); // Remove '%'
                    double percent = trimmed.toDouble(&ok);

                    if (ok && percent >= 0.0 && percent <= 100.0) {
                        return qRound(percent * 2.55);
                    }

                    return -1;
                } else {
                    // Integer value: 0-255
                    int value = trimmed.toInt(&ok);

                    if (ok && value >= 0 && value <= 255) {
                        return value;
                    }
                    return -1;
                }
            };

            bool ok1, ok2, ok3;
            int r = parseComponent(components[0], ok1);
            int g = parseComponent(components[1], ok2);
            int b = parseComponent(components[2], ok3);

            if (ok1 && ok2 && ok3 && r >= 0 && g >= 0 && b >= 0) {
                return QColor(r, g, b);
            }
        }
    }

    return QColor(); // Invalid color
}

void TBuffer::resetColors()
{
    Host* pHost = mpHost;
    if (!pHost) {
        qWarning() << "TBuffer::resetColors(...) ERROR - Called when mpHost pointer is nullptr";
        return;
    }

    // These should match the corresponding settings in
    // dlgProfilePreferences::slot_resetColors() :
    pHost->mBlack = Qt::black;
    pHost->mLightBlack = Qt::darkGray;
    pHost->mRed = Qt::darkRed;
    pHost->mLightRed = Qt::red;
    pHost->mGreen = Qt::darkGreen;
    pHost->mLightGreen = Qt::green;
    pHost->mBlue = Qt::darkBlue;
    pHost->mLightBlue = Qt::blue;
    pHost->mYellow = Qt::darkYellow;
    pHost->mLightYellow = Qt::yellow;
    pHost->mCyan = Qt::darkCyan;
    pHost->mLightCyan = Qt::cyan;
    pHost->mMagenta = Qt::darkMagenta;
    pHost->mLightMagenta = Qt::magenta;
    pHost->mWhite = Qt::lightGray;
    pHost->mLightWhite = Qt::white;

    // This will refresh the "main" console as it is only this class instance
    // associated with that one that will call this method from the
    // decodeOSC(...) method:
    if (pHost->mpConsole) {
        pHost->mpConsole->changeColors();
    }

    // Also need to update the Lua sub-system's "color_table"
    pHost->updateAnsi16ColorsInTable();
}

void TBuffer::append(const QString& text, int sub_start, int sub_end, const TChar& format, int linkID)
{
    append(text, sub_start, sub_end, format.mFgColor, format.mBgColor, format.mFlags, linkID);
}

void TBuffer::appendFormatted(const QString& text, const std::deque<TChar>& formatting, const TLinkStore& sourceLinkStore)
{
    if (text.isEmpty()) {
        return;
    }

    // Ensure we have a line to append to BEFORE computing line indices
    if (buffer.empty()) {
        appendEmptyLine();
    }

    // Check for text/formatting size mismatch - this is a programming error
    if (text.size() != static_cast<qsizetype>(formatting.size())) {
        qWarning() << "TBuffer::appendFormatted: text size" << text.size() << "differs from formatting size" << formatting.size()
                   << "- using longer length with default formatting for missing entries";
    }

    const int lastLineBeforeWrap = buffer.size() - 1;
    const int lastLineLength = lineBuffer.at(lastLineBeforeWrap).size();

    bool firstChar = lineBuffer.back().isEmpty();
    int oldSourceLinkId = 0;
    int destLinkId = 0;
    const qsizetype length = std::max(text.size(), static_cast<qsizetype>(formatting.size()));
    const TChar defaultChar;

    for (qsizetype i = 0; i < length; ++i) {
        if (i >= text.size()) {
            break;
        }

        const QChar ch = text.at(i);
        if (ch == QChar::LineFeed) {
            firstChar = true;
            appendEmptyLine();
            continue;
        }

        const TChar& srcChar = (i < static_cast<qsizetype>(formatting.size())) ? formatting.at(i) : defaultChar;

        const int sourceLinkId = srcChar.linkIndex();
        if (sourceLinkId && (oldSourceLinkId != sourceLinkId)) {
            destLinkId = mLinkStore.addLinks(sourceLinkStore.getLinksConst(sourceLinkId), sourceLinkStore.getHintsConst(sourceLinkId), mpHost);
            mLinkStore.setStyling(destLinkId, sourceLinkStore.getStyling(sourceLinkId));
            oldSourceLinkId = sourceLinkId;
        } else if (!sourceLinkId) {
            destLinkId = 0;
        }

        lineBuffer.back().append(ch);
        TChar destChar(srcChar);
        destChar.mLinkIndex = destLinkId;
        buffer.back().push_back(destChar);

        if (firstChar) {
            timeBuffer.back() = QTime::currentTime().toString(mudlet::smTimeStampFormat);
            firstChar = false;
        }
    }

    appendEmptyLine();

    if (lastLineLength == lineBuffer.at(lastLineBeforeWrap).size()) {
        log(lastLineBeforeWrap, lastLineBeforeWrap);
        wrapLine(lastLineBeforeWrap + 1, mWrapAt, mWrapIndent, mWrapHangingIndent);
    } else {
        wrapLine(lastLineBeforeWrap, mWrapAt, mWrapIndent, mWrapHangingIndent);
    }

    if (static_cast<int>(buffer.size()) > mLinesLimit) {
        shrinkBuffer();
    }

    if (!mpConsole.isNull()) {
        mpConsole->handleLinesOverflowEvent(lineBuffer.size());
    }
}

void TBuffer::append(const QString& text, int sub_start, int sub_end, const QColor& fgColor, const QColor& bgColor, TChar::AttributeFlags flags, int linkID)
{
    const int lastLineBeforeWrap = buffer.size() - 1;
    const int lastLineLength = lineBuffer.at(lastLineBeforeWrap).size();
    appendLine(text, sub_start, sub_end, fgColor, bgColor, flags, linkID);
    if (text.isEmpty()) {
        return;
    }
    // optimization: if the lastLine length hasn't changed,
    // skip it and wrap subsequent lines
    if (lastLineLength == lineBuffer.at(lastLineBeforeWrap).size()) {
        log(lastLineBeforeWrap, lastLineBeforeWrap);
        wrapLine(lastLineBeforeWrap + 1, mWrapAt, mWrapIndent, mWrapHangingIndent);
    } else {
        wrapLine(lastLineBeforeWrap, mWrapAt, mWrapIndent, mWrapHangingIndent);
    }
    if (static_cast<int>(buffer.size()) > mLinesLimit) {
        shrinkBuffer();
    }
    // Whilst shrinkBuffer() is used when the buffer exceeds a user defined
    // limit to prevent it growing beyond a "reasonable" size we also
    // want to check - for TConsoles that have been set to be "non-scrollable"
    // - that the content has not exceeded the number of lines that can be
    // shown in the upper pane and to raise an event if it has
    if (!mpConsole.isNull()) {
        mpConsole->handleLinesOverflowEvent(lineBuffer.size());
    }
}

void TBuffer::appendLine(const QString& text, const int sub_start, const int sub_end, const QColor& fgColor, const QColor& bgColor, const TChar::AttributeFlags flags, const int linkID)
{
    if (sub_end < 0) {
        return;
    }

    // Use a 1-second debounce to prevent duplicate injection from echo + server response
    if (text.contains("!osc8-docs")) {
        const qint64 now = QDateTime::currentMSecsSinceEpoch();
        TBuffer& mainBuffer = mpHost->mpConsole->buffer;

        if (now - mainBuffer.mLastOSC8DocsInjectionTime > 1000) {
#if defined(DEBUG_OSC_PROCESSING)
            qDebug() << "[OSC] Documentation examples trigger phrase detected";
#endif
            mainBuffer.mLastOSC8DocsInjectionTime = now;
            mainBuffer.injectOSC8DocumentationExamples();
        }
        return; // Don't display the trigger phrase itself
    }

    int lastLine = buffer.size() - 1;

    if (Q_UNLIKELY(lastLine < 0)) {
        // There are NO lines in the buffer - so initialize with a new empty line
        appendEmptyLine();
        lastLine = 0;
        // The ternary operator is used here to set/reset only the TChar::Echo bit in the flags:
        const TChar styling(fgColor, bgColor, (mEchoingText ? (TChar::Echo | (flags & TChar::TestMask)) : (flags & TChar::TestMask)));
        buffer.back().push_back(styling);
    }

    if (text.isEmpty()) {
        return;
    }

    bool firstChar = (lineBuffer.back().isEmpty());
    const int length = std::min(static_cast<int>(text.size()), MAX_CHARACTERS_PER_ECHO);
    int lineEndPos = sub_end;

    if (lineEndPos >= length) {
        lineEndPos = text.size() - 1;
    }

    for (int i = sub_start; i <= (sub_start + lineEndPos); i++) {
        const QChar thisChar = text.at(i);

        if (thisChar == QChar::LineFeed) {
            firstChar = true;
            appendEmptyLine();
            continue;
        }

        lineBuffer.back().append(thisChar);
        const TChar styling(fgColor, bgColor, (mEchoingText ? (TChar::Echo | flags) : flags), linkID);
        buffer.back().push_back(styling);

        // Note: Original character storage for ANSI-styled OSC 8 links happens in
        // translateToPlainText() where the TChar is created with ANSI formatting
        // before JSON styling is applied

        if (firstChar) {
            timeBuffer.back() = QTime::currentTime().toString(mudlet::smTimeStampFormat);
            firstChar = false;
        }
    }
}

void TBuffer::appendEmptyLine()
{
    std::deque<TChar> const newLine;
    buffer.push_back(newLine);
    lineBuffer.push_back(QString());
    timeBuffer << QTime::currentTime().toString(mudlet::smTimeStampFormat);
    promptBuffer << false;
}

// This was called "insert" but that is commonly used for built in methods and
// it makes it harder to pick out usages of this specific method:
bool TBuffer::insertInLine(QPoint& P, const QString& text, const TChar& format)
{
    if (text.isEmpty()) {
        return false;
    }
    const int x = P.x();
    const int y = P.y();
    if ((y >= 0) && (y < static_cast<int>(buffer.size()))) {
        if (x < 0) {
            return false;
        }
        if (x >= static_cast<int>(buffer.at(y).size())) {
            TChar c(mpConsole);
            expandLine(y, x - buffer.at(y).size(), c);
        }
        for (int i = 0, total = text.size(); i < total; ++i) {
            lineBuffer[y].insert(x + i, text.at(i));
            const TChar c = format;
            auto it = buffer[y].begin();
            buffer[y].insert(it + x + i, c);
        }
    } else {
        appendLine(text, 0, text.size(), format.mFgColor, format.mBgColor, format.mFlags);
    }
    return true;
}

// This is very poorly designed as P2 is used to determine the last character to
// copy BUT no consideration is given to P2.y() != p1.y() i.e. a copy of more
// than a single line - and it copies a single QChar at a time....
TBuffer TBuffer::copy(QPoint& P1, QPoint& P2)
{
    TBuffer slice(mpHost);
    slice.clear();
    const int y = P1.y();
    int x = P1.x();
    if (y < 0 || y >= static_cast<int>(buffer.size())) {
        return slice;
    }

    // Ensure x starts within the valid range, and adjust P2.x() if it's out of bounds
    if (x < 0 || x >= static_cast<int>(buffer.at(y).size())) {
        x = 0; // Reset x to start of line if out of bounds
    }
    int P2x_corrected = std::min(P2.x(), static_cast<int>(buffer.at(y).size()) - 1); // Correct P2.x() to prevent out-of-bounds

    int oldLinkId{}, id{};
    for (; x <= P2x_corrected; ++x) {
        const int linkId = buffer.at(y).at(x).linkIndex();
        if (linkId && (linkId != oldLinkId)) {
            id = slice.mLinkStore.addLinks(mLinkStore.getLinksConst(linkId), mLinkStore.getHintsConst(linkId), mpHost);
            if (mLinkStore.hasStyling(linkId)) {
                slice.mLinkStore.setStyling(id, mLinkStore.getStyling(linkId));
            }
            oldLinkId = linkId;
        }

        if (!linkId) {
            id = 0;
        }
        const QString s(lineBuffer.at(y).at(x));
        slice.append(s, 0, 1, buffer.at(y).at(x).mFgColor, buffer.at(y).at(x).mBgColor, buffer.at(y).at(x).mFlags, id);
    }
    return slice;
}

// This is constrained to P1.y() == P2.y()....
TBuffer TBuffer::cut(QPoint& P1, QPoint& P2)
{
    TBuffer slice = copy(P1, P2);
    TChar format(mpConsole);
    replaceInLine(P1, P2, QString(), format);
    return slice;
}

// This only copies the first line of chunk's contents:
void TBuffer::paste(QPoint& P, const TBuffer& chunk)
{
    const bool needAppend = false;
    bool hasAppended = false;
    int y = P.y();
    const int x = P.x();
    if (chunk.buffer.empty()) {
        return;
    }
    if (y < 0 || y > getLastLineNumber()) {
        y = getLastLineNumber();
    }
    // FIXME: RISK OF EXCEPTION getLastLineNumber() returns zero (not -1) if
    // the buffer is empty, so y can never be less than zero here - however that
    // will cause an exception with std::deque::at(size_t) - previously
    // std::deque::operator[size_t] was used and that exhibits UNDEFINED
    // BEHAVIOUR in the same situation:
    if (x < 0 || x >= static_cast<int>(buffer.at(y).size())) {
        return;
    }

    for (int cx = 0, total = static_cast<int>(chunk.buffer.at(0).size()); cx < total; ++cx) {
        // This is rather inefficient as s is only ever one QChar long
        QPoint P_current(cx, y);
        if ((y < getLastLineNumber()) && (!needAppend)) {
            const TChar& format = chunk.buffer.at(0).at(cx);
            const QString s = QString(chunk.lineBuffer.at(0).at(cx));
            insertInLine(P_current, s, format);
        } else {
            hasAppended = true;
            const QString s(chunk.lineBuffer.at(0).at(cx));
            append(s, 0, 1, chunk.buffer.at(0).at(cx).mFgColor, chunk.buffer.at(0).at(cx).mBgColor, chunk.buffer.at(0).at(cx).mFlags);
        }
    }

    if (hasAppended && y != -1) {
        TChar format(mpConsole);
        wrapLine(y, mWrapAt, mWrapIndent, mWrapHangingIndent);
    }
}

// This only appends the FIRST line of chunk:
void TBuffer::appendBuffer(const TBuffer& chunk)
{
    if (chunk.buffer.empty()) {
        return;
    }
    int oldLinkId{}, id{};
    for (int cx = 0, total = static_cast<int>(chunk.buffer.at(0).size()); cx < total; ++cx) {
        const int linkId = chunk.buffer.at(0).at(cx).linkIndex();
        if (linkId && (oldLinkId != linkId)) {
            id = mLinkStore.addLinks(chunk.mLinkStore.getLinksConst(linkId), chunk.mLinkStore.getHintsConst(linkId), mpHost);
            if (chunk.mLinkStore.hasStyling(linkId)) {
                mLinkStore.setStyling(id, chunk.mLinkStore.getStyling(linkId));
            }
            oldLinkId = linkId;
        }
        if (!linkId) {
            id = 0;
        }
        const QString s(chunk.lineBuffer.at(0).at(cx));
        append(s, 0, 1, chunk.buffer.at(0).at(cx).mFgColor, chunk.buffer.at(0).at(cx).mBgColor, chunk.buffer.at(0).at(cx).mFlags, id);
    }

    append(QString(QChar::LineFeed), 0, 1, Qt::black, Qt::black, TChar::None);
}

int TBuffer::calculateWrapPosition(int lineNumber, int begin, int end)
{
    const QString lineBreaks = qsl("- \n");
    if (lineBuffer.size() < lineNumber) {
        return 0;
    }
    const int lineSize = static_cast<int>(lineBuffer[lineNumber].size()) - 1;
    if (lineSize < end) {
        end = lineSize;
    }
    const auto line = lineBuffer[lineNumber];
    for (int i = end; i >= begin; --i) {
        if (lineBreaks.indexOf(line.at(i)) > -1) {
            return i;
        }
    }

    return lineSize;
}

int TBuffer::skipSpacesAtBeginOfLine(const int row, const int column)
{
    int offset = 0;
    int position = column;
    const int endOfLinePosition = lineBuffer.at(row).size();
    while (position < endOfLinePosition) {
        if (buffer.at(row).at(position).mFlags & TChar::Echo) {
            break;
        }
        if (lineBuffer.at(row).at(position) == QChar::Space) {
            ++offset;
        } else {
            break;
        }
        position++;
    }
    return offset;
}

// find lindbreaks and indents (if not necessary, return empty list)
inline QList<WrapInfo> TBuffer::getWrapInfo(const QString& lineText, bool isNewline, const int maxWidth, const int indent, const int hangingIndent)
{
    QList<WrapInfo> output;
    if (lineText.isEmpty()) {
        return output;
    }

    // Safety check: during destruction, mpHost might be null
    if (!mpHost) {
        // Return a simple wrap info without character width calculations
        output.append(WrapInfo(isNewline, false, 0, lineText.length()));
        return output;
    }

    QTextBoundaryFinder boundaryFinder(QTextBoundaryFinder::Grapheme, lineText);
    QTextBoundaryFinder lineBreakFinder(QTextBoundaryFinder::Line, lineText);
    int xPos = 0;
    int totalWidth = 0;
    int firstChar = 0;
    bool needsIndent = isNewline;
    bool hasNewline = false;

    // find all the appropriate wrap points assuming (hanging-)indentation prepended to each line
    for (int indexOfChar = 0, total = lineText.size(); indexOfChar < total and indexOfChar >= 0;) {
        const QChar c = lineText.at(indexOfChar);
        // skip leading spaces for any wrapped lines
        if (xPos == 0 and !isNewline and !output.isEmpty() and c == QChar::Space) {
            indexOfChar++;
            firstChar = indexOfChar;
            boundaryFinder.setPosition(indexOfChar);
            continue;
        }
        // handle embedded linefeed
        if (c == QChar::LineFeed) {
            hasNewline = true;
            output.append(WrapInfo(isNewline, needsIndent, firstChar, indexOfChar));
            indexOfChar++;
            boundaryFinder.setPosition(indexOfChar);
            firstChar = indexOfChar;
            isNewline = true;
            needsIndent = false;
            xPos = 0;
            continue;
        }
        int nextBoundary = boundaryFinder.toNextBoundary();
        const QString grapheme = lineText.mid(indexOfChar, nextBoundary - indexOfChar);
        const uint unicode = graphemeInfo::getBaseCharacter(grapheme);
        // Safety check: during destruction, mpHost might be null
        const int charWidth = mpHost ? graphemeInfo::getWidth(unicode, mpHost->wideAmbiguousEAsianGlyphs()) : graphemeInfo::getWidth(unicode, false);
        const int indentationHere = isNewline ? indent : hangingIndent;
        if (xPos + charWidth > maxWidth - (needsIndent ? indentationHere : 0)) {
            if (isNewline) {
                needsIndent = true;
            }
            lineBreakFinder.setPosition(indexOfChar);
            // we check c == QChar::Space since we are happy to break at -any- space,
            // unlike the indirect-linebreak permission of QTextBoundaryFinder::Line
            // (see: https://www.unicode.org/reports/tr14/#LD9) which will only break at
            // at the first char after 1+ space(s)
            const int firstNonIndentChar = firstChar + (needsIndent ? 0 : indentationHere);
            if (c == QChar::Space or lineBreakFinder.isAtBoundary() or lineBreakFinder.toPreviousBoundary() <= firstNonIndentChar) {
                boundaryFinder.setPosition(indexOfChar);
                output.append(WrapInfo(isNewline, needsIndent, firstChar, indexOfChar));
            } else {
                indexOfChar = lineBreakFinder.position();
                nextBoundary = lineBreakFinder.position();
                boundaryFinder.setPosition(nextBoundary);
                output.append(WrapInfo(isNewline, needsIndent, firstChar, indexOfChar));
            }
            isNewline = false;
            needsIndent = true;
            xPos = 0;
            firstChar = indexOfChar;
            continue;
        }
        xPos += charWidth;
        totalWidth += charWidth;
        indexOfChar = nextBoundary;
    }
    // it's possible that no wrapping is needed
    if (totalWidth <= mWrapAt && !hasNewline) {
        output.clear();
        return output;
    }
    // if the line has been wrapped, append last remaining bit
    if (!output.isEmpty()) {
        output.append(WrapInfo(isNewline, !isNewline, firstChar, lineText.size()));
    }
    return output;
}

// This only works on the Main Console for a profile
void TBuffer::log(int fromLine, int toLine)
{
    if (mpHost.isNull()) {
        return;
    }

    TBuffer* pB = &mpHost->mpConsole->buffer;
    if (pB != this || !mpHost->mpConsole->mLogToLogFile) {
        return;
    }

    if (fromLine >= size() || fromLine < 0) {
        return;
    }
    if (toLine >= size()) {
        toLine = size() - 1;
    }
    if (toLine < 0) {
        return;
    }

    // if we've been called to log the same line - which can happen when the user
    // enters a command after in-game text - then skip recording the last line
    if (fromLine != lastLoggedFromLine && toLine != lastloggedToLine) {
        mpHost->mpConsole->mLogStream << lastTextToLog;
        mpHost->mpConsole->mLogStream.flush();
    }

    QStringList linesToLog;
    for (int i = fromLine; i <= toLine; ++i) {
        if (mpHost->mIsCurrentLogFileInHtmlFormat) {
            // This only handles a single line of logged text at a time:
            linesToLog << bufferToHtml(mpHost->mIsLoggingTimestamps, i);
        } else {
            linesToLog << ((mpHost->mIsLoggingTimestamps && !timeBuffer.at(i).isEmpty()) ? timeBuffer.at(i).left(mudlet::smTimeStampFormat.length()) : QString()) % lineBuffer.at(i) % QChar::LineFeed;
        }
    }

    // record the last log call into a temporary buffer - we'll actually log
    // on the next iteration after duplication detection has run
    lastTextToLog = linesToLog.join(QString());
    lastLoggedFromLine = fromLine;
    lastloggedToLine = toLine;
}

// logs the remaining output when logging gets stopped, without duplication checks
void TBuffer::logRemainingOutput()
{
    mpHost->mpConsole->mLogStream << lastTextToLog;
    mpHost->mpConsole->mLogStream.flush();
}

// logs a string directly to the log file
void TBuffer::appendLog(const QString& text)
{
    TBuffer* pB = &mpHost->mpConsole->buffer;
    if (pB != this || !mpHost->mpConsole->mLogToLogFile) {
        return;
    }

    mpHost->mpConsole->mLogStream << text;
}

// returns how many new lines have been inserted by the wrapping action
int TBuffer::wrapLine(int startLine, int maxWidth, int indentSize, int hangingIndentSize)
{
    if (static_cast<int>(buffer.size()) < startLine || startLine < 0) {
        return 0;
    }

    std::queue<std::deque<TChar>> queue;
    QStringList tempList;
    QStringList timeList;
    QList<bool> promptList;
    int lineCount = 0;
    // consider moving this upstream and returning an error if you try to set indentation higher than wrapWidth
    const int indent = (indentSize < maxWidth) ? indentSize : 0;
    const int hangingIndent = (hangingIndentSize < maxWidth) ? hangingIndentSize : 0;
    for (int i = startLine, total = static_cast<int>(buffer.size()); i < total; ++i) {
        lineCount++;
        std::deque<TChar> newBufferLine;
        QString newLineText;
        const QString time = timeBuffer[i];
        // trivial case
        if (buffer[i].empty()) {
            tempList.append(newLineText);
            queue.push(newBufferLine);
            timeList.append(time);
            promptList.append(false);
            continue;
        }

        // consider wrapping line
        int newBufferCharPosition = 0;
        const bool isPrompt = promptBuffer[i];
        const QString lineText = lineBuffer[i];
        // a blank timestamp indicates a wrapped line
        const bool isNewline = (time != mudlet::smBlankTimeStamp);
        QList<WrapInfo> lineBreaks = getWrapInfo(lineText, isNewline, maxWidth, indent, hangingIndent);
        if (lineBreaks.isEmpty()) {
            tempList.append(lineText);
            queue.push(buffer[i]);
            timeList.append(time);
            promptList.append(isPrompt);
            continue;
        }
        const QString qIndent(indent, QChar::Space);
        const QString qHangingIndent(hangingIndent, QChar::Space);
        for (const WrapInfo w : std::as_const(lineBreaks)) {
            // skip TChars as needed
            while (newBufferCharPosition < w.firstChar && !buffer[i].empty()) {
                buffer[i].pop_front();
                newBufferCharPosition++;
            }
            if (w.needsIndent && !buffer[i].empty()) {
                // background color of indentation spaces should match first char in the line
                const TChar indentSpace = buffer[i].front();
                // add indentation to TChar buffer and newLineText
                if (w.isNewline) {
                    for (int i = 0; i < indent; ++i) {
                        newBufferLine.push_front(indentSpace);
                    }
                    newLineText.append(qIndent);
                } else {
                    for (int i = 0; i < hangingIndent; ++i) {
                        newBufferLine.push_front(indentSpace);
                    }
                    newLineText.append(qHangingIndent);
                }
            }
            // append TChars of the wrapped lineText to TChar buffer
            while (newBufferCharPosition < w.lastChar && !buffer[i].empty()) {
                newBufferLine.push_back(buffer[i].front());
                buffer[i].pop_front();
                newBufferCharPosition++;
            }
            // everything else
            newLineText.append(lineText.mid(w.firstChar, w.lastChar - w.firstChar));
            tempList.append(newLineText);
            if (w.isNewline) {
                timeList.append(time);
            } else {
                timeList.append(mudlet::smBlankTimeStamp);
            }
            queue.push(newBufferLine);
            promptList.append(isPrompt);
            newBufferLine.clear();
            newLineText = QString();
        }
    }
    for (int i = 0; i < lineCount; ++i) {
        buffer.pop_back();
        lineBuffer.pop_back();
        timeBuffer.pop_back();
        promptBuffer.pop_back();
    }

    const int insertedLines = queue.size() - 1;
    for (int i = 0; i <= insertedLines; ++i) {
        if (tempList[i].isEmpty()) {
            queue.pop();
            appendEmptyLine();
        } else {
            buffer.push_back(queue.front());
            queue.pop();
            lineBuffer.append(tempList[i]);
            timeBuffer.append(timeList[i]);
            promptBuffer.push_back(promptList[i]);
        }
    }

    if (insertedLines > 0) {
        // log all lines but the last one (in case further text is appended later)
        log(startLine, startLine + insertedLines - 1);
        return insertedLines;
    }
    return 0;
}

bool TBuffer::moveCursor(QPoint& where)
{
    const int x = where.x();
    const int y = where.y();
    if (y < 0) {
        return false;
    }
    if (y >= static_cast<int>(buffer.size())) {
        return false;
    }

    if (static_cast<int>(buffer[y].size()) - 1 > x) {
        TChar c(mpConsole);
        // CHECKME: should "buffer[cookedY].size() - 1" be bracketed - which would change the -1 to +1 in the following:
        expandLine(y, x - buffer[y].size() - 1, c);
    }
    return true;
}

// Needed, at least, as a filler for missing lines past end of the lineBuffer
// requested by lua function getLines(...):
QString badLineError = qsl("ERROR: invalid line number");

QString& TBuffer::line(int lineNumber)
{
    if ((lineNumber < 0) || (lineNumber >= lineBuffer.size())) {
        return badLineError;
    }
    return lineBuffer[lineNumber];
}

int TBuffer::find(int line, const QString& what, int pos = 0)
{
    if (lineBuffer[line].size() >= pos) {
        return -1;
    }
    if (pos < 0) {
        return -1;
    }
    if ((line >= static_cast<int>(buffer.size())) || (line < 0)) {
        return -1;
    }
    return lineBuffer[line].indexOf(what, pos);
}

QStringList TBuffer::split(int line, const QString& splitter)
{
    if ((line >= static_cast<int>(buffer.size())) || (line < 0)) {
        return QStringList();
    }
    return lineBuffer[line].split(splitter);
}

QStringList TBuffer::split(int line, const QRegularExpression& splitter)
{
    if ((line >= static_cast<int>(buffer.size())) || (line < 0)) {
        return QStringList();
    }
    return lineBuffer[line].split(splitter);
}

void TBuffer::expandLine(int y, int count, TChar& pC)
{
    const int size = buffer[y].size() - 1;
    for (int i = size, total = size + count; i < total; ++i) {
        buffer[y].push_back(pC);
        lineBuffer[y].append(QChar::Space);
    }
}

bool TBuffer::replaceInLine(QPoint& P_begin, QPoint& P_end, const QString& with, TChar& format)
{
    const int x1 = P_begin.x();
    const int x2 = P_end.x();
    const int y1 = P_begin.y();
    const int y2 = P_end.y();
    if ((y1 >= static_cast<int>(buffer.size())) || (y2 >= static_cast<int>(buffer.size()))) {
        return false;
    }
    if ((x2 > static_cast<int>(buffer[y2].size())) || (x1 > static_cast<int>(buffer[y1].size()))) {
        return false;
    }
    if (x1 < 0 || x2 < 0) {
        return false;
    }

    int xb, xe, yb, ye;
    if (y1 <= y2) {
        yb = y1;
        ye = y2;
        xb = x1;
        xe = x2;
    } else {
        yb = y2;
        ye = y1;
        xb = x2;
        xe = x1;
    }

    for (int y = yb; y <= ye; y++) {
        int x = 0;
        if (y == yb) {
            x = xb;
        }
        int x_end = buffer[y].size() - 1;
        if (y == ye) {
            x_end = xe;
        }
        lineBuffer[y].remove(x, x_end - x);
        auto it1 = buffer[y].begin() + x;
        auto it2 = buffer[y].begin() + x_end;
        buffer[y].erase(it1, it2);
    }

    // insert replacement
    insertInLine(P_begin, with, format);
    return true;
}

void TBuffer::clear()
{
    mCurrentHyperlinkCommand.clear();
    mCurrentHyperlinkHint.clear();
    mCurrentHyperlinkLinkId = 0;
    mHyperlinkActive = false;

    while (!buffer.empty()) {
        if (!deleteLines(0, 0)) {
            break;
        }
    }

    // After deleting all lines, clear all links (none are referenced)
    clearLinkState();

    std::deque<TChar> const newLine;
    buffer.push_back(newLine);
    lineBuffer << QString();
    timeBuffer << QString();
    promptBuffer.push_back(false);
}

void TBuffer::clearLinkState()
{
    Host* pH = mpHost;
    const QSet<int> activeLinkIds = collectActiveLinkIds();

    if (pH) {
        mLinkStore.removeUnreferencedLinks(activeLinkIds, pH);
    } else {
        qWarning() << "TBuffer::clearLinkState() WARNING - mpHost is null, cannot remove unreferenced links from store";
    }

    QSet<int> staleIds;

    auto collectStale = [&activeLinkIds, &staleIds](const auto& map) {
        for (auto it = map.constBegin(); it != map.constEnd(); ++it) {
            if (!activeLinkIds.contains(it.key())) {
                staleIds.insert(it.key());
            }
        }
    };

    collectStale(mLinkStates);
    collectStale(mVisitedLinks);
    collectStale(mLinkSelectionState);
    collectStale(mLinkOriginalBackgrounds);
    collectStale(mLinkOriginalCharacters);
    collectStale(mLinkOriginalText);

    for (int key : staleIds) {
        mLinkStates.remove(key);
        mVisitedLinks.remove(key);
        mLinkSelectionState.remove(key);
        mLinkOriginalBackgrounds.remove(key);
        mLinkOriginalCharacters.remove(key);
        mLinkOriginalText.remove(key);
    }

    // Prevent applyPendingSelectionStyling from updating non-existent links
    mPendingSelectionStyling &= activeLinkIds;

    // Reset hover/active/focus state if those links are gone
    if (!activeLinkIds.contains(mCurrentHoveredLinkIndex)) {
        mCurrentHoveredLinkIndex = 0;
    }

    if (!activeLinkIds.contains(mCurrentActiveLinkIndex)) {
        mCurrentActiveLinkIndex = 0;
    }

    if (!activeLinkIds.contains(mCurrentFocusedLinkIndex)) {
        mCurrentFocusedLinkIndex = 0;
    }

    if (!activeLinkIds.contains(mLastClickedLinkIndex)) {
        mLastClickedLinkIndex = 0;
    }
}

QSet<int> TBuffer::collectActiveLinkIds() const
{
    QSet<int> activeLinkIds;

    for (const auto& line : buffer) {
        for (const TChar& tchar : line) {
            int linkId = tchar.linkIndex();

            if (linkId > 0) {
                activeLinkIds.insert(linkId);
            }
        }
    }

    return activeLinkIds;
}

void TBuffer::clearLastLine()
{
    if (!buffer.empty()) {
        buffer.back().clear();
        if (!lineBuffer.isEmpty()) {
            lineBuffer.back().clear();
        }
    }
}

bool TBuffer::deleteLine(int y)
{
    return deleteLines(y, y);
}

void TBuffer::shrinkBuffer()
{
    for (int i = 0; i < mBatchDeleteSize; ++i) {
        lineBuffer.pop_front();
        promptBuffer.pop_front();
        timeBuffer.pop_front();
        buffer.pop_front();
        mCursorY--;
    }
    // We need to adjust the search result line as some lines have now gone
    // away:
    mpConsole->mCurrentSearchResult = qMax(0, mpConsole->mCurrentSearchResult - mBatchDeleteSize);

    // Clean up unreferenced links after removing old lines
    clearLinkState();

    if (mpConsole->getType() & (TConsole::MainConsole | TConsole::UserWindow | TConsole::SubConsole | TConsole::Buffer)) {
        // Signal to lua subsystem that indexes into the Console will need adjusting
        TEvent bufferShrinkEvent{};
        bufferShrinkEvent.mArgumentList.append(QLatin1String("sysBufferShrinkEvent"));
        bufferShrinkEvent.mArgumentTypeList.append(ARGUMENT_TYPE_STRING);
        bufferShrinkEvent.mArgumentList.append(mpConsole->mConsoleName);
        bufferShrinkEvent.mArgumentTypeList.append(ARGUMENT_TYPE_STRING);
        bufferShrinkEvent.mArgumentList.append(QString::number(mBatchDeleteSize));
        bufferShrinkEvent.mArgumentTypeList.append(ARGUMENT_TYPE_NUMBER);
        mpHost->raiseEvent(bufferShrinkEvent);
    }
}

bool TBuffer::deleteLines(int from, int to)
{
    if ((from >= 0) && (from < static_cast<int>(buffer.size())) && (from <= to) && (to < static_cast<int>(buffer.size()))) {
        const int delta = to - from + 1;

        for (int i = from, total = from + delta; i < total; ++i) {
            lineBuffer.removeAt(i);
            timeBuffer.removeAt(i);
            promptBuffer.removeAt(i);
        }

        buffer.erase(buffer.begin() + from, buffer.begin() + to + 1);
        return true;
    }
    return false;
}

bool TBuffer::applyLink(const QPoint& P_begin, const QPoint& P_end, const QStringList& linkFunction, const QStringList& linkHint, QVector<int> luaReference)
{
    const int x1 = P_begin.x();
    const int x2 = P_end.x();
    const int y1 = P_begin.y();
    const int y2 = P_end.y();
    int linkID = 0;

    // clang-format off
    if ((x1 >= 0)
        && ((x2 > x1) || (y2 > y1))
        && ((y2 >= 0) && (y2 < static_cast<int>(buffer.size())))
        && (x1 < static_cast<int>(buffer.at(y1).size()))) {
        // clang-format on

        /*
         * Even if the end selection is out of bounds we still apply the format
         * until the end of the line to simplify and ultimately speed up user
         * scripting (no need to calc end of line) - so we don't use:
         * && ( x2 < static_cast<int>(buffer.at(y2).size()) ) )
         */
        for (int y = y1; y <= y2; ++y) {
            int x = 0;
            if (y == y1) {
                x = x1;
            }
            while (x < static_cast<int>(buffer.at(y).size())) {
                if (y >= y2) {
                    if (x >= x2) {
                        return true;
                    }
                }
                if (linkID == 0) {
                    linkID = mLinkStore.addLinks(linkFunction, linkHint, mpHost, luaReference);
                }
                buffer.at(y).at(x++).mLinkIndex = linkID;
            }
        }
        return true;
    }
    return false;
}

// Replaces (bool)TBuffer::applyXxxx(QPoint& P_begin, QPoint& P_end, bool state)
// where Xxxxx is Bold, Italics, Strikeout, Underline
// Can set multiple attributes to given state
bool TBuffer::applyAttribute(const QPoint& P_begin, const QPoint& P_end, const TChar::AttributeFlags attributes, const bool state)
{
    const int x1 = P_begin.x();
    const int x2 = P_end.x();
    const int y1 = P_begin.y();
    const int y2 = P_end.y();

    // clang-format off
    if ((x1 >= 0)
        && ((x2 > x1) || (y2 > y1))
        && ((y2 >= 0) && (y2 < static_cast<int>(buffer.size())))
            && (x1 < static_cast<int>(buffer[y1].size()))) {
        // clang-format on

        /*
         * Even if the end selection is out of bounds we still apply the format
         * until the end of the line to simplify and ultimately speed up user
         * scripting (no need to calc end of line) - so we don't use:
         * && ( x2 < static_cast<int>(buffer.at(y2).size()) ) )
         */

        for (int y = y1; y <= y2; ++y) {
            int x = 0;
            if (y == y1) {
                x = x1;
            }
            while (x < static_cast<int>(buffer.at(y).size())) {
                if (y >= y2) {
                    if (x >= x2) {
                        return true;
                    }
                }
                buffer.at(y).at(x).mFlags = (buffer.at(y).at(x).mFlags & ~(attributes)) | (state ? attributes : TChar::None);
                ++x;
            }
        }
        return true;
    }
    return false;
}

bool TBuffer::applyFgColor(const QPoint& P_begin, const QPoint& P_end, const QColor& newColor)
{
    const int x1 = P_begin.x();
    const int x2 = P_end.x();
    const int y1 = P_begin.y();
    const int y2 = P_end.y();

    // clang-format off
    if ((x1 >= 0)
        && ((x2 > x1) || (y2 > y1))
        && ((y2 >= 0) && (y2 < static_cast<int>(buffer.size())))
            && (x1 < static_cast<int>(buffer[y1].size()))) {
        // clang-format on

        /*
         * Even if the end selection is out of bounds we still apply the format
         * until the end of the line to simplify and ultimately speed up user
         * scripting (no need to calc end of line) - so we don't use:
         * && ( x2 < static_cast<int>(buffer.at(y2).size()) ) )
         */

        for (int y = y1; y <= y2; ++y) {
            int x = 0;
            if (y == y1) {
                // Override position start column if on first line to given start column
                x = x1;
            }
            while (x < static_cast<int>(buffer.at(y).size())) {
                if (y >= y2 && x >= x2) {
                    // Escape if on or past last line and past last character on the last line
                    return true;
                }

                buffer.at(y).at(x++).mFgColor = newColor;
            }
        }
        return true;
    }
    return false;
}

bool TBuffer::applyBgColor(const QPoint& P_begin, const QPoint& P_end, const QColor& newColor)
{
    const int x1 = P_begin.x();
    const int x2 = P_end.x();
    const int y1 = P_begin.y();
    const int y2 = P_end.y();

    // clang-format off
    if ((x1 >= 0)
        && ((x2 > x1) || (y2 > y1))
        && ((y2 >= 0) && (y2 < static_cast<int>(buffer.size())))
            && (x1 < static_cast<int>(buffer[y1].size()))) {
        // clang-format on

        /*
         * Even if the end selection is out of bounds we still apply the format
         * until the end of the line to simplify and ultimately speed up user
         * scripting (no need to calc end of line) - so we don't use:
         * && ( x2 < static_cast<int>(buffer.at(y2).size()) ) )
         */

        for (int y = y1; y <= y2; ++y) {
            int x = 0;
            if (y == y1) {
                // Override position start column if on first line to given start column
                x = x1;
            }
            while (x < static_cast<int>(buffer.at(y).size())) {
                if (y >= y2 && x >= x2) {
                    // Escape if on or past last line and on or past last character on last line
                    return true;
                }

                buffer.at(y).at(x++).mBgColor = newColor;
            }
        }
        return true;
    }
    return false;
}

QStringList TBuffer::getEndLines(int n)
{
    QStringList linesList;
    for (int i = getLastLineNumber() - n, total = getLastLineNumber(); i < total; ++i) {
        linesList << line(i);
    }
    return linesList;
}

// This actually only works on a SINGLE line at a time - so was restuctured to
// reflect that in the arguments needed - with sensible defaults on all
// arguments - the positions within the line refer to raw QChar/TChar indexes
// and not graphemes, it is up to the caller to ensure those indexes are useful
// this method only checks that they fit.
// Note: spacePadding is expected to be non-zero on ONLY the first call to this
// method - it is needed to pad the first line out when the first line of a
// selection is not a complete line of text and there are more lines to follow
QString TBuffer::bufferToHtml(const bool showTimeStamp /*= false*/, const int row /*= -1*/, const int endColumn /*= -1*/, const int startColumn /*= 0*/, int spacePadding /*= 0*/)
{
    int pos = startColumn;
    QString s;
    if (row < 0 || row >= static_cast<int>(buffer.size())) {
        // Empty string
        return s;
    }

    // std:deque uses std::deque:size_type as index type which is unsigned,
    // but row (and pos) are signed ints...!
    auto cookedRow = static_cast<size_t>(row);

    if ((pos < 0) || (pos >= static_cast<int>(buffer.at(cookedRow).size()))) {
        pos = 0;
    }

    int lastPos = endColumn;
    if (lastPos < 0 || lastPos > static_cast<int>(buffer.at(cookedRow).size())) {
        // lastPos is now at ONE PAST the last valid one to use to index into
        // row - this can have been triggered by a -1 argument
        lastPos = static_cast<int>(buffer.at(cookedRow).size());
    }

    TChar::AttributeFlags currentFlags = TChar::None;
    QColor currentFgColor(Qt::black);
    QColor currentBgColor(Qt::black);
    int currentLinkIndex = 0;
    // This combination of color values (black on black) cannot usefully be used in practice
    // - so use as initialization values

    // Assume we are on the first line until told otherwise - and we will need
    // to NOT close a previous <span ...>:
    bool firstSpan = true;
    // If times stamps are to be shown AND the first line is a partial
    // then we need:
    // <span timestamp format>Timestamp (13 chars)</span><span default>___padding spaces___</span><span first chunk style>first chunk...
    // we will NOT need a closing "</span>"
    if (showTimeStamp && !timeBuffer.at(row).isEmpty()) {
        // TODO: formatting according to TTextEdit.cpp: if( i2 < timeOffset ) - needs updating if we allow the colours to be user set:
        s.append(qsl("<span style=\"color: rgb(200,150,0); background: rgb(22,22,22); \">%1").arg(timeBuffer.at(row).left(mudlet::smTimeStampFormat.length())));
        // Set the current idea of what the formatting is so we can spot if it
        // changes:
        currentFgColor = QColor(200, 150, 0);
        currentBgColor = QColor(22, 22, 22);
        currentFlags = TChar::None;
        // We are no longer before the first span - so we need to flag that
        // there will be one to close:
        firstSpan = false;
    }

    if (spacePadding > 0) {
        // used for "copy HTML", this is the first line of selection (because of
        // the padding needed)
        if (firstSpan) {
            // Must skip the close of the preceding span as there isn't one
            firstSpan = false;
        } else {
            s.append(QLatin1String("</span>"));
        }

        // Pad out with spaces to the right so a partial first line lines up
        s.append(qsl("<span>%1").arg(QString(spacePadding, QChar::Space)));
    }

    for (auto cookedPos = static_cast<size_t>(pos); pos < lastPos; ++cookedPos, ++pos) {
        const int charLinkIndex = buffer.at(cookedRow).at(cookedPos).linkIndex();
        // Do we need to start a new span?
        if (firstSpan || buffer.at(cookedRow).at(cookedPos).mFgColor != currentFgColor || buffer.at(cookedRow).at(cookedPos).mBgColor != currentBgColor
            || (buffer.at(cookedRow).at(cookedPos).mFlags & TChar::TestMask) != currentFlags || charLinkIndex != currentLinkIndex) {
            if (firstSpan) {
                firstSpan = false; // The first span - won't need to close the previous one
            } else {
                s.append(QLatin1String("</span>"));
            }
            currentFgColor = buffer.at(cookedRow).at(cookedPos).mFgColor;
            currentBgColor = buffer.at(cookedRow).at(cookedPos).mBgColor;
            currentFlags = buffer.at(cookedRow).at(cookedPos).mFlags & TChar::TestMask;
            currentLinkIndex = charLinkIndex;

            // clang-format off
            // Determine blink class if any (only when blink is enabled in settings)
            QString blinkClass;
            const bool enableBlink = (mpHost != nullptr) && mpHost->getEnableBlinkText();

            if (enableBlink) {
                if (currentFlags & TChar::FastBlink) {
                    blinkClass = qsl(" class='blink-fast'");
                } else if (currentFlags & TChar::Blink) {
                    blinkClass = qsl(" class='blink-slow'");
                }
            }

            // Build text-decoration CSS including decoration colors from TLinkStore
            QString textDecorationCss;
            if (currentFlags & (TChar::Underline | TChar::StrikeOut | TChar::Overline)) {
                QString decorations;
                if (currentFlags & TChar::Underline) {
                    decorations.append(QLatin1String(" underline"));
                }
                if (currentFlags & TChar::StrikeOut) {
                    decorations.append(QLatin1String(" line-through"));
                }
                if (currentFlags & TChar::Overline) {
                    decorations.append(QLatin1String(" overline"));
                }
                textDecorationCss = qsl(" text-decoration:%1;").arg(decorations);

                // Look up decoration color from TLinkStore if available
                if (currentLinkIndex > 0 && mLinkStore.hasStyling(currentLinkIndex)) {
                    Mudlet::HyperlinkStyling styling = mLinkStore.getStyling(currentLinkIndex);
                    styling.currentState = getLinkState(currentLinkIndex);
                    Mudlet::HyperlinkStyling::StateStyle effectiveStyle = styling.getEffectiveStyle();

                    // CSS text-decoration-color applies a single color to all
                    // decoration lines, so pick the most visually prominent one
                    // (underline > strikeout > overline). The render path in
                    // drawCustomDecorations can apply each color independently.
                    QColor decorationColor;
                    bool hasDecorationColor = false;
                    if (effectiveStyle.hasUnderlineColor && (currentFlags & TChar::Underline)) {
                        decorationColor = effectiveStyle.underlineColor;
                        hasDecorationColor = true;
                    } else if (effectiveStyle.hasStrikeoutColor && (currentFlags & TChar::StrikeOut)) {
                        decorationColor = effectiveStyle.strikeoutColor;
                        hasDecorationColor = true;
                    } else if (effectiveStyle.hasOverlineColor && (currentFlags & TChar::Overline)) {
                        decorationColor = effectiveStyle.overlineColor;
                        hasDecorationColor = true;
                    }

                    if (hasDecorationColor) {
                        textDecorationCss.append(qsl(" text-decoration-color: rgb(%1,%2,%3);")
                            .arg(QString::number(decorationColor.red()),
                                 QString::number(decorationColor.green()),
                                 QString::number(decorationColor.blue())));
                    }
                }
            }

            if (currentFlags & TChar::Reverse) {
                // Swap the fore and background colours:
                s.append(qsl("<span%9 style=\"color: rgb(%1,%2,%3); background: rgb(%4,%5,%6);%7%8")
                         .arg(QString::number(currentBgColor.red()), QString::number(currentBgColor.green()), QString::number(currentBgColor.blue()), // args 1 to 3
                              QString::number(currentFgColor.red()), QString::number(currentFgColor.green()), QString::number(currentFgColor.blue()), // args 4 to 6
                              currentFlags & TChar::Bold ? QLatin1String(" font-weight: bold;") : QString(), // arg 7
                              currentFlags & TChar::Italic ? QLatin1String(" font-style: italic;") : QString(), // arg 8
                              blinkClass) // arg 9
                         + textDecorationCss
                         + qsl("\">"));
            } else {
                s.append(qsl("<span%9 style=\"color: rgb(%1,%2,%3); background: rgb(%4,%5,%6);%7%8")
                         .arg(QString::number(currentFgColor.red()), QString::number(currentFgColor.green()), QString::number(currentFgColor.blue()), // args 1 to 3
                              QString::number(currentBgColor.red()), QString::number(currentBgColor.green()), QString::number(currentBgColor.blue()), // args 4 to 6
                              currentFlags & TChar::Bold ? QLatin1String(" font-weight: bold;") : QString(), // arg 7
                              currentFlags & TChar::Italic ? QLatin1String(" font-style: italic;") : QString(), // arg 8
                              blinkClass) // arg 9
                         + textDecorationCss
                         + qsl("\">"));
            }
            // clang-format on
        }
        if (lineBuffer.at(row).at(pos) == QChar('<')) {
            s.append(QLatin1String("&lt;"));
        } else if (lineBuffer.at(row).at(pos) == QChar('>')) {
            s.append("&gt;");
        } else {
            s.append(lineBuffer.at(row).at(pos));
        }
    }
    if (!s.isEmpty()) {
        s.append(QLatin1String("</span>"));
        // Needed to balance the very first open <span>, but only if we have
        // included anything. the previously appearing <br /> is an XML tag, NOT
        // a (strict) HTML 4 one
    }

    s.append(QLatin1String("<br>\n"));
    // Needed to reproduce empty lines in capture, as this method is called for
    // EACH line, even the empty ones, the spans are styled as "pre" so literal
    // linefeeds would be treated as such THERE but we deliberately place the
    // line-feeds OUTSIDE so they come under the <body>s no wrap and as such
    // line-feeds can be used to break the HTML over lots of lines (which is
    // easier to hand edit and examine afterwards) without impacting the
    // formatting. To get the line feeds at the end of displayed HTML lines the
    // <br> is used.  This slightly weird way of doing things is so that some
    // on-line tools preserve the formatting when the HTML-lised selection is
    // pasted to them AND retain the ability to paste the HTML from the
    // clipboard into a plain text editor and not have everything on one line in
    // that editor!

    return s;
}

bool TBuffer::processUtf8Sequence(const std::string& bufferData, const bool isFromServer, const size_t len, size_t& pos, bool& isNonBMPCharacter)
{
    // In Utf-8 mode we have to process the data more than one byte at a
    // time because there is not necessarily a one-byte to one TChar
    // mapping, instead we use one TChar per QChar - and that has to be
    // tweaked for non-BMP characters that use TWO QChars per codepoint.
    if (bufferData.at(pos) & 0x80) {
        // MSB is set, so if this is Utf-8 then assume this is the first byte
        size_t utf8SequenceLength = 1;
        if ((bufferData.at(pos) & 0xE0) == 0xC0) {
            // 2 byte sequence - Unicode code-points: U+00000080 to U+000007FF
            utf8SequenceLength = 2;
        } else if ((bufferData.at(pos) & 0xF0) == 0xE0) {
            // 3 byte sequence - Unicode code-points: U+00000800 to U+0000FFFF
            utf8SequenceLength = 3;
        } else if ((bufferData.at(pos) & 0xF8) == 0xF0) {
            // 4 byte sequence - Unicode code-points: U+00010000 to U+001FFFFF (<= U+0010FFF LEGAL)
            utf8SequenceLength = 4;
        } else if ((bufferData.at(pos) & 0xFC) == 0xF8) {
            // 5 byte sequence - Unicode code-points: U+00200000 to U+03FFFFFF (ALL ILLEGAL)
            utf8SequenceLength = 5;
        } else if ((bufferData.at(pos) & 0xFE) == 0xFC) {
            // 6 byte sequence - Unicode code-points: U+04000000 to U+7FFFFFFF (ALL ILLEGAL)
            utf8SequenceLength = 6;
        }

        if ((pos + utf8SequenceLength) > len) {
            // Not enough bytes left in bufferData to complete the utf-8
            // sequence - need to save and prepend onto incoming data next
            // time around.
            // The absence of a second argument takes all the available
            // bytes - this is only for data from the Server NOT from
            // locally generated material from Lua feedTriggers(...)
            if (isFromServer) {
#if defined(DEBUG_UTF8_PROCESSING)
                qDebug() << "TBuffer::processUtf8Sequence(...) Insufficient bytes in buffer to complete UTF-8 sequence, need:" << utf8SequenceLength
                         << " but we currently only have: " << bufferData.substr(pos).length() << " bytes (which we will store for next call to this method)...";
#endif
                mIncompleteSequenceBytes = bufferData.substr(pos);
            }
            return false; // Bail out
        }

        // If we have got here we have enough bytes to work with:
        bool isValid = true;
        bool isToUseReplacementMark = false;
        bool isToUseByteOrderMark = false; // When BOM seen in stream it transcodes as zero characters
        switch (utf8SequenceLength) {
        case 4:
            // Check the 4th byte is a valid continuation byte (2 MS-Bits are 10)
            if ((bufferData.at(pos + 3) & 0xC0) != 0x80) {
#if defined(DEBUG_UTF8_PROCESSING)
                qDebug() << "TBuffer::processUtf8Sequence(...) 4th byte in UTF-8 sequence is invalid!";
#endif
                isValid = false;
                isToUseReplacementMark = true;
            } else if (((bufferData.at(pos) & 0x07) > 0x04) || (((bufferData.at(pos) & 0x07) == 0x04) && ((bufferData.at(pos + 1) & 0x3F) > 0x0F))) {
                // For 4 byte values the bits are distributed:
                //  Byte 1    Byte 2    Byte 3    Byte 4
                // 11110ABC  10DEFGHI  10JKLMNO  10PQRSTU   A is MSB
                // U+10FFFF in binary is: 1 0000 1111 1111 1111 1111
                // So this (the maximum valid character) is:
                //      ABC    DEFGHI    JKLMNO    PQRSTU
                //      100    001111    111111    111111
                // So if the first byte bufferData.at(pos] & 0x07 is:
                //  < 0x04 then must be in range
                //  > 0x04 then must be out of range
                // == 0x04 then consider bufferData.at(pos+1] & 0x3F:
                //     <= 001111 0x0F then must be in range
                //      > 001111 0x0F then must be out of range

#if defined(DEBUG_UTF8_PROCESSING)
                qDebug() << "TBuffer::processUtf8Sequence(...) 4 byte UTF-8 sequence is valid but is beyond range of legal codepoints!";
#endif
                isValid = false;
                isToUseReplacementMark = true;
            }

            // Fall-through
            [[fallthrough]];
        case 3:
            // Check the 3rd byte is a valid continuation byte (2 MS-Bits are 10)
            if ((bufferData.at(pos + 2) & 0xC0) != 0x80) {
#if defined(DEBUG_UTF8_PROCESSING)
                qDebug() << "TBuffer::processUtf8Sequence(...) 3rd byte in UTF-8 sequence is invalid!";
#endif
                isValid = false;
                isToUseReplacementMark = true;
            } else if ((bufferData.at(pos) & 0x0F) == 0x0D && (bufferData.at(pos + 1) & 0x20) == 0x20) {
// For 3 byte values the bits are distributed:
//  Byte 1    Byte 2    Byte 3
// 1110ABCD  10DEFGHI  10JKLMNO   A is MSB
// First High surrogate 0xed 0xa0 0x80 (U+D800)
// 1101 1000 0000 0000
// ----1101  --100000  --000000
// Last Low surrogate 0xed 0xbf 0xbf (U+DFFF)
// 1101 1111 1111 1111
// ----1101  --111111  --111111
/*
    * As per Wikipedia {https://en.wikipedia.org/wiki/UTF-16#U.2BD800_to_U.2BDFFF}
    * "The Unicode standard permanently reserves these code point values for UTF-16
    * encoding of the high and low surrogates, and they will never be assigned a
    * character, so there should be no reason to encode them. The official Unicode
    * standard says that no UTF forms, including UTF-16, can encode these code
    * points.
    *
    * However UCS-2, UTF-8, and UTF-32 can encode these code points in trivial and
    * obvious ways, and large amounts of software does so even though the standard
    * states that such arrangements should be treated as encoding errors. It is
    * possible to unambiguously encode them in UTF-16 by using a code unit equal to
    * the code point, as long as no sequence of two code units can be interpreted
    * as a legal surrogate pair (that is, as long as a high surrogate is never
    * followed by a low surrogate). The majority of UTF-16 encoder and decoder
    * implementations translate between encodings as though this were the case
    * and Windows allows such sequences in filenames."
    *
    * So test for and, considering the LS Nibble of first byte:
    * * accept if LS Nibble of first byte is             less than 0xD
    * * accept if LS Nibble of first byte is greater than/equal to 0xE
    * * otherwise (if LS Nibble of first byte IS 0xD)
    *   * accept if 6 LS Bits of second byte is 0x1F of or less
    * Conversely this can be stated as:
    * * reject if LS Nibble of first byte is 0xD AND 6th MS Bit of second byte is set
    */
#if defined(DEBUG_UTF8_PROCESSING)
                qDebug() << "TBuffer::processUtf8Sequence(...) 3 byte UTF-8 sequence is a High or Low UTF-16 Surrogate and is not valid in UTF-8!";
#endif
                isValid = false;
                isToUseReplacementMark = true;
            } else if ((static_cast<quint8>(bufferData.at(pos + 2)) == 0xBF) && (static_cast<quint8>(bufferData.at(pos + 1)) == 0xBB) && (static_cast<quint8>(bufferData.at(pos)) == 0xEF)) {
                // Got caught out by this one - it is the UTF-8 BOM (or
                // Zero-Width No-Break Space) and needs to be detected specially
                // as Qt's codec ignores it and transcodes it to NO codepoints!
#if defined(DEBUG_UTF8_PROCESSING)
                qDebug() << "TBuffer::processUtf8Sequence(...) UTF-8 BOM sequence seen and handled!";
#endif
                isValid = false;
                isToUseByteOrderMark = true;
            }

            // Fall-through
            [[fallthrough]];
        case 2: {
            // Check the 2nd byte is a valid continuation byte (2 MS-Bits are 10)
            if ((static_cast<quint8>(bufferData.at(pos + 1)) & 0xC0) != 0x80) {
#if defined(DEBUG_UTF8_PROCESSING)
                qDebug() << "TBuffer::processUtf8Sequence(...) 2nd byte in UTF-8 sequence is invalid!";
#endif
                isValid = false;
                isToUseReplacementMark = true;
            }

            // Also test for (and reject) overlong sequences - don't need to check
            // 5 or 6 byte ones as those are already rejected above
            const auto firstByte = static_cast<quint8>(bufferData.at(pos));
            const auto secondByte = static_cast<quint8>(bufferData.at(pos + 1));
            // Overlong 2-byte: C0/C1 xx (could be encoded in 1 byte)
            const bool twoByteOverlong = ((firstByte & 0xFE) == 0xC0) && ((secondByte & 0xC0) == 0x80);
            // Overlong 3-byte: E0 80-9F xx (could be encoded in 2 bytes)
            const bool threeByteOverlong = (firstByte == 0xE0) && ((secondByte & 0xE0) == 0x80);
            // Overlong 4-byte: F0 80-8F xx xx (could be encoded in 3 bytes)
            const bool fourByteOverlong = (firstByte == 0xF0) && ((secondByte & 0xF0) == 0x80);

            if (twoByteOverlong || threeByteOverlong || fourByteOverlong) {
#if defined(DEBUG_UTF8_PROCESSING)
                qDebug().nospace() << "TBuffer::processUtf8Sequence(...) Overlong " << utf8SequenceLength << "-byte sequence as UTF-8 rejected!";
#endif
                isValid = false;
                isToUseReplacementMark = true;
            }
            break;
        }

        default:
#if defined(DEBUG_UTF8_PROCESSING)
            qDebug().nospace() << "TBuffer::processUtf8Sequence(...) " << utf8SequenceLength << "-byte sequence as UTF-8 rejected!";
#endif
            isValid = false;
            isToUseReplacementMark = true;
        }

        // Will be one (BMP codepoint) or two (non-BMP codepoints) QChar(s)
        if (isValid) {
            const QString codePoint = QString(bufferData.substr(pos, utf8SequenceLength).c_str());
            switch (codePoint.size()) {
            default:
                Q_UNREACHABLE(); // This can't happen, unless we got start or length wrong in std::string::substr()
                qWarning().nospace() << "TBuffer::processUtf8Sequence(...) " << utf8SequenceLength << "-byte UTF-8 sequence accepted, and it encoded to " << codePoint.size()
                                     << " QChars which does not make sense!!!";
                isValid = false;
                isToUseReplacementMark = true;
                break;
            case 2:
                isNonBMPCharacter = true;
                // Fall-through
                [[fallthrough]];
            case 1:
#if defined(DEBUG_UTF8_PROCESSING)
                qDebug().nospace() << "TBuffer::processUtf8Sequence(...) " << utf8SequenceLength << "-byte UTF-8 sequence accepted, it was " << codePoint.size() << " QChar(s) long [" << codePoint
                                   << "]";
#endif
                mMudLine.append(codePoint);
                break;
            case 0:
                qWarning().nospace() << "TBuffer::processUtf8Sequence(...) " << utf8SequenceLength
                                     << "-byte UTF-8 sequence accepted, but it did not encode to "
                                        "ANY QChar(s)!!!";
                isValid = false;
                isToUseReplacementMark = true;
            }
        }

        if (!isValid) {
#if defined(DEBUG_UTF8_PROCESSING)
            QString debugMsg;
            for (size_t i = 0; i < utf8SequenceLength; ++i) {
                debugMsg.append(qsl("<%1>").arg(static_cast<quint8>(bufferData.at(pos + i)), 2, 16, QChar('0')));
            }
            qDebug().nospace() << "    Sequence bytes are: " << debugMsg;
#endif
            if (isToUseReplacementMark) {
                mMudLine.append(QChar::ReplacementCharacter);
            } else if (isToUseByteOrderMark) {
                mMudLine.append(QChar::ByteOrderMark);
            }
        }

        // As there is already a unit increment at the bottom of loop
        // add one less than the sequence length:
        pos += utf8SequenceLength - 1;
    } else {
        // Single byte character i.e. Unicode points: U+00000000 to U+0000007F
        mMudLine.append(bufferData.at(pos));
    }

    return true;
}

bool TBuffer::processGBSequence(const std::string& bufferData, const bool isFromServer, const bool isGB18030, const size_t len, size_t& pos, bool& isNonBmpCharacter)
{
    // In GBK/GB18030 mode we have to process the data more than one byte at a
    // time because there is not necessarily a one-byte to one TChar
    // mapping, instead we use one TChar per QChar - and that has to be
    // tweaked for non-BMP characters that use TWO QChars per codepoint.
    // GB2312 is the predecessor to both and - according to Wikipedia (EN) covers
    // over 99% of the characters of contemporary usage.
    // GBK is a sub-set of GB18030 so can be processed in the same method
    // Assume we are at the first byte of a single (ASCII), pair (GBK/GB18030)
    // or four byte (GB18030) sequence

#if defined(DEBUG_GB_PROCESSING)
    std::string dataIdentity;
#endif

    // The range deductions for two-byte sequences are take from:
    // https://en.wikipedia.org/wiki/GBK#Encoding
    size_t gbSequenceLength = 1;
    bool isValid = true;
    bool isToUseReplacementMark = false;
    // Only set this if we are adding more than one code-point to
    // mCurrentLineCharacters:
    isNonBmpCharacter = false;
    if (static_cast<quint8>(bufferData.at(pos)) < 0x80) {
        // Is ASCII - single byte character, straight forward for a "first" byte case
        mMudLine.append(bufferData.at(pos));
        // As there is already a unit increment at the bottom of caller's loop
        // there is no need to tweak pos in THIS case

        return true;
    } else if (static_cast<quint8>(bufferData.at(pos)) == 0x80) {
        // Invalid as first byte
        isValid = false;
        isToUseReplacementMark = true;
#if defined(DEBUG_GB_PROCESSING)
        qDebug().nospace() << "TBuffer::processGBSequence(...) 1-byte sequence as " << (isGB18030 ? "GB18030" : "GB2312/GBK") << " rejected!";
#endif

        // Proceed to handle 1 byte (as GB2312/GBK/GB18030 data) outside of checks...

    } else if (!isGB18030) {
        // Could be two byte GBK - but do we have a second byte?
        // As we are not in GB18030 mode treat it as if it is a 2 byte sequence
        gbSequenceLength = 2;
        if ((pos + gbSequenceLength - 1) < len) {
            // We have enough bytes to look at the second one - let's see which
            // range it is in:
            const auto byte1 = static_cast<quint8>(bufferData.at(pos));
            const auto byte2 = static_cast<quint8>(bufferData.at(pos + 1));

            // GBK Area 3: byte1 81-A0, byte2 40-FE (excluding 7F)
            const bool gbkArea3 = (byte1 >= 0x81) && (byte1 <= 0xA0) && (byte2 >= 0x40) && (byte2 <= 0xFE) && (byte2 != 0x7F);
            // GBK Area 1 (& GB2312): byte1 A1-A9, byte2 A1-FE
            const bool gbkArea1 = (byte1 >= 0xA1) && (byte1 <= 0xA9) && (byte2 >= 0xA1) && (byte2 <= 0xFE);
            // GBK Area 2 (& GB2312): byte1 B0-F7, byte2 A1-FE
            const bool gbkArea2 = (byte1 >= 0xB0) && (byte1 <= 0xF7) && (byte2 >= 0xA1) && (byte2 <= 0xFE);
            // GBK Area 5: byte1 A8-A9, byte2 40-A0 (excluding 7F)
            const bool gbkArea5 = (byte1 >= 0xA8) && (byte1 <= 0xA9) && (byte2 >= 0x40) && (byte2 <= 0xA0) && (byte2 != 0x7F);
            // GBK Area 4: byte1 AA-FE, byte2 40-A0 (excluding 7F)
            const bool gbkArea4 = (byte1 >= 0xAA) && (byte1 <= 0xFE) && (byte2 >= 0x40) && (byte2 <= 0xA0) && (byte2 != 0x7F);
            // User Defined (PU) Area 1: byte1 AA-AF, byte2 A1-FE
            const bool userArea1 = (byte1 >= 0xAA) && (byte1 <= 0xAF) && (byte2 >= 0xA1) && (byte2 <= 0xFE);
            // User Defined (PU) Area 2: byte1 F8-FE, byte2 A1-FE
            const bool userArea2 = (byte1 >= 0xF8) && (byte1 <= 0xFE) && (byte2 >= 0xA1) && (byte2 <= 0xFE);
            // User Defined (PU) Area 3: byte1 A1-A7, byte2 A1-FE (excluding 7F)
            const bool userArea3 = (byte1 >= 0xA1) && (byte1 <= 0xA7) && (byte2 >= 0xA1) && (byte2 <= 0xFE) && (byte2 != 0x7F);
            // Looks like first pair of 4-byte GB18030 non-BMP sequence: byte1 90-E3, byte2 30-39
            const bool looksLikeGB18030NonBMP = (byte1 >= 0x90) && (byte1 <= 0xE3) && (byte2 >= 0x30) && (byte2 <= 0x39);
            // Looks like first pair of 4-byte GB18030 Private Use sequence: byte1 FD-FE, byte2 30-39
            const bool looksLikeGB18030PrivateUse = (byte1 >= 0xFD) && (byte1 <= 0xFE) && (byte2 >= 0x30) && (byte2 <= 0x39);

            if (gbkArea3) {
                // GBK Area 3 sequence
#if defined(DEBUG_GB_PROCESSING)
                dataIdentity = "GBK Area 3";
#endif
            } else if (gbkArea1) {
                // GBK Area 1 (& GB2312) sequence
#if defined(DEBUG_GB_PROCESSING)
                dataIdentity = "GBK Area 1 (or GB2312)";
#endif
            } else if (gbkArea2) {
                // GBK Area 2 (& GB2312) sequence
#if defined(DEBUG_GB_PROCESSING)
                dataIdentity = "GBK Area 2 (or GB2312)";
#endif
            } else if (gbkArea5) {
                // GBK/5 sequence
#if defined(DEBUG_GB_PROCESSING)
                dataIdentity = "GBK Area 5";
#endif
            } else if (gbkArea4) {
                // GBK/4 sequence
#if defined(DEBUG_GB_PROCESSING)
                dataIdentity = "GBK Area 4";
#endif
            } else if (userArea1) {
                // User Defined 1 sequence - possibly invalid for us but if the
                // MUD supplies their own font it could be used:
#if defined(DEBUG_GB_PROCESSING)
                dataIdentity = "User Defined (PU) Area 1";
#endif
            } else if (userArea2) {
                // User Defined 2 sequence - possibly invalid for us but if the
                // MUD supplies their own font it could be used:
#if defined(DEBUG_GB_PROCESSING)
                dataIdentity = "User Defined (PU) Area 2";
#endif
            } else if (userArea3) {
                // User Defined 3 sequence - possibly invalid for us but if the
                // MUD supplies their own font it could be used:
#if defined(DEBUG_GB_PROCESSING)
                dataIdentity = "User Defined (PU) Area 3";
#endif
            } else if (looksLikeGB18030NonBMP) {
                // First two bytes of a 4-byte GB18030 sequence - for a non-BMP mapped Unicode
                // codepoint if byte 3 is within 0x81-0xFE and byte 4 is within 0x30-0x39
                isValid = false;
                isToUseReplacementMark = true;
#if defined(DEBUG_GB_PROCESSING)
                qDebug().nospace() << "TBuffer::processGBSequence(...) 2-byte sequence as "
                                      "GB2312/GBK rejected because it is seems to be the "
                                      "first pair of a 4-byte GB18030 non-BMP Unicode sequence!";
#endif
            } else if (looksLikeGB18030PrivateUse) {
                // First two bytes of a 4-byte GB18030 sequence - for a non-BMP mapped Unicode
                // codepoint if byte 3 is within 0x81-0xFE and byte 4 is within 0x30-0x39
                isValid = false;
                isToUseReplacementMark = true;
#if defined(DEBUG_GB_PROCESSING)
                qDebug().nospace() << "TBuffer::processGBSequence(...) 2-byte sequence as "
                                      "GB2312/GBK rejected because it is seems to be the "
                                      "first pair of a 4-byte GB18030 Private Use sequence!";
#endif
            } else {
                // Outside expected ranges
                isValid = false;
                isToUseReplacementMark = true;
#if defined(DEBUG_GB_PROCESSING)
                qDebug().nospace() << "TBuffer::processGBSequence(...) 2-byte sequence as "
                                      "GB2312/GBK rejected!";
#endif
            }

            // Proceed to handle 2 bytes (of GB2312/GBK data) outside of checks...

        } else {
            // Not enough bytes to process yet - so store what we have and return
            if (isFromServer) {
#if defined(DEBUG_GB_PROCESSING)
                qDebug().nospace() << "TBuffer::processGBSequence(...) Insufficient bytes in buffer to "
                                      "complete GB2312/GBK sequence, need at least: "
                                   << gbSequenceLength << " but we currently only have: " << bufferData.substr(pos).length() << " bytes (which we will store for next call to this method)...";
#endif
                mIncompleteSequenceBytes = bufferData.substr(pos);
            }
            return false; // Bail out
        }

    } else {
        // isGB18030 is true!
        // Could be two bytes or four bytes - but do we have at least a second
        // byte? Treat it as if it is a 2 byte sequence until we know we have a
        // four byte one - from examining the second byte and it is in range
        // 0x30 to 0x39 inclusive:

        gbSequenceLength = 2;
        if ((pos + gbSequenceLength - 1) < len) {
            // We have enough bytes to look at the second one - let's see which
            // range it is in:
            const auto byte1 = static_cast<quint8>(bufferData.at(pos));
            const auto byte2 = static_cast<quint8>(bufferData.at(pos + 1));

            // Check if this is a 4-byte sequence: byte1 81-FE, byte2 30-39
            const bool fourByteSequence = (byte1 >= 0x81) && (byte1 <= 0xFE) && (byte2 >= 0x30) && (byte2 <= 0x39);

            if (fourByteSequence) {
                // This IS a 4-byte sequence
                gbSequenceLength = 4;

                if ((pos + gbSequenceLength - 1) >= len) {
                    // Not enough bytes to process yet - so store what we have and return
                    if (isFromServer) {
#if defined(DEBUG_GB_PROCESSING)
                        qDebug().nospace() << "TBuffer::processGBSequence(...) Insufficient bytes in buffer to "
                                              "complete GB18030 sequence, need at least: "
                                           << gbSequenceLength << " but we currently only have: " << bufferData.substr(pos).length() << " bytes (which we will store for next call to this method)...";
#endif
                        mIncompleteSequenceBytes = bufferData.substr(pos);
                    }

                    return false; // Bail out
                }

                // Continue with four-byte sequence validation processing as we
                // have all four bytes to work with:
                const auto byte3 = static_cast<quint8>(bufferData.at(pos + 2));
                const auto byte4 = static_cast<quint8>(bufferData.at(pos + 3));

                // Valid 4-byte: byte1 in (81-84 or 90-E3), byte2 30-39 (already checked),
                // byte3 81-FE, byte4 30-39
                const bool byte1InValidRange = (byte1 <= 0x84) || ((byte1 >= 0x90) && (byte1 <= 0xE3));
                const bool byte3Valid = (byte3 >= 0x81) && (byte3 <= 0xFE);
                const bool byte4Valid = (byte4 >= 0x30) && (byte4 <= 0x39);

                if (byte1InValidRange && byte3Valid && byte4Valid) {
                    // Okay we should have a valid four byte sequence now
#if defined(DEBUG_GB_PROCESSING)
                    dataIdentity = "Non-BMP mapped Unicode";
#endif
                } else {
                    // if first byte was < 0x90 then it would have been a BMP
                    // unicode codepoint but it is academic as it is not
                    // currently defined as a valid codepoint value and will be
                    // substituted with the replacement character anyway:
                    isValid = false;
                    isToUseReplacementMark = true;

#if defined(DEBUG_GB_PROCESSING)
                    qDebug().nospace() << "TBuffer::processGBSequence(...) 4-byte sequence as "
                                          "GB18030 rejected!";
#endif
                }

                // Proceed to handle 4 bytes (as GB18030 data) outside of checks...

            } else {
                // Looks as though it is a two-byte sequence after all - so
                // validate it as that:

                // GBK Area 3: byte1 81-A0, byte2 40-FE (excluding 7F)
                const bool isGBKArea3 = (byte1 >= 0x81) && (byte1 <= 0xA0) && (byte2 >= 0x40) && (byte2 <= 0xFE) && (byte2 != 0x7F);
                // GBK Area 1 (& GB2312): byte1 A1-A9, byte2 A1-FE
                const bool isGBKArea1 = (byte1 >= 0xA1) && (byte1 <= 0xA9) && (byte2 >= 0xA1) && (byte2 <= 0xFE);
                // GBK Area 2 (& GB2312): byte1 B0-F7, byte2 A1-FE
                const bool isGBKArea2 = (byte1 >= 0xB0) && (byte1 <= 0xF7) && (byte2 >= 0xA1) && (byte2 <= 0xFE);
                // GBK Area 5: byte1 A8-A9, byte2 40-A0 (excluding 7F)
                const bool isGBKArea5 = (byte1 >= 0xA8) && (byte1 <= 0xA9) && (byte2 >= 0x40) && (byte2 <= 0xA0) && (byte2 != 0x7F);
                // GBK Area 4: byte1 AA-FE, byte2 40-A0 (excluding 7F)
                const bool isGBKArea4 = (byte1 >= 0xAA) && (byte1 <= 0xFE) && (byte2 >= 0x40) && (byte2 <= 0xA0) && (byte2 != 0x7F);
                // User Defined (PU) Area 1: byte1 AA-AF, byte2 A1-FE
                const bool isUserArea1 = (byte1 >= 0xAA) && (byte1 <= 0xAF) && (byte2 >= 0xA1) && (byte2 <= 0xFE);
                // User Defined (PU) Area 2: byte1 F8-FE, byte2 A1-FE
                const bool isUserArea2 = (byte1 >= 0xF8) && (byte1 <= 0xFE) && (byte2 >= 0xA1) && (byte2 <= 0xFE);
                // User Defined (PU) Area 3: byte1 A1-A7, byte2 A1-FE (excluding 7F)
                const bool isUserArea3 = (byte1 >= 0xA1) && (byte1 <= 0xA7) && (byte2 >= 0xA1) && (byte2 <= 0xFE) && (byte2 != 0x7F);

                if (isGBKArea3) {
                    // GBK/3 sequence
#if defined(DEBUG_GB_PROCESSING)
                    dataIdentity = "GBK Area 3";
#endif
                } else if (isGBKArea1) {
                    // GBK/1 (& GB2312) sequence
#if defined(DEBUG_GB_PROCESSING)
                    dataIdentity = "GBK Area 1";
#endif
                } else if (isGBKArea2) {
                    // GBK/2 (& GB2312) sequence
#if defined(DEBUG_GB_PROCESSING)
                    dataIdentity = "GBK Area 2";
#endif
                } else if (isGBKArea5) {
                    // GBK/5 sequence
#if defined(DEBUG_GB_PROCESSING)
                    dataIdentity = "GBK Area 5";
#endif
                } else if (isGBKArea4) {
                    // GBK/4 sequence
#if defined(DEBUG_GB_PROCESSING)
                    dataIdentity = "GBK Area 4";
#endif
                } else if (isUserArea1) {
                    // User Defined 1 sequence - possibly invalid for us but if the
                    // MUD supplies their own font it could be used:
#if defined(DEBUG_GB_PROCESSING)
                    dataIdentity = "User Defined (PU) Area 1";
#endif
                } else if (isUserArea2) {
                    // User Defined 2 sequence - possibly invalid for us but if the
                    // MUD supplies their own font it could be used:
#if defined(DEBUG_GB_PROCESSING)
                    dataIdentity = "User Defined (PU) Area 2";
#endif
                } else if (isUserArea3) {
                    // User Defined 3 sequence - possibly invalid for us but if the
                    // MUD supplies their own font it could be used:
#if defined(DEBUG_GB_PROCESSING)
                    dataIdentity = "User Defined (PU) Area 3";
#endif
                } else {
                    // Outside expected range
                    isValid = false;
                    isToUseReplacementMark = true;
#if defined(DEBUG_GB_PROCESSING)
                    qDebug().nospace() << "TBuffer::processGBSequence(...) 2-byte sequence as GB18030 rejected!";
#endif
                }

            } // End of IF is a four-byte ELSE is a two-byte sequence...

            // Proceed to handle 2 bytes (of GB18030 data) outside of checks...

        } else {
            // Not enough bytes to process yet could be we need two OR four but
            // we only have one - so store what we have and return
            if (isFromServer) {
#if defined(DEBUG_GB_PROCESSING)
                qDebug().nospace() << "TBuffer::processGBSequence(...) Insufficient bytes in buffer to complete GB18030 sequence, need at least:" << gbSequenceLength
                                   << " but we currently only have: " << bufferData.substr(pos).length() << " bytes (which we will store for next call to this method)...";
#endif
                mIncompleteSequenceBytes = bufferData.substr(pos);
            }
            return false; // Bail out
        }
    }

    // At this point we know how many bytes to consume, and whether they are in
    // the right ranges of individual values to be valid

    if (isValid) {
        // Try and convert two or four byte sequence to Unicode using Qts own
        // decoder - and check number of codepoints returned

        QString codePoint;
        if (TEncodingHelper::isEncodingAvailable(mEncoding)) {
            codePoint = TEncodingHelper::decode(QByteArray::fromRawData(bufferData.substr(pos, gbSequenceLength).c_str(), gbSequenceLength), mEncoding);
            switch (codePoint.size()) {
            default:
                Q_UNREACHABLE(); // This can't happen, unless we got start or length wrong in std::string::substr()
                qWarning().nospace() << "TBuffer::processGBSequence(...) " << gbSequenceLength << "-byte " << (isGB18030 ? "GB18030" : "GB2312/GBK") << " sequence accepted, and it encoded to "
                                     << codePoint.size() << " QChars which does not make sense!!!";
                isValid = false;
                isToUseReplacementMark = true;
                break;
            case 2:
                isNonBmpCharacter = true;
                // Fall-through
                [[fallthrough]];
            case 1:
#if defined(DEBUG_GB_PROCESSING)
                qDebug().nospace() << "TBuffer::processGBSequence(...) " << gbSequenceLength << "-byte " << (isGB18030 ? "GB18030" : "GB2312/GBK") << " sequence accepted, it is " << codePoint.size()
                                   << " QChar(s) long [" << codePoint << "] and is in the " << dataIdentity.c_str() << " range";
#endif
                mMudLine.append(codePoint);
                break;
            case 0:
                qWarning().nospace() << "TBuffer::processGBSequence(...) " << gbSequenceLength << "-byte " << (isGB18030 ? "GB18030" : "GB2312/GBK")
                                     << " sequence accepted, but it did not encode to ANY QChar(s)!!!";
                isValid = false;
                isToUseReplacementMark = true;
            }
        } else {
            // Unable to decode it - no Qt decoder...!
            isValid = false;
            isToUseReplacementMark = true;
        }
    }

    if (!isValid) {
#if defined(DEBUG_GB_PROCESSING)
        QString debugMsg;
        for (size_t i = 0; i < gbSequenceLength; ++i) {
            debugMsg.append(qsl("<%1>").arg(static_cast<quint8>(bufferData.at(pos + i)), 2, 16, QChar('0')));
        }
        qDebug().nospace() << "    Sequence bytes are: " << debugMsg;
#endif
        if (isToUseReplacementMark) {
            mMudLine.append(QChar::ReplacementCharacter);
        }
    }

    // As there is already a unit increment at the bottom of loop
    // add one less than the sequence length:
    pos += gbSequenceLength - 1;

    return true;
}

bool TBuffer::processBig5Sequence(const std::string& bufferData, const bool isFromServer, const size_t len, size_t& pos, bool& isNonBmpCharacter)
{
#if defined(DEBUG_BIG5_PROCESSING)
    std::string dataIdentity;
#endif

    // The encoding standard are taken from https://en.wikipedia.org/wiki/Big5
    size_t big5SequenceLength = 1;
    bool isValid = true;
    bool isToUseReplacementMark = false;
    // Only set this if we are adding more than one code-point to
    // mCurrentLineCharacters:
    isNonBmpCharacter = false;
    if (static_cast<quint8>(bufferData.at(pos)) < 0x80) {
        // Is ASCII - single byte character, straight forward for a "first" byte case
        mMudLine.append(bufferData.at(pos));
        // As there is already a unit increment at the bottom of caller's loop
        // there is no need to tweak pos in THIS case

        return true;
    } else if (static_cast<quint8>(bufferData.at(pos)) == 0x80 || static_cast<quint8>(bufferData.at(pos)) > 0xFE) {
        // Invalid as first byte
        isValid = false;
        isToUseReplacementMark = true;
#if defined(DEBUG_BIG5_PROCESSING)
        qDebug().nospace() << "TBuffer::processBig5Sequence(...) 1-byte sequence as Big5 rejected!";
#endif
    } else {
        // We have two bytes
        big5SequenceLength = 2;
        if ((pos + big5SequenceLength - 1) >= len) {
            // Not enough bytes to process yet - so store what we have and return
            if (isFromServer) {
#if defined(DEBUG_BIG5_PROCESSING)
                qDebug().nospace() << "TBuffer::processBig5Sequence(...) Insufficient bytes in buffer to "
                                      "complete Big5 sequence, need at least: "
                                   << big5SequenceLength << " but we currently only have: " << bufferData.substr(pos).length() << " bytes (which we will store for next call to this method)...";
#endif
                mIncompleteSequenceBytes = bufferData.substr(pos);
            }
            return false; // Bail out
        } else {
            // check if second byte range is valid
            auto val2 = static_cast<quint8>(bufferData.at(pos + 1));
            if (val2 < 0x40 || (val2 > 0x7E && val2 < 0xA1) || val2 > 0xFE) {
                // second byte range is invalid
                isValid = false;
                isToUseReplacementMark = true;
            }
        }
    }

    // At this point we know how many bytes to consume, and whether they are in
    // the right ranges of individual values to be valid

    if (isValid) {
        // Try and convert two byte sequence to Unicode using Qts own
        // decoder - and check number of codepoints returned

        QString codePoint;
        if (TEncodingHelper::isEncodingAvailable(mEncoding)) {
            codePoint = TEncodingHelper::decode(QByteArray::fromRawData(bufferData.substr(pos, big5SequenceLength).c_str(), big5SequenceLength), mEncoding);
            switch (codePoint.size()) {
            default:
                Q_UNREACHABLE(); // This can't happen, unless we got start or length wrong in std::string::substr()
                qWarning().nospace() << "TBuffer::processBig5Sequence(...) " << big5SequenceLength << "-byte Big5 sequence accepted, and it encoded to " << codePoint.size()
                                     << " QChars which does not make sense!!!";
                isValid = false;
                isToUseReplacementMark = true;
                break;
            case 2:
                // Fall-through
                [[fallthrough]];
            case 1:
                // If Qt's decoder found bad characters, update status flags to reflect that.
                if (codePoint.contains(QChar::ReplacementCharacter)) {
                    isValid = false;
                    isToUseReplacementMark = true;
                    break;
                }
#if defined(DEBUG_BIG5_PROCESSING)
                qDebug().nospace() << "TBuffer::processBig5Sequence(...) " << big5SequenceLength << "-byte Big5 sequence accepted, it is " << codePoint.size() << " QChar(s) long [" << codePoint
                                   << "] and is in the " << dataIdentity.c_str() << " range";
#endif
                mMudLine.append(codePoint);
                break;
            case 0:
                qWarning().nospace() << "TBuffer::processBig5Sequence(...) " << big5SequenceLength << "-byte Big5" << "sequence accepted, but it did not encode to ANY QChar(s)!!!";
                isValid = false;
                isToUseReplacementMark = true;
            }
        } else {
            // Unable to decode it - no Qt decoder...!
#if defined(DEBUG_BIG5_PROCESSING)
            qDebug().nospace() << "No Qt decoder found...";
#endif
            isValid = false;
            isToUseReplacementMark = true;
        }
    }

    if (!isValid) {
#if defined(DEBUG_BIG5_PROCESSING)
        QString debugMsg;
        for (size_t i = 0; i < big5SequenceLength; ++i) {
            debugMsg.append(qsl("<%1>").arg(static_cast<quint8>(bufferData.at(pos + i)), 2, 16, QChar('0')));
        }
        qDebug().nospace() << "    Invalid.  Sequence bytes are: " << debugMsg;
#endif
        if (isToUseReplacementMark) {
            mMudLine.append(QChar::ReplacementCharacter);
        }
    }

    // As there is already a unit increment at the bottom of loop
    // add one less than the sequence length:
    pos += big5SequenceLength - 1;

    return true;
}

bool TBuffer::processEUC_KRSequence(const std::string& bufferData, const bool isFromServer, const size_t len, size_t& pos, bool& isNonBmpCharacter)
{
#if defined(DEBUG_EUC_KR_PROCESSING)
    std::string dataIdentity;
#endif

    // The encoding standard are taken from https://en.wikipedia.org/wiki/Extended_Unix_Code
    size_t eucSequenceLength = 1;
    bool isValid = true;
    bool isToUseReplacementMark = false;
    // Only set this if we are adding more than one code-point to
    // mCurrentLineCharacters:
    isNonBmpCharacter = false;
    if (static_cast<quint8>(bufferData.at(pos)) < 0x7F) {
        // Is ASCII - single byte character, straight forward for a "first" byte case
        mMudLine.append(bufferData.at(pos));
        // As there is already a unit increment at the bottom of caller's loop
        // there is no need to tweak pos in THIS case

        return true;
    } else if (static_cast<quint8>(bufferData.at(pos)) < 0xA1 || static_cast<quint8>(bufferData.at(pos)) == 0xFF) {
        // Invalid as first byte
        isValid = false;
        isToUseReplacementMark = true;
#if defined(DEBUG_EUC_KR_PROCESSING)
        qDebug().nospace() << "TBuffer::processEUC_KRSequence(...) 1-byte sequence as EUC-KR rejected!";
#endif
    } else {
        // We have two bytes
        eucSequenceLength = 2;
        if ((pos + eucSequenceLength - 1) >= len) {
            // Not enough bytes to process yet - so store what we have and return
            if (isFromServer) {
#if defined(DEBUG_EUC_KR_PROCESSING)
                qDebug().nospace() << "TBuffer::processEUC_KRSequence(...) Insufficient bytes in buffer to "
                                      "complete EUC-KR sequence, need at least: "
                                   << eucSequenceLength << " but we currently only have: " << bufferData.substr(pos).length() << " bytes (which we will store for next call to this method)...";
#endif
                mIncompleteSequenceBytes = bufferData.substr(pos);
            }
            return false; // Bail out
        } else {
            // check if second byte range is valid
            auto val2 = static_cast<quint8>(bufferData.at(pos + 1));
            if (val2 < 0xA1 || val2 == 0xFF) {
                // second byte range is invalid
                isValid = false;
                isToUseReplacementMark = true;
            }
        }
    }

    // At this point we know how many bytes to consume, and whether they are in
    // the right ranges of individual values to be valid

    if (isValid) {
        // Try and convert two byte sequence to Unicode using Qts own
        // decoder - and check number of codepoints returned

        QString codePoint;
        if (TEncodingHelper::isEncodingAvailable(mEncoding)) {
            codePoint = TEncodingHelper::decode(QByteArray::fromRawData(bufferData.substr(pos, eucSequenceLength).c_str(), eucSequenceLength), mEncoding);
            switch (codePoint.size()) {
            default:
                Q_UNREACHABLE(); // This can't happen, unless we got start or length wrong in std::string::substr()
                qWarning().nospace() << "TBuffer::processEUC_KRSequence(...) " << eucSequenceLength << "-byte EUC-KR sequence accepted, and it encoded to " << codePoint.size()
                                     << " QChars which does not make sense!!!";
                isValid = false;
                isToUseReplacementMark = true;
                break;
            case 2:
                // Fall-through
                [[fallthrough]];
            case 1:
                // If Qt's decoder found bad characters, update status flags to reflect that.
                if (codePoint.contains(QChar::ReplacementCharacter)) {
                    isValid = false;
                    isToUseReplacementMark = true;
                    break;
                }
#if defined(DEBUG_EUC_KR_PROCESSING)
                qDebug().nospace() << "TBuffer::processEUC_KRSequence(...) " << eucSequenceLength << "-byte EUC-KR sequence accepted, it is " << codePoint.size() << " QChar(s) long [" << codePoint
                                   << "] and is in the " << dataIdentity.c_str() << " range";
#endif
                mMudLine.append(codePoint);
                break;
            case 0:
                qWarning().nospace() << "TBuffer::processEUC_KRSequence(...) " << eucSequenceLength << "-byte EUC-KR" << "sequence accepted, but it did not encode to ANY QChar(s)!!!";
                isValid = false;
                isToUseReplacementMark = true;
            }
        } else {
            // Unable to decode it - no Qt decoder...!
#if defined(DEBUG_EUC_KR_PROCESSING)
            qDebug().nospace() << "No Qt decoder found...";
#endif
            isValid = false;
            isToUseReplacementMark = true;
        }
    }

    if (!isValid) {
#if defined(DEBUG_EUC_KR_PROCESSING)
        QString debugMsg;
        for (size_t i = 0; i < eucSequenceLength; ++i) {
            debugMsg.append(qsl("<%1>").arg(static_cast<quint8>(bufferData.at(pos + i)), 2, 16, QChar('0')));
        }
        qDebug().nospace() << "    Invalid.  Sequence bytes are: " << debugMsg;
#endif
        if (isToUseReplacementMark) {
            mMudLine.append(QChar::ReplacementCharacter);
        }
    }

    // As there is already a unit increment at the bottom of loop
    // add one less than the sequence length:
    pos += eucSequenceLength - 1;

    return true;
}

void TBuffer::encodingChanged(const QByteArray& newEncoding)
{
    if (mEncoding != newEncoding) {
        mEncoding = newEncoding;
        if (mEncoding == "GBK" || mEncoding == "GB18030" || mEncoding == "BIG5" || mEncoding == "BIG5-HKSCS" || mEncoding == "EUC-KR") {
            if (!TEncodingHelper::isEncodingAvailable(mEncoding)) {
                qCritical().nospace() << "encodingChanged(" << newEncoding << ") ERROR: This encoding cannot be handled as a required codec was not found in the system!";
            } else {
                qDebug().nospace() << "encodingChanged(" << newEncoding << ") INFO: Encoding is available and will be used.";
            }
        }
    }
}

// Count the graphemes in a QString - returning its length in terms of those:
int TBuffer::lengthInGraphemes(const QString& text)
{
    if (text.isEmpty()) {
        return 0;
    }

    QTextBoundaryFinder graphemeFinder(QTextBoundaryFinder::Grapheme, text);
    int pos = graphemeFinder.toNextBoundary();
    int count = 0;
    while (pos > 0) {
        ++count;
        pos = graphemeFinder.toNextBoundary();
    }
    return count;
}

const QList<QByteArray> TBuffer::getEncodingNames()
{
    return csmEncodingTable.getEncodingNames();
}

void TBuffer::clearSearchHighlights()
{
    for (auto& line : buffer) {
        for (auto& character : line) {
            character.mFlags &= ~TChar::AttributeFlag::Found;
        }
    }
}

void TBuffer::injectOSC8DocumentationExamples()
{
    // Inject OSC 8 hyperlink examples - concise, progressive, matching wiki docs
#if defined(DEBUG_OSC_PROCESSING)
    qDebug() << "[OSC] injectOSC8DocumentationExamples() called";
#endif

    QString output = "\n";
    output += "╔══════════════════════════════════════════════════════════════════════╗\n";
    output += "║              OSC 8 Hyperlink Examples - Try them all!                ║\n";
    output += "╚══════════════════════════════════════════════════════════════════════╝\n\n";

    // ═══════════════════════════════════════════════════════════════════
    // 1. FUNDAMENTALS - Basic links with color
    // ═══════════════════════════════════════════════════════════════════
    output += "── FUNDAMENTALS ───────────────────────────────────────────────────────\n";
    output += "Basic: ";
    output += "\x1b]8;;send:look\x1b\\\x1b[34mLook\x1b[0m\x1b]8;;\x1b\\ ";
    output += "\x1b]8;;prompt:cast%20fireball\x1b\\\x1b[33mCast Spell\x1b[0m\x1b]8;;\x1b\\ ";
    output += "\x1b]8;;https://mudlet.org\x1b\\\x1b[36mWebsite\x1b[0m\x1b]8;;\x1b\\\n";
    output += "       send:CMD  prompt:CMD (editable)  https://URL (browser)\n";
    output += "URLs:  ";
    output += "\x1b]8;;https://mudlet.org/?id=42&lang=en\x1b\\\x1b[36mWith params\x1b[0m\x1b]8;;\x1b\\ ";
    output += "\x1b]8;;https://mudlet.org/?%63%6F%6E%66%69%67=value\x1b\\\x1b[36mEncoded config\x1b[0m\x1b]8;;\x1b\\\n";
    output += "       query params preserved (?id=42&lang=en)  percent-encoded reserved names kept\n\n";

    // ═══════════════════════════════════════════════════════════════════
    // 2. JSON CONFIGURATION - Show the structure early
    // ═══════════════════════════════════════════════════════════════════
    output += "── JSON CONFIG (append ?config={...} to URI) ───────────────────────────\n";
    output += "Structure: send:cmd?config={\"style\":{...},\"menu\":[...],\"tooltip\":\"...\"}\n";
    output += "Example:   \x1b]8;;send:attack?config={\"style\":{\"color\":\"red\",\"bold\":true}}\x1b\\Attack\x1b]8;;\x1b\\ ← ";
    output += "{\"style\":{\"color\":\"red\",\"bold\":true}}\n\n";

    // ═══════════════════════════════════════════════════════════════════
    // 3. STYLING - Colors, decorations, states
    // ═══════════════════════════════════════════════════════════════════
    output += "── STYLING ────────────────────────────────────────────────────────────\n";
    output += "Colors: ";
    output += "\x1b]8;;send:c1?config={\"style\":{\"color\":\"red\"}}\x1b\\red\x1b]8;;\x1b\\ ";
    output += "\x1b]8;;send:c2?config={\"style\":{\"color\":\"#0066ff\"}}\x1b\\#0066ff\x1b]8;;\x1b\\ ";
    output += "\x1b]8;;send:c3?config={\"style\":{\"color\":\"rgb(0,200,100)\"}}\x1b\\rgb()\x1b]8;;\x1b\\ ";
    output += "\x1b]8;;send:c4?config={\"style\":{\"bg\":\"yellow\",\"color\":\"black\"}}\x1b\\bg:yellow\x1b]8;;\x1b\\\n";
    output += "Text:   ";
    output += "\x1b]8;;send:t1?config={\"style\":{\"bold\":true}}\x1b\\bold\x1b]8;;\x1b\\ ";
    output += "\x1b]8;;send:t2?config={\"style\":{\"italic\":true}}\x1b\\italic\x1b]8;;\x1b\\ ";
    output += "\x1b]8;;send:t3?config={\"style\":{\"underline\":true}}\x1b\\underline\x1b]8;;\x1b\\ ";
    output += "\x1b]8;;send:t4?config={\"style\":{\"underline\":\"wavy\",\"text-decoration-color\":\"red\"}}\x1b\\wavy-red\x1b]8;;\x1b\\ ";
    output += "\x1b]8;;send:t5?config={\"style\":{\"strikethrough\":true}}\x1b\\strike\x1b]8;;\x1b\\\n";
    output += "States: ";
    output += "\x1b]8;;send:s1?config={\"style\":{\"color\":\"blue\",\"hover\":{\"color\":\"red\"}}}\x1b\\hover:red\x1b]8;;\x1b\\ ";
    output += "\x1b]8;;send:s2?config={\"style\":{\"bg\":\"green\",\"active\":{\"bg\":\"darkgreen\"}}}\x1b\\active:dark\x1b]8;;\x1b\\ ";
    output += "\x1b]8;;send:s3?config={\"style\":{\"link\":{\"color\":\"blue\"},\"visited\":{\"color\":\"purple\"}}}\x1b\\visited:purple\x1b]8;;\x1b\\\n\n";

    // ═══════════════════════════════════════════════════════════════════
    // 4. MENUS & TOOLTIPS
    // ═══════════════════════════════════════════════════════════════════
    output += "── MENUS & TOOLTIPS ───────────────────────────────────────────────────\n";
    output += "Menu: \x1b]8;;send:attack?config={\"menu\":[{\"Strike\":\"send:strike\"},{\"Power\":\"send:power\"},\"-\",{\"Flee\":\"send:flee\"}]}\x1b\\⚔️ Combat\x1b]8;;\x1b\\ ← right-click "
              "(left=primary, \"-\"=separator)\n";
    output += "Tip:  \x1b]8;;send:item?config={\"tooltip\":\"Legendary sword +5 damage\",\"style\":{\"color\":\"orange\"}}\x1b\\🗡️ Flaming Blade\x1b]8;;\x1b\\ ← hover for tooltip\n";
    output += "Both: \x1b]8;;send:spell?config={\"menu\":[{\"Fire\":\"send:fireball\"},{\"Ice\":\"send:icebolt\"}],\"tooltip\":\"Magic spells\",\"style\":{\"color\":\"#9966ff\"}}\x1b\\✨ "
              "Magic\x1b]8;;\x1b\\\n\n";

    // ═══════════════════════════════════════════════════════════════════
    // 5. VISIBILITY - Hide/reveal
    // ═══════════════════════════════════════════════════════════════════
    output += "── VISIBILITY (auto-hide/reveal) ──────────────────────────────────────\n";
    output += "Click-hide: \x1b]8;;send:h1?config={\"style\":{\"color\":\"yellow\"},\"visibility\":{\"action\":\"conceal\",\"delay\":2000}}\x1b\\I vanish 2s after click\x1b]8;;\x1b\\\n";
    output += "Expire:     \x1b]8;;send:h2?config={\"style\":{\"color\":\"cyan\"},\"visibility\":{\"action\":\"conceal\",\"expire\":{\"input\":true}}}\x1b\\Send any command to hide\x1b]8;;\x1b\\\n";
    output += "Reveal:     Wait... \x1b]8;;send:h3?config={\"style\":{\"color\":\"lime\",\"bold\":true},\"visibility\":{\"action\":\"reveal\",\"delay\":5000}}\x1b\\I APPEAR!\x1b]8;;\x1b\\ (5 "
              "seconds)\n";
    output += "Wide chars: \x1b]8;;send:h4?config={\"style\":{\"bg\":\"blue\"},\"visibility\":{\"action\":\"conceal\",\"delay\":2000}}\x1b\\🎉🚀💎\x1b]8;;\x1b\\ ← emojis handled correctly\n\n";

    // ═══════════════════════════════════════════════════════════════════
    // 6. SPOILERS - Click to reveal
    // ═══════════════════════════════════════════════════════════════════
    output += "── SPOILERS (click-to-reveal) ─────────────────────────────────────────\n";
    output += "The answer is: \x1b]8;;send:sp1?config={\"spoiler\":true,\"disabled\":true}\x1b\\42\x1b]8;;\x1b\\  ";
    output += "Secret code: \x1b]8;;https://www.mudlet.org?config={\"spoiler\":true,\"style\":{\"color\":\"yellow\"}}\x1b\\XYZZY\x1b]8;;\x1b\\  ";
    output += "Emoji secret: \x1b]8;;send:sp3?config={\"spoiler\":true,\"disabled\":true}\x1b\\🔮💀🗝️\x1b]8;;\x1b\\\n\n";

    // ═══════════════════════════════════════════════════════════════════
    // 7. DISABLED - Non-clickable
    // ═══════════════════════════════════════════════════════════════════
    output += "── DISABLED LINKS ─────────────────────────────────────────────────────\n";
    output += "\x1b]8;;send:d1?config={\"disabled\":true,\"style\":{\"color\":\"gray\",\"strikethrough\":true}}\x1b\\Locked\x1b]8;;\x1b\\ ";
    output += "\x1b]8;;send:d2?config={\"disabled\":true,\"style\":{\"color\":\"#666\"},\"tooltip\":\"Requires level 10\"}\x1b\\Premium\x1b]8;;\x1b\\ ";
    output += "← click/right-click blocked, tooltip works\n\n";

    // ═══════════════════════════════════════════════════════════════════
    // 8. SELECTION - Toggle states
    // ═══════════════════════════════════════════════════════════════════
    output += "── SELECTION (stateful toggles) ───────────────────────────────────────\n";
    output += "Radio:    ";
    output += "\x1b]8;;send:easy?config={\"selection\":{\"group\":\"diff\",\"value\":\"easy\",\"exclusive\":true},\"style\":{\"color\":\"#8f8\",\"selected\":{\"bg\":\"green\",\"bold\":true}}}"
              "\x1b\\Easy\x1b]8;;\x1b\\ ";
    output += "\x1b]8;;send:hard?config={\"selection\":{\"group\":\"diff\",\"value\":\"hard\",\"exclusive\":true},\"style\":{\"color\":\"#f88\",\"selected\":{\"bg\":\"red\",\"bold\":true}}}"
              "\x1b\\Hard\x1b]8;;\x1b\\ (one at a time)\n";
    output += "Checkbox: ";
    output += "\x1b]8;;send:b1?config={\"selection\":{\"group\":\"buffs\",\"value\":\"str\",\"exclusive\":false},\"style\":{\"selected\":{\"bg\":\"#f60\",\"bold\":true}}}\x1b\\[STR]\x1b]8;;\x1b\\ ";
    output += "\x1b]8;;send:b2?config={\"selection\":{\"group\":\"buffs\",\"value\":\"dex\",\"exclusive\":false},\"style\":{\"selected\":{\"bg\":\"#08f\",\"bold\":true}}}\x1b\\[DEX]\x1b]8;;\x1b\\ ";
    output += "\x1b]8;;send:b3?config={\"selection\":{\"group\":\"buffs\",\"value\":\"int\",\"exclusive\":false},\"style\":{\"selected\":{\"bg\":\"#a0f\",\"bold\":true}}}\x1b\\[INT]\x1b]8;;\x1b\\ "
              "(multi-select)\n";
    output += "Server receives: &selected=true/false in callback\n\n";

    // ═══════════════════════════════════════════════════════════════════
    // 9. COMPACT SYNTAX - Shorthand keys
    // ═══════════════════════════════════════════════════════════════════
    output += "── COMPACT SYNTAX (shorthand) ─────────────────────────────────────────\n";
    output += "Full:  {\"style\":{\"color\":\"red\",\"bold\":true},\"tooltip\":\"info\"}\n";
    output += "Short: {\"s\":{\"c\":\"red\",\"b\":true},\"t\":\"info\"}  ";
    output += "\x1b]8;;send:sh1?config={\"s\":{\"c\":\"red\",\"b\":true},\"t\":\"Shorthand!\"}\x1b\\Try me\x1b]8;;\x1b\\\n";
    output += "Keys: s=style c=color bg=bg b=bold i=italic u=underline t=tooltip m=menu\n\n";

    // ═══════════════════════════════════════════════════════════════════
    // 10. PRESETS - Reusable styles
    // ═══════════════════════════════════════════════════════════════════
    output += "── PRESETS (define once, reuse) ───────────────────────────────────────\n";
    // Define presets (invisible)
    output += "\x1b]8;;preset:btn?config={\"s\":{\"bg\":\"#07f\",\"c\":\"white\",\"b\":true},\"t\":\"Button preset\"}\x1b\\\x1b]8;;\x1b\\";
    output += "\x1b]8;;preset:warn?config={\"s\":{\"bg\":\"orange\",\"c\":\"black\",\"b\":true}}\x1b\\\x1b]8;;\x1b\\";
    output += "\x1b]8;;preset:danger?config={\"s\":{\"bg\":\"red\",\"c\":\"white\",\"b\":true}}\x1b\\\x1b]8;;\x1b\\";
    output += "Define: preset:NAME?config={...}  Use: ?preset=NAME\n";
    output += "Usage:  ";
    output += "\x1b]8;;send:p1?preset=btn\x1b\\Button\x1b]8;;\x1b\\ ";
    output += "\x1b]8;;send:p2?preset=warn\x1b\\Warning\x1b]8;;\x1b\\ ";
    output += "\x1b]8;;send:p3?preset=danger\x1b\\Danger\x1b]8;;\x1b\\ ";
    output += "\x1b]8;;send:p4?preset=btn&config={\"s\":{\"c\":\"yellow\"}}\x1b\\Override\x1b]8;;\x1b\\\n\n";

    // ═══════════════════════════════════════════════════════════════════
    // REAL-WORLD SHOWCASE
    // ═══════════════════════════════════════════════════════════════════
    output += "── REAL-WORLD EXAMPLE ─────────────────────────────────────────────────\n";
    output += "Nav: ";
    output += "\x1b]8;;send:north?config={\"s\":{\"c\":\"#0af\",\"b\":true,\"h\":{\"u\":true}}}\x1b\\North\x1b]8;;\x1b\\ ";
    output += "\x1b]8;;send:south?config={\"s\":{\"c\":\"#0af\",\"b\":true,\"h\":{\"u\":true}}}\x1b\\South\x1b]8;;\x1b\\ ";
    output += "\x1b]8;;send:east?config={\"s\":{\"c\":\"#0af\",\"b\":true,\"h\":{\"u\":true}}}\x1b\\East\x1b]8;;\x1b\\ ";
    output += "\x1b]8;;send:west?config={\"s\":{\"c\":\"#0af\",\"b\":true,\"h\":{\"u\":true}}}\x1b\\West\x1b]8;;\x1b\\\n";
    output += "Item: "
              "\x1b]8;;send:sword?config={\"style\":{\"color\":\"#f80\",\"bold\":true,\"hover\":{\"bg\":\"#fc9\",\"color\":\"black\"}},\"menu\":[{\"Equip\":\"send:equip\"},{\"Examine\":\"send:exam\"}"
              ",\"-\",{\"Drop\":\"send:drop\"}],\"tooltip\":\"+5 Fire Damage\"}\x1b\\🗡️ Flaming Sword\x1b]8;;\x1b\\\n\n";

    output += "───────────────────────────────────────────────────────────────────────\n";
    output += "Docs: https://wiki.mudlet.org/w/Area_51#OSC_8:_Hyperlink_Protocol\n";

    // Process the output through the normal text processing pipeline
    // Skip trigger processing to avoid re-entrancy issues during injection
    std::string outputBytes = output.toStdString();
#if defined(DEBUG_OSC_PROCESSING)
    qDebug() << "[OSC] About to process documentation examples through translateToPlainText, length:" << outputBytes.length();
#endif
    mSkipTriggerProcessing = true;
    translateToPlainText(outputBytes, true); // Mark as from server
    mSkipTriggerProcessing = false;
}

// ============================================================================
// Link States Implementation with Accessibility Best Practices
// ============================================================================

Mudlet::HyperlinkStyling::StateStyle Mudlet::HyperlinkStyling::getEffectiveStyle() const
{
    Mudlet::HyperlinkStyling::StateStyle effective;

    // Start with base/default styling
    effective.foregroundColor = foregroundColor;
    effective.backgroundColor = backgroundColor;
    effective.underlineColor = underlineColor;
    effective.overlineColor = overlineColor;
    effective.strikeoutColor = strikeoutColor;
    effective.hasForegroundColor = hasForegroundColor;
    effective.hasBackgroundColor = hasBackgroundColor;
    effective.hasUnderlineColor = hasUnderlineColor;
    effective.hasOverlineColor = hasOverlineColor;
    effective.hasStrikeoutColor = hasStrikeoutColor;
    effective.isBold = isBold;
    effective.isItalic = isItalic;
    effective.isUnderlined = isUnderlined;
    effective.isStrikeOut = isStrikeOut;
    effective.isOverlined = isOverlined;
    effective.underlineStyle = underlineStyle;

    // Apply :any-link styles (applies to both :link and :visited)
    if (anyLinkStyle.hasCustomStyling) {
        if (anyLinkStyle.hasForegroundColor)
            effective.foregroundColor = anyLinkStyle.foregroundColor;
        if (anyLinkStyle.hasBackgroundColor)
            effective.backgroundColor = anyLinkStyle.backgroundColor;
        if (anyLinkStyle.hasUnderlineColor)
            effective.underlineColor = anyLinkStyle.underlineColor;
        if (anyLinkStyle.hasOverlineColor)
            effective.overlineColor = anyLinkStyle.overlineColor;
        if (anyLinkStyle.hasStrikeoutColor)
            effective.strikeoutColor = anyLinkStyle.strikeoutColor;
        effective.isBold = anyLinkStyle.isBold;
        effective.isItalic = anyLinkStyle.isItalic;
        effective.isUnderlined = anyLinkStyle.isUnderlined;
        effective.isStrikeOut = anyLinkStyle.isStrikeOut;
        effective.isOverlined = anyLinkStyle.isOverlined;
        effective.underlineStyle = anyLinkStyle.underlineStyle;
    }

    // Apply state-specific styles with proper cascade order
    // Priority: disabled > selected > active > focus-visible > focus > hover > visited > link > default
    const StateStyle* stateStyle = nullptr;

    switch (currentState) {
    case StateDisabled:
        if (disabledStyle.hasCustomStyling) {
            stateStyle = &disabledStyle;
#if defined(DEBUG_OSC_PROCESSING)
            qDebug() << "[OSC] Using disabled style - hasCustomStyling:" << disabledStyle.hasCustomStyling << "hasForegroundColor:" << disabledStyle.hasForegroundColor
                     << "foregroundColor:" << (disabledStyle.hasForegroundColor ? disabledStyle.foregroundColor.name() : "none") << "hasBackgroundColor:" << disabledStyle.hasBackgroundColor
                     << "backgroundColor:" << (disabledStyle.hasBackgroundColor ? disabledStyle.backgroundColor.name() : "none") << "isBold:" << disabledStyle.isBold
                     << "isStrikeOut:" << disabledStyle.isStrikeOut;
#endif
        } else {
#if defined(DEBUG_OSC_PROCESSING)
            qDebug() << "[OSC] Disabled style has no custom styling - hasCustomStyling:" << disabledStyle.hasCustomStyling;
#endif
        }
        break;
    case StateSelected:
        if (selectedStyle.hasCustomStyling) {
            stateStyle = &selectedStyle;
#if defined(DEBUG_OSC_PROCESSING)
            qDebug() << "[OSC] Using selected style - hasCustomStyling:" << selectedStyle.hasCustomStyling << "hasForegroundColor:" << selectedStyle.hasForegroundColor
                     << "foregroundColor:" << (selectedStyle.hasForegroundColor ? selectedStyle.foregroundColor.name() : "none") << "hasBackgroundColor:" << selectedStyle.hasBackgroundColor
                     << "backgroundColor:" << (selectedStyle.hasBackgroundColor ? selectedStyle.backgroundColor.name() : "none") << "isBold:" << selectedStyle.isBold;
#endif
        } else {
#if defined(DEBUG_OSC_PROCESSING)
            qDebug() << "[OSC] Selected style has no custom styling - hasCustomStyling:" << selectedStyle.hasCustomStyling;
#endif
        }
        break;
    case StateVisited:
        if (visitedStyle.hasCustomStyling)
            stateStyle = &visitedStyle;
        break;
    case StateHover:
        if (hoverStyle.hasCustomStyling)
            stateStyle = &hoverStyle;
        break;
    case StateActive:
        if (activeStyle.hasCustomStyling)
            stateStyle = &activeStyle;
        break;
    case StateFocus:
        if (focusStyle.hasCustomStyling)
            stateStyle = &focusStyle;
        break;
    case StateFocusVisible:
        if (focusVisibleStyle.hasCustomStyling)
            stateStyle = &focusVisibleStyle;
        break;
    case StateDefault:
    default:
        if (linkStyle.hasCustomStyling)
            stateStyle = &linkStyle;
        break;
    }

    // Apply the selected state style
    if (stateStyle) {
        if (stateStyle->hasForegroundColor) {
            effective.foregroundColor = stateStyle->foregroundColor;
            effective.hasForegroundColor = true;
        }
        if (stateStyle->hasBackgroundColor) {
            effective.backgroundColor = stateStyle->backgroundColor;
            effective.hasBackgroundColor = true;
        }
        if (stateStyle->hasUnderlineColor) {
            effective.underlineColor = stateStyle->underlineColor;
            effective.hasUnderlineColor = true;
        }
        if (stateStyle->hasOverlineColor) {
            effective.overlineColor = stateStyle->overlineColor;
            effective.hasOverlineColor = true;
        }
        if (stateStyle->hasStrikeoutColor) {
            effective.strikeoutColor = stateStyle->strikeoutColor;
            effective.hasStrikeoutColor = true;
        }
        effective.isBold = stateStyle->isBold;
        effective.isItalic = stateStyle->isItalic;
        effective.isUnderlined = stateStyle->isUnderlined;
        effective.isStrikeOut = stateStyle->isStrikeOut;
        effective.isOverlined = stateStyle->isOverlined;
        effective.underlineStyle = stateStyle->underlineStyle;
        effective.hasCustomStyling = true;
    }

    // Even if the current state doesn't have custom styling, we need to mark that
    // the link has SOME pseudo-class styling so updateLinkCharacters() processes it.
    // This ensures ANSI base links with only :hover (but no :link) styling get updated.
    if (!effective.hasCustomStyling) {
        // Check if ANY pseudo-class state has custom styling
        if (linkStyle.hasCustomStyling || visitedStyle.hasCustomStyling || hoverStyle.hasCustomStyling || activeStyle.hasCustomStyling || focusStyle.hasCustomStyling
            || focusVisibleStyle.hasCustomStyling || anyLinkStyle.hasCustomStyling || selectedStyle.hasCustomStyling || disabledStyle.hasCustomStyling) {
            effective.hasCustomStyling = true;
        }
    }

    return effective;
}

void TBuffer::applyAccessibilityEnhancements(Mudlet::HyperlinkStyling& styling)
{
    // Ensure focus states are appropriately visible
    if (!styling.focusStyle.hasCustomStyling && !styling.focusVisibleStyle.hasCustomStyling) {
        // Provide default focus styling for accessibility
        styling.focusStyle.hasCustomStyling = true;
        styling.focusStyle.isUnderlined = true;
        styling.focusStyle.underlineStyle = Mudlet::HyperlinkStyling::UnderlineSolid;
        styling.focusStyle.hasUnderlineColor = true;
        styling.focusStyle.underlineColor = QColor(0, 120, 215); // Windows accent blue

        styling.focusVisibleStyle = styling.focusStyle; // Copy focus style to focus-visible
    }
}

// Link state management methods for interactive pseudo-classes
void TBuffer::setLinkState(int linkIndex, Mudlet::HyperlinkStyling::LinkState state)
{
    if (linkIndex <= 0) {
        return; // Invalid link index
    }

    mLinkStates[linkIndex] = state;

#if defined(DEBUG_OSC_PROCESSING)
    QString stateName;
    switch (state) {
    case Mudlet::HyperlinkStyling::StateDefault:
        stateName = "Default";
        break;
    case Mudlet::HyperlinkStyling::StateVisited:
        stateName = "Visited";
        break;
    case Mudlet::HyperlinkStyling::StateHover:
        stateName = "Hover";
        break;
    case Mudlet::HyperlinkStyling::StateActive:
        stateName = "Active";
        break;
    case Mudlet::HyperlinkStyling::StateFocus:
        stateName = "Focus";
        break;
    case Mudlet::HyperlinkStyling::StateFocusVisible:
        stateName = "FocusVisible";
        break;
    case Mudlet::HyperlinkStyling::StateSelected:
        stateName = "Selected";
        break;
    case Mudlet::HyperlinkStyling::StateDisabled:
        stateName = "Disabled";
        break;
    }
    qDebug() << "[OSC] Link" << linkIndex << "state changed to:" << stateName;
#endif
}

Mudlet::HyperlinkStyling::LinkState TBuffer::getLinkState(int linkIndex) const
{
    if (linkIndex <= 0) {
        return Mudlet::HyperlinkStyling::StateDefault;
    }
    return mLinkStates.value(linkIndex, Mudlet::HyperlinkStyling::StateDefault);
}

Mudlet::HyperlinkStyling TBuffer::getEffectiveHyperlinkStyling(int linkIndex) const
{
    if (linkIndex <= 0) {
        return Mudlet::HyperlinkStyling(); // Return default styling
    } else {
        // Get the stored styling for this link from mLinkStore
        Mudlet::HyperlinkStyling styling = mLinkStore.getStyling(linkIndex);

        // Update the current state based on tracked state
        styling.currentState = getLinkState(linkIndex);

        // The HyperlinkStyling::getEffectiveStyle() method will compute the
        // effective styling based on currentState, cascading from base -> :any-link -> state-specific
        // However, for rendering we need to apply it to the base styling properties
        // so the rendering code can use it directly

        auto effective = styling.getEffectiveStyle();

#if defined(DEBUG_OSC_PROCESSING)
        qDebug() << "[OSC] getEffectiveHyperlinkStyling for link" << linkIndex << "state:" << styling.currentState << "isSpoiler:" << styling.isSpoiler
                 << "base hasBackgroundColor:" << styling.hasBackgroundColor << "base backgroundColor:" << (styling.hasBackgroundColor ? styling.backgroundColor.name() : "none")
                 << "effective hasForegroundColor:" << effective.hasForegroundColor << "effective foregroundColor:" << (effective.hasForegroundColor ? effective.foregroundColor.name() : "none")
                 << "effective hasBackgroundColor:" << effective.hasBackgroundColor << "effective backgroundColor:" << (effective.hasBackgroundColor ? effective.backgroundColor.name() : "none")
                 << "effective isBold:" << effective.isBold;
#endif

        // Copy effective state back to base properties for rendering
        styling.foregroundColor = effective.foregroundColor;
        styling.backgroundColor = effective.backgroundColor;
        styling.underlineColor = effective.underlineColor;
        styling.overlineColor = effective.overlineColor;
        styling.strikeoutColor = effective.strikeoutColor;
        styling.hasForegroundColor = effective.hasForegroundColor;
        styling.hasBackgroundColor = effective.hasBackgroundColor;
        styling.hasUnderlineColor = effective.hasUnderlineColor;
        styling.hasOverlineColor = effective.hasOverlineColor;
        styling.hasStrikeoutColor = effective.hasStrikeoutColor;
        styling.isBold = effective.isBold;
        styling.isItalic = effective.isItalic;
        styling.isUnderlined = effective.isUnderlined;
        styling.isStrikeOut = effective.isStrikeOut;
        styling.isOverlined = effective.isOverlined;
        styling.underlineStyle = effective.underlineStyle;
        styling.hasCustomStyling = effective.hasCustomStyling; // CRITICAL: Copy hasCustomStyling flag

        // SPOILER FIX: Ensure spoiler backgrounds are always applied regardless of pseudo-class cascade
        // This fixes the issue where spoilers show background on hover but not on initial display
        if (styling.isSpoiler) {
            // Get the base styling directly from link store to ensure spoiler background is preserved
            Mudlet::HyperlinkStyling baseStyling = mLinkStore.getStyling(linkIndex);
            if (baseStyling.hasBackgroundColor) {
                // For spoilers, ALWAYS use the base background that was auto-generated
                // Ignore whatever the pseudo-class cascade decided
                styling.backgroundColor = baseStyling.backgroundColor;
                styling.hasBackgroundColor = true;
                styling.hasCustomStyling = true;
#if defined(DEBUG_OSC_PROCESSING)
                qDebug() << "[OSC] SPOILER FIX: Forced spoiler background" << baseStyling.backgroundColor.name() << "over effective background"
                         << (effective.hasBackgroundColor ? effective.backgroundColor.name() : "none") << "for link" << linkIndex;
#endif
            }
        }

        return styling;
    }
}

void TBuffer::setHoveredLink(int linkIndex)
{
    int previousHoveredLink = mCurrentHoveredLinkIndex;
    mCurrentHoveredLinkIndex = linkIndex;

    // Reset previous hovered link to its base state (default, visited, or selected)
    if (previousHoveredLink > 0 && previousHoveredLink != linkIndex) {
        auto currentState = getLinkState(previousHoveredLink);

        if (currentState == Mudlet::HyperlinkStyling::StateHover) {
            // Return to the appropriate base state:
            // Priority: disabled > selected > visited > default
            // Disabled links should stay disabled
            Mudlet::HyperlinkStyling linkStyling = getEffectiveHyperlinkStyling(previousHoveredLink);
            if (linkStyling.selection.hasSelectionSettings && linkStyling.selection.disabled) {
                setLinkState(previousHoveredLink, Mudlet::HyperlinkStyling::StateDisabled);
            } else if (isLinkSelected(previousHoveredLink)) {
                setLinkState(previousHoveredLink, Mudlet::HyperlinkStyling::StateSelected);
            } else if (isLinkVisited(previousHoveredLink)) {
                setLinkState(previousHoveredLink, Mudlet::HyperlinkStyling::StateVisited);
            } else {
                setLinkState(previousHoveredLink, Mudlet::HyperlinkStyling::StateDefault);
            }

            updateLinkCharacters(previousHoveredLink); // Update displayed characters
        }
    }

    // Set new link to hover state
    if (linkIndex > 0) {
        auto currentState = getLinkState(linkIndex);
        // Don't override active or disabled states with hover
        // Disabled links should stay disabled and show their disabled styling
        if (currentState != Mudlet::HyperlinkStyling::StateActive && currentState != Mudlet::HyperlinkStyling::StateDisabled) {
            setLinkState(linkIndex, Mudlet::HyperlinkStyling::StateHover);
            updateLinkCharacters(linkIndex); // Update displayed characters
        }
    }
}

void TBuffer::setActiveLink(int linkIndex)
{
    int previousActiveLink = mCurrentActiveLinkIndex;
    mCurrentActiveLinkIndex = linkIndex;

    // Reset previous active link
    if (previousActiveLink > 0 && previousActiveLink != linkIndex) {
        // Return to appropriate state based on priority: disabled > hover > selected > visited > default
        Mudlet::HyperlinkStyling linkStyling = getEffectiveHyperlinkStyling(previousActiveLink);
        if (linkStyling.selection.hasSelectionSettings && linkStyling.selection.disabled) {
            setLinkState(previousActiveLink, Mudlet::HyperlinkStyling::StateDisabled);
        } else if (previousActiveLink == mCurrentHoveredLinkIndex) {
            setLinkState(previousActiveLink, Mudlet::HyperlinkStyling::StateHover);
        } else if (isLinkSelected(previousActiveLink)) {
            setLinkState(previousActiveLink, Mudlet::HyperlinkStyling::StateSelected);
        } else if (isLinkVisited(previousActiveLink)) {
            setLinkState(previousActiveLink, Mudlet::HyperlinkStyling::StateVisited);
        } else {
            setLinkState(previousActiveLink, Mudlet::HyperlinkStyling::StateDefault);
        }

        updateLinkCharacters(previousActiveLink); // Update displayed characters
    }

    // Set new link to active state
    if (linkIndex > 0) {
        setLinkState(linkIndex, Mudlet::HyperlinkStyling::StateActive);
        updateLinkCharacters(linkIndex); // Update displayed characters
    }
}

void TBuffer::setFocusedLink(int linkIndex)
{
    int previousFocusedLink = mCurrentFocusedLinkIndex;
    mCurrentFocusedLinkIndex = linkIndex;

    // Reset previous focused link to appropriate base state
    if (previousFocusedLink > 0 && previousFocusedLink != linkIndex) {
        // Preserve disabled state, otherwise return to base state
        Mudlet::HyperlinkStyling linkStyling = getEffectiveHyperlinkStyling(previousFocusedLink);
        if (linkStyling.selection.hasSelectionSettings && linkStyling.selection.disabled) {
            setLinkState(previousFocusedLink, Mudlet::HyperlinkStyling::StateDisabled);
        } else if (isLinkSelected(previousFocusedLink)) {
            setLinkState(previousFocusedLink, Mudlet::HyperlinkStyling::StateSelected);
        } else if (isLinkVisited(previousFocusedLink)) {
            setLinkState(previousFocusedLink, Mudlet::HyperlinkStyling::StateVisited);
        } else {
            setLinkState(previousFocusedLink, Mudlet::HyperlinkStyling::StateDefault);
        }
    }

    // Set new link to focus state
    if (linkIndex > 0) {
        // Determine if focus came from keyboard (would use StateFocusVisible)
        // For now, we use StateFocus
        setLinkState(linkIndex, Mudlet::HyperlinkStyling::StateFocus);
    }
}

void TBuffer::markLinkAsVisited(int linkIndex)
{
    if (linkIndex > 0) {
        mVisitedLinks[linkIndex] = true;

        // If the link is not in an interactive state (hover/active/focus),
        // update it to visited state immediately
        auto currentState = getLinkState(linkIndex);

        if (currentState == Mudlet::HyperlinkStyling::StateDefault) {
            setLinkState(linkIndex, Mudlet::HyperlinkStyling::StateVisited);
            updateLinkCharacters(linkIndex);
        }

#if defined(DEBUG_OSC_PROCESSING)
        qDebug() << "[OSC] Link" << linkIndex << "marked as visited";
#endif
    }
}

bool TBuffer::isLinkVisited(int linkIndex) const
{
    return mVisitedLinks.value(linkIndex, false);
}

void TBuffer::setLinkSelected(int linkIndex, bool selected)
{
    mLinkSelectionState[linkIndex] = selected;
}

bool TBuffer::isLinkSelected(int linkIndex) const
{
    return mLinkSelectionState.value(linkIndex, false);
}

void TBuffer::revealSpoilerLink(int linkIndex)
{
    if (!mLinkOriginalText.contains(linkIndex)) {
        return;
    }

    QString originalText = mLinkOriginalText[linkIndex];

#if defined(DEBUG_OSC_PROCESSING)
    qDebug() << "[OSC] Revealing spoiler link" << linkIndex << "with text:" << originalText;
#endif

    if (!mLinkStore.hasStyling(linkIndex)) {
        return;
    }

    Mudlet::HyperlinkStyling styling = mLinkStore.getStyling(linkIndex);

    if (styling.isSpoiler) {
        QStringList hints = mLinkStore.getHintsConst(linkIndex);
        if (!hints.isEmpty() && hints.first() == QObject::tr("Click to reveal")) {
            mLinkStore.getHints(linkIndex).clear();

#if defined(DEBUG_OSC_PROCESSING)
            qDebug() << "[OSC] Cleared auto-generated 'Click to reveal' tooltip for spoiler link" << linkIndex;
#endif
        }

        if (styling.hasBackgroundColor && mLinkOriginalBackgrounds.contains(linkIndex)) {
            QColor originalBackground = mLinkOriginalBackgrounds[linkIndex];
            styling.backgroundColor = originalBackground;

            mLinkStore.setStyling(linkIndex, styling);

#if defined(DEBUG_OSC_PROCESSING)
            qDebug() << "[OSC] Restored original background color for spoiler link" << linkIndex;
#endif
        }
    }

    // Find all characters with this link index and restore their original text
    int charIndex = 0;
    for (size_t lineNum = 0; lineNum < buffer.size(); ++lineNum) {
        auto& line = buffer[lineNum];
        QString lineText = lineBuffer.at(static_cast<int>(lineNum));
        bool lineModified = false;

        for (size_t charPos = 0; charPos < line.size(); ++charPos) {
            if (line[charPos].linkIndex() == linkIndex && charIndex < originalText.length()) {
                // Replace the space character with the original character
                QChar originalChar = originalText[charIndex];
                if (charPos < static_cast<size_t>(lineText.length())) {
                    lineText[static_cast<int>(charPos)] = originalChar;
                    lineModified = true;
                }
                charIndex++;
            }
        }

        if (lineModified) {
            lineBuffer[static_cast<int>(lineNum)] = lineText;
        }
    }

    updateLinkCharacters(linkIndex);

    mLinkOriginalText.remove(linkIndex);

    if (mpConsole) {
        mpConsole->update();
    }
}

void TBuffer::clearGroupSelection(const QString& group, const QString& exceptValue)
{
    int maxLinkId = mLinkStore.getCurrentLinkID();
    int clearedCount = 0;

    for (int linkIndex = 1; linkIndex <= maxLinkId; ++linkIndex) {
        if (!mLinkStore.hasStyling(linkIndex)) {
            continue;
        }

        auto styling = mLinkStore.getStyling(linkIndex);
        if (styling.selection.hasSelectionSettings && styling.selection.group == group && styling.selection.value != exceptValue) {
            if (mpConsole) {
                mpConsole->getHyperlinkSelectionManager().setSelected(styling.selection.group, styling.selection.value, false);
            }

            setLinkSelected(linkIndex, false);
            if (styling.selection.disabled) {
                setLinkState(linkIndex, Mudlet::HyperlinkStyling::StateDisabled);
            } else {
                setLinkState(linkIndex, Mudlet::HyperlinkStyling::StateDefault);
            }

            updateLinkCharacters(linkIndex);
            clearedCount++;

#if defined(DEBUG_OSC_PROCESSING)
            qDebug() << "[OSC] clearGroupSelection: Deselected link" << linkIndex << "group:" << group << "value:" << styling.selection.value;
#endif
        }
    }

#if defined(DEBUG_OSC_PROCESSING)
    qDebug() << "[OSC] clearGroupSelection: Cleared" << clearedCount << "links in group" << group;
#endif
}

void TBuffer::applyPendingSelectionStyling()
{
    if (!mPendingSelectionStyling.isEmpty()) {
#if defined(DEBUG_OSC_PROCESSING)
        qDebug() << "[OSC] Processing pending selection styling for" << mPendingSelectionStyling.size() << "links";
#endif
        for (const int linkId : std::as_const(mPendingSelectionStyling)) {
            updateLinkCharacters(linkId);
        }
        mPendingSelectionStyling.clear();
    }
}

int TBuffer::getLinkIndexAt(int line, int column) const
{
    // Validate line bounds
    if (line < 0 || line >= lineBuffer.size()) {
        return 0;
    }

    const auto& bufferLine = buffer.at(line);

    // Validate column bounds
    if (column < 0 || column >= static_cast<int>(bufferLine.size())) {
        return 0;
    }

    return bufferLine.at(column).linkIndex();
}

bool TBuffer::findNextLink(int fromLine, int fromColumn, int& outLine, int& outColumn, bool* wrapped) const
{
    if (buffer.empty()) {
        return false;
    }

    const int lineCount = static_cast<int>(buffer.size());
    if (fromLine < 0 || fromLine >= lineCount) {
        return false;
    }
    fromColumn = qBound(-1, fromColumn, static_cast<int>(buffer.at(fromLine).size()) - 1);

    if (wrapped) {
        *wrapped = false;
    }

    int currentLinkAtStart = getLinkIndexAt(fromLine, fromColumn);

    for (int line = fromLine; line < lineCount; ++line) {
        const auto& bufferLine = buffer.at(line);
        int startCol = (line == fromLine) ? fromColumn + 1 : 0;

        for (int col = startCol; col < static_cast<int>(bufferLine.size()); ++col) {
            int linkIdx = bufferLine.at(col).linkIndex();
            if (linkIdx > 0 && linkIdx != currentLinkAtStart) {
                outLine = line;
                outColumn = col;
                return true;
            }
        }
    }

    // Wrap around: search from the beginning up to the starting position
    for (int line = 0; line <= fromLine; ++line) {
        const auto& bufferLine = buffer.at(line);
        int endCol = (line == fromLine) ? qMin(fromColumn + 1, static_cast<int>(bufferLine.size())) : static_cast<int>(bufferLine.size());

        for (int col = 0; col < endCol; ++col) {
            int linkIdx = bufferLine.at(col).linkIndex();
            if (linkIdx > 0 && linkIdx != currentLinkAtStart) {
                outLine = line;
                outColumn = col;
                if (wrapped) {
                    *wrapped = true;
                }
                return true;
            }
        }
    }

    return false;
}

bool TBuffer::findPreviousLink(int fromLine, int fromColumn, int& outLine, int& outColumn, bool* wrapped) const
{
    if (buffer.empty()) {
        return false;
    }

    const int lineCount = static_cast<int>(buffer.size());
    if (fromLine < 0 || fromLine >= lineCount) {
        return false;
    }
    if (buffer.at(fromLine).empty()) {
        fromColumn = -1;
    } else {
        fromColumn = qBound(0, fromColumn, static_cast<int>(buffer.at(fromLine).size()) - 1);
    }

    if (wrapped) {
        *wrapped = false;
    }

    int currentLinkAtStart = getLinkIndexAt(fromLine, fromColumn);

    for (int line = fromLine; line >= 0; --line) {
        const auto& bufferLine = buffer.at(line);
        int startCol = (line == fromLine) ? fromColumn - 1 : static_cast<int>(bufferLine.size()) - 1;

        for (int col = startCol; col >= 0; --col) {
            int linkIdx = bufferLine.at(col).linkIndex();
            if (linkIdx > 0 && linkIdx != currentLinkAtStart) {
                // Found a different link — now find its start (first column with this linkIdx on this line)
                int linkStart = col;
                while (linkStart > 0 && bufferLine.at(linkStart - 1).linkIndex() == linkIdx) {
                    --linkStart;
                }
                outLine = line;
                outColumn = linkStart;
                return true;
            }
        }
    }

    // Wrap around: search from the end back to the starting position
    for (int line = lineCount - 1; line >= fromLine; --line) {
        const auto& bufferLine = buffer.at(line);
        int startCol = (line == fromLine) ? qMin(fromColumn, static_cast<int>(bufferLine.size()) - 1) : static_cast<int>(bufferLine.size()) - 1;

        for (int col = startCol; col >= 0; --col) {
            int linkIdx = bufferLine.at(col).linkIndex();
            if (linkIdx > 0 && linkIdx != currentLinkAtStart) {
                int linkStart = col;
                while (linkStart > 0 && bufferLine.at(linkStart - 1).linkIndex() == linkIdx) {
                    --linkStart;
                }
                outLine = line;
                outColumn = linkStart;
                if (wrapped) {
                    *wrapped = true;
                }
                return true;
            }
        }
    }

    return false;
}

QString TBuffer::getLinkTooltip(int linkIndex) const
{
    if (linkIndex <= 0) {
        return QString();
    }

    // When hints outnumber commands by one, the extra leading hint is the tooltip/title (not a menu item)
    const QStringList hints = mLinkStore.getHintsConst(linkIndex);
    const QStringList commands = mLinkStore.getLinksConst(linkIndex);
    if (hints.size() > commands.size() && !hints.isEmpty()) {
        return hints.first();
    }

    // If there's exactly one hint, use it
    if (hints.size() == 1 && !hints.first().isEmpty()) {
        return hints.first();
    }

    return QString();
}

// Update all TChar objects in the buffer that have the specified linkIndex
// with the effective styling for the current state
void TBuffer::updateLinkCharacters(int linkIndex)
{
    if (linkIndex <= 0) {
        return;
    }

    // Get the effective styling for this link's current state
    Mudlet::HyperlinkStyling effectiveStyling = getEffectiveHyperlinkStyling(linkIndex);

#if defined(DEBUG_OSC_PROCESSING)
    qDebug() << "[OSC] Link" << linkIndex << "effective styling:" << "hasFg:" << effectiveStyling.hasForegroundColor
             << "fg:" << (effectiveStyling.hasForegroundColor ? effectiveStyling.foregroundColor.name() : "none") << "hasBg:" << effectiveStyling.hasBackgroundColor
             << "bold:" << effectiveStyling.isBold << "underline:" << effectiveStyling.isUnderlined << "hasCustom:" << effectiveStyling.hasCustomStyling;
#endif

    // IMPORTANT: If this link has no custom styling at all (neither base nor pseudo-class),
    // don't modify the characters. This preserves ANSI formatting for links without styling.
    if (!effectiveStyling.hasCustomStyling) {
#if defined(DEBUG_OSC_PROCESSING)
        qDebug() << "[OSC] Link" << linkIndex << "has no custom styling - preserving ANSI formatting";
#endif
        return; // Don't modify characters - preserve original ANSI formatting
    }

    // Never restore ANSI base when effective styling has custom properties.
    // This includes both base-level styling AND pseudo-class styling.
    // If the cascade resolved custom colors/formatting from :link, :hover, etc.,
    // use those instead of reverting to ANSI.
    bool useAnsiBase = false;

#if defined(DEBUG_OSC_PROCESSING)
    qDebug() << "[OSC] Updating link" << linkIndex << "- hasBaseCustomStyling:" << effectiveStyling.hasBaseCustomStyling << "currentState:" << effectiveStyling.currentState
             << "useAnsiBase:" << useAnsiBase;
#endif

    // Debug: Count how many characters we're checking and how many match
#if defined(DEBUG_OSC_PROCESSING)
    int totalCharacters = 0;
    int matchingCharacters = 0;
#endif

    // Iterate through all lines in the buffer
    for (auto& line : buffer) {
        // Iterate through all characters in the line
        for (auto& tchar : line) {
#if defined(DEBUG_OSC_PROCESSING)
            totalCharacters++;
#endif
            // Check if this character belongs to the link we're updating
            if (tchar.linkIndex() == linkIndex) {
#if defined(DEBUG_OSC_PROCESSING)
                matchingCharacters++;
                static int charUpdateCount = 0;
                if (charUpdateCount++ < 2) { // Only log first 2 characters to avoid spam
                    qDebug() << "[OSC] Before update - char with linkIndex" << linkIndex << "- FgColor:" << tchar.mFgColor.name() << "hasFg:" << effectiveStyling.hasForegroundColor
                             << "new fg:" << (effectiveStyling.hasForegroundColor ? effectiveStyling.foregroundColor.name() : "none");
                }
#endif
                // Apply the effective styling to this character

                // If we should use ANSI base (no base styling but in default/visited state),
                // restore the original ANSI colors and formatting as a BASE,
                // then let pseudo-class styling override it
                if (useAnsiBase && mLinkOriginalCharacters.contains(linkIndex)) {
                    TChar originalChar = mLinkOriginalCharacters.value(linkIndex);
#if defined(DEBUG_OSC_PROCESSING)
                    qDebug() << "[OSC] Restoring ANSI base for link" << linkIndex << "- Original FgColor:" << originalChar.mFgColor.name() << "Original BgColor:" << originalChar.mBgColor.name()
                             << "Original Bold:" << bool(originalChar.mFlags & TChar::Bold) << "Current FgColor:" << tchar.mFgColor.name() << "Current Bold:" << bool(tchar.mFlags & TChar::Bold);
#endif
                    // Restore ANSI base - these will be overridden below if styling specifies them
                    tchar.mFgColor = originalChar.mFgColor;
                    tchar.mBgColor = originalChar.mBgColor;
                    tchar.mFlags = originalChar.mFlags; // Restore ALL ANSI formatting flags including decorations

                    // DON'T continue here - let the pseudo-class styling below override the ANSI base
                    // This allows e.g. :visited{color:#bb66dd} to work with ANSI base formatting
                }

                // Apply styling

                // Update foreground color
                if (effectiveStyling.hasForegroundColor) {
                    tchar.mFgColor = effectiveStyling.foregroundColor;
#if defined(DEBUG_OSC_PROCESSING)
                    static int fgUpdateCount = 0;
                    if (fgUpdateCount++ < 2) {
                        qDebug() << "[OSC] Applied FG color to link" << linkIndex << "- New FgColor:" << tchar.mFgColor.name();
                    }
#endif
                }

                // Update background color - restore original if not specified in styling
                if (effectiveStyling.hasBackgroundColor) {
                    tchar.mBgColor = effectiveStyling.backgroundColor;
                } else {
                    // Restore the original background color from when the link was created
                    tchar.mBgColor = mLinkOriginalBackgrounds.value(linkIndex, mBackGroundColor);
                }

                // Update text decorations (only for CSS styling, not ANSI-base)
                if (effectiveStyling.isUnderlined) {
                    tchar.mFlags |= TChar::Underline;

                    // Apply underline style
                    // First clear any existing underline style flags
                    tchar.mFlags &= ~(TChar::UnderlineWavy | TChar::UnderlineDotted | TChar::UnderlineDashed);

                    switch (effectiveStyling.underlineStyle) {
                    case Mudlet::HyperlinkStyling::UnderlineWavy:
                        tchar.mFlags |= TChar::UnderlineWavy;
                        break;
                    case Mudlet::HyperlinkStyling::UnderlineDotted:
                        tchar.mFlags |= TChar::UnderlineDotted;
                        break;
                    case Mudlet::HyperlinkStyling::UnderlineDashed:
                        tchar.mFlags |= TChar::UnderlineDashed;
                        break;
                    default:
                        break;
                    }
                } else {
                    tchar.mFlags &= ~TChar::Underline;
                    tchar.mFlags &= ~(TChar::UnderlineWavy | TChar::UnderlineDotted | TChar::UnderlineDashed);
                }

                if (effectiveStyling.isOverlined) {
                    tchar.mFlags |= TChar::Overline;
                } else {
                    tchar.mFlags &= ~TChar::Overline;
                }

                if (effectiveStyling.isStrikeOut) {
                    tchar.mFlags |= TChar::StrikeOut;
                } else {
                    tchar.mFlags &= ~TChar::StrikeOut;
                }

                // Update bold and italic
                if (effectiveStyling.isBold) {
                    tchar.mFlags |= TChar::Bold;
                } else {
                    tchar.mFlags &= ~TChar::Bold;
                }

                if (effectiveStyling.isItalic) {
                    tchar.mFlags |= TChar::Italic;
                } else {
                    tchar.mFlags &= ~TChar::Italic;
                }
            }
        }
    }

    // Debug: Report search results
#if defined(DEBUG_OSC_PROCESSING)
    qDebug() << "[OSC] Character search completed for link" << linkIndex << "- Total characters searched:" << totalCharacters << "- Matching characters found:" << matchingCharacters;
#endif

    // Refresh the display to show the updated character styling
    // Use updateScreenView and repaint for immediate Qt rendering
    if (mpConsole) {
        mpConsole->mUpperPane->updateScreenView();
        mpConsole->mUpperPane->repaint();
        mpConsole->mLowerPane->updateScreenView();
        mpConsole->mLowerPane->repaint();
    }
}
