"use strict";
/**
 * Async job runner for long-running submission actions (0.2.0).
 *
 * Why this exists: the previous synchronous handlers (`registerForDustGeneration`,
 * `sendNight`, `deployContract`, ...) awaited multi-minute-to-hours work
 * inline. That kept the OData request's `req.tx` open for the duration,
 * holding a pool connection and blocking unrelated DB ops (notably the
 * periodic wallet-sync-state save, which deadlocked on the first SELECT). The
 * fix: insert a `BackgroundJobs` row on the request's tx, return
 * `{ jobId, status: 'pending' }` in milliseconds, and detach the actual work
 * to `cds.spawn`. Clients poll `getJobStatus(jobId)` for results.
 *
 * Tx isolation: each row mutation inside the spawn uses an explicit
 * `db.tx(async tx => tx.run(...))` short transaction, so the spawn's
 * top-level tx never acquires a connection. Net effect: the spawn callback
 * holds no pool resources while the long work is in flight — only the
 * per-mutation short txs (milliseconds each) consume connections.
 *
 * Concurrency: a per-`kind` in-process semaphore caps how many jobs of a
 * given kind can run at once. Heavy kinds (full ZK proof + chain submit)
 * default to 4, light kinds (sync-bound only) to 16. Tunable via
 * `cds.requires.nightgate.jobs.concurrency.{heavy,light}`.
 *
 * Idempotency: an optional `idempotencyKey` lets the caller dedupe retries.
 * If a non-failed row exists for the same `(sessionId, kind, idempotencyKey)`,
 * `startJob` returns that row's jobId and result (if any) without starting a
 * new spawn. Failed rows do NOT block a retry — that's the intended path for
 * flaky-network recovery.
 *
 * Error classification: failures are run through `classifySubmissionError`
 * (shared with TransactionSubmitter), so the same Substrate / SDK error
 * codes (`1014`, `1016`, `TxFailed`, `WalletSigningNotAvailable`, ...) appear
 * in the job row as the synchronous path used to expose them.
 */
var __importDefault = (this && this.__importDefault) || function (mod) {
    return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.startJob = startJob;
exports.getJobById = getJobById;
exports.recoverInterruptedJobs = recoverInterruptedJobs;
exports.__resetForTests = __resetForTests;
const cds_1 = __importDefault(require("@sap/cds"));
const crypto_1 = __importDefault(require("crypto"));
const midnight_1 = require("#cds-models/midnight");
const TransactionSubmitter_1 = require("./TransactionSubmitter");
const nightgate_config_1 = require("../utils/nightgate-config");
const { SELECT, INSERT, UPDATE } = cds_1.default.ql;
// ---- Concurrency caps ------------------------------------------------------
const DEFAULT_CONCURRENCY = { heavy: 4, light: 16 };
/**
 * Kinds where each job involves full ZK proof generation through the proof
 * server. 4 concurrent jobs is enough to saturate one proof-server instance;
 * going wider just queues inside the proof server anyway.
 *
 * "light" kinds (anything not in this set) are sync-bound — they wait on the
 * wallet's `waitForSyncedState` but don't drive heavy compute on this server.
 */
const HEAVY_KINDS = new Set([
    'registerForDustGeneration',
    'deregisterFromDustGeneration',
    'sendNight',
    'shieldFunds',
    'unshieldFunds',
    'deployContract',
    'submitContractCall',
    'anchorDocument',
    'issuePredicateAttestation'
]);
class Semaphore {
    max;
    inFlight = 0;
    waiters = [];
    constructor(max) {
        this.max = max;
    }
    async acquire() {
        if (this.inFlight < this.max) {
            this.inFlight++;
            return;
        }
        await new Promise(resolve => this.waiters.push(resolve));
        // Slot transferred directly from release(); inFlight already counted.
    }
    release() {
        const next = this.waiters.shift();
        if (next) {
            next();
        }
        else {
            this.inFlight = Math.max(0, this.inFlight - 1);
        }
    }
}
const semaphores = new Map();
function getSemaphore(kind) {
    const cached = semaphores.get(kind);
    if (cached)
        return cached;
    const userCaps = (cds_1.default.env.requires?.nightgate?.jobs?.concurrency || {});
    const max = HEAVY_KINDS.has(kind)
        ? (typeof userCaps.heavy === 'number' ? userCaps.heavy : DEFAULT_CONCURRENCY.heavy)
        : (typeof userCaps.light === 'number' ? userCaps.light : DEFAULT_CONCURRENCY.light);
    const sem = new Semaphore(max);
    semaphores.set(kind, sem);
    return sem;
}
// ---- Network resolution (memoized) -----------------------------------------
let cachedNetwork;
function getNetwork() {
    if (!cachedNetwork) {
        try {
            cachedNetwork = (0, nightgate_config_1.resolveNightgateRuntimeConfig)((0, nightgate_config_1.getNightgatePluginConfig)()).network;
        }
        catch {
            cachedNetwork = 'preprod';
        }
    }
    return cachedNetwork;
}
// ---- Serialization ---------------------------------------------------------
/**
 * JSON.stringify with a BigInt → string replacer. The Midnight SDK returns
 * NIGHT/DUST amounts as decimal strings already, but defensive: any handler
 * that returns a bigint by accident still serializes cleanly into `result`.
 */
function safeStringify(value) {
    return JSON.stringify(value, (_, v) => typeof v === 'bigint' ? v.toString() : v);
}
// ---- Public API ------------------------------------------------------------
/**
 * Insert a `BackgroundJobs` row on the caller's tx and detach the long work
 * to `cds.spawn`. Returns the `jobId` immediately so the OData handler can
 * respond in milliseconds.
 */
async function startJob(args) {
    const { kind, sessionId, idempotencyKey, request, work } = args;
    if (!kind)
        throw new Error('startJob: kind is required');
    if (!sessionId)
        throw new Error('startJob: sessionId is required');
    if (typeof work !== 'function')
        throw new Error('startJob: work must be a function');
    const db = await cds_1.default.connect.to('db');
    // Idempotency dedupe — runs on the caller's tx so this read sees rows
    // committed by prior requests but not in-flight ones in the same tx.
    if (idempotencyKey) {
        const existing = await db.run(SELECT.one.from(midnight_1.BackgroundJobs)
            .where({ sessionId, kind, idempotencyKey })
            .orderBy('createdAt desc'));
        if (existing && existing.status !== 'failed') {
            return {
                jobId: existing.ID,
                status: existing.status === 'succeeded' ? 'succeeded' : 'pending',
                result: existing.status === 'succeeded' && existing.result
                    ? JSON.parse(existing.result)
                    : undefined
            };
        }
    }
    const jobId = crypto_1.default.randomUUID();
    await db.run(INSERT.into(midnight_1.BackgroundJobs).entries({
        ID: jobId,
        kind,
        sessionId,
        status: 'pending',
        idempotencyKey: idempotencyKey || null,
        request: safeStringify(request)
    }));
    // Detach. The semaphore caps concurrent jobs of this kind. Each mutation
    // inside the spawn uses its own short tx (see markRunning/Succeeded/Failed),
    // so the spawn's top-level tx never acquires a pool connection.
    const semaphore = getSemaphore(kind);
    // First arg is an empty options object — `cds.spawn` typings require it,
    // and the default behaviour (one-shot via setImmediate, no tenant, no
    // interval) is exactly what we want.
    cds_1.default.spawn({}, async () => {
        await semaphore.acquire();
        try {
            await markRunning(jobId);
            // CRITICAL: run `work()` with the ambient cds.context CLEARED.
            //
            // @cap-js/sqlite uses a single pooled connection (pool.max=1). If
            // `work()` ran inside the spawn's transaction, the first db.run it
            // makes (e.g. loadSyncState's SELECT during a facade build) would
            // open a transaction on that ONE connection and hold it for the
            // entire multi-minute-to-hours worker round-trip — starving every
            // other query (periodic wallet-state saves, getJobStatus polls) at
            // pool.acquire(). Clearing the context makes each db.run inside
            // `work()` its own short acquire→commit→release cycle, so the
            // connection stays free during the long awaits.
            const result = await runWithoutAmbientTx(work);
            await markSucceeded(jobId, result);
        }
        catch (err) {
            const classification = (0, TransactionSubmitter_1.classifySubmissionError)(err, getNetwork());
            await markFailed(jobId, classification);
        }
        finally {
            semaphore.release();
        }
    });
    return { jobId, status: 'pending' };
}
/**
 * Run `fn` with `cds.context` cleared, so any `db.run(...)` it performs gets a
 * fresh short-lived transaction instead of joining a long-lived ambient one.
 * See the call site in `startJob` for why this matters (pool.max=1).
 *
 * `cds._with(undefined, fn)` runs `fn` under an AsyncLocalStorage store of
 * `undefined`, which propagates through `fn`'s entire async chain.
 */
function runWithoutAmbientTx(fn) {
    return cds_1.default._with(undefined, fn);
}
/**
 * Look up a job by ID. Returns null when not found.
 */
async function getJobById(jobId) {
    if (!jobId)
        return null;
    const db = await cds_1.default.connect.to('db');
    const row = await db.run(SELECT.one.from(midnight_1.BackgroundJobs).where({ ID: jobId }));
    return row || null;
}
/**
 * Flip every still-`pending` or `running` row to `failed:PROCESS_RESTART`.
 * Called once at plugin init (`cds.on('served')`) so jobs interrupted by a
 * crash don't linger forever. Idempotent.
 *
 * Returns the count of rows updated for logging.
 */
async function recoverInterruptedJobs() {
    const db = await cds_1.default.connect.to('db');
    const stuck = await db.run(SELECT.from(midnight_1.BackgroundJobs)
        .columns('ID')
        .where({ status: { in: ['pending', 'running'] } }));
    const count = Array.isArray(stuck) ? stuck.length : 0;
    if (count === 0)
        return 0;
    await db.run(UPDATE.entity(midnight_1.BackgroundJobs)
        .set({
        status: 'failed',
        errorCode: 'PROCESS_RESTART',
        errorMessage: 'Job was interrupted by a server restart.',
        finishedAt: new Date().toISOString()
    })
        .where({ status: { in: ['pending', 'running'] } }));
    return count;
}
// ---- Internal --------------------------------------------------------------
async function markRunning(jobId) {
    const db = await cds_1.default.connect.to('db');
    await db.tx(async (tx) => {
        await tx.run(UPDATE.entity(midnight_1.BackgroundJobs)
            .set({ status: 'running', startedAt: new Date().toISOString() })
            .where({ ID: jobId }));
    });
}
async function markSucceeded(jobId, result) {
    const db = await cds_1.default.connect.to('db');
    await db.tx(async (tx) => {
        await tx.run(UPDATE.entity(midnight_1.BackgroundJobs)
            .set({
            status: 'succeeded',
            result: safeStringify(result),
            finishedAt: new Date().toISOString()
        })
            .where({ ID: jobId }));
    });
}
async function markFailed(jobId, classification) {
    const db = await cds_1.default.connect.to('db');
    await db.tx(async (tx) => {
        await tx.run(UPDATE.entity(midnight_1.BackgroundJobs)
            .set({
            status: 'failed',
            errorCode: classification.code.slice(0, 64),
            errorMessage: classification.message.slice(0, 4000),
            finishedAt: new Date().toISOString()
        })
            .where({ ID: jobId }));
    });
}
/** Test-only reset of the in-memory caches. */
function __resetForTests() {
    semaphores.clear();
    cachedNetwork = undefined;
}
