// 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.

// ideProject.c -- DVX BASIC project file management and project window
//
// The .dbp (DVX BASIC Project) file is INI-format:
//
//   [Project]
//   Name = MyProject
//
//   [Modules]
//   Count = 2
//   File0 = MAIN.BAS
//   File1 = UTILS.BAS
//
//   [Forms]
//   Count = 1
//   File0 = FORM1.FRM
//
//   [Settings]
//   StartupForm = Form1
//
// All file paths are relative to the directory containing the .dbp file.
// Uses the handle-based dvxPrefs API with a dedicated handle per load/save
// so project files don't interfere with the IDE's own preferences.

#include "ideProject.h"
#include "../basRes.h"
#include "dvxApp.h"
#include "dvxDlg.h"
#include "dvxPrefs.h"
#include "dvxWm.h"
#include "box/box.h"
#include "button/button.h"
#include "checkbox/checkbox.h"
#include "dropdown/dropdown.h"
#include "image/image.h"
#include "label/label.h"
#include "textInput/textInpt.h"
#include "treeView/treeView.h"

#include "thirdparty/stb_ds_wrap.h"

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

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

#define PRJ_WIN_W       180
#define PRJ_WIN_H       300
#define PRJ_WIN_Y       250
#define PRJ_ICON_SIZE   32    // project icon must be PRJ_ICON_SIZE square
#define PRJ_MSG_BUF     128   // error-message scratch buffer
#define PRJ_MODIFIED_SUFFIX " *"  // tree label suffix for an unsaved file
#define PRJ_PLACEHOLDER_BPP 4 // bytes per pixel of the 1x1 icon placeholder
#define PRJ_INI_KEY_LEN   16    // "FileNNN" INI key buffer size
#define PRJ_FILE_KEY_FMT  "File%d"  // INI key for an indexed module/form path
#define PRJ_COUNT_KEY     "Count"   // INI key for the file count in a section

#define PPD_WIDTH    380
#define PPD_HEIGHT   410
#define PPD_LABEL_W  96
#define PPD_BTN_W    70
#define PPD_BTN_H    24
#define PPD_DESC_H   60
#define PPD_ROOT_SPACING 2
#define PPD_ROW_SPACING  4

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

static PrjStateT       *sPrj      = NULL;
static WindowT         *sPrjWin   = NULL;
static WidgetT         *sTree     = NULL;
static PrjFileClickFnT  sOnClick     = NULL;
static PrjSelChangeFnT  sOnSelChange = NULL;
static char            **sLabels  = NULL; // stb_ds array of strdup'd strings

static struct {
    bool          done;
    bool          accepted;
    WidgetT      *name;
    WidgetT      *author;
    WidgetT      *publisher;
    WidgetT      *version;
    WidgetT      *copyright;
    WidgetT      *description;
    WidgetT      *startupForm;
    WidgetT      *optionExplicit;
    const char  **formNames;    // stb_ds array of form name strings for startup dropdown
    WidgetT      *helpFileInput;
    WidgetT      *iconPreview;
    char          iconPath[DVX_MAX_PATH];
    const char   *appPath;
    AppContextT  *ctx;
    PrjStateT    *prj;
} sPpd;

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

static void     onPrjWinClose(WindowT *win);
static void     onTreeItemDblClick(WidgetT *w);
static void     onTreeSelChanged(WidgetT *w);
static WidgetT *ppdAddRow(WidgetT *parent, const char *labelText, const char *value, int32_t fieldSize);
static void     ppdLoadIconPreview(void);
static void     ppdOnBrowseHelp(WidgetT *w);
static void     ppdOnBrowseIcon(WidgetT *w);
static void     ppdOnCancel(WidgetT *w);
static void     ppdOnClose(WindowT *win);
static void     ppdOnOk(WidgetT *w);
static void     prjDeriveDir(PrjStateT *prj, const char *dbpPath);
static void     prjLoadFileSection(PrjStateT *prj, PrefsHandleT *h, const char *section, bool isForm);
static const char *prjMakeRelative(const PrjStateT *prj, const char *path);
static void     prjSaveFileSection(PrefsHandleT *h, const PrjStateT *prj, const char *section, bool isForm);
static bool     validateIcon(const char *fullPath, bool showErrors);


static void onPrjWinClose(WindowT *win) {
    (void)win;
}


static void onTreeItemDblClick(WidgetT *w) {
    if (!sPrj || !sOnClick) {
        return;
    }

    int32_t fileIdx = (int32_t)(intptr_t)w->userData;

    if (fileIdx >= 0 && fileIdx < sPrj->fileCount) {
        sOnClick(fileIdx, sPrj->files[fileIdx].isForm);
    }
}


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

    if (sOnSelChange) {
        sOnSelChange();
    }
}


// Label + text input row.  fieldSize is the size of the char[] the value
// is copied back into; the input accepts one character less so the copy
// never truncates.
static WidgetT *ppdAddRow(WidgetT *parent, const char *labelText, const char *value, int32_t fieldSize) {
    WidgetT *row = wgtHBox(parent);
    row->spacing = wgtPixels(PPD_ROW_SPACING);

    WidgetT *lbl = wgtLabel(row, labelText);
    lbl->minW = wgtPixels(PPD_LABEL_W);

    WidgetT *input = wgtTextInput(row, fieldSize - 1);
    input->weight = WGT_WEIGHT_FILL;
    wgtSetText(input, value);

    return input;
}


static void ppdLoadIconPreview(void) {
    if (!sPpd.iconPreview || !sPpd.ctx || !sPpd.prj) {
        return;
    }

    if (!sPpd.iconPath[0]) {
        return;
    }

    const char *relPath = sPpd.iconPath;

    char fullPath[DVX_MAX_PATH * 2];
    snprintf(fullPath, sizeof(fullPath), "%s" DVX_PATH_SEP "%s", sPpd.prj->projectDir, relPath);

    if (!validateIcon(fullPath, true)) {
        sPpd.iconPath[0] = '\0';
        return;
    }

    int32_t w     = 0;
    int32_t h     = 0;
    int32_t pitch = 0;
    uint8_t *data = dvxLoadImage(sPpd.ctx, fullPath, &w, &h, &pitch);

    if (data) {
        wgtImageSetData(sPpd.iconPreview, data, w, h, pitch);
    }
}


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

    FileFilterT filters[] = {
        { "Help Files (*.hlp)" },
        { "All Files (*.*)" }
    };

    char path[DVX_MAX_PATH];

    if (!dvxFileDialog(sPpd.ctx, "Select Help File", FD_SAVE, NULL, filters, (int32_t)(sizeof(filters) / sizeof(filters[0])), path, sizeof(path))) {
        return;
    }

    // Convert to project-relative path
    const char *relPath = prjMakeRelative(sPpd.prj, path);

    wgtSetText(sPpd.helpFileInput, relPath ? relPath : path);
}


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

    FileFilterT filters[] = {
        { "Images (*.bmp;*.png;*.jpg;*.gif)" },
        { "All Files (*.*)" }
    };

    char path[DVX_MAX_PATH];

    if (dvxFileDialog(sPpd.ctx, "Select Icon", FD_OPEN, NULL, filters, (int32_t)(sizeof(filters) / sizeof(filters[0])), path, sizeof(path))) {
        if (!validateIcon(path, true)) {
            return;
        }

        // The icon must be in the project directory so the relative
        // path works when the project is reloaded.
        const char *relPath = prjMakeRelative(sPpd.prj, path);

        if (!relPath) {
            int32_t result = dvxMessageBox(sPpd.ctx, "Copy Icon",
                "The icon is outside the project directory.\nCopy it to the project?",
                MB_YESNO | MB_ICONQUESTION);

            if (result != ID_YES) {
                return;
            }

            // Get just the filename
            const char *fname = platformPathBaseName(path);

            // Check if destination already exists
            char destPath[DVX_MAX_PATH * 2];
            snprintf(destPath, sizeof(destPath), "%s" DVX_PATH_SEP "%s", sPpd.prj->projectDir, fname);

            FILE *existing = fopen(destPath, "rb");

            if (existing) {
                fclose(existing);

                char msg[DVX_MAX_PATH + PRJ_MSG_BUF];
                snprintf(msg, sizeof(msg), "%s already exists.\nOverwrite it?", fname);
                int32_t ow = dvxMessageBox(sPpd.ctx, "Overwrite", msg, MB_YESNO | MB_ICONQUESTION);

                if (ow != ID_YES) {
                    return;
                }
            }

            if (!platformCopyFile(path, destPath)) {
                dvxErrorBox(sPpd.ctx, NULL, "Failed to copy icon into the project directory.");
                return;
            }

            relPath = fname;
        }

        snprintf(sPpd.iconPath, sizeof(sPpd.iconPath), "%s", relPath);
        ppdLoadIconPreview();
    }
}


static void ppdOnCancel(WidgetT *w) {
    (void)w;
    sPpd.accepted = false;
    sPpd.done     = true;
}


static void ppdOnClose(WindowT *win) {
    (void)win;
    sPpd.accepted = false;
    sPpd.done     = true;
}


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

    // Validate icon path if set
    if (sPpd.iconPath[0] && sPpd.prj) {
        char fullPath[DVX_MAX_PATH * 2];
        snprintf(fullPath, sizeof(fullPath), "%s" DVX_PATH_SEP "%s", sPpd.prj->projectDir, sPpd.iconPath);

        if (!validateIcon(fullPath, true)) {
            return;
        }
    }

    sPpd.accepted = true;
    sPpd.done     = true;
}


int32_t prjAddFile(PrjStateT *prj, const char *relativePath, bool isForm) {
    PrjFileT entry;
    memset(&entry, 0, sizeof(entry));
    snprintf(entry.path, sizeof(entry.path), "%s", relativePath);
    entry.isForm = isForm;
    arrput(prj->files, entry);
    prj->fileCount = (int32_t)arrlen(prj->files);
    prj->dirty = true;
    return prj->fileCount - 1;
}


void prjClose(PrjStateT *prj) {
    for (int32_t i = 0; i < prj->fileCount; i++) {
        free(prj->files[i].buffer);
    }

    arrfree(prj->files);
    arrfree(prj->sourceMap);
    prjInit(prj);
}


WindowT *prjCreateWindow(AppContextT *ctx, PrjStateT *prj, PrjFileClickFnT onClick, PrjSelChangeFnT onSelChange) {
    sPrj         = prj;
    sOnClick     = onClick;
    sOnSelChange = onSelChange;

    sPrjWin = dvxCreateWindow(ctx, "Project", 0, PRJ_WIN_Y, PRJ_WIN_W, PRJ_WIN_H, true);

    if (!sPrjWin) {
        return NULL;
    }

    sPrjWin->onClose = onPrjWinClose;

    WidgetT *root = wgtInitWindow(ctx, sPrjWin);
    sTree = wgtTreeView(root);
    sTree->weight   = WGT_WEIGHT_FILL;
    sTree->onChange  = onTreeSelChanged;

    prjRebuildTree(prj);
    return sPrjWin;
}


// Set prj->projectDir to the directory containing dbpPath, or "." when
// dbpPath has no directory component.
static void prjDeriveDir(PrjStateT *prj, const char *dbpPath) {
    snprintf(prj->projectDir, sizeof(prj->projectDir), "%s", dbpPath);
    char *sep = platformPathDirEnd(prj->projectDir);

    if (sep) {
        *sep = '\0';
    } else {
        prj->projectDir[0] = '.';
        prj->projectDir[1] = '\0';
    }
}


void prjDestroyWindow(AppContextT *ctx, WindowT *win) {
    if (win) {
        dvxDestroyWindow(ctx, win);
    }

    // Free label strings
    if (sLabels) {
        for (int32_t i = 0; i < (int32_t)arrlen(sLabels); i++) {
            free(sLabels[i]);
        }

        arrfree(sLabels);
        sLabels = NULL;
    }

    sPrjWin      = NULL;
    sTree        = NULL;
    sPrj         = NULL;
    sOnClick     = NULL;
    sOnSelChange = NULL;
}


void prjFullPath(const PrjStateT *prj, int32_t fileIdx, char *outPath, int32_t outSize) {
    if (fileIdx < 0 || fileIdx >= prj->fileCount) {
        outPath[0] = '\0';
        return;
    }

    snprintf(outPath, outSize, "%s" DVX_PATH_SEP "%s", prj->projectDir, prj->files[fileIdx].path);
}


int32_t prjGetSelectedFileIdx(void) {
    if (!sTree) {
        return -1;
    }

    WidgetT *sel = wgtTreeViewGetSelected(sTree);

    if (!sel) {
        return -1;
    }

    return (int32_t)(intptr_t)sel->userData;
}


void prjInit(PrjStateT *prj) {
    memset(prj, 0, sizeof(*prj));
    prj->activeFileIdx = -1;
}


bool prjLoad(PrjStateT *prj, const char *dbpPath) {
    PrefsHandleT *h = prefsLoad(dbpPath);

    if (!h) {
        return false;
    }

    // Release whatever project was open (file buffers, arrays) before
    // starting from a blank state.
    prjClose(prj);
    snprintf(prj->projectPath, sizeof(prj->projectPath), "%s", dbpPath);

    // Derive project directory
    prjDeriveDir(prj, dbpPath);

    // [Project] section
    const char *val;

    val = prefsGetString(h, BAS_INI_SECTION_PROJECT, BAS_INI_KEY_NAME, NULL);
    if (val) { snprintf(prj->name, sizeof(prj->name), "%s", val); }

    val = prefsGetString(h, BAS_INI_SECTION_PROJECT, BAS_INI_KEY_AUTHOR, NULL);
    if (val) { snprintf(prj->author, sizeof(prj->author), "%s", val); }

    val = prefsGetString(h, BAS_INI_SECTION_PROJECT, BAS_INI_KEY_PUBLISHER, NULL);
    if (val) { snprintf(prj->publisher, sizeof(prj->publisher), "%s", val); }

    val = prefsGetString(h, BAS_INI_SECTION_PROJECT, BAS_INI_KEY_VERSION, NULL);
    if (val) { snprintf(prj->version, sizeof(prj->version), "%s", val); }

    val = prefsGetString(h, BAS_INI_SECTION_PROJECT, BAS_INI_KEY_COPYRIGHT, NULL);
    if (val) { snprintf(prj->copyright, sizeof(prj->copyright), "%s", val); }

    val = prefsGetString(h, BAS_INI_SECTION_PROJECT, BAS_INI_KEY_DESCRIPTION, NULL);
    if (val) { snprintf(prj->description, sizeof(prj->description), "%s", val); }

    val = prefsGetString(h, BAS_INI_SECTION_PROJECT, BAS_INI_KEY_ICON, NULL);
    if (val) { snprintf(prj->iconPath, sizeof(prj->iconPath), "%s", val); }

    val = prefsGetString(h, BAS_INI_SECTION_PROJECT, BAS_INI_KEY_HELPFILE, NULL);
    if (val) { snprintf(prj->helpFile, sizeof(prj->helpFile), "%s", val); }

    // [Modules] and [Forms] sections -- File0, File1, ... (no cap)
    prjLoadFileSection(prj, h, BAS_INI_SECTION_MODULES, false);
    prjLoadFileSection(prj, h, BAS_INI_SECTION_FORMS, true);

    // [Settings] section
    val = prefsGetString(h, BAS_INI_SECTION_SETTINGS, BAS_INI_KEY_STARTUPFORM, NULL);
    if (val) { snprintf(prj->startupForm, sizeof(prj->startupForm), "%s", val); }

    prj->optionExplicit = prefsGetBool(h, BAS_INI_SECTION_SETTINGS, BAS_INI_KEY_OPTIONEXPLICIT, false);

    prefsClose(h);
    prj->dirty = false;
    return true;
}


// prjLoadAllFiles -- read all project files into memory buffers
// and extract form names from .frm files.
void prjLoadAllFiles(PrjStateT *prj, AppContextT *ctx) {
    for (int32_t i = 0; i < prj->fileCount; i++) {
        if (prj->files[i].buffer) {
            continue; // already loaded
        }

        char fullPath[DVX_MAX_PATH];
        prjFullPath(prj, i, fullPath, sizeof(fullPath));

        int32_t bytesRead = 0;
        char   *buf       = platformReadFile(fullPath, &bytesRead);

        if (!buf || bytesRead <= 0) {
            free(buf);
            continue;
        }

        prj->files[i].buffer = buf;

        // Extract form name from .frm files
        if (prj->files[i].isForm) {
            basExtractFormName(buf, prj->files[i].formName, sizeof(prj->files[i].formName));
        }

        // Yield between files to keep the UI responsive
        if (ctx) {
            dvxUpdate(ctx);
        }
    }
}


// Load File0, File1, ... entries from an INI section, registering each as a
// project file.  The loop ends at the first missing key (no fixed cap).
static void prjLoadFileSection(PrjStateT *prj, PrefsHandleT *h, const char *section, bool isForm) {
    for (int32_t i = 0; ; i++) {
        char key[PRJ_INI_KEY_LEN];
        snprintf(key, sizeof(key), PRJ_FILE_KEY_FMT, (int)i);

        const char *val = prefsGetString(h, section, key, NULL);

        if (!val) {
            break;
        }

        prjAddFile(prj, val, isForm);
    }
}


// Return path relative to prj->projectDir when path lives under it, else NULL
// (callers apply their own fallback).
static const char *prjMakeRelative(const PrjStateT *prj, const char *path) {
    int32_t dirLen = (int32_t)strlen(prj->projectDir);

    if (strncasecmp(path, prj->projectDir, dirLen) == 0 &&
        (path[dirLen] == '/' || path[dirLen] == '\\')) {
        return path + dirLen + 1;
    }

    return NULL;
}


bool prjMapLine(const PrjStateT *prj, int32_t concatLine, int32_t *outFileIdx, int32_t *outLocalLine) {
    for (int32_t i = 0; i < prj->sourceMapCount; i++) {
        const PrjSourceMapT *m = &prj->sourceMap[i];

        if (concatLine >= m->startLine && concatLine < m->startLine + m->lineCount) {
            *outFileIdx  = m->fileIdx;
            *outLocalLine = concatLine - m->startLine + 1;
            return true;
        }
    }

    return false;
}


void prjNew(PrjStateT *prj, const char *name, const char *directory, PrefsHandleT *prefs) {
    prjInit(prj);
    snprintf(prj->name, sizeof(prj->name), "%s", name);
    snprintf(prj->projectDir, sizeof(prj->projectDir), "%s", directory);
    snprintf(prj->projectPath, sizeof(prj->projectPath), "%s" DVX_PATH_SEP "%s.dbp", directory, name);
    prj->dirty = true;

    // Apply defaults from preferences
    if (prefs) {
        snprintf(prj->author, sizeof(prj->author), "%s", prefsGetString(prefs, IDE_PREF_SECTION_DEFAULTS, IDE_PREF_KEY_AUTHOR, ""));
        snprintf(prj->publisher, sizeof(prj->publisher), "%s", prefsGetString(prefs, IDE_PREF_SECTION_DEFAULTS, IDE_PREF_KEY_PUBLISHER, ""));
        snprintf(prj->version, sizeof(prj->version), "%s", prefsGetString(prefs, IDE_PREF_SECTION_DEFAULTS, IDE_PREF_KEY_VERSION, IDE_PREF_DEFAULT_VERSION));
        snprintf(prj->copyright, sizeof(prj->copyright), "%s", prefsGetString(prefs, IDE_PREF_SECTION_DEFAULTS, IDE_PREF_KEY_COPYRIGHT, ""));
        snprintf(prj->description, sizeof(prj->description), "%s", prefsGetString(prefs, IDE_PREF_SECTION_DEFAULTS, IDE_PREF_KEY_DESCRIPTION, ""));
        prj->optionExplicit = prefsGetBool(prefs, IDE_PREF_SECTION_EDITOR, IDE_PREF_KEY_OPTION_EXPLICIT, false);
    }
}


bool prjPropertiesDialog(AppContextT *ctx, PrjStateT *prj, const char *appPath) {
    if (!ctx || !prj) {
        return false;
    }

    WindowT *win = dvxCreateWindowCentered(ctx, "Project Properties", PPD_WIDTH, PPD_HEIGHT, false);

    if (!win) {
        return false;
    }

    win->modal   = true;
    win->onClose = ppdOnClose;
    win->maxW    = win->w;
    win->maxH    = win->h;

    sPpd.done     = false;
    sPpd.accepted = false;
    sPpd.ctx      = ctx;
    sPpd.prj      = prj;
    sPpd.appPath  = appPath;

    WidgetT *root = wgtInitWindow(ctx, win);

    if (!root) {
        dvxDestroyWindow(ctx, win);
        return false;
    }

    root->spacing = wgtPixels(PPD_ROOT_SPACING);

    sPpd.name      = ppdAddRow(root, "Name:",      prj->name,      sizeof(prj->name));
    sPpd.author    = ppdAddRow(root, "Author:",    prj->author,    sizeof(prj->author));
    sPpd.publisher = ppdAddRow(root, "Publisher:", prj->publisher, sizeof(prj->publisher));
    sPpd.version   = ppdAddRow(root, "Version:",   prj->version,   sizeof(prj->version));
    sPpd.copyright = ppdAddRow(root, "Copyright:", prj->copyright, sizeof(prj->copyright));

    // Startup form dropdown
    {
        WidgetT *sfRow = wgtHBox(root);
        sfRow->spacing = wgtPixels(PPD_ROW_SPACING);

        WidgetT *sfLbl = wgtLabel(sfRow, "Startup Form:");
        sfLbl->minW = wgtPixels(PPD_LABEL_W);

        sPpd.startupForm = wgtDropdown(sfRow);
        sPpd.startupForm->weight = WGT_WEIGHT_FILL;

        // Populate with form names from the project
        sPpd.formNames = NULL;
        int32_t selectedIdx = 0;

        for (int32_t i = 0; i < prj->fileCount; i++) {
            if (!prj->files[i].isForm) {
                continue;
            }

            // Use the cached form object name, fall back to filename
            const char *name = prj->files[i].formName;
            char fallback[DVX_MAX_PATH];

            if (!name[0]) {
                snprintf(fallback, sizeof(fallback), "%s", prj->files[i].path);
                char *dot = strrchr(fallback, '.');
                if (dot) { *dot = '\0'; }
                name = fallback;
            }

            arrput(sPpd.formNames, strdup(name));

            if (strcasecmp(name, prj->startupForm) == 0) {
                selectedIdx = (int32_t)arrlen(sPpd.formNames) - 1;
            }
        }

        int32_t formCount = (int32_t)arrlen(sPpd.formNames);
        wgtDropdownSetItems(sPpd.startupForm, sPpd.formNames, formCount);

        if (formCount > 0) {
            wgtDropdownSetSelected(sPpd.startupForm, selectedIdx);
        }
    }

    // Icon row: label + preview + Browse button
    {
        WidgetT *iconRow = wgtHBox(root);
        iconRow->spacing = wgtPixels(PPD_ROW_SPACING);

        WidgetT *iconLbl = wgtLabel(iconRow, "Icon:");
        iconLbl->minW = wgtPixels(PPD_LABEL_W);

        // Load "noicon" placeholder from app resources
        int32_t niW = 0;
        int32_t niH = 0;
        int32_t niP = 0;
        uint8_t *noIconData = appPath ? dvxResLoadIcon(ctx, appPath, "noicon", &niW, &niH, &niP) : NULL;

        if (noIconData) {
            sPpd.iconPreview = wgtImage(iconRow, noIconData, niW, niH, niP);
        } else {
            uint8_t *placeholder = (uint8_t *)calloc(PRJ_PLACEHOLDER_BPP, 1);
            sPpd.iconPreview = wgtImage(iconRow, placeholder, 1, 1, PRJ_PLACEHOLDER_BPP);
        }

        WidgetT *browseBtn = wgtButton(iconRow, "Browse...");
        browseBtn->onClick = ppdOnBrowseIcon;

        snprintf(sPpd.iconPath, sizeof(sPpd.iconPath), "%s", prj->iconPath);
        ppdLoadIconPreview();
    }

    // Help file row
    {
        WidgetT *hlpRow = wgtHBox(root);
        hlpRow->spacing = wgtPixels(PPD_ROW_SPACING);

        WidgetT *hlpLbl = wgtLabel(hlpRow, "Help File:");
        hlpLbl->minW = wgtPixels(PPD_LABEL_W);

        sPpd.helpFileInput = wgtTextInput(hlpRow, (int32_t)sizeof(prj->helpFile) - 1);
        sPpd.helpFileInput->weight = WGT_WEIGHT_FILL;
        wgtSetText(sPpd.helpFileInput, prj->helpFile);

        WidgetT *hlpBrowse = wgtButton(hlpRow, "Browse...");
        hlpBrowse->onClick = ppdOnBrowseHelp;
    }

    // Compiler options: OPTION EXPLICIT enforces DIM-before-use for this project.
    sPpd.optionExplicit = wgtCheckbox(root, "Require variable declaration (OPTION EXPLICIT)");
    wgtCheckboxSetChecked(sPpd.optionExplicit, prj->optionExplicit);

    // Description: label above, textarea below (matches Preferences layout)
    wgtLabel(root, "Description:");
    sPpd.description = wgtTextArea(root, (int32_t)sizeof(prj->description) - 1);
    sPpd.description->weight = WGT_WEIGHT_FILL;
    sPpd.description->minH   = wgtPixels(PPD_DESC_H);
    wgtSetText(sPpd.description, prj->description);

    // OK / Cancel buttons
    WidgetT *btnRow = wgtHBox(root);
    btnRow->align = AlignCenterE;

    WidgetT *okBtn = wgtButton(btnRow, "&OK");
    okBtn->minW    = wgtPixels(PPD_BTN_W);
    okBtn->minH    = wgtPixels(PPD_BTN_H);
    okBtn->onClick = ppdOnOk;

    WidgetT *cancelBtn = wgtButton(btnRow, "&Cancel");
    cancelBtn->minW    = wgtPixels(PPD_BTN_W);
    cancelBtn->minH    = wgtPixels(PPD_BTN_H);
    cancelBtn->onClick = ppdOnCancel;

    dvxFitWindow(ctx, win);

    WindowT *prevModal = ctx->modalWindow;
    ctx->modalWindow   = win;

    while (!sPpd.done && ctx->running) {
        dvxUpdate(ctx);
    }

    if (sPpd.accepted) {
        const char *s;

        s = wgtGetText(sPpd.name);
        if (s) { snprintf(prj->name, sizeof(prj->name), "%s", s); }

        s = wgtGetText(sPpd.author);
        if (s) { snprintf(prj->author, sizeof(prj->author), "%s", s); }

        s = wgtGetText(sPpd.publisher);
        if (s) { snprintf(prj->publisher, sizeof(prj->publisher), "%s", s); }

        s = wgtGetText(sPpd.version);
        if (s) { snprintf(prj->version, sizeof(prj->version), "%s", s); }

        s = wgtGetText(sPpd.copyright);
        if (s) { snprintf(prj->copyright, sizeof(prj->copyright), "%s", s); }

        s = wgtGetText(sPpd.description);
        if (s) { snprintf(prj->description, sizeof(prj->description), "%s", s); }

        snprintf(prj->iconPath, sizeof(prj->iconPath), "%s", sPpd.iconPath);

        s = wgtGetText(sPpd.helpFileInput);
        if (s) { snprintf(prj->helpFile, sizeof(prj->helpFile), "%s", s); }

        // Read startup form from dropdown
        if (sPpd.startupForm && sPpd.formNames) {
            int32_t sfIdx = wgtDropdownGetSelected(sPpd.startupForm);

            if (sfIdx >= 0 && sfIdx < (int32_t)arrlen(sPpd.formNames)) {
                snprintf(prj->startupForm, sizeof(prj->startupForm), "%s", sPpd.formNames[sfIdx]);
            }
        }

        prj->optionExplicit = wgtCheckboxIsChecked(sPpd.optionExplicit);
        prj->dirty = true;
    }

    // Free form name strings
    for (int32_t i = 0; i < (int32_t)arrlen(sPpd.formNames); i++) {
        free((char *)sPpd.formNames[i]);
    }
    arrfree(sPpd.formNames);
    sPpd.formNames = NULL;

    ctx->modalWindow = prevModal;
    dvxDestroyWindow(ctx, win);

    return sPpd.accepted;
}


void prjRebuildTree(PrjStateT *prj) {
    if (!sTree) {
        return;
    }

    // Destroy existing items so the old tree-item widgets are freed
    // instead of leaked on every rebuild.
    wgtDestroyChildren(sTree);

    // Free old labels
    if (sLabels) {
        for (int32_t i = 0; i < (int32_t)arrlen(sLabels); i++) {
            free(sLabels[i]);
        }

        arrfree(sLabels);
        sLabels = NULL;
    }

    if (!prj || prj->fileCount == 0) {
        return;
    }

    // Project name as root
    char *projLabel = strdup(prj->name[0] ? prj->name : "Project");
    arrput(sLabels, projLabel);
    WidgetT *projNode = wgtTreeItem(sTree, projLabel);
    projNode->userData = (void *)(intptr_t)-1;
    wgtTreeItemSetExpanded(projNode, true);

    // Forms and Modules groups
    char *formsLabel = strdup("Forms");
    arrput(sLabels, formsLabel);
    WidgetT *formsNode = wgtTreeItem(projNode, formsLabel);
    formsNode->userData = (void *)(intptr_t)-1;
    wgtTreeItemSetExpanded(formsNode, true);

    char *modsLabel = strdup("Modules");
    arrput(sLabels, modsLabel);
    WidgetT *modsNode = wgtTreeItem(projNode, modsLabel);
    modsNode->userData = (void *)(intptr_t)-1;
    wgtTreeItemSetExpanded(modsNode, true);

    for (int32_t i = 0; i < prj->fileCount; i++) {
        char buf[DVX_MAX_PATH + sizeof(PRJ_MODIFIED_SUFFIX)];
        snprintf(buf, sizeof(buf), "%s%s", prj->files[i].path, prj->files[i].modified ? PRJ_MODIFIED_SUFFIX : "");
        char *label = strdup(buf);
        arrput(sLabels, label);
        WidgetT *item = wgtTreeItem(prj->files[i].isForm ? formsNode : modsNode, label);
        item->userData   = (void *)(intptr_t)i;
        item->onDblClick = onTreeItemDblClick;
    }

    wgtInvalidate(sTree);
}


void prjRemoveFile(PrjStateT *prj, int32_t idx) {
    if (idx < 0 || idx >= prj->fileCount) {
        return;
    }

    free(prj->files[idx].buffer);
    arrdel(prj->files, idx);
    prj->fileCount = (int32_t)arrlen(prj->files);

    // Adjust active file index
    if (prj->activeFileIdx == idx) {
        prj->activeFileIdx = -1;
    } else if (prj->activeFileIdx > idx) {
        prj->activeFileIdx--;
    }

    prj->dirty = true;
}


bool prjSave(const PrjStateT *prj) {
    if (prj->projectPath[0] == '\0') {
        return false;
    }

    PrefsHandleT *h = prefsCreate();

    if (!h) {
        return false;
    }

    // [Project] section
    prefsSetString(h, BAS_INI_SECTION_PROJECT, BAS_INI_KEY_NAME, prj->name);

    if (prj->author[0])      { prefsSetString(h, BAS_INI_SECTION_PROJECT, BAS_INI_KEY_AUTHOR, prj->author); }
    if (prj->publisher[0])     { prefsSetString(h, BAS_INI_SECTION_PROJECT, BAS_INI_KEY_PUBLISHER, prj->publisher); }
    if (prj->version[0])     { prefsSetString(h, BAS_INI_SECTION_PROJECT, BAS_INI_KEY_VERSION, prj->version); }
    if (prj->copyright[0])   { prefsSetString(h, BAS_INI_SECTION_PROJECT, BAS_INI_KEY_COPYRIGHT, prj->copyright); }
    if (prj->description[0]) { prefsSetString(h, BAS_INI_SECTION_PROJECT, BAS_INI_KEY_DESCRIPTION, prj->description); }
    if (prj->iconPath[0])    { prefsSetString(h, BAS_INI_SECTION_PROJECT, BAS_INI_KEY_ICON, prj->iconPath); }
    if (prj->helpFile[0])   { prefsSetString(h, BAS_INI_SECTION_PROJECT, BAS_INI_KEY_HELPFILE, prj->helpFile); }

    // [Modules] and [Forms] sections
    prjSaveFileSection(h, prj, BAS_INI_SECTION_MODULES, false);
    prjSaveFileSection(h, prj, BAS_INI_SECTION_FORMS, true);

    // [Settings] section
    prefsSetString(h, BAS_INI_SECTION_SETTINGS, BAS_INI_KEY_STARTUPFORM, prj->startupForm);
    prefsSetBool(h, BAS_INI_SECTION_SETTINGS, BAS_INI_KEY_OPTIONEXPLICIT, prj->optionExplicit);

    bool ok = prefsSaveAs(h, prj->projectPath);
    prefsClose(h);
    return ok;
}


// Write the project files whose isForm flag matches into an INI section as
// File0, File1, ... plus a Count entry.
static void prjSaveFileSection(PrefsHandleT *h, const PrjStateT *prj, const char *section, bool isForm) {
    int32_t idx = 0;

    for (int32_t i = 0; i < prj->fileCount; i++) {
        if (prj->files[i].isForm == isForm) {
            char key[PRJ_INI_KEY_LEN];
            snprintf(key, sizeof(key), PRJ_FILE_KEY_FMT, (int)idx++);
            prefsSetString(h, section, key, prj->files[i].path);
        }
    }

    prefsSetInt(h, section, PRJ_COUNT_KEY, idx);
}


// validateIcon -- check that an image file is a valid PRJ_ICON_SIZE square icon.
// Returns true if valid.  Shows an error dialog and returns false if not.
static bool validateIcon(const char *fullPath, bool showErrors) {
    int32_t infoW = 0;
    int32_t infoH = 0;

    if (!dvxImageInfo(fullPath, &infoW, &infoH)) {
        if (showErrors) {
            dvxErrorBox(sPpd.ctx, "Invalid Icon", "Could not read image file.");
        }
        return false;
    }

    if (infoW != PRJ_ICON_SIZE || infoH != PRJ_ICON_SIZE) {
        if (showErrors) {
            char msg[PRJ_MSG_BUF];
            snprintf(msg, sizeof(msg), "Icon must be %dx%d pixels.\nThis image is %dx%d.", PRJ_ICON_SIZE, PRJ_ICON_SIZE, (int)infoW, (int)infoH);
            dvxMessageBox(sPpd.ctx, "Invalid Icon", msg, MB_OK | MB_ICONWARNING);
        }
        return false;
    }

    return true;
}
