// ST-BME: 向量模式、后端索引与直连兜底

import { getRequestHeaders } from "../../../../../script.js";
import { embedBatch, embedText, searchSimilar } from "./embedding.js";
import { getActiveNodes } from "../graph/graph.js";
import { describeMemoryScope, normalizeMemoryScope } from "../graph/memory-scope.js";
import { resolveConfiguredTimeoutMs } from "../runtime/request-timeout.js";
import { buildVectorCollectionId, stableHashString } from "../runtime/runtime-state.js";
import {
  createVectorManifest,
  deriveVectorSpace,
  isVectorManifestCompatible,
  summarizeVectorSpaceChange,
} from "./vector-space.js";
import {
  AUTHORITY_VECTOR_MODE,
  AUTHORITY_VECTOR_SOURCE,
  applyAuthorityBmeVectorManifest,
  deleteAuthorityTriviumNodes,
  isAuthorityVectorConfig,
  normalizeAuthorityVectorConfig,
  purgeAuthorityTriviumNamespace,
  searchAuthorityTriviumNodes,
  syncAuthorityTriviumLinks,
  testAuthorityTriviumConnection,
  upsertAuthorityTriviumEntries,
} from "./authority-vector-primary-adapter.js";

export {
  AUTHORITY_VECTOR_MODE,
  AUTHORITY_VECTOR_SOURCE,
  isAuthorityVectorConfig,
  normalizeAuthorityVectorConfig,
};

export const BACKEND_VECTOR_SOURCES = [
  "openai",
  "openrouter",
  "cohere",
  "mistral",
  "electronhub",
  "chutes",
  "nanogpt",
  "ollama",
  "llamacpp",
  "vllm",
];

const BACKEND_SOURCES_REQUIRING_API_URL = new Set([
  "ollama",
  "llamacpp",
  "vllm",
]);

const MODEL_LIST_ENDPOINTS = {
  openrouter: "/api/openrouter/models/embedding",
  chutes: "/api/openai/chutes/models/embedding",
  nanogpt: "/api/openai/nanogpt/models/embedding",
  electronhub: "/api/openai/electronhub/models",
};
const VECTOR_REQUEST_TIMEOUT_MS = 300000;

function getConfiguredTimeoutMs(config = {}) {
  return typeof resolveConfiguredTimeoutMs === "function"
    ? resolveConfiguredTimeoutMs(config, VECTOR_REQUEST_TIMEOUT_MS)
    : (() => {
        const timeoutMs = Number(config?.timeoutMs);
        return Number.isFinite(timeoutMs) && timeoutMs > 0
          ? timeoutMs
          : VECTOR_REQUEST_TIMEOUT_MS;
      })();
}

const VECTOR_CONNECTION_PROBE_TEXTS = ["test connection", "runtime batch probe"];

const BACKEND_STATUS_MODEL_SOURCES = {
  openai: "openai",
  cohere: "cohere",
  mistral: "mistralai",
};

function isAbortError(error) {
  return error?.name === "AbortError";
}

function throwIfAborted(signal) {
  if (signal?.aborted) {
    throw signal.reason instanceof Error
      ? signal.reason
      : Object.assign(new Error("操作已终止"), { name: "AbortError" });
  }
}

function nowMs() {
  if (typeof performance?.now === "function") {
    return performance.now();
  }
  return Date.now();
}

function roundMs(value) {
  return Math.round((Number(value) || 0) * 10) / 10;
}

export const BACKEND_DEFAULT_MODELS = {
  openai: "text-embedding-3-small",
  openrouter: "openai/text-embedding-3-small",
  cohere: "embed-multilingual-v3.0",
  mistral: "mistral-embed",
  electronhub: "text-embedding-3-small",
  chutes: "chutes-qwen-qwen3-embedding-8b",
  nanogpt: "text-embedding-3-small",
  ollama: "nomic-embed-text",
  llamacpp: "text-embedding-3-small",
  vllm: "BAAI/bge-m3",
};

function createCombinedAbortSignal(...signals) {
  const validSignals = signals.filter(Boolean);
  if (validSignals.length <= 1) {
    return validSignals[0] || undefined;
  }

  if (
    typeof AbortSignal !== "undefined" &&
    typeof AbortSignal.any === "function"
  ) {
    return AbortSignal.any(validSignals);
  }

  const controller = new AbortController();
  for (const signal of validSignals) {
    if (signal.aborted) {
      controller.abort(signal.reason);
      return controller.signal;
    }
    signal.addEventListener("abort", () => controller.abort(signal.reason), {
      once: true,
    });
  }
  return controller.signal;
}

async function fetchWithTimeout(
  url,
  options = {},
  timeoutMs = VECTOR_REQUEST_TIMEOUT_MS,
) {
  const controller = new AbortController();
  const timeout = setTimeout(
    () =>
      controller.abort(
        new DOMException(
          `向量请求超时 (${Math.round(timeoutMs / 1000)}s)`,
          "AbortError",
        ),
      ),
    timeoutMs,
  );
  const signal = options.signal
    ? createCombinedAbortSignal(options.signal, controller.signal)
    : controller.signal;

  try {
    return await fetch(url, {
      ...options,
      signal,
    });
  } finally {
    clearTimeout(timeout);
  }
}

export function normalizeOpenAICompatibleBaseUrl(value, autoSuffix = true) {
  let normalized = String(value || "")
    .trim()
    .replace(/\/+(chat\/completions|embeddings)$/i, "")
    .replace(/\/+$/, "");

  if (autoSuffix && normalized && !/\/v\d+$/i.test(normalized)) {
    normalized = normalized;
  }

  return normalized;
}

export function getVectorConfigFromSettings(settings = {}) {
  const mode =
    settings.embeddingTransportMode === "backend" ? "backend" : "direct";
  const autoSuffix = settings.embeddingAutoSuffix !== false;

  if (mode === "direct") {
    return {
      mode,
      source: "direct",
      apiUrl: normalizeOpenAICompatibleBaseUrl(
        settings.embeddingApiUrl,
        autoSuffix,
      ),
      apiKey: String(settings.embeddingApiKey || "").trim(),
      model: String(settings.embeddingModel || "").trim(),
      autoSuffix,
      timeoutMs: getConfiguredTimeoutMs(settings),
    };
  }

  const source = BACKEND_VECTOR_SOURCES.includes(
    settings.embeddingBackendSource,
  )
    ? settings.embeddingBackendSource
    : "openai";

  return {
    mode,
    source,
    apiUrl: normalizeOpenAICompatibleBaseUrl(
      settings.embeddingBackendApiUrl,
      autoSuffix,
    ),
    apiKey: "",
    model: String(
      settings.embeddingBackendModel || BACKEND_DEFAULT_MODELS[source] || "",
    ).trim(),
    autoSuffix,
    timeoutMs: getConfiguredTimeoutMs(settings),
  };
}

export function getSuggestedBackendModel(source) {
  return BACKEND_DEFAULT_MODELS[source] || "text-embedding-3-small";
}

export function isBackendVectorConfig(config) {
  return config?.mode === "backend";
}

export function isDirectVectorConfig(config) {
  return config?.mode === "direct";
}

export function getVectorModelScope(config) {
  if (!config) return "";

  if (config?.mode === "authority" || config?.source === "authority-trivium") {
    return [
      "authority",
      config.source || "authority-trivium",
      config.embeddingMode || "direct",
      config.embeddingSource || "direct",
      normalizeOpenAICompatibleBaseUrl(config.apiUrl || "", config.autoSuffix),
      normalizeOpenAICompatibleBaseUrl(config.baseUrl || ""),
      config.model || "",
    ].join("|");
  }

  if (isDirectVectorConfig(config)) {
    return [
      "direct",
      normalizeOpenAICompatibleBaseUrl(config.apiUrl, config.autoSuffix),
      config.model || "",
    ].join("|");
  }

  return [
    "backend",
    config.source || "",
    normalizeOpenAICompatibleBaseUrl(config.apiUrl, config.autoSuffix),
    config.model || "",
  ].join("|");
}

export function validateVectorConfig(config) {
  if (!config) {
    return { valid: false, error: "未找到向量配置" };
  }

  if (config?.mode === "authority" || config?.source === "authority-trivium") {
    if (!config.baseUrl) {
      return { valid: false, error: "Authority Trivium 地址不可用" };
    }
    if (!config.model) {
      return { valid: false, error: "请先填写 Embedding 模型（Authority 默认复用当前用户设置）" };
    }
    const authorityEmbeddingMode = String(config.embeddingMode || "direct").trim().toLowerCase();
    const authorityEmbeddingSource = String(config.embeddingSource || "openai").trim().toLowerCase();
    if (authorityEmbeddingMode === "backend") {
      if (BACKEND_SOURCES_REQUIRING_API_URL.has(authorityEmbeddingSource) && !config.apiUrl) {
        return { valid: false, error: "当前后端 Embedding 源需要 API 地址（Authority 默认复用当前用户设置）" };
      }
      return { valid: true, error: "" };
    }
    if (!config.apiUrl) {
      return { valid: false, error: "请先填写 Embedding API 地址（Authority 默认复用当前用户设置）" };
    }
    return { valid: true, error: "" };
  }

  if (isDirectVectorConfig(config)) {
    if (!config.apiUrl) {
      return { valid: false, error: "请填写直连 Embedding API 地址" };
    }
    if (!config.model) {
      return { valid: false, error: "请填写直连 Embedding 模型" };
    }
    return { valid: true, error: "" };
  }

  if (!config.model) {
    return { valid: false, error: "请填写后端向量模型" };
  }

  if (BACKEND_SOURCES_REQUIRING_API_URL.has(config.source) && !config.apiUrl) {
    return { valid: false, error: "当前后端向量源需要填写 API 地址" };
  }

  return { valid: true, error: "" };
}

export function buildNodeVectorText(node) {
  const fields = node?.fields || {};
  const preferredKeys = [
    "summary",
    "insight",
    "title",
    "name",
    "state",
    "traits",
    "constraint",
    "goal",
    "participants",
    "suggestion",
    "status",
    "scope",
  ];

  const parts = [];

  for (const key of preferredKeys) {
    const value = fields[key];
    if (value == null || value === "") continue;
    if (Array.isArray(value)) {
      if (value.length > 0) parts.push(value.join(", "));
    } else if (typeof value === "object") {
      parts.push(JSON.stringify(value));
    } else {
      parts.push(String(value));
    }
  }

  for (const [key, value] of Object.entries(fields)) {
    if (preferredKeys.includes(key) || value == null || value === "") continue;
    if (key === "embedding") continue;
    if (Array.isArray(value)) {
      if (value.length > 0) parts.push(`${key}: ${value.join(", ")}`);
      continue;
    }
    if (typeof value === "object") {
      parts.push(`${key}: ${JSON.stringify(value)}`);
      continue;
    }
    parts.push(`${key}: ${value}`);
  }

  const scope = normalizeMemoryScope(node?.scope);
  const scopeText = describeMemoryScope(scope);
  const regionPath = Array.isArray(scope?.regionPath) ? scope.regionPath : [];
  const regionSecondary = Array.isArray(scope?.regionSecondary)
    ? scope.regionSecondary
    : [];
  if (scopeText) {
    parts.push(`memory_scope: ${scopeText}`);
  }
  if (regionPath.length > 0) {
    parts.push(`memory_region_path: ${regionPath.join(" / ")}`);
  }
  if (regionSecondary.length > 0) {
    parts.push(`memory_region_secondary: ${regionSecondary.join(", ")}`);
  }

  return parts.join(" | ").trim();
}

export function buildNodeVectorHash(node, config) {
  const text = buildNodeVectorText(node);
  const seqEnd = node?.seqRange?.[1] ?? node?.seq ?? 0;
  const payload = [
    node?.id || "",
    text,
    String(seqEnd),
    getVectorModelScope(config),
  ].join("::");
  return stableHashString(payload);
}

function buildBackendSourceRequest(config) {
  const body = {
    source: config.source,
    model: config.model,
  };

  if (BACKEND_SOURCES_REQUIRING_API_URL.has(config.source)) {
    body.apiUrl = config.apiUrl;
  }

  if (config.source === "ollama") {
    body.keep = false;
  }

  return body;
}

function getEligibleVectorNodes(graph, range = null) {
  let nodes = getActiveNodes(graph).filter((node) => !node.archived);

  if (range && Number.isFinite(range.start) && Number.isFinite(range.end)) {
    const start = Math.min(range.start, range.end);
    const end = Math.max(range.start, range.end);
    nodes = nodes.filter((node) => {
      const seqStart = node?.seqRange?.[0] ?? node?.seq ?? -1;
      const seqEnd = node?.seqRange?.[1] ?? node?.seq ?? -1;
      return seqEnd >= start && seqStart <= end;
    });
  }

  return nodes.filter((node) => buildNodeVectorText(node).length > 0);
}

function buildDesiredVectorEntries(graph, config, range = null, diagnostics = null) {
  const modelScope = getVectorModelScope(config);
  let textBuildMs = 0;
  let hashBuildMs = 0;
  const entries = getEligibleVectorNodes(graph, range).map((node) => {
    const textStartedAt = diagnostics ? nowMs() : 0;
    const text = buildNodeVectorText(node);
    if (diagnostics) {
      textBuildMs += nowMs() - textStartedAt;
    }
    const seqEnd = node?.seqRange?.[1] ?? node?.seq ?? 0;
    const hashStartedAt = diagnostics ? nowMs() : 0;
    const payload = [node?.id || "", text, String(seqEnd), modelScope].join("::");
    const hash = stableHashString(payload);
    if (diagnostics) {
      hashBuildMs += nowMs() - hashStartedAt;
    }
    return {
      nodeId: node.id,
      hash,
      text,
      index: seqEnd,
    };
  });

  if (diagnostics && typeof diagnostics === "object") {
    diagnostics.textBuildMs = roundMs(textBuildMs);
    diagnostics.hashBuildMs = roundMs(hashBuildMs);
    diagnostics.entryCount = entries.length;
  }

  return entries;
}

function computeVectorStats(graph, desiredEntries) {
  const state = graph.vectorIndexState || {};
  const desiredByNodeId = new Map(
    desiredEntries.map((entry) => [entry.nodeId, entry]),
  );
  const nodeToHash = state.nodeToHash || {};
  const hashToNodeId = state.hashToNodeId || {};

  let indexed = 0;
  let pending = 0;

  for (const entry of desiredEntries) {
    if (nodeToHash[entry.nodeId] === entry.hash) {
      indexed++;
    } else {
      pending++;
    }
  }

  let stale = 0;
  for (const [nodeId, hash] of Object.entries(nodeToHash)) {
    const desired = desiredByNodeId.get(nodeId);
    if (!desired || desired.hash !== hash || hashToNodeId[hash] !== nodeId) {
      stale++;
    }
  }

  return {
    total: desiredEntries.length,
    indexed,
    stale,
    pending,
  };
}

async function purgeVectorCollection(collectionId, signal) {
  throwIfAborted(signal);
  const response = await fetchWithTimeout(
    "/api/vector/purge",
    {
      method: "POST",
      headers: getRequestHeaders(),
      signal,
      body: JSON.stringify({ collectionId }),
    },
    getConfiguredTimeoutMs(),
  );

  if (!response.ok) {
    const message = await response.text().catch(() => response.statusText);
    throw new Error(message || `HTTP ${response.status}`);
  }
}

async function deleteVectorHashes(collectionId, config, hashes, signal) {
  if (!Array.isArray(hashes) || hashes.length === 0) return;
  throwIfAborted(signal);

  const response = await fetchWithTimeout(
    "/api/vector/delete",
    {
      method: "POST",
      headers: getRequestHeaders(),
      signal,
      body: JSON.stringify({
        collectionId,
        hashes,
        ...buildBackendSourceRequest(config),
      }),
    },
    getConfiguredTimeoutMs(config),
  );

  if (!response.ok) {
    const message = await response.text().catch(() => response.statusText);
    throw new Error(message || `HTTP ${response.status}`);
  }
}

export async function deleteBackendVectorHashesForRecovery(
  collectionId,
  config,
  hashes,
  signal = undefined,
) {
  if (!collectionId || !isBackendVectorConfig(config)) return;
  await deleteVectorHashes(collectionId, config, hashes, signal);
}

async function insertVectorEntries(collectionId, config, entries, signal) {
  if (!Array.isArray(entries) || entries.length === 0) return;
  throwIfAborted(signal);

  const response = await fetchWithTimeout(
    "/api/vector/insert",
    {
      method: "POST",
      headers: getRequestHeaders(),
      signal,
      body: JSON.stringify({
        collectionId,
        items: entries.map((entry) => ({
          hash: entry.hash,
          text: entry.text,
          index: entry.index,
        })),
        ...buildBackendSourceRequest(config),
      }),
    },
    getConfiguredTimeoutMs(config),
  );

  if (!response.ok) {
    const message = await response.text().catch(() => response.statusText);
    throw new Error(message || `HTTP ${response.status}`);
  }
}

function resetVectorMappings(graph, config, chatId) {
  graph.vectorIndexState.mode = config.mode;
  graph.vectorIndexState.source = config.source || "";
  graph.vectorIndexState.modelScope = getVectorModelScope(config);
  graph.vectorIndexState.collectionId = buildVectorCollectionId(chatId);
  graph.vectorIndexState.hashToNodeId = {};
  graph.vectorIndexState.nodeToHash = {};
}

function getEmbeddingDimensionFromEntries(graph, entries = []) {
  const nodesById = new Map((graph?.nodes || []).map((node) => [String(node?.id || ""), node]));
  let dim = 0;
  for (const entry of entries || []) {
    const node = nodesById.get(String(entry?.nodeId || ""));
    const vector = Array.isArray(node?.embedding) ? node.embedding : [];
    if (!vector.length) continue;
    if (!dim) dim = vector.length;
    if (dim && vector.length !== dim) return -1;
  }
  return dim;
}

function updateVectorManifest(graph, config, {
  backend = "local",
  chatId = "",
  collectionId = "",
  graphRevision = 0,
  desiredEntries = [],
  observedDim = 0,
  status = "clean",
  failedNodeCount = 0,
  lastError = "",
} = {}) {
  if (!graph?.vectorIndexState) return null;
  const vectorSpace = observedDim > 0
    ? deriveVectorSpace(config, observedDim)
    : null;
  const manifest = createVectorManifest({
    backend,
    chatId: chatId || graph?.historyState?.chatId || "",
    collectionId: collectionId || graph.vectorIndexState.collectionId || "",
    graphRevision,
    vectorSpace,
    status,
    nodeCount: desiredEntries.length,
    embeddedNodeCount: Math.max(0, desiredEntries.length - failedNodeCount),
    failedNodeCount,
    lastError,
  });
  graph.vectorIndexState.currentVectorSpace = vectorSpace;
  graph.vectorIndexState.manifest = manifest;
  return manifest;
}

function markLocalVectorManifestStale(graph, config, reason = "vector-space-changed") {
  if (!graph?.vectorIndexState) return;
  const state = graph.vectorIndexState;
  const previousManifest = state.manifest && typeof state.manifest === "object"
    ? state.manifest
    : null;
  state.manifest = {
    ...(previousManifest || createVectorManifest({ backend: "local", status: "stale" })),
    backend: previousManifest?.backend || "local",
    status: "stale",
    lastError: reason,
    completedAt: 0,
  };
  state.dirty = true;
  state.dirtyReason = reason;
  state.lastWarning = reason === "dimension-changed"
    ? "向量模型维度变化，索引已标记为待重建"
    : "向量模型配置变化，索引已标记为待重建";
}

function isVectorApplyCompatibilityError(error = null) {
  const detailCategory = String(error?.payload?.details?.category || error?.details?.category || "").trim();
  const message = String(error?.message || "").toLowerCase();
  return detailCategory === "vector-dimension-mismatch" ||
    detailCategory === "vector-space-mismatch" ||
    message.includes("dimension mismatch") ||
    message.includes("vectorspaceid mismatch") ||
    message.includes("single vector dimension");
}

function markBackendVectorStateDirty(
  graph,
  config,
  reason = "backend-query-failed",
  warning = "后端向量查询失败，已标记待重建",
) {
  if (!graph?.vectorIndexState || !isBackendVectorConfig(config)) {
    return;
  }

  const state = graph.vectorIndexState;
  const total = Math.max(
    Number(state.lastStats?.total || 0),
    Object.keys(state.nodeToHash || {}).length,
    Object.keys(state.hashToNodeId || {}).length,
  );
  const previousIndexed = Number.isFinite(Number(state.lastStats?.indexed))
    ? Math.max(0, Math.floor(Number(state.lastStats.indexed)))
    : 0;
  const previousStale = Number.isFinite(Number(state.lastStats?.stale))
    ? Math.max(0, Math.floor(Number(state.lastStats.stale)))
    : 0;
  const previousPending = Number.isFinite(Number(state.lastStats?.pending))
    ? Math.max(0, Math.floor(Number(state.lastStats.pending)))
    : 0;

  state.mode = "backend";
  state.source = config.source || state.source || "";
  state.modelScope = getVectorModelScope(config) || state.modelScope || "";
  state.collectionId = buildVectorCollectionId(graph?.historyState?.chatId);
  state.dirty = true;
  state.dirtyReason = String(reason || "backend-query-failed");
  state.pendingRepairFromFloor = Number.isFinite(Number(state.pendingRepairFromFloor))
    ? Math.max(0, Math.floor(Number(state.pendingRepairFromFloor)))
    : 0;
  state.lastStats = {
    total,
    indexed: previousIndexed,
    stale: Math.max(previousStale, total > 0 ? 1 : 0),
    pending: total > 0 ? Math.max(1, previousPending) : previousPending,
  };
  state.lastWarning = String(warning || "后端向量查询失败，已标记待重建");
}

function markAuthorityVectorStateDirty(
  graph,
  config = {},
  reason = "authority-trivium-failed",
  warning = "Authority Trivium 索引失败，已标记待重建",
  diagnostics = {},
) {
  if (!graph?.vectorIndexState || !isAuthorityVectorConfig(config)) {
    return;
  }
  const state = graph.vectorIndexState;
  const total = Math.max(
    Number(state.lastStats?.total || 0),
    Object.keys(state.nodeToHash || {}).length,
    Object.keys(state.hashToNodeId || {}).length,
  );
  const previousIndexed = Number.isFinite(Number(state.lastStats?.indexed))
    ? Math.max(0, Math.floor(Number(state.lastStats.indexed)))
    : 0;
  state.mode = "authority";
  state.source = config.source || "authority-trivium";
  state.modelScope = getVectorModelScope(config) || state.modelScope || "";
  state.collectionId = state.collectionId || buildVectorCollectionId(graph?.historyState?.chatId);
  state.dirty = true;
  state.dirtyReason = String(reason || "authority-trivium-failed");
  state.pendingRepairFromFloor = Number.isFinite(Number(state.pendingRepairFromFloor))
    ? Math.max(0, Math.floor(Number(state.pendingRepairFromFloor)))
    : 0;
  state.lastStats = {
    total,
    indexed: previousIndexed,
    stale: total > 0 ? Math.max(1, Number(state.lastStats?.stale || 0)) : 0,
    pending: total > 0 ? Math.max(1, Number(state.lastStats?.pending || 0)) : 0,
  };
  state.lastWarning = String(warning || "Authority Trivium 索引失败，已标记待重建");
  const errorCategory = String(diagnostics.errorCategory || diagnostics.authorityErrorCategory || "").trim();
  const errorDomain = String(diagnostics.errorDomain || diagnostics.authorityErrorDomain || "").trim();
  if (errorCategory) state.lastErrorCategory = errorCategory;
  if (errorDomain) state.lastErrorDomain = errorDomain;
}

function getErrorCategory(error = null) {
  return String(error?.category || error?.errorCategory || "").trim();
}

function getErrorDomain(error = null, fallback = "") {
  if (!error) return "";
  if (error?.errorDomain) return String(error.errorDomain).trim();
  if (getErrorCategory(error)) return fallback || "authority";
  return fallback;
}

function getAuthorityDiagnosticsErrorPatch(error = null) {
  const errorCategory = getErrorCategory(error);
  const errorDomain = getErrorDomain(error, errorCategory ? "authority" : "");
  return {
    ...(errorCategory ? { errorCategory, authorityErrorCategory: errorCategory } : {}),
    ...(errorDomain ? { errorDomain, authorityErrorDomain: errorDomain } : {}),
    ...(Number(error?.status || 0) > 0 ? { status: Number(error.status) } : {}),
  };
}

function createEmbeddingProviderError(failures = 0) {
  const count = Math.max(0, Math.floor(Number(failures) || 0));
  const error = new Error(`Embedding provider failed for ${count} item(s)`);
  error.errorCategory = "embedding-provider";
  error.errorDomain = "embedding";
  return error;
}

async function ensureEntryEmbeddings(graph, entries = [], config = {}, signal = undefined) {
  const nodesById = new Map((graph?.nodes || []).map((node) => [String(node?.id || ""), node]));
  const entriesToEmbed = [];
  for (const entry of entries || []) {
    const node = nodesById.get(String(entry?.nodeId || ""));
    const hasEmbedding = Array.isArray(node?.embedding) && node.embedding.length > 0;
    if (node && !hasEmbedding) {
      entriesToEmbed.push({ entry, node });
    }
  }
  if (!entriesToEmbed.length) {
    return { requested: 0, failures: 0, elapsedMs: 0 };
  }
  throwIfAborted(signal);
  const startedAt = nowMs();
  const embeddings = await embedBatch(
    entriesToEmbed.map(({ entry }) => entry.text),
    config,
    { signal },
  );
  let failures = 0;
  for (let index = 0; index < entriesToEmbed.length; index++) {
    const embedding = embeddings[index];
    if (embedding) {
      entriesToEmbed[index].node.embedding = Array.from(embedding);
    } else {
      failures += 1;
    }
  }
  return {
    requested: entriesToEmbed.length,
    failures,
    elapsedMs: nowMs() - startedAt,
  };
}

export async function syncGraphVectorIndex(
  graph,
  config,
  {
    chatId = "",
    purge = false,
    force = false,
    range = null,
    signal = undefined,
    triviumClient = undefined,
    headerProvider = undefined,
    fetchImpl = undefined,
  } = {},
) {
  if (!graph || !config) {
    return {
      insertedHashes: [],
      stats: { total: 0, indexed: 0, stale: 0, pending: 0 },
      timings: null,
    };
  }
  throwIfAborted(signal);

  const syncStartedAt = nowMs();
  const syncMode = isAuthorityVectorConfig(config)
    ? "authority"
    : isBackendVectorConfig(config)
      ? "backend"
      : "direct";

  const validation = validateVectorConfig(config);
  if (!validation.valid) {
    graph.vectorIndexState.lastWarning = validation.error;
    graph.vectorIndexState.dirty = true;
    graph.vectorIndexState.lastTimings = {
      mode: syncMode,
      validationError: validation.error,
      totalMs: roundMs(nowMs() - syncStartedAt),
      updatedAt: Date.now(),
    };
    return {
      insertedHashes: [],
      stats: graph.vectorIndexState.lastStats,
      timings: graph.vectorIndexState.lastTimings,
    };
  }

  const state = graph.vectorIndexState;
  const collectionId = buildVectorCollectionId(
    chatId || graph?.historyState?.chatId,
  );
  const desiredBuildDiagnostics = {};
  const desiredBuildStartedAt = nowMs();
  const desiredEntries = buildDesiredVectorEntries(
    graph,
    config,
    range,
    desiredBuildDiagnostics,
  );
  const desiredBuildMs = nowMs() - desiredBuildStartedAt;
  const desiredByNodeId = new Map(
    desiredEntries.map((entry) => [entry.nodeId, entry]),
  );
  const insertedHashes = [];
  let backendPurgeMs = 0;
  let backendDeleteMs = 0;
  let backendInsertMs = 0;
  let authorityPurgeMs = 0;
  let authorityDeleteMs = 0;
  let authorityUpsertMs = 0;
  let authorityLinkMs = 0;
  let authorityPurgeDiagnostics = null;
  let authorityDeleteDiagnostics = null;
  let authorityUpsertDiagnostics = null;
  let authorityLinkDiagnostics = null;
  let embedBatchMs = 0;
  let deletedHashCount = 0;
  let deletedNodeCount = 0;
  let embeddingsRequested = 0;
  const hasConcreteRange =
    range && Number.isFinite(range.start) && Number.isFinite(range.end);
  const rangedNodeIds = new Set(desiredEntries.map((entry) => entry.nodeId));

  if (isAuthorityVectorConfig(config)) {
    const effectiveChatId = chatId || graph?.historyState?.chatId || "";
    const authorityOptions = {
      namespace: collectionId,
      collectionId,
      chatId: effectiveChatId,
      modelScope: getVectorModelScope(config),
      revision: graph?.meta?.revision || graph?.revision || 0,
      signal,
      triviumClient,
      headerProvider,
      fetchImpl,
    };
    const scopeChanged =
      state.mode !== "authority" ||
      state.source !== (config.source || "authority-trivium") ||
      state.modelScope !== getVectorModelScope(config) ||
      state.collectionId !== collectionId;
    const fullReset = purge || state.dirty || scopeChanged;

    try {
      if (fullReset) {
        const embeddingResult = await ensureEntryEmbeddings(graph, desiredEntries, config, signal);
        embeddingsRequested += embeddingResult.requested;
        embedBatchMs += embeddingResult.elapsedMs;
        if (embeddingResult.failures > 0) {
          throw createEmbeddingProviderError(embeddingResult.failures);
        }
        let appliedViaBme = false;
        if (config.bmeVectorApplyReady === true) {
          try {
            const applyStartedAt = nowMs();
            const applyResult = await applyAuthorityBmeVectorManifest(
              graph,
              config,
              desiredEntries,
              authorityOptions,
            );
            authorityUpsertMs += nowMs() - applyStartedAt;
            authorityUpsertDiagnostics = applyResult?.diagnostics || null;
            const observedDim = Number(applyResult?.manifest?.observedDim || getEmbeddingDimensionFromEntries(graph, desiredEntries) || 0);
            if (observedDim > 0) {
              updateVectorManifest(graph, config, {
                backend: "authority",
                chatId: effectiveChatId,
                collectionId,
                graphRevision: graph?.meta?.revision || graph?.revision || 0,
                desiredEntries,
                observedDim,
                status: "clean",
              });
            }
            authorityLinkDiagnostics = {
              operation: "bmeVectorApply:links",
              totalItems: Number(applyResult?.diagnostics?.linkItems || 0),
              linked: Number(applyResult?.diagnostics?.linked || 0),
              totalMs: 0,
            };
            resetVectorMappings(graph, config, effectiveChatId);
            appliedViaBme = true;
          } catch (applyError) {
            if (isAbortError(applyError)) throw applyError;
            if (isVectorApplyCompatibilityError(applyError)) throw applyError;
            console.warn("[ST-BME] BME 服务端向量 apply 失败，回退 Authority Trivium 旧路径:", applyError);
          }
        }
        if (!appliedViaBme) {
          const purgeStartedAt = nowMs();
          const purgeResult = await purgeAuthorityTriviumNamespace(config, authorityOptions);
          authorityPurgeMs += nowMs() - purgeStartedAt;
          authorityPurgeDiagnostics = purgeResult?.diagnostics || null;
          if (purgeResult?.truncated) {
            throw new Error(`Authority Trivium purge truncated after ${purgeResult.pages || 0} page(s)`);
          }
          resetVectorMappings(graph, config, effectiveChatId);
          const upsertStartedAt = nowMs();
          const upsertResult = await upsertAuthorityTriviumEntries(
            graph,
            config,
            desiredEntries,
            authorityOptions,
          );
          authorityUpsertMs += nowMs() - upsertStartedAt;
          authorityUpsertDiagnostics = upsertResult?.diagnostics || null;
        }
        for (const entry of desiredEntries) {
          state.hashToNodeId[entry.hash] = entry.nodeId;
          state.nodeToHash[entry.nodeId] = entry.hash;
          insertedHashes.push(entry.hash);
        }
      } else {
        const nodeIdsToDelete = [];
        const entriesToUpsert = [];
        const queuedNodeIds = new Set();

        if (force && hasConcreteRange) {
          for (const entry of desiredEntries) {
            entriesToUpsert.push(entry);
            queuedNodeIds.add(entry.nodeId);
          }
        }

        for (const [nodeId, hash] of Object.entries(state.nodeToHash || {})) {
          if (hasConcreteRange && !rangedNodeIds.has(nodeId)) {
            continue;
          }
          const desired = desiredByNodeId.get(nodeId);
          if (!desired) {
            nodeIdsToDelete.push(nodeId);
            delete state.nodeToHash[nodeId];
            delete state.hashToNodeId[hash];
          } else if (desired.hash !== hash && !queuedNodeIds.has(nodeId)) {
            entriesToUpsert.push(desired);
            queuedNodeIds.add(nodeId);
            delete state.hashToNodeId[hash];
          }
        }

        for (const entry of desiredEntries) {
          if (force && hasConcreteRange) continue;
          if (state.nodeToHash[entry.nodeId] === entry.hash) continue;
          if (queuedNodeIds.has(entry.nodeId)) continue;
          entriesToUpsert.push(entry);
          queuedNodeIds.add(entry.nodeId);
        }

        const embeddingResult = await ensureEntryEmbeddings(graph, entriesToUpsert, config, signal);
        embeddingsRequested += embeddingResult.requested;
        embedBatchMs += embeddingResult.elapsedMs;
        if (embeddingResult.failures > 0) {
          throw createEmbeddingProviderError(embeddingResult.failures);
        }
        deletedNodeCount = nodeIdsToDelete.length;
        let appliedViaBme = false;
        if (config.bmeVectorApplyReady === true && nodeIdsToDelete.length === 0) {
          try {
            const applyStartedAt = nowMs();
            const applyResult = await applyAuthorityBmeVectorManifest(
              graph,
              config,
              entriesToUpsert,
              authorityOptions,
            );
            authorityUpsertMs += nowMs() - applyStartedAt;
            authorityUpsertDiagnostics = applyResult?.diagnostics || null;
            const observedDim = Number(applyResult?.manifest?.observedDim || getEmbeddingDimensionFromEntries(graph, entriesToUpsert) || 0);
            if (observedDim > 0) {
              updateVectorManifest(graph, config, {
                backend: "authority",
                chatId: effectiveChatId,
                collectionId,
                graphRevision: graph?.meta?.revision || graph?.revision || 0,
                desiredEntries,
                observedDim,
                status: "clean",
              });
            }
            authorityLinkDiagnostics = {
              operation: "bmeVectorApply:links",
              totalItems: Number(applyResult?.diagnostics?.linkItems || 0),
              linked: Number(applyResult?.diagnostics?.linked || 0),
              totalMs: 0,
            };
            appliedViaBme = true;
          } catch (applyError) {
            if (isAbortError(applyError)) throw applyError;
            if (isVectorApplyCompatibilityError(applyError)) throw applyError;
            console.warn("[ST-BME] BME 服务端向量 apply 失败，回退 Authority Trivium 旧路径:", applyError);
          }
        }
        if (!appliedViaBme) {
          const deleteStartedAt = nowMs();
          const deleteResult = await deleteAuthorityTriviumNodes(config, nodeIdsToDelete, authorityOptions);
          authorityDeleteMs += nowMs() - deleteStartedAt;
          authorityDeleteDiagnostics = deleteResult?.diagnostics || null;
          const upsertStartedAt = nowMs();
          const upsertResult = await upsertAuthorityTriviumEntries(
            graph,
            config,
            entriesToUpsert,
            authorityOptions,
          );
          authorityUpsertMs += nowMs() - upsertStartedAt;
          authorityUpsertDiagnostics = upsertResult?.diagnostics || null;
        }

        for (const entry of entriesToUpsert) {
          state.hashToNodeId[entry.hash] = entry.nodeId;
          state.nodeToHash[entry.nodeId] = entry.hash;
          insertedHashes.push(entry.hash);
        }
      }

      if (!authorityLinkDiagnostics || authorityLinkDiagnostics.operation !== "bmeVectorApply:links") {
        const linkStartedAt = nowMs();
        const linkResult = await syncAuthorityTriviumLinks(graph, config, authorityOptions);
        authorityLinkMs += nowMs() - linkStartedAt;
        authorityLinkDiagnostics = linkResult?.diagnostics || null;
      }

      for (const node of graph.nodes || []) {
        if (Array.isArray(node.embedding) && node.embedding.length > 0) {
          node.embedding = null;
        }
      }
      state.mode = "authority";
      state.source = config.source || "authority-trivium";
      state.modelScope = getVectorModelScope(config);
      state.collectionId = collectionId;
      state.dirty = false;
      state.lastWarning = "";
    } catch (error) {
      if (isAbortError(error)) throw error;
      const message = error?.message || String(error) || "Authority Trivium 同步失败";
      const errorCategory = getErrorCategory(error);
      const errorDomain = getErrorDomain(error, errorCategory ? "authority" : "");
      const dirtyReason = errorDomain === "embedding"
        ? "embedding-provider-sync-failed"
        : "authority-trivium-sync-failed";
      const warningPrefix = errorDomain === "embedding"
        ? "Embedding provider 同步失败"
        : "Authority Trivium 同步失败";
      markAuthorityVectorStateDirty(
        graph,
        config,
        dirtyReason,
        `${warningPrefix}（${message}），已标记待重建`,
        { errorCategory, errorDomain },
      );
      state.lastSyncAt = Date.now();
      state.lastTimings = {
        mode: syncMode,
        success: false,
        error: message,
        ...(errorCategory ? { errorCategory } : {}),
        ...(errorDomain ? { errorDomain } : {}),
        ...(errorCategory && errorDomain === "authority" ? { authorityErrorCategory: errorCategory, authorityErrorDomain: errorDomain } : {}),
        desiredEntries: Number(desiredBuildDiagnostics.entryCount || desiredEntries.length),
        desiredBuildMs: roundMs(desiredBuildMs),
        authorityPurgeMs: roundMs(authorityPurgeMs),
        authorityDeleteMs: roundMs(authorityDeleteMs),
        authorityUpsertMs: roundMs(authorityUpsertMs),
        authorityLinkMs: roundMs(authorityLinkMs),
        authorityDiagnostics: {
          purge: authorityPurgeDiagnostics,
          delete: authorityDeleteDiagnostics,
          upsert: error?.authorityDiagnostics || authorityUpsertDiagnostics,
          link: authorityLinkDiagnostics,
        },
        totalMs: roundMs(nowMs() - syncStartedAt),
        updatedAt: Date.now(),
      };
      const result = {
        insertedHashes,
        stats: state.lastStats,
        timings: state.lastTimings,
        error: message,
        ...(errorCategory ? { errorCategory } : {}),
        ...(errorDomain ? { errorDomain } : {}),
      };
      if (config.failOpen === false) {
        throw error;
      }
      return result;
    }
  } else if (isBackendVectorConfig(config)) {
    const scopeChanged =
      state.mode !== "backend" ||
      state.source !== config.source ||
      state.modelScope !== getVectorModelScope(config) ||
      state.collectionId !== collectionId;
    const fullReset =
      purge || state.dirty || scopeChanged || (force && !hasConcreteRange);

    if (fullReset) {
      const purgeStartedAt = nowMs();
      await purgeVectorCollection(collectionId, signal);
      backendPurgeMs += nowMs() - purgeStartedAt;
      resetVectorMappings(graph, config, chatId);
      const insertStartedAt = nowMs();
      await insertVectorEntries(collectionId, config, desiredEntries, signal);
      backendInsertMs += nowMs() - insertStartedAt;
      for (const entry of desiredEntries) {
        state.hashToNodeId[entry.hash] = entry.nodeId;
        state.nodeToHash[entry.nodeId] = entry.hash;
        insertedHashes.push(entry.hash);
      }
    } else {
      const hashesToDelete = [];
      const entriesToInsert = [];

      if (force && hasConcreteRange) {
        for (const entry of desiredEntries) {
          const currentHash = state.nodeToHash[entry.nodeId];
          if (currentHash) {
            hashesToDelete.push(currentHash);
            delete state.hashToNodeId[currentHash];
            delete state.nodeToHash[entry.nodeId];
          }
          entriesToInsert.push(entry);
        }
      }

      for (const [nodeId, hash] of Object.entries(state.nodeToHash)) {
        if (hasConcreteRange && !rangedNodeIds.has(nodeId)) {
          continue;
        }
        const desired = desiredByNodeId.get(nodeId);
        if (!desired || desired.hash !== hash) {
          hashesToDelete.push(hash);
          delete state.nodeToHash[nodeId];
          delete state.hashToNodeId[hash];
        }
      }

      for (const entry of desiredEntries) {
        if (force && hasConcreteRange) continue;
        if (state.nodeToHash[entry.nodeId] === entry.hash) continue;
        entriesToInsert.push(entry);
      }

      deletedHashCount = hashesToDelete.length;
      const deleteStartedAt = nowMs();
      await deleteVectorHashes(collectionId, config, hashesToDelete, signal);
      backendDeleteMs += nowMs() - deleteStartedAt;
      const insertStartedAt = nowMs();
      await insertVectorEntries(collectionId, config, entriesToInsert, signal);
      backendInsertMs += nowMs() - insertStartedAt;

      for (const entry of entriesToInsert) {
        state.hashToNodeId[entry.hash] = entry.nodeId;
        state.nodeToHash[entry.nodeId] = entry.hash;
        insertedHashes.push(entry.hash);
      }
    }

    for (const node of graph.nodes || []) {
      if (Array.isArray(node.embedding) && node.embedding.length > 0) {
        node.embedding = null;
      }
    }
  } else {
    const directScopeChanged =
      state.mode !== "direct" ||
      state.modelScope !== getVectorModelScope(config) ||
      state.collectionId !== collectionId;
    if (directScopeChanged && state.manifest?.vectorSpaceId) {
      const previous = state.currentVectorSpace || {
        vectorSpaceId: state.manifest.vectorSpaceId,
        observedDim: state.manifest.observedDim,
        model: state.manifest.model,
        normalizedApiUrl: state.manifest.normalizedApiUrl,
      };
      const current = deriveVectorSpace(config, Number(state.manifest.observedDim || 0));
      markLocalVectorManifestStale(
        graph,
        config,
        summarizeVectorSpaceChange(previous, current),
      );
    }
    const clearStaleDirectEmbeddings =
      directScopeChanged || purge || state.dirty || force;
    if (clearStaleDirectEmbeddings) {
      const rangedIds = hasConcreteRange ? rangedNodeIds : null;
      for (const node of graph.nodes || []) {
        if (rangedIds && !rangedIds.has(node.id)) continue;
        if (Array.isArray(node?.embedding) && node.embedding.length > 0) {
          node.embedding = null;
        }
      }
      for (const [nodeId, hash] of Object.entries(state.nodeToHash || {})) {
        if (rangedIds && !rangedIds.has(nodeId)) continue;
        delete state.nodeToHash[nodeId];
        if (state.hashToNodeId) {
          delete state.hashToNodeId[hash];
        }
      }
    }
    const entriesToEmbed = [];
    const hashByNodeId = {};
    const currentDim = Number(state.currentVectorSpace?.observedDim || state.manifest?.observedDim || 0);
    const currentVectorSpace = currentDim > 0
      ? deriveVectorSpace(config, currentDim)
      : state.currentVectorSpace;
    const mayReuseExistingEmbeddings =
      !directScopeChanged &&
      !force &&
      !purge &&
      state.dirty !== true &&
      isVectorManifestCompatible(state.manifest, currentVectorSpace);

    for (const entry of desiredEntries) {
      hashByNodeId[entry.nodeId] = entry.hash;
      const currentHash = state.nodeToHash?.[entry.nodeId];
      const node = graph.nodes.find(
        (candidate) => candidate.id === entry.nodeId,
      );
      const hasEmbedding =
        Array.isArray(node?.embedding) && node.embedding.length > 0;

      if (mayReuseExistingEmbeddings && !currentHash && hasEmbedding) {
        state.hashToNodeId[entry.hash] = entry.nodeId;
        state.nodeToHash[entry.nodeId] = entry.hash;
        continue;
      }

      if (force || purge || currentHash !== entry.hash || !hasEmbedding) {
        entriesToEmbed.push(entry);
      }
    }

    if (purge || directScopeChanged) {
      resetVectorMappings(graph, config, chatId);
    } else {
      for (const [nodeId, hash] of Object.entries(state.nodeToHash || {})) {
        if (hasConcreteRange && !rangedNodeIds.has(nodeId)) {
          continue;
        }
        if (!hashByNodeId[nodeId]) {
          delete state.nodeToHash[nodeId];
          delete state.hashToNodeId[hash];
        }
      }
    }

    let directSyncHadFailures = false;
    if (entriesToEmbed.length > 0) {
      throwIfAborted(signal);
      embeddingsRequested = entriesToEmbed.length;
      const embedStartedAt = nowMs();
      const embeddings = await embedBatch(
        entriesToEmbed.map((entry) => entry.text),
        config,
        { signal },
      );
      embedBatchMs += nowMs() - embedStartedAt;

      for (let index = 0; index < entriesToEmbed.length; index++) {
        const entry = entriesToEmbed[index];
        const node = graph.nodes.find(
          (candidate) => candidate.id === entry.nodeId,
        );
        if (!node) continue;

        if (embeddings[index]) {
          node.embedding = Array.from(embeddings[index]);
          state.hashToNodeId[entry.hash] = entry.nodeId;
          state.nodeToHash[entry.nodeId] = entry.hash;
          insertedHashes.push(entry.hash);
        } else {
          directSyncHadFailures = true;
        }
      }
    }

    state.mode = "direct";
    state.source = "direct";
    state.modelScope = getVectorModelScope(config);
    state.collectionId = collectionId;
    state.dirty = directSyncHadFailures;
    state.dirtyReason = directSyncHadFailures ? "partial-embedding-failure" : "";
    state.lastWarning = directSyncHadFailures
      ? "部分节点 embedding 生成失败，向量索引仍待修复"
      : "";
    const observedDim = getEmbeddingDimensionFromEntries(graph, desiredEntries);
    if (observedDim < 0) {
      updateVectorManifest(graph, config, {
        backend: "local",
        chatId,
        collectionId,
        graphRevision: graph?.meta?.revision || graph?.revision || 0,
        desiredEntries,
        observedDim: 0,
        status: "failed",
        failedNodeCount: desiredEntries.length,
        lastError: "mixed-dimensions",
      });
      state.dirty = true;
      state.dirtyReason = "mixed-vector-dimensions";
      state.lastWarning = "检测到混合向量维度，索引已标记为待重建";
    } else if (observedDim > 0) {
      updateVectorManifest(graph, config, {
        backend: "local",
        chatId,
        collectionId,
        graphRevision: graph?.meta?.revision || graph?.revision || 0,
        desiredEntries,
        observedDim,
        status: directSyncHadFailures ? "dirty" : "clean",
        failedNodeCount: directSyncHadFailures ? Math.max(1, desiredEntries.length - insertedHashes.length) : 0,
        lastError: directSyncHadFailures ? "partial-embedding-failure" : "",
      });
    } else {
      updateVectorManifest(graph, config, {
        backend: "local",
        chatId,
        collectionId,
        graphRevision: graph?.meta?.revision || graph?.revision || 0,
        desiredEntries,
        observedDim: 0,
        status: "missing",
        failedNodeCount: desiredEntries.length,
        lastError: "no-vectors",
      });
    }
  }

  if (state.mode !== "direct") {
    state.dirty = false;
    state.lastWarning = "";
  }
  state.lastSyncAt = Date.now();
  const statsBuildStartedAt = nowMs();
  state.lastStats = computeVectorStats(
    graph,
    buildDesiredVectorEntries(graph, config),
  );
  const statsBuildMs = nowMs() - statsBuildStartedAt;
  state.lastTimings = {
    mode: syncMode,
    desiredEntries: Number(desiredBuildDiagnostics.entryCount || desiredEntries.length),
    desiredBuildMs: roundMs(desiredBuildMs),
    textBuildMs: Number(desiredBuildDiagnostics.textBuildMs || 0),
    hashBuildMs: Number(desiredBuildDiagnostics.hashBuildMs || 0),
    backendPurgeMs: roundMs(backendPurgeMs),
    backendDeleteMs: roundMs(backendDeleteMs),
    backendInsertMs: roundMs(backendInsertMs),
    authorityPurgeMs: roundMs(authorityPurgeMs),
    authorityDeleteMs: roundMs(authorityDeleteMs),
    authorityUpsertMs: roundMs(authorityUpsertMs),
    authorityLinkMs: roundMs(authorityLinkMs),
    authorityDiagnostics: {
      purge: authorityPurgeDiagnostics,
      delete: authorityDeleteDiagnostics,
      upsert: authorityUpsertDiagnostics,
      link: authorityLinkDiagnostics,
    },
    embedBatchMs: roundMs(embedBatchMs),
    statsBuildMs: roundMs(statsBuildMs),
    deletedHashes: Math.max(0, Math.floor(deletedHashCount)),
    deletedNodes: Math.max(0, Math.floor(deletedNodeCount)),
    insertedEntries: insertedHashes.length,
    embeddingsRequested: Math.max(0, Math.floor(embeddingsRequested)),
    totalMs: roundMs(nowMs() - syncStartedAt),
    updatedAt: Date.now(),
  };

  return {
    insertedHashes,
    stats: state.lastStats,
    timings: state.lastTimings,
  };
}

export async function findSimilarNodesByText(
  graph,
  text,
  config,
  topK = 10,
  candidates = null,
  signal = undefined,
  options = {},
) {
  if (!text || !graph || !config) return [];
  throwIfAborted(signal);
  const readOnly = options?.readOnly === true;

  const candidateNodes = Array.isArray(candidates)
    ? candidates
    : getEligibleVectorNodes(graph);
  const searchStartedAt = nowMs();
  const mode = isAuthorityVectorConfig(config)
    ? "authority"
    : isDirectVectorConfig(config)
      ? "direct"
      : "backend";
  const recordSearchTimings = (patch = {}) => {
    if (readOnly) return;
    const state = graph?.vectorIndexState;
    if (!state || typeof state !== "object" || Array.isArray(state)) return;
    state.lastSearchTimings = {
      ...(state.lastSearchTimings &&
      typeof state.lastSearchTimings === "object" &&
      !Array.isArray(state.lastSearchTimings)
        ? state.lastSearchTimings
        : {}),
      mode,
      queryLength: String(text || "").length,
      candidateCount: candidateNodes.length,
      topK: Math.max(1, Math.floor(Number(topK) || 1)),
      ...patch,
      totalMs: roundMs(nowMs() - searchStartedAt),
      updatedAt: Date.now(),
    };
  };

  if (candidateNodes.length === 0) {
    recordSearchTimings({
      success: true,
      reason: "no-candidates",
      resultCount: 0,
    });
    return [];
  }

  if (isDirectVectorConfig(config)) {
    const state = graph?.vectorIndexState || {};
    const currentDim = Number(state.currentVectorSpace?.observedDim || state.manifest?.observedDim || 0);
    const currentVectorSpace = currentDim > 0
      ? deriveVectorSpace(config, currentDim)
      : state.currentVectorSpace;
    if (!isVectorManifestCompatible(state.manifest, currentVectorSpace)) {
      recordSearchTimings({
        success: false,
        reason: "vector-space-mismatch",
        resultCount: 0,
      });
      if (!readOnly && state) {
        state.dirty = true;
        state.dirtyReason = "vector-space-mismatch";
        state.lastWarning = "向量空间不匹配，已切换到非向量召回并等待重建";
      }
      return [];
    }
    const queryEmbedStartedAt = nowMs();
    const queryVec = await embedText(text, config, { signal, isQuery: true });
    const queryEmbedMs = nowMs() - queryEmbedStartedAt;
    if (!queryVec) {
      recordSearchTimings({
        success: false,
        reason: "direct-query-embed-empty",
        queryEmbedMs: roundMs(queryEmbedMs),
        resultCount: 0,
      });
      return [];
    }
    if (currentDim > 0 && queryVec.length !== currentDim) {
      recordSearchTimings({
        success: false,
        reason: "query-dimension-mismatch",
        queryDim: queryVec.length,
        expectedDim: currentDim,
        queryEmbedMs: roundMs(queryEmbedMs),
        resultCount: 0,
      });
      if (!readOnly) {
        state.dirty = true;
        state.dirtyReason = "query-dimension-mismatch";
        state.lastWarning = `查询向量维度 ${queryVec.length} 与索引维度 ${currentDim} 不一致，已切换到非向量召回`;
        if (state.manifest) {
          state.manifest = { ...state.manifest, status: "stale", lastError: "query-dimension-mismatch" };
        }
      }
      return [];
    }

    const localSearchStartedAt = nowMs();
    const results = searchSimilar(
      queryVec,
      candidateNodes
        .filter(
          (node) => Array.isArray(node.embedding) && node.embedding.length > 0,
        )
        .map((node) => ({
          nodeId: node.id,
          embedding: node.embedding,
        })),
      topK,
    );
    recordSearchTimings({
      success: true,
      reason: "ok",
      queryEmbedMs: roundMs(queryEmbedMs),
      searchMs: roundMs(nowMs() - localSearchStartedAt),
      resultCount: results.length,
    });
    return results;
  }

  const validation = validateVectorConfig(config);
  if (!validation.valid) {
    recordSearchTimings({
      success: false,
      reason: "vector-config-invalid",
      error: validation.error,
      resultCount: 0,
    });
    return [];
  }

  if (isAuthorityVectorConfig(config)) {
    const state = graph?.vectorIndexState || {};
    if (config.bmeVectorApplyReady === true || config.bmeVectorManifestReady === true) {
      const currentDim = Number(state.currentVectorSpace?.observedDim || state.manifest?.observedDim || 0);
      const currentVectorSpace = currentDim > 0
        ? deriveVectorSpace(config, currentDim)
        : state.currentVectorSpace;
      if (!isVectorManifestCompatible(state.manifest, currentVectorSpace)) {
        recordSearchTimings({
          success: false,
          reason: "authority-vector-space-mismatch",
          resultCount: 0,
        });
        if (!readOnly) {
          state.dirty = true;
          state.dirtyReason = "authority-vector-space-mismatch";
          state.lastWarning = "Authority 向量空间不匹配，已切换到非向量召回并等待重建";
        }
        return [];
      }
    }
    const requestStartedAt = nowMs();
    try {
      const queryEmbedStartedAt = nowMs();
      const queryVec = await embedText(text, config, { signal, isQuery: true });
      const queryEmbedMs = nowMs() - queryEmbedStartedAt;
      if (!queryVec) {
        recordSearchTimings({
          success: false,
          reason: "authority-query-embed-empty",
          queryEmbedMs: roundMs(queryEmbedMs),
          resultCount: 0,
        });
        return [];
      }
      const allowedIds = new Set(candidateNodes.map((node) => node.id));
      const results = (
        await searchAuthorityTriviumNodes(graph, text, config, {
          namespace: graph.vectorIndexState?.collectionId,
          collectionId: graph.vectorIndexState?.collectionId,
          chatId: graph?.historyState?.chatId || "",
          modelScope: getVectorModelScope(config),
          topK,
          candidateIds: candidateNodes.map((node) => node.id),
          queryVector: Array.from(queryVec),
          signal,
        })
      )
        .filter((entry) => entry.nodeId && allowedIds.has(entry.nodeId))
        .slice(0, topK);
      recordSearchTimings({
        success: true,
        reason: "ok",
        queryEmbedMs: roundMs(queryEmbedMs),
        requestMs: roundMs(nowMs() - requestStartedAt),
        resultCount: results.length,
      });
      return results;
    } catch (error) {
      if (isAbortError(error)) {
        recordSearchTimings({
          success: false,
          reason: "aborted",
          error: error?.message || String(error),
        });
        throw error;
      }
      const message = error?.message || String(error) || "Authority Trivium 查询失败";
      const errorPatch = getAuthorityDiagnosticsErrorPatch(error);
      if (!readOnly) {
        markAuthorityVectorStateDirty(
          graph,
          config,
          "authority-trivium-query-failed",
          `Authority Trivium 查询失败（${message}），已标记待重建`,
          errorPatch,
        );
      }
      recordSearchTimings({
        success: false,
        reason: "authority-trivium-query-failed",
        requestMs: roundMs(nowMs() - requestStartedAt),
        error: message,
        ...errorPatch,
        resultCount: 0,
      });
      if (config.failOpen === false) {
        throw error;
      }
      return [];
    }
  }

  try {
    const requestStartedAt = nowMs();
    const response = await fetchWithTimeout(
      "/api/vector/query",
      {
        method: "POST",
        headers: getRequestHeaders(),
        signal,
        body: JSON.stringify({
          collectionId: graph.vectorIndexState.collectionId,
          searchText: text,
          topK,
          threshold: 0,
          ...buildBackendSourceRequest(config),
        }),
      },
      getConfiguredTimeoutMs(config),
    );
    const requestMs = nowMs() - requestStartedAt;

    if (!response.ok) {
      const errorText = await response.text().catch(() => response.statusText);
      const message = errorText || response.statusText || `HTTP ${response.status}`;
      console.warn("[ST-BME] 后端向量查询失败:", message);
      markBackendVectorStateDirty(
        graph,
        config,
        "backend-query-failed",
        `后端向量查询失败（${message}），已标记待重建`,
      );
      recordSearchTimings({
        success: false,
        reason: "backend-query-http-failed",
        statusCode: Number(response.status || 0),
        requestMs: roundMs(requestMs),
        error: message,
        resultCount: 0,
      });
      return [];
    }

    const parseStartedAt = nowMs();
    const data = await response.json().catch(() => ({ hashes: [] }));
    const parseMs = nowMs() - parseStartedAt;
    const hashes = Array.isArray(data?.hashes) ? data.hashes : [];
    const nodeIdByHash = graph.vectorIndexState?.hashToNodeId || {};
    const allowedIds = new Set(candidateNodes.map((node) => node.id));

    const results = hashes
      .map((hash, index) => ({
        nodeId: nodeIdByHash[hash],
        score: Math.max(0.01, 1 - index / Math.max(1, hashes.length)),
      }))
      .filter((entry) => entry.nodeId && allowedIds.has(entry.nodeId))
      .slice(0, topK);
    recordSearchTimings({
      success: true,
      reason: "ok",
      requestMs: roundMs(requestMs),
      parseMs: roundMs(parseMs),
      resultCount: results.length,
      hashCount: hashes.length,
    });
    return results;
  } catch (error) {
    if (isAbortError(error)) {
      recordSearchTimings({
        success: false,
        reason: "aborted",
        error: error?.message || String(error),
      });
      throw error;
    }
    const message = error?.message || String(error) || "后端向量查询失败";
    markBackendVectorStateDirty(
      graph,
      config,
      "backend-query-failed",
      `后端向量查询失败（${message}），已标记待重建`,
    );
    recordSearchTimings({
      success: false,
      reason: "backend-query-exception",
      error: message,
    });
    throw error;
  }
}

export async function testVectorConnection(config, chatId = "connection-test") {
  const validation = validateVectorConfig(config);
  if (!validation.valid) {
    return { success: false, dimensions: 0, error: validation.error };
  }

  if (isDirectVectorConfig(config) || isBackendVectorConfig(config)) {
    try {
      const vectors = await embedBatch(VECTOR_CONNECTION_PROBE_TEXTS, {
        ...config,
        throwOnEmptyBatch: true,
      }, {
        isQuery: true,
      });
      const firstVector = vectors.find((vector) => vector && vector.length > 0);
      if (firstVector) {
        if (isBackendVectorConfig(config)) {
          const response = await fetchWithTimeout(
            "/api/vector/query",
            {
              method: "POST",
              headers: getRequestHeaders(),
              body: JSON.stringify({
                collectionId: buildVectorCollectionId(chatId),
                searchText: VECTOR_CONNECTION_PROBE_TEXTS[0],
                topK: 1,
                threshold: 0,
                ...buildBackendSourceRequest(config),
              }),
            },
            getConfiguredTimeoutMs(config),
          );
          const payload = await response.text().catch(() => "");
          if (!response.ok) {
            return {
              success: false,
              dimensions: firstVector.length,
              error: payload || response.statusText,
              batchCapable: true,
              vectorStoreCapable: false,
              mode: config.mode,
              source: config.source || "backend",
            };
          }
        }
        return {
          success: true,
          dimensions: firstVector.length,
          error: "",
          batchCapable: true,
          vectorStoreCapable: isBackendVectorConfig(config) ? true : undefined,
          mode: config.mode,
          source: config.source || "direct",
        };
      }
      return {
        success: false,
        dimensions: 0,
        error: "批量 Embedding API 返回空结果",
        batchCapable: false,
        mode: config.mode,
        source: config.source || "direct",
      };
    } catch (error) {
      return {
        success: false,
        dimensions: 0,
        error: String(error),
        batchCapable: false,
        mode: config.mode,
        source: config.source || "direct",
      };
    }
  }

  if (isAuthorityVectorConfig(config)) {
    try {
      return await testAuthorityTriviumConnection(config, {
        collectionId: buildVectorCollectionId(chatId),
        chatId,
      });
    } catch (error) {
      return { success: false, dimensions: 0, error: String(error) };
    }
  }

  return { success: false, dimensions: 0, error: "未知向量配置" };
}
export function getVectorIndexStats(graph) {
  const state = graph?.vectorIndexState;
  if (!state) {
    return { total: 0, indexed: 0, stale: 0, pending: 0 };
  }
  return state.lastStats || { total: 0, indexed: 0, stale: 0, pending: 0 };
}

function normalizeModelOptions(items = [], { embeddingOnly = false } = {}) {
  if (!Array.isArray(items)) return [];

  const candidates = [];
  for (const item of items) {
    if (typeof item === "string") {
      const id = item.trim();
      if (id) candidates.push({ id, label: id, raw: item });
      continue;
    }

    if (!item || typeof item !== "object") continue;
    const id = String(
      item.id || item.name || item.label || item.slug || item.value || "",
    ).trim();
    const label = String(
      item.label || item.name || item.id || item.slug || item.value || "",
    ).trim();
    if (!id) continue;

    if (
      embeddingOnly &&
      Array.isArray(item.endpoints) &&
      !item.endpoints.includes("/v1/embeddings")
    ) {
      continue;
    }

    candidates.push({ id, label: label || id, raw: item });
  }

  const embeddingRegex =
    /(embed|embedding|bge|e5|gte|nomic|voyage|mxbai|jina|minilm)/i;
  const embeddingTagged = candidates.filter(
    (item) => embeddingRegex.test(item.id) || embeddingRegex.test(item.label),
  );
  const source = embeddingTagged.length > 0 ? embeddingTagged : candidates;

  const seen = new Set();
  return source
    .filter((item) => {
      if (seen.has(item.id)) return false;
      seen.add(item.id);
      return true;
    })
    .map(({ id, label }) => ({ id, label }));
}

async function fetchJsonEndpoint(url, { method = "POST" } = {}) {
  const response = await fetchWithTimeout(url, {
    method,
    headers: getRequestHeaders({ omitContentType: true }),
  });

  const payload = await response.json().catch(() => []);
  if (!response.ok) {
    throw new Error(
      (typeof payload === "object" && payload?.error) ||
        response.statusText ||
        `HTTP ${response.status}`,
    );
  }

  return payload;
}

async function fetchBackendStatusModelList(source) {
  const chatCompletionSource = BACKEND_STATUS_MODEL_SOURCES[source];
  if (!chatCompletionSource) {
    throw new Error("当前后端向量源暂不支持自动拉取模型，请手动填写");
  }

  const response = await fetchWithTimeout(
    "/api/backends/chat-completions/status",
    {
      method: "POST",
      headers: getRequestHeaders(),
      body: JSON.stringify({
        chat_completion_source: chatCompletionSource,
      }),
    },
  );

  const payload = await response.json().catch(() => ({}));
  if (!response.ok || payload?.error) {
    throw new Error(
      payload?.message ||
        payload?.error ||
        response.statusText ||
        `HTTP ${response.status}`,
    );
  }

  return normalizeModelOptions(payload?.data || payload, {
    embeddingOnly: false,
  });
}

async function fetchOpenAICompatibleModelList(apiUrl, apiKey = "") {
  const normalizedUrl = normalizeOpenAICompatibleBaseUrl(apiUrl);
  if (!normalizedUrl) {
    throw new Error("请先填写 API 地址");
  }

  const response = await fetchWithTimeout(`${normalizedUrl}/models`, {
    method: "GET",
    headers: {
      ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
    },
  });

  const payload = await response.json().catch(() => ({}));
  if (!response.ok) {
    throw new Error(
      payload?.error?.message || payload?.message || response.statusText,
    );
  }

  return normalizeModelOptions(payload?.data || payload, {
    embeddingOnly: false,
  });
}

async function fetchOllamaModelList(apiUrl) {
  const normalizedUrl = normalizeOpenAICompatibleBaseUrl(apiUrl).replace(
    /\/v1$/i,
    "",
  );
  if (!normalizedUrl) {
    throw new Error("请先填写 Ollama API 地址");
  }

  const response = await fetchWithTimeout(`${normalizedUrl}/api/tags`, {
    method: "GET",
  });
  const payload = await response.json().catch(() => ({}));
  if (!response.ok) {
    throw new Error(payload?.error || payload?.message || response.statusText);
  }

  return normalizeModelOptions(
    Array.isArray(payload?.models)
      ? payload.models.map((item) => ({
          id: item?.model || item?.name,
          name: item?.model || item?.name,
        }))
      : [],
    { embeddingOnly: false },
  );
}

export async function fetchAvailableEmbeddingModels(config) {
  const validation = validateVectorConfig(config);
  if (!validation.valid) {
    return { success: false, models: [], error: validation.error };
  }

  try {
    if (isAuthorityVectorConfig(config)) {
      return {
        success: false,
        models: [],
        error: "Authority Trivium 使用服务端索引配置，无需拉取 Embedding 模型",
      };
    }

    if (isDirectVectorConfig(config)) {
      const models = normalizeModelOptions(
        await fetchOpenAICompatibleModelList(config.apiUrl, config.apiKey),
      );
      if (models.length === 0) {
        return {
          success: false,
          models: [],
          error: "未拉取到可用 Embedding 模型",
        };
      }
      return { success: true, models, error: "" };
    }

    if (config.source === "ollama") {
      const models = await fetchOllamaModelList(config.apiUrl);
      if (models.length === 0) {
        return {
          success: false,
          models: [],
          error: "未拉取到可用 Ollama 模型",
        };
      }
      return { success: true, models, error: "" };
    }

    if (MODEL_LIST_ENDPOINTS[config.source]) {
      const payload = await fetchJsonEndpoint(
        MODEL_LIST_ENDPOINTS[config.source],
      );
      const models = normalizeModelOptions(payload, {
        embeddingOnly: config.source === "electronhub",
      });
      if (models.length === 0) {
        return {
          success: false,
          models: [],
          error: "未拉取到可用 Embedding 模型",
        };
      }
      return { success: true, models, error: "" };
    }

    if (BACKEND_STATUS_MODEL_SOURCES[config.source]) {
      const models = await fetchBackendStatusModelList(config.source);
      if (models.length === 0) {
        return {
          success: false,
          models: [],
          error: "未拉取到可用 Embedding 模型",
        };
      }
      return { success: true, models, error: "" };
    }

    if (config.apiUrl) {
      const models = normalizeModelOptions(
        await fetchOpenAICompatibleModelList(config.apiUrl),
      );
      if (models.length === 0) {
        return {
          success: false,
          models: [],
          error: "未拉取到可用 Embedding 模型",
        };
      }
      return { success: true, models, error: "" };
    }

    return {
      success: false,
      models: [],
      error: "当前后端向量源暂不支持自动拉取模型，请手动填写",
    };
  } catch (error) {
    return { success: false, models: [], error: String(error) };
  }
}
