// ui.c - file browser, sidebar controls, sliders, and PNG export
#include "ui.h"
#include "render.h"
#include "stft.h"
#include "audio.h"
#include "platform.h"

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

// Internal sidebar widgets (defined after DrawSidebar, which uses them)
static void DrawSlider(Rectangle bounds, float value);
static bool UpdateSlider(Rectangle bounds, float* value);

// True if the rect was left-clicked this frame (hovered + button pressed).
static bool Clicked(Rectangle r)
{
    return CheckCollisionPointRec(GetMousePosition(), r) && IsMouseButtonPressed(MOUSE_LEFT_BUTTON);
}

// The ubiquitous "filled rectangle + 1px border" panel/button chrome.
static void DrawPanelBox(Rectangle r, Color fill, Color border)
{
    DrawRectangleRec(r, fill);
    DrawRectangleLinesEx(r, 1, border);
}

// ===== File browser =====
void FreeBrowserFiles(void)
{
    if (app.browserFiles) {
        for (int i = 0; i < app.browserFileCount; i++) free(app.browserFiles[i]);
        free(app.browserFiles);
        free(app.browserIsDir);
        app.browserFiles = NULL;
        app.browserIsDir = NULL;
    }
    app.browserFileCount = 0;
}

void ScanDirectory(const char* path)
{
    FreeBrowserFiles();
    strncpy(app.browserPath, path, sizeof(app.browserPath) - 1);
    FilePathList files = LoadDirectoryFiles(path);
    
    int dirCount = 0, wavCount = 0;
    for (int i = 0; i < files.count; i++) {
        const char* name = GetFileName(files.paths[i]);
        if (strcmp(name, ".") == 0 || strcmp(name, "..") == 0) continue;
        if (DirectoryExists(files.paths[i])) dirCount++;
        else {
            const char* ext = GetFileExtension(files.paths[i]);
            if (ext && (strcmp(ext, ".wav") == 0 || strcmp(ext, ".WAV") == 0 ||
                        strcmp(ext, ".Wave") == 0 || strcmp(ext, ".Wav") == 0)) wavCount++;
        }
    }
    
    int totalCount = dirCount + wavCount;
    if (totalCount > 0) {
        app.browserFiles = (char**)malloc(totalCount * sizeof(char*));
        app.browserIsDir = (bool*)malloc(totalCount * sizeof(bool));
        app.browserFileCount = 0;
        
        for (int i = 0; i < files.count; i++) {
            const char* name = GetFileName(files.paths[i]);
            if (strcmp(name, ".") == 0 || strcmp(name, "..") == 0) continue;
            if (DirectoryExists(files.paths[i])) {
                size_t len = strlen(name) + 1;
                app.browserFiles[app.browserFileCount] = (char*)malloc(len);
                if (app.browserFiles[app.browserFileCount]) {
                    memcpy(app.browserFiles[app.browserFileCount], name, len);
                    app.browserIsDir[app.browserFileCount] = true;
                    app.browserFileCount++;
                }
            }
        }
        for (int i = 0; i < files.count; i++) {
            const char* name = GetFileName(files.paths[i]);
            if (strcmp(name, ".") == 0 || strcmp(name, "..") == 0) continue;
            if (!DirectoryExists(files.paths[i])) {
                const char* ext = GetFileExtension(files.paths[i]);
                if (ext && (strcmp(ext, ".wav") == 0 || strcmp(ext, ".WAV") == 0 ||
                            strcmp(ext, ".Wave") == 0 || strcmp(ext, ".Wav") == 0)) {
                    size_t len = strlen(name) + 1;
                    app.browserFiles[app.browserFileCount] = (char*)malloc(len);
                    if (app.browserFiles[app.browserFileCount]) {
                        memcpy(app.browserFiles[app.browserFileCount], name, len);
                        app.browserIsDir[app.browserFileCount] = false;
                        app.browserFileCount++;
                    }
                }
            }
        }
    }
    UnloadDirectoryFiles(files);
    app.browserScroll = 0;
    app.browserSelected = -1;
}

static void NavigateToParentDirectory(void)
{
    const char* parent = GetPrevDirectoryPath(app.browserPath);
    if (parent && strlen(parent) > 0) ScanDirectory(parent);
}

static void NavigateToDirectory(const char* dirName)
{
    char newPath[512];
    int written = snprintf(newPath, sizeof(newPath), "%s/%s", app.browserPath, dirName);
    if (written < 0 || written >= (int)sizeof(newPath)) return;  // Path too long
    if (DirectoryExists(newPath)) ScanDirectory(newPath);
}

static void LoadSelectedFile(void)
{
    if (app.browserSelected < 0 || app.browserSelected >= app.browserFileCount) return;
    
    char filePath[512];
    int written = snprintf(filePath, sizeof(filePath), "%s/%s", app.browserPath, app.browserFiles[app.browserSelected]);
    if (written < 0 || written >= (int)sizeof(filePath)) return;  // Path too long
    
    if (app.browserIsDir[app.browserSelected]) {
        NavigateToDirectory(app.browserFiles[app.browserSelected]);
    } else if (FileExists(filePath) && LoadWavFile(filePath, &app.signal)) {
        ResetForNewSignal();
        LoadMlnlFromWav(filePath, &app.annotations);
        app.showFileBrowser = false;
        TraceLog(LOG_INFO, "Loaded: %s", filePath);
    }
}

void DrawFileBrowser(void)
{
    // Draw semi-transparent overlay first
    DrawRectangle(0, 0, GetScreenWidth(), GetScreenHeight(), Fade(BLACK, 0.85f));

    float scale = GetUIScale();
    float bw = 900.0f * scale, bh = 700.0f * scale;
    float bx = (GetScreenWidth() - bw) / 2, by = (GetScreenHeight() - bh) / 2;

    DrawRectangle(bx, by, bw, bh, (Color){ 45, 45, 55, 255 });
    DrawRectangleLinesEx((Rectangle){ bx, by, bw, bh }, (int)(2 * scale), GRAY);
    DrawRectangle(bx, by, bw, (int)(40 * scale), (Color){ 60, 60, 75, 255 });
    DrawTextScaled("File Browser - Select WAV File", bx + (int)(15 * scale), by + (int)(8 * scale), (int)(20 * scale), WHITE);

    // Path bar
    float pathBarY = by + (int)(46 * scale);
    DrawRectangle(bx + (int)(15 * scale), pathBarY, bw - (int)(110 * scale), (int)(30 * scale), (Color){ 30, 30, 40, 255 });
    DrawRectangleLinesEx((Rectangle){ bx + (int)(15 * scale), pathBarY, bw - (int)(110 * scale), (int)(30 * scale) }, (int)(1 * scale), GRAY);

    char displayPath[300];
    strncpy(displayPath, app.browserPath, sizeof(displayPath) - 1);
    displayPath[sizeof(displayPath) - 1] = '\0';
    if (strlen(displayPath) > 60) sprintf(displayPath, "...%s", app.browserPath + strlen(app.browserPath) - 57);
    DrawTextScaled(displayPath, bx + (int)(22 * scale), pathBarY + (int)(5 * scale), (int)(14 * scale), LIGHTGRAY);

    // Up button
    Rectangle upBtn = { bx + bw - (int)(90 * scale), pathBarY, (int)(75 * scale), (int)(30 * scale) };
    if (CheckCollisionPointRec(GetMousePosition(), upBtn)) DrawRectangleRec(upBtn, (Color){ 80, 80, 90, 255 });
    DrawTextScaled("UP (..)", upBtn.x + (int)(10 * scale), upBtn.y + (int)(7 * scale), (int)(14 * scale), WHITE);

    // File list
    float lx = bx + (int)(15 * scale), ly = pathBarY + (int)(40 * scale);
    float lw = bw - (int)(30 * scale), lh = bh - (int)(195 * scale);
    DrawRectangle(lx, ly, lw, lh, (Color){ 25, 25, 35, 255 });
    DrawRectangleLinesEx((Rectangle){ lx, ly, lw, lh }, (int)(1 * scale), GRAY);

    // Line height: base 36px scaled (enough for icon + filename without overlap)
    float lineH = 36 * scale;

    // Handle empty directory
    int visibleItems = (int)(lh / lineH);
    if (visibleItems < 1) visibleItems = 1;

    if (app.browserFileCount <= 0 || !app.browserFiles) {
        DrawTextScaled("(No WAV files in directory)", lx + (int)(20 * scale), ly + (int)(lh / 2 - 12 * scale), (int)(14 * scale), GRAY);
    } else {
        if (app.browserFileCount > visibleItems) {
            float sh = (float)visibleItems / app.browserFileCount * lh;
            if (sh < (int)(10 * scale)) sh = (int)(10 * scale);
            float sy = ly + (float)app.browserScroll / (app.browserFileCount - visibleItems) * (lh - sh);
            DrawRectangle(lx + lw - (int)(10 * scale), sy, (int)(8 * scale), sh, GRAY);
        }

        int startItem = app.browserScroll;
        int endItem = startItem + visibleItems + 1;
        if (endItem > app.browserFileCount) endItem = app.browserFileCount;

        float iconW = (int)(45 * scale);  // space for icon column
        for (int i = startItem; i < endItem; i++) {
            if (i < 0 || i >= app.browserFileCount || !app.browserFiles[i] || !app.browserIsDir) continue;

            float iy = ly + (i - startItem) * lineH + (int)(2 * scale);
            bool hovered = CheckCollisionPointRec((Vector2){ GetMouseX(), GetMouseY() }, (Rectangle){ lx + (int)(2 * scale), iy, lw - (int)(14 * scale), lineH - (int)(4 * scale) });

            if (i == app.browserSelected) DrawRectangle(lx + (int)(2 * scale), iy, lw - (int)(14 * scale), (int)((lineH - 4) * scale), (Color){ 50, 70, 120, 180 });
            else if (hovered) DrawRectangle(lx + (int)(2 * scale), iy, lw - (int)(14 * scale), (int)((lineH - 4) * scale), (Color){ 60, 60, 80, 100 });

            const char* icon = app.browserIsDir[i] ? "[DIR]" : "[WAV]";
            Color iconCol = app.browserIsDir[i] ? (Color){ 255, 220, 80, 255 } : (Color){ 80, 200, 120, 255 };
            DrawTextScaled(icon, lx + (int)(8 * scale), iy + (int)(4 * scale), (int)(13 * scale), iconCol);
            DrawTextScaled(app.browserFiles[i], lx + iconW + (int)(10 * scale), iy + (int)(4 * scale), (int)(14 * scale), WHITE);
        }
    }

    // Scroll with mouse wheel
    if (CheckCollisionPointRec(GetMousePosition(), (Rectangle){ lx, ly, lw - (int)(10 * scale), lh }) && app.browserFileCount > 0) {
        int wheel = GetMouseWheelMove();
        if (wheel > 0) app.browserScroll--;
        if (wheel < 0) app.browserScroll++;
        if (app.browserScroll < 0) app.browserScroll = 0;
        int maxScroll = app.browserFileCount - visibleItems;
        if (maxScroll < 0) maxScroll = 0;
        if (app.browserScroll > maxScroll) app.browserScroll = maxScroll;
    }

    // Handle clicks
    if (CheckCollisionPointRec(GetMousePosition(), upBtn) && IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) NavigateToParentDirectory();

    if (CheckCollisionPointRec(GetMousePosition(), (Rectangle){ lx, ly, lw - (int)(10 * scale), lh }) && IsMouseButtonPressed(MOUSE_LEFT_BUTTON) && app.browserFileCount > 0) {
        int clicked = app.browserScroll + (int)((GetMouseY() - ly) / lineH);
        if (clicked >= 0 && clicked < app.browserFileCount) {
            app.browserSelected = clicked;
            static double lastClick = 0;
            if (GetTime() - lastClick < 0.3) LoadSelectedFile();
            lastClick = GetTime();
        }
    }

    // Buttons
    float btnY = by + bh - (int)(55 * scale);
    Rectangle openBtn = { bx + bw - (int)(170 * scale), btnY, (int)(150 * scale), (int)(40 * scale) };
    Rectangle cancelBtn = { bx + (int)(15 * scale), btnY, (int)(120 * scale), (int)(40 * scale) };

    bool openHovered = CheckCollisionPointRec(GetMousePosition(), openBtn);
    bool openClicked = openHovered && IsMouseButtonPressed(MOUSE_LEFT_BUTTON);
    if (openHovered) DrawRectangleRec(openBtn, (Color){ 100, 100, 120, 255 });
    else DrawRectangleRec(openBtn, (Color){ 80, 80, 90, 255 });
    DrawRectangleLinesEx(openBtn, (int)(1 * scale), WHITE);
    DrawTextScaled("OPEN (Enter)", openBtn.x + (int)(25 * scale), openBtn.y + (int)(12 * scale), (int)(16 * scale), WHITE);

    DrawRectangleRec(cancelBtn, (Color){ 100, 40, 40, 255 });
    DrawTextScaled("ESC Cancel", cancelBtn.x + (int)(18 * scale), cancelBtn.y + (int)(12 * scale), (int)(16 * scale), WHITE);

    if ((IsKeyPressed(KEY_ENTER) || openClicked) && app.browserSelected >= 0 && app.browserFileCount > 0) LoadSelectedFile();
    if (IsKeyPressed(KEY_ESCAPE)) app.showFileBrowser = false;
    if (IsKeyPressed(KEY_UP) && app.browserSelected > 0 && app.browserFileCount > 0) {
        app.browserSelected--;
        if (app.browserSelected < app.browserScroll) app.browserScroll = app.browserSelected;
    }
    if (IsKeyPressed(KEY_DOWN) && app.browserSelected < app.browserFileCount - 1 && app.browserFileCount > 0) {
        app.browserSelected++;
        if (app.browserSelected >= app.browserScroll + visibleItems) app.browserScroll = app.browserSelected - visibleItems + 1;
    }
}


// ===== PNG export =====
void ExportPNG(const SpectrogramApp* spa, const char* dirPath)
{
    if (!spa->stftComputed || !spa->spectrogramImage.data) return;

    int imgW = spa->spectrogramImage.width;
    int imgH = spa->spectrogramImage.height;

    // Selection region in image-pixel coordinates. The image spans the full
    // Nyquist axis, but sel.freq* are fractions of the *displayed* axis (capped
    // at EffectiveMaxFreqHz), so scale by DisplayFreqFraction() the same way the
    // on-screen texture sub-sampling does — otherwise the export grabs a band
    // higher in frequency than what was box-selected.
    float cropFrac = DisplayFreqFraction();
    int selX0 = (int)(spa->sel.timeStart * imgW);
    int selX1 = (int)(spa->sel.timeEnd   * imgW);
    int selY0 = (int)((1.0f - spa->sel.freqEnd   * cropFrac) * imgH);
    int selY1 = (int)((1.0f - spa->sel.freqStart * cropFrac) * imgH);

    // Clamp to image bounds
    selX0 = Clamp(selX0, 0, imgW);
    selX1 = Clamp(selX1, 0, imgW);
    selY0 = Clamp(selY0, 0, imgH);
    selY1 = Clamp(selY1, 0, imgH);

    int regionW = selX1 - selX0;
    int regionH = selY1 - selY0;
    if (regionW <= 0 || regionH <= 0) return;

    // Extract selected region by copying pixel rows
    Image region = { 0 };
    region.width  = regionW;
    region.height = regionH;
    region.mipmaps = 1;
    region.format = spa->spectrogramImage.format;
    region.data = RL_MALLOC(regionW * regionH * 4);
    if (!region.data) return;

    // Raylib stores images as tightly packed RGBA rows - memcpy each row at a time
    Color* src = (Color*)spa->spectrogramImage.data;
    Color* dst = (Color*)region.data;
    for (int y = 0; y < regionH; y++) {
        memcpy(dst + y * regionW,
               src + (selY0 + y) * imgW + selX0,
               regionW * 4);
    }

    // Scale if a non-zero exportScale is set (and region isn't too large)
    if (spa->exportScale > 0.0f && regionW < 4096) {
        int outW = regionW * (int)spa->exportScale;
        int outH = regionH * (int)spa->exportScale;
        if (outW > 0 && outH > 0) {
            ImageResize(&region, outW, outH);
        }
    }

    // Export as PNG
    char path[4096];
    if (spa->exportScale <= 0.0f && regionW == imgW) {
        // Full width export without scaling
        snprintf(path, sizeof(path), "%s/spectrogram_full.png", dirPath);
    } else {
        snprintf(path, sizeof(path), "%s/spectrogram_export.png", dirPath);
    }

    if (ExportImage(region, path)) {
        Platform_OfferFileToUser(path);  // desktop: no-op; web: browser download
        snprintf((char*)spa->exportMessage, sizeof(spa->exportMessage),
                 "Exported: %dx%d %.40s", region.width, region.height, GetFileName(path));
    } else {
        snprintf((char*)spa->exportMessage, sizeof(spa->exportMessage), "Export failed");
    }
    ((SpectrogramApp*)spa)->exportMessageTimer = 3.0f;

    RL_FREE(region.data);
}

// ===== Sidebar =====
void DrawSidebar(void)
{
    float scale = GetUIScale();
    float sidebarWidth = 300 * scale;
    float x = 10 * scale;
    float topMargin = 10 * scale;
    // Content is laid out top-to-bottom from `y`; shift it up by the scroll
    // offset so controls that overflow a short window can be reached. Clicks and
    // sliders track automatically since their rects are derived from `y`.
    float y = topMargin - app.sidebarScroll;
    int fontSize = (int)(12 * scale);
    bool needsRegen = false;

    // Dark sidebar background (fixed; does not scroll)
    DrawRectangle(0, 0, (int)(sidebarWidth + 20 * scale), GetScreenHeight(), (Color){ 35, 35, 40, 255 });
    DrawLine((int)(sidebarWidth + 10 * scale), 0, (int)(sidebarWidth + 10 * scale), GetScreenHeight(), GRAY);
    
    // Title
    DrawTextScaled("Spectrogram Controls", x, y, 16, WHITE); y += 28 * scale;

    // FFT Size clicker
    DrawTextScaled(TextFormat("FFT: %d (%.1f Hz/bin)", app.fftSize, (float)app.signal.sampleRate / app.fftSize), x, y, 14, LIGHTGRAY); y += 20 * scale;
    Rectangle fftMinus = { x, y, 30 * scale, 25 * scale };
    Rectangle fftPlus = { x + sidebarWidth - 40 * scale, y, 30 * scale, 25 * scale };
    if (Clicked(fftMinus)) {
        int newFFT = app.fftSize / 2;
        if (newFFT >= FFT_SIZE_MIN) {
            ChangeFFTSize(newFFT);
        }
    }
    if (Clicked(fftPlus)) {
        int newFFT = app.fftSize * 2;
        if (newFFT <= FFT_SIZE_MAX) {
            ChangeFFTSize(newFFT);
        }
    }
    DrawPanelBox(fftMinus, (Color){ 50, 50, 60, 255 }, GRAY);
    DrawTextScaled("-", fftMinus.x + 12 * scale, fftMinus.y + 5 * scale, 18, WHITE);
    DrawPanelBox(fftPlus, (Color){ 50, 50, 60, 255 }, GRAY);
    DrawTextScaled("+", fftPlus.x + 10 * scale, fftPlus.y + 5 * scale, 18, WHITE);
    y += 32 * scale;

    // Amplitude scale mode toggle (Relative dynamic range vs Absolute dBFS)
    Rectangle scaleBtn = { x, y, sidebarWidth - 10 * scale, 22 * scale };
    if (Clicked(scaleBtn)) {
        app.amplitudeMode = (app.amplitudeMode == SCALE_RELATIVE) ? SCALE_ABSOLUTE : SCALE_RELATIVE;
        AutoScaleAmplitude(&app.stft);  // re-derive floor/ceiling for the new mode
        needsRegen = true;
    }
    DrawPanelBox(scaleBtn, (Color){ 50, 50, 60, 255 }, GRAY);
    DrawTextScaled(app.amplitudeMode == SCALE_ABSOLUTE ? "Scale: Absolute (dBFS)" : "Scale: Relative",
                   scaleBtn.x + 8 * scale, scaleBtn.y + 4 * scale, 13, WHITE);
    y += 30 * scale;

    // Floor control — meaning depends on the scale mode.
    Rectangle dbSlider = { x, y + 20 * scale, sidebarWidth - 10 * scale, 20 * scale };
    if (app.amplitudeMode == SCALE_ABSOLUTE) {
        DrawTextScaled(TextFormat("Floor: %.1f dBFS", app.absoluteFloorDb), x, y, 14, LIGHTGRAY);
        float dbValue = (app.absoluteFloorDb + 100.0f) / 100.0f;  // -100..0 dBFS -> 0..1
        DrawSlider(dbSlider, dbValue);
        if (UpdateSlider(dbSlider, &dbValue)) {
            app.absoluteFloorDb = -100.0f + dbValue * 100.0f;
            app.amplitudeCeilingDb = 0.0f;
            app.amplitudeFloorDb = app.absoluteFloorDb;
            needsRegen = true;
        }
    } else {
        DrawTextScaled(TextFormat("Dyn Range: %.0f dB", app.dynRangeDb), x, y, 14, LIGHTGRAY);
        float dbValue = (100.0f - app.dynRangeDb) / 90.0f;  // 100..10 dB -> 0..1 (right = more contrast)
        DrawSlider(dbSlider, dbValue);
        if (UpdateSlider(dbSlider, &dbValue)) {
            app.dynRangeDb = 100.0f - dbValue * 90.0f;
            app.amplitudeFloorDb = app.amplitudeCeilingDb - app.dynRangeDb;
            needsRegen = true;
        }
    }
    y += 48 * scale;

    // Colormap dropdown
    DrawTextScaled("Colormap:", x, y, 14, LIGHTGRAY); y += 20 * scale;
    Rectangle cmapButton = { x, y, sidebarWidth - 10 * scale, 25 * scale };
    if (Clicked(cmapButton)) {
        app.colormap = (ColormapType)((app.colormap + 1) % COLORMAP_COUNT);
        GenerateColormapTexture();
        needsRegen = true;
    }
    DrawPanelBox(cmapButton, (Color){ 50, 50, 60, 255 }, GRAY);
    DrawTextScaled(ColormapName(app.colormap), cmapButton.x + 10 * scale, cmapButton.y + 6 * scale, 14, WHITE);
    DrawTexturePro(colormapTexture, (Rectangle){ 0, 0, 256, 1 },
                   (Rectangle){ cmapButton.x + cmapButton.width - 60 * scale, cmapButton.y + 5 * scale, 50 * scale, 15 * scale },
                   (Vector2){ 0, 0 }, 0.0f, WHITE);
    y += 32 * scale;

    // Grid toggle
    Rectangle gridCheck = { x, y, 18 * scale, 18 * scale };
    if (Clicked(gridCheck)) {
        app.showGrid = !app.showGrid;
    }
    DrawPanelBox(gridCheck, app.showGrid ? BLUE : DARKGRAY, WHITE);
    DrawTextScaled("Show Grid", x + 25 * scale, y + 2 * scale, 14, LIGHTGRAY); y += 28 * scale;

    // Display freq crop — only meaningful when a signal is loaded.
    if (app.loaded && app.signal.sampleRate > 0) {
        float nyq = app.signal.sampleRate * 0.5f;
        float curr = EffectiveMaxFreqHz();
        DrawTextScaled(TextFormat("Display max: %.0f Hz", curr), x, y, 13, LIGHTGRAY);
        // "Auto" button to re-run the auto-crop heuristic (annotations first,
        // then energy). Same logic that runs once on file load.
        Rectangle autoBtn = { x + sidebarWidth - 58 * scale, y - 2 * scale,
                              48 * scale, 16 * scale };
        if (Clicked(autoBtn)) ApplyAutoCrop();
        DrawPanelBox(autoBtn, (Color){ 60, 50, 80, 255 }, (Color){ 160, 130, 200, 255 });
        DrawTextScaled("auto", autoBtn.x + 12 * scale, autoBtn.y + 2 * scale, 10, WHITE);
        y += 18 * scale;
        Rectangle cropSlider = { x, y, sidebarWidth - 10 * scale, 14 * scale };
        float frac = curr / nyq;
        DrawSlider(cropSlider, frac);
        if (UpdateSlider(cropSlider, &frac)) {
            // Don't let the user crop to a useless sliver — keep at least
            // ~2% of the band visible (250 Hz on a 12 kHz file).
            if (frac < 0.02f) frac = 0.02f;
            app.displayMaxFreqHz = frac * nyq;
            // Reset view to show the full cropped range — otherwise a prior
            // zoom-into-half-band would magnify even further after a crop.
            app.view.freqStart = 0.0f;
            app.view.freqEnd = 1.0f;
            app.visibleTextureValid = false;
        }
        y += 22 * scale;
    }

    // Analysis toggles: marker/ruler tool and spectrum-slice (PSD) panel.
    Rectangle markerBtn = { x, y, sidebarWidth - 10 * scale, 24 * scale };
    if (Clicked(markerBtn)) app.markerMode = !app.markerMode;
    DrawPanelBox(markerBtn, app.markerMode ? (Color){ 110, 70, 30, 255 } : (Color){ 50, 50, 60, 255 },
                 app.markerMode ? ORANGE : GRAY);
    DrawTextScaled(app.markerMode ? "Marker Tool: ON (M)" : "Marker Tool (M)",
                   markerBtn.x + 10 * scale, markerBtn.y + 5 * scale, 13, WHITE);
    y += 28 * scale;

    Rectangle specBtn = { x, y, sidebarWidth - 10 * scale, 24 * scale };
    if (Clicked(specBtn)) app.showSpectrum = !app.showSpectrum;
    DrawPanelBox(specBtn, app.showSpectrum ? (Color){ 30, 70, 90, 255 } : (Color){ 50, 50, 60, 255 },
                 app.showSpectrum ? SKYBLUE : GRAY);
    DrawTextScaled(app.showSpectrum ? "Spectrum: ON (S)" : "Spectrum Slice (S)",
                   specBtn.x + 10 * scale, specBtn.y + 5 * scale, 13, WHITE);
    y += 30 * scale;

    // ---- mLnL annotations panel — only when the loaded file has any ----
    if (app.annotations.loaded && app.annotations.eventCount > 0) {
        // Left half = master toggle. Right edge = small expand chevron that
        // reveals per-kind checkboxes. The chevron is also clickable when the
        // master toggle is OFF so the user can configure what to show before
        // re-enabling overlays.
        Rectangle annBtn = { x, y, (sidebarWidth - 10 * scale) - 28 * scale, 24 * scale };
        Rectangle annExp = { annBtn.x + annBtn.width + 2 * scale, y, 26 * scale, 24 * scale };
        if (Clicked(annBtn)) app.showAnnotations = !app.showAnnotations;
        if (Clicked(annExp)) app.annotationsExpanded = !app.annotationsExpanded;

        DrawPanelBox(annBtn,
                     app.showAnnotations ? (Color){ 70, 40, 90, 255 } : (Color){ 50, 50, 60, 255 },
                     app.showAnnotations ? (Color){ 200, 160, 255, 255 } : GRAY);
        DrawTextScaled(app.showAnnotations ? "Annotations: ON" : "Annotations: off",
                       annBtn.x + 10 * scale, annBtn.y + 5 * scale, 13, WHITE);
        DrawPanelBox(annExp, (Color){ 50, 50, 60, 255 }, GRAY);
        DrawTextScaled(app.annotationsExpanded ? "v" : ">",
                       annExp.x + 9 * scale, annExp.y + 5 * scale, 13, WHITE);
        y += 28 * scale;

        if (app.annotationsExpanded) {
            // Two opacity sliders: the spectrogram overlay is drawn at the
            // "Base" alpha by default, and bumps to "Highlight" for any event
            // that's hovered or selected in the timeline lane. Outlines and
            // labels scale together with the fill so the whole overlay fades
            // as one unit.
            DrawTextScaled(TextFormat("Base: %d%%", (int)(app.annotationOpacityBase * 100.0f)),
                           x + 8 * scale, y, 12, LIGHTGRAY);
            y += 16 * scale;
            Rectangle baseSlider = { x + 8 * scale, y, sidebarWidth - 26 * scale, 12 * scale };
            DrawSlider(baseSlider, app.annotationOpacityBase);
            UpdateSlider(baseSlider, &app.annotationOpacityBase);
            y += 20 * scale;

            DrawTextScaled(TextFormat("Highlight: %d%%", (int)(app.annotationOpacityHover * 100.0f)),
                           x + 8 * scale, y, 12, LIGHTGRAY);
            y += 16 * scale;
            Rectangle hovSlider = { x + 8 * scale, y, sidebarWidth - 26 * scale, 12 * scale };
            DrawSlider(hovSlider, app.annotationOpacityHover);
            UpdateSlider(hovSlider, &app.annotationOpacityHover);
            y += 22 * scale;

            // Indented checkbox per kind actually present in this file. Order:
            // walk the enum so the menu order is stable across files.
            for (int k = 0; k < MLNL_KIND_MAX; k++) {
                if (!app.annotations.kindPresent[k]) continue;
                Rectangle cb = { x + 8 * scale, y + 2 * scale, 14 * scale, 14 * scale };
                if (Clicked(cb)) app.annotationKindEnabled[k] = !app.annotationKindEnabled[k];
                DrawPanelBox(cb, app.annotationKindEnabled[k] ? (Color){ 140, 100, 200, 255 } : DARKGRAY,
                             LIGHTGRAY);
                DrawTextScaled(MlnlKindName((MlnlKind)k),
                               cb.x + 22 * scale, cb.y - 1 * scale, 12, LIGHTGRAY);
                y += 18 * scale;
            }
            y += 4 * scale;
        }
        if (app.annotations.truncated) {
            DrawTextScaled("(file truncated)", x + 4 * scale, y, 11,
                           (Color){ 255, 180, 180, 255 });
            y += 16 * scale;
        }
        y += 6 * scale;
    }

    // File loading
    DrawTextScaled("File:", x, y, 14, LIGHTGRAY); y += 20 * scale;
    Rectangle fileButton = { x, y, sidebarWidth - 10 * scale, 25 * scale };
    if (Clicked(fileButton)) {
        app.showFileBrowser = true;
        ScanDirectory(GetWorkingDirectory());
    }
    DrawPanelBox(fileButton, (Color){ 50, 50, 60, 255 }, GRAY);
    DrawTextScaled("Open File Browser (O)", fileButton.x + 10 * scale, fileButton.y + 6 * scale, 14, WHITE);
    y += 38 * scale;

    // Playback
    Rectangle playButton = { x, y, sidebarWidth - 10 * scale, 35 * scale };
    if (Clicked(playButton)) {
        if (app.isPlaying && AudioPlaybackSound.frameCount > 0) {
            StopSound(AudioPlaybackSound);
            app.isPlaying = false;
            app.playheadElapsed = 0;
            app.playheadT = 0;
        } else {
            PlaySelectedRegion();
            app.isPlaying = true;
        }
    }
    const char* playText = app.isPlaying ? "STOP (SPACE)" : "PLAY (SPACE)";
    DrawPanelBox(playButton, app.isPlaying ? (Color){ 120, 40, 40, 255 } : (Color){ 40, 100, 40, 255 },
                 app.isPlaying ? RED : GREEN);
    DrawTextScaled(playText, playButton.x + 10 * scale, playButton.y + 12 * scale, 14, WHITE);
    y += 48 * scale;

    // Fullscreen toggle
    Rectangle fsButton = { x, y, sidebarWidth - 10 * scale, 25 * scale };
    bool isFullscreen = IsWindowFullscreen();
    if (Clicked(fsButton)) {
        ToggleFullscreen();
    }
    DrawPanelBox(fsButton, isFullscreen ? (Color){ 40, 80, 120, 255 } : (Color){ 50, 50, 60, 255 }, GRAY);
    DrawTextScaled(isFullscreen ? "Exit Fullscreen (F11)" : "Fullscreen (F11)", fsButton.x + 10 * scale, fsButton.y + 6 * scale, 14, WHITE);
    y += 38 * scale;

    // Clear selection (identical to the Esc key)
    Rectangle clearButton = { x, y, sidebarWidth - 10 * scale, 25 * scale };
    if (Clicked(clearButton)) {
        ClearSelection();
    }
    DrawPanelBox(clearButton, (Color){ 80, 50, 50, 255 }, RED);
    DrawTextScaled("Clear Selection (ESC)", clearButton.x + 10 * scale, clearButton.y + 6 * scale, 14, WHITE);
    y += 38 * scale;

    // Export PNG
    {
        Rectangle exportButton = { x, y, sidebarWidth - 10 * scale, 30 * scale };
        if (Clicked(exportButton)) {
            ExportPNG(&app, app.exportDir);
        }
        DrawPanelBox(exportButton, (Color){ 40, 60, 80, 255 }, CYAN);
        DrawTextScaled("Export PNG (E)", exportButton.x + 10 * scale, exportButton.y + 7 * scale, 14, WHITE);
        y += 35 * scale;

        // Export selection audio (band-limited, time-cropped) as a WAV
        Rectangle wavButton = { x, y, sidebarWidth - 10 * scale, 30 * scale };
        if (Clicked(wavButton)) {
            ExportSelectionWAV(app.exportDir);
        }
        DrawPanelBox(wavButton, (Color){ 40, 60, 80, 255 }, CYAN);
        DrawTextScaled("Export WAV (W)", wavButton.x + 10 * scale, wavButton.y + 7 * scale, 14, WHITE);
        y += 35 * scale;

        // Export scale slider
        DrawTextScaled(TextFormat("Export Scale: %.1fx", app.exportScale), x, y, 12, LIGHTGRAY); y += 18 * scale;
        Rectangle scaleSlider = { x, y, sidebarWidth - 10 * scale, 14 * scale };
        DrawSlider(scaleSlider, app.exportScale / 10.0f);
        float scaleVal = 0.0f;
        if (UpdateSlider(scaleSlider, &scaleVal)) {
            app.exportScale = scaleVal * 10.0f;
        }
        y += 24 * scale;
    }

    // Signal info
    DrawTextScaled("Signal Info:", x, y, 14, LIGHTGRAY); y += 20 * scale;
    if (app.loaded) {
        DrawTextScaled(TextFormat("Sample Rate: %d Hz", app.signal.sampleRate), x, y, 14, GRAY); y += 18 * scale;
        DrawTextScaled(TextFormat("Duration: %.2f sec", app.signal.duration), x, y, 14, GRAY); y += 18 * scale;
        DrawTextScaled(TextFormat("Max Freq: %.1f kHz", (float)app.signal.sampleRate / 2000.0f), x, y, 14, GRAY); y += 18 * scale;
    } else {
        DrawTextScaled("No file loaded", x, y, 14, GRAY); y += 18 * scale;
    }
    y += 10 * scale;

    // About / Help button
    Rectangle aboutBtn = { x, y, sidebarWidth - 10 * scale, 24 * scale };
    if (Clicked(aboutBtn)) {
        app.showAbout = true;
    }
    DrawPanelBox(aboutBtn, (Color){ 50, 50, 60, 255 }, GRAY);
    DrawTextScaled("About / Help  (F1)", aboutBtn.x + 8 * scale, aboutBtn.y + 5 * scale, 13, WHITE);
    y += 30 * scale;

    if (needsRegen && app.stftComputed) {
        // dB floor / colormap only — re-map the cached reassignment, don't recompute it.
        ColorizeSpectrogram(&app.spectrogramImage, &app.spectrogramTexture);
        app.visibleTextureValid = false;  // Force cache invalidation
    }

    // ---- Scroll handling ----
    // `y` now sits at the bottom of the laid-out content (in screen space).
    // Its natural position (scroll == 0) is y + sidebarScroll; allow scrolling
    // until that bottom, plus a small pad, reaches the window's bottom edge.
    float contentBottom = y + app.sidebarScroll;
    float maxScroll = contentBottom + topMargin - GetScreenHeight();
    if (maxScroll < 0) maxScroll = 0;

    // Wheel scrolls the sidebar only when the cursor is over it and no modal is
    // up. The spectrogram's own wheel-zoom already ignores the sidebar column,
    // so the two never both fire.
    bool overSidebar = GetMousePosition().x < (sidebarWidth + 20 * scale);
    if (overSidebar && !UiModalOpen()) {
        float wheel = GetMouseWheelMove();
        if (wheel != 0.0f) app.sidebarScroll -= wheel * 40 * scale;
    }
    if (app.sidebarScroll > maxScroll) app.sidebarScroll = maxScroll;
    if (app.sidebarScroll < 0.0f) app.sidebarScroll = 0.0f;
}

static void DrawSlider(Rectangle bounds, float value)
{
    // Background
    DrawRectangleRec(bounds, DARKGRAY);
    DrawRectangleLinesEx(bounds, 1, GRAY);
    
    // Fill
    float fillWidth = (bounds.width - 4) * value;
    DrawRectangle(bounds.x + 2, bounds.y + 2, fillWidth, bounds.height - 4, BLUE);
    
    // Handle
    float handleX = bounds.x + 2 + fillWidth;
    DrawRectangle(handleX - 3, bounds.y + 1, 6, bounds.height - 2, WHITE);
}

static bool UpdateSlider(Rectangle bounds, float* value)
{
    bool changed = false;
    if (CheckCollisionPointRec(GetMousePosition(), bounds) && IsMouseButtonDown(MOUSE_LEFT_BUTTON)) {
        float newValue = (GetMousePosition().x - bounds.x - 2) / (bounds.width - 4);
        newValue = Clamp(newValue, 0.0f, 1.0f);
        if (fabsf(newValue - *value) > 0.001f) {
            *value = newValue;
            changed = true;
        }
    }
    return changed;
}

// ===== About / Help dialog =====

void DrawAboutDialog(void)
{
    if (!app.showAbout) return;

    float scale = GetUIScale();
    int sw = GetScreenWidth();
    int sh = GetScreenHeight();

    // Dim the background; a click outside the panel (or Esc/F1) closes it.
    DrawRectangle(0, 0, sw, sh, Fade(BLACK, 0.7f));

    float pw = 560 * scale;
    float ph = 540 * scale;
    Rectangle panel = { (sw - pw) / 2, (sh - ph) / 2, pw, ph };
    DrawRectangleRec(panel, (Color){ 30, 30, 38, 255 });
    DrawRectangleLinesEx(panel, 2, (Color){ 90, 90, 110, 255 });

    float px = panel.x + 24 * scale;
    float py = panel.y + 20 * scale;

    DrawTextScaled("rspektrum - synchrosqueezed spectrogram viewer", px, py, 18, WHITE);
    py += 34 * scale;

    // Body lines. NULL = blank spacer; headings start with no leading spaces.
    const char* lines[] = {
        "Amplitude scale",
        "  Relative: ceiling tracks the signal peak; the slider sets how many",
        "    dB of range are shown below it (good for seeing structure).",
        "  Absolute: fixed dBFS scale, 0 dBFS = digital full scale (sample = 1.0).",
        "    Brightness then reflects a real level, comparable across files.",
        "",
        "Why dBFS and not dBm",
        "  A WAV carries no power reference (impedance / receiver calibration),",
        "  so absolute levels are shown in dBFS, not dBm. 0 dBFS = full scale.",
        "",
        "Accuracy caveats",
        "  - The Hann window adds a fixed ~6 dB coherent-gain offset to",
        "    spectral magnitudes.",
        "  - Pixels show synchrosqueezed (reassigned) energy, not raw FFT bins.",
        "  Good to ~a dB for visualization; not lab-grade metrology.",
    };
    int n = sizeof(lines) / sizeof(lines[0]);
    for (int i = 0; i < n; i++) {
        const char* s = lines[i];
        bool heading = (s[0] != '\0' && s[0] != ' ');
        DrawTextScaled(s, px, py, heading ? 14 : 13, heading ? (Color){ 150, 200, 255, 255 } : LIGHTGRAY);
        py += (s[0] == '\0' ? 8 : 18) * scale;
    }

    // Keys — rendered straight from the keymap table so this list can never
    // drift from the actual bindings.
    py += 10 * scale;
    DrawTextScaled("Keys", px, py, 14, (Color){ 150, 200, 255, 255 });
    py += 20 * scale;
    int kn;
    const KeyBinding* km = GetKeymap(&kn);
    for (int i = 0; i < kn; i++) {
        DrawTextScaled(TextFormat("  %-5s %s", km[i].label, km[i].help), px, py, 13, LIGHTGRAY);
        py += 16 * scale;
    }
    DrawTextScaled("  Mouse  wheel = zoom, Alt+drag = pan, drag = select box",
                   px, py, 13, LIGHTGRAY);

    DrawTextScaled("F1 / Esc / click to close", panel.x + pw - 196 * scale,
                   panel.y + ph - 26 * scale, 12, GRAY);
    // Note: opening/closing is handled in the main input loop (not here) so the
    // same click/keypress that opens the dialog can't immediately close it.
}

// ===== Auto-crop notice splash =====
//
// Raised by ApplyAutoCrop when the view actually shrank. Modal: routes
// through UiModalOpen() so the spectrogram doesn't accept clicks underneath.
// Two buttons:
//   [Uncrop] — restore full freq axis and full-duration view
//   [OK]     — dismiss; keep the cropped view
// Esc closes (= OK). Clicks outside the panel do nothing (avoids losing the
// crop by missing a button by a few px).
void DrawAutocropNotice(void)
{
    if (!app.autocropNoticeActive) return;
    float scale = GetUIScale();
    int sw = GetScreenWidth();
    int sh = GetScreenHeight();

    DrawRectangle(0, 0, sw, sh, Fade(BLACK, 0.55f));

    float pw = 460 * scale;
    float ph = 170 * scale;
    Rectangle panel = { (sw - pw) * 0.5f, (sh - ph) * 0.5f, pw, ph };
    DrawRectangleRec(panel, (Color){ 30, 30, 40, 255 });
    DrawRectangleLinesEx(panel, 2, (Color){ 160, 130, 200, 255 });

    DrawTextScaled("Auto-crop applied",
                   panel.x + 20 * scale, panel.y + 16 * scale, 16,
                   (Color){ 200, 170, 240, 255 });

    // Body: word-wrap not needed for the short message ApplyAutoCrop builds.
    DrawTextScaled(app.autocropNoticeMsg,
                   panel.x + 20 * scale, panel.y + 50 * scale, 13, LIGHTGRAY);

    float btnW = 110 * scale, btnH = 32 * scale;
    float btnY = panel.y + ph - btnH - 16 * scale;
    Rectangle uncropBtn = { panel.x + 20 * scale,           btnY, btnW, btnH };
    Rectangle okBtn     = { panel.x + pw - btnW - 20*scale, btnY, btnW, btnH };

    bool uncropHover = CheckCollisionPointRec(GetMousePosition(), uncropBtn);
    bool okHover     = CheckCollisionPointRec(GetMousePosition(), okBtn);

    DrawPanelBox(uncropBtn,
                 uncropHover ? (Color){ 100, 60, 60, 255 } : (Color){ 70, 40, 40, 255 },
                 (Color){ 230, 160, 160, 255 });
    DrawTextScaled("Uncrop",
                   uncropBtn.x + btnW * 0.5f - MeasureTextScaled("Uncrop", 14) * 0.5f,
                   uncropBtn.y + 8 * scale, 14, WHITE);

    DrawPanelBox(okBtn,
                 okHover ? (Color){ 60, 90, 60, 255 } : (Color){ 40, 70, 40, 255 },
                 (Color){ 160, 220, 160, 255 });
    DrawTextScaled("OK",
                   okBtn.x + btnW * 0.5f - MeasureTextScaled("OK", 14) * 0.5f,
                   okBtn.y + 8 * scale, 14, WHITE);

    DrawTextScaled("Esc or Enter = OK", panel.x + pw - 156 * scale,
                   panel.y + ph - 14 * scale, 10, GRAY);

    // Hit handling. Esc/Enter dismiss (keep crop). Uncrop restores full view.
    if (IsKeyPressed(KEY_ESCAPE) || IsKeyPressed(KEY_ENTER)) {
        app.autocropNoticeActive = false;
    }
    if (uncropHover && IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) {
        app.displayMaxFreqHz = 0.0f;
        app.view.start = 0.0f; app.view.end = 1.0f;
        app.view.freqStart = 0.0f; app.view.freqEnd = 1.0f;
        app.visibleTextureValid = false;
        app.autocropNoticeActive = false;
    } else if (okHover && IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) {
        app.autocropNoticeActive = false;
    }
}
