// Validates normalized OpenClaw config and reports user-facing errors.
import path from "node:path";
import { collectConfiguredModelRefs } from "@openclaw/model-catalog-core/configured-model-refs";
import { isCanonicalDottedDecimalIPv4, isLoopbackIpAddress } from "@openclaw/net-policy/ip";
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
import { isPathInside } from "../infra/path-guards.js";
import { planManifestModelCatalogSuppressions } from "../model-catalog/index.js";
import {
  normalizePluginsConfig,
  normalizePluginId,
  resolveEffectivePluginActivationState,
  resolveMemorySlotDecision,
} from "../plugins/config-state.js";
import { loadInstalledPluginIndexInstallRecordsSync } from "../plugins/installed-plugin-index-record-reader.js";
import { resolveManifestCommandAliasOwnerInRegistry } from "../plugins/manifest-command-aliases.js";
import type { PluginManifestRegistry } from "../plugins/manifest-registry.js";
import {
  getOfficialExternalPluginCatalogEntry,
  resolveOfficialExternalPluginInstall,
} from "../plugins/official-external-plugin-catalog.js";
import {
  resolvePluginMetadataSnapshot,
  type PluginMetadataSnapshot,
} from "../plugins/plugin-metadata-snapshot.js";
import { validateJsonSchemaValue } from "../plugins/schema-validator.js";
import { hasKind } from "../plugins/slots.js";
import { resolveWebSearchInstallCatalogEntries } from "../plugins/web-search-install-catalog.js";
import { collectUnsupportedSecretRefConfigCandidates } from "../secrets/unsupported-surface-policy.js";
import {
  hasAvatarUriScheme,
  isAvatarDataUrl,
  isAvatarHttpUrl,
  isPathWithinRoot,
  isWindowsAbsolutePath,
} from "../shared/avatar-policy.js";
import {
  formatUnsafeGatewayTailscaleNoAuthMessage,
  isUnsafeGatewayTailscaleNoAuth,
} from "../shared/gateway-tailscale-auth-policy.js";
import { isRecord, resolveUserPath } from "../utils.js";
import { findDuplicateAgentDirs, formatDuplicateAgentDirError } from "./agent-dirs.js";
import { appendAllowedValuesHint, summarizeAllowedValues } from "./allowed-values.js";
import { GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA } from "./bundled-channel-config-metadata.generated.js";
import { collectChannelSchemaMetadata } from "./channel-config-metadata.js";
import { shouldSuppressMissingCodexPluginDiagnostics } from "./codex-plugin-diagnostics.js";
import { materializeRuntimeConfig } from "./materialize.js";
import type { OpenClawConfig, ConfigValidationIssue } from "./types.js";
import { coerceSecretRef } from "./types.secrets.js";
import { isBuiltInModelProviderOverlayId } from "./zod-schema.core.js";
import { OpenClawSchema } from "./zod-schema.js";

const LEGACY_REMOVED_PLUGIN_IDS = new Set([
  "google-antigravity-auth",
  "google-gemini-cli-auth",
  "skill-workshop",
]);
const BLOCKED_PLUGIN_CANDIDATE_PREFIX = "blocked plugin candidate:";

function formatRemovedPluginConfigWarning(pluginId: string): string {
  if (pluginId === "skill-workshop") {
    return "plugin removed: skill-workshop (stale plugin config ignored; Skill Workshop is built into OpenClaw skills now. Use skills.workshop settings and openclaw skills workshop commands, then remove this plugins config entry)";
  }
  return `plugin removed: ${pluginId} (stale config entry ignored; remove it from plugins config)`;
}

type UnknownIssueRecord = Record<string, unknown>;
type ConfigPathSegment = string | number;
type ExplicitPluginReferences = {
  entries: Set<string>;
  allow: Set<string>;
  deny: Set<string>;
  slots: Map<string, string>;
};
type AllowedValuesCollection = {
  values: unknown[];
  incomplete: boolean;
  hasValues: boolean;
};
type JsonSchemaLike = Record<string, unknown>;

function stripDeprecatedValidationKeys(raw: unknown): unknown {
  if (!isRecord(raw) || !isRecord(raw.commands) || !Object.hasOwn(raw.commands, "modelsWrite")) {
    return raw;
  }
  const commands = { ...raw.commands };
  delete commands.modelsWrite;
  return {
    ...raw,
    commands,
  };
}

function materializeBundledModelProviderOverlays(config: OpenClawConfig): OpenClawConfig {
  const providers = config.models?.providers;
  if (!providers) {
    return config;
  }
  let nextProviders: typeof providers | undefined;
  for (const [providerId, providerConfig] of Object.entries(providers)) {
    if (
      !isBuiltInModelProviderOverlayId(providerId) ||
      (providerConfig.baseUrl && Array.isArray(providerConfig.models))
    ) {
      continue;
    }
    nextProviders ??= { ...providers };
    nextProviders[providerId] = {
      ...providerConfig,
      baseUrl: providerConfig.baseUrl ?? "",
      models: providerConfig.models ?? [],
    };
  }
  if (!nextProviders) {
    return config;
  }
  return {
    ...config,
    models: {
      ...config.models,
      providers: nextProviders,
    },
  };
}

function stripPreservedLegacyRootKeysForValidation(
  raw: unknown,
  keys?: readonly string[],
): unknown {
  if (!keys || keys.length === 0 || !isRecord(raw)) {
    return raw;
  }
  const next = { ...raw };
  for (const key of keys) {
    delete next[key];
  }
  return next;
}

const CUSTOM_EXPECTED_ONE_OF_RE = /expected one of ((?:"[^"]+"(?:\|"?[^"]+"?)*)+)/i;
const SECRETREF_POLICY_DOC_URL = "https://docs.openclaw.ai/reference/secretref-credential-surface";
const bundledChannelSchemaById = new Map<string, unknown>(
  GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA.filter((entry) => entry.configurable !== false).map(
    (entry) => [entry.channelId, entry.schema] as const,
  ),
);
const bundledChannelIds = Object.freeze(
  GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA.filter((entry) => entry.configurable !== false)
    .map((entry) => normalizeLowercaseStringOrEmpty(entry.channelId))
    .filter((channelId) => channelId.length > 0),
);
const bundledChannelIdSet = new Set(bundledChannelIds);
const bundledChannelAliases = new Map<string, string>(
  GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA.filter((entry) => entry.configurable !== false).flatMap(
    (entry) => {
      const channelId = normalizeLowercaseStringOrEmpty(entry.channelId);
      if (!channelId) {
        return [];
      }
      return (entry.aliases ?? [])
        .map((alias) => [normalizeLowercaseStringOrEmpty(alias), channelId] as const)
        .filter(([alias]) => alias.length > 0);
    },
  ),
);

function normalizeBundledChannelId(raw?: string | null): string | null {
  const normalized = normalizeLowercaseStringOrEmpty(raw);
  if (!normalized) {
    return null;
  }
  const resolved = bundledChannelAliases.get(normalized) ?? normalized;
  return bundledChannelIdSet.has(resolved) ? resolved : null;
}

function toIssueRecord(value: unknown): UnknownIssueRecord | null {
  if (!value || typeof value !== "object") {
    return null;
  }
  return value as UnknownIssueRecord;
}

function toConfigPathSegments(pathLocal3: unknown): ConfigPathSegment[] {
  if (!Array.isArray(pathLocal3)) {
    return [];
  }
  return pathLocal3.filter((segment): segment is ConfigPathSegment => {
    const segmentType = typeof segment;
    return segmentType === "string" || segmentType === "number";
  });
}

function formatConfigPath(segments: readonly ConfigPathSegment[]): string {
  return segments.join(".");
}

function formatMissingOfficialExternalPluginWarning(
  pluginId: string,
  opts?: { selectedMissingMemorySlot?: boolean },
): string | null {
  const catalogEntry = getOfficialExternalPluginCatalogEntry(pluginId);
  if (!catalogEntry) {
    return null;
  }
  const install = resolveOfficialExternalPluginInstall(catalogEntry);
  const npmSpec = install?.npmSpec?.trim();
  const clawhubSpec = install?.clawhubSpec?.trim();
  const installSpec =
    install?.defaultChoice === "clawhub" ? (clawhubSpec ?? npmSpec) : (npmSpec ?? clawhubSpec);
  if (!installSpec) {
    return null;
  }
  if (pluginId === "memory-lancedb" && opts?.selectedMissingMemorySlot) {
    return `plugin not installed: ${pluginId} — gateway will run without persistent memory until installed; install the official external plugin with: openclaw plugins install ${installSpec}`;
  }
  return `plugin not installed: ${pluginId} — install the official external plugin with: openclaw plugins install ${installSpec}`;
}

function asJsonSchemaLike(value: unknown): JsonSchemaLike | null {
  return value && typeof value === "object" ? (value as JsonSchemaLike) : null;
}

function lookupJsonSchemaNode(
  schema: unknown,
  pathSegments: readonly ConfigPathSegment[],
): JsonSchemaLike | null {
  let current = asJsonSchemaLike(schema);
  for (const segment of pathSegments) {
    if (!current) {
      return null;
    }
    if (typeof segment === "number") {
      const items = current.items;
      if (Array.isArray(items)) {
        current = asJsonSchemaLike(items[segment] ?? items[0]);
        continue;
      }
      current = asJsonSchemaLike(items);
      continue;
    }
    const properties = asJsonSchemaLike(current.properties);
    const next =
      (properties && asJsonSchemaLike(properties[segment])) ||
      asJsonSchemaLike(current.additionalProperties);
    current = next;
  }
  return current;
}

function collectAllowedValuesFromJsonSchemaNode(schema: unknown): AllowedValuesCollection {
  const node = asJsonSchemaLike(schema);
  if (!node) {
    return { values: [], incomplete: false, hasValues: false };
  }

  if (Object.hasOwn(node, "const")) {
    return { values: [node.const], incomplete: false, hasValues: true };
  }

  if (Array.isArray(node.enum)) {
    return { values: node.enum, incomplete: false, hasValues: node.enum.length > 0 };
  }

  const type = node.type;
  if (type === "boolean") {
    return { values: [true, false], incomplete: false, hasValues: true };
  }
  if (Array.isArray(type) && type.includes("boolean")) {
    return { values: [true, false], incomplete: false, hasValues: true };
  }

  const unionBranches = Array.isArray(node.anyOf)
    ? node.anyOf
    : Array.isArray(node.oneOf)
      ? node.oneOf
      : null;
  if (!unionBranches) {
    return { values: [], incomplete: false, hasValues: false };
  }

  const collected: unknown[] = [];
  for (const branch of unionBranches) {
    const branchCollected = collectAllowedValuesFromJsonSchemaNode(branch);
    if (branchCollected.incomplete || !branchCollected.hasValues) {
      return { values: [], incomplete: true, hasValues: false };
    }
    collected.push(...branchCollected.values);
  }

  return { values: collected, incomplete: false, hasValues: collected.length > 0 };
}

function collectAllowedValuesFromBundledChannelSchemaPath(
  pathSegments: readonly ConfigPathSegment[],
): AllowedValuesCollection {
  if (pathSegments[0] !== "channels" || typeof pathSegments[1] !== "string") {
    return { values: [], incomplete: false, hasValues: false };
  }
  const channelSchema = bundledChannelSchemaById.get(pathSegments[1]);
  if (!channelSchema) {
    return { values: [], incomplete: false, hasValues: false };
  }
  const targetNode = lookupJsonSchemaNode(channelSchema, pathSegments.slice(2));
  if (!targetNode) {
    return { values: [], incomplete: false, hasValues: false };
  }
  return collectAllowedValuesFromJsonSchemaNode(targetNode);
}

function formatRawChannelConfigIssueMessage(message: string): string {
  return `invalid config: ${message}`;
}
function collectRawBundledChannelConfigIssues(config: OpenClawConfig): ConfigValidationIssue[] {
  if (!config.channels || !isRecord(config.channels)) {
    return [];
  }
  const issues: ConfigValidationIssue[] = [];
  for (const [channelId, schema] of bundledChannelSchemaById) {
    if (!Object.hasOwn(config.channels, channelId)) {
      continue;
    }
    const result = validateJsonSchemaValue({
      schema: schema as Record<string, unknown>,
      cacheKey: `raw-channel:${channelId}`,
      value: config.channels[channelId],
      applyDefaults: false,
    });
    if (result.ok) {
      continue;
    }
    for (const error of result.errors) {
      const message = error.additionalProperty
        ? `${error.message}: "${error.additionalProperty}"`
        : error.message;
      const pathLocal2 =
        error.path === "<root>" ? `channels.${channelId}` : `channels.${channelId}.${error.path}`;
      issues.push({
        path: pathLocal2,
        message: formatRawChannelConfigIssueMessage(message),
        allowedValues: error.allowedValues,
        allowedValuesHiddenCount: error.allowedValuesHiddenCount,
      });
    }
  }
  return issues;
}

function collectAllowedValuesFromCustomIssue(record: UnknownIssueRecord): AllowedValuesCollection {
  const message = typeof record.message === "string" ? record.message : "";
  const expectedMatch = message.match(CUSTOM_EXPECTED_ONE_OF_RE);
  if (expectedMatch?.[1]) {
    const values = [...expectedMatch[1].matchAll(/"([^"]+)"/g)].map((match) => match[1]);
    return { values, incomplete: false, hasValues: values.length > 0 };
  }

  // Custom Zod issues usually come from superRefine rules, but some normalized
  // channel unions collapse to a generic custom issue. Use generated channel
  // config metadata here so we can recover enum hints without touching runtime
  // plugin registries during validation formatting.
  return collectAllowedValuesFromBundledChannelSchemaPath(toConfigPathSegments(record.path));
}

function appendNumericBoundHint(message: string, record: UnknownIssueRecord): string {
  const origin = typeof record.origin === "string" ? record.origin : "";
  if (origin !== "number") {
    return message;
  }
  const inclusive = record.inclusive === true;
  if (record.code === "too_big") {
    const maximum = typeof record.maximum === "number" ? record.maximum : undefined;
    if (maximum !== undefined) {
      return inclusive
        ? `${message} (maximum: ${maximum})`
        : `${message} (must be less than ${maximum})`;
    }
  }
  if (record.code === "too_small") {
    const minimum = typeof record.minimum === "number" ? record.minimum : undefined;
    if (minimum !== undefined) {
      return inclusive
        ? `${message} (minimum: ${minimum})`
        : `${message} (must be greater than ${minimum})`;
    }
  }
  return message;
}

function collectAllowedValuesFromIssue(issue: unknown): AllowedValuesCollection {
  const record = toIssueRecord(issue);
  if (!record) {
    return { values: [], incomplete: false, hasValues: false };
  }
  const code = typeof record.code === "string" ? record.code : "";

  if (code === "invalid_value") {
    const values = record.values;
    if (!Array.isArray(values)) {
      return { values: [], incomplete: true, hasValues: false };
    }
    return { values, incomplete: false, hasValues: values.length > 0 };
  }

  if (code === "invalid_type") {
    const expected = typeof record.expected === "string" ? record.expected : "";
    if (expected === "boolean") {
      return { values: [true, false], incomplete: false, hasValues: true };
    }
    return { values: [], incomplete: true, hasValues: false };
  }

  if (code === "custom") {
    return collectAllowedValuesFromCustomIssue(record);
  }

  if (code !== "invalid_union") {
    return { values: [], incomplete: false, hasValues: false };
  }

  const nested = record.errors;
  if (!Array.isArray(nested) || nested.length === 0) {
    return { values: [], incomplete: true, hasValues: false };
  }

  const collected: unknown[] = [];
  for (const branch of nested) {
    if (!Array.isArray(branch) || branch.length === 0) {
      return { values: [], incomplete: true, hasValues: false };
    }
    const branchCollected = collectAllowedValuesFromIssueList(branch);
    if (branchCollected.incomplete || !branchCollected.hasValues) {
      return { values: [], incomplete: true, hasValues: false };
    }
    collected.push(...branchCollected.values);
  }

  return { values: collected, incomplete: false, hasValues: collected.length > 0 };
}

function collectAllowedValuesFromIssueList(
  issues: ReadonlyArray<unknown>,
): AllowedValuesCollection {
  const collected: unknown[] = [];
  let hasValues = false;
  for (const issue of issues) {
    const branch = collectAllowedValuesFromIssue(issue);
    if (branch.incomplete) {
      return { values: [], incomplete: true, hasValues: false };
    }
    if (!branch.hasValues) {
      continue;
    }
    hasValues = true;
    collected.push(...branch.values);
  }
  return { values: collected, incomplete: false, hasValues };
}

function collectAllowedValuesFromUnknownIssue(issue: unknown): unknown[] {
  const collection = collectAllowedValuesFromIssue(issue);
  if (collection.incomplete || !collection.hasValues) {
    return [];
  }
  return collection.values;
}

function isBindingsIssuePath(pathSegments: readonly ConfigPathSegment[]): boolean {
  return pathSegments[0] === "bindings" && typeof pathSegments[1] === "number";
}

function isRouteTypeMismatchIssue(issue: UnknownIssueRecord): boolean {
  const issuePath = toConfigPathSegments(issue.path);
  if (issuePath.length !== 1 || issuePath[0] !== "type") {
    return false;
  }
  if (issue.code !== "invalid_value" || !Array.isArray(issue.values)) {
    return false;
  }
  return issue.values.includes("route");
}

function extractBindingsSpecificUnionIssue(
  record: UnknownIssueRecord,
  parentPath: string,
): ConfigValidationIssue | null {
  if (!isBindingsIssuePath(toConfigPathSegments(record.path)) || !Array.isArray(record.errors)) {
    return null;
  }

  let matchingBranchIssue: UnknownIssueRecord | null = null;
  let matchingBranchIsUnrecognized = false;
  let matchingBranchPathLen = -1;
  let sawRouteTypeMismatch = false;

  for (const errGroup of record.errors) {
    if (!Array.isArray(errGroup)) {
      continue;
    }

    const branch = errGroup
      .map((issue) => toIssueRecord(issue))
      .filter(Boolean) as UnknownIssueRecord[];
    if (branch.length === 0) {
      continue;
    }

    if (branch.some((issue) => isRouteTypeMismatchIssue(issue))) {
      sawRouteTypeMismatch = true;
      continue;
    }

    let branchBestIssue: UnknownIssueRecord | null = null;
    let branchBestIsUnrecognized = false;
    let branchBestPathLen = -1;

    for (const issue of branch) {
      const issueCode = typeof issue.code === "string" ? issue.code : "";
      const issuePathLen = toConfigPathSegments(issue.path).length;
      const issueIsUnrecognized = issueCode === "unrecognized_keys";
      const issueIsBetter =
        issuePathLen > branchBestPathLen
          ? true
          : issuePathLen === branchBestPathLen && issueIsUnrecognized && !branchBestIsUnrecognized;

      if (issueIsBetter) {
        branchBestIssue = issue;
        branchBestIsUnrecognized = issueIsUnrecognized;
        branchBestPathLen = issuePathLen;
      }
    }

    if (!branchBestIssue) {
      continue;
    }

    if (matchingBranchIssue) {
      return null;
    }

    matchingBranchIssue = branchBestIssue;
    matchingBranchIsUnrecognized = branchBestIsUnrecognized;
    matchingBranchPathLen = branchBestPathLen;
  }

  if (!sawRouteTypeMismatch || !matchingBranchIssue) {
    return null;
  }

  if (matchingBranchPathLen === 0 && !matchingBranchIsUnrecognized) {
    return null;
  }

  const subPath = formatConfigPath(toConfigPathSegments(matchingBranchIssue.path));
  const fullPath = parentPath && subPath ? `${parentPath}.${subPath}` : parentPath || subPath;
  const subMessage =
    typeof matchingBranchIssue.message === "string" ? matchingBranchIssue.message : "Invalid input";
  return { path: fullPath, message: subMessage };
}

function isObjectSecretRefCandidate(value: unknown): boolean {
  if (!value || typeof value !== "object" || Array.isArray(value)) {
    return false;
  }
  return coerceSecretRef(value) !== null;
}

function formatUnsupportedMutableSecretRefMessage(pathInner: string): string {
  return [
    `SecretRef objects are not supported at ${pathInner}.`,
    "This credential is runtime-mutable or runtime-managed and must stay a plain string value.",
    'Use a plain string (env template strings like "${MY_VAR}" are allowed).',
    `See ${SECRETREF_POLICY_DOC_URL}.`,
  ].join(" ");
}

function pushUnsupportedMutableSecretRefIssue(
  issues: ConfigValidationIssue[],
  pathScoped: string,
  value: unknown,
): void {
  if (!isObjectSecretRefCandidate(value)) {
    return;
  }
  issues.push({
    path: pathScoped,
    message: formatUnsupportedMutableSecretRefMessage(pathScoped),
  });
}

function collectUnsupportedMutableSecretRefIssues(raw: unknown): ConfigValidationIssue[] {
  const issues: ConfigValidationIssue[] = [];
  for (const candidate of collectUnsupportedSecretRefConfigCandidates(raw)) {
    pushUnsupportedMutableSecretRefIssue(issues, candidate.path, candidate.value);
  }

  return issues;
}

function formatFilteredUnrecognizedKeyMessage(message: string, keys: string[]): string {
  const quotedKeys = keys.map((key) => `"${key}"`).join(", ");
  if (/must not have additional properties/i.test(message)) {
    return `must not have additional properties: ${quotedKeys}`;
  }
  return keys.length === 1 ? `Unrecognized key: ${quotedKeys}` : `Unrecognized keys: ${quotedKeys}`;
}

function filterUnsupportedMutableSecretRefSchemaIssue(params: {
  issue: ConfigValidationIssue;
  policyIssue: ConfigValidationIssue;
}): ConfigValidationIssue | null {
  const { issue, policyIssue } = params;
  if (issue.path === policyIssue.path) {
    return /expected string, received object/i.test(issue.message) ? null : issue;
  }

  if (!issue.path || !policyIssue.path || !policyIssue.path.startsWith(`${issue.path}.`)) {
    return issue;
  }

  const remainder = policyIssue.path.slice(issue.path.length + 1);
  const childKey = remainder.split(".")[0];
  if (!childKey) {
    return issue;
  }

  if (!/Unrecognized key|must not have additional properties/i.test(issue.message)) {
    return issue;
  }
  const unrecognizedKeys = [...issue.message.matchAll(/"([^"]+)"/g)].map((match) => match[1]);
  if (unrecognizedKeys.length === 0) {
    return issue;
  }
  if (!unrecognizedKeys.includes(childKey)) {
    return issue;
  }
  const remainingKeys = unrecognizedKeys.filter((key) => key !== childKey);
  if (remainingKeys.length === 0) {
    return null;
  }
  return {
    ...issue,
    message: formatFilteredUnrecognizedKeyMessage(issue.message, remainingKeys),
  };
}

function mergeUnsupportedMutableSecretRefIssues(
  policyIssues: ConfigValidationIssue[],
  schemaIssues: ConfigValidationIssue[],
): ConfigValidationIssue[] {
  if (policyIssues.length === 0) {
    return schemaIssues;
  }
  const filteredSchemaIssues = schemaIssues.flatMap((issue) => {
    let filteredIssue: ConfigValidationIssue | null = issue;
    for (const policyIssue of policyIssues) {
      if (!filteredIssue) {
        return [];
      }
      filteredIssue = filterUnsupportedMutableSecretRefSchemaIssue({
        issue: filteredIssue,
        policyIssue,
      });
    }
    return filteredIssue ? [filteredIssue] : [];
  });
  return [...policyIssues, ...filteredSchemaIssues];
}

export function collectUnsupportedSecretRefPolicyIssues(raw: unknown): ConfigValidationIssue[] {
  return collectUnsupportedMutableSecretRefIssues(raw);
}

function mapZodIssueToConfigIssue(issue: unknown): ConfigValidationIssue {
  const record = toIssueRecord(issue);
  const pathItem = formatConfigPath(toConfigPathSegments(record?.path));
  const message = typeof record?.message === "string" ? record.message : "Invalid input";

  // Numeric ceiling/floor hints (too_big / too_small with numeric origin).
  // Append a parenthesized bound alongside Zod's native message,
  // matching the clarity that enum/union rejections get via (allowed: …).
  const enrichedMessage = record ? appendNumericBoundHint(message, record) : message;

  const allowedValuesSummary = summarizeAllowedValues(collectAllowedValuesFromUnknownIssue(issue));

  // Bindings use a plain union because legacy route bindings may omit `type`.
  // When an explicit ACP binding fails strict-object checks, Zod collapses the
  // useful ACP branch issue behind a generic union-level "Invalid input".
  if (
    record &&
    typeof record.code === "string" &&
    record.code === "invalid_union" &&
    !allowedValuesSummary
  ) {
    const betterIssue = extractBindingsSpecificUnionIssue(record, pathItem);
    if (betterIssue) {
      return betterIssue;
    }
  }

  if (!allowedValuesSummary) {
    return { path: pathItem, message: enrichedMessage };
  }

  return {
    path: pathItem,
    message: appendAllowedValuesHint(enrichedMessage, allowedValuesSummary),
    allowedValues: allowedValuesSummary.values,
    allowedValuesHiddenCount: allowedValuesSummary.hiddenCount,
  };
}

function collectExplicitPluginReferences(raw: unknown): ExplicitPluginReferences {
  const references: ExplicitPluginReferences = {
    entries: new Set(),
    allow: new Set(),
    deny: new Set(),
    slots: new Map(),
  };
  if (!isRecord(raw) || !isRecord(raw.plugins)) {
    return references;
  }
  const { plugins } = raw;
  if (isRecord(plugins.entries)) {
    for (const pluginId of Object.keys(plugins.entries)) {
      const normalized = normalizePluginId(pluginId);
      if (normalized) {
        references.entries.add(normalized);
      }
    }
  }
  for (const [key, target] of [
    ["allow", references.allow],
    ["deny", references.deny],
  ] as const) {
    const value = plugins[key];
    if (!Array.isArray(value)) {
      continue;
    }
    for (const entry of value) {
      if (typeof entry !== "string") {
        continue;
      }
      const normalized = normalizePluginId(entry);
      if (normalized) {
        target.add(normalized);
      }
    }
  }
  if (isRecord(plugins.slots)) {
    for (const [slotId, pluginId] of Object.entries(plugins.slots)) {
      if (typeof pluginId !== "string") {
        continue;
      }
      const normalized = normalizePluginId(pluginId);
      if (normalized && normalized !== "none") {
        references.slots.set(normalized, slotId);
      }
    }
  }
  return references;
}

function resolveExplicitPluginReferencePath(
  references: ExplicitPluginReferences,
  pluginId: string,
): string | undefined {
  const normalized = normalizePluginId(pluginId);
  if (!normalized) {
    return undefined;
  }
  if (references.entries.has(normalized)) {
    return `plugins.entries.${normalized}`;
  }
  if (references.allow.has(normalized)) {
    return "plugins.allow";
  }
  if (references.deny.has(normalized)) {
    return "plugins.deny";
  }
  const slotId = references.slots.get(normalized);
  if (slotId) {
    return `plugins.slots.${slotId}`;
  }
  return undefined;
}

export const testing = {
  mapZodIssueToConfigIssue,
};

function isWorkspaceAvatarPath(value: string, workspaceDir: string): boolean {
  const workspaceRoot = path.resolve(workspaceDir);
  const resolved = path.resolve(workspaceRoot, value);
  return isPathWithinRoot(workspaceRoot, resolved);
}

function validateIdentityAvatar(config: OpenClawConfig): ConfigValidationIssue[] {
  const agents = config.agents?.list;
  if (!Array.isArray(agents) || agents.length === 0) {
    return [];
  }
  const issues: ConfigValidationIssue[] = [];
  for (const [index, entry] of agents.entries()) {
    if (!entry || typeof entry !== "object") {
      continue;
    }
    const avatarRaw = entry.identity?.avatar;
    if (typeof avatarRaw !== "string") {
      continue;
    }
    const avatar = avatarRaw.trim();
    if (!avatar) {
      continue;
    }
    if (isAvatarDataUrl(avatar) || isAvatarHttpUrl(avatar)) {
      continue;
    }
    if (avatar.startsWith("~")) {
      issues.push({
        path: `agents.list.${index}.identity.avatar`,
        message: "identity.avatar must be a workspace-relative path, http(s) URL, or data URI.",
      });
      continue;
    }
    const hasScheme = hasAvatarUriScheme(avatar);
    if (hasScheme && !isWindowsAbsolutePath(avatar)) {
      issues.push({
        path: `agents.list.${index}.identity.avatar`,
        message: "identity.avatar must be a workspace-relative path, http(s) URL, or data URI.",
      });
      continue;
    }
    const workspaceDir = resolveAgentWorkspaceDir(
      config,
      entry.id ?? resolveDefaultAgentId(config),
    );
    if (!isWorkspaceAvatarPath(avatar, workspaceDir)) {
      issues.push({
        path: `agents.list.${index}.identity.avatar`,
        message: "identity.avatar must stay within the agent workspace.",
      });
    }
  }
  return issues;
}

function validateGatewayTailscaleBind(config: OpenClawConfig): ConfigValidationIssue[] {
  const tailscaleMode = config.gateway?.tailscale?.mode ?? "off";
  if (tailscaleMode !== "serve" && tailscaleMode !== "funnel") {
    return [];
  }
  const bindMode = config.gateway?.bind ?? "loopback";
  if (bindMode === "loopback") {
    return [];
  }
  const customBindHost = config.gateway?.customBindHost;
  if (
    bindMode === "custom" &&
    isCanonicalDottedDecimalIPv4(customBindHost) &&
    isLoopbackIpAddress(customBindHost)
  ) {
    return [];
  }
  return [
    {
      path: "gateway.bind",
      message:
        `gateway.bind must resolve to loopback when gateway.tailscale.mode=${tailscaleMode} ` +
        '(use gateway.bind="loopback" or gateway.bind="custom" with gateway.customBindHost="127.0.0.1")',
    },
  ];
}

function validateGatewayTailscaleAuth(config: OpenClawConfig): ConfigValidationIssue[] {
  const tailscaleMode = config.gateway?.tailscale?.mode ?? "off";
  if (!isUnsafeGatewayTailscaleNoAuth({ authMode: config.gateway?.auth?.mode, tailscaleMode })) {
    return [];
  }
  return [
    {
      path: "gateway.auth.mode",
      message: formatUnsafeGatewayTailscaleNoAuthMessage(tailscaleMode),
    },
  ];
}

/**
 * Validates config without applying runtime defaults.
 * Use this when you need the raw validated config (e.g., for writing back to file).
 */
export function validateConfigObjectRaw(
  raw: unknown,
  opts?: {
    sourceRaw?: unknown;
    touchedPaths?: ReadonlyArray<ReadonlyArray<string>>;
    validateBundledChannels?: boolean;
    preservedLegacyRootKeys?: readonly string[];
  },
): { ok: true; config: OpenClawConfig } | { ok: false; issues: ConfigValidationIssue[] } {
  const normalizedRaw = stripPreservedLegacyRootKeysForValidation(
    stripDeprecatedValidationKeys(raw),
    opts?.preservedLegacyRootKeys,
  );
  const policyIssues = collectUnsupportedSecretRefPolicyIssues(normalizedRaw);
  const validated = OpenClawSchema.safeParse(normalizedRaw);
  if (!validated.success) {
    const schemaIssues = validated.error.issues.map((issue) => mapZodIssueToConfigIssue(issue));
    return {
      ok: false,
      issues: mergeUnsupportedMutableSecretRefIssues(policyIssues, schemaIssues),
    };
  }
  const validatedConfig = materializeBundledModelProviderOverlays(validated.data as OpenClawConfig);
  const channelIssues =
    policyIssues.length > 0 || opts?.validateBundledChannels
      ? collectRawBundledChannelConfigIssues(validatedConfig)
      : [];
  if (channelIssues.length > 0) {
    return {
      ok: false,
      issues: mergeUnsupportedMutableSecretRefIssues(policyIssues, channelIssues),
    };
  }
  if (policyIssues.length > 0) {
    return { ok: false, issues: policyIssues };
  }
  const duplicates = findDuplicateAgentDirs(validatedConfig);
  if (duplicates.length > 0) {
    return {
      ok: false,
      issues: [
        {
          path: "agents.list",
          message: formatDuplicateAgentDirError(duplicates),
        },
      ],
    };
  }
  const avatarIssues = validateIdentityAvatar(validatedConfig);
  if (avatarIssues.length > 0) {
    return { ok: false, issues: avatarIssues };
  }
  const gatewayTailscaleBindIssues = validateGatewayTailscaleBind(validatedConfig);
  if (gatewayTailscaleBindIssues.length > 0) {
    return { ok: false, issues: gatewayTailscaleBindIssues };
  }
  const gatewayTailscaleAuthIssues = validateGatewayTailscaleAuth(validatedConfig);
  if (gatewayTailscaleAuthIssues.length > 0) {
    return { ok: false, issues: gatewayTailscaleAuthIssues };
  }
  return {
    ok: true,
    config: validatedConfig,
  };
}

export function validateConfigObject(
  raw: unknown,
  opts?: {
    manifestRegistry?: Pick<PluginMetadataSnapshot, "manifestRegistry">["manifestRegistry"];
    sourceRaw?: unknown;
  },
): { ok: true; config: OpenClawConfig } | { ok: false; issues: ConfigValidationIssue[] } {
  const result = validateConfigObjectRaw(raw, opts);
  if (!result.ok) {
    return result;
  }
  return {
    ok: true,
    config: materializeRuntimeConfig(result.config, "snapshot", {
      manifestRegistry: opts?.manifestRegistry,
    }),
  };
}

type ValidateConfigWithPluginsResult =
  | {
      ok: true;
      config: OpenClawConfig;
      warnings: ConfigValidationIssue[];
    }
  | {
      ok: false;
      issues: ConfigValidationIssue[];
      warnings: ConfigValidationIssue[];
    };

type ValidateConfigWithPluginsParams = {
  env?: NodeJS.ProcessEnv;
  pluginValidation?: "full" | "skip";
  pluginMetadataSnapshot?: Pick<PluginMetadataSnapshot, "manifestRegistry">;
  loadPluginMetadataSnapshot?: (
    config: OpenClawConfig,
  ) => Pick<PluginMetadataSnapshot, "manifestRegistry">;
  sourceRaw?: unknown;
  preservedLegacyRootKeys?: readonly string[];
};

export function validateConfigObjectWithPlugins(
  raw: unknown,
  params?: ValidateConfigWithPluginsParams,
): ValidateConfigWithPluginsResult {
  return validateConfigObjectWithPluginsBase(raw, {
    applyDefaults: true,
    env: params?.env,
    pluginValidation: params?.pluginValidation ?? "full",
    pluginMetadataSnapshot: params?.pluginMetadataSnapshot,
    loadPluginMetadataSnapshot: params?.loadPluginMetadataSnapshot,
    sourceRaw: params?.sourceRaw,
    preservedLegacyRootKeys: params?.preservedLegacyRootKeys,
  });
}

export function validateConfigObjectRawWithPlugins(
  raw: unknown,
  params?: ValidateConfigWithPluginsParams,
): ValidateConfigWithPluginsResult {
  return validateConfigObjectWithPluginsBase(raw, {
    applyDefaults: false,
    env: params?.env,
    pluginValidation: params?.pluginValidation ?? "full",
    pluginMetadataSnapshot: params?.pluginMetadataSnapshot,
    loadPluginMetadataSnapshot: params?.loadPluginMetadataSnapshot,
    sourceRaw: params?.sourceRaw,
    preservedLegacyRootKeys: params?.preservedLegacyRootKeys,
  });
}

function validateConfigObjectWithPluginsBase(
  raw: unknown,
  opts: ValidateConfigWithPluginsParams & { applyDefaults: boolean },
): ValidateConfigWithPluginsResult {
  const base = validateConfigObjectRaw(raw, {
    sourceRaw: opts.sourceRaw,
    preservedLegacyRootKeys: opts.preservedLegacyRootKeys,
  });
  if (!base.ok) {
    return { ok: false, issues: base.issues, warnings: [] };
  }

  let registryInfo: RegistryInfo | null = opts.pluginMetadataSnapshot
    ? { registry: opts.pluginMetadataSnapshot.manifestRegistry }
    : null;
  if (opts.applyDefaults && !registryInfo) {
    const pluginMetadataSnapshot = opts.loadPluginMetadataSnapshot?.(base.config);
    if (pluginMetadataSnapshot) {
      registryInfo = { registry: pluginMetadataSnapshot.manifestRegistry };
    }
  }
  const config = opts.applyDefaults
    ? materializeRuntimeConfig(base.config, "snapshot", {
        manifestRegistry: registryInfo?.registry,
      })
    : base.config;
  if (opts.pluginValidation === "skip") {
    return {
      ok: true,
      config,
      warnings: [],
    };
  }

  const issues: ConfigValidationIssue[] = [];
  const warnings: ConfigValidationIssue[] = [];
  const hasExplicitPluginsConfig = isRecord(raw) && Object.hasOwn(raw, "plugins");
  const explicitPluginReferences = collectExplicitPluginReferences(raw);

  const resolvePluginConfigIssuePath = (pluginId: string, errorPath: string): string => {
    const baseLocal = `plugins.entries.${pluginId}.config`;
    if (!errorPath || errorPath === "<root>") {
      return baseLocal;
    }
    return `${baseLocal}.${errorPath}`;
  };

  type RegistryInfo = {
    registry: PluginManifestRegistry;
    knownIds?: Set<string>;
    overriddenPluginIds?: Set<string>;
    normalizedPlugins?: ReturnType<typeof normalizePluginsConfig>;
    channelSchemas?: Map<
      string,
      {
        schema?: Record<string, unknown>;
      }
    >;
  };

  let compatConfig: OpenClawConfig | null | undefined;
  let compatPluginIds: ReadonlySet<string> | null = null;
  let compatPluginIdsResolved = false;
  let registryDiagnosticsPushed = false;

  const pushRegistryDiagnostics = (registry: PluginManifestRegistry): void => {
    if (registryDiagnosticsPushed) {
      return;
    }
    registryDiagnosticsPushed = true;
    for (const diag of registry.diagnostics) {
      const explicitPath = diag.pluginId
        ? resolveExplicitPluginReferencePath(explicitPluginReferences, diag.pluginId)
        : undefined;
      let pathCandidate = explicitPath ?? (diag.pluginId ? "plugins" : "plugins");
      if (!diag.pluginId && diag.message.includes("plugin path not found")) {
        pathCandidate = "plugins.load.paths";
      }
      const pluginLabel = diag.pluginId ? `plugin ${diag.pluginId}` : "plugin";
      const message = `${pluginLabel}: ${diag.message}`;
      if (diag.level === "error" && (explicitPath || !diag.pluginId)) {
        issues.push({ path: pathCandidate, message });
      } else {
        warnings.push({ path: pathCandidate, message });
      }
    }
  };

  const loadValidationRegistry = (): RegistryInfo => {
    const pluginMetadataSnapshot = opts.loadPluginMetadataSnapshot?.(config);
    if (pluginMetadataSnapshot) {
      registryInfo = { registry: pluginMetadataSnapshot.manifestRegistry };
      return registryInfo;
    }
    const workspaceDir = resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config));
    const registry = resolvePluginMetadataSnapshot({
      config,
      workspaceDir: workspaceDir ?? undefined,
      env: opts.env ?? process.env,
      allowWorkspaceScopedCurrent: true,
    }).manifestRegistry;
    registryInfo = { registry };
    return registryInfo;
  };

  const ensureCompatPluginIds = (): ReadonlySet<string> => {
    if (compatPluginIdsResolved) {
      return compatPluginIds ?? new Set<string>();
    }
    compatPluginIdsResolved = true;
    const allow = config.plugins?.allow;
    if (!Array.isArray(allow) || allow.length === 0) {
      compatPluginIds = new Set<string>();
      return compatPluginIds;
    }
    const { registry } = registryInfo ?? loadValidationRegistry();
    const overriddenBundledPluginIds = new Set(
      registry.diagnostics
        .filter((diag) => diag.message.includes("duplicate plugin id detected"))
        .map((diag) => diag.pluginId)
        .filter((pluginId): pluginId is string => typeof pluginId === "string" && pluginId !== ""),
    );
    compatPluginIds = new Set(
      registry.plugins
        .filter(
          (plugin) =>
            plugin.origin === "bundled" &&
            (plugin.contracts?.webSearchProviders?.length ?? 0) > 0 &&
            !overriddenBundledPluginIds.has(plugin.id),
        )
        .map((plugin) => plugin.id),
    );
    return compatPluginIds;
  };

  const ensureCompatConfig = (): OpenClawConfig => {
    if (compatConfig !== undefined) {
      return compatConfig ?? config;
    }

    compatConfig = config;
    return config;
  };

  const ensureRegistry = (): RegistryInfo => {
    const info = registryInfo ?? loadValidationRegistry();
    ensureCompatConfig();
    pushRegistryDiagnostics(info.registry);
    return info;
  };

  const ensureKnownIds = (): Set<string> => {
    const info = ensureRegistry();
    if (!info.knownIds) {
      info.knownIds = new Set(info.registry.plugins.map((record) => record.id));
    }
    return info.knownIds;
  };

  const ensureOverriddenPluginIds = (): Set<string> => {
    const info = ensureRegistry();
    if (!info.overriddenPluginIds) {
      info.overriddenPluginIds = new Set(
        info.registry.diagnostics
          .filter((diag) => diag.message.includes("duplicate plugin id detected"))
          .map((diag) => diag.pluginId)
          .filter(
            (pluginId): pluginId is string => typeof pluginId === "string" && pluginId !== "",
          ),
      );
    }
    return info.overriddenPluginIds;
  };

  const ensureNormalizedPlugins = (): ReturnType<typeof normalizePluginsConfig> => {
    const info = ensureRegistry();
    if (!info.normalizedPlugins) {
      info.normalizedPlugins = normalizePluginsConfig(ensureCompatConfig().plugins);
    }
    return info.normalizedPlugins;
  };

  const ensureChannelSchemas = (): Map<
    string,
    {
      schema?: Record<string, unknown>;
    }
  > => {
    const info = ensureRegistry();
    if (!info.channelSchemas) {
      info.channelSchemas = new Map(
        GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA.map(
          (entry) => [entry.channelId, { schema: entry.schema }] as const,
        ),
      );
      for (const entry of collectChannelSchemaMetadata(info.registry)) {
        const current = info.channelSchemas.get(entry.id);
        if (entry.configSchema) {
          info.channelSchemas.set(entry.id, { schema: entry.configSchema });
          continue;
        }
        if (!current) {
          info.channelSchemas.set(entry.id, {});
        }
      }
    }
    return info.channelSchemas;
  };

  let mutatedConfig = config;
  let channelsCloned = false;
  let pluginsCloned = false;
  let pluginEntriesCloned = false;
  let installedPluginRecordIds: Set<string> | undefined;

  const ensureInstalledPluginRecordIds = (): Set<string> => {
    if (installedPluginRecordIds) {
      return installedPluginRecordIds;
    }
    try {
      installedPluginRecordIds = new Set(
        Object.keys(loadInstalledPluginIndexInstallRecordsSync({ env: opts.env })).map(
          normalizePluginId,
        ),
      );
    } catch {
      installedPluginRecordIds = new Set();
    }
    return installedPluginRecordIds;
  };

  const hasStalePluginEvidenceForUnknownChannel = (channelId: string): boolean => {
    const normalizedChannelId = normalizePluginId(channelId);
    if (!normalizedChannelId || ensureKnownIds().has(normalizedChannelId)) {
      return false;
    }
    const pluginConfig = config.plugins;
    if (
      Array.isArray(pluginConfig?.allow) &&
      pluginConfig.allow.some((pluginId) => normalizePluginId(pluginId) === normalizedChannelId)
    ) {
      return true;
    }
    if (
      isRecord(pluginConfig?.entries) &&
      Object.keys(pluginConfig.entries).some(
        (pluginId) => normalizePluginId(pluginId) === normalizedChannelId,
      )
    ) {
      return true;
    }
    if (
      isRecord(pluginConfig?.installs) &&
      Object.keys(pluginConfig.installs).some(
        (pluginId) => normalizePluginId(pluginId) === normalizedChannelId,
      )
    ) {
      return true;
    }
    return ensureInstalledPluginRecordIds().has(normalizedChannelId);
  };

  const collectActiveWebSearchProviderIds = (): string[] => {
    const { registry } = ensureRegistry();
    return [
      ...new Set(
        registry.plugins
          .flatMap((record) => record.contracts?.webSearchProviders ?? [])
          .map((providerId) => providerId.trim())
          .filter((providerId) => providerId.length > 0),
      ),
    ].toSorted((left, right) => left.localeCompare(right));
  };

  const collectKnownWebSearchProviderIds = (): string[] => {
    return [
      ...new Set([
        ...collectActiveWebSearchProviderIds(),
        ...resolveWebSearchInstallCatalogEntries()
          .map((entry) => entry.provider.id.trim())
          .filter((providerId) => providerId.length > 0),
      ]),
    ].toSorted((left, right) => left.localeCompare(right));
  };

  const hasPluginEvidenceForWebSearchProvider = (
    ...pluginOrProviderIds: readonly string[]
  ): boolean => {
    const candidateIds = new Set(
      pluginOrProviderIds.map((id) => normalizePluginId(id)).filter((id) => id.length > 0),
    );
    if (candidateIds.size === 0) {
      return false;
    }
    const matches = (pluginId: string) => candidateIds.has(normalizePluginId(pluginId));
    const pluginConfig = config.plugins;
    if (Array.isArray(pluginConfig?.allow) && pluginConfig.allow.some(matches)) {
      return true;
    }
    if (isRecord(pluginConfig?.entries) && Object.keys(pluginConfig.entries).some(matches)) {
      return true;
    }
    if (isRecord(pluginConfig?.installs) && Object.keys(pluginConfig.installs).some(matches)) {
      return true;
    }
    for (const pluginId of candidateIds) {
      if (ensureInstalledPluginRecordIds().has(pluginId)) {
        return true;
      }
    }
    return false;
  };

  const hasStalePluginEvidenceForUnknownWebSearchProvider = (providerId: string): boolean => {
    const normalizedProviderId = normalizePluginId(providerId);
    if (!normalizedProviderId || ensureKnownIds().has(normalizedProviderId)) {
      return false;
    }
    return hasPluginEvidenceForWebSearchProvider(providerId);
  };

  const validateWebSearchProvider = () => {
    const provider = config.tools?.web?.search?.provider;
    if (typeof provider !== "string") {
      return;
    }
    const trimmed = provider.trim();
    const pathEntry = "tools.web.search.provider";
    if (!trimmed) {
      issues.push({ path: pathEntry, message: "web_search provider must not be empty" });
      return;
    }
    const activeProviderIds = collectActiveWebSearchProviderIds();
    if (activeProviderIds.includes(trimmed)) {
      return;
    }
    const installCatalogEntry = resolveWebSearchInstallCatalogEntries().find(
      (entry) => entry.provider.id === trimmed,
    );
    if (installCatalogEntry) {
      const issue = {
        path: pathEntry,
        message: `web_search provider is not available: ${trimmed} (install or enable plugin "${installCatalogEntry.pluginId}", then run openclaw doctor --fix)`,
        allowedValues: collectKnownWebSearchProviderIds(),
      };
      if (hasPluginEvidenceForWebSearchProvider(trimmed, installCatalogEntry.pluginId)) {
        warnings.push({
          ...issue,
          message: `web_search provider is not available: ${trimmed} (configured plugin "${installCatalogEntry.pluginId}" is unavailable; Gateway will ignore this optional provider until the plugin is installed/enabled or openclaw doctor --fix repairs the config)`,
        });
        return;
      }
      issues.push(issue);
      return;
    }
    const allowedValues = collectKnownWebSearchProviderIds();
    if (allowedValues.length === 0) {
      return;
    }
    const issue = {
      path: pathEntry,
      message: `unknown web_search provider: ${trimmed}`,
      allowedValues,
    };
    if (hasStalePluginEvidenceForUnknownWebSearchProvider(trimmed)) {
      warnings.push({
        ...issue,
        message: `${issue.message} (stale web search plugin config ignored; run openclaw doctor --fix to remove stale config, or install the plugin)`,
      });
      return;
    }
    issues.push(issue);
  };

  const parseProviderModelRef = (value: string): { provider: string; model: string } | null => {
    const slashIndex = value.indexOf("/");
    if (slashIndex <= 0 || slashIndex >= value.length - 1) {
      return null;
    }
    const provider = normalizeLowercaseStringOrEmpty(value.slice(0, slashIndex));
    const model = normalizeLowercaseStringOrEmpty(value.slice(slashIndex + 1));
    return provider && model ? { provider, model } : null;
  };

  const validateConfiguredModelRefs = () => {
    const configuredRefs = collectConfiguredModelRefs(config);
    if (configuredRefs.length === 0) {
      return;
    }
    const { registry } = ensureRegistry();
    const suppressedModels = new Map<
      string,
      { provider: string; model: string; reason?: string }
    >();
    for (const suppression of planManifestModelCatalogSuppressions({ registry }).suppressions) {
      if (suppression.when) {
        continue;
      }
      const key = `${suppression.provider}/${suppression.model}`;
      if (!suppressedModels.has(key)) {
        suppressedModels.set(key, {
          provider: suppression.provider,
          model: suppression.model,
          ...(suppression.reason ? { reason: suppression.reason } : {}),
        });
      }
    }
    if (suppressedModels.size === 0) {
      return;
    }
    const seen = new Set<string>();
    for (const ref of configuredRefs) {
      const parsed = parseProviderModelRef(ref.value);
      if (!parsed) {
        continue;
      }
      const suppression = suppressedModels.get(`${parsed.provider}/${parsed.model}`);
      if (!suppression) {
        continue;
      }
      const issueKey = `${ref.path}\0${parsed.provider}/${parsed.model}`;
      if (seen.has(issueKey)) {
        continue;
      }
      seen.add(issueKey);
      const modelRef = `${suppression.provider}/${suppression.model}`;
      issues.push({
        path: ref.path,
        message: suppression.reason
          ? `Unknown model: ${modelRef}. ${suppression.reason}`
          : `Unknown model: ${modelRef}.`,
      });
    }
  };

  const replaceChannelConfig = (channelId: string, nextValue: unknown) => {
    if (!channelsCloned) {
      mutatedConfig = {
        ...mutatedConfig,
        channels: {
          ...mutatedConfig.channels,
        },
      };
      channelsCloned = true;
    }
    (mutatedConfig.channels as Record<string, unknown>)[channelId] = nextValue;
  };

  const replacePluginEntryConfig = (pluginId: string, nextValue: Record<string, unknown>) => {
    if (!pluginsCloned) {
      mutatedConfig = {
        ...mutatedConfig,
        plugins: {
          ...mutatedConfig.plugins,
        },
      };
      pluginsCloned = true;
    }
    if (!pluginEntriesCloned) {
      mutatedConfig.plugins = {
        ...mutatedConfig.plugins,
        entries: {
          ...mutatedConfig.plugins?.entries,
        },
      };
      pluginEntriesCloned = true;
    }
    const currentEntry = mutatedConfig.plugins?.entries?.[pluginId];
    mutatedConfig.plugins!.entries![pluginId] = {
      ...currentEntry,
      config: nextValue,
    };
  };

  const allowedChannels = new Set<string>(["defaults", "modelByChannel", ...bundledChannelIds]);

  if (config.channels && isRecord(config.channels)) {
    for (const key of Object.keys(config.channels)) {
      const trimmed = key.trim();
      if (!trimmed) {
        continue;
      }
      if (!allowedChannels.has(trimmed)) {
        const { registry } = ensureRegistry();
        for (const record of registry.plugins) {
          for (const channelId of record.channels) {
            allowedChannels.add(channelId);
          }
        }
      }
      if (!allowedChannels.has(trimmed)) {
        const issue = {
          path: `channels.${trimmed}`,
          message: `unknown channel id: ${trimmed}`,
        };
        if (hasStalePluginEvidenceForUnknownChannel(trimmed)) {
          warnings.push({
            ...issue,
            message: `${issue.message} (stale channel plugin config ignored; run openclaw doctor --fix to remove stale config, or install the plugin)`,
          });
        } else {
          issues.push(issue);
        }
        continue;
      }

      const channelSchema = ensureChannelSchemas().get(trimmed)?.schema;
      if (!channelSchema) {
        continue;
      }
      const result = validateJsonSchemaValue({
        schema: channelSchema,
        cacheKey: `channel:${trimmed}`,
        value: config.channels[trimmed],
        applyDefaults: true, // Always apply defaults for AJV schema validation;
        // writeConfigFile persists persistCandidate, not validated.config (#61841)
      });
      if (!result.ok) {
        for (const error of result.errors) {
          const pathResult =
            error.path === "<root>" ? `channels.${trimmed}` : `channels.${trimmed}.${error.path}`;
          issues.push({
            path: pathResult,
            message: formatRawChannelConfigIssueMessage(error.message),
            allowedValues: error.allowedValues,
            allowedValuesHiddenCount: error.allowedValuesHiddenCount,
          });
        }
        continue;
      }
      replaceChannelConfig(trimmed, result.value);
    }
  }

  const heartbeatChannelIds = new Set<string>();
  for (const channelId of bundledChannelIds) {
    heartbeatChannelIds.add(normalizeLowercaseStringOrEmpty(channelId));
  }

  const validateHeartbeatTarget = (target: string | undefined, pathValue: string) => {
    if (typeof target !== "string") {
      return;
    }
    const trimmed = target.trim();
    if (!trimmed) {
      issues.push({ path: pathValue, message: "heartbeat target must not be empty" });
      return;
    }
    const normalized = normalizeLowercaseStringOrEmpty(trimmed);
    if (normalized === "last" || normalized === "none") {
      return;
    }
    if (normalizeBundledChannelId(trimmed)) {
      return;
    }
    if (!heartbeatChannelIds.has(normalized)) {
      const { registry } = ensureRegistry();
      for (const record of registry.plugins) {
        for (const channelId of record.channels) {
          const pluginChannel = channelId.trim();
          if (pluginChannel) {
            heartbeatChannelIds.add(normalizeLowercaseStringOrEmpty(pluginChannel));
          }
        }
      }
    }
    if (heartbeatChannelIds.has(normalized)) {
      return;
    }
    issues.push({ path: pathValue, message: `unknown heartbeat target: ${target}` });
  };

  validateHeartbeatTarget(
    config.agents?.defaults?.heartbeat?.target,
    "agents.defaults.heartbeat.target",
  );
  if (Array.isArray(config.agents?.list)) {
    for (const [index, entry] of config.agents.list.entries()) {
      validateHeartbeatTarget(entry?.heartbeat?.target, `agents.list.${index}.heartbeat.target`);
    }
  }

  validateWebSearchProvider();
  validateConfiguredModelRefs();

  if (!hasExplicitPluginsConfig) {
    if (issues.length > 0) {
      return { ok: false, issues, warnings };
    }
    return { ok: true, config: mutatedConfig, warnings };
  }

  const { registry } = ensureRegistry();
  const knownIds = ensureKnownIds();
  const normalizedPlugins = ensureNormalizedPlugins();
  const effectiveConfig = ensureCompatConfig();
  const blockedPluginDiagnostics = new Map<string, { message: string; source?: string }>();
  const blockedPluginDiagnosticsWithSource: Array<{ message: string; source: string }> = [];
  const normalizeBlockedDiagnosticPath = (value: string | undefined): string => {
    const trimmed = value?.trim();
    if (!trimmed) {
      return "";
    }
    try {
      return path.resolve(resolveUserPath(trimmed, opts.env ?? process.env));
    } catch {
      return path.resolve(trimmed);
    }
  };
  for (const diag of registry.diagnostics) {
    if (!diag.message.startsWith(BLOCKED_PLUGIN_CANDIDATE_PREFIX)) {
      continue;
    }
    if (!diag.pluginId && diag.source) {
      blockedPluginDiagnosticsWithSource.push({
        message: diag.message,
        source: diag.source,
      });
    }
    if (diag.pluginId) {
      const normalizedPluginId = normalizePluginId(diag.pluginId);
      for (const key of [diag.pluginId, normalizedPluginId]) {
        if (!key || blockedPluginDiagnostics.has(key)) {
          continue;
        }
        blockedPluginDiagnostics.set(key, {
          message: diag.message,
          ...(diag.source ? { source: diag.source } : {}),
        });
      }
    }
  }
  const blockedDiagnosticSourceMatchesPluginId = (
    diagnostic: { message: string; source: string },
    pluginId: string,
  ): boolean => {
    const normalizedPluginId = normalizePluginId(pluginId);
    if (!normalizedPluginId) {
      return false;
    }
    const sourcePath = normalizeBlockedDiagnosticPath(diagnostic.source);
    if (!sourcePath) {
      return false;
    }
    if (
      normalizePluginId(path.basename(sourcePath)) === normalizedPluginId ||
      normalizePluginId(path.basename(path.dirname(sourcePath))) === normalizedPluginId
    ) {
      return true;
    }
    const loadPaths = config.plugins?.load?.paths;
    if (!Array.isArray(loadPaths)) {
      return false;
    }
    for (const loadPath of loadPaths) {
      if (typeof loadPath !== "string") {
        continue;
      }
      const resolvedLoadPath = normalizeBlockedDiagnosticPath(loadPath);
      if (!resolvedLoadPath) {
        continue;
      }
      if (normalizePluginId(path.basename(resolvedLoadPath)) !== normalizedPluginId) {
        continue;
      }
      if (
        sourcePath === resolvedLoadPath ||
        isPathInside(resolvedLoadPath, sourcePath) ||
        isPathInside(sourcePath, resolvedLoadPath)
      ) {
        return true;
      }
    }
    return false;
  };
  const findBlockedPluginDiagnostic = (pluginId: string) => {
    const direct =
      blockedPluginDiagnostics.get(pluginId) ??
      blockedPluginDiagnostics.get(normalizePluginId(pluginId));
    if (direct) {
      return direct;
    }
    return blockedPluginDiagnosticsWithSource.find((diagnostic) =>
      blockedDiagnosticSourceMatchesPluginId(diagnostic, pluginId),
    );
  };
  const missingOfficialPluginWarningIds = new Set<string>();
  const pushMissingPluginIssue = (
    pathLocal: string,
    pluginId: string,
    optsLocal?: {
      warnOnly?: boolean;
      officialInstallHint?: boolean;
      missingMessage?: string | null;
    },
  ) => {
    if (LEGACY_REMOVED_PLUGIN_IDS.has(pluginId)) {
      warnings.push({
        path: pathLocal,
        message: formatRemovedPluginConfigWarning(pluginId),
      });
      return;
    }
    const blockedDiagnostic = findBlockedPluginDiagnostic(pluginId);
    if (blockedDiagnostic) {
      const source = blockedDiagnostic.source ? `; source: ${blockedDiagnostic.source}` : "";
      const message = `plugin present but blocked: ${pluginId} (see preceding plugin warning${source}; fix the blocked plugin path instead of removing config)`;
      if (optsLocal?.warnOnly) {
        warnings.push({ path: pathLocal, message });
      } else {
        issues.push({ path: pathLocal, message });
      }
      return;
    }
    if (
      normalizePluginId(pluginId) === "codex" &&
      pathLocal === "plugins.entries.codex" &&
      shouldSuppressMissingCodexPluginDiagnostics(config)
    ) {
      return;
    }
    if (optsLocal?.warnOnly && optsLocal.officialInstallHint !== false) {
      const externalInstallWarning =
        optsLocal.missingMessage ?? formatMissingOfficialExternalPluginWarning(pluginId);
      if (externalInstallWarning) {
        const normalizedPluginId = normalizePluginId(pluginId);
        if (!optsLocal.missingMessage && normalizedPluginId) {
          if (missingOfficialPluginWarningIds.has(normalizedPluginId)) {
            return;
          }
          missingOfficialPluginWarningIds.add(normalizedPluginId);
        }
        warnings.push({
          path: pathLocal,
          message: externalInstallWarning,
        });
        return;
      }
    }
    if (optsLocal?.warnOnly) {
      warnings.push({
        path: pathLocal,
        message: `plugin not found: ${pluginId} (stale config entry ignored; remove it from plugins config)`,
      });
      return;
    }
    issues.push({
      path: pathLocal,
      message: `plugin not found: ${pluginId}`,
    });
  };

  const pluginsConfig = config.plugins;

  const entries = pluginsConfig?.entries;
  if (entries && isRecord(entries)) {
    for (const pluginId of Object.keys(entries)) {
      if (!knownIds.has(pluginId)) {
        // Keep gateway startup resilient when plugins are removed/renamed across upgrades.
        pushMissingPluginIssue(`plugins.entries.${pluginId}`, pluginId, { warnOnly: true });
      }
    }
  }

  const allow = pluginsConfig?.allow ?? [];
  for (const pluginId of allow) {
    if (typeof pluginId !== "string" || !pluginId.trim()) {
      continue;
    }
    if (!knownIds.has(pluginId)) {
      const commandAlias = resolveManifestCommandAliasOwnerInRegistry({
        command: pluginId,
        registry,
      });
      if (commandAlias?.pluginId && knownIds.has(commandAlias.pluginId)) {
        warnings.push({
          path: "plugins.allow",
          message:
            `"${pluginId}" is not a plugin — it is a command provided by the "${commandAlias.pluginId}" plugin. ` +
            `Use "${commandAlias.pluginId}" in plugins.allow instead.`,
        });
      } else {
        pushMissingPluginIssue("plugins.allow", pluginId, { warnOnly: true });
      }
    }
  }

  const deny = pluginsConfig?.deny ?? [];
  for (const pluginId of deny) {
    if (typeof pluginId !== "string" || !pluginId.trim()) {
      continue;
    }
    if (!knownIds.has(pluginId)) {
      pushMissingPluginIssue("plugins.deny", pluginId, {
        warnOnly: true,
        officialInstallHint: false,
      });
    }
  }

  // The default memory slot is inferred; only a user-configured slot should block startup.
  const pluginSlots = pluginsConfig?.slots;
  const hasExplicitMemorySlot = pluginSlots !== undefined && Object.hasOwn(pluginSlots, "memory");
  const memorySlot = normalizedPlugins.slots.memory;
  if (
    hasExplicitMemorySlot &&
    typeof memorySlot === "string" &&
    memorySlot.trim() &&
    !knownIds.has(memorySlot)
  ) {
    const isMissingOfficialExternalMemorySlot =
      memorySlot === "memory-lancedb" &&
      Boolean(
        formatMissingOfficialExternalPluginWarning(memorySlot, {
          selectedMissingMemorySlot: true,
        }),
      );
    pushMissingPluginIssue("plugins.slots.memory", memorySlot, {
      warnOnly: isMissingOfficialExternalMemorySlot && !findBlockedPluginDiagnostic(memorySlot),
      missingMessage: formatMissingOfficialExternalPluginWarning(memorySlot, {
        selectedMissingMemorySlot: true,
      }),
    });
  }

  let selectedMemoryPluginId: string | null = null;
  const seenPlugins = new Set<string>();
  for (const record of registry.plugins) {
    const pluginId = record.id;
    if (seenPlugins.has(pluginId)) {
      continue;
    }
    seenPlugins.add(pluginId);
    const entry = normalizedPlugins.entries[pluginId];
    const entryHasConfig = Boolean(entry?.config);

    const activationState = resolveEffectivePluginActivationState({
      id: pluginId,
      origin: record.origin,
      config: normalizedPlugins,
      rootConfig: effectiveConfig,
    });
    let enabled = activationState.activated;
    let reason = activationState.reason;

    if (enabled) {
      const memoryDecision = resolveMemorySlotDecision({
        id: pluginId,
        kind: record.kind,
        slot: memorySlot,
        selectedId: selectedMemoryPluginId,
      });
      if (!memoryDecision.enabled) {
        enabled = false;
        reason = memoryDecision.reason;
      }
      if (memoryDecision.selected && hasKind(record.kind, "memory")) {
        selectedMemoryPluginId = pluginId;
      }
    }

    const shouldReplacePluginConfig = entryHasConfig || (opts.applyDefaults && enabled);
    const shouldValidate = enabled || entryHasConfig;
    if (shouldValidate) {
      if (record.configSchema) {
        const res = validateJsonSchemaValue({
          schema: record.configSchema,
          cacheKey: record.schemaCacheKey ?? record.manifestPath ?? pluginId,
          value: entry?.config ?? {},
          applyDefaults: true, // Always apply defaults for AJV schema validation;
          // writeConfigFile persists persistCandidate, not validated.config (#61841)
        });
        if (!res.ok) {
          for (const error of res.errors) {
            issues.push({
              path: resolvePluginConfigIssuePath(pluginId, error.path),
              message: `invalid config: ${error.message}`,
              allowedValues: error.allowedValues,
              allowedValuesHiddenCount: error.allowedValuesHiddenCount,
            });
          }
        } else if (shouldReplacePluginConfig) {
          replacePluginEntryConfig(pluginId, res.value as Record<string, unknown>);
        }
      } else if (record.format === "bundle") {
        // Compatible bundles currently expose no native OpenClaw config schema.
        // Treat them as schema-less capability packs rather than failing validation.
      } else {
        issues.push({
          path: `plugins.entries.${pluginId}`,
          message: `plugin schema missing for ${pluginId}`,
        });
      }
    }

    const suppressDisabledConfigWarning =
      ensureCompatPluginIds().has(pluginId) && !ensureOverriddenPluginIds().has(pluginId);
    if (!enabled && entryHasConfig && !suppressDisabledConfigWarning) {
      warnings.push({
        path: `plugins.entries.${pluginId}`,
        message: `plugin disabled (${reason ?? "disabled"}) but config is present`,
      });
    }
  }

  if (issues.length > 0) {
    return { ok: false, issues, warnings };
  }

  return { ok: true, config: mutatedConfig, warnings };
}
export { testing as __testing };
