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

// shellApp.c  -- DVX Shell application loading, lifecycle, and reaping
//
// Manages DXE app loading via dlopen/dlsym, resource tracking through
// ctx->currentAppId, and clean teardown of both callback-only and main-loop apps.

#include "shellApp.h"
#include "dvxDlg.h"
#include "dvxPlat.h"
#include "stb_ds_wrap.h"

#include <dlfcn.h>
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <strings.h>
#include "dvxMem.h"

// File copy chunk size for multi-instance temp copies
#define COPY_BUF_SIZE 32768

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

// Dynamic app slot index (stb_ds array of pointers). App IDs are array
// indices. Slot 0 is reserved (represents the shell itself); apps use
// slots 1+. New slots are appended as needed; freed slots are recycled.
// Each ShellAppT is heap-allocated once and only its pointer lives here,
// so a slot's address never moves when the index array grows -- held
// ShellAppT pointers (and app->name) stay valid across later app loads.
static ShellAppT **sApps = NULL;

// Ctrl+Esc handler -- set by taskmgr DXE constructor, NULL if not loaded
void (*shellCtrlEscFn)(AppContextT *ctx) = NULL;

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

static int32_t     allocSlot(void);
static void        appTaskWrapper(void *arg);
static void        cleanupTempFile(ShellAppT *app);
static int32_t     copyFile(const char *src, const char *dst);
static void        destroyAppWindows(AppContextT *ctx, int32_t appId);
static ShellAppT  *findLoadedPath(const char *path);
static void        killAppTask(ShellAppT *app);
static void        makeTempPath(const char *origPath, int32_t id, char *out, int32_t outSize);
static void        releaseAppResources(ShellAppT *app);
void               shellAppInit(void);
int32_t            shellAppSlotCount(void);
void               shellConfigPath(const DxeAppContextT *ctx, const char *filename, char *outPath, int32_t outSize);
int32_t            shellEnsureConfigDir(const DxeAppContextT *ctx);
void               shellForceKillApp(AppContextT *ctx, ShellAppT *app);
ShellAppT         *shellGetApp(int32_t appId);
int32_t            shellLoadApp(AppContextT *ctx, const char *path);
static int32_t     shellLoadAppInternal(AppContextT *ctx, const char *path, const char *args);
int32_t            shellLoadAppWithArgs(AppContextT *ctx, const char *path, const char *args);
static int32_t     shellLoadFail(void *handle, const char *tempPath);
void               shellReapApp(AppContextT *ctx, ShellAppT *app);
bool               shellReapApps(AppContextT *ctx);
int32_t            shellRunningAppCount(void);
void               shellTerminateAllApps(AppContextT *ctx);


// Find the first free slot, starting at 1 (slot 0 is the shell).
// Returns the slot index which also serves as the app's unique ID, or -1
// if a new slot was needed but could not be allocated.
// If no free slot exists, appends a new heap-allocated one.
static int32_t allocSlot(void) {
    for (int32_t i = 1; i < arrlen(sApps); i++) {
        if (sApps[i]->state == AppStateFreeE) {
            return i;
        }
    }

    // No free slot -- allocate a new one. The ShellAppT lives on the heap
    // and only its pointer is appended to the index, so its address stays
    // stable even when the index array reallocs.
    ShellAppT *newSlot = (ShellAppT *)calloc(1, sizeof(ShellAppT));

    if (!newSlot) {
        return -1;
    }

    arrput(sApps, newSlot);

    return arrlen(sApps) - 1;
}


// Task entry point for main-loop apps. Runs in its own cooperative task.
// Sets ctx->currentAppId before calling the app's entry point so that any
// GUI resources created during the app's lifetime are tagged with the
// correct owner. When the app's main loop returns (normal exit), the
// state is set to Terminating so the shell's main loop will reap it.
//
// If the app crashes (signal), the signal handler longjmps to main and
// this function never completes  -- the task is killed externally via
// shellForceKillApp + tsKill.
static void appTaskWrapper(void *arg) {
    // The task's arg is the app's slot ID. Resolve it to the app's stable
    // heap pointer once -- slots never move, so the pointer stays valid for
    // the whole task lifetime (even across other app loads) without any
    // need to re-resolve after the app's main loop returns.
    int32_t    appId = (int32_t)(intptr_t)arg;
    ShellAppT *app   = sApps[appId];
    app->dxeCtx->shellCtx->currentAppId = app->appId;
    app->entryFn(app->dxeCtx);

    app->dxeCtx->shellCtx->currentAppId = 0;

    // App returned from its main loop  -- mark for reaping.  This task is
    // about to self-terminate (taskTrampoline -> tsExit), which frees its
    // slot; clear mainTaskId so the deferred reap does not tsKill a recycled
    // slot that a later tsCreate may already have handed to a new task.
    app->mainTaskId = 0;
    app->state      = AppStateTerminatingE;
}


// Delete the temp file for a multi-instance app copy, if one exists.
static void cleanupTempFile(ShellAppT *app) {
    if (app->tempPath[0] != '\0') {
        remove(app->tempPath);
        app->tempPath[0] = '\0';
    }
}


// Binary file copy. Returns 0 on success, -1 on failure.
static int32_t copyFile(const char *src, const char *dst) {
    FILE *in = fopen(src, "rb");

    if (!in) {
        dvxLog("copyFile: failed to open source '%s' (errno=%d)", src, errno);
        return -1;
    }

    FILE *out = fopen(dst, "wb");

    if (!out) {
        dvxLog("copyFile: failed to create dest '%s' (errno=%d)", dst, errno);
        fclose(in);
        return -1;
    }

    // Static rather than on-stack: shellLoadApp runs on app task stacks as
    // small as TS_DEFAULT_STACK_SIZE (32KB), which a 32KB stack frame would
    // overflow.  The cooperative switcher is single-threaded, so a shared
    // copy buffer is safe.
    static char buf[COPY_BUF_SIZE];
    size_t n;

    while ((n = fread(buf, 1, sizeof(buf), in)) > 0) {
        if (fwrite(buf, 1, n, out) != n) {
            dvxLog("copyFile: write failed '%s' -> '%s' (errno=%d)", src, dst, errno);
            fclose(in);
            fclose(out);
            remove(dst);
            return -1;
        }
    }

    fclose(in);
    fclose(out);
    return 0;
}


// Destroy every window belonging to an app SYNCHRONOUSLY, before its DXE
// is unmapped -- a deferred destroy would leave live timer/poll/window
// callbacks pointing into unmapped code for the rest of the dispatch pass.
//
// First pass: flag every window of the app destroyPending (and hide it).
// This makes all of them inert at once, so the successor-focus selection
// inside each destroy can only land on OTHER apps' windows -- focus
// callbacks (and BASIC Activate events) can never fire into the app being
// torn down.  The flush hint is incremented alongside the flag so
// dvxDestroyWindowNow's decrement stays balanced.
//
// Second pass: rescan-until-none-left rather than a single index walk,
// because callbacks fired mid-destroy (another app's onFocus) can mutate
// the window stack and a plain walk could skip an entry.
static void destroyAppWindows(AppContextT *ctx, int32_t appId) {
    for (int32_t i = 0; i < ctx->stack.count; i++) {
        WindowT *win = ctx->stack.windows[i];

        if (win->appId == appId && !win->destroyPending) {
            win->destroyPending = true;
            ctx->pendingDestroyCount++;

            if (win->visible) {
                dirtyListAdd(&ctx->dirty, win->x, win->y, win->w, win->h);
                win->visible = false;
            }

            // Silent focus drop (mirrors deferDestroyWindow): with
            // focusedIdx pre-cleared, the wmSetFocus that follows the
            // first destroy sees no old window and fires only the
            // successor's onFocus -- never onBlur/Deactivate into the
            // dying app.
            if (win->focused) {
                win->focused          = false;
                ctx->stack.focusedIdx = -1;
            }
        }
    }

    bool found = true;

    while (found) {
        found = false;

        for (int32_t i = ctx->stack.count - 1; i >= 0; i--) {
            if (ctx->stack.windows[i]->appId == appId) {
                dvxDestroyWindowNow(ctx, ctx->stack.windows[i]);
                found = true;
                break;
            }
        }
    }
}


// Find an active slot with the given DXE path. Returns the slot pointer,
// or NULL if no running app was loaded from this path.
static ShellAppT *findLoadedPath(const char *path) {
    for (int32_t i = 1; i < arrlen(sApps); i++) {
        if (sApps[i]->state != AppStateFreeE && strcmp(sApps[i]->path, path) == 0) {
            return sApps[i];
        }
    }

    return NULL;
}


// Kill an app's cooperative task if it has one and it is still alive.
// Shared by shellForceKillApp and shellReapApp.
static void killAppTask(ShellAppT *app) {
    if (app->hasMainLoop && app->mainTaskId > 0) {
        if (tsGetState(app->mainTaskId) != TaskStateTerminated) {
            tsKill(app->mainTaskId);
        }
    }
}


// Build a temp path for a multi-instance copy. Uses the TEMP or TMP
// environment variable if set, otherwise falls back to the current directory.
// The slot ID is embedded in the filename to ensure uniqueness.
// Example: TEMP=C:\TEMP -> "C:\TEMP\_dvx02.app"
static void makeTempPath(const char *origPath, int32_t id, char *out, int32_t outSize) {
    // Find extension from original path
    const char *lastSlash = platformPathDirEnd(origPath);
    const char *dot       = strrchr(origPath, '.');

    if (!dot || (lastSlash && dot < lastSlash)) {
        dot = origPath + strlen(origPath);
    }

    const char *tmpDir = getenv("TEMP");

    if (!tmpDir) {
        tmpDir = getenv("TMP");
    }

    if (tmpDir && tmpDir[0]) {
        snprintf(out, outSize, "%s" DVX_PATH_SEP "_dvx%02ld%s", tmpDir, (long)id, dot);
    } else {
        snprintf(out, outSize, "_dvx%02ld%s", (long)id, dot);
    }
}


// Shared teardown tail for both reap and force-kill: unmap the DXE, delete
// any multi-instance temp copy, release the heap app context, and reset the
// app's memory accounting. Callers handle the parts that differ between the
// two paths (app-code shutdown or its deliberate omission, callback purging,
// window destruction, and the final state/log line).
static void releaseAppResources(ShellAppT *app) {
    if (app->dxeHandle) {
        dlclose(app->dxeHandle);
        app->dxeHandle = NULL;
    }

    cleanupTempFile(app);
    free(app->dxeCtx);
    app->dxeCtx = NULL;
    dvxMemResetApp(app->appId);
}


void shellAppInit(void) {
    // Seed slot 0 (reserved for the shell). Slots are heap-allocated so
    // their addresses stay stable as the sApps index array grows.
    ShellAppT *slot0 = (ShellAppT *)calloc(1, sizeof(ShellAppT));
    arrput(sApps, slot0);
}


int32_t shellAppSlotCount(void) {
    return arrlen(sApps);
}


void shellConfigPath(const DxeAppContextT *ctx, const char *filename, char *outPath, int32_t outSize) {
    snprintf(outPath, outSize, "%s" DVX_PATH_SEP "%s", ctx->configDir, filename);
}


int32_t shellEnsureConfigDir(const DxeAppContextT *ctx) {
    return platformMkdirRecursive(ctx->configDir);
}


// Forcible kill  -- reclaims an app WITHOUT running any of the app's own code.
// End Task's primary target is a HUNG or FAULTING app: its shutdownFn would
// re-enter the same broken state (re-hang the single-threaded cooperative
// shell, or re-fault and longjmp past all cleanup), so the shutdownFn is
// deliberately NOT called here.  Instead the shell itself purges every
// callback the app registered (desktop-update hooks, idle/serial pollers)
// via shellPurgeAppCallbacks, so nothing dangles once the DXE is unmapped.
// Used for crashed apps, "End Task", and shell shutdown.
// Cleanup order matters: purge shell callbacks and destroy windows (removes
// them from the compositor) first, then the task (frees the stack), then the
// DXE handle (unmaps the code).  If we closed the DXE first, destroying
// windows could call into unmapped callback code and crash the shell.
void shellForceKillApp(AppContextT *ctx, ShellAppT *app) {
    if (!app || app->state == AppStateFreeE) {
        return;
    }

    // Purge every shell-side callback this app registered (desktop-update and
    // idle handlers) BEFORE its DXE is unmapped.  A force kill never runs the
    // app's shutdownFn -- the app is being killed precisely because its own
    // code cannot be trusted to run -- so the shell, not the app, is
    // responsible for unhooking these; otherwise the dangling pointers crash
    // the next shellDesktopUpdate() or idle frame.
    shellPurgeAppCallbacks(app->appId);

    // Destroy all windows belonging to this app -- synchronously, even if
    // End Task ran inside a widget callback (dispatchDepth > 0), so no
    // timer/poll/window callback can point into the DXE once it unmaps.
    // Also reclaims windows the app had already self-deferred.
    destroyAppWindows(ctx, app->appId);

    // Kill the task, then unmap the DXE and release the app's resources.
    killAppTask(app);
    releaseAppResources(app);
    app->state = AppStateFreeE;
    dvxLog("Shell: force-killed app '%s'", app->name);
}


ShellAppT *shellGetApp(int32_t appId) {
    if (appId < 1 || appId >= arrlen(sApps)) {
        return NULL;
    }

    if (sApps[appId]->state == AppStateFreeE) {
        return NULL;
    }

    return sApps[appId];
}


// Load a DXE app: dlopen the module, resolve symbols, set up context, launch.
// DXE3 is DJGPP's dynamic linking system  -- similar to dlopen/dlsym on Unix.
// Each .app file is a DXE3 shared object that exports _appDescriptor and
// _appMain (and optionally _appShutdown). The leading underscore is the
// COFF symbol convention; DJGPP's dlsym expects it.
//
// Multi-instance support: DXE3's dlopen returns the same handle for the
// same path (reference-counted), so two loads of the same .dxe share all
// global/static state  -- only one main loop runs and closing one kills both.
// If the app's descriptor sets multiInstance=true, we copy the .dxe to a
// unique temp file before dlopen, giving each instance its own code+data.
// If multiInstance=false (the default), a second load of the same path is
// blocked with an error message.
int32_t shellLoadApp(AppContextT *ctx, const char *path) {
    return shellLoadAppInternal(ctx, path, NULL);
}


static int32_t shellLoadAppInternal(AppContextT *ctx, const char *path, const char *args) {
    // Check if this DXE is already loaded. If so, check whether the app
    // allows multiple instances. We read the descriptor from the existing
    // slot directly  -- no need to dlopen again. This runs before the
    // slot is allocated so a rejected launch never grows the slot index.
    ShellAppT *existing = findLoadedPath(path);

    if (existing) {
        // Read multiInstance from the already-loaded descriptor
        AppDescriptorT *existDesc = (AppDescriptorT *)dlsym(existing->dxeHandle, "_appDescriptor");

        if (!existDesc || !existDesc->multiInstance) {
            char msg[SHELL_MSG_MAX];
            snprintf(msg, sizeof(msg), "%s is already running.", platformPathBaseName(path));
            dvxErrorBox(ctx, NULL, msg);
            return -1;
        }
    }

    // Allocate a slot
    int32_t id = allocSlot();

    if (id < 0) {
        dvxErrorBox(ctx, NULL, "Out of memory: cannot allocate an application slot.");
        return -1;
    }

    const char *loadPath = path;
    char        tempPath[DVX_MAX_PATH] = {0};

    if (existing) {
        // Multi-instance allowed: copy to a temp file so dlopen gets
        // an independent code+data image.
        makeTempPath(path, id, tempPath, sizeof(tempPath));

        if (copyFile(path, tempPath) != 0) {
            char msg[SHELL_MSG_MAX];
            snprintf(msg, sizeof(msg), "Failed to create instance copy of %s.", platformPathBaseName(path));
            dvxErrorBox(ctx, NULL, msg);
            return -1;
        }

        loadPath = tempPath;
    }

    // Show hourglass during the load (dlopen + symbol resolution + appMain)
    dvxSetBusy(ctx, true);

    // Load the DXE
    void *handle = dlopen(loadPath, RTLD_GLOBAL);

    if (!handle) {
        char msg[SHELL_MSG_MAX];
        snprintf(msg, sizeof(msg), "Failed to load %s:\n%s", platformPathBaseName(path), dlerror());
        dvxLog("DXE load failed: %s", msg);
        dvxSetBusy(ctx, false);
        dvxErrorBox(ctx, NULL, msg);
        return shellLoadFail(handle, tempPath);
    }

    // Look up required symbols
    AppDescriptorT *desc = (AppDescriptorT *)dlsym(handle, "_appDescriptor");

    if (!desc) {
        char msg[SHELL_MSG_MAX];
        snprintf(msg, sizeof(msg), "%s: missing appDescriptor", platformPathBaseName(path));
        dvxLog("DXE symbol error: %s", msg);
        dvxSetBusy(ctx, false);
        dvxErrorBox(ctx, NULL, msg);
        return shellLoadFail(handle, tempPath);
    }

    int32_t (*entry)(DxeAppContextT *) = (int32_t (*)(DxeAppContextT *))dlsym(handle, "_appMain");

    if (!entry) {
        char msg[SHELL_MSG_MAX];
        snprintf(msg, sizeof(msg), "%s: missing appMain", platformPathBaseName(path));
        dvxSetBusy(ctx, false);
        dvxErrorBox(ctx, NULL, msg);
        return shellLoadFail(handle, tempPath);
    }

    void (*shutdown)(void) = (void (*)(void))dlsym(handle, "_appShutdown");

    // Fill in the app slot
    ShellAppT *app = sApps[id];
    memset(app, 0, sizeof(*app));

    app->appId = id;
    snprintf(app->name, SHELL_APP_NAME_MAX, "%s", desc->name);
    snprintf(app->path, sizeof(app->path), "%s", path);
    snprintf(app->tempPath, sizeof(app->tempPath), "%s", tempPath);
    app->dxeHandle   = handle;
    app->hasMainLoop  = desc->hasMainLoop;
    app->entryFn      = entry;
    app->shutdownFn   = shutdown;
    app->state        = AppStateLoadedE;

    // Heap-allocate the DxeAppContextT so its address is stable across
    // sApps reallocs. Apps save this pointer in their static globals.
    app->dxeCtx = (DxeAppContextT *)calloc(1, sizeof(DxeAppContextT));

    if (!app->dxeCtx) {
        dvxLog("Shell: failed to allocate app context for %s", app->name);
        dvxSetBusy(ctx, false);
        app->state = AppStateFreeE;
        return shellLoadFail(handle, tempPath);
    }

    app->dxeCtx->shellCtx = ctx;
    app->dxeCtx->appId    = id;

    // Store the full app path so the app can load its own resources.
    snprintf(app->dxeCtx->appPath, sizeof(app->dxeCtx->appPath), "%s", path);

    // Derive app directory from path (everything up to the last separator).
    // This lets apps load resources relative to their own location rather
    // than the shell's working directory.
    snprintf(app->dxeCtx->appDir, sizeof(app->dxeCtx->appDir), "%s", path);

    char *sep = platformPathDirEnd(app->dxeCtx->appDir);

    if (sep) {
        *sep = '\0';
    } else {
        app->dxeCtx->appDir[0] = '.';
        app->dxeCtx->appDir[1] = '\0';
    }

    // Derive config directory: mirror the app directory under CONFIG/.
    // e.g. "APPS/KPUNCH/DVXBASIC" -> "CONFIG/APPS/KPUNCH/DVXBASIC"
    // If the path doesn't start with a recognized prefix, fall back to
    // "CONFIG/APPS/<appname>" using the descriptor name.
    const char *appDirStr = app->dxeCtx->appDir;

    if (appDirStr[0]) {
        snprintf(app->dxeCtx->configDir, sizeof(app->dxeCtx->configDir), "CONFIG/%s", appDirStr);
    } else {
        snprintf(app->dxeCtx->configDir, sizeof(app->dxeCtx->configDir), "CONFIG/APPS/%s", desc->name);
    }

    // Copy launch arguments (if any)
    if (args && args[0]) {
        snprintf(app->dxeCtx->args, sizeof(app->dxeCtx->args), "%s", args);
    }

    // Launch. Set currentAppId before any app code runs so that
    // dvxCreateWindow stamps the correct owner on new windows.
    ctx->currentAppId = id;

    if (desc->hasMainLoop) {
        uint32_t stackSize = desc->stackSize > 0 ? (uint32_t)desc->stackSize : TS_DEFAULT_STACK_SIZE;
        int32_t  priority  = desc->priority;

        int32_t taskId = tsCreate(desc->name, appTaskWrapper, (void *)(intptr_t)id, stackSize, priority);

        if (taskId < 0) {
            ctx->currentAppId = 0;
            dvxSetBusy(ctx, false);
            dvxErrorBox(ctx, NULL, "Failed to create task for application.");
            free(app->dxeCtx);
            app->dxeCtx = NULL;
            app->state = AppStateFreeE;
            return shellLoadFail(handle, tempPath);
        }

        app->mainTaskId = (uint32_t)taskId;
    } else {
        // Callback-only: call entry directly in task 0 (the shell).
        // The app creates its windows and returns. From this point on,
        // the app lives entirely through event callbacks dispatched by
        // the shell's dvxUpdate loop. No separate task or stack needed.
        app->entryFn(app->dxeCtx);
    }

    // The slot pointer is stable across the callback-only entry point above
    // (which can pump dvxUpdate and yield to other app loads), so no
    // re-resolve is needed.
    ctx->currentAppId = 0;
    app->state = AppStateRunningE;

    dvxLog("Shell: loaded '%s' (id=%ld, mainLoop=%s, entry=0x%08lx, desc=0x%08lx)", app->name, (long)id, app->hasMainLoop ? "yes" : "no", (unsigned long)entry, (unsigned long)desc);
    dvxSetBusy(ctx, false);
    shellDesktopUpdate();
    return id;
}


int32_t shellLoadAppWithArgs(AppContextT *ctx, const char *path, const char *args) {
    return shellLoadAppInternal(ctx, path, args);
}


// Shared load-failure cleanup for shellLoadAppInternal: unmap the DXE (if
// dlopen got far enough to return a handle) and delete any multi-instance
// temp copy. Touches only these locals, never the slot, so it is safe from
// every failure branch regardless of how far the load progressed. The busy
// hourglass, the error dialog, and any slot-state reset are handled by the
// caller before this runs. Always returns -1 for the caller to propagate.
static int32_t shellLoadFail(void *handle, const char *tempPath) {
    if (handle) {
        dlclose(handle);
    }

    if (tempPath[0]) {
        remove(tempPath);
    }

    return -1;
}


// Graceful reap  -- called from shellReapApps when an app has reached
// the Terminating state. Unlike forceKill, this calls the app's
// shutdown hook (if provided) giving it a chance to save state, close
// files, etc. The ctx->currentAppId is set during the shutdown call so
// any final resource operations are attributed correctly.
void shellReapApp(AppContextT *ctx, ShellAppT *app) {
    if (!app || app->state == AppStateFreeE) {
        return;
    }

    // Call shutdown hook if present
    if (app->shutdownFn) {
        ctx->currentAppId = app->appId;
        app->shutdownFn();
        ctx->currentAppId = 0;
    }

    // Destroy all windows belonging to this app, synchronously (see
    // destroyAppWindows) -- they must be gone before dlclose unmaps the
    // callback code they point into.
    destroyAppWindows(ctx, app->appId);

    // Kill the task if it has one and it's still alive.
    killAppTask(app);

    // Safety net: a well-behaved shutdownFn already unregistered its shell
    // callbacks above, but the shell owns teardown regardless of app
    // cooperation -- drop any the app forgot before the DXE is unmapped.
    shellPurgeAppCallbacks(app->appId);

    releaseAppResources(app);
    dvxLog("Shell: reaped app '%s'", app->name);
    app->state = AppStateFreeE;
}


// Called every frame from the shell's main loop. Scans for apps that have
// entered the Terminating state (either their task returned or their last
// window was closed) and cleans them up. The deferred-reap design avoids
// destroying resources in the middle of a callback chain  -- the app marks
// itself for termination, and cleanup happens at a safe top-level point.
bool shellReapApps(AppContextT *ctx) {
    bool reaped = false;

    for (int32_t i = 1; i < arrlen(sApps); i++) {
        if (sApps[i]->state == AppStateTerminatingE) {
            shellReapApp(ctx, sApps[i]);
            reaped = true;
            continue;
        }

        // Callback-only apps terminate when their last window closes.
        // They have no main loop to set AppStateTerminatingE, so we
        // detect termination by checking for zero remaining windows.
        if (sApps[i]->state == AppStateRunningE && !sApps[i]->hasMainLoop) {
            bool hasWindow = false;

            for (int32_t w = 0; w < ctx->stack.count; w++) {
                if (ctx->stack.windows[w]->appId == sApps[i]->appId) {
                    hasWindow = true;
                    break;
                }
            }

            if (!hasWindow) {
                shellReapApp(ctx, sApps[i]);
                reaped = true;
            }
        }
    }

    return reaped;
}


int32_t shellRunningAppCount(void) {
    int32_t count = 0;

    for (int32_t i = 1; i < arrlen(sApps); i++) {
        if (sApps[i]->state == AppStateRunningE || sApps[i]->state == AppStateLoadedE) {
            count++;
        }
    }

    return count;
}


void shellTerminateAllApps(AppContextT *ctx) {
    for (int32_t i = 1; i < arrlen(sApps); i++) {
        if (sApps[i]->state != AppStateFreeE) {
            shellForceKillApp(ctx, sApps[i]);
        }
    }
}
