/**
 * @file PlotPanel.cpp
 * @brief ImPlot oscilloscope panel — oscilloscope-style fixed Y axis.
 *
 * Y axis: always [-4, +4] divisions.  Normalisation per plot mode:
 *   normal — each signal scaled by its VScale (auto / range / manual)
 *   digital — each signal quantised hi/lo within its own horizontal band
 *   mixed   — per-signal band; quantised or auto-scaled (vs.digitalInMixed)
 *
 * X axis: live mode follows the wall clock (hub time base is Unix seconds);
 * scroll/drag switches to a stored range with zoom history (Back/Fit/Live).
 * When zoomed, a hi-res window is fetched from the hub over WS (zoom cmd).
 * When a trigger capture exists (and the trigger bar is open) the panel
 * renders the capture relative to the trigger instant in [-pre, +post].
 */

#include "PlotPanel.h"
#include "App.h"
#include "Protocol.h"
#include "SignalBuffer.h"

#include "imgui.h"
#include "implot.h"
#include "Icons.h"

#include <cstdio>
#include <cstring>
#include <cmath>
#include <algorithm>
#include <chrono>
#include <vector>
#include <string>

namespace StreamHubClient {

/* DnD payload */
struct DragPayload { int srcIdx; int sigIdx; };

static const int kPlotSlotsMax = 8;   /* mirrors App::kMaxPlotSlots */

/* Live windows at or below this width refresh via hub hi-res zoom fetches
 * instead of (in addition to) the decimated push stream.  Wide windows
 * benefit too: LTTB on the server ring gives much better coverage than
 * the client's accumulated push-stream data. */
static const double kLiveHiResMaxWin = 600.0;  /* seconds (effectively always) */

/* ── Helpers ─────────────────────────────────────────────────────────────── */

static const char* fmtVal(char* buf, size_t sz, double v) {
    if (!std::isfinite(v)) { snprintf(buf, sz, "?"); return buf; }
    double abs = std::fabs(v);
    if (v == 0.0)        snprintf(buf, sz, "0");
    else if (abs >= 1e4 || abs < 1e-3) snprintf(buf, sz, "%.2e", v);
    else                 snprintf(buf, sz, "%.3g", v);
    return buf;
}

/** Resolve the effective divValue and offset for this assignment given raw data. */
static void resolveVScale(PlotAssignment& a, const Signal& sig,
                          const std::vector<double>& rawV) {
    if (a.vs.mode == 1) {  /* range */
        double rmin = sig.meta.rangeMin, rmax = sig.meta.rangeMax;
        if (rmax > rmin) {
            a.vs.resolvedDiv    = std::max((rmax - rmin) / 8.0, 1e-30);
            a.vs.resolvedOffset = (rmin + rmax) / 2.0;
            return;
        }
        /* fall through to auto if no valid range */
    }
    if (a.vs.mode == 2) {  /* manual */
        a.vs.resolvedDiv    = std::max(a.vs.divValue, 1e-30);
        a.vs.resolvedOffset = a.vs.offset;
        return;
    }
    /* auto: fit data in central 6 of 8 divisions */
    double mn = 1e300, mx = -1e300;
    for (double v : rawV) { if (v < mn) mn = v; if (v > mx) mx = v; }
    if (!std::isfinite(mn) || mn > mx) { mn = -1.0; mx = 1.0; }
    if (mn == mx) { mn -= 1.0; mx += 1.0; }
    a.vs.resolvedDiv    = std::max((mx - mn) / 6.0, 1e-30);
    a.vs.resolvedOffset = (mx + mn) / 2.0;
}

static double normalizeY(double raw, const VScale& vs) {
    return (raw - vs.resolvedOffset) / vs.resolvedDiv + vs.screenPos;
}

/** Min/max of a vector (returns false if empty/non-finite). */
static bool dataMinMax(const std::vector<double>& v, double& mn, double& mx) {
    mn = 1e300; mx = -1e300;
    for (double x : v) { if (std::isfinite(x)) { if (x < mn) mn = x; if (x > mx) mx = x; } }
    return mx >= mn;
}

/** Digital/mixed band normalisation (ports web applyDigitalNorm/applyMixedNorm).
 *  Band index ki of n; quantize=true → hi/lo threshold mode. */
static void bandNormalize(const std::vector<double>& raw, std::vector<double>& out,
                          int ki, int n, bool quantize) {
    double bandH   = 8.0 / std::max(n, 1);
    double centerY = 4.0 - (ki + 0.5) * bandH;
    double hi = centerY + bandH * 0.35;
    double lo = centerY - bandH * 0.35;

    double mn, mx;
    bool haveMM = dataMinMax(raw, mn, mx);

    out.resize(raw.size());
    if (quantize) {
        double thr = haveMM ? (mn + mx) / 2.0 : 0.5;
        for (size_t i = 0; i < raw.size(); i++) {
            double v = raw[i];
            out[i] = !std::isfinite(v) ? NAN : (v >= thr ? hi : lo);
        }
    } else {
        if (!haveMM) { mn = 0.0; mx = 1.0; }
        if (mn == mx) { mn -= 1.0; mx += 1.0; }
        double range = mx - mn, bandRange = hi - lo;
        for (size_t i = 0; i < raw.size(); i++) {
            double v = raw[i];
            out[i] = !std::isfinite(v) ? NAN : lo + (v - mn) / range * bandRange;
        }
    }
}

/** Nearest sample value at time x (t sorted ascending). Returns NAN if empty. */
static double sampleAt(const std::vector<double>& t, const std::vector<double>& v,
                       double x) {
    if (t.empty() || v.size() != t.size()) { return NAN; }
    auto it = std::lower_bound(t.begin(), t.end(), x);
    size_t i = static_cast<size_t>(it - t.begin());
    if (i >= t.size()) { i = t.size() - 1; }
    else if (i > 0 && (x - t[i-1]) < (t[i] - x)) { i--; }
    return v[i];
}

/* ── Main render ─────────────────────────────────────────────────────────── */

void RenderPlotPanel(App& app, int plotIdx, bool& paused) {
    auto& slots   = app.plotSlot(plotIdx);
    auto& sources = app.sources();
    bool& live    = app.liveFollow(plotIdx);
    int&  actSlot = app.activeSlot(plotIdx);
    int&  vMode   = app.plotVMode(plotIdx);

    ImGui::PushID(plotIdx);

    /* Hub timestamps are Unix wall-clock seconds: live window follows the
     * local wall clock, not the newest data timestamp. */
    const double wallNow = std::chrono::duration<double>(
        std::chrono::system_clock::now().time_since_epoch()).count();

    /* Trigger view: render the hub capture relative to the trigger instant */
    const CaptureFrame* cap = app.capture();
    const bool trigView = (cap != nullptr) && app.showTrigBar();

    /* Hi-res zoom cache for this plot */
    auto& zc = app.zoomCache(plotIdx);

    /* ── Paused view snapshot (double buffer: active ring / frozen view) ── *
     * The rings keep filling while paused; on the pause edge we freeze a
     * copy of each slot's data and render from it until resumed.           */
    auto& snap = app.plotSnap(plotIdx);
    if (paused) {
        if (!snap.valid) {
            snap.keys.clear(); snap.t.clear(); snap.v.clear();
            for (const auto& a : slots) {
                std::vector<double> tt, vv;
                if (a.sourceIdx >= 0 && a.sourceIdx < (int)sources.size()) {
                    (void) sources[a.sourceIdx].signals[a.signalIdx]
                              .buf.readLast((size_t)app.maxPoints(), tt, vv);
                }
                snap.keys.push_back(app.slotKey(a));
                snap.t.push_back(std::move(tt));
                snap.v.push_back(std::move(vv));
            }
            snap.valid = true;
            /* Freeze the X axis where it is and switch to zoom/pan mode */
            if (live) {
                app.initPlotX(plotIdx, wallNow);
                live = false;
            }
        }
    } else if (snap.valid) {
        snap.valid = false;
    }

    /* ── Collect raw data & resolve vscale ──────────────────────────────── */
    /* per-slot storage so normalisation pass can reference them */
    static thread_local std::vector<std::vector<double>> tStore, vStore;
    tStore.resize(slots.size());
    vStore.resize(slots.size());

    /* Live hi-res refresh: with a narrow live window the client ring (built
     * from decimated pushes) undersamples the visible range.  Periodically
     * fetch a fresh ~2400-pt slice from the hub raw ring and anchor the X
     * axis to the fetched slice (scope-style refresh at the fetch rate). */
    const bool liveHiRes = !trigView && live && !paused &&
                           app.windowSec() <= kLiveHiResMaxWin &&
                           zc.valid &&
                           (zc.t1 - zc.t0) >= app.windowSec() * 0.9 &&
                           (wallNow - zc.t1) < 3.0;

    const bool useZoomData = !trigView && !paused && zc.valid &&
        (liveHiRes ||
         (!live &&
          zc.t0 <= app.plotXMin(plotIdx) + 1e-9 &&
          zc.t1 >= app.plotXMax(plotIdx) - 1e-9));

    /* History zoom cache (disk-backed, for ranges beyond in-memory ring).
     * Zoom responses carry the *requested* t0/t1 (not actual data bounds),
     * so a zoom cache can report coverage even when the ring had no data.
     * Prefer history over zoom when the in-memory ring cannot span the full
     * visible window — that is the typical history-browse case. */
    auto& hc = app.histZoomCache(plotIdx);
    const bool haveHistCover = hc.valid &&
        hc.t0 <= app.plotXMin(plotIdx) + 1e-9 &&
        hc.t1 >= app.plotXMax(plotIdx) - 1e-9;
    bool useHistData = !trigView && !paused && !live && haveHistCover;
    if (useHistData) {
        /* Check that at least one signal has actual data points */
        bool anyData = false;
        for (const auto& hs : hc.signals) {
            if (!hs.t.empty()) { anyData = true; break; }
        }
        if (!anyData) { useHistData = false; }
    }

    /* Visible X window to read from the ring.  Reading only the visible slice
     * (binary-searched) instead of the entire ring — which can hold a million
     * points — is what keeps live rendering fluid; the full-ring readLast()
     * copied tens of MB per signal per frame.  A 10% margin keeps a sample on
     * each side so the later fine clip still has its boundary points. */
    double visT0, visT1;
    if (live) { visT1 = wallNow; visT0 = wallNow - app.windowSec(); }
    else      { visT1 = app.plotXMax(plotIdx); visT0 = app.plotXMin(plotIdx); }
    {
        double margin = (visT1 - visT0) * 0.1;
        if (!(margin > 0.0)) { margin = 1.0; }
        visT0 -= margin; visT1 += margin;
    }

    /* Fill tStore/vStore[si] from the frozen view (paused) or the live ring */
    auto readBase = [&](size_t si, const Signal& sig, const std::string& key) {
        if (paused && snap.valid) {
            for (size_t k = 0; k < snap.keys.size(); k++) {
                if (snap.keys[k] == key) {
                    tStore[si] = snap.t[k];
                    vStore[si] = snap.v[k];
                    return;
                }
            }
        }
        (void) sig.buf.readRange(visT0, visT1, tStore[si], vStore[si]);
    };

    for (size_t si = 0; si < slots.size(); si++) {
        auto& a = slots[si];
        tStore[si].clear(); vStore[si].clear();
        if (a.sourceIdx < 0 || a.sourceIdx >= (int)sources.size()) continue;
        const auto& sig = sources[a.sourceIdx].signals[a.signalIdx];
        const std::string key = app.slotKey(a);

        if (trigView) {
            /* Capture signals carry full keys "src:sig"; t relative to trigTime */
            for (const auto& cs : cap->signals) {
                if (cs.key != key) continue;
                size_t n = std::min(cs.t.size(), cs.v.size());
                tStore[si].reserve(n); vStore[si].reserve(n);
                for (size_t i = 0; i < n; i++) {
                    tStore[si].push_back(cs.t[i] - cap->trigTime);
                    vStore[si].push_back(cs.v[i]);
                }
                break;
            }
        } else if (useZoomData) {
            bool found = false;
            for (const auto& zs : zc.signals) {
                if (zs.name != key) continue;
                tStore[si] = zs.t;
                vStore[si] = zs.v;
                found = true;
                break;
            }
            if (!found) {
                readBase(si, sig, key);
            }
        } else if (useHistData) {
            bool found = false;
            for (const auto& hs : hc.signals) {
                if (hs.name != key) continue;
                tStore[si] = hs.t;
                vStore[si] = hs.v;
                found = true;
                break;
            }
            if (!found) {
                readBase(si, sig, key);
            }
        } else {
            readBase(si, sig, key);
        }
        resolveVScale(a, sig, vStore[si]);
    }

    /* clamp active slot */
    if (actSlot >= (int)slots.size()) actSlot = -1;

    /* ── Badge row ──────────────────────────────────────────────────────── */
    /* Catppuccin accent for active badge border */
    static const ImVec4 kAccent{0.537f,0.706f,0.980f,1.f};

    for (int i = (int)slots.size()-1; i >= 0; i--) {
        auto& a = slots[i];
        if (a.sourceIdx < 0 || a.sourceIdx >= (int)sources.size()) continue;
        auto& sig = sources[a.sourceIdx].signals[a.signalIdx];

        bool isActive = (actSlot == i);

        /* colored badge */
        ImGui::PushStyleColor(ImGuiCol_Button,
            isActive ? kAccent : sig.color);
        ImGui::PushStyleColor(ImGuiCol_ButtonHovered,
            isActive ? kAccent : ImVec4(sig.color.x*1.15f,sig.color.y*1.15f,
                                        sig.color.z*1.15f,sig.color.w));
        ImGui::PushStyleColor(ImGuiCol_Text,
            ImVec4(0.067f,0.067f,0.106f,1.f));

        char badge[80];
        /* show vscale info: resolved div value */
        char dvbuf[16];
        fmtVal(dvbuf, sizeof(dvbuf), a.vs.resolvedDiv);
        snprintf(badge, sizeof(badge), "%s  %s/div##b%d",
                 sig.meta.name.c_str(), dvbuf, i);

        if (ImGui::SmallButton(badge)) {
            actSlot = (actSlot == i) ? -1 : i;  /* toggle active */
        }
        ImGui::PopStyleColor(3);

        /* right-click: styling + remove */
        char popId[24]; snprintf(popId, sizeof(popId), "##bp%d_%d", plotIdx, i);
        if (ImGui::BeginPopupContextItem(popId)) {
            ImGui::TextUnformatted(sig.meta.name.c_str());
            ImGui::Separator();

            float col[4] = {sig.color.x, sig.color.y, sig.color.z, sig.color.w};
            if (ImGui::ColorEdit4("Color##sc", col,
                    ImGuiColorEditFlags_NoInputs | ImGuiColorEditFlags_NoAlpha)) {
                sig.color = ImVec4(col[0], col[1], col[2], col[3]);
            }
            ImGui::SetNextItemWidth(120.f);
            ImGui::SliderFloat("Width##sw", &sig.lineWidth, 0.5f, 5.f, "%.1f");

            static const char* kMarkerNames[] =
                {"None", "Circle", "Square", "Diamond", "Up", "Down",
                 "Left", "Right", "Cross", "Plus", "Asterisk"};
            int mk = sig.marker + 1;  /* -1 (none) → 0 */
            ImGui::SetNextItemWidth(120.f);
            if (ImGui::Combo("Marker##sm", &mk, kMarkerNames, 11)) {
                sig.marker = mk - 1;
            }

            if (vMode == 2) {  /* mixed mode: per-trace digital toggle */
                ImGui::Checkbox("Digital (mixed)##sd", &a.vs.digitalInMixed);
            }

            ImGui::Separator();
            bool shouldBreak = false;
            if (ImGui::MenuItem("Remove from plot")) {
                if (actSlot == i) actSlot = -1;
                else if (actSlot > i) actSlot--;
                slots.erase(slots.begin() + i);
                shouldBreak = true;
            }
            ImGui::EndPopup();
            if (shouldBreak) { ImGui::SameLine(); break; }
        }
        ImGui::SameLine();
    }

    /* live button (pause & cursors live in the global toolbar) */
    {
        static const ImVec4 kLiveOn {0.086f,0.494f,0.267f,1.f};
        static const ImVec4 kLiveOnH{0.114f,0.627f,0.341f,1.f};
        static const ImVec4 kLiveOff{0.192f,0.196f,0.267f,1.f};
        static const ImVec4 kLiveOffH{0.271f,0.278f,0.353f,1.f};
        ImGui::PushStyleColor(ImGuiCol_Button,
            live ? kLiveOn : kLiveOff);
        ImGui::PushStyleColor(ImGuiCol_ButtonHovered,
            live ? kLiveOnH : kLiveOffH);
        if (ImGui::SmallButton(ICON_FA_TOWER_BROADCAST " Live")) {
            live   = true;
            paused = false;
            app.zoomHist(plotIdx).clear();
            zc.valid = false;
        }
        ImGui::PopStyleColor(2);
    }

    /* Back / Fit / Reset (zoom history) */
    if (!live || (trigView && app.trigZoomed(plotIdx))) {
        ImGui::SameLine();
        auto& hist = app.zoomHist(plotIdx);
        if (hist.empty()) { ImGui::BeginDisabled(); }
        if (ImGui::SmallButton(ICON_FA_ARROW_ROTATE_LEFT " Back##zb")) {
            app.setPlotX(plotIdx, hist.back().first, hist.back().second);
            hist.pop_back();
        }
        if (hist.empty()) { ImGui::EndDisabled(); }
        ImGui::SameLine();
        if (trigView) {
            /* Reset to full capture window */
            if (ImGui::SmallButton(ICON_FA_EXPAND " Reset##zr")) {
                app.trigZoomed(plotIdx) = false;
                app.zoomHist(plotIdx).clear();
            }
        } else {
            if (ImGui::SmallButton(ICON_FA_EXPAND " Fit##zf")) {
                double mn = 1e300, mx = -1e300;
                for (size_t si = 0; si < slots.size(); si++) {
                    if (tStore[si].empty()) continue;
                    mn = std::min(mn, tStore[si].front());
                    mx = std::max(mx, tStore[si].back());
                }
                if (mx > mn) {
                    app.pushZoomHist(plotIdx);
                    app.setPlotX(plotIdx, mn, mx);
                }
            }
        }
    }

    /* History indicator */
    if (useHistData) {
        ImGui::SameLine();
        ImGui::TextColored(ImVec4(0.980f,0.702f,0.529f,1.f),
                           ICON_FA_CLOCK_ROTATE_LEFT " HIST");
    }

    /* Norm/Dig/Mix mode — compact toggle buttons matching SmallButton height */
    ImGui::SameLine();
    {
        static const char* kVLabels[] = {"N", "D", "M"};
        static const char* kVTooltips[] = {"Normal", "Digital", "Mixed"};
        for (int vm = 0; vm < 3; vm++) {
            char vmId[16]; snprintf(vmId, sizeof(vmId), "%s##vm%d_%d", kVLabels[vm], plotIdx, vm);
            bool sel = (vMode == vm);
            if (sel) {
                ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.537f,0.706f,0.980f,0.4f));
                ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.537f,0.706f,0.980f,1.f));
            }
            if (ImGui::SmallButton(vmId)) { vMode = vm; }
            if (sel) { ImGui::PopStyleColor(2); }
            if (ImGui::IsItemHovered()) { ImGui::SetTooltip("%s", kVTooltips[vm]); }
            if (vm < 2) { ImGui::SameLine(0.f, 1.f); }
        }
    }

    /* ── VScale toolbar (shown when an active signal is selected) ───────── */
    if (vMode == 0 && actSlot >= 0 && actSlot < (int)slots.size()) {
        auto& a = slots[actSlot];

        ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(4.f,2.f));

        /* mode buttons */
        static const char* kModeLabels[] = {"Auto","Range","Manual"};
        for (int m = 0; m < 3; m++) {
            bool sel = (a.vs.mode == m);
            if (sel) {
                ImGui::PushStyleColor(ImGuiCol_Button,
                    ImVec4(0.537f,0.706f,0.980f,0.3f));
                ImGui::PushStyleColor(ImGuiCol_Text,
                    ImVec4(0.537f,0.706f,0.980f,1.f));
            }
            if (ImGui::SmallButton(kModeLabels[m])) { a.vs.mode = m; }
            if (sel) ImGui::PopStyleColor(2);
            if (m < 2) ImGui::SameLine(0.f,2.f);
        }
        ImGui::SameLine(0.f,10.f);

        /* resolved info */
        char rbuf[24], obuf[24];
        fmtVal(rbuf, sizeof(rbuf), a.vs.resolvedDiv);
        fmtVal(obuf, sizeof(obuf), a.vs.resolvedOffset);

        if (a.vs.mode == 2) {  /* manual: editable */
            ImGui::SetNextItemWidth(70.f);
            ImGui::InputDouble("V/div##vd", &a.vs.divValue, 0,0,"%.4g");
            ImGui::SameLine(0.f,4.f);
            ImGui::SetNextItemWidth(80.f);
            ImGui::InputDouble("Offset##vo", &a.vs.offset, 0,0,"%.4g");
        } else {
            ImGui::TextDisabled("%s/div  @%s", rbuf, obuf);
        }
        ImGui::SameLine(0.f,10.f);
        ImGui::SetNextItemWidth(50.f);
        float sp = (float)a.vs.screenPos;
        if (ImGui::InputFloat("Pos(div)##vp", &sp, 0,0,"%.1f")) {
            a.vs.screenPos = sp;
        }

        ImGui::PopStyleVar();
    }

    /* ── Cursor readouts ─────────────────────────────────────────────────── */
    if (app.cursorsOn()) {
        double cA = app.cursorA(), cB = app.cursorB();
        double dT = cB - cA;
        char d1[24], d2[24];
        fmtVal(d1, sizeof(d1), dT);
        fmtVal(d2, sizeof(d2), (dT != 0.0) ? 1.0 / std::fabs(dT) : 0.0);
        ImGui::TextColored(ImVec4(0.980f,0.702f,0.529f,1.f),
                           "dT=%ss  1/dT=%sHz", d1, d2);
        for (size_t si = 0; si < slots.size(); si++) {
            auto& a = slots[si];
            if (a.sourceIdx < 0 || a.sourceIdx >= (int)sources.size()) continue;
            if (tStore[si].empty()) continue;
            const auto& sig = sources[a.sourceIdx].signals[a.signalIdx];
            double vA = sampleAt(tStore[si], vStore[si], cA);
            double vB = sampleAt(tStore[si], vStore[si], cB);
            char a1[24], a2[24], a3[24];
            fmtVal(a1, sizeof(a1), vA);
            fmtVal(a2, sizeof(a2), vB);
            fmtVal(a3, sizeof(a3), vB - vA);
            ImGui::SameLine(0.f, 14.f);
            ImGui::TextColored(sig.color, "%s: A=%s B=%s d=%s",
                               sig.meta.name.c_str(), a1, a2, a3);
        }
    }

    ImGui::Separator();

    /* ── ImPlot ──────────────────────────────────────────────────────────── */
    char plotId[32]; snprintf(plotId, sizeof(plotId), "##plot%d", plotIdx);

    /* Both axes are fully controlled by us — disable ImPlot's native zoom. */
    ImPlotFlags plotFlags = ImPlotFlags_NoTitle | ImPlotFlags_NoLegend
                          | ImPlotFlags_NoInputs;
    if (ImPlot::BeginPlot(plotId, ImVec2(-1.f,-1.f), plotFlags)) {

        /* Both axes locked so ImPlot never overrides our explicit limits. */
        ImPlot::SetupAxes(trigView ? "t - trig (s)" : "Time (s)", nullptr,
                          ImPlotAxisFlags_Lock,
                          ImPlotAxisFlags_Lock);

        /* Y axis always ±4 div space */
        ImPlot::SetupAxisLimits(ImAxis_Y1, -4.05, 4.05, ImGuiCond_Always);

        /* X axis: trig view → capture window (zoomable); live → wall clock; else stored */
        double xMin, xMax;
        bool& trigZm = app.trigZoomed(plotIdx);
        if (trigView) {
            if (trigZm) {
                xMin = app.plotXMin(plotIdx);
                xMax = app.plotXMax(plotIdx);
            } else {
                xMin = -cap->preSec;
                xMax = cap->postSec;
            }
        } else if (live && !paused) {
            if (liveHiRes) {
                /* Anchor to the latest fetched hi-res slice so the trace
                 * fills the window (refreshes at the fetch rate). */
                xMax = zc.t1; xMin = zc.t1 - app.windowSec();
            } else {
                xMax = wallNow; xMin = wallNow - app.windowSec();
            }
        } else {
            xMin = app.plotXMin(plotIdx); xMax = app.plotXMax(plotIdx);
        }
        if (trigView || (live && !paused) || !live) {
            if (xMax > xMin) {
                ImPlot::SetupAxisLimits(ImAxis_X1, xMin, xMax, ImGuiCond_Always);
            }
        }

        /* ── Y axis ticks labelled from active signal (normal mode only) ── */
        static double yTickVals[9] = {-4,-3,-2,-1,0,1,2,3,4};
        static char   yTickBufs[9][20];
        static const char* yTickLabels[9];

        if (vMode == 0 && actSlot >= 0 && actSlot < (int)slots.size()) {
            const auto& av = slots[actSlot].vs;
            for (int d = 0; d < 9; d++) {
                double divPos = yTickVals[d];
                double rawVal = av.resolvedOffset + (divPos - av.screenPos) * av.resolvedDiv;
                fmtVal(yTickBufs[d], sizeof(yTickBufs[d]), rawVal);
                yTickLabels[d] = yTickBufs[d];
            }
        } else {
            for (int d = 0; d < 9; d++) {
                snprintf(yTickBufs[d], sizeof(yTickBufs[d]), "%.0f", yTickVals[d]);
                yTickLabels[d] = yTickBufs[d];
            }
        }
        ImPlot::SetupAxisTicks(ImAxis_Y1, yTickVals, 9, yTickLabels, false);

        /* ── X axis ticks ───────────────────────────────────────────────── */
        if (xMax > xMin) {
            double step = (xMax - xMin) / 10.0;
            double tickX[11];
            for (int t = 0; t <= 10; t++) tickX[t] = xMin + t * step;
            ImPlot::SetupAxisTicks(ImAxis_X1, tickX, 11, nullptr, false);
        }

        ImPlot::SetupFinish();

        /* ── Custom oscilloscope input handling (after SetupFinish) ─────── *
         *
         * Scroll         → Y zoom : active signal V/div × factor
         * Ctrl + Scroll  → X zoom : windowSec_ (live) or stored X range (non-live)
         * Shift + Scroll → Y pan  : active signal screenPos shift
         * Right-drag     → X pan  : (non-live) translate stored X range
         *
         * Right-drag while live → transitions to non-live mode first. */

        /* With ImPlotFlags_NoInputs ImPlot never updates plot.Hovered, so
         * IsPlotHovered()/IsAxisHovered() always return false — test the
         * plot rectangle against the mouse ourselves. */
        const ImVec2 ppos = ImPlot::GetPlotPos();
        const ImVec2 psz  = ImPlot::GetPlotSize();
        bool overPlot = ImGui::IsWindowHovered(
                            ImGuiHoveredFlags_AllowWhenBlockedByActiveItem) &&
                        ImGui::IsMouseHoveringRect(
                            ppos, ImVec2(ppos.x + psz.x, ppos.y + psz.y));

        /* Zoom-history debounce: coalesce rapid scroll steps into one entry */
        static double lastHistPush[kPlotSlotsMax] = {};

        if (overPlot) {
            ImGuiIO& io2 = ImGui::GetIO();
            const float wheel = io2.MouseWheel;
            const bool ctrl   = io2.KeyCtrl;
            const bool shift  = io2.KeyShift;
            const double now  = ImGui::GetTime();

            /* Helper: enter zoomed mode for trigger view (seed from capture window) */
            auto enterTrigZoom = [&]() {
                if (trigView && !trigZm) {
                    app.setPlotX(plotIdx, -cap->preSec, cap->postSec);
                    trigZm = true;
                }
            };

            /* Helper: X-zoom the stored range by factor around center */
            auto xZoomStored = [&](double factor) {
                if (trigView) { enterTrigZoom(); }
                if (now - lastHistPush[plotIdx] > 0.6) {
                    app.pushZoomHist(plotIdx);
                    lastHistPush[plotIdx] = now;
                }
                double cx   = (app.plotXMin(plotIdx) + app.plotXMax(plotIdx)) * 0.5;
                double half = (app.plotXMax(plotIdx) - app.plotXMin(plotIdx)) * 0.5 * factor;
                app.setPlotX(plotIdx, cx - half, cx + half);
            };

            if (wheel != 0.f) {
                const double zoomIn  = 0.8;
                const double zoomOut = 1.25;
                double factor = (wheel > 0.f) ? zoomIn : zoomOut;

                if (ctrl) {
                    /* ── X zoom ─────────────────────────────────────────── */
                    if (!trigView && live) {
                        app.setWindowSec(app.windowSec() * factor);
                    } else {
                        xZoomStored(factor);
                    }
                } else if (shift) {
                    /* ── Y offset of active signal ───────────────────────── */
                    if (actSlot >= 0 && actSlot < (int)slots.size()) {
                        auto& a = slots[actSlot];
                        if (a.vs.mode != 2) {
                            a.vs.divValue = std::max(a.vs.resolvedDiv, 1e-30);
                            a.vs.offset   = a.vs.resolvedOffset;
                            a.vs.mode     = 2;
                        }
                        a.vs.screenPos += (wheel > 0.f) ? 0.5 : -0.5;
                    }
                } else {
                    /* ── Y zoom of active signal ─────────────────────────── */
                    if (actSlot >= 0 && actSlot < (int)slots.size()) {
                        auto& a = slots[actSlot];
                        if (a.vs.mode != 2) {
                            a.vs.divValue = std::max(a.vs.resolvedDiv, 1e-30);
                            a.vs.offset   = a.vs.resolvedOffset;
                            a.vs.mode     = 2;
                        }
                        a.vs.divValue = std::max(a.vs.divValue * factor, 1e-30);
                    } else {
                        /* No active signal: plain scroll → X zoom */
                        if (!trigView && live) {
                            app.setWindowSec(app.windowSec() * factor);
                        } else {
                            xZoomStored(factor);
                        }
                    }
                }
            }

            /* Right-drag → X pan. Transition live→non-live on drag start;
             * in trigger view, enter trigger-zoom mode. */
            if (ImGui::IsMouseDragging(ImGuiMouseButton_Right)) {
                if (trigView) { enterTrigZoom(); }
                if (!trigView && live) {
                    app.initPlotX(plotIdx, wallNow);
                    live = false;
                    lastHistPush[plotIdx] = now;
                }
                ImVec2 delta   = ImGui::GetMouseDragDelta(ImGuiMouseButton_Right, 0.f);
                ImGui::ResetMouseDragDelta(ImGuiMouseButton_Right);
                ImVec2 pxSize  = ImPlot::GetPlotSize();
                if (pxSize.x > 0.f) {
                    double xRange = app.plotXMax(plotIdx) - app.plotXMin(plotIdx);
                    double dt     = -(double)delta.x / (double)pxSize.x * xRange;
                    app.setPlotX(plotIdx,
                                 app.plotXMin(plotIdx) + dt,
                                 app.plotXMax(plotIdx) + dt);
                }
            }
        }

        /* ── Hi-res WS zoom requests (suppressed while paused) ──────────── */
        if (!trigView && !paused) {
            std::string csv;
            for (const auto& a : slots) {
                std::string k = app.slotKey(a);
                if (k.empty()) continue;
                if (!csv.empty()) csv += ",";
                csv += k;
            }

            if (live && !paused && app.windowSec() <= kLiveHiResMaxWin) {
                /* Live, zoomed in: continuously refresh the window with a
                 * fresh hi-res slice from the hub raw ring (throttled). */
                static double lastLiveReq[kPlotSlotsMax] = {};
                const double now = ImGui::GetTime();
                if (!csv.empty() && !zc.pending &&
                    now - lastLiveReq[plotIdx] > 0.25) {
                    app.requestZoom(plotIdx, wallNow - app.windowSec(),
                                    wallNow, csv);
                    lastLiveReq[plotIdx] = now;
                }
            } else if (!live) {
                /* Non-live: debounced — every zoom/pan settles into one
                 * request, so each zoom level gets fresh resolution.
                 * If the range is covered by the in-memory ring, use regular
                 * zoom; otherwise try historyZoom (disk-backed). */
                static double rangeChangedAt[kPlotSlotsMax] = {};
                static double lastT0[kPlotSlotsMax] = {}, lastT1[kPlotSlotsMax] = {};
                const double now = ImGui::GetTime();
                double t0 = app.plotXMin(plotIdx), t1 = app.plotXMax(plotIdx);
                if (t0 != lastT0[plotIdx] || t1 != lastT1[plotIdx]) {
                    lastT0[plotIdx] = t0;
                    lastT1[plotIdx] = t1;
                    rangeChangedAt[plotIdx] = now;
                } else if (rangeChangedAt[plotIdx] > 0.0 &&
                           now - rangeChangedAt[plotIdx] > 0.35 &&
                           !csv.empty()) {
                    /* Try regular zoom first */
                    if (!zc.pending && !(zc.valid && zc.t0 == t0 && zc.t1 == t1)) {
                        app.requestZoom(plotIdx, t0, t1, csv);
                    }
                    /* Also try history zoom if history is available */
                    const auto& hi = app.historyInfo();
                    if (hi.enabled && !hc.pending &&
                        !(hc.valid && hc.t0 == t0 && hc.t1 == t1)) {
                        app.requestHistoryZoom(plotIdx, t0, t1, csv);
                    }
                    rangeChangedAt[plotIdx] = 0.0;
                }
            }
        }

        /* ── Clip data to visible X range before LTTB ─────────────────── *
         * The ring buffer may hold far more data than the visible window.
         * LTTB must operate only on the visible slice so it picks the best
         * 2400 points for what's actually on screen. */
        if (xMax > xMin) {
            for (size_t si = 0; si < slots.size(); si++) {
                auto& tv = tStore[si];
                auto& vv = vStore[si];
                if (tv.empty()) continue;
                auto lo = std::lower_bound(tv.begin(), tv.end(), xMin);
                auto hi = std::upper_bound(lo, tv.end(), xMax);
                if (lo != tv.begin()) --lo; /* keep one sample before window */
                if (hi != tv.end())   ++hi; /* keep one sample after window  */
                size_t i0 = (size_t)(lo - tv.begin());
                size_t i1 = (size_t)(hi - tv.begin());
                if (i0 > 0 || i1 < tv.size()) {
                    tv = std::vector<double>(tv.begin() + i0, tv.begin() + i1);
                    vv = std::vector<double>(vv.begin() + i0, vv.begin() + i1);
                }
            }
        }

        /* ── Plot each signal ──────────────────────────────────────────── */
        static const size_t kMaxPush = 2400;
        static thread_local std::vector<double> tDec, vDec, vNorm;

        int nTraces = 0;
        for (const auto& a : slots) {
            if (a.sourceIdx >= 0 && a.sourceIdx < (int)sources.size()) nTraces++;
        }

        int ki = 0;
        for (size_t si = 0; si < slots.size(); si++) {
            auto& a = slots[si];
            if (a.sourceIdx < 0 || a.sourceIdx >= (int)sources.size()) continue;
            const auto& sig = sources[a.sourceIdx].signals[a.signalIdx];
            int myKi = ki++;

            if (tStore[si].empty()) continue;

            size_t nOut = LTTBDecimate(tStore[si], vStore[si], tDec, vDec, kMaxPush);
            if (nOut == 0) continue;

            /* normalise to ±4 div space */
            if (vMode == 1) {        /* digital */
                bandNormalize(vDec, vNorm, myKi, nTraces, true);
            } else if (vMode == 2) { /* mixed */
                bandNormalize(vDec, vNorm, myKi, nTraces, a.vs.digitalInMixed);
            } else {
                vNorm.resize(nOut);
                for (size_t k = 0; k < nOut; k++) {
                    vNorm[k] = normalizeY(vDec[k], a.vs);
                }
            }

            ImPlot::SetNextLineStyle(sig.color, sig.lineWidth);
            if (sig.marker >= 0) {
                ImPlot::SetNextMarkerStyle(sig.marker, 3.f, sig.color);
            }
            ImPlot::PlotLine(sig.meta.name.c_str(),
                             tDec.data(), vNorm.data(), (int)nOut);
        }

        /* Trigger instant marker (capture view: t = 0) */
        if (trigView) {
            double t0m = 0.0;
            ImPlot::DragLineX(900, &t0m, ImVec4(1.f,1.f,0.f,0.8f),
                              1.5f, ImPlotDragToolFlags_NoInputs);
        }

        /* Cursors A/B — global: same position on every plot, drag anywhere.
         * Re-seed to visible range if both cursors are outside the view
         * (e.g. switching between live and trigger mode). */
        if (app.cursorsOn()) {
            bool aBeyond = (app.cursorA() < xMin || app.cursorA() > xMax);
            bool bBeyond = (app.cursorB() < xMin || app.cursorB() > xMax);
            if (aBeyond && bBeyond) {
                double range = xMax - xMin;
                app.cursorA() = xMin + range * 0.25;
                app.cursorB() = xMin + range * 0.75;
            }
            static const ImVec4 kCursA{0.980f,0.702f,0.529f,0.9f};
            static const ImVec4 kCursB{0.796f,0.651f,0.969f,0.9f};
            ImPlot::DragLineX(910, &app.cursorA(), kCursA, 1.2f);
            ImPlot::DragLineX(911, &app.cursorB(), kCursB, 1.2f);
            ImPlot::TagX(app.cursorA(), kCursA, "A");
            ImPlot::TagX(app.cursorB(), kCursB, "B");
        }

        /* Drag-and-drop target */
        if (ImPlot::BeginDragDropTargetPlot()) {
            if (const ImGuiPayload* pl =
                    ImGui::AcceptDragDropPayload("SIGNAL_REF")) {
                DragPayload dp;
                memcpy(&dp, pl->Data, sizeof(dp));
                bool found = false;
                for (const auto& a : slots) {
                    if (a.sourceIdx == dp.srcIdx && a.signalIdx == dp.sigIdx) {
                        found = true; break;
                    }
                }
                if (!found) {
                    PlotAssignment pa;
                    pa.sourceIdx = dp.srcIdx;
                    pa.signalIdx = dp.sigIdx;
                    slots.push_back(pa);
                }
            }
            ImPlot::EndDragDropTarget();
        }

        ImPlot::EndPlot();
    }

    ImGui::PopID();
}

} /* namespace StreamHubClient */
