/** Updates installed plugins across npm, ClawHub, marketplace, Git, and bundled bridge sources. */
import path from "node:path";
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { PluginInstallRecord } from "../config/types.plugins.js";
import { parseClawHubPluginSpec } from "../infra/clawhub-spec.js";
import { satisfiesPluginApiRange } from "../infra/clawhub.js";
import type { NpmSpecResolution } from "../infra/install-source-utils.js";
import { createNpmMetadataEnv, resolveNpmSpecMetadata } from "../infra/install-source-utils.js";
import {
  compareOpenClawReleaseVersions,
  isExactSemverVersion,
  isOpenClawOrgNpmSpec,
  isPrereleaseSemverVersion,
  isPrereleaseResolutionAllowed,
  parseRegistryNpmSpec,
} from "../infra/npm-registry-spec.js";
import {
  expectedIntegrityForUpdate,
  installedPackageNeedsOpenClawPeerLinkRepair,
  readInstalledPackagePeerDependencies,
  readInstalledPackageVersion,
} from "../infra/package-update-utils.js";
import { compareComparableSemver, parseComparableSemver } from "../infra/semver-compare.js";
import type { UpdateChannel } from "../infra/update-channels.js";
import { runCommandWithTimeout } from "../process/exec.js";
import { resolveUserPath } from "../utils.js";
import { resolveCompatibilityHostVersion } from "../version.js";
import { resolveBundledPluginSources } from "./bundled-sources.js";
import { buildClawHubPluginInstallRecordFields } from "./clawhub-install-records.js";
import { CLAWHUB_INSTALL_ERROR_CODE, installPluginFromClawHub } from "./clawhub.js";
import { normalizePluginsConfig, resolveEffectiveEnableState } from "./config-state.js";
import {
  getExternalizedBundledPluginLegacyPathSuffix,
  getExternalizedBundledPluginClawHubSpec,
  getExternalizedBundledPluginLookupIds,
  getExternalizedBundledPluginNpmSpec,
  getExternalizedBundledPluginPreferredSource,
  getExternalizedBundledPluginTargetId,
  type ExternalizedBundledPluginBridge,
} from "./externalized-bundled-plugins.js";
import { installPluginFromGitSpec } from "./git-install.js";
import {
  resolveClawHubInstallSpecsForUpdateChannel,
  resolveNpmInstallSpecsForUpdateChannel,
} from "./install-channel-specs.js";
import {
  installPluginFromNpmSpec,
  PLUGIN_INSTALL_ERROR_CODE,
  resolvePluginInstallDir,
} from "./install.js";
import {
  buildNpmResolutionInstallFields,
  recordPluginInstall,
  resolveNpmInstallRecordSpec,
} from "./installs.js";
import { installPluginFromMarketplace } from "./marketplace.js";
import { checkMinHostVersion } from "./min-host-version.js";
import {
  resolveTrustedSourceLinkedOfficialClawHubSpec,
  resolveTrustedSourceLinkedOfficialNpmSpec,
} from "./official-external-install-records.js";
import {
  getOfficialExternalPluginCatalogEntry,
  resolveOfficialExternalPluginInstall,
} from "./official-external-plugin-catalog.js";
import { resolvePackagePluginApiRange } from "./package-compat.js";
import { linkOpenClawPeerDependencies } from "./plugin-peer-link.js";
import { defaultSlotIdForKey } from "./slots.js";

/** Logger surface used by plugin update flows. */
export type PluginUpdateLogger = {
  info?: (message: string) => void;
  warn?: (message: string) => void;
  error?: (message: string) => void;
};

/** Outcome status for one plugin update attempt. */
export type PluginUpdateStatus = "updated" | "unchanged" | "skipped" | "error";

export type PluginUpdateChannelFallback = {
  requestedSpec: string;
  usedSpec: string;
  requestedLabel: string;
  usedLabel: string;
  reason: "unavailable" | "failed";
  message: string;
};

export type PluginUpdateOutcome = {
  pluginId: string;
  status: PluginUpdateStatus;
  message: string;
  currentVersion?: string;
  nextVersion?: string;
  channelFallback?: PluginUpdateChannelFallback;
};

export type PluginUpdateSummary = {
  config: OpenClawConfig;
  changed: boolean;
  outcomes: PluginUpdateOutcome[];
};

export type PluginUpdateIntegrityDriftParams = {
  pluginId: string;
  spec: string;
  expectedIntegrity: string;
  actualIntegrity: string;
  resolvedSpec?: string;
  resolvedVersion?: string;
  dryRun: boolean;
};

export type PluginChannelSyncSummary = {
  switchedToBundled: string[];
  switchedToClawHub: string[];
  switchedToNpm: string[];
  warnings: string[];
  errors: string[];
};

export type PluginChannelSyncResult = {
  config: OpenClawConfig;
  changed: boolean;
  summary: PluginChannelSyncSummary;
};

function formatNpmInstallFailure(params: {
  pluginId: string;
  spec: string;
  phase: "check" | "update";
  result: { error: string; code?: string };
}): string {
  if (params.result.code === PLUGIN_INSTALL_ERROR_CODE.NPM_PACKAGE_NOT_FOUND) {
    return `Failed to ${params.phase} ${params.pluginId}: npm package not found for ${params.spec}.`;
  }
  return `Failed to ${params.phase} ${params.pluginId}: ${params.result.error}`;
}

function formatMarketplaceInstallFailure(params: {
  pluginId: string;
  marketplaceSource: string;
  marketplacePlugin: string;
  phase: "check" | "update";
  error: string;
}): string {
  return (
    `Failed to ${params.phase} ${params.pluginId}: ` +
    `${params.error} (marketplace plugin ${params.marketplacePlugin} from ${params.marketplaceSource}).`
  );
}

function formatClawHubInstallFailure(params: {
  pluginId: string;
  spec: string;
  phase: "check" | "update";
  error: string;
}): string {
  return `Failed to ${params.phase} ${params.pluginId}: ${params.error} (ClawHub ${params.spec}).`;
}

function formatGitInstallFailure(params: {
  pluginId: string;
  spec: string;
  phase: "check" | "update";
  error: string;
}): string {
  return `Failed to ${params.phase} ${params.pluginId}: ${params.error} (git ${params.spec}).`;
}

type InstallIntegrityDrift = {
  spec: string;
  expectedIntegrity: string;
  actualIntegrity: string;
  resolution: {
    resolvedSpec?: string;
    version?: string;
  };
};

function shouldSkipUnchangedNpmInstall(params: {
  currentVersion?: string;
  record: {
    integrity?: string;
    shasum?: string;
    resolvedName?: string;
    resolvedSpec?: string;
    resolvedVersion?: string;
  };
  metadata: NpmSpecResolution;
}): boolean {
  if (!params.currentVersion || !params.metadata.version) {
    return false;
  }
  if (params.currentVersion !== params.metadata.version) {
    return false;
  }
  if (
    !params.record.resolvedName ||
    !params.record.resolvedSpec ||
    !params.record.resolvedVersion
  ) {
    return false;
  }
  if (!params.metadata.name || !params.metadata.resolvedSpec) {
    return false;
  }
  if (params.metadata.integrity && !params.record.integrity) {
    return false;
  }
  if (params.metadata.shasum && !params.record.shasum) {
    return false;
  }
  return (
    (!params.metadata.integrity || params.record.integrity === params.metadata.integrity) &&
    (!params.metadata.shasum || params.record.shasum === params.metadata.shasum) &&
    params.record.resolvedName === params.metadata.name &&
    params.record.resolvedSpec === params.metadata.resolvedSpec &&
    params.record.resolvedVersion === params.metadata.version
  );
}

function shouldBypassTrustedOfficialUnchangedNpmCheck(params: {
  metadata: NpmSpecResolution;
  spec: string;
  trustedSourceLinkedOfficialInstall: boolean;
}): boolean {
  if (!params.trustedSourceLinkedOfficialInstall || !params.metadata.version) {
    return false;
  }
  const parsedSpec = parseRegistryNpmSpec(params.spec);
  return Boolean(
    parsedSpec &&
    !isPrereleaseResolutionAllowed({
      spec: parsedSpec,
      resolvedVersion: params.metadata.version,
    }),
  );
}

function expectedIntegrityForNpmUpdate(params: {
  effectiveSpec: string | undefined;
  metadata?: NpmSpecResolution;
  record: PluginInstallRecord;
  trustedSourceLinkedOfficialInstall: boolean;
}): string | undefined {
  if (params.record.source !== "npm") {
    return undefined;
  }
  if (params.effectiveSpec === params.record.spec) {
    return expectedIntegrityForUpdate(params.record.spec, params.record.integrity);
  }
  if (!params.trustedSourceLinkedOfficialInstall || !params.metadata) {
    return undefined;
  }
  const metadataName = params.metadata.name ?? resolveNpmSpecPackageName(params.effectiveSpec);
  const recordName =
    params.record.resolvedName ??
    resolveNpmSpecPackageName(params.record.resolvedSpec) ??
    resolveNpmSpecPackageName(params.record.spec);
  if (!metadataName || metadataName !== recordName) {
    return undefined;
  }
  if (!params.metadata.version || params.metadata.version !== params.record.resolvedVersion) {
    return undefined;
  }
  return expectedIntegrityForUpdate(
    params.record.resolvedSpec ?? params.record.spec,
    params.record.integrity,
  );
}

function compareNpmSemverForUpdate(left: string, right: string): number {
  const releaseCmp = compareOpenClawReleaseVersions(left, right);
  if (releaseCmp !== null) {
    return releaseCmp;
  }
  return compareComparableSemver(parseComparableSemver(left), parseComparableSemver(right)) ?? 0;
}

async function loadNpmPackageVersionsForUpdate(params: {
  packageName: string;
  timeoutMs?: number;
}): Promise<string[] | null> {
  const versions = await runCommandWithTimeout(
    ["npm", "view", params.packageName, "versions", "--json"],
    {
      timeoutMs: Math.max(params.timeoutMs ?? 0, 60_000),
      env: createNpmMetadataEnv(),
    },
  );
  if (!versions || versions.code !== 0) {
    return null;
  }

  let parsed: unknown;
  try {
    parsed = JSON.parse(versions.stdout.trim());
  } catch {
    return null;
  }
  return (Array.isArray(parsed) ? parsed : [parsed]).filter(
    (value): value is string => typeof value === "string" && isExactSemverVersion(value),
  );
}

async function resolveTrustedOfficialPrereleaseFallbackMetadataForUpdate(params: {
  metadata: NpmSpecResolution;
  spec: string;
  timeoutMs?: number;
}): Promise<
  | {
      kind: "stable" | "prerelease-only";
      metadata: NpmSpecResolution;
    }
  | undefined
> {
  const parsedSpec = parseRegistryNpmSpec(params.spec);
  if (
    !parsedSpec ||
    !parsedSpec.name.startsWith("@openclaw/") ||
    !params.metadata.version ||
    isPrereleaseResolutionAllowed({
      spec: parsedSpec,
      resolvedVersion: params.metadata.version,
    })
  ) {
    return undefined;
  }
  const versions = await loadNpmPackageVersionsForUpdate({
    packageName: parsedSpec.name,
    timeoutMs: params.timeoutMs,
  });
  const stableVersion = versions
    ?.filter((value) => !isPrereleaseSemverVersion(value))
    .toSorted(compareNpmSemverForUpdate)
    .at(-1);
  if (stableVersion) {
    const stableMetadata = await resolveNpmSpecMetadata({
      spec: `${parsedSpec.name}@${stableVersion}`,
      timeoutMs: params.timeoutMs,
    });
    return stableMetadata.ok ? { kind: "stable", metadata: stableMetadata.metadata } : undefined;
  }

  const prereleaseVersion = versions
    ?.filter(isPrereleaseSemverVersion)
    .toSorted(compareNpmSemverForUpdate)
    .at(-1);
  if (!prereleaseVersion || !versions?.every(isPrereleaseSemverVersion)) {
    return undefined;
  }
  if (prereleaseVersion === params.metadata.version) {
    return { kind: "prerelease-only", metadata: params.metadata };
  }
  const prereleaseMetadata = await resolveNpmSpecMetadata({
    spec: `${parsedSpec.name}@${prereleaseVersion}`,
    timeoutMs: params.timeoutMs,
  });
  return prereleaseMetadata.ok
    ? { kind: "prerelease-only", metadata: prereleaseMetadata.metadata }
    : undefined;
}

async function expectedIntegrityForNpmFallback(params: {
  fallbackSpec: string | undefined;
  record: PluginInstallRecord;
  timeoutMs?: number;
  trustedSourceLinkedOfficialInstall: boolean;
}): Promise<string | undefined> {
  if (params.record.source !== "npm" || !params.fallbackSpec) {
    return undefined;
  }
  if (params.fallbackSpec === params.record.spec) {
    return expectedIntegrityForUpdate(params.record.spec, params.record.integrity);
  }
  if (!params.trustedSourceLinkedOfficialInstall) {
    return undefined;
  }
  const fallbackMetadata = await resolveNpmSpecMetadata({
    spec: params.fallbackSpec,
    timeoutMs: params.timeoutMs,
  });
  if (!fallbackMetadata.ok) {
    return undefined;
  }
  const trustedPrereleaseFallback = await resolveTrustedOfficialPrereleaseFallbackMetadataForUpdate(
    {
      metadata: fallbackMetadata.metadata,
      spec: params.fallbackSpec,
      timeoutMs: params.timeoutMs,
    },
  );
  const expectedIntegrityMetadata =
    trustedPrereleaseFallback?.metadata ?? fallbackMetadata.metadata;
  if (!isNpmMetadataCompatibleWithCurrentHost(expectedIntegrityMetadata)) {
    return undefined;
  }
  return expectedIntegrityForNpmUpdate({
    effectiveSpec: params.fallbackSpec,
    metadata: expectedIntegrityMetadata,
    record: params.record,
    trustedSourceLinkedOfficialInstall: true,
  });
}

function isNpmMetadataCompatibleWithCurrentHost(metadata: NpmSpecResolution): boolean {
  const hostVersion = resolveCompatibilityHostVersion();
  const installMetadata = metadata.packageOpenClaw?.install;
  const minHostVersionCheck = checkMinHostVersion({
    currentVersion: hostVersion,
    minHostVersion: isRecord(installMetadata) ? installMetadata.minHostVersion : undefined,
  });
  if (!minHostVersionCheck.ok) {
    return false;
  }
  const pluginApiRangeCheck = resolvePackagePluginApiRange(metadata.packageOpenClaw);
  if (!pluginApiRangeCheck.ok) {
    return false;
  }
  const pluginApiRange = pluginApiRangeCheck.range;
  if (!pluginApiRange) {
    return true;
  }
  return satisfiesPluginApiRange(hostVersion, pluginApiRange);
}

function isBundledVersionNewer(bundledVersion: string, installedVersion: string): boolean {
  const releaseCmp = compareOpenClawReleaseVersions(bundledVersion, installedVersion);
  if (releaseCmp !== null) {
    return releaseCmp > 0;
  }
  const bundled = parseComparableSemver(bundledVersion);
  const installed = parseComparableSemver(installedVersion);
  const cmp = compareComparableSemver(bundled, installed);
  return cmp !== null && cmp > 0;
}

function pathsEqual(
  left: string | undefined,
  right: string | undefined,
  env: NodeJS.ProcessEnv = process.env,
): boolean {
  if (!left || !right) {
    return false;
  }
  return resolveUserPath(left, env) === resolveUserPath(right, env);
}

function resolveRecordedExtensionsDir(params: {
  pluginId: string;
  installPath: string;
}): string | undefined {
  const parentDir = path.dirname(params.installPath);
  try {
    const canonicalInstallPath = resolvePluginInstallDir(params.pluginId, parentDir);
    return canonicalInstallPath === params.installPath ? parentDir : undefined;
  } catch {
    return undefined;
  }
}

function buildLoadPathHelpers(existing: string[], env: NodeJS.ProcessEnv = process.env) {
  let paths = [...existing];
  const resolveSet = () => new Set(paths.map((entry) => resolveUserPath(entry, env)));
  let resolved = resolveSet();
  let changed = false;

  const addPath = (value: string) => {
    const normalized = resolveUserPath(value, env);
    if (resolved.has(normalized)) {
      return;
    }
    paths.push(value);
    resolved.add(normalized);
    changed = true;
  };

  const removePath = (value: string) => {
    const normalized = resolveUserPath(value, env);
    if (!resolved.has(normalized)) {
      return;
    }
    paths = paths.filter((entry) => resolveUserPath(entry, env) !== normalized);
    resolved = resolveSet();
    changed = true;
  };

  const removeMatching = (predicate: (value: string) => boolean) => {
    const next = paths.filter((entry) => !predicate(entry));
    if (next.length === paths.length) {
      return;
    }
    paths = next;
    resolved = resolveSet();
    changed = true;
  };

  return {
    addPath,
    removePath,
    removeMatching,
    get changed() {
      return changed;
    },
    get paths() {
      return paths;
    },
  };
}

function normalizePathSegment(value: string | undefined): string {
  return (
    value
      ?.trim()
      .replaceAll("\\", "/")
      .replace(/^\/+|\/+$/g, "") ?? ""
  );
}

function pathEndsWithSegment(params: {
  value: string | undefined;
  segment: string | undefined;
  env: NodeJS.ProcessEnv;
}): boolean {
  const value = normalizePathSegment(params.value ? resolveUserPath(params.value, params.env) : "");
  const segment = normalizePathSegment(params.segment);
  return Boolean(value && segment && (value === segment || value.endsWith(`/${segment}`)));
}

function isBridgeBundledPathRecord(params: {
  bridge: ExternalizedBundledPluginBridge;
  bundledLocalPath?: string;
  record: PluginInstallRecord;
  env: NodeJS.ProcessEnv;
}): boolean {
  if (params.record.source !== "path") {
    return false;
  }
  if (
    params.bundledLocalPath &&
    (pathsEqual(params.record.sourcePath, params.bundledLocalPath, params.env) ||
      pathsEqual(params.record.installPath, params.bundledLocalPath, params.env))
  ) {
    return true;
  }
  const bundledPathSuffix = getExternalizedBundledPluginLegacyPathSuffix(params.bridge);
  return (
    pathEndsWithSegment({
      value: params.record.sourcePath,
      segment: bundledPathSuffix,
      env: params.env,
    }) ||
    pathEndsWithSegment({
      value: params.record.installPath,
      segment: bundledPathSuffix,
      env: params.env,
    })
  );
}

function removeBridgeBundledLoadPaths(params: {
  bridge: ExternalizedBundledPluginBridge;
  loadPaths: ReturnType<typeof buildLoadPathHelpers>;
  env: NodeJS.ProcessEnv;
}) {
  const bundledPathSuffix = getExternalizedBundledPluginLegacyPathSuffix(params.bridge);
  params.loadPaths.removeMatching((entry) =>
    pathEndsWithSegment({
      value: entry,
      segment: bundledPathSuffix,
      env: params.env,
    }),
  );
}

function resolveBridgeInstallRecord(params: {
  installs: Record<string, PluginInstallRecord>;
  bridge: ExternalizedBundledPluginBridge;
}): { pluginId: string; record: PluginInstallRecord } | undefined {
  for (const pluginId of getExternalizedBundledPluginLookupIds(params.bridge)) {
    const record = params.installs[pluginId];
    if (record) {
      return { pluginId, record };
    }
  }
  return undefined;
}

function isBridgeChannelEnabledByConfig(params: {
  config: OpenClawConfig;
  bridge: ExternalizedBundledPluginBridge;
}): boolean {
  const channels = params.config.channels;
  if (!channels || typeof channels !== "object" || Array.isArray(channels)) {
    return false;
  }
  for (const channelId of params.bridge.channelIds ?? []) {
    const entry = (channels as Record<string, unknown>)[channelId];
    if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
      continue;
    }
    if (Object.is((entry as Record<string, unknown>).enabled, true)) {
      return true;
    }
  }
  return false;
}

function isExternalizedBundledPluginEnabled(params: {
  config: OpenClawConfig;
  bridge: ExternalizedBundledPluginBridge;
}): boolean {
  const normalized = normalizePluginsConfig(params.config.plugins);
  if (!normalized.enabled) {
    return false;
  }
  const pluginIds = getExternalizedBundledPluginLookupIds(params.bridge);
  if (
    pluginIds.some(
      (pluginId) =>
        normalized.deny.includes(pluginId) ||
        Object.is(normalized.entries[pluginId]?.enabled, false),
    )
  ) {
    return false;
  }
  for (const pluginId of pluginIds) {
    if (
      resolveEffectiveEnableState({
        id: pluginId,
        origin: "bundled",
        config: normalized,
        rootConfig: params.config,
        enabledByDefault: params.bridge.enabledByDefault,
      }).enabled
    ) {
      return true;
    }
  }
  if (isBridgeChannelEnabledByConfig(params)) {
    return true;
  }
  return false;
}

function shouldFallbackClawHubBridgeToNpm(params: {
  result: { ok: false; code?: string };
  npmSpec?: string;
}): boolean {
  if (!isOpenClawOrgNpmSpec(params.npmSpec)) {
    return false;
  }
  return (
    params.result.code === CLAWHUB_INSTALL_ERROR_CODE.PACKAGE_NOT_FOUND ||
    params.result.code === CLAWHUB_INSTALL_ERROR_CODE.VERSION_NOT_FOUND ||
    params.result.code === CLAWHUB_INSTALL_ERROR_CODE.ARTIFACT_DOWNLOAD_UNAVAILABLE ||
    params.result.code === CLAWHUB_INSTALL_ERROR_CODE.ARTIFACT_UNAVAILABLE
  );
}

function shouldFallbackClawHubToDefault(result: { ok: false; code?: string }): boolean {
  return (
    result.code === CLAWHUB_INSTALL_ERROR_CODE.PACKAGE_NOT_FOUND ||
    result.code === CLAWHUB_INSTALL_ERROR_CODE.VERSION_NOT_FOUND
  );
}

function shouldFallbackBetaClawHubUpdate(result: { ok: false; code?: string }): boolean {
  return shouldFallbackClawHubToDefault(result);
}

function isUnavailableNpmTarget(result: { ok: false; code?: string; error: string }): boolean {
  return (
    result.code === PLUGIN_INSTALL_ERROR_CODE.NPM_PACKAGE_NOT_FOUND ||
    /\b(ETARGET|notarget)\b|No matching version found|dist-tag|tag .*not found/i.test(result.error)
  );
}

function describeBetaNpmFallback(params: {
  pluginId: string;
  betaSpec: string | undefined;
  fallbackSpec: string;
  result: { ok: false; code?: string; error: string };
}): string {
  const betaSpec = params.betaSpec ?? "the beta npm release";
  const missingBeta = isUnavailableNpmTarget(params.result);
  const reason = missingBeta ? "has no beta npm release" : "failed beta npm update";
  return `Plugin "${params.pluginId}" ${reason} for ${betaSpec}; using ${params.fallbackSpec} instead. Core update can still complete.`;
}

function formatNpmSpecSelectorLabel(spec: string | undefined): string {
  const parsed = spec ? parseRegistryNpmSpec(spec) : undefined;
  if (!parsed) {
    return spec ?? "unknown";
  }
  if (parsed.selectorKind === "none") {
    return "@latest";
  }
  return `@${parsed.selector}`;
}

function describeNpmChannelFallback(params: {
  pluginId: string;
  requestedSpec: string | undefined;
  usedSpec: string;
  result: { ok: false; code?: string; error: string };
  verb: "used" | "would use";
}): PluginUpdateChannelFallback {
  const requestedSpec = params.requestedSpec ?? "unknown";
  const requestedLabel = formatNpmSpecSelectorLabel(params.requestedSpec);
  const usedLabel = formatNpmSpecSelectorLabel(params.usedSpec);
  const reason = isUnavailableNpmTarget(params.result) ? "unavailable" : "failed";
  const message =
    reason === "unavailable"
      ? `plugin channel fallback: ${params.pluginId} ${params.verb} ${usedLabel} because ${requestedLabel} was unavailable`
      : `plugin channel fallback: ${params.pluginId} ${params.verb} ${usedLabel} after ${requestedLabel} failed`;
  return {
    requestedSpec,
    usedSpec: params.usedSpec,
    requestedLabel,
    usedLabel,
    reason,
    message,
  };
}

function formatBetaChannelFallbackOutcomeSuffix(params: {
  fallbackLabel: string | undefined;
  fallbackSpec: string | undefined;
  verb: "used" | "would use";
}): string {
  if (!params.fallbackSpec) {
    return "";
  }
  const betaTarget = params.fallbackLabel ?? "beta target";
  return ` (warning: beta channel fallback ${params.verb} ${params.fallbackSpec} because ${betaTarget} could not be used).`;
}

function npmUpdateFailureSpec(params: {
  effectiveSpec: string | undefined;
  fallbackSpec: string | undefined;
  usedFallback: boolean;
}): string {
  if (params.usedFallback && params.fallbackSpec) {
    return params.fallbackSpec;
  }
  return params.effectiveSpec ?? params.fallbackSpec ?? "unknown";
}

function resolveNpmSpecPackageName(spec: string | undefined): string | undefined {
  return spec ? parseRegistryNpmSpec(spec)?.name : undefined;
}

function resolveExactNpmSpecVersion(spec: string | undefined): string | undefined {
  const parsed = spec ? parseRegistryNpmSpec(spec) : null;
  return parsed?.selectorKind === "exact-version" ? parsed.selector : undefined;
}

function resolveNpmResultVersion(result: {
  npmResolution?: NpmSpecResolution;
}): string | undefined {
  return result.npmResolution?.version;
}

function resolveClawHubSpecPackageName(spec: string | undefined): string | undefined {
  return spec ? parseClawHubPluginSpec(spec)?.name : undefined;
}

function isOfficialClawHubInstallRecord(record: PluginInstallRecord): boolean {
  if (record.source !== "clawhub" || record.clawhubChannel !== "official") {
    return false;
  }
  return (record.clawhubUrl ?? "").replace(/\/+$/, "") === "https://clawhub.ai";
}

function resolveTrustedSourceLinkedOfficialNpmFallbackForClawHubUpdate(params: {
  pluginId: string;
  record: PluginInstallRecord;
  effectiveClawHubSpec?: string;
  recordClawHubSpec?: string;
  updateChannel?: UpdateChannel;
}): {
  installSpec: string;
  recordSpec: string;
  fallbackSpec?: string;
  fallbackLabel?: string;
} | null {
  if (!isOfficialClawHubInstallRecord(params.record)) {
    return null;
  }
  const entry = getOfficialExternalPluginCatalogEntry(params.pluginId);
  if (!entry) {
    return null;
  }
  const officialSpec = resolveOfficialExternalPluginInstall(entry)?.npmSpec;
  const officialPackageName = resolveNpmSpecPackageName(officialSpec);
  if (!officialSpec || !officialPackageName) {
    return null;
  }
  const recordedPackageNames = [
    params.record.clawhubPackage,
    resolveClawHubSpecPackageName(params.record.spec),
    resolveClawHubSpecPackageName(params.effectiveClawHubSpec),
  ].filter((value): value is string => Boolean(value));
  if (!recordedPackageNames.includes(officialPackageName)) {
    return null;
  }

  const effectiveClawHubVersion = params.effectiveClawHubSpec
    ? parseClawHubPluginSpec(params.effectiveClawHubSpec)?.version
    : undefined;
  const recordClawHubVersion = params.recordClawHubSpec
    ? parseClawHubPluginSpec(params.recordClawHubSpec)?.version
    : undefined;
  if (effectiveClawHubVersion && effectiveClawHubVersion.toLowerCase() !== "latest") {
    return {
      installSpec: `${officialPackageName}@${effectiveClawHubVersion}`,
      recordSpec:
        recordClawHubVersion && recordClawHubVersion.toLowerCase() !== "latest"
          ? `${officialPackageName}@${recordClawHubVersion}`
          : officialSpec,
      ...(params.updateChannel === "beta" && effectiveClawHubVersion.toLowerCase() === "beta"
        ? { fallbackSpec: officialSpec, fallbackLabel: `${officialPackageName}@beta` }
        : {}),
    };
  }
  return resolveNpmInstallSpecsForUpdateChannel({
    spec: officialSpec,
    updateChannel: params.updateChannel,
  });
}

function isTrustedSourceLinkedOfficialNpmUpdate(params: {
  pluginId: string;
  spec: string | undefined;
  record: PluginInstallRecord;
}): boolean {
  const officialSpec = resolveTrustedSourceLinkedOfficialNpmSpec(params);
  const officialPackageName = resolveNpmSpecPackageName(officialSpec);
  const requestedPackageName = resolveNpmSpecPackageName(params.spec);
  return Boolean(officialPackageName && requestedPackageName === officialPackageName);
}

function isTrustedSourceLinkedOfficialBridgeNpmInstall(params: {
  targetPluginId: string;
  npmSpec: string | undefined;
}): boolean {
  const entry = getOfficialExternalPluginCatalogEntry(params.targetPluginId);
  if (!entry) {
    return false;
  }
  const officialPackageName = resolveNpmSpecPackageName(
    resolveOfficialExternalPluginInstall(entry)?.npmSpec,
  );
  const requestedPackageName = resolveNpmSpecPackageName(params.npmSpec);
  return Boolean(officialPackageName && requestedPackageName === officialPackageName);
}

function isBridgeNpmInstall(params: {
  bridge: ExternalizedBundledPluginBridge;
  record: PluginInstallRecord;
}): boolean {
  const npmSpec = getExternalizedBundledPluginNpmSpec(params.bridge);
  if (!npmSpec || params.record.source !== "npm") {
    return false;
  }
  const bridgePackageName = resolveNpmSpecPackageName(npmSpec);
  const recordPackageName =
    params.record.resolvedName ??
    resolveNpmSpecPackageName(params.record.spec) ??
    resolveNpmSpecPackageName(params.record.resolvedSpec);
  return Boolean(bridgePackageName && recordPackageName === bridgePackageName);
}

function isBridgeClawHubInstall(params: {
  bridge: ExternalizedBundledPluginBridge;
  record: PluginInstallRecord;
}): boolean {
  if (params.record.source !== "clawhub") {
    return false;
  }
  const clawhubSpec = getExternalizedBundledPluginClawHubSpec(params.bridge);
  const bridgeClawHubPackage = clawhubSpec ? parseClawHubPluginSpec(clawhubSpec)?.name : undefined;
  const recordClawHubPackage =
    params.record.clawhubPackage ?? parseClawHubPluginSpec(params.record.spec ?? "")?.name;
  return Boolean(bridgeClawHubPackage && recordClawHubPackage === bridgeClawHubPackage);
}

function resolveNpmUpdateSpecs(params: {
  record: PluginInstallRecord;
  specOverride?: string;
  officialSpecOverride?: string;
  updateChannel?: UpdateChannel;
}): {
  installSpec?: string;
  recordSpec?: string;
  fallbackSpec?: string;
  fallbackLabel?: string;
} {
  const recordSpec = params.specOverride ?? params.officialSpecOverride ?? params.record.spec;
  if (!recordSpec) {
    return {};
  }
  if (params.specOverride) {
    return {
      installSpec: recordSpec,
      recordSpec,
    };
  }
  return resolveNpmInstallSpecsForUpdateChannel({
    spec: recordSpec,
    updateChannel: params.updateChannel,
  });
}

function resolveClawHubUpdateSpecs(params: {
  record: PluginInstallRecord;
  officialSpecOverride?: string;
  updateChannel?: UpdateChannel;
}): {
  installSpec?: string;
  recordSpec?: string;
  fallbackSpec?: string;
  fallbackLabel?: string;
} {
  if (!params.officialSpecOverride && !params.record.clawhubPackage) {
    return {};
  }
  const recordSpec =
    params.officialSpecOverride ?? params.record.spec ?? `clawhub:${params.record.clawhubPackage}`;
  return resolveClawHubInstallSpecsForUpdateChannel({
    spec: recordSpec,
    updateChannel: params.updateChannel,
  });
}

function isBridgeAlreadyInstalledFromPreferredSource(params: {
  bridge: ExternalizedBundledPluginBridge;
  record: PluginInstallRecord;
}): boolean {
  const preferredSource = getExternalizedBundledPluginPreferredSource(params.bridge);
  return preferredSource === "clawhub"
    ? isBridgeClawHubInstall(params)
    : isBridgeNpmInstall(params);
}

function isBridgeInstalledFromFallbackSource(params: {
  bridge: ExternalizedBundledPluginBridge;
  record: PluginInstallRecord;
}): boolean {
  const preferredSource = getExternalizedBundledPluginPreferredSource(params.bridge);
  return preferredSource === "clawhub"
    ? isBridgeNpmInstall(params)
    : isBridgeClawHubInstall(params);
}

function replacePluginIdInList(
  entries: string[] | undefined,
  fromId: string,
  toId: string,
): string[] | undefined {
  if (!entries || entries.length === 0 || fromId === toId) {
    return entries;
  }
  const next: string[] = [];
  for (const entry of entries) {
    const value = entry === fromId ? toId : entry;
    if (!next.includes(value)) {
      next.push(value);
    }
  }
  return next;
}

function migratePluginConfigId(cfg: OpenClawConfig, fromId: string, toId: string): OpenClawConfig {
  if (fromId === toId) {
    return cfg;
  }

  const installs = cfg.plugins?.installs;
  const entries = cfg.plugins?.entries;
  const slots = cfg.plugins?.slots;
  const allow = replacePluginIdInList(cfg.plugins?.allow, fromId, toId);
  const deny = replacePluginIdInList(cfg.plugins?.deny, fromId, toId);

  const nextInstalls = installs ? { ...installs } : undefined;
  if (nextInstalls && fromId in nextInstalls) {
    const record = nextInstalls[fromId];
    if (record && !(toId in nextInstalls)) {
      nextInstalls[toId] = record;
    }
    delete nextInstalls[fromId];
  }

  const nextEntries = entries ? { ...entries } : undefined;
  if (nextEntries && fromId in nextEntries) {
    const entry = nextEntries[fromId];
    if (entry) {
      nextEntries[toId] = nextEntries[toId]
        ? {
            ...entry,
            ...nextEntries[toId],
          }
        : entry;
    }
    delete nextEntries[fromId];
  }

  const nextSlots = slots
    ? {
        ...slots,
        ...(slots.memory === fromId ? { memory: toId } : {}),
        ...(slots.contextEngine === fromId ? { contextEngine: toId } : {}),
      }
    : undefined;

  return {
    ...cfg,
    plugins: {
      ...cfg.plugins,
      allow,
      deny,
      entries: nextEntries,
      installs: nextInstalls,
      slots: nextSlots,
    },
  };
}

function withoutPluginInstallRecord(cfg: OpenClawConfig, pluginId: string): OpenClawConfig {
  const installs = cfg.plugins?.installs;
  if (!installs || !Object.hasOwn(installs, pluginId)) {
    return cfg;
  }
  const { [pluginId]: _removed, ...nextInstalls } = installs;
  return {
    ...cfg,
    plugins: {
      ...cfg.plugins,
      installs: nextInstalls,
    },
  };
}

function createPluginUpdateIntegrityDriftHandler(params: {
  pluginId: string;
  dryRun: boolean;
  logger: PluginUpdateLogger;
  onIntegrityDrift?: (params: PluginUpdateIntegrityDriftParams) => boolean | Promise<boolean>;
}) {
  return async (drift: InstallIntegrityDrift) => {
    const payload: PluginUpdateIntegrityDriftParams = {
      pluginId: params.pluginId,
      spec: drift.spec,
      expectedIntegrity: drift.expectedIntegrity,
      actualIntegrity: drift.actualIntegrity,
      resolvedSpec: drift.resolution.resolvedSpec,
      resolvedVersion: drift.resolution.version,
      dryRun: params.dryRun,
    };
    if (params.onIntegrityDrift) {
      return await params.onIntegrityDrift(payload);
    }
    params.logger.warn?.(
      `Integrity drift for "${params.pluginId}" (${payload.resolvedSpec ?? payload.spec}): expected ${payload.expectedIntegrity}, got ${payload.actualIntegrity}`,
    );
    return false;
  };
}

function removeDisabledPluginIdFromList(
  list: string[] | undefined,
  pluginId: string,
): string[] | undefined {
  if (!Array.isArray(list) || !list.includes(pluginId)) {
    return list;
  }
  const next = list.filter((id) => id !== pluginId);
  return next.length > 0 ? next : undefined;
}

function resetDisabledPluginSlots(
  slots: NonNullable<OpenClawConfig["plugins"]>["slots"] | undefined,
  pluginId: string,
): NonNullable<OpenClawConfig["plugins"]>["slots"] | undefined {
  if (!slots) {
    return slots;
  }
  let next = slots;
  if (next.memory === pluginId) {
    next = {
      ...next,
      memory: defaultSlotIdForKey("memory"),
    };
  }
  if (next.contextEngine === pluginId) {
    next = {
      ...next,
      contextEngine: defaultSlotIdForKey("contextEngine"),
    };
  }
  return next;
}

function disablePluginConfigEntry(config: OpenClawConfig, pluginId: string): OpenClawConfig {
  const pluginsConfig = config.plugins ?? {};
  const existingEntry = pluginsConfig.entries?.[pluginId];
  return {
    ...config,
    plugins: {
      ...pluginsConfig,
      allow: removeDisabledPluginIdFromList(pluginsConfig.allow, pluginId),
      deny: removeDisabledPluginIdFromList(pluginsConfig.deny, pluginId),
      slots: resetDisabledPluginSlots(pluginsConfig.slots, pluginId),
      entries: {
        ...pluginsConfig.entries,
        [pluginId]: {
          ...existingEntry,
          enabled: false,
        },
      },
    },
  };
}

async function repairOpenClawPeerLinksForNpmInstalls(params: {
  config: OpenClawConfig;
  logger: PluginUpdateLogger;
}): Promise<boolean> {
  let repaired = false;
  for (const [pluginId, record] of Object.entries(params.config.plugins?.installs ?? {})) {
    if (record.source !== "npm") {
      continue;
    }

    let installPath: string;
    try {
      installPath = resolveUserPath(
        record.installPath?.trim() || resolvePluginInstallDir(pluginId),
      );
    } catch (err) {
      params.logger.warn?.(
        `Could not repair openclaw peer link for "${pluginId}" due to invalid install path: ${String(err)}`,
      );
      continue;
    }

    if (!installedPackageNeedsOpenClawPeerLinkRepair(installPath)) {
      continue;
    }

    const peerDependencies = readInstalledPackagePeerDependencies(installPath);
    if (!Object.hasOwn(peerDependencies, "openclaw")) {
      continue;
    }

    try {
      const warnings: string[] = [];
      const peerLinkRepair = await linkOpenClawPeerDependencies({
        installedDir: installPath,
        peerDependencies,
        logger: {
          info: (message) => params.logger.info?.(message),
          warn: (message) => warnings.push(message),
        },
      });
      if (peerLinkRepair.skipped > 0) {
        params.logger.warn?.(
          `Could not repair openclaw peer link for "${pluginId}" at ${installPath}: ${warnings.join("; ") || "peer link repair was skipped"}`,
        );
        continue;
      }
      repaired = !installedPackageNeedsOpenClawPeerLinkRepair(installPath) || repaired;
    } catch (err) {
      params.logger.warn?.(
        `Could not repair openclaw peer link for "${pluginId}" at ${installPath}: ${String(err)}`,
      );
    }
  }
  return repaired;
}

export async function updateNpmInstalledPlugins(params: {
  config: OpenClawConfig;
  logger?: PluginUpdateLogger;
  pluginIds?: string[];
  skipIds?: Set<string>;
  skipDisabledPlugins?: boolean;
  syncOfficialPluginInstalls?: boolean;
  disableOnFailure?: boolean;
  timeoutMs?: number;
  dryRun?: boolean;
  updateChannel?: UpdateChannel;
  dangerouslyForceUnsafeInstall?: boolean;
  specOverrides?: Record<string, string>;
  onIntegrityDrift?: (params: PluginUpdateIntegrityDriftParams) => boolean | Promise<boolean>;
}): Promise<PluginUpdateSummary> {
  const logger = params.logger ?? {};
  const installs = params.config.plugins?.installs ?? {};
  const targets = params.pluginIds?.length ? params.pluginIds : Object.keys(installs);
  const normalizedPluginConfig = params.skipDisabledPlugins
    ? normalizePluginsConfig(params.config.plugins)
    : undefined;
  const bundled = resolveBundledPluginSources({});
  const outcomes: PluginUpdateOutcome[] = [];
  let next = params.config;
  let changed = false;
  let ranNpmInstaller = false;
  const installNpmSpecForUpdate = async (
    installParams: Parameters<typeof installPluginFromNpmSpec>[0],
  ): Promise<Awaited<ReturnType<typeof installPluginFromNpmSpec>>> => {
    ranNpmInstaller = true;
    return await installPluginFromNpmSpec(installParams);
  };

  const recordFailure = (
    pluginId: string,
    message: string,
    channelFallback?: PluginUpdateChannelFallback,
  ) => {
    if (params.disableOnFailure && !params.dryRun) {
      const disabledMessage =
        `Disabled "${pluginId}" after plugin update failure; OpenClaw will continue without it. ` +
        message;
      logger.warn?.(disabledMessage);
      next = disablePluginConfigEntry(next, pluginId);
      changed = true;
      outcomes.push({
        pluginId,
        status: "skipped",
        message: disabledMessage,
        ...(channelFallback ? { channelFallback } : {}),
      });
      return;
    }
    outcomes.push({
      pluginId,
      status: "error",
      message,
      ...(channelFallback ? { channelFallback } : {}),
    });
  };

  for (const pluginId of targets) {
    if (params.skipIds?.has(pluginId)) {
      outcomes.push({
        pluginId,
        status: "skipped",
        message: `Skipping "${pluginId}" (already updated).`,
      });
      continue;
    }

    const record = installs[pluginId];
    if (!record) {
      outcomes.push({
        pluginId,
        status: "skipped",
        message: `No install record for "${pluginId}".`,
      });
      continue;
    }

    const officialNpmSpec = params.syncOfficialPluginInstalls
      ? resolveTrustedSourceLinkedOfficialNpmSpec({ pluginId, record })
      : undefined;
    const officialClawHubSpec = params.syncOfficialPluginInstalls
      ? resolveTrustedSourceLinkedOfficialClawHubSpec({ pluginId, record })
      : undefined;

    if (normalizedPluginConfig) {
      const enableState = resolveEffectiveEnableState({
        id: pluginId,
        origin: "global",
        config: normalizedPluginConfig,
        rootConfig: params.config,
      });
      if (!enableState.enabled && !officialNpmSpec && !officialClawHubSpec) {
        outcomes.push({
          pluginId,
          status: "skipped",
          message: `Skipping "${pluginId}" (${enableState.reason ?? "disabled by plugin config"}).`,
        });
        continue;
      }
    }

    if (
      record.source !== "npm" &&
      record.source !== "marketplace" &&
      record.source !== "clawhub" &&
      record.source !== "git"
    ) {
      outcomes.push({
        pluginId,
        status: "skipped",
        message: `Skipping "${pluginId}" (source: ${record.source}).`,
      });
      continue;
    }

    const npmSpecs =
      record.source === "npm"
        ? resolveNpmUpdateSpecs({
            record,
            specOverride: params.specOverrides?.[pluginId],
            officialSpecOverride: officialNpmSpec,
            updateChannel: params.updateChannel,
          })
        : undefined;
    const clawhubSpecs =
      record.source === "clawhub"
        ? resolveClawHubUpdateSpecs({
            record,
            officialSpecOverride: officialClawHubSpec,
            updateChannel: params.updateChannel,
          })
        : undefined;
    const effectiveSpec =
      record.source === "npm"
        ? npmSpecs?.installSpec
        : record.source === "clawhub"
          ? clawhubSpecs?.installSpec
          : record.spec;
    const recordSpec =
      record.source === "npm"
        ? npmSpecs?.recordSpec
        : record.source === "clawhub"
          ? clawhubSpecs?.recordSpec
          : record.spec;
    const officialNpmFallbackSpecs =
      record.source === "clawhub"
        ? resolveTrustedSourceLinkedOfficialNpmFallbackForClawHubUpdate({
            pluginId,
            record,
            effectiveClawHubSpec: effectiveSpec,
            recordClawHubSpec: recordSpec,
            updateChannel: params.updateChannel,
          })
        : null;
    let officialNpmFallbackInstallSpec = officialNpmFallbackSpecs?.installSpec;
    let officialNpmFallbackRecordSpec = officialNpmFallbackSpecs?.recordSpec;
    let activeClawHubInstallSpec = effectiveSpec;
    const trustedSourceLinkedOfficialInstall = isTrustedSourceLinkedOfficialNpmUpdate({
      pluginId,
      spec: effectiveSpec,
      record,
    });
    let expectedIntegrity = expectedIntegrityForNpmUpdate({
      effectiveSpec,
      record,
      trustedSourceLinkedOfficialInstall,
    });
    let fallbackExpectedIntegrityLoaded = false;
    let fallbackExpectedIntegrity: string | undefined;
    const getFallbackExpectedIntegrity = async () => {
      if (!fallbackExpectedIntegrityLoaded) {
        fallbackExpectedIntegrity = await expectedIntegrityForNpmFallback({
          fallbackSpec: npmSpecs?.fallbackSpec,
          record,
          timeoutMs: params.timeoutMs,
          trustedSourceLinkedOfficialInstall,
        });
        fallbackExpectedIntegrityLoaded = true;
      }
      return fallbackExpectedIntegrity;
    };

    if (record.source === "npm" && !effectiveSpec) {
      outcomes.push({
        pluginId,
        status: "skipped",
        message: `Skipping "${pluginId}" (missing npm spec).`,
      });
      continue;
    }

    if (record.source === "git" && !effectiveSpec) {
      outcomes.push({
        pluginId,
        status: "skipped",
        message: `Skipping "${pluginId}" (missing git spec).`,
      });
      continue;
    }

    if (record.source === "clawhub" && !record.clawhubPackage && !officialClawHubSpec) {
      outcomes.push({
        pluginId,
        status: "skipped",
        message: `Skipping "${pluginId}" (missing ClawHub package metadata).`,
      });
      continue;
    }

    if (record.source === "clawhub" || record.source === "marketplace") {
      const bundledSource = bundled.get(pluginId);
      if (
        bundledSource?.version &&
        record.version &&
        isBundledVersionNewer(bundledSource.version, record.version)
      ) {
        logger.warn?.(
          `Skipping "${pluginId}" update: bundled version ${bundledSource.version} is newer than the installed ${record.source} version ${record.version}. ` +
            `Uninstall the ${record.source} plugin to use the bundled version, or pin a newer version explicitly.`,
        );
        outcomes.push({
          pluginId,
          status: "skipped",
          message: `Skipping "${pluginId}": bundled version ${bundledSource.version} is newer than ${record.source} version ${record.version}.`,
        });
        continue;
      }
    }

    if (
      record.source === "marketplace" &&
      (!record.marketplaceSource || !record.marketplacePlugin)
    ) {
      outcomes.push({
        pluginId,
        status: "skipped",
        message: `Skipping "${pluginId}" (missing marketplace source metadata).`,
      });
      continue;
    }

    let installPath: string;
    try {
      installPath = resolveUserPath(
        record.installPath?.trim() || resolvePluginInstallDir(pluginId),
      );
    } catch (err) {
      recordFailure(pluginId, `Invalid install path for "${pluginId}": ${String(err)}`);
      continue;
    }
    let currentVersion: string | undefined;
    try {
      currentVersion = await readInstalledPackageVersion(installPath);
    } catch (err) {
      recordFailure(
        pluginId,
        `Failed to inspect installed package for ${pluginId}: ${String(err)}`,
      );
      continue;
    }
    const extensionsDir = resolveRecordedExtensionsDir({
      pluginId,
      installPath,
    });

    if (
      !params.dryRun &&
      record.source === "npm" &&
      (currentVersion || (params.syncOfficialPluginInstalls && trustedSourceLinkedOfficialInstall))
    ) {
      const metadataResult = await resolveNpmSpecMetadata({
        spec: effectiveSpec!,
        timeoutMs: params.timeoutMs,
      });
      if (metadataResult.ok) {
        const bypassTrustedOfficialUnchangedNpmCheck = shouldBypassTrustedOfficialUnchangedNpmCheck(
          {
            metadata: metadataResult.metadata,
            spec: effectiveSpec!,
            trustedSourceLinkedOfficialInstall,
          },
        );
        const trustedPrereleaseFallback = trustedSourceLinkedOfficialInstall
          ? await resolveTrustedOfficialPrereleaseFallbackMetadataForUpdate({
              metadata: metadataResult.metadata,
              spec: effectiveSpec!,
              timeoutMs: params.timeoutMs,
            })
          : undefined;
        const expectedIntegrityMetadata =
          trustedPrereleaseFallback?.metadata ?? metadataResult.metadata;
        expectedIntegrity = expectedIntegrityForNpmUpdate({
          effectiveSpec,
          metadata: expectedIntegrityMetadata,
          record,
          trustedSourceLinkedOfficialInstall,
        });
        if (!isNpmMetadataCompatibleWithCurrentHost(expectedIntegrityMetadata)) {
          expectedIntegrity = undefined;
        }
        if (bypassTrustedOfficialUnchangedNpmCheck && !trustedPrereleaseFallback) {
          expectedIntegrity = undefined;
        }
        if (
          currentVersion &&
          !bypassTrustedOfficialUnchangedNpmCheck &&
          isNpmMetadataCompatibleWithCurrentHost(metadataResult.metadata) &&
          !installedPackageNeedsOpenClawPeerLinkRepair(installPath) &&
          shouldSkipUnchangedNpmInstall({
            currentVersion,
            record,
            metadata: metadataResult.metadata,
          })
        ) {
          if (params.syncOfficialPluginInstalls && trustedSourceLinkedOfficialInstall) {
            const nextRecordSpec = resolveNpmInstallRecordSpec({
              requestedSpec: recordSpec,
              resolution: metadataResult.metadata,
              pinResolvedRegistrySpec: true,
            });
            if (nextRecordSpec !== record.spec) {
              const resolutionFields = buildNpmResolutionInstallFields(metadataResult.metadata);
              next = {
                ...next,
                plugins: {
                  ...next.plugins,
                  installs: {
                    ...next.plugins?.installs,
                    [pluginId]: {
                      ...record,
                      spec: nextRecordSpec,
                      resolvedName: resolutionFields.resolvedName ?? record.resolvedName,
                      resolvedVersion: resolutionFields.resolvedVersion ?? record.resolvedVersion,
                      resolvedSpec: resolutionFields.resolvedSpec ?? record.resolvedSpec,
                      integrity: resolutionFields.integrity ?? record.integrity,
                      shasum: resolutionFields.shasum ?? record.shasum,
                      resolvedAt: resolutionFields.resolvedAt ?? record.resolvedAt,
                    },
                  },
                },
              };
              changed = true;
            }
          }
          outcomes.push({
            pluginId,
            status: "unchanged",
            currentVersion,
            nextVersion: metadataResult.metadata.version,
            message: `${pluginId} is up to date (${currentVersion}).`,
          });
          continue;
        }
      } else {
        logger.warn?.(
          `Could not check ${pluginId} before update; falling back to installer path: ${metadataResult.error}`,
        );
      }
    }

    if (params.dryRun) {
      let probe:
        | Awaited<ReturnType<typeof installPluginFromNpmSpec>>
        | Awaited<ReturnType<typeof installPluginFromClawHub>>
        | Awaited<ReturnType<typeof installPluginFromGitSpec>>
        | Awaited<ReturnType<typeof installPluginFromMarketplace>>;
      try {
        probe =
          record.source === "npm"
            ? await installPluginFromNpmSpec({
                spec: effectiveSpec!,
                config: params.config,
                mode: "update",
                extensionsDir,
                timeoutMs: params.timeoutMs,
                dryRun: true,
                dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall,
                trustedSourceLinkedOfficialInstall,
                expectedPluginId: pluginId,
                expectedIntegrity,
                onIntegrityDrift: createPluginUpdateIntegrityDriftHandler({
                  pluginId,
                  dryRun: true,
                  logger,
                  onIntegrityDrift: params.onIntegrityDrift,
                }),
                logger,
              })
            : record.source === "clawhub"
              ? await installPluginFromClawHub({
                  spec: effectiveSpec ?? `clawhub:${record.clawhubPackage!}`,
                  config: params.config,
                  baseUrl: record.clawhubUrl,
                  mode: "update",
                  extensionsDir,
                  timeoutMs: params.timeoutMs,
                  dryRun: true,
                  dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall,
                  expectedPluginId: pluginId,
                  logger,
                })
              : record.source === "git"
                ? await installPluginFromGitSpec({
                    spec: effectiveSpec!,
                    config: params.config,
                    mode: "update",
                    extensionsDir,
                    timeoutMs: params.timeoutMs,
                    dryRun: true,
                    dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall,
                    expectedPluginId: pluginId,
                    logger,
                  })
                : await installPluginFromMarketplace({
                    marketplace: record.marketplaceSource!,
                    plugin: record.marketplacePlugin!,
                    config: params.config,
                    mode: "update",
                    extensionsDir,
                    timeoutMs: params.timeoutMs,
                    dryRun: true,
                    dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall,
                    expectedPluginId: pluginId,
                    logger,
                  });
      } catch (err) {
        recordFailure(pluginId, `Failed to check ${pluginId}: ${String(err)}`);
        continue;
      }
      let usedNpmFallback = false;
      let usedOfficialNpmFallback = false;
      let channelFallbackSuffix = "";
      let npmChannelFallback: PluginUpdateChannelFallback | undefined;
      if (!probe.ok && record.source === "npm" && npmSpecs?.fallbackSpec) {
        logger.warn?.(
          describeBetaNpmFallback({
            pluginId,
            betaSpec: npmSpecs.fallbackLabel ?? effectiveSpec,
            fallbackSpec: npmSpecs.fallbackSpec,
            result: probe,
          }),
        );
        usedNpmFallback = true;
        npmChannelFallback = describeNpmChannelFallback({
          pluginId,
          requestedSpec: npmSpecs.fallbackLabel ?? effectiveSpec,
          usedSpec: npmSpecs.fallbackSpec,
          result: probe,
          verb: "would use",
        });
        channelFallbackSuffix = formatBetaChannelFallbackOutcomeSuffix({
          fallbackLabel: npmSpecs.fallbackLabel ?? effectiveSpec,
          fallbackSpec: npmSpecs.fallbackSpec,
          verb: "would use",
        });
        probe = await installPluginFromNpmSpec({
          spec: npmSpecs.fallbackSpec,
          config: params.config,
          mode: "update",
          extensionsDir,
          timeoutMs: params.timeoutMs,
          dryRun: true,
          dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall,
          trustedSourceLinkedOfficialInstall,
          expectedPluginId: pluginId,
          expectedIntegrity: await getFallbackExpectedIntegrity(),
          onIntegrityDrift: createPluginUpdateIntegrityDriftHandler({
            pluginId,
            dryRun: true,
            logger,
            onIntegrityDrift: params.onIntegrityDrift,
          }),
          logger,
        });
      }
      if (
        !probe.ok &&
        record.source === "clawhub" &&
        clawhubSpecs?.fallbackSpec &&
        shouldFallbackBetaClawHubUpdate(probe)
      ) {
        channelFallbackSuffix = formatBetaChannelFallbackOutcomeSuffix({
          fallbackLabel: clawhubSpecs.fallbackLabel ?? effectiveSpec,
          fallbackSpec: clawhubSpecs.fallbackSpec,
          verb: "would use",
        });
        logger.warn?.(
          `Plugin "${pluginId}" has no beta ClawHub release for ${clawhubSpecs.fallbackLabel ?? effectiveSpec}; using ${clawhubSpecs.fallbackSpec} instead. Core update can still complete.`,
        );
        probe = await installPluginFromClawHub({
          spec: clawhubSpecs.fallbackSpec,
          config: params.config,
          baseUrl: record.clawhubUrl,
          mode: "update",
          extensionsDir,
          timeoutMs: params.timeoutMs,
          dryRun: true,
          dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall,
          expectedPluginId: pluginId,
          logger,
        });
        activeClawHubInstallSpec = clawhubSpecs.fallbackSpec;
        if (officialNpmFallbackSpecs?.fallbackSpec) {
          officialNpmFallbackInstallSpec = officialNpmFallbackSpecs.fallbackSpec;
        }
      }
      if (
        !probe.ok &&
        record.source === "clawhub" &&
        officialNpmFallbackInstallSpec &&
        shouldFallbackClawHubBridgeToNpm({
          result: probe,
          npmSpec: officialNpmFallbackInstallSpec,
        })
      ) {
        channelFallbackSuffix = ` (warning: official ClawHub artifact fallback would use ${officialNpmFallbackInstallSpec}).`;
        logger.warn?.(
          `Plugin "${pluginId}" could not download official ClawHub artifact for ${activeClawHubInstallSpec ?? `clawhub:${record.clawhubPackage!}`}; using npm ${officialNpmFallbackInstallSpec} instead. Core update can still complete.`,
        );
        usedNpmFallback = true;
        usedOfficialNpmFallback = true;
        probe = await installPluginFromNpmSpec({
          spec: officialNpmFallbackInstallSpec,
          config: params.config,
          mode: "update",
          extensionsDir,
          timeoutMs: params.timeoutMs,
          dryRun: true,
          dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall,
          trustedSourceLinkedOfficialInstall: true,
          expectedPluginId: pluginId,
          logger,
        });
      }
      if (!probe.ok) {
        recordFailure(
          pluginId,
          record.source === "npm" || usedOfficialNpmFallback
            ? formatNpmInstallFailure({
                pluginId,
                spec: usedOfficialNpmFallback
                  ? (officialNpmFallbackInstallSpec ?? effectiveSpec ?? "")
                  : npmUpdateFailureSpec({
                      effectiveSpec,
                      fallbackSpec: npmSpecs?.fallbackSpec,
                      usedFallback: usedNpmFallback,
                    }),
                phase: "check",
                result: probe,
              })
            : record.source === "clawhub"
              ? formatClawHubInstallFailure({
                  pluginId,
                  spec: activeClawHubInstallSpec ?? `clawhub:${record.clawhubPackage!}`,
                  phase: "check",
                  error: probe.error,
                })
              : record.source === "git"
                ? formatGitInstallFailure({
                    pluginId,
                    spec: effectiveSpec!,
                    phase: "check",
                    error: probe.error,
                  })
                : formatMarketplaceInstallFailure({
                    pluginId,
                    marketplaceSource: record.marketplaceSource!,
                    marketplacePlugin: record.marketplacePlugin!,
                    phase: "check",
                    error: probe.error,
                  }),
          npmChannelFallback,
        );
        continue;
      }

      const probeSpec = usedNpmFallback
        ? (npmSpecs?.fallbackSpec ?? officialNpmFallbackInstallSpec)
        : effectiveSpec;
      const npmProbeVersion =
        record.source === "npm" || usedOfficialNpmFallback
          ? resolveNpmResultVersion(probe)
          : undefined;
      const resolvedProbeVersion =
        probe.version ??
        npmProbeVersion ??
        (record.source === "npm" || usedOfficialNpmFallback
          ? resolveExactNpmSpecVersion(probeSpec)
          : undefined);
      const nextVersion = resolvedProbeVersion ?? "unknown";
      const currentLabel = currentVersion ?? "unknown";
      const gitProbe =
        record.source === "git"
          ? (probe as Extract<Awaited<ReturnType<typeof installPluginFromGitSpec>>, { ok: true }>)
              .git
          : undefined;
      const unchanged =
        record.source === "git" && record.gitCommit && gitProbe?.commit
          ? record.gitCommit === gitProbe.commit
          : Boolean(
              currentVersion && resolvedProbeVersion && currentVersion === resolvedProbeVersion,
            );
      if (unchanged) {
        outcomes.push({
          pluginId,
          status: "unchanged",
          currentVersion: currentVersion ?? undefined,
          nextVersion: resolvedProbeVersion,
          message: `${pluginId} is up to date (${currentLabel}).${channelFallbackSuffix}`,
          ...(npmChannelFallback ? { channelFallback: npmChannelFallback } : {}),
        });
      } else {
        outcomes.push({
          pluginId,
          status: "updated",
          currentVersion: currentVersion ?? undefined,
          nextVersion: resolvedProbeVersion,
          message: `Would update ${pluginId}: ${currentLabel} -> ${nextVersion}.${channelFallbackSuffix}`,
          ...(npmChannelFallback ? { channelFallback: npmChannelFallback } : {}),
        });
      }
      continue;
    }

    let result:
      | Awaited<ReturnType<typeof installPluginFromNpmSpec>>
      | Awaited<ReturnType<typeof installPluginFromClawHub>>
      | Awaited<ReturnType<typeof installPluginFromGitSpec>>
      | Awaited<ReturnType<typeof installPluginFromMarketplace>>;
    try {
      result =
        record.source === "npm"
          ? await installNpmSpecForUpdate({
              spec: effectiveSpec!,
              config: params.config,
              mode: "update",
              extensionsDir,
              timeoutMs: params.timeoutMs,
              dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall,
              trustedSourceLinkedOfficialInstall,
              expectedPluginId: pluginId,
              expectedIntegrity,
              onIntegrityDrift: createPluginUpdateIntegrityDriftHandler({
                pluginId,
                dryRun: false,
                logger,
                onIntegrityDrift: params.onIntegrityDrift,
              }),
              logger,
            })
          : record.source === "clawhub"
            ? await installPluginFromClawHub({
                spec: effectiveSpec ?? `clawhub:${record.clawhubPackage!}`,
                config: params.config,
                baseUrl: record.clawhubUrl,
                mode: "update",
                extensionsDir,
                timeoutMs: params.timeoutMs,
                dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall,
                expectedPluginId: pluginId,
                logger,
              })
            : record.source === "git"
              ? await installPluginFromGitSpec({
                  spec: effectiveSpec!,
                  config: params.config,
                  mode: "update",
                  extensionsDir,
                  timeoutMs: params.timeoutMs,
                  dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall,
                  expectedPluginId: pluginId,
                  logger,
                })
              : await installPluginFromMarketplace({
                  marketplace: record.marketplaceSource!,
                  plugin: record.marketplacePlugin!,
                  config: params.config,
                  mode: "update",
                  extensionsDir,
                  timeoutMs: params.timeoutMs,
                  dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall,
                  expectedPluginId: pluginId,
                  logger,
                });
    } catch (err) {
      recordFailure(pluginId, `Failed to update ${pluginId}: ${String(err)}`);
      continue;
    }
    let usedNpmFallback = false;
    let usedOfficialNpmFallback = false;
    let channelFallbackSuffix = "";
    let resultSource = record.source;
    activeClawHubInstallSpec = effectiveSpec;
    let npmChannelFallback: PluginUpdateChannelFallback | undefined;
    if (!result.ok && record.source === "npm" && npmSpecs?.fallbackSpec) {
      logger.warn?.(
        describeBetaNpmFallback({
          pluginId,
          betaSpec: npmSpecs.fallbackLabel ?? effectiveSpec,
          fallbackSpec: npmSpecs.fallbackSpec,
          result,
        }),
      );
      usedNpmFallback = true;
      npmChannelFallback = describeNpmChannelFallback({
        pluginId,
        requestedSpec: npmSpecs.fallbackLabel ?? effectiveSpec,
        usedSpec: npmSpecs.fallbackSpec,
        result,
        verb: "used",
      });
      channelFallbackSuffix = formatBetaChannelFallbackOutcomeSuffix({
        fallbackLabel: npmSpecs.fallbackLabel ?? effectiveSpec,
        fallbackSpec: npmSpecs.fallbackSpec,
        verb: "used",
      });
      result = await installNpmSpecForUpdate({
        spec: npmSpecs.fallbackSpec,
        mode: "update",
        extensionsDir,
        timeoutMs: params.timeoutMs,
        dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall,
        trustedSourceLinkedOfficialInstall,
        expectedPluginId: pluginId,
        expectedIntegrity: await getFallbackExpectedIntegrity(),
        onIntegrityDrift: createPluginUpdateIntegrityDriftHandler({
          pluginId,
          dryRun: false,
          logger,
          onIntegrityDrift: params.onIntegrityDrift,
        }),
        logger,
      });
    }
    if (
      !result.ok &&
      record.source === "clawhub" &&
      clawhubSpecs?.fallbackSpec &&
      shouldFallbackBetaClawHubUpdate(result)
    ) {
      channelFallbackSuffix = formatBetaChannelFallbackOutcomeSuffix({
        fallbackLabel: clawhubSpecs.fallbackLabel ?? effectiveSpec,
        fallbackSpec: clawhubSpecs.fallbackSpec,
        verb: "used",
      });
      logger.warn?.(
        `Plugin "${pluginId}" has no beta ClawHub release for ${clawhubSpecs.fallbackLabel ?? effectiveSpec}; using ${clawhubSpecs.fallbackSpec} instead. Core update can still complete.`,
      );
      result = await installPluginFromClawHub({
        spec: clawhubSpecs.fallbackSpec,
        config: params.config,
        baseUrl: record.clawhubUrl,
        mode: "update",
        extensionsDir,
        timeoutMs: params.timeoutMs,
        dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall,
        expectedPluginId: pluginId,
        logger,
      });
      activeClawHubInstallSpec = clawhubSpecs.fallbackSpec;
      if (officialNpmFallbackSpecs?.fallbackSpec) {
        officialNpmFallbackInstallSpec = officialNpmFallbackSpecs.fallbackSpec;
        officialNpmFallbackRecordSpec = officialNpmFallbackSpecs.fallbackSpec;
      }
    }
    if (
      !result.ok &&
      record.source === "clawhub" &&
      officialNpmFallbackInstallSpec &&
      shouldFallbackClawHubBridgeToNpm({
        result,
        npmSpec: officialNpmFallbackInstallSpec,
      })
    ) {
      logger.warn?.(
        `Plugin "${pluginId}" could not download official ClawHub artifact for ${activeClawHubInstallSpec ?? `clawhub:${record.clawhubPackage!}`}; using npm ${officialNpmFallbackInstallSpec} instead. Core update can still complete.`,
      );
      usedNpmFallback = true;
      usedOfficialNpmFallback = true;
      resultSource = "npm";
      channelFallbackSuffix = ` (warning: official ClawHub artifact fallback used ${officialNpmFallbackInstallSpec}).`;
      result = await installNpmSpecForUpdate({
        spec: officialNpmFallbackInstallSpec,
        config: params.config,
        mode: "update",
        extensionsDir,
        timeoutMs: params.timeoutMs,
        dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall,
        trustedSourceLinkedOfficialInstall: true,
        expectedPluginId: pluginId,
        logger,
      });
    }
    if (!result.ok) {
      recordFailure(
        pluginId,
        resultSource === "npm"
          ? formatNpmInstallFailure({
              pluginId,
              spec: usedOfficialNpmFallback
                ? (officialNpmFallbackInstallSpec ?? effectiveSpec ?? "")
                : npmUpdateFailureSpec({
                    effectiveSpec,
                    fallbackSpec: npmSpecs?.fallbackSpec,
                    usedFallback: usedNpmFallback,
                  }),
              phase: "update",
              result,
            })
          : resultSource === "clawhub"
            ? formatClawHubInstallFailure({
                pluginId,
                spec: activeClawHubInstallSpec ?? `clawhub:${record.clawhubPackage!}`,
                phase: "update",
                error: result.error,
              })
            : record.source === "git"
              ? formatGitInstallFailure({
                  pluginId,
                  spec: effectiveSpec!,
                  phase: "update",
                  error: result.error,
                })
              : formatMarketplaceInstallFailure({
                  pluginId,
                  marketplaceSource: record.marketplaceSource!,
                  marketplacePlugin: record.marketplacePlugin!,
                  phase: "update",
                  error: result.error,
                }),
        npmChannelFallback,
      );
      continue;
    }

    const resolvedPluginId = result.pluginId;
    if (resolvedPluginId !== pluginId) {
      next = migratePluginConfigId(next, pluginId, resolvedPluginId);
    }

    const nextVersion = result.version ?? (await readInstalledPackageVersion(result.targetDir));
    if (resultSource === "npm") {
      const npmResult = result as Extract<
        Awaited<ReturnType<typeof installPluginFromNpmSpec>>,
        { ok: true }
      >;
      next = recordPluginInstall(
        usedOfficialNpmFallback ? withoutPluginInstallRecord(next, resolvedPluginId) : next,
        {
          pluginId: resolvedPluginId,
          source: "npm",
          spec: resolveNpmInstallRecordSpec({
            requestedSpec: usedOfficialNpmFallback ? officialNpmFallbackRecordSpec : recordSpec,
            resolution: npmResult.npmResolution,
            pinResolvedRegistrySpec:
              (params.syncOfficialPluginInstalls && trustedSourceLinkedOfficialInstall) ||
              usedOfficialNpmFallback,
          }),
          installPath: result.targetDir,
          version: nextVersion,
          ...buildNpmResolutionInstallFields(npmResult.npmResolution),
        },
      );
    } else if (resultSource === "clawhub") {
      const clawhubResult = result as Extract<
        Awaited<ReturnType<typeof installPluginFromClawHub>>,
        { ok: true }
      >;
      next = recordPluginInstall(next, {
        pluginId: resolvedPluginId,
        ...buildClawHubPluginInstallRecordFields(clawhubResult.clawhub),
        spec: recordSpec ?? record.spec ?? `clawhub:${record.clawhubPackage!}`,
        installPath: result.targetDir,
        version: nextVersion,
      });
    } else if (record.source === "git") {
      const gitResult = result as Extract<
        Awaited<ReturnType<typeof installPluginFromGitSpec>>,
        { ok: true }
      >;
      next = recordPluginInstall(next, {
        pluginId: resolvedPluginId,
        source: "git",
        spec: effectiveSpec ?? record.spec,
        installPath: result.targetDir,
        version: nextVersion,
        resolvedAt: gitResult.git.resolvedAt,
        gitUrl: gitResult.git.url,
        gitRef: gitResult.git.ref,
        gitCommit: gitResult.git.commit,
      });
    } else {
      const marketplaceResult = result as Extract<
        Awaited<ReturnType<typeof installPluginFromMarketplace>>,
        { ok: true }
      >;
      next = recordPluginInstall(next, {
        pluginId: resolvedPluginId,
        source: "marketplace",
        installPath: result.targetDir,
        version: nextVersion,
        marketplaceName: marketplaceResult.marketplaceName ?? record.marketplaceName,
        marketplaceSource: record.marketplaceSource,
        marketplacePlugin: record.marketplacePlugin,
      });
    }
    changed = true;

    const currentLabel = currentVersion ?? "unknown";
    const nextLabel = nextVersion ?? "unknown";
    if (currentVersion && nextVersion && currentVersion === nextVersion) {
      outcomes.push({
        pluginId,
        status: "unchanged",
        currentVersion: currentVersion ?? undefined,
        nextVersion: nextVersion ?? undefined,
        message: `${pluginId} already at ${currentLabel}.${channelFallbackSuffix}`,
        ...(npmChannelFallback ? { channelFallback: npmChannelFallback } : {}),
      });
    } else {
      outcomes.push({
        pluginId,
        status: "updated",
        currentVersion: currentVersion ?? undefined,
        nextVersion: nextVersion ?? undefined,
        message: `Updated ${pluginId}: ${currentLabel} -> ${nextLabel}.${channelFallbackSuffix}`,
        ...(npmChannelFallback ? { channelFallback: npmChannelFallback } : {}),
      });
    }
  }

  if (ranNpmInstaller) {
    changed =
      (await repairOpenClawPeerLinksForNpmInstalls({
        config: next,
        logger,
      })) || changed;
  }

  return { config: next, changed, outcomes };
}

export async function syncPluginsForUpdateChannel(params: {
  config: OpenClawConfig;
  channel: UpdateChannel;
  workspaceDir?: string;
  env?: NodeJS.ProcessEnv;
  logger?: PluginUpdateLogger;
  externalizedBundledPluginBridges?: readonly ExternalizedBundledPluginBridge[];
}): Promise<PluginChannelSyncResult> {
  const env = params.env ?? process.env;
  const logger = params.logger ?? {};
  const summary: PluginChannelSyncSummary = {
    switchedToBundled: [],
    switchedToClawHub: [],
    switchedToNpm: [],
    warnings: [],
    errors: [],
  };
  const bundled = resolveBundledPluginSources({
    workspaceDir: params.workspaceDir,
    env,
  });

  let next = params.config;
  const loadHelpers = buildLoadPathHelpers(next.plugins?.load?.paths ?? [], env);
  let installs = next.plugins?.installs ?? {};
  let changed = false;

  if (params.channel === "dev") {
    for (const [pluginId, record] of Object.entries(installs)) {
      const bundledInfo = bundled.get(pluginId);
      if (!bundledInfo) {
        continue;
      }

      loadHelpers.addPath(bundledInfo.localPath);

      const alreadyBundled =
        record.source === "path" && pathsEqual(record.sourcePath, bundledInfo.localPath, env);
      if (alreadyBundled) {
        continue;
      }

      next = recordPluginInstall(next, {
        pluginId,
        source: "path",
        sourcePath: bundledInfo.localPath,
        installPath: bundledInfo.localPath,
        spec: record.spec ?? bundledInfo.npmSpec,
        version: record.version,
      });
      summary.switchedToBundled.push(pluginId);
      changed = true;
    }
  } else {
    const bridges = params.externalizedBundledPluginBridges ?? [];
    for (const bridge of bridges) {
      const targetPluginId = getExternalizedBundledPluginTargetId(bridge);
      const bundledInfo = bundled.get(bridge.bundledPluginId);
      if (bundledInfo) {
        continue;
      }
      const existing = resolveBridgeInstallRecord({ installs, bridge });
      if (
        !existing &&
        !isExternalizedBundledPluginEnabled({
          config: next,
          bridge,
        })
      ) {
        continue;
      }
      if (
        existing &&
        !isExternalizedBundledPluginEnabled({
          config: next,
          bridge,
        })
      ) {
        continue;
      }

      if (
        existing &&
        isBridgeAlreadyInstalledFromPreferredSource({
          bridge,
          record: existing.record,
        })
      ) {
        if (existing.pluginId !== targetPluginId) {
          next = migratePluginConfigId(next, existing.pluginId, targetPluginId);
          installs = next.plugins?.installs ?? {};
          changed = true;
        }
        removeBridgeBundledLoadPaths({ bridge, loadPaths: loadHelpers, env });
        continue;
      }

      if (
        existing &&
        !isBridgeBundledPathRecord({
          bridge,
          record: existing.record,
          env,
        }) &&
        !isBridgeInstalledFromFallbackSource({
          bridge,
          record: existing.record,
        })
      ) {
        continue;
      }

      const preferredSource = getExternalizedBundledPluginPreferredSource(bridge);
      const npmSpec = getExternalizedBundledPluginNpmSpec(bridge);
      const clawhubSpec = getExternalizedBundledPluginClawHubSpec(bridge);
      const trustedSourceLinkedOfficialInstall = isTrustedSourceLinkedOfficialBridgeNpmInstall({
        targetPluginId,
        npmSpec,
      });
      let installSource = preferredSource;
      let installSpec = preferredSource === "clawhub" ? clawhubSpec : npmSpec;
      let result:
        | Awaited<ReturnType<typeof installPluginFromNpmSpec>>
        | Awaited<ReturnType<typeof installPluginFromClawHub>>;

      if (!installSpec) {
        const message = `Failed to update ${targetPluginId}: missing ${preferredSource} install spec for externalized bundled plugin.`;
        summary.errors.push(message);
        logger.error?.(message);
        continue;
      }

      if (preferredSource === "clawhub") {
        result = await installPluginFromClawHub({
          spec: clawhubSpec,
          config: params.config,
          ...(bridge.clawhubUrl ? { baseUrl: bridge.clawhubUrl } : {}),
          mode: "update",
          expectedPluginId: targetPluginId,
          logger,
        });
        if (!result.ok && npmSpec && shouldFallbackClawHubBridgeToNpm({ result, npmSpec })) {
          const warning = `ClawHub ${clawhubSpec} unavailable for ${targetPluginId}; falling back to npm ${npmSpec}.`;
          summary.warnings.push(warning);
          logger.warn?.(warning);
          installSource = "npm";
          installSpec = npmSpec;
          result = await installPluginFromNpmSpec({
            spec: npmSpec,
            config: params.config,
            mode: "update",
            expectedPluginId: targetPluginId,
            trustedSourceLinkedOfficialInstall,
            logger,
          });
        }
      } else {
        result = await installPluginFromNpmSpec({
          spec: npmSpec,
          config: params.config,
          mode: "update",
          expectedPluginId: targetPluginId,
          trustedSourceLinkedOfficialInstall,
          logger,
        });
      }

      if (!result.ok) {
        const message =
          installSource === "clawhub"
            ? formatClawHubInstallFailure({
                pluginId: targetPluginId,
                spec: installSpec,
                phase: "update",
                error: result.error,
              })
            : formatNpmInstallFailure({
                pluginId: targetPluginId,
                spec: installSpec,
                phase: "update",
                result,
              });
        summary.errors.push(message);
        logger.error?.(message);
        continue;
      }

      const resolvedPluginId = result.pluginId;
      if (existing && existing.pluginId !== resolvedPluginId) {
        next = migratePluginConfigId(next, existing.pluginId, resolvedPluginId);
      }
      const nextVersion = result.version ?? (await readInstalledPackageVersion(result.targetDir));
      if (installSource === "clawhub") {
        const clawhubResult = result as Extract<
          Awaited<ReturnType<typeof installPluginFromClawHub>>,
          { ok: true }
        >;
        next = recordPluginInstall(next, {
          pluginId: resolvedPluginId,
          ...buildClawHubPluginInstallRecordFields(clawhubResult.clawhub),
          spec: installSpec,
          installPath: result.targetDir,
          version: nextVersion,
        });
      } else {
        const npmResult = result as Extract<
          Awaited<ReturnType<typeof installPluginFromNpmSpec>>,
          { ok: true }
        >;
        next = recordPluginInstall(next, {
          pluginId: resolvedPluginId,
          source: "npm",
          spec: resolveNpmInstallRecordSpec({
            requestedSpec: installSpec,
            resolution: npmResult.npmResolution,
            pinResolvedRegistrySpec: trustedSourceLinkedOfficialInstall,
          }),
          installPath: result.targetDir,
          version: nextVersion,
          ...buildNpmResolutionInstallFields(npmResult.npmResolution),
        });
      }
      installs = next.plugins?.installs ?? {};
      if (existing?.record.sourcePath) {
        loadHelpers.removePath(existing.record.sourcePath);
      }
      if (existing?.record.installPath) {
        loadHelpers.removePath(existing.record.installPath);
      }
      removeBridgeBundledLoadPaths({ bridge, loadPaths: loadHelpers, env });
      if (installSource === "clawhub") {
        summary.switchedToClawHub.push(resolvedPluginId);
      } else {
        summary.switchedToNpm.push(resolvedPluginId);
      }
      changed = true;
    }

    for (const [pluginId, record] of Object.entries(installs)) {
      const bundledInfo = bundled.get(pluginId);
      if (!bundledInfo) {
        continue;
      }

      if (record.source === "npm") {
        loadHelpers.removePath(bundledInfo.localPath);
        continue;
      }

      if (record.source !== "path") {
        continue;
      }
      if (!pathsEqual(record.sourcePath, bundledInfo.localPath, env)) {
        continue;
      }
      // Keep explicit bundled installs on release channels. Replacing them with
      // npm installs can reintroduce duplicate-id shadowing and packaging drift.
      loadHelpers.addPath(bundledInfo.localPath);
      const alreadyBundled =
        record.source === "path" &&
        pathsEqual(record.sourcePath, bundledInfo.localPath, env) &&
        pathsEqual(record.installPath, bundledInfo.localPath, env);
      if (alreadyBundled) {
        continue;
      }

      next = recordPluginInstall(next, {
        pluginId,
        source: "path",
        sourcePath: bundledInfo.localPath,
        installPath: bundledInfo.localPath,
        spec: record.spec ?? bundledInfo.npmSpec,
        version: record.version,
      });
      changed = true;
    }
  }

  if (loadHelpers.changed) {
    next = {
      ...next,
      plugins: {
        ...next.plugins,
        load: {
          ...next.plugins?.load,
          paths: loadHelpers.paths,
        },
      },
    };
    changed = true;
  }

  return { config: next, changed, summary };
}
