/* Channel edit screen with tabs: SAMPLE / EQ / PRE FX / POST FX */

// noteToMidi and convertSampleParamForBackend live in data.jsx so both the
// piano keyboard here and syncStateToEngine can share them.

// MIDI bank base (MSB.LSB) per soundmap category. Program byte indexes
// soundmaps within the category alphabetically. Keys are uppercase to match
// MapBrowser's category grouping.
const CATEGORY_BANK_BASE = {
  'DRUMS':          { msb: 120, lsb: 64 },
  'PERCUSSIVE':     { msb: 120, lsb: 65 },
  'BASS':           { msb: 121, lsb: 64 },
  'GUITAR':         { msb: 121, lsb: 65 },
  'STRINGS':        { msb: 121, lsb: 66 },
  'PIANO':          { msb: 121, lsb: 67 },
  'CHROMATIC-PERC': { msb: 121, lsb: 68 },
  'ORGAN':          { msb: 121, lsb: 69 },
  'ENSEMBLE':       { msb: 121, lsb: 70 },
  'BRASS':          { msb: 121, lsb: 71 },
  'REED':           { msb: 121, lsb: 72 },
  'PIPE':           { msb: 121, lsb: 73 },
  'SYNTH-LEAD':     { msb: 121, lsb: 74 },
  'SYNTH-PAD':      { msb: 121, lsb: 75 },
  'SYNTH-FX':       { msb: 121, lsb: 76 },
  'ETHNIC':         { msb: 121, lsb: 77 },
  'SFX':            { msb: 121, lsb: 78 },
};

function formatCategoryBank(catUpper, program){
  var base = CATEGORY_BANK_BASE[catUpper];
  if(!base) return null;
  return base.msb + '.' + base.lsb + '.' + String(program).padStart(3, '0');
}

function ChannelEditScreen({ state, setState, chId, onBack, midiActivity, soundmapLibrary, refreshSoundmaps }){
  const ch = CHANNELS.find(c=>c.id===chId);
  const cs = state.channels[chId];
  const [tab, setTab] = useState('sample');
  const [selFx, setSelFx] = useState({ chain:'pre', id:'comp' });
  const [selectedKey, setSelectedKey] = useState(null); // note string, e.g. 'C2'
  const [soundmapRev, setSoundmapRev] = useState(0);

  // Mirror an edit-map note selection coming from the other screen (set_edit_note
  // → applyCommandToState → state.editNote): move our selection so the same
  // sample shows here.
  useEffect(function(){
    var en = state.editNote;
    if(en && en.ch === chId && en.note && en.note !== selectedKey){ setSelectedKey(en.note); }
  }, [state.editNote]);

  const updateChannel = (patch)=>{
    setState(s=>({...s, channels:{...s.channels, [chId]: {...s.channels[chId], ...patch}}}));
    // MapBrowser / ImportDialog call TauriAPI.loadSoundmap directly before invoking
    // onPick; we just mirror the result into state here so sync-on-boot can replay it.
    if (patch.midiCh !== undefined) TauriAPI.setMidiChannel(chId, patch.midiCh);
    if (patch.eq !== undefined) {
      var eq = patch.eq;
      if (eq.low) TauriAPI.setEqBand(chId, 'low', eq.low.gain, eq.low.freq);
      if (eq.mid) TauriAPI.setEqBand(chId, 'mid', eq.mid.gain, eq.mid.freq);
      if (eq.high) TauriAPI.setEqBand(chId, 'high', eq.high.gain, eq.high.freq);
    }
  };

  // Real (imported) soundmaps aren't in SAMPLE_MAPS — fetch the note list from
  // the backend so the piano can highlight loaded notes and the sample params
  // panel can resolve sampleByNote[selectedKey].
  //
  // Also merge per-note params from the JSON into cs.sampleParams so the UI
  // sliders show what the engine actually loaded. Without this the UI displays
  // the JS-side defaults (180 ms release, 80% sustain, etc.) while the engine
  // runs with whatever the SF2 import wrote — leading to "I never touched the
  // release slider but the note rings for a minute" surprises.
  //
  // Per-note merge: only fill notes that don't already have a UI tweak. That
  // preserves un-saved tweaks across remounts and protects session-restored
  // state when the channel-edit screen is re-entered.
  useEffect(()=>{
    if (!cs.map || !cs.mapCategory || !TauriAPI.available) return;
    let cancelled = false;
    TauriAPI.getSoundmapSamples(cs.mapCategory, cs.map).then(function(list){
      if (cancelled || !Array.isArray(list)) return;
      setState(function(s){
        const ch = s.channels[chId];
        const existing = ch.sampleParams || {};
        const merged = { ...existing };
        list.forEach(function(samp){
          const noteName = samp.note;
          if (!noteName) return;
          if (merged[noteName]) return; // user has tweaks — don't overwrite
          merged[noteName] = {
            volume:  convertSampleParamFromBackend('volume',  samp.volume),
            pan:     convertSampleParamFromBackend('pan',     samp.pan),
            attack:  convertSampleParamFromBackend('attack',  samp.attack),
            decay:   convertSampleParamFromBackend('decay',   samp.decay),
            sustain: convertSampleParamFromBackend('sustain', samp.sustain),
            release: convertSampleParamFromBackend('release', samp.release),
            cutoff:  samp.cutoff,
            one_shot: !!samp.one_shot,
          };
        });
        return {...s, channels:{...s.channels, [chId]: {...ch, samples: list, sampleParams: merged}}};
      });
    }).catch(function(){ /* hard-coded demo maps have no index.json — ignore */ });
    return ()=>{ cancelled = true; };
  }, [cs.map, cs.mapCategory, soundmapRev]);

  const samples = (cs.samples && cs.samples.length) ? cs.samples : (SAMPLE_MAPS[cs.map] || []);

  // per-sample params: cs.sampleParams[note] = {volume, pan, eqLo, eqMid, eqHi, attack, ...}
  const sampleParams = cs.sampleParams || {};
  // Merge side-card defaults defensively: when the bottom panel writes
  // params for this note via SampleParamsPanel.setParam, its def doesn't
  // include volume/pan/eqLo/eqMid/eqHi, so a `sampleParams[note] || ...`
  // fallback short-circuits on the bottom-written object and leaves
  // sp.volume = undefined → Knob shows NaN. Spread defaults FIRST then
  // overlay whatever the user has saved.
  const SIDE_CARD_DEFAULTS = {volume:0, pan:0, eqLo:0, eqMid:0, eqHi:0};
  const sp = selectedKey ? { ...SIDE_CARD_DEFAULTS, ...(sampleParams[selectedKey] || {}) } : null;
  const updateSampleParam = (note, patch)=>{
    // Same merge on the write path: cur may have been seeded by the bottom
    // panel without the side-card keys, so re-add defaults before patching.
    const cur = { ...SIDE_CARD_DEFAULTS, ...(sampleParams[note] || {}) };
    updateChannel({sampleParams: {...sampleParams, [note]: {...cur, ...patch}}});
    const midi = noteToMidi(note);
    Object.keys(patch).forEach(function(k){
      TauriAPI.setSampleParam(chId, midi, k, convertSampleParamForBackend(k, patch[k]));
    });
  };

  const updateFx = (chain, id, patch)=> setState(s=>{
    const arr = [...s.channels[chId][chain]];
    const i = arr.findIndex(x=>x.id===id);
    arr[i] = {...arr[i], ...patch};
    if (patch.enabled !== undefined) TauriAPI.toggleEffect(chId, chain, id, patch.enabled);
    return {...s, channels:{...s.channels, [chId]: {...s.channels[chId], [chain]: arr}}};
  });
  const updateFxParam = (chain, id, k, v)=> setState(s=>{
    const arr = [...s.channels[chId][chain]];
    const i = arr.findIndex(x=>x.id===id);
    const target = arr[i].params.find(p=> p.k===k);
    const params = arr[i].params.map(p=> p.k===k ? {...p, v} : p);
    arr[i] = {...arr[i], params};
    // Send the backend key — paramKey override (Tremolo's lowercase keys) takes
    // precedence over the UI label `k` so SHAPE → "shape", DEPTH → "depth_tremolo", etc.
    const sendKey = (target && target.paramKey) || k;
    TauriAPI.setEffectParam(chId, chain, id, sendKey, v);
    return {...s, channels:{...s.channels, [chId]: {...s.channels[chId], [chain]: arr}}};
  });

  return (
    <div className="edit-layout">
      <aside className="edit-side">
        <button className="tb-btn ghost" onClick={onBack} style={{alignSelf:'flex-start'}}>
          ← BACK TO MIXER
        </button>

        <div className="side-card">
          <div className="title">Channel</div>
          <div className="big-name">{ch.name}</div>
          <div className="row gap-6" style={{flexWrap:'wrap'}}>
            <span className="channel-pill">CH {String(ch.id+1).padStart(2,'0')}</span>
            <span className="channel-pill" style={{color:ch.color}}>{ch.kind}</span>
            <div className="row gap-4" style={{alignItems:'center'}}>
              <span className="channel-pill" style={{paddingRight:4}}>
                <span style={{color:'var(--ink-mute)',marginRight:4}}>MIDI</span>
                <select value={cs.midiCh}
                  onChange={e=>updateChannel({midiCh: parseInt(e.target.value,10)})}
                  style={{background:'transparent',border:'none',color:'var(--accent-a)',fontFamily:'JetBrains Mono',fontSize:10,fontWeight:700,cursor:'pointer',outline:'none'}}>
                  {Array.from({length:16},(_,i)=>i).map(n=>(
                    <option key={n} value={n} style={{background:'#12131b',color:'#e7e8ee'}}>CH {n+1}</option>
                  ))}
                </select>
              </span>
            </div>
          </div>
          <div className="row gap-6">
            <button className="tb-btn icon-only">‹</button>
            <div className="flex1 col gap-2" style={{textAlign:'center'}}>
              <div style={{fontFamily:'Rajdhani',fontWeight:600,fontSize:11,letterSpacing:'.12em',textTransform:'uppercase',color:'var(--ink-dim)'}}>
                {cs.map || '— no map —'}
              </div>
              {cs.mapCategory && (
                <div style={{fontFamily:'JetBrains Mono',fontSize:8.5,letterSpacing:'.14em',textTransform:'uppercase',color:'var(--accent-a)'}}>
                  {cs.mapCategory}
                </div>
              )}
            </div>
            <button className="tb-btn icon-only">›</button>
          </div>
        </div>

        <div className="side-card">
          <div className="title" style={{display:'flex',justifyContent:'space-between',alignItems:'center'}}>
            <span>Sample</span>
            {selectedKey && (
              <span style={{fontFamily:'JetBrains Mono',fontSize:9,color:'var(--accent-a)',letterSpacing:'.08em',textTransform:'none'}}>
                {selectedKey}
              </span>
            )}
          </div>
          {selectedKey ? (
            <>
              <div style={{fontFamily:'JetBrains Mono',fontSize:9,color:'var(--ink-mute)',lineHeight:1.4}}>
                {samples.find(s=>s.note===selectedKey)?.name || <em style={{color:'var(--ink-mute)'}}>empty slot</em>}
              </div>
              <div className="knob-grid" style={{gridTemplateColumns:'1fr 1fr'}}>
                <Knob value={sp.volume} min={-60} max={10} unit=' dB' label="VOLUME"
                      onChange={v=>updateSampleParam(selectedKey,{volume:Math.round(v*100)/100})}/>
                <Knob value={sp.pan} min={-50} max={50} unit='' label="PAN"
                      onChange={v=>updateSampleParam(selectedKey,{pan:Math.round(v)})}/>
              </div>
              <div style={{fontFamily:'Rajdhani',fontWeight:700,fontSize:9,letterSpacing:'.2em',color:'var(--ink-mute)',marginTop:4,paddingTop:8,borderTop:'1px solid var(--line-soft)'}}>
                SAMPLE EQ · 3 BAND
              </div>
              <div className="knob-grid" style={{gridTemplateColumns:'1fr 1fr 1fr'}}>
                <Knob value={sp.eqLo} min={-18} max={18} unit=' dB' label="LOW" size="small"
                      onChange={v=>updateSampleParam(selectedKey,{eqLo:Math.round(v*10)/10})}/>
                <Knob value={sp.eqMid} min={-18} max={18} unit=' dB' label="MID" size="small"
                      onChange={v=>updateSampleParam(selectedKey,{eqMid:Math.round(v*10)/10})}/>
                <Knob value={sp.eqHi} min={-18} max={18} unit=' dB' label="HI" size="small"
                      onChange={v=>updateSampleParam(selectedKey,{eqHi:Math.round(v*10)/10})}/>
              </div>
            </>
          ) : (
            <div className="empty-sample-hint">
              <div className="esh-icon">
                <svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
                  <rect x="3" y="14" width="18" height="7" rx="1"/>
                  <line x1="8" y1="14" x2="8" y2="21"/>
                  <line x1="13" y1="14" x2="13" y2="21"/>
                  <line x1="18" y1="14" x2="18" y2="21"/>
                  <path d="M6 14V10l3-7 3 4 3-3 3 5v5" strokeLinejoin="round"/>
                </svg>
              </div>
              <div className="esh-title">No Sample Selected</div>
              <div className="esh-body">Tap a key on the keyboard to edit its sample parameters.</div>
            </div>
          )}
        </div>

        <div className="side-card">
          <div className="title">Signal Flow</div>
          <div style={{fontFamily:'JetBrains Mono',fontSize:10,color:'var(--ink-dim)',lineHeight:1.7}}>
            MIDI {cs.midiCh+1} →<br/>
            SAMPLE →<br/>
            <span style={{color:'var(--accent-a)'}}>PRE FX</span> → EQ →<br/>
            <span style={{color:'var(--accent-b)'}}>POST FX</span> → MIX
          </div>
        </div>
      </aside>

      <main className="edit-main">
        <div className="edit-tabs">
          <button className={"etab "+(tab==='sample'?'is-active':'')} onClick={()=>setTab('sample')}>
            SAMPLE MAP
          </button>
          <button className={"etab "+(tab==='pre'?'is-active':'')} onClick={()=>setTab('pre')}>
            PRE FX {cs.pre.some(f=>f.enabled) && <span className="badge-dot"/>}
          </button>
          <button className={"etab "+(tab==='eq'?'is-active':'')} onClick={()=>setTab('eq')}>EQ</button>
          <button className={"etab "+(tab==='post'?'is-active':'')} onClick={()=>setTab('post')}>
            POST FX {cs.post.some(f=>f.enabled) && <span className="badge-dot"/>}
          </button>
        </div>

        <div className="edit-content">
          {tab==='sample' && <SampleMapView cs={cs} ch={ch} updateChannel={updateChannel} midiActive={!!midiActivity[ch.id]} selectedKey={selectedKey} setSelectedKey={setSelectedKey} soundmapLibrary={soundmapLibrary} refreshSoundmaps={refreshSoundmaps} samples={samples} reloadSamples={()=>setSoundmapRev(function(r){return r+1;})}/>}
          {tab==='eq'     && <EqView cs={cs} updateChannel={updateChannel}/>}
          {tab==='pre'    && <FxChainView chain="pre" list={cs.pre} selFx={selFx} setSelFx={setSelFx} updateFx={updateFx} updateFxParam={updateFxParam}/>}
          {tab==='post'   && <FxChainView chain="post" list={cs.post} selFx={selFx} setSelFx={setSelFx} updateFx={updateFx} updateFxParam={updateFxParam}/>}
        </div>
      </main>
    </div>
  );
}

/* Note name to MIDI number and NOTE_SEMITONES lookup are defined in data.jsx
   (NOTE_SEMITONES_LUT / noteToMidi) and exposed on window for shared use. */
var NOTE_SEMITONES = NOTE_SEMITONES_LUT;

// Convert one note's UI-unit sample params to a NoteAdjustmentDto (backend units).
// Single source of truth for the UI→backend mapping, used by the SAVE button and
// the "TO ALL" apply path. NOTE: `lfo` has no backend/JSON field, so it is
// intentionally omitted (not persisted) — matching current behavior.
function sampleParamsToDto(midi, p){
  const dto = { note: midi };
  if ('volume' in p) dto.volume = p.volume;          // already dB gain
  if ('pan' in p) dto.pan = p.pan / 50;              // -50..50 → -1..1
  if ('attack' in p) dto.attack = p.attack / 1000;   // ms → s
  if ('decay' in p) dto.decay = p.decay / 1000;
  if ('sustain' in p) dto.sustain = p.sustain / 100; // % → 0..1
  if ('release' in p) dto.release = p.release / 1000;
  if ('cutoff' in p) dto.filter_cutoff = p.cutoff;   // already Hz; DTO field rename
  if ('one_shot' in p) dto.one_shot = !!p.one_shot;
  return dto;
}

/* ---------- Save Note Adjustments Button ---------- */
function SaveNoteAdjustmentsButton({ cs, soundmapLibrary }){
  const [saveState, setSaveState] = useState('idle'); // idle | saving | saved
  const sampleParams = cs.sampleParams || {};

  const handleSave = ()=>{
    // Build the adjustments array in the shape NoteAdjustmentDto expects:
    // a Vec keyed internally by `note` (MIDI number), each field already in
    // the canonical units the engine + JSON store (seconds / 0..1 / -1..+1 /
    // dB gain / Hz). The UI keeps values in friendly units (ms / % / -50..50)
    // for the sliders, so we mirror what convertSampleParamForBackend does
    // on the live SetSampleParam path here for the persistent path.
    const adjustments = [];
    Object.keys(sampleParams).forEach(function(noteName){
      const p = sampleParams[noteName] || {};
      if (Object.keys(p).length === 0) return;
      const midi = noteToMidi(noteName);
      if (midi == null || midi < 0 || midi > 127) return;
      adjustments.push(sampleParamsToDto(midi, p));
    });

    // Find category from soundmap library
    var category = 'uncategorized';
    if(Array.isArray(soundmapLibrary)){
      var found = soundmapLibrary.find(function(m){ return m.name === cs.map; });
      if(found) category = found.category || 'uncategorized';
    }

    setSaveState('saving');
    TauriAPI.saveNoteAdjustments(category, cs.map, adjustments).then(function(){
      setSaveState('saved');
      setTimeout(function(){ setSaveState('idle'); }, 1500);
    }).catch(function(){
      setSaveState('idle');
    });
  };

  return (
    <button className={"tb-btn "+(saveState==='saved'?'is-saved':saveState==='saving'?'is-saving':'')}
            onClick={handleSave}
            disabled={saveState==='saving'}>
      {saveState==='saving' ? (<>
        <span className="save-spin"/>SAVING…
      </>) : saveState==='saved' ? (<>
        <span style={{color:'#5ee8a8'}}>✓</span> SAVED
      </>) : 'SAVE NOTE ADJUSTMENTS TO MAP'}
    </button>
  );
}

/* ---------- Sample map with piano keyboard ---------- */
function SampleMapView({ cs, ch, updateChannel, midiActive, selectedKey, setSelectedKey, soundmapLibrary, refreshSoundmaps, samples, reloadSamples }){
  const [mapBrowser, setMapBrowser] = useState(false);
  const [importOpen, setImportOpen] = useState(false);
  const [importRouter, setImportRouter] = useState(false);
  const [loopImportOpen, setLoopImportOpen] = useState(false);
  const [newMapOpen, setNewMapOpen] = useState(false);
  const [playNote, setPlayNote] = useState(true);
  const [loopBrowser, setLoopBrowser] = useState(false);
  const [loopBanks, setLoopBanks] = useState([]);
  const [saveLoopOpen, setSaveLoopOpen] = useState(false);
  const [saveLoopBpm, setSaveLoopBpm] = useState('');
  const [saveLoopBars, setSaveLoopBars] = useState('');
  const [saveLoopState, setSaveLoopState] = useState('idle'); // idle | saving | saved
  const [dragOverKey, setDragOverKey] = useState(null);
  const [dropMsg, setDropMsg] = useState(null);
  const dropTimer = React.useRef(null);
  const sampleByNote = useMemo(()=>Object.fromEntries(samples.map(s=>[s.note,s])), [samples]);

  // Re-fetch loop banks each time the dropdown opens so newly imported banks
  // appear without requiring an app restart.
  useEffect(()=>{
    if (loopBrowser) {
      BSInvoke('list_loop_banks')
        .then(setLoopBanks)
        .catch(err=>console.warn('list_loop_banks failed:', err));
    }
  }, [loopBrowser]);

  // Also fetch when a bank is loaded on this channel, so the LOOPS badge can
  // resolve the active PC into its full MSB.LSB.PRG address even before the
  // user opens the dropdown for the first time.
  useEffect(()=>{
    if (cs.loopBank) {
      BSInvoke('list_loop_banks').then(setLoopBanks).catch(()=>{});
    }
  }, [cs.loopBank]);

  // Korg-Pa-style address helpers for the LOOPS badge: zero-pad to 3 digits
  // and format the active loop's MSB.LSB.PRG triplet by looking it up in the
  // currently-loaded bank.
  const fmt3 = (n) => String(n ?? 0).padStart(3, '0');
  const lookupLoop = (bankName, pc) => {
    if (bankName == null || pc == null) return null;
    const bank = loopBanks.find(x => x.name === bankName);
    if (!bank) return null;
    return bank.loops.find(l => l.pc === pc) || null;
  };
  const fmtAddress = (loop) => loop ? `${fmt3(loop.bank_msb)}.${fmt3(loop.bank_lsb)}.${fmt3(loop.pc)}` : null;

  // Persist a Korg-Pa-style MSB.LSB.PRG address change for a loop in `bank`.
  // Validates the triplet, then writes through `set_loop_address`, refreshes
  // the bank list, and (if the bank is currently loaded on this channel)
  // reloads the engine bank so it picks up the new wiring. On invalid input
  // we leave the input value as-is — the next `list_loop_banks` refresh
  // re-renders with the persisted value.
  const commitAddress = (bank, loop, newMsb, newLsb, newPc) => {
    const valid = [newMsb, newLsb, newPc].every(v => Number.isFinite(v) && v >= 0 && v <= 127);
    const unchanged = newMsb === loop.bank_msb && newLsb === loop.bank_lsb && newPc === loop.pc;
    if (!valid || unchanged) {
      return;
    }
    BSInvoke('set_loop_address', {
      bankName: bank.name, oldPc: loop.pc, newMsb, newLsb, newPc,
    })
      .then(()=> BSInvoke('list_loop_banks').then(setLoopBanks))
      .then(()=>{
        if (cs.loopBank === bank.name) {
          return BSInvoke('load_loop_bank', { channel: ch.id, bankName: bank.name })
            .then(()=> updateChannel({ activeLoopPc: newPc, pendingLoopPc: null, loopArmed: true }));
        }
      })
      .catch(err=>{
        console.warn('set_loop_address failed:', err);
        alert('Failed to change address: ' + err);
      });
  };

  // Currently-active loop within the channel's loaded bank, if any.
  const activeLoop = lookupLoop(cs.loopBank, cs.activeLoopPc);

  // Open the SAVE LOOP inline editor with the active loop's current BPM + bars
  // pre-filled (typical use: the user noticed a wrong auto-detected BPM and
  // wants to type the right one).
  const openSaveLoop = ()=>{
    if (!activeLoop) return;
    setSaveLoopBpm(String(activeLoop.native_bpm));
    setSaveLoopBars(String(activeLoop.length_bars));
    setSaveLoopState('idle');
    setSaveLoopOpen(true);
  };

  // Persist the user's BPM + length edit to bank.json, then re-issue
  // load_loop_bank so the engine swaps in the corrected entry on the next
  // bar boundary. Mirrors commitAddress's refresh chain.
  const commitSaveLoop = ()=>{
    if (!activeLoop) return;
    const bpm = Number(saveLoopBpm);
    const bars = Number(saveLoopBars);
    if (!Number.isFinite(bpm) || bpm <= 0 || !Number.isInteger(bars) || bars < 1) {
      alert('BPM must be > 0 and bars must be a whole number ≥ 1');
      return;
    }
    setSaveLoopState('saving');
    BSInvoke('update_loop_metadata', {
      bankName: cs.loopBank, pc: activeLoop.pc, nativeBpm: bpm, lengthBars: bars,
    })
      .then(()=> BSInvoke('list_loop_banks').then(setLoopBanks))
      .then(()=> BSInvoke('load_loop_bank', { channel: ch.id, bankName: cs.loopBank }))
      .then(()=>{
        setSaveLoopState('saved');
        setTimeout(()=>{ setSaveLoopState('idle'); setSaveLoopOpen(false); }, 1200);
      })
      .catch(err=>{
        console.warn('update_loop_metadata failed:', err);
        alert('Failed to save loop: ' + err);
        setSaveLoopState('idle');
      });
  };

  // Bank number for the currently loaded map: MSB.LSB from CATEGORY_BANK_BASE,
  // program byte = alphabetical position of this map within its category.
  const currentBank = useMemo(()=>{
    if(!cs.map || !cs.mapCategory || !soundmapLibrary || !soundmapLibrary.length) return null;
    var catUpper = cs.mapCategory.toUpperCase();
    var m = soundmapLibrary.find(function(m){
      return (m.category || '').toUpperCase() === catUpper && m.name === cs.map;
    });
    if(!m) return null;
    return formatCategoryBank(catUpper, (m.program | 0));
  }, [cs.map, cs.mapCategory, soundmapLibrary]);

  // generate keys C-3..B9 (full MIDI range, 9+ octaves)
  const keys = useMemo(()=>{
    const out = [];
    const noteOrder = ['C','C#','D','D#','E','F','F#','G','G#','A','A#','B'];
    for(let oct=-3; oct<=9; oct++){
      for(const n of noteOrder){
        var midi = (oct+1)*12 + (NOTE_SEMITONES[n] || 0);
        if(midi < 0 || midi > 127) continue;
        out.push({ note: n+oct, name: n, oct, isBlack: n.includes('#') });
      }
    }
    return out;
  }, []);
  const whites = keys.filter(k=>!k.isBlack);

  // calc positioning for black keys: which white key index they sit after
  const layout = [];
  let whiteIdx = 0;
  keys.forEach(k=>{
    if(!k.isBlack){
      layout.push({...k, x: whiteIdx*38});
      whiteIdx++;
    } else {
      // position between current whiteIdx-1 and whiteIdx (black keys come after a white)
      layout.push({...k, x: whiteIdx*38 - 12});
    }
  });

  const [playing, setPlaying] = useState({});
  const heldRef = React.useRef(null);

  const handlePointerDown = (note, e)=>{
    if(e) e.stopPropagation();
    setSelectedKey(note);
    // Mirror the selection to the other screen (web ⇄ Pi) so it shows the same sample.
    if(window.BSInvoke) BSInvoke('set_edit_note', { channelId: ch.id, note: note }).catch(function(){});
    if(playNote){
      setPlaying(p=>({...p, [note]: true}));
      heldRef.current = note;
      TauriAPI.playNote(ch.id, noteToMidi(note), 100);
    }
  };
  const handlePointerUp = (note, e)=>{
    if(e) e.stopPropagation();
    if(heldRef.current === note){
      heldRef.current = null;
      setPlaying(p=>{ var n={...p}; delete n[note]; return n; });
      TauriAPI.stopNote(ch.id, noteToMidi(note));
    }
  };
  const handlePointerLeave = (note, e)=>{
    if(e) e.stopPropagation();
    if(heldRef.current === note){
      heldRef.current = null;
      setPlaying(p=>{ var n={...p}; delete n[note]; return n; });
      TauriAPI.stopNote(ch.id, noteToMidi(note));
    }
  };

  // MIDI simulation flash (visual only)
  const flashKey = (note)=>{
    setPlaying(p=>({...p, [note]: true}));
    setTimeout(()=> setPlaying(p=>{ const n={...p}; delete n[note]; return n; }), 220);
  };

  // auto-simulate MIDI hits on this channel
  useEffect(()=>{
    if(!midiActive) return;
    const notes = samples.map(s=>s.note);
    if(!notes.length) return;
    const t = setInterval(()=>{
      if(Math.random() < 0.55){
        const n = notes[Math.floor(Math.random()*notes.length)];
        flashKey(n);
      }
    }, 500);
    return ()=> clearInterval(t);
  }, [midiActive, cs.map]);

  useEffect(function(){
    // Subscribe to OS drag-drop via the `.event` channel (the same one the PG
    // listener uses and that is confirmed working on the Pi), rather than
    // `.webview.getCurrentWebview().onDragDropEvent`. Tauri emits the raw
    // `tauri://drag-*` events when `dragDropEnabled` is on (the default).
    const ev = window.__TAURI__ && window.__TAURI__.event;
    if(!ev || !ev.listen) return;

    function showDrop(msg){
      setDropMsg(msg);
      if(dropTimer.current) clearTimeout(dropTimer.current);
      dropTimer.current = setTimeout(function(){ setDropMsg(null); }, 3000);
    }
    function noteAtPosition(pos){
      if(!pos) return null;
      // Tauri reports the position in PHYSICAL pixels; elementFromPoint wants CSS.
      const dpr = window.devicePixelRatio || 1;
      const el = document.elementFromPoint(pos.x / dpr, pos.y / dpr);
      if(!el || !el.closest) return null;
      const keyEl = el.closest('[data-note]');
      return keyEl ? keyEl.getAttribute('data-note') : null;
    }
    function onDrop(paths, pos){
      setDragOverKey(null);
      if(!cs.map || !cs.mapCategory){ showDrop('Create or import a map first'); return; }
      const wavf = (paths||[]).filter(function(p){ return /\.wav$/i.test(p); })[0];
      if(!wavf){ showDrop('Drop a .wav file'); return; }
      if(paths && paths.length > 1) showDrop('Loaded first WAV; others ignored');
      const note = noteAtPosition(pos);
      if(!note){ showDrop('Drop onto a key'); return; }
      TauriAPI.addSampleToSoundmap(cs.mapCategory, cs.map, noteToMidi(note), wavf)
        .then(function(){ return TauriAPI.loadSoundmap(ch.id, cs.mapCategory, cs.map); })
        .then(function(){ setSelectedKey(note); if(reloadSamples) reloadSamples(); })
        .catch(function(err){ showDrop(String(err||'failed to load sample')); });
    }

    let disposed = false; const unlisteners = [];
    const reg = function(name, cb){
      ev.listen(name, cb).then(function(fn){ if(disposed){ fn(); } else { unlisteners.push(fn); } });
    };
    reg('tauri://drag-over', function(e){ setDragOverKey(noteAtPosition(e && e.payload && e.payload.position)); });
    reg('tauri://drag-leave', function(){ setDragOverKey(null); });
    reg('tauri://drag-drop', function(e){ const pl = (e && e.payload) || {}; onDrop(pl.paths, pl.position); });

    return function(){
      disposed = true;
      unlisteners.forEach(function(f){ f(); });
      if(dropTimer.current) clearTimeout(dropTimer.current);
    };
  }, [cs.map, cs.mapCategory, ch.id]);

  const totalWidth = whites.length * 38;

  return (
    <div className="sample-screen">
      <div className="sample-toolbar">
        <label className="row gap-4" style={{alignItems:'center',cursor:'pointer',userSelect:'none'}}>
          <input type="checkbox" checked={playNote} onChange={e=>setPlayNote(e.target.checked)}
            style={{accentColor:'var(--accent-a)',width:13,height:13,margin:0}}/>
          <span style={{fontFamily:'Rajdhani',fontWeight:700,fontSize:10,letterSpacing:'.14em',color: playNote?'var(--accent-a)':'var(--ink-mute)'}}>PLAY NOTE</span>
        </label>
        <div className="flex1"/>
        <button className="bselect" onClick={()=>setMapBrowser(!mapBrowser)}>
          <span style={{color:'var(--ink-mute)'}}>MAP:</span>
          <span style={{color:'var(--accent-a)',fontWeight:700}}>{cs.map}</span>
          {currentBank && <span style={{color:'var(--accent-b)',fontFamily:'JetBrains Mono',fontSize:10,marginLeft:6}}>{currentBank}</span>}
          <span style={{color:'var(--ink-mute)'}}>▾</span>
        </button>
        <button className="bselect bselect-loops" onClick={()=>setLoopBrowser(!loopBrowser)}>
          <span style={{color:'var(--ink-mute)'}}>LOOPS:</span>
          <span style={{color:'var(--accent-b)',fontWeight:700}}>
            {cs.loopBank || '—'}
          </span>
          {cs.loopBank && (
            <span className="lpc-badge">
              {(() => {
                const active = lookupLoop(cs.loopBank, cs.activeLoopPc);
                const pending = lookupLoop(cs.loopBank, cs.pendingLoopPc);
                const activeAddr = fmtAddress(active);
                const pendingAddr = fmtAddress(pending);
                return (
                  <>
                    {activeAddr || (cs.activeLoopPc != null ? `…${fmt3(cs.activeLoopPc)}` : '—')}
                    {pendingAddr && pending !== active && (
                      <span className="lpc-pending"> → {pendingAddr}</span>
                    )}
                  </>
                );
              })()}
            </span>
          )}
          <span style={{color:'var(--ink-mute)'}}>▾</span>
        </button>
        <button
          className={"tb-btn loop-arm " + (cs.loopArmed ? "is-armed" : "")}
          disabled={!cs.loopBank}
          title={cs.loopBank ? (cs.loopArmed ? "Disarm loop (silence this channel)" : "Arm loop (allow playback)") : "Load a loop bank first"}
          onClick={()=>{
            const next = !cs.loopArmed;
            BSInvoke('set_loop_armed', { channel: ch.id, armed: next })
              .then(()=> updateChannel({ loopArmed: next }))
              .catch(err=>console.warn('set_loop_armed failed:', err));
          }}>
          {cs.loopArmed ? "● ARMED" : "○ ARM"}
        </button>
        <button className="tb-btn" onClick={()=>setImportRouter(true)}>+ IMPORT</button>
        {saveLoopOpen ? (
          <div className="row gap-4" style={{alignItems:'center'}}
               title="REC BPM is the BPM the loop was recorded at — the engine stretches it to the master tempo. Higher REC BPM = less stretch = SLOWER playback (the opposite of an intuitive 'speed' knob).">
            <span style={{fontFamily:'Rajdhani',fontWeight:700,fontSize:10,letterSpacing:'.14em',color:'var(--ink-mute)'}}>REC&nbsp;BPM</span>
            <input type="number" min="1" max="999" step="0.1"
                   className="loop-addr-input" style={{width:54}}
                   title="BPM the loop was recorded at. Engine plays at MASTER tempo by stretching this. Lower this number → engine stretches more → playback gets FASTER."
                   value={saveLoopBpm} onChange={e=>setSaveLoopBpm(e.target.value)}/>
            <span style={{fontFamily:'Rajdhani',fontWeight:700,fontSize:10,letterSpacing:'.14em',color:'var(--ink-mute)'}}>BARS</span>
            <input type="number" min="1" max="64" step="1"
                   className="loop-addr-input" style={{width:42}}
                   title="Loop length in bars (used by the bar-quantized PC-swap and loop arm timing)."
                   value={saveLoopBars} onChange={e=>setSaveLoopBars(e.target.value)}/>
            <button className={"tb-btn "+(saveLoopState==='saved'?'is-saved':saveLoopState==='saving'?'is-saving':'')}
                    onClick={commitSaveLoop} disabled={saveLoopState==='saving'}>
              {saveLoopState==='saving' ? (<><span className="save-spin"/>SAVING…</>)
               : saveLoopState==='saved' ? (<><span style={{color:'#5ee8a8'}}>✓</span> SAVED</>)
               : 'SAVE'}
            </button>
            <button className="tb-btn" onClick={()=>setSaveLoopOpen(false)}>✗</button>
          </div>
        ) : (
          <button className="tb-btn" disabled={!activeLoop}
                  title={activeLoop ? `Edit ${activeLoop.file} REC BPM / bars (REC BPM = source recording tempo, NOT a playback speed knob)` : 'Load a loop bank first'}
                  onClick={openSaveLoop}>
            SAVE LOOP
          </button>
        )}
        <SaveNoteAdjustmentsButton cs={cs} soundmapLibrary={soundmapLibrary}/>
      </div>

      {mapBrowser && <MapBrowser cs={cs} chId={ch.id} soundmapLibrary={soundmapLibrary || []} refreshSoundmaps={refreshSoundmaps} onPick={(name, category)=>{
        // Mirror the audio engine's reset-on-swap: clear per-sample tweaks so note 60's
        // old kick-level attenuation doesn't silently apply to the new map's pad at note 60.
        updateChannel({map:name, mapCategory:category, sampleParams:{}, samples:[]});
        setMapBrowser(false);
      }} onClose={()=>setMapBrowser(false)}/>}
      {loopBrowser && (
        <div className="loop-browser">
          <div className="loop-browser-head">LOOP BANK · {loopBanks.length}</div>
          {loopBanks.length === 0 && (
            <div className="loop-browser-empty">No loop banks. Use IMPORT to add one.</div>
          )}
          {loopBanks.map(b => (
            <React.Fragment key={b.name}>
              <div className={"loop-browser-row" + (cs.loopBank === b.name ? " is-active" : "")}
                   onClick={()=>{
                     if (cs.loopBank === b.name) return; // already loaded
                     BSInvoke('load_loop_bank', { channel: ch.id, bankName: b.name })
                       .then(()=> updateChannel({
                         loopBank: b.name,
                         activeLoopPc: (b.loops[0]?.pc ?? null),
                         pendingLoopPc: null,
                         loopArmed: true,
                       }))
                       .catch(err=>console.warn('load_loop_bank failed:', err));
                   }}>
                <div className="lb-name">{b.name}</div>
                <div className="lb-meta">{b.loops.length} loops</div>
                <button className="lb-del" title="Delete bank"
                        onClick={(e)=>{
                          e.stopPropagation();
                          if (!window.confirm('Delete loop bank "' + b.name + '"?')) return;
                          BSInvoke('delete_loop_bank', { bankName: b.name })
                            .then(()=>{
                              // If the deleted one was loaded, clear channel state.
                              if (cs.loopBank === b.name) {
                                updateChannel({ loopBank: null, activeLoopPc: null, pendingLoopPc: null, loopArmed: false });
                              }
                              // Refresh list.
                              BSInvoke('list_loop_banks').then(setLoopBanks);
                            })
                            .catch(err=>console.warn('delete_loop_bank failed:', err));
                        }}>×</button>
              </div>
              {cs.loopBank === b.name && b.loops.map(loop => (
                <div key={loop.pc}
                     className={"loop-row" + (cs.activeLoopPc === loop.pc ? " is-active" : "") + (cs.pendingLoopPc === loop.pc ? " is-pending" : "")}
                     onClick={()=>{
                       BSInvoke('set_active_loop_pc', { channel: ch.id, pc: loop.pc })
                         .then(()=>{
                           // Optimistic UI: if running, this becomes pending; else active.
                           // We don't know transport here, so set both fields conservatively:
                           // mark pending immediately for instant feedback, the engine will
                           // promote it on the next bar boundary.
                           updateChannel({ pendingLoopPc: loop.pc });
                         })
                         .catch(err=>console.warn('set_active_loop_pc failed:', err));
                     }}>
                  <div className="loop-addr-wrap">
                    <input type="number" min="0" max="127" className="loop-addr-input" defaultValue={loop.bank_msb}
                           onClick={(e)=>e.stopPropagation()}
                           onKeyDown={(e)=>{ if (e.key === 'Enter') e.target.blur(); }}
                           onBlur={(e)=>commitAddress(b, loop, Number(e.target.value), loop.bank_lsb, loop.pc)}/>
                    <span className="loop-addr-dot">.</span>
                    <input type="number" min="0" max="127" className="loop-addr-input" defaultValue={loop.bank_lsb}
                           onClick={(e)=>e.stopPropagation()}
                           onKeyDown={(e)=>{ if (e.key === 'Enter') e.target.blur(); }}
                           onBlur={(e)=>commitAddress(b, loop, loop.bank_msb, Number(e.target.value), loop.pc)}/>
                    <span className="loop-addr-dot">.</span>
                    <input type="number" min="0" max="127" className="loop-addr-input" defaultValue={loop.pc}
                           onClick={(e)=>e.stopPropagation()}
                           onKeyDown={(e)=>{ if (e.key === 'Enter') e.target.blur(); }}
                           onBlur={(e)=>commitAddress(b, loop, loop.bank_msb, loop.bank_lsb, Number(e.target.value))}/>
                  </div>
                  <div className="loop-name">{loop.file}</div>
                  <div className="loop-meta">{loop.native_bpm}bpm · {loop.length_bars}bar{loop.length_bars > 1 ? 's' : ''}</div>
                </div>
              ))}
            </React.Fragment>
          ))}
        </div>
      )}
      {importOpen && <ImportDialog chId={ch.id} refreshSoundmaps={refreshSoundmaps} onClose={()=>setImportOpen(false)} onImport={(name, category)=>{
        updateChannel({map:name, mapCategory:category, sampleParams:{}, samples:[]});
        setImportOpen(false);
      }}/>}

      {importRouter && (
        <div className="import-router-overlay" onClick={()=>setImportRouter(false)}>
          <div className="import-router" onClick={e=>e.stopPropagation()}>
            <div className="ir-title">What are you importing?</div>
            <button className="ir-choice" onClick={()=>{ setImportRouter(false); setImportOpen(true); }}>
              <div className="ir-name">Sample map (SF2)</div>
              <div className="ir-sub">A pitched/playable instrument across MIDI notes</div>
            </button>
            <button className="ir-choice" onClick={()=>{ setImportRouter(false); setLoopImportOpen(true); }}>
              <div className="ir-name">Loop bank (WAV folder)</div>
              <div className="ir-sub">Tempo-locked loops indexed by Program Change</div>
            </button>
            <button className="ir-choice" onClick={()=>{ setImportRouter(false); setNewMapOpen(true); }}>
              <div className="ir-name">New empty map</div>
              <div className="ir-sub">Start blank and drag WAV samples onto the keys</div>
            </button>
          </div>
        </div>
      )}

      {loopImportOpen && (
        <LoopImportDialog
          chId={ch.id}
          onClose={()=>setLoopImportOpen(false)}
          onImported={(bankName)=>{
            BSInvoke('load_loop_bank', { channel: ch.id, bankName })
              .then(()=> updateChannel({ loopBank: bankName, activeLoopPc: 0, loopArmed: true }))
              .finally(()=> setLoopImportOpen(false));
          }}
        />
      )}

      {newMapOpen && (
        <NewMapDialog
          chId={ch.id}
          refreshSoundmaps={refreshSoundmaps}
          onClose={()=>setNewMapOpen(false)}
          onCreated={(name, category)=>{
            updateChannel({map:name, mapCategory:category, sampleParams:{}, samples:[]});
            setNewMapOpen(false);
          }}
        />
      )}

      <div className="kbd-scroll">
        <div className="kbd-wrap" style={{minWidth: totalWidth}}>
          <div className="piano" style={{width: totalWidth}}>
            {layout.filter(k=>!k.isBlack).map(k=>{
              const s = sampleByNote[k.note];
              const isSel = selectedKey===k.note;
              return (
                <div key={k.note}
                     className={"key white "+(s?'has-sample':'')+(playing[k.note]?' is-playing':'')+(isSel?' is-selected':'')+(dragOverKey===k.note?' is-dragover':'')}
                     data-note={k.note}
                     style={{touchAction:'none'}}
                     onPointerDown={e=>handlePointerDown(k.note,e)}
                     onPointerUp={e=>handlePointerUp(k.note,e)}
                     onPointerLeave={e=>handlePointerLeave(k.note,e)}>
                  {s && <div className="kname">{s.name}</div>}
                  <div className="note">{k.note}</div>
                </div>
              );
            })}
            {layout.filter(k=>k.isBlack).map(k=>{
              const s = sampleByNote[k.note];
              const isSel = selectedKey===k.note;
              return (
                <div key={k.note}
                     className={"key black "+(s?'has-sample':'')+(playing[k.note]?' is-playing':'')+(isSel?' is-selected':'')+(dragOverKey===k.note?' is-dragover':'')}
                     data-note={k.note}
                     style={{left: k.x+'px', touchAction:'none'}}
                     onPointerDown={e=>handlePointerDown(k.note,e)}
                     onPointerUp={e=>handlePointerUp(k.note,e)}
                     onPointerLeave={e=>handlePointerLeave(k.note,e)}>
                  {s && <div className="kname" style={{color:'#d7c9ff'}}>{s.name}</div>}
                  <div className="note">{k.note}</div>
                </div>
              );
            })}
          </div>
        </div>
      </div>

      {dropMsg && (
        <div style={{position:'absolute',top:8,left:'50%',transform:'translateX(-50%)',zIndex:30,background:'rgba(3,4,8,0.9)',border:'1px solid var(--line-soft)',borderRadius:8,padding:'6px 14px',fontFamily:'JetBrains Mono',fontSize:11,color:'var(--accent-a)',pointerEvents:'none'}}>
          {dropMsg}
        </div>
      )}
      <div className="sample-info-row">
        <SampleParamsPanel cs={cs} chId={ch.id} updateChannel={updateChannel} selectedKey={selectedKey} sampleByNote={sampleByNote}/>
      </div>
    </div>
  );
}

function SampleParamsPanel({ cs, chId, updateChannel, selectedKey, sampleByNote }){
  const params = cs.sampleParams || {};
  // Only keys the backend understands — reset() iterates these to restore defaults.
  // Previously also had gain/pitch, which had no backend equivalent and caused
  // the engine to reject each reset call with "unknown sample param".
  const def = { attack:5, decay:120, sustain:80, release:180, cutoff:18000, lfo:0, reverse:false, one_shot:false };
  const p = { ...def, ...(params[selectedKey] || {}) };
  const sample = sampleByNote[selectedKey];

  const setParam = (k,v)=>{
    const next = { ...params, [selectedKey]: { ...p, [k]:v } };
    updateChannel({ sampleParams: next });
    if(selectedKey){
      TauriAPI.setSampleParam(chId, noteToMidi(selectedKey), k, convertSampleParamForBackend(k, v));
    }
  };
  const reset = ()=>{
    const next = { ...params };
    delete next[selectedKey];
    updateChannel({ sampleParams: next });
    if(selectedKey){
      const midi = noteToMidi(selectedKey);
      Object.keys(def).forEach(function(k){
        TauriAPI.setSampleParam(chId, midi, k, convertSampleParamForBackend(k, def[k]));
      });
    }
  };

  const applyToAll = (k)=>{
    if(!selectedKey) return;
    const v = p[k];                                   // selected note's UI value for this param
    const notes = Object.keys(sampleByNote);
    const next = { ...params };
    notes.forEach(function(note){
      const cur = next[note] || {};
      next[note] = { ...cur, [k]: v };
      const midi = noteToMidi(note);
      if(midi != null && midi >= 0 && midi <= 127){
        TauriAPI.setSampleParam(chId, midi, k, convertSampleParamForBackend(k, v));
      }
    });
    updateChannel({ sampleParams: next });
    // Persist immediately (lfo is dropped by sampleParamsToDto, matching current behavior).
    if(cs.map && cs.mapCategory){
      const adjustments = [];
      notes.forEach(function(note){
        const midi = noteToMidi(note);
        if(midi == null || midi < 0 || midi > 127) return;
        adjustments.push(sampleParamsToDto(midi, next[note]));
      });
      TauriAPI.saveNoteAdjustments(cs.mapCategory, cs.map, adjustments)
        .catch(function(err){ console.warn('[toAll] save failed', err); });
    }
  };

  const fmtMs = (v)=> v<1000 ? Math.round(v)+' ms' : (v/1000).toFixed(2)+' s';
  const fmtHz = (v)=> v<1000 ? Math.round(v)+' Hz' : (v/1000).toFixed(1)+' kHz';

  if(!sample){
    return (
      <div className="sinfo-card" style={{justifyContent:'center',padding:'14px 16px'}}>
        <div style={{fontFamily:'JetBrains Mono',fontSize:10,color:'var(--ink-mute)',textAlign:'center',width:'100%'}}>
          select a key with a sample to edit · {Object.keys(sampleByNote).length} samples on {cs.map}
        </div>
      </div>
    );
  }

  return (
    <div className="sinfo-card" style={{gap:10,padding:'10px 12px'}}>
      <div className="col gap-2" style={{minWidth:120}}>
        <div style={{fontFamily:'Rajdhani',fontWeight:700,fontSize:10,letterSpacing:'.14em',color:'var(--accent-a)'}}>EDITING</div>
        <div style={{fontFamily:'JetBrains Mono',fontWeight:600,fontSize:13,color:'var(--ink)'}}>{selectedKey}</div>
        <div style={{fontFamily:'JetBrains Mono',fontSize:9,color:'var(--ink-mute)',letterSpacing:'.05em',overflow:'hidden',textOverflow:'ellipsis',whiteSpace:'nowrap'}}>{sample.name}.wav</div>
        <button className="tb-btn" style={{fontSize:9,padding:'3px 8px',marginTop:2}} onClick={reset}>RESET</button>
      </div>

      <div style={{width:1,alignSelf:'stretch',background:'var(--line-soft)'}}/>

      <div style={{display:'grid',gridTemplateColumns:'repeat(6, 1fr)',gap:10,flex:1}}>
        <ParamSlider label="ATTACK"  value={p.attack}  min={0}   max={2000} step={1}   fmt={fmtMs} onChange={v=>setParam('attack',v)} onApplyAll={()=>applyToAll('attack')} color="var(--accent-b)"/>
        <ParamSlider label="DECAY"   value={p.decay}   min={0}   max={2000} step={1}   fmt={fmtMs} onChange={v=>setParam('decay',v)}  onApplyAll={()=>applyToAll('decay')} color="var(--accent-b)"/>
        <ParamSlider label="SUSTAIN" value={p.sustain} min={0}   max={100}  step={1}   fmt={v=>v+' %'} onChange={v=>setParam('sustain',v)} onApplyAll={()=>applyToAll('sustain')} color="var(--accent-b)"/>
        <ParamSlider label="RELEASE" value={p.release} min={0}   max={4000} step={1}   fmt={fmtMs} onChange={v=>setParam('release',v)} onApplyAll={()=>applyToAll('release')} color="var(--accent-b)"/>
        <ParamSlider label="CUTOFF"  value={p.cutoff}  min={80}  max={20000} step={10} fmt={fmtHz} onChange={v=>setParam('cutoff',v)} onApplyAll={()=>applyToAll('cutoff')} color="var(--accent-a)" log/>
        <ParamSlider label="LFO"     value={p.lfo}     min={0}   max={100}  step={1}   fmt={v=>v+' %'} onChange={v=>setParam('lfo',v)} onApplyAll={()=>applyToAll('lfo')} color="var(--accent-a)"/>
      </div>

      <div style={{width:1,alignSelf:'stretch',background:'var(--line-soft)'}}/>

      <div className="col gap-6" style={{minWidth:90,alignItems:'stretch'}}>
        <div style={{fontFamily:'Rajdhani',fontWeight:700,fontSize:10,letterSpacing:'.14em',color:'var(--ink-mute)'}}>PLAYBACK</div>
        <button
          className={"tb-btn "+(p.reverse?'is-active':'')}
          onClick={()=>setParam('reverse', !p.reverse)}
          style={{fontSize:10,padding:'4px 8px'}}
        >
          {p.reverse ? '◀◀ REVERSE' : 'REVERSE OFF'}
        </button>
        <div style={{fontFamily:'JetBrains Mono',fontSize:8.5,color:'var(--ink-mute)',letterSpacing:'.05em',textAlign:'center'}}>
          {p.reverse ? 'plays backwards' : 'forward →'}
        </div>
        <button
          className={"tb-btn "+(p.one_shot?'is-active':'')}
          onClick={()=>setParam('one_shot', !p.one_shot)}
          style={{fontSize:10,padding:'4px 8px'}}
          title="When ON, the note plays through to the end of the sample even if you release the key (crash cymbal style)."
        >
          {p.one_shot ? '◉ ONE-SHOT' : 'ONE-SHOT OFF'}
        </button>
        <div style={{fontFamily:'JetBrains Mono',fontSize:8.5,color:'var(--ink-mute)',letterSpacing:'.05em',textAlign:'center'}}>
          {p.one_shot ? 'ignores note-off' : 'responds to note-off'}
        </div>
      </div>
    </div>
  );
}

function ParamSlider({ label, value, min, max, step, fmt, onChange, color='var(--accent-b)', log, onApplyAll }){
  const toNorm = (v)=> log ? (Math.log(v/min) / Math.log(max/min)) : (v-min)/(max-min);
  const fromNorm = (n)=> log ? (min * Math.pow(max/min, n)) : (min + n*(max-min));
  const norm = Math.max(0, Math.min(1, toNorm(value)));
  const trackRef = React.useRef(null);

  const drag = (e)=>{
    const rect = trackRef.current.getBoundingClientRect();
    const update = (ev)=>{
      const x = (ev.touches?ev.touches[0].clientX:ev.clientX) - rect.left;
      const n = Math.max(0, Math.min(1, x/rect.width));
      let v = fromNorm(n);
      if(step) v = Math.round(v/step)*step;
      onChange(v);
    };
    update(e);
    const up = ()=>{window.removeEventListener('mousemove',update);window.removeEventListener('mouseup',up);};
    window.addEventListener('mousemove',update);window.addEventListener('mouseup',up);
  };

  return (
    <div className="col gap-3" style={{alignItems:'stretch',userSelect:'none'}}>
      <div style={{fontFamily:'Rajdhani',fontWeight:700,fontSize:9,letterSpacing:'.14em',color:'var(--ink-mute)'}}>{label}</div>
      <div
        ref={trackRef}
        onMouseDown={drag}
        onDoubleClick={()=>onChange((min+max)/2)}
        style={{
          height:22,borderRadius:4,background:'var(--bg-0)',border:'1px solid var(--line-soft)',
          position:'relative',cursor:'ew-resize',touchAction:'none',overflow:'hidden'
        }}
      >
        <div style={{
          position:'absolute',left:0,top:0,bottom:0,width:(norm*100)+'%',
          background:`linear-gradient(90deg, ${color}33, ${color}88)`,
        }}/>
        <div style={{
          position:'absolute',left:`calc(${norm*100}% - 1px)`,top:-1,bottom:-1,width:2,
          background:color,boxShadow:`0 0 6px ${color}`,
        }}/>
        <div style={{
          position:'absolute',inset:0,display:'flex',alignItems:'center',justifyContent:'center',
          fontFamily:'JetBrains Mono',fontSize:10,color:'var(--ink)',fontWeight:500,
          textShadow:'0 1px 2px rgba(0,0,0,0.8)',pointerEvents:'none'
        }}>
          {fmt(value)}
        </div>
      </div>
      {onApplyAll && (
        <button
          onClick={onApplyAll}
          title="Apply this value to all notes in the map"
          style={{fontFamily:'Rajdhani',fontWeight:700,fontSize:8,letterSpacing:'.12em',color:'var(--ink-mute)',background:'var(--bg-2)',border:'1px solid var(--line-soft)',borderRadius:4,padding:'2px 0',cursor:'pointer'}}
        >TO ALL</button>
      )}
    </div>
  );
}

function MapBrowser({ cs, chId, soundmapLibrary, refreshSoundmaps, onPick, onClose }){
  const [confirmDelete, setConfirmDelete] = useState(null); // {category, name}
  const [renaming, setRenaming] = useState(null); // {category, name}
  const [renameValue, setRenameValue] = useState('');
  const [error, setError] = useState(null);
  // Guitar Mode chip state, keyed by "category/name". Seeded from the soundmap
  // list (SoundmapInfo now carries guitar_mode/guitar_preset read from each
  // index.json) so a saved voicing (e.g. Metal) shows ON/Metal on reload instead
  // of OFF/Clean. Toggling updates this optimistically; the backend persists the
  // flag in index.json via set_guitar_mode and enforces the 4-channel cap.
  const [guitarOn, setGuitarOn] = useState({}); // { "category/name": true }
  const [guitarPreset, setGuitarPreset] = useState({}); // { "category/name": presetIndex }
  const [gtrMenu, setGtrMenu] = useState(null); // gtrKey of row whose preset popover is open, or null
  const [editPgKey, setEditPgKey] = useState(null); // "category/name" being edited
  const [editPgVal, setEditPgVal] = useState('');

  var GTR_PRESETS = ['Clean','Crunch','Lead','Metal','Acoustic'];

  var gtrKey = function(m){ return m.category + '/' + m.name; };

  // Seed chip + preset pills from persisted index.json state whenever the
  // soundmap list arrives/changes. Only entries flagged guitar_mode become ON.
  useEffect(function(){
    var on = {};
    var presets = {};
    (soundmapLibrary || []).forEach(function(m){
      if(m.guitar_mode){
        var key = m.category + '/' + m.name;
        on[key] = true;
        presets[key] = (m.guitar_preset | 0);
      }
    });
    setGuitarOn(on);
    setGuitarPreset(presets);
  }, [soundmapLibrary]);

  var isGuitarCat = function(m){ return (m.category || '').toUpperCase() === 'GUITAR'; };
  var guitarCount = function(){
    return Object.keys(guitarOn).filter(function(k){ return guitarOn[k]; }).length;
  };

  // Group entries by category
  var grouped = useMemo(function(){
    var cats = {};
    (soundmapLibrary || []).forEach(function(m){
      var cat = (m.category || 'uncategorized').toUpperCase();
      if(!cats[cat]) cats[cat] = [];
      cats[cat].push(m);
    });
    // Sort categories alphabetically, sort entries within each
    var keys = Object.keys(cats).sort();
    keys.forEach(function(k){ cats[k].sort(function(a,b){ return a.name.localeCompare(b.name); }); });
    // Resolve MSB.LSB.PROGRAM per map: MSB.LSB comes from CATEGORY_BANK_BASE,
    // the program byte is the backend's stable per-map `program` (no longer a
    // positional index). Categories without an entry in CATEGORY_BANK_BASE get no bank shown.
    var bankByKey = {};
    keys.forEach(function(k){
      cats[k].forEach(function(m){
        bankByKey[m.category + '/' + m.name] = formatCategoryBank(k, (m.program | 0));
      });
    });
    return { keys: keys, cats: cats, bankByKey: bankByKey };
  }, [soundmapLibrary]);

  var handleSelect = function(m){
    TauriAPI.loadSoundmap(chId, m.category, m.name).then(function(){
      onPick(m.name, m.category);
    }).catch(function(err){
      setError('Failed to load: '+(err||'unknown error'));
    });
  };

  var handleDelete = function(m){
    setError(null);
    TauriAPI.deleteSoundmap(m.category, m.name).then(function(){
      setConfirmDelete(null);
      if(refreshSoundmaps) refreshSoundmaps();
    }).catch(function(err){
      setError('Delete failed: '+(err||'unknown error'));
      setConfirmDelete(null);
    });
  };

  var handleRename = function(){
    if(!renaming || !renameValue.trim()) return;
    setError(null);
    TauriAPI.renameSoundmap(renaming.category, renaming.name, renameValue.trim()).then(function(){
      setRenaming(null);
      setRenameValue('');
      if(refreshSoundmaps) refreshSoundmaps();
    }).catch(function(err){
      setError('Rename failed: '+(err||'unknown error'));
    });
  };

  var commitPg = function(m){
    var n = parseInt(editPgVal, 10);
    setEditPgKey(null);
    if(isNaN(n) || n < 0 || n > 127) return;       // ignore invalid
    if((m.program | 0) === n) return;               // no change
    TauriAPI.setSoundmapProgram(m.category, m.name, n)
      .then(function(){ if(refreshSoundmaps) refreshSoundmaps(); })
      .catch(function(err){ console.warn('[PG] set failed', err); });
  };

  var isEmpty = grouped.keys.length === 0;

  return (
    <div style={{position:'absolute',inset:0,background:'rgba(3,4,8,0.85)',zIndex:20,display:'flex',alignItems:'center',justifyContent:'center',backdropFilter:'blur(3px)'}}
         onClick={onClose}>
      <div style={{width:520,maxHeight:480,background:'var(--bg-1)',border:'1px solid var(--line)',borderRadius:14,padding:16,display:'flex',flexDirection:'column',gap:10,boxShadow:'0 20px 60px rgba(0,0,0,0.7)'}} onClick={function(e){e.stopPropagation(); if(gtrMenu) setGtrMenu(null);}}>
        <div className="row gap-8" style={{justifyContent:'space-between'}}>
          <div style={{fontFamily:'Rajdhani',fontWeight:700,fontSize:13,letterSpacing:'.15em',textTransform:'uppercase'}}>Choose Sample Map</div>
          <button className="tb-btn icon-only" onClick={onClose}>✕</button>
        </div>

        {error && (
          <div style={{background:'rgba(255,80,80,0.12)',border:'1px solid rgba(255,80,80,0.3)',borderRadius:8,padding:'6px 10px'}}>
            <div style={{fontFamily:'JetBrains Mono',fontSize:10,color:'#ff5050'}}>{error}</div>
          </div>
        )}

        {/* Delete confirmation */}
        {confirmDelete && (
          <div style={{background:'rgba(255,80,80,0.08)',border:'1px solid rgba(255,80,80,0.25)',borderRadius:8,padding:'10px 14px',display:'flex',alignItems:'center',justifyContent:'space-between'}}>
            <div style={{fontFamily:'JetBrains Mono',fontSize:10,color:'#ff5050'}}>
              Delete "{confirmDelete.name}"?
            </div>
            <div className="row gap-6">
              <button className="tb-btn ghost" onClick={function(){setConfirmDelete(null);}}>CANCEL</button>
              <button className="tb-btn" style={{color:'#ff5050',borderColor:'rgba(255,80,80,0.4)'}} onClick={function(){handleDelete(confirmDelete);}}>DELETE</button>
            </div>
          </div>
        )}

        <div style={{overflow:'auto',display:'flex',flexDirection:'column',gap:12}}>
          {isEmpty && (
            <div style={{padding:'32px 0',textAlign:'center'}}>
              <div style={{fontFamily:'Rajdhani',fontWeight:700,fontSize:12,letterSpacing:'.14em',color:'var(--ink-mute)',marginBottom:6}}>NO SOUNDMAPS</div>
              <div style={{fontFamily:'JetBrains Mono',fontSize:10,color:'var(--ink-mute)'}}>Import a soundfont to get started.</div>
            </div>
          )}

          {grouped.keys.map(function(cat){
            return (
              <div key={cat}>
                <div style={{fontFamily:'Rajdhani',fontWeight:700,fontSize:10,letterSpacing:'.2em',color:'var(--ink-mute)',marginBottom:6,paddingBottom:4,borderBottom:'1px solid var(--line-soft)'}}>
                  {cat} · {grouped.cats[cat].length}
                </div>
                <div style={{display:'grid',gridTemplateColumns:'1fr 1fr',gap:6}}>
                  {grouped.cats[cat].map(function(m){
                    var isActive = cs.map === m.name;
                    var isRenaming = renaming && renaming.category === m.category && renaming.name === m.name;
                    return (
                      <div key={m.category+'/'+m.name}
                        style={{
                          background: isActive ? 'linear-gradient(180deg,#1a1230,#0f0a1f)' : 'var(--bg-2)',
                          border: isActive ? '1px solid var(--accent-a)' : '1px solid var(--line-soft)',
                          borderRadius:8,padding:'10px 12px',color:'var(--ink)',cursor:'pointer',
                          display:'flex',flexDirection:'column',gap:4,position:'relative'
                        }}>
                        {isRenaming ? (
                          <div style={{display:'flex',gap:4,alignItems:'center'}}>
                            <input type="text" value={renameValue}
                              onChange={function(e){setRenameValue(e.target.value);}}
                              onKeyDown={function(e){ if(e.key==='Enter') handleRename(); if(e.key==='Escape'){ setRenaming(null); setRenameValue(''); } }}
                              autoFocus
                              style={{flex:1,background:'var(--bg-0)',border:'1px solid var(--accent-a)',borderRadius:4,padding:'4px 6px',color:'var(--ink)',fontFamily:'Manrope',fontWeight:700,fontSize:12,outline:'none',boxSizing:'border-box'}}/>
                            <button className="tb-btn icon-only" onClick={handleRename} style={{fontSize:10}}>✓</button>
                          </div>
                        ) : (
                          <div onClick={function(){handleSelect(m);}} style={{cursor:'pointer'}}>
                            <div style={{fontFamily:'Manrope',fontWeight:700,fontSize:12}}>{m.name}</div>
                            <div style={{fontFamily:'JetBrains Mono',fontSize:9,color:'var(--ink-mute)',letterSpacing:'.1em'}}>
                              {grouped.bankByKey[m.category+'/'+m.name] && (
                                <React.Fragment>
                                  {editPgKey === (m.category+'/'+m.name) ? (
                                    <input
                                      type="number" min={0} max={127} autoFocus
                                      value={editPgVal}
                                      onClick={function(e){ e.stopPropagation(); }}
                                      onChange={function(e){ setEditPgVal(e.target.value); }}
                                      onBlur={function(){ commitPg(m); }}
                                      onKeyDown={function(e){ if(e.key==='Enter'){ commitPg(m); } else if(e.key==='Escape'){ setEditPgKey(null); } }}
                                      style={{width:54,background:'var(--bg-2)',border:'1px solid var(--accent-b)',borderRadius:4,color:'var(--accent-b)',fontFamily:'JetBrains Mono',fontSize:9,padding:'1px 4px',outline:'none'}}
                                    />
                                  ) : (
                                    <span
                                      style={{color:'var(--accent-b)',cursor:'pointer'}}
                                      title="Click to change program number"
                                      onClick={function(e){ e.stopPropagation(); setEditPgKey(m.category+'/'+m.name); setEditPgVal(String(m.program | 0)); }}
                                    >{grouped.bankByKey[m.category+'/'+m.name]}</span>
                                  )}
                                  <span style={{margin:'0 6px',opacity:0.5}}>·</span>
                                </React.Fragment>
                              )}
                              {m.sample_count} SAMPLES
                            </div>
                          </div>
                        )}
                        {!isRenaming && (
                          <div style={{position:'absolute',top:6,right:6,display:'flex',gap:2,alignItems:'center'}}>
                            {isGuitarCat(m) && (function(){
                              var key = gtrKey(m);
                              var on = !!guitarOn[key];
                              var presetIdx = guitarPreset[key] | 0;
                              return (
                                <div style={{position:'relative',display:'flex'}}>
                                  <button className={'gtr-chip'+(on ? ' on' : '')}
                                    title="Guitar Mode"
                                    onClick={function(e){
                                      e.stopPropagation();
                                      if(!on){
                                        // turning ON — enforce 4-channel cap
                                        if(guitarCount() >= 4){
                                          setError('Max 4 Guitar Mode channels (CPU protection)');
                                          return;
                                        }
                                        setError(null);
                                        var idx = guitarPreset[key] | 0;
                                        TauriAPI.setGuitarMode(chId, m.category, m.name, true, idx).then(function(){
                                          setGuitarOn(function(prev){ var n = Object.assign({}, prev); n[key] = true; return n; });
                                          if(refreshSoundmaps) refreshSoundmaps();
                                        }).catch(function(err){
                                          setError('Guitar Mode failed: '+(err||'unknown error'));
                                        });
                                        setGtrMenu(key);
                                      } else {
                                        // already on — just toggle the popover
                                        setGtrMenu(gtrMenu === key ? null : key);
                                      }
                                    }}>{on ? GTR_PRESETS[presetIdx] : 'GTR'}</button>
                                  {gtrMenu === key && (
                                    <div className="gtr-menu" onClick={function(e){e.stopPropagation();}}>
                                      {GTR_PRESETS.map(function(label, idx){
                                        var sel = presetIdx === idx;
                                        return (
                                          <button key={label}
                                            className={'gtr-menu-item'+(sel ? ' on' : '')}
                                            onClick={function(e){
                                              e.stopPropagation();
                                              setGuitarPreset(function(prev){ var n = Object.assign({}, prev); n[key] = idx; return n; });
                                              TauriAPI.setGuitarMode(chId, m.category, m.name, true, idx);
                                              setGtrMenu(null);
                                            }}>{label}</button>
                                        );
                                      })}
                                      <button className="gtr-menu-item off"
                                        onClick={function(e){
                                          e.stopPropagation();
                                          TauriAPI.setGuitarMode(chId, m.category, m.name, false, presetIdx).then(function(){
                                            setGuitarOn(function(prev){ var n = Object.assign({}, prev); n[key] = false; return n; });
                                            if(refreshSoundmaps) refreshSoundmaps();
                                          }).catch(function(err){
                                            setError('Guitar Mode failed: '+(err||'unknown error'));
                                          });
                                          setGtrMenu(null);
                                        }}>OFF</button>
                                    </div>
                                  )}
                                </div>
                              );
                            })()}
                            <button className="tb-btn icon-only" style={{fontSize:9,width:20,height:20,padding:0,minWidth:0}}
                              title="Rename"
                              onClick={function(e){e.stopPropagation(); setRenaming({category:m.category, name:m.name}); setRenameValue(m.name);}}>✎</button>
                            <button className="tb-btn icon-only" style={{fontSize:9,width:20,height:20,padding:0,minWidth:0,color:'#ff5050'}}
                              title="Delete"
                              onClick={function(e){e.stopPropagation(); setConfirmDelete({category:m.category, name:m.name});}}>✕</button>
                          </div>
                        )}
                      </div>
                    );
                  })}
                </div>
              </div>
            );
          })}
        </div>
      </div>
    </div>
  );
}

/* ---------- EQ view ---------- */
const EQ_PRESETS = [
  { name:'FLAT',       eq:{ low:{gain:0,freq:120}, mid:{gain:0,freq:1000}, high:{gain:0,freq:6000} } },
  { name:'VOCAL AIR',  eq:{ low:{gain:-2,freq:120}, mid:{gain:-1.5,freq:400}, high:{gain:4,freq:8000} } },
  { name:'WARM BASS',  eq:{ low:{gain:4,freq:80}, mid:{gain:-2,freq:500}, high:{gain:1,freq:6000} } },
  { name:'DRUM PUNCH', eq:{ low:{gain:3,freq:90}, mid:{gain:-3,freq:400}, high:{gain:3,freq:5000} } },
  { name:'TELEPHONE',  eq:{ low:{gain:-12,freq:300}, mid:{gain:6,freq:1500}, high:{gain:-10,freq:4000} } },
  { name:'BRIGHT',     eq:{ low:{gain:-1,freq:120}, mid:{gain:0,freq:1000}, high:{gain:5,freq:8000} } },
];

function EqView({ cs, updateChannel }){
  const eq = cs.eq;
  const [presetIdx, setPresetIdx] = React.useState(()=>{
    const i = EQ_PRESETS.findIndex(p =>
      p.eq.low.gain===eq.low.gain && p.eq.mid.gain===eq.mid.gain && p.eq.high.gain===eq.high.gain);
    return i>=0 ? i : 0;
  });
  const [eqOn, setEqOn] = React.useState(cs.eqOn !== false);
  const setLo = (k,v)=> updateChannel({eq:{...eq, low:{...eq.low, [k]:v}}});
  const setMi = (k,v)=> updateChannel({eq:{...eq, mid:{...eq.mid, [k]:v}}});
  const setHi = (k,v)=> updateChannel({eq:{...eq, high:{...eq.high, [k]:v}}});

  const applyPreset = (i)=>{
    const p = EQ_PRESETS[(i + EQ_PRESETS.length) % EQ_PRESETS.length];
    setPresetIdx((i + EQ_PRESETS.length) % EQ_PRESETS.length);
    updateChannel({eq: JSON.parse(JSON.stringify(p.eq))});
  };
  const resetEq = ()=>{
    setPresetIdx(0);
    updateChannel({eq: JSON.parse(JSON.stringify(EQ_PRESETS[0].eq)), eqOut:0});
  };
  const toggleEq = ()=>{
    const next = !eqOn;
    setEqOn(next);
    updateChannel({eqOn: next});
  };

  return (
    <div className="eq-screen">
      <div className="row gap-10" style={{justifyContent:'space-between'}}>
        <div className="row gap-8">
          <div style={{fontFamily:'Rajdhani',fontWeight:700,fontSize:13,letterSpacing:'.15em',textTransform:'uppercase'}}>Channel EQ</div>
          <span className="channel-pill">3-BAND · LOW · MID · HI</span>
        </div>
        <div className="row gap-6">
          <button className="tb-btn" onClick={()=>applyPreset(presetIdx-1)} title="Previous preset">◀</button>
          <button className="tb-btn" onClick={()=>applyPreset(presetIdx+1)} style={{minWidth:140}}>
            PRESET: {EQ_PRESETS[presetIdx].name}
          </button>
          <button className="tb-btn" onClick={()=>applyPreset(presetIdx+1)} title="Next preset">▶</button>
          <button className="tb-btn" onClick={resetEq}>RESET</button>
          <button className={"tb-btn "+(eqOn?'is-active':'')} onClick={toggleEq}>{eqOn?'EQ ON':'EQ BYPASS'}</button>
        </div>
      </div>

      <div className="eq-graph">
        <EqCurve eq={eq}/>
      </div>

      <div className="eq-knobs">
        <div className="knob-cell">
          <div style={{fontFamily:'Rajdhani',fontSize:10,color:'var(--accent-a)',letterSpacing:'.2em',fontWeight:700}}>LOW SHELF</div>
          <Knob value={eq.low.gain} min={-18} max={18} unit=' dB' label="GAIN" onChange={v=>setLo('gain', Math.round(v*10)/10)}/>
        </div>
        <div className="knob-cell">
          <div style={{fontFamily:'Rajdhani',fontSize:10,color:'var(--accent-b)',letterSpacing:'.2em',fontWeight:700}}>MID BELL</div>
          <Knob value={eq.mid.gain} min={-18} max={18} unit=' dB' label="GAIN" onChange={v=>setMi('gain', Math.round(v*10)/10)}/>
        </div>
        <div className="knob-cell">
          <div style={{fontFamily:'Rajdhani',fontSize:10,color:'var(--accent-b)',letterSpacing:'.2em',fontWeight:700}}>MID BELL</div>
          <Knob value={eq.mid.freq} min={200} max={5000} unit=' Hz' label="FREQ" onChange={v=>setMi('freq', Math.round(v))}/>
        </div>
        <div className="knob-cell">
          <div style={{fontFamily:'Rajdhani',fontSize:10,color:'var(--accent-a)',letterSpacing:'.2em',fontWeight:700}}>HI SHELF</div>
          <Knob value={eq.high.gain} min={-18} max={18} unit=' dB' label="GAIN" onChange={v=>setHi('gain', Math.round(v*10)/10)}/>
        </div>
        <div className="knob-cell">
          <div style={{fontFamily:'Rajdhani',fontSize:10,color:'var(--ink-mute)',letterSpacing:'.2em',fontWeight:700}}>OUTPUT</div>
          <Knob value={cs.eqOut||0} min={-12} max={12} unit=' dB' label="TRIM" onChange={v=>updateChannel({eqOut: Math.round(v*10)/10})}/>
        </div>
      </div>
    </div>
  );
}

function EqCurve({ eq }){
  // build SVG response curve (approx)
  const W=820, H=260, padX=30, padY=20;
  const freqToX = (f)=> padX + (Math.log10(f)-Math.log10(20)) / (Math.log10(20000)-Math.log10(20)) * (W-padX*2);
  const gainToY = (g)=> padY + (1 - (g+18)/36) * (H-padY*2);

  const pts = [];
  for(let i=0;i<=200;i++){
    const f = 20 * Math.pow(1000, i/200);
    // simple low shelf
    const lowG = eq.low.gain * (1 / (1 + Math.pow(f/eq.low.freq, 2)));
    // bell mid
    const q=1.2, bw = eq.mid.freq/q;
    const x = (f-eq.mid.freq)/bw;
    const midG = eq.mid.gain * Math.exp(-x*x);
    // high shelf
    const highG = eq.high.gain * (1 / (1 + Math.pow(eq.high.freq/f, 2)));
    const g = lowG + midG + highG;
    pts.push([freqToX(f), gainToY(g)]);
  }
  const d = 'M '+pts.map(p=>p.join(',')).join(' L ');
  const area = d + ` L ${freqToX(20000)},${gainToY(0)} L ${freqToX(20)},${gainToY(0)} Z`;

  // grid lines
  const freqMarks = [100, 1000, 10000];
  const gainMarks = [12, 6, 0, -6, -12];

  return (
    <svg viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none" style={{width:'100%',height:'100%',display:'block'}}>
      <defs>
        <linearGradient id="eqarea" x1="0" y1="0" x2="0" y2="1">
          <stop offset="0" stopColor="#9b5cff" stopOpacity="0.4"/>
          <stop offset="1" stopColor="#22d3ee" stopOpacity="0.05"/>
        </linearGradient>
        <linearGradient id="eqline" x1="0" y1="0" x2="1" y2="0">
          <stop offset="0" stopColor="#9b5cff"/>
          <stop offset="1" stopColor="#22d3ee"/>
        </linearGradient>
      </defs>
      <rect x="0" y="0" width={W} height={H} fill="transparent"/>
      {gainMarks.map(g=>(
        <g key={g}>
          <line x1={padX} x2={W-padX} y1={gainToY(g)} y2={gainToY(g)} stroke={g===0?"#2a2d40":"#14161f"} strokeDasharray={g===0?"":"2 4"}/>
          <text x={padX-4} y={gainToY(g)+3} fontSize="9" fill="#61657a" textAnchor="end" fontFamily="JetBrains Mono">{g>0?'+'+g:g}</text>
        </g>
      ))}
      {freqMarks.map(f=>(
        <g key={f}>
          <line x1={freqToX(f)} x2={freqToX(f)} y1={padY} y2={H-padY} stroke="#14161f"/>
          <text x={freqToX(f)} y={H-4} fontSize="9" fill="#61657a" textAnchor="middle" fontFamily="JetBrains Mono">
            {f>=1000?(f/1000)+'k':f}
          </text>
        </g>
      ))}
      <path d={area} fill="url(#eqarea)"/>
      <path d={d} fill="none" stroke="url(#eqline)" strokeWidth="2"/>
      {/* band dots */}
      <circle cx={freqToX(eq.low.freq)} cy={gainToY(eq.low.gain)} r="6" fill="#9b5cff" stroke="#fff" strokeOpacity="0.4"/>
      <circle cx={freqToX(eq.mid.freq)} cy={gainToY(eq.mid.gain)} r="6" fill="#22d3ee" stroke="#fff" strokeOpacity="0.4"/>
      <circle cx={freqToX(eq.high.freq)} cy={gainToY(eq.high.gain)} r="6" fill="#9b5cff" stroke="#fff" strokeOpacity="0.4"/>
    </svg>
  );
}

/* ---------- FX chain view ---------- */
function FxChainView({ chain, list, selFx, setSelFx, updateFx, updateFxParam }){
  const selected = list.find(f=> selFx.chain===chain && f.id===selFx.id) || list[0];

  return (
    <div className="fx-screen">
      <div className="row gap-10" style={{justifyContent:'space-between'}}>
        <div className="row gap-8">
          <div style={{fontFamily:'Rajdhani',fontWeight:700,fontSize:13,letterSpacing:'.15em',textTransform:'uppercase',color:chain==='pre'?'var(--accent-a)':'var(--accent-b)'}}>
            {chain==='pre' ? 'PRE FX  ·  on sample before mix' : 'POST FX  ·  on channel stream after mix-bus'}
          </div>
        </div>
        <div className="row gap-6">
          <button className="tb-btn">COPY CHAIN</button>
          <button className="tb-btn">PASTE</button>
        </div>
      </div>

      <div className="fx-chain">
        {list.map((f, idx) => (
          <React.Fragment key={f.id}>
            {idx > 0 && <div className="chain-flow">→</div>}
            <div className="chain-block">
              <div className="chain-label"
                   style={{color: chain==='pre' ? 'var(--accent-a)' : 'var(--accent-b)'}}>
                SLOT {idx+1}
              </div>
              <FxBlock f={f} chain={chain}
                       selected={selFx.chain===chain && selFx.id===f.id}
                       onSelect={()=>setSelFx({chain, id:f.id})}
                       onToggle={()=>updateFx(chain, f.id, {enabled:!f.enabled})}/>
            </div>
          </React.Fragment>
        ))}
      </div>

      <div className="fx-detail">
        {selected.id === 'tremolo' ? (
          <TremoloDetail selected={selected} chain={chain}
                         updateFx={updateFx} updateFxParam={updateFxParam}/>
        ) : selected.id === 'autowah' ? (
          <AutoWahDetail selected={selected} chain={chain}
                         updateFx={updateFx} updateFxParam={updateFxParam}/>
        ) : selected.id === 'phaser' ? (
          <PhaserDetail selected={selected} chain={chain}
                        updateFx={updateFx} updateFxParam={updateFxParam}/>
        ) : selected.id === 'flanger' ? (
          <FlangerDetail selected={selected} chain={chain}
                         updateFx={updateFx} updateFxParam={updateFxParam}/>
        ) : (
          <>
            <div className="fx-detail-head">
              <div className="row gap-10">
                <div className="title">{selected.name}</div>
                <span className="channel-pill">{selected.preset}</span>
              </div>
              <div className="row gap-6">
                <button className="tb-btn">◀ PRESET</button>
                <button className="tb-btn">PRESET ▶</button>
                <button className={"tb-btn "+(selected.enabled?'is-active':'')} onClick={()=>updateFx(chain, selected.id, {enabled:!selected.enabled})}>
                  {selected.enabled?'ENABLED':'BYPASS'}
                </button>
              </div>
            </div>
            <div className="fx-params">
              {selected.params && selected.params.map(p=>(
                <Knob key={p.k} value={p.v} min={p.min} max={p.max} unit={' '+p.unit}
                      label={p.k}
                      onChange={v=>updateFxParam(chain, selected.id, p.k, Math.round(v*100)/100)}/>
              ))}
            </div>
          </>
        )}
      </div>
    </div>
  );
}

function TremoloDetail({ selected, chain, updateFx, updateFxParam }){
  const [masterBpm, setMasterBpm] = useState(0);

  const paramByKey = (k) => selected.params.find(p => p.k === k);
  const wf       = paramByKey('WAVEFORM');
  const sync     = paramByKey('MIDI_SYNC');
  const speed    = paramByKey('SPEED');
  const baseNote = paramByKey('BASE_NOTE');
  const times    = paramByKey('TIMES');
  const knobs    = ['SHAPE','PHASE','DEPTH','MIX'].map(paramByKey);

  // Poll master BPM every 500ms while MIDI sync is on, so the display tracks
  // an external clock without re-rendering when sync is off.
  useEffect(()=>{
    if (sync.v < 0.5) return;
    let alive = true;
    const tick = ()=> {
      BSInvoke('get_master_bpm')
        .then(v => { if (alive) setMasterBpm(v); })
        .catch(()=>{});
    };
    tick();
    const id = setInterval(tick, 500);
    return ()=> { alive = false; clearInterval(id); };
  }, [sync.v]);

  const setParam = (k, v) =>
    updateFxParam(chain, selected.id, k, Math.round(v * 100) / 100);

  return (
    <>
      <div className="fx-detail-head">
        <div className="row gap-10">
          <div className="title">{selected.name}</div>
          <span className="channel-pill">{selected.preset}</span>
        </div>
        <div className="row gap-6">
          <button className="tb-btn">◀ PRESET</button>
          <button className="tb-btn">PRESET ▶</button>
          <button className={"tb-btn "+(selected.enabled?'is-active':'')}
                  onClick={()=>updateFx(chain, selected.id, {enabled:!selected.enabled})}>
            {selected.enabled?'ENABLED':'BYPASS'}
          </button>
        </div>
      </div>

      <div className="tremolo-panel">
        <div className="waveform-pills">
          <span className="pill-label">WAVEFORM</span>
          {wf.options.map((name, idx)=>(
            <button key={idx}
                    className={"pill "+(wf.v === idx ? 'is-on' : '')}
                    onClick={()=>setParam('WAVEFORM', idx)}>
              {name}
            </button>
          ))}
        </div>

        <div className="tremolo-knobs">
          {knobs.map(p => (
            <Knob key={p.k} value={p.v} min={p.min} max={p.max} unit={' '+p.unit}
                  label={p.k}
                  onChange={v=>setParam(p.k, v)}/>
          ))}
        </div>

        <div className="tremolo-speed-row">
          <button className={"sync-toggle "+(sync.v > 0.5 ? 'is-on' : '')}
                  onClick={()=>setParam('MIDI_SYNC', sync.v > 0.5 ? 0 : 1)}>
            {sync.v > 0.5 ? '● MIDI SYNC ON' : '○ MIDI SYNC OFF'}
          </button>
          {sync.v < 0.5 ? (
            <Knob value={speed.v} min={speed.min} max={speed.max} unit=" Hz"
                  label="SPEED"
                  onChange={v=>setParam('SPEED', v)}/>
          ) : (
            <div className="row gap-10" style={{alignItems:'center'}}>
              <span className="bpm-display">BPM: {masterBpm > 0 ? masterBpm.toFixed(1) : '—'}</span>
              <select className="base-note-select"
                      value={baseNote.v}
                      onChange={e=>setParam('BASE_NOTE', parseInt(e.target.value, 10))}>
                {baseNote.options.map((n, idx)=>(
                  <option key={idx} value={idx}>{n}</option>
                ))}
              </select>
              <span>×</span>
              <Knob value={times.v} min={times.min} max={times.max} unit="×"
                    label="TIMES"
                    onChange={v=>setParam('TIMES', Math.round(v))}/>
            </div>
          )}
        </div>

        {/* Tremolo Phase-1 overhaul controls (mode + level compensation).
            Map to backend keys mode_tr / level_comp_tr. */}
        {(() => {
          const mode = selected.params.find(p => p.k === 'MODE');
          const lvl  = selected.params.find(p => p.k === 'LEVEL_COMP');
          if (!mode && !lvl) return null;
          return (
            <>
              {mode && (
                <div className="waveform-pills">
                  <span className="pill-label">MODE</span>
                  {mode.options.map((label, idx)=>(
                    <button key={idx}
                            className={"pill "+(mode.v === idx ? 'is-on' : '')}
                            onClick={()=>setParam('MODE', idx)}>
                      {label}
                    </button>
                  ))}
                </div>
              )}
              {lvl && (
                <div className="tremolo-knobs">
                  <Knob value={lvl.v} min={lvl.min} max={lvl.max} unit={' '+lvl.unit}
                        label={lvl.k} onChange={v=>setParam('LEVEL_COMP', v)}/>
                </div>
              )}
            </>
          );
        })()}
      </div>
    </>
  );
}

function AutoWahDetail({ selected, chain, updateFx, updateFxParam }){
  const [masterBpm, setMasterBpm] = useState(0);

  const paramByKey = (k) => selected.params.find(p => p.k === k);
  const mode     = paramByKey('MODE');
  const wf       = paramByKey('WAVEFORM');
  const sync     = paramByKey('MIDI_SYNC');
  const rate     = paramByKey('RATE');
  const baseNote = paramByKey('BASE_NOTE');
  const times    = paramByKey('TIMES');
  const sharedKnobs = ['MIN_FREQ','MAX_FREQ','Q','DEPTH','MIX'].map(paramByKey);
  const envKnobs    = ['SENS','ATTACK_MS','RELEASE_MS'].map(paramByKey);

  // Poll master BPM only while in LFO mode + MIDI sync on
  useEffect(()=>{
    if (mode.v < 0.5) return;       // envelope mode
    if (sync.v < 0.5) return;       // sync off
    let alive = true;
    const tick = ()=> {
      BSInvoke('get_master_bpm')
        .then(v => { if (alive) setMasterBpm(v); })
        .catch(()=>{});
    };
    tick();
    const id = setInterval(tick, 500);
    return ()=> { alive = false; clearInterval(id); };
  }, [mode.v, sync.v]);

  const setParam = (k, v) =>
    updateFxParam(chain, selected.id, k, Math.round(v * 100) / 100);

  const isEnvelope = mode.v < 0.5;

  return (
    <>
      <div className="fx-detail-head">
        <div className="row gap-10">
          <div className="title">{selected.name}</div>
          <span className="channel-pill">{selected.preset}</span>
        </div>
        <div className="row gap-6">
          <button className="tb-btn">◀ PRESET</button>
          <button className="tb-btn">PRESET ▶</button>
          <button className={"tb-btn "+(selected.enabled?'is-active':'')}
                  onClick={()=>updateFx(chain, selected.id, {enabled:!selected.enabled})}>
            {selected.enabled?'ENABLED':'BYPASS'}
          </button>
        </div>
      </div>

      <div className="autowah-panel">
        <div className="waveform-pills">
          <span className="pill-label">MODE</span>
          <button className={"pill "+(isEnvelope ? 'is-on' : '')}
                  onClick={()=>setParam('MODE', 0)}>Envelope</button>
          <button className={"pill "+(!isEnvelope ? 'is-on' : '')}
                  onClick={()=>setParam('MODE', 1)}>LFO</button>
        </div>

        <div className="autowah-knobs">
          {sharedKnobs.map(p => (
            <Knob key={p.k} value={p.v} min={p.min} max={p.max} unit={' '+p.unit}
                  label={p.k}
                  onChange={v=>setParam(p.k, v)}/>
          ))}
        </div>

        {isEnvelope ? (
          <div className="tremolo-speed-row">
            {envKnobs.map(p => (
              <Knob key={p.k} value={p.v} min={p.min} max={p.max} unit={' '+p.unit}
                    label={p.k}
                    onChange={v=>setParam(p.k, v)}/>
            ))}
          </div>
        ) : (
          <>
            <div className="waveform-pills">
              <span className="pill-label">WAVEFORM</span>
              {wf.options.map((name, idx)=>(
                <button key={idx}
                        className={"pill "+(wf.v === idx ? 'is-on' : '')}
                        onClick={()=>setParam('WAVEFORM', idx)}>
                  {name}
                </button>
              ))}
            </div>
            <div className="tremolo-speed-row">
              <button className={"sync-toggle "+(sync.v > 0.5 ? 'is-on' : '')}
                      onClick={()=>setParam('MIDI_SYNC', sync.v > 0.5 ? 0 : 1)}>
                {sync.v > 0.5 ? '● MIDI SYNC ON' : '○ MIDI SYNC OFF'}
              </button>
              {sync.v < 0.5 ? (
                <Knob value={rate.v} min={rate.min} max={rate.max} unit=" Hz"
                      label="RATE"
                      onChange={v=>setParam('RATE', v)}/>
              ) : (
                <div className="row gap-10" style={{alignItems:'center'}}>
                  <span className="bpm-display">BPM: {masterBpm > 0 ? masterBpm.toFixed(1) : '—'}</span>
                  <select className="base-note-select"
                          value={baseNote.v}
                          onChange={e=>setParam('BASE_NOTE', parseInt(e.target.value, 10))}>
                    {baseNote.options.map((n, idx)=>(
                      <option key={idx} value={idx}>{n}</option>
                    ))}
                  </select>
                  <span>×</span>
                  <Knob value={times.v} min={times.min} max={times.max} unit="×"
                        label="TIMES"
                        onChange={v=>setParam('TIMES', Math.round(v))}/>
                </div>
              )}
            </div>
          </>
        )}

        {/* Auto-Wah v2 overhaul controls (filter type, direction, drive,
            Q-tracking, auto-gain). Rendered from selected.params so they map
            to the backend keys filter_type_wah / direction_wah / drive_wah /
            q_track_wah / auto_gain_wah. */}
        {(() => {
          const filt = selected.params.find(p => p.k === 'FILTER');
          const dir  = selected.params.find(p => p.k === 'DIRECTION');
          const ag   = selected.params.find(p => p.k === 'AUTO_GAIN');
          const drv  = selected.params.find(p => p.k === 'WAH_DRIVE');
          const qtr  = selected.params.find(p => p.k === 'Q_TRACK');
          if (!filt && !dir && !ag && !drv && !qtr) return null;
          return (
            <>
              {filt && (
                <div className="waveform-pills">
                  <span className="pill-label">FILTER</span>
                  {filt.options.map((label, idx)=>(
                    <button key={idx}
                            className={"pill "+(filt.v === idx ? 'is-on' : '')}
                            onClick={()=>setParam('FILTER', idx)}>
                      {label}
                    </button>
                  ))}
                </div>
              )}
              {dir && (
                <div className="waveform-pills">
                  <span className="pill-label">DIRECTION</span>
                  {dir.options.map((label, idx)=>(
                    <button key={idx}
                            className={"pill "+(dir.v === idx ? 'is-on' : '')}
                            onClick={()=>setParam('DIRECTION', idx)}>
                      {label}
                    </button>
                  ))}
                </div>
              )}
              <div className="autowah-knobs">
                {drv && (
                  <Knob value={drv.v} min={drv.min} max={drv.max} unit={' '+drv.unit}
                        label={drv.k} onChange={v=>setParam('WAH_DRIVE', v)}/>
                )}
                {qtr && (
                  <Knob value={qtr.v} min={qtr.min} max={qtr.max} unit={' '+qtr.unit}
                        label={qtr.k} onChange={v=>setParam('Q_TRACK', v)}/>
                )}
                {ag && (
                  <button className={"sync-toggle "+(ag.v > 0.5 ? 'is-on' : '')}
                          onClick={()=>setParam('AUTO_GAIN', ag.v > 0.5 ? 0 : 1)}>
                    {ag.v > 0.5 ? '● AUTO GAIN ON' : '○ AUTO GAIN OFF'}
                  </button>
                )}
              </div>
            </>
          );
        })()}
      </div>
    </>
  );
}

function PhaserDetail({ selected, chain, updateFx, updateFxParam }){
  const [masterBpm, setMasterBpm] = useState(0);

  const paramByKey = (k) => selected.params.find(p => p.k === k);
  const stages   = paramByKey('STAGES');
  const wf       = paramByKey('WAVEFORM');
  const sync     = paramByKey('MIDI_SYNC');
  const rate     = paramByKey('RATE');
  const baseNote = paramByKey('BASE_NOTE');
  const times    = paramByKey('TIMES');
  const stereo   = paramByKey('STEREO');
  const knobs = ['MIN_FREQ','MAX_FREQ','FEEDBK','DEPTH','PHASE','MIX','PH_DRIVE'].map(paramByKey);

  useEffect(()=>{
    if (sync.v < 0.5) return;
    let alive = true;
    const tick = ()=> {
      BSInvoke('get_master_bpm')
        .then(v => { if (alive) setMasterBpm(v); })
        .catch(()=>{});
    };
    tick();
    const id = setInterval(tick, 500);
    return ()=> { alive = false; clearInterval(id); };
  }, [sync.v]);

  const setParam = (k, v) =>
    updateFxParam(chain, selected.id, k, Math.round(v * 100) / 100);

  return (
    <>
      <div className="fx-detail-head">
        <div className="row gap-10">
          <div className="title">{selected.name}</div>
          <span className="channel-pill">{selected.preset}</span>
        </div>
        <div className="row gap-6">
          <button className="tb-btn">◀ PRESET</button>
          <button className="tb-btn">PRESET ▶</button>
          <button className={"tb-btn "+(selected.enabled?'is-active':'')}
                  onClick={()=>updateFx(chain, selected.id, {enabled:!selected.enabled})}>
            {selected.enabled?'ENABLED':'BYPASS'}
          </button>
        </div>
      </div>

      <div className="phaser-panel">
        <div className="waveform-pills">
          <span className="pill-label">STAGES</span>
          {stages.options.map((label, idx)=>(
            <button key={idx}
                    className={"pill "+(stages.v === idx ? 'is-on' : '')}
                    onClick={()=>setParam('STAGES', idx)}>
              {label}
            </button>
          ))}
        </div>

        {stereo && (
          <div className="waveform-pills">
            <span className="pill-label">STEREO</span>
            {stereo.options.map((label, idx)=>(
              <button key={idx}
                      className={"pill "+(stereo.v === idx ? 'is-on' : '')}
                      onClick={()=>setParam('STEREO', idx)}>
                {label}
              </button>
            ))}
          </div>
        )}

        <div className="phaser-knobs">
          {knobs.map(p => (
            <Knob key={p.k} value={p.v} min={p.min} max={p.max} unit={' '+p.unit}
                  label={p.k}
                  onChange={v=>setParam(p.k, v)}/>
          ))}
        </div>

        <div className="waveform-pills">
          <span className="pill-label">WAVEFORM</span>
          {wf.options.map((name, idx)=>(
            <button key={idx}
                    className={"pill "+(wf.v === idx ? 'is-on' : '')}
                    onClick={()=>setParam('WAVEFORM', idx)}>
              {name}
            </button>
          ))}
        </div>

        <div className="tremolo-speed-row">
          <button className={"sync-toggle "+(sync.v > 0.5 ? 'is-on' : '')}
                  onClick={()=>setParam('MIDI_SYNC', sync.v > 0.5 ? 0 : 1)}>
            {sync.v > 0.5 ? '● MIDI SYNC ON' : '○ MIDI SYNC OFF'}
          </button>
          {sync.v < 0.5 ? (
            <Knob value={rate.v} min={rate.min} max={rate.max} unit=" Hz"
                  label="RATE"
                  onChange={v=>setParam('RATE', v)}/>
          ) : (
            <div className="row gap-10" style={{alignItems:'center'}}>
              <span className="bpm-display">BPM: {masterBpm > 0 ? masterBpm.toFixed(1) : '—'}</span>
              <select className="base-note-select"
                      value={baseNote.v}
                      onChange={e=>setParam('BASE_NOTE', parseInt(e.target.value, 10))}>
                {baseNote.options.map((n, idx)=>(
                  <option key={idx} value={idx}>{n}</option>
                ))}
              </select>
              <span>×</span>
              <Knob value={times.v} min={times.min} max={times.max} unit="×"
                    label="TIMES"
                    onChange={v=>setParam('TIMES', Math.round(v))}/>
            </div>
          )}
        </div>
      </div>
    </>
  );
}

function FlangerDetail({ selected, chain, updateFx, updateFxParam }){
  const [masterBpm, setMasterBpm] = useState(0);

  const paramByKey = (k) => selected.params.find(p => p.k === k);
  const wf       = paramByKey('WAVEFORM');
  const sync     = paramByKey('MIDI_SYNC');
  const rate     = paramByKey('RATE');
  const baseNote = paramByKey('BASE_NOTE');
  const times    = paramByKey('TIMES');
  const mode     = paramByKey('MODE');
  const knobs = ['MIN_DLY','MAX_DLY','FEEDBK','DEPTH','PHASE','MIX','REF_MS','DAMP'].map(paramByKey);

  useEffect(()=>{
    if (sync.v < 0.5) return;
    let alive = true;
    const tick = ()=> {
      BSInvoke('get_master_bpm')
        .then(v => { if (alive) setMasterBpm(v); })
        .catch(()=>{});
    };
    tick();
    const id = setInterval(tick, 500);
    return ()=> { alive = false; clearInterval(id); };
  }, [sync.v]);

  const setParam = (k, v) =>
    updateFxParam(chain, selected.id, k, Math.round(v * 100) / 100);

  return (
    <>
      <div className="fx-detail-head">
        <div className="row gap-10">
          <div className="title">{selected.name}</div>
          <span className="channel-pill">{selected.preset}</span>
        </div>
        <div className="row gap-6">
          <button className="tb-btn">◀ PRESET</button>
          <button className="tb-btn">PRESET ▶</button>
          <button className={"tb-btn "+(selected.enabled?'is-active':'')}
                  onClick={()=>updateFx(chain, selected.id, {enabled:!selected.enabled})}>
            {selected.enabled?'ENABLED':'BYPASS'}
          </button>
        </div>
      </div>

      <div className="flanger-panel">
        {mode && (
          <div className="waveform-pills">
            <span className="pill-label">MODE</span>
            {mode.options.map((label, idx)=>(
              <button key={idx}
                      className={"pill "+(mode.v === idx ? 'is-on' : '')}
                      onClick={()=>setParam('MODE', idx)}>
                {label}
              </button>
            ))}
          </div>
        )}

        <div className="flanger-knobs">
          {knobs.map(p => (
            <Knob key={p.k} value={p.v} min={p.min} max={p.max} unit={' '+p.unit}
                  label={p.k}
                  onChange={v=>setParam(p.k, v)}/>
          ))}
        </div>

        <div className="waveform-pills">
          <span className="pill-label">WAVEFORM</span>
          {wf.options.map((name, idx)=>(
            <button key={idx}
                    className={"pill "+(wf.v === idx ? 'is-on' : '')}
                    onClick={()=>setParam('WAVEFORM', idx)}>
              {name}
            </button>
          ))}
        </div>

        <div className="tremolo-speed-row">
          <button className={"sync-toggle "+(sync.v > 0.5 ? 'is-on' : '')}
                  onClick={()=>setParam('MIDI_SYNC', sync.v > 0.5 ? 0 : 1)}>
            {sync.v > 0.5 ? '● MIDI SYNC ON' : '○ MIDI SYNC OFF'}
          </button>
          {sync.v < 0.5 ? (
            <Knob value={rate.v} min={rate.min} max={rate.max} unit=" Hz"
                  label="RATE"
                  onChange={v=>setParam('RATE', v)}/>
          ) : (
            <div className="row gap-10" style={{alignItems:'center'}}>
              <span className="bpm-display">BPM: {masterBpm > 0 ? masterBpm.toFixed(1) : '—'}</span>
              <select className="base-note-select"
                      value={baseNote.v}
                      onChange={e=>setParam('BASE_NOTE', parseInt(e.target.value, 10))}>
                {baseNote.options.map((n, idx)=>(
                  <option key={idx} value={idx}>{n}</option>
                ))}
              </select>
              <span>×</span>
              <Knob value={times.v} min={times.min} max={times.max} unit="×"
                    label="TIMES"
                    onChange={v=>setParam('TIMES', Math.round(v))}/>
            </div>
          )}
        </div>
      </div>
    </>
  );
}

function FxBlock({ f, chain, selected, onSelect, onToggle }){
  return (
    <div className={"fx-block "+(f.enabled?'is-enabled ':'')+(selected?'is-selected':'')} onClick={onSelect}>
      <button className="fx-power" onClick={e=>{e.stopPropagation(); onToggle();}}/>
      <div className="col gap-2 flex1">
        <div className="fxname">{f.name}</div>
        <div className="fxpreset">{f.preset}</div>
      </div>
    </div>
  );
}

Object.assign(window, { ChannelEditScreen, CATEGORY_BANK_BASE, formatCategoryBank });

/* ---------- Import dialog (SF2 browse + transcode) ---------- */
var SF2_CATEGORIES = ['piano','chromatic-perc','organ','guitar','bass','strings','ensemble','brass','reed','pipe','synth-lead','synth-pad','synth-fx','ethnic','percussive','sfx','drums'];

function ImportDialog({ chId, refreshSoundmaps, onClose, onImport }){
  const [sf2Path, setSf2Path] = useState(null);
  const [category, setCategory] = useState('drums');
  const [nameOverride, setNameOverride] = useState('');
  const [importing, setImporting] = useState(false);
  const [error, setError] = useState(null);

  // --- Remote-mode upload state (only used when RemoteLink.isConnected()) ---
  const isRemote = RemoteLink.isConnected();
  const [uploading, setUploading] = useState(false);   // chunked upload in progress
  const [uploadPct, setUploadPct] = useState(0);       // 0..100
  const [uploadId, setUploadId] = useState(null);      // id of the uploaded sf2 on the Pi
  const [presets, setPresets] = useState(null);        // [[name, bank, preset], ...] once listed
  const [presetIndex, setPresetIndex] = useState(0);   // chosen preset (index into presets[])
  const [browsing, setBrowsing] = useState(false);     // native file picker is open (for disabled styling)
  const browsingRef = React.useRef(false);             // synchronous guard against double-open

  var sf2Filename = sf2Path ? sf2Path.replace(/\\/g,'/').split('/').pop() : '';
  var defaultName = sf2Filename ? sf2Filename.replace(/\.sf2$/i, '') : '';

  var handleBrowse = function(){
    // Single-open guard: ignore extra clicks while a picker is already open
    // (or an import/upload is running). browsingRef is checked synchronously so
    // a rapid double-click can't open the native picker twice.
    if(browsingRef.current || importing || uploading) return;
    if(isRemote){ return handleBrowseRemote(); }
    browsingRef.current = true;
    setBrowsing(true);
    setError(null);
    TauriAPI.browseSoundfonts().then(function(path){
      browsingRef.current = false; setBrowsing(false);
      if(path){
        setSf2Path(path);
        setNameOverride('');
      }
    }).catch(function(err){
      browsingRef.current = false; setBrowsing(false);
      setError('Failed to open file browser: '+(err||'unknown error'));
    });
  };

  // Remote browse → upload (chunked) → list presets. The picker + local reads
  // use LocalInvoke (THIS Windows machine); the upload + list go via BSInvoke
  // (the Pi). On completion `presets` is populated and the picker UI is shown.
  var handleBrowseRemote = function(){
    browsingRef.current = true;
    setBrowsing(true);
    setError(null);
    setPresets(null);
    setPresetIndex(0);
    var path;
    LocalInvoke('browse_soundfonts', {}).then(function(picked){
      // Picker has returned — release the single-open guard (upload, if any,
      // is tracked separately by `uploading`).
      browsingRef.current = false; setBrowsing(false);
      if(!picked){ return; } // user cancelled
      path = picked;
      setSf2Path(path);
      setNameOverride('');
      setUploading(true);
      setUploadPct(0);
      return doRemoteUpload(path);
    }).catch(function(err){
      browsingRef.current = false; setBrowsing(false);
      setUploading(false);
      setError('Remote upload failed: '+(err && err.message ? err.message : String(err||'unknown error')));
    });
  };

  // Reads `path` locally in 256KB slices and streams each to the Pi. Resolves
  // with the array of presets once the upload is complete and listed.
  var doRemoteUpload = function(path){
    var CHUNK = 256*1024;
    var upId = 'up' + Date.now();
    return LocalInvoke('local_file_size', { path: path }).then(function(size){
      size = Number(size) || 0;
      var sendChunk = function(offset, seq){
        // Empty file: send a single seq-0 empty chunk so the Pi finalizes.
        if(size === 0){
          return BSInvoke('upload_sf2_chunk', { uploadId: upId, seq: 0, dataB64: '', last: true }).then(function(){
            setUploadPct(100);
          });
        }
        if(offset >= size){ return Promise.resolve(); }
        return LocalInvoke('read_local_file_chunk', { path: path, offset: offset, length: CHUNK }).then(function(b64){
          var last = (offset + CHUNK >= size);
          return BSInvoke('upload_sf2_chunk', { uploadId: upId, seq: seq, dataB64: b64, last: last });
        }).then(function(){
          var pct = Math.min(100, Math.round((offset + CHUNK) / size * 100));
          setUploadPct(pct);
          return sendChunk(offset + CHUNK, seq + 1);
        });
      };
      return sendChunk(0, 0);
    }).then(function(){
      return BSInvoke('list_uploaded_presets', { uploadId: upId });
    }).then(function(list){
      setUploadId(upId);
      setPresets(Array.isArray(list) ? list : []);
      setPresetIndex(0);
      setUploading(false);
    });
  };

  var handleImport = function(){
    if(isRemote){ return handleImportRemote(); }
    if(!sf2Path) return;
    var name = nameOverride.trim() || defaultName;
    setImporting(true);
    setError(null);
    TauriAPI.importSoundfont(sf2Path, 0, category, name).then(function(){
      // Refresh the soundmap list so it shows the newly imported map
      if(refreshSoundmaps) refreshSoundmaps();
      // Auto-load the imported soundmap to the current channel
      return TauriAPI.loadSoundmap(chId, category, name);
    }).then(function(){
      onImport(name, category);
    }).catch(function(err){
      setError(String(err||'unknown error'));
      setImporting(false);
    });
  };

  // Remote import: the sf2 already lives on the Pi (uploaded above); ask the Pi
  // to transcode the chosen preset into its library, then load it to the channel.
  var handleImportRemote = function(){
    if(uploadId == null || !presets) return;
    var name = nameOverride.trim() || defaultName;
    setImporting(true);
    setError(null);
    BSInvoke('import_uploaded_soundfont', {
      uploadId: uploadId,
      presetIndex: presetIndex,
      category: category,
      nameOverride: name || null,
    }).then(function(){
      if(refreshSoundmaps) refreshSoundmaps();
      // loadSoundmap runs Pi-side via BSInvoke (TauriAPI routes through it).
      return TauriAPI.loadSoundmap(chId, category, name);
    }).then(function(){
      onImport(name, category);
    }).catch(function(err){
      setError(String(err && err.message ? err.message : (err||'unknown error')));
      setImporting(false);
    });
  };

  var handleSetDefaultFolder = function(){
    setError(null);
    TauriAPI.browseFolder().then(function(folderPath){
      if(folderPath){
        return TauriAPI.setSoundmapConfig({ defaultFolder: folderPath });
      }
    }).catch(function(err){
      setError('Failed to set folder: '+(err||'unknown error'));
    });
  };

  return (
    <div style={{position:'absolute',inset:0,background:'rgba(3,4,8,0.85)',zIndex:25,display:'flex',alignItems:'center',justifyContent:'center',backdropFilter:'blur(3px)'}}
         onClick={onClose}>
      <div className="import-dialog" onClick={e=>e.stopPropagation()}>
        <div className="row gap-8" style={{justifyContent:'space-between',alignItems:'center'}}>
          <div className="col gap-2">
            <div style={{fontFamily:'Manrope',fontWeight:800,fontSize:15}}>IMPORT SOUNDFONT</div>
            <div style={{fontFamily:'JetBrains Mono',fontSize:9,color:'var(--ink-mute)',letterSpacing:'.08em'}}>
              {isRemote ? 'UPLOAD .SF2 FROM THIS PC TO THE SAMPLER' : 'SELECT .SF2 FILE TO TRANSCODE'}
            </div>
          </div>
          <button className="tb-btn icon-only" onClick={onClose} disabled={importing||uploading}>✕</button>
        </div>

        {/* Browse SF2 button */}
        <div style={{display:'flex',flexDirection:'column',gap:10,marginTop:8}}>
          <button className="tb-btn is-active" onClick={handleBrowse} disabled={browsing||importing||uploading}
            style={{padding:'10px 16px',fontSize:12,fontWeight:700,letterSpacing:'.1em'}}>
            {isRemote ? 'BROWSE SF2 (THIS PC)' : 'BROWSE SF2'}
          </button>

          {sf2Path && (
            <div style={{background:'var(--bg-2)',border:'1px solid var(--line-soft)',borderRadius:8,padding:'10px 14px'}}>
              <div style={{fontFamily:'Rajdhani',fontWeight:700,fontSize:9,letterSpacing:'.14em',color:'var(--ink-mute)',marginBottom:4}}>SELECTED FILE</div>
              <div style={{fontFamily:'JetBrains Mono',fontSize:11,color:'var(--accent-a)',wordBreak:'break-all',lineHeight:1.4}}>{sf2Path}</div>
            </div>
          )}

          {/* Remote upload progress */}
          {isRemote && uploading && (
            <div style={{background:'var(--bg-2)',border:'1px solid var(--line-soft)',borderRadius:8,padding:'10px 14px'}}>
              <div style={{display:'flex',justifyContent:'space-between',alignItems:'center',marginBottom:6}}>
                <div style={{fontFamily:'Rajdhani',fontWeight:700,fontSize:9,letterSpacing:'.14em',color:'var(--ink-mute)'}}>UPLOADING TO SAMPLER</div>
                <div style={{fontFamily:'JetBrains Mono',fontSize:11,color:'var(--accent-a)'}}>{uploadPct}%</div>
              </div>
              <div style={{height:6,background:'var(--bg-1)',border:'1px solid var(--line-soft)',borderRadius:4,overflow:'hidden'}}>
                <div style={{height:'100%',width:uploadPct+'%',background:'var(--accent-a)',transition:'width 0.15s linear'}}/>
              </div>
            </div>
          )}

          {/* Remote preset picker (after upload completes) */}
          {isRemote && presets && (
            <div style={{display:'flex',flexDirection:'column',gap:4}}>
              <div style={{fontFamily:'Rajdhani',fontWeight:700,fontSize:9,letterSpacing:'.14em',color:'var(--ink-mute)',marginBottom:2}}>PRESET</div>
              {presets.length === 0 ? (
                <div style={{fontFamily:'JetBrains Mono',fontSize:11,color:'var(--ink-mute)'}}>No presets found in this soundfont.</div>
              ) : (
                <select value={presetIndex} onChange={e=>setPresetIndex(Number(e.target.value))} disabled={importing}
                  style={{width:'100%',background:'var(--bg-2)',border:'1px solid var(--line-soft)',borderRadius:6,padding:'8px 10px',color:'var(--ink)',fontFamily:'JetBrains Mono',fontSize:11,fontWeight:600,cursor:'pointer',outline:'none',letterSpacing:'.04em'}}>
                  {presets.map(function(p, i){
                    var pname = (p && p[0] != null) ? String(p[0]) : ('Preset '+i);
                    var bank = (p && p[1] != null) ? p[1] : 0;
                    var prog = (p && p[2] != null) ? p[2] : 0;
                    return (<option key={i} value={i} style={{background:'#12131b',color:'#e7e8ee'}}>{pname+'  ['+bank+':'+prog+']'}</option>);
                  })}
                </select>
              )}
            </div>
          )}
        </div>

        {/* Category dropdown — local: shown once a file is picked;
            remote: shown once the upload's presets are listed. */}
        {(isRemote ? !!presets : !!sf2Path) && (
          <div style={{display:'flex',flexDirection:'column',gap:10,marginTop:8}}>
            <div style={{display:'flex',gap:12,alignItems:'center'}}>
              <div style={{flex:1}}>
                <div style={{fontFamily:'Rajdhani',fontWeight:700,fontSize:9,letterSpacing:'.14em',color:'var(--ink-mute)',marginBottom:4}}>CATEGORY</div>
                <select value={category} onChange={e=>setCategory(e.target.value)} disabled={importing}
                  style={{width:'100%',background:'var(--bg-2)',border:'1px solid var(--line-soft)',borderRadius:6,padding:'8px 10px',color:'var(--ink)',fontFamily:'JetBrains Mono',fontSize:11,fontWeight:600,cursor:'pointer',outline:'none',letterSpacing:'.08em',textTransform:'uppercase'}}>
                  {SF2_CATEGORIES.map(c=>(
                    <option key={c} value={c} style={{background:'#12131b',color:'#e7e8ee'}}>{c.toUpperCase()}</option>
                  ))}
                </select>
              </div>
              <div style={{flex:1}}>
                <div style={{fontFamily:'Rajdhani',fontWeight:700,fontSize:9,letterSpacing:'.14em',color:'var(--ink-mute)',marginBottom:4}}>NAME (OPTIONAL)</div>
                <input type="text" value={nameOverride} onChange={e=>setNameOverride(e.target.value)}
                  placeholder={defaultName} disabled={importing}
                  style={{width:'100%',background:'var(--bg-2)',border:'1px solid var(--line-soft)',borderRadius:6,padding:'8px 10px',color:'var(--ink)',fontFamily:'JetBrains Mono',fontSize:11,outline:'none',boxSizing:'border-box'}}/>
              </div>
            </div>
          </div>
        )}

        {/* Error message */}
        {error && (
          <div style={{background:'rgba(255,80,80,0.12)',border:'1px solid rgba(255,80,80,0.3)',borderRadius:8,padding:'8px 12px',marginTop:6}}>
            <div style={{fontFamily:'JetBrains Mono',fontSize:10,color:'#ff5050',lineHeight:1.4}}>{error}</div>
          </div>
        )}

        {/* Loading state */}
        {importing && (
          <div style={{display:'flex',alignItems:'center',justifyContent:'center',gap:10,padding:'16px 0'}}>
            <div style={{width:16,height:16,border:'2px solid var(--accent-a)',borderTopColor:'transparent',borderRadius:'50%',animation:'spin 0.8s linear infinite'}}/>
            <div style={{fontFamily:'Rajdhani',fontWeight:700,fontSize:12,letterSpacing:'.14em',color:'var(--accent-a)'}}>IMPORTING...</div>
          </div>
        )}

        <div className="row gap-8" style={{justifyContent:'space-between',marginTop:6}}>
          <button className="tb-btn ghost" onClick={handleSetDefaultFolder} disabled={importing||uploading||isRemote}>SET DEFAULT FOLDER</button>
          <div className="row gap-8">
            <button className="tb-btn ghost" onClick={onClose} disabled={importing||uploading}>CANCEL</button>
            <button className="tb-btn is-active"
              disabled={isRemote ? (uploading||importing||!presets||presets.length===0) : (!sf2Path||importing)}
              onClick={handleImport}>
              {importing ? 'IMPORTING...' : uploading ? 'UPLOADING...' : 'IMPORT'}
            </button>
          </div>
        </div>
      </div>
    </div>
  );
}

/* ---------- Loop bank import dialog ---------- */
function LoopImportDialog({ chId, onClose, onImported }){
  const [bankName, setBankName] = useState('My Loop Bank');
  const [picked, setPicked] = useState([]); // [{path, name, native_bpm, length_bars}]

  const pickFiles = async () => {
    const files = await BSInvoke('browse_wavs').catch(err => {
      console.warn('browse_wavs failed:', err);
      return [];
    });
    if (!files || !files.length) return;
    const arr = Array.isArray(files) ? files : [files];
    setPicked(prev => prev.concat(arr.map(p => {
      const name = p.split(/[\\/]/).pop();
      // native_bpm: 0 signals "auto-detect on the backend".
      return { path: p, name, native_bpm: 0, length_bars: 4 };
    })));
  };

  const doImport = async () => {
    if (!picked.length) return;
    await BSInvoke('import_loop_bank', {
      bankName,
      files: picked.map(p => p.path),
      nativeBpms: picked.map(p => p.native_bpm),
      lengthBars: picked.map(p => p.length_bars),
    });
    onImported(bankName);
  };

  return (
    <div className="import-router-overlay" onClick={onClose}>
      <div className="loop-import" onClick={e=>e.stopPropagation()}>
        <div className="li-title">Import loop bank</div>
        <div className="li-help">Addresses auto-assigned (121.067.XXX). BPM auto-detected from the audio. Edit later in the LOOPS dropdown.</div>
        <label className="li-row">
          <span>Bank name</span>
          <input className="li-input" value={bankName} onChange={e=>setBankName(e.target.value)}/>
        </label>
        <button className="tb-btn" onClick={pickFiles}>+ ADD WAVs</button>
        <div className="li-list">
          {picked.map((p, idx) => (
            <div key={idx} className="li-item">
              <span className="li-name">{p.name}</span>
              <input type="number" className="li-bars" value={p.length_bars}
                     onChange={e=>setPicked(picked.map((x,i)=> i===idx ? {...x, length_bars:Number(e.target.value)} : x))}/>
              <span className="li-unit">bars</span>
            </div>
          ))}
        </div>
        <div className="li-actions">
          <button className="tb-btn" onClick={onClose}>CANCEL</button>
          <button className="tb-btn is-active" disabled={!picked.length} onClick={doImport}>SAVE BANK</button>
        </div>
      </div>
    </div>
  );
}

/* ---------- New empty map dialog ---------- */
function NewMapDialog({ chId, refreshSoundmaps, onClose, onCreated }){
  const [name, setName] = useState('New Kit');
  const [category, setCategory] = useState('drums');
  const [error, setError] = useState(null);

  const create = ()=>{
    const nm = name.trim();
    if(!nm) return;
    setError(null);
    TauriAPI.createEmptySoundmap(category, nm).then(function(){
      if(refreshSoundmaps) refreshSoundmaps();
      return TauriAPI.loadSoundmap(chId, category, nm);
    }).then(function(){
      onCreated(nm, category);
    }).catch(function(err){
      setError(String(err||'failed to create map'));
    });
  };

  return (
    <div className="import-router-overlay" onClick={onClose}>
      <div className="loop-import" onClick={e=>e.stopPropagation()}>
        <div className="li-title">New empty map</div>
        <div className="li-help">Creates a blank map. Drag WAV files onto the keys to fill it.</div>
        <label className="li-row">
          <span>Name</span>
          <input className="li-input" value={name} onChange={e=>setName(e.target.value)}/>
        </label>
        <label className="li-row">
          <span>Category</span>
          <select className="li-input" value={category} onChange={e=>setCategory(e.target.value)}>
            {SF2_CATEGORIES.map(c=>(<option key={c} value={c}>{c.toUpperCase()}</option>))}
          </select>
        </label>
        {error && <div className="li-help" style={{color:'#ff5050'}}>{error}</div>}
        <div className="li-actions">
          <button className="tb-btn" onClick={onClose}>CANCEL</button>
          <button className="tb-btn is-active" disabled={!name.trim()} onClick={create}>CREATE</button>
        </div>
      </div>
    </div>
  );
}