/**
 * A loader that cancels its in-flight predecessor on each call.
 *
 * Background — 0.87.9 added per-loader AbortControllers to the admin
 * analysis dashboard so a debounced search-burst couldn't race itself
 * (slow first response overwriting a faster second). The 0.87.16
 * rereview noted that the contract (predecessor-abort + result-
 * ownership check + spinner guard) was only inlined into a single
 * Svelte component and untested.
 *
 * Lift the four invariants into a reusable, testable helper:
 *
 *   1. **Predecessor abort.** Every `run()` aborts whatever came before
 *      via the prior AbortController.
 *   2. **Late-result suppression.** A predecessor whose fetch finishes
 *      AFTER the successor's run() started must not surface its value
 *      OR its rejection — `run()` returns `null` ("superseded") on
 *      both paths so the caller never overwrites the successor's
 *      state with stale data or with an error from the dead call.
 *   3. **AbortError swallowed.** Aborts are normal cancellation, not
 *      errors to toast; `run()` returns `null` rather than rethrowing.
 *   4. **Ownership-aware finally.** Whichever call still owns the
 *      controller clears `this.current` on completion; a later call
 *      that overwrote it leaves the marker alone (or the spinner would
 *      flicker on the older call's abort).
 *
 * Usage:
 *   const loader = new CancellableLoader<Result>();
 *   const result = await loader.run(({ signal }) =>
 *     fetch(url, { signal }).then(r => r.json())
 *   );
 *   if (result === null) return;  // superseded or aborted
 *   // …apply result…
 *
 * Note the `null` outcome is INTENTIONAL — the caller MUST decide
 * whether to flip its spinner off (yes if no other call is in flight,
 * no if a successor is). The `isOwnerOf` helper makes that decision
 * explicit:
 *   if (loader.isOwnerOf(signal)) loading = false;
 */
export class CancellableLoader {
    private current: AbortController | null = null;

    /**
     * Run `fn` with a fresh AbortController, aborting any prior in-
     * flight call. Returns the resolved value, or `null` when this
     * call was superseded mid-flight OR fn rejected with AbortError.
     * Other errors propagate.
     */
    async run<T>(fn: (ctx: { signal: AbortSignal }) => Promise<T>): Promise<T | null> {
        this.current?.abort();
        const ctrl = new AbortController();
        this.current = ctrl;
        try {
            const value = await fn({ signal: ctrl.signal });
            // Successor may have replaced us between the await above
            // and here. Drop the result silently — the caller should
            // not write stale data over the successor's state.
            if (this.current !== ctrl) return null;
            return value;
        } catch (e) {
            // A superseded call returns null regardless of HOW it
            // died — Abort, a 5xx, a JSON parse failure, anything.
            // Otherwise the caller's catch-block would clear the
            // successor's spinner / overwrite its `error`. Symmetric
            // with the late-resolve suppression above.
            if (this.current !== ctrl) return null;
            // AbortError on the still-owning call is normal
            // cancellation (e.g. consumer called cancel()); swallow.
            if ((e as { name?: string } | null | undefined)?.name === 'AbortError') {
                return null;
            }
            throw e;
        } finally {
            // Only clear the marker if this call still owns it. A
            // predecessor that lost ownership leaves the successor's
            // controller untouched.
            if (this.current === ctrl) this.current = null;
        }
    }

    /**
     * True iff the given signal belongs to the current in-flight call.
     * Use from the caller's finally{} to decide whether to flip
     * loading=false. Reading `signal.aborted` directly works for the
     * abort case but not for the superseded-without-abort case (only
     * `null` from `run()` covers both).
     */
    isOwnerOf(signal: AbortSignal): boolean {
        return this.current?.signal === signal;
    }

    /**
     * Abort the current in-flight call without queueing a successor.
     * Used from onDestroy so a late response can't write into a dead
     * page.
     */
    cancel(): void {
        this.current?.abort();
        this.current = null;
    }
}
