import { describe, it, expect, vi } from 'vitest';
import { CancellableLoader } from './cancellable';

/**
 * Helper: a Promise that resolves when `signal` aborts. Mirrors the
 * shape a real `fetch(url, { signal })` rejects on cancellation.
 */
function rejectsOnAbort<T = never>(signal: AbortSignal, value?: T): Promise<T> {
    return new Promise<T>((resolve, reject) => {
        if (signal.aborted) {
            reject(new DOMException('aborted', 'AbortError'));
            return;
        }
        signal.addEventListener(
            'abort',
            () => reject(new DOMException('aborted', 'AbortError')),
            { once: true }
        );
        // The caller wires its own resolve via the AbortSignal listeners
        // in the test; if a value is provided, resolve after a tick.
        if (value !== undefined) {
            // microtask so the test can call run() again first.
            queueMicrotask(() => resolve(value));
        }
    });
}

describe('CancellableLoader', () => {
    it('returns the resolved value when no successor cancels the call', async () => {
        const loader = new CancellableLoader();
        const result = await loader.run(async () => 42);
        expect(result).toBe(42);
    });

    it('aborts the predecessor signal when a successor starts', async () => {
        const loader = new CancellableLoader();

        // Predecessor: long-running, observes its own abort.
        let predecessorSignal: AbortSignal | null = null;
        const predecessor = loader.run(({ signal }) => {
            predecessorSignal = signal;
            return rejectsOnAbort(signal);
        });

        // Tick the microtask queue so the predecessor's signal is set.
        await Promise.resolve();
        expect(predecessorSignal).toBeInstanceOf(AbortSignal);
        expect(predecessorSignal!.aborted).toBe(false);

        // Successor: resolves immediately.
        const successor = loader.run(async () => 'B');
        // The predecessor's signal must have been aborted.
        expect(predecessorSignal!.aborted).toBe(true);

        // Successor wins; predecessor is swallowed.
        expect(await successor).toBe('B');
        expect(await predecessor).toBeNull();
    });

    it('suppresses a predecessor whose body resolves AFTER the successor started', async () => {
        // Race: predecessor's fetch hasn't been cancelled in time (e.g.
        // it had already started writing the response). Its resolved
        // value must still NOT leak into the loader's caller — the
        // successor owns the state.
        const loader = new CancellableLoader();

        // Predecessor: ignores its signal, resolves slowly.
        let predecessorResolve: (v: string) => void = () => {};
        const predecessor = loader.run(
            () =>
                new Promise<string>((resolve) => {
                    predecessorResolve = resolve;
                })
        );
        await Promise.resolve();

        // Successor: replaces predecessor, resolves quickly.
        const successor = loader.run(async () => 'fresh');
        expect(await successor).toBe('fresh');

        // Now resolve the (ignored) predecessor late.
        predecessorResolve('stale');
        expect(await predecessor).toBeNull();
    });

    it('returns null on AbortError without rethrowing', async () => {
        const loader = new CancellableLoader();
        // Start a never-resolving call. We can fire its abort by
        // starting a fresh run() — the AbortError swallow is what
        // we're pinning here. Crucially, DON'T await `pending` before
        // starting the successor; the await must come after.
        const pending = loader.run(({ signal }) => rejectsOnAbort<string>(signal));
        await Promise.resolve();
        const successor = loader.run(async () => 'B');
        expect(await pending).toBeNull();
        await successor;
    });

    it('propagates non-AbortError rejections', async () => {
        const loader = new CancellableLoader();
        const err = new Error('network kaboom');
        await expect(loader.run(async () => { throw err; })).rejects.toBe(err);
    });

    it('isOwnerOf is true only for the current in-flight call', async () => {
        const loader = new CancellableLoader();
        // No call in flight: every signal is non-owner.
        expect(loader.isOwnerOf(new AbortController().signal)).toBe(false);

        let signal: AbortSignal | null = null;
        const pending = loader.run(({ signal: s }) => {
            signal = s;
            return rejectsOnAbort(s);
        });
        await Promise.resolve();

        // Mid-flight: the active call's signal is the owner.
        expect(loader.isOwnerOf(signal!)).toBe(true);
        // A stranger signal is not.
        expect(loader.isOwnerOf(new AbortController().signal)).toBe(false);

        // Starting a successor flips ownership.
        const successor = loader.run(async () => 'next');
        expect(loader.isOwnerOf(signal!)).toBe(false);
        await pending;
        await successor;
    });

    it('cancel() aborts the current call and leaves the slot empty', async () => {
        const loader = new CancellableLoader();
        let signal: AbortSignal | null = null;
        const pending = loader.run(({ signal: s }) => {
            signal = s;
            return rejectsOnAbort(s);
        });
        await Promise.resolve();
        expect(signal!.aborted).toBe(false);

        loader.cancel();
        expect(signal!.aborted).toBe(true);
        expect(await pending).toBeNull();
        // After cancel, isOwnerOf is false for the old signal — slot
        // empty, no successor queued.
        expect(loader.isOwnerOf(signal!)).toBe(false);
    });

    it('rapid burst of three calls: only the last resolves', async () => {
        // The exact debounce-race shape the dashboard's search box used
        // to suffer from. Three back-to-back calls; only the third's
        // value wins; predecessors return null.
        const loader = new CancellableLoader();

        const a = loader.run(({ signal }) => rejectsOnAbort<string>(signal));
        await Promise.resolve();
        const b = loader.run(({ signal }) => rejectsOnAbort<string>(signal));
        await Promise.resolve();
        const c = loader.run(async () => 'WIN');

        const [ra, rb, rc] = await Promise.all([a, b, c]);
        expect(ra).toBeNull();
        expect(rb).toBeNull();
        expect(rc).toBe('WIN');
    });

    it('cleanup: late re-call after a no-cancellation completion finds an empty slot', async () => {
        const loader = new CancellableLoader();
        const v = await loader.run(async () => 'first');
        expect(v).toBe('first');
        // No predecessor abort fires on the second run; ownership flows.
        const v2 = await loader.run(async () => 'second');
        expect(v2).toBe('second');
    });

    it('suppresses a non-AbortError rejection from a superseded predecessor', async () => {
        // Real-world shape: a predecessor's `fetch` survives the abort
        // (timing-of-cancel race or an `fn` that ignores `signal`) and
        // then rejects with a NON-Abort error — a 5xx, a TypeError, a
        // JSON parse failure. Before this fix the rejection bubbled
        // out of `run()`, which let the caller's catch-block clear the
        // spinner / overwrite `error` for the successor that already
        // took over. After the fix, a call that no longer owns
        // `this.current` returns `null` regardless of how it died —
        // symmetric with the late-resolve suppression at line ~58.
        const loader = new CancellableLoader();
        let predecessorReject: (e: unknown) => void = () => {};
        const predecessor = loader.run(
            () =>
                new Promise<string>((_resolve, reject) => {
                    predecessorReject = reject;
                })
        );
        await Promise.resolve();
        // Successor takes ownership.
        const successor = loader.run(async () => 'fresh');
        expect(await successor).toBe('fresh');
        // NOW the predecessor's body throws — well after it lost
        // ownership. Must NOT propagate to the caller; must resolve
        // to null.
        predecessorReject(new TypeError('5xx after supersede'));
        await expect(predecessor).resolves.toBeNull();
    });

    it('still propagates a non-AbortError rejection when no successor exists', async () => {
        // Regression-guard the other side of the supersede check: a
        // standalone call that fails with a real error must still
        // throw — otherwise the caller never sees the network/JSON
        // failure on the only in-flight request.
        const loader = new CancellableLoader();
        const err = new Error('the only call kaboomed');
        await expect(loader.run(async () => { throw err; })).rejects.toBe(err);
    });

    it('swallows AbortError on a call that still owns the controller', async () => {
        // After the 0.87.24 strengthening, the supersede check returns
        // null FIRST for any call that lost ownership — so the existing
        // AbortError-suppression tests (predecessor aborted, cancel()
        // fired) hit the supersede branch and never reach the
        // AbortError branch. Without this test, removing the
        // AbortError swallow entirely would pass the suite.
        //
        // Real-world path: an underlying library (e.g. fetch backed by
        // an upstream cancellation we didn't initiate) surfaces an
        // AbortError to `fn` without any successor `run()` or
        // `cancel()` having fired. The owning call must still resolve
        // to null per invariant #3 in the class docstring.
        const loader = new CancellableLoader();
        const result = await loader.run(async () => {
            throw new DOMException('aborted', 'AbortError');
        });
        expect(result).toBeNull();
    });
});
