var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
    function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
    return new (P || (P = Promise))(function (resolve, reject) {
        function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
        function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
        function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
        step((generator = generator.apply(thisArg, _arguments || [])).next());
    });
};
import { DwnInterfaceName, DwnMethodName, Encoder, Message } from '@enbox/dwn-sdk-js';
import { DwnInterface } from './types/dwn.js';
import { isRecordsWrite } from './utils.js';
import { topologicalSort } from './sync-topological-sort.js';
/** Maximum data size (in bytes) to buffer in memory for retry. Larger payloads are re-fetched. */
const MAX_BUFFER_SIZE = 1048576; // 1 MB
/**
 * 202: message was successfully written to the remote DWN
 * 204: an initial write message was written without any data
 * 409: message was already present on the remote DWN
 *
 * When the *pushed* message is known (e.g. during push-sync), pass it as the
 * second argument so that RecordsDelete + 404 ("initial write was not found or
 * already deleted") can be detected.  The DWN's 404 reply omits `entry`, so
 * checking `reply.entry` alone is insufficient.
 */
export function syncMessageReplyIsSuccessful(reply, pushedMessage) {
    var _a, _b;
    if (reply.status.code === 202 || reply.status.code === 204 || reply.status.code === 409) {
        return true;
    }
    if (reply.status.code === 404) {
        // Check the pushed message first (always available during push-sync).
        if ((pushedMessage === null || pushedMessage === void 0 ? void 0 : pushedMessage.descriptor.interface) === DwnInterfaceName.Records &&
            (pushedMessage === null || pushedMessage === void 0 ? void 0 : pushedMessage.descriptor.method) === DwnMethodName.Delete) {
            return true;
        }
        // Fallback: check the reply entry (for callers that don't pass the pushed message).
        if (((_a = reply.entry) === null || _a === void 0 ? void 0 : _a.message.descriptor.interface) === DwnInterfaceName.Records &&
            ((_b = reply.entry) === null || _b === void 0 ? void 0 : _b.message.descriptor.method) === DwnMethodName.Delete) {
            return true;
        }
    }
    return false;
}
/**
 * 400 detail substrings that indicate a protocol dependency hasn't
 * arrived on the remote yet. These resolve on retry once the
 * dependency is pushed, so they must NOT be treated as permanent.
 */
const TRANSIENT_DEPENDENCY_PATTERNS = [
    'ComposedProtocolNotInstalled',
    'ProtocolNotFound',
];
/**
 * Classifies a failed push reply as permanent (dead-letter) or
 * transient (retry with backoff).
 *
 * Permanent: 401/403 auth errors, most 400 validation errors.
 * Transient: 5xx, network errors, and 400 protocol-dependency errors
 * that self-heal once the dependency arrives on the remote.
 */
export function isPermanentPushFailure(reply) {
    const { code, detail } = reply.status;
    if (code === 401 || code === 403) {
        return true;
    }
    if (code === 400) {
        return !TRANSIENT_DEPENDENCY_PATTERNS.some(pattern => detail === null || detail === void 0 ? void 0 : detail.includes(pattern));
    }
    return false;
}
/**
 * Helper to get the CID of a message for logging purposes.
 */
export function getMessageCid(message) {
    return __awaiter(this, void 0, void 0, function* () {
        try {
            return yield Message.getCid(message);
        }
        catch (_a) {
            return 'unknown';
        }
    });
}
/**
 * Fetches missing messages from the remote DWN and processes them on the local DWN
 * in dependency order (topological sort).
 *
 * Small data payloads (≤ 1 MB) are buffered during the initial fetch so that
 * retries can replay the data from memory instead of re-fetching from remote.
 * Large payloads are re-fetched on retry since buffering them would consume
 * too much memory.
 */
export function pullMessages(_a) {
    return __awaiter(this, arguments, void 0, function* ({ did, dwnUrl, delegateDid, protocol, messageCids, prefetched, agent, permissionsApi }) {
        // Convert prefetched diff entries into SyncMessageEntry format.
        const prefetchedEntries = [];
        if (prefetched) {
            for (const entry of prefetched) {
                if (!entry.message) {
                    continue;
                }
                const syncEntry = { message: entry.message };
                if (entry.encodedData) {
                    // Convert base64url-encoded data to a ReadableStream.
                    const bytes = Encoder.base64UrlToBytes(entry.encodedData);
                    syncEntry.bufferedData = bytes;
                    syncEntry.dataStream = new ReadableStream({
                        start(controller) {
                            controller.enqueue(bytes);
                            controller.close();
                        }
                    });
                }
                prefetchedEntries.push(syncEntry);
            }
        }
        // Step 1: Fetch remaining messages (not prefetched) from the remote.
        const fetched = messageCids.length > 0
            ? yield fetchRemoteMessages({ did, dwnUrl, delegateDid, protocol, messageCids, agent, permissionsApi })
            : [];
        // Merge prefetched entries with remotely fetched ones.
        const allFetched = [...prefetchedEntries, ...fetched];
        // Step 2: Build dependency graph and topological sort.
        const sorted = topologicalSort(allFetched);
        // Step 3: Buffer small data streams so they can be replayed on retry.
        yield bufferSmallStreams(sorted);
        // Step 4: Process messages in dependency order with multi-pass retry.
        const MAX_RETRY_PASSES = 3;
        let pending = sorted;
        for (let pass = 0; pass <= MAX_RETRY_PASSES && pending.length > 0; pass++) {
            const failed = [];
            for (const entry of pending) {
                // Create a fresh ReadableStream from the buffer if available (stream is single-use).
                const dataStream = entry.bufferedData
                    ? new ReadableStream({ start(c) { c.enqueue(entry.bufferedData); c.close(); } })
                    : entry.dataStream;
                const pullReply = yield agent.dwn.processRawMessage(did, entry.message, { dataStream });
                if (!syncMessageReplyIsSuccessful(pullReply)) {
                    failed.push(entry);
                }
            }
            if (failed.length > 0) {
                // Separate entries that have a buffer (can retry locally) from those
                // that need a fresh fetch (large payloads whose stream was consumed).
                const needsRefetch = [];
                const canRetry = [];
                for (const entry of failed) {
                    if (entry.bufferedData || !entry.dataStream) {
                        // Has a buffer or has no data — can retry without re-fetching.
                        canRetry.push(entry);
                    }
                    else {
                        // Large payload whose stream was consumed — must re-fetch.
                        const cid = yield getMessageCid(entry.message);
                        needsRefetch.push(cid);
                    }
                }
                // Re-fetch only the large-payload messages that we couldn't buffer.
                if (needsRefetch.length > 0) {
                    const reFetched = yield fetchRemoteMessages({ did, dwnUrl, delegateDid, protocol, messageCids: needsRefetch, agent, permissionsApi });
                    canRetry.push(...reFetched);
                }
                pending = topologicalSort(canRetry);
            }
            else {
                pending = [];
            }
        }
        // Return CIDs that permanently failed after all retry passes.
        const permanentlyFailed = [];
        for (const entry of pending) {
            const cid = yield getMessageCid(entry.message);
            permanentlyFailed.push(cid);
        }
        return permanentlyFailed;
    });
}
/**
 * Buffers small data streams into `Uint8Array` so they can be replayed on retry.
 * Streams larger than `MAX_BUFFER_SIZE` are left as-is (will be re-fetched on retry).
 */
function bufferSmallStreams(entries) {
    return __awaiter(this, void 0, void 0, function* () {
        for (const entry of entries) {
            if (!entry.dataStream) {
                continue;
            }
            // Read the stream into memory. If it exceeds the threshold, stop and
            // leave the entry without a buffer (it will be re-fetched on retry).
            const chunks = [];
            let totalSize = 0;
            let exceededThreshold = false;
            const reader = entry.dataStream.getReader();
            try {
                for (;;) {
                    const { done, value } = yield reader.read();
                    if (done) {
                        break;
                    }
                    totalSize += value.byteLength;
                    if (totalSize > MAX_BUFFER_SIZE) {
                        exceededThreshold = true;
                        break;
                    }
                    chunks.push(value);
                }
            }
            finally {
                reader.releaseLock();
            }
            if (exceededThreshold) {
                // Stream exceeded the buffer threshold. Leave dataStream consumed —
                // the retry path will re-fetch from remote.
                entry.dataStream = undefined;
                continue;
            }
            // Combine chunks into a single Uint8Array buffer.
            const buffer = new Uint8Array(totalSize);
            let offset = 0;
            for (const chunk of chunks) {
                buffer.set(chunk, offset);
                offset += chunk.byteLength;
            }
            entry.bufferedData = buffer;
            // Create a fresh ReadableStream from the buffer for the first processing attempt.
            entry.dataStream = new ReadableStream({
                start(controller) {
                    controller.enqueue(buffer);
                    controller.close();
                }
            });
        }
    });
}
/**
 * Fetches messages from a remote DWN by their CIDs using MessagesRead.
 */
export function fetchRemoteMessages(_a) {
    return __awaiter(this, arguments, void 0, function* ({ did, dwnUrl, delegateDid, protocol, messageCids, agent, permissionsApi }) {
        const results = [];
        let permissionGrantId;
        if (delegateDid) {
            const messagesReadGrant = yield permissionsApi.getPermissionForRequest({
                connectedDid: did,
                messageType: DwnInterface.MessagesRead,
                delegateDid,
                protocol,
                cached: true
            });
            permissionGrantId = messagesReadGrant.grant.id;
        }
        // Fetch messages in parallel with bounded concurrency.  Keep this low
        // to avoid bursting through the remote server's rate limits during sync.
        const CONCURRENCY = 4;
        let cursor = 0;
        while (cursor < messageCids.length) {
            const batch = messageCids.slice(cursor, cursor + CONCURRENCY);
            cursor += CONCURRENCY;
            const batchResults = yield Promise.all(batch.map((messageCid) => __awaiter(this, void 0, void 0, function* () {
                var _a, _b;
                const messagesRead = yield agent.processDwnRequest({
                    store: false,
                    author: did,
                    target: did,
                    messageType: DwnInterface.MessagesRead,
                    granteeDid: delegateDid,
                    messageParams: { messageCid, permissionGrantId }
                });
                let reply;
                try {
                    reply = (yield agent.rpc.sendDwnRequest({
                        dwnUrl,
                        targetDid: did,
                        message: messagesRead.message,
                    }));
                }
                catch (error) {
                    console.error(`SyncEngineLevel: pull - failed to read ${messageCid} from ${dwnUrl}:`, (_a = error.message) !== null && _a !== void 0 ? _a : error);
                    return undefined;
                }
                if (reply.status.code !== 200 || !((_b = reply.entry) === null || _b === void 0 ? void 0 : _b.message)) {
                    return undefined;
                }
                const replyEntry = reply.entry;
                let dataStream;
                if (isRecordsWrite(replyEntry) && replyEntry.data) {
                    dataStream = replyEntry.data;
                }
                return { message: replyEntry.message, dataStream };
            })));
            for (const result of batchResults) {
                if (result) {
                    results.push(result);
                }
            }
        }
        return results;
    });
}
/**
 * Reads missing messages from the local DWN and pushes them to the remote DWN.
 * Messages are fetched first, then sorted in dependency order (topological sort)
 * so that initial writes come before updates, and ProtocolsConfigures come before
 * records that reference those protocols.
 *
 * Returns a {@link PushResult} with per-CID outcome tracking instead of throwing
 * on the first failure. Callers use this to advance the push checkpoint
 * incrementally — only up to the highest contiguous success.
 */
export function pushMessages(_a) {
    return __awaiter(this, arguments, void 0, function* ({ did, dwnUrl, delegateDid, protocol, messageCids, agent, permissionsApi }) {
        var _b, _c, _d;
        const succeeded = [];
        const failed = [];
        const permanentlyFailed = [];
        // Step 1: Fetch all local messages (streams are pull-based, not yet consumed).
        const fetched = [];
        for (const messageCid of messageCids) {
            const dwnMessage = yield getLocalMessage({ author: did, messageCid, delegateDid, protocol, agent, permissionsApi });
            if (dwnMessage) {
                fetched.push(dwnMessage);
            }
            else {
                // Message could not be fetched locally — mark as failed.
                failed.push(messageCid);
            }
        }
        // Step 2: Sort in dependency order using topological sort.
        const sorted = topologicalSort(fetched);
        // Step 3: Buffer data streams so they survive fetch retries.
        // ReadableStream is single-use — if sendDwnRequest's underlying fetch
        // retries the HTTP request, the original stream is already consumed.
        yield bufferSmallStreams(sorted);
        // Step 4: Push messages in dependency order.
        for (const entry of sorted) {
            const cid = yield getMessageCid(entry.message);
            // Create a fresh stream from the buffer for each send attempt.
            const data = entry.bufferedData
                ? new ReadableStream({ start(c) { c.enqueue(entry.bufferedData); c.close(); } })
                : entry.dataStream;
            try {
                const reply = yield agent.rpc.sendDwnRequest({
                    dwnUrl,
                    targetDid: did,
                    data,
                    message: entry.message
                });
                if (syncMessageReplyIsSuccessful(reply, entry.message)) {
                    succeeded.push(cid);
                }
                else if (isPermanentPushFailure(reply)) {
                    // Permanent failures (400/401/403) will never succeed — do NOT retry.
                    // These include protocol violations (RecordLimitExceeded), auth errors,
                    // and schema validation failures.
                    if (reply.status.code === 400 && ((_b = reply.status.detail) === null || _b === void 0 ? void 0 : _b.includes('record limit'))) {
                        // Expected for singleton convergence in multi-device scenarios:
                        // one device created a singleton record, this device has a
                        // different one, and the remote rejects the duplicate.
                        console.debug(`SyncEngineLevel: singleton already exists on remote, skipping push for ${cid}`);
                    }
                    else {
                        console.warn(`SyncEngineLevel: push permanently failed for ${cid}: ${reply.status.code} ${reply.status.detail}`);
                    }
                    permanentlyFailed.push({ cid, statusCode: reply.status.code, detail: (_c = reply.status.detail) !== null && _c !== void 0 ? _c : '' });
                }
                else {
                    // Transient failures (5xx, etc.) — worth retrying.
                    console.error(`SyncEngineLevel: push failed for ${cid}: ${reply.status.code} ${reply.status.detail}`);
                    failed.push(cid);
                }
            }
            catch (error) {
                // Network errors — transient, worth retrying.
                console.error(`SyncEngineLevel: push error for ${cid}: ${(_d = error.message) !== null && _d !== void 0 ? _d : error}`);
                failed.push(cid);
            }
        }
        return { succeeded, failed, permanentlyFailed };
    });
}
/**
 * Reads a message from the local DWN by its CID using MessagesRead.
 */
export function getLocalMessage(_a) {
    return __awaiter(this, arguments, void 0, function* ({ author, delegateDid, protocol, messageCid, agent, permissionsApi }) {
        let permissionGrantId;
        if (delegateDid) {
            const messagesReadGrant = yield permissionsApi.getPermissionForRequest({
                connectedDid: author,
                messageType: DwnInterface.MessagesRead,
                delegateDid,
                protocol,
                cached: true
            });
            permissionGrantId = messagesReadGrant.grant.id;
        }
        const { reply } = yield agent.dwn.processRequest({
            author,
            target: author,
            messageType: DwnInterface.MessagesRead,
            granteeDid: delegateDid,
            messageParams: { messageCid, permissionGrantId }
        });
        if (reply.status.code !== 200 || !reply.entry) {
            return undefined;
        }
        const messageEntry = reply.entry;
        const result = {
            message: messageEntry.message
        };
        if (isRecordsWrite(messageEntry) && messageEntry.data) {
            result.dataStream = messageEntry.data;
        }
        return result;
    });
}
//# sourceMappingURL=sync-messages.js.map