/* FLV → MP3 / MP4 converter — fully client-side via ffmpeg.wasm.
   Groups uploaded files by prefix (cameraVoip_* → audio → MP3;
   screenshare_* → video → MP4), orders them by the two trailing
   numeric segments, then uses FFmpeg to concatenate / convert.

   Notes:
   * Uses the single-threaded @ffmpeg/core build so there's no need
     for COOP/COEP headers. Everything runs in the page + one Worker.
   * Corrupt / unreadable FLV files are caught per-file and reported
     in the log; they do NOT abort the whole batch. */

(function () {
  'use strict';

  // -------- Theme toggle --------
  var THEME_KEY = 'theme';
  var $themeToggle = document.getElementById('themeToggle');
  if ($themeToggle) {
    $themeToggle.addEventListener('click', function () {
      var cur = document.documentElement.getAttribute('data-theme');
      if (!cur) {
        var dark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
        cur = dark ? 'dark' : 'light';
      }
      var nxt = (cur === 'dark') ? 'light' : 'dark';
      document.documentElement.setAttribute('data-theme', nxt);
      try { localStorage.setItem(THEME_KEY, nxt); } catch (e) { /* ignore */ }
    });
  }

  // -------- DOM refs --------
  var $dropZone   = document.getElementById('dropZone');
  var $filePicker = document.getElementById('filePicker');
  var $folderPicker = document.getElementById('folderPicker');
  var $btnPickFiles  = document.getElementById('btnPickFiles');
  var $btnPickFolder = document.getElementById('btnPickFolder');
  var $btnClear  = document.getElementById('btnClear');
  var $btnConvert = document.getElementById('btnConvert');
  var $btnCancel  = document.getElementById('btnCancel');
  var $summary = document.getElementById('summary');
  var $groups  = document.getElementById('groups');
  var $outputs = document.getElementById('outputs');
  var $statusBanner = document.getElementById('statusBanner');
  var $progress = document.getElementById('progressBar');
  var $progressText = document.getElementById('progressText');
  var $log = document.getElementById('log');
  // -------- State --------
  var files = [];          // Array<File>
  var groupedAudio = [];   // sorted cameraVoip files
  var groupedVideo = [];   // sorted screenshare files
  var skippedFiles = [];   // files we don't understand

  var ffmpeg = null;       // FFmpeg instance (lazy)
  var ffmpegLoading = null; // Promise while loading
  var cancelled = false;   // user hit Stop
  var running   = false;   // a conversion is in flight

  // Previous outputs (one per kind). When the user runs a second conversion,
  // we can optionally concat the previous output with the new files so the
  // class is continued seamlessly. Previous outputs are preserved in the UI
  // unless the user clicks "پاک کردن لیست".
  var prevOutputs = { audio: null, video: null };
  // prevOutputs[kind] = { blob, origName, extName, mime }
  var outputCounter = { audio: 0, video: 0 };

  // Keys of files that have already been consumed by a successful conversion.
  // When the user adds more files and clicks Convert again, only files NOT in
  // this set are processed, so a second run never re-converts files the user
  // already has output for.
  var convertedKeys = new Set();
  function keyOf(f) { return f.name + '|' + f.size + '|' + (f.lastModified || 0); }

  // -------- Helpers --------

  function setStatus(kind, message) {
    $statusBanner.className = 'status-banner ' + (kind || '');
    $statusBanner.innerHTML = '';
    if (kind === 'loading' || kind === 'success' || kind === 'error') {
      var dot = document.createElement('span');
      dot.className = 'dot';
      $statusBanner.appendChild(dot);
    }
    var span = document.createElement('span');
    span.textContent = message || '';
    $statusBanner.appendChild(span);
    $statusBanner.style.display = message ? 'flex' : 'none';
  }

  function setProgress(v) {
    var pct = Math.max(0, Math.min(100, (v || 0) * 100));
    $progress.style.width = pct.toFixed(1) + '%';
    if ($progressText) $progressText.textContent = pct.toFixed(0) + '%';
  }

  function setCancelVisible(on) {
    if ($btnCancel) $btnCancel.style.display = on ? 'inline-flex' : 'none';
  }

  function logLine(text, cls) {
    var line = document.createElement('div');
    if (cls) line.className = cls;
    line.textContent = text;
    $log.appendChild(line);
    $log.scrollTop = $log.scrollHeight;
  }

  function clearLog() { $log.innerHTML = ''; }

  function parseSortKey(name) {
    // Match trailing _<num>_<num>.flv  (e.g. cameraVoip_0_3.flv → [0, 3])
    // Fallback: any two trailing numbers; else [Infinity, filename].
    var m = /_(\d+)_(\d+)\.flv$/i.exec(name);
    if (m) return [parseInt(m[1], 10), parseInt(m[2], 10)];
    var m2 = /(\d+)[_.-](\d+)\.flv$/i.exec(name);
    if (m2) return [parseInt(m2[1], 10), parseInt(m2[2], 10)];
    return [Number.MAX_SAFE_INTEGER, name.toLowerCase()];
  }

  // Known Adobe Connect metadata / chat / index streams that shouldn't be
  // converted. Anything matching these prefixes is ignored.
  var BLACKLIST_RE = /^(ftchat|ftcontent|ftstage|indexstream|mainstream|transcriptstream)/i;

  function categorize(file) {
    var n = (file.name || '').toLowerCase();
    if (!n.endsWith('.flv')) return 'skip';
    if (BLACKLIST_RE.test(n)) return 'skip';
    if (/^cameravoip_\d/.test(n)) return 'audio';
    if (/^screenshare_\d/.test(n)) return 'video';
    // Anything else with an unknown prefix is ignored — the user asked for
    // strict matching on the two known patterns only.
    return 'skip';
  }

  function rebuildGroups() {
    groupedAudio = [];
    groupedVideo = [];
    skippedFiles = [];

    files.forEach(function (f) {
      var kind = categorize(f);
      if (kind === 'audio') groupedAudio.push(f);
      else if (kind === 'video') groupedVideo.push(f);
      else skippedFiles.push(f);
    });

    groupedAudio.sort(function (a, b) {
      var ka = parseSortKey(a.name), kb = parseSortKey(b.name);
      return (ka[0] - kb[0]) || (ka[1] - kb[1]);
    });
    groupedVideo.sort(function (a, b) {
      var ka = parseSortKey(a.name), kb = parseSortKey(b.name);
      return (ka[0] - kb[0]) || (ka[1] - kb[1]);
    });

    renderGroups();
  }

  function renderGroups() {
    $summary.style.display = files.length ? 'block' : 'none';
    $groups.innerHTML = '';
    $summary.innerHTML =
      'تعداد فایل‌های انتخاب‌شده: <strong>' + files.length + '</strong> — ' +
      'صوت: <strong>' + groupedAudio.length + '</strong> · ' +
      'اسکرین‌شیر/ویدیو: <strong>' + groupedVideo.length + '</strong> · ' +
      'ناشناخته: <strong>' + skippedFiles.length + '</strong>';

    if (!files.length) {
      $btnConvert.disabled = true;
      return;
    }
    $btnConvert.disabled = (groupedAudio.length === 0 && groupedVideo.length === 0);

    if (groupedAudio.length) {
      $groups.appendChild(buildGroupCard('🎙️ صوت کلاس (MP3)', groupedAudio, 'audio'));
    }
    if (groupedVideo.length) {
      $groups.appendChild(buildGroupCard('🖥️ اسکرین‌شیر / ویدیو (MP4)', groupedVideo, 'video'));
    }
    if (skippedFiles.length) {
      $groups.appendChild(buildGroupCard('⚠️ فایل‌های نامشخص (نادیده گرفته می‌شوند)', skippedFiles, 'skip'));
    }
  }

  function buildGroupCard(title, list, kind) {
    var card = document.createElement('div');
    card.className = 'group-card';
    var h = document.createElement('h3');
    h.innerHTML = title + ' <span class="count-badge">' + list.length + ' فایل</span>';
    card.appendChild(h);

    var ol = document.createElement('ol');
    ol.id = 'list-' + kind;
    list.forEach(function (f, i) {
      var li = document.createElement('li');
      li.id = 'li-' + kind + '-' + i;
      li.textContent = f.name;
      ol.appendChild(li);
    });
    card.appendChild(ol);
    return card;
  }

  function markItem(kind, index, cls) {
    var li = document.getElementById('li-' + kind + '-' + index);
    if (li) li.classList.add(cls);
  }

  function acceptFileList(list) {
    var added = 0;
    for (var i = 0; i < list.length; i++) {
      var f = list[i];
      if (!f) continue;
      // Keep only FLV; ignore everything else silently.
      if (!/\.flv$/i.test(f.name)) continue;
      // De-dupe by name+size.
      var dup = files.some(function (x) { return x.name === f.name && x.size === f.size; });
      if (!dup) { files.push(f); added++; }
    }
    rebuildGroups();
    return added;
  }

  // -------- File pickers --------

  $btnPickFiles.addEventListener('click', function (e) {
    e.preventDefault(); e.stopPropagation();
    $filePicker.click();
  });
  $btnPickFolder.addEventListener('click', function (e) {
    e.preventDefault(); e.stopPropagation();
    // IMPORTANT: only the folder picker, never the file picker.
    $folderPicker.click();
  });
  $btnClear.addEventListener('click', function () {
    files = []; rebuildGroups(); $outputs.innerHTML = '';
    clearLog(); setProgress(0); setStatus(null, '');
    convertedKeys = new Set();
    prevOutputs = { audio: null, video: null };
    outputCounter = { audio: 0, video: 0 };
  });

  $filePicker.addEventListener('change', function (e) {
    acceptFileList(e.target.files);
    e.target.value = '';
  });
  $folderPicker.addEventListener('change', function (e) {
    acceptFileList(e.target.files);
    e.target.value = '';
  });

  // Drag and drop
  ['dragenter', 'dragover'].forEach(function (ev) {
    $dropZone.addEventListener(ev, function (e) {
      e.preventDefault(); e.stopPropagation();
      $dropZone.classList.add('drag');
    });
  });
  ['dragleave', 'drop'].forEach(function (ev) {
    $dropZone.addEventListener(ev, function (e) {
      e.preventDefault(); e.stopPropagation();
      $dropZone.classList.remove('drag');
    });
  });
  $dropZone.addEventListener('drop', function (e) {
    var dt = e.dataTransfer;
    if (!dt) return;
    acceptFileList(dt.files);
  });
  $dropZone.addEventListener('click', function (e) {
    // Only react when the user clicks the drop-zone itself or its visual
    // children. Programmatic/bubbled clicks from hidden inputs elsewhere
    // on the page never reach here (inputs are outside the zone now), but
    // we still guard by verifying the target isn't a form control.
    var t = e.target;
    if (t && (t.tagName === 'INPUT' || t.tagName === 'BUTTON')) return;
    $filePicker.click();
  });

  // -------- FFmpeg loader --------

  // Engine preload banner DOM refs.
  var $engineBanner = document.getElementById('engineBanner');
  var $engineProgress = document.getElementById('engineProgress');
  var $engineProgressText = document.getElementById('engineProgressText');

  function setEngineProgress(p) {
    if (!$engineBanner) return;
    $engineBanner.classList.add('active');
    var pct = Math.round(Math.min(1, Math.max(0, p)) * 100);
    if ($engineProgress) $engineProgress.style.width = pct + '%';
    if ($engineProgressText) $engineProgressText.textContent = pct + '٪';
  }
  function setEngineReady() {
    if (!$engineBanner) return;
    $engineBanner.classList.add('active');
    $engineBanner.classList.add('ready');
    var label = $engineBanner.querySelector('.engine-label');
    if (label) label.textContent = '✓ موتور تبدیل آماده است — می‌توانید فایل‌ها را اضافه کنید';
    if ($engineProgressText) $engineProgressText.textContent = '۱۰۰٪';
    // Fade out after 3 seconds.
    setTimeout(function () {
      if ($engineBanner) $engineBanner.classList.remove('active');
    }, 3000);
  }

  // Tear down the current FFmpeg instance so the next loadFFmpeg() builds
  // a fresh wasm module. ffmpeg.wasm leaks heap memory between exec() calls,
  // and on long batches (many cameraVoip_*.flv files) the heap eventually
  // runs out of address space and aborts; reloading is the documented
  // workaround.
  async function reloadFFmpeg() {
    var prev = ffmpeg;
    ffmpeg = null;
    ffmpegLoading = null;
    if (prev) {
      try { prev.terminate && prev.terminate(); } catch (_) {}
    }
    return await loadFFmpeg();
  }

  async function loadFFmpeg() {
    if (ffmpeg && ffmpeg.loaded) return ffmpeg;
    if (ffmpegLoading) return ffmpegLoading;

    ffmpegLoading = (async function () {
      var FFmpeg = (window.FFmpegWASM || {}).FFmpeg;
      if (!FFmpeg) throw new Error('کتابخانه FFmpeg بارگذاری نشد.');
      var inst = new FFmpeg();

      inst.on('log', function (ev) {
        if (window && window.console) {
          console.log('[ffmpeg]', ev && ev.type, ev && ev.message);
        }
      });
      inst.on('progress', function (ev) {
        if (ev && typeof ev.progress === 'number') {
          var p = Math.min(1, Math.max(0, ev.progress));
          setProgress(0.10 + p * 0.90);
        }
      });

      // Core JS is small (~100KB) — no need for fine-grained progress.
      setEngineProgress(0.01);
      var coreURL = await toBlobURL('assets/vendor/ffmpeg/ffmpeg-core.js', 'text/javascript', null);

      // Wasm is ~30MB — show real-time download progress.
      var wasmURL = await toBlobURL(
        'assets/vendor/ffmpeg/ffmpeg-core.wasm',
        'application/wasm',
        function (loaded, total) {
          // 0..95% covers download; remaining 5% is for instantiation.
          var frac = total > 0 ? loaded / total : 0;
          setEngineProgress(0.02 + frac * 0.93);
        }
      );

      setEngineProgress(0.97);
      await inst.load({ coreURL: coreURL, wasmURL: wasmURL });
      setEngineProgress(1);
      setEngineReady();
      ffmpeg = inst;
      return inst;
    })();

    try {
      return await ffmpegLoading;
    } finally {
      ffmpegLoading = null;
    }
  }

  // Fetch a URL as a blob URL, optionally reporting download progress.
  // `onProgress` is called with (bytesLoaded, bytesTotal); `bytesTotal` is
  // 0 if the server doesn't provide Content-Length.
  async function toBlobURL(url, mime, onProgress) {
    var r = await fetch(url);
    if (!r.ok) throw new Error('خطای بارگذاری ' + url + ': ' + r.status);

    // If caller doesn't need progress, fall back to arrayBuffer (faster path).
    if (!onProgress) {
      var buf = await r.arrayBuffer();
      return URL.createObjectURL(new Blob([buf], { type: mime }));
    }

    var total = parseInt(r.headers.get('content-length') || '0', 10);
    var reader = r.body && r.body.getReader ? r.body.getReader() : null;
    if (!reader) {
      var buf2 = await r.arrayBuffer();
      return URL.createObjectURL(new Blob([buf2], { type: mime }));
    }
    var chunks = [];
    var loaded = 0;
    while (true) {
      var step = await reader.read();
      if (step.done) break;
      chunks.push(step.value);
      loaded += step.value.byteLength;
      onProgress(loaded, total);
    }
    return URL.createObjectURL(new Blob(chunks, { type: mime }));
  }

  async function fileToU8(file) {
    var buf = await file.arrayBuffer();
    return new Uint8Array(buf);
  }

  // Read the first video tag in an FLV stream and return the video codec
  // id (4 = VP6, 5 = VP6 alpha, 7 = H.264/AVC, etc.). Returns -1 if not
  // detectable. Lets us skip the stream-copy fast path when we already
  // know the codec can't be muxed into MP4 (e.g. VP6 from old Adobe
  // Connect / Vianna recordings).
  function detectFlvVideoCodec(u8) {
    try {
      if (u8.length < 13) return -1;
      if (u8[0] !== 0x46 || u8[1] !== 0x4c || u8[2] !== 0x56) return -1;
      var dataOffset = (u8[5] << 24) | (u8[6] << 16) | (u8[7] << 8) | u8[8];
      var p = dataOffset + 4; // skip PreviousTagSize0
      var guard = 0;
      while (p + 11 < u8.length && guard < 200) {
        guard++;
        var tagType = u8[p] & 0x1f;
        var dataSize = (u8[p + 1] << 16) | (u8[p + 2] << 8) | u8[p + 3];
        // Video tag
        if (tagType === 9 && dataSize >= 1) {
          var vb = u8[p + 11];
          return vb & 0x0f; // codec id
        }
        p += 11 + dataSize + 4;
      }
    } catch (e) { /* ignore */ }
    return -1;
  }

  // -------- Conversion core --------

  $btnConvert.addEventListener('click', function () { convertAll().catch(function (e) {
    console.error(e);
    setStatus('error', 'خطای غیرمنتظره: ' + (e && e.message ? e.message : e));
  }); });

  if ($btnCancel) $btnCancel.addEventListener('click', cancelConversion);

  function cancelConversion() {
    if (!running) return;
    cancelled = true;
    logLine('🛑 کاربر درخواست توقف کرد.', 'err');
    if (ffmpeg) {
      try { ffmpeg.terminate(); } catch (e) { /* ignore */ }
      ffmpeg = null;
    }
    setStatus('error', 'عملیات متوقف شد توسط کاربر.');
    setCancelVisible(false);
    $btnConvert.disabled = false;
    running = false;
  }

  async function convertAll() {
    if (running) return;
    running = true;
    cancelled = false;
    // Keep previous output cards visible — DON'T clear $outputs here.
    $btnConvert.disabled = true;
    setCancelVisible(true);
    clearLog();
    setProgress(0);
    setStatus('loading', 'در حال آماده‌سازی…');

    try {
      // Skip files that have already been consumed by a previous conversion.
      // The user's rule: a second run on a list that contains both old +
      // newly-added files must only process the newly-added files.
      var audioToRun = groupedAudio.filter(function (f) { return !convertedKeys.has(keyOf(f)); });
      var videoToRun = groupedVideo.filter(function (f) { return !convertedKeys.has(keyOf(f)); });

      if (!audioToRun.length && !videoToRun.length) {
        setStatus('error', 'فایل جدیدی برای تبدیل وجود ندارد. همه‌ی فایل‌های انتخاب‌شده قبلاً تبدیل شده‌اند.');
        running = false; setCancelVisible(false); $btnConvert.disabled = false;
        return;
      }

      // If either kind already has a previous output, ask the user what
      // they want: fresh output, or continuation (concat previous + new).
      var audioMode = 'new';
      var videoMode = 'new';
      if (audioToRun.length && prevOutputs.audio) {
        audioMode = await askNewOrContinue('صوت');
        if (audioMode === null) { running = false; setCancelVisible(false); $btnConvert.disabled = false; return; }
      }
      if (videoToRun.length && prevOutputs.video) {
        videoMode = await askNewOrContinue('ویدیو');
        if (videoMode === null) { running = false; setCancelVisible(false); $btnConvert.disabled = false; return; }
      }

      // The wasm engine preloads on page-open so by the time the user hits
      // convert it's already in memory. On a cold load (e.g. the user hit
      // Convert before preload finished), await the in-flight load.
      var needsLoad = !(ffmpeg && ffmpeg.loaded);
      if (needsLoad) setStatus('loading', 'در حال بارگذاری موتور تبدیل…');
      var ff = await loadFFmpeg();
      if (cancelled) return;

      if (audioToRun.length && !cancelled) {
        await runAudioGroup(audioToRun, audioMode === 'continue');
      }

      if (videoToRun.length && !cancelled) {
        await convertGroup(ff, 'video', videoToRun, {
          outExt: 'mp4',
          mime: 'video/mp4',
          title: '🖥️ اسکرین‌شیر / ویدیو',
          probe: false, // per-file remux already handles broken files
          continueMode: videoMode === 'continue',
          // Skip the primary concat-demuxer attempt and go straight to
          // per-file remux. This is the same strategy PotPlayer uses:
          // stream-copy each FLV to MP4 (container change only, no
          // re-encode), then concat-copy the MP4 segments. No libx264
          // involvement unless a single file's codec is exotic.
          skipPrimary: true,
          perFileFallback: true
        });
      }

      if (cancelled) return;
      setStatus('success', 'تبدیل با موفقیت پایان یافت.');
      setProgress(1);
    } catch (err) {
      if (cancelled) return;
      console.error(err);
      setStatus('error', (err && err.message) || 'خطای ناشناخته');
    } finally {
      running = false;
      setCancelVisible(false);
      $btnConvert.disabled = false;
    }
  }

  // Prompt shown when the user already has an output for the given kind.
  // Resolves to 'new', 'continue', or null (cancel).
  function askNewOrContinue(labelFa) {
    return new Promise(function (resolve) {
      var ov = document.createElement('div');
      ov.className = 'modal-overlay';
      ov.innerHTML =
        '<div class="modal-box">' +
        '  <h3>فایل‌های ' + labelFa + ' جدید</h3>' +
        '  <p>قبلاً یک فایل ' + labelFa + ' ساخته‌اید. این فایل‌های جدید را چطور تبدیل کنیم؟</p>' +
        '  <div class="modal-btns">' +
        '    <button type="button" class="btn" data-a="new">🆕 کلاس جدید — خروجی مستقل</button>' +
        '    <button type="button" class="btn secondary" data-a="continue">➕ ادامه کلاس قبلی — به انتهای خروجی قبل بچسبانید</button>' +
        '    <button type="button" class="btn secondary" data-a="cancel">انصراف</button>' +
        '  </div>' +
        '</div>';
      document.body.appendChild(ov);
      ov.addEventListener('click', function (e) {
        var t = e.target;
        if (t && t.dataset && t.dataset.a) {
          var a = t.dataset.a;
          ov.remove();
          resolve(a === 'cancel' ? null : a);
        }
      });
    });
  }

  // Strip ID3v2 (frame headers at start of each segment MP3) so the final
  // concatenated MP3 doesn't contain duplicate ID3 metadata that confuses
  // some players. Returns a view into `data` that starts past any ID3v2
  // header.
  function stripId3v2(data) {
    if (data.length < 10) return data;
    if (data[0] !== 0x49 || data[1] !== 0x44 || data[2] !== 0x33) return data;
    var size = ((data[6] & 0x7f) << 21) | ((data[7] & 0x7f) << 14) | ((data[8] & 0x7f) << 7) | (data[9] & 0x7f);
    return data.subarray(10 + size);
  }

  function concatUint8Arrays(parts) {
    var total = 0;
    for (var i = 0; i < parts.length; i++) total += parts[i].length;
    var out = new Uint8Array(total);
    var off = 0;
    for (var j = 0; j < parts.length; j++) { out.set(parts[j], off); off += parts[j].length; }
    return out;
  }

  // Audio path — hybrid for Adobe Connect classes:
  //
  //   1. PRIMARY (high fidelity): write every healthy cameraVoip_*.flv to
  //      the wasm FS, then run a single filter_complex concat with
  //      aformat normalisation. This preserves the original stream timing
  //      between segments — silences and pauses in the recording survive
  //      to the output.
  //
  //   2. FALLBACK (bullet-proof): if primary fails (ffmpeg.wasm aborts on
  //      memory pressure with many inputs, or rejects mismatched
  //      streams), reload the engine and transcode every input
  //      independently to a uniform MP3 segment, then byte-concat the
  //      segments in pure JavaScript. The wasm engine is recycled every
  //      RELOAD_EVERY conversions to dodge the ffmpeg.wasm heap leak.
  async function runAudioGroup(list, continueMode) {
    var RELOAD_EVERY = 12;
    var startSuffix = continueMode ? ' (ادامه کلاس قبلی)' : '';
    logLine('— 🎙️ صوت کلاس — ' + list.length + ' فایل' + startSuffix, 'step');
    setStatus('loading', 'در حال آماده‌سازی فایل‌ها…');
    setProgress(0);

    var ff = await loadFFmpeg();
    var brokenCount = 0;

    // -------- Phase 1: write + probe -------------------------------------
    // Each healthy file ends up in the wasm FS as a_<i>.flv. Broken files
    // are dropped here — they never reach the heavy stages.
    var goodInputs = []; // {file, vName, origName, idx}
    for (var i = 0; i < list.length; i++) {
      if (cancelled) return;
      var f = list[i];
      var vName = 'a_' + i + '.flv';
      setStatus('loading', 'بررسی فایل ' + (i + 1) + '/' + list.length + ' …');
      setProgress((i / list.length) * 0.10);
      var ok = false;
      try {
        var headerData = await fileToU8(f);
        if (headerData.length < 9 || headerData[0] !== 0x46 || headerData[1] !== 0x4c || headerData[2] !== 0x56) {
          throw new Error('bad-header');
        }
        await ff.writeFile(vName, headerData);
        // Tiny audio-only decode to confirm the file actually has a usable
        // audio stream. Files with only a data stream (no audio) are
        // silently skipped here so they don't poison the concat filter.
        var probeCode = await ff.exec([
          '-v', 'error', '-i', vName, '-vn', '-map', '0:a:0', '-t', '0.5', '-f', 'null', '-'
        ]);
        ok = (probeCode === 0);
      } catch (_) { ok = false; }
      if (ok) {
        goodInputs.push({ file: f, vName: vName, origName: f.name, idx: i });
        markItem('audio', i, 'done');
      } else {
        brokenCount++;
        logLine('⨯ فایل غیرقابل تبدیل، رد شد: ' + f.name, 'err');
        markItem('audio', i, 'skipped');
        try { await ff.deleteFile(vName); } catch (_) {}
      }
    }

    if (cancelled) return;
    if (!goodInputs.length) {
      logLine('⚠️ فایل سالمی برای صوت کلاس پیدا نشد.', 'err');
      return;
    }
    logLine('✓ ' + goodInputs.length + ' فایل سالم' + (brokenCount ? '، ' + brokenCount + ' فایل خراب رد شد' : ''), 'ok');

    // -------- Phase 2: try filter_complex (high-fidelity primary) --------
    setStatus('loading', 'تلاش برای ادغام با کیفیت بالا…');
    setProgress(0.15);
    var primaryBlob = null;
    try {
      var args = [];
      for (var k = 0; k < goodInputs.length; k++) args.push('-i', goodInputs[k].vName);
      var fc = '';
      for (var k2 = 0; k2 < goodInputs.length; k2++) {
        fc += '[' + k2 + ':a]aformat=sample_rates=44100:channel_layouts=stereo,aresample=async=1[a' + k2 + '];';
      }
      for (var k3 = 0; k3 < goodInputs.length; k3++) fc += '[a' + k3 + ']';
      fc += 'concat=n=' + goodInputs.length + ':v=0:a=1[aout]';
      args.push(
        '-filter_complex', fc,
        '-map', '[aout]',
        '-c:a', 'libmp3lame', '-q:a', '2',
        '-ar', '44100',
        '-y', 'out.mp3'
      );
      var primaryCode = await ff.exec(args);
      if (primaryCode === 0) {
        var primaryData = await ff.readFile('out.mp3');
        var primaryU8 = new Uint8Array(primaryData.buffer ? primaryData.buffer.slice(0) : primaryData);
        if (primaryU8.length > 0) {
          primaryBlob = new Blob([primaryU8], { type: 'audio/mpeg' });
        }
      }
    } catch (e) {
      primaryBlob = null;
    }

    // Free FS so phase 3 starts on a clean slate.
    try { await ff.deleteFile('out.mp3'); } catch (_) {}
    for (var c = 0; c < goodInputs.length; c++) {
      try { await ff.deleteFile(goodInputs[c].vName); } catch (_) {}
    }

    var blob;
    var goodCount;

    if (primaryBlob) {
      logLine('✓ ادغام با حالت کیفیت‌بالا انجام شد.', 'ok');
      blob = primaryBlob;
      goodCount = goodInputs.length;
    } else {
      // -------- Phase 3: per-file fallback ----------------------------
      logLine('… حالت پشتیبان: تبدیل تک‌به‌تک هر فایل (مقاوم برای کلاس‌های طولانی)…', 'step');
      ff = await reloadFFmpeg();
      var segs = [];
      var callsSinceReload = 0;
      var perFileOk = 0;
      var perFileBad = 0;

      for (var p = 0; p < goodInputs.length; p++) {
        if (cancelled) return;
        if (callsSinceReload >= RELOAD_EVERY) {
          ff = await reloadFFmpeg();
          callsSinceReload = 0;
        }
        var gi = goodInputs[p];
        setStatus('loading', 'تبدیل صوت ' + (p + 1) + '/' + goodInputs.length + ': ' + gi.origName);
        setProgress(0.15 + (p / goodInputs.length) * 0.80);

        var converted = false;
        try {
          var fdata = await fileToU8(gi.file);
          await ff.writeFile('a_in.flv', fdata);
          var code = await ff.exec([
            '-fflags', '+genpts+igndts',
            '-i', 'a_in.flv',
            '-vn',
            '-map', '0:a:0?',
            '-af', 'asetpts=N/SR/TB,aformat=sample_rates=44100:channel_layouts=stereo,aresample=async=1',
            '-c:a', 'libmp3lame', '-q:a', '2',
            '-ar', '44100',
            '-y', 'a_out.mp3'
          ]);
          converted = (code === 0);
          callsSinceReload++;
          if (converted) {
            var seg = await ff.readFile('a_out.mp3');
            var su8 = new Uint8Array(seg.buffer ? seg.buffer.slice(0) : seg);
            if (su8.length > 0) {
              segs.push(stripId3v2(su8));
              perFileOk++;
            } else {
              converted = false;
            }
          }
          fdata = null;
        } catch (e) {
          converted = false;
        }
        if (!converted) {
          perFileBad++;
          logLine('⨯ فایل غیرقابل تبدیل، رد شد: ' + gi.origName, 'err');
          markItem('audio', gi.idx, 'skipped');
        }
        try { await ff.deleteFile('a_in.flv'); } catch (_) {}
        try { await ff.deleteFile('a_out.mp3'); } catch (_) {}
      }

      if (cancelled) return;
      if (!segs.length) {
        logLine('⚠️ هیچ فایلی به‌درستی تبدیل نشد.', 'err');
        return;
      }
      var finalBytes = concatUint8Arrays(segs);
      blob = new Blob([finalBytes], { type: 'audio/mpeg' });
      goodCount = perFileOk;
      brokenCount += perFileBad;
    }

    // -------- Phase 4: continue-mode prepend + emit output --------------
    if (continueMode && prevOutputs.audio) {
      try {
        var prevBuf = new Uint8Array(await prevOutputs.audio.blob.arrayBuffer());
        var nextBuf = new Uint8Array(await blob.arrayBuffer());
        var combined = concatUint8Arrays([stripId3v2(prevBuf), stripId3v2(nextBuf)]);
        blob = new Blob([combined], { type: 'audio/mpeg' });
      } catch (_) {
        logLine('⚠️ نتوانستیم خروجی قبلی را بخوانیم، فقط فایل‌های جدید تبدیل می‌شوند.', 'err');
      }
    }

    setStatus('loading', 'ادغام نهایی صوت…');
    setProgress(0.97);

    var url = URL.createObjectURL(blob);

    outputCounter.audio++;
    var cnt = outputCounter.audio;
    var downloadName = 'class-audio' + (cnt > 1 ? '-' + cnt : '') + '.mp3';
    var cardTitle = '🎙️ صوت کلاس' + (cnt > 1 ? ' #' + cnt : '');

    $outputs.appendChild(buildOutputCard({
      title: cardTitle,
      mime: 'audio/mpeg',
      url: url,
      downloadName: downloadName,
      isAudio: true,
      fileCount: goodCount
    }));

    prevOutputs.audio = {
      blob: blob,
      origName: downloadName,
      extName: 'mp3',
      mime: 'audio/mpeg'
    };

    // Mark these inputs as already converted so a subsequent run with
    // "new class" mode only processes freshly-added files.
    for (var k = 0; k < list.length; k++) {
      convertedKeys.add(keyOf(list[k]));
    }

    setProgress(1);
    logLine('✓ خروجی صوت آماده شد: ' + downloadName, 'ok');
  }

  async function convertGroup(ff, kind, list, opts) {
    var startSuffix = opts.continueMode ? ' (ادامه کلاس قبلی)' : '';
    logLine('— ' + opts.title + ' — ' + list.length + ' فایل' + startSuffix, 'step');
    setStatus('loading', 'خواندن فایل‌های ' + opts.title + '…');
    setProgress(0);

    // 1. Write every input into the virtual FS. Broken files (bad header)
    //    are skipped immediately.
    var goodInputs = [];
    var brokenCount = 0;
    for (var i = 0; i < list.length; i++) {
      if (cancelled) return;
      var f = list[i];
      var vName = kind + '_' + i + '.flv';
      try {
        var data = await fileToU8(f);
        if (data.length < 9 || data[0] !== 0x46 || data[1] !== 0x4c || data[2] !== 0x56) {
          throw new Error('bad-header');
        }
        var vCodec = detectFlvVideoCodec(data);
        await ff.writeFile(vName, data);
        goodInputs.push({ vName: vName, origName: f.name, idx: i, vCodec: vCodec });
        markItem(kind, i, 'done');
      } catch (e) {
        brokenCount++;
        logLine('⨯ فایل خراب، رد شد: ' + f.name, 'err');
        markItem(kind, i, 'skipped');
      }
      setProgress(Math.min(0.10, (i + 1) / list.length * 0.10));
    }
    if (cancelled) return;

    // 2. Probe each file with a tiny decode test. FLV recordings sometimes
    //    arrive with unreadable streams that would poison a concat pass;
    //    kick those out individually so the rest of the batch still succeeds.
    //    For audio (kind === 'audio') we force audio-only decode so that a
    //    cameraVoip_*.flv with a missing/empty audio stream is caught and
    //    dropped instead of breaking the filter_complex concat.
    if (opts.probe && goodInputs.length) {
      setStatus('loading', 'بررسی سلامت فایل‌های ' + opts.title + '…');
      var filtered = [];
      for (var p = 0; p < goodInputs.length; p++) {
        if (cancelled) return;
        var gi = goodInputs[p];
        var ok = false;
        try {
          var probeArgs = ['-v', 'error', '-i', gi.vName];
          if (kind === 'audio') {
            probeArgs.push('-vn', '-map', '0:a:0');
          }
          probeArgs.push('-t', '0.5', '-f', 'null', '-');
          var probeCode = await ff.exec(probeArgs);
          ok = (probeCode === 0);
        } catch (pe) { ok = false; }
        if (ok) {
          filtered.push(gi);
        } else {
          brokenCount++;
          logLine('⨯ فایل غیرقابل تبدیل، رد شد: ' + gi.origName, 'err');
          markItem(kind, gi.idx, 'skipped');
          try { await ff.deleteFile(gi.vName); } catch (_) {}
        }
        setProgress(0.10 + (p + 1) / goodInputs.length * 0.10);
      }
      goodInputs = filtered;
    }

    if (cancelled) return;

    // Prepend the previous output if the user chose "continue".
    if (opts.continueMode && prevOutputs[kind]) {
      try {
        var prevBuf = new Uint8Array(await prevOutputs[kind].blob.arrayBuffer());
        var prevName = 'prev-' + kind + '.' + prevOutputs[kind].extName;
        await ff.writeFile(prevName, prevBuf);
        goodInputs.unshift({ vName: prevName, origName: prevOutputs[kind].origName, idx: -1 });
      } catch (pe) {
        logLine('⚠️ نتوانستیم خروجی قبلی را بخوانیم، فقط فایل‌های جدید تبدیل می‌شوند.', 'err');
      }
    }

    if (!goodInputs.length) {
      logLine('⚠️ فایل سالمی برای ' + opts.title + ' پیدا نشد.', 'err');
      return;
    }

    // Summary of what's going to be converted.
    var goodCount = goodInputs.length - (opts.continueMode && prevOutputs[kind] ? 1 : 0);
    logLine('✓ ' + goodCount + ' فایل سالم' + (brokenCount ? '، ' + brokenCount + ' فایل خراب رد شد' : ''), 'ok');

    setStatus('loading', 'در حال تبدیل ' + opts.title + '…');

    // 3. Write the concat list file (used by video; harmless for audio).
    var listText = goodInputs.map(function (x) { return "file '" + x.vName + "'"; }).join('\n') + '\n';
    await ff.writeFile('inputs.txt', new TextEncoder().encode(listText));

    // 4. Run the primary command (unless skipped); on failure, try the
    //    per-file fallback if provided.
    var runPrimary = !opts.skipPrimary && typeof opts.ffmpegArgs === 'function';
    var primaryFailed = !runPrimary;
    if (runPrimary) {
      try {
        var args = opts.ffmpegArgs(goodInputs);
        var code = await ff.exec(args);
        if (code !== 0) throw new Error('exit ' + code);
      } catch (e) {
        primaryFailed = true;
      }
    }
    if (primaryFailed) {
      if (!opts.perFileFallback) {
        logLine('✗ تبدیل ' + opts.title + ' ناموفق بود.', 'err');
        throw new Error('primary failed and no fallback');
      }
      if (runPrimary) logLine('… تلاش با حالت سازگاری بیشتر…', 'step');
      // Encode every input independently to a uniform segment, then
      // concat-copy the segments. A single bad file is skipped in
      // isolation instead of poisoning the whole batch.
      var segs;
      if (opts.perFileFallback === 'audio') {
        segs = await perFileAudioTranscode(ff, goodInputs);
      } else {
        segs = await perFileVideoTranscode(ff, goodInputs);
      }
      if (!segs.length) {
        logLine('✗ تبدیل ' + opts.title + ' ناموفق بود.', 'err');
        throw new Error('no usable segments');
      }
      var segListText = segs.map(function (n) { return "file '" + n + "'"; }).join('\n') + '\n';
      await ff.writeFile('segs.txt', new TextEncoder().encode(segListText));
      var concatArgs = ['-f', 'concat', '-safe', '0', '-i', 'segs.txt', '-c', 'copy'];
      if (opts.outExt === 'mp4') concatArgs.push('-movflags', '+faststart');
      concatArgs.push('-y', 'out.' + opts.outExt);
      var codeCat = await ff.exec(concatArgs);
      try { await ff.deleteFile('segs.txt'); } catch (_) {}
      for (var si = 0; si < segs.length; si++) {
        try { await ff.deleteFile(segs[si]); } catch (_) {}
      }
      if (codeCat !== 0) {
        logLine('✗ تبدیل ' + opts.title + ' ناموفق بود.', 'err');
        throw new Error('concat exit ' + codeCat);
      }
    }

    // 5. Read output, emit preview + download.
    var outData = await ff.readFile('out.' + opts.outExt);
    var blob = new Blob([outData.buffer], { type: opts.mime });
    var url = URL.createObjectURL(blob);

    outputCounter[kind]++;
    var cnt = outputCounter[kind];
    var downloadName = (kind === 'audio' ? 'class-audio' : 'class-video')
      + (cnt > 1 ? '-' + cnt : '')
      + '.' + opts.outExt;
    var cardTitle = opts.title + (cnt > 1 ? ' #' + cnt : '');

    $outputs.appendChild(buildOutputCard({
      title: cardTitle,
      mime: opts.mime,
      url: url,
      downloadName: downloadName,
      isAudio: opts.outExt === 'mp3',
      fileCount: goodCount
    }));

    // Remember this output so the next convert run can optionally append
    // to it (continuation mode).
    prevOutputs[kind] = {
      blob: blob,
      origName: downloadName,
      extName: opts.outExt,
      mime: opts.mime
    };

    logLine('✓ خروجی ' + (kind === 'audio' ? 'صوت' : 'ویدیو') + ' آماده شد: ' + downloadName, 'ok');

    // Mark every input file as "already converted" so a subsequent run
    // doesn't re-convert them. We mark ALL files in the originally-supplied
    // list (even broken ones) so the user isn't re-prompted on every click.
    for (var mi = 0; mi < list.length; mi++) {
      convertedKeys.add(keyOf(list[mi]));
    }

    // 6. Cleanup virtual FS so subsequent runs aren't polluted.
    try { await ff.deleteFile('out.' + opts.outExt); } catch (_) {}
    try { await ff.deleteFile('inputs.txt'); } catch (_) {}
    for (var j = 0; j < list.length; j++) {
      try { await ff.deleteFile(kind + '_' + j + '.flv'); } catch (_) {}
    }
    try { await ff.deleteFile('prev-' + kind + '.' + opts.outExt); } catch (_) {}
  }

  // Convert each input to a uniform MP4 segment. FAST PATH: stream-copy
  // (FLV → MP4 remux). Most screenshare FLVs contain H.264 + AAC which
  // MP4 can hold natively, so no re-encode is needed — this takes
  // milliseconds per file and is what PotPlayer does under the hood.
  // SLOW PATH: if stream-copy fails (exotic codec, truncated stream, etc.),
  // we re-encode that single file. Broken files still get skipped.
  // FLV codec IDs that MP4 container natively supports (stream-copy works).
  // 7 = AVC/H.264. Everything else (VP6 = 4/5, Sorenson = 2, Screen = 3/6)
  // must be re-encoded because MP4 can't carry those streams.
  function isMp4CompatibleVCodec(id) { return id === 7; }

  // Per-file audio path: encode every cameraVoip_*.flv to a uniform MP3
  // segment (44.1 kHz stereo) independently, then concat-copy them.
  // Each input has its timestamps reset to zero (asetpts=PTS-STARTPTS) so
  // the giant absolute timestamps inside Adobe Connect Nellymoser streams
  // don't poison the muxer, and so concat-copy at the end produces a clean
  // monotonic MP3.
  async function perFileAudioTranscode(ff, inputs) {
    var ok = [];
    for (var i = 0; i < inputs.length; i++) {
      if (cancelled) return ok;
      var inp = inputs[i];
      var segName = 'seg_' + i + '.mp3';
      setStatus('loading', 'تبدیل صوت ' + (i + 1) + '/' + inputs.length + ': ' + inp.origName);
      setProgress(0.20 + (i / inputs.length) * 0.75);
      var converted = false;
      try {
        var code = await ff.exec([
          '-fflags', '+genpts+igndts',
          '-i', inp.vName,
          '-vn',
          '-map', '0:a:0?',
          '-af', 'asetpts=N/SR/TB,aformat=sample_rates=44100:channel_layouts=stereo,aresample=async=1',
          '-c:a', 'libmp3lame', '-q:a', '2',
          '-ar', '44100',
          '-write_xing', '0',
          '-y', segName
        ]);
        converted = (code === 0);
      } catch (e) { converted = false; }
      if (converted) {
        ok.push(segName);
      } else {
        logLine('⨯ فایل غیرقابل تبدیل، رد شد: ' + inp.origName, 'err');
        try { await ff.deleteFile(segName); } catch (_) {}
      }
    }
    setProgress(0.95);
    return ok;
  }

  async function perFileVideoTranscode(ff, inputs) {
    var ok = [];
    for (var i = 0; i < inputs.length; i++) {
      if (cancelled) return ok;
      var inp = inputs[i];
      var segName = 'seg_' + i + '.mp4';
      var codecName = (inp.vCodec === 4 || inp.vCodec === 5) ? 'VP6 (Flash)'
                    : (inp.vCodec === 7) ? 'H.264'
                    : ('کدک ' + inp.vCodec);
      setStatus('loading', 'تبدیل فایل ' + (i + 1) + '/' + inputs.length + ' (' + codecName + '): ' + inp.origName);
      setProgress(i / inputs.length);
      if (i === 0 && (inp.vCodec === 4 || inp.vCodec === 5)) {
        logLine('ℹ️ کدک ویدیو: VP6 قدیمی (Flash) — ری‌انکد به H.264 لازم است.');
      } else if (i === 0 && inp.vCodec === 7) {
        logLine('ℹ️ کدک ویدیو: H.264 — بدون ری‌انکد (سریع).');
      }

      // --- Fast path: remux (stream copy) — only if codec is MP4-compatible.
      // For H.264 sources this is near-instant (just container change).
      // For VP6 (Flash-era FLV) we skip straight to the re-encode path
      // since MP4 can't hold VP6 streams and the copy attempt is wasted time.
      if (isMp4CompatibleVCodec(inp.vCodec)) {
        var copyOk = false;
        try {
          var codeCopy = await ff.exec([
            '-fflags', '+genpts+igndts',
            '-i', inp.vName,
            '-c', 'copy',
            '-bsf:a', 'aac_adtstoasc',
            '-movflags', '+faststart',
            '-avoid_negative_ts', 'make_zero',
            '-y', segName
          ]);
          copyOk = (codeCopy === 0);
        } catch (e) { copyOk = false; }
        if (copyOk) { ok.push(segName); continue; }
        try { await ff.deleteFile(segName); } catch (_) {}
      }

      // --- Re-encode path (required for VP6 or when copy failed). Tuned
      // for speed on ffmpeg.wasm (single-threaded wasm build):
      //   * ultrafast + zerolatency  → fastest x264 settings, no B-frames
      //   * crf 30                   → visually fine for screenshare, ~4x
      //                               smaller/faster than crf 23
      //   * scale to max width 960   → VP6 screenshare is usually 1920x1088;
      //                               encoding at native res is 4x slower
      //                               for no visible benefit on screen
      //                               recordings. H.264 sources are kept
      //                               at native resolution.
      //   * -r 15                    → class screenshare is static most of
      //                               the time; capping frame rate at 15
      //                               halves encoder load.
      var isVP6 = (inp.vCodec === 4 || inp.vCodec === 5);
      var scaleFilter = isVP6
        ? "scale='if(gt(iw,960),960,iw)':'-2':flags=fast_bilinear"
        : 'scale=trunc(iw/2)*2:trunc(ih/2)*2';
      var encArgs = [
        '-fflags', '+genpts+igndts',
        '-i', inp.vName,
        '-c:v', 'libx264',
        '-preset', 'ultrafast',
        '-tune', 'zerolatency',
        '-crf', '30',
        '-pix_fmt', 'yuv420p',
        '-vf', scaleFilter,
      ];
      if (isVP6) encArgs.push('-r', '15');
      encArgs.push(
        '-an',               // screenshare tracks have no useful audio
        '-movflags', '+faststart',
        '-y', segName
      );
      try {
        var codeEnc = await ff.exec(encArgs);
        if (codeEnc === 0) {
          ok.push(segName);
        } else {
          logLine('⨯ فایل غیرقابل تبدیل، رد شد: ' + inp.origName, 'err');
          try { await ff.deleteFile(segName); } catch (_) {}
        }
      } catch (e) {
        logLine('⨯ فایل غیرقابل تبدیل، رد شد: ' + inp.origName, 'err');
        try { await ff.deleteFile(segName); } catch (_) {}
      }
    }
    setProgress(1);
    return ok;
  }

  function buildOutputCard(o) {
    var card = document.createElement('div');
    card.className = 'output-card';

    var h = document.createElement('h3');
    h.innerHTML = o.title + ' <span class="count-badge">' + o.fileCount + ' فایل ورودی</span>';
    card.appendChild(h);

    var media = document.createElement(o.isAudio ? 'audio' : 'video');
    media.controls = true;
    media.preload = 'metadata';
    media.src = o.url;
    if (!o.isAudio) {
      media.style.maxHeight = '60vh';
      media.playsInline = true;
    }
    card.appendChild(media);

    var row = document.createElement('div');
    row.className = 'btn-row';
    row.style.justifyContent = 'flex-start';

    var a = document.createElement('a');
    a.href = o.url;
    a.download = o.downloadName;
    a.className = 'btn';
    a.textContent = '⬇️ دانلود ' + o.downloadName;
    row.appendChild(a);

    card.appendChild(row);
    return card;
  }


  // Initial render state
  renderGroups();
  setStatus(null, '');
  setProgress(0);

  // Kick off FFmpeg wasm preload immediately so by the time the user has
  // picked their files, the engine is already in memory and conversions
  // start instantly. Errors here are non-fatal — loadFFmpeg will retry
  // lazily when the user clicks Convert.
  setTimeout(function () {
    loadFFmpeg().catch(function (err) {
      console.warn('[flv-converter] preload failed, will retry on convert:', err);
      if ($engineBanner) $engineBanner.classList.remove('active');
    });
  }, 150);
})();
