import { openDB, type IDBPDatabase } from 'idb';
import { writable, get } from 'svelte/store';
import { getToken, getUserId, clearAuth, onClearAuth, onSetAuth } from './auth';
import { onSseEvent } from './sse';
import { refreshQuota } from './quota-store';
import { toast } from './toast-store';

export interface QueueItem {
	id: string;
	userId: string;
	fileName: string;
	fileSize: number;
	/** Source file's last-modified ms; part of the dedup key so two distinct photos that
	 *  happen to share a name and byte length don't collapse into one. */
	lastModified?: number;
	mimeType: string;
	caption: string;
	hashtags: string;
	// 'error' is retryable (network / 5xx); 'blocked' is TERMINAL (a 4xx the server will
	// keep rejecting — locked event, banned user, released gallery, quota full). Blocked
	// items have had their blob purged from IndexedDB and offer no retry.
	status: 'pending' | 'uploading' | 'done' | 'error' | 'blocked';
	progress: number;
	error?: string;
}

// Store does NOT hold file blobs — those stay in IndexedDB only
export const queueItems = writable<QueueItem[]>([]);
export const isProcessing = writable(false);

/** Set to the timestamp (ms) at which the rate-limit lifts, or null when clear. */
export const rateLimitRetryAt = writable<number | null>(null);

const DB_NAME = 'eventsnap-uploads';
const STORE_NAME = 'queue';

/** Hard cap on queued items per device — bounds IndexedDB growth from stuck blobs. */
const MAX_QUEUE_ITEMS = 100;

/**
 * How many times an item may be re-sent by an AUTOMATIC resume (`online`, SSE reconnect,
 * backoff timer) before it parks and waits for the guest to tap "Erneut".
 *
 * Without a cap, `requeueRetriable` flips every failed item back to `pending` on every
 * `online` event and every SSE reconnect — on a congested venue AP those fire constantly,
 * so one failing 200 MB video re-uploads from byte zero all evening and starves the ~100
 * other guests sharing the uplink. A manual retry resets the counter: an explicit tap is
 * evidence the guest wants to spend the bandwidth.
 */
const MAX_AUTO_ATTEMPTS = 5;

/**
 * Quiet time after which an item's automatic-retry budget refills.
 *
 * The cap above is a rate limiter, and a rate limiter needs a window or it is a lifetime quota.
 * Five attempts on a 5/10/20/40s ladder is ~75 seconds, so ANY outage longer than that — a venue
 * AP brownout, a captive portal re-arming, an `app` container restart — permanently parked every
 * in-flight photo behind a per-row button three taps deep that no guest will find.
 *
 * 10 minutes is chosen against the thing being protected: the concern is a hot loop re-sending a
 * 200 MB video over a shared uplink, and one re-send per item per 10 minutes is not that. It is
 * also comfortably longer than every outage the queue can ride out on its own.
 */
const RETRY_BUDGET_WINDOW_MS = 10 * 60_000;

/** Exponential backoff between automatic attempts: 5s, 10s, 20s, 40s, … capped below. */
const RETRY_BASE_DELAY_MS = 5_000;
const MAX_RETRY_DELAY_MS = 5 * 60_000;

/**
 * Abort an upload that has not moved a single byte for this long.
 *
 * A phone roaming between APs leaves a half-open TCP connection: the XHR neither errors nor
 * completes, so the item sits in `uploading` forever and `processQueue`'s `processing` flag
 * never clears — the ENTIRE queue stops draining, with no way out short of force-quitting
 * the app. Bytes-moved (not elapsed time) is the right signal: it never punishes a slow but
 * healthy LTE upload, which is why the wall-clock `xhr.timeout` below is only a backstop.
 */
const STALL_TIMEOUT_MS = 90_000;
const STALL_CHECK_INTERVAL_MS = 5_000;

/**
 * Ceiling for the window AFTER the last byte is sent.
 *
 * `upload.progress` is silent there BY DEFINITION — the server is sniffing the magic bytes,
 * committing the row and writing the response — so the bytes-moved signal above has nothing
 * to measure and the watchdog used to simply switch off at `loadend`. That left the wall-clock
 * `xhr.timeout` as the only remaining bound: 5 minutes for a photo, up to 60 for a video. A
 * half-open socket in that window (phone roams wifi→LTE while the server is still writing a
 * 500 MB video) pinned `processing` for that entire time, so NOTHING else in the guest's queue
 * drained either.
 *
 * Sized above the backend's worst-case commit path, not above compression — compression is
 * spawned after the response and does not hold it open.
 */
const RESPONSE_TIMEOUT_MS = 120_000;

/**
 * Pure predicate behind the watchdog, extracted so the policy is unit-testable without
 * standing up an XHR harness.
 */
export function shouldAbortForStall(
	lastActivityAt: number,
	now: number,
	bodySent: boolean
): boolean {
	const ceiling = bodySent ? RESPONSE_TIMEOUT_MS : STALL_TIMEOUT_MS;
	return now - lastActivityAt > ceiling;
}

/**
 * Normal timer jitter/throttling budget. A tick later than `interval + this` did not run
 * because the page was suspended, not because it was merely late.
 */
const SUSPEND_TOLERANCE_MS = 2_000;

/**
 * Total suspension credit a single silent stretch may ever be granted.
 *
 * A tick gap cannot distinguish "the page was frozen" from "the page is being throttled to one
 * tick per minute while the network stack keeps running" — and in the second case the socket can
 * be genuinely dead. Crediting every late tick meant the observed silence grew by only one
 * interval per real minute, so a dead upload took ~18 minutes to detect while holding the
 * queue's `processing` latch and blocking every other photo the guest had queued.
 *
 * Capping the total at one stall window bounds detection at roughly 2x the ceiling (~3 min)
 * whatever the throttling pattern, while still absorbing the pocket-length freezes this exists
 * for. Crucially the budget RESETS on real progress (see the `progress` handler), so a long
 * upload that is actually moving survives any number of screen locks — only an upload that is
 * silent AND suspended spends it, and that is exactly the case where being wrong is cheap: one
 * re-send, bounded by MAX_AUTO_ATTEMPTS.
 */
export const MAX_SUSPEND_CREDIT_MS = STALL_TIMEOUT_MS;

/**
 * Wall-clock the watchdog interval FAILED to cover because the page was suspended.
 *
 * `Date.now()` keeps advancing while a backgrounded phone is frozen, but `setInterval` does
 * not run. So the first tick after a screen lock saw the entire sleep as "no bytes moved" and
 * aborted a connection that was very possibly healthy — restarting a 200 MB video from byte
 * zero, burning one of five PERMANENT auto-attempts (`chargeAttempt`), and breaking the drain
 * loop for every other queued photo. A phone in a pocket between shots is the common case at a
 * party, not an edge case.
 *
 * The interval is its own suspension detector: a tick scheduled 5s out that arrives 130s late
 * means the page was frozen for ~125s, and that is exactly the window the watchdog had no
 * right to measure. Deliberately chosen over a `visibilitychange` listener, which only covers
 * the causes that happen to fire that event — a throttled-but-visible tab, a closed laptop lid
 * and an occluded window all freeze timers without one. It also needs no listener, no
 * module-level state, no SSR guard and no teardown.
 *
 * `performance.now()` was rejected as the clock source: Safari pauses it across system sleep
 * on some paths while Chrome does not, which is precisely the non-uniformity that makes it
 * unusable as the sole signal.
 *
 * Returns 0 for a normal tick, and 0 if the clock jumps BACKWARDS (an NTP correction) — that
 * fails open, and the wall-clock `xhr.timeout` still bounds the request.
 */
export function suspendedSinceLastTick(
	lastTickAt: number,
	now: number,
	intervalMs: number = STALL_CHECK_INTERVAL_MS
): number {
	const overshoot = now - lastTickAt - intervalMs;
	return overshoot > SUSPEND_TOLERANCE_MS ? overshoot : 0;
}

/**
 * Wall-clock cap for one attempt, scaled by file size assuming a floor of ~8 kB/s — a
 * deliberately pessimistic rate, because killing a slow-but-progressing upload would lose
 * exactly the videos that are hardest to re-take. The stall watchdog is what actually
 * catches a dead connection; this only bounds the pathological case where bytes trickle
 * fast enough to reset the watchdog but the upload would never finish.
 */
const MIN_UPLOAD_TIMEOUT_MS = 5 * 60_000;
const MAX_UPLOAD_TIMEOUT_MS = 60 * 60_000;
const ASSUMED_MIN_BYTES_PER_SEC = 8_000;

/**
 * How long to wait for IndexedDB to open before giving up and running from memory.
 * `openDB` does not settle at all while another tab pins an older version (the `blocked`
 * callback fires but the promise stays pending), and every queue operation awaits it — so
 * without this bound a second open tab silently disables uploading device-wide.
 */
const DB_OPEN_TIMEOUT_MS = 5_000;

/** The persisted shape of a queue row. `blob` is the only field that never reaches the store. */
interface QueueEntry {
	id: string;
	userId: string;
	fileName: string;
	fileSize: number;
	lastModified?: number;
	mimeType: string;
	caption: string;
	hashtags: string;
	status: QueueItem['status'];
	error?: string;
	/** Consecutive failed attempts; gates + delays automatic resumes. Reset by a manual retry. */
	attempts?: number;
	/** Earliest ms timestamp at which an automatic resume may re-send this item. */
	nextAttemptAt?: number;
	/** When the most recent attempt failed. Lets the budget refill after a quiet spell. */
	lastFailureAt?: number;
	/**
	 * The guest stopped this transfer themselves (the ✕ on an in-flight row).
	 *
	 * `requeueRetriable` requeues any blob-bearing `error` item that is under budget and past its
	 * backoff — and a cancel deliberately charges NO attempt and sets NO backoff, so without this
	 * flag it matched on both counts and the upload restarted from byte zero within ~120s (an
	 * `online` event, or the SSE backstop's `feed-delta` poll). It then restarted forever, because
	 * a path that never charges an attempt can never exhaust the budget that would stop it. The row
	 * said "Abgebrochen. Tippe auf „Erneut“." the whole time, on a shared venue uplink.
	 *
	 * Cleared by `retryItem` — an explicit tap is the guest changing their mind.
	 */
	cancelled?: boolean;
	/**
	 * Parked waiting for a specific host action, with the blob intact.
	 *
	 * Both values are reversible 403s whose answer cannot change without somebody deciding to
	 * change it, which makes automatic retries pure waste: they re-pushed the whole photo over
	 * cellular on every budget refill for the rest of the night while the guest was told to tap
	 * a camera button that 403s.
	 *
	 *  - `'reopen'` — the gallery was released (`gallery_released`). Cleared by `event-opened`.
	 *  - `'unban'`  — the uploader is banned (`user_banned`). Cleared by `user-shown`.
	 *
	 * An explicit `retryItem` clears either: a deliberate tap is the guest asking us to try
	 * anyway, and if the condition still holds the next response simply re-parks it.
	 */
	parkedFor?: 'reopen' | 'unban';
	blob?: Blob;
}

// Resume the queue as soon as connectivity returns. Registered once, guarded for SSR.
// This is the other half of the "flushes when you're back online" promise — without it
// a reconnect only resumes if the user manually re-stages a file.
let onlineBound = false;
function bindOnline(): void {
	if (onlineBound || typeof window === 'undefined') return;
	window.addEventListener('online', () => {
		void (async () => {
			await requeueRetriable();
			await processQueue();
		})();
	});
	onlineBound = true;
}
bindOnline();

// Rehydrate on every IDENTITY change, not just on a cold boot.
//
// `+layout.svelte` calls `loadQueue()` on mount, but only `if (getToken())` — and the boot
// that matters most has no token: a mid-event 401 (host PIN reset, redeployed JWT_SECRET)
// clears auth and hard-navigates to /join, so the layout skips it. The guest then recovers,
// which finishes with `goto('/feed')` — a client-side navigation, so `onMount` never runs
// again and `queueItems` stays the empty array it was initialised to. Their queued photos
// sit in IndexedDB with nothing reading them, and the FAB badge reads 0 — exactly the
// silent-loss failure the boot-time call was added to prevent, one route over.
//
// `onSetAuth` is the mechanism the role store already uses for this same reason.
onSetAuth(() => void loadQueue());

// Drop the in-memory view when an identity goes away, so the next user of a shared device
// does not see the previous guest's file names in the queue. This deliberately does NOT
// touch IndexedDB: a 401 is often transient, the blobs are user-scoped on every read, and
// destroying them here would lose photos the AuthError path goes out of its way to keep.
onClearAuth(() => queueItems.set([]));

// Resume the queue when the host reopens the event. A queued upload that hit a locked/
// released event kept its blob and parked as a retryable `error` (LockedError); flipping it
// back to pending and re-draining recovers it so a photo staged during a lock isn't lost.
// We resume on TWO signals:
//   - `event-opened`: the live reopen, when the SSE happens to be connected.
//   - `feed-delta`: fired on every SSE (re)connect (see sse.ts `deltaFetchAndFan`). Because
//     `event-opened` has no server-side replay and the SSE is disconnected on non-feed pages
//     / backgrounded tabs, a reopen is often MISSED live — this catches up on the next
//     reconnect. Retrying while still locked just re-parks the item (bounded, one per event).
// One-way import (sse.ts never imports this module).
let sseBound = false;
function bindSse(): void {
	if (sseBound || typeof window === 'undefined') return;
	const resume = (options: { resetAttempts?: boolean; release?: 'reopen' | 'unban' } = {}) => {
		void (async () => {
			await requeueRetriable(options);
			await processQueue();
		})();
	};
	// A reopen is a deliberate host action that changes the server's answer, so it's fair to
	// give parked items a fresh retry budget. A plain reconnect is not — that's the signal
	// that fires over and over on a flapping AP.
	onSseEvent('event-opened', () => resume({ resetAttempts: true, release: 'reopen' }));
	// An unban is the same kind of evidence, for the guest it names. `user-shown` is broadcast to
	// everyone (every feed needs to un-hide that user's photos), so check it is actually us
	// before resuming — otherwise one guest's unban would resume every OTHER banned guest's
	// queue straight into another 403.
	onSseEvent('user-shown', (payload) => {
		let userId: unknown;
		try {
			userId = (JSON.parse(String(payload)) as { user_id?: unknown }).user_id;
		} catch {
			return;
		}
		if (userId && userId === getUserId()) resume({ resetAttempts: true, release: 'unban' });
	});
	onSseEvent('feed-delta', () => resume());
	sseBound = true;
}
bindSse();

/**
 * Flip transient `error` items (5xx / a network drop that got marked before we could
 * reclassify it) back to `pending` so a resume actually retries them. Terminal `blocked`
 * items (403/413) are left alone — retrying those never succeeds.
 *
 * This is the ONLY automatic retry path, and it is driven by events that fire constantly on
 * a bad network (`online`, and `feed-delta` on every SSE reconnect), so it must be the thing
 * that enforces the budget: an item is only requeued while it is under `MAX_AUTO_ATTEMPTS`
 * and past its backoff deadline. Beyond that it stays parked until the guest taps "Erneut".
 *
 * `resetAttempts` is for signals that are positive evidence the blocking condition changed
 * (the host reopening the event), where starting the budget over is warranted.
 *
 * `release` names the host action that just happened, and un-parks only the items that were
 * waiting for exactly that (`parkedFor`). A reopen must not resume a banned guest's queue, and
 * an unban must not resume uploads into a released gallery — both would just 403 again.
 */
async function requeueRetriable(
	options: { resetAttempts?: boolean; release?: 'reopen' | 'unban' } = {}
): Promise<void> {
	const myUserId = getUserId();
	const all = await storeGetAll();
	const now = Date.now();
	const requeued = new Set<string>();
	let soonest: number | null = null;
	for (const entry of all) {
		if (entry.userId !== myUserId || entry.status !== 'error' || !entry.blob) continue;
		// A cancel is the guest's decision, not a transient failure — never undo it automatically,
		// not even on `resetAttempts` (the host reopening the event says nothing about whether
		// this guest still wants this photo sent). Only `retryItem` clears it.
		if (entry.cancelled) continue;
		// Parked waiting on a host action. Only the matching signal releases it, so a plain
		// reconnect leaves it alone instead of re-pushing the photo at a server whose answer
		// cannot have changed. A manual "Erneut" bypasses this via `retryItem`.
		if (entry.parkedFor) {
			if (entry.parkedFor !== options.release) continue;
			entry.parkedFor = undefined;
		}
		if (options.resetAttempts) {
			entry.attempts = 0;
			entry.nextAttemptAt = undefined;
		}
		// The budget is a RATE, not a lifetime allowance.
		//
		// Five attempts with a 5/10/20/40s ladder is ~75 seconds of failure end to end. A venue
		// AP brownout, a re-armed captive portal or a backend restart lasting two minutes — with
		// `navigator.onLine` still true the whole time, so none of it takes the offline path —
		// therefore exhausted every automatic attempt and parked the item until the guest went
		// FAB → sheet → "Warteschlange" → per-row "Erneut". Nobody does that; the photo simply
		// never arrives. Refilling after a quiet spell keeps the bound that matters (no hot
		// retry loop against a server that is genuinely down) while letting the evening recover
		// from a blip on its own.
		const lastFailureAt = entry.lastFailureAt ?? 0;
		if (now - lastFailureAt > RETRY_BUDGET_WINDOW_MS) {
			entry.attempts = 0;
			entry.nextAttemptAt = undefined;
		}
		if ((entry.attempts ?? 0) >= MAX_AUTO_ATTEMPTS) continue;
		if (entry.nextAttemptAt && entry.nextAttemptAt > now) {
			// Still cooling down — remember the earliest deadline so the sweep below can
			// come back for it without waiting for another `online`/reconnect to happen by.
			soonest = soonest === null ? entry.nextAttemptAt : Math.min(soonest, entry.nextAttemptAt);
			continue;
		}
		entry.status = 'pending';
		entry.error = undefined;
		entry.nextAttemptAt = undefined;
		await storePut(entry);
		requeued.add(entry.id);
	}
	if (soonest !== null) scheduleRetrySweep(soonest - now);
	queueItems.update((items) =>
		items.map((item) =>
			requeued.has(item.id)
				? { ...item, status: 'pending' as const, progress: 0, error: undefined }
				: item
		)
	);
}

// A single pending wake-up for the earliest backoff deadline. Without it an item that fails
// while the network is nominally fine would sit until the next `online` event or SSE
// reconnect — which on a stable-but-broken connection (captive portal, dead upstream) may
// never come, leaving the guest with a red badge and no visible progress.
let retrySweepTimer: ReturnType<typeof setTimeout> | null = null;
let retrySweepAt = 0;

function scheduleRetrySweep(delayMs: number): void {
	if (typeof window === 'undefined') return;
	const at = Date.now() + Math.max(0, delayMs);
	if (retrySweepTimer && retrySweepAt <= at) return;
	if (retrySweepTimer) clearTimeout(retrySweepTimer);
	retrySweepAt = at;
	retrySweepTimer = setTimeout(
		() => {
			retrySweepTimer = null;
			void (async () => {
				await requeueRetriable();
				await processQueue();
			})();
		},
		Math.max(0, delayMs)
	);
}

/**
 * Record a failed attempt on an entry and compute when the next automatic one may run.
 * Returns true once the budget is spent, so the caller can say so in the item's error text —
 * the queue row is the only place a guest can learn that nothing is retrying any more.
 */
function chargeAttempt(entry: QueueEntry): boolean {
	const attempts = (entry.attempts ?? 0) + 1;
	entry.attempts = attempts;
	entry.lastFailureAt = Date.now();
	if (attempts >= MAX_AUTO_ATTEMPTS) {
		entry.nextAttemptAt = undefined;
		return true;
	}
	const delay = Math.min(RETRY_BASE_DELAY_MS * 2 ** (attempts - 1), MAX_RETRY_DELAY_MS);
	entry.nextAttemptAt = Date.now() + delay;
	scheduleRetrySweep(delay);
	return false;
}

/** Append the "auto-retry has stopped" hint once the budget is spent. */
function withRetryHint(message: string, exhausted: boolean): string {
	return exhausted ? `${message} Tippe auf „Erneut“.` : message;
}

// ---------------------------------------------------------------------------------------
// Persistence layer.
//
// IndexedDB is NOT guaranteed to be there: iOS private mode denies it outright, a storage
// quota can be refused mid-event, and a second open tab holding an older DB version leaves
// `openDB` pending forever. Every one of those used to reject or hang the very first `await`
// of `addToQueue`, which stranded the composer with both buttons stuck on "Wird hochgeladen…".
//
// So the store degrades instead of failing: entries that cannot be persisted live in an
// in-memory overlay, and the guest is told ONCE, in German, that their photos won't survive
// closing the app. An upload that works but isn't crash-proof beats no upload at all.
// ---------------------------------------------------------------------------------------

let dbPromise: Promise<IDBPDatabase | null> | null = null;
const memoryEntries = new Map<string, QueueEntry>();
let warnedNoPersistence = false;

function warnNoPersistence(): void {
	if (warnedNoPersistence) return;
	warnedNoPersistence = true;
	toast(
		'Fotos können auf diesem Gerät nicht zwischengespeichert werden. Die Uploads laufen weiter — lass die App bitte offen, bis sie fertig sind.',
		'warning',
		9000
	);
}

async function openQueueDb(): Promise<IDBPDatabase | null> {
	if (dbPromise) return dbPromise;
	dbPromise = (async () => {
		try {
			// v1 → v2: add `userId` index so each guest's queue is isolated on shared devices.
			// Pre-existing entries (no userId) are dropped on upgrade; nothing useful was ever
			// persisted across logouts before this version.
			// Version 3 self-heals installs corrupted by a shipped v1→v2 bug: that upgrade
			// opened a *new* transaction inside the callback, which throws InvalidStateError
			// ("A version change transaction is running") and aborts the whole upgrade —
			// leaving some browsers at version 2 with NO 'queue' object store (so every queue
			// write failed and no upload ever fired). Bumping to 3 re-runs this upgrade for
			// those installs; the contains() guard recreates the missing store instead of
			// assuming createObjectStore only ever runs on a brand-new DB.
			const opening = openDB(DB_NAME, 3, {
				upgrade(database, oldVersion, _newVersion, transaction) {
					if (!database.objectStoreNames.contains(STORE_NAME)) {
						database.createObjectStore(STORE_NAME, { keyPath: 'id' });
					} else if (oldVersion < 2) {
						// Existing v1 store: its entries predate the `userId` field, so drop them
						// rather than misattribute them to whoever is signed in now. Reuse the
						// active version-change transaction (never open a new one here — see above).
						// Skipped when we just created the store, which is already empty.
						transaction.objectStore(STORE_NAME).clear();
					}
				},
				blocked() {
					// Another tab still holds an older version, so the upgrade cannot start. There
					// is nothing we can do from here except tell the guest which action unblocks it
					// — the timeout below keeps the queue usable meanwhile.
					toast(
						'EventSnap ist noch in einem anderen Tab geöffnet. Bitte schließe die anderen Tabs.',
						'warning',
						9000
					);
				}
			});
			const database = await Promise.race([
				opening,
				new Promise<null>((resolve) => setTimeout(() => resolve(null), DB_OPEN_TIMEOUT_MS))
			]);
			if (!database) {
				warnNoPersistence();
				return null;
			}
			return database;
		} catch {
			// Private mode / quota denial / corrupted profile — fall back to memory.
			warnNoPersistence();
			return null;
		}
	})();
	return dbPromise;
}

async function storeGet(id: string): Promise<QueueEntry | undefined> {
	const overlay = memoryEntries.get(id);
	if (overlay) return overlay;
	const database = await openQueueDb();
	if (!database) return undefined;
	try {
		return await database.get(STORE_NAME, id);
	} catch {
		return undefined;
	}
}

async function storeGetAll(): Promise<QueueEntry[]> {
	const database = await openQueueDb();
	let persisted: QueueEntry[] = [];
	if (database) {
		try {
			persisted = await database.getAll(STORE_NAME);
		} catch {
			persisted = [];
		}
	}
	// The overlay shadows the persisted copy: once a row fails to write, memory holds the
	// newer state and the stale IndexedDB row must not resurrect it.
	return [...memoryEntries.values(), ...persisted.filter((entry) => !memoryEntries.has(entry.id))];
}

async function storePut(entry: QueueEntry): Promise<void> {
	if (memoryEntries.has(entry.id)) {
		memoryEntries.set(entry.id, entry);
		return;
	}
	const database = await openQueueDb();
	if (database) {
		try {
			await database.put(STORE_NAME, entry);
			return;
		} catch {
			// Most often QuotaExceededError on a phone with a full photo library. Keep going
			// from memory rather than throwing out of addToQueue/uploadItem.
		}
	}
	memoryEntries.set(entry.id, entry);
	warnNoPersistence();
}

async function storeDelete(id: string): Promise<void> {
	memoryEntries.delete(id);
	const database = await openQueueDb();
	if (!database) return;
	try {
		await database.delete(STORE_NAME, id);
	} catch {
		/* nothing to recover — the in-memory view is already authoritative */
	}
}

async function storeClear(): Promise<void> {
	memoryEntries.clear();
	const database = await openQueueDb();
	if (!database) return;
	try {
		await database.clear(STORE_NAME);
	} catch {
		/* see storeDelete */
	}
}

/**
 * Wipe every queue entry — both IndexedDB rows and the in-memory store. Called on
 * explicit logout so a second guest using the same device doesn't inherit (or be
 * blamed for) the previous guest's pending uploads.
 */
export async function clearQueue(): Promise<void> {
	await storeClear();
	queueItems.set([]);
	rateLimitRetryAt.set(null);
}

class RateLimitError extends Error {
	retryAfterSecs: number;
	constructor(secs: number) {
		super('rate_limited');
		this.retryAfterSecs = secs;
	}
}

/**
 * A permanent, non-retryable failure — the server returned a 4xx (other than 429) that
 * will never succeed on retry: uploads locked, user banned, gallery already released, or
 * per-user quota full. The item is moved to the terminal `blocked` state and its blob is
 * dropped from IndexedDB (no point keeping bytes we'll never send).
 */
class TerminalError extends Error {
	constructor(message: string) {
		super(message);
	}
}

/**
 * A connectivity failure — the request never reached the server (offline, DNS, dropped
 * connection). The item stays `pending` (not `error`) so the `online` listener and the
 * next `processQueue` pick it up automatically. This is what makes "the queue flushes
 * when you're back online" actually true for a file staged with no signal.
 */
class NetworkError extends Error {}

/**
 * The guest aborted this upload themselves (the ✕ on an in-flight row). A NetworkError
 * subclass because the transport outcome is identical — but it must NOT stop the batch or
 * count against the retry budget, since it says nothing about the connection.
 */
class CancelledError extends NetworkError {}

/**
 * The session is gone/expired (HTTP 401). This is emphatically NOT terminal: the blob is
 * KEPT (deleting it — the old behavior for any 4xx — destroyed a guest's staged photos the
 * instant their sliding session lapsed or a host reset their PIN, exactly when auto-resume
 * fires). We mark the item retryable and route to re-auth; once the user signs back in with
 * the same identity (via /recover), `loadQueue` re-associates these entries by userId and
 * they resume. Distinct from `TerminalError` so the 4xx branch can't purge the blob.
 */
class AuthError extends Error {}

/**
 * A REVERSIBLE 403 — the event is closed or the gallery was released, but a host can reopen
 * it. Backend tags these with code `uploads_locked` (distinct from a permanent `forbidden`
 * like a banned user). The blob is KEPT and the item parks as retryable; the `event-opened`
 * SSE (or a manual retry) resumes it. Without this, a photo staged during a lock was purged
 * as terminal and lost the moment the host reopened.
 */
class LockedError extends Error {}

/**
 * The gallery has been RELEASED — `gallery_released`. A subclass of `LockedError` so every
 * blob-preserving code path below keeps treating it as a reversible lock (the host *can* still
 * reopen, and losing a photo is the worst outcome).
 *
 * What differs is the retry policy. A closed event is a pause the host means to undo, so
 * auto-resuming on reconnect is right. A released gallery is the end of the event, and in the
 * normal flow nobody reopens it — so auto-retrying re-pushes a multi-megabyte photo over
 * cellular on every budget refill, forever, for an answer that will not change, while the guest
 * is told to tap a camera button that 403s. Items parked this way sit still (see
 * `awaitingReopen` in `requeueRetriable`) until a real `event-opened` arrives or the guest
 * retries by hand.
 */
class ReleasedError extends LockedError {}

/**
 * The uploader is banned — `user_banned`. Also a `LockedError` subclass, so the blob survives:
 * `unban_user` exists and the host's confirm copy promises the photos come back, which the old
 * generic-`forbidden` classification made impossible for anything mid-flight (blob purged, row
 * moved to `blocked`, and `blocked` has no retry button).
 *
 * Parks still like `ReleasedError` and waits for `user-shown`.
 */
class BannedError extends LockedError {}

/** Retry policy for an upload response status. */
export type UploadOutcome = 'success' | 'rate_limit' | 'auth' | 'transient' | 'terminal';

/**
 * Classify an upload HTTP status into a retry policy. Pure + exported so the
 * data-loss-critical rules are unit-testable without an XHR/IndexedDB harness:
 *  - 401 → `auth`      (session gone: KEEP the blob, re-auth — NEVER purge it)
 *  - 408 → `transient` (request timeout: retryable)
 *  - 429 → `rate_limit`(back off, auto-resume)
 *  - other 4xx → `terminal` (locked/banned/released/quota — the only case that purges)
 *  - 2xx → `success`; 5xx/other → `transient`
 * The one rule that must never regress: a 401 is `auth`, not `terminal`.
 */
export function classifyUploadStatus(status: number): UploadOutcome {
	if (status >= 200 && status < 300) return 'success';
	if (status === 429) return 'rate_limit';
	if (status === 401) return 'auth';
	if (status === 408) return 'transient';
	if (status >= 400 && status < 500) return 'terminal';
	return 'transient';
}

/**
 * Within the `terminal` bucket, decide whether a 4xx is a REVERSIBLE lock (keep the blob,
 * park retryable for a host reopen) rather than a permanent rejection (purge the blob).
 * Pure + exported so this data-loss-critical rule is unit-testable without an XHR harness.
 *
 * Reversible when:
 *  - the backend tagged it `uploads_locked` (event closed / gallery released — a host can reopen), OR
 *  - it's `quota_exceeded` (413). The per-user ceiling is `free_disk * tolerance / uploaders`,
 *    which MOVES: the numerator falls and the denominator rises all evening, so a guest who was
 *    comfortably under it at 20:00 is over it at 22:00 through nobody's action, and a host
 *    deleting content or the hourly reclaim can put them back under it just as passively. It is
 *    the textbook reversible lock, and treating it as permanent meant a 400 MB video was pushed
 *    across cellular in full and THEN deleted from IndexedDB — gone on both sides, unrecoverable
 *    without re-picking from the camera roll (impossible for an in-app camera capture). OR
 *  - it's ANY 403 we can't positively identify as a permanent ban (`forbidden`). An unparseable
 *    403 body (proxy/WAF/captive portal) must NOT purge the blob — losing a photo is the worst
 *    outcome, and 403 is the reversible-lock status here.
 * A `forbidden` 403 (banned) and every other 4xx (too large, wrong type) are permanent → purge.
 */
export function isReversibleLock(status: number, errorCode: unknown): boolean {
	return (
		errorCode === 'uploads_locked' ||
		errorCode === 'gallery_released' ||
		// A ban is lifted by `unban_user`, and the host UI promises the photos come back. Purging
		// the blob here made that promise impossible to keep for anything mid-flight.
		errorCode === 'user_banned' ||
		errorCode === 'quota_exceeded' ||
		(status === 403 && errorCode !== 'forbidden')
	);
}

/**
 * Within the reversible-lock bucket, is this the END of the event rather than a pause?
 *
 * `gallery_released` means the keepsake has been snapshotted. The blob is still kept (a host
 * reopen is possible), but the item must stop auto-retrying — see `ReleasedError`. Pure +
 * exported for the same reason as `isReversibleLock`: it decides whether a guest's photo gets
 * re-pushed over cellular all night.
 */
export function isGalleryReleased(errorCode: unknown): boolean {
	return errorCode === 'gallery_released';
}

/**
 * Is this a ban (`user_banned`)? Reversible, blob kept — but like a release it will not lift on
 * its own, so the item parks still and waits for the `user-shown` SSE rather than re-pushing the
 * photo on every reconnect at a guest who is currently not allowed to upload.
 */
export function isUserBanned(errorCode: unknown): boolean {
	return errorCode === 'user_banned';
}

/**
 * Rehydrate a persisted IndexedDB entry into an in-memory `QueueItem`. Pure + exported so the
 * field-mapping is unit-testable. The rule that must not regress: `lastModified` MUST be carried
 * across — addToQueue's dedup keys on it, so an item restored from IndexedDB (page reload / PWA
 * relaunch) with an undefined lastModified would fail to match a re-selection of the same file
 * and silently queue it twice. `uploading` is downgraded to `pending` (an interrupted in-flight
 * upload must resume, not stay stuck spinning).
 */
export function entryToQueueItem(entry: {
	id: string;
	userId: string;
	fileName: string;
	fileSize: number;
	lastModified?: number;
	mimeType: string;
	caption?: string;
	hashtags?: string;
	status: QueueItem['status'] | 'uploading';
	error?: string;
}): QueueItem {
	return {
		id: entry.id,
		userId: entry.userId,
		fileName: entry.fileName,
		fileSize: entry.fileSize,
		lastModified: entry.lastModified,
		mimeType: entry.mimeType,
		caption: entry.caption ?? '',
		hashtags: entry.hashtags ?? '',
		status: entry.status === 'uploading' ? 'pending' : entry.status,
		progress: entry.status === 'done' ? 100 : 0,
		error: entry.error
	};
}

export async function loadQueue(): Promise<void> {
	const myUserId = getUserId();
	const all = await storeGetAll();
	// Only surface entries that belong to the current user. Entries from a previous
	// guest on this device are filtered out (and would also be wiped on their next
	// explicit logout via `clearQueue`).
	const items: QueueItem[] = all
		.filter((entry) => entry.userId && entry.userId === myUserId)
		.map(entryToQueueItem);
	queueItems.set(items);
	// Staged-but-unsent items from a prior session (queued offline, tab closed before
	// reconnect) must resume now — otherwise the "queue flushes when you're back online"
	// promise only holds if the user manually re-stages a file. Reclaim transient errors
	// (a network drop from a prior session) so they retry instead of stalling.
	void (async () => {
		await requeueRetriable();
		await processQueue();
	})();
}

/**
 * Release parked items whose blocking condition is already over, using the authoritative state
 * the app fetches at boot.
 *
 * `parkedFor` is persisted to IndexedDB, but the only things that cleared it were the LIVE
 * `event-opened` / `user-shown` SSE events. Those only reach a tab that is open at the moment the
 * host acts — and the realistic sequence is the opposite one: the guest's photo is parked, they
 * close the app at the end of the night, and the host lifts the ban or reopens uploads the next
 * morning. Nothing then ever un-parked the item, so it sat in the queue forever while the toast
 * had promised "wird gesendet, sobald die Sperre aufgehoben ist".
 *
 * Called once per boot with what `/me/context` and the event state actually say, so a park can
 * never outlive the condition it was waiting on. Cheap: a no-op unless something is parked.
 */
export async function releaseResolvedParks(state: {
	banned: boolean;
	uploadsOpen: boolean;
}): Promise<void> {
	// Each release is scoped to its own signal, exactly as the SSE path is: being unbanned says
	// nothing about whether the gallery reopened, and vice versa.
	if (!state.banned) {
		await requeueRetriable({ resetAttempts: true, release: 'unban' });
	}
	if (state.uploadsOpen) {
		await requeueRetriable({ resetAttempts: true, release: 'reopen' });
	}
	await processQueue();
}

/** Outcome of an `addToQueue` call, so the caller can tell the user when a file was NOT
 *  actually queued (deduped, or the queue is full of un-evictable in-flight items). */
export type EnqueueResult = 'queued' | 'duplicate' | 'full';

export async function addToQueue(
	file: File,
	caption: string,
	hashtags: string
): Promise<EnqueueResult> {
	const userId = getUserId();
	// Not authenticated — nothing to queue. Return the silent 'duplicate' outcome rather than
	// 'full' so the caller doesn't show a misleading "queue full" toast. Practically
	// unreachable: the upload view sits behind a token guard.
	if (!userId) return 'duplicate';

	// Dedup: don't queue the same file twice while an identical one is still unsent
	// (double-tap, re-added after a flaky reconnect). Matches on name+size+lastModified+user
	// — including lastModified so two genuinely different photos sharing a name and byte
	// length aren't silently collapsed into one.
	const mine = get(queueItems).filter((i) => i.userId === userId);
	const dup = mine.some(
		(i) =>
			i.fileName === file.name &&
			i.fileSize === file.size &&
			(i.lastModified ?? 0) === file.lastModified &&
			(i.status === 'pending' || i.status === 'uploading')
	);
	if (dup) return 'duplicate';

	// Cap the queue so stuck/blocked blobs can't grow IndexedDB without bound. When full,
	// evict the oldest item whose blob is already gone or unrecoverable — `done` (blob deleted
	// on success), `blocked` (blob purged, terminal), or an `error` whose blob is missing.
	// An `error` item that STILL HOLDS a blob is never evicted: it is retryable (a network
	// blip, or a locked/released upload that resumes on reopen) and dropping it would silently
	// lose a photo the retry paths deliberately kept. If nothing is evictable, refuse and tell
	// the caller so it can surface a "queue full" message rather than dropping a photo the
	// user believed was queued.
	if (mine.length >= MAX_QUEUE_ITEMS) {
		const evictable = await findEvictable(userId);
		if (evictable) {
			await storeDelete(evictable);
			queueItems.update((items) => items.filter((it) => it.id !== evictable));
		} else {
			return 'full';
		}
	}

	// This id is also the server-side idempotency key (`client_upload_id`), so it is minted
	// exactly ONCE per file here and reused by every retry — see uploadItem.
	const id = crypto.randomUUID();
	const entry: QueueEntry = {
		id,
		userId,
		fileName: file.name,
		fileSize: file.size,
		lastModified: file.lastModified,
		mimeType: file.type,
		caption,
		hashtags,
		status: 'pending',
		blob: file
	};
	await storePut(entry);

	queueItems.update((items) => [
		...items,
		{
			id,
			userId,
			fileName: file.name,
			fileSize: file.size,
			lastModified: file.lastModified,
			mimeType: file.type,
			caption,
			hashtags,
			status: 'pending',
			progress: 0
		}
	]);

	processQueue();
	return 'queued';
}

export async function retryItem(id: string): Promise<void> {
	const entry = await storeGet(id);
	if (!entry) return;

	entry.status = 'pending';
	entry.error = undefined;
	// A deliberate tap outranks the automatic budget: reset the counter and clear any backoff
	// so a guest who watched their photo fail five times can still get it sent right now.
	entry.attempts = 0;
	entry.nextAttemptAt = undefined;
	// And it is the one thing that un-cancels: tapping "Erneut" on a row the guest stopped
	// themselves is them changing their mind.
	entry.cancelled = false;
	// Same for a parked item: an explicit tap is the guest asking us to try anyway (the host may
	// have reopened or unbanned without this device seeing the SSE). If the condition still
	// holds the next response re-parks it, so this cannot become a loop.
	entry.parkedFor = undefined;
	await storePut(entry);

	queueItems.update((items) =>
		items.map((item) =>
			item.id === id ? { ...item, status: 'pending' as const, progress: 0, error: undefined } : item
		)
	);

	processQueue();
}

/**
 * Abort an in-flight upload on the guest's command. The only escape hatch from an upload that
 * is technically alive but going nowhere — without it the queue's single slot stays occupied
 * until the stall watchdog fires (or the app is force-quit). The item parks as retryable with
 * its blob intact, so "Erneut" still works afterwards.
 */
export function cancelItem(id: string): void {
	const xhr = activeUploads.get(id);
	if (!xhr) return;
	cancelledUploads.add(id);
	xhr.abort();
}

export async function removeItem(id: string): Promise<void> {
	// An item can be removed while its request is still on the wire — stop the transfer, or it
	// keeps pushing bytes for a row that no longer exists. `removedUploads` tells the abort
	// handler that the row is gone, so it doesn't race the delete below and resurrect it.
	if (activeUploads.has(id)) {
		removedUploads.add(id);
		cancelItem(id);
	}
	await storeDelete(id);
	queueItems.update((items) => items.filter((item) => item.id !== id));
}

export async function clearCompleted(): Promise<void> {
	const items = get(queueItems);
	for (const item of items) {
		if (item.status === 'done') {
			await storeDelete(item.id);
		}
	}
	queueItems.update((items) => items.filter((item) => item.status !== 'done'));
}

/**
 * Find the id of an item whose slot can be reclaimed: its blob is gone (`done`, `blocked`) or
 * was never there to begin with. The blob-less `error` case matters because several early
 * returns in `uploadItem` produce exactly that — an item with no bytes left to send, which can
 * never succeed on retry, yet used to hold a queue slot and a red FAB badge forever.
 */
async function findEvictable(userId: string): Promise<string | null> {
	for (const item of get(queueItems)) {
		if (item.userId !== userId) continue;
		if (item.status === 'done' || item.status === 'blocked') return item.id;
		if (item.status !== 'error') continue;
		const entry = await storeGet(item.id);
		if (!entry?.blob) return item.id;
	}
	return null;
}

let processing = false;

/** In-flight requests by item id, so a stuck upload can be aborted from the UI. */
const activeUploads = new Map<string, XMLHttpRequest>();
/** Ids whose abort was requested by the guest, to tell a ✕ apart from a dropped connection. */
const cancelledUploads = new Set<string>();
/** Ids aborted because the row itself is being deleted — their failure must persist nothing. */
const removedUploads = new Set<string>();

/**
 * Send a guest whose session died back to /join.
 *
 * Duplicated from api.ts (where it is module-private) rather than imported: api.ts owns the
 * foreground half of the same rule and deliberately uses `window.location` over `goto`, since
 * a full document load resets every module-level store — the correct behaviour after a session
 * loss, and safe here because the queued blobs live in IndexedDB and survive it.
 */
function redirectToJoin(): void {
	if (typeof window === 'undefined') return;
	const path = window.location.pathname;
	// Already on an auth screen — re-navigating would throw away half-typed recovery input.
	if (['/join', '/recover'].some((r) => path === r || path.startsWith(`${r}/`))) return;
	window.location.assign('/join');
}

async function processQueue(): Promise<void> {
	if (processing) return;
	processing = true;
	isProcessing.set(true);

	try {
		while (true) {
			// Offline: leave items 'pending' rather than burning through them into 'error'.
			// The `online` listener re-enters here the moment connectivity returns.
			if (typeof navigator !== 'undefined' && navigator.onLine === false) break;

			const items = get(queueItems);
			const next = items.find((item) => item.status === 'pending');
			if (!next) break;

			try {
				await uploadItem(next.id);
			} catch (e) {
				if (e instanceof RateLimitError) {
					// Keep all pending items as-is; schedule queue resume when limit lifts
					const retryAt = Date.now() + e.retryAfterSecs * 1000;
					rateLimitRetryAt.set(retryAt);
					setTimeout(() => {
						rateLimitRetryAt.set(null);
						processQueue();
					}, e.retryAfterSecs * 1000);
					break;
				}
				if (e instanceof AuthError) {
					// Dead session — stop the batch (every further item would 401 too). Items
					// stay retryable with blobs intact; the re-auth flow (clearAuth) takes over.
					break;
				}
				if (e instanceof LockedError) {
					// Event locked — stop the batch (every item would hit the same lock). Items
					// stay retryable with blobs intact; the `event-opened` SSE resumes them.
					break;
				}
				if (e instanceof CancelledError) {
					// The guest cancelled THIS item, which says nothing about the connection —
					// keep draining so the rest of their photos still go out. (Checked before
					// NetworkError, which it extends.)
					continue;
				}
				if (e instanceof NetworkError) {
					// Connectivity dropped mid-flight. If offline the item is back to 'pending'
					// and the `online` listener resumes it; if the failure hit while nominally
					// online it's now a retryable 'error'. Either way, stop hammering here.
					break;
				}
				// Other errors are already handled inside uploadItem (marked 'error'/'blocked')
			}
		}
	} finally {
		processing = false;
		isProcessing.set(false);
	}
}

async function uploadItem(id: string): Promise<void> {
	const entry = await storeGet(id);
	if (!entry || !entry.blob) {
		// Persist the status, don't just paint it. `updateItemStatus` alone writes the store
		// only, so the row on disk still said `pending` — leaving a blob-less `error` item that
		// re-entered the drain loop on every resume, and which nothing could ever evict.
		await failEarly(id, entry, 'Datei nicht gefunden.');
		return;
	}

	const token = getToken();
	const currentUserId = getUserId();
	if (!token || !currentUserId) {
		await failEarly(id, entry, 'Nicht angemeldet.');
		return;
	}
	// Defense-in-depth: if the device's signed-in user changed since this entry was
	// queued, refuse to upload it under the new identity. `loadQueue` already filters
	// by user; this guards the in-memory store path too.
	if (entry.userId && entry.userId !== currentUserId) {
		await failEarly(id, entry, 'Anderer Nutzer angemeldet.');
		return;
	}

	updateItemStatus(id, 'uploading');

	try {
		const formData = new FormData();
		// Idempotency key, sent FIRST so the server can read it before it starts streaming the
		// body (multipart fields arrive in order). The queue item id is stable across every
		// retry, so a reply lost on the way back — the classic congested-wifi failure — makes
		// the server return the ORIGINAL upload instead of committing the photo a second time
		// and charging the guest's quota twice. Both 200 (deduped) and 201 (created) are
		// success; `classifyUploadStatus` already treats the whole 2xx range that way.
		formData.append('client_upload_id', entry.id);
		formData.append('file', entry.blob, entry.fileName);
		if (entry.caption) formData.append('caption', entry.caption);
		if (entry.hashtags) formData.append('hashtags', entry.hashtags);

		await new Promise<void>((resolve, reject) => {
			const xhr = new XMLHttpRequest();
			xhr.open('POST', '/api/v1/upload');
			xhr.setRequestHeader('Authorization', `Bearer ${token}`);
			// Wall-clock backstop only — generous enough that a slow-but-alive LTE upload is
			// never killed by it. See MIN/MAX_UPLOAD_TIMEOUT_MS.
			xhr.timeout = Math.min(
				MAX_UPLOAD_TIMEOUT_MS,
				Math.max(MIN_UPLOAD_TIMEOUT_MS, (entry.fileSize / ASSUMED_MIN_BYTES_PER_SEC) * 1000)
			);

			// Stall watchdog: a phone that roams between APs mid-upload leaves a half-open
			// connection that never errors and never completes. Only "no bytes moved" catches
			// that without also punishing a healthy slow link.
			let lastProgressAt = Date.now();
			let lastTickAt = Date.now();
			let creditSpent = 0;
			let bodySent = false;
			let stalled = false;
			const stallTimer = setInterval(() => {
				// Never fire twice. `xhr.abort()` on a request already in `readyState === DONE`
				// emits NO `abort` event, so `settle()` would never run: the interval would keep
				// running forever, re-aborting every 5s, and `activeUploads` would keep a stale
				// entry so the guest's ✕ button silently did nothing.
				if (stalled) return;
				const now = Date.now();
				// Credit back the window the page was frozen. The watchdog measures SILENCE, and
				// a period in which it could not observe anything is not evidence of silence.
				// Clamped to `now` so a progress event delivered right at resume cannot push the
				// timestamp into the future.
				// Bounded: see MAX_SUSPEND_CREDIT_MS. Without the cap, sustained background
				// throttling credited back all but one interval on every tick, so a dead socket
				// took ~18 minutes to notice while the queue's latch stayed held.
				const credit = Math.min(
					suspendedSinceLastTick(lastTickAt, now),
					Math.max(0, MAX_SUSPEND_CREDIT_MS - creditSpent)
				);
				creditSpent += credit;
				lastProgressAt = Math.min(now, lastProgressAt + credit);
				lastTickAt = now;
				if (!shouldAbortForStall(lastProgressAt, now, bodySent)) return;
				stalled = true;
				clearInterval(stallTimer);
				xhr.abort();
			}, STALL_CHECK_INTERVAL_MS);
			const settle = (fn: () => void) => {
				clearInterval(stallTimer);
				activeUploads.delete(id);
				fn();
			};
			activeUploads.set(id, xhr);

			xhr.upload.addEventListener('progress', (e) => {
				lastProgressAt = Date.now();
				// Bytes moved: this stretch of silence is over, so the suspension budget refills.
				creditSpent = 0;
				if (e.lengthComputable) {
					const pct = Math.round((e.loaded / e.total) * 100);
					queueItems.update((items) =>
						items.map((item) => (item.id === id ? { ...item, progress: pct } : item))
					);
				}
			});
			// Once the last byte is out the watchdog has nothing left to measure: the server may
			// legitimately sit on the request while it validates and stores the file, and no
			// progress event fires in that window. Aborting on the 90s no-progress ceiling there
			// would re-send a whole video the server had already accepted.
			//
			// But switching the watchdog OFF here (which is what this used to do) handed the
			// window to `xhr.timeout` alone — 5 to 60 minutes, during which a half-open socket
			// holds `processing` and the guest's whole queue stops draining. So instead of
			// disarming, widen: `shouldAbortForStall` switches to RESPONSE_TIMEOUT_MS once
			// `bodySent` flips, which bounds the wedge at 2 minutes without ever firing on a
			// server that is legitimately still working.
			xhr.upload.addEventListener('loadend', () => {
				bodySent = true;
				lastProgressAt = Date.now();
			});

			xhr.addEventListener('load', () => {
				const body = (() => {
					try {
						return JSON.parse(xhr.responseText);
					} catch {
						return null;
					}
				})();
				switch (classifyUploadStatus(xhr.status)) {
					case 'success':
						// 201 = created, 200 = the server recognised `client_upload_id` and replayed
						// the original upload. Identical outcome for us: the photo is on the server.
						settle(resolve);
						break;
					case 'rate_limit': {
						// Back off and auto-resume when the window lifts (quota-full is a distinct
						// 413, classified 'terminal' below).
						const secs = typeof body?.retry_after_secs === 'number' ? body.retry_after_secs : 60;
						settle(() => reject(new RateLimitError(secs)));
						break;
					}
					case 'auth':
						// Session expired/revoked. NOT terminal — keep the blob and re-auth. Lumping
						// this into the terminal bucket (which purges the blob) irrecoverably
						// destroyed queued photos whenever a sliding session lapsed or a host reset
						// the PIN — precisely when the `online` auto-resume kicks in.
						settle(() => reject(new AuthError('Sitzung abgelaufen. Bitte melde dich erneut an.')));
						break;
					case 'transient':
						// 408 (request timeout) behaves like a network blip so it stays retryable;
						// 5xx is a generic retryable error. Neither purges the blob.
						settle(() =>
							xhr.status === 408
								? reject(new NetworkError('Zeitüberschreitung'))
								: reject(new Error(body?.message || `HTTP ${xhr.status}`))
						);
						break;
					case 'terminal': {
						// A REVERSIBLE lock (event closed / gallery released) is tagged
						// `uploads_locked` by the backend — keep the blob and park it retryable so
						// a host reopen resumes it, instead of purging it like a permanent 4xx.
						// Also treat ANY 403 we can't positively identify as permanent (`forbidden`
						// = banned) as reversible: an unparseable 403 body (proxy/WAF/captive
						// portal) must NOT purge the blob — losing a photo is the worst outcome, and
						// 403 is the reversible-lock status here.
						if (isReversibleLock(xhr.status, body?.error)) {
							const msg = body?.message || 'Event ist geschlossen.';
							settle(() =>
								reject(
									isGalleryReleased(body?.error)
										? new ReleasedError(msg)
										: isUserBanned(body?.error)
											? new BannedError(msg)
											: new LockedError(msg)
								)
							);
							break;
						}
						// Any other 4xx the server will keep rejecting (banned, too large, wrong
						// type). Quota is NOT here any more — it moves with free disk and the
						// uploader count, so it is a reversible lock handled above.
						let msg = body?.message || 'Upload nicht möglich.';
						if (!body?.message && xhr.status === 413) msg = 'Speicher-Limit erreicht.';
						settle(() => reject(new TerminalError(msg)));
						break;
					}
				}
			});

			xhr.addEventListener('error', () => settle(() => reject(new NetworkError('Netzwerkfehler'))));
			xhr.addEventListener('timeout', () =>
				settle(() => reject(new NetworkError('Zeitüberschreitung')))
			);
			xhr.addEventListener('abort', () =>
				settle(() => {
					// Three ways to land here, and they need different answers: the guest tapped ✕
					// (don't stop the batch, don't spend retry budget), the watchdog killed a dead
					// connection, or the browser aborted on its own.
					if (cancelledUploads.delete(id)) reject(new CancelledError('Abgebrochen'));
					else if (stalled) reject(new NetworkError('Verbindung eingeschlafen'));
					else reject(new NetworkError('Abgebrochen'));
				})
			);
			// `send` can throw SYNCHRONOUSLY — most plausibly on a phone whose OS purged the
			// backing store for the blob, leaving a neutered File. The executor would turn that
			// into a rejection and `uploadItem` would recover, but `settle()` never runs: the
			// stall interval leaks and `activeUploads` keeps a stale entry, so the ✕ button on
			// that item stops working for the rest of the session.
			try {
				xhr.send(formData);
			} catch {
				settle(() => reject(new NetworkError('Netzwerkfehler')));
			}
		});

		// Success — remove blob from IndexedDB, mark done
		entry.status = 'done';
		entry.error = undefined;
		entry.attempts = 0;
		entry.nextAttemptAt = undefined;
		delete entry.blob;
		await storePut(entry);
		updateItemStatus(id, 'done');
		// Refresh the per-user quota snapshot so the My Account widget reflects this
		// upload's bytes without a manual reload.
		void refreshQuota();
	} catch (e) {
		if (e instanceof RateLimitError) {
			// Reset to pending so it will be retried when the queue resumes
			entry.status = 'pending';
			await storePut(entry);
			updateItemStatus(id, 'pending');
			throw e; // Propagate to processQueue for scheduling
		}
		if (e instanceof LockedError) {
			// A condition that can lift without the guest doing anything: the event is closed or
			// the gallery released (a host can reopen), or the storage quota is currently
			// exceeded (it moves with free disk and the uploader count, and a host takedown or
			// the hourly media reclaim puts them back under it). KEEP the blob and park the item
			// as retryable so it survives until then. `event-opened` and the `feed-delta`
			// reconnect both auto-resume it; a manual "Erneut" also works. Never purge here.
			// Neither a release nor a ban lifts on its own, so charging an attempt — and with it
			// the backoff ladder and budget refill that drive automatic re-pushes — buys nothing
			// but bandwidth. Park those still and wait for the host action.
			const parkedFor: 'reopen' | 'unban' | undefined =
				e instanceof ReleasedError ? 'reopen' : e instanceof BannedError ? 'unban' : undefined;
			const exhausted = parkedFor ? false : chargeAttempt(entry);
			entry.status = 'error';
			entry.parkedFor = parkedFor;
			entry.error = parkedFor ? e.message : withRetryHint(e.message, exhausted);
			await storePut(entry);
			updateItemStatus(id, 'error', entry.error);
			// Say it out loud. The queue list is only mounted on /upload and the composer sends
			// the guest straight to /feed, so this message otherwise lands in a store that
			// nothing on screen renders — the photo just never appears and the guest, with no
			// operator to ask, assumes it worked.
			//
			// For a release, be explicit that the photo is NOT lost and NOT coming back on its
			// own — that is the whole difference the guest needs to act on.
			const parkedHint =
				parkedFor === 'reopen'
					? ' Dein Foto bleibt auf diesem Gerät gespeichert — frag die Gastgeber, ob sie die Galerie noch einmal öffnen.'
					: parkedFor === 'unban'
						? ' Dein Foto bleibt auf diesem Gerät gespeichert und wird gesendet, sobald die Sperre aufgehoben ist.'
						: ' Du findest den Upload über den Kamera-Button.';
			toast(`${entry.fileName}: ${e.message}${parkedHint}`, 'warning', parkedFor ? 9000 : 6000);
			throw e;
		}
		if (e instanceof AuthError) {
			// Dead session — KEEP the blob (never purge on auth failure) and mark retryable so
			// the file survives re-auth. Route through the app's single de-auth path (matches
			// api.ts's 401 handling). The blob persists in IndexedDB keyed by userId; once the
			// user signs back in with the SAME identity (via /recover), the next time the upload
			// view loads `loadQueue` re-associates this entry and it can be retried/resumed.
			entry.status = 'error';
			entry.error = e.message;
			await storePut(entry);
			updateItemStatus(id, 'error', e.message);
			clearAuth();
			// And actually TAKE them to /join. `clearAuth` alone hides the bottom nav and the
			// FAB (both gated on `isAuthenticated`), route guards only run in onMount, and a
			// standalone PWA has no URL bar — so a 401 that arrived from a background upload
			// left the guest on a dead screen with no control that leads anywhere. api.ts does
			// this for every foreground request; a background one is no different.
			redirectToJoin();
			throw e;
		}
		if (e instanceof CancelledError) {
			// Aborted because the row is being deleted — writing the entry back here would
			// undo `removeItem`'s delete and the item would reappear on the next reload.
			if (removedUploads.delete(id)) throw e;
			// The guest's own ✕. Park it retryable with the blob intact and spend no retry
			// budget — they asked for the transfer to stop, not for the photo to be dropped.
			// `cancelled` is what keeps the automatic path from immediately undoing that; see the
			// field's docstring. Without it the ✕ was purely cosmetic.
			entry.status = 'error';
			entry.cancelled = true;
			entry.error = 'Abgebrochen. Tippe auf „Erneut“.';
			await storePut(entry);
			updateItemStatus(id, 'error', entry.error);
			throw e;
		}
		if (e instanceof NetworkError) {
			const offline = typeof navigator !== 'undefined' && navigator.onLine === false;
			if (offline) {
				// Genuinely offline — keep the item pending; the `online` listener resumes it
				// automatically with no user action.
				entry.status = 'pending';
				await storePut(entry);
				updateItemStatus(id, 'pending');
			} else {
				// Network-level failure while the OS still reports online (server down,
				// connection refused, TLS error, captive portal, or our stall watchdog firing on
				// a half-open connection). The `online` event will NEVER fire in this case, so
				// leaving it 'pending' would strand the item with no retry path and no spinner.
				// Mark it retryable 'error' so the user gets a working "Erneut" button, and let
				// the backoff sweep pick it up.
				const exhausted = chargeAttempt(entry);
				const msg = withRetryHint(`${e.message}. Erneut versuchen.`, exhausted);
				entry.status = 'error';
				entry.error = msg;
				await storePut(entry);
				updateItemStatus(id, 'error', msg);
				// Only shout once the automatic attempts are used up: a single blip self-heals
				// seconds later and a toast for each one would just train guests to ignore them.
				if (exhausted) {
					toast(
						`${entry.fileName} konnte nicht hochgeladen werden. Tippe auf den Kamera-Button, um es erneut zu versuchen.`,
						'error',
						7000
					);
				}
			}
			throw e;
		}
		if (e instanceof TerminalError) {
			// Permanent rejection — drop the blob (we'll never resend it) and mark blocked
			// so the UI shows a clear reason and offers no retry.
			delete entry.blob;
			entry.status = 'blocked';
			entry.error = e.message;
			await storePut(entry);
			updateItemStatus(id, 'blocked', e.message);
			// Tell the user NOW. The queue list only lives on /upload, and the flow sends
			// them straight to /feed after staging a photo — so without this a rejected
			// upload was silently swallowed: the blob is gone, the FAB badge drops exactly
			// as if it had succeeded, and the photo simply never appears.
			toast(`${entry.fileName}: ${e.message}`, 'error');
			return;
		}
		// Everything else is a retryable server-side failure (5xx, an unparseable response).
		const exhausted = chargeAttempt(entry);
		const msg = withRetryHint(e instanceof Error ? e.message : 'Upload fehlgeschlagen.', exhausted);
		entry.status = 'error';
		entry.error = msg;
		await storePut(entry);
		updateItemStatus(id, 'error', msg);
		// Same reasoning as the network branch: nothing renders this message where the guest
		// is standing, so a 5xx would otherwise be completely invisible to them.
		if (exhausted) {
			toast(
				`${entry.fileName} konnte nicht hochgeladen werden. Tippe auf den Kamera-Button, um es erneut zu versuchen.`,
				'error',
				7000
			);
		}
	}
}

/**
 * Mark an item failed on BOTH sides of the queue before a single byte is sent. The in-memory
 * `updateItemStatus` on its own left the persisted row saying `pending`, so the next resume
 * picked the item up again — forever, since these paths produce items with no blob to send.
 */
async function failEarly(
	id: string,
	entry: QueueEntry | undefined,
	message: string
): Promise<void> {
	if (entry) {
		entry.status = 'error';
		entry.error = message;
		await storePut(entry);
	}
	updateItemStatus(id, 'error', message);
}

function updateItemStatus(id: string, status: QueueItem['status'], error?: string): void {
	queueItems.update((items) =>
		items.map((item) =>
			item.id === id
				? {
						...item,
						status,
						progress: status === 'done' ? 100 : status === 'pending' ? 0 : item.progress,
						error
					}
				: item
		)
	);
}
