#include "ofApp.h"

#include "ofxOsc.h"

#include <fstream>
#include <sstream>

namespace {
// Mini-parseur JSON minimaliste pour settings.json — suffisant pour les clés
// plates qu'on utilise (pas de vraie dépendance à ofxJSON).
std::string extractString(const std::string& src, const std::string& key,
                          const std::string& fallback) {
    auto p = src.find("\"" + key + "\"");
    if (p == std::string::npos) return fallback;
    p = src.find(':', p);
    if (p == std::string::npos) return fallback;
    auto q = src.find('"', p);
    if (q == std::string::npos) return fallback;
    auto r = src.find('"', q + 1);
    if (r == std::string::npos) return fallback;
    return src.substr(q + 1, r - q - 1);
}

float extractNumber(const std::string& src, const std::string& key, float fallback) {
    auto p = src.find("\"" + key + "\"");
    if (p == std::string::npos) return fallback;
    p = src.find(':', p);
    if (p == std::string::npos) return fallback;
    std::size_t i = p + 1;
    while (i < src.size() && (src[i] == ' ' || src[i] == '\n' || src[i] == '\t')) ++i;
    std::size_t j = i;
    while (j < src.size() && (std::isdigit(src[j]) || src[j] == '.' || src[j] == '-')) ++j;
    if (j == i) return fallback;
    try { return std::stof(src.substr(i, j - i)); } catch (...) { return fallback; }
}

const char* modeName(ofApp::Mode m) {
    switch (m) {
        case ofApp::Mode::Lissajous:   return "Lissajous";
        case ofApp::Mode::Spectrogram: return "Spectrogram";
        case ofApp::Mode::Reactive:    return "Reactive";
        case ofApp::Mode::Waveform:    return "Waveform";
        case ofApp::Mode::Polar:       return "Polar";
        case ofApp::Mode::Plasma:      return "Plasma";
        case ofApp::Mode::Particles:   return "Particles";
        case ofApp::Mode::Kaleido:     return "Kaleido";
        case ofApp::Mode::Tunnel:      return "Tunnel 3D";
        case ofApp::Mode::Mesh:        return "Mesh 3D";
        case ofApp::Mode::Scope4:      return "Scope 4-up";
        case ofApp::Mode::Hybrid:      return "Hybrid";
    }
    return "?";
}
} // namespace

void ofApp::setup() {
    ofSetVerticalSync(true);
    ofSetFrameRate(60);
    ofBackground(0);
    ofSetCircleResolution(72);

    loadSettings();

    osc_.setup(oscListenPort_, oscSendHost_, oscSendPort_);

    auto st = scope_.start();
    if (st == oscope::HantekStatus::FirmwareNeeded) {
        ofLogWarning("ofApp") << "Hantek firmware non charge — fonctionnement degrade.";
    }

    const int W = ofGetWidth();
    const int H = ofGetHeight();
    lissajous_ = std::make_unique<oscope::LissajousVis>();
    spectro_   = std::make_unique<oscope::SpectrogramVis>();
    reactive_  = std::make_unique<oscope::ReactiveVis>();
    waveform_  = std::make_unique<oscope::WaveformVis>();
    webcam_    = std::make_unique<oscope::WebcamVis>();
    polar_     = std::make_unique<oscope::PolarVis>();
    plasma_    = std::make_unique<oscope::PlasmaVis>();
    particles_ = std::make_unique<oscope::ParticleVis>();
    kaleido_   = std::make_unique<oscope::KaleidoVis>();
    tunnel_    = std::make_unique<oscope::TunnelVis>();
    mesh_      = std::make_unique<oscope::MeshVis>();
    metaballs_ = std::make_unique<oscope::ShaderVis>("shaders/metaballs");
    voronoi_   = std::make_unique<oscope::ShaderVis>("shaders/voronoi");
    twister_   = std::make_unique<oscope::ShaderVis>("shaders/twister");
    plasmaFbm_ = std::make_unique<oscope::ShaderVis>("shaders/plasma_fbm");
    rotozoom_  = std::make_unique<oscope::ShaderVis>("shaders/rotozoom");
    truchet_   = std::make_unique<oscope::ShaderVis>("shaders/truchet");
    sdfTunnel_  = std::make_unique<oscope::ShaderVis>("shaders/sdf_tunnel");
    kifs_       = std::make_unique<oscope::ShaderVis>("shaders/kifs");
    fire_       = std::make_unique<oscope::ShaderVis>("shaders/fire");
    gridPersp_  = std::make_unique<oscope::ShaderVis>("shaders/grid_persp");
    tunnelCubes_= std::make_unique<oscope::ShaderVis>("shaders/tunnel_cubes");
    caustics_   = std::make_unique<oscope::ShaderVis>("shaders/caustics");
    vortex_     = std::make_unique<oscope::ShaderVis>("shaders/vortex");
    octahedron_ = std::make_unique<oscope::ShaderVis>("shaders/octahedron");
    vectorCubes_= std::make_unique<oscope::VectorCubesVis>();
    auto mp = [](const char* n){
        return std::make_unique<oscope::ModelVis>(
            ofToDataPath(std::string("models/") + n, true));
    };
    modelMobius_       = mp("mobius.ply");
    modelKlein_        = mp("klein.ply");
    modelTrefoil_      = mp("trefoil_tube.ply");
    modelTwistedTorus_ = mp("twisted_torus.ply");
    modelLucy_         = mp("lucy.ply");
    modelHelix_        = mp("helix.ply");
    modelCatenoid_     = mp("catenoid.ply");
    modelHyperboloid_  = mp("hyperboloid.ply");
    modelBoys_         = mp("boys.ply");
    modelLemniscate_   = mp("lemniscate.ply");
    modelPenrose_      = mp("penrose.ply");
    modelSphere_       = mp("sphere.ply");
    modelIco_          = mp("icosahedron.ply");
    modelDod_          = mp("dodecahedron.ply");
    modelTorus_        = mp("torus.ply");
    modelSupershape_   = mp("supershape.ply");
    modelLorenz_       = mp("lorenz.ply");
    modelHopf_         = mp("hopf.ply");
    modelEnneper_      = mp("enneper.ply");
    modelHopfLink_     = mp("hopf_link.ply");
    boing_     = std::make_unique<oscope::ShaderVis>("shaders/boing");
    mode7_     = std::make_unique<oscope::ShaderVis>("shaders/mode7");
    plasmaC64_ = std::make_unique<oscope::ShaderVis>("shaders/plasma_c64");
    dotTunnel_ = std::make_unique<oscope::ShaderVis>("shaders/dot_tunnel");
    modelGear_      = mp("gear.ply");
    modelCone_      = mp("cone.ply");
    modelPyramid_   = mp("pyramid.ply");
    modelRose_      = mp("rose3d.ply");
    modelGeosphere_ = mp("geosphere.ply");
    modelDNA_       = mp("dna.ply");
    workbench_     = std::make_unique<oscope::ShaderVis>("shaders/workbench");
    macosClassic_  = std::make_unique<oscope::ShaderVis>("shaders/macos_classic");
    atariFuji_     = std::make_unique<oscope::ShaderVis>("shaders/atari_fuji");
    oceanLoader_   = std::make_unique<oscope::ShaderVis>("shaders/ocean_loader");
    win95_         = std::make_unique<oscope::ShaderVis>("shaders/win95");
    sphereWave_    = std::make_unique<oscope::SphereWaveVis>();
    auto img = [](const char* fn){
        return std::make_unique<oscope::ImageVis>(
            ofToDataPath(std::string("sprites/") + fn, true));
    };
    imgWin1_   = img("windows_1_0.png");
    imgWin311_ = img("windows_311.png");
    imgWin95_  = img("windows_95.png");
    imgLotus_  = img("lotus_123.png");
    imgDos_    = img("msdos.png");
    imgAtari_  = img("atari_fuji.png");
    imgApple_  = img("apple_rainbow.png");
    imgWB_     = img("amiga_wb.png");
    imgNeXT_   = img("nextstep.png");
    imgBeOS_   = img("beos.png");
    imgOS2_    = img("os2_warp.png");
    imgIrix_   = img("irix.png");
    imgZX_     = img("zx_spectrum.png");
    imgC64_    = img("c64_basic.png");
    lissajous_->setup(W, H);
    spectro_->setup(W, H / 4);
    reactive_->setup(W, H);
    waveform_->setup(W, H);
    webcam_->setup(W, H);
    polar_->setup(W, H);
    plasma_->setup(W, H);
    particles_->setup(W, H);
    kaleido_->setup(W, H);
    tunnel_->setup(W, H);
    mesh_->setup(W, H);
    metaballs_->setup(W, H);
    voronoi_->setup(W, H);
    twister_->setup(W, H);
    plasmaFbm_->setup(W, H);
    rotozoom_->setup(W, H);
    truchet_->setup(W, H);
    sdfTunnel_->setup(W, H);
    kifs_->setup(W, H);
    fire_->setup(W, H);
    gridPersp_->setup(W, H);
    tunnelCubes_->setup(W, H);
    caustics_->setup(W, H);
    vortex_->setup(W, H);
    octahedron_->setup(W, H);
    vectorCubes_->setup(W, H);
    modelMobius_->setup(W, H);
    modelKlein_->setup(W, H);
    modelTrefoil_->setup(W, H);
    modelTwistedTorus_->setup(W, H);
    modelLucy_->setup(W, H);
    modelHelix_->setup(W, H);
    modelCatenoid_->setup(W, H);
    modelHyperboloid_->setup(W, H);
    modelBoys_->setup(W, H);
    modelLemniscate_->setup(W, H);
    modelPenrose_->setup(W, H);
    modelSphere_->setup(W, H);
    modelIco_->setup(W, H);
    modelDod_->setup(W, H);
    modelTorus_->setup(W, H);
    modelSupershape_->setup(W, H);
    modelLorenz_->setup(W, H);
    modelHopf_->setup(W, H);
    modelEnneper_->setup(W, H);
    modelHopfLink_->setup(W, H);
    boing_->setup(W, H);
    mode7_->setup(W, H);
    plasmaC64_->setup(W, H);
    dotTunnel_->setup(W, H);
    modelGear_->setup(W, H);
    modelCone_->setup(W, H);
    modelPyramid_->setup(W, H);
    modelRose_->setup(W, H);
    modelGeosphere_->setup(W, H);
    modelDNA_->setup(W, H);
    workbench_->setup(W, H);
    macosClassic_->setup(W, H);
    atariFuji_->setup(W, H);
    oceanLoader_->setup(W, H);
    win95_->setup(W, H);
    sphereWave_->setup(W, H);
    imgWin1_->setup(W, H);   imgWin311_->setup(W, H); imgWin95_->setup(W, H);
    imgLotus_->setup(W, H);  imgDos_->setup(W, H);
    imgAtari_->setup(W, H);  imgApple_->setup(W, H);
    imgWB_->setup(W, H);     imgNeXT_->setup(W, H);   imgBeOS_->setup(W, H);
    imgOS2_->setup(W, H);    imgIrix_->setup(W, H);
    imgZX_->setup(W, H);     imgC64_->setup(W, H);

    postfx_.setup(W, H);

    // FBO pour transitions inter-scènes (ping-pong)
    ofFboSettings sfx;
    sfx.width = W; sfx.height = H; sfx.internalformat = GL_RGBA;
    sfx.useDepth = false;
    sfx.textureTarget = GL_TEXTURE_RECTANGLE_ARB;
    sceneFbo_[0].allocate(sfx);
    sceneFbo_[1].allocate(sfx);
    sceneFbo_[0].begin(); ofClear(0, 0, 0, 255); sceneFbo_[0].end();
    sceneFbo_[1].begin(); ofClear(0, 0, 0, 255); sceneFbo_[1].end();
    sceneFboReady_ = true;
    initTransitions();

    demo_.setup(ofToDataPath("greetings.txt", true));
    initDemos();

    gui_.setup("oscope-of");
    gui_.add(modeLabel_.setup("Mode", modeName(mode_)));
    gui_.add(scopeStatusLabel_.setup("Scope", scope_.statusString()));
    gui_.add(oscStatusLabel_.setup("OSC", "listening"));
    gui_.add(gainCh1_.setup("CH1 V/div", 1.0f, 0.25f, 5.0f));
    gui_.add(gainCh2_.setup("CH2 V/div", 1.0f, 0.25f, 5.0f));
    gui_.add(trailFade_.setup("Trail fade", 0.07f, 0.0f, 1.0f));
    gui_.add(sampleRateHz_.setup("Sample rate", 8000000, 1000000, 48000000));
    gui_.add(timeMsPerDiv_.setup("Time ms/div", 5.0f, 0.05f, 200.0f));
    gui_.add(slowMsPerDiv_.setup("Slow ms/div", 50.0f, 5.0f, 500.0f));
    gui_.add(slowOverlayEnabled_.setup("Slow overlay", true));
    gui_.add(scrollSpeed_.setup("Scroll", 1.0f, 0.0f, 1.0f));
    // start() a déjà appelé configureDevice() avec sampleRateHz_=8e6 (valeur
    // par défaut côté HantekDevice). On NE rappelle PAS setSampleRate ici :
    // ça enverrait un control transfer 0xE2 pendant la première bulk transfer
    // et désynchroniserait le FX2 (pixels 0/255 entrelacés -> écran "blanc").
    lastSampleRateApplied_ = sampleRateHz_;
    pendingSampleRate_     = sampleRateHz_;

    // PostFx panel — placed to the right of the main GUI
    fxGui_.setup("post-fx", "fx-settings.xml", 230, 10);
    fxGui_.add(fxEnableToggle_.setup("enabled", true));
    fxGui_.add(fxChroma_.setup("chroma",     0.0f, 0.0f, 1.0f));
    fxGui_.add(fxBloom_.setup("bloom",       0.15f, 0.0f, 1.0f));
    fxGui_.add(fxRgbShift_.setup("hue rot",  0.0f, 0.0f, 1.0f));
    fxGui_.add(fxSat_.setup("saturation",    1.0f, 0.0f, 2.0f));
    fxGui_.add(fxScan_.setup("scanlines",    0.3f, 0.0f, 1.0f));
    fxGui_.add(fxVignette_.setup("vignette", 0.5f, 0.0f, 1.0f));
    fxGui_.add(fxGrain_.setup("film grain",  0.15f, 0.0f, 1.0f));
    fxGui_.add(fxPixelate_.setup("pixelate", 0.0f, 0.0f, 1.0f));
    fxGui_.add(fxKaleido_.setup("kaleido",   0.0f, 0.0f, 1.0f));
    fxGui_.add(fxFeedback_.setup("feedback", 0.0f, 0.0f, 1.0f));
    fxGui_.add(fxFbZoom_.setup("fb zoom",    1.00f, 0.92f, 1.08f));
    fxGui_.add(fxFbRot_.setup("fb rot",      0.0f, -0.10f, 0.10f));
    fxGui_.add(fxGlitch_.setup("glitch",     0.0f, 0.0f, 1.0f));
    fxGui_.add(fxGlitchProb_.setup("glitch prob", 0.15f, 0.0f, 1.0f));

    ch1_.reserve(bufferSize_);
    ch2_.reserve(bufferSize_);
}

void ofApp::loadSettings() {
    std::ifstream f(ofToDataPath("settings.json"));
    if (!f.is_open()) return;
    std::stringstream ss; ss << f.rdbuf();
    const std::string s = ss.str();
    oscListenPort_ = static_cast<int>(extractNumber(s, "listen_port", oscListenPort_));
    oscSendPort_   = static_cast<int>(extractNumber(s, "send_port", oscSendPort_));
    oscSendHost_   = extractString(s, "send_host", oscSendHost_);
    bufferSize_    = static_cast<int>(extractNumber(s, "buffer_size", bufferSize_));
    const std::string m = extractString(s, "default_mode", "scope4");
    if      (m == "lissajous")    mode_ = Mode::Lissajous;
    else if (m == "spectro" ||
             m == "spectrogram")  mode_ = Mode::Spectrogram;
    else if (m == "reactive")     mode_ = Mode::Reactive;
    else if (m == "waveform" ||
             m == "scope")        mode_ = Mode::Waveform;
    else if (m == "polar")        mode_ = Mode::Polar;
    else if (m == "plasma")       mode_ = Mode::Plasma;
    else if (m == "particles")    mode_ = Mode::Particles;
    else if (m == "kaleido")      mode_ = Mode::Kaleido;
    else if (m == "tunnel")       mode_ = Mode::Tunnel;
    else if (m == "mesh")         mode_ = Mode::Mesh;
    else if (m == "scope4" ||
             m == "quad")         mode_ = Mode::Scope4;
    else                          mode_ = Mode::Hybrid;
}

void ofApp::applyOscFx() {
    // OSC values override GUI sliders when explicitly received. Sliders
    // remain the source of truth for values that have no OSC binding.
    auto bind = [&](const std::string& name, ofxFloatSlider& s) {
        s = osc_.fx(name, static_cast<float>(s));
    };
    bind("chroma",      fxChroma_);
    bind("bloom",       fxBloom_);
    bind("hue",         fxRgbShift_);
    bind("sat",         fxSat_);
    bind("scan",        fxScan_);
    bind("vignette",    fxVignette_);
    bind("grain",       fxGrain_);
    bind("pixelate",    fxPixelate_);
    bind("kaleido",     fxKaleido_);
    bind("feedback",    fxFeedback_);
    bind("fbzoom",      fxFbZoom_);
    bind("fbrot",       fxFbRot_);
    bind("glitch",      fxGlitch_);
    bind("glitchprob",  fxGlitchProb_);

    // /oscope/glitch <amount> → momentary pulse
    float pulse = 0.0f;
    if (osc_.consumeGlitchPulse(pulse)) {
        postfx_.triggerGlitch(pulse);
    }
    // Auto-glitch on kick if enabled
    if (autoGlitchOnKick_) {
        const float k = osc_.amp("kick");
        if (k > 0.55f) postfx_.triggerGlitch(0.5f * (k - 0.5f) * 2.0f, 0.12f);
    }
}

void ofApp::update() {
    osc_.update();
    // Re-apply sample rate when the slider changes — debounce 250ms pour
    // ne pas spammer le FX2 avec des control transfers pendant que l'user
    // fait glisser le slider (chaque transfer désynchronise le bulk stream).
    const int curSr = sampleRateHz_;
    if (curSr != pendingSampleRate_) {
        pendingSampleRate_   = curSr;
        pendingSampleRateAt_ = ofGetElapsedTimef();
    }
    if (pendingSampleRate_ != lastSampleRateApplied_ &&
        ofGetElapsedTimef() - pendingSampleRateAt_ > 0.25f) {
        lastSampleRateApplied_ = pendingSampleRate_;
        scope_.setSampleRate(static_cast<uint32_t>(lastSampleRateApplied_));
    }
    // Push live timebase + scroll into WaveformVis.
    waveform_->setTimeMsPerDiv(timeMsPerDiv_);
    waveform_->setSlowMsPerDiv(slowMsPerDiv_);
    waveform_->setShowSlowOverlay(slowOverlayEnabled_);
    waveform_->setScrollSpeed(scrollSpeed_);
    waveform_->setSampleRate(static_cast<float>(lastSampleRateApplied_));
    scope_.ring().readLatest(ch1_, ch2_, static_cast<std::size_t>(bufferSize_));

    // FFT audio depuis le ring Hantek (downsampled vers 48 kHz) pour les
    // bandes bass/lowMid/mid/treble + transitoires kick/snare. Pilote
    // les visualizers (Tunnel, Polar) sans dépendre de l'OSC.
    audio_.update(ch1_, ch2_, static_cast<float>(lastSampleRateApplied_));
    demo_.update(static_cast<float>(ofGetLastFrameTime()));
    updateNarrative(static_cast<float>(ofGetLastFrameTime()));

    // Beat-sync : si une démo est en attente, fire au prochain kick
    // (rising edge sur audio_.bands().kick).
    const float kNow = audio_.bands().kick;
    if (pendingDemoIdx_ >= 0 && kNow > 0.55f && prevKick_ <= 0.55f) {
        launchDemo(pendingDemoIdx_);
        pendingDemoIdx_ = -1;
    }
    prevKick_ = kNow;

    oscope::VisFrame frame{ch1_, ch2_, osc_, audio_.bands(),
                           &audio_.monoDown(), &audio_.magDown(),
                           audio_.monoHead()};
    lissajous_->update(frame);
    spectro_->update(frame);
    reactive_->update(frame);
    waveform_->update(frame);
    webcam_->update(frame);
    polar_->update(frame);
    plasma_->update(frame);
    particles_->update(frame);
    kaleido_->update(frame);
    tunnel_->update(frame);
    mesh_->update(frame);
    metaballs_->update(frame);
    voronoi_->update(frame);
    twister_->update(frame);
    plasmaFbm_->update(frame);
    rotozoom_->update(frame);
    truchet_->update(frame);
    sdfTunnel_->update(frame);
    kifs_->update(frame);
    fire_->update(frame);
    gridPersp_->update(frame);
    tunnelCubes_->update(frame);
    caustics_->update(frame);
    vortex_->update(frame);
    octahedron_->update(frame);
    vectorCubes_->update(frame);
    modelMobius_->update(frame);
    modelKlein_->update(frame);
    modelTrefoil_->update(frame);
    modelTwistedTorus_->update(frame);
    modelLucy_->update(frame);
    modelHelix_->update(frame);
    modelCatenoid_->update(frame);
    modelHyperboloid_->update(frame);
    modelBoys_->update(frame);
    modelLemniscate_->update(frame);
    modelPenrose_->update(frame);
    modelSphere_->update(frame);
    modelIco_->update(frame);
    modelDod_->update(frame);
    modelTorus_->update(frame);
    modelSupershape_->update(frame);
    modelLorenz_->update(frame);
    modelHopf_->update(frame);
    modelEnneper_->update(frame);
    modelHopfLink_->update(frame);
    boing_->update(frame);
    mode7_->update(frame);
    plasmaC64_->update(frame);
    dotTunnel_->update(frame);
    modelGear_->update(frame);
    modelCone_->update(frame);
    modelPyramid_->update(frame);
    modelRose_->update(frame);
    modelGeosphere_->update(frame);
    modelDNA_->update(frame);
    workbench_->update(frame);
    macosClassic_->update(frame);
    atariFuji_->update(frame);
    oceanLoader_->update(frame);
    win95_->update(frame);
    sphereWave_->update(frame);
    imgWin1_->update(frame); imgWin311_->update(frame); imgWin95_->update(frame);
    imgLotus_->update(frame); imgDos_->update(frame);
    imgAtari_->update(frame); imgApple_->update(frame);
    imgWB_->update(frame);   imgNeXT_->update(frame); imgBeOS_->update(frame);
    imgOS2_->update(frame);  imgIrix_->update(frame);
    imgZX_->update(frame);   imgC64_->update(frame);

    applyOscFx();

    postFxEnabled_ = fxEnableToggle_;
    postfx_.params.chroma       = fxChroma_;
    postfx_.params.bloom        = fxBloom_;
    postfx_.params.rgbShift     = fxRgbShift_;
    postfx_.params.saturation   = fxSat_;
    postfx_.params.scanlines    = fxScan_;
    postfx_.params.vignette     = fxVignette_;
    postfx_.params.filmGrain    = fxGrain_;
    postfx_.params.pixelate     = fxPixelate_;
    postfx_.params.kaleido      = fxKaleido_;
    postfx_.params.feedback     = fxFeedback_;
    postfx_.params.feedbackZoom = fxFbZoom_;
    postfx_.params.feedbackRot  = fxFbRot_;
    postfx_.params.glitch       = fxGlitch_;
    postfx_.params.glitchProb   = fxGlitchProb_;
    postfx_.update(ofGetLastFrameTime());

    scopeStatusLabel_ = "Scope: " + scope_.statusString() + " (" +
                        ofToString(ch1_.size()) + " s)";
    oscStatusLabel_ = "OSC bpm=" + ofToString(osc_.bpm(), 1) +
                      " beat=" + ofToString(osc_.beat()) +
                      " kick=" + ofToString(osc_.amp("kick"), 2);
    modeLabel_ = modeName(mode_);
}

void ofApp::drawMode(Mode m, int x, int y, int w, int h) {
    switch (m) {
        case Mode::Lissajous:   lissajous_->draw(x, y, w, h);   break;
        case Mode::Spectrogram: spectro_->draw(x, y, w, h);     break;
        case Mode::Reactive:    reactive_->draw(x, y, w, h);    break;
        case Mode::Waveform:    waveform_->draw(x, y, w, h);    break;
        case Mode::Polar:       polar_->draw(x, y, w, h);       break;
        case Mode::Plasma:      plasma_->draw(x, y, w, h);      break;
        case Mode::Particles:
            ofPushStyle();
            ofSetColor(0); ofDrawRectangle(x, y, w, h);
            ofPopStyle();
            particles_->draw(x, y, w, h);
            break;
        case Mode::Kaleido:     kaleido_->draw(x, y, w, h);     break;
        case Mode::Tunnel:      tunnel_->draw(x, y, w, h);      break;
        case Mode::Mesh:        mesh_->draw(x, y, w, h);        break;
        case Mode::Scope4:      drawScope4(w, h);               break;
        case Mode::Hybrid:      drawHybrid(w, h);               break;
    }
}

void ofApp::drawHybrid(int W, int H) {
    // Background : the 3D tunnel fills the canvas
    tunnel_->draw(0, 0, W, H);

    // 2x2 quadrant overlay with additive blending
    ofEnableBlendMode(OF_BLENDMODE_ADD);
    const int hw = W / 2;
    const int hh = H / 2;
    lissajous_->draw(0,  0,  hw, hh);
    polar_->draw   (hw, 0,  hw, hh);
    kaleido_->draw (0,  hh, hw, hh);
    mesh_->draw    (hw, hh, hw, hh);
    ofDisableBlendMode();

    // Particle field on top, fullscreen
    particles_->draw(0, 0, W, H);

    // Spectrogram strip across the bottom
    const int sh = H / 6;
    spectro_->draw(0, H - sh, W, sh);
}

void ofApp::initTransitions() {
    transShader_[0].load("shaders/transitions/crossfade");
    transShader_[1].load("shaders/transitions/dissolve");
    transShader_[2].load("shaders/transitions/wipe");
}

void ofApp::beginTransition() {
    if (!sceneFboReady_) return;
    // Le FBO courant devient le "previous" pour le composite. On choisit
    // une transition aléatoire parmi les 3.
    sceneFboIdx_  = 1 - sceneFboIdx_;
    transitionT_  = 0.0f;
    transKind_    = static_cast<TransKind>(static_cast<int>(ofRandom(0, 3)));
}

void ofApp::drawTransitionComposite(int W, int H) {
    if (!sceneFboReady_) return;
    const int curI  = sceneFboIdx_;
    const int prevI = 1 - sceneFboIdx_;
    if (transitionT_ >= 1.0f) {
        sceneFbo_[curI].draw(0, 0, W, H);
        return;
    }
    auto& sh = transShader_[static_cast<int>(transKind_)];
    if (!sh.isLoaded()) {
        sceneFbo_[curI].draw(0, 0, W, H);
        return;
    }
    sh.begin();
    sh.setUniformTexture("uPrev", sceneFbo_[prevI].getTexture(), 0);
    sh.setUniformTexture("uCur",  sceneFbo_[curI].getTexture(),  1);
    sh.setUniform2f("uRes", static_cast<float>(W), static_cast<float>(H));
    sh.setUniform1f("uT",   transitionT_);
    sceneFbo_[curI].draw(0, 0, W, H);
    sh.end();
}

void ofApp::initDemos() {
    using SS = oscope::ScrollerStyle;
    auto stars = []{
        ScopeToggles s{};
        s.tunnel=false; s.starfield=true; s.copperBars=false;
        s.bobs=false; s.tunnelHud=false; s.polar=false;
        s.spectroRing=false; s.waveform=false;
        return s;
    };
    auto starsWave = []{
        ScopeToggles s{};
        s.tunnel=false; s.starfield=true; s.copperBars=false;
        s.bobs=false; s.tunnelHud=false; s.polar=false;
        s.spectroRing=false; s.waveform=true;
        return s;
    };
    auto tunOnly = []{
        ScopeToggles s{};
        s.starfield=false; s.spectroRing=false; s.polar=false;
        s.waveform=false; s.bobs=false; s.copperBars=false;
        s.tunnelHud=false;
        return s;
    };
    auto polSpe = []{
        ScopeToggles s{};
        s.tunnel=false; s.starfield=false; s.copperBars=false;
        s.bobs=false; s.tunnelHud=false; s.waveform=false;
        return s;
    };
    auto cleanScope = []{
        ScopeToggles s{};
        s.tunnel=false; s.starfield=false; s.copperBars=false;
        s.bobs=false; s.tunnelHud=false;
        return s;
    };
    auto all = []{ return ScopeToggles{}; };

    demos_.clear();
    demos_.resize(15);

    // ─── 1 · AMIGA TRIBUTE ───────────────────────────────────
    demos_[0] = {"AMIGA TRIBUTE", {
        {"BOING BALL",   30.0f, all(),       SS::Bouncy,
         "    *** BOING BALL ***    JULY 1984 RJ MICAL DALE LUCK    "
         "    THE FIRST AMIGA DEMO    8 MERIDIANS RED AND WHITE    "
         "    GREETINGS TO COMMODORE-AMIGA INC    ", "M",
         BgKind::BoingBall},
        {"INSERT DISK",  12.0f, stars(),     SS::Neon,
         "    *** AMIGA 500 RELOADED ***    KICKSTART 1.3    "
         "    INSERT WORKBENCH DISK ", "R", BgKind::Starfield},
        {"SCROLLZ",      28.0f, all(),       SS::Classic,
         "    GREETINGS FROM 1989    COPPER BARS AND BOBS FOREVER    "
         "    AMIGA NEVER DIES    HELLO TO ALL DEMOSCENE VETERANS    ", "G",
         BgKind::Twister},
        {"COPPER STORM", 25.0f, all(),       SS::Rainbow,
         "    COPPER LIST IS POETRY    EVERY SCANLINE A NEW COLOR    "
         "    GREETINGS TO SPACEBALLS AND ANARCHY    ", "B",
         BgKind::Twister},
        {"VECTOR DEMO",  20.0f, all(),       SS::Chrome,
         "    VECTOR GRAPHICS    BEFORE TEXTURES THERE WAS GEOMETRY    ",
         "C", BgKind::VectorCubes},
        {"PYRAMIDE",     20.0f, polSpe(),    SS::Chrome,
         "    PYRAMID OF MEMORY    GIZA OF VECTORS    "
         "    A 4-SIDED MONUMENT FROM 1986    ", "B",
         BgKind::Pyramid},
        {"WORKBENCH",    22.0f, polSpe(),    SS::Cascade,
         "    WORKBENCH 1.3    KICKSTART READY    "
         "    DH0: DISK CONTAINS THE DEMO    ", "M",
         BgKind::ImgWB},
    }};

    // ─── 2 · C64 LOWLIFE ─────────────────────────────────────
    demos_[1] = {"C64 LOWLIFE", {
        {"BOOT",      18.0f, stars(),    SS::Glitch,
         "    **** COMMODORE 64 BASIC V2 ****    "
         "    64K RAM SYSTEM 38911 BASIC BYTES FREE    "
         "    READY.    LOAD \"DEMO\",8,1    SEARCHING FOR DEMO    ", "N",
         BgKind::ImgC64},
        {"PLASMA",    25.0f, all(),      SS::Wavy3D,
         "    *** PLASMA EFFECT ***    SID CHIP 6581 SCREAMING    "
         "    8 COULEURS INDEXEES PALETTE FIXE    "
         "    GREETINGS TO BOOZE DESIGN AND HOKUTO FORCE    ", "C",
         BgKind::PlasmaC64},
        {"DOT TUNNEL",25.0f, all(),      SS::Bouncy,
         "    DOT TUNNEL CLASSIC    PRECOMPUTED TABLES IN ZP    "
         "    1MHZ NEVER FELT SO FAST    ", "D", BgKind::DotTunnel},
        {"FREEZE",    20.0f, all(),      SS::Mirror,
         "    HARDWARE SCROLLER LOCKED IN VIC-II    "
         "    GROOVE THAT DEFIED 1MHZ    ", "K", BgKind::Rotozoom},
        {"CLIMAX",    25.0f, all(),      SS::Glitch,
         "    BREADBIN STILL ALIVE    NTSC COLORS DRIFTING    "
         "    LOAD\"$\",8 LIST    NEVER STOP    ", "V", BgKind::Voronoi},
    }};

    // ─── 3 · ACID JOURNEY ────────────────────────────────────
    demos_[2] = {"ACID JOURNEY", {
        {"DROP IN",    20.0f, stars(),   SS::Neon,
         "    THE ACID IS KICKING IN    303 STARTING TO CRY    "
         "    BREATHE SLOW    LET IT TAKE YOU    ", "A", BgKind::PlasmaFbm},
        {"303 LOOP",   35.0f, all(),     SS::Wavy3D,
         "    SQUELCH OF THE 303    FOREVER ASCENDING    "
         "    THE PATTERN NEVER REPEATS    HUE CYCLES IN OUR EYES    "
         "    EVERY KNOB TURN A NEW UNIVERSE    ", "H", BgKind::Truchet},
        {"SUPERSHAPE", 25.0f, polSpe(),  SS::Wavy3D,
         "    GEOMETRIC PSYCHEDELIA    GIELIS SUPER FORMULA    "
         "    LIVING SHAPE BREATHING    ", "D", BgKind::Supershape},
        {"PEAK",       30.0f, all(),     SS::Rainbow,
         "    FULL ACID    NEURONS DANCING    REALITY MELTING    "
         "    THE FILTER OPENS THE FILTER CLOSES    "
         "    AND THE HEAVENS SQUELCH BACK    ", "H", BgKind::Metaballs},
        {"VORTEX",     20.0f, all(),     SS::Rainbow,
         "    DOWN THE SPIRAL    YOUR EGO DISSOLVES    "
         "    GREETINGS TO MERCURY AND TBL    ", "H", BgKind::Vortex},
        {"DNA HELIX",  25.0f, polSpe(),  SS::Cascade,
         "    DOUBLE HELIX OF SOUND    THE ACID IS IN OUR DNA    "
         "    A T C G TURNED INTO A B Eb F#    ", "D", BgKind::DNA},
        {"ROSACE",     20.0f, polSpe(),  SS::Wavy3D,
         "    K=5 PETALS POLAR ROSE    EACH PETAL A KICK    ", "H",
         BgKind::Rose3d},
        {"ATARI",      18.0f, polSpe(),    SS::Rainbow,
         "    *** ATARI 2600 ***    PRINT FROM 1977    "
         "    THE FIRST PIXEL TRIPS WERE HERE    ", "A",
         BgKind::ImgAtari},
        {"COMEDOWN",   22.0f, starsWave(), SS::Mirror,
         "    AND SOFTLY BACK TO EARTH    "
         "    THE 303 SLEEPS IN ITS CIRCUIT    ", "J", BgKind::Caustics},
    }};

    // ─── 4 · TUNNEL VISION ───────────────────────────────────
    demos_[3] = {"TUNNEL VISION", {
        {"DARK",     20.0f, tunOnly(), SS::Classic,
         "    INTO THE DARK    DEEPER WE GO    "
         "    NO STARS NO POLAR ONLY THE TUNNEL    "
         "    HOLD YOUR BREATH    ", "P", BgKind::SdfTunnel},
        {"TREFOIL",  35.0f, tunOnly(), SS::Wavy3D,
         "    THE PATH KNOTS ITSELF    A REAL TREFOIL KNOT    "
         "    NO ENDS NO BEGINNINGS    PURE TOPOLOGY    ", "S",
         BgKind::TrefoilTube},
        {"TORUS",    35.0f, tunOnly(), SS::Wavy3D,
         "    REALITY BENDS AROUND THE PATH    "
         "    A NEVER ENDING TORUS    "
         "    GREETINGS TO ANDROMEDA AND CONSPIRACY    ", "E",
         BgKind::TorusMesh},
        {"NEXT CUBE",30.0f, polSpe(), SS::Chrome,
         "    THE NEXT CUBE APPEARS    OBJC SMALLTALK 1989    "
         "    THE OS THAT BIRTHED OS X    ", "F", BgKind::ImgNeXT},
        {"OPENING",  25.0f, all(),     SS::Rainbow,
         "    THE TUNNEL OPENS UP    EVERYTHING APPEARS    "
         "    PURE LIGHT BEYOND THE WALLS    ", "F", BgKind::Tunnel},
    }};

    // ─── 5 · FREQUENCIES ─────────────────────────────────────
    demos_[4] = {"FREQUENCIES", {
        {"SILENCE",     12.0f, polSpe(), SS::Neon,
         "    LISTEN    THE SPECTRUM IS LOADING    "
         "    FFT 1024 BINS WAITING    ", "I", BgKind::PlasmaFbm},
        {"BANDS",       30.0f, polSpe(), SS::Wavy3D,
         "    20HZ TO 200HZ : BASS    "
         "    200 TO 800 : LOW MID    800 TO 3200 : MID    "
         "    3200+ : TREBLE    EVERY HZ HAS A COLOR    "
         "    NYQUIST IS WATCHING    ", "W", BgKind::PlasmaFbm},
        {"HARMONICS",   30.0f, polSpe(), SS::Mirror,
         "    EVERY NOTE A SERIES    FUNDAMENTAL PLUS OVERTONES    "
         "    THE HARMONIC LADDER NEVER ENDS    ", "C", BgKind::Truchet},
        {"LORENZ",      25.0f, polSpe(), SS::Cascade,
         "    THE STRANGE ATTRACTOR    CHAOS IN ORDER    "
         "    EVERY SIGNAL TENDS HERE    ", "Q", BgKind::Lorenz},
        {"HOPF",        25.0f, polSpe(), SS::Wavy3D,
         "    HOPF FIBRATION    SPHERES INSIDE SPHERES    "
         "    HARMONIC DECOMPOSITION VISUAL    ", "B", BgKind::Hopf},
        {"LOTUS",       22.0f, polSpe(), SS::Cascade,
         "    LOTUS 1-2-3    THE FIRST KILLER APP    "
         "    SPREADSHEET MEETS SPECTRUM    ", "Q", BgKind::ImgLotus},
        {"SPHERE",      25.0f, polSpe(), SS::Wavy3D,
         "    THE SPECTRUM AS A SPHERE    "
         "    EACH LONGITUDE A FREQUENCY    EACH LATITUDE A TIME    ", "Q",
         BgKind::SphereWave},
        {"FULL",        22.0f, all(),    SS::Rainbow,
         "    EVERY BAND ALIVE NOW    THE SPECTRUM IS COMPLETE    "
         "    FROM SUBSONIC TO ULTRASONIC    ", "Q", BgKind::Voronoi},
    }};

    // ─── 6 · GLITCH WORLD ────────────────────────────────────
    demos_[5] = {"GLITCH WORLD", {
        {"PROBE",     15.0f, stars(),  SS::Neon,
         "    SCANNING ANOMALY    REALITY UNSTABLE    "
         "    SIGNAL FROM AN UNKNOWN DIMENSION    ", "V", BgKind::Voronoi},
        {"HOPF LINK", 22.0f, polSpe(), SS::Glitch,
         "    TWO RINGS LINKED IMPOSSIBLY    "
         "    TOPOLOGY CORRUPT    ", "V", BgKind::HopfLink},
        {"DATA",      22.0f, all(),    SS::Glitch,
         "    BUFFER OVERFLOW    SIGNAL CORRUPTED    "
         "    THE GHOSTS IN THE WIRES ARE WAKING UP    ", "D",
         BgKind::DotTunnel},
        {"FRACTALS",  22.0f, all(),    SS::Glitch,
         "    EVERY PIXEL CONTAINS AN ERROR    "
         "    ERROR IS THE NEW NORMAL    "
         "    GREETINGS TO HACKERS EVERYWHERE    ", "V", BgKind::TunnelCubes},
        {"CRASH",     20.0f, all(),    SS::Glitch,
         "    KERNEL PANIC    BUT THE BEAT GOES ON    ", "Q",
         BgKind::PlasmaC64},
        {"OS/2 WARP",  20.0f, polSpe(), SS::Glitch,
         "    OS/2 WARP    THE BLUE SCREEN OF DESPAIR    "
         "    BUT THE WARP CONTINUES    ", "Q", BgKind::ImgOS2},
        {"REBOOT",    18.0f, polSpe(), SS::Cascade,
         "    SYSTEM REINITIALIZING    NEW REALITY LOADING    ", "Q",
         BgKind::Vortex},
    }};

    // ─── 7 · AMBIENT VOID ────────────────────────────────────
    demos_[6] = {"AMBIENT VOID", {
        {"DRIFT",      30.0f, stars(),     SS::Neon,
         "    NO BEAT    JUST THE DRIFT    SLOW STARS PASSING    "
         "    SOMEWHERE BEYOND THE OORT CLOUD    ", "J", BgKind::Starfield},
        {"OCTAHEDRON", 25.0f, polSpe(),    SS::Chrome,
         "    A CHROME OCTAHEDRON IN THE VOID    "
         "    REFLECTING NO ONE    ", "O", BgKind::Octahedron},
        {"CAUSTICS",   30.0f, polSpe(),    SS::Mirror,
         "    LIGHT THROUGH WATER    "
         "    THE SURFACE OF DEEP TIME    ", "S", BgKind::Caustics},
        {"DEEPNESS",   30.0f, polSpe(),    SS::Mirror,
         "    THE SPECTRUM BREATHES    SLOW AND BLUE    "
         "    20HZ DRONES UNDER THE SKIN    ", "S", BgKind::PlasmaFbm},
        {"ENNEPER",    25.0f, polSpe(),    SS::Mirror,
         "    A MINIMAL SURFACE    LEAST AREA POSSIBLE    "
         "    GREETINGS TO ASD AND CONSPIRACY    ", "S", BgKind::Enneper},
        {"SPHERE",     22.0f, polSpe(),    SS::Mirror,
         "    THE SPECTRUM AS A LIVING SPHERE    "
         "    EVERY HZ A LATITUDE    EVERY KICK A PULSE    ", "S",
         BgKind::SphereWave},
        {"APPLE",      18.0f, polSpe(),    SS::Mirror,
         "    THE APPLE RAINBOW    1977 - 1998    "
         "    SIX BANDS OF GENTLE NOSTALGIA    ", "J", BgKind::ImgApple},
        {"RETURN",     22.0f, starsWave(), SS::Mirror,
         "    AND BACK TO THE STARS    REMEMBER THIS QUIET    "
         "    YOU WERE HERE    YOU LISTENED    ", "J", BgKind::Starfield},
    }};

    // ─── 8 · RAVE ────────────────────────────────────────────
    demos_[7] = {"RAVE", {
        {"BUILD",      20.0f, all(),  SS::Wavy3D,
         "    180 BPM    HOLD ON    "
         "    THE WAREHOUSE IS WAITING    "
         "    SECURITY GUARD ALREADY GIVE UP    ", "T", BgKind::Twister},
        {"DROP",       20.0f, all(),  SS::Glitch,
         "    HERE WE GO    KICK PUNCH MAXIMUM    "
         "    GABBA GABBA HEY    ", "T", BgKind::Voronoi},
        {"MODE7",      25.0f, all(),  SS::Bouncy,
         "    LASER FLOOR    CARS ZOOMING ON THE NEON GRID    "
         "    SUPER SCALER NEVER FORGOTTEN    ", "T", BgKind::Mode7},
        {"FULL POWER", 35.0f, all(),  SS::Rainbow,
         "    NO SLEEP TIL DAWN    KICK BIAS MAXIMUM    "
         "    HARDCORE NEVER DIES    GREETINGS TO ALL RAVERS    "
         "    ALSO TO RAZOR 1911 AND FAIRLIGHT    ", "T", BgKind::Truchet},
        {"INFERNO",    25.0f, all(),  SS::Glitch,
         "    BURN THIS PLACE DOWN    180 BPM ETERNAL FLAME    "
         "    GREETINGS TO HARDCODE TITAN ANARCHY    ", "T",
         BgKind::Fire},
        {"WIN95",      18.0f, all(),    SS::Glitch,
         "    WINDOWS 95 START.WAV    BRIAN ENO 6 SECONDS    "
         "    PLUG AND PRAY    ", "Q", BgKind::ImgWin95},
        {"AFTER",      20.0f, polSpe(), SS::Mirror,
         "    THE BEAT IS GONE    THE WALLS STILL SHAKE    "
         "    YOUR HEART STILL THINKS IT IS RAVING    ", "Q",
         BgKind::PlasmaFbm},
    }};

    // ─── 9 · MEMORY LANE ─────────────────────────────────────
    demos_[8] = {"MEMORY LANE", {
        {"REWIND",    18.0f, starsWave(), SS::Mirror,
         "    *** AESTHETIC MODE ***    SLOWING DOWN    "
         "    LET THE TAPE WHIRR    ", "U", BgKind::Rotozoom},
        {"NEXTSTEP",  18.0f, polSpe(),    SS::Chrome,
         "    NEXTSTEP / OPENSTEP    1989 PURE OBJC ELEGANCE    "
         "    GREETINGS TO STEVE JOBS AND BERTRAND SERLET    ", "U",
         BgKind::ImgNeXT},
        {"BEOS",      18.0f, polSpe(),    SS::Cascade,
         "    BEOS R5    1995 MULTITASK BEAUTIFUL    "
         "    JEAN LOUIS GASSEE WAS RIGHT    ", "R", BgKind::ImgBeOS},
        {"STATUE",    30.0f, polSpe(),    SS::Chrome,
         "    AN ANGEL FROM THE STANFORD ARCHIVE    "
         "    LUCY 50K POINTS MARBLE FROZEN IN TIME    ", "R",
         BgKind::Lucy},
        {"VAPOR",     35.0f, all(),       SS::Mirror,
         "    PINK NEON ON CHROME    LOST PALACES OF THE 90S    "
         "    THIS IS HOW WE REMEMBER YOU    "
         "    GREETINGS TO TPOLM AND PLASTIC    ", "R", BgKind::Rotozoom},
        {"HELIX",     25.0f, polSpe(),    SS::Cascade,
         "    DNA OF MEMORY    SPIRAL OF LOST AFTERNOONS    ", "R",
         BgKind::Helix},
        {"CATENOID",  25.0f, polSpe(),    SS::Mirror,
         "    THE SHAPE OF A SOAP FILM    "
         "    MINIMAL SURFACE OF WHAT REMAINS    ", "U",
         BgKind::Catenoid},
        {"FOG",       25.0f, polSpe(),    SS::Mirror,
         "    THE MEMORY FADES    BUT NEVER DISAPPEARS    "
         "    UNTIL THE NEXT REWIND    ", "U", BgKind::PlasmaFbm},
    }};

    // ─── 10 · GREETINGS FROM SAILLANS (megamix demoscene) ────
    demos_[9] = {"GREETINGS", {
        {"OPENING",    15.0f, stars(),  SS::Neon,
         "    *** GREETINGS FROM SAILLANS 2026 ***    "
         "    *** AV-LIVE / L-ELECTRON RARE ***    ", "W",
         BgKind::Starfield},
        {"BOING BALL", 18.0f, all(),    SS::Bouncy,
         "    AMIGA SECTION    1984 BOING BALL    "
         "    THE FIRST DEMO EVER    ", "M", BgKind::BoingBall},
        {"PLASMA",     18.0f, all(),    SS::Wavy3D,
         "    C64 SECTION    PLASMA EFFECT 8 COLORS    ", "M",
         BgKind::PlasmaC64},
        {"DOT TUNNEL", 18.0f, all(),    SS::Cascade,
         "    DOT TUNNEL CLASSIC    1MHZ POWER    ", "M",
         BgKind::DotTunnel},
        {"TWISTER",    18.0f, all(),    SS::Classic,
         "    TWISTER BARS SPACEBALLS STYLE    ", "G", BgKind::Twister},
        {"COPPER",     15.0f, all(),    SS::Rainbow,
         "    COPPER LIST POETRY    ", "G", BgKind::Twister},
        {"FRACTAL",    15.0f, all(),    SS::Wavy3D,
         "    FRACTAL ITERATIONS    ", "L", BgKind::Kifs},
        {"MOBIUS",     15.0f, all(),    SS::Mirror,
         "    IMPOSSIBLE TOPOLOGY    ", "L", BgKind::Mobius},
        {"WIN 1.0",    12.0f, polSpe(), SS::Bouncy,
         "    1985 WINDOWS 1.0    ", "M", BgKind::ImgWin1},
        {"LOTUS 123",  12.0f, polSpe(), SS::Cascade,
         "    LOTUS 1-2-3    SPREADSHEET KING 1983    ", "M",
         BgKind::ImgLotus},
        {"ATARI",      12.0f, polSpe(), SS::Rainbow,
         "    ATARI 1972    GAME OVER OR NEW HIGH SCORE    ", "M",
         BgKind::ImgAtari},
        {"APPLE 6",    12.0f, polSpe(), SS::Mirror,
         "    APPLE RAINBOW LOGO 1977    ", "M", BgKind::ImgApple},
        {"OS/2 WARP",  12.0f, polSpe(), SS::Cascade,
         "    OS/2 WARP IBM 1996    "
         "    THE OS THAT COULD HAVE BEEN    ", "M", BgKind::ImgOS2},
        {"IRIX SGI",   12.0f, polSpe(), SS::Chrome,
         "    SILICON GRAPHICS IRIX    JURASSIC PARK MAGIC    ",
         "M", BgKind::ImgIrix},
        {"ZX SPECTRUM",12.0f, polSpe(), SS::Rainbow,
         "    SINCLAIR 1982    UK COMPUTING REVOLUTION    ", "M",
         BgKind::ImgZX},
        {"ROLL CALL",  35.0f, all(),    SS::Rainbow,
         "    GREETINGS TO :: KXKM CREW :: HYPNEUM LAB :: SUPERCOLLIDER ::"
         "    OPENFRAMEWORKS HACKERS :: COOKIE COLLECTIVE :: ALL LIVE CODERS    "
         "    FAIRLIGHT :: RAZOR 1911 :: ANDROMEDA :: ASD :: CONSPIRACY ::"
         "    FARBRAUSCH :: MERCURY :: TPOLM :: TBL :: LOONIES ::"
         "    THIS SCROLLER IS FOR YOU    ", "L", BgKind::Truchet},
        {"DEMOSCENE",  25.0f, all(),    SS::Wavy3D,
         "    THE SCENE IS NOT DEAD    THE SCENE IS EVERYWHERE    "
         "    EVERY LIVE CODER A NEW MEMBER    "
         "    1985 - 2026 - INFINITY    ", "L", BgKind::TunnelCubes},
        {"FINALE",     25.0f, all(),    SS::Mirror,
         "    SEE YOU NEXT TIME    KEEP THE PHOSPHOR ALIVE    "
         "    AV-LIVE / L-ELECTRON RARE    "
         "    *** WRAP *** PRESS 1-9 0 TO RESTART ***    ", "U",
         BgKind::Starfield},
    }};

    // ─── 11 · ESCHER DREAMS (formes impossibles + KIFS) ──────
    demos_[10] = {"ESCHER DREAMS", {
        {"GENESIS",     18.0f, polSpe(), SS::Neon,
         "    *** ESCHER DREAMS ***    "
         "    IMPOSSIBLE GEOMETRY AWAKENS    ", "O", BgKind::Kifs},
        {"MOBIUS",      30.0f, polSpe(), SS::Wavy3D,
         "    A MOBIUS STRIP    ONE SIDE ONE EDGE    "
         "    WALK FOREVER YOU WILL NEVER LEAVE    ", "S",
         BgKind::Mobius},
        {"KLEIN",       30.0f, polSpe(), SS::Wavy3D,
         "    A KLEIN BOTTLE    INSIDE IS OUTSIDE    "
         "    THE FOURTH DIMENSION SHOWS ITS HAND    ", "S",
         BgKind::Klein},
        {"PENROSE",     25.0f, polSpe(), SS::Mirror,
         "    THE PENROSE TRIANGLE    THREE BARS THREE LIES    "
         "    GREETINGS TO M.C.ESCHER AND ROGER PENROSE    ", "L",
         BgKind::Penrose},
        {"BOYS",        25.0f, polSpe(), SS::Wavy3D,
         "    BOYS SURFACE    NON ORIENTABLE    "
         "    YOUR INTUITION FAILS HERE    ", "L", BgKind::Boys},
        {"INFINITE",    25.0f, all(),    SS::Rainbow,
         "    THE FRACTAL HAS NO END    "
         "    GREETINGS TO INIGO QUILEZ AND KNIGHTY    ", "L",
         BgKind::Kifs},
        {"WIN 1.0",     18.0f, polSpe(), SS::Bouncy,
         "    WINDOWS 1.0 1985    LEAKED PIXELS BLOCK BY BLOCK    "
         "    GEOMETRY BEFORE GUI    ", "O", BgKind::ImgWin1},
        {"COLLAPSE",    18.0f, polSpe(), SS::Mirror,
         "    AND THE FUNCTION RESETS    BACK TO X = 0 Y = 0    ", "J",
         BgKind::Starfield},
    }};

    // ─── 12 · INFERNO ────────────────────────────────────────
    demos_[11] = {"INFERNO", {
        {"SPARK",      15.0f, stars(),   SS::Glitch,
         "    *** INFERNO ***    A SPARK IGNITES IN THE DARK    "
         "    THE FIRST PHOTON HITS YOUR EYE    ", "E", BgKind::Fire},
        {"WILDFIRE",   25.0f, all(),     SS::Glitch,
         "    FLAMES RISE FAST    EVERY KICK FUELS THE BLAZE    "
         "    GREETINGS TO HARDCODE AND TITAN    ", "T", BgKind::Vortex},
        {"DOS PROMPT",  20.0f, polSpe(),  SS::Glitch,
         "    C:\\DEMOS\\>RUN INFERNO.EXE    "
         "    640K OUGHT TO BE ENOUGH FOR ANYBODY    ", "E",
         BgKind::ImgDos},
        {"FORGE",      25.0f, polSpe(),  SS::Cascade,
         "    THE METAL MELTS    GEAR TURNS IN MOLTEN STEEL    "
         "    HEPHAESTUS WORKS THE ANVIL    ", "P", BgKind::Gear},
        {"PHOENIX",    25.0f, all(),     SS::Rainbow,
         "    BIRD OF FIRE    REBORN FROM ASHES    "
         "    KICK BIAS IS THE FUEL    ", "T", BgKind::Caustics},
        {"BURNOUT",    20.0f, all(),     SS::Glitch,
         "    EVERYTHING IS BURNING    NOTHING REMAINS    ",
         "T", BgKind::Fire},
        {"ASHES",      20.0f, polSpe(),  SS::Mirror,
         "    THE EMBERS COOL    BUT THEY DO NOT DIE    ", "S",
         BgKind::PlasmaFbm},
    }};

    // ─── 13 · OUTRUN ─────────────────────────────────────────
    demos_[12] = {"OUTRUN", {
        {"DRIVE OFF",   18.0f, all(),    SS::Neon,
         "    *** OUTRUN ***    SUNSET IGNITES THE HORIZON    "
         "    KEY IN THE IGNITION    GREETINGS TO STILL AND RGBA    ", "R",
         BgKind::GridPersp},
        {"NEON HIGHWAY",30.0f, all(),    SS::Mirror,
         "    PINK NEON ON CHROME ROAD    "
         "    1985 STILL SPEEDING    ", "R", BgKind::Mode7},
        {"DASHBOARD",   25.0f, polSpe(), SS::Chrome,
         "    SPEEDOMETER NEEDLE LOCKED IN ULTRA    "
         "    CHROME REFLECTIONS EVERYWHERE    ", "R",
         BgKind::Octahedron},
        {"OVERDRIVE",   30.0f, all(),    SS::Rainbow,
         "    KICKDOWN    THE VECTOR SUN BENDS    "
         "    EVERY MILE A NEW HUE    ", "T", BgKind::Rotozoom},
        {"VAPOR ROAD",  25.0f, all(),    SS::Mirror,
         "    THE ROAD BECOMES MEMORY    "
         "    GHOSTS OF FUTURES PAST    ", "R", BgKind::PlasmaFbm},
        {"WIN 3.11",    18.0f, all(),    SS::Glitch,
         "    WINDOWS 3.11 NT    THE PRE-START-MENU AGE    "
         "    PROGRAM MANAGER RULES    ", "R", BgKind::ImgWin311},
        {"HORIZON",     18.0f, starsWave(), SS::Mirror,
         "    AND THE ENGINE FADES INTO THE NIGHT    "
         "    UNTIL TOMORROW WE RIDE AGAIN    ", "U",
         BgKind::Starfield},
    }};

    // ─── 14 · CUBE STORM (geometry attack 3D) ────────────────
    demos_[13] = {"CUBE STORM", {
        {"INCOMING",  18.0f, polSpe(),   SS::Neon,
         "    *** CUBE STORM ***    SOMETHING APPROACHES    ", "Q",
         BgKind::TunnelCubes},
        {"VECTOR",    25.0f, all(),      SS::Wavy3D,
         "    VECTOR CUBES    REAL 3D MESH    "
         "    EACH CUBE A SEPARATE EXISTENCE    ", "Q",
         BgKind::VectorCubes},
        {"ICOSAHEDRON",25.0f, polSpe(),  SS::Glitch,
         "    PLATONIC SOLIDS RETURN    20 FACES OF GREEK GEOMETRY    ", "V",
         BgKind::IcoMesh},
        {"DODEC",     25.0f, polSpe(),   SS::Glitch,
         "    THE DODECAHEDRON    12 PENTAGONS PERFECT    "
         "    GREETINGS TO FARBRAUSCH AND ANDROMEDA    ", "V",
         BgKind::DodMesh},
        {"CASCADE",   25.0f, all(),      SS::Wavy3D,
         "    EUCLIDEAN SPACE COLLAPSES    THE CUBES SCATTER    ", "Q",
         BgKind::TunnelCubes},
        {"ZX",        20.0f, polSpe(),   SS::Rainbow,
         "    SINCLAIR ZX SPECTRUM    7 ATTRIBUTES OF GLORY    "
         "    UK SCENE FOREVER    ", "Q", BgKind::ImgZX},
        {"IRIX",      20.0f, polSpe(),   SS::Chrome,
         "    SGI IRIX WORKSTATION    9 SQUARES OF POWER    "
         "    JURASSIC PARK MAGIC    ", "V", BgKind::ImgIrix},
        {"ZERO",      18.0f, polSpe(),   SS::Mirror,
         "    BACK TO ORIGIN    DIMENSION COLLAPSED    ", "J",
         BgKind::Starfield},
    }};

    // ─── 15 · GRAND FINAL (mix everything) ───────────────────
    demos_[14] = {"GRAND FINAL", {
        {"OPEN",       12.0f, stars(),    SS::Neon,
         "    *** GRAND FINAL ***    ALL EFFECTS ENGAGED    ", "L",
         BgKind::Starfield},
        {"AMIGA",      18.0f, all(),      SS::Classic,
         "    AMIGA SECTION    COPPER LIST POETRY    ", "M",
         BgKind::Twister},
        {"FRACTAL",    18.0f, all(),      SS::Wavy3D,
         "    FRACTAL SECTION    INFINITE DETAIL    ", "L",
         BgKind::Kifs},
        {"FIRE",       18.0f, all(),      SS::Glitch,
         "    FIRE SECTION    BURN IT ALL DOWN    ", "T",
         BgKind::Fire},
        {"OUTRUN",     18.0f, all(),      SS::Mirror,
         "    OUTRUN SECTION    NEON HORIZON    ", "R",
         BgKind::GridPersp},
        {"CUBES",      18.0f, all(),      SS::Wavy3D,
         "    CUBE STORM    GEOMETRY ATTACK    ", "Q",
         BgKind::TunnelCubes},
        {"SPHERE",     20.0f, polSpe(),   SS::Wavy3D,
         "    SPECTRUM SPHERE    THE FREQUENCIES BECOME GEOMETRY    ", "U",
         BgKind::SphereWave},
        {"FAREWELL",   25.0f, all(),      SS::Rainbow,
         "    THIS IS THE GRAND FINAL    "
         "    GREETINGS TO EVERYBODY OUT THERE    "
         "    THE SCENE IS YOUR FAMILY    THE PHOSPHOR YOUR HOME    "
         "    AV-LIVE / L-ELECTRON RARE / 2026    ", "U",
         BgKind::PlasmaFbm},
    }};

    launchDemo(0);
}

void ofApp::launchDemo(int demoIdx) {
    if (demoIdx < 0 || demoIdx >= (int)demos_.size()) return;
    // Si beat-sync actif, on queue plutôt que de fire immédiatement.
    // Le déclenchement aura lieu au prochain rising edge du kick.
    if (beatSyncEnabled_) {
        pendingDemoIdx_ = demoIdx;
        ofLogNotice("ofApp") << "queued demo " << demoIdx
                             << " (waiting kick)";
        return;
    }
    currentDemo_  = demoIdx;
    narrativeMode_ = true;
    enterScene(0);
    ofLogNotice("ofApp") << "launch demo " << demoIdx
                         << " : " << demos_[demoIdx].name
                         << " (" << demos_[demoIdx].scenes.size() << " scenes)";
}

void ofApp::enterScene(int idx) {
    auto& scenes = demos_[currentDemo_].scenes;
    if (idx < 0 || idx >= (int)scenes.size()) {
        // Fin de démo : reboucle sur la 1ère scène.
        idx = 0;
    }
    narrativeIdx_ = idx;
    narrativeT_   = 0.0f;
    const auto& s = scenes[idx];
    scope4_ = s.toggles;
    demo_.setScrollerStyle(s.scroller);
    demo_.setText(s.narration);
    if (s.albumLetter) {
        osc_.sendControl("/control/playAlbum", std::string(s.albumLetter));
    }
    // Transition punchy : flash blanc + glitch postfx 0.5s.
    transitionFlash_ = 1.0f;
    postfx_.triggerGlitch(0.7f, 0.45f);
    // Transition FBO crossfade/dissolve/wipe en plus du flash.
    beginTransition();
}

void ofApp::updateNarrative(float dt) {
    if (transitionFlash_ > 0.0f) transitionFlash_ -= dt / 0.45f;
    if (transitionFlash_ < 0.0f) transitionFlash_ = 0.0f;
    if (!narrativeMode_) return;
    auto& scenes = demos_[currentDemo_].scenes;
    if (narrativeIdx_ >= (int)scenes.size()) return;
    narrativeT_ += dt;
    if (narrativeT_ >= scenes[narrativeIdx_].durSec) {
        if (narrativeIdx_ + 1 >= (int)scenes.size()) {
            // Fin : reboucle l'acte 0 de la même démo (loop continu).
            enterScene(0);
        } else {
            enterScene(narrativeIdx_ + 1);
        }
    }
}

void ofApp::drawNarrativeOverlay(int W, int H) {
    if (!narrativeMode_) return;
    auto& scenes = demos_[currentDemo_].scenes;
    if (narrativeIdx_ >= (int)scenes.size()) return;
    const auto& s = scenes[narrativeIdx_];
    const float prog = std::min(1.0f, narrativeT_ / s.durSec);

    ofPushStyle();
    ofEnableBlendMode(OF_BLENDMODE_ADD);

    const float fadeIn  = std::min(1.0f, narrativeT_ / 1.5f);
    const float fadeOut = std::min(1.0f, (s.durSec - narrativeT_) / 1.5f);
    const int alpha = static_cast<int>(220 * std::min(fadeIn, fadeOut));

    // Ligne 1 : nom de la démo (petit)
    const std::string demoTag = std::string("DEMO ") +
        ofToString(currentDemo_ + 1) + " / " + ofToString(demos_.size()) + "   " +
        demos_[currentDemo_].name;
    ofSetColor(140, 255, 200, alpha);
    ofDrawBitmapString(demoTag, W / 2 - demoTag.size() * 4, 18);

    // Ligne 2 : "ACT N — TITLE" en gros
    const std::string title = "ACT " + ofToString(narrativeIdx_ + 1) +
                              " / " + ofToString(scenes.size())
                              + "   " + s.name;
    ofSetColor(255, 200, 100, alpha);
    ofPushMatrix();
    ofTranslate(W / 2 - title.size() * 10, 50);
    ofScale(2.5f, 2.5f, 1.0f);
    ofDrawBitmapString(title, 0, 0);
    ofPopMatrix();

    // Barre de progression — y=92 pour être sous le titre 2.5× (qui
    // s'étend jusqu'à y≈80) sans chevauchement.
    const float barW = W - 200;
    ofSetColor(60, 130, 80, 140);
    ofDrawRectangle(100, 92, barW, 3);
    ofSetColor(100, 220, 140, 220);
    ofDrawRectangle(100, 92, barW * prog, 3);

    // Hint clavier — placé à H-78 pour ne pas chevaucher le hint FX
    // (à H-50) ni l'overlay slots (H-22 à H-4).
    ofSetColor(200, 220, 220, 180);
    ofDrawBitmapString("[1-9 0 ! @ # $ %] demo   [enter] next act   "
                       "[esc / q] live mode",
                       100, H - 78);

    ofDisableBlendMode();
    ofPopStyle();
}

void ofApp::drawTunnelHud(int W, int H) {
    const float now = ofGetElapsedTimef();
    const float dt  = ofGetLastFrameTime();

    // Refresh : ajoute 1-2 nouveaux items toutes les ~150-400 ms et
    // remplace les expirés. Cap à 14 items simultanés.
    if (now > tunnelHudNext_) {
        tunnelHudNext_ = now + ofRandom(0.15f, 0.40f);
        const int spawn = static_cast<int>(ofRandom(1.0f, 2.99f));
        for (int s = 0; s < spawn && tunnelHud_.size() < 14; ++s) {
            HudItem it;
            // Position périphérique : on évite le quart central pour rester
            // "sur les carreaux" et pas sur le polar/spectro.
            const float ang = ofRandom(0.0f, TWO_PI);
            const float rad = ofRandom(0.34f, 0.49f);  // 0.5 = bord
            it.u = 0.5f + std::cos(ang) * rad;
            it.v = 0.5f + std::sin(ang) * rad;
            it.life  = ofRandom(0.8f, 2.5f);
            it.born  = 0.0f;
            it.scale = ofRandom(0.85f, 1.4f);
            const float hz = ofRandom(0.2f, 9.9f);
            it.text  = ofToString(hz, 1) + " Hz";
            const char* labels[] = {
                "LFO", "DC ", "DRIFT", "SUB", "MOD", "Δf",
                "PHASE", "GAIN", "BIAS", "TRIG", "PWM", "ENV"
            };
            it.label = labels[static_cast<int>(ofRandom(0, 12))];
            tunnelHud_.push_back(std::move(it));
        }
    }

    // Update + draw
    ofPushStyle();
    ofEnableBlendMode(OF_BLENDMODE_ADD);
    for (auto it = tunnelHud_.begin(); it != tunnelHud_.end(); ) {
        it->life -= dt;
        it->born += dt;
        if (it->life <= 0.0f) {
            it = tunnelHud_.erase(it);
            continue;
        }
        // Alpha : fade-in 0.2s, fade-out dernière 0.4s.
        const float fadeIn  = std::min(1.0f, it->born / 0.2f);
        const float fadeOut = std::min(1.0f, it->life / 0.4f);
        const int alpha = static_cast<int>(180 * fadeIn * fadeOut);

        const float x = it->u * W;
        const float y = it->v * H;

        // Petit cadre rectangulaire phosphor — texture "tile HUD".
        ofPushMatrix();
        ofTranslate(x, y);
        // Léger flicker scintillant
        const float jitter = std::sin(now * 30.0f + it->born * 10.0f) * 0.5f + 0.5f;
        ofSetColor(0, 200, 130, static_cast<int>(alpha * (0.4f + 0.6f * jitter)));
        ofNoFill();
        ofSetLineWidth(1);
        const float bw = 64 * it->scale;
        const float bh = 22 * it->scale;
        ofDrawRectangle(-bw * 0.5f, -bh * 0.5f, bw, bh);
        // Tick gauche (style cadran)
        ofDrawLine(-bw * 0.5f - 6, 0, -bw * 0.5f, 0);

        // Texte
        ofSetColor(120, 255, 180, alpha);
        ofDrawBitmapString(it->label, -bw * 0.5f + 4, -bh * 0.5f + 9);
        ofSetColor(80, 220, 140, alpha);
        ofDrawBitmapString(it->text,  -bw * 0.5f + 4,  bh * 0.5f - 3);
        ofPopMatrix();
        ++it;
    }
    ofDisableBlendMode();
    ofPopStyle();
}

void ofApp::drawPanelLabel(int x, int y, const char* title,
                           const std::string& metric) {
    ofPushStyle();
    ofSetColor(0, 0, 0, 140);
    const int textW = 6 * (static_cast<int>(metric.size()) + 16);
    ofDrawRectangle(x, y, textW + 14, 22);
    ofSetColor(0, 255, 140, 220);
    ofDrawBitmapString(title, x + 8, y + 14);
    ofSetColor(180, 230, 200, 220);
    ofDrawBitmapString(metric, x + 8 + 6 * 8, y + 14);
    ofPopStyle();
}

void ofApp::drawScope4(int W, int H) {
    // Synchronise les flags du DemoFx avec ScopeToggles (touches 0-9).
    demo_.scrollerEnabled()  = scope4_.scroller;
    demo_.copperEnabled()    = scope4_.copperBars;
    demo_.bobsEnabled()      = scope4_.bobs;
    demo_.starfieldEnabled() = scope4_.starfield;

    // 1) Fond : 3 sources priorité haute-bas :
    //    1. mode narratif → scène active dicte le bg
    //    2. liveBgOverride_ → bg sélectionné par l'utilisateur via touche
    //    3. fallback toggles starfield/tunnel
    BgKind bg = BgKind::Tunnel;
    if (narrativeMode_ && currentDemo_ < (int)demos_.size() &&
        narrativeIdx_ < (int)demos_[currentDemo_].scenes.size()) {
        bg = demos_[currentDemo_].scenes[narrativeIdx_].background;
    } else if (liveBgOverride_) {
        bg = liveBg_;
    } else if (scope4_.starfield) {
        bg = BgKind::Starfield;
    } else if (!scope4_.tunnel) {
        bg = BgKind::Tunnel;
    }
    switch (bg) {
        case BgKind::Tunnel:
            if (scope4_.tunnel) tunnel_->draw(0, 0, W, H);
            else                ofBackground(0);
            break;
        case BgKind::Starfield: ofBackground(0); demo_.drawStarfield(W, H); break;
        case BgKind::Metaballs: metaballs_->draw(0, 0, W, H); break;
        case BgKind::Voronoi:   voronoi_->draw(0, 0, W, H);   break;
        case BgKind::Twister:   twister_->draw(0, 0, W, H);   break;
        case BgKind::PlasmaFbm: plasmaFbm_->draw(0, 0, W, H); break;
        case BgKind::Rotozoom:  rotozoom_->draw(0, 0, W, H);  break;
        case BgKind::Truchet:   truchet_->draw(0, 0, W, H);   break;
        case BgKind::SdfTunnel: sdfTunnel_->draw(0, 0, W, H); break;
        case BgKind::Kifs:        kifs_->draw(0, 0, W, H);        break;
        case BgKind::Fire:        fire_->draw(0, 0, W, H);        break;
        case BgKind::GridPersp:   gridPersp_->draw(0, 0, W, H);   break;
        case BgKind::TunnelCubes: tunnelCubes_->draw(0, 0, W, H); break;
        case BgKind::Caustics:    caustics_->draw(0, 0, W, H);    break;
        case BgKind::Vortex:      vortex_->draw(0, 0, W, H);      break;
        case BgKind::Octahedron:  octahedron_->draw(0, 0, W, H);  break;
        case BgKind::VectorCubes: vectorCubes_->draw(0, 0, W, H); break;
        case BgKind::Mobius:       modelMobius_->draw(0, 0, W, H);       break;
        case BgKind::Klein:        modelKlein_->draw(0, 0, W, H);        break;
        case BgKind::TrefoilTube:  modelTrefoil_->draw(0, 0, W, H);      break;
        case BgKind::TwistedTorus: modelTwistedTorus_->draw(0, 0, W, H); break;
        case BgKind::Lucy:         modelLucy_->draw(0, 0, W, H);         break;
        case BgKind::Helix:        modelHelix_->draw(0, 0, W, H);        break;
        case BgKind::Catenoid:     modelCatenoid_->draw(0, 0, W, H);     break;
        case BgKind::Hyperboloid:  modelHyperboloid_->draw(0, 0, W, H);  break;
        case BgKind::Boys:         modelBoys_->draw(0, 0, W, H);         break;
        case BgKind::Lemniscate:   modelLemniscate_->draw(0, 0, W, H);   break;
        case BgKind::Penrose:      modelPenrose_->draw(0, 0, W, H);      break;
        case BgKind::Sphere3D:     modelSphere_->draw(0, 0, W, H);       break;
        case BgKind::IcoMesh:      modelIco_->draw(0, 0, W, H);          break;
        case BgKind::DodMesh:      modelDod_->draw(0, 0, W, H);          break;
        case BgKind::TorusMesh:    modelTorus_->draw(0, 0, W, H);        break;
        case BgKind::Supershape:   modelSupershape_->draw(0, 0, W, H);   break;
        case BgKind::Lorenz:       modelLorenz_->draw(0, 0, W, H);       break;
        case BgKind::Hopf:         modelHopf_->draw(0, 0, W, H);         break;
        case BgKind::Enneper:      modelEnneper_->draw(0, 0, W, H);      break;
        case BgKind::HopfLink:     modelHopfLink_->draw(0, 0, W, H);     break;
        case BgKind::BoingBall:    boing_->draw(0, 0, W, H);             break;
        case BgKind::Mode7:        mode7_->draw(0, 0, W, H);             break;
        case BgKind::PlasmaC64:    plasmaC64_->draw(0, 0, W, H);         break;
        case BgKind::DotTunnel:    dotTunnel_->draw(0, 0, W, H);         break;
        case BgKind::Gear:         modelGear_->draw(0, 0, W, H);         break;
        case BgKind::Cone:         modelCone_->draw(0, 0, W, H);         break;
        case BgKind::Pyramid:      modelPyramid_->draw(0, 0, W, H);      break;
        case BgKind::Rose3d:       modelRose_->draw(0, 0, W, H);         break;
        case BgKind::Geosphere:    modelGeosphere_->draw(0, 0, W, H);    break;
        case BgKind::DNA:          modelDNA_->draw(0, 0, W, H);          break;
        case BgKind::Workbench:    workbench_->draw(0, 0, W, H);    break;
        case BgKind::MacOSClassic: macosClassic_->draw(0, 0, W, H); break;
        case BgKind::AtariFuji:    atariFuji_->draw(0, 0, W, H);    break;
        case BgKind::OceanLoader:  oceanLoader_->draw(0, 0, W, H);  break;
        case BgKind::Win95:        win95_->draw(0, 0, W, H);        break;
        case BgKind::SphereWave:   sphereWave_->draw(0, 0, W, H);   break;
        case BgKind::ImgWin1:    imgWin1_->draw(0, 0, W, H);    break;
        case BgKind::ImgWin311:  imgWin311_->draw(0, 0, W, H);  break;
        case BgKind::ImgWin95:   imgWin95_->draw(0, 0, W, H);   break;
        case BgKind::ImgLotus:   imgLotus_->draw(0, 0, W, H);   break;
        case BgKind::ImgDos:     imgDos_->draw(0, 0, W, H);     break;
        case BgKind::ImgAtari:   imgAtari_->draw(0, 0, W, H);   break;
        case BgKind::ImgApple:   imgApple_->draw(0, 0, W, H);   break;
        case BgKind::ImgWB:      imgWB_->draw(0, 0, W, H);      break;
        case BgKind::ImgNeXT:    imgNeXT_->draw(0, 0, W, H);    break;
        case BgKind::ImgBeOS:    imgBeOS_->draw(0, 0, W, H);    break;
        case BgKind::ImgOS2:     imgOS2_->draw(0, 0, W, H);     break;
        case BgKind::ImgIrix:    imgIrix_->draw(0, 0, W, H);    break;
        case BgKind::ImgZX:      imgZX_->draw(0, 0, W, H);      break;
        case BgKind::ImgC64:     imgC64_->draw(0, 0, W, H);     break;
        case BgKind::Webcam:     webcam_->draw(0, 0, W, H);     break;
    }

    // 1bis) HUD pseudo-aléatoire de valeurs sub-10 Hz.
    if (scope4_.tunnelHud) drawTunnelHud(W, H);

    // 1ter) Copper bars + logo bobs.
    if (scope4_.copperBars) demo_.drawCopperBars(W, H);
    if (scope4_.bobs)       demo_.drawBobs(W, H, "AV-LIVE");

    const float cx = W * 0.5f;
    const float cy = H * 0.5f;
    const float minSide = static_cast<float>(std::min(W, H));

    // 2) Polar central — toggle 8.
    if (scope4_.polar) {
        const int polarS = static_cast<int>(minSide * 0.55f);
        polar_->draw(static_cast<int>(cx) - polarS / 2,
                     static_cast<int>(cy) - polarS / 2,
                     polarS, polarS);
    }

    // 3) Spectro ring colorisé — toggle 7.
    if (scope4_.spectroRing) {
        const float ringInner = minSide * 0.27f;
        const float ringOuter = minSide * 0.36f;
        spectro_->drawCircular(cx, cy, ringInner, ringOuter);
    }

    // 4) Lissajous additif fullscreen pour le glow ambiant (sous le scope).
    ofEnableBlendMode(OF_BLENDMODE_ADD);
    ofPushStyle();
    ofSetColor(255, 255, 255, 70);
    lissajous_->draw(0, 0, W, H);
    ofPopStyle();
    ofDisableBlendMode();

    // 4bis) Overlay des multiplicateurs FX (a→z) en bas d'écran, toujours
    //       visible — phosphor vert, en blend additif pour s'intégrer.
    {
        const auto& m = tunnel_->mults();
        ofPushStyle();
        ofEnableBlendMode(OF_BLENDMODE_ADD);
        const int rowY = H - 22;
        const int padX = 14;
        ofSetColor(0, 0, 0, 0); // pure additif, pas de fond
        const auto& pp = postfx_.params;
        struct Slot { const char* label; const char* key; float val; };
        Slot slots[] = {
            {"SPD",  "s", m.speed},
            {"KCK",  "d", m.kickBoost},
            {"ROL",  "f", m.rollAmp},
            {"PAN",  "g", m.panAmp},
            {"CRV",  "h", m.curveAmp},
            {"TLX",  "j", m.tileX},
            {"TLZ",  "k", m.tileZ},
            {"DIR",  "l", m.dirLerp},
            {"BLM",  "m", pp.bloom},
            {"CHR",  "w", pp.chroma},
            {"KAL",  "x", pp.kaleido},
            {"SCN",  "c", pp.scanlines},
            {"PIX",  "v", pp.pixelate},
            {"HUE",  "b", pp.rgbShift},
            {"GRN",  "n", pp.filmGrain},
            {"VIG",  ",", pp.vignette},
            {"SAT",  ";", pp.saturation},
        };
        const int nSlots = static_cast<int>(sizeof(slots) / sizeof(Slot));
        const int slotW = (W - padX * 2) / nSlots;
        for (int i = 0; i < nSlots; ++i) {
            const int sx = padX + i * slotW;
            const bool sel = (i == selectedFx_);
            // Cadre de sélection magenta autour du slot actif
            if (sel) {
                ofSetColor(255, 100, 200, 180);
                ofNoFill();
                ofDrawRectangle(sx - 4, rowY - 14, slotW - 4, 38);
                ofFill();
            }
            const float v = slots[i].val;
            int rC = static_cast<int>(80 + std::max(0.0f, v - 1.0f) * 120);
            int gC = 230;
            int bC = static_cast<int>(80 + std::max(0.0f, 1.0f - v) * 120);
            rC = std::min(255, rC);
            bC = std::min(255, bC);
            ofSetColor(rC, gC, bC, sel ? 255 : 200);
            ofDrawBitmapString(slots[i].label, sx, rowY);
            ofSetColor(sel ? 255 : rC, sel ? 100 : gC, sel ? 200 : bC,
                       sel ? 230 : 140);
            ofDrawBitmapString(std::string("[") + slots[i].key + "]",
                               sx, rowY + 12);
            ofSetColor(180, 255, 180, 230);
            ofDrawBitmapString(ofToString(v, 2) + "x", sx + 28, rowY);
            const float barW = static_cast<float>(slotW - 12);
            const float t = std::min(1.0f, std::max(0.0f,
                std::log(v) / std::log(12.0f) * 0.5f + 0.5f));
            ofSetColor(60, 130, 80, 90);
            ofDrawRectangle(sx, rowY + 16, barW, 2);
            ofSetColor(sel ? 255 : 80, sel ? 100 : 220, sel ? 200 : 140,
                       sel ? 230 : 200);
            ofDrawRectangle(sx, rowY + 16, barW * t, 2);
            ofSetColor(120, 200, 100, 130);
            ofDrawRectangle(sx + barW * 0.5f - 0.5f, rowY + 14, 1, 6);
        }
        // Hints clavier
        ofSetColor(140, 220, 220, 180);
        ofDrawBitmapString("[1-0 / & e \" ' ( s e ! c a] demos 0-9   "
                           "[F6-F10] demos 10-14   "
                           "[a z e r t y u i o p] scenes   "
                           "[s d f g h j k l m / w x c v b n , ;] params   "
                           "[UP DOWN] adjust   [:] reset",
                           padX, rowY - 28);
        if (narrativeMode_ && currentDemo_ < (int)demos_.size()) {
            ofSetColor(255, 100, 200, 200);
            ofDrawBitmapString(std::string("[") +
                ofToString(currentDemo_ + 1) + "] " +
                demos_[currentDemo_].name, W - 240, rowY - 18);
        }
        ofDisableBlendMode();
        ofPopStyle();
    }

    // 5) WAVEFORM circulaire CRT rétro — toggle 9.
    if (scope4_.waveform) {
        waveform_->setCircular(true);
        waveform_->draw(0, 0, W, H);
        waveform_->setCircular(false);
    }

    // 6) Sine scroller — toggle 3 (synchronisé avec demo_.scrollerEnabled).
    demo_.drawScroller(W, H);

    // 7) Flash de transition entre scènes (cosine ease-out 0.45s).
    if (transitionFlash_ > 0.0f) {
        ofPushStyle();
        const float t = std::max(0.0f, std::min(1.0f, transitionFlash_));
        const int alpha = static_cast<int>(180 * t * t);  // ease square
        ofSetColor(255, 240, 220, alpha);
        ofDrawRectangle(0, 0, W, H);
        ofPopStyle();
    }

    // 8) Overlay narratif (titre acte + barre progression) si actif.
    drawNarrativeOverlay(W, H);
}

void ofApp::draw() {
    const int W = ofGetWidth();
    const int H = ofGetHeight();

    // Avance la progression de la transition FBO
    if (transitionT_ < 1.0f) {
        transitionT_ += static_cast<float>(ofGetLastFrameTime()) / transitionDur_;
        if (transitionT_ > 1.0f) transitionT_ = 1.0f;
    }

    // FBO transition désactivée : provoquait un flip Y des textes (FBO
    // rendu top-left vs gl_FragCoord bottom-left). On garde flash + glitch
    // postfx qui suffisent à marquer les changements de scène.
    if (postFxEnabled_) {
        postfx_.beginScene();
        ofClear(0, 255);
        drawMode(mode_, 0, 0, W, H);
        postfx_.endScene();
        postfx_.draw(0, 0, W, H);
    } else {
        ofBackground(0);
        drawMode(mode_, 0, 0, W, H);
    }

    if (showGui_) {
        gui_.draw();
        fxGui_.draw();
        drawHud();
    }
}

void ofApp::drawHud() {
    ofPushStyle();

    // Bottom-left : FPS + mode
    ofSetColor(180, 220, 200, 220);
    ofDrawBitmapString("FPS: " + ofToString(ofGetFrameRate(), 1),
                       12, ofGetHeight() - 28);
    ofDrawBitmapString(std::string("Mode: ") + modeName(mode_) +
        (postFxEnabled_ ? "  [fx ON]" : "  [fx OFF]"),
        12, ofGetHeight() - 12);

    // Top-left : sound_algo state from /sync/* (album, melody, synthdef,
    // bpm, beat). Boxed background so it stays readable over any visual.
    const std::string& album = osc_.album();
    const std::string& melody = osc_.melody();
    const std::string& synthdef = osc_.synthdef();
    const float bpm = osc_.bpm();
    const int beat = osc_.beat();
    const float kick = osc_.amp("kick");

    std::vector<std::string> lines;
    if (!album.empty())    lines.push_back("ALBUM   " + album);
    if (!melody.empty())   lines.push_back("MELODY  " + melody);
    if (!synthdef.empty()) lines.push_back("SYNTH   " + synthdef);
    lines.push_back("BPM     " + ofToString(bpm, 1) +
                    "   BEAT " + ofToString(beat));

    if (!lines.empty()) {
        const int padding = 8;
        const int lineH = 14;
        const int boxW = 280;
        const int boxH = static_cast<int>(lines.size()) * lineH + padding * 2;
        ofSetColor(0, 0, 0, 150);
        ofDrawRectangle(12, 12, boxW, boxH);
        ofSetColor(140, 200, 255, 60);
        ofNoFill();
        ofDrawRectangle(12, 12, boxW, boxH);
        ofFill();
        for (std::size_t i = 0; i < lines.size(); ++i) {
            ofSetColor(220, 230, 240, 230);
            ofDrawBitmapString(lines[i],
                               12 + padding,
                               12 + padding + 11 + static_cast<int>(i) * lineH);
        }
        // Beat indicator dot pulses on each beat (uses kick amp as proxy)
        const float pulse = std::min(1.0f, kick * 2.0f);
        ofSetColor(255, 180, 80, static_cast<int>(120 + pulse * 135.0f));
        ofDrawCircle(12 + boxW - padding - 6, 12 + padding + 6,
                     3.0f + pulse * 4.0f);
    }

    ofPopStyle();
}

void ofApp::exit() {
    scope_.stop();
}

void ofApp::windowResized(int w, int h) {
    postfx_.resize(w, h);
}

void ofApp::keyPressed(int key) {
    // Touches 1..9 + 0 — lance une démoparty narrative complète.
    // 'q' reste libre pour le mode live (pas de scénario auto).
    switch (key) {
        // Démos 0-9 : digits (avec shift sur Mac AZERTY FR) + chars
        // AZERTY FR direct (sans shift) en alias.
        case '1': case '&': launchDemo(0); break;  // AMIGA TRIBUTE
        case '2': case 0xe9: launchDemo(1); break; // C64 (é)
        case '3': case '"': launchDemo(2); break;  // ACID
        case '4': case '\'':launchDemo(3); break;  // TUNNEL
        case '5': case '(': launchDemo(4); break;  // FREQ
        case '6': case 0xa7: launchDemo(5); break; // GLITCH (§)
        case '7': case 0xe8: launchDemo(6); break; // AMBIENT (è)
        // 8 sans shift = '!' sur AZERTY FR Mac → conflit ancien demo 10
        case '8': launchDemo(7); break;            // RAVE
        case '9': case 0xe7: launchDemo(8); break; // MEMORY (ç)
        case '0': case 0xe0: launchDemo(9); break; // GREETINGS (à)
        // Démos 10-14 : F6-F10 (universels, indépendants du layout).
        case OF_KEY_F6:  launchDemo(10); break;    // FRACTAL DREAMS
        case OF_KEY_F7:  launchDemo(11); break;    // INFERNO
        case OF_KEY_F8:  launchDemo(12); break;    // OUTRUN
        case OF_KEY_F9:  launchDemo(13); break;    // CUBE STORM
        case OF_KEY_F10: launchDemo(14); break;    // GRAND FINAL
        // '!' aussi pour AZERTY FR Mac (8 sans shift) -> demo 7 RAVE.
        case '!': launchDemo(7); break;
        // Beat-sync toggle. '*' (US) ou '\' (universel).
        case '*': case '\\':
            beatSyncEnabled_ = !beatSyncEnabled_;
            if (!beatSyncEnabled_) pendingDemoIdx_ = -1;
            ofLogNotice("ofApp") << "beatSync " << (beatSyncEnabled_ ? "ON" : "OFF");
            break;

        // Skip à la scène suivante de la démo en cours.
        case OF_KEY_RETURN:
            if (narrativeMode_) enterScene(narrativeIdx_ + 1);
            break;
        // === Layout AZERTY ===
        // Top row AZERTYUIOP = 10 backgrounds live (override scope4 bg)
        // Middle/bottom rows = paramètres FX (sélection + flèches up/down)
        // System keys deplacés sur F-keys car letters reservées :
        case OF_KEY_F1:
            fullscreen_ = !fullscreen_; ofSetFullscreen(fullscreen_); break;
        case OF_KEY_F2: showGui_ = !showGui_; break;
        case OF_KEY_F3: fxEnableToggle_ = !fxEnableToggle_; break;
        case OF_KEY_F4: autoGlitchOnKick_ = !autoGlitchOnKick_; break;
        case OF_KEY_F5:
            lissajous_->reloadShaders(); reactive_->reloadShaders();
            plasma_->reloadShaders(); tunnel_->reloadShaders();
            postfx_.reloadShader();
            ofLogNotice("ofApp") << "Shaders rechargés"; break;
        case ' ': postfx_.triggerGlitch(0.8f); break;
        case 'q':
        case OF_KEY_ESC:
            // Live mode : sphere wave 3D par défaut comme background.
            narrativeMode_ = false;
            liveBg_ = BgKind::SphereWave;
            liveBgOverride_ = true;
            demo_.setText("");
            break;

        // --- 10 backgrounds AZERTYUIOP ---
        case 'a': liveBg_ = BgKind::BoingBall;   liveBgOverride_=true; break;
        case 'z': liveBg_ = BgKind::Tunnel;      liveBgOverride_=true; break;
        case 'e': liveBg_ = BgKind::Metaballs;   liveBgOverride_=true; break;
        case 'r': liveBg_ = BgKind::Voronoi;     liveBgOverride_=true; break;
        case 't': liveBg_ = BgKind::Twister;     liveBgOverride_=true; break;
        case 'y': liveBg_ = BgKind::Kifs;        liveBgOverride_=true; break;
        case 'u': liveBg_ = BgKind::Fire;        liveBgOverride_=true; break;
        case 'i': liveBg_ = BgKind::Mode7;       liveBgOverride_=true; break;
        case 'o': liveBg_ = BgKind::Octahedron;  liveBgOverride_=true; break;
        case 'p': liveBg_ = BgKind::PlasmaC64;   liveBgOverride_=true; break;
        // Webcam + ofxOpenCv + overlay pose (touche '&' = AZERTY shift+1).
        case '&': liveBg_ = BgKind::Webcam;      liveBgOverride_=true; break;

        // --- 17 paramètres FX (middle + bottom row) ---
        // Tunnel mults (multiplicatifs ×0.83/×1.20, clamp 0.05..12)
        case 's': selectedFx_ = 0;  break;  // Speed tunnel
        case 'd': selectedFx_ = 1;  break;  // kick boost (D=Drum)
        case 'f': selectedFx_ = 2;  break;  // roll amp (F=Flip)
        case 'g': selectedFx_ = 3;  break;  // pan amp (G=Glide)
        case 'h': selectedFx_ = 4;  break;  // curve amp (H=Hyperbolic)
        case 'j': selectedFx_ = 5;  break;  // tileX (J)
        case 'k': selectedFx_ = 6;  break;  // tileZ (K)
        case 'l': selectedFx_ = 7;  break;  // dirLerp (L)
        case 'm': selectedFx_ = 8;  break;  // bloom (M)
        // PostFx params (additifs ±0.05, clamp 0..1)
        case 'w': selectedFx_ = 9;  break;  // chroma (W)
        case 'x': selectedFx_ = 10; break;  // kaleido (X)
        case 'c': selectedFx_ = 11; break;  // scanlines (C=CRT)
        case 'v': selectedFx_ = 12; break;  // pixelate (V)
        case 'b': selectedFx_ = 13; break;  // hue rotation (B)
        case 'n': selectedFx_ = 14; break;  // grain (N=Noise)
        case ',': selectedFx_ = 15; break;  // vignette
        case ';': selectedFx_ = 16; break;  // saturation
        case ':': {
            // Reset all FX (touche : = shift+. sur AZERTY)
            tunnel_->setMults(oscope::TunnelVis::Mults{});
            postfx_.params.bloom    = 0.15f;
            postfx_.params.chroma   = 0.0f;
            postfx_.params.kaleido  = 0.0f;
            postfx_.params.scanlines= 0.3f;
            postfx_.params.pixelate = 0.0f;
            postfx_.params.rgbShift = 0.0f;
            postfx_.params.filmGrain= 0.15f;
            postfx_.params.vignette = 0.5f;
            postfx_.params.saturation = 1.0f;
            break;
        }
        case OF_KEY_UP:
        case OF_KEY_DOWN: {
            const bool up = (key == OF_KEY_UP);
            auto bumpMul = [&](float v) {
                v *= up ? 1.20f : 0.83f;
                return std::max(0.05f, std::min(12.0f, v));
            };
            auto bumpAdd = [&](float v, float lo=0.0f, float hi=1.0f) {
                v += up ? 0.05f : -0.05f;
                return std::max(lo, std::min(hi, v));
            };
            auto m = tunnel_->mults();
            auto& p = postfx_.params;
            switch (selectedFx_) {
                case 0:  m.speed     = bumpMul(m.speed);     break;  // s
                case 1:  m.kickBoost = bumpMul(m.kickBoost); break;  // d
                case 2:  m.rollAmp   = bumpMul(m.rollAmp);   break;  // f
                case 3:  m.panAmp    = bumpMul(m.panAmp);    break;  // g
                case 4:  m.curveAmp  = bumpMul(m.curveAmp);  break;  // h
                case 5:  m.tileX     = bumpMul(m.tileX);     break;  // j
                case 6:  m.tileZ     = bumpMul(m.tileZ);     break;  // k
                case 7:  m.dirLerp   = bumpMul(m.dirLerp);   break;  // l
                case 8:  p.bloom     = bumpAdd(p.bloom);     break;  // m
                case 9:  p.chroma    = bumpAdd(p.chroma);    break;  // w
                case 10: p.kaleido   = bumpAdd(p.kaleido);   break;  // x
                case 11: p.scanlines = bumpAdd(p.scanlines); break;  // c
                case 12: p.pixelate  = bumpAdd(p.pixelate);  break;  // v
                case 13: p.rgbShift  = bumpAdd(p.rgbShift);  break;  // b
                case 14: p.filmGrain = bumpAdd(p.filmGrain); break;  // n
                case 15: p.vignette  = bumpAdd(p.vignette);  break;  // ,
                case 16: p.saturation= bumpAdd(p.saturation, 0.0f, 2.0f); break; // ;
            }
            tunnel_->setMults(m);
            fxBloom_     = p.bloom;
            fxChroma_    = p.chroma;
            fxKaleido_   = p.kaleido;
            fxScan_      = p.scanlines;
            fxPixelate_  = p.pixelate;
            fxRgbShift_  = p.rgbShift;
            fxGrain_     = p.filmGrain;
            fxVignette_  = p.vignette;
            fxSat_       = p.saturation;
            break;
        }
        default: break;
    }
}
