// ST-BME: 提取编排控制器（纯函数）
// 通过 runtime 依赖注入，避免直接访问 index.js 模块级状态。

import { debugLog } from "../runtime/debug-logging.js";
import { getPendingPersistenceTargetRevision } from "../sync/legacy-persistence-repair.js";
import {
  buildDialogueFloorMap,
  normalizeDialogueFloorRange,
} from "./chat-history.js";

let nativePersistDeltaInstallPromise = null;

function toSafeFloor(value, fallback = null) {
  if (value == null || value === "") return fallback;
  const numeric = Number(value);
  return Number.isFinite(numeric) ? Math.floor(numeric) : fallback;
}

function clampIntValue(value, fallback = 0, min = 0, max = 9999) {
  const numeric = Number(value);
  if (!Number.isFinite(numeric)) return fallback;
  return Math.min(max, Math.max(min, Math.trunc(numeric)));
}

function uniqueStringList(values = []) {
  const seen = new Set();
  const result = [];
  for (const value of Array.isArray(values) ? values : []) {
    const normalized = String(value || "").trim();
    if (!normalized || seen.has(normalized)) continue;
    seen.add(normalized);
    result.push(normalized);
  }
  return result;
}

function resolveBulkAutoConsolidationWindow(settings = {}, options = {}) {
  return {
    everyBatches: clampIntValue(
      options?.bulkAutoConsolidationEveryBatches ??
        settings?.bulkAutoConsolidationEveryBatches,
      20,
      2,
      200,
    ),
    minNewNodes: clampIntValue(
      options?.bulkAutoConsolidationMinNewNodes ??
        settings?.bulkAutoConsolidationMinNewNodes,
      60,
      4,
      500,
    ),
  };
}

function isAssistantFloor(runtime, chat, index) {
  if (!Array.isArray(chat)) return false;
  const message = chat[index];
  if (!message) return false;
  if (typeof runtime?.isAssistantChatMessage === "function") {
    return Boolean(
      runtime.isAssistantChatMessage(message, {
        index,
        chat,
      }),
    );
  }
  return Boolean(message) && !message.is_user && !message.is_system;
}

function getAssistantTurnsFallback(runtime, chat = []) {
  if (!Array.isArray(chat)) return [];
  const assistantTurns = [];
  for (let index = 0; index < chat.length; index++) {
    if (!isAssistantFloor(runtime, chat, index)) continue;
    if (!String(chat[index]?.mes ?? "").trim()) continue;
    assistantTurns.push(index);
  }
  return assistantTurns;
}

function normalizeSmartTriggerDecision(decision = null) {
  if (!decision || typeof decision !== "object") {
    return { triggered: false, score: 0, reasons: [] };
  }
  return {
    triggered: decision.triggered === true,
    score: Number.isFinite(Number(decision.score)) ? Number(decision.score) : 0,
    reasons: Array.isArray(decision.reasons)
      ? decision.reasons.map((item) => String(item || "")).filter(Boolean)
      : [],
  };
}

function normalizePersistenceStateRecord(persistResult = null) {
  const accepted = persistResult?.accepted === true;
  const queued = persistResult?.queued === true;
  const blocked = persistResult?.blocked === true;
  let outcome = "failed";
  if (accepted && String(persistResult?.storageTier || "") === "indexeddb") {
    outcome = "saved";
  } else if (accepted) {
    outcome = "fallback";
  } else if (queued) {
    outcome = "queued";
  } else if (blocked) {
    outcome = "blocked";
  }

  return {
    outcome,
    accepted,
    storageTier: String(persistResult?.storageTier || "none"),
    reason: String(persistResult?.reason || ""),
    revision: Number.isFinite(Number(persistResult?.revision))
      ? Number(persistResult.revision)
      : 0,
    saveMode: String(persistResult?.saveMode || ""),
    recoverable: persistResult?.recoverable === true,
    saved: persistResult?.saved === true,
    queued,
    blocked,
    attempted: true,
  };
}

function hasMeaningfulPersistenceRecord(persistence = null) {
  if (!persistence || typeof persistence !== "object") return false;
  if (persistence.attempted === true) return true;
  const revision = Number(persistence?.revision || 0);
  if (Number.isFinite(revision) && revision > 0) return true;
  if (String(persistence?.storageTier || "").trim() && persistence.storageTier !== "none") {
    return true;
  }
  if (String(persistence?.saveMode || "").trim()) return true;
  if (String(persistence?.reason || "").trim()) return true;
  return (
    persistence.saved === true ||
    persistence.queued === true ||
    persistence.blocked === true
  );
}

function cloneSerializable(value, fallback = null) {
  try {
    return JSON.parse(JSON.stringify(value));
  } catch {
    return fallback;
  }
}

function readNow() {
  if (typeof performance === "object" && typeof performance.now === "function") {
    return performance.now();
  }
  return Date.now();
}

async function ensureNativePersistDeltaHookInstalled() {
  if (typeof globalThis.__stBmeNativeBuildPersistDelta === "function") {
    return {
      loaded: true,
      source: "global-hook",
    };
  }
  if (!nativePersistDeltaInstallPromise) {
    nativePersistDeltaInstallPromise = import("../vendor/wasm/stbme_core.js")
      .then((module) => module?.installNativePersistDeltaHook?.())
      .catch((error) => {
        nativePersistDeltaInstallPromise = null;
        throw error;
      });
  }
  return await nativePersistDeltaInstallPromise;
}

function setExtractionProgressStatus(
  runtime,
  text,
  meta = "",
  level = "info",
  options = {},
) {
  if (typeof runtime?.setLastExtractionStatus === "function") {
    runtime.setLastExtractionStatus(text, meta, level, options);
    return;
  }
  if (options?.syncRuntime !== false && typeof runtime?.setRuntimeStatus === "function") {
    runtime.setRuntimeStatus(text, meta, level);
  }
}

function resolveLatestAssistantDialogueFloor(chat = []) {
  const map = buildDialogueFloorMap(chat);
  const assistantDialogueFloors = Array.isArray(map.assistantDialogueFloors)
    ? map.assistantDialogueFloors
    : [];
  return assistantDialogueFloors.length > 0
    ? assistantDialogueFloors[assistantDialogueFloors.length - 1]
    : null;
}

function resolveRerunDialogueTask(chat = [], options = {}) {
  const hasStart = Number.isFinite(Number(options?.startFloor));
  const hasEnd = Number.isFinite(Number(options?.endFloor));
  if (!hasStart && !hasEnd) {
    const latestAssistantDialogueFloor = resolveLatestAssistantDialogueFloor(chat);
    if (!Number.isFinite(Number(latestAssistantDialogueFloor))) {
      return {
        valid: false,
        reason: "当前没有可重提的 AI 回复",
      };
    }
    const normalizedRange = normalizeDialogueFloorRange(
      chat,
      latestAssistantDialogueFloor,
      latestAssistantDialogueFloor,
    );
    return {
      ...normalizedRange,
      mode: "current",
      requestedStartFloor: null,
      requestedEndFloor: null,
    };
  }

  const normalizedRange = normalizeDialogueFloorRange(
    chat,
    options?.startFloor,
    options?.endFloor,
  );
  return {
    ...normalizedRange,
    mode: "range",
    requestedStartFloor: hasStart ? Number(options.startFloor) : null,
    requestedEndFloor: hasEnd ? Number(options.endFloor) : null,
  };
}

function buildRerunVisibilityWarning(runtime, chat = [], rerunTask = null) {
  const settings =
    typeof runtime?.getSettings === "function" ? runtime.getSettings() || {} : {};
  const requestedStart = Number.isFinite(Number(rerunTask?.requestedStartFloor))
    ? Number(rerunTask.requestedStartFloor)
    : null;
  const requestedEnd = Number.isFinite(Number(rerunTask?.requestedEndFloor))
    ? Number(rerunTask.requestedEndFloor)
    : null;
  const latestDialogueFloor = Number.isFinite(Number(rerunTask?.latestDialogueFloor))
    ? Number(rerunTask.latestDialogueFloor)
    : null;
  const chatLength = Array.isArray(chat) ? chat.length : 0;
  const keepLastN = Math.max(
    0,
    Math.trunc(Number(settings?.hideOldMessagesKeepLastN ?? 0) || 0),
  );
  const renderLastN = Math.max(
    0,
    Math.trunc(Number(settings?.hideOldMessagesRenderLimit ?? 0) || 0),
  );
  const hiddenActive = settings?.hideOldMessagesEnabled === true && keepLastN > 0;
  const renderLimitActive =
    settings?.enabled !== false &&
    settings?.hideOldMessagesRenderLimitEnabled === true &&
    renderLastN > 0;
  const warnings = [];

  if (
    latestDialogueFloor != null &&
    ((requestedStart != null && requestedStart > latestDialogueFloor) ||
      (requestedEnd != null && requestedEnd > latestDialogueFloor))
  ) {
    warnings.push(
      `当前可见聊天最高楼层只有 ${latestDialogueFloor}，请求范围已被夹到可见范围内`,
    );
  }

  if (hiddenActive) {
    const hiddenBoundary = Math.max(-1, chatLength - keepLastN - 1);
    if (
      hiddenBoundary >= 0 &&
      (requestedStart == null || requestedStart <= hiddenBoundary || requestedEnd == null)
    ) {
      warnings.push(
        `当前启用了旧楼层隐藏（保留最近 ${keepLastN} 层），如需重提隐藏范围，请先在面板中清除/解除消息隐藏`,
      );
    }
  }

  if (renderLimitActive) {
    warnings.push(
      `当前启用了聊天区渲染楼层限制（最近 ${renderLastN} 层），如需重提更早楼层，请先关闭该限制并重新加载聊天`,
    );
  }

  const uniqueWarnings = Array.from(new Set(warnings.filter(Boolean)));
  return uniqueWarnings.length > 0 ? uniqueWarnings.join("；") : "";
}

function resolveAssistantTargetRange(chat = [], dialogueRange = [-1, -1]) {
  const map = buildDialogueFloorMap(chat);
  const assistantDialogueFloors = Array.isArray(map.assistantDialogueFloors)
    ? map.assistantDialogueFloors
    : [];
  const assistantChatIndices = Array.isArray(map.assistantChatIndices)
    ? map.assistantChatIndices
    : [];
  const [startFloor, endFloor] = Array.isArray(dialogueRange)
    ? dialogueRange
    : [-1, -1];
  const targeted = [];

  for (let index = 0; index < assistantDialogueFloors.length; index += 1) {
    const floor = Number(assistantDialogueFloors[index]);
    const chatIndex = Number(assistantChatIndices[index]);
    if (!Number.isFinite(floor) || !Number.isFinite(chatIndex)) continue;
    if (floor < startFloor || floor > endFloor) continue;
    targeted.push({
      dialogueFloor: floor,
      chatIndex,
    });
  }

  return {
    map,
    targeted,
    startAssistantChatIndex: targeted.length > 0 ? targeted[0].chatIndex : null,
    endAssistantChatIndex:
      targeted.length > 0 ? targeted[targeted.length - 1].chatIndex : null,
    latestAssistantDialogueFloor:
      assistantDialogueFloors.length > 0
        ? assistantDialogueFloors[assistantDialogueFloors.length - 1]
        : null,
  };
}

function buildRerunFallbackInfo(chat = [], targetDialogueRange = [-1, -1]) {
  const assistantRange = resolveAssistantTargetRange(chat, targetDialogueRange);
  if (!assistantRange.targeted.length) {
    return {
      valid: false,
      reason: "目标范围内没有可重提的 AI 回复",
      fallbackToLatest: false,
      ...assistantRange,
    };
  }

  const latestTargetedDialogueFloor = Number(
    assistantRange.targeted[assistantRange.targeted.length - 1]?.dialogueFloor,
  );
  const latestAssistantDialogueFloor = Number(
    assistantRange.latestAssistantDialogueFloor,
  );
  const fallbackToLatest =
    Number.isFinite(latestTargetedDialogueFloor) &&
    Number.isFinite(latestAssistantDialogueFloor) &&
    latestTargetedDialogueFloor < latestAssistantDialogueFloor;

  return {
    valid: true,
    reason: fallbackToLatest
      ? "当前图谱对中段范围重提的后缀保留证据不足，已退化为从起始楼层到最新重提"
      : "",
    fallbackToLatest,
    ...assistantRange,
  };
}

async function buildCommittedBatchPersistSnapshot(
  runtime,
  {
    graph = null,
    chat = [],
    settings = null,
    beforeSnapshot = null,
    processedRange = [null, null],
    postProcessArtifacts = [],
    vectorHashesInserted = [],
    extractionCountBefore = 0,
  } = {},
) {
  if (!graph || typeof runtime?.cloneGraphSnapshot !== "function") {
    return {
      persistGraphSnapshot: null,
      committedBatchJournalEntry: null,
      afterSnapshot: null,
      committedAfterSnapshot: null,
      postProcessArtifacts: Array.isArray(postProcessArtifacts)
        ? [...postProcessArtifacts]
        : [],
    };
  }

  const range = Array.isArray(processedRange) ? processedRange : [null, null];
  const rangeStart = Number.isFinite(Number(range[0])) ? Number(range[0]) : null;
  const rangeEnd = Number.isFinite(Number(range[1])) ? Number(range[1]) : null;
  const runtimeSettings =
    settings && typeof settings === "object" && !Array.isArray(settings)
      ? settings
      : runtime?.getSettings?.() || {};
  const dialogueMap = buildDialogueFloorMap(chat);
  const processedDialogueRange = [
    Number.isFinite(Number(rangeStart))
      ? dialogueMap.chatIndexToFloor[rangeStart]
      : null,
    Number.isFinite(Number(rangeEnd))
      ? dialogueMap.chatIndexToFloor[rangeEnd]
      : null,
  ];
  const sourceChatIndexRange = [
    Number.isFinite(Number(rangeStart))
      ? Math.max(
          0,
          Number(rangeStart) -
            Math.max(
              0,
              Number(runtimeSettings?.extractContextTurns) || 0,
            ) *
              2,
        )
      : null,
    rangeEnd,
  ];
  const afterSnapshot = graph;
  const effectiveArtifacts = Array.isArray(postProcessArtifacts)
    ? [...postProcessArtifacts]
    : [];
  const committedGraphSnapshot = runtime.cloneGraphSnapshot(graph);

  if (typeof runtime.applyProcessedHistorySnapshotToGraph === "function") {
    runtime.applyProcessedHistorySnapshotToGraph(
      committedGraphSnapshot,
      chat,
      rangeEnd,
    );
  } else {
    if (
      !committedGraphSnapshot.historyState ||
      typeof committedGraphSnapshot.historyState !== "object" ||
      Array.isArray(committedGraphSnapshot.historyState)
    ) {
      committedGraphSnapshot.historyState = {};
    }
    committedGraphSnapshot.historyState.lastProcessedAssistantFloor =
      Number.isFinite(rangeEnd) ? Math.floor(rangeEnd) : -1;
    committedGraphSnapshot.lastProcessedSeq =
      Number.isFinite(rangeEnd) ? Math.floor(rangeEnd) : -1;
  }

  const committedBatchJournalEntry =
    typeof runtime.createBatchJournalEntry === "function"
      ? runtime.createBatchJournalEntry(beforeSnapshot, afterSnapshot, {
          processedRange: [rangeStart, rangeEnd],
          processedDialogueRange,
          sourceChatIndexRange,
          postProcessArtifacts: effectiveArtifacts,
          vectorHashesInserted: Array.isArray(vectorHashesInserted)
            ? vectorHashesInserted
            : [],
          extractionCountBefore,
        })
      : null;

  if (
    committedBatchJournalEntry &&
    typeof runtime.appendBatchJournal === "function"
  ) {
    runtime.appendBatchJournal(
      committedGraphSnapshot,
      cloneSerializable(committedBatchJournalEntry, committedBatchJournalEntry),
    );
  }

  let persistDelta = null;
  let persistSnapshot = null;
  const shouldUseNativePersistDelta =
    runtimeSettings?.persistUseNativeDelta === true &&
    runtimeSettings?.graphNativeForceDisable !== true;
  const nativeFailOpen = runtimeSettings?.nativeEngineFailOpen !== false;
  if (typeof runtime.buildSnapshotFromGraph === "function") {
    persistSnapshot = runtime.buildSnapshotFromGraph(committedGraphSnapshot, {
      chatId:
        committedGraphSnapshot?.historyState?.chatId ||
        beforeSnapshot?.meta?.chatId ||
        "",
      revision: Number(beforeSnapshot?.meta?.revision || 0) + 1,
      baseSnapshot: beforeSnapshot || undefined,
      lastModified: Date.now(),
    });
  }
  if (typeof runtime.buildPersistDelta === "function") {
    if (shouldUseNativePersistDelta) {
      const preloadStartedAt = readNow();
      try {
        await ensureNativePersistDeltaHookInstalled();
      } catch (error) {
        if (!nativeFailOpen) {
          throw error;
        }
        runtime?.console?.warn?.(
          "[ST-BME] extraction native persist delta preload failed, fallback to JS delta:",
          {
            error: error?.message || String(error),
            preloadMs: readNow() - preloadStartedAt,
          },
        );
      }
    }

    persistDelta = runtime.buildPersistDelta(
      beforeSnapshot,
      persistSnapshot || committedGraphSnapshot,
      {
      useNativeDelta: shouldUseNativePersistDelta,
      nativeFailOpen,
      persistNativeDeltaThresholdRecords:
        runtimeSettings?.persistNativeDeltaThresholdRecords,
      persistNativeDeltaThresholdStructuralDelta:
        runtimeSettings?.persistNativeDeltaThresholdStructuralDelta,
      persistNativeDeltaThresholdSerializedChars:
        runtimeSettings?.persistNativeDeltaThresholdSerializedChars,
      persistNativeDeltaBridgeMode: runtimeSettings?.persistNativeDeltaBridgeMode,
      },
    );
  }

  return {
    persistDelta,
    persistSnapshot,
    persistGraphSnapshot: committedGraphSnapshot,
    committedBatchJournalEntry,
    afterSnapshot,
    committedAfterSnapshot: committedGraphSnapshot,
    postProcessArtifacts: effectiveArtifacts,
  };
}

function isPersistenceRevisionAccepted(runtime, persistence = null) {
  const persistenceRevision = Number(persistence?.revision || 0);
  if (!Number.isFinite(persistenceRevision) || persistenceRevision <= 0) {
    return false;
  }
  const lastAcceptedRevision = Number(
    runtime?.getGraphPersistenceState?.()?.lastAcceptedRevision || 0,
  );
  return Number.isFinite(lastAcceptedRevision) && lastAcceptedRevision >= persistenceRevision;
}

function isPendingPersistenceRevisionAccepted(runtime, persistence = null) {
  const persistenceState = runtime?.getGraphPersistenceState?.() || {};
  const targetRevision = getPendingPersistenceTargetRevision({
    batchPersistence: persistence,
    persistenceState,
  });
  if (targetRevision <= 0) return false;
  const lastAcceptedRevision = Number(persistenceState.lastAcceptedRevision || 0);
  return Number.isFinite(lastAcceptedRevision) && lastAcceptedRevision >= targetRevision;
}

function hasRecoverablePendingPersistence(runtime) {
  const persistenceState = runtime?.getGraphPersistenceState?.() || {};
  if (persistenceState.pendingPersist !== true) {
    return false;
  }
  const recoverableTier = String(
    persistenceState.lastRecoverableStorageTier || "none",
  ).trim();
  if (recoverableTier === "metadata-full") {
    return true;
  }
  if (recoverableTier !== "shadow") {
    return false;
  }
  const queuedRevision = Number(persistenceState.queuedPersistRevision || 0);
  const shadowRevision = Number(persistenceState.shadowSnapshotRevision || 0);
  if (!Number.isFinite(queuedRevision) || queuedRevision <= 0) {
    return true;
  }
  return Number.isFinite(shadowRevision) && shadowRevision >= queuedRevision;
}

function getPendingPersistenceGateInfo(runtime) {
  const graph = runtime?.getCurrentGraph?.();
  const batchStatus = graph?.historyState?.lastBatchStatus || null;
  const persistence = batchStatus?.persistence || null;
  const pendingPersist = runtime?.getGraphPersistenceState?.()?.pendingPersist === true;
  const accepted = pendingPersist
    ? isPendingPersistenceRevisionAccepted(runtime, persistence)
    : isPersistenceRevisionAccepted(runtime, persistence);
  const attempted = hasMeaningfulPersistenceRecord(persistence);
  if (pendingPersist && attempted && accepted) {
    return null;
  }
  if (!pendingPersist && (!attempted || accepted)) {
    return null;
  }

  return {
    pendingPersist,
    accepted,
    attempted,
    outcome: String(persistence?.outcome || ""),
    reason: String(persistence?.reason || ""),
    revision: Number.isFinite(Number(persistence?.revision))
      ? Number(persistence.revision)
      : 0,
  };
}

async function maybeRetryPendingPersistence(runtime, reason = "pending-persist-retry") {
  const gate = getPendingPersistenceGateInfo(runtime);
  if (!gate) {
    return gate;
  }

  if (typeof runtime?.retryPendingGraphPersist !== "function") {
    return hasRecoverablePendingPersistence(runtime) ? null : gate;
  }

  try {
    const retryResult = await runtime.retryPendingGraphPersist({ reason });
    if (retryResult?.accepted === true) {
      return null;
    }
  } catch (error) {
    runtime?.console?.warn?.("[ST-BME] pending persistence retry failed", error);
  }

  const nextGate = getPendingPersistenceGateInfo(runtime);
  if (nextGate && hasRecoverablePendingPersistence(runtime)) {
    return null;
  }
  return nextGate;
}

function formatPendingPersistenceGateMessage(runtime, operationLabel = "当前提取") {
  const gate = getPendingPersistenceGateInfo(runtime);
  if (!gate) return "";
  const reason = gate.reason ? ` · ${gate.reason}` : "";
  const revision =
    Number.isFinite(Number(gate.revision)) && Number(gate.revision) > 0
      ? ` · rev ${Number(gate.revision)}`
      : "";
  return `${operationLabel}已暂停：上一批持久化尚未确认，请先使用“重试持久化”或“重新探测图谱”${revision}${reason}`;
}

export function resolveAutoExtractionPlanController(
  runtime,
  {
    chat = null,
    settings = null,
    lastProcessedAssistantFloor = null,
    lockedEndFloor = null,
  } = {},
) {
  const resolvedChat = Array.isArray(chat)
    ? chat
    : runtime?.getContext?.()?.chat || [];
  const resolvedSettings =
    settings && typeof settings === "object"
      ? settings
      : runtime?.getSettings?.() || {};
  const safeLastProcessedAssistantFloor = toSafeFloor(
    lastProcessedAssistantFloor,
    toSafeFloor(runtime?.getLastProcessedAssistantFloor?.(), -1),
  );
  const safeLockedEndFloor = toSafeFloor(lockedEndFloor, null);
  const strategy =
    resolvedSettings.extractAutoDelayLatestAssistant === true
      ? "lag-one-assistant"
      : "normal";
  const extractEvery = clampIntValue(
    resolvedSettings.extractEvery,
    1,
    1,
    50,
  );
  if (resolvedSettings.enabled === false) {
    return {
      strategy,
      chat: resolvedChat,
      settings: resolvedSettings,
      lastProcessedAssistantFloor: safeLastProcessedAssistantFloor,
      lockedEndFloor: safeLockedEndFloor,
      extractEvery,
      pendingAssistantTurns: [],
      candidateAssistantTurns: [],
      eligibleAssistantTurns: [],
      eligibleEndFloor: null,
      waitingForNextAssistant: false,
      smartTriggerDecision: { triggered: false, score: 0, reasons: [] },
      meetsExtractEvery: false,
      canRun: false,
      batchAssistantTurns: [],
      plannedBatchEndFloor: null,
      startIdx: null,
      endIdx: null,
      reason: "plugin-disabled",
    };
  }
  if (resolvedSettings.extractAutoEnabled === false) {
    return {
      strategy,
      chat: resolvedChat,
      settings: resolvedSettings,
      lastProcessedAssistantFloor: safeLastProcessedAssistantFloor,
      lockedEndFloor: safeLockedEndFloor,
      extractEvery,
      pendingAssistantTurns: [],
      candidateAssistantTurns: [],
      eligibleAssistantTurns: [],
      eligibleEndFloor: null,
      waitingForNextAssistant: false,
      smartTriggerDecision: { triggered: false, score: 0, reasons: [] },
      meetsExtractEvery: false,
      canRun: false,
      batchAssistantTurns: [],
      plannedBatchEndFloor: null,
      startIdx: null,
      endIdx: null,
      reason: "auto-extraction-disabled",
    };
  }
  const assistantTurns =
    typeof runtime?.getAssistantTurns === "function"
      ? runtime.getAssistantTurns(resolvedChat)
      : getAssistantTurnsFallback(runtime, resolvedChat);
  const pendingAssistantTurns = assistantTurns.filter(
    (floor) => floor > safeLastProcessedAssistantFloor,
  );
  const candidateAssistantTurns =
    safeLockedEndFloor == null
      ? pendingAssistantTurns
      : pendingAssistantTurns.filter((floor) => floor <= safeLockedEndFloor);

  let eligibleAssistantTurns = candidateAssistantTurns;
  let waitingForNextAssistant = false;
  if (safeLockedEndFloor == null && strategy === "lag-one-assistant") {
    if (candidateAssistantTurns.length <= 1) {
      eligibleAssistantTurns = [];
      waitingForNextAssistant = candidateAssistantTurns.length === 1;
    } else {
      eligibleAssistantTurns = candidateAssistantTurns.slice(0, -1);
    }
  }

  const eligibleEndFloor =
    eligibleAssistantTurns.length > 0
      ? eligibleAssistantTurns[eligibleAssistantTurns.length - 1]
      : null;
  const smartTriggerDecision =
    resolvedSettings.enableSmartTrigger && eligibleEndFloor != null
      ? normalizeSmartTriggerDecision(
          runtime?.getSmartTriggerDecision?.(
            resolvedChat,
            safeLastProcessedAssistantFloor,
            resolvedSettings,
            eligibleEndFloor,
          ),
        )
      : { triggered: false, score: 0, reasons: [] };
  const meetsExtractEvery = eligibleAssistantTurns.length >= extractEvery;
  const canRun =
    eligibleAssistantTurns.length > 0 &&
    (meetsExtractEvery || smartTriggerDecision.triggered);
  const batchAssistantTurns = canRun
    ? smartTriggerDecision.triggered
      ? eligibleAssistantTurns
      : eligibleAssistantTurns.slice(0, extractEvery)
    : [];
  const plannedBatchEndFloor =
    batchAssistantTurns.length > 0
      ? batchAssistantTurns[batchAssistantTurns.length - 1]
      : null;

  let reason = "";
  if (pendingAssistantTurns.length === 0) {
    reason = "no-unprocessed-assistant-turns";
  } else if (candidateAssistantTurns.length === 0) {
    reason =
      safeLockedEndFloor == null
        ? "no-candidate-assistant-turns"
        : "locked-target-missing";
  } else if (waitingForNextAssistant) {
    reason = "waiting-next-assistant";
  } else if (!canRun && !smartTriggerDecision.triggered) {
    reason = "below-extract-every";
  }

  return {
    strategy,
    chat: resolvedChat,
    settings: resolvedSettings,
    lastProcessedAssistantFloor: safeLastProcessedAssistantFloor,
    lockedEndFloor: safeLockedEndFloor,
    extractEvery,
    pendingAssistantTurns,
    candidateAssistantTurns,
    eligibleAssistantTurns,
    eligibleEndFloor,
    waitingForNextAssistant,
    smartTriggerDecision,
    meetsExtractEvery,
    canRun,
    batchAssistantTurns,
    plannedBatchEndFloor,
    startIdx: batchAssistantTurns[0] ?? null,
    endIdx: plannedBatchEndFloor,
    reason,
  };
}

export async function executeExtractionBatchController(
  runtime,
  {
    chat,
    startIdx,
    endIdx,
    settings,
    smartTriggerDecision = null,
    signal = undefined,
    postProcessContext = null,
  } = {},
) {
  runtime.ensureCurrentGraphRuntimeState();
  runtime.throwIfAborted(signal, "提取已终止");

  const currentGraph = runtime.getCurrentGraph();
  const lastProcessed = runtime.getLastProcessedAssistantFloor();
  const extractionCountBefore = runtime.getExtractionCount();
  const beforeSnapshot = runtime.cloneGraphSnapshot(currentGraph);
  const messages = runtime.buildExtractionMessages(chat, startIdx, endIdx, settings);
  const batchStatus = runtime.createBatchStatusSkeleton({
    processedRange: [startIdx, endIdx],
    extractionCountBefore,
  });

  debugLog(
    `[ST-BME] 开始提取: 楼层 ${startIdx}-${endIdx}` +
      (smartTriggerDecision?.triggered
        ? ` [智能触发 score=${smartTriggerDecision.score}; ${smartTriggerDecision.reasons.join(" / ")}]`
        : ""),
  );

  const result = await runtime.extractMemories({
    graph: currentGraph,
    messages,
    startSeq: startIdx,
    endSeq: endIdx,
    lastProcessedSeq: lastProcessed,
    schema: runtime.getSchema(),
    embeddingConfig: runtime.getEmbeddingConfig(),
    extractPrompt: undefined,
    settings,
    signal,
    onStreamProgress: ({ previewText, receivedChars }) => {
      const preview =
        previewText?.length > 60 ? "…" + previewText.slice(-60) : previewText || "";
      runtime.setLastExtractionStatus(
        "AI 生成中",
        `${preview}  [${receivedChars}字]`,
        "running",
        { syncRuntime: false, noticeMarquee: true },
      );
    },
  });

  if (!result.success) {
    runtime.setBatchStageOutcome(
      batchStatus,
      "core",
      "failed",
      result?.error || "提取阶段未返回有效操作",
    );
    runtime.setBatchStageOutcome(
      batchStatus,
      "finalize",
      "failed",
      "提取阶段失败，未进入持久化",
    );
    batchStatus.persistence = null;
    batchStatus.historyAdvanceAllowed = false;
    batchStatus.historyAdvanced = false;
    runtime.finalizeBatchStatus(batchStatus, runtime.getExtractionCount());
    runtime.getCurrentGraph().historyState.lastBatchStatus = batchStatus;
    return {
      success: false,
      result,
      effects: null,
      batchStatus,
      error: result?.error || "提取阶段未返回有效操作",
    };
  }

  runtime.setBatchStageOutcome(batchStatus, "core", "success");
  const effects = await runtime.handleExtractionSuccess(
    result,
    endIdx,
    settings,
    signal,
    batchStatus,
    postProcessContext,
  );
  const batchStatusRef = effects?.batchStatus || batchStatus;
  const committedPersistState = await buildCommittedBatchPersistSnapshot(runtime, {
    graph: runtime.getCurrentGraph(),
    chat,
    settings,
    beforeSnapshot,
    processedRange: [startIdx, endIdx],
    postProcessArtifacts: runtime.computePostProcessArtifacts(
      beforeSnapshot,
      runtime.getCurrentGraph(),
      effects?.postProcessArtifacts || [],
    ),
    vectorHashesInserted: effects?.vectorHashesInserted || [],
    extractionCountBefore,
  });
  const persistResult = await runtime.persistExtractionBatchResult({
    reason: "extraction-batch-complete",
    lastProcessedAssistantFloor: endIdx,
    graphSnapshot: committedPersistState.persistGraphSnapshot,
    persistSnapshot: committedPersistState.persistSnapshot,
    persistDelta: committedPersistState.persistDelta,
  });
  const persistence = normalizePersistenceStateRecord(persistResult);
  batchStatusRef.persistence = persistence;
  batchStatusRef.historyAdvanceAllowed = persistence.accepted === true;
  let backgroundMaintenanceQueue = null;
  if (
    persistence.accepted === true &&
    Array.isArray(effects?.backgroundMaintenance) &&
    effects.backgroundMaintenance.length > 0 &&
    typeof runtime.scheduleBackgroundMaintenancePostProcess === "function"
  ) {
    backgroundMaintenanceQueue = runtime.scheduleBackgroundMaintenancePostProcess(
      effects.backgroundMaintenance,
      settings,
    );
    batchStatusRef.backgroundMaintenanceState =
      backgroundMaintenanceQueue?.queued === true ? "queued" : "queue-failed";
    batchStatusRef.backgroundMaintenanceQueue =
      cloneSerializable(backgroundMaintenanceQueue, backgroundMaintenanceQueue);
    if (backgroundMaintenanceQueue?.queued !== true) {
      runtime.setBatchStageOutcome(
        batchStatusRef,
        "finalize",
        "partial",
        `后台维护入队失败: ${backgroundMaintenanceQueue?.reason || "queue-failed"}`,
      );
    }
  } else if (
    Array.isArray(effects?.backgroundMaintenance) &&
    effects.backgroundMaintenance.length > 0
  ) {
    batchStatusRef.backgroundMaintenanceState = "blocked-by-persistence";
  }
  let backgroundVectorSyncQueue = null;
  if (
    persistence.accepted === true &&
    effects?.backgroundVectorSync?.enabled === true &&
    typeof runtime.scheduleBackgroundVectorSync === "function"
  ) {
    backgroundVectorSyncQueue = runtime.scheduleBackgroundVectorSync(
      effects.backgroundVectorSync,
      settings,
    );
    batchStatusRef.backgroundVectorSyncState =
      backgroundVectorSyncQueue?.queued === true ? "queued" : "queue-failed";
    batchStatusRef.backgroundVectorSyncQueue =
      cloneSerializable(backgroundVectorSyncQueue, backgroundVectorSyncQueue);
    if (backgroundVectorSyncQueue?.queued !== true) {
      runtime.setBatchStageOutcome(
        batchStatusRef,
        "finalize",
        "partial",
        `后台向量同步入队失败: ${backgroundVectorSyncQueue?.reason || "queue-failed"}`,
      );
    }
  } else if (effects?.backgroundVectorSync?.enabled === true) {
    batchStatusRef.backgroundVectorSyncState = "blocked-by-persistence";
  }
  const finalizedBatchStatus = runtime.finalizeBatchStatus(
    batchStatusRef,
    runtime.getExtractionCount(),
  );

  runtime.getCurrentGraph().historyState.lastBatchStatus = {
    ...finalizedBatchStatus,
    persistence,
    historyAdvanceAllowed: persistence.accepted === true,
    historyAdvanced: runtime.shouldAdvanceProcessedHistory({
      ...finalizedBatchStatus,
      historyAdvanceAllowed: persistence.accepted === true,
    }),
  };

  if (runtime.getCurrentGraph().historyState.lastBatchStatus.historyAdvanced) {
    runtime.updateProcessedHistorySnapshot(chat, endIdx);
    if (committedPersistState.committedBatchJournalEntry) {
      runtime.appendBatchJournal(
        runtime.getCurrentGraph(),
        cloneSerializable(
          committedPersistState.committedBatchJournalEntry,
          committedPersistState.committedBatchJournalEntry,
        ),
      );
    }
  } else if (!persistence.accepted) {
    // 即使持久化未被接受，仍在内存中推进 lastProcessedAssistantFloor，
    // 防止同一会话内对已经抽取过的楼层重复提取。
    // 此时不追加 batchJournal（保持回滚完整性）。
    // 如果用户重载，floor 和图谱都会回退到最后持久化状态，保持一致。
    runtime.updateProcessedHistorySnapshot(chat, endIdx);
    runtime.setLastExtractionStatus(
      "提取待恢复",
      `楼层 ${startIdx}-${endIdx} 已抽取，但持久化状态为 ${persistence.outcome || "failed"}${persistence.reason ? ` · ${persistence.reason}` : ""}`,
      "warning",
      { syncRuntime: true },
    );
    runtime.console?.warn?.("[ST-BME] extraction persist not accepted", {
      chatId: runtime.getGraphPersistenceState?.()?.chatId || "",
      persistence,
      processedRange: [startIdx, endIdx],
    });
  }

  return {
    success: finalizedBatchStatus.completed,
    result,
    effects: {
      ...(effects || {}),
      persistResult,
      backgroundMaintenanceQueue,
      backgroundVectorSyncQueue,
    },
    batchStatus: finalizedBatchStatus,
    persistResult,
    historyAdvanceAllowed: persistence.accepted === true,
    error: finalizedBatchStatus.completed
      ? ""
      : effects?.vectorError ||
        finalizedBatchStatus.errors?.[0] ||
        "批次未完成 finalize 闭环",
  };
}

export async function runExtractionController(runtime, options = {}) {
  const lockedEndFloor = toSafeFloor(options?.lockedEndFloor, null);
  const triggerSource = String(options?.triggerSource || "auto").trim() || "auto";
  const settings = runtime.getSettings?.() || {};
  const context = runtime.getContext?.() || {};
  const chat = Array.isArray(context?.chat) ? context.chat : [];
  const plan = resolveAutoExtractionPlanController(runtime, {
    chat,
    settings,
    lockedEndFloor,
  });
  const deferredTargetEndFloor =
    plan.plannedBatchEndFloor ?? lockedEndFloor;

  if (runtime.getIsExtracting()) {
    runtime.console?.debug?.("[ST-BME] auto extraction deferred: extraction already in progress");
    runtime.deferAutoExtraction?.("extracting", {
      targetEndFloor: deferredTargetEndFloor,
      strategy: plan.strategy,
    });
    return;
  }

  if (!settings.enabled || settings.extractAutoEnabled === false) return;
  if (!runtime.ensureGraphMutationReady("自动提取", { notify: false })) {
    runtime.console?.debug?.("[ST-BME] auto extraction blocked: graph-not-ready", {
      loadState: runtime.getGraphPersistenceState?.()?.loadState || "",
    });
    runtime.deferAutoExtraction?.("graph-not-ready", {
      targetEndFloor: deferredTargetEndFloor,
      strategy: plan.strategy,
    });
    runtime.setLastExtractionStatus(
      "等待图谱加载",
      runtime.getGraphMutationBlockReason("自动提取"),
      "warning",
      { syncRuntime: true },
    );
    return;
  }

  const pendingPersistGate = await maybeRetryPendingPersistence(
    runtime,
    "auto-extraction-persist-retry",
  );
  const pendingPersistMessage = pendingPersistGate
    ? formatPendingPersistenceGateMessage(runtime, "自动提取")
    : "";
  if (pendingPersistMessage) {
    runtime.console?.debug?.("[ST-BME] auto extraction paused: pending persistence", {
      persistence: runtime.getCurrentGraph?.()?.historyState?.lastBatchStatus?.persistence || null,
    });
    runtime.deferAutoExtraction?.("pending-persist", {
      targetEndFloor: deferredTargetEndFloor,
      strategy: plan.strategy,
    });
    runtime.setLastExtractionStatus(
      "等待持久化确认",
      pendingPersistMessage,
      "warning",
      { syncRuntime: true },
    );
    return;
  }

  if (!runtime.getCurrentGraph()) {
    runtime.ensureCurrentGraphRuntimeState?.();
  }

  if (!(await runtime.recoverHistoryIfNeeded("auto-extract"))) {
    runtime.console?.debug?.("[ST-BME] auto extraction paused during history recovery", {
      recovering: runtime.getIsRecoveringHistory?.() === true,
    });
    if (runtime.getIsRecoveringHistory?.()) {
      runtime.deferAutoExtraction?.("history-recovering", {
        targetEndFloor: deferredTargetEndFloor,
        strategy: plan.strategy,
      });
    }
    return;
  }

  if (!chat || chat.length === 0) return;
  if (!plan.canRun || plan.startIdx == null || plan.endIdx == null) {
    return;
  }

  const startIdx = plan.startIdx;
  const endIdx = plan.endIdx;
  const smartTriggerDecision = plan.smartTriggerDecision;
  runtime.setIsExtracting(true);
  const extractionController = runtime.beginStageAbortController("extraction");
  const extractionSignal = extractionController.signal;
  runtime.setLastExtractionStatus(
    "提取中",
    `楼层 ${startIdx}-${endIdx}${smartTriggerDecision.triggered ? " · 智能触发" : ""}${triggerSource !== "auto" ? ` · ${triggerSource}` : ""}`,
    "running",
    { syncRuntime: true },
  );

  try {
    const batchResult = await runtime.executeExtractionBatch({
      chat,
      startIdx,
      endIdx,
      settings,
      smartTriggerDecision,
      signal: extractionSignal,
    });

    if (!batchResult.success) {
      const message =
        batchResult.error ||
        batchResult?.result?.error ||
        "提取批次未返回有效结果";
      runtime.console.warn("[ST-BME] 提取批次未返回有效结果:", message);
      runtime.notifyExtractionIssue(message);
      return;
    }

    const persistence = batchResult.batchStatus?.persistence || null;
    if (batchResult.historyAdvanceAllowed === false) {
      runtime.setLastExtractionStatus(
        "提取完成，持久化待确认",
        `楼层 ${startIdx}-${endIdx} · 新建 ${batchResult.result?.newNodes || 0} · 更新 ${batchResult.result?.updatedNodes || 0} · 新边 ${batchResult.result?.newEdges || 0}${persistence?.reason ? ` · ${persistence.reason}` : ""}`,
        "warning",
        { syncRuntime: true },
      );
    } else {
      runtime.setLastExtractionStatus(
        "提取完成",
        `楼层 ${startIdx}-${endIdx} · 新建 ${batchResult.result?.newNodes || 0} · 更新 ${batchResult.result?.updatedNodes || 0} · 新边 ${batchResult.result?.newEdges || 0}`,
        "success",
        { syncRuntime: true },
      );
    }
  } catch (e) {
    if (runtime.isAbortError(e)) {
      runtime.setLastExtractionStatus(
        "提取已终止",
        e?.message || "已手动终止当前提取",
        "warning",
        {
          syncRuntime: true,
        },
      );
      return;
    }
    runtime.console.error("[ST-BME] 提取失败:", e);
    runtime.notifyExtractionIssue(e?.message || String(e) || "自动提取失败");
  } finally {
    runtime.finishStageAbortController("extraction", extractionController);
    runtime.setIsExtracting(false);
  }
}

export async function onManualExtractController(runtime, options = {}) {
  if (runtime.getIsExtracting()) {
    runtime.toastr.info("记忆提取正在进行中，请稍候");
    return;
  }
  const taskLabel = String(options?.taskLabel || "手动提取").trim() || "手动提取";
  const toastTitle = String(options?.toastTitle || `ST-BME ${taskLabel}`).trim() ||
    `ST-BME ${taskLabel}`;
  const showStartToast = options?.showStartToast !== false;
  const lockedEndFloor = toSafeFloor(options?.lockedEndFloor, null);
  if (!runtime.ensureGraphMutationReady(taskLabel)) return;
  const pendingPersistGate = await maybeRetryPendingPersistence(
    runtime,
    "manual-extraction-persist-retry",
  );
  const pendingPersistMessage = pendingPersistGate
    ? formatPendingPersistenceGateMessage(runtime, taskLabel)
    : "";
  if (pendingPersistMessage) {
    runtime.setLastExtractionStatus(
      "等待持久化确认",
      pendingPersistMessage,
      "warning",
      {
        syncRuntime: true,
      },
    );
    runtime.toastr.warning("上一批持久化尚未确认，请先点“重试持久化”或“重新探测图谱”");
    return;
  }
  if (!(await runtime.recoverHistoryIfNeeded("manual-extract"))) return;
  if (!runtime.getCurrentGraph()) {
    runtime.setCurrentGraph(
      runtime.normalizeGraphRuntimeState(
        runtime.createEmptyGraph(),
        runtime.getCurrentChatId(),
      ),
    );
  }

  const context = runtime.getContext();
  const chat = context.chat;
  if (!Array.isArray(chat) || chat.length === 0) {
    runtime.toastr.info("当前聊天为空，暂无可提取内容");
    return;
  }

  const assistantTurns = runtime.getAssistantTurns(chat);
  const lastProcessed = runtime.getLastProcessedAssistantFloor();
  const pendingAssistantTurns = assistantTurns.filter((i) => i > lastProcessed);
  const targetAssistantTurns = pendingAssistantTurns.filter((i) => {
    if (lockedEndFloor != null && i > lockedEndFloor) return false;
    return true;
  });
  if (pendingAssistantTurns.length === 0) {
    runtime.toastr.info("没有待提取的新回复");
    return;
  }

  const settings = runtime.getSettings();
  const extractEvery = runtime.clampInt(settings.extractEvery, 1, 1, 50);
  const bulkConsolidationRequested =
    options?.suppressIntermediateAutoConsolidation === true &&
    options?.drainAll !== false;
  const bulkConsolidationWindow = resolveBulkAutoConsolidationWindow(
    settings,
    options,
  );
  const totals = {
    newNodes: 0,
    updatedNodes: 0,
    newEdges: 0,
    batches: 0,
  };
  let processedAssistantTurns = 0;
  let deferredAutoConsolidationBatches = 0;
  let deferredAutoConsolidationNodeIds = [];
  const warnings = [];

  runtime.setIsExtracting(true);
  const extractionController = runtime.beginStageAbortController("extraction");
  const extractionSignal = extractionController.signal;
  setExtractionProgressStatus(
    runtime,
    `${taskLabel}中`,
    lockedEndFloor != null
      ? `待处理 AI 回复 ${targetAssistantTurns.length} 条 · 截止 chatIndex ${lockedEndFloor}`
      : `待处理 AI 回复 ${targetAssistantTurns.length} 条`,
    "running",
    {
      syncRuntime: true,
      toastKind: showStartToast ? "info" : "",
      toastTitle,
    },
  );
  try {
    while (true) {
      const pendingTurns = runtime
        .getAssistantTurns(chat)
        .filter((i) => {
          if (i <= runtime.getLastProcessedAssistantFloor()) return false;
          if (lockedEndFloor != null && i > lockedEndFloor) return false;
          return true;
        });
      if (pendingTurns.length === 0) break;

      const batchAssistantTurns = pendingTurns.slice(0, extractEvery);
      const startIdx = batchAssistantTurns[0];
      const endIdx = batchAssistantTurns[batchAssistantTurns.length - 1];
      const remainingAfterBatch = Math.max(
        0,
        pendingTurns.length - batchAssistantTurns.length,
      );
      const finalBulkBatch =
        bulkConsolidationRequested && remainingAfterBatch <= 0;
      const windowReady =
        bulkConsolidationRequested &&
        remainingAfterBatch > 0 &&
        (deferredAutoConsolidationBatches + 1 >=
          bulkConsolidationWindow.everyBatches ||
          deferredAutoConsolidationNodeIds.length >=
            bulkConsolidationWindow.minNewNodes);
      const shouldDelayAutoConsolidation =
        bulkConsolidationRequested &&
        remainingAfterBatch > 0 &&
        !windowReady;
      const pendingAutoConsolidationNodeIds =
        windowReady || finalBulkBatch ? deferredAutoConsolidationNodeIds : [];
      const bulkPostProcessContext =
        !bulkConsolidationRequested
          ? null
          : shouldDelayAutoConsolidation
            ? {
              suppressAutoConsolidation: true,
              autoConsolidationSuppressReason: `批量重新提取仍有 ${remainingAfterBatch} 条 AI 回复待处理，已延后本批自动整合（累计 ${deferredAutoConsolidationBatches + 1}/${bulkConsolidationWindow.everyBatches} 批，${deferredAutoConsolidationNodeIds.length}/${bulkConsolidationWindow.minNewNodes} 个待整合节点）`,
              bulkExtraction: true,
              remainingAssistantTurnsAfterBatch: remainingAfterBatch,
              deferredAutoConsolidationBatches,
              deferredAutoConsolidationNodeCount:
                deferredAutoConsolidationNodeIds.length,
            }
            : pendingAutoConsolidationNodeIds.length > 0
              ? {
                  suppressAutoConsolidation: false,
                  bulkExtraction: true,
                  pendingAutoConsolidationNodeIds,
                  autoConsolidationWindowReason: finalBulkBatch
                    ? "批量重新提取已到最后一批，执行窗口整合"
                    : `批量重新提取累计达到窗口阈值（${deferredAutoConsolidationBatches} 批，${deferredAutoConsolidationNodeIds.length} 个待整合节点），执行一次中间整合`,
                  remainingAssistantTurnsAfterBatch: remainingAfterBatch,
                  deferredAutoConsolidationBatches,
                  deferredAutoConsolidationNodeCount:
                    deferredAutoConsolidationNodeIds.length,
                }
              : null;
      const batchResult = await runtime.executeExtractionBatch({
        chat,
        startIdx,
        endIdx,
        settings,
        signal: extractionSignal,
        postProcessContext: bulkPostProcessContext,
      });

      if (!batchResult.success) {
        throw new Error(
          batchResult.error ||
            batchResult?.result?.error ||
            "手动提取未返回有效结果",
        );
      }

      totals.newNodes += batchResult.result.newNodes || 0;
      totals.updatedNodes += batchResult.result.updatedNodes || 0;
      totals.newEdges += batchResult.result.newEdges || 0;
      totals.batches++;
      processedAssistantTurns += batchAssistantTurns.length;

      if (bulkConsolidationRequested) {
        if (shouldDelayAutoConsolidation) {
          deferredAutoConsolidationBatches += 1;
          deferredAutoConsolidationNodeIds = uniqueStringList([
            ...deferredAutoConsolidationNodeIds,
            ...(Array.isArray(batchResult.result?.newNodeIds)
              ? batchResult.result.newNodeIds
              : []),
          ]);
        } else {
          deferredAutoConsolidationBatches = 0;
          deferredAutoConsolidationNodeIds = [];
        }
      }

      if (Array.isArray(batchResult.effects?.warnings)) {
        warnings.push(...batchResult.effects.warnings);
      }

      const totalTurnsForDisplay = Math.max(
        processedAssistantTurns,
        targetAssistantTurns.length,
      );
      setExtractionProgressStatus(
        runtime,
        `${taskLabel}中`,
        totalTurnsForDisplay > 0
          ? `已处理 ${processedAssistantTurns}/${totalTurnsForDisplay} 条 AI 回复 · 当前楼层 ${startIdx}-${endIdx} · 累计 ${totals.batches} 批`
          : `当前楼层 ${startIdx}-${endIdx} · 累计 ${totals.batches} 批`,
        "running",
        {
          syncRuntime: true,
          toastKind: "",
          toastTitle,
        },
      );

      if (batchResult.historyAdvanceAllowed === false) {
        warnings.push(
          batchResult.batchStatus?.persistence?.reason ||
            "当前批次持久化尚未确认",
        );
        break;
      }

      if (options?.drainAll === false) {
        break;
      }
    }

    if (totals.batches === 0) {
      setExtractionProgressStatus(
        runtime,
        "无待提取内容",
        lockedEndFloor != null
          ? "指定范围内没有新的 assistant 回复需要处理"
          : "没有新的 assistant 回复需要处理",
        "info",
        {
          syncRuntime: true,
        },
      );
      runtime.toastr.info("没有待提取的新回复");
      return;
    }

    const pendingAfterRun = getPendingPersistenceGateInfo(runtime);
    if (pendingAfterRun) {
      runtime.toastr.warning(
        `提取完成但持久化待确认：${pendingAfterRun.reason || pendingAfterRun.outcome || "unknown"}`,
      );
      runtime.setLastExtractionStatus(
        `${taskLabel}完成，持久化待确认`,
        `${totals.batches} 批 · 新建 ${totals.newNodes} · 更新 ${totals.updatedNodes} · 新边 ${totals.newEdges}${pendingAfterRun.reason ? ` · ${pendingAfterRun.reason}` : ""}`,
        "warning",
        {
          syncRuntime: true,
          toastKind: "",
          toastTitle,
        },
      );
    } else {
      runtime.toastr.success(
        `提取完成：${totals.batches} 批，新建 ${totals.newNodes}，更新 ${totals.updatedNodes}，新边 ${totals.newEdges}`,
      );
      runtime.setLastExtractionStatus(
        `${taskLabel}完成`,
        `${totals.batches} 批 · 新建 ${totals.newNodes} · 更新 ${totals.updatedNodes} · 新边 ${totals.newEdges}`,
        "success",
        {
          syncRuntime: true,
          toastKind: "success",
          toastTitle,
        },
      );
    }
    if (warnings.length > 0) {
      runtime.toastr.warning(warnings.slice(0, 2).join("；"), "ST-BME 提取警告", {
        timeOut: 5000,
      });
    }
  } catch (e) {
    if (runtime.isAbortError(e)) {
      runtime.setLastExtractionStatus(
        `${taskLabel}已终止`,
        e?.message || "已手动终止当前提取",
        "warning",
        {
          syncRuntime: true,
        },
      );
      return;
    }
    runtime.console.error("[ST-BME] 手动提取失败:", e);
    runtime.setLastExtractionStatus(`${taskLabel}失败`, e?.message || String(e), "error", {
      syncRuntime: true,
      toastKind: "",
      toastTitle,
    });
    runtime.toastr.error(`${taskLabel}失败: ${e.message || e}`);
  } finally {
    runtime.finishStageAbortController("extraction", extractionController);
    runtime.setIsExtracting(false);
    runtime.refreshPanelLiveState();
  }
}

export async function onExtractionTaskController(runtime, options = {}) {
  const requestedMode = String(options?.mode || "pending").trim().toLowerCase();
  const context = runtime.getContext?.() || {};
  const chat = Array.isArray(context?.chat) ? context.chat : [];
  const runManualExtract = async (manualOptions = {}) => {
    if (typeof runtime?.onManualExtract === "function") {
      return await runtime.onManualExtract(manualOptions);
    }
    return await onManualExtractController(runtime, manualOptions);
  };

  if (requestedMode === "pending") {
    return await runManualExtract({
      ...options,
      taskLabel: "提取未处理",
      toastTitle: "ST-BME 重新提取",
    });
  }

  const rerunTask = resolveRerunDialogueTask(chat, options);
  if (!rerunTask.valid) {
    runtime.toastr?.info?.(rerunTask.reason || "当前没有可重提的范围");
    return {
      success: false,
      rerunPerformed: false,
      fallbackToLatest: false,
      requestedRange: [null, null],
      effectiveDialogueRange: [null, null],
      reason: rerunTask.reason || "invalid-rerun-range",
    };
  }

  const fallbackInfo = buildRerunFallbackInfo(chat, [
    rerunTask.startFloor,
    rerunTask.endFloor,
  ]);
  const visibilityWarning = buildRerunVisibilityWarning(runtime, chat, rerunTask);
  if (!fallbackInfo.valid) {
    runtime.toastr?.info?.(fallbackInfo.reason || "目标范围内没有可重提的 AI 回复");
    return {
      success: false,
      rerunPerformed: false,
      fallbackToLatest: false,
      requestedRange: [rerunTask.requestedStartFloor, rerunTask.requestedEndFloor],
      effectiveDialogueRange: [rerunTask.startFloor, rerunTask.endFloor],
      reason: fallbackInfo.reason || "no-assistant-in-range",
      visibilityWarning,
    };
  }

  const effectiveLockedEndFloor = fallbackInfo.fallbackToLatest
    ? null
    : fallbackInfo.endAssistantChatIndex;
  const effectiveDialogueRange = [
    rerunTask.startFloor,
    fallbackInfo.fallbackToLatest
      ? Number.isFinite(Number(fallbackInfo.latestAssistantDialogueFloor))
        ? Number(fallbackInfo.latestAssistantDialogueFloor)
        : rerunTask.endFloor
      : rerunTask.endFloor,
  ];

  setExtractionProgressStatus(
    runtime,
    "重新提取准备中",
    [
      fallbackInfo.fallbackToLatest
        ? `范围 ${rerunTask.startFloor} ~ ${rerunTask.endFloor} 命中旧批次，但当前将退化为从 ${effectiveDialogueRange[0]} 到最新重提`
        : `准备重提范围 ${rerunTask.startFloor} ~ ${rerunTask.endFloor}`,
      visibilityWarning,
    ]
      .filter(Boolean)
      .join("；"),
    fallbackInfo.fallbackToLatest || visibilityWarning ? "warning" : "running",
    {
      syncRuntime: true,
      toastKind: "info",
      toastTitle: "ST-BME 重新提取",
    },
  );

  if (visibilityWarning) {
    runtime.toastr?.warning?.(visibilityWarning, "ST-BME 重新提取", {
      timeOut: 7000,
    });
  }

  let rollbackResult = await runtime.rollbackGraphForReroll(
    fallbackInfo.startAssistantChatIndex,
    context,
  );

  // 回滚点不可用时，自动尝试历史恢复后降级为 pending 模式
  if (
    !rollbackResult?.success &&
    rollbackResult?.resultCode === "reroll.rollback.unavailable" &&
    typeof runtime.recoverHistoryIfNeeded === "function"
  ) {
    setExtractionProgressStatus(
      runtime,
      "重新提取准备中",
      "未找到回滚点，正在自动执行历史恢复后重新提取",
      "running",
      {
        syncRuntime: true,
        toastKind: "info",
        toastTitle: "ST-BME 重新提取",
      },
    );
    const recovered = await runtime.recoverHistoryIfNeeded(
      "rerun-rollback-unavailable",
    );
    if (recovered) {
      // 历史恢复成功，降级为 pending 模式继续提取
      setExtractionProgressStatus(
        runtime,
        "重新提取中",
        "历史恢复完成，正在提取未处理内容",
        "running",
        {
          syncRuntime: true,
          toastKind: "",
          toastTitle: "ST-BME 重新提取",
        },
      );
      await runManualExtract({
        drainAll: true,
        suppressIntermediateAutoConsolidation: true,
        taskLabel: "重新提取（恢复后）",
        toastTitle: "ST-BME 重新提取",
        showStartToast: false,
      });
      return {
        success: true,
        rerunPerformed: true,
        recoveryFallback: true,
        fallbackToLatest: true,
        requestedRange: [
          rerunTask.requestedStartFloor,
          rerunTask.requestedEndFloor,
        ],
        effectiveDialogueRange,
        visibilityWarning,
        reason: "rollback-unavailable-recovered-pending",
      };
    }
  }

  if (!rollbackResult?.success) {
    const rollbackError = String(
      rollbackResult?.error ||
        rollbackResult?.reason ||
        rollbackResult?.recoveryPath ||
        "回滚失败",
    ).trim() || "回滚失败";
    setExtractionProgressStatus(
      runtime,
      "重新提取失败",
      rollbackError,
      "warning",
      {
        syncRuntime: true,
        toastKind: "",
        toastTitle: "ST-BME 重新提取",
      },
    );
    runtime.toastr?.warning?.(
      `重新提取未开始：${rollbackError}`,
      "ST-BME 重新提取",
      {
        timeOut: 4500,
      },
    );
    return {
      ...rollbackResult,
      rerunPerformed: false,
      fallbackToLatest: fallbackInfo.fallbackToLatest,
      requestedRange: [rerunTask.requestedStartFloor, rerunTask.requestedEndFloor],
      effectiveDialogueRange,
      visibilityWarning,
    };
  }

  if (fallbackInfo.reason) {
    runtime.toastr?.warning?.(fallbackInfo.reason, "ST-BME 重新提取", {
      timeOut: 3500,
    });
  }

  const rollbackDesc =
    rollbackResult.effectiveFromFloor !== fallbackInfo.startAssistantChatIndex
      ? `已按批次边界回滚到楼层 ${rollbackResult.effectiveFromFloor}，正在开始重新提取`
      : `已回滚到楼层 ${fallbackInfo.startAssistantChatIndex}，正在开始重新提取`;
  setExtractionProgressStatus(
    runtime,
    "重新提取中",
    rollbackDesc,
    "running",
    {
      syncRuntime: true,
      toastKind: "",
      toastTitle: "ST-BME 重新提取",
    },
  );

  await runManualExtract({
    drainAll: true,
    lockedEndFloor: effectiveLockedEndFloor,
    suppressIntermediateAutoConsolidation: true,
    taskLabel: "重新提取",
    toastTitle: "ST-BME 重新提取",
    showStartToast: false,
  });

  return {
    success: true,
    rerunPerformed: true,
    fallbackToLatest: fallbackInfo.fallbackToLatest,
    requestedRange: [rerunTask.requestedStartFloor, rerunTask.requestedEndFloor],
    effectiveDialogueRange,
    effectiveAssistantChatRange: [
      fallbackInfo.startAssistantChatIndex,
      effectiveLockedEndFloor,
    ],
    rollbackResult,
    visibilityWarning,
    reason: fallbackInfo.reason || "",
  };
}

export async function onRerollController(runtime, { fromFloor } = {}) {
  if (runtime.getIsExtracting?.()) {
    runtime.toastr?.info?.("记忆提取正在进行中，请稍候");
    return {
      success: false,
      rollbackPerformed: false,
      extractionTriggered: false,
      requestedFloor: null,
      effectiveFromFloor: null,
      recoveryPath: "busy",
      affectedBatchCount: 0,
      error: "记忆提取正在进行中",
    };
  }

  if (
    typeof runtime.ensureGraphMutationReady === "function" &&
    !runtime.ensureGraphMutationReady("重新提取")
  ) {
    return {
      success: false,
      rollbackPerformed: false,
      extractionTriggered: false,
      requestedFloor: Number.isFinite(fromFloor) ? fromFloor : null,
      effectiveFromFloor: null,
      recoveryPath: runtime.getGraphPersistenceState?.()?.loadState || "graph-not-ready",
      affectedBatchCount: 0,
      error:
        typeof runtime.getGraphMutationBlockReason === "function"
          ? runtime.getGraphMutationBlockReason("重新提取")
          : "重新提取已暂停：图谱尚未就绪。",
    };
  }

  if (!runtime.getCurrentGraph?.()) {
    runtime.toastr?.info?.("图谱为空，无需重 Roll");
    return {
      success: false,
      rollbackPerformed: false,
      extractionTriggered: false,
      requestedFloor: null,
      effectiveFromFloor: null,
      recoveryPath: "empty-graph",
      affectedBatchCount: 0,
      error: "图谱为空",
    };
  }

  const context = runtime.getContext();
  const chat = context?.chat;
  if (!Array.isArray(chat) || chat.length === 0) {
    runtime.toastr?.info?.("当前聊天为空");
    return {
      success: false,
      rollbackPerformed: false,
      extractionTriggered: false,
      requestedFloor: null,
      effectiveFromFloor: null,
      recoveryPath: "empty-chat",
      affectedBatchCount: 0,
      error: "当前聊天为空",
    };
  }

  let targetFloor = Number.isFinite(fromFloor) ? fromFloor : null;
  if (targetFloor === null) {
    const assistantTurns = runtime.getAssistantTurns(chat);
    if (assistantTurns.length === 0) {
      runtime.toastr?.info?.("聊天中没有 AI 回复");
      return {
        success: false,
        rollbackPerformed: false,
        extractionTriggered: false,
        requestedFloor: null,
        effectiveFromFloor: null,
        recoveryPath: "no-assistant-turn",
        affectedBatchCount: 0,
        error: "聊天中没有 AI 回复",
      };
    }
    targetFloor = assistantTurns[assistantTurns.length - 1];
  }

  setExtractionProgressStatus(
    runtime,
    "重新提取准备中",
    Number.isFinite(targetFloor)
      ? `准备从楼层 ${targetFloor} 开始回滚并重新提取`
      : "准备回滚最新 AI 楼并重新提取",
    "running",
    {
      syncRuntime: true,
      toastKind: "info",
      toastTitle: "ST-BME 重 Roll",
    },
  );

  const lastProcessed = runtime.getLastProcessedAssistantFloor();
  const alreadyExtracted = targetFloor <= lastProcessed;

  if (!alreadyExtracted) {
    runtime.toastr?.info?.("该楼层尚未提取，直接执行提取…", "ST-BME 重 Roll", {
      timeOut: 2000,
    });
    await runtime.onManualExtract();
    return {
      success: true,
      rollbackPerformed: false,
      extractionTriggered: true,
      requestedFloor: targetFloor,
      effectiveFromFloor: lastProcessed + 1,
      recoveryPath: "direct-extract",
      affectedBatchCount: 0,
      extractionStatus: runtime.getLastExtractionStatusLevel?.() || "idle",
      error: "",
    };
  }

  debugLog(`[ST-BME] 重 Roll 开始，目标楼层: ${targetFloor}`);
  let rollbackResult;
  try {
    rollbackResult = await runtime.rollbackGraphForReroll(targetFloor, context);
  } catch (e) {
    if (runtime.isAbortError(e)) {
      setExtractionProgressStatus(
        runtime,
        "重新提取已取消",
        e.message || "聊天已切换",
        "warning",
        {
          syncRuntime: true,
        },
      );
      return {
        success: false,
        rollbackPerformed: false,
        extractionTriggered: false,
        requestedFloor: targetFloor,
        effectiveFromFloor: null,
        recoveryPath: "aborted",
        affectedBatchCount: 0,
        error: e.message || "聊天已切换，重新提取已取消",
      };
    }
    throw e;
  }

  if (!rollbackResult?.success) {
    setExtractionProgressStatus(
      runtime,
      "重新提取失败",
      rollbackResult.error || "回滚失败",
      "error",
      {
        syncRuntime: true,
      },
    );
    runtime.toastr?.error?.(rollbackResult.error, "ST-BME 重 Roll");
    return rollbackResult;
  }

  const rerollDesc =
    rollbackResult.effectiveFromFloor !== targetFloor
      ? `已按批次边界回滚到楼层 ${rollbackResult.effectiveFromFloor} 开始重新提取…`
      : `已回滚到楼层 ${targetFloor} 开始重新提取…`;
  runtime.toastr?.info?.(rerollDesc, "ST-BME 重 Roll", {
    timeOut: 2500,
  });

  setExtractionProgressStatus(
    runtime,
    "重新提取中",
    rerollDesc,
    "running",
    {
      syncRuntime: true,
      toastKind: "",
      toastTitle: "ST-BME 重 Roll",
    },
  );

  await runtime.onManualExtract({ drainAll: false, showStartToast: false });
  runtime.refreshPanelLiveState();
  return {
    ...rollbackResult,
    extractionTriggered: true,
    extractionStatus: runtime.getLastExtractionStatusLevel?.() || "idle",
  };
}
