import { describe, it, expect } from 'vitest';
import {
	classifyUploadStatus,
	isReversibleLock,
	entryToQueueItem,
	shouldAbortForStall,
	suspendedSinceLastTick,
	MAX_SUSPEND_CREDIT_MS
} from './upload-queue';

/**
 * Regression guard for the upload-queue retry policy (H2 + M1). The bug being locked out:
 * every 4xx except 429 was classified terminal, and a terminal item has its blob PURGED
 * from IndexedDB. A 401 (a sliding session that lapsed, or a host PIN-reset) is a 4xx, so a
 * guest's queued photos were irrecoverably destroyed the moment the `online` auto-resume
 * fired against a dead session. 401 must be `auth` (blob kept, re-auth), NEVER `terminal`.
 */
describe('classifyUploadStatus', () => {
	it('2xx → success', () => {
		expect(classifyUploadStatus(200)).toBe('success');
		expect(classifyUploadStatus(201)).toBe('success');
		expect(classifyUploadStatus(299)).toBe('success');
	});

	it('401 → auth, NOT terminal (never purge the blob on a dead session)', () => {
		expect(classifyUploadStatus(401)).toBe('auth');
	});

	it('429 → rate_limit (back off, auto-resume)', () => {
		expect(classifyUploadStatus(429)).toBe('rate_limit');
	});

	it('408 → transient (request timeout is retryable, not terminal)', () => {
		expect(classifyUploadStatus(408)).toBe('transient');
	});

	it('genuinely permanent 4xx → terminal (locked / banned / released / quota)', () => {
		expect(classifyUploadStatus(403)).toBe('terminal'); // banned / locked / released
		expect(classifyUploadStatus(413)).toBe('terminal'); // quota exhausted
		expect(classifyUploadStatus(400)).toBe('terminal');
		expect(classifyUploadStatus(404)).toBe('terminal');
	});

	it('5xx / unexpected → transient (retryable, blob kept)', () => {
		expect(classifyUploadStatus(500)).toBe('transient');
		expect(classifyUploadStatus(502)).toBe('transient');
		expect(classifyUploadStatus(503)).toBe('transient');
	});
});

/**
 * Regression guard for the reversible-lock discrimination inside the `terminal` bucket — the
 * branch that decides whether a 4xx KEEPS the blob (event closed / gallery released: a host can
 * reopen and the photo resumes) or PURGES it (permanent ban / quota). Getting this wrong either
 * loses a photo the guest expected to survive a reopen, or lets a banned device retry forever.
 */
describe('isReversibleLock', () => {
	it('an `uploads_locked` code is reversible at any status (event closed / released)', () => {
		expect(isReversibleLock(403, 'uploads_locked')).toBe(true);
		expect(isReversibleLock(409, 'uploads_locked')).toBe(true);
	});

	it('a `forbidden` 403 (banned) is PERMANENT — purge, never resume', () => {
		expect(isReversibleLock(403, 'forbidden')).toBe(false);
	});

	it('an unidentifiable 403 (unparseable proxy/WAF/captive-portal body) is treated reversible', () => {
		// Losing a photo is the worst outcome; 403 is the reversible-lock status here.
		expect(isReversibleLock(403, undefined)).toBe(true);
		expect(isReversibleLock(403, null)).toBe(true);
		expect(isReversibleLock(403, '')).toBe(true);
	});

	it('a non-403 permanent 4xx (e.g. 413 quota) is NOT reversible unless explicitly locked', () => {
		expect(isReversibleLock(413, undefined)).toBe(false);
		expect(isReversibleLock(400, 'bad_request')).toBe(false);
		expect(isReversibleLock(413, 'uploads_locked')).toBe(true); // explicit tag still wins
	});
});

/**
 * Regression guard for the queue-rehydration mapping. The bug this locks out: `loadQueue`
 * rebuilt items from IndexedDB WITHOUT copying `lastModified`, so a reloaded item had
 * `lastModified === undefined`. addToQueue's dedup keys on (name, size, lastModified), so
 * re-selecting the same file after a reload would MISS the duplicate and queue it twice.
 */
describe('entryToQueueItem', () => {
	const base = {
		id: 'e1',
		userId: 'u1',
		fileName: 'photo.jpg',
		fileSize: 1234,
		lastModified: 1_700_000_000_000,
		mimeType: 'image/jpeg',
		status: 'pending' as const
	};

	it('carries lastModified across rehydration (dedup depends on it)', () => {
		expect(entryToQueueItem(base).lastModified).toBe(1_700_000_000_000);
	});

	it('downgrades an interrupted `uploading` entry to `pending` so it resumes', () => {
		expect(entryToQueueItem({ ...base, status: 'uploading' }).status).toBe('pending');
	});

	it('a `done` entry reports 100% progress; others start at 0', () => {
		expect(entryToQueueItem({ ...base, status: 'done' }).progress).toBe(100);
		expect(entryToQueueItem(base).progress).toBe(0);
	});

	it('defaults caption/hashtags to empty strings', () => {
		const item = entryToQueueItem(base);
		expect(item.caption).toBe('');
		expect(item.hashtags).toBe('');
	});
});

/**
 * The upload XHR had no timeout of any kind while `processQueue` held the `isProcessing`
 * latch across it. On a half-open socket neither `error` nor `abort` ever fires, so the
 * latch was pinned forever and the whole queue wedged with no recovery but a reload.
 *
 * The policy that matters: bound SILENCE, not total duration. A 500 MB video over a venue
 * uplink legitimately runs 30+ minutes while making steady progress, and a flat total cap
 * would kill exactly the uploads worth keeping.
 */
describe('shouldAbortForStall', () => {
	const now = 1_000_000;

	it('lets a long upload run as long as progress keeps arriving', () => {
		// Two hours in, but progress landed a second ago.
		expect(shouldAbortForStall(now - 1_000, now, false)).toBe(false);
	});

	it('aborts once the body stalls past the no-progress ceiling', () => {
		expect(shouldAbortForStall(now - 89_000, now, false)).toBe(false);
		expect(shouldAbortForStall(now - 91_000, now, false)).toBe(true);
	});

	it('applies the wider ceiling once the body is sent and progress goes quiet', () => {
		// Silence that would abort mid-body is normal while waiting for the response.
		expect(shouldAbortForStall(now - 91_000, now, true)).toBe(false);
		expect(shouldAbortForStall(now - 121_000, now, true)).toBe(true);
	});
});

/**
 * The watchdog measures SILENCE via `Date.now()`, but a backgrounded phone freezes the
 * interval while the clock keeps running. Without crediting the un-run window back, the first
 * tick after a screen lock reads the whole sleep as a stall and aborts a healthy upload —
 * re-sending from byte zero and spending one of five permanent auto-attempts. That is what
 * every phone does between shots at a party.
 *
 * Detecting the freeze from the tick gap (rather than from `visibilitychange`) also covers the
 * causes that fire no visibility event at all: a throttled-but-visible tab, a closed lid, an
 * occluded window.
 */
describe('suspendedSinceLastTick', () => {
	const now = 1_000_000;

	it('credits nothing for a tick that arrived on schedule', () => {
		expect(suspendedSinceLastTick(now - 5_000, now, 5_000)).toBe(0);
	});

	it('credits nothing for ordinary timer jitter or throttling', () => {
		expect(suspendedSinceLastTick(now - 6_900, now, 5_000)).toBe(0);
	});

	/**
	 * The bound that matters: however the page is throttled or frozen, a genuinely dead socket
	 * must be detected in a time a guest would tolerate — not left to `xhr.timeout` (5-60 min).
	 */
	it('detects a dead socket in bounded time even under 1-tick-per-minute throttling', () => {
		// Background tabs are throttled to ~1 tick/min WITHOUT the network stack pausing, so a
		// socket can be dead while ticks keep arriving. Unbounded crediting made this take ~18
		// minutes, holding the queue's latch the whole time.
		const CAP = MAX_SUSPEND_CREDIT_MS;
		let lastProgressAt = 0;
		let lastTickAt = 0;
		let creditSpent = 0;
		let clock = 0;
		let ticks = 0;
		while (!shouldAbortForStall(lastProgressAt, clock, false) && ticks < 100) {
			clock += 60_000; // throttled tick
			const credit = Math.min(
				suspendedSinceLastTick(lastTickAt, clock, 5_000),
				Math.max(0, CAP - creditSpent)
			);
			creditSpent += credit;
			lastProgressAt = Math.min(clock, lastProgressAt + credit);
			lastTickAt = clock;
			ticks += 1;
		}
		expect(clock).toBeLessThanOrEqual(240_000);
	});

	it('credits the whole frozen window when the interval did not run', () => {
		// Screen locked ~2 minutes: a 5s interval arriving 130s late.
		expect(suspendedSinceLastTick(now - 130_000, now, 5_000)).toBe(125_000);
	});

	it('credits nothing when the clock jumps backwards (NTP correction)', () => {
		expect(suspendedSinceLastTick(now + 60_000, now, 5_000)).toBe(0);
	});

	/**
	 * Replays the production watchdog tick faithfully — including the MAX_SUSPEND_CREDIT_MS clamp.
	 *
	 * The previous version of the test below omitted that clamp, so it asserted a property the
	 * shipped code does not have and could not fail. Anything checking the suspension behaviour
	 * must go through here.
	 */
	function runTicks(lockMs: number, tickMs = 5_000, ticks = 3): boolean {
		const CAP = MAX_SUSPEND_CREDIT_MS;
		let lastProgressAt = 0;
		let lastTickAt = 0;
		let creditSpent = 0;
		let clock = lockMs; // first tick lands when the page resumes
		for (let i = 0; i < ticks; i++) {
			const credit = Math.min(
				suspendedSinceLastTick(lastTickAt, clock, tickMs),
				Math.max(0, CAP - creditSpent)
			);
			creditSpent += credit;
			lastProgressAt = Math.min(clock, lastProgressAt + credit);
			lastTickAt = clock;
			if (shouldAbortForStall(lastProgressAt, clock, false)) return true;
			clock += tickMs;
		}
		return false;
	}

	it('a pocket-length screen lock does not abort a healthy upload', () => {
		// 60s locked, then the interval resumes: fully credited, nothing aborted.
		expect(runTicks(60_000)).toBe(false);
	});

	it('a suspension beyond the credit cap DOES abort — the cap is the deliberate bound', () => {
		// 3 minutes locked. The cap forgives 90s, so the first tick after resume survives and the
		// next one aborts. This is intended: after a lock that long the socket is almost certainly
		// reaped (iOS does so without firing `error`), and re-sending beats hanging on `xhr.timeout`
		// for 5-60 minutes while the queue's latch is held.
		//
		// It is asserted rather than merely tolerated because the cost lands on the retry budget —
		// see RETRY_BUDGET_WINDOW_MS, which is what keeps this from parking the photo for good.
		expect(runTicks(180_000)).toBe(true);
	});

	it('but a socket still silent 91s AFTER resume is aborted, never left to xhr.timeout', () => {
		// iOS reaps backgrounded sockets without firing `error`. The credit buys one fresh
		// window, not immunity — otherwise a dead upload would hang for 5-60 minutes holding
		// the queue's `processing` latch.
		const resumedAt = now - 91_000;
		expect(shouldAbortForStall(resumedAt, now, false)).toBe(true);
	});
});
