// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.

/**
 * Zod schema for AkmConfig — the single source of truth for the on-disk shape.
 *
 * Two responsibilities:
 * 1. **Validate + transform** the raw JSON-parsed config object into the runtime
 *    `AkmConfig` shape consumed by the rest of the codebase. Replaces the
 *    ~1.4k LOC of legacy per-shape parsers (parseLlmConfig, parseEmbeddingConfig,
 *    parseIndexConfig, etc.) — see `loadConfig` in `./config.ts`.
 * 2. **Reject hard-errored values** (openviking source type, legacy
 *    `stashes[]` key) at load time via `superRefine`.
 *
 * Design rules:
 * - Top-level uses `.passthrough()` so unknown future keys round-trip intact on
 *   read; `sanitizeConfigForWrite` decides what to persist.
 * - Most nested sub-objects use `.catch(undefined)` so malformed entries are
 *   silently dropped (matches the legacy parser's warn-and-ignore semantics for
 *   field-level shape errors — keeps cold-start working when a user has a
 *   typo in their config).
 * - Two exceptions (hard-rejected): openviking source type and legacy
 *   `stashes[]` key. Both have explicit migration paths; silently dropping
 *   would mask user data loss.
 * - `.strict()` walls still gate `registries[]`, `sources[]`, `profiles.*`
 *   sub-shapes so typos in those structured records are caught (#462).
 * - `defaultWriteTarget` resolution and similar cross-field invariants are
 *   enforced at save time via `superRefine` on the top-level schema.
 */
import { z } from "zod";
import { VALID_HARNESS_IDS } from "./config-types";

// ── Reusable atomic schemas ─────────────────────────────────────────────────

/** Positive integer (used for tokens, timeouts, batch sizes). */
const positiveInt = z.number().int().positive();

/** Non-negative finite number (used for scores, weights, days). */
const nonNegativeNumber = z.number().finite().min(0);

/** Non-empty string (rejects "" and whitespace-only). */
const nonEmptyString = z
  .string()
  .min(1)
  .refine((v) => v.trim().length > 0, { message: "expected a non-empty string" });

/** HTTP(S) URL string. */
const httpUrl = z.string().refine((v) => v.startsWith("http://") || v.startsWith("https://"), {
  message: "endpoint must start with http:// or https://",
});

// ── Feedback failure modes ──────────────────────────────────────────────────

export const FEEDBACK_FAILURE_MODES = ["incorrect", "outdated", "dangerous", "incomplete", "redundant"] as const;

// ── Connection configs (LLM / embedding) ────────────────────────────────────

const LlmCapabilitiesSchema = z
  .object({
    structuredOutput: z.boolean().optional(),
  })
  .strict();

/**
 * Connection config used for both top-level `llm` (after migration) and
 * `profiles.llm[*]`. `model` is required at schema level — partial entries
 * created by `akm config set llm.endpoint <url>` (where model is left absent)
 * are normalized to `model: ""` *before* Zod sees them by the load-time
 * pre-Zod migrator hook, so this strict shape gates CLI writes without
 * breaking legacy load-time partial configs.
 */
export const LlmConnectionConfigSchema = z
  .object({
    provider: z.string().optional(),
    endpoint: z.string(),
    model: z.string(),
    apiKey: z.string().optional(),
    temperature: z.number().finite().optional(),
    maxTokens: positiveInt.optional(),
    timeoutMs: positiveInt.optional(),
    concurrency: positiveInt.optional(),
    capabilities: LlmCapabilitiesSchema.optional(),
    extraParams: z.record(z.unknown()).optional(),
    contextLength: positiveInt.optional(),
    judgeModel: z.string().min(1).optional(),
    enableThinking: z.boolean().optional(),
  })
  .strict();

export const LlmProfileConfigSchema = LlmConnectionConfigSchema.extend({
  supportsJsonSchema: z.boolean().optional(),
}).strict();

const EmbeddingOllamaOptionsSchema = z
  .object({
    num_ctx: positiveInt.optional(),
  })
  .strict();

/**
 * Embedding connection config. Both `endpoint` and `model` are optional:
 *   - Remote: provide `endpoint` (http/https URL) + `model`.
 *   - Local-only: omit `endpoint`/`model`; set `localModel` (or fall back to
 *     {@link DEFAULT_LOCAL_MODEL}).
 *
 * Consumers route via `hasRemoteEndpoint()` which checks for an http(s)
 * endpoint — absent fields take the local path naturally, no sentinels needed.
 */
export const EmbeddingConnectionConfigSchema = z
  .object({
    provider: z.string().optional(),
    endpoint: z.string().optional(),
    model: z.string().optional(),
    apiKey: z.string().optional(),
    dimension: positiveInt.optional(),
    localModel: z.string().min(1).optional(),
    maxTokens: positiveInt.optional(),
    batchSize: positiveInt.optional(),
    chunkSize: positiveInt.optional(),
    contextLength: positiveInt.optional(),
    ollamaOptions: EmbeddingOllamaOptionsSchema.optional(),
  })
  .strict();

// ── Agent profiles ──────────────────────────────────────────────────────────

// Derives from the canonical VALID_HARNESS_IDS (#565) so the Zod gate cannot
// drift from the TS union / parse check / setup detection.
const AgentPlatformSchema = z.enum(VALID_HARNESS_IDS);

export const AgentProfileConfigSchema = z
  .object({
    platform: AgentPlatformSchema,
    bin: z.string().min(1).optional(),
    args: z.array(z.string()).optional(),
    workspace: z.string().min(1).optional(),
    model: z.string().min(1).optional(),
  })
  .strict();

// ── Improve profile / process ──────────────────────────────────────────────

export const ImproveProcessConfigSchema = z
  .object({
    enabled: z.boolean().optional(),
    mode: z.enum(["llm", "agent", "sdk"]).optional(),
    profile: z.string().min(1).optional(),
    timeoutMs: z.union([positiveInt, z.null()]).optional(),
    allowedTypes: z.array(z.string().min(1)).optional(),
    // Consolidate process: minimum eligible-memory pool size below which the
    // consolidation pass skips entirely (emits `pool_below_min_size`). 0 disables
    // the guard. Only meaningful on the `consolidate` process. Default 500.
    minPoolSize: z.number().int().min(0).optional(),
    qualityGate: z.object({ enabled: z.boolean().optional() }).strict().optional(),
    contradictionDetection: z.object({ enabled: z.boolean().optional() }).strict().optional(),
    // Extract process config (only meaningful for extract process)
    defaultSince: z.string().min(1).optional(),
    maxTotalChars: positiveInt.optional(),
    // Extract process: minimum raw session size (pre-filter inputCount) below
    // which the extract LLM call is skipped (#595/#596). 0 disables the gate.
    // Absent = default 10 (skip only truly empty sessions). Only meaningful
    // on the `extract` process.
    minContentChars: z.number().int().min(0).optional(),
    maxChunkSize: z.number().int().min(1).max(50).optional(),
    // Consolidate process: narrow candidate pool to memories modified within
    // this duration window plus their graph neighbours. Only meaningful on
    // the `consolidate` process. Absent = full-pool sweep.
    incrementalSince: z.string().optional(),
    // Consolidate process: hard cap on memories processed per pass.
    // Reflect/distill: max refs processed (same as profile-level `limit`).
    limit: positiveInt.optional(),
    // Consolidate process: graph neighbours per changed memory during
    // incremental consolidation. Default 5. Only meaningful with incrementalSince.
    neighborsPerChanged: z.number().int().min(1).optional(),
    // Distill process: skip distill entirely when reflect produced zero planned refs.
    requirePlannedRefs: z.boolean().optional(),
    // MemoryInference process: minimum pending memory count to run the pass.
    minPendingCount: z.number().int().min(0).optional(),
    // Extract process: minimum number of new (unseen, in-window) candidate
    // sessions below which the extract pass skips entirely (emits an
    // `improve_skipped` event with `reason: "below_min_new_sessions"`). 0
    // disables the guard. Only meaningful on the `extract` process. Default 0
    // (disabled) so existing behaviour is preserved; only opted-in profiles set it.
    minNewSessions: z.number().int().min(0).optional(),
    // #561 — index agent sessions as a searchable `session` asset (extract
    // process). Absent = on-when-an-LLM-is-available (fail-open when offline).
    indexSessions: z.boolean().optional(),
    // #561 — minimum session duration in minutes for session indexing. 0
    // disables the gate. Absent = default 5. Only meaningful on `extract`.
    minSessionDuration: z.number().min(0).optional(),
    // Triage process config (only meaningful for the `triage` process)
    applyMode: z.enum(["queue", "promote"]).optional(),
    policy: z.string().min(1).optional(),
    maxAcceptsPerRun: positiveInt.optional(),
    maxDiffLines: positiveInt.optional(),
    rejectEmpty: z.boolean().optional(),
    judgment: z
      .object({
        mode: z.enum(["llm", "agent", "sdk"]).optional(),
        profile: z.string().min(1).optional(),
        timeoutMs: z.union([positiveInt, z.null()]).optional(),
      })
      .strict()
      .optional(),
  })
  .strict();

const ImproveProfileProcessesSchema = z
  .object({
    reflect: ImproveProcessConfigSchema.optional(),
    distill: ImproveProcessConfigSchema.optional(),
    consolidate: ImproveProcessConfigSchema.optional(),
    memoryInference: ImproveProcessConfigSchema.optional(),
    graphExtraction: ImproveProcessConfigSchema.optional(),
    validation: ImproveProcessConfigSchema.optional(),
    triage: ImproveProcessConfigSchema.optional(),
  })
  .passthrough()
  .superRefine((val, ctx) => {
    // 0.8.0 removed the duplicated `feedbackDistillation` process key — it was
    // a thin wrapper around `processes.distill.enabled`. Single source of truth.
    const raw = val as Record<string, unknown>;
    if ("feedbackDistillation" in raw) {
      ctx.addIssue({
        code: z.ZodIssueCode.custom,
        message:
          "feedbackDistillation was removed in 0.8.0 — use processes.distill.enabled instead. " +
          "It now controls both the orchestration gate and the LLM-call gate.",
      });
      return;
    }
    const allowed = new Set([
      "reflect",
      "distill",
      "consolidate",
      "memoryInference",
      "graphExtraction",
      "validation",
      "extract",
      "triage",
    ]);
    for (const k of Object.keys(raw)) {
      if (!allowed.has(k)) {
        ctx.addIssue({
          code: z.ZodIssueCode.unrecognized_keys,
          keys: [k],
          message: `Unrecognized improve process key: "${k}".`,
        });
      }
    }
  });

export const ImproveProfileConfigSchema = z
  .object({
    description: z.string().min(1).optional(),
    processes: ImproveProfileProcessesSchema.optional(),
    autoAccept: nonNegativeNumber.optional(),
    limit: positiveInt.optional(),
    sync: z
      .object({
        enabled: z.boolean().optional(),
        push: z.boolean().optional(),
        message: z.string().min(1).optional(),
      })
      .strict()
      .optional(),
  })
  .strict();

// ── Profiles / defaults ────────────────────────────────────────────────────

export const ProfilesSchema = z
  .object({
    llm: z.record(z.string(), LlmProfileConfigSchema).optional(),
    agent: z.record(z.string(), AgentProfileConfigSchema).optional(),
    improve: z.record(z.string(), ImproveProfileConfigSchema).optional(),
  })
  .strict();

export const DefaultsSchema = z
  .object({
    llm: z.string().min(1).optional(),
    agent: z.string().min(1).optional(),
    improve: z.string().min(1).optional(),
  })
  .strict();

// ── Sources / registries / installed ────────────────────────────────────────

const SourceConfigEntryOptionsSchema = z
  .object({
    /**
     * @deprecated 0.9.0 (issue #507). Retired per-asset push-on-commit. Kept so
     * old configs still parse; its intent maps onto the batch push gate and
     * encountering it emits a one-time deprecation warning.
     */
    pushOnCommit: z.boolean().optional(),
  })
  .passthrough();

export const SourceConfigEntrySchema = z
  .object({
    type: nonEmptyString,
    path: z.string().min(1).optional(),
    url: z.string().min(1).optional(),
    name: z.string().min(1).optional(),
    enabled: z.boolean().optional(),
    writable: z.boolean().optional(),
    primary: z.boolean().optional(),
    options: SourceConfigEntryOptionsSchema.optional(),
    wikiName: z.string().min(1).optional(),
  })
  .strict()
  .superRefine((entry, ctx) => {
    if (entry.writable === true && (entry.type === "website" || entry.type === "npm")) {
      ctx.addIssue({
        code: z.ZodIssueCode.custom,
        message:
          `writable: true is only supported on filesystem and git sources (got "${entry.type}"` +
          (entry.name ? ` on source "${entry.name}"` : "") +
          ").",
      });
    }
  });

export const RegistryConfigEntrySchema = z
  .object({
    url: httpUrl,
    name: z.string().min(1).optional(),
    enabled: z.boolean().optional(),
    provider: z.string().min(1).optional(),
    options: z.record(z.unknown()).optional(),
  })
  .strict();

const KitSourceSchema = z.enum(["filesystem", "git", "npm", "github", "website", "local"]);

export const InstalledStashEntrySchema = z
  .object({
    id: nonEmptyString,
    source: KitSourceSchema,
    ref: nonEmptyString,
    artifactUrl: nonEmptyString,
    stashRoot: nonEmptyString,
    cacheDir: nonEmptyString,
    installedAt: nonEmptyString,
    writable: z.boolean().optional(),
    resolvedVersion: z.string().min(1).optional(),
    resolvedRevision: z.string().min(1).optional(),
    wikiName: z.string().min(1).optional(),
  })
  .strict()
  .superRefine((entry, ctx) => {
    if (entry.writable === true && entry.source !== "git" && entry.source !== "filesystem") {
      ctx.addIssue({
        code: z.ZodIssueCode.custom,
        message: `writable: true is only supported on filesystem and git sources (got "${entry.source}" on installed entry "${entry.id}").`,
      });
    }
  });

// ── Output ──────────────────────────────────────────────────────────────────

export const OutputConfigSchema = z
  .object({
    format: z.enum(["json", "yaml", "text"]).optional(),
    detail: z.enum(["brief", "normal", "full"]).optional(),
  })
  .strict();

// ── Search ──────────────────────────────────────────────────────────────────

const SearchGraphBoostSchema = z
  .object({
    directBoostPerEntity: nonNegativeNumber.optional(),
    directBoostCap: nonNegativeNumber.optional(),
    hopBoostPerEntity: nonNegativeNumber.optional(),
    hopBoostCap: nonNegativeNumber.optional(),
    /** Hard-capped at 3; values > 3 hard-error so users see the typo. */
    maxHops: positiveInt.max(3).optional(),
    confidenceMode: z.enum(["off", "blend", "multiply"]).default("blend").optional(),
    /** Range [0, 1]; values > 1 hard-error (no silent clamp). */
    confidenceWeight: z.number().finite().min(0).max(1).default(0.2).optional(),
  })
  .strict();

export const SearchConfigSchema = z
  .object({
    minScore: nonNegativeNumber.optional(),
    curateRerank: z.object({ enabled: z.boolean().optional() }).strict().optional(),
    graphBoost: SearchGraphBoostSchema.optional(),
  })
  .strict();

// ── Feedback ────────────────────────────────────────────────────────────────

export const FeedbackConfigSchema = z
  .object({
    requireReason: z.boolean().optional(),
    allowedFailureModes: z.array(nonEmptyString).optional(),
  })
  .strict();

// ── Improve top-level (utility decay, event retention) ─────────────────────

const ImproveUtilityDecaySchema = z
  .object({
    halfLifeDays: z.number().finite().min(0.1).optional(),
    feedbackStabilityBoost: z.number().finite().min(1).optional(),
  })
  .strict();

export const ImproveConfigSchema = z
  .object({
    utilityDecay: ImproveUtilityDecaySchema.optional(),
    eventRetentionDays: nonNegativeNumber.optional(),
  })
  .strict();

// ── Index / per-pass ────────────────────────────────────────────────────────

const GRAPH_EXTRACTION_INCLUDE_TYPES_ALLOWED = [
  "memory",
  "knowledge",
  "skill",
  "command",
  "agent",
  "workflow",
  "lesson",
  "task",
  "wiki",
] as const;

const INDEX_PASS_PROVIDER_KEYS = new Set([
  "endpoint",
  "model",
  "provider",
  "apiKey",
  "baseUrl",
  "temperature",
  "maxTokens",
  "capabilities",
]);

const INDEX_PASS_KNOWN_KEYS = new Set([
  "llm",
  "graphExtractionBatchSize",
  "graphExtractionIncludeTypes",
  "memoryInferenceBatchSize",
]);

/**
 * Per-pass `index.<pass>` entry. Uses preprocess + manual validation so we can
 * emit the legacy parser's targeted error messages ("Duplicate LLM provider
 * configuration", "Unknown key `index.<pass>.<key>`", "expected a boolean")
 * instead of Zod's generic `Unrecognized key` / `Expected boolean, received
 * string` strings — keeps `akm` startup errors actionable.
 */
export const IndexPassConfigSchema = z.preprocess(
  (raw, ctx) => {
    if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
      return raw; // let z.object below produce the type error
    }
    const obj = raw as Record<string, unknown>;
    for (const key of Object.keys(obj)) {
      if (INDEX_PASS_PROVIDER_KEYS.has(key)) {
        ctx.addIssue({
          code: z.ZodIssueCode.custom,
          message:
            `Duplicate LLM provider configuration: \`${[...(ctx.path ?? []), key].join(".")}\` is not allowed. ` +
            "Configure provider/model/endpoint under `profiles.llm` only; per-pass entries support `{ llm: false }` opt-out.",
        });
        return raw;
      }
      if (!INDEX_PASS_KNOWN_KEYS.has(key)) {
        ctx.addIssue({
          code: z.ZodIssueCode.custom,
          message:
            `Unknown key \`${[...(ctx.path ?? []), key].join(".")}\`. Per-pass entries support \`llm\` ` +
            "(boolean opt-out), `graphExtractionBatchSize`, `graphExtractionIncludeTypes`, and " +
            "`memoryInferenceBatchSize`.",
        });
        return raw;
      }
    }
    if ("llm" in obj && typeof obj.llm !== "boolean") {
      ctx.addIssue({
        code: z.ZodIssueCode.custom,
        message: `Invalid \`${[...(ctx.path ?? []), "llm"].join(".")}\`: expected a boolean (true to use the default LLM profile, false to opt out). Got ${typeof obj.llm}.`,
      });
      return raw;
    }
    return raw;
  },
  z
    .object({
      llm: z.boolean().optional(),
      graphExtractionBatchSize: positiveInt.optional(),
      graphExtractionIncludeTypes: z.array(z.enum(GRAPH_EXTRACTION_INCLUDE_TYPES_ALLOWED)).nonempty().optional(),
      memoryInferenceBatchSize: positiveInt.optional(),
    })
    .passthrough(),
);

const MetadataEnhanceSchema = z.object({ enabled: z.boolean().optional() }).strict();

const StalenessDetectionSchema = z
  .object({
    enabled: z.boolean().optional(),
    thresholdDays: positiveInt.optional(),
  })
  .strict();

/**
 * Index config is a union of reserved feature sections and per-pass entries.
 * Passthrough so per-pass entries (keyed by arbitrary pass names like `graph`,
 * `enrichment`) can live next to the reserved keys.
 *
 * The outer preprocess emits the legacy parser's actionable error messages
 * for the two most common type-shape mistakes:
 *   - An array at the `index` block.
 *   - A non-object at `index.<passName>`.
 * Inner field validation (graphExtractionIncludeTypes enum, llm boolean,
 * provider-key rejection) is delegated to {@link IndexPassConfigSchema}.
 */
export const IndexConfigSchema = z.preprocess(
  (raw, ctx) => {
    if (raw === undefined || raw === null) return raw;
    if (Array.isArray(raw)) {
      ctx.addIssue({
        code: z.ZodIssueCode.custom,
        message:
          'Invalid `index` config: expected an object keyed by pass name (e.g. `{ "enrichment": { "llm": false } }`).',
      });
      return raw;
    }
    if (typeof raw !== "object") return raw;
    for (const [passName, value] of Object.entries(raw as Record<string, unknown>)) {
      if (typeof value !== "object" || value === null || Array.isArray(value)) {
        ctx.addIssue({
          code: z.ZodIssueCode.custom,
          message: `Invalid \`index.${passName}\` config: expected an object like \`{ "llm": false }\`.`,
        });
        return raw;
      }
      if (
        passName !== "metadataEnhance" &&
        passName !== "stalenessDetection" &&
        Array.isArray((value as Record<string, unknown>).graphExtractionIncludeTypes)
      ) {
        const arr = (value as Record<string, unknown>).graphExtractionIncludeTypes as unknown[];
        const invalid: string[] = [];
        for (const t of arr) {
          if (
            typeof t === "string" &&
            !GRAPH_EXTRACTION_INCLUDE_TYPES_ALLOWED.includes(
              t.toLowerCase() as (typeof GRAPH_EXTRACTION_INCLUDE_TYPES_ALLOWED)[number],
            )
          ) {
            invalid.push(t);
          }
        }
        if (invalid.length > 0) {
          ctx.addIssue({
            code: z.ZodIssueCode.custom,
            message: `Invalid \`index.${passName}.graphExtractionIncludeTypes\`: unsupported type(s): ${invalid.join(", ")}.`,
          });
          return raw;
        }
      }
    }
    return raw;
  },
  z
    .object({
      metadataEnhance: MetadataEnhanceSchema.optional(),
      stalenessDetection: StalenessDetectionSchema.optional(),
    })
    .catchall(IndexPassConfigSchema),
);

// ── Setup-derived recommendations ──────────────────────────────────────────

/**
 * Cron-style schedule hints derived by `akm setup --reset-recommended`.
 *
 * These record the *recommended* cadence for the improve and index background
 * tasks. They are advisory metadata persisted into config so the value
 * survives a re-run; actual task scheduling lives in the tasks subsystem.
 */
export const SetupTaskSchedulesSchema = z
  .object({
    improve: z.string().min(1).optional(),
    index: z.string().min(1).optional(),
  })
  .strict();

export const SetupConfigSchema = z
  .object({
    taskSchedules: SetupTaskSchedulesSchema.optional(),
  })
  .strict();

// ── Top-level AkmConfig ────────────────────────────────────────────────────

/**
 * Base object schema used both as the top-level shape and as the source of
 * truth for {@link listTopLevelConfigKeys}. {@link AkmConfigSchema} wraps this
 * with cross-field refinements (`.superRefine()`).
 *
 * All fields validate loudly — typos and shape errors throw at load time. The
 * legacy parser's warn-and-drop tolerance was a frequent source of silent
 * configuration loss; the migration module ({@link migrateConfigShape}) handles
 * one-time 0.7→0.8 input transforms before the schema sees the value.
 */
export const AkmConfigShape = {
  configVersion: z.union([z.string().min(1), z.number()]).optional(),
  profiles: ProfilesSchema.optional(),
  defaults: DefaultsSchema.optional(),
  stashDir: nonEmptyString.optional(),
  semanticSearchMode: z.enum(["off", "auto"]).default("auto"),
  embedding: EmbeddingConnectionConfigSchema.optional(),
  index: IndexConfigSchema.optional(),
  installed: z.array(InstalledStashEntrySchema).optional(),
  registries: z.array(RegistryConfigEntrySchema).optional(),
  sources: z.array(SourceConfigEntrySchema).optional(),
  output: OutputConfigSchema.optional(),
  writable: z.boolean().optional(),
  defaultWriteTarget: nonEmptyString.optional(),
  search: SearchConfigSchema.optional(),
  feedback: FeedbackConfigSchema.optional(),
  archiveRetentionDays: nonNegativeNumber.optional(),
  improve: ImproveConfigSchema.optional(),
  setup: SetupConfigSchema.optional(),
} as const;

export const AkmConfigBaseSchema = z.object(AkmConfigShape).strict();

export const AkmConfigSchema = AkmConfigBaseSchema.superRefine((config, ctx) => {
  // #464.a: defaultWriteTarget must name a configured source when sources
  // are present. With no sources configured, error out instead of silently
  // accepting (no implicit "first writable" fallback — see locked decision 3).
  if (config.defaultWriteTarget !== undefined) {
    const knownNames = (config.sources ?? [])
      .map((s) => s.name)
      .filter((n): n is string => typeof n === "string" && n.length > 0);
    if (knownNames.length === 0) {
      ctx.addIssue({
        code: z.ZodIssueCode.custom,
        path: ["defaultWriteTarget"],
        message:
          `defaultWriteTarget "${config.defaultWriteTarget}" cannot be resolved: no sources configured. ` +
          "Add at least one entry to `sources` with a matching `name` first.",
      });
    } else if (!knownNames.includes(config.defaultWriteTarget)) {
      ctx.addIssue({
        code: z.ZodIssueCode.custom,
        path: ["defaultWriteTarget"],
        message: `defaultWriteTarget "${config.defaultWriteTarget}" does not match any configured source name: ${knownNames.map((n) => `"${n}"`).join(", ")}.`,
      });
    }
  }
});

/** Canonical inferred type. Mirrors the runtime `AkmConfig` shape. */
export type AkmConfigInput = z.input<typeof AkmConfigSchema>;
export type AkmConfigParsed = z.output<typeof AkmConfigSchema>;

// ── Validation helpers ──────────────────────────────────────────────────────

export interface ConfigValidationIssue {
  path: string;
  message: string;
}

/**
 * Validate a raw object against {@link AkmConfigSchema}. Returns a structured
 * result so callers can render errors as a list (instead of throwing on the
 * first issue).
 */
export function validateConfigShape(
  raw: unknown,
): { ok: true; value: AkmConfigParsed; errors: [] } | { ok: false; errors: ConfigValidationIssue[] } {
  const result = AkmConfigSchema.safeParse(raw);
  if (result.success) {
    return { ok: true, value: result.data, errors: [] };
  }
  return {
    ok: false,
    errors: result.error.issues.map((issue) => ({
      path: issue.path.join("."),
      message: issue.message,
    })),
  };
}

// ── Top-level key listing (for hint messages) ───────────────────────────────

/**
 * Return the sorted list of top-level config keys recognized by the schema.
 * Used by error hints so the list stays in sync with the schema automatically
 * (#460).
 */
export function listTopLevelConfigKeys(): string[] {
  return Object.keys(AkmConfigShape).sort();
}
