#include "configuration.h"
#if HAS_SCREEN
#include "MessageRenderer.h"

// Core includes
#include "MessageStore.h"
#include "NodeDB.h"
#include "UIRenderer.h"
#include "UptimeClock.h"
#include "gps/RTC.h"
#include "graphics/EmoteRenderer.h"
#include "graphics/Screen.h"
#include "graphics/ScreenFonts.h"
#include "graphics/SharedUIDisplay.h"
#include "graphics/TFTColorRegions.h"
#include "graphics/TFTPalette.h"
#include "graphics/TimeFormatters.h"
#include "graphics/draw/NotificationRenderer.h"
#include "graphics/emotes.h"
#include "main.h"
#include "meshUtils.h"
#include "modules/CannedMessageModule.h"
#include <string>
#include <vector>

// External declarations
extern bool hasUnreadMessage;
extern std::unique_ptr<graphics::Screen> screen;

using graphics::Emote;
using graphics::emotes;
using graphics::numEmotes;

namespace graphics
{
namespace MessageRenderer
{

static std::vector<std::string> cachedLines;
static std::vector<int> cachedHeights;
static bool manualScrolling = false;

// Scroll state (file scope so we can reset on new message)
float scrollY = 0.0f;
uint32_t lastTime = 0;
uint32_t scrollStartDelay = 0;
uint32_t pauseStart = 0;
bool waitingToReset = false;
bool scrollStarted = false;
static bool didReset = false;
static constexpr int MESSAGE_BLOCK_GAP = 6;

void scrollUp()
{
    manualScrolling = true;
    scrollY -= 12;
    if (scrollY < 0)
        scrollY = 0;
}

void scrollDown()
{
    manualScrolling = true;

    int totalHeight = 0;
    for (int h : cachedHeights)
        totalHeight += h;

    int visibleHeight = screen->getHeight() - (FONT_HEIGHT_SMALL * 2);
    int maxScroll = totalHeight - visibleHeight;
    if (maxScroll < 0)
        maxScroll = 0;

    if (graphics::isCompactPanel(screen->getDisplayDevice()) && scrollY >= maxScroll) {
        // Compact panels: scrolling past the bottom wraps back to the top.
        scrollY = 0;
        return;
    }

    scrollY += 12;
    if (scrollY > maxScroll)
        scrollY = maxScroll;
}

void drawStringWithEmotes(OLEDDisplay *display, int x, int y, const std::string &line, const Emote *emotes, int emoteCount)
{
    graphics::EmoteRenderer::drawStringWithEmotes(display, x, y, line, FONT_HEIGHT_SMALL, emotes, emoteCount);
}

// Reset scroll state when new messages arrive
void resetScrollState()
{
    scrollY = 0.0f;
    scrollStarted = false;
    waitingToReset = false;
    scrollStartDelay = millis();
    lastTime = millis();
    manualScrolling = false;
    didReset = false;
}

void nudgeScroll(int8_t direction)
{
    if (direction == 0)
        return;

    if (cachedHeights.empty()) {
        scrollY = 0.0f;
        return;
    }

    OLEDDisplay *display = (screen != nullptr) ? screen->getDisplayDevice() : nullptr;
    const int displayHeight = display ? display->getHeight() : 64;
    const int navHeight = FONT_HEIGHT_SMALL;
    const int usableHeight = std::max(0, displayHeight - navHeight);

    int totalHeight = 0;
    for (int h : cachedHeights)
        totalHeight += h;

    if (totalHeight <= usableHeight) {
        scrollY = 0.0f;
        return;
    }

    const int scrollStop = std::max(0, totalHeight - usableHeight + cachedHeights.back());
    const int step = std::max(FONT_HEIGHT_SMALL, usableHeight / 3);

    float newScroll = scrollY + static_cast<float>(direction) * static_cast<float>(step);
    if (newScroll < 0.0f)
        newScroll = 0.0f;
    if (newScroll > scrollStop)
        newScroll = static_cast<float>(scrollStop);

    if (newScroll != scrollY) {
        scrollY = newScroll;
        waitingToReset = false;
        scrollStarted = false;
        scrollStartDelay = millis();
        lastTime = millis();
    }
}

// Fully free cached message data from heap
void clearMessageCache()
{
    std::vector<std::string>().swap(cachedLines);
    std::vector<int>().swap(cachedHeights);

    // Reset scroll so we rebuild cleanly next time we enter the screen
    resetScrollState();
}

// Current thread state
static ThreadMode currentMode = ThreadMode::ALL;
static int currentChannel = -1;
static uint32_t currentPeer = 0;

// Registry of seen threads for manual toggle
static std::vector<int> seenChannels;
static std::vector<uint32_t> seenPeers;

// Public helper so menus / store can clear stale registries
void clearThreadRegistries()
{
    seenChannels.clear();
    seenPeers.clear();
}

// Setter so other code can switch threads
void setThreadMode(ThreadMode mode, int channel /* = -1 */, uint32_t peer /* = 0 */)
{
    currentMode = mode;
    currentChannel = channel;
    currentPeer = peer;
    didReset = false; // force reset when mode changes

    // Track channels we’ve seen
    if (mode == ThreadMode::CHANNEL && channel >= 0) {
        if (std::find(seenChannels.begin(), seenChannels.end(), channel) == seenChannels.end()) {
            seenChannels.push_back(channel);
        }
    }

    // Track DMs we’ve seen
    if (mode == ThreadMode::DIRECT && peer != 0) {
        if (std::find(seenPeers.begin(), seenPeers.end(), peer) == seenPeers.end()) {
            seenPeers.push_back(peer);
        }
    }
}

ThreadMode getThreadMode()
{
    return currentMode;
}

int getThreadChannel()
{
    return currentChannel;
}

uint32_t getThreadPeer()
{
    return currentPeer;
}

// Accessors for menuHandler
const std::vector<int> &getSeenChannels()
{
    return seenChannels;
}
const std::vector<uint32_t> &getSeenPeers()
{
    return seenPeers;
}

static int centerYForRow(int y, int size)
{
    int midY = y + (FONT_HEIGHT_SMALL / 2);
    return midY - (size / 2);
}

// Helpers for drawing status marks (thickened strokes)
static void drawCheckMark(OLEDDisplay *display, int x, int y, int size)
{
    int topY = centerYForRow(y, size);
    display->setColor(WHITE);
    display->drawLine(x, topY + size / 2, x + size / 3, topY + size);
    display->drawLine(x, topY + size / 2 + 1, x + size / 3, topY + size + 1);
    display->drawLine(x + size / 3, topY + size, x + size, topY);
    display->drawLine(x + size / 3, topY + size + 1, x + size, topY + 1);
}

static void drawXMark(OLEDDisplay *display, int x, int y, int size = 8)
{
    int topY = centerYForRow(y, size);
    display->setColor(WHITE);
    display->drawLine(x, topY, x + size, topY + size);
    display->drawLine(x, topY + 1, x + size, topY + size + 1);
    display->drawLine(x + size, topY, x, topY + size);
    display->drawLine(x + size, topY + 1, x, topY + size + 1);
}

static void drawRelayMark(OLEDDisplay *display, int x, int y, int size = 8)
{
    int r = size / 2;
    int centerY = centerYForRow(y, size) + r;
    int centerX = x + r;
    display->setColor(WHITE);
    display->drawCircle(centerX, centerY, r);
    display->drawLine(centerX, centerY - 2, centerX, centerY);
    display->setPixel(centerX, centerY + 2);
    display->drawLine(centerX - 1, centerY - 4, centerX + 1, centerY - 4);
}

static inline int getRenderedLineWidth(OLEDDisplay *display, const std::string &line, const Emote *emotes, int emoteCount)
{
    return graphics::EmoteRenderer::analyzeLine(display, line, 0, emotes, emoteCount).width;
}

struct MessageBlock {
    size_t start;
    size_t end;
    bool mine;
};

#if GRAPHICS_TFT_COLORING_ENABLED
static void setDarkModeBubbleRoleColors(uint32_t themeId, bool mine)
{
    uint16_t bubbleOnColor;
    uint16_t bubbleOffColor;

    if (themeId == ThemeID::Blue) {
        bubbleOnColor = mine ? TFTPalette::Navy : TFTPalette::White;
        bubbleOffColor = mine ? TFTPalette::SkyBlue : TFTPalette::DeepBlue;
    } else {
        bubbleOnColor = mine ? TFTPalette::Black : getThemeBodyFg();
        bubbleOffColor = mine ? TFTPalette::SkyBlue : TFTPalette::DarkGray;
    }

    setTFTColorRole(TFTColorRole::ActionMenuBody, bubbleOnColor, bubbleOffColor);
}

static void registerRoundedBubbleFillRegion(int x, int y, int w, int h, int radius)
{
    if (w <= 0 || h <= 0) {
        return;
    }

    if (radius <= 0 || w < 3 || h < 3) {
        registerTFTColorRegion(TFTColorRole::ActionMenuBody, x, y, w, h);
        return;
    }

    // Keep region count low so we don't churn MAX_TFT_COLOR_REGIONS while
    // scrolling long message lists (which can flatten older bubble corners).
    int capRows = 0;
    if (radius >= 4 && h >= 5) {
        capRows = 2; // 5 regions total (2 top caps + middle + 2 bottom caps)
    } else if (radius >= 2 && h >= 3) {
        capRows = 1; // 3 regions total
    }
    if (capRows <= 0) {
        registerTFTColorRegion(TFTColorRole::ActionMenuBody, x, y, w, h);
        return;
    }

    for (int row = 0; row < capRows; ++row) {
        int inset = 0;
        if (radius >= 4) {
            inset = (row == 0) ? 2 : 1;
        } else if (radius >= 2) {
            inset = 1;
        }
        const int stripW = w - (inset * 2);
        if (stripW <= 0) {
            continue;
        }

        const int topY = y + row;
        registerTFTColorRegion(TFTColorRole::ActionMenuBody, x + inset, topY, stripW, 1);

        const int bottomY = y + h - 1 - row;
        if (bottomY != topY) {
            registerTFTColorRegion(TFTColorRole::ActionMenuBody, x + inset, bottomY, stripW, 1);
        }
    }

    const int middleY = y + capRows;
    const int middleH = h - (capRows * 2);
    if (middleH > 0) {
        registerTFTColorRegion(TFTColorRole::ActionMenuBody, x, middleY, w, middleH);
    }
}
#endif

static int getDrawnLinePixelBottom(int lineTopY, const std::string &line, bool isHeaderLine)
{
    if (isHeaderLine) {
        return lineTopY + (FONT_HEIGHT_SMALL - 1);
    }

    const int tallest = graphics::EmoteRenderer::analyzeLine(nullptr, line, FONT_HEIGHT_SMALL, emotes, numEmotes).tallestHeight;

    const int lineHeight = std::max(FONT_HEIGHT_SMALL, tallest);
    const int iconTop = lineTopY + (lineHeight - tallest) / 2;

    return iconTop + tallest - 1;
}

static std::vector<MessageBlock> buildMessageBlocks(const std::vector<bool> &isHeaderVec, const std::vector<bool> &isMineVec)
{
    std::vector<MessageBlock> blocks;
    if (isHeaderVec.empty())
        return blocks;

    size_t start = 0;
    bool mine = isMineVec[0];

    for (size_t i = 1; i < isHeaderVec.size(); ++i) {
        if (isHeaderVec[i]) {
            MessageBlock b;
            b.start = start;
            b.end = i - 1;
            b.mine = mine;
            blocks.push_back(b);

            start = i;
            mine = isMineVec[i];
        }
    }

    MessageBlock last;
    last.start = start;
    last.end = isHeaderVec.size() - 1;
    last.mine = mine;
    blocks.push_back(last);

    return blocks;
}

static void drawMessageScrollbar(OLEDDisplay *display, int visibleHeight, int totalHeight, int scrollOffset, int startY)
{
    if (totalHeight <= visibleHeight)
        return; // no scrollbar needed

    int scrollbarX = display->getWidth() - 2;
    int scrollbarHeight = visibleHeight;
    int thumbHeight = std::max(6, (scrollbarHeight * visibleHeight) / totalHeight);
    int maxScroll = std::max(1, totalHeight - visibleHeight);
    int thumbY = startY + (scrollbarHeight - thumbHeight) * scrollOffset / maxScroll;

    for (int i = 0; i < thumbHeight; i++) {
        display->setPixel(scrollbarX, thumbY + i);
    }
}

void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
{
    // Ensure any boot-relative timestamps are upgraded if RTC is valid
    messageStore.upgradeBootRelativeTimestamps();

    if (!didReset) {
        resetScrollState();
        didReset = true;
    }

    // Clear the unread message indicator when viewing the message
    hasUnreadMessage = false;

    // Filter messages based on thread mode
    std::deque<StoredMessage> filtered;
    for (const auto &m : messageStore.getLiveMessages()) {
        if (!messageStore.isMessageVisible(m))
            continue;
        bool include = false;
        switch (currentMode) {
        case ThreadMode::ALL:
            include = true;
            break;
        case ThreadMode::CHANNEL:
            if (m.type == MessageType::BROADCAST && (int)m.channelIndex == currentChannel)
                include = true;
            break;
        case ThreadMode::DIRECT:
            if (m.dest != NODENUM_BROADCAST && (m.sender == currentPeer || m.dest == currentPeer))
                include = true;
            break;
        }
        if (include)
            filtered.push_back(m);
    }

    display->clear();
    display->setTextAlignment(TEXT_ALIGN_LEFT);
    display->setFont(FONT_SMALL);
    const bool compactPanel = graphics::isCompactPanel(display);
    // Compact panels: no bottom nav row anymore (see UIRenderer::drawNavigationBar), full height available.
    const int navHeight = compactPanel ? 0 : FONT_HEIGHT_SMALL;
    const int scrollBottom = SCREEN_HEIGHT - navHeight;
    const int contentTop = compactPanel ? 0 : getTextPositions(display)[1];
    const int usableHeight = compactPanel ? scrollBottom - contentTop : scrollBottom;
    constexpr int LEFT_MARGIN = 2;
    constexpr int RIGHT_MARGIN = 2;
    constexpr int SCROLLBAR_WIDTH = 3;
    constexpr int BUBBLE_PAD_X = 3;
    constexpr int BUBBLE_PAD_Y = 4;
    constexpr int BUBBLE_RADIUS = 4;
    constexpr int BUBBLE_MIN_W = 24;
    constexpr int BUBBLE_TEXT_INDENT = 2;

    // Check if bubbles are enabled
    const bool showBubbles = config.display.enable_message_bubbles && !compactPanel;
    const int textIndent = showBubbles ? (BUBBLE_PAD_X + BUBBLE_TEXT_INDENT) : LEFT_MARGIN;

    // Derived widths
    const int leftTextWidth = SCREEN_WIDTH - LEFT_MARGIN - RIGHT_MARGIN - (showBubbles ? (BUBBLE_PAD_X * 2) : 0);
    const int rightTextWidth = SCREEN_WIDTH - LEFT_MARGIN - RIGHT_MARGIN - SCROLLBAR_WIDTH;

    // Title string depending on mode
    char titleStr[48];
    snprintf(titleStr, sizeof(titleStr), "Messages");
    switch (currentMode) {
    case ThreadMode::ALL:
        snprintf(titleStr, sizeof(titleStr), "Messages");
        break;
    case ThreadMode::CHANNEL: {
        const char *cname = channels.getName(currentChannel);
        if (cname && cname[0]) {
            snprintf(titleStr, sizeof(titleStr), "#%s", cname);
        } else {
            snprintf(titleStr, sizeof(titleStr), "Ch%d", currentChannel);
        }
        break;
    }
    case ThreadMode::DIRECT: {
        meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(currentPeer);
        if (nodeInfoLiteHasUser(node) && node->short_name[0]) {
            snprintf(titleStr, sizeof(titleStr), "@%s", node->short_name);
        } else {
            snprintf(titleStr, sizeof(titleStr), "@%08x", currentPeer);
        }
        break;
    }
    }

    if (filtered.empty()) {
        // If current conversation is empty go back to ALL view
        if (currentMode != ThreadMode::ALL) {
            setThreadMode(ThreadMode::ALL);
            resetScrollState();
            return; // Next draw will rerun in ALL mode
        }

        // Still in ALL mode and no messages at all → show placeholder
        graphics::drawCommonHeader(display, x, y, titleStr);
        didReset = false;
        const char *messageString = "No messages";
        int center_text = (SCREEN_WIDTH / 2) - (display->getStringWidth(messageString) / 2);
        display->drawString(center_text, getTextPositions(display)[2], messageString);
        graphics::drawCommonFooter(display, x, y);
        return;
    }

    // Build lines for filtered messages (newest first)
    std::vector<std::string> allLines;
    std::vector<bool> isMine;   // track alignment
    std::vector<bool> isHeader; // track header lines
    std::vector<AckStatus> ackForLine;
    // Hard limit on total cached lines to prevent unbounded growth from a single long message.
    // Reserve to the actual cache cap up front, because a single message can expand to many more
    // wrapped display lines than a small per-message estimate would predict. For a display
    // rendering only ~5-30 lines at a time, caching more than this limit wastes heap. Stop
    // appending once we reach MAX_CACHED_LINES to prevent a single message from blowing out the
    // heap.
    constexpr size_t MAX_CACHED_LINES = 100U; // ~5-6KB for std::string overhead on 32-bit (if each ~50-60 bytes avg)
    allLines.reserve(MAX_CACHED_LINES);
    isMine.reserve(MAX_CACHED_LINES);
    isHeader.reserve(MAX_CACHED_LINES);
    ackForLine.reserve(MAX_CACHED_LINES);

    for (auto it = filtered.rbegin(); it != filtered.rend(); ++it) {
        const auto &m = *it;

        // Channel / destination labeling
        char chanType[32] = "";
        if (currentMode == ThreadMode::ALL) {
            if (m.dest == NODENUM_BROADCAST) {
                const char *name = channels.getName(m.channelIndex);
                if (currentResolution == ScreenResolution::Low || currentResolution == ScreenResolution::UltraLow) {
                    if (strcmp(name, "ShortTurbo") == 0)
                        name = "ShortT";
                    else if (strcmp(name, "ShortSlow") == 0)
                        name = "ShortS";
                    else if (strcmp(name, "ShortFast") == 0)
                        name = "ShortF";
                    else if (strcmp(name, "MediumSlow") == 0)
                        name = "MedS";
                    else if (strcmp(name, "MediumFast") == 0)
                        name = "MedF";
                    else if (strcmp(name, "LongSlow") == 0)
                        name = "LongS";
                    else if (strcmp(name, "LongFast") == 0)
                        name = "LongF";
                    else if (strcmp(name, "LongTurbo") == 0)
                        name = "LongT";
                    else if (strcmp(name, "LongMod") == 0)
                        name = "LongM";
                }
                snprintf(chanType, sizeof(chanType), "#%s", name);
            } else {
                snprintf(chanType, sizeof(chanType), "(DM)");
            }
        }

        // Calculate how long ago
        uint32_t nowSecs = getValidTime(RTCQuality::RTCQualityDevice, true);
        uint32_t seconds = 0;
        bool invalidTime = true;

        if (m.timestamp > 0 && nowSecs > 0) {
            if (nowSecs >= m.timestamp) {
                seconds = nowSecs - m.timestamp;
                invalidTime = (seconds > 315360000); // >10 years
            } else {
                uint32_t ahead = m.timestamp - nowSecs;
                if (ahead <= 600) { // allow small skew
                    seconds = 0;
                    invalidTime = false;
                }
            }
        } else if (m.timestamp > 0 && nowSecs == 0) {
            // RTC not valid: only trust boot-relative if same boot
            uint32_t bootNow = Time::getUptimeSecs();
            if (m.isBootRelative && m.timestamp <= bootNow) {
                seconds = bootNow - m.timestamp;
                invalidTime = false;
            } else {
                invalidTime = true; // old persisted boot-relative, ignore until healed
            }
        }

        char timeBuf[16];
        if (invalidTime) {
            snprintf(timeBuf, sizeof(timeBuf), "???");
        } else if (seconds < 60) {
            snprintf(timeBuf, sizeof(timeBuf), "%us", seconds);
        } else if (seconds < 3600) {
            snprintf(timeBuf, sizeof(timeBuf), "%um", seconds / 60);
        } else if (seconds < 86400) {
            snprintf(timeBuf, sizeof(timeBuf), "%uh", seconds / 3600);
        } else {
            snprintf(timeBuf, sizeof(timeBuf), "%ud", seconds / 86400);
        }

        // Build header line for this message
        meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(m.sender);
        meshtastic_NodeInfoLite *node_recipient = nodeDB->getMeshNode(m.dest);

        char senderName[64] = "";
        if (nodeInfoLiteHasUser(node)) {
            if (node->long_name[0]) {
                strncpy(senderName, node->long_name, sizeof(senderName) - 1);
            } else if (node->short_name[0]) {
                strncpy(senderName, node->short_name, sizeof(senderName) - 1);
            }
            senderName[sizeof(senderName) - 1] = '\0';
        }
        if (!senderName[0]) {
            snprintf(senderName, sizeof(senderName), "(%08x)", m.sender);
        }

        // If this is *our own* message, override senderName to who the recipient was
        bool mine = (m.sender == nodeDB->getNodeNum());
        if (mine && nodeInfoLiteHasUser(node_recipient)) {
            if (node_recipient->long_name[0]) {
                strncpy(senderName, node_recipient->long_name, sizeof(senderName) - 1);
                senderName[sizeof(senderName) - 1] = '\0';
            } else if (node_recipient->short_name[0]) {
                strncpy(senderName, node_recipient->short_name, sizeof(senderName) - 1);
                senderName[sizeof(senderName) - 1] = '\0';
            }
        }
        // If recipient info is missing/empty, prefer a recipient identifier for outbound messages.
        if (mine && (!nodeInfoLiteHasUser(node_recipient) || (!node_recipient->long_name[0] && !node_recipient->short_name[0]))) {
            snprintf(senderName, sizeof(senderName), "(%08x)", m.dest);
        }

        // Shrink Sender name if needed; compact panels put it on its own line, so no sharing with timeBuf/chanType.
        int availWidth = compactPanel ? (mine ? rightTextWidth : leftTextWidth)
                                      : (mine ? rightTextWidth : leftTextWidth) - display->getStringWidth(timeBuf) -
                                            display->getStringWidth(chanType);
        // Compact panels hard-cut (no "...") so drop its width reservation too.
        availWidth -= graphics::UIRenderer::measureStringWithEmotes(display, compactPanel ? "*@" : "  *@...");
        if (availWidth < 0)
            availWidth = 0;
        char truncatedSender[64];
        graphics::UIRenderer::truncateStringWithEmotes(display, senderName, truncatedSender, sizeof(truncatedSender), availWidth,
                                                       compactPanel ? "" : "...");

        // Determine signed-message prefix before building the header line, since it needs to go
        // at the front of headerStr rather than appended after (strncat only appends at the end).
        const char *signPrefix = "";
#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI)
        bool is_xeddsa_signed = m.xeddsaSigned;
        if (is_xeddsa_signed) {
            signPrefix = "*";
        }
#endif

        if (compactPanel) {
            // Time and sender don't fit on one line at this width - time first, name below.
            allLines.push_back(timeBuf);
            isMine.push_back(mine);
            isHeader.push_back(true);
            ackForLine.push_back(AckStatus::NONE); // ack mark shown on the name line instead

            char nameLine[80] = "";
            if (mine) {
                if (currentMode == ThreadMode::ALL) {
                    if (strcmp(chanType, "(DM)") == 0) {
                        snprintf(nameLine, sizeof(nameLine), "to %s", truncatedSender);
                    } else {
                        snprintf(nameLine, sizeof(nameLine), "to %s", chanType);
                    }
                }
            } else {
                snprintf(nameLine, sizeof(nameLine), chanType[0] ? "%s%s@%s" : "%s%s", signPrefix, truncatedSender, chanType);
            }

            if (nameLine[0]) {
                allLines.push_back(nameLine);
                isMine.push_back(mine);
                isHeader.push_back(true);
                ackForLine.push_back(m.ackStatus);
            } else {
                // Nothing to show on a second line (e.g. "mine" in ALL mode) - move the ack mark back.
                ackForLine.back() = m.ackStatus;
            }
        } else {
            // Final header line
            char headerStr[128];
            if (mine) {
                if (currentMode == ThreadMode::ALL) {
                    if (strcmp(chanType, "(DM)") == 0) {
                        snprintf(headerStr, sizeof(headerStr), "%s to %s", timeBuf, truncatedSender);
                    } else {
                        snprintf(headerStr, sizeof(headerStr), "%s to %s", timeBuf, chanType);
                    }
                } else {
                    snprintf(headerStr, sizeof(headerStr), "%s", timeBuf);
                }
            } else {
                snprintf(headerStr, sizeof(headerStr), chanType[0] ? "%s %s@%s %s" : "%s %s@%s", timeBuf, signPrefix,
                         truncatedSender, chanType);
            }

            allLines.push_back(headerStr);
            isMine.push_back(mine);
            isHeader.push_back(true);
            ackForLine.push_back(m.ackStatus);
        }

        const char *msgText = MessageStore::getText(m);

        int wrapWidth = mine ? rightTextWidth : leftTextWidth;
        std::vector<std::string> wrapped = generateLines(display, "", msgText, wrapWidth);
        // Per-message wrap-line limit: even if wrapping produces many lines, cap them to prevent
        // a single long message from consuming most or all of the cache.
        constexpr size_t MAX_WRAPPED_LINES_PER_MSG = 20U;
        size_t wrappedCount = 0;
        for (auto &ln : wrapped) {
            if (allLines.size() >= MAX_CACHED_LINES || wrappedCount >= MAX_WRAPPED_LINES_PER_MSG)
                break; // Cache limit or per-message limit reached; stop adding lines from this message
            allLines.emplace_back(std::move(ln));
            isMine.push_back(mine);
            isHeader.push_back(false);
            ackForLine.push_back(AckStatus::NONE);
            ++wrappedCount;
        }
    }

    // Cache lines and heights
    cachedLines.swap(allLines);
    cachedHeights = calculateLineHeights(cachedLines, emotes, isHeader);
    if (compactPanel) {
        for (size_t i = 0; i < cachedHeights.size(); ++i) {
            if (isHeader[i]) {
                cachedHeights[i] = 10;
            }
        }
    }

    std::vector<MessageBlock> blocks = buildMessageBlocks(isHeader, isMine);

    // Scrolling logic (unchanged)
    int totalHeight = 0;
    for (size_t i = 0; i < cachedHeights.size(); ++i)
        totalHeight += cachedHeights[i];
    int usableScrollHeight = usableHeight;
    int scrollStop = std::max(0, totalHeight - usableScrollHeight + cachedHeights.back());

#ifndef USE_EINK
    uint32_t now = millis();
    float delta = (now - lastTime) / 400.0f;
    lastTime = now;
    const float scrollSpeed = 2.0f;

    if (scrollStartDelay == 0)
        scrollStartDelay = now;
    if (!scrollStarted && now - scrollStartDelay > 2000)
        scrollStarted = true;

    if (!manualScrolling && totalHeight > usableScrollHeight) {
        if (scrollStarted) {
            if (!waitingToReset) {
                scrollY += delta * scrollSpeed;
                if (scrollY >= scrollStop) {
                    scrollY = scrollStop;
                    waitingToReset = true;
                    pauseStart = lastTime;
                }
            } else if (lastTime - pauseStart > 3000) {
                scrollY = 0;
                waitingToReset = false;
                scrollStarted = false;
                scrollStartDelay = lastTime;
            }
        }
    } else if (!manualScrolling) {
        scrollY = 0;
    }
#else
    // E-Ink: disable autoscroll
    scrollY = 0.0f;
    waitingToReset = false;
    scrollStarted = false;
    lastTime = millis();
#endif

    int finalScroll = (int)scrollY;
    int yOffset = -finalScroll + contentTop;
    const int contentBottom = scrollBottom; // already excludes nav line
    const int rightEdge = SCREEN_WIDTH - SCROLLBAR_WIDTH - RIGHT_MARGIN;
    const int bubbleGapY = std::max(1, MESSAGE_BLOCK_GAP / 2);
#if GRAPHICS_TFT_COLORING_ENABLED
    const uint32_t themeId = getActiveTheme().id;
    // Blue is a dark variant but uses full frame inversion, Keep it on the same filled bubble style as Default Dark.
    const bool useDarkModeBubbleFill = showBubbles && (!isThemeFullFrameInvert() || themeId == ThemeID::Blue);
#endif

    std::vector<int> lineTop;
    lineTop.resize(cachedLines.size());
    {
        int acc = 0;
        for (size_t i = 0; i < cachedLines.size(); ++i) {
            lineTop[i] = yOffset + acc;
            acc += cachedHeights[i];
        }
    }

    // Draw bubbles (only if enabled)
    if (showBubbles) {
        for (size_t bi = 0; bi < blocks.size(); ++bi) {
            const auto &b = blocks[bi];
            if (b.start >= cachedLines.size() || b.end >= cachedLines.size() || b.start > b.end)
                continue;

            int visualTop = lineTop[b.start];

            int topY;
            if (isHeader[b.start]) {
                // Header start
                constexpr int BUBBLE_PAD_TOP_HEADER = 1; // try 1 or 2
                topY = visualTop - BUBBLE_PAD_TOP_HEADER;
            } else {
                // Body start
                const bool thisLineHasEmote =
                    graphics::EmoteRenderer::analyzeLine(nullptr, cachedLines[b.start].c_str(), 0, emotes, numEmotes).hasEmote;
                if (thisLineHasEmote) {
                    constexpr int EMOTE_PADDING_ABOVE = 4;
                    visualTop -= EMOTE_PADDING_ABOVE;
                }
                topY = visualTop - BUBBLE_PAD_Y;
            }
            int visualBottom = getDrawnLinePixelBottom(lineTop[b.end], cachedLines[b.end], isHeader[b.end]);
            int bottomY = visualBottom + BUBBLE_PAD_Y;

            // On high-res screens, keep a 1px gap under the header
            if (currentResolution == ScreenResolution::High) {
                const int minTopY = contentTop + 1;
                if (topY < minTopY) {
                    // Preserve bubble height when we push it down from the header.
                    const int shift = minTopY - topY;
                    topY = minTopY;
                    bottomY += shift;
                }
            }

            if (bi + 1 < blocks.size()) {
                int nextHeaderIndex = (int)blocks[bi + 1].start;
                int nextTop = lineTop[nextHeaderIndex];
                int maxBottom = nextTop - 1 - bubbleGapY;
                if (bottomY > maxBottom)
                    bottomY = maxBottom;
            }

            if (bottomY <= topY + 2)
                continue;

            if (bottomY < contentTop || topY > contentBottom - 1)
                continue;

            int maxLineW = 0;

            for (size_t i = b.start; i <= b.end; ++i) {
                int w = 0;
                if (isHeader[i]) {
                    w = graphics::UIRenderer::measureStringWithEmotes(display, cachedLines[i].c_str());
                    if (b.mine)
                        w += 12; // room for ACK/NACK/relay mark
                } else {
                    w = getRenderedLineWidth(display, cachedLines[i], emotes, numEmotes);
                }
                if (w > maxLineW)
                    maxLineW = w;
            }

            int bubbleW = std::max(BUBBLE_MIN_W, maxLineW + (textIndent * 2));
            int bubbleH = (bottomY - topY) + 1;
            int bubbleX = 0;
            if (b.mine) {
                bubbleX = rightEdge - bubbleW;
            } else {
                bubbleX = x;
            }
            if (bubbleX < x)
                bubbleX = x;
            if (bubbleX + bubbleW > rightEdge)
                bubbleW = std::max(1, rightEdge - bubbleX);

            // Draw rounded rectangle bubble
            if (bubbleW > BUBBLE_RADIUS * 2 && bubbleH > BUBBLE_RADIUS * 2) {
                const int r = BUBBLE_RADIUS;
                const int bx = bubbleX;
                const int by = topY;
                const int bw = bubbleW;
                const int bh = bubbleH;
#if GRAPHICS_TFT_COLORING_ENABLED
                const bool drawBubbleOutline = !useDarkModeBubbleFill;
#else
                const bool drawBubbleOutline = true;
#endif
#if GRAPHICS_TFT_COLORING_ENABLED
                if (useDarkModeBubbleFill) {
                    setDarkModeBubbleRoleColors(themeId, b.mine);
                    registerRoundedBubbleFillRegion(bx, by, bw, bh, r);
                }
#endif

                if (drawBubbleOutline) {
                    // Draw the 4 corner arcs using drawCircleQuads
                    display->drawCircleQuads(bx + r, by + r, r, 0x2);                   // Top-left
                    display->drawCircleQuads(bx + bw - r - 1, by + r, r, 0x1);          // Top-right
                    display->drawCircleQuads(bx + r, by + bh - r - 1, r, 0x4);          // Bottom-left
                    display->drawCircleQuads(bx + bw - r - 1, by + bh - r - 1, r, 0x8); // Bottom-right

                    // Draw the 4 edges between corners
                    display->drawHorizontalLine(bx + r, by, bw - 2 * r);          // Top edge
                    display->drawHorizontalLine(bx + r, by + bh - 1, bw - 2 * r); // Bottom edge
                    display->drawVerticalLine(bx, by + r, bh - 2 * r);            // Left edge
                    display->drawVerticalLine(bx + bw - 1, by + r, bh - 2 * r);   // Right edge
                }
            } else if (bubbleW > 1 && bubbleH > 1) {
                // Fallback to simple rectangle for very small bubbles
#if GRAPHICS_TFT_COLORING_ENABLED
                const bool drawBubbleOutline = !useDarkModeBubbleFill;
#else
                const bool drawBubbleOutline = true;
#endif
#if GRAPHICS_TFT_COLORING_ENABLED
                if (useDarkModeBubbleFill) {
                    setDarkModeBubbleRoleColors(themeId, b.mine);
                    registerTFTColorRegion(TFTColorRole::ActionMenuBody, bubbleX, topY, bubbleW, bubbleH);
                }
#endif
                if (drawBubbleOutline) {
                    display->drawRect(bubbleX, topY, bubbleW, bubbleH);
                }
            }
        }
    } // end if (showBubbles)
#if GRAPHICS_TFT_COLORING_ENABLED
    if (useDarkModeBubbleFill) {
        // Restore theme role defaults so other screens keep their intended palette.
        loadThemeDefaults();
    }
#endif

    // Render visible lines
    int lineY = yOffset;
    for (size_t i = 0; i < cachedLines.size(); ++i) {

        if (lineY > -cachedHeights[i] && lineY < scrollBottom) {
            if (isHeader[i]) {

                int w = graphics::UIRenderer::measureStringWithEmotes(display, cachedLines[i].c_str());
                int headerX;
                if (isMine[i]) {
                    // push header left to avoid overlap with scrollbar
                    headerX = (SCREEN_WIDTH - SCROLLBAR_WIDTH - RIGHT_MARGIN) - w - (showBubbles ? textIndent : 0);
                    if (headerX < LEFT_MARGIN)
                        headerX = LEFT_MARGIN;
                } else {
                    headerX = x + textIndent;
                }
                graphics::UIRenderer::drawStringWithEmotes(display, headerX, lineY, cachedLines[i].c_str(), FONT_HEIGHT_SMALL, 1,
                                                           true);

                if (!compactPanel) {
                    // Draw underline just under header text
                    int underlineY = lineY + FONT_HEIGHT_SMALL;

                    int underlineW = w;
                    int maxW = rightEdge - headerX;
                    if (maxW < 0)
                        maxW = 0;
                    if (underlineW > maxW)
                        underlineW = maxW;

                    for (int px = 0; px < underlineW; ++px) {
                        display->setPixel(headerX + px, underlineY);
                    }
                }

                // Draw ACK/NACK mark for our own messages
                if (isMine[i]) {
                    int markX = headerX - 10;
                    int markY = lineY;
                    if (ackForLine[i] == AckStatus::ACKED) {
                        // Destination ACK
                        drawCheckMark(display, markX, markY, 8);
                    } else if (ackForLine[i] == AckStatus::NACKED || ackForLine[i] == AckStatus::TIMEOUT) {
                        // Failure or timeout
                        drawXMark(display, markX, markY, 8);
                    } else if (ackForLine[i] == AckStatus::RELAYED) {
                        // Relay ACK
                        drawRelayMark(display, markX, markY, 8);
                    }
                    // AckStatus::NONE → show nothing
                }

            } else {
                // Render message line
                if (isMine[i]) {
                    // Calculate actual rendered width including emotes
                    int renderedWidth = getRenderedLineWidth(display, cachedLines[i], emotes, numEmotes);
                    int rightX = (SCREEN_WIDTH - SCROLLBAR_WIDTH - RIGHT_MARGIN) - renderedWidth - (showBubbles ? textIndent : 0);
                    if (rightX < LEFT_MARGIN)
                        rightX = LEFT_MARGIN;

                    drawStringWithEmotes(display, rightX, lineY, cachedLines[i], emotes, numEmotes);
                } else {
                    drawStringWithEmotes(display, x + textIndent, lineY, cachedLines[i], emotes, numEmotes);
                }
            }
        }

        lineY += cachedHeights[i];
    }

    // Draw scrollbar
    drawMessageScrollbar(display, usableHeight, totalHeight, finalScroll, contentTop);
    if (!compactPanel) {
        graphics::drawCommonHeader(display, x, y, titleStr);
    }
    graphics::drawCommonFooter(display, x, y);
}

std::vector<std::string> generateLines(OLEDDisplay *display, const char *headerStr, const char *messageBuf, int textWidth)
{
    std::vector<std::string> lines;

    // Only push headerStr if it's not empty (prevents extra blank line after headers)
    if (headerStr && headerStr[0] != '\0') {
        lines.push_back(std::string(headerStr));
    }

    std::string line, word;
    for (int i = 0; messageBuf[i]; ++i) {
        char ch = messageBuf[i];
        if ((unsigned char)messageBuf[i] == 0xE2 && (unsigned char)messageBuf[i + 1] == 0x80 &&
            (unsigned char)messageBuf[i + 2] == 0x99) {
            ch = '\''; // plain apostrophe
            i += 2;    // skip over the extra UTF-8 bytes
        }
        if (ch == '\n') {
            if (!word.empty())
                line += word;
            if (!line.empty())
                lines.push_back(line);
            line.clear();
            word.clear();
        } else if (ch == ' ') {
            line += word + ' ';
            word.clear();
        } else {
            word += ch;
            std::string test = line + word;
            uint16_t strWidth = graphics::UIRenderer::measureStringWithEmotes(display, test.c_str());
            if (strWidth > textWidth) {
                if (!line.empty())
                    lines.push_back(line);
                line = word;
                word.clear();
            }
        }
    }

    if (!word.empty())
        line += word;
    if (!line.empty())
        lines.push_back(line);

    return lines;
}
std::vector<int> calculateLineHeights(const std::vector<std::string> &lines, const Emote *emotes,
                                      const std::vector<bool> &isHeaderVec)
{
    // Tunables for layout control
    constexpr int HEADER_UNDERLINE_GAP = 0; // space between underline and first body line
    constexpr int HEADER_UNDERLINE_PIX = 1; // underline thickness (1px row drawn)
    constexpr int BODY_LINE_LEADING = -4;   // default vertical leading for normal body lines
    constexpr int EMOTE_PADDING_ABOVE = 4;  // space above emote line (added to line above)
    constexpr int EMOTE_PADDING_BELOW = 3;  // space below emote line (added to emote line)

    std::vector<int> rowHeights;
    rowHeights.reserve(lines.size());
    std::vector<graphics::EmoteRenderer::LineMetrics> lineMetrics;
    lineMetrics.reserve(lines.size());

    for (const auto &line : lines) {
        lineMetrics.push_back(graphics::EmoteRenderer::analyzeLine(nullptr, line, FONT_HEIGHT_SMALL, emotes, numEmotes));
    }

    for (size_t idx = 0; idx < lines.size(); ++idx) {
        const int baseHeight = FONT_HEIGHT_SMALL;
        int lineHeight = baseHeight;

        const int tallestEmote = lineMetrics[idx].tallestHeight;
        const bool hasEmote = lineMetrics[idx].hasEmote;
        const bool nextHasEmote = (idx + 1 < lines.size()) && lineMetrics[idx + 1].hasEmote;

        if (isHeaderVec[idx]) {
            // Header line spacing
            lineHeight = baseHeight + HEADER_UNDERLINE_PIX + HEADER_UNDERLINE_GAP;
        } else {
            // Base spacing for normal lines
            int desiredBody = baseHeight + BODY_LINE_LEADING;

            if (hasEmote) {
                // Emote line: add overshoot + bottom padding
                int overshoot = std::max(0, tallestEmote - baseHeight);
                lineHeight = desiredBody + overshoot + EMOTE_PADDING_BELOW;
            } else {
                // Regular line: no emote → standard spacing
                lineHeight = desiredBody;

                // If next line has an emote → add top padding *here*
                if (nextHasEmote) {
                    lineHeight += EMOTE_PADDING_ABOVE;
                }
            }

            // Add block gap if next is a header
            if (idx + 1 < lines.size() && isHeaderVec[idx + 1]) {
                lineHeight += MESSAGE_BLOCK_GAP;
            }
        }

        rowHeights.push_back(lineHeight);
    }

    return rowHeights;
}

void handleNewMessage(OLEDDisplay *display, const StoredMessage &sm, const meshtastic_MeshPacket &packet)
{
    if (packet.from != 0) {
        hasUnreadMessage = true;
        const bool suppressBanner = cannedMessageModule && cannedMessageModule->isFreeTextActive();
        // Don't let the pop-up clobber a menu/picker the user is interacting with; the wake below
        // still happens so a message can light the screen back up.
        const bool menuShowing = NotificationRenderer::isMenuShowing();

        // Determine if message belongs to a muted channel
        bool isChannelMuted = false;
        if (sm.type == MessageType::BROADCAST) {
            const meshtastic_Channel channel = channels.getByIndex(packet.channel ? packet.channel : channels.getPrimaryIndex());
            if (channel.settings.has_module_settings && channel.settings.module_settings.is_muted)
                isChannelMuted = true;
        }

        // Banner logic
        const meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(packet.from);
        char longName[64] = "?";
        if (nodeInfoLiteHasUser(node)) {
            if (node->long_name[0]) {
                strncpy(longName, node->long_name, sizeof(longName) - 1);
                longName[sizeof(longName) - 1] = '\0';
            } else if (node->short_name[0]) {
                strncpy(longName, node->short_name, sizeof(longName) - 1);
                longName[sizeof(longName) - 1] = '\0';
            }
        }
        int availWidth = display->getWidth() - ((currentResolution == ScreenResolution::High) ? 40 : 20);
        if (availWidth < 0)
            availWidth = 0;
        char truncatedLongName[64];
        graphics::UIRenderer::truncateStringWithEmotes(display, longName, truncatedLongName, sizeof(truncatedLongName),
                                                       availWidth);
        const char *msgRaw = reinterpret_cast<const char *>(packet.decoded.payload.bytes);

        char banner[256];
        bool isAlert = false;

        // Check if alert detection is enabled via external notification module
        if (moduleConfig.external_notification.alert_bell || moduleConfig.external_notification.alert_bell_vibra ||
            moduleConfig.external_notification.alert_bell_buzzer) {
            for (size_t i = 0; i < packet.decoded.payload.size && i < 100; i++) {
                if (msgRaw[i] == '\x07') {
                    isAlert = true;
                    break;
                }
            }
        }

        if (isAlert) {
            if (truncatedLongName[0])
                snprintf(banner, sizeof(banner), "Alert Received from\n%s", truncatedLongName);
            else
                strcpy(banner, "Alert Received");
        } else {
            // Skip muted channels unless it's an alert
            if (isChannelMuted)
                return;

            if (truncatedLongName[0]) {
                if (currentResolution == ScreenResolution::UltraLow) {
                    strcpy(banner, "New Message");
                } else {
                    snprintf(banner, sizeof(banner), "New Message from\n%s", truncatedLongName);
                }
            } else
                strcpy(banner, "New Message");
        }

        // Append context (which channel or DM) so the banner shows where the message arrived
        {
            char contextBuf[64] = "";
            if (sm.type == MessageType::BROADCAST) {
                const char *cname = channels.getName(sm.channelIndex);
                if (cname && cname[0])
                    snprintf(contextBuf, sizeof(contextBuf), "in #%s", cname);
                else
                    snprintf(contextBuf, sizeof(contextBuf), "in Ch%d", sm.channelIndex);
            }

            if (contextBuf[0]) {
                size_t cur = strlen(banner);
                if (cur + 1 < sizeof(banner)) {
                    if (cur > 0 && banner[cur - 1] != '\n') {
                        banner[cur] = '\n';
                        banner[cur + 1] = '\0';
                        cur++;
                    }
                    strncat(banner, contextBuf, sizeof(banner) - cur - 1);
                }
            }
        }

        // Shorter banner if already in a conversation (Channel or Direct)
        bool inThread = (getThreadMode() != ThreadMode::ALL);

        if (!suppressBanner && shouldWakeOnReceivedMessage()) {
            screen->setOn(true);
        }

        if (!suppressBanner && !menuShowing) {
            screen->showSimpleBanner(banner, inThread ? 1000 : 3000);
        }
    }

    // Always focus into the correct conversation thread when a message with real text arrives
    const char *msgText = MessageStore::getText(sm);
    if (msgText && msgText[0] != '\0') {
        setThreadFor(sm, packet);
    }

    // Reset scroll for a clean start
    resetScrollState();
}

void setThreadFor(const StoredMessage &sm, const meshtastic_MeshPacket &packet)
{
    if (packet.to == 0 || packet.to == NODENUM_BROADCAST) {
        setThreadMode(ThreadMode::CHANNEL, sm.channelIndex);
    } else {
        uint32_t localNode = nodeDB->getNodeNum();
        uint32_t peer = (sm.sender == localNode) ? packet.to : sm.sender;
        setThreadMode(ThreadMode::DIRECT, -1, peer);
    }
}

} // namespace MessageRenderer
} // namespace graphics
#endif
