/**
 * syncRequestResponse — request/response protocol for WARP sync.
 *
 * Handles creation and processing of SyncRequest/SyncResponse messages,
 * plus the client-side application of received patches.
 *
 * @module domain/services/sync/syncRequestResponse
 * @see SyncProtocol — WARP sync spec Section 11 (Network Sync)
 * @see JoinReducer — CRDT merge implementation
 */
import nullLogger from "../../utils/nullLogger.js";
import { assertOpsCompatible, EDGE_PROPERTY_PATCH_SCHEMA_VERSION } from "../codec/WarpMessageCodec.js";
import { applyFast, cloneState, isKnownRawOp } from "../JoinReducer.js";
import SchemaUnsupportedError from "../../errors/SchemaUnsupportedError.js";
import SyncError from "../../errors/SyncError.js";
import { cloneFrontier, updateFrontier } from "../Frontier.js";
import { computeSyncDelta } from "./syncDelta.js";
import { normalizePatch, loadPatchRange } from "./syncPatchLoader.js";
import { appendPatchForPage, estimateResponsePayloadBytes, logResponseMetrics, normalizeObservedLatencyMs, normalizeSyncPageRequest, } from "./SyncResponsePaging.js";
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
/**
 * Converts a frontier Map to a plain object for JSON serialization.
 */
function frontierToObject(map) {
    const obj = {};
    for (const [writerId, sha] of map) {
        obj[writerId] = sha;
    }
    return obj;
}
/**
 * Converts a frontier plain object back to a Map.
 */
function objectToFrontier(obj) {
    return new Map(Object.entries(obj));
}
// ---------------------------------------------------------------------------
// Exports
// ---------------------------------------------------------------------------
/**
 * Creates a sync request message.
 *
 * Converts the frontier Map to a plain object for JSON serialization.
 * The resulting message can be sent over HTTP, WebSocket, or any other
 * transport that supports JSON.
 *
 * @param frontier - Local frontier mapping writer IDs to their latest known patch SHAs
 * @returns A sync request message ready for serialization
 *
 * @example
 * const frontier = new Map([['w1', 'sha-a'], ['w2', 'sha-b']]);
 * const request = createSyncRequest(frontier);
 * // { type: 'sync-request', frontier: { w1: 'sha-a', w2: 'sha-b' } }
 */
export function createSyncRequest(frontier, options = {}) {
    const page = options.page !== undefined ? normalizeSyncPageRequest(options.page) : null;
    return {
        type: 'sync-request',
        frontier: frontierToObject(frontier),
        ...(options.page !== undefined
            ? { page: { maxPatches: page?.maxPatches ?? options.page.maxPatches, cursor: options.page.cursor ?? null } }
            : {}),
    };
}
/**
 * Processes a sync request and returns patches the requester needs.
 *
 * This is the server-side handler for sync requests. It:
 * 1. Converts the incoming frontier from plain object to Map
 * 2. Computes what the requester is missing (using computeSyncDelta)
 * 3. Loads the missing patches from storage
 * 4. Returns a response with the local frontier and patches
 *
 * **Error handling**: If divergence is detected for a writer (their chain
 * has forked), that writer is silently skipped. The requester will not
 * receive patches for that writer and may need to handle this separately.
 *
 * @param request - Incoming sync request containing the requester's frontier
 * @param localFrontier - Local frontier (what this node has)
 * @param persistence - Git persistence layer for loading patches
 * @param graphName - Graph name for error messages and logging
 * @param options - Options including optional patchJournal and logger
 * @returns Response containing local frontier and patches
 * @throws {Error} If patch loading fails for reasons other than divergence
 *
 * @example
 * // Server-side sync handler
 * app.post('/sync', async (req, res) => {
 *   const request = req.body;
 *   const response = await processSyncRequest(request, localFrontier, persistence, 'events');
 *   res.json(response);
 * });
 */
export async function processSyncRequest(request, localFrontier, persistence, graphName, { patchJournal, logger, observedLatencyMs } = {}) {
    const log = logger ?? nullLogger;
    const remoteFrontier = objectToFrontier(request.frontier);
    const pageRequest = normalizeSyncPageRequest(request.page);
    const latencyMs = normalizeObservedLatencyMs(observedLatencyMs);
    // Compute what the requester needs
    const delta = computeSyncDelta(remoteFrontier, localFrontier);
    // Load patches that the requester needs (from local to requester)
    const patches = [];
    const skippedWriters = [];
    let seenPatches = 0;
    let hasMore = false;
    let cursor = null;
    const writerRanges = [...delta.needFromRemote.entries()]
        .sort(([left], [right]) => left.localeCompare(right));
    for (const [writerId, range] of writerRanges) {
        if (hasMore) {
            break;
        }
        try {
            // Pre-check ancestry to avoid expensive chain walk (B107 / S3 fix).
            // If the persistence layer provides isAncestor, use it to detect
            // divergence early without walking the full commit chain.
            const persistenceWithIsAncestor = persistence;
            const hasIsAncestor = typeof persistenceWithIsAncestor.isAncestor === 'function';
            if (range.from !== null && range.from !== undefined && range.from.length > 0 && hasIsAncestor) {
                const isAnc = await persistenceWithIsAncestor.isAncestor(range.from, range.to);
                if (!isAnc) {
                    const entry = {
                        writerId,
                        reason: 'E_SYNC_DIVERGENCE',
                        localSha: range.to,
                        remoteSha: range.from,
                    };
                    skippedWriters.push(entry);
                    log.warn('Sync divergence detected — skipping writer', {
                        code: 'E_SYNC_DIVERGENCE',
                        graphName,
                        ...entry,
                    });
                    continue;
                }
            }
            // Prefer streaming scan when patchJournal supports it; fall back to legacy array load.
            if (patchJournal !== undefined && patchJournal !== null && typeof patchJournal.scanPatchRange === 'function') {
                const stream = patchJournal.scanPatchRange(writerId, range.from, range.to);
                for await (const entry of stream) {
                    const control = appendPatchForPage({
                        patches,
                        entry: { writerId, sha: entry.sha, patch: entry.patch },
                        page: pageRequest,
                        seenPatches,
                    });
                    seenPatches = control.seenPatches;
                    hasMore = control.hasMore;
                    cursor = control.cursor;
                    if (hasMore) {
                        break;
                    }
                }
            }
            else {
                const writerPatches = await loadPatchRange(persistence, graphName, writerId, range.from, range.to, patchJournal !== undefined ? { patchJournal } : {});
                for (const { patch, sha } of writerPatches) {
                    const control = appendPatchForPage({
                        patches,
                        entry: { writerId, sha, patch },
                        page: pageRequest,
                        seenPatches,
                    });
                    seenPatches = control.seenPatches;
                    hasMore = control.hasMore;
                    cursor = control.cursor;
                    if (hasMore) {
                        break;
                    }
                }
            }
        }
        catch (err) {
            // If we detect divergence, log and skip this writer (B65).
            // The requester will not receive patches for this writer.
            const isSyncDivergence = (err instanceof SyncError && err.code === 'E_SYNC_DIVERGENCE') ||
                (err instanceof Error && err.message?.includes('Divergence detected'));
            if (isSyncDivergence) {
                const entry = {
                    writerId,
                    reason: 'E_SYNC_DIVERGENCE',
                    localSha: range.to,
                    remoteSha: range.from ?? '',
                };
                skippedWriters.push(entry);
                log.warn('Sync divergence detected — skipping writer', {
                    code: 'E_SYNC_DIVERGENCE',
                    graphName,
                    ...entry,
                });
                continue;
            }
            throw err;
        }
    }
    const frontier = frontierToObject(localFrontier);
    const page = {
        maxPatches: pageRequest.maxPatches,
        cursor: hasMore ? cursor : null,
        hasMore,
        returnedPatches: patches.length,
    };
    const metrics = {
        patchCount: patches.length,
        skippedWriterCount: skippedWriters.length,
        estimatedPayloadBytes: estimateResponsePayloadBytes({
            frontier,
            patches,
            skippedWriters,
            page,
        }),
        latencyMs,
    };
    logResponseMetrics({
        logger: log,
        graphName,
        page,
        metrics,
    });
    return {
        type: 'sync-response',
        frontier,
        patches,
        skippedWriters,
        page,
        metrics,
    };
}
/**
 * Applies a sync response to local state.
 *
 * This is the client-side handler for sync responses. It:
 * 1. Clones state and frontier to avoid mutating inputs
 * 2. Groups patches by writer for correct ordering
 * 3. Validates each patch against known op types (schema compatibility)
 * 4. Applies patches using CRDT merge semantics (JoinReducer.join)
 * 5. Updates the frontier with new writer tips
 *
 * **CRDT convergence**: Patches can be applied in any order and the final
 * state will be identical. However, applying in chronological order (as
 * provided) is slightly more efficient.
 *
 * **Schema validation**: Patches are checked against EDGE_PROPERTY_PATCH_SCHEMA_VERSION before apply.
 * If a patch contains op types we don't understand (from a newer schema),
 * assertOpsCompatible throws to prevent silent data loss.
 *
 * **Immutability**: This function does not modify the input state or frontier.
 * It returns new objects.
 *
 * @param response - Incoming sync response containing patches
 * @param state - Current CRDT state
 * @param frontier - Current frontier mapping writer IDs to SHAs
 * @returns New state, frontier, and count of applied patches
 * @throws {SchemaUnsupportedError} If a patch contains unsupported op types
 *
 * @example
 * const response = await fetch('/sync', { ... }).then(r => r.json());
 * const result = applySyncResponse(response, currentState, currentFrontier);
 * console.log(`Applied ${result.applied} patches`);
 * currentState = result.state;
 * currentFrontier = result.frontier;
 */
export function applySyncResponse(response, state, frontier) {
    // Clone state and frontier to avoid mutating inputs
    const newState = cloneState(state);
    const newFrontier = cloneFrontier(frontier);
    let applied = 0;
    // Patches arrive pre-grouped by writer from the sync response. This
    // re-grouping is defensive — it handles edge cases where patches from
    // multiple writers arrive interleaved (e.g., from a relay that merges
    // streams).
    const patchesByWriter = new Map();
    for (const { writerId, sha, patch } of response.patches) {
        if (!patchesByWriter.has(writerId)) {
            patchesByWriter.set(writerId, []);
        }
        const writerList = patchesByWriter.get(writerId);
        writerList.push({ sha, patch });
    }
    // Apply patches for each writer
    for (const [writerId, writerPatches] of patchesByWriter) {
        // Patches should already be in chronological order from processSyncRequest
        for (const { sha, patch } of writerPatches) {
            // Normalize patch context (in case it came from network serialization)
            const normalizedPatch = normalizePatch(patch);
            // Guard: reject patches with genuinely unknown op types (B106 / C2 fix). // nosemgrep: ts-no-unknown-outside-adapters -- 0025B
            // Uses isKnownRawOp to accept only the 6 wire-format types. Canonical-only
            // types (NodePropSet, EdgePropSet) must never appear on the wire before
            // ADR 2 capability cutover — reject them here to fail closed.
            for (const op of normalizedPatch.ops) {
                if (!isKnownRawOp(op)) {
                    throw new SchemaUnsupportedError(`Patch ${sha} contains unknown op type: ${op.type}`);
                }
            }
            // Guard: reject patches exceeding our maximum supported schema version.
            assertOpsCompatible(normalizedPatch.ops, EDGE_PROPERTY_PATCH_SCHEMA_VERSION);
            // Apply patch to state (applyFast mutates in-place; return value is the same reference)
            applyFast(newState, normalizedPatch, sha);
            applied++;
        }
        // Update frontier to the last SHA for this writer
        if (writerPatches.length > 0) {
            const lastPatch = writerPatches[writerPatches.length - 1];
            if (lastPatch !== undefined) {
                updateFrontier(newFrontier, writerId, lastPatch.sha);
            }
        }
    }
    return {
        state: newState,
        frontier: newFrontier,
        applied,
    };
}
/**
 * Creates an empty sync response (used when no patches are needed).
 *
 * This is a convenience function for responding to sync requests when
 * the requester is already up-to-date (or ahead). The response includes
 * the local frontier but no patches.
 *
 * @param frontier - Local frontier to include in the response
 * @returns A sync response with empty patches array
 *
 * @example
 * if (!syncNeeded(remoteFrontier, localFrontier)) {
 *   return createEmptySyncResponse(localFrontier);
 * }
 */
export function createEmptySyncResponse(frontier) {
    return {
        type: 'sync-response',
        frontier: frontierToObject(frontier),
        patches: [],
    };
}
