// Bulk-Upload — admin-only catalog seeding tool. Drag-drop dozens
// of MP3s + an optional folder of cover images that we'll auto-pair
// by filename stem. Each row uploads through the existing
// /api/submissions endpoint, parallelised at MAX_CONCURRENT.
//
// Why a dedicated page instead of extending Submit: regular users
// rarely have 100+ tracks to seed. The bulk flow needs row-by-row
// status indicators, retry buttons, and concurrency control that
// would clutter the single-track form. Keeping them separate also
// means admins can use Submit normally for one-offs without losing
// the bulk queue.
//
// Limits: rate limiters (3/h, 10/day) and the pending cap (2)
// are server-side bypassed for admins (see middleware.js +
// submissions.js comments). MP3 size cap (32 MB) still applies.

// How many uploads to have in flight at once. Higher = faster total
// time but server CPU spike (ffmpeg transcode is the hot path).
// 3 is a comfortable balance — leaves room for the lyrics worker
// to keep up with the per-track tail without backing up.
const MAX_CONCURRENT = 3;

// Image extensions we'll match against MP3 filenames.
const IMAGE_EXTS = ['.png', '.jpg', '.jpeg', '.webp'];

// Random Beachy-Lila palette per track when no cover image matched.
// Same 9 gradients the single-Submit form offers; we just pick one
// at random instead of asking. Admins can re-cover any track later
// via the AdminMenu "Cover ändern" entry.
const PALETTES = [
  ['#FF7E8B', '#8A4FE2'],  ['#6EE0D2', '#2BB0C8'],  ['#FFD27A', '#FF7E8B'],
  ['#A972F4', '#6F39C9'],  ['#E94CD0', '#8A4FE2'],  ['#FFD27A', '#FFB48A'],
  ['#8FB7FF', '#A972F4'],  ['#6EE0D2', '#8FB7FF'],  ['#FF7E8B', '#FFD27A'],
];

// Strip filename extension + collapse case/whitespace so MP3-cover
// pairing is forgiving across `Cinder Bloom.mp3` ↔ `cinder_bloom.png`
// etc. Strip diacritics is intentionally skipped — easier to debug
// "why didn't this pair" by eye.
function stemOf(name) {
  return name
    .replace(/\.[a-z0-9]+$/i, '')
    .toLowerCase()
    .replace(/[\s_]+/g, '');
}

function BulkUploadScreen({ me, refresh }) {
  // Each entry: {
  //   id (local nanoid), mp3 (File), coverFile (File | null),
  //   coverPalette (idx into PALETTES), title (editable),
  //   status: 'queued' | 'uploading' | 'done' | 'failed',
  //   error (string | null), trackId (string | null after success)
  // }
  const [rows, setRows] = React.useState([]);
  const [mp3Drag, setMp3Drag] = React.useState(false);
  const [coverDrag, setCoverDrag] = React.useState(false);
  const [running, setRunning] = React.useState(false);

  // Cache of object URLs for cover preview thumbnails. Keyed by
  // row.id so we revoke + recreate when the row changes. Without
  // this we'd leak a few MB per cover-preview across many uploads.
  const previewUrls = React.useRef(new Map());
  React.useEffect(() => {
    return () => {
      // On unmount, clean up everything we created.
      for (const url of previewUrls.current.values()) URL.revokeObjectURL(url);
      previewUrls.current.clear();
    };
  }, []);
  const previewFor = (row) => {
    if (!row.coverFile) return null;
    const existing = previewUrls.current.get(row.id);
    if (existing) return existing;
    const url = URL.createObjectURL(row.coverFile);
    previewUrls.current.set(row.id, url);
    return url;
  };

  // Accept MP3 file list — typically from the drop-zone or file-
  // input. Builds new row entries and APPENDS to the existing queue
  // (so you can drop a second batch on top of an in-progress run).
  // Auto-deduplicates by `name + size + lastModified` so dropping
  // the same folder twice doesn't double-queue.
  const addMp3s = (files) => {
    if (!files || files.length === 0) return;
    const fresh = [];
    setRows(prev => {
      const existing = new Set(prev.map(r => r.mp3.name + r.mp3.size + r.mp3.lastModified));
      for (const f of files) {
        if (!/\.mp3$/i.test(f.name) && f.type !== 'audio/mpeg') continue;
        const key = f.name + f.size + f.lastModified;
        if (existing.has(key)) continue;
        existing.add(key);
        fresh.push({
          id: Math.random().toString(36).slice(2, 10),
          mp3: f,
          coverFile: null,
          coverPalette: Math.floor(Math.random() * PALETTES.length),
          title: f.name.replace(/\.mp3$/i, ''),
          status: 'queued',
          error: null,
          trackId: null,
        });
      }
      return [...prev, ...fresh];
    });
    if (fresh.length === 0) {
      toast.info('Keine neuen MP3-Dateien gefunden.');
    } else {
      toast.success(`${fresh.length} Track${fresh.length === 1 ? '' : 's'} hinzugefügt.`);
    }
  };

  // Accept cover files and pair them with existing MP3 rows by
  // filename stem. Unmatched covers get dropped with a count so
  // the user knows.
  const addCovers = (files) => {
    if (!files || files.length === 0) return;
    setRows(prev => {
      // Build lookup of stem → File for the dropped images.
      const byStem = new Map();
      let unmatchedExts = 0;
      for (const f of files) {
        const lower = f.name.toLowerCase();
        if (!IMAGE_EXTS.some(ext => lower.endsWith(ext))) {
          unmatchedExts++;
          continue;
        }
        byStem.set(stemOf(f.name), f);
      }

      let paired = 0;
      const next = prev.map(r => {
        if (r.coverFile) return r;   // already has a cover, don't overwrite
        const match = byStem.get(stemOf(r.mp3.name));
        if (!match) return r;
        paired++;
        // Free any old preview URL — we may have rolled a different
        // cover earlier.
        const old = previewUrls.current.get(r.id);
        if (old) { URL.revokeObjectURL(old); previewUrls.current.delete(r.id); }
        return { ...r, coverFile: match };
      });
      const skipped = byStem.size - paired;
      if (paired === 0 && byStem.size > 0) {
        toast.info('Keine Cover-Dateien passten zu MP3-Namen.');
      } else if (skipped > 0) {
        toast.info(`${paired} Cover gepaart, ${skipped} ohne passende MP3 übersprungen.`);
      } else {
        toast.success(`${paired} Cover gepaart.`);
      }
      if (unmatchedExts > 0) {
        toast.error(`${unmatchedExts} Dateien sind keine Bilder.`);
      }
      return next;
    });
  };

  const removeRow = (id) => {
    setRows(prev => prev.filter(r => r.id !== id));
    const url = previewUrls.current.get(id);
    if (url) { URL.revokeObjectURL(url); previewUrls.current.delete(id); }
  };
  const clearAll = () => {
    if (running) { toast.error('Upload läuft noch.'); return; }
    if (!confirm('Alle Einträge entfernen?')) return;
    for (const url of previewUrls.current.values()) URL.revokeObjectURL(url);
    previewUrls.current.clear();
    setRows([]);
  };
  const updateTitle = (id, title) =>
    setRows(prev => prev.map(r => r.id === id ? { ...r, title } : r));
  const rerollPalette = (id) =>
    setRows(prev => prev.map(r => r.id === id
      ? { ...r, coverPalette: (r.coverPalette + 1) % PALETTES.length }
      : r));

  // Upload a single row. Returns nothing — mutates `rows` via
  // setRows so the UI reflects status as it changes.
  const uploadOne = async (row) => {
    setRows(prev => prev.map(r => r.id === row.id
      ? { ...r, status: 'uploading', error: null } : r));
    try {
      const form = new FormData();
      form.append('title', row.title.trim() || row.mp3.name.replace(/\.mp3$/i, ''));
      form.append('coverGradient', JSON.stringify(PALETTES[row.coverPalette]));
      if (row.coverFile) form.append('cover', row.coverFile);
      form.append('mp3', row.mp3);
      const result = await Api.submit(form);
      setRows(prev => prev.map(r => r.id === row.id
        ? { ...r, status: 'done', trackId: result?.id || null }
        : r));
    } catch (e) {
      setRows(prev => prev.map(r => r.id === row.id
        ? { ...r, status: 'failed', error: e.message || 'unbekannt' }
        : r));
    }
  };

  // Drain the queue in batches of MAX_CONCURRENT. Doesn't return
  // until everything that was queued is either done or failed.
  // `getQueue` is re-read on each iteration so rows added mid-run
  // get picked up too.
  const runAll = async () => {
    setRunning(true);
    try {
      // We need a closure over the latest rows array, but React state
      // setters are async. Use a ref-like approach: snapshot the
      // queued IDs at the start, work from that list.
      // (Newly-added rows after this point won't be auto-included —
      // the user can hit "Alle hochladen" again.)
      const snapshot = rows.filter(r => r.status === 'queued' || r.status === 'failed');
      let cursor = 0;
      const workers = Array.from({ length: MAX_CONCURRENT }, async () => {
        while (cursor < snapshot.length) {
          const next = snapshot[cursor++];
          await uploadOne(next);
        }
      });
      await Promise.all(workers);
      // Refresh the global submissions cache so any open Home /
      // Stats screens see the new tracks immediately.
      if (refresh) await refresh();
    } finally {
      setRunning(false);
    }
  };

  // Counts for the action bar.
  const counts = {
    total:   rows.length,
    queued:  rows.filter(r => r.status === 'queued').length,
    running: rows.filter(r => r.status === 'uploading').length,
    done:    rows.filter(r => r.status === 'done').length,
    failed:  rows.filter(r => r.status === 'failed').length,
  };
  const pendingWork = counts.queued + counts.failed;

  // Gate the whole page — defense-in-depth (router also blocks).
  if (!canForceAccept(me)) {
    return (
      <div style={{ padding: '60px 32px 140px', maxWidth: 620, margin: '0 auto' }}>
        <Glass radius={20} padding={36} style={{ textAlign: 'center' }}>
          <ApIcon name="shield" size={32} color="var(--gold)"/>
          <h2 style={{ fontSize: 20, fontWeight: 800, margin: '12px 0 6px' }}>Nur Admins</h2>
        </Glass>
      </div>
    );
  }

  return (
    <div style={{ padding: '32px 36px 140px', maxWidth: 1240, margin: '0 auto' }}>

      <div style={{ marginBottom: 22 }}>
        <Eyebrow style={{ marginBottom: 6 }}>Admin-Werkzeug · Katalog-Seeding</Eyebrow>
        <h1 style={{ fontWeight: 800, fontSize: 40, lineHeight: 1.05, letterSpacing: '-0.02em', margin: 0 }}>
          Bulk-Upload
        </h1>
        <p style={{ fontSize: 14.5, color: 'var(--fg-2)', marginTop: 8, maxWidth: 720, opacity: 0.82 }}>
          Mehrere MP3-Dateien gleichzeitig hochladen. Optional kannst du einen Ordner Cover-Bilder
          dazuwerfen — wir matchen sie automatisch nach Dateiname (z. B. <code>track.mp3</code> ↔ <code>track.png</code>).
          Tracks ohne Cover bekommen einen zufälligen Farbverlauf, den du sp&auml;ter pro Track im Admin-Menü tauschen kannst.
        </p>
      </div>

      {/* Two drop zones side by side */}
      <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 14, marginBottom: 18 }}>
        <DropZone
          icon="music"
          title="MP3-Dateien"
          subtitle="Ziehe Dateien oder einen ganzen Ordner hierher"
          accept="audio/mpeg,.mp3"
          multiple
          drag={mp3Drag} setDrag={setMp3Drag}
          onFiles={addMp3s}
        />
        <DropZone
          icon="image"
          title="Cover-Bilder (optional)"
          subtitle="Werden nach Dateiname mit den MP3s gepaart"
          accept="image/*"
          multiple
          drag={coverDrag} setDrag={setCoverDrag}
          onFiles={addCovers}
        />
      </div>

      {/* Action bar */}
      {rows.length > 0 && (
        <Glass radius={14} padding="14px 18px" style={{
          marginBottom: 14, display: 'flex', alignItems: 'center', gap: 14, flexWrap: 'wrap',
        }}>
          <div style={{ display: 'flex', gap: 14, alignItems: 'center', flexWrap: 'wrap' }}>
            <StatBubble label="Total"  value={counts.total}  color="var(--lila-300)"/>
            <StatBubble label="Wartet" value={counts.queued} color="var(--fg-2)"/>
            {counts.running > 0 && <StatBubble label="Läuft" value={counts.running} color="var(--info)"/>}
            <StatBubble label="Fertig" value={counts.done}   color="var(--success)"/>
            {counts.failed > 0 && <StatBubble label="Fehler" value={counts.failed} color="var(--danger)"/>}
          </div>
          <div style={{ flex: 1 }}/>
          <Button variant="ghost" size="sm" icon="trash" onClick={clearAll} disabled={running}>
            Liste leeren
          </Button>
          <Button variant="primary" size="md" icon="cloud-arrow-up"
            disabled={running || pendingWork === 0}
            onClick={runAll}>
            {running
              ? `Lädt… (${counts.done + counts.failed} / ${counts.total})`
              : `${pendingWork} hochladen`}
          </Button>
        </Glass>
      )}

      {/* Row list */}
      {rows.length === 0 ? (
        <EmptyState
          icon="folder-open"
          title="Noch keine Dateien"
          description="Zieh deine MP3s in den linken Bereich oben."
        />
      ) : (
        <Glass radius={20} padding={0}>
          {rows.map((r, i) => (
            <RowItem key={r.id} row={r} previewUrl={previewFor(r)}
              border={i < rows.length - 1}
              onTitleChange={(v) => updateTitle(r.id, v)}
              onRerollPalette={() => rerollPalette(r.id)}
              onRemove={() => removeRow(r.id)}
              onRetry={() => uploadOne(r)}
              disabled={running}/>
          ))}
        </Glass>
      )}
    </div>
  );
}

// Generic drop-zone — handles drag-over highlight + click-to-pick.
// The file <input> stays inside the <label> so the whole zone
// becomes a clickable picker target.
function DropZone({ icon, title, subtitle, accept, multiple, drag, setDrag, onFiles }) {
  return (
    <label
      onDragOver={(e) => { e.preventDefault(); setDrag(true); }}
      onDragLeave={() => setDrag(false)}
      onDrop={(e) => {
        e.preventDefault(); setDrag(false);
        // Most browsers expose the dropped files via dataTransfer.files;
        // when the user drops a folder, modern browsers also include
        // every file recursively. We rely on that — no webkitDirectory
        // gymnastics needed.
        onFiles(Array.from(e.dataTransfer.files || []));
      }}
      style={{
        display: 'flex', alignItems: 'center', gap: 16, padding: 22, borderRadius: 18,
        background: drag ? 'rgba(255,126,139,0.10)' : 'var(--bg-input)',
        border: drag ? '2px dashed var(--coral)' : '2px dashed var(--border-medium)',
        cursor: 'pointer', transition: 'all 150ms var(--ease-out)',
      }}
    >
      <div style={{
        width: 56, height: 56, borderRadius: 14, flexShrink: 0,
        background: 'rgba(196,154,255,0.10)',
        display: 'flex', alignItems: 'center', justifyContent: 'center',
      }}>
        <ApIcon name={icon} size={22} color="var(--fg-2)"/>
      </div>
      <div style={{ flex: 1, minWidth: 0 }}>
        <div style={{ fontSize: 15, fontWeight: 700, color: 'var(--foam)' }}>{title}</div>
        <div style={{ fontSize: 12, color: 'var(--fg-3)', marginTop: 3 }}>{subtitle}</div>
      </div>
      <input type="file" accept={accept} multiple={multiple} hidden
        onChange={(e) => {
          onFiles(Array.from(e.target.files || []));
          e.target.value = '';
        }}/>
    </label>
  );
}

// Tiny pill for the action bar counter cluster.
function StatBubble({ label, value, color }) {
  return (
    <div style={{
      display: 'inline-flex', alignItems: 'baseline', gap: 6,
      padding: '4px 10px', borderRadius: 999,
      background: 'rgba(196,154,255,0.08)',
      border: '1px solid var(--border-soft)',
    }}>
      <span style={{ fontSize: 14, fontWeight: 700, color, fontVariantNumeric: 'tabular-nums' }}>
        {value}
      </span>
      <span style={{ fontSize: 10.5, color: 'var(--fg-3)', letterSpacing: 0.4, textTransform: 'uppercase' }}>
        {label}
      </span>
    </div>
  );
}

// Single row in the bulk-upload queue. Shows cover preview / palette,
// editable title, file size, status, and per-row actions.
function RowItem({ row, previewUrl, border, onTitleChange, onRerollPalette, onRemove, onRetry, disabled }) {
  const [a, b] = PALETTES[row.coverPalette];
  const statusInfo = {
    queued:    { fg: 'var(--fg-3)',   bg: 'rgba(196,154,255,0.10)', label: 'Wartet',  icon: 'clock' },
    uploading: { fg: 'var(--info)',   bg: 'rgba(143,183,255,0.16)', label: 'Lädt…',   icon: 'arrows-rotate' },
    done:      { fg: 'var(--success)', bg: 'rgba(95,216,166,0.16)', label: 'Fertig',  icon: 'check' },
    failed:    { fg: 'var(--danger)', bg: 'rgba(255,110,138,0.16)', label: 'Fehler',  icon: 'x' },
  }[row.status];

  return (
    <div style={{
      display: 'flex', alignItems: 'center', gap: 14, padding: '14px 18px',
      borderBottom: border ? '1px solid var(--border-hairline)' : 'none',
    }}>
      {/* Cover preview — either the uploaded image or the palette. */}
      <div style={{
        width: 48, height: 48, borderRadius: 12, flexShrink: 0,
        background: previewUrl
          ? `url(${previewUrl}) center/cover`
          : `linear-gradient(135deg, ${a}, ${b})`,
        boxShadow: `0 4px 12px ${a}55`,
        position: 'relative', cursor: previewUrl ? 'default' : 'pointer',
      }}
      onClick={() => { if (!previewUrl && !disabled) onRerollPalette(); }}
      title={previewUrl ? row.coverFile.name : 'Klicken für anderen Farbverlauf'}>
        {!previewUrl && (
          <div style={{
            position: 'absolute', bottom: 2, right: 2,
            width: 16, height: 16, borderRadius: '50%',
            background: 'rgba(17,7,36,0.7)',
            display: 'flex', alignItems: 'center', justifyContent: 'center',
          }}>
            <ApIcon name="shuffle" size={8} color="var(--foam)"/>
          </div>
        )}
      </div>

      {/* Title + filename + size */}
      <div style={{ flex: 1, minWidth: 0 }}>
        <input
          value={row.title}
          onChange={(e) => onTitleChange(e.target.value)}
          disabled={disabled || row.status === 'uploading' || row.status === 'done'}
          maxLength={80}
          style={{
            width: '100%', background: 'transparent', border: 'none',
            color: 'var(--foam)', fontSize: 14, fontWeight: 700,
            fontFamily: 'inherit', padding: 0, outline: 'none',
          }}/>
        <div style={{
          fontSize: 11.5, color: 'var(--fg-3)', display: 'flex', alignItems: 'center', gap: 8,
        }}>
          <span style={{ fontFamily: 'var(--font-mono)' }}>{row.mp3.name}</span>
          <span style={{ color: 'var(--fg-4)' }}>·</span>
          <span style={{ fontVariantNumeric: 'tabular-nums' }}>
            {(row.mp3.size / 1048576).toFixed(1)} MB
          </span>
          {row.error && (
            <>
              <span style={{ color: 'var(--fg-4)' }}>·</span>
              <span style={{ color: 'var(--danger)' }} title={row.error}>{row.error}</span>
            </>
          )}
        </div>
      </div>

      {/* Status pill */}
      <span style={{
        display: 'inline-flex', alignItems: 'center', gap: 5, padding: '4px 10px',
        borderRadius: 999, background: statusInfo.bg, color: statusInfo.fg,
        fontSize: 11, fontWeight: 700, letterSpacing: 0.3, textTransform: 'uppercase',
      }}>
        <ApIcon name={statusInfo.icon} size={10}
          className={row.status === 'uploading' ? 'spin' : ''}/>
        {statusInfo.label}
      </span>

      {/* Per-row actions */}
      <div style={{ display: 'flex', gap: 6 }}>
        {row.status === 'failed' && (
          <Button variant="warning" size="sm" icon="arrows-rotate"
            onClick={onRetry} disabled={disabled}>
            Erneut
          </Button>
        )}
        {row.status !== 'done' && (
          <button onClick={onRemove} disabled={disabled || row.status === 'uploading'}
            title="Entfernen" aria-label="Entfernen"
            style={{
              background: 'transparent', border: 'none', cursor: 'pointer',
              color: 'var(--fg-3)', padding: 6, display: 'flex',
              opacity: disabled || row.status === 'uploading' ? 0.4 : 1,
            }}>
            <ApIcon name="x" size={14}/>
          </button>
        )}
      </div>
    </div>
  );
}

window.BulkUploadScreen = BulkUploadScreen;
