/**
 * Generic worker-pool drain over a batched fetch source. The pipeline
 * owns mechanism: concurrent workers, low-watermark refill,
 * batch-completion detection, deferred-release accumulation, and
 * end-of-drain cleanup. Plugin-specific policy (signal interpretation,
 * error swallowing, locator resolution) lives in the caller's hooks.
 */
// With per-item ack the tracker is purely a counter for refill math and
// the worker exit condition. It used to also accumulate fulfilled item
// ids for a per-batch ack call.
function createBatchTracker() {
    const batches = new Map();
    return {
        addBatch(id, total) {
            batches.set(id, { total, inFlight: 0, completed: 0 });
        },
        markInFlight(id) {
            const e = batches.get(id);
            if (e)
                e.inFlight++;
        },
        /** Decrement inFlight and increment completed. Paired with markInFlight. */
        markCompleted(id) {
            const e = batches.get(id);
            if (!e)
                return;
            e.inFlight--;
            e.completed++;
        },
        /** Drop the batch once all items completed; tracker stays small over long drains. */
        finalizeIfDone(id) {
            const e = batches.get(id);
            if (e && e.completed === e.total)
                batches.delete(id);
        },
        inFlightTotal() {
            let n = 0;
            for (const e of batches.values())
                n += e.inFlight;
            return n;
        },
        /** Items leased but not yet completed (queued + in-flight). */
        pipelineSize() {
            let n = 0;
            for (const e of batches.values())
                n += e.total - e.completed;
            return n;
        },
    };
}
/**
 * One-shot waiter. Workers `wait()` when the queue is empty; producers
 * `notifyAll()` to wake them. Each `notifyAll` swaps in a fresh waiter
 * so already-woken workers don't re-fire on the next notify.
 */
function createWaiter() {
    const make = () => {
        let signal;
        const promise = new Promise((r) => {
            signal = r;
        });
        return { promise, signal };
    };
    let current = make();
    return {
        wait: () => current.promise,
        notifyAll: () => {
            const prev = current;
            current = make();
            prev.signal();
        },
    };
}
function addToMap(m, key, value) {
    const existing = m.get(key) ?? [];
    m.set(key, [...existing, value]);
}
export async function runBatchedDrainPipeline(options) {
    const { concurrency, maxItems, leaseSize, fetchBatch, processItem, ackItem, releaseItems, } = options;
    const tracker = createBatchTracker();
    const waiter = createWaiter();
    const accumulated = [];
    const pendingReleases = new Map();
    const queue = [];
    let totalProcessed = 0;
    let aborted = false;
    let exhausted = false;
    let fetchInProgress = false;
    let fetchError = undefined;
    async function tryFetch() {
        if (fetchInProgress || exhausted || aborted)
            return;
        const remainingCap = maxItems !== undefined
            ? maxItems - totalProcessed - tracker.pipelineSize()
            : Number.POSITIVE_INFINITY;
        if (remainingCap <= 0)
            return;
        const requestedSize = Math.min(remainingCap, leaseSize);
        fetchInProgress = true;
        try {
            const result = await fetchBatch(requestedSize);
            if (result === "exhausted") {
                exhausted = true;
                return;
            }
            tracker.addBatch(result.id, result.items.length);
            for (const item of result.items) {
                queue.push({ item, batchId: result.id });
            }
        }
        catch (err) {
            fetchError = err;
            exhausted = true;
        }
        finally {
            fetchInProgress = false;
            waiter.notifyAll();
        }
    }
    async function processQueueEntry(entry) {
        tracker.markInFlight(entry.batchId);
        // markCompleted / finalizeIfDone / notifyAll all run in finally so a
        // throw from processItem or ackItem still decrements inFlight and
        // wakes peer workers. Skipping markCompleted on a processItem throw
        // wedges sibling workers: they wake on notifyAll, check
        // `inFlightTotal === 0` for exit, see the never-decremented count,
        // and block again — permanently with concurrency > 1.
        try {
            const result = await processItem(entry.item);
            accumulated.push(result.value);
            totalProcessed++;
            if (result.action === "release") {
                addToMap(pendingReleases, entry.batchId, entry.item.id);
            }
            if (result.abort)
                aborted = true;
            if (result.action === "ack") {
                await ackItem(entry.batchId, entry.item.id);
            }
        }
        finally {
            tracker.markCompleted(entry.batchId);
            tracker.finalizeIfDone(entry.batchId);
            waiter.notifyAll();
        }
    }
    async function worker() {
        while (true) {
            if (aborted)
                return;
            if (maxItems !== undefined && totalProcessed >= maxItems)
                return;
            const entry = queue.shift();
            if (entry !== undefined) {
                // Low-watermark refill: keep the pipeline at ~concurrency so a
                // freed worker has work queued. Fire-and-forget so the worker
                // doesn't block on the lease HTTP.
                if (tracker.pipelineSize() < concurrency &&
                    !fetchInProgress &&
                    !exhausted) {
                    void tryFetch();
                }
                await processQueueEntry(entry);
                continue;
            }
            if (!exhausted && !fetchInProgress)
                void tryFetch();
            if (queue.length === 0 &&
                !fetchInProgress &&
                (exhausted || aborted) &&
                tracker.inFlightTotal() === 0) {
                return;
            }
            await waiter.wait();
        }
    }
    // Initial fetch primes the queue. Errors here surface directly without
    // ever spinning up workers.
    await tryFetch();
    if (fetchError !== undefined)
        throw fetchError;
    // Cleanup runs in finally so a thrown lease-boundary ack or a captured
    // mid-drain fetchError still flushes deferred releases.
    let cleanupError = undefined;
    try {
        if (!exhausted || queue.length > 0) {
            // Catch-and-track on each worker so all settle before we surface
            // the first error. With Promise.all, a failing worker rejects
            // immediately and sibling workers continue running on the next
            // tick, firing API calls after the caller has received the
            // rejection.
            let firstWorkerError = undefined;
            const workers = Array.from({ length: concurrency }, () => worker().catch((err) => {
                if (firstWorkerError === undefined)
                    firstWorkerError = err;
            }));
            await Promise.all(workers);
            if (firstWorkerError !== undefined)
                throw firstWorkerError;
        }
        if (fetchError !== undefined)
            throw fetchError;
    }
    finally {
        // Aborted-but-unstarted items are still leased to us but were never
        // offered to the callback — release them. With per-item ack there's
        // nothing else to flush at cleanup; successful items were acked
        // immediately as they completed.
        while (queue.length > 0) {
            const entry = queue.shift();
            addToMap(pendingReleases, entry.batchId, entry.item.id);
        }
        for (const [batchId, itemIds] of pendingReleases) {
            try {
                await releaseItems(batchId, itemIds);
            }
            catch (err) {
                cleanupError ?? (cleanupError = err);
            }
        }
    }
    if (cleanupError !== undefined)
        throw cleanupError;
    return accumulated;
}
