// The MIT License (MIT)
//
// Copyright (C) 2026 Scott Duensing
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to
// deal in the Software without restriction, including without limitation the
// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
// sell copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.  IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
// IN THE SOFTWARE.

// dvxhelp.c -- DVX Help Viewer application
//
// A callback-only DXE app that displays .hlp help files with a TreeView
// TOC sidebar and scrollable content area. Supports headings, text,
// links, images, lists, horizontal rules, notes, and code blocks.
//
// The content area is rebuilt per-topic by reading records from the .hlp
// file and creating private widget types (HelpText, HelpHeading, HelpLink)
// registered via wgtRegisterClass at startup.
//
// Navigation history supports back/forward with a fixed-size circular stack.

#include "hlpformat.h"
#include "dvxApp.h"
#include "dvxDlg.h"
#include "dvxWgt.h"
#include "dvxWgtP.h"
#include "dvxWm.h"
#include "dvxDraw.h"
#include "dvxVideo.h"
#include "dvxPlat.h"
#include "shellApp.h"
#include "box/box.h"
#include "button/button.h"
#include "splitter/splitter.h"
#include "treeView/treeView.h"
#include "scrollPane/scrlPane.h"
#include "toolbar/toolbar.h"
#include "image/image.h"

#include <stdint.h>
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <strings.h>

#include "dvxMem.h"

// ============================================================
// Constants
// ============================================================

#define HELP_WIN_W          560
#define HELP_WIN_H          400
#define HELP_CASCADE_OFFSET 16
#define HELP_SPLITTER_POS   160
#define HELP_TOC_MIN_W      100
#define HELP_CONTENT_PAD    6
#define HELP_PARA_SPACING   12
#define HELP_HEADING1_TOP   24
#define HELP_HEADING1_PAD   6
#define HELP_HEADING2_TOP   16
#define HELP_HEADING2_PAD   4
#define HELP_HEADING3_TOP   8
#define HELP_LINK_PAD_X     2
#define HELP_LINK_PAD_Y     1
#define HELP_NOTE_PAD       6
#define HELP_NOTE_PREFIX_W  4
#define HELP_CODE_PAD       4
#define HELP_RULE_H         5            // horizontal-rule widget min height
#define HELP_RULE_LINE_Y    2            // rule line offset within the widget
#define HELP_TITLE_MAX      300          // window-title buffer length

// MAX_HISTORY -- fixed back-button history depth. 64 entries is plenty for a
// help viewer; the oldest is dropped when exceeded (see the memmove below).
#define MAX_HISTORY         64
#define MAX_SEARCH_BUF      128

#define CMD_BACK    100
#define CMD_FORWARD 101
#define CMD_INDEX   102
#define CMD_SEARCH  103
#define CMD_EXIT    104

// userData stores topicIdx + 1 so that topic 0 doesn't collide with NULL
#define TOPIC_TO_USERDATA(idx) ((void *)(intptr_t)((idx) + 1))
#define USERDATA_TO_TOPIC(ud)  ((int32_t)(intptr_t)(ud) - 1)

// ============================================================
// Private widget data types
// ============================================================

typedef struct {
    char    *text;       // original flowing text (no newlines)
    char    *wrapped;    // cached wrapped text (with \n), NULL = needs wrap
    int32_t  lineCount;  // line count of wrapped text
    int32_t  wrapWidth;  // pixel width text was wrapped at (-1 = dirty)
} HelpTextDataT;

typedef struct {
    char    *text;
    int32_t  level;
} HelpHeadingDataT;

typedef struct {
    char *displayText;
    char *targetTopicId;
} HelpLinkDataT;

typedef struct {
    char    *text;
    char    *wrapped;
    int32_t  lineCount;
    int32_t  wrapWidth;
    uint8_t  noteType;
} HelpNoteDataT;

typedef struct {
    char    *text;
    int32_t  lineCount;
} HelpCodeDataT;

typedef struct {
    char    *text;
    char    *wrapped;
    int32_t  lineCount;
    int32_t  wrapWidth;
} HelpListItemDataT;

// ============================================================
// Module state
// ============================================================

static AppContextT    *sAc          = NULL;
static WindowT        *sWin         = NULL;
static WidgetT        *sTocTree     = NULL;
static WidgetT        *sContentScroll = NULL;
static WidgetT        *sContentBox  = NULL;
static WidgetT        *sBtnBack     = NULL;
static WidgetT        *sBtnForward  = NULL;

// .hlp file data
static FILE           *sHlpFile     = NULL;
static HlpHeaderT      sHeader;
static HlpTopicDirT   *sTopicDir    = NULL;
static char            *sStringTable = NULL;
static HlpTocEntryT   *sTocEntries  = NULL;
static HlpIndexEntryT *sIndexEntries = NULL;

// Navigation history
static int32_t sHistory[MAX_HISTORY];
static int32_t sHistoryPos   = -1;
static int32_t sHistoryCount = 0;
static int32_t sCurrentTopic = -1;

// Viewport wrap width — the width text should wrap at, derived from the
// ScrollPane's inner area. Updated when the window is first laid out and
// on resize. Wrapping widgets use this instead of w->parent->w so that
// wide tables/code don't inflate the wrap width.
static int32_t sWrapWidth = 0;

// Custom widget type IDs
static int32_t sHelpTextTypeId    = -1;
static int32_t sHelpHeadingTypeId = -1;
static int32_t sHelpLinkTypeId    = -1;
static int32_t sHelpNoteTypeId    = -1;
static int32_t sHelpCodeTypeId    = -1;
static int32_t sHelpRuleTypeId    = -1;
static int32_t sHelpListItemTypeId = -1;

// ============================================================
// Prototypes
// ============================================================

int32_t     appMain(DxeAppContextT *ctx);
static void buildContentWidgets(void);
static void closeHelpFile(void);
static int32_t countLines(const char *text);
static void displayRecord(const HlpRecordHdrT *hdr, const char *payload);
static const char *doWrap(char **wrappedPtr, int32_t *wrapWidthPtr, int32_t *lineCountPtr, const char *text, int32_t pixelW, int32_t charW, int32_t padPixels);
static int32_t findTopicByIdStr(const char *topicId);
static WidgetT *findTreeItemByTopic(WidgetT *node, int32_t topicIdx);
static void helpCodeCalcMinSize(WidgetT *w, const BitmapFontT *font);
static void helpCodeDestroy(WidgetT *w);
static void helpCodePaint(WidgetT *w, DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, const ColorSchemeT *colors);
static void helpHeadingCalcMinSize(WidgetT *w, const BitmapFontT *font);
static void helpHeadingDestroy(WidgetT *w);
static void helpHeadingPaint(WidgetT *w, DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, const ColorSchemeT *colors);
static void helpLinkCalcMinSize(WidgetT *w, const BitmapFontT *font);
static void helpLinkDestroy(WidgetT *w);
static void helpLinkOnMouse(WidgetT *w, WidgetT *root, int32_t vx, int32_t vy);
static void helpLinkPaint(WidgetT *w, DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, const ColorSchemeT *colors);
static void helpListItemCalcMinSize(WidgetT *w, const BitmapFontT *font);
static void helpListItemDestroy(WidgetT *w);
static void helpListItemPaint(WidgetT *w, DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, const ColorSchemeT *colors);
static void helpNoteCalcMinSize(WidgetT *w, const BitmapFontT *font);
static void helpNoteDestroy(WidgetT *w);
static void helpNotePaint(WidgetT *w, DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, const ColorSchemeT *colors);
static void helpRuleCalcMinSize(WidgetT *w, const BitmapFontT *font);
static void helpRulePaint(WidgetT *w, DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, const ColorSchemeT *colors);
static void helpTextCalcMinSize(WidgetT *w, const BitmapFontT *font);
static void helpTextDestroy(WidgetT *w);
static void helpTextPaint(WidgetT *w, DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, const ColorSchemeT *colors);
static void historyPush(int32_t topicIdx);
static const char *hlpString(uint32_t offset);
static bool isFirstChild(WidgetT *w);
static int32_t maxLineWidth(const BitmapFontT *font, const char *text);
static bool nextSiblingHasOwnBorder(WidgetT *w);
static void navigateBack(void);
static void navigateForward(void);
static void navigateToTopic(int32_t topicIdx);
static void onClose(WindowT *win);
static void onIndex(WidgetT *w);
static void onMenu(WindowT *win, int32_t menuId);
static void onNavBack(WidgetT *w);
static void onNavForward(WidgetT *w);
static void onSearch(WidgetT *w);
static void onTocChange(WidgetT *w);
static bool openHelpFile(const char *path);
static void paintLines(DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, int32_t x, int32_t y, const char *text, int32_t lineCount, uint32_t fg);
static void populateToc(void);
static void registerWidgetClasses(void);
static void showTopic(int32_t topicIdx);
static void updateNavButtons(void);
static void updateWrapWidth(void);
static char *wrapText(const char *text, int32_t colWidth);

// ============================================================
// Widget classes
// ============================================================

static const WidgetClassT sClassHelpText = {
    .version  = WGT_CLASS_VERSION,
    .flags    = 0,
    .handlers = {
        [WGT_METHOD_PAINT]         = (void *)helpTextPaint,
        [WGT_METHOD_CALC_MIN_SIZE] = (void *)helpTextCalcMinSize,
        [WGT_METHOD_DESTROY]       = (void *)helpTextDestroy,
    }
};

static const WidgetClassT sClassHelpHeading = {
    .version  = WGT_CLASS_VERSION,
    .flags    = 0,
    .handlers = {
        [WGT_METHOD_PAINT]         = (void *)helpHeadingPaint,
        [WGT_METHOD_CALC_MIN_SIZE] = (void *)helpHeadingCalcMinSize,
        [WGT_METHOD_DESTROY]       = (void *)helpHeadingDestroy,
    }
};

static const WidgetClassT sClassHelpLink = {
    .version  = WGT_CLASS_VERSION,
    .flags    = WCLASS_FOCUSABLE,
    .handlers = {
        [WGT_METHOD_PAINT]         = (void *)helpLinkPaint,
        [WGT_METHOD_CALC_MIN_SIZE] = (void *)helpLinkCalcMinSize,
        [WGT_METHOD_ON_MOUSE]      = (void *)helpLinkOnMouse,
        [WGT_METHOD_DESTROY]       = (void *)helpLinkDestroy,
    }
};

static const WidgetClassT sClassHelpNote = {
    .version  = WGT_CLASS_VERSION,
    .flags    = 0,
    .handlers = {
        [WGT_METHOD_PAINT]         = (void *)helpNotePaint,
        [WGT_METHOD_CALC_MIN_SIZE] = (void *)helpNoteCalcMinSize,
        [WGT_METHOD_DESTROY]       = (void *)helpNoteDestroy,
    }
};

static const WidgetClassT sClassHelpCode = {
    .version  = WGT_CLASS_VERSION,
    .flags    = 0,
    .handlers = {
        [WGT_METHOD_PAINT]         = (void *)helpCodePaint,
        [WGT_METHOD_CALC_MIN_SIZE] = (void *)helpCodeCalcMinSize,
        [WGT_METHOD_DESTROY]       = (void *)helpCodeDestroy,
    }
};

static const WidgetClassT sClassHelpRule = {
    .version  = WGT_CLASS_VERSION,
    .flags    = 0,
    .handlers = {
        [WGT_METHOD_PAINT]         = (void *)helpRulePaint,
        [WGT_METHOD_CALC_MIN_SIZE] = (void *)helpRuleCalcMinSize,
    }
};

static const WidgetClassT sClassHelpListItem = {
    .version  = WGT_CLASS_VERSION,
    .flags    = 0,
    .handlers = {
        [WGT_METHOD_PAINT]         = (void *)helpListItemPaint,
        [WGT_METHOD_CALC_MIN_SIZE] = (void *)helpListItemCalcMinSize,
        [WGT_METHOD_DESTROY]       = (void *)helpListItemDestroy,
    }
};

// ============================================================
// App descriptor
// ============================================================

AppDescriptorT appDescriptor = {
    .name          = "DVX Help",
    .hasMainLoop   = false,
    .multiInstance = true,
    .stackSize     = SHELL_STACK_DEFAULT,
    .priority      = TS_PRIORITY_NORMAL
};


// Re-wrap all text content widgets at the current content box width.

static void buildContentWidgets(void) {
    if (!sContentBox || sCurrentTopic < 0) {
        return;
    }

    // Destroy old content
    widgetDestroyChildren(sContentBox);

    // Read topic content from file
    HlpTopicDirT *topic = &sTopicDir[sCurrentTopic];

    fseek(sHlpFile, topic->contentOffset, SEEK_SET);

    uint32_t bytesRead = 0;

    while (bytesRead < topic->contentSize) {
        HlpRecordHdrT recHdr;

        if (fread(&recHdr, sizeof(recHdr), 1, sHlpFile) != 1) {
            break;
        }

        bytesRead += sizeof(recHdr);

        if (recHdr.type == HLP_REC_END) {
            break;
        }

        if (recHdr.length == 0) {
            displayRecord(&recHdr, "");
            continue;
        }

        // Read payload
        char *payload = (char *)dvxMalloc(recHdr.length + 1);

        if (!payload) {
            break;
        }

        if (fread(payload, 1, recHdr.length, sHlpFile) != recHdr.length) {
            dvxFree(payload);
            break;
        }

        payload[recHdr.length] = '\0';
        bytesRead += recHdr.length;

        displayRecord(&recHdr, payload);
        dvxFree(payload);
    }

    // Compute viewport wrap width from the ScrollPane.
    // ScrollPane border = 2*2 = 4, vertical scrollbar = 14, VBox padding = 2*6 = 12.
    // Total overhead = 30. This is approximate but stable.
    if (sContentScroll && sContentScroll->w > 30) {
        updateWrapWidth();
    }

    // Reset scroll to top
    if (sContentScroll) {
        wgtScrollPaneScrollToTop(sContentScroll);
    }

    if (sContentScroll) {
        wgtInvalidate(sContentScroll);
    }
}


static void closeHelpFile(void) {
    if (sHlpFile) {
        fclose(sHlpFile);
        sHlpFile = NULL;
    }

    dvxFree(sTopicDir);
    sTopicDir = NULL;

    dvxFree(sStringTable);
    sStringTable = NULL;

    dvxFree(sTocEntries);
    sTocEntries = NULL;

    dvxFree(sIndexEntries);
    sIndexEntries = NULL;
}


static int32_t countLines(const char *text) {
    int32_t count = 1;

    if (!text) {
        return count;
    }

    for (const char *p = text; *p; p++) {
        if (*p == '\n') {
            count++;
        }
    }

    return count;
}


static void displayRecord(const HlpRecordHdrT *hdr, const char *payload) {
    if (!sContentBox) {
        return;
    }

    switch (hdr->type) {
        case HLP_REC_TEXT: {
            WidgetT *w = widgetAlloc(sContentBox, sHelpTextTypeId);

            if (w) {
                HelpTextDataT *td = (HelpTextDataT *)dvxCalloc(1, sizeof(HelpTextDataT));

                if (td) {
                    td->text      = dvxStrdup(payload);
                    td->wrapWidth = -1;
                    w->data       = td;
                } else {
                    // The class handlers deref w->data unconditionally, so a
                    // data-less widget must not survive into the next layout.
                    widgetAllocRollback(w);
                }
            }

            break;
        }

        case HLP_REC_HEADING1:
        case HLP_REC_HEADING2:
        case HLP_REC_HEADING3: {
            WidgetT *w = widgetAlloc(sContentBox, sHelpHeadingTypeId);

            if (w) {
                HelpHeadingDataT *hd = (HelpHeadingDataT *)dvxCalloc(1, sizeof(HelpHeadingDataT));

                if (hd) {
                    hd->text  = dvxStrdup(payload);
                    hd->level = hdr->type - HLP_REC_HEADING1 + 1;
                    w->data   = hd;
                } else {
                    widgetAllocRollback(w);
                }
            }

            break;
        }

        case HLP_REC_LINK: {
            // Payload: target topic ID \0 display text \0
            size_t targetLen = strlen(payload);

            // Reject only a payload with no terminator within bounds so
            // displayText doesn't point past the allocation.  A payload of
            // exactly targetLen + 1 bytes is the writer's legal encoding of
            // a link with empty display text (displayText then points at the
            // NUL the reader appended at payload[length]).
            if (targetLen + 1 > hdr->length) {
                break;
            }

            const char *targetTopicId = payload;
            const char *displayText   = payload + targetLen + 1;

            // Empty display text: fall back to the target ID, matching the
            // compiler's own HTML emitter.
            if (displayText[0] == '\0') {
                displayText = targetTopicId;
            }
            WidgetT *w = widgetAlloc(sContentBox, sHelpLinkTypeId);

            if (w) {
                HelpLinkDataT *ld = (HelpLinkDataT *)dvxCalloc(1, sizeof(HelpLinkDataT));

                if (ld) {
                    ld->displayText   = dvxStrdup(displayText);
                    ld->targetTopicId = dvxStrdup(targetTopicId);
                    w->data           = ld;
                } else {
                    widgetAllocRollback(w);
                }
            }

            break;
        }

        case HLP_REC_IMAGE: {
            if (hdr->length >= sizeof(HlpImageRefT)) {
                const HlpImageRefT *imgRef = (const HlpImageRefT *)payload;
                uint32_t absOffset = sHeader.imagePoolOffset + imgRef->imageOffset;

                // Save file position — buildContentWidgets reads records
                // sequentially and we must not disturb its position.
                long savedPos = ftell(sHlpFile);

                uint8_t *bmpData = (uint8_t *)dvxMalloc(imgRef->imageSize);

                if (bmpData) {
                    fseek(sHlpFile, absOffset, SEEK_SET);
                    size_t bytesRead = fread(bmpData, 1, imgRef->imageSize, sHlpFile);

                    if (bytesRead == imgRef->imageSize) {
                        int32_t  imgW     = 0;
                        int32_t  imgH     = 0;
                        int32_t  imgP     = 0;
                        bool     hasAlpha = false;
                        uint32_t keyColor = 0;
                        uint8_t *pixels   = dvxLoadImageAlpha(sAc, bmpData, (int32_t)imgRef->imageSize, &imgW, &imgH, &imgP, &hasAlpha, &keyColor);

                        if (pixels) {
                            WidgetT *parent = sContentBox;

                            if (hdr->flags == HLP_IMG_CENTER || hdr->flags == HLP_IMG_RIGHT) {
                                WidgetT *row = wgtHBox(sContentBox);

                                if (row) {
                                    row->align = (hdr->flags == HLP_IMG_CENTER) ? AlignCenterE : AlignEndE;
                                    parent = row;
                                }
                            }

                            // wgtImage takes ownership of pixels even on
                            // failure (it frees them itself before returning
                            // NULL), so no cleanup is needed here.
                            WidgetT *imgWgt = wgtImage(parent, pixels, imgW, imgH, imgP);

                            if (imgWgt && hasAlpha) {
                                wgtImageSetTransparent(imgWgt, true, keyColor);
                            }
                        }
                    }

                    dvxFree(bmpData);
                }

                // Restore file position for sequential record reading
                fseek(sHlpFile, savedPos, SEEK_SET);
            }

            break;
        }

        case HLP_REC_LIST_ITEM: {
            WidgetT *w = widgetAlloc(sContentBox, sHelpListItemTypeId);

            if (w) {
                HelpListItemDataT *ld = (HelpListItemDataT *)dvxCalloc(1, sizeof(HelpListItemDataT));

                if (ld) {
                    // Prepend bullet + space to the text
                    int32_t pLen  = (int32_t)strlen(payload);
                    ld->text      = (char *)dvxMalloc(pLen + 3);

                    if (ld->text) {
                        ld->text[0] = '\x07';
                        ld->text[1] = ' ';
                        memcpy(ld->text + 2, payload, pLen + 1);
                    }

                    ld->wrapWidth = -1;
                    w->data       = ld;
                } else {
                    widgetAllocRollback(w);
                }
            }

            break;
        }

        case HLP_REC_HRULE: {
            widgetAlloc(sContentBox, sHelpRuleTypeId);
            break;
        }

        case HLP_REC_NOTE: {
            WidgetT *w = widgetAlloc(sContentBox, sHelpNoteTypeId);

            if (w) {
                HelpNoteDataT *nd = (HelpNoteDataT *)dvxCalloc(1, sizeof(HelpNoteDataT));

                if (nd) {
                    nd->text      = dvxStrdup(payload);
                    nd->wrapWidth = -1;
                    nd->noteType  = hdr->flags;
                    w->data       = nd;
                } else {
                    widgetAllocRollback(w);
                }
            }

            break;
        }

        // Tables render as preformatted text, identical to code blocks.
        case HLP_REC_TABLE:
        case HLP_REC_CODE: {
            WidgetT *w = widgetAlloc(sContentBox, sHelpCodeTypeId);

            if (w) {
                HelpCodeDataT *cd = (HelpCodeDataT *)dvxCalloc(1, sizeof(HelpCodeDataT));

                if (cd) {
                    cd->text      = dvxStrdup(payload);
                    cd->lineCount = countLines(cd->text);
                    w->data       = cd;
                } else {
                    widgetAllocRollback(w);
                }
            }

            break;
        }
    }
}


// Ensure a HelpText-style widget's wrapped text is up to date for the given pixel width.
// Returns the wrapped text and updates lineCount. Uses cached result if width unchanged.
// Wrap text at the given pixel width. Caches the result — only re-wraps
// when pixelW changes. Returns the wrapped string.
static const char *doWrap(char **wrappedPtr, int32_t *wrapWidthPtr, int32_t *lineCountPtr, const char *text, int32_t pixelW, int32_t charW, int32_t padPixels) {
    int32_t cols = (pixelW - padPixels) / charW;

    if (cols < 10) {
        cols = 10;
    }

    // Return cached result if width hasn't changed
    if (*wrappedPtr && *wrapWidthPtr == pixelW) {
        // cppcheck-suppress identicalInnerCondition
        return *wrappedPtr;
    }

    dvxFree(*wrappedPtr);
    *wrappedPtr   = wrapText(text, cols);
    *wrapWidthPtr = pixelW;
    *lineCountPtr = countLines(*wrappedPtr);
    return *wrappedPtr;
}


static int32_t findTopicByIdStr(const char *topicId) {
    if (!topicId || !sTopicDir) {
        return -1;
    }

    // Linear search -- topic directory is typically small
    for (uint32_t i = 0; i < sHeader.topicCount; i++) {
        const char *id = hlpString(sTopicDir[i].topicIdStr);

        if (strcmp(id, topicId) == 0) {
            return (int32_t)i;
        }
    }

    return -1;
}


static WidgetT *findTreeItemByTopic(WidgetT *node, int32_t topicIdx) {
    for (WidgetT *child = node->firstChild; child; child = child->nextSibling) {
        if (child->userData && USERDATA_TO_TOPIC(child->userData) == topicIdx) {
            return child;
        }

        WidgetT *found = findTreeItemByTopic(child, topicIdx);

        if (found) {
            return found;
        }
    }

    return NULL;
}


static void helpCodeCalcMinSize(WidgetT *w, const BitmapFontT *font) {
    HelpCodeDataT *cd = (HelpCodeDataT *)w->data;
    w->calcMinW = maxLineWidth(font, cd->text) + HELP_CONTENT_PAD * 2 + HELP_CODE_PAD * 2;
    w->calcMinH = cd->lineCount * font->charHeight + HELP_CODE_PAD * 2;
}


static void helpCodeDestroy(WidgetT *w) {
    HelpCodeDataT *cd = (HelpCodeDataT *)w->data;

    if (cd) {
        dvxFree(cd->text);
        dvxFree(cd);
        w->data = NULL;
    }
}


static void helpCodePaint(WidgetT *w, DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, const ColorSchemeT *colors) {
    HelpCodeDataT *cd = (HelpCodeDataT *)w->data;

    int32_t boxX = w->x + HELP_CONTENT_PAD;
    int32_t boxW = w->w - HELP_CONTENT_PAD * 2;

    // Sunken background
    rectFill(d, ops, boxX, w->y, boxW, w->h, colors->contentBg);
    drawHLine(d, ops, boxX, w->y, boxW, colors->windowShadow);
    drawHLine(d, ops, boxX, w->y + w->h - 1, boxW, colors->windowHighlight);

    paintLines(d, ops, font, boxX + HELP_CODE_PAD, w->y + HELP_CODE_PAD, cd->text, cd->lineCount, colors->contentFg);
}


static void helpHeadingCalcMinSize(WidgetT *w, const BitmapFontT *font) {
    HelpHeadingDataT *hd = (HelpHeadingDataT *)w->data;
    int32_t textH = font->charHeight;
    bool    first = isFirstChild(w);

    w->calcMinW = 0;

    if (hd->level == 1) {
        int32_t top = first ? 0 : HELP_HEADING1_TOP;
        w->calcMinH = top + textH + HELP_HEADING1_PAD * 2;
    } else if (hd->level == 2) {
        int32_t top = first ? 0 : HELP_HEADING2_TOP;
        bool nextHasBorder = nextSiblingHasOwnBorder(w);
        int32_t extra = nextHasBorder ? 0 : 2;
        w->calcMinH = top + textH + HELP_HEADING2_PAD + extra;
    } else {
        int32_t top = first ? 0 : HELP_HEADING3_TOP;
        w->calcMinH = top + textH;
    }
}


static void helpHeadingDestroy(WidgetT *w) {
    HelpHeadingDataT *hd = (HelpHeadingDataT *)w->data;

    if (hd) {
        dvxFree(hd->text);
        dvxFree(hd);
        w->data = NULL;
    }
}


static void helpHeadingPaint(WidgetT *w, DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, const ColorSchemeT *colors) {
    HelpHeadingDataT *hd = (HelpHeadingDataT *)w->data;
    int32_t textLen = (int32_t)strlen(hd->text);
    bool    first   = isFirstChild(w);

    if (hd->level == 1) {
        int32_t top  = first ? 0 : HELP_HEADING1_TOP;
        int32_t barY = w->y + top;
        int32_t barH = w->h - top;
        rectFill(d, ops, w->x, barY, w->w, barH, colors->activeTitleBg);
        int32_t textY = barY + HELP_HEADING1_PAD;
        drawTextN(d, ops, font, w->x + HELP_CONTENT_PAD, textY, hd->text, textLen, colors->activeTitleFg, 0, false);
    } else if (hd->level == 2) {
        int32_t top   = first ? 0 : HELP_HEADING2_TOP;
        int32_t textY = w->y + top;
        drawTextN(d, ops, font, w->x + HELP_CONTENT_PAD, textY, hd->text, textLen, colors->contentFg, 0, false);

        // Suppress the underline when the next sibling paints its own
        // top border, so we don't end up with two nearly-adjacent lines.
        // Tables and code blocks both use sHelpCodeTypeId; .hr uses
        // sHelpRuleTypeId.
        bool nextHasBorder = nextSiblingHasOwnBorder(w);

        if (!nextHasBorder) {
            int32_t lineY = textY + font->charHeight + 1;
            drawHLine(d, ops, w->x + HELP_CONTENT_PAD, lineY, w->w - HELP_CONTENT_PAD * 2, colors->windowShadow);
        }
    } else {
        int32_t top   = first ? 0 : HELP_HEADING3_TOP;
        int32_t textY = w->y + top;
        drawTextN(d, ops, font, w->x + HELP_CONTENT_PAD, textY, hd->text, textLen, colors->contentFg, 0, false);
    }
}


static void helpLinkCalcMinSize(WidgetT *w, const BitmapFontT *font) {
    w->calcMinW = 0;
    w->calcMinH = font->charHeight + HELP_LINK_PAD_Y * 2;
}


static void helpLinkDestroy(WidgetT *w) {
    HelpLinkDataT *ld = (HelpLinkDataT *)w->data;

    if (ld) {
        dvxFree(ld->displayText);
        dvxFree(ld->targetTopicId);
        dvxFree(ld);
        w->data = NULL;
    }
}


static void helpLinkOnMouse(WidgetT *w, WidgetT *root, int32_t vx, int32_t vy) {
    (void)root;
    (void)vx;
    (void)vy;

    HelpLinkDataT *ld = (HelpLinkDataT *)w->data;

    if (!ld || !ld->targetTopicId) {
        return;
    }

    int32_t topicIdx = findTopicByIdStr(ld->targetTopicId);

    if (topicIdx >= 0) {
        navigateToTopic(topicIdx);
    }
}


static void helpLinkPaint(WidgetT *w, DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, const ColorSchemeT *colors) {
    HelpLinkDataT *ld = (HelpLinkDataT *)w->data;
    int32_t textLen = (int32_t)strlen(ld->displayText);
    int32_t x       = w->x + HELP_CONTENT_PAD + HELP_LINK_PAD_X;
    int32_t y       = w->y + HELP_LINK_PAD_Y;

    // Use active title color as link color
    uint32_t linkColor = colors->activeTitleBg;

    drawTextN(d, ops, font, x, y, ld->displayText, textLen, linkColor, 0, false);

    // Underline
    int32_t lineY = y + font->charHeight;
    int32_t lineW = textLen * font->charWidth;
    drawHLine(d, ops, x, lineY, lineW, linkColor);
}


static void helpListItemCalcMinSize(WidgetT *w, const BitmapFontT *font) {
    HelpListItemDataT *ld = (HelpListItemDataT *)w->data;
    updateWrapWidth();

    if (sWrapWidth > 0) {
        doWrap(&ld->wrapped, &ld->wrapWidth, &ld->lineCount, ld->text, sWrapWidth, font->charWidth, 0);
    }

    w->calcMinW = 0;
    w->calcMinH = ld->lineCount * font->charHeight;
}


static void helpListItemDestroy(WidgetT *w) {
    HelpListItemDataT *ld = (HelpListItemDataT *)w->data;

    if (ld) {
        dvxFree(ld->wrapped);
        dvxFree(ld->text);
        dvxFree(ld);
        w->data = NULL;
    }
}


static void helpListItemPaint(WidgetT *w, DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, const ColorSchemeT *colors) {
    HelpListItemDataT *ld = (HelpListItemDataT *)w->data;
    const char *text = ld->wrapped ? ld->wrapped : ld->text;
    paintLines(d, ops, font, w->x + HELP_CONTENT_PAD, w->y, text, ld->lineCount, colors->contentFg);
}


static void helpNoteCalcMinSize(WidgetT *w, const BitmapFontT *font) {
    HelpNoteDataT *nd = (HelpNoteDataT *)w->data;
    updateWrapWidth();

    if (sWrapWidth > 0) {
        int32_t notePad = HELP_NOTE_PAD * 2 + HELP_NOTE_PREFIX_W;
        doWrap(&nd->wrapped, &nd->wrapWidth, &nd->lineCount, nd->text, sWrapWidth, font->charWidth, notePad);
    }

    w->calcMinW = 0;
    w->calcMinH = nd->lineCount * font->charHeight + HELP_NOTE_PAD * 2;
}


static void helpNoteDestroy(WidgetT *w) {
    HelpNoteDataT *nd = (HelpNoteDataT *)w->data;

    if (nd) {
        dvxFree(nd->wrapped);
        dvxFree(nd->text);
        dvxFree(nd);
        w->data = NULL;
    }
}


static void helpNotePaint(WidgetT *w, DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, const ColorSchemeT *colors) {
    HelpNoteDataT *nd = (HelpNoteDataT *)w->data;

    uint32_t barColor;

    if (nd->noteType == HLP_NOTE_WARNING) {
        barColor = colors->activeTitleBg;
    } else if (nd->noteType == HLP_NOTE_TIP) {
        barColor = colors->menuHighlightBg;
    } else {
        barColor = colors->windowShadow;
    }

    int32_t boxX = w->x + HELP_CONTENT_PAD;
    int32_t boxW = w->w - HELP_CONTENT_PAD * 2;

    rectFill(d, ops, boxX, w->y, boxW, w->h, colors->windowFace);
    rectFill(d, ops, boxX, w->y, HELP_NOTE_PREFIX_W, w->h, barColor);

    int32_t textX = boxX + HELP_NOTE_PREFIX_W + HELP_NOTE_PAD;
    const char *text = nd->wrapped ? nd->wrapped : nd->text;
    paintLines(d, ops, font, textX, w->y + HELP_NOTE_PAD, text, nd->lineCount, colors->contentFg);
}


static void helpRuleCalcMinSize(WidgetT *w, const BitmapFontT *font) {
    (void)font;
    w->calcMinW = 0;
    w->calcMinH = HELP_RULE_H;
}


static void helpRulePaint(WidgetT *w, DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, const ColorSchemeT *colors) {
    (void)font;
    int32_t lineY = w->y + HELP_RULE_LINE_Y;
    drawHLine(d, ops, w->x + HELP_CONTENT_PAD, lineY, w->w - HELP_CONTENT_PAD * 2, colors->windowShadow);
    drawHLine(d, ops, w->x + HELP_CONTENT_PAD, lineY + 1, w->w - HELP_CONTENT_PAD * 2, colors->windowHighlight);
}


static void helpTextCalcMinSize(WidgetT *w, const BitmapFontT *font) {
    HelpTextDataT *td = (HelpTextDataT *)w->data;
    updateWrapWidth();

    if (sWrapWidth > 0) {
        doWrap(&td->wrapped, &td->wrapWidth, &td->lineCount, td->text, sWrapWidth, font->charWidth, 0);
    }

    w->calcMinW = 0;
    w->calcMinH = td->lineCount * font->charHeight;
}


static void helpTextDestroy(WidgetT *w) {
    HelpTextDataT *td = (HelpTextDataT *)w->data;

    if (td) {
        dvxFree(td->wrapped);
        dvxFree(td->text);
        dvxFree(td);
        w->data = NULL;
    }
}


static void helpTextPaint(WidgetT *w, DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, const ColorSchemeT *colors) {
    HelpTextDataT *td = (HelpTextDataT *)w->data;
    const char *text = td->wrapped ? td->wrapped : td->text;
    paintLines(d, ops, font, w->x + HELP_CONTENT_PAD, w->y, text, td->lineCount, colors->contentFg);
}


static void historyPush(int32_t topicIdx) {
    // Truncate forward history when navigating to a new topic
    sHistoryPos++;

    if (sHistoryPos >= MAX_HISTORY) {
        // Shift history down to make room
        memmove(&sHistory[0], &sHistory[1], (MAX_HISTORY - 1) * sizeof(int32_t));
        sHistoryPos = MAX_HISTORY - 1;
    }

    sHistory[sHistoryPos] = topicIdx;
    sHistoryCount         = sHistoryPos + 1;
}


static const char *hlpString(uint32_t offset) {
    if (!sStringTable || offset >= sHeader.stringTableSize) {
        return "";
    }

    return sStringTable + offset;
}


static bool isFirstChild(WidgetT *w) {
    return w->parent && w->parent->firstChild == w;
}


static int32_t maxLineWidth(const BitmapFontT *font, const char *text) {
    int32_t maxW    = 0;
    int32_t lineLen = 0;

    if (!text) {
        return 0;
    }

    for (const char *p = text; ; p++) {
        if (*p == '\n' || *p == '\0') {
            int32_t w = lineLen * font->charWidth;

            if (w > maxW) {
                maxW = w;
            }

            lineLen = 0;

            if (*p == '\0') {
                break;
            }
        } else {
            lineLen++;
        }
    }

    return maxW;
}


static void navigateBack(void) {
    if (sHistoryPos <= 0) {
        return;
    }

    sHistoryPos--;
    showTopic(sHistory[sHistoryPos]);
}


static void navigateForward(void) {
    if (sHistoryPos >= sHistoryCount - 1) {
        return;
    }

    sHistoryPos++;
    showTopic(sHistory[sHistoryPos]);
}


static void navigateToTopic(int32_t topicIdx) {
    if (topicIdx < 0 || (uint32_t)topicIdx >= sHeader.topicCount) {
        return;
    }

    if (topicIdx == sCurrentTopic) {
        return;
    }

    historyPush(topicIdx);
    showTopic(topicIdx);
}


// True if w's next sibling is a code or rule widget, which draws its own top
// border -- the heading then omits its bottom spacing to avoid a double rule.
static bool nextSiblingHasOwnBorder(WidgetT *w) {
    return w->nextSibling &&
           (w->nextSibling->type == sHelpCodeTypeId ||
            w->nextSibling->type == sHelpRuleTypeId);
}


static void onClose(WindowT *win) {
    closeHelpFile();
    dvxDestroyWindow(sAc, win);
    sWin            = NULL;
    sTocTree        = NULL;
    sContentScroll  = NULL;
    sContentBox     = NULL;
    sBtnBack        = NULL;
    sBtnForward     = NULL;
}


static void onIndex(WidgetT *w) {
    (void)w;

    if (!sIndexEntries || sHeader.indexCount == 0) {
        dvxInfoBox(sAc, "DVX Help", "No index available.");
        return;
    }

    // Build list of keyword strings
    const char **items = (const char **)dvxMalloc(sHeader.indexCount * sizeof(const char *));

    if (!items) {
        return;
    }

    for (uint32_t i = 0; i < sHeader.indexCount; i++) {
        items[i] = hlpString(sIndexEntries[i].keywordStr);
    }

    int32_t selected = -1;
    bool ok = dvxChoiceDialog(sAc, "Index", "Select a topic:", items, (int32_t)sHeader.indexCount, 0, &selected);

    if (ok && selected >= 0 && (uint32_t)selected < sHeader.indexCount) {
        int32_t topicIdx = (int32_t)sIndexEntries[selected].topicIdx;
        navigateToTopic(topicIdx);
    }

    dvxFree(items);
}


static void onMenu(WindowT *win, int32_t menuId) {
    (void)win;

    switch (menuId) {
        case CMD_BACK:
            navigateBack();
            break;

        case CMD_FORWARD:
            navigateForward();
            break;

        case CMD_INDEX:
            onIndex(NULL);
            break;

        case CMD_SEARCH:
            onSearch(NULL);
            break;

        case CMD_EXIT:
            if (sWin) {
                onClose(sWin);
            }
            break;
    }
}


static void onNavBack(WidgetT *w) {
    (void)w;
    navigateBack();
}


static void onNavForward(WidgetT *w) {
    (void)w;
    navigateForward();
}


static void onSearch(WidgetT *w) {
    (void)w;

    if (!sIndexEntries || sHeader.indexCount == 0) {
        dvxInfoBox(sAc, "DVX Help", "No search index available.");
        return;
    }

    char searchBuf[MAX_SEARCH_BUF] = "";

    if (!dvxInputBox(sAc, "Search Help", "Search for:", NULL, searchBuf, sizeof(searchBuf))) {
        return;
    }

    if (searchBuf[0] == '\0') {
        return;
    }

    // Linear scan of keyword index for case-insensitive substring match
    int32_t firstMatch = -1;
    int32_t needleLen  = strlen(searchBuf);

    for (uint32_t i = 0; i < sHeader.indexCount; i++) {
        const char *keyword = hlpString(sIndexEntries[i].keywordStr);
        bool        found   = false;

        for (const char *kp = keyword; *kp; kp++) {
            if (strncasecmp(kp, searchBuf, needleLen) == 0) {
                found = true;
                break;
            }
        }

        if (found) {
            firstMatch = (int32_t)sIndexEntries[i].topicIdx;
            break;
        }
    }

    if (firstMatch >= 0) {
        navigateToTopic(firstMatch);
    } else {
        dvxInfoBox(sAc, "Search Help", "No matching topics found.");
    }
}


static void onTocChange(WidgetT *w) {
    WidgetT *selected = wgtTreeViewGetSelected(w);

    if (!selected || !selected->userData) {
        return;
    }

    int32_t topicIdx = USERDATA_TO_TOPIC(selected->userData);

    if (topicIdx < 0 || topicIdx >= (int32_t)sHeader.topicCount) {
        return;
    }

    navigateToTopic(topicIdx);
}


static bool openHelpFile(const char *path) {
    sHlpFile = fopen(path, "rb");

    if (!sHlpFile) {
        return false;
    }

    // Read header from end of file
    fseek(sHlpFile, -(long)sizeof(HlpHeaderT), SEEK_END);

    if (fread(&sHeader, sizeof(HlpHeaderT), 1, sHlpFile) != 1) {
        fclose(sHlpFile);
        sHlpFile = NULL;
        return false;
    }

    // Validate magic
    if (sHeader.magic != HLP_MAGIC) {
        fclose(sHlpFile);
        sHlpFile = NULL;
        return false;
    }

    // Validate version
    if (sHeader.version != HLP_VERSION) {
        fclose(sHlpFile);
        sHlpFile = NULL;
        return false;
    }

    // Read topic directory
    sTopicDir = (HlpTopicDirT *)dvxMalloc(sHeader.topicCount * sizeof(HlpTopicDirT));

    if (!sTopicDir) {
        closeHelpFile();
        return false;
    }

    fseek(sHlpFile, sHeader.topicDirOffset, SEEK_SET);

    if (fread(sTopicDir, sizeof(HlpTopicDirT), sHeader.topicCount, sHlpFile) != sHeader.topicCount) {
        closeHelpFile();
        return false;
    }

    // Read string table
    sStringTable = (char *)dvxMalloc(sHeader.stringTableSize);

    if (!sStringTable) {
        closeHelpFile();
        return false;
    }

    fseek(sHlpFile, sHeader.stringTableOffset, SEEK_SET);

    if (fread(sStringTable, 1, sHeader.stringTableSize, sHlpFile) != sHeader.stringTableSize) {
        closeHelpFile();
        return false;
    }

    // Force-terminate the table so a truncated/corrupt file can't make any
    // hlpString() consumer walk past the allocation.
    if (sHeader.stringTableSize > 0) {
        sStringTable[sHeader.stringTableSize - 1] = '\0';
    }

    // Read TOC entries
    if (sHeader.tocCount > 0) {
        sTocEntries = (HlpTocEntryT *)dvxMalloc(sHeader.tocCount * sizeof(HlpTocEntryT));

        if (!sTocEntries) {
            closeHelpFile();
            return false;
        }

        fseek(sHlpFile, sHeader.tocOffset, SEEK_SET);

        if (fread(sTocEntries, sizeof(HlpTocEntryT), sHeader.tocCount, sHlpFile) != sHeader.tocCount) {
            closeHelpFile();
            return false;
        }
    }

    // Read keyword index
    if (sHeader.indexCount > 0) {
        sIndexEntries = (HlpIndexEntryT *)dvxMalloc(sHeader.indexCount * sizeof(HlpIndexEntryT));

        if (!sIndexEntries) {
            closeHelpFile();
            return false;
        }

        fseek(sHlpFile, sHeader.indexOffset, SEEK_SET);

        if (fread(sIndexEntries, sizeof(HlpIndexEntryT), sHeader.indexCount, sHlpFile) != sHeader.indexCount) {
            closeHelpFile();
            return false;
        }
    }

    return true;
}


static void paintLines(DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, int32_t x, int32_t y, const char *text, int32_t lineCount, uint32_t fg) {
    if (!text) {
        return;
    }

    const char *line = text;

    for (int32_t i = 0; i < lineCount; i++) {
        const char *end = strchr(line, '\n');
        int32_t len = end ? (int32_t)(end - line) : (int32_t)strlen(line);

        if (len > 0) {
            drawTextN(d, ops, font, x, y, line, len, fg, 0, false);
        }

        y += font->charHeight;
        line = end ? end + 1 : line + len;
    }
}


static void populateToc(void) {
    if (!sTocTree || !sTocEntries) {
        return;
    }

    // Track parent nodes at each depth level
    #define MAX_TOC_DEPTH 16
    WidgetT *parents[MAX_TOC_DEPTH];

    for (int32_t i = 0; i < MAX_TOC_DEPTH; i++) {
        parents[i] = sTocTree;
    }

    for (uint32_t i = 0; i < sHeader.tocCount; i++) {
        HlpTocEntryT *entry = &sTocEntries[i];
        const char *title    = hlpString(entry->titleStr);
        int32_t depth        = entry->depth;

        if (depth >= MAX_TOC_DEPTH) {
            depth = MAX_TOC_DEPTH - 1;
        }

        WidgetT *parent = parents[depth];
        WidgetT *item   = wgtTreeItem(parent, title);

        if (item) {
            // Store topic index in userData (+1 so topic 0 != NULL)
            item->userData = TOPIC_TO_USERDATA(entry->topicIdx);

            // Update parent for next deeper level
            if (depth + 1 < MAX_TOC_DEPTH) {
                parents[depth + 1] = item;
            }

            // Expand by default if flagged
            if (entry->flags & HLP_TOC_EXPANDED) {
                wgtTreeItemSetExpanded(item, true);
            }
        }
    }
}


static void registerWidgetClasses(void) {
    sHelpTextTypeId     = wgtRegisterClass(&sClassHelpText);
    sHelpHeadingTypeId  = wgtRegisterClass(&sClassHelpHeading);
    sHelpLinkTypeId     = wgtRegisterClass(&sClassHelpLink);
    sHelpNoteTypeId     = wgtRegisterClass(&sClassHelpNote);
    sHelpCodeTypeId     = wgtRegisterClass(&sClassHelpCode);
    sHelpRuleTypeId     = wgtRegisterClass(&sClassHelpRule);
    sHelpListItemTypeId = wgtRegisterClass(&sClassHelpListItem);
}


// Display a topic and sync the chrome (title + TOC selection).  Shared by all
// three navigation paths so Back/Forward don't leave a stale title/selection.
// Does NOT push history -- callers manage sHistory.
static void showTopic(int32_t topicIdx) {
    sCurrentTopic = topicIdx;
    buildContentWidgets();
    updateNavButtons();

    const char *title = hlpString(sTopicDir[topicIdx].titleStr);
    char winTitle[HELP_TITLE_MAX];
    snprintf(winTitle, sizeof(winTitle), "%s - DVX Help", title);
    dvxSetTitle(sAc, sWin, winTitle);

    if (sTocTree) {
        WidgetT *item = findTreeItemByTopic(sTocTree, topicIdx);

        if (item) {
            // Suppress onChange to avoid re-entering navigation
            void (*savedOnChange)(WidgetT *) = sTocTree->onChange;
            sTocTree->onChange = NULL;
            wgtTreeViewSetSelected(sTocTree, item);
            sTocTree->onChange = savedOnChange;
        }
    }
}


static void updateNavButtons(void) {
    if (sBtnBack) {
        wgtSetEnabled(sBtnBack, sHistoryPos > 0);
    }

    if (sBtnForward) {
        wgtSetEnabled(sBtnForward, sHistoryPos < sHistoryCount - 1);
    }
}


// Update sWrapWidth from the ScrollPane's current size.
static void updateWrapWidth(void) {
    // 38 = ScrollPane border (4) + VBox padding (12) + scrollbar (14) + 1 char buffer (8)
    if (sContentScroll && sContentScroll->w > 38) {
        sWrapWidth = sContentScroll->w - 38;
    }
}


// Word-wrap flowing text to fit within colWidth characters.
// Returns a dvxMalloc'd string with \n at wrap points.
// Caller must dvxFree the result.
static char *wrapText(const char *text, int32_t colWidth) {
    if (!text || !text[0] || colWidth < 10) {
        return dvxStrdup(text ? text : "");
    }

    int32_t textLen = (int32_t)strlen(text);
    char *result    = (char *)dvxMalloc(textLen + textLen / colWidth + 16);

    if (!result) {
        return dvxStrdup(text);
    }

    int32_t outPos = 0;
    int32_t col    = 0;
    const char *p  = text;

    while (*p) {
        // Skip leading whitespace at start of line
        if (col == 0) {
            while (*p == ' ') {
                p++;
            }
        }

        if (!*p) {
            break;
        }

        // Find end of word
        const char *wordStart = p;

        while (*p && *p != ' ' && *p != '\n') {
            p++;
        }

        int32_t wordLen = (int32_t)(p - wordStart);

        // Check if word fits on current line
        if (col > 0 && col + 1 + wordLen > colWidth) {
            result[outPos++] = '\n';
            col = 0;
        } else if (col > 0) {
            result[outPos++] = ' ';
            col++;
        }

        memcpy(result + outPos, wordStart, wordLen);
        outPos += wordLen;
        col    += wordLen;

        // Skip spaces between words
        while (*p == ' ') {
            p++;
        }

        // Explicit newline in source = paragraph break
        if (*p == '\n') {
            result[outPos++] = '\n';
            col = 0;
            p++;
        }
    }

    result[outPos] = '\0';
    return result;
}


int32_t appMain(DxeAppContextT *ctx) {
    sAc  = ctx->shellCtx;

    // Register private widget classes
    registerWidgetClasses();

    // Determine help file path: use args if provided, else default
    char hlpPath[DVX_MAX_PATH];

    if (ctx->args[0]) {
        // args may be "path/to/file.hlp" or "path/to/file.hlp topicId"
        snprintf(hlpPath, sizeof(hlpPath), "%s", ctx->args);

        // Truncate at first space to separate path from topic ID
        char *space = strchr(hlpPath, ' ');

        if (space) {
            *space = '\0';
        }
    } else {
        snprintf(hlpPath, sizeof(hlpPath), "%s" DVX_PATH_SEP "%s", ctx->appDir, "dvxhelp.hlp");
    }

    if (!openHelpFile(hlpPath)) {
        dvxSetBusy(sAc, false);
        dvxErrorBox(sAc, "DVX Help", "Could not open help file.");
        return -1;
    }

    // Create window
    int32_t screenW = sAc->display.width;
    int32_t screenH = sAc->display.height;
    int32_t winW    = HELP_WIN_W;
    int32_t winH    = HELP_WIN_H;
    int32_t winX    = (screenW - winW) / 2 + HELP_CASCADE_OFFSET;
    int32_t winY    = (screenH - winH) / 3 + HELP_CASCADE_OFFSET;

    sWin = dvxCreateWindow(sAc, "DVX Help", winX, winY, winW, winH, true);

    if (!sWin) {
        closeHelpFile();
        return -1;
    }

    sWin->onClose  = onClose;
    sWin->onMenu   = onMenu;

    // Menu bar
    MenuBarT *menuBar = wmAddMenuBar(sWin);

    MenuT *navMenu = wmAddMenu(menuBar, "&Navigate");
    wmAddMenuItem(navMenu, "&Back", CMD_BACK);
    wmAddMenuItem(navMenu, "&Forward", CMD_FORWARD);
    wmAddMenuSeparator(navMenu);
    wmAddMenuItem(navMenu, "&Index...", CMD_INDEX);
    wmAddMenuItem(navMenu, "&Search...", CMD_SEARCH);
    wmAddMenuSeparator(navMenu);
    wmAddMenuItem(navMenu, "E&xit", CMD_EXIT);

    // Widget tree
    WidgetT *root = wgtInitWindow(sAc, sWin);

    // Toolbar
    WidgetT *toolbar = wgtToolbar(root);

    sBtnBack = wgtButton(toolbar, "< Back");
    sBtnBack->onClick = onNavBack;
    wgtSetEnabled(sBtnBack, false);

    sBtnForward = wgtButton(toolbar, "Fwd >");
    sBtnForward->onClick = onNavForward;
    wgtSetEnabled(sBtnForward, false);

    WidgetT *btnIndex = wgtButton(toolbar, "Index");
    btnIndex->onClick = onIndex;

    WidgetT *btnSearch = wgtButton(toolbar, "Search");
    btnSearch->onClick = onSearch;

    // Splitter with TOC tree and content scroll pane
    WidgetT *splitter = wgtSplitter(root, true);
    splitter->weight  = WGT_WEIGHT_FILL;

    sTocTree         = wgtTreeView(splitter);
    sTocTree->weight = 0;
    sTocTree->minW   = wgtPixels(HELP_TOC_MIN_W);
    sTocTree->onChange = onTocChange;

    sContentScroll         = wgtScrollPane(splitter);
    sContentScroll->weight = WGT_WEIGHT_FILL;

    sContentBox          = wgtVBox(sContentScroll);
    sContentBox->spacing = wgtPixels(HELP_PARA_SPACING);
    sContentBox->padding = wgtPixels(HELP_CONTENT_PAD);

    wgtSplitterSetPos(splitter, HELP_SPLITTER_POS);

    // Populate TOC tree
    populateToc();

    // Navigate to requested topic (from args) or default
    int32_t startTopic = -1;

    if (ctx->args[0]) {
        // Check for topic ID after the file path
        const char *space = strchr(ctx->args, ' ');

        if (space && space[1]) {
            startTopic = findTopicByIdStr(space + 1);
        }
    }

    if (startTopic < 0) {
        startTopic = findTopicByIdStr(hlpString(sHeader.defaultTopicStr));
    }

    if (startTopic < 0 && sHeader.topicCount > 0) {
        startTopic = 0;
    }

    if (startTopic >= 0) {
        navigateToTopic(startTopic);
    }

    return 0;
}
