// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
//
// In-app, Material-styled image picker. A modal overlay that browses the filesystem and lets the
// user pick an image file, shown as a thumbnail grid. Used by the Contacts edit dialog to choose a
// custom contact avatar. Like FolderPicker, only one overlay renders at a time (the framework does
// not nest), so the caller suppresses its own overlay while ImagePicker::isOpen().
//
// Thumbnails are decoded to raw pixels, box-downscaled to a small texture, and cached per directory
// (destroyed on navigate/close) so browsing a Pictures folder full of large photos stays cheap.

#pragma once

#include <algorithm>
#include <atomic>
#include <cctype>
#include <cmath>
#include <cstdint>
#include <filesystem>
#include <functional>
#include <memory>
#include <string>
#include <thread>
#include <unordered_map>
#include <vector>

#include "imgui.h"

#include "../../util/i18n.h"
#include "../../util/platform.h"
#include "../../util/texture_loader.h"
#include "../../embedded/IconsMaterialDesign.h"
#include "../layout.h"
#include "../material/colors.h"
#include "../material/draw_helpers.h"
#include "../material/type.h"

namespace dragonx {
namespace ui {

class ImagePicker {
public:
    // Open the picker starting at `startDir` (falls back to the user's Pictures dir, then home, if
    // empty/invalid). `onPick` receives the chosen absolute image path when the user confirms.
    static void open(const std::string& startDir, std::function<void(const std::string&)> onPick) {
        namespace fs = std::filesystem;
        std::error_code ec;
        std::string start = startDir;
        if (start.empty() || !fs::is_directory(fs::path(start), ec)) {
            if (!s_dir.empty() && fs::is_directory(fs::path(s_dir), ec)) start = s_dir;   // reopen last
            else start = defaultStartDir();
        }
        navigate(start);
        s_onPick = std::move(onPick);
        s_selected.clear();
        s_open = true;
    }

    static bool isOpen() { return s_open; }
    static void close()  { s_open = false; clearThumbs(); }

    static void render() {
        if (!s_open) return;
        using namespace material;
        const float dp = Layout::dpiScale();

        s_loadedThisFrame = 0;   // budget: decode at most a few thumbnails per frame (no scroll hitch)

        ImFont* rowFont  = Type().body1();
        ImFont* icoFont  = Type().iconSmall();
        ImFont* metaFont = Type().caption();

        const float vpH   = ImGui::GetMainViewport()->Size.y;
        const float cardH = (vpH * 0.82f) / dp;   // logical; the framework re-applies dp + caps at vp-32

        OverlayDialogSpec ov;
        ov.title      = TR("img_picker_title");
        ov.p_open     = &s_open;
        ov.style      = OverlayStyle::BlurFloat;
        ov.cardWidth  = 760.0f;
        ov.cardHeight = cardH;
        ov.idSuffix   = "imagepicker";
        if (!BeginOverlayDialog(ov)) { if (!s_open) clearThumbs(); return; }

        if (ImGui::IsKeyPressed(ImGuiKey_Escape)) s_open = false;

        auto tw = [](ImFont* f, const std::string& s){ return f->CalcTextSizeA(f->LegacySize, FLT_MAX, 0, s.c_str()).x; };
        auto fitPath = [&](std::string s, ImFont* f, float maxW){
            const std::string ell = "\xE2\x80\xA6";
            if (tw(f,s) <= maxW) return s;
            while (s.size()>1 && tw(f, ell+s) > maxW){
                s.erase(s.begin());
                while (!s.empty() && (static_cast<unsigned char>(s.front()) & 0xC0) == 0x80) s.erase(s.begin());
            }
            return ell + s;
        };
        auto fit = [&](std::string s, ImFont* f, float maxW){
            bool t=false;
            while (s.size()>1 && tw(f,s)>maxW){
                while (s.size()>1 && (static_cast<unsigned char>(s.back()) & 0xC0) == 0x80) s.pop_back();
                if (s.size()>1) s.pop_back();
                t=true;
            }
            if (t) s += "\xE2\x80\xA6";
            return s;
        };

        std::string pendingNav;

        // Inner padded body so the filled list + thumbnail grid keep a clear margin from the card's
        // rounded edges (the overlay's own content padding is tight for edge-to-edge filled content).
        ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(10.0f * dp, 8.0f * dp));
        ImGui::BeginChild("##imgPickBody", ImVec2(0, 0), ImGuiChildFlags_AlwaysUseWindowPadding,
                          ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse);

        // ---- Path bar: Up + Home + Pictures + current path strip ------------------------------
        {
            const float bh = ImGui::GetFrameHeight();
            IconButtonStyle st; st.color = OnSurfaceMedium(); st.hoverBg = StateHover();
            st.bgRounding = 5.0f * dp;
            std::filesystem::path cur(s_dir);
            const bool hasParent = cur.has_parent_path() && cur.parent_path() != cur;
            st.tooltip = TR("picker_up");
            if (IconButton("##imgUp", ICON_MD_ARROW_UPWARD, icoFont, ImVec2(bh, bh), st) && hasParent)
                pendingNav = cur.parent_path().string();
            ImGui::SameLine(0.0f, Layout::spacingXs());
            IconButtonStyle hst = st; hst.tooltip = TR("picker_home");
            if (IconButton("##imgHome", ICON_MD_HOME, icoFont, ImVec2(bh, bh), hst))
                pendingNav = util::Platform::getHomeDir();
            ImGui::SameLine(0.0f, Layout::spacingXs());
            IconButtonStyle pst = st; pst.tooltip = TR("img_picker_pictures");
            if (IconButton("##imgPics", ICON_MD_IMAGE, icoFont, ImVec2(bh, bh), pst))
                pendingNav = defaultStartDir();
            ImGui::SameLine(0.0f, Layout::spacingSm());

            ImVec2 sMin = ImGui::GetCursorScreenPos();
            const float stripW = ImGui::GetContentRegionAvail().x;
            ImVec2 sMax(sMin.x + stripW, sMin.y + bh);
            ImDrawList* wdl = ImGui::GetWindowDrawList();
            wdl->AddRectFilled(sMin, sMax, WithAlpha(OnSurface(), 14), 6.0f * dp);
            wdl->AddRect(sMin, sMax, WithAlpha(OnSurface(), 40), 6.0f * dp, 0, 1.0f);
            const float tpad = Layout::spacingSm();
            wdl->AddText(rowFont, rowFont->LegacySize,
                         ImVec2(sMin.x + tpad, sMin.y + (bh - rowFont->LegacySize) * 0.5f),
                         OnSurface(), fitPath(s_dir, rowFont, stripW - tpad * 2.0f).c_str());
            ImGui::Dummy(ImVec2(stripW, bh));
        }
        ImGui::Dummy(ImVec2(0, Layout::spacingSm()));

        // ---- Body: subfolder rows (to navigate) then a thumbnail grid of images ----------------
        const ImGuiStyle& gstyle = ImGui::GetStyle();
        const float footerBlockH = Layout::spacingXs() + metaFont->LegacySize          // status line
                                 + Layout::spacingSm() + 1.0f + Layout::spacingSm()     // separator block
                                 + ImGui::GetFrameHeight()                              // footer buttons
                                 + 6.0f * gstyle.ItemSpacing.y;
        float listH = ImGui::GetContentRegionAvail().y - footerBlockH;
        listH = std::max(listH, 160.0f * dp);
        const float fullW = ImGui::GetContentRegionAvail().x;
        // Bordered/rounded outer frame whose 6px padding insets the inner scrollbar so it clears the
        // card's rounded corners; the inner child scrolls smoothly (ApplySmoothScroll lerps the wheel).
        ImGui::PushStyleColor(ImGuiCol_ChildBg, WithAlpha(OnSurface(), 20));
        ImGui::PushStyleVar(ImGuiStyleVar_ChildRounding, 6.0f * dp);
        ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(6.0f * dp, 6.0f * dp));
        ImGui::BeginChild("##imgListFrame", ImVec2(fullW, listH), true,
                          ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse);
        ImGui::PopStyleVar(2);     // ChildRounding + outer WindowPadding (captured by the frame child)
        ImGui::PopStyleColor();    // ChildBg — the frame already drew it; the inner child stays transparent
        ImGui::PushStyleVar(ImGuiStyleVar_ScrollbarSize, 11.0f * dp);
        ImGui::PushStyleVar(ImGuiStyleVar_ScrollbarRounding, 5.5f * dp);
        ImGui::BeginChild("##imgList", ImVec2(0, 0), false, ImGuiWindowFlags_NoScrollWithMouse);
        ApplySmoothScroll();
        ImDrawList* dl = ImGui::GetWindowDrawList();
        const float padX = Layout::spacingSm();

        if (s_subdirs.empty() && s_images.empty()) {
            ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
            ImGui::Indent(padX);
            Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), TR("img_picker_empty"));
            ImGui::Unindent(padX);
        }

        // Folders: a 2-column grid of thin rounded rectangles (folder icon + name), clickable to descend.
        if (!s_subdirs.empty()) {
            const int cols = 2;
            const float cellGap = Layout::spacingSm();
            const float availW = ImGui::GetContentRegionAvail().x;
            const float cellW = (availW - cellGap * (cols - 1)) / (float)cols;
            const float cellH = rowFont->LegacySize + Layout::spacingSm() * 1.5f;
            int col = 0;
            for (std::size_t i = 0; i < s_subdirs.size(); ++i) {
                if (col != 0) ImGui::SameLine(0, cellGap);
                ImVec2 mn = ImGui::GetCursorScreenPos();
                ImVec2 mx(mn.x + cellW, mn.y + cellH);
                ImGui::PushID((int)(i + 1));
                const bool clicked = ImGui::InvisibleButton("##idir", ImVec2(cellW, cellH));
                const bool hov = ImGui::IsItemHovered();
                ImGui::PopID();
                dl->AddRectFilled(mn, mx, WithAlpha(OnSurface(), hov ? 34 : 16), 6.0f * dp);
                if (hov) { dl->AddRect(mn, mx, WithAlpha(OnSurface(), 70), 6.0f * dp, 0, 1.0f); ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); }
                const float midY = (mn.y + mx.y) * 0.5f;
                const float icoSz = rowFont->LegacySize * 1.05f;
                float x = mn.x + padX;
                dl->AddText(icoFont, icoSz, ImVec2(x, midY - icoSz * 0.5f), hov ? Primary() : OnSurfaceMedium(), ICON_MD_FOLDER);
                x += icoSz + Layout::spacingSm();
                std::string nm = fit(s_subdirs[i], rowFont, (mx.x - padX) - x);
                dl->AddText(rowFont, rowFont->LegacySize, ImVec2(x, midY - rowFont->LegacySize * 0.5f), OnSurface(), nm.c_str());
                if (clicked) pendingNav = (std::filesystem::path(s_dir) / s_subdirs[i]).string();
                col = (col + 1) % cols;
            }
            if (!s_images.empty()) ImGui::Dummy(ImVec2(0, Layout::spacingSm()));   // gap before the thumbnails
        }

        // Thumbnail grid: a fixed 6 columns whose square cells scale to fill the width.
        {
            const float availW = ImGui::GetContentRegionAvail().x;
            const float gap  = Layout::spacingSm();
            const int cols = 6;
            const float cell = std::max(24.0f * dp, (availW - gap * (cols - 1)) / (float)cols);
            int col = 0;
            for (std::size_t i = 0; i < s_images.size(); ++i) {
                if (col != 0) ImGui::SameLine(0, gap);
                ImVec2 mn = ImGui::GetCursorScreenPos();
                ImVec2 mx(mn.x + cell, mn.y + cell);
                const std::string abs = (std::filesystem::path(s_dir) / s_images[i]).string();
                const bool sel = (s_selected == abs);
                ImGui::PushID((int)(i + 1));
                const bool clicked = ImGui::InvisibleButton("##thumb", ImVec2(cell, cell));
                const bool dbl = ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left) && ImGui::IsItemHovered();
                const bool hov = ImGui::IsItemHovered();
                ImGui::PopID();

                dl->AddRectFilled(mn, mx, WithAlpha(OnSurface(), 18), 6.0f * dp);
                // Only decode thumbnails for cells actually on-screen, and only a few per frame.
                const Thumb* t = ImGui::IsRectVisible(mn, mx) ? thumbFor(abs) : nullptr;
                ImTextureID tex = t ? t->tex : 0;
                int tw = t ? t->w : 0, thh = t ? t->h : 0;
                // Hovering an animated image loads its frames in the BACKGROUND (no UI hang) and plays
                // them once ready; the still thumbnail shows meanwhile.
                if (t && hov && t->animated) {
                    Thumb& mt = s_thumbs[abs];   // t is non-null => the entry exists
                    driveThumbAnim(mt, abs);
                    if (mt.animReady && mt.frames.size() > 1 && mt.totalDur > 0.0f) {
                        s_animatingThisFrame = true;
                        double phase = std::fmod(ImGui::GetTime(), (double)mt.totalDur);
                        double acc = 0.0;
                        for (size_t k = 0; k < mt.delays.size() && k < mt.frames.size(); ++k) {
                            acc += mt.delays[k];
                            if (phase < acc) { tex = mt.frames[k]; tw = mt.aw; thh = mt.ah; break; }
                        }
                    }
                }
                if (tex) {
                    float u0=0,v0=0,u1=1,v1=1;   // centre-crop to a square
                    if (tw > thh) { float m=(tw-thh)*0.5f/tw; u0=m; u1=1-m; }
                    else if (thh > tw) { float m=(thh-tw)*0.5f/thh; v0=m; v1=1-m; }
                    const float ins = 3.0f * dp;
                    dl->AddImageRounded(tex, ImVec2(mn.x+ins, mn.y+ins), ImVec2(mx.x-ins, mx.y-ins),
                                        ImVec2(u0,v0), ImVec2(u1,v1), IM_COL32_WHITE, 5.0f * dp);
                } else {
                    ImVec2 cc(mn.x + cell*0.5f, mn.y + cell*0.5f);
                    dl->AddText(icoFont, cell*0.34f, ImVec2(cc.x - cell*0.17f, cc.y - cell*0.17f),
                                OnSurfaceDisabled(), ICON_MD_IMAGE);
                }
                // "Animated" badge (bottom-right) on GIF/WebP that move — hidden while it plays on hover.
                if (t && t->animated && !hov) {
                    float bh = std::max(13.0f * dp, cell * 0.24f);
                    float bw = bh * 1.15f;
                    ImVec2 bmax(mx.x - 4.0f * dp, mx.y - 4.0f * dp);
                    ImVec2 bmin(bmax.x - bw, bmax.y - bh);
                    dl->AddRectFilled(bmin, bmax, IM_COL32(0, 0, 0, 180), 4.0f * dp);
                    float gsz = bh * 0.92f;
                    ImVec2 gs = icoFont->CalcTextSizeA(gsz, FLT_MAX, 0, ICON_MD_PLAY_ARROW);
                    dl->AddText(icoFont, gsz,
                                ImVec2((bmin.x + bmax.x) * 0.5f - gs.x * 0.5f, (bmin.y + bmax.y) * 0.5f - gs.y * 0.5f),
                                IM_COL32(255, 255, 255, 235), ICON_MD_PLAY_ARROW);
                }
                if (sel) dl->AddRect(mn, mx, WithAlpha(Primary(), 220), 6.0f * dp, 0, 2.0f * dp);
                else if (hov) { dl->AddRect(mn, mx, WithAlpha(OnSurface(), 90), 6.0f * dp, 0, 1.5f * dp); ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); }
                if (hov) material::Tooltip("%s", s_images[i].c_str());

                if (dbl && s_onPick) { s_onPick(abs); s_open = false; }
                else if (clicked) s_selected = abs;

                col = (col + 1) % cols;
            }
        }

        ImGui::EndChild();       // inner scrolling list
        ImGui::PopStyleVar(2);   // ScrollbarSize + ScrollbarRounding
        ImGui::EndChild();       // outer frame

        // ---- Status ----------------------------------------------------------------------------
        ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
        if (!s_images.empty()) {
            char buf[96];
            snprintf(buf, sizeof(buf), TR("img_picker_count"), (int)s_images.size());
            Type().textColored(TypeStyle::Caption, OnSurfaceMedium(), buf);
        } else {
            Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), TR("img_picker_none"));
        }

        // ---- Footer: Use image / Cancel (centered) ---------------------------------------------
        ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
        {
            const float useW = 200.0f * dp, cancelW = 120.0f * dp;
            const float total = useW + cancelW + ImGui::GetStyle().ItemSpacing.x;
            ImGui::SetCursorPosX(ImGui::GetCursorPosX() +
                                 std::max(0.0f, (ImGui::GetContentRegionAvail().x - total) * 0.5f));
            ImGui::BeginDisabled(s_selected.empty());
            if (StyledButton(TR("img_picker_use"), ImVec2(useW, 0))) {
                if (s_onPick && !s_selected.empty()) s_onPick(s_selected);
                s_open = false;
            }
            ImGui::EndDisabled();
            ImGui::SameLine();
            if (StyledButton(TR("cancel"), ImVec2(cancelW, 0))) s_open = false;
        }

        ImGui::EndChild();       // ##imgPickBody
        ImGui::PopStyleVar();    // WindowPadding
        EndOverlayDialog();

        if (!pendingNav.empty()) navigate(pendingNav);
        if (!s_open) clearThumbs();   // release GL textures the frame the picker dismisses
    }

public:
    // Clear-on-read: true if an animated thumbnail was playing this frame (mouse over it), so the render
    // loop keeps drawing while a hover-preview animates. Consumed by ConsumeContactsAvatarAnimation.
    static bool consumeAnimationActive() { bool v = s_animatingThisFrame; s_animatingThisFrame = false; return v; }

private:
    // Background decode job: a detached worker fills `result` off the UI thread and sets `done`. The
    // shared_ptr keeps it alive if the owning Thumb is destroyed mid-decode (no blocking on navigate).
    struct AnimJob {
        std::atomic<bool> done{false};
        util::AnimFrames result;
    };

    struct Thumb {
        ImTextureID tex = 0; int w = 0, h = 0; bool tried = false;   // static frame-0 thumbnail
        bool animated = false;                                        // multi-frame GIF/WebP (badge)
        // Async animation load (kicked off on hover): the full frame decode runs on a worker thread,
        // then frames are uploaded to textures a few per UI frame — so hovering never blocks the UI.
        bool animRequested = false, staged = false, animReady = false;
        std::shared_ptr<AnimJob> job;
        util::AnimFrames pending;                                     // decoded frames awaiting GPU upload
        size_t uploaded = 0;
        std::vector<ImTextureID> frames;
        std::vector<float> delays; int aw = 0, ah = 0; float totalDur = 0.0f;
    };

    // Drive the async animation load for a hovered thumbnail: (1) kick off a background decode once,
    // (2) move its result to a staging buffer when done, (3) upload a few frames per UI frame. Never
    // blocks — the static thumbnail keeps showing until the sequence is fully uploaded.
    static void driveThumbAnim(Thumb& th, const std::string& path) {
        if (th.animReady) return;
        if (!th.animRequested) {
            if (s_animInFlight->load() >= kMaxAnimInFlight) return;   // cap workers; retry a later frame
            th.animRequested = true;
            th.job = std::make_shared<AnimJob>();
            s_animInFlight->fetch_add(1);
            std::shared_ptr<AnimJob> job = th.job;
            std::shared_ptr<std::atomic<int>> inflight = s_animInFlight;   // co-owned: safe past teardown
            std::string p = path;
            std::thread([job, inflight, p]() {
                util::LoadAnimatedRGBA(p.c_str(), kThumbMaxFrames, job->result);
                job->done.store(true, std::memory_order_release);
                inflight->fetch_sub(1);
            }).detach();
            return;
        }
        if (th.job && th.job->done.load(std::memory_order_acquire)) {
            th.pending = std::move(th.job->result);
            th.job.reset();
            if (th.pending.frames.size() <= 1) {                     // turned out not to animate
                th.animated = false; th.pending = util::AnimFrames{}; th.animReady = true; return;
            }
            th.staged = true;
        }
        if (th.staged) {
            s_animatingThisFrame = true;                             // keep redrawing so the upload progresses
            int budget = 4;
            while (th.uploaded < th.pending.frames.size() && budget-- > 0) {
                ImTextureID t = 0;
                if (util::CreateRawTexture(th.pending.frames[th.uploaded].data(), th.pending.w, th.pending.h, false, &t)) {
                    th.frames.push_back(t);
                    th.delays.push_back(th.uploaded < th.pending.delaysSec.size() ? th.pending.delaysSec[th.uploaded] : 0.1f);
                }
                th.uploaded++;
            }
            if (th.uploaded >= th.pending.frames.size()) {
                th.aw = th.pending.w; th.ah = th.pending.h;
                for (float d : th.delays) th.totalDur += d;
                th.pending = util::AnimFrames{};
                th.animReady = true;
            }
        }
    }

    static bool isImageName(const std::string& name) {
        auto lower = name;
        for (char& c : lower) c = (char)std::tolower((unsigned char)c);
        static const char* kExt[] = { ".png", ".jpg", ".jpeg", ".bmp", ".gif", ".webp",
                                      ".tga", ".psd", ".pnm", ".ppm", ".pgm", ".pic" };
        for (const char* e : kExt) {
            const std::string ext(e);
            if (lower.size() > ext.size() && lower.compare(lower.size()-ext.size(), ext.size(), ext) == 0)
                return true;
        }
        return false;
    }

    // Only these can hold multiple frames in our decoders; skip the hover re-decode for other formats.
    static bool isAnimatableName(const std::string& name) {
        auto lo = name;
        for (char& c : lo) c = (char)std::tolower((unsigned char)c);
        auto ends = [&](const char* e){ std::string s(e); return lo.size()>s.size() && lo.compare(lo.size()-s.size(), s.size(), s)==0; };
        return ends(".gif") || ends(".webp");
    }

    static std::string defaultStartDir() {
        namespace fs = std::filesystem;
        std::error_code ec;
        const std::string home = util::Platform::getHomeDir();
        fs::path pics = fs::path(home) / "Pictures";
        if (fs::is_directory(pics, ec)) return pics.string();
        return home;
    }

    // Lazy, budgeted thumbnail loader: decodes to raw pixels, box-downscales to <=THUMB px, uploads a
    // small texture. Returns null (placeholder icon shown) until the budget lets it load.
    static const Thumb* thumbFor(const std::string& absPath) {
        auto it = s_thumbs.find(absPath);
        if (it != s_thumbs.end()) return &it->second;
        if (s_loadedThisFrame >= kLoadsPerFrame) return nullptr;   // defer to a later frame
        s_loadedThisFrame++;
        Thumb th; th.tried = true;
        // Cheap animation probe (header/structure only) so animated items can show a badge without
        // decoding all frames; only .gif/.webp can be animations in our decoders.
        if (isAnimatableName(absPath)) th.animated = util::IsAnimatedImageFile(absPath.c_str());
        int sw = 0, sh = 0;
        unsigned char* raw = util::LoadRawPixelsFromFile(absPath.c_str(), &sw, &sh);
        if (raw && sw > 0 && sh > 0) {
            int dw = sw, dh = sh;
            if (sw > kThumbPx || sh > kThumbPx) {
                float s = (float)kThumbPx / (float)std::max(sw, sh);
                dw = std::max(1, (int)(sw * s));
                dh = std::max(1, (int)(sh * s));
            }
            std::vector<unsigned char> small((size_t)dw * dh * 4);
            for (int y = 0; y < dh; y++) {
                int sy0 = y * sh / dh, sy1 = std::max(sy0 + 1, (y + 1) * sh / dh);
                for (int x = 0; x < dw; x++) {
                    int sx0 = x * sw / dw, sx1 = std::max(sx0 + 1, (x + 1) * sw / dw);
                    uint32_t r=0,g=0,b=0,a=0,n=0;
                    for (int yy = sy0; yy < sy1; yy++)
                        for (int xx = sx0; xx < sx1; xx++) {
                            const unsigned char* p = raw + ((size_t)yy * sw + xx) * 4;
                            r+=p[0]; g+=p[1]; b+=p[2]; a+=p[3]; ++n;
                        }
                    unsigned char* d = small.data() + ((size_t)y * dw + x) * 4;
                    d[0]=(unsigned char)(r/n); d[1]=(unsigned char)(g/n); d[2]=(unsigned char)(b/n); d[3]=(unsigned char)(a/n);
                }
            }
            if (util::CreateRawTexture(small.data(), dw, dh, false, &th.tex)) { th.w = dw; th.h = dh; }
        }
        if (raw) util::FreeRawPixels(raw);
        auto& slot = (s_thumbs[absPath] = th);
        return &slot;
    }

    static void clearThumbs() {
        for (auto& kv : s_thumbs) {
            if (kv.second.tex) util::DestroyTexture(kv.second.tex);
            for (ImTextureID f : kv.second.frames) if (f) util::DestroyTexture(f);
        }
        s_thumbs.clear();
    }

    static void navigate(const std::string& dir) {
        namespace fs = std::filesystem;
        std::error_code ec;
        fs::path p(dir);
        if (!fs::is_directory(p, ec)) return;
        clearThumbs();   // thumbnails are per-directory
        fs::path abs = fs::absolute(p, ec);
        s_dir = ec ? dir : abs.lexically_normal().string();
        s_subdirs.clear();
        s_images.clear();
        fs::directory_iterator it(p, ec), end;
        int guard = 0;
        for (; !ec && it != end && guard < 8000; it.increment(ec), ++guard) {
            std::error_code fec;
            std::string name = it->path().filename().string();
            if (name.empty() || name[0] == '.') continue;   // skip hidden/dotfiles
            if (it->is_directory(fec)) s_subdirs.push_back(name);
            else if (it->is_regular_file(fec) && isImageName(name)) s_images.push_back(name);
        }
        auto ci = [](const std::string& a, const std::string& b){
            return std::lexicographical_compare(a.begin(), a.end(), b.begin(), b.end(),
                [](char x, char y){ return std::tolower((unsigned char)x) < std::tolower((unsigned char)y); });
        };
        std::sort(s_subdirs.begin(), s_subdirs.end(), ci);
        std::sort(s_images.begin(), s_images.end(), ci);
    }

    static constexpr int kThumbPx = 128;      // max thumbnail dimension (downscaled)
    static constexpr int kLoadsPerFrame = 6;  // decode budget per frame (spreads a big folder over frames)
    static constexpr int kThumbMaxFrames = 300;  // cap frames per hovered animation
    static constexpr int kMaxAnimInFlight = 2;   // max concurrent background decodes

    // Heap-owned so a detached worker (which decrements it) can safely outlive static teardown at
    // process exit — the worker holds a shared_ptr copy, so it never touches a destroyed static.
    static inline std::shared_ptr<std::atomic<int>> s_animInFlight = std::make_shared<std::atomic<int>>(0);
    static inline bool s_open = false;
    // Main-thread-only (set in render/driveThumbAnim, read+cleared in consumeAnimationActive); NOT atomic
    // on purpose — do not read/write it from a worker thread.
    static inline bool s_animatingThisFrame = false;  // a hover-preview animation is playing
    static inline int s_loadedThisFrame = 0;
    static inline std::string s_dir;
    static inline std::string s_selected;
    static inline std::vector<std::string> s_subdirs;
    static inline std::vector<std::string> s_images;
    static inline std::unordered_map<std::string, Thumb> s_thumbs;
    static inline std::function<void(const std::string&)> s_onPick;
};

} // namespace ui
} // namespace dragonx
