import type express from 'express';
import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
import child_process from 'node:child_process';
import { PassThrough, Readable } from 'node:stream';
import multer from 'multer';
import {
  joinVoiceChannel, createAudioPlayer, createAudioResource,
  AudioPlayerStatus, NoSubscriberBehavior, getVoiceConnection,
  VoiceConnectionStatus, StreamType, entersState,
  generateDependencyReport,
  type VoiceConnection, type AudioResource,
} from '@discordjs/voice';
import sodium from 'libsodium-wrappers';
import nacl from 'tweetnacl';
import { ChannelType, Events, type VoiceBasedChannel, type VoiceState, type Message } from 'discord.js';
import type { Plugin, PluginContext } from '../../core/plugin.js';
import { sseBroadcast } from '../../core/sse.js';

// ── Config (env) ──
const SOUNDS_DIR = process.env.SOUNDS_DIR ?? '/data/sounds';
const NORMALIZE_ENABLE = String(process.env.NORMALIZE_ENABLE ?? 'true').toLowerCase() !== 'false';
const NORMALIZE_I = String(process.env.NORMALIZE_I ?? '-16');
const NORMALIZE_LRA = String(process.env.NORMALIZE_LRA ?? '11');
const NORMALIZE_TP = String(process.env.NORMALIZE_TP ?? '-1.5');
const NORM_CONCURRENCY = Math.max(1, Number(process.env.NORM_CONCURRENCY ?? 2));
const PCM_MEMORY_CACHE_MAX_MB = Number(process.env.PCM_CACHE_MAX_MB ?? '512');
const PCM_PER_FILE_MAX_MB = 50;

// ── Types ──
type Category = { id: string; name: string; color?: string; sort?: number };
type PersistedState = {
  volumes: Record<string, number>;
  plays: Record<string, number>;
  totalPlays: number;
  categories?: Category[];
  fileCategories?: Record<string, string[]>;
  fileBadges?: Record<string, string[]>;
  selectedChannels?: Record<string, string>;
  entranceSounds?: Record<string, string>;
  exitSounds?: Record<string, string>;
};
type ListedSound = { fileName: string; name: string; folder: string; relativePath: string };
type GuildAudioState = {
  connection: VoiceConnection;
  player: ReturnType<typeof createAudioPlayer>;
  guildId: string;
  channelId: string;
  currentResource?: AudioResource;
  currentVolume: number;
};

// ── Persisted State ──
const STATE_FILE = path.join(SOUNDS_DIR, 'state.json');
const STATE_FILE_OLD = path.join(path.resolve(SOUNDS_DIR, '..'), 'state.json');

function readPersistedState(): PersistedState {
  try {
    if (fs.existsSync(STATE_FILE)) {
      const p = JSON.parse(fs.readFileSync(STATE_FILE, 'utf8'));
      return { volumes: p.volumes ?? {}, plays: p.plays ?? {}, totalPlays: p.totalPlays ?? 0,
        categories: Array.isArray(p.categories) ? p.categories : [], fileCategories: p.fileCategories ?? {},
        fileBadges: p.fileBadges ?? {}, selectedChannels: p.selectedChannels ?? {},
        entranceSounds: p.entranceSounds ?? {}, exitSounds: p.exitSounds ?? {} };
    }
    if (fs.existsSync(STATE_FILE_OLD)) {
      const p = JSON.parse(fs.readFileSync(STATE_FILE_OLD, 'utf8'));
      const m: PersistedState = { volumes: p.volumes ?? {}, plays: p.plays ?? {}, totalPlays: p.totalPlays ?? 0,
        categories: Array.isArray(p.categories) ? p.categories : [], fileCategories: p.fileCategories ?? {},
        fileBadges: p.fileBadges ?? {}, selectedChannels: p.selectedChannels ?? {},
        entranceSounds: p.entranceSounds ?? {}, exitSounds: p.exitSounds ?? {} };
      try { fs.mkdirSync(path.dirname(STATE_FILE), { recursive: true }); fs.writeFileSync(STATE_FILE, JSON.stringify(m, null, 2), 'utf8'); } catch {}
      return m;
    }
  } catch {}
  return { volumes: {}, plays: {}, totalPlays: 0 };
}

let persistedState: PersistedState;
let _writeTimer: ReturnType<typeof setTimeout> | null = null;

function writeState(): void {
  try { fs.mkdirSync(path.dirname(STATE_FILE), { recursive: true }); fs.writeFileSync(STATE_FILE, JSON.stringify(persistedState, null, 2), 'utf8'); } catch (e) { console.warn('[Soundboard] state write error:', e); }
}
function writeStateDebounced(): void {
  if (_writeTimer) return;
  _writeTimer = setTimeout(() => { _writeTimer = null; writeState(); }, 2000);
}

function getPersistedVolume(guildId: string): number {
  const v = persistedState.volumes[guildId];
  return typeof v === 'number' && Number.isFinite(v) ? Math.max(0, Math.min(1, v)) : 1;
}

function safeSoundsPath(rel: string): string | null {
  const resolved = path.resolve(SOUNDS_DIR, rel);
  if (!resolved.startsWith(path.resolve(SOUNDS_DIR) + path.sep) && resolved !== path.resolve(SOUNDS_DIR)) return null;
  return resolved;
}

function incrementPlaysFor(relativePath: string) {
  try { const key = relativePath.replace(/\\/g, '/');
    persistedState.plays[key] = (persistedState.plays[key] ?? 0) + 1;
    persistedState.totalPlays = (persistedState.totalPlays ?? 0) + 1;
    writeStateDebounced();
  } catch {}
}

// ── Loudnorm Cache ──
const NORM_CACHE_DIR = path.join(SOUNDS_DIR, '.norm-cache');

function normCacheKey(filePath: string): string {
  const rel = path.relative(SOUNDS_DIR, filePath).replace(/\\/g, '/');
  return rel.replace(/[/\\:*?"<>|]/g, '_') + '.pcm';
}

function getNormCachePath(filePath: string): string | null {
  const cacheFile = path.join(NORM_CACHE_DIR, normCacheKey(filePath));
  if (!fs.existsSync(cacheFile)) return null;
  try {
    const srcMtime = fs.statSync(filePath).mtimeMs;
    const cacheMtime = fs.statSync(cacheFile).mtimeMs;
    if (srcMtime > cacheMtime) { try { fs.unlinkSync(cacheFile); } catch {} invalidatePcmMemory(cacheFile); return null; }
  } catch { return null; }
  return cacheFile;
}

function normalizeToCache(filePath: string): Promise<string> {
  const cacheFile = path.join(NORM_CACHE_DIR, normCacheKey(filePath));
  return new Promise((resolve, reject) => {
    const ff = child_process.spawn('ffmpeg', ['-hide_banner', '-loglevel', 'error', '-y', '-i', filePath,
      '-af', `loudnorm=I=${NORMALIZE_I}:LRA=${NORMALIZE_LRA}:TP=${NORMALIZE_TP}`,
      '-f', 's16le', '-ar', '48000', '-ac', '2', cacheFile]);
    ff.on('error', reject);
    ff.on('close', (code) => { if (code === 0) resolve(cacheFile); else reject(new Error(`ffmpeg exit ${code}`)); });
  });
}

// ── yt-dlp URL detection & download ──
const YTDLP_HOSTS = [
  'youtube.com', 'www.youtube.com', 'm.youtube.com', 'youtu.be',
  'music.youtube.com',
  'instagram.com', 'www.instagram.com',
];

function isYtDlpUrl(url: string): boolean {
  try {
    const host = new URL(url).hostname.toLowerCase();
    return YTDLP_HOSTS.some(h => host === h || host.endsWith('.' + h));
  } catch { return false; }
}

function isDirectMp3Url(url: string): boolean {
  try {
    return new URL(url).pathname.toLowerCase().endsWith('.mp3');
  } catch { return false; }
}

function isSupportedUrl(url: string): boolean {
  return isYtDlpUrl(url) || isDirectMp3Url(url);
}

/** Download audio via yt-dlp → MP3 file in SOUNDS_DIR */
function downloadWithYtDlp(url: string): Promise<{ filename: string; filepath: string }> {
  return new Promise((resolve, reject) => {
    const outputTemplate = path.join(SOUNDS_DIR, '%(title)s.%(ext)s');
    const args = [
      '-x',                          // extract audio only
      '--audio-format', 'mp3',       // convert to MP3
      '--audio-quality', '0',        // best quality
      '-o', outputTemplate,          // output path template
      '--no-playlist',               // single video only
      '--no-overwrites',             // don't overwrite existing
      '--restrict-filenames',        // safe filenames (ASCII, no spaces)
      '--max-filesize', '50m',       // same limit as file upload
      '--socket-timeout', '30',      // timeout for slow connections
      '--verbose',                   // verbose output for logging
      url,
    ];

    const startTime = Date.now();
    console.log(`${SB} [yt-dlp] ▶ START url=${url}`);
    console.log(`${SB} [yt-dlp]   args: yt-dlp ${args.join(' ')}`);

    const proc = child_process.spawn('yt-dlp', args, { stdio: ['ignore', 'pipe', 'pipe'] });

    let stdout = '';
    let stderr = '';
    proc.stdout?.on('data', (d: Buffer) => {
      const line = d.toString();
      stdout += line;
      // Stream yt-dlp progress to console in real-time
      for (const l of line.split('\n').filter((s: string) => s.trim())) {
        console.log(`${SB} [yt-dlp:out] ${l.trim()}`);
      }
    });
    proc.stderr?.on('data', (d: Buffer) => {
      const line = d.toString();
      stderr += line;
      for (const l of line.split('\n').filter((s: string) => s.trim())) {
        console.error(`${SB} [yt-dlp:err] ${l.trim()}`);
      }
    });

    proc.on('error', (err) => {
      const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
      console.error(`${SB} [yt-dlp] ✗ SPAWN ERROR after ${elapsed}s: ${err.message}`);
      reject(new Error('yt-dlp nicht verfügbar'));
    });

    proc.on('close', (code) => {
      const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);

      if (code !== 0) {
        console.error(`${SB} [yt-dlp] ✗ FAILED exit=${code} after ${elapsed}s`);
        console.error(`${SB} [yt-dlp]   stderr (last 1000 chars): ${stderr.slice(-1000)}`);
        console.error(`${SB} [yt-dlp]   stdout (last 500 chars): ${stdout.slice(-500)}`);

        // Extract useful error for frontend
        if (stderr.includes('Video unavailable') || stderr.includes('is not available'))
          reject(new Error('Video nicht verfügbar'));
        else if (stderr.includes('Private video'))
          reject(new Error('Privates Video'));
        else if (stderr.includes('Sign in') || stderr.includes('login'))
          reject(new Error('Login erforderlich'));
        else if (stderr.includes('exceeds maximum'))
          reject(new Error('Datei zu groß (max 50 MB)'));
        else if (stderr.includes('Unsupported URL'))
          reject(new Error('URL nicht unterstützt'));
        else if (stderr.includes('HTTP Error 404'))
          reject(new Error('Video nicht gefunden (404)'));
        else if (stderr.includes('HTTP Error 403'))
          reject(new Error('Zugriff verweigert (403)'));
        else
          reject(new Error(`yt-dlp Fehler (exit ${code})`));
        return;
      }

      console.log(`${SB} [yt-dlp] ✓ DONE exit=0 after ${elapsed}s`);

      // Find the downloaded MP3 file — yt-dlp prints the final filename
      const destMatch = stdout.match(/\[ExtractAudio\] Destination: (.+\.mp3)/i)
        ?? stdout.match(/\[download\] (.+\.mp3) has already been downloaded/i)
        ?? stdout.match(/Destination: (.+\.mp3)/i);

      if (destMatch) {
        const filepath = destMatch[1].trim();
        const filename = path.basename(filepath);
        console.log(`${SB} [yt-dlp]   saved: ${filename} (regex match)`);
        resolve({ filename, filepath });
        return;
      }

      // Fallback: scan SOUNDS_DIR for newest MP3 (within last 60s)
      const now = Date.now();
      const mp3s = fs.readdirSync(SOUNDS_DIR)
        .filter(f => f.endsWith('.mp3'))
        .map(f => ({ name: f, mtime: fs.statSync(path.join(SOUNDS_DIR, f)).mtimeMs }))
        .filter(f => now - f.mtime < 60000)
        .sort((a, b) => b.mtime - a.mtime);

      if (mp3s.length > 0) {
        const filename = mp3s[0].name;
        console.log(`${SB} [yt-dlp]   saved: ${filename} (fallback scan, ${mp3s.length} recent files)`);
        resolve({ filename, filepath: path.join(SOUNDS_DIR, filename) });
        return;
      }

      console.error(`${SB} [yt-dlp] ✗ OUTPUT FILE NOT FOUND`);
      console.error(`${SB} [yt-dlp]   full stdout:\n${stdout}`);
      console.error(`${SB} [yt-dlp]   full stderr:\n${stderr}`);
      reject(new Error('Download abgeschlossen, aber Datei nicht gefunden'));
    });
  });
}

// ── PCM Memory Cache ──
const pcmMemoryCache = new Map<string, Buffer>();
let pcmMemoryCacheBytes = 0;

function getPcmFromMemory(cachedPath: string): Buffer | null {
  const buf = pcmMemoryCache.get(cachedPath);
  if (buf) return buf;
  try {
    const stat = fs.statSync(cachedPath);
    if (stat.size > PCM_PER_FILE_MAX_MB * 1024 * 1024) return null;
    if (pcmMemoryCacheBytes + stat.size > PCM_MEMORY_CACHE_MAX_MB * 1024 * 1024) return null;
    const data = fs.readFileSync(cachedPath);
    pcmMemoryCache.set(cachedPath, data);
    pcmMemoryCacheBytes += data.byteLength;
    return data;
  } catch { return null; }
}

function invalidatePcmMemory(cachedPath: string): void {
  const buf = pcmMemoryCache.get(cachedPath);
  if (buf) { pcmMemoryCacheBytes -= buf.byteLength; pcmMemoryCache.delete(cachedPath); }
}

// ── Sound listing ──
function listAllSounds(): ListedSound[] {
  const rootEntries = fs.readdirSync(SOUNDS_DIR, { withFileTypes: true });
  const rootFiles: ListedSound[] = rootEntries
    .filter(d => d.isFile() && /\.(mp3|wav)$/i.test(d.name))
    .map(d => ({ fileName: d.name, name: path.parse(d.name).name, folder: '', relativePath: d.name }));

  const folderItems: ListedSound[] = [];
  for (const dirent of rootEntries.filter(d => d.isDirectory() && d.name !== '.norm-cache')) {
    const folderPath = path.join(SOUNDS_DIR, dirent.name);
    for (const e of fs.readdirSync(folderPath, { withFileTypes: true })) {
      if (!e.isFile() || !/\.(mp3|wav)$/i.test(e.name)) continue;
      folderItems.push({ fileName: e.name, name: path.parse(e.name).name, folder: dirent.name,
        relativePath: path.join(dirent.name, e.name) });
    }
  }
  return [...rootFiles, ...folderItems].sort((a, b) => a.name.localeCompare(b.name));
}

// ── Norm cache sync ──
async function syncNormCache(): Promise<void> {
  if (!NORMALIZE_ENABLE) return;
  const t0 = Date.now();
  const allSounds = listAllSounds();
  const expectedKeys = new Set<string>();
  const toProcess: string[] = [];

  for (const s of allSounds) {
    const fp = path.join(SOUNDS_DIR, s.relativePath);
    const key = normCacheKey(fp);
    expectedKeys.add(key);
    if (!fs.existsSync(fp)) continue;
    if (getNormCachePath(fp)) continue;
    toProcess.push(fp);
  }

  let created = 0, failed = 0;
  const skipped = allSounds.length - toProcess.length;
  const queue = [...toProcess];
  async function worker(): Promise<void> {
    while (queue.length > 0) {
      const fp = queue.shift()!;
      try { await normalizeToCache(fp); created++; }
      catch (e) { failed++; console.warn(`[Soundboard] norm-cache failed: ${path.relative(SOUNDS_DIR, fp)}`, e); }
    }
  }
  await Promise.all(Array.from({ length: Math.min(NORM_CONCURRENCY, toProcess.length || 1) }, worker));

  let cleaned = 0;
  try {
    for (const f of fs.readdirSync(NORM_CACHE_DIR)) {
      if (!expectedKeys.has(f)) { try { fs.unlinkSync(path.join(NORM_CACHE_DIR, f)); cleaned++; } catch {} }
    }
  } catch {}

  console.log(`[Soundboard] Norm-cache sync (${((Date.now() - t0) / 1000).toFixed(1)}s): ${created} new, ${skipped} cached, ${failed} failed, ${cleaned} orphans`);
}

// ── Audio State ──
const guildAudioState = new Map<string, GuildAudioState>();
const partyTimers = new Map<string, NodeJS.Timeout>();
const partyActive = new Set<string>();
const nowPlaying = new Map<string, string>();
const connectedSince = new Map<string, string>();

// ── Logging helper ──
const SB = '[Soundboard]';

// ── Voice Lifecycle ──
async function ensureConnectionReady(connection: VoiceConnection, channelId: string, guildId: string, guild: any): Promise<VoiceConnection> {
  console.log(`${SB} ensureConnectionReady: guild=${guildId} channel=${channelId} status=${connection.state.status}`);
  try { await entersState(connection, VoiceConnectionStatus.Ready, 15_000); console.log(`${SB} Connection ready (attempt 1)`); return connection; }
  catch (e) { console.warn(`${SB} Attempt 1 failed: ${(e as Error)?.message ?? e}`); }
  try { connection.rejoin({ channelId, selfDeaf: false, selfMute: false }); await entersState(connection, VoiceConnectionStatus.Ready, 15_000); console.log(`${SB} Connection ready (rejoin)`); return connection; }
  catch (e) { console.warn(`${SB} Rejoin failed: ${(e as Error)?.message ?? e}`); }
  try { connection.destroy(); } catch {}
  guildAudioState.delete(guildId);
  console.log(`${SB} Creating fresh connection (attempt 3)...`);
  const newConn = joinVoiceChannel({ channelId, guildId, adapterCreator: debugAdapterCreator(guild), selfMute: false, selfDeaf: false, group: 'soundboard' });
  newConn.on('stateChange', (o: any, n: any) => console.log(`${SB} [fresh-conn] ${o.status} → ${n.status}`));
  newConn.on('error', (err: any) => console.error(`${SB} [fresh-conn] ERROR: ${err?.message ?? err}`));
  try { await entersState(newConn, VoiceConnectionStatus.Ready, 15_000); console.log(`${SB} Connection ready (fresh)`); return newConn; }
  catch (e) { console.error(`${SB} All 3 connection attempts failed: ${(e as Error)?.message ?? e}`); try { newConn.destroy(); } catch {} guildAudioState.delete(guildId); throw new Error('Voice connection failed after 3 attempts'); }
}

function attachVoiceLifecycle(state: GuildAudioState, guild: any) {
  const { connection } = state;
  if ((connection as any).__lifecycleAttached) return;
  try { (connection as any).setMaxListeners?.(0); } catch {}
  let reconnectAttempts = 0;
  const MAX_RECONNECT_ATTEMPTS = 3;
  let isReconnecting = false;

  connection.on('stateChange', async (oldS: any, newS: any) => {
    console.log(`${SB} Voice state: ${oldS.status} → ${newS.status} (guild=${state.guildId})`);
    if (newS.status === VoiceConnectionStatus.Ready) {
      reconnectAttempts = 0; isReconnecting = false;
      if (!connectedSince.has(state.guildId)) connectedSince.set(state.guildId, new Date().toISOString());
      console.log(`${SB} Voice READY for guild=${state.guildId}`);
      return;
    }
    if (isReconnecting) { console.log(`${SB} Already reconnecting, skipping ${newS.status}`); return; }
    try {
      if (newS.status === VoiceConnectionStatus.Disconnected) {
        console.warn(`${SB} Disconnected – waiting for Signalling/Connecting...`);
        try { await Promise.race([entersState(connection, VoiceConnectionStatus.Signalling, 5_000), entersState(connection, VoiceConnectionStatus.Connecting, 5_000)]); }
        catch {
          if (reconnectAttempts < MAX_RECONNECT_ATTEMPTS) { reconnectAttempts++; console.log(`${SB} Rejoin attempt ${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS}`); connection.rejoin({ channelId: state.channelId, selfDeaf: false, selfMute: false }); }
          else { reconnectAttempts = 0; console.log(`${SB} Max reconnect attempts reached, creating fresh connection`); try { connection.destroy(); } catch {}
            const nc = joinVoiceChannel({ channelId: state.channelId, guildId: state.guildId, adapterCreator: debugAdapterCreator(guild), selfMute: false, selfDeaf: false, group: 'soundboard' });
            state.connection = nc; nc.subscribe(state.player); attachVoiceLifecycle(state, guild); }
        }
      } else if (newS.status === VoiceConnectionStatus.Destroyed) {
        console.warn(`${SB} Connection destroyed, recreating...`);
        connectedSince.delete(state.guildId);
        const nc = joinVoiceChannel({ channelId: state.channelId, guildId: state.guildId, adapterCreator: debugAdapterCreator(guild), selfMute: false, selfDeaf: false, group: 'soundboard' });
        state.connection = nc; nc.subscribe(state.player); attachVoiceLifecycle(state, guild);
      } else if (newS.status === VoiceConnectionStatus.Connecting || newS.status === VoiceConnectionStatus.Signalling) {
        isReconnecting = true;
        console.log(`${SB} Waiting for Ready from ${newS.status}...`);
        try { await entersState(connection, VoiceConnectionStatus.Ready, 15_000); }
        catch (e) {
          reconnectAttempts++;
          console.warn(`${SB} Timeout waiting for Ready from ${newS.status} (attempt ${reconnectAttempts}): ${(e as Error)?.message ?? e}`);
          if (reconnectAttempts < MAX_RECONNECT_ATTEMPTS) { await new Promise(r => setTimeout(r, reconnectAttempts * 2000)); isReconnecting = false; connection.rejoin({ channelId: state.channelId, selfDeaf: false, selfMute: false }); }
          else { reconnectAttempts = 0; isReconnecting = false; console.error(`${SB} Max attempts from ${newS.status}, fresh connection`); try { connection.destroy(); } catch {}
            const nc = joinVoiceChannel({ channelId: state.channelId, guildId: state.guildId, adapterCreator: debugAdapterCreator(guild), selfMute: false, selfDeaf: false, group: 'soundboard' });
            state.connection = nc; nc.subscribe(state.player); attachVoiceLifecycle(state, guild); }
        }
      }
    } catch (e) { console.error(`${SB} Lifecycle error: ${(e as Error)?.message ?? e}`); isReconnecting = false; }
  });
  (connection as any).__lifecycleAttached = true;
}

// ── Debug adapter wrapper ──
// voiceAdapterCreator(libraryMethods) → { sendPayload, destroy }
//   libraryMethods = { onVoiceServerUpdate, onVoiceStateUpdate, destroy }
//   returned adapter = { sendPayload(payload) → boolean, destroy() }
function debugAdapterCreator(guild: any): any {
  const original = guild.voiceAdapterCreator;
  return (libraryMethods: any) => {
    // Wrap library methods to log when Discord gateway events arrive
    const wrappedLibraryMethods = {
      ...libraryMethods,
      onVoiceServerUpdate(data: any) {
        console.log(`${SB} ← onVoiceServerUpdate: token=${data?.token ? 'yes' : 'no'} endpoint=${data?.endpoint ?? 'none'}`);
        return libraryMethods.onVoiceServerUpdate(data);
      },
      onVoiceStateUpdate(data: any) {
        console.log(`${SB} ← onVoiceStateUpdate: session_id=${data?.session_id ? 'yes' : 'no'} channel_id=${data?.channel_id ?? 'none'}`);
        return libraryMethods.onVoiceStateUpdate(data);
      },
    };
    // Call original adapter creator with our wrapped library methods
    const adapter = original(wrappedLibraryMethods);
    // Wrap the adapter's sendPayload to log outgoing gateway commands
    const origSend = adapter.sendPayload.bind(adapter);
    adapter.sendPayload = (payload: any) => {
      const result = origSend(payload);
      console.log(`${SB} → sendPayload op=${payload?.op ?? '?'} guild=${payload?.d?.guild_id ?? '?'} channel=${payload?.d?.channel_id ?? '?'} → ${result}`);
      return result;
    };
    return adapter;
  };
}

// ── Playback ──
let _pluginCtx: PluginContext | null = null;

async function playFilePath(guildId: string, channelId: string, filePath: string, volume?: number, relativeKey?: string): Promise<void> {
  console.log(`${SB} playFilePath: guild=${guildId} channel=${channelId} file=${path.basename(filePath)} vol=${volume ?? 'default'}`);
  const ctx = _pluginCtx!;
  const guild = ctx.client.guilds.cache.get(guildId);
  if (!guild) { console.error(`${SB} Guild ${guildId} not found in cache (cached: ${ctx.client.guilds.cache.map(g => g.id).join(', ')})`); throw new Error('Guild nicht gefunden'); }

  let state = guildAudioState.get(guildId);
  if (!state) {
    console.log(`${SB} No existing audio state, creating new connection...`);
    const connection = joinVoiceChannel({ channelId, guildId, adapterCreator: debugAdapterCreator(guild), selfMute: false, selfDeaf: false, group: 'soundboard' });
    // Debug: catch ALL state transitions and errors from the start
    connection.on('stateChange', (o: any, n: any) => {
      console.log(`${SB} [conn] ${o.status} → ${n.status}`);
      // Log networking info if available
      if (n.networking) console.log(`${SB} [conn] networking state: ${n.networking?.state?.code ?? 'unknown'}`);
    });
    connection.on('error', (err: any) => console.error(`${SB} [conn] ERROR: ${err?.message ?? err}`));
    console.log(`${SB} Connection created, initial status=${connection.state.status}, state keys=${Object.keys(connection.state).join(',')}`);
    const player = createAudioPlayer({ behaviors: { noSubscriber: NoSubscriberBehavior.Play } });
    connection.subscribe(player);
    state = { connection, player, guildId, channelId, currentVolume: getPersistedVolume(guildId) };
    guildAudioState.set(guildId, state);
    state.connection = await ensureConnectionReady(connection, channelId, guildId, guild);
    attachVoiceLifecycle(state, guild);
    console.log(`${SB} New voice connection established`);
  } else {
    console.log(`${SB} Existing audio state found, connection status=${state.connection.state.status}`);
  }

  // Channel-Wechsel
  try {
    const current = getVoiceConnection(guildId, 'soundboard');
    if (current && current.joinConfig?.channelId !== channelId) {
      current.destroy();
      const connection = joinVoiceChannel({ channelId, guildId, adapterCreator: debugAdapterCreator(guild), selfMute: false, selfDeaf: false, group: 'soundboard' });
      const player = state.player ?? createAudioPlayer({ behaviors: { noSubscriber: NoSubscriberBehavior.Play } });
      connection.subscribe(player);
      state = { connection, player, guildId, channelId, currentVolume: state.currentVolume ?? getPersistedVolume(guildId) };
      guildAudioState.set(guildId, state);
      state.connection = await ensureConnectionReady(connection, channelId, guildId, guild);
      attachVoiceLifecycle(state, guild);
    }
  } catch {}

  if (!getVoiceConnection(guildId, 'soundboard')) {
    const connection = joinVoiceChannel({ channelId, guildId, adapterCreator: debugAdapterCreator(guild), selfMute: false, selfDeaf: false, group: 'soundboard' });
    const player = state?.player ?? createAudioPlayer({ behaviors: { noSubscriber: NoSubscriberBehavior.Play } });
    connection.subscribe(player);
    state = { connection, player, guildId, channelId, currentVolume: state?.currentVolume ?? getPersistedVolume(guildId) };
    guildAudioState.set(guildId, state);
    state.connection = await ensureConnectionReady(connection, channelId, guildId, guild);
    attachVoiceLifecycle(state, guild);
  }

  const useVolume = typeof volume === 'number' && Number.isFinite(volume)
    ? Math.max(0, Math.min(1, volume))
    : (state.currentVolume ?? 1);

  let resource: AudioResource;
  if (NORMALIZE_ENABLE) {
    const cachedPath = getNormCachePath(filePath);
    if (cachedPath) {
      const pcmBuf = getPcmFromMemory(cachedPath);
      if (pcmBuf) {
        resource = createAudioResource(Readable.from(pcmBuf), { inlineVolume: useVolume !== 1, inputType: StreamType.Raw });
      } else {
        resource = createAudioResource(fs.createReadStream(cachedPath, { highWaterMark: 256 * 1024 }), { inlineVolume: true, inputType: StreamType.Raw });
      }
    } else {
      const cacheFile = path.join(NORM_CACHE_DIR, normCacheKey(filePath));
      const ff = child_process.spawn('ffmpeg', ['-hide_banner', '-loglevel', 'error', '-i', filePath,
        '-af', `loudnorm=I=${NORMALIZE_I}:LRA=${NORMALIZE_LRA}:TP=${NORMALIZE_TP}`,
        '-f', 's16le', '-ar', '48000', '-ac', '2', 'pipe:1']);
      const playerStream = new PassThrough();
      const cacheWrite = fs.createWriteStream(cacheFile);
      ff.stdout.on('data', (chunk: Buffer) => { playerStream.write(chunk); cacheWrite.write(chunk); });
      ff.stdout.on('end', () => {
        playerStream.end(); cacheWrite.end();
        try { const buf = fs.readFileSync(cacheFile);
          if (pcmMemoryCacheBytes + buf.byteLength <= PCM_MEMORY_CACHE_MAX_MB * 1024 * 1024) { pcmMemoryCache.set(cacheFile, buf); pcmMemoryCacheBytes += buf.byteLength; }
        } catch {}
      });
      ff.on('error', () => { try { fs.unlinkSync(cacheFile); } catch {} });
      ff.on('close', (code) => { if (code !== 0) { try { fs.unlinkSync(cacheFile); } catch {} } });
      resource = createAudioResource(playerStream, { inlineVolume: true, inputType: StreamType.Raw });
    }
  } else {
    resource = createAudioResource(filePath, { inlineVolume: true });
  }

  if (resource.volume) resource.volume.setVolume(useVolume);
  state.player.stop();
  console.log(`${SB} Playing resource: vol=${useVolume} normalized=${NORMALIZE_ENABLE} connStatus=${state.connection.state.status}`);

  // Log player errors
  state.player.removeAllListeners('error');
  state.player.on('error', (err: any) => {
    console.error(`${SB} AudioPlayer error: ${err?.message ?? err}`);
    if (err?.resource?.metadata) console.error(`${SB}   resource metadata:`, err.resource.metadata);
  });
  state.player.on('stateChange', (oldS: any, newS: any) => {
    if (oldS.status !== newS.status) console.log(`${SB} Player state: ${oldS.status} → ${newS.status}`);
  });

  state.player.play(resource);
  state.currentResource = resource;
  state.currentVolume = useVolume;

  const soundLabel = relativeKey ? path.parse(relativeKey).name : path.parse(filePath).name;
  nowPlaying.set(guildId, soundLabel);
  console.log(`${SB} Now playing: "${soundLabel}" in guild=${guildId}`);
  sseBroadcast({ type: 'soundboard_nowplaying', plugin: 'soundboard', guildId, name: soundLabel });
  if (relativeKey) incrementPlaysFor(relativeKey);
}

// ── Admin Auth (JWT-like with HMAC) ──
type AdminPayload = { iat: number; exp: number };
function b64url(input: Buffer | string): string {
  return Buffer.from(input).toString('base64').replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');
}
function signAdminToken(adminPwd: string, payload: AdminPayload): string {
  const body = b64url(JSON.stringify(payload));
  const sig = crypto.createHmac('sha256', adminPwd).update(body).digest('base64url');
  return `${body}.${sig}`;
}
function verifyAdminToken(adminPwd: string, token: string | undefined): boolean {
  if (!token || !adminPwd) return false;
  const [body, sig] = token.split('.');
  if (!body || !sig) return false;
  const expected = crypto.createHmac('sha256', adminPwd).update(body).digest('base64url');
  if (expected !== sig) return false;
  try {
    const payload = JSON.parse(Buffer.from(body, 'base64').toString('utf8')) as AdminPayload;
    return typeof payload.exp === 'number' && Date.now() < payload.exp;
  } catch { return false; }
}
function readCookie(req: express.Request, key: string): string | undefined {
  const c = req.headers.cookie;
  if (!c) return undefined;
  for (const part of c.split(';')) { const [k, v] = part.trim().split('='); if (k === key) return decodeURIComponent(v || ''); }
  return undefined;
}

// ── Party Mode ──
function schedulePartyPlayback(guildId: string, channelId: string) {
  const doPlay = async () => {
    try {
      const all = listAllSounds();
      if (all.length === 0) return;
      const pick = all[Math.floor(Math.random() * all.length)];
      await playFilePath(guildId, channelId, path.join(SOUNDS_DIR, pick.relativePath));
    } catch (e) { console.error('[Soundboard] party play error:', e); }
  };
  const loop = async () => {
    if (!partyActive.has(guildId)) return;
    await doPlay();
    if (!partyActive.has(guildId)) return;
    const delay = 30_000 + Math.floor(Math.random() * 60_000);
    partyTimers.set(guildId, setTimeout(loop, delay));
  };
  partyActive.add(guildId);
  void loop();
  sseBroadcast({ type: 'soundboard_party', plugin: 'soundboard', guildId, active: true, channelId });
}

// ── Discord Commands (DM) ──
async function handleCommand(message: Message, content: string) {
  const reply = async (txt: string) => { try { await message.author.send?.(txt); } catch { await message.reply(txt); } };
  const parts = content.split(/\s+/);
  const cmd = parts[0].toLowerCase();

  if (cmd === '?help') {
    await reply('Soundboard Commands:\n?help - Hilfe\n?list - Sounds\n?entrance <file> | remove\n?exit <file> | remove');
    return;
  }
  if (cmd === '?list') {
    const files = listAllSounds().map(s => s.relativePath);
    await reply(files.length ? files.join('\n') : 'Keine Dateien.');
    return;
  }
  if (cmd === '?entrance' || cmd === '?exit') {
    const isEntrance = cmd === '?entrance';
    const map = isEntrance ? 'entranceSounds' : 'exitSounds';
    const [, fileNameRaw] = parts;
    const userId = message.author?.id ?? '';
    if (!userId) { await reply('Kein Benutzer erkannt.'); return; }
    const fileName = fileNameRaw?.trim();
    if (!fileName) { await reply(`Verwendung: ${cmd} <datei.mp3|datei.wav> | remove`); return; }
    if (/^(remove|clear|delete)$/i.test(fileName)) {
      persistedState[map] = persistedState[map] ?? {};
      delete (persistedState[map] as Record<string, string>)[userId];
      writeState();
      await reply(`${isEntrance ? 'Entrance' : 'Exit'}-Sound entfernt.`);
      return;
    }
    if (!/\.(mp3|wav)$/i.test(fileName)) { await reply('Nur .mp3 oder .wav'); return; }
    const resolve = (() => {
      try {
        if (fs.existsSync(path.join(SOUNDS_DIR, fileName))) return fileName;
        for (const d of fs.readdirSync(SOUNDS_DIR, { withFileTypes: true })) {
          if (!d.isDirectory()) continue;
          if (fs.existsSync(path.join(SOUNDS_DIR, d.name, fileName))) return `${d.name}/${fileName}`;
        }
        return '';
      } catch { return ''; }
    })();
    if (!resolve) { await reply('Datei nicht gefunden. Nutze ?list.'); return; }
    persistedState[map] = persistedState[map] ?? {};
    (persistedState[map] as Record<string, string>)[userId] = resolve;
    writeState();
    await reply(`${isEntrance ? 'Entrance' : 'Exit'}-Sound gesetzt: ${resolve}`);
    return;
  }
  await reply('Unbekannter Command. Nutze ?help.');
}

// ── The Plugin ──
const soundboardPlugin: Plugin = {
  name: 'soundboard',
  version: '1.0.0',
  description: 'Discord Soundboard – MP3/WAV Sounds im Voice-Channel abspielen',

  async init(ctx) {
    _pluginCtx = ctx;
    fs.mkdirSync(SOUNDS_DIR, { recursive: true });
    fs.mkdirSync(NORM_CACHE_DIR, { recursive: true });
    persistedState = readPersistedState();

    // Voice encryption libs must be initialized before first voice connection
    await sodium.ready;
    void nacl.randomBytes(1);
    console.log(generateDependencyReport());

    console.log(`[Soundboard] ${listAllSounds().length} sounds, ${persistedState.totalPlays ?? 0} total plays`);
  },

  async onReady(ctx) {
    // Entrance/Exit Sounds
    ctx.client.on(Events.VoiceStateUpdate, async (oldState: VoiceState, newState: VoiceState) => {
      try {
        const userId = (newState.id || oldState.id) as string;
        if (!userId || userId === ctx.client.user?.id) return;
        const guildId = (newState.guild?.id || oldState.guild?.id) as string;
        if (!guildId) return;
        const before = oldState.channelId;
        const after = newState.channelId;

        if (after && before !== after) {
          const file = persistedState.entranceSounds?.[userId];
          if (file) {
            const abs = path.join(SOUNDS_DIR, file.replace(/\\/g, '/'));
            if (fs.existsSync(abs)) { try { await playFilePath(guildId, after, abs, undefined, file); } catch {} }
          }
        }
        if (before && !after) {
          const file = persistedState.exitSounds?.[userId];
          if (file) {
            const abs = path.join(SOUNDS_DIR, file.replace(/\\/g, '/'));
            if (fs.existsSync(abs)) { try { await playFilePath(guildId, before, abs, undefined, file); } catch {} }
          }
        }
      } catch {}
    });

    // DM Commands
    ctx.client.on(Events.MessageCreate, async (message: Message) => {
      try {
        if (message.author?.bot) return;
        const content = (message.content || '').trim();
        if (content.startsWith('?')) { await handleCommand(message, content); return; }
        if (!message.channel?.isDMBased?.()) return;
        if (message.attachments.size === 0) return;
        for (const [, attachment] of message.attachments) {
          const name = attachment.name ?? 'upload';
          if (!/\.(mp3|wav)$/i.test(name)) continue;
          const safeName = name.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_');
          let targetPath = path.join(SOUNDS_DIR, safeName);
          let i = 2;
          while (fs.existsSync(targetPath)) { const { name: n, ext } = path.parse(safeName); targetPath = path.join(SOUNDS_DIR, `${n}-${i}${ext}`); i++; }
          const res = await fetch(attachment.url);
          if (!res.ok) continue;
          fs.writeFileSync(targetPath, Buffer.from(await res.arrayBuffer()));
          if (NORMALIZE_ENABLE) normalizeToCache(targetPath).catch(() => {});
          await message.author.send?.(`Sound gespeichert: ${path.basename(targetPath)}`);
        }
      } catch {}
    });

    // Norm-Cache Sync
    syncNormCache();

    // Voice stats broadcast
    setInterval(() => {
      if (guildAudioState.size === 0) return;
      for (const [gId, st] of guildAudioState) {
        const status = st.connection.state?.status ?? 'unknown';
        if (status === 'ready' && !connectedSince.has(gId)) connectedSince.set(gId, new Date().toISOString());
        const ch = ctx.client.channels.cache.get(st.channelId);
        sseBroadcast({ type: 'soundboard_voicestats', plugin: 'soundboard', guildId: gId,
          voicePing: (st.connection.ping as any)?.ws ?? null, gatewayPing: ctx.client.ws.ping,
          status, channelName: ch && 'name' in ch ? (ch as any).name : null,
          connectedSince: connectedSince.get(gId) ?? null });
      }
    }, 5_000);
  },

  registerRoutes(app: express.Application, ctx: PluginContext) {
    const requireAdmin = (req: express.Request, res: express.Response, next: () => void) => {
      if (!ctx.adminPwd) { res.status(503).json({ error: 'Admin nicht konfiguriert' }); return; }
      if (!verifyAdminToken(ctx.adminPwd, readCookie(req, 'admin'))) { res.status(401).json({ error: 'Nicht eingeloggt' }); return; }
      next();
    };

    // ── Admin Auth ──
    app.post('/api/soundboard/admin/login', (req, res) => {
      if (!ctx.adminPwd) { res.status(503).json({ error: 'Admin nicht konfiguriert' }); return; }
      const { password } = req.body ?? {};
      if (!password || password !== ctx.adminPwd) { res.status(401).json({ error: 'Falsches Passwort' }); return; }
      const token = signAdminToken(ctx.adminPwd, { iat: Date.now(), exp: Date.now() + 7 * 24 * 3600 * 1000 });
      res.setHeader('Set-Cookie', `admin=${encodeURIComponent(token)}; HttpOnly; Path=/; Max-Age=${7 * 24 * 3600}; SameSite=Lax`);
      res.json({ ok: true });
    });
    app.post('/api/soundboard/admin/logout', (_req, res) => {
      res.setHeader('Set-Cookie', 'admin=; HttpOnly; Path=/; Max-Age=0; SameSite=Lax');
      res.json({ ok: true });
    });
    app.get('/api/soundboard/admin/status', (req, res) => {
      res.json({ authenticated: verifyAdminToken(ctx.adminPwd, readCookie(req, 'admin')) });
    });

    // ── Sounds ──
    app.get('/api/soundboard/sounds', (req, res) => {
      const q = String(req.query.q ?? '').toLowerCase();
      const folderFilter = typeof req.query.folder === 'string' ? req.query.folder : '__all__';
      const categoryFilter = typeof req.query.categoryId === 'string' ? String(req.query.categoryId) : undefined;
      const useFuzzy = String(req.query.fuzzy ?? '0') === '1';

      const allItems = listAllSounds();
      const folderCounts = new Map<string, number>();
      for (const it of allItems) { if (it.folder) folderCounts.set(it.folder, (folderCounts.get(it.folder) ?? 0) + 1); }
      const folders: Array<{ key: string; name: string; count: number }> = [];
      for (const [key, count] of folderCounts) folders.push({ key, name: key, count });

      const allWithTime = allItems.map(it => {
        try { return { ...it, mtimeMs: fs.statSync(path.join(SOUNDS_DIR, it.relativePath)).mtimeMs }; }
        catch { return { ...it, mtimeMs: 0 }; }
      });
      const sortedByNewest = [...allWithTime].sort((a, b) => b.mtimeMs - a.mtimeMs);
      const recentTop10 = sortedByNewest.slice(0, 10);
      const recentTop5Set = new Set(recentTop10.slice(0, 5).map(x => x.relativePath));

      let itemsByFolder = allItems as ListedSound[];
      if (folderFilter !== '__all__') {
        if (folderFilter === '__recent__') itemsByFolder = recentTop10;
        else itemsByFolder = allItems.filter(it => folderFilter === '' ? it.folder === '' : it.folder === folderFilter);
      }

      function fuzzyScore(text: string, pattern: string): number {
        if (!pattern) return 1;
        if (text === pattern) return 2000;
        const idx = text.indexOf(pattern);
        if (idx !== -1) return 1000 + (idx === 0 ? 200 : 0) - idx * 2;
        let tI = 0, pI = 0, score = 0, last = -1, gaps = 0, first = -1;
        while (tI < text.length && pI < pattern.length) {
          if (text[tI] === pattern[pI]) { if (first === -1) first = tI; if (last === tI - 1) score += 5; last = tI; pI++; }
          else if (first !== -1) gaps++;
          tI++;
        }
        if (pI !== pattern.length) return 0;
        return score + Math.max(0, 300 - first * 2) + Math.max(0, 100 - gaps * 10);
      }

      let filteredItems = itemsByFolder;
      if (q) {
        if (useFuzzy) {
          filteredItems = itemsByFolder.map(it => ({ it, score: fuzzyScore(it.name.toLowerCase(), q) }))
            .filter(x => x.score > 0).sort((a, b) => b.score - a.score || a.it.name.localeCompare(b.it.name))
            .map(x => x.it);
        } else {
          filteredItems = itemsByFolder.filter(s => s.name.toLowerCase().includes(q));
        }
      }

      const playsEntries = Object.entries(persistedState.plays || {});
      const top3 = playsEntries.sort((a, b) => (b[1] as number) - (a[1] as number)).slice(0, 3)
        .map(([rel, count]) => { const it = allItems.find(i => i.relativePath === rel || i.fileName === rel);
          return it ? { key: `__top__:${rel}`, name: `${it.name} (${count})`, count: 1 } : null; })
        .filter(Boolean) as Array<{ key: string; name: string; count: number }>;

      const foldersOut = [
        { key: '__all__', name: 'Alle', count: allItems.length },
        { key: '__recent__', name: 'Neu', count: Math.min(10, allItems.length) },
        ...(top3.length ? [{ key: '__top3__', name: 'Most Played (3)', count: top3.length }] : []),
        ...folders
      ];

      let result = filteredItems;
      if (categoryFilter) {
        const fc = persistedState.fileCategories ?? {};
        result = result.filter(it => (fc[it.relativePath ?? it.fileName] ?? []).includes(categoryFilter));
      }
      if (folderFilter === '__top3__') {
        const keys = new Set(top3.map(t => t.key.split(':')[1]));
        result = allItems.filter(i => keys.has(i.relativePath ?? i.fileName));
      }

      const top3Set = new Set(top3.map(t => t.key.split(':')[1]));
      const customBadges = persistedState.fileBadges ?? {};
      const withBadges = result.map(it => {
        const key = it.relativePath ?? it.fileName;
        const badges: string[] = [];
        if (recentTop5Set.has(key)) badges.push('new');
        if (top3Set.has(key)) badges.push('rocket');
        for (const b of (customBadges[key] ?? [])) badges.push(b);
        return { ...it, isRecent: recentTop5Set.has(key), badges };
      });

      res.json({ items: withBadges, total: allItems.length, folders: foldersOut,
        categories: persistedState.categories ?? [], fileCategories: persistedState.fileCategories ?? {} });
    });

    // ── Analytics ──
    app.get('/api/soundboard/analytics', (_req, res) => {
      const allItems = listAllSounds();
      const byKey = new Map<string, ListedSound>();
      for (const it of allItems) { byKey.set(it.relativePath, it); if (!byKey.has(it.fileName)) byKey.set(it.fileName, it); }
      const mostPlayed = Object.entries(persistedState.plays ?? {})
        .map(([rel, count]) => { const it = byKey.get(rel); return it ? { name: it.name, relativePath: it.relativePath, count: Number(count) || 0 } : null; })
        .filter((x): x is { name: string; relativePath: string; count: number } => !!x)
        .sort((a, b) => b.count - a.count || a.name.localeCompare(b.name)).slice(0, 10);
      res.json({ totalSounds: allItems.length, totalPlays: persistedState.totalPlays ?? 0, mostPlayed });
    });

    // ── Channels ──
    app.get('/api/soundboard/channels', (_req, res) => {
      if (!ctx.client.isReady()) { res.status(503).json({ error: 'Bot noch nicht bereit' }); return; }
      const allowed = new Set(ctx.allowedGuildIds);
      const result: any[] = [];
      for (const [, guild] of ctx.client.guilds.cache) {
        if (allowed.size > 0 && !allowed.has(guild.id)) continue;
        for (const [, ch] of guild.channels.cache) {
          if (ch?.type === ChannelType.GuildVoice || ch?.type === ChannelType.GuildStageVoice) {
            const sel = persistedState.selectedChannels?.[guild.id];
            const members = ('members' in ch)
              ? (ch as VoiceBasedChannel).members.filter(m => !m.user.bot).size
              : 0;
            result.push({ guildId: guild.id, guildName: guild.name, channelId: ch.id, channelName: ch.name, members, selected: sel === ch.id });
          }
        }
      }
      result.sort((a: any, b: any) => a.guildName.localeCompare(b.guildName) || a.channelName.localeCompare(b.channelName));
      res.json(result);
    });

    app.get('/api/soundboard/selected-channels', (_req, res) => {
      res.json({ selected: persistedState.selectedChannels ?? {} });
    });

    app.post('/api/soundboard/selected-channel', async (req, res) => {
      const { guildId, channelId } = req.body ?? {};
      if (!guildId || !channelId) { res.status(400).json({ error: 'guildId und channelId erforderlich' }); return; }
      const guild = ctx.client.guilds.cache.get(guildId);
      if (!guild) { res.status(404).json({ error: 'Guild nicht gefunden' }); return; }
      const ch = guild.channels.cache.get(channelId);
      if (!ch || (ch.type !== ChannelType.GuildVoice && ch.type !== ChannelType.GuildStageVoice)) { res.status(400).json({ error: 'Ungültiger Voice-Channel' }); return; }
      if (!persistedState.selectedChannels) persistedState.selectedChannels = {};
      persistedState.selectedChannels[guildId] = channelId;
      writeState();
      sseBroadcast({ type: 'soundboard_channel', plugin: 'soundboard', guildId, channelId });
      res.json({ ok: true });
    });

    // ── Play ──
    app.post('/api/soundboard/play', async (req, res) => {
      try {
        const { soundName, guildId, channelId, volume, folder, relativePath } = req.body ?? {};
        console.log(`${SB} POST /play: sound=${soundName} guild=${guildId} channel=${channelId} folder=${folder ?? '-'} relPath=${relativePath ?? '-'}`);
        if (!soundName || !guildId || !channelId) { console.warn(`${SB} /play missing params`); res.status(400).json({ error: 'soundName, guildId, channelId erforderlich' }); return; }
        let filePath: string;
        if (relativePath) filePath = path.join(SOUNDS_DIR, relativePath);
        else if (folder) { const mp3 = path.join(SOUNDS_DIR, folder, `${soundName}.mp3`); filePath = fs.existsSync(mp3) ? mp3 : path.join(SOUNDS_DIR, folder, `${soundName}.wav`); }
        else { const mp3 = path.join(SOUNDS_DIR, `${soundName}.mp3`); filePath = fs.existsSync(mp3) ? mp3 : path.join(SOUNDS_DIR, `${soundName}.wav`); }
        if (!fs.existsSync(filePath)) { console.warn(`${SB} Sound file not found: ${filePath}`); res.status(404).json({ error: 'Sound nicht gefunden' }); return; }
        console.log(`${SB} Resolved file: ${filePath}`);
        const relKey = relativePath || (folder ? `${folder}/${soundName}` : soundName);
        await playFilePath(guildId, channelId, filePath, volume, relKey);
        res.json({ ok: true });
      } catch (e: any) { console.error(`${SB} /play error: ${e?.message ?? e}`); res.status(500).json({ error: e?.message ?? 'Fehler' }); }
    });

    /** Shared download logic for play-url and download-url */
    async function handleUrlDownload(url: string, customFilename?: string): Promise<{ savedFile: string; savedPath: string }> {
      let savedFile: string;
      let savedPath: string;

      if (isYtDlpUrl(url)) {
        console.log(`${SB} [url-dl] → yt-dlp...`);
        const result = await downloadWithYtDlp(url);
        savedFile = result.filename;
        savedPath = result.filepath;
      } else {
        const parsed = new URL(url);
        savedFile = path.basename(parsed.pathname);
        savedPath = path.join(SOUNDS_DIR, savedFile);
        console.log(`${SB} [url-dl] → direct MP3: ${savedFile}`);
        const r = await fetch(url);
        if (!r.ok) throw new Error(`Download fehlgeschlagen (HTTP ${r.status})`);
        const buf = Buffer.from(await r.arrayBuffer());
        fs.writeFileSync(savedPath, buf);
        console.log(`${SB} [url-dl]   saved ${(buf.length / 1024).toFixed(0)} KB → ${savedFile}`);
      }

      // Rename if custom filename provided
      if (customFilename) {
        const safeName = customFilename.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_').trim();
        if (safeName) {
          const ext = path.extname(savedFile).toLowerCase() || '.mp3';
          const newName = safeName.endsWith(ext) ? safeName : safeName + ext;
          const newPath = path.join(SOUNDS_DIR, newName);
          if (newPath !== savedPath && !fs.existsSync(newPath)) {
            fs.renameSync(savedPath, newPath);
            console.log(`${SB} [url-dl]   renamed: ${savedFile} → ${newName}`);
            savedFile = newName;
            savedPath = newPath;
          }
        }
      }

      if (NORMALIZE_ENABLE) {
        try { await normalizeToCache(savedPath); console.log(`${SB} [url-dl]   normalized`); }
        catch (e: any) { console.error(`${SB} [url-dl]   normalize failed: ${e?.message}`); }
      }

      return { savedFile, savedPath };
    }

    app.post('/api/soundboard/play-url', async (req, res) => {
      const startTime = Date.now();
      try {
        const { url, guildId, channelId, volume, filename } = req.body ?? {};
        const urlType = isYtDlpUrl(url || '') ? 'yt-dlp' : isDirectMp3Url(url || '') ? 'mp3' : 'unknown';
        console.log(`${SB} [play-url] ▶ type=${urlType} url=${url} filename=${filename || '(auto)'} guild=${guildId}`);

        if (!url || !guildId || !channelId) { res.status(400).json({ error: 'url, guildId, channelId erforderlich' }); return; }
        try { new URL(url); } catch { res.status(400).json({ error: 'Ungültige URL' }); return; }
        if (!isSupportedUrl(url)) { res.status(400).json({ error: 'Nur YouTube, Instagram oder direkte MP3-Links' }); return; }

        const { savedFile, savedPath } = await handleUrlDownload(url, filename);

        try { await playFilePath(guildId, channelId, savedPath, volume, savedFile); console.log(`${SB} [play-url]   playing`); }
        catch (e: any) { console.error(`${SB} [play-url]   play failed (file saved): ${e?.message}`); }

        const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
        console.log(`${SB} [play-url] ✓ DONE in ${elapsed}s → ${savedFile}`);
        res.json({ ok: true, saved: savedFile });
      } catch (e: any) {
        const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
        console.error(`${SB} [play-url] ✗ ERROR after ${elapsed}s: ${e?.message ?? e}`);
        res.status(500).json({ error: e?.message ?? 'Fehler' });
      }
    });

    app.post('/api/soundboard/download-url', requireAdmin, async (req, res) => {
      const startTime = Date.now();
      try {
        const { url, filename } = req.body ?? {};
        const urlType = isYtDlpUrl(url || '') ? 'yt-dlp' : isDirectMp3Url(url || '') ? 'mp3' : 'unknown';
        console.log(`${SB} [download-url] ▶ type=${urlType} url=${url} filename=${filename || '(auto)'}`);

        if (!url) { res.status(400).json({ error: 'URL erforderlich' }); return; }
        try { new URL(url); } catch { res.status(400).json({ error: 'Ungültige URL' }); return; }
        if (!isSupportedUrl(url)) { res.status(400).json({ error: 'Nur YouTube, Instagram oder direkte MP3-Links' }); return; }

        const { savedFile } = await handleUrlDownload(url, filename);

        const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
        console.log(`${SB} [download-url] ✓ DONE in ${elapsed}s → ${savedFile}`);
        res.json({ ok: true, saved: savedFile });
      } catch (e: any) {
        const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
        console.error(`${SB} [download-url] ✗ ERROR after ${elapsed}s: ${e?.message ?? e}`);
        res.status(500).json({ error: e?.message ?? 'Fehler' });
      }
    });

    // ── Volume ──
    app.post('/api/soundboard/volume', (req, res) => {
      const { guildId, volume } = req.body ?? {};
      if (!guildId || typeof volume !== 'number') { res.status(400).json({ error: 'guildId und volume erforderlich' }); return; }
      const safeVol = Math.max(0, Math.min(1, volume));
      const state = guildAudioState.get(guildId);
      if (state) {
        state.currentVolume = safeVol;
        if (state.currentResource?.volume) state.currentResource.volume.setVolume(safeVol);
      }
      persistedState.volumes[guildId] = safeVol;
      writeState();
      sseBroadcast({ type: 'soundboard_volume', plugin: 'soundboard', guildId, volume: safeVol });
      res.json({ ok: true, volume: safeVol });
    });

    app.get('/api/soundboard/volume', (req, res) => {
      const guildId = String(req.query.guildId ?? '');
      if (!guildId) { res.status(400).json({ error: 'guildId erforderlich' }); return; }
      const state = guildAudioState.get(guildId);
      res.json({ volume: state?.currentVolume ?? getPersistedVolume(guildId) });
    });

    // ── Stop ──
    app.post('/api/soundboard/stop', (req, res) => {
      const guildId = String((req.query.guildId || req.body?.guildId) ?? '');
      if (!guildId) { res.status(400).json({ error: 'guildId erforderlich' }); return; }
      const state = guildAudioState.get(guildId);
      if (!state) { res.status(404).json({ error: 'Kein aktiver Player' }); return; }
      state.player.stop(true);
      nowPlaying.delete(guildId);
      sseBroadcast({ type: 'soundboard_nowplaying', plugin: 'soundboard', guildId, name: '' });
      const t = partyTimers.get(guildId); if (t) clearTimeout(t);
      partyTimers.delete(guildId); partyActive.delete(guildId);
      sseBroadcast({ type: 'soundboard_party', plugin: 'soundboard', guildId, active: false });
      res.json({ ok: true });
    });

    // ── Party ──
    app.post('/api/soundboard/party/start', (req, res) => {
      const { guildId, channelId } = req.body ?? {};
      if (!guildId || !channelId) { res.status(400).json({ error: 'guildId und channelId erforderlich' }); return; }
      const old = partyTimers.get(guildId); if (old) clearTimeout(old); partyTimers.delete(guildId);
      schedulePartyPlayback(guildId, channelId);
      res.json({ ok: true });
    });
    app.post('/api/soundboard/party/stop', (req, res) => {
      const guildId = String(req.body?.guildId ?? '');
      if (!guildId) { res.status(400).json({ error: 'guildId erforderlich' }); return; }
      const t = partyTimers.get(guildId); if (t) clearTimeout(t);
      partyTimers.delete(guildId); partyActive.delete(guildId);
      sseBroadcast({ type: 'soundboard_party', plugin: 'soundboard', guildId, active: false });
      res.json({ ok: true });
    });

    // ── Categories ──
    app.get('/api/soundboard/categories', (_req, res) => { res.json({ categories: persistedState.categories ?? [] }); });
    app.post('/api/soundboard/categories', requireAdmin, (req, res) => {
      const { name, color, sort } = req.body ?? {};
      if (!name?.trim()) { res.status(400).json({ error: 'name erforderlich' }); return; }
      const cat = { id: crypto.randomUUID(), name: name.trim(), color, sort };
      persistedState.categories = [...(persistedState.categories ?? []), cat];
      writeState(); res.json({ ok: true, category: cat });
    });
    app.patch('/api/soundboard/categories/:id', requireAdmin, (req, res) => {
      const cats = persistedState.categories ?? [];
      const idx = cats.findIndex(c => c.id === req.params.id);
      if (idx === -1) { res.status(404).json({ error: 'Nicht gefunden' }); return; }
      const { name, color, sort } = req.body ?? {};
      if (typeof name === 'string') cats[idx].name = name;
      if (typeof color === 'string') cats[idx].color = color;
      if (typeof sort === 'number') cats[idx].sort = sort;
      writeState(); res.json({ ok: true, category: cats[idx] });
    });
    app.delete('/api/soundboard/categories/:id', requireAdmin, (req, res) => {
      const cats = persistedState.categories ?? [];
      if (!cats.find(c => c.id === req.params.id)) { res.status(404).json({ error: 'Nicht gefunden' }); return; }
      persistedState.categories = cats.filter(c => c.id !== req.params.id);
      const fc = persistedState.fileCategories ?? {};
      for (const k of Object.keys(fc)) fc[k] = (fc[k] ?? []).filter(x => x !== req.params.id);
      writeState(); res.json({ ok: true });
    });
    app.post('/api/soundboard/categories/assign', requireAdmin, (req, res) => {
      const { files, add, remove } = req.body ?? {};
      if (!Array.isArray(files) || !files.length) { res.status(400).json({ error: 'files[] erforderlich' }); return; }
      const validCats = new Set((persistedState.categories ?? []).map(c => c.id));
      const fc = persistedState.fileCategories ?? {};
      for (const rel of files) {
        const old = new Set(fc[rel] ?? []);
        for (const a of (add ?? []).filter((id: string) => validCats.has(id))) old.add(a);
        for (const r of (remove ?? []).filter((id: string) => validCats.has(id))) old.delete(r);
        fc[rel] = Array.from(old);
      }
      persistedState.fileCategories = fc; writeState(); res.json({ ok: true, fileCategories: fc });
    });

    // ── Badges ──
    app.post('/api/soundboard/badges/assign', requireAdmin, (req, res) => {
      const { files, add, remove } = req.body ?? {};
      if (!Array.isArray(files) || !files.length) { res.status(400).json({ error: 'files[] erforderlich' }); return; }
      const fb = persistedState.fileBadges ?? {};
      for (const rel of files) { const old = new Set(fb[rel] ?? []);
        for (const a of (add ?? [])) old.add(a); for (const r of (remove ?? [])) old.delete(r); fb[rel] = Array.from(old); }
      persistedState.fileBadges = fb; writeState(); res.json({ ok: true, fileBadges: fb });
    });
    app.post('/api/soundboard/badges/clear', requireAdmin, (req, res) => {
      const { files } = req.body ?? {};
      if (!Array.isArray(files) || !files.length) { res.status(400).json({ error: 'files[] erforderlich' }); return; }
      const fb = persistedState.fileBadges ?? {};
      for (const rel of files) delete fb[rel];
      persistedState.fileBadges = fb; writeState(); res.json({ ok: true, fileBadges: fb });
    });

    // ── Admin: Delete & Rename ──
    app.post('/api/soundboard/admin/sounds/delete', requireAdmin, (req, res) => {
      const { paths: pathsList } = req.body ?? {};
      if (!Array.isArray(pathsList) || !pathsList.length) { res.status(400).json({ error: 'paths[] erforderlich' }); return; }
      const results: any[] = [];
      for (const rel of pathsList) {
        const full = safeSoundsPath(rel);
        if (!full) { results.push({ path: rel, ok: false, error: 'Ungültig' }); continue; }
        try {
          if (fs.existsSync(full) && fs.statSync(full).isFile()) {
            fs.unlinkSync(full);
            try { const cf = path.join(NORM_CACHE_DIR, normCacheKey(full)); if (fs.existsSync(cf)) fs.unlinkSync(cf); } catch {}
            results.push({ path: rel, ok: true });
          } else results.push({ path: rel, ok: false, error: 'nicht gefunden' });
        } catch (e: any) { results.push({ path: rel, ok: false, error: e?.message }); }
      }
      res.json({ ok: true, results });
    });

    app.post('/api/soundboard/admin/sounds/rename', requireAdmin, (req, res) => {
      const { from, to } = req.body ?? {};
      if (!from || !to) { res.status(400).json({ error: 'from und to erforderlich' }); return; }
      const src = safeSoundsPath(from);
      if (!src) { res.status(400).json({ error: 'Ungültiger Quellpfad' }); return; }
      const parsed = path.parse(from);
      const sanitizedName = to.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_');
      const dstRel = path.join(parsed.dir || '', `${sanitizedName}.mp3`);
      const dst = safeSoundsPath(dstRel);
      if (!dst) { res.status(400).json({ error: 'Ungültiger Zielpfad' }); return; }
      if (!fs.existsSync(src)) { res.status(404).json({ error: 'Quelle nicht gefunden' }); return; }
      if (fs.existsSync(dst)) { res.status(409).json({ error: 'Ziel existiert bereits' }); return; }
      fs.renameSync(src, dst);
      try { const cf = path.join(NORM_CACHE_DIR, normCacheKey(src)); if (fs.existsSync(cf)) fs.unlinkSync(cf); } catch {}
      res.json({ ok: true, from, to: dstRel });
    });

    // ── Upload ──
    const uploadStorage = multer.diskStorage({
      destination: (_req: any, _file: any, cb: (e: null, dest: string) => void) => cb(null, SOUNDS_DIR),
      filename: (_req: any, file: { originalname: string }, cb: (e: null, name: string) => void) => {
        const safe = file.originalname.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_');
        const { name, ext } = path.parse(safe);
        let finalName = safe; let i = 2;
        while (fs.existsSync(path.join(SOUNDS_DIR, finalName))) { finalName = `${name}-${i}${ext}`; i++; }
        cb(null, finalName);
      },
    });
    const uploadMulter = multer({ storage: uploadStorage,
      fileFilter: (_req: any, file: { originalname: string }, cb: (e: null, ok: boolean) => void) => {
        cb(null, /\.(mp3|wav)$/i.test(file.originalname));
      }, limits: { fileSize: 50 * 1024 * 1024, files: 20 } });

    app.post('/api/soundboard/upload', requireAdmin, (req, res) => {
      uploadMulter.array('files', 20)(req, res, async (err: any) => {
        if (err) { res.status(400).json({ error: err?.message ?? 'Upload fehlgeschlagen' }); return; }
        const files = (req as any).files as any[] | undefined;
        if (!files?.length) { res.status(400).json({ error: 'Keine gültigen Dateien' }); return; }
        if (NORMALIZE_ENABLE) { for (const f of files) normalizeToCache(f.path).catch(() => {}); }
        res.json({ ok: true, files: files.map(f => ({ name: f.filename, size: f.size })) });
      });
    });

    // ── Health ──
    app.get('/api/soundboard/health', (_req, res) => {
      res.json({ ok: true, totalPlays: persistedState.totalPlays ?? 0, categories: (persistedState.categories ?? []).length, sounds: listAllSounds().length });
    });

    // ── SSE Events (soundboard-specific data in main SSE stream) ──
    // The main hub SSE already exists at /api/events, snapshot data is provided via getSnapshot()
  },

  getSnapshot() {
    const statsSnap: Record<string, any> = {};
    for (const [gId, st] of guildAudioState) {
      const status = st.connection.state?.status ?? 'unknown';
      if (status === 'ready' && !connectedSince.has(gId)) connectedSince.set(gId, new Date().toISOString());
      const ch = _pluginCtx?.client.channels.cache.get(st.channelId);
      statsSnap[gId] = { voicePing: (st.connection.ping as any)?.ws ?? null,
        gatewayPing: _pluginCtx?.client.ws.ping, status,
        channelName: ch && 'name' in ch ? (ch as any).name : null,
        connectedSince: connectedSince.get(gId) ?? null };
    }
    return {
      soundboard: {
        party: Array.from(partyActive),
        selected: persistedState?.selectedChannels ?? {},
        volumes: persistedState?.volumes ?? {},
        nowplaying: Object.fromEntries(nowPlaying),
        voicestats: statsSnap,
      },
    };
  },

  async destroy() {
    for (const t of partyTimers.values()) clearTimeout(t);
    partyTimers.clear(); partyActive.clear();
    for (const [gId, state] of guildAudioState) {
      try { state.player.stop(true); } catch {}
      try { state.connection.destroy(); } catch {}
    }
    guildAudioState.clear();
    if (_writeTimer) { clearTimeout(_writeTimer); writeState(); }
    console.log('[Soundboard] Destroyed');
  },
};

export default soundboardPlugin;
