import { definePlugin } from "../../../utils/plugin-utils";
import { DrainTriggerInboxSchema, ZapierAbortDrainSignal, ZapierReleaseTriggerMessageSignal, } from "./schemas";
import { runBatchedDrainPipeline } from "./pipeline";
import { ZapierApiError, ZapierValidationError } from "../../../types/errors";
import { ZapierSignal } from "../../../types/signals";
import { triggerInboxResolver } from "../../../resolvers";
import { resolveTriggerInboxId } from "../utils";
import { triggersDefaults } from "../shared";
import { isAbortError } from "../../../utils/abort-utils";
export { ZapierAbortDrainSignal, ZapierReleaseTriggerMessageSignal, } from "./schemas";
/**
 * Server returns 400 lease_expired when a UUIDv7 timestamp is in the
 * past. The API atomically quarantines messages that have hit
 * MESSAGE_LEASE_MAX before raising — work has been done server-side
 * already, so failing the drain on a clock-skew race would be a poor
 * UX.
 */
function isLeaseExpiredError(err) {
    if (!(err instanceof ZapierValidationError))
        return false;
    return err.errors?.some((e) => e.code === "lease_expired") ?? false;
}
/**
 * One drain pass through `runBatchedDrainPipeline`. Used directly by
 * `drainTriggerInbox` (single pass) and as the inner loop body of
 * `watchTriggerInbox` (called repeatedly with poll backoff between
 * empty passes).
 */
export async function runDrainPass(options) {
    const { sdk, inboxId, onMessage, concurrency, leaseLimit, leaseSeconds, maxMessages, releaseOnError, continueOnError, onError, signal, } = options;
    let firstFetch = options.firstFetch;
    let abortedFromCallback = false;
    let firstHandlerError = undefined;
    const outcomes = await runBatchedDrainPipeline({
        concurrency,
        maxItems: maxMessages,
        leaseSize: leaseLimit,
        fetchBatch: async (requestedSize) => {
            if (signal?.aborted)
                return "exhausted";
            let lease;
            try {
                ({ data: lease } = await sdk.leaseTriggerInboxMessages({
                    inbox: inboxId,
                    leaseLimit: requestedSize,
                    leaseSeconds,
                    signal,
                }));
            }
            catch (err) {
                // Only swallow abort-shaped failures. A real auth / validation
                // / transport error that races with cancellation must still
                // reject — checking `signal?.aborted` alone would silently hide
                // a 401 that landed in the same tick the user pressed Ctrl-C.
                if (signal?.aborted && isAbortError(err))
                    return "exhausted";
                throw err;
            }
            if (lease.results.length === 0) {
                if (firstFetch &&
                    lease.inbox_attributes.status === "initialization_failure") {
                    throw new ZapierApiError(`Trigger inbox ${inboxId} is in initialization_failure state — inspect via getTriggerInbox.`);
                }
                firstFetch = false;
                return "exhausted";
            }
            firstFetch = false;
            if (!lease.lease_id)
                return "exhausted";
            return { id: lease.lease_id, items: lease.results };
        },
        processItem: async (message) => {
            if (signal?.aborted) {
                return {
                    value: message,
                    action: "release",
                    abort: true,
                };
            }
            try {
                await onMessage(message);
                return { value: message, action: "ack" };
            }
            catch (err) {
                let action = "ignore";
                let abort = false;
                if (err instanceof ZapierAbortDrainSignal) {
                    abort = true;
                    abortedFromCallback = true;
                    if (releaseOnError)
                        action = "release";
                }
                else if (err instanceof ZapierReleaseTriggerMessageSignal) {
                    action = "release";
                }
                else if (releaseOnError) {
                    action = "release";
                }
                // Observe (signals filtered — they're control-flow, not failures).
                if (onError && !(err instanceof ZapierSignal)) {
                    try {
                        await onError(err, message);
                    }
                    catch {
                        // observer's own error is intentionally swallowed.
                    }
                }
                // Fail-fast: capture the first real error and tell the
                // pipeline to abort. continueOnError keeps it running.
                if (!continueOnError && !(err instanceof ZapierSignal)) {
                    if (firstHandlerError === undefined)
                        firstHandlerError = err;
                    abort = true;
                }
                return { value: message, action, abort };
            }
        },
        ackItem: async (leaseId, messageId) => {
            try {
                await sdk.ackTriggerInboxMessages({
                    inbox: inboxId,
                    lease: leaseId,
                    messages: [messageId],
                });
            }
            catch (err) {
                if (!isLeaseExpiredError(err))
                    throw err;
            }
        },
        releaseItems: async (leaseId, messageIds) => {
            try {
                await sdk.releaseTriggerInboxMessages({
                    inbox: inboxId,
                    lease: leaseId,
                    messages: messageIds,
                });
            }
            catch (err) {
                if (!isLeaseExpiredError(err))
                    throw err;
            }
        },
    });
    if (firstHandlerError !== undefined)
        throw firstHandlerError;
    return { abortedFromCallback, processed: outcomes.length };
}
/**
 * Validate the per-message callback. `onMessage` is required — no
 * identity default, since "lease and ack everything in this inbox"
 * is a footgun on a casual call. JS callers that skip onMessage hit
 * this guard at runtime; TS callers are rejected at the call site.
 */
export function requireOnMessage(onMessage) {
    if (typeof onMessage !== "function") {
        throw new ZapierValidationError("onMessage is required.");
    }
    return onMessage;
}
/**
 * Compute concurrency and lease size with symmetric defaulting:
 *
 *   both unset     -> concurrency=1, leaseLimit=1
 *   concurrency=N  -> concurrency=N, leaseLimit=N
 *   leaseLimit=L   -> concurrency=L, leaseLimit=L
 *   both set       -> use both as given
 *
 * The symmetry kills the "set leaseLimit alone, get prefetch
 * pathology" footgun: a leased message buffer is never larger than
 * the worker pool that can immediately work it.
 */
export function resolveConcurrencyAndLease(options) {
    const concurrency = options.concurrency ?? options.leaseLimit ?? 1;
    const leaseLimit = options.leaseLimit ?? concurrency;
    return { concurrency, leaseLimit };
}
export const drainTriggerInboxPlugin = definePlugin((sdk) => {
    async function drainTriggerInbox(options) {
        const onMessage = requireOnMessage(options.onMessage);
        const { concurrency, leaseLimit } = resolveConcurrencyAndLease(options);
        const inboxId = await resolveTriggerInboxId({
            api: sdk.context.api,
            inbox: options.inbox,
        });
        await runDrainPass({
            sdk,
            inboxId,
            onMessage,
            concurrency,
            leaseLimit,
            leaseSeconds: options.leaseSeconds,
            maxMessages: options.maxMessages,
            releaseOnError: options.releaseOnError ?? false,
            continueOnError: options.continueOnError ?? false,
            onError: options.onError,
            signal: options.signal,
            firstFetch: true,
        });
    }
    return {
        drainTriggerInbox,
        context: {
            meta: {
                drainTriggerInbox: {
                    ...triggersDefaults,
                    type: "create",
                    description: "Drain an existing trigger inbox: lease currently-available messages, process each via onMessage, return when the inbox is empty, maxMessages is reached, or the abort signal fires.",
                    itemType: "void",
                    // The doc generator's default for type="create" + itemType
                    // appends "Item" (e.g. "RecordItem"), which would render
                    // "voidItem" here. drainTriggerInbox returns Promise<void>,
                    // so we override.
                    returnType: "void",
                    inputSchema: DrainTriggerInboxSchema,
                    resolvers: {
                        inbox: triggerInboxResolver,
                    },
                    // Returns Promise<void>; the CLI ships a hand-written
                    // drain-trigger-inbox plugin that adds presentation flags
                    // (interactive, --json, etc.) around this command.
                    packages: ["sdk"],
                },
            },
        },
    };
});
