/* Customer Cloud Gallery — Bulk-Download.
 * Pfad-Auswahl beim Klick auf „Alle Bilder herunterladen":
 *   1. File System Access API (Chrome/Edge/Opera): Bilder einzeln in den
 *      vom Besucher gewählten Ordner — kein ZIP, kein Größenlimit.
 *   2. Streaming-ZIP via Service Worker: ein ZIP, beliebig groß, Browser
 *      schreibt direkt in eine Datei ohne RAM-Hold. Funktioniert in allen
 *      modernen Browsern inkl. Firefox/Safari, sobald der Site HTTPS ist.
 *   3. Blob-ZIP: kleines Archiv komplett im RAM, dann <a download>. Nur
 *      noch als Notnagel für Inkognito-Modi und uralte Browser ohne SW.
 *   4. Server-Streaming-ZIP (alter Pfad): wenn weder FSA noch SW noch Blob
 *      gehen. Sequenziell, langsam, aber funktioniert immer.
 */
(function (global) {
	'use strict';

	const MAX_PARALLEL = 6;
	const MAX_RETRIES  = 2;
	const BLOB_LIMIT_DESKTOP = 1.8 * 1024 * 1024 * 1024;
	const BLOB_LIMIT_IOS     = 500 * 1024 * 1024;

	// Debug-Logs nur aktivieren via window.CCGAL_DEBUG = true ODER ?ccgal_debug=1
	// in der URL. In Production bleibt die Browser-Konsole sauber, bei Bug-
	// Reports kann der User mit dem URL-Param diagnostische Logs anschalten.
	const CCGAL_DEBUG = !!global.CCGAL_DEBUG ||
		(global.location && /[?&]ccgal_debug=1/.test(global.location.search));
	function dlog() {
		if (!CCGAL_DEBUG) return;
		try { console.log.apply(console, arguments); } catch (_) {}
	}

	function isIOS() {
		return /iPad|iPhone|iPod/.test(navigator.userAgent)
			|| (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
	}

	function s(config, key, fallback) {
		return (config && config.strings && config.strings[key]) || fallback;
	}

	function fmtBytes(n) {
		if (!n) return '0 B';
		const units = ['B', 'KB', 'MB', 'GB'];
		let i = 0;
		let v = n;
		while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }
		return v.toFixed(v >= 100 || i === 0 ? 0 : 1) + ' ' + units[i];
	}

	function safeFolderName(name) {
		return String(name || 'gallery').replace(/[\\/:*?"<>|]+/g, '_').trim() || 'gallery';
	}

	// ── Progress-UI ──────────────────────────────────────────────────────
	// Toast unten rechts — bewusst KEIN Modal, damit der Besucher während des
	// Downloads weiter durch die Galerie scrollen, Lightbox öffnen, Favoriten
	// markieren etc. kann. Aria-live="polite" sorgt für Screenreader-Updates,
	// ohne die Tastatur-Navigation zu kapern.

	function createProgressToast(config, totalFiles, totalBytes, onCancel) {
		const root = document.createElement('div');
		root.className = 'ccgal-bulk-toast';
		root.setAttribute('role', 'status');
		root.setAttribute('aria-live', 'polite');
		root.setAttribute('aria-atomic', 'false');
		root.innerHTML =
			'<p class="ccgal-bulk-toast-title"></p>'
			+ '<p class="ccgal-bulk-toast-status"></p>'
			+ '<div class="ccgal-bulk-progress"><div class="ccgal-bulk-progress-bar"></div></div>'
			+ '<p class="ccgal-bulk-toast-current"></p>'
			+ '<div class="ccgal-bulk-toast-actions">'
			+ '<button type="button" class="ccgal-btn ccgal-btn-ghost ccgal-bulk-cancel"></button>'
			+ '</div>';

		const titleEl   = root.querySelector('.ccgal-bulk-toast-title');
		const statusEl  = root.querySelector('.ccgal-bulk-toast-status');
		const barEl     = root.querySelector('.ccgal-bulk-progress-bar');
		const currentEl = root.querySelector('.ccgal-bulk-toast-current');
		const cancelBtn = root.querySelector('.ccgal-bulk-cancel');

		titleEl.textContent  = s(config, 'bulk_preparing', 'Preparing download…');
		cancelBtn.textContent = s(config, 'bulk_cancel', 'Cancel');

		document.body.appendChild(root);

		let bytesDone = 0;
		let filesDone = 0;

		cancelBtn.addEventListener('click', function () {
			cancelBtn.disabled = true;
			titleEl.textContent = s(config, 'bulk_cancelling', 'Cancelling…');
			if (typeof onCancel === 'function') onCancel();
		});

		return {
			el: root,
			setTitle: function (t) { titleEl.textContent = t; },
			setStatus: function (t) { statusEl.textContent = t; },
			setCurrent: function (name) {
				currentEl.textContent = name ? '› ' + name : '';
			},
			fileDone: function (size) {
				filesDone++;
				bytesDone += (size || 0);
				const pctFiles = Math.min(100, Math.round(filesDone * 100 / Math.max(1, totalFiles)));
				const pctBytes = totalBytes > 0 ? Math.min(100, Math.round(bytesDone * 100 / totalBytes)) : pctFiles;
				barEl.style.width = pctBytes + '%';
				const tpl = s(config, 'bulk_progress_template', '{files} of {total_files} · {done} of {total_size}');
				statusEl.textContent = tpl
					.replace('{files}', String(filesDone))
					.replace('{total_files}', String(totalFiles))
					.replace('{done}', fmtBytes(bytesDone))
					.replace('{total_size}', fmtBytes(totalBytes));
			},
			close: function () {
				if (root.parentNode) root.parentNode.removeChild(root);
			},
		};
	}

	// ── Manifest ─────────────────────────────────────────────────────────

	async function fetchManifest(config, opts) {
		opts = opts || {};
		const body = new FormData();
		body.append('action', 'ccgal_download_manifest');
		body.append('gallery', String(config.gallery_id));
		body.append('nonce', config.download_nonce);
		body.append('mode', opts.mode || 'folder');
		if ((opts.mode === 'selection' || opts.mode === 'favorites') && Array.isArray(opts.fileIds)) {
			// JSON-Single-Field statt files[] — umgeht PHPs max_input_vars
			// (Default 1000), das bei vielen selektierten Bildern Einträge
			// stillschweigend abschneiden würde.
			body.append('files', JSON.stringify(opts.fileIds));
		}
		const res = await fetch(config.download_url, {
			method: 'POST',
			body: body,
			credentials: 'same-origin',
		});
		const json = await res.json().catch(function () { return null; });
		if (!res.ok || !json || !json.success) {
			const err = new Error('manifest failed');
			err.code = (json && json.data && json.data.code) ? json.data.code : null;
			err.httpStatus = res.status;
			throw err;
		}
		return json.data;
	}

	async function notifyBulkCompleted(config, count) {
		try {
			const body = new FormData();
			body.append('action', 'ccgal_notify_bulk_complete');
			body.append('gallery', String(config.gallery_id));
			body.append('nonce', config.download_nonce);
			body.append('count', String(count));
			await fetch(config.download_url, {
				method: 'POST',
				body: body,
				credentials: 'same-origin',
				keepalive: true,
			});
		} catch (e) {
			// Notification failures are silently ignored — the download itself
			// has already completed and matters more to the visitor than the ping.
		}
	}

	function buildSingleDownloadUrl(config, fileId) {
		// Wir nutzen den existierenden Single-Download-Endpoint für die eigentlichen
		// Bytes. Vorteil: serverseitig keine Code-Duplikation und Auth-Logik bleibt
		// konsolidiert in CCGAL_Downloads::handle_single().
		return config.download_url;
	}

	async function fetchFile(config, file, signal) {
		const body = new FormData();
		body.append('action', 'ccgal_download_single');
		body.append('gallery', String(config.gallery_id));
		body.append('nonce', config.download_nonce);
		body.append('file', file.id);
		const res = await fetch(buildSingleDownloadUrl(config, file.id), {
			method: 'POST',
			body: body,
			credentials: 'same-origin',
			signal: signal,
		});
		if (!res.ok) throw new Error('http ' + res.status);
		return res;
	}

	async function fetchAsBytes(config, file, signal) {
		const res = await fetchFile(config, file, signal);
		const buf = await res.arrayBuffer();
		return new Uint8Array(buf);
	}

	async function fetchToFile(config, file, fileHandle, signal) {
		const res = await fetchFile(config, file, signal);
		const writable = await fileHandle.createWritable();
		try {
			if (res.body && typeof res.body.pipeTo === 'function') {
				await res.body.pipeTo(writable, { signal: signal });
				// Manueller Abort-Check: Safari < 17 und Firefox < 113 ignorieren
				// die signal-Option an pipeTo, Abort hat dort keinen Effekt.
				if (signal && signal.aborted) {
					try { await writable.abort(); } catch (_) { /* noop */ }
					throw new DOMException('Aborted', 'AbortError');
				}
				// pipeTo schließt den WritableStream per Spec, aber FSA-Implementationen
				// in nicht-Chrome-Browsern lassen den Stream teilweise offen, was die
				// Datei nicht final auf Disk committet. Defensive close, „already closed"
				// schluckt der catch.
				try { await writable.close(); } catch (_) { /* already closed */ }
			} else {
				const buf = await res.arrayBuffer();
				await writable.write(new Uint8Array(buf));
				await writable.close();
			}
		} catch (e) {
			try { await writable.abort(); } catch (_) { /* noop */ }
			throw e;
		}
	}

	// ── Worker-Pool ──────────────────────────────────────────────────────

	function makePool(items, workerFn, concurrency) {
		let index = 0;
		let aborted = false;
		const results = new Array(items.length);

		function next() {
			if (aborted || index >= items.length) return null;
			const i = index++;
			return Promise.resolve(workerFn(items[i], i)).then(function (r) {
				results[i] = r;
				return next();
			});
		}

		const runners = [];
		for (let k = 0; k < Math.min(concurrency, items.length); k++) {
			runners.push(next());
		}
		const done = Promise.all(runners).then(function () { return results; });
		return {
			done: done,
			abort: function () { aborted = true; },
		};
	}

	async function withRetry(fn, attempts) {
		let lastErr;
		for (let i = 0; i <= attempts; i++) {
			try {
				return await fn();
			} catch (e) {
				if (e && e.name === 'AbortError') throw e;
				lastErr = e;
			}
		}
		throw lastErr;
	}

	// ── Service-Worker-Setup ─────────────────────────────────────────────

	function genToken() {
		const buf = new Uint8Array(12);
		(global.crypto || global.msCrypto).getRandomValues(buf);
		let s = '';
		for (let i = 0; i < buf.length; i++) {
			s += buf[i].toString(16).padStart(2, '0');
		}
		return s;
	}

	function swSupported(config) {
		return ('serviceWorker' in navigator)
			&& global.isSecureContext !== false
			&& !!config.sw_zip_url
			&& !!config.sw_zip_scope;
	}

	let swRegPromise = null;
	function ensureServiceWorker(config) {
		if (!swSupported(config)) {
			dlog('[ccgal-bulk] SW nicht verfügbar:',
				!('serviceWorker' in navigator) ? 'kein navigator.serviceWorker' :
				global.isSecureContext === false ? 'kein secure context (HTTP?)' :
				!config.sw_zip_url ? 'keine sw_zip_url im config' :
				!config.sw_zip_scope ? 'kein sw_zip_scope im config' : 'unbekannt');
			return Promise.resolve(null);
		}
		if (swRegPromise) return swRegPromise;
		const attempt = (async function () {
			try {
				dlog('[ccgal-bulk] SW register:', config.sw_zip_url, 'scope:', config.sw_zip_scope);
				// Timeout um die Registrierung selbst — wenn ein alter SW im
				// inkonsistenten Zustand hängt, kann register() unbestimmt warten.
				// 8 s reichen für jeden gesunden Install-Cycle.
				const reg = await Promise.race([
					navigator.serviceWorker.register(config.sw_zip_url, { scope: config.sw_zip_scope }),
					new Promise(function (_, reject) {
						setTimeout(function () { reject(new Error('register-timeout-8s')); }, 8000);
					}),
				]);
				dlog('[ccgal-bulk] SW register OK:', 'active=', !!reg.active, 'installing=', !!reg.installing, 'waiting=', !!reg.waiting);
				let sw = reg.active;
				if (!sw) {
					sw = reg.installing || reg.waiting;
					if (sw && sw.state !== 'activated') {
						dlog('[ccgal-bulk] SW installing, warte auf activate…', sw.state);
						// Timeout auch hier — sonst hängen wir, falls der state nie 'activated' erreicht
						await Promise.race([
							new Promise(function (resolve, reject) {
								sw.addEventListener('statechange', function () {
									dlog('[ccgal-bulk] SW statechange:', sw.state);
									if (sw.state === 'activated') resolve();
									else if (sw.state === 'redundant') reject(new Error('sw redundant'));
								});
							}),
							new Promise(function (_, reject) {
								setTimeout(function () { reject(new Error('activate-timeout-5s')); }, 5000);
							}),
						]);
					}
				}
				if (!reg.active) {
					console.warn('[ccgal-bulk] SW nach register nicht active');
					return null;
				}

				// Wichtig: KEIN `await navigator.serviceWorker.ready` hier — das
				// hängt unbestimmt, wenn die Galerie-Page außerhalb des SW-Scopes
				// liegt (typisch: Plugin liefert SW unter /wp-content/.../js/,
				// die Page liegt unter /galerie/…). Browser ohne FSA (Firefox)
				// können diesen SW-Pfad daher gar nicht nutzen.
				//
				// Stattdessen: kurzer 1.5s-Wait auf controllerchange. Wenn nichts
				// passiert, geben wir das Streaming auf und fallen sauber auf
				// Blob/Server zurück, statt den User mit einem grauen Button zu
				// stranden.
				if (!navigator.serviceWorker.controller) {
					dlog('[ccgal-bulk] SW activated, warte 1.5s auf controllerchange…');
					await new Promise(function (resolve) {
						const timer = setTimeout(resolve, 1500);
						const onChange = function () {
							clearTimeout(timer);
							navigator.serviceWorker.removeEventListener('controllerchange', onChange);
							resolve();
						};
						navigator.serviceWorker.addEventListener('controllerchange', onChange);
					});
				}

				if (!navigator.serviceWorker.controller) {
					console.warn('[ccgal-bulk] Tab liegt außerhalb des SW-Scopes — Streaming-Pfad nicht nutzbar (typisch in Firefox/Safari, dort fällt die Logik sauber auf Server-Pfad zurück)');
					return null;
				}

				dlog('[ccgal-bulk] SW aktiv & controlling, scope:', reg.scope);
				return reg;
			} catch (e) {
				console.warn('[ccgal-bulk] SW register fehlgeschlagen:', e && e.message ? e.message : e);
				return null;
			}
		})();
		// Bei null-Resolution den Cache löschen, damit der nächste Klick
		// einen neuen Versuch starten kann. Sonst ist die Streaming-Pfad-Nutzung
		// nach einem einzigen transienten Fehler für die ganze Page-Session blockiert.
		swRegPromise = attempt.then(function (r) {
			if (!r) swRegPromise = null;
			return r;
		});
		return swRegPromise;
	}

	// ── Pfade ────────────────────────────────────────────────────────────

	async function runFSA(config, manifest, dirHandle) {
		const ctrl  = new AbortController();
		const modal = createProgressToast(
			config, manifest.count, manifest.total_size,
			function () { ctrl.abort(); }
		);
		modal.setTitle(s(config, 'bulk_downloading', 'Downloading images…'));

		const seenNames = Object.create(null);
		function uniqueName(raw) {
			let name = raw || 'image';
			if (seenNames[name] === undefined) { seenNames[name] = 0; return name; }
			seenNames[name]++;
			const dot  = name.lastIndexOf('.');
			const base = dot > -1 ? name.slice(0, dot) : name;
			const ext  = dot > -1 ? name.slice(dot)    : '';
			return base + '_' + seenNames[name] + ext;
		}

		const errors = [];
		const pool = makePool(manifest.files, async function (file) {
			if (ctrl.signal.aborted) return;
			modal.setCurrent(file.name);
			const safeName = uniqueName(file.name);
			try {
				await withRetry(async function () {
					const fileHandle = await dirHandle.getFileHandle(safeName, { create: true });
					await fetchToFile(config, file, fileHandle, ctrl.signal);
				}, MAX_RETRIES);
				modal.fileDone(file.size);
			} catch (e) {
				if (e && e.name === 'AbortError') return;
				errors.push({ file: file, error: e });
			}
		}, MAX_PARALLEL);

		try {
			await pool.done;
		} catch (e) { /* swallowed below */ }

		modal.close();

		if (ctrl.signal.aborted) return { mode: 'fsa', cancelled: true };
		if (errors.length) {
			global.alert(
				s(config, 'bulk_some_errors', 'Some files could not be downloaded:') + ' ' + errors.length
			);
		}
		await notifyBulkCompleted(config, manifest.count - errors.length);
		return { mode: 'fsa', errors: errors };
	}

	async function runStreamingZip(config, manifest, swReg) {
		if (!global.CCGALZipWriter) throw new Error('zip writer missing');
		const ctrl  = new AbortController();
		const modal = createProgressToast(
			config, manifest.count, manifest.total_size,
			function () { ctrl.abort(); }
		);
		modal.setTitle(s(config, 'bulk_downloading', 'Downloading images…'));
		try {
			return await runStreamingZipInner(config, manifest, swReg, ctrl, modal);
		} finally {
			// Garantiert, dass der Toast nicht als Zombie hängen bleibt, wenn
			// irgendwo im Setup (z. B. sw-init-timeout) eine Exception fliegt
			// und der reguläre modal.close()-Pfad nicht erreicht wird.
			modal.close();
			// Iframe vom Stream-Trigger sicher entfernen, falls einer angelegt
			// wurde. 5 s Pufferzeit, damit der Browser den Save-Dialog auch
			// nach dem Toast-Close noch zuverlässig behandelt.
			if (modal._dlFrame) {
				const fr = modal._dlFrame;
				setTimeout(function () { if (fr.parentNode) fr.parentNode.removeChild(fr); }, 5000);
			}
		}
	}

	async function runStreamingZipInner(config, manifest, swReg, ctrl, modal) {

		const seenNames = Object.create(null);
		function uniqueName(raw) {
			let name = raw || 'image';
			if (seenNames[name] === undefined) { seenNames[name] = 0; return name; }
			seenNames[name]++;
			const dot  = name.lastIndexOf('.');
			const base = dot > -1 ? name.slice(0, dot) : name;
			const ext  = dot > -1 ? name.slice(dot)    : '';
			return base + '_' + seenNames[name] + ext;
		}

		// SW-Pipeline initialisieren: Token, MessageChannel, dann den Stream-URL
		// klicken, damit der Browser den Save-Dialog öffnet. Ab dem Moment fließen
		// alle ZIP-Bytes vom Writer → Port → SW → Browser-Datei.
		const token    = genToken();
		const filename = manifest.zip_name || (manifest.gallery_title || 'gallery') + '.zip';
		const channel  = new MessageChannel();
		const port     = channel.port1;
		const targetSw = swReg.active;

		// Wir warten auf das 'ready'-Echo des SW, bevor wir den fetch auf die
		// Stream-URL feuern. Außerdem hört dieser Handler permanent auf
		// 'cancelled' — der Browser kann den Save-Stream jederzeit beenden
		// (User klickt Abbrechen im Browser-Save-Dialog, Tab schließt, Disk voll).
		// Ohne diesen Listener pumpten die Producer-Worker weiter Chunks ins
		// Leere und der Toast suggerierte fortlaufende Aktivität.
		let resolveReady, rejectReady;
		const ready = new Promise(function (res, rej) { resolveReady = res; rejectReady = rej; });
		port.onmessage = function (e) {
			const m = e.data || {};
			if (m.action === 'ready') {
				resolveReady();
			} else if (m.action === 'cancelled') {
				ctrl.abort();
				// Falls cancel VOR dem ready-Echo eintrifft, das ready-Promise
				// nicht weiter den Code blockieren lassen — sonst hängt der
				// Reader 2 s am Init-Timeout, bevor der Fallback greift.
				rejectReady(new Error('cancelled-before-ready'));
			}
		};
		port.start();
		const readyTimer = setTimeout(function () {
			rejectReady(new Error('sw-init-timeout'));
		}, 2000);

		targetSw.postMessage({ action: 'ccgal-zip-init', token: token, filename: filename }, [channel.port2]);
		try {
			await ready;
		} finally {
			clearTimeout(readyTimer);
		}

		// scope endet bereits auf '/' — alle trailing-Slashes erst wegnehmen,
		// dann genau einen voranstellen, sonst entsteht '…/js//zip-stream/…',
		// und der SW-Regex (single-slash) matcht nicht.
		const scopeBase = swReg.scope.replace(/\/+$/, '');
		const streamUrl = scopeBase + '/zip-stream/' + token;

		// Probe: prüft, ob der SW Fetches im Scope wirklich beantwortet.
		// Im Firefox-Inkognito mit „Strict Tracking Protection" registriert
		// sich der SW, kontrolliert den Tab aber nicht — ohne dieses Probing
		// würde der spätere iframe-Load auf 404 vom WordPress-Server treffen
		// und der Streaming-Pfad still scheitern.
		try {
			const probeUrl = scopeBase + '/zip-stream-probe';
			dlog('[ccgal-bulk] SW probe:', probeUrl);
			// Eigener AbortController + 5s Timeout. Default-fetch-Timeout ist
			// browser-spezifisch (Firefox 90s, Chrome 300s) — viel zu lange,
			// wenn der SW nicht antwortet und der Server eh 404 schickt.
			const probeCtrl = new AbortController();
			const probeTimer = setTimeout(function () { probeCtrl.abort(); }, 5000);
			let probe;
			try {
				probe = await fetch(probeUrl, { cache: 'no-store', signal: probeCtrl.signal });
			} finally {
				clearTimeout(probeTimer);
			}
			dlog('[ccgal-bulk] SW probe response:', probe.status, 'controller:', !!navigator.serviceWorker.controller);
			if (!probe.ok) throw new Error('probe-not-ok-' + probe.status);
		} catch (e) {
			console.warn('[ccgal-bulk] SW probe fehlgeschlagen:', e && e.message ? e.message : e, '→ Fallback');
			throw new Error('sw-not-active');
		}

		// Iframe statt <a download> — Vorteile:
		//   1. Kein Tab-Flicker, wenn der Stream-Endpoint aus irgendeinem Grund
		//      keine attachment-Response liefert (404 → unsichtbares iframe
		//      bleibt leer statt Tab-Navigation auszulösen).
		//   2. Browser triggert bei Content-Disposition: attachment trotzdem
		//      den Save-Dialog, weil das die Standard-Reaktion auf attachment-
		//      Header ist — egal aus welchem Kontext der Request kam.
		const dlFrame = document.createElement('iframe');
		dlFrame.style.display = 'none';
		dlFrame.src = streamUrl;
		document.body.appendChild(dlFrame);
		// Auto-Cleanup nach Stream-Ende — iframe wird beim Toast-Close mit entfernt.
		modal._dlFrame = dlFrame;

		// Writer pumpt alle Bytes direkt durch den Port an den SW. Buffer-Transfer
		// (chunk.buffer in der Liste) hält den Speicherbedarf konstant — die Bytes
		// wandern „weg" aus dem Main-Thread.
		const writer = new global.CCGALZipWriter(function (chunk) {
			port.postMessage({ action: 'chunk', chunk: chunk.buffer }, [chunk.buffer]);
		});

		// Producer-Pool: holt Files parallel ab. Reader weiter unten konsumiert
		// sie sequenziell, damit ZIP-Reihenfolge stimmt.
		const slots = manifest.files.map(function () {
			let resolve;
			const promise = new Promise(function (r) { resolve = r; });
			return { promise: promise, resolve: resolve };
		});

		let nextIdx = 0;
		let readCursor = 0;
		const MAX_INFLIGHT = MAX_PARALLEL * 2;
		async function workerFn() {
			while (true) {
				// Backpressure: warten, wenn der Reader zu weit hinter den
				// Producern hängt — sonst füllen 6 Worker bei einem hängenden Slot
				// die ganze Galerie ins RAM, bis der Tab OOM-killed wird.
				while (!ctrl.signal.aborted && (nextIdx - readCursor) >= MAX_INFLIGHT) {
					await new Promise(function (r) { setTimeout(r, 50); });
				}
				const i = nextIdx++;
				if (i >= manifest.files.length) return;
				if (ctrl.signal.aborted) {
					slots[i].resolve({ aborted: true });
					continue;
				}
				try {
					const bytes = await withRetry(function () {
						return fetchAsBytes(config, manifest.files[i], ctrl.signal);
					}, MAX_RETRIES);
					slots[i].resolve({ bytes: bytes });
				} catch (e) {
					if (e && e.name === 'AbortError') {
						slots[i].resolve({ aborted: true });
					} else {
						slots[i].resolve({ error: e });
					}
				}
			}
		}
		const workers = [];
		for (let k = 0; k < MAX_PARALLEL; k++) workers.push(workerFn());

		const errors = [];

		try {
			for (let i = 0; i < manifest.files.length; i++) {
				if (ctrl.signal.aborted) break;
				const r = await slots[i].promise;
				const file = manifest.files[i];
				modal.setCurrent(file.name);
				if (r.aborted) {
					readCursor = i + 1;
					continue;
				}
				if (r.bytes) {
					// byteLength VOR writeChunk lesen — writer transferiert den
					// ArrayBuffer via postMessage, danach ist r.bytes.byteLength = 0.
					const byteLen = r.bytes.byteLength;
					try {
						writer.beginFile(uniqueName(file.name));
						writer.writeChunk(r.bytes);
						writer.endFile();
						modal.fileDone(byteLen);
					} catch (e) {
						errors.push({ file: file, error: e });
					}
				} else if (r.error) {
					errors.push({ file: file, error: r.error });
				}
				// Slot-Referenz freigeben, sonst hält der buffer den (jetzt detachten)
				// Uint8Array künstlich am Leben.
				slots[i] = null;
				readCursor = i + 1;
			}
			writer.finish();
		} finally {
			// Worker leerlaufen lassen (sie sehen aborted oder das Ende der Liste).
			await Promise.all(workers).catch(function () {});
		}

		if (ctrl.signal.aborted) {
			try { port.postMessage({ action: 'abort' }); } catch (_) {}
			try { port.close(); } catch (_) {}
			modal.close();
			return { mode: 'streaming', cancelled: true };
		}

		try { port.postMessage({ action: 'finish' }); } catch (_) {}
		try { port.close(); } catch (_) {}

		modal.close();
		if (errors.length) {
			global.alert(
				s(config, 'bulk_some_errors', 'Some files could not be downloaded:') + ' ' + errors.length
			);
		}
		await notifyBulkCompleted(config, manifest.count - errors.length);
		return { mode: 'streaming', errors: errors };
	}

	async function runBlobZip(config, manifest) {
		if (!global.CCGALZipWriter) throw new Error('zip writer missing');
		const ctrl  = new AbortController();
		const modal = createProgressToast(
			config, manifest.count, manifest.total_size,
			function () { ctrl.abort(); }
		);
		modal.setTitle(s(config, 'bulk_downloading', 'Downloading images…'));

		const writer = new global.CCGALZipWriter();
		const seenNames = Object.create(null);
		function uniqueName(raw) {
			let name = raw || 'image';
			if (seenNames[name] === undefined) { seenNames[name] = 0; return name; }
			seenNames[name]++;
			const dot  = name.lastIndexOf('.');
			const base = dot > -1 ? name.slice(0, dot) : name;
			const ext  = dot > -1 ? name.slice(dot)    : '';
			return base + '_' + seenNames[name] + ext;
		}

		// Wir holen bytes parallel, schreiben aber sequenziell ins ZIP, um die
		// Reihenfolge der Central Directory deterministisch zu halten.
		const buffer = new Array(manifest.files.length);
		const errors = [];
		let writeCursor = 0;

		function flushReady() {
			while (writeCursor < buffer.length && buffer[writeCursor] !== undefined) {
				const slot = buffer[writeCursor];
				if (slot && slot.bytes) {
					writer.addFile(uniqueName(slot.name), slot.bytes);
					modal.fileDone(slot.bytes.byteLength);
				}
				buffer[writeCursor] = null;
				writeCursor++;
			}
		}

		const pool = makePool(manifest.files, async function (file, idx) {
			if (ctrl.signal.aborted) return;
			modal.setCurrent(file.name);
			try {
				const bytes = await withRetry(function () {
					return fetchAsBytes(config, file, ctrl.signal);
				}, MAX_RETRIES);
				buffer[idx] = { bytes: bytes, name: file.name };
			} catch (e) {
				if (e && e.name === 'AbortError') return;
				errors.push({ file: file, error: e });
				buffer[idx] = { bytes: null, name: file.name };
			}
			flushReady();
		}, MAX_PARALLEL);

		try {
			await pool.done;
		} catch (e) { /* swallowed below */ }
		flushReady();

		if (ctrl.signal.aborted) {
			modal.close();
			return { mode: 'blob', cancelled: true };
		}

		modal.setTitle(s(config, 'bulk_packing', 'Packaging ZIP…'));
		modal.setCurrent('');
		const blob = writer.finish();

		const url = URL.createObjectURL(blob);
		const a = document.createElement('a');
		a.href = url;
		a.download = manifest.zip_name || (safeFolderName(manifest.gallery_title) + '.zip');
		document.body.appendChild(a);
		a.click();
		document.body.removeChild(a);
		setTimeout(function () { URL.revokeObjectURL(url); }, 60000);

		modal.close();
		if (errors.length) {
			global.alert(
				s(config, 'bulk_some_errors', 'Some files could not be downloaded:') + ' ' + errors.length
			);
		}
		await notifyBulkCompleted(config, manifest.count - errors.length);
		return { mode: 'blob', errors: errors };
	}

	function submitServerZip(config, opts) {
		opts = opts || {};
		// Fallback auf den existierenden Server-streaming-ZIP-Endpoint.
		// target='_self' wegen Popup-Blocker nach async-await (siehe vorherige Reviews).
		const form = document.createElement('form');
		form.method = 'POST';
		form.action = config.download_url;
		form.target = '_self';
		form.style.display = 'none';
		function input(name, value) {
			const i = document.createElement('input');
			i.type = 'hidden'; i.name = name; i.value = value;
			form.appendChild(i);
		}
		input('action', 'ccgal_download_zip');
		input('gallery', String(config.gallery_id));
		input('nonce', config.download_nonce);
		input('mode', opts.mode || 'folder');
		if ((opts.mode === 'selection' || opts.mode === 'favorites') && Array.isArray(opts.fileIds)) {
			// JSON-Single-Field analog zum Manifest, gleiche max_input_vars-
			// Schutzlogik. Backend akzeptiert beide Formate.
			input('files', JSON.stringify(opts.fileIds));
		}
		document.body.appendChild(form);
		form.submit();
		document.body.removeChild(form);
		// Page wird gleich navigieren — wir kehren mit einem Sentinel zurück,
		// damit der äußere setRunning(false) NICHT in dem ms-Fenster zwischen
		// form.submit() und Tab-Tear-Down die Buttons re-enabled und ein
		// schneller zweiter Klick durchgeht.
		return { navigating: true };
	}

	// ── Doppelklick-Sperre ───────────────────────────────────────────────
	// Während ein Download läuft, sperren wir ALLE drei Bulk-Buttons (all,
	// selected, favorites) auf jeder Galerie auf der Seite. Das verhindert,
	// dass ein zweiter parallel-Download den ersten zerschießt (Service Worker
	// re-registriert, Token-Kollisionen, doppelte Tracking-Beacons etc.).

	const BULK_BTN_SELECTOR = '.ccgal-download-all, .ccgal-download-selected, .ccgal-download-favorites';
	let running = false;

	function setRunning(value) {
		running = !!value;
		document.querySelectorAll(BULK_BTN_SELECTOR).forEach(function (btn) {
			btn.disabled = running;
			btn.setAttribute('aria-busy', running ? 'true' : 'false');
		});
	}

	// ── Public API ───────────────────────────────────────────────────────

	async function start(galleryId, opts) {
		opts = opts || {};
		if (running) return;
		setRunning(true);
		let result;
		try {
			result = await runStart(galleryId, opts);
		} finally {
			// Wenn der Pfad das Tab gleich navigiert (Server-Fallback), Lock
			// halten — sonst öffnet ein schneller zweiter Klick im ms-Fenster
			// vor dem Page-Reload einen zweiten Download.
			if (!result || !result.navigating) {
				setRunning(false);
			}
		}
	}

	async function runStart(galleryId, opts) {
		const config = (global.CCGAL_CONFIG && global.CCGAL_CONFIG[galleryId]) || null;
		if (!config) return;

		let manifest;
		try {
			manifest = await fetchManifest(config, opts);
		} catch (e) {
			return submitServerZip(config, opts);
		}
		if (!manifest || !manifest.files || !manifest.files.length) {
			global.alert(s(config, 'bulk_empty', 'This gallery has no downloadable files.'));
			return;
		}

		const fsaAvailable = ('showDirectoryPicker' in global) && global.isSecureContext !== false;
		const limit        = isIOS() ? BLOB_LIMIT_IOS : BLOB_LIMIT_DESKTOP;
		const canBlob      = manifest.total_size > 0 && manifest.total_size <= limit;

		// 1) FSA — User wählt Ordner, Bilder gehen einzeln rein. Kein Größenlimit.
		if (fsaAvailable) {
			let dirHandle = null;
			try {
				dirHandle = await global.showDirectoryPicker({ mode: 'readwrite', id: 'ccgal-bulk' });
			} catch (e) {
				if (e && e.name === 'AbortError') return;
				dirHandle = null;
			}
			if (dirHandle) {
				let target;
				try {
					target = await dirHandle.getDirectoryHandle(safeFolderName(manifest.gallery_title), { create: true });
				} catch (e) {
					target = dirHandle;
				}
				try {
					await runFSA(config, manifest, target);
					return;
				} catch (e) {
					global.alert(s(config, 'bulk_failed', 'Bulk download failed. Falling back to ZIP.'));
					return submitServerZip(config, opts);
				}
			}
		}

		// 2) Streaming-ZIP via Service Worker — funktioniert in Firefox/Safari/Chrome
		// auf HTTPS, kein RAM-Limit. Hauptpfad für große Galerien.
		const swReg = await ensureServiceWorker(config);
		if (swReg && global.CCGALZipWriter) {
			dlog('[ccgal-bulk] Pfad: Streaming-ZIP via Service Worker');
			try {
				await runStreamingZip(config, manifest, swReg);
				return;
			} catch (e) {
				console.warn('[ccgal-bulk] Streaming-Pfad-Fehler:', e && e.message ? e.message : e, '→ Fallback');
			}
		} else {
			dlog('[ccgal-bulk] Streaming-Pfad übersprungen (SW=' + !!swReg + ', Writer=' + !!global.CCGALZipWriter + ')');
		}

		// 3) Blob-ZIP — Notnagel, wenn SW geblockt ist und das Archiv ins RAM passt.
		if (canBlob && global.CCGALZipWriter) {
			dlog('[ccgal-bulk] Pfad: Blob-ZIP (' + Math.round(manifest.total_size/1024/1024) + ' MB)');
			try {
				await runBlobZip(config, manifest);
				return;
			} catch (e) {
				console.warn('[ccgal-bulk] Blob-Pfad-Fehler:', e && e.message ? e.message : e, '→ Server-Fallback');
				return submitServerZip(config, opts);
			}
		}

		// 4) Letzter Ausweg: alter Server-Streaming-ZIP-Pfad.
		dlog('[ccgal-bulk] Pfad: Server-ZIP-Fallback (langsam) — Total ' + Math.round(manifest.total_size/1024/1024) + ' MB, canBlob=' + canBlob);
		return submitServerZip(config, opts);
	}

	global.WPGBulkDownload = { start: start };
})(window);
