// runConsolidation: the engine entry point. Pure function; no IO; no clock reads; no
// randomness. Every impurity is injected via ConsolidationOptions.
//
// Design choice pinned here so future contributors do not "fix" it:
//   updatesProposed is ALWAYS the empty array in v1. Every merge / supersession is routed
//   through a ReviewItem carrying a ProposedAction; the caller (#211 Memory Center UI or a
//   workflow) materializes the actual MemorySupersession envelope after explicit review. The
//   updatesProposed slot is reserved for a future model-assisted body-summarisation pass
//   (issue #212 or follow-up). This preserves the Epic #204 invariant: "consolidation never
//   mutates accepted memories without preserving provenance and audit history".
//
// Cancellation: cancellationSignal is polled BEFORE each cluster is inspected (once per
// cluster), so the cost is bounded by the cluster count and partial results survive the
// cancel. The signal is polled once at the very start as well, so a caller that already knows
// it wants to abort can short-circuit without inspecting any cluster.
import { validateMemoryRecord, } from "@oscharko-dev/keiko-contracts/memory";
import { JACCARD_DEFAULT, MAX_AGE_MS_DEFAULT, MAX_CLUSTERS_PER_RUN_DEFAULT, STALE_CONFIDENCE_DEFAULT, } from "./_constants.js";
import { compareEdges, compareReviewItems } from "./_ordering.js";
import { CONFLICT_OVERLAP_THRESHOLD, detectConflicts, findConflictPairs } from "./conflicts.js";
import { scanDuplicateClusters } from "./dedupe.js";
import { findStaleMemories } from "./stale.js";
const ELIGIBLE_STATUS = "accepted";
function isFiniteInRange(n, lo, hi) {
    return Number.isFinite(n) && n >= lo && n <= hi;
}
function isFiniteNonNegative(n) {
    return Number.isFinite(n) && n >= 0;
}
function neverCancel() {
    return false;
}
function validateNumericKnobs(knobs) {
    if (!isFiniteInRange(knobs.jaccardThreshold, 0, 1))
        return false;
    if (!isFiniteInRange(knobs.staleConfidenceThreshold, 0, 1))
        return false;
    if (!isFiniteNonNegative(knobs.maxAgeMs))
        return false;
    if (!isFiniteNonNegative(knobs.maxClustersPerRun))
        return false;
    return Number.isInteger(knobs.maxClustersPerRun);
}
function resolveOptions(options) {
    const knobs = {
        jaccardThreshold: options.jaccardThreshold ?? JACCARD_DEFAULT,
        staleConfidenceThreshold: options.staleConfidenceThreshold ?? STALE_CONFIDENCE_DEFAULT,
        maxAgeMs: options.maxAgeMs ?? MAX_AGE_MS_DEFAULT,
        maxClustersPerRun: options.maxClustersPerRun ?? MAX_CLUSTERS_PER_RUN_DEFAULT,
    };
    if (!validateNumericKnobs(knobs))
        return null;
    return {
        nowMs: options.nowMs,
        newEdgeId: options.newEdgeId,
        newReviewItemId: options.newReviewItemId,
        ...knobs,
        cancellationSignal: options.cancellationSignal ?? neverCancel,
    };
}
function emptyResult(state) {
    return {
        state,
        edgesProposed: [],
        updatesProposed: [],
        staleFlags: [],
        reviewItems: [],
        clustersInspected: 0,
        elapsedMs: 0,
    };
}
function eligibleMemories(memories) {
    const accepted = [];
    for (const memory of memories) {
        const validated = validateMemoryRecord(memory);
        if (!validated.ok)
            return null;
        if (validated.value.status === ELIGIBLE_STATUS) {
            accepted.push(validated.value);
        }
    }
    return accepted;
}
function processTwoMemberCluster(cluster, resolved) {
    const conflictItems = detectConflicts([cluster], {
        nowMs: resolved.nowMs,
        newReviewItemId: resolved.newReviewItemId,
    });
    if (conflictItems.length > 0) {
        return { edge: null, reviewItem: conflictItems[0] ?? null };
    }
    const older = cluster.members[0];
    const newer = cluster.members[1];
    if (older === undefined || newer === undefined) {
        return { edge: null, reviewItem: null };
    }
    const edge = {
        id: resolved.newEdgeId(),
        schemaVersion: "1",
        fromMemoryId: older.id,
        toMemoryId: newer.id,
        kind: "derived-from",
        createdAt: resolved.nowMs,
        provenanceSummary: "consolidation: near-duplicate",
    };
    return { edge, reviewItem: null };
}
function processMultiWayCluster(cluster, resolved) {
    const items = detectConflicts([cluster], {
        nowMs: resolved.nowMs,
        newReviewItemId: resolved.newReviewItemId,
    });
    return { edge: null, reviewItem: items[0] ?? null };
}
function processCluster(cluster, resolved) {
    if (cluster.members.length > 2)
        return processMultiWayCluster(cluster, resolved);
    if (cluster.members.length === 2)
        return processTwoMemberCluster(cluster, resolved);
    return { edge: null, reviewItem: null };
}
function consumeClusters(clusters, resolved) {
    const edges = [];
    const reviewItems = [];
    let clustersInspected = 0;
    const limit = Math.min(clusters.length, resolved.maxClustersPerRun);
    for (let i = 0; i < limit; i += 1) {
        if (resolved.cancellationSignal()) {
            return { state: "canceled", edges, reviewItems, clustersInspected };
        }
        const cluster = clusters[i];
        if (cluster === undefined)
            continue;
        const effects = processCluster(cluster, resolved);
        if (effects.edge !== null)
            edges.push(effects.edge);
        if (effects.reviewItem !== null)
            reviewItems.push(effects.reviewItem);
        clustersInspected += 1;
    }
    return { state: "completed", edges, reviewItems, clustersInspected };
}
function collectStaleFlags(records, resolved) {
    return findStaleMemories(records, {
        nowMs: resolved.nowMs,
        staleConfidenceThreshold: resolved.staleConfidenceThreshold,
        maxAgeMs: resolved.maxAgeMs,
    });
}
function collectConflictPairs(records, clusters, resolved, consumedState, clusterScanCanceled) {
    // If the cluster sweep was canceled, do NOT extend work into the conflict-pair sweep —
    // honour the cancellation boundary so partial results stay partial.
    if (consumedState === "canceled" || clusterScanCanceled)
        return [];
    return findConflictPairs(records, clusters, CONFLICT_OVERLAP_THRESHOLD, {
        nowMs: resolved.nowMs,
        newReviewItemId: resolved.newReviewItemId,
        cancellationSignal: resolved.cancellationSignal,
    });
}
// Public entry. Same input + same options => byte-identical result.
export function runConsolidation(memories, options) {
    const resolved = resolveOptions(options);
    if (resolved === null)
        return emptyResult("failed");
    if (resolved.cancellationSignal())
        return emptyResult("canceled");
    const eligible = eligibleMemories(memories);
    if (eligible === null)
        return emptyResult("failed");
    if (eligible.length === 0 || resolved.maxClustersPerRun === 0)
        return emptyResult("skipped");
    const scanned = scanDuplicateClusters(eligible, resolved.jaccardThreshold, {
        cancellationSignal: resolved.cancellationSignal,
    });
    if (scanned.canceled && scanned.clusters.length === 0)
        return emptyResult("canceled");
    const clusters = scanned.clusters;
    const consumed = consumeClusters(clusters, resolved);
    const conflictPairs = collectConflictPairs(eligible, clusters, resolved, consumed.state, scanned.canceled);
    const staleFlags = collectStaleFlags(eligible, resolved);
    const mergedReviewItems = [...consumed.reviewItems, ...conflictPairs].sort(compareReviewItems);
    return {
        state: scanned.canceled ? "canceled" : consumed.state,
        edgesProposed: [...consumed.edges].sort(compareEdges),
        updatesProposed: [],
        staleFlags,
        reviewItems: mergedReviewItems,
        clustersInspected: consumed.clustersInspected + conflictPairs.length,
        elapsedMs: 0,
    };
}
