/**
 * The single schema for `config.yaml`. Used to:
 *   1. Validate user files at load time (`loadConfig`).
 *   2. Provide JSON Schema for editor autocomplete (writer can emit it).
 *   3. Generate the `templates/config.<agent>.yaml` defaults during code review.
 *
 * Adding fields: provide a default in `defaults.ts` (so old configs upgrade).
 * Removing fields: log a warning at load time; don't crash.
 */

import { Type, type Static } from "@sinclair/typebox";

// ─── Reusable building blocks ───────────────────────────────────────────────

const StringWithDefault = (def = "") => Type.String({ default: def });
const Bool = (def: boolean) => Type.Boolean({ default: def });
const NumberInRange = (def: number, min?: number, max?: number) =>
  Type.Number({ default: def, ...(min != null ? { minimum: min } : {}), ...(max != null ? { maximum: max } : {}) });

// ─── Sub-schemas ────────────────────────────────────────────────────────────

const ViewerSchema = Type.Object({
  port: NumberInRange(18799, 1, 65535),
  bindHost: StringWithDefault("127.0.0.1"),
  openOnFirstTurn: Bool(false),
}, { default: {} });

const BridgeSchema = Type.Object({
  port: NumberInRange(18911, 1, 65535),
  mode: Type.Union([Type.Literal("stdio"), Type.Literal("tcp")], { default: "stdio" }),
}, { default: {} });

const EmbeddingSchema = Type.Object({
  provider: Type.Union([
    Type.Literal("local"),
    Type.Literal("openai_compatible"),
    Type.Literal("gemini"),
  ], { default: "local" }),
  endpoint: StringWithDefault(""),
  model: StringWithDefault("Xenova/all-MiniLM-L6-v2"),
  apiKey: StringWithDefault(""),
  cache: Type.Object({
    enabled: Bool(true),
    maxItems: NumberInRange(20_000, 0),
  }, { default: {} }),
}, { default: {} });

const LlmSchema = Type.Object({
  provider: Type.Union([
    Type.Literal(""),
    Type.Literal("local_only"),
    Type.Literal("openai_compatible"),
    Type.Literal("gemini"),
    Type.Literal("anthropic"),
    Type.Literal("bedrock"),
    Type.Literal("host"),
  ], { default: "" }),
  endpoint: StringWithDefault(""),
  model: StringWithDefault(""),
  temperature: NumberInRange(0, 0, 2),
  /** When true, fall back to the agent host's LLM if `provider` fails. */
  fallbackToHost: Bool(true),
  apiKey: StringWithDefault(""),
  /** Per-call timeout in ms. */
  timeoutMs: NumberInRange(45_000, 1_000),
  /** Max retries on transient errors. */
  maxRetries: NumberInRange(3, 0, 10),
}, { default: {} });

/**
 * Dedicated model slot for the **skill evolver** (V7 Phase 11 skill
 * crystallisation + Phase 10 L2 induction). Often the operator wants
 * a more capable model here than for the per-turn summarizer because
 * skill generation writes code that will be invoked by the agent.
 *
 * All fields are optional. When `model` is empty we fall back to the
 * main `llm.*` settings — this matches the legacy plugin's "使用
 * Summarizer" button and keeps fresh installs zero-config.
 */
const SkillEvolverSchema = Type.Object({
  provider: Type.Union([
    Type.Literal(""),
    Type.Literal("openai_compatible"),
    Type.Literal("gemini"),
    Type.Literal("anthropic"),
  ], { default: "" }),
  endpoint: StringWithDefault(""),
  model: StringWithDefault(""),
  apiKey: StringWithDefault(""),
  temperature: NumberInRange(0, 0, 2),
  timeoutMs: NumberInRange(60_000, 1_000),
}, { default: {} });

const AlgorithmSchema = Type.Object({
  lightweightMemory: Type.Object({
    /**
     * Low-cost mode for users who only want raw conversation memory +
     * recall. When enabled, the runtime skips task/reward/L2/L3/skill
     * evolution and keeps only summarize + embedding + retrieval filter.
     * The viewer exposes the inverse as "memory self-evolution".
     */
    enabled: Bool(true),
  }, { default: {} }),
  capture: Type.Object({
    /** Cap on agent/user text length (chars). Longer content is summarized. */
    maxTextChars: NumberInRange(4_000, 200, 64_000),
    /** Maximum tool outputs we keep verbatim per step. Extras are truncated. */
    maxToolOutputChars: NumberInRange(2_000, 200, 32_000),
    /** Embed state+action vectors when writing traces. Default on. */
    embedTraces: Bool(true),
    /** When true, ask the LLM to score α for each reflection. Default on. */
    alphaScoring: Bool(true),
    /** Synthesize reflections with the LLM if extractor found none. Default off. */
    synthReflections: Bool(false),
    /** Concurrency for α scoring + synth LLM calls (per_step mode only). */
    llmConcurrency: NumberInRange(4, 1, 32),
    /**
     * V7 §3.2 batched variant. When/how to fold per-step reflection synth +
     * α scoring into one episode-level LLM call:
     *   - "per_step"    : legacy path, N per-step LLM calls
     *   - "per_episode" : always batch
     *   - "auto"        : batch when stepCount ≤ batchThreshold, else per-step
     */
    batchMode: Type.Union(
      [Type.Literal("per_step"), Type.Literal("per_episode"), Type.Literal("auto")],
      { default: "auto" },
    ),
    /**
     * Step-count cap for "auto" mode. Episodes above this limit fall back
     * to per-step calls so the batched prompt cannot overflow context.
     */
    batchThreshold: NumberInRange(12, 1, 64),
    /**
     * Optional context blocks for per-step reflection and α prompts.
     * Defaults to "task" to preserve the current task-summary enrichment;
     * downstream preview remains opt-in.
     */
    reflectionContextMode: Type.Union(
      [
        Type.Literal("none"),
        Type.Literal("task"),
        Type.Literal("downstream"),
        Type.Literal("task_downstream"),
      ],
      { default: "task" },
    ),
    /**
     * Long-episode fallback mode after batch auto-threshold is exceeded.
     * `per_step_downstream` keeps parallelism but adds step+1..step+3 preview.
     */
    longEpisodeReflectMode: Type.Union(
      [Type.Literal("per_step_parallel"), Type.Literal("per_step_downstream")],
      { default: "per_step_parallel" },
    ),
    /** Max downstream steps attached to a per-step prompt. */
    downstreamStepCount: NumberInRange(3, 0, 3),
    /** Character cap for the task-context block. */
    taskContextMaxChars: NumberInRange(800, 100, 4_000),
    /** Total character cap for all downstream preview blocks. */
    downstreamContextMaxChars: NumberInRange(1_200, 0, 8_000),
    /** Character cap per downstream preview block. */
    downstreamPerStepMaxChars: NumberInRange(400, 100, 2_000),
    /** Character cap for current-step tool outcome in synth / α prompts. */
    synthOutcomeMaxChars: NumberInRange(600, 100, 4_000),
  }, { default: {} }),
  reward: Type.Object({
    /** V7 §0.6 eq. 4/5: discount factor γ for reflection-weighted backprop. */
    gamma: NumberInRange(0.9, 0, 1),
    /** V7 §2.4.5 eq. 3: temperature τ for softmax reweighting in L2 induction. */
    tauSoftmax: NumberInRange(0.5, 0.01, 10),
    /** V7 §3.3: priority decay half-life in days. */
    decayHalfLifeDays: NumberInRange(30, 1, 365),
    /** Ask LLM to score user feedback → R_human. Off falls back to polarity heuristics. */
    llmScoring: Bool(true),
    /** Auto-trigger backprop when R_human ≥ this from implicit signals. */
    implicitThreshold: NumberInRange(0.2, 0, 1),
    /**
     * Seconds to wait for explicit user feedback after `capture.done` before
     * falling back to implicit-signals scoring. 0 disables the timer.
     */
    feedbackWindowSec: NumberInRange(600, 0, 86_400),
    /** Max characters for the task summary fed into the human-scorer LLM. */
    summaryMaxChars: NumberInRange(2_000, 200, 16_000),
    /** Concurrency for human-scoring LLM calls. */
    llmConcurrency: NumberInRange(2, 1, 16),
    /**
     * Min user↔assistant *exchanges* before an episode is scored.
     * Shorter episodes are closed as abandoned. Default 1 — admits
     * single-shot CLI patterns (`hermes chat -q "..."`,
     * `openclaw run --once`) which always have exactly one
     * user-assistant pair. Set 2 for the strict legacy behaviour
     * (skip episodes that aren't a real back-and-forth).
     */
    minExchangesForCompletion: NumberInRange(1, 1, 20),
    /**
     * Min combined user+assistant content characters before scoring.
     * Filters trivial turns ("hi"/"ok"). Default 40 — pairs with the
     * relaxed exchanges floor; raise to 80+ if your workflow always
     * sends long prompts and you want stronger triviality gating.
     */
    minContentCharsForCompletion: NumberInRange(40, 0, 4_000),
    /**
     * Fraction of turns that are tool calls above which an episode is
     * considered "tool-heavy". When combined with low assistant text
     * the episode is skipped as noise. Default 0.7 (70%).
     */
    toolHeavyRatio: NumberInRange(0.7, 0, 1),
    /**
     * Minimum total assistant content chars to keep an episode that
     * would otherwise be flagged by the tool-heavy heuristic. If the
     * assistant wrote at least this many characters the episode is
     * scored normally even if tool calls dominate. Default 80.
     */
    minAssistantCharsForToolHeavy: NumberInRange(80, 0, 10_000),
  }, { default: {} }),
  l2Induction: Type.Object({
    /** Cosine ≥ this to associate a new trace with an existing L2 policy. */
    minSimilarity: NumberInRange(0.72, 0, 1),
    /** TTL (days) for unpromoted rows in `l2_candidate_pool`. */
    candidateTtlDays: NumberInRange(30, 1),
    /** Min distinct episodes in a candidate bucket before we run induction. */
    minEpisodesForInduction: NumberInRange(1, 1, 20),
    /** Ignore traces whose V is below this floor (prevents noise-driven L2). */
    minTraceValue: NumberInRange(0.01, -1, 1),
    /** When true, call the LLM to induce policies; else collect candidates only. */
    useLlm: Bool(true),
    /** Character cap for traces handed into the `l2.induction` prompt. */
    traceCharCap: NumberInRange(3_000, 600, 16_000),
    /** EMA alpha for gain updates. 1 means overwrite, lower values preserve history. */
    gainEmaAlpha: NumberInRange(0.4, 0, 1),
    /** Archive active policies whose gain dips below this value. */
    archiveGain: NumberInRange(-0.05, -1, 1),
  }, { default: {} }),
  l3Abstraction: Type.Object({
    /** Minimum number of compatible active L2 policies to trigger an L3 abstraction. */
    minPolicies: NumberInRange(1, 1, 50),
    /** Hard minimum gain for an L2 to be eligible as abstraction evidence. */
    minPolicyGain: NumberInRange(0.02, -1, 1),
    /** Hard minimum support for an L2 to be eligible as abstraction evidence. */
    minPolicySupport: NumberInRange(1, 1),
    /**
     * Cosine ≥ this between two L2 vectors → same bucket. Buckets below this
     * are ignored (policies too disparate to share a world model).
     */
    clusterMinSimilarity: NumberInRange(0.6, 0, 1),
    /** Chars of L2 body handed to `l3.abstraction`. */
    policyCharCap: NumberInRange(800, 200, 4_000),
    /** Chars of trace body handed per evidence trace. */
    traceCharCap: NumberInRange(500, 100, 4_000),
    /** Max evidence traces in the prompt — one per policy. */
    traceEvidencePerPolicy: NumberInRange(1, 0, 4),
    /**
     * When true, call `l3.abstraction` to generate/update world models.
     * When false, buckets are logged but no LLM call fires — useful for
     * cost-sensitive deployments.
     */
    useLlm: Bool(true),
    /** Cooldown in days between L3 runs for the same domain tag. */
    cooldownDays: NumberInRange(1, 0, 365),
    /** Confidence delta per positive/negative user feedback. */
    confidenceDelta: NumberInRange(0.05, 0, 1),
    /** Below this confidence, a world model is hidden from Tier-3 retrieval. */
    minConfidenceForRetrieval: NumberInRange(0.2, 0, 1),
  }, { default: {} }),
  skill: Type.Object({
    minSupport: NumberInRange(2, 1),
    // V7 §2.5 graduation floor. The schema allows negative values so
    // demo / single-success-line scenarios (where with-without ≈ 0 by
    // construction even after Bayesian shrinkage) can still force-
    // graduate candidate policies into active. Production default is
    // 0.02 — see `core/config/defaults.ts` for rationale and
    // `core/memory/l2/gain.ts` for how gain is now anchored to a
    // neutral 0.5 baseline so this floor is reachable on real data.
    minGain: NumberInRange(0.02, -1, 1),
    /** Trials a skill must accumulate in `candidate` before it can graduate. */
    candidateTrials: NumberInRange(3, 1),
    /** Back-off before we retry a failed-to-verify policy. */
    cooldownMs: NumberInRange(6 * 60 * 60 * 1000, 0, 30 * 24 * 60 * 60 * 1000),
    /** Chars per evidence trace fed into the crystallize prompt. */
    traceCharCap: NumberInRange(500, 100, 4_000),
    /** Max evidence traces per policy given to the LLM. */
    evidenceLimit: NumberInRange(6, 1, 20),
    /** Turn the LLM crystallization off (collect candidates only). */
    useLlm: Bool(true),
    /** η delta applied per user thumbs up/down. */
    etaDelta: NumberInRange(0.1, 0, 1),
    /** Archive an active skill whose η drops below this. */
    archiveEta: NumberInRange(0.1, 0, 1),
    /** Hide Tier-1 skills whose η is below this. Mirrors retrieval.minSkillEta. */
    minEtaForRetrieval: NumberInRange(0.1, 0, 1),
  }, { default: {} }),
  feedback: Type.Object({
    /** Raise a burst after this many failures of the same tool in-window. */
    failureThreshold: NumberInRange(3, 2, 20),
    /** Rolling window (number of steps) for the burst counter. */
    failureWindow: NumberInRange(5, 2, 50),
    /** Min |mean(high) - mean(low)| to fire without an explicit user signal. */
    valueDelta: NumberInRange(0.5, 0, 2),
    /**
     * Minimum absolute value threshold for lowValue traces. Only traces with
     * value < -minLowValueThreshold will be collected as failure evidence
     * (unless they match isFailureLike patterns). This filters out trivial
     * negative feedback (e.g., value = -0.001) and focuses on genuine failures.
     * Default 0.01 — adjust higher (e.g., 0.1) to be more conservative.
     */
    minLowValueThreshold: NumberInRange(0.01, 0, 1),
    /** Let the LLM rewrite the preference / anti-pattern lines. */
    useLlm: Bool(true),
    /** Tag the L2 policies referenced by the evidence with the guidance. */
    attachToPolicy: Bool(true),
    /** Debounce (ms) for repeat repairs on the same context hash. */
    cooldownMs: NumberInRange(60_000, 0, 24 * 60 * 60 * 1000),
    /** Char cap per trace handed to the repair prompt. */
    traceCharCap: NumberInRange(500, 100, 4_000),
    /** Max evidence traces per class (high-value / low-value). */
    evidenceLimit: NumberInRange(4, 1, 20),
  }, { default: {} }),
  session: Type.Object({
    /**
     * How a user's next message should relate to the previously closed
     * episode. Mirrors V7 §0.1 but softens the default so same-topic
     * follow-ups stay in one "task" from the user's POV.
     *
     *   - "merge_follow_ups" (default)  — both `revision` and `follow_up`
     *     reopen the previous episode and append the new turn. Only
     *     `new_task` opens a fresh episode/session. This matches the
     *     legacy `memos-local-openclaw` behaviour where one "task"
     *     aggregates many related turns and skills crystallise from a
     *     coherent transcript.
     *   - "episode_per_turn"           — follow-ups open a NEW episode in
     *     the same session (V7 §0.1 strict). Each user query gets its
     *     own R_human + V backprop pass. Useful when you want fine-grained
     *     credit assignment per sub-task.
     */
    followUpMode: Type.Union([
      Type.Literal("merge_follow_ups"),
      Type.Literal("episode_per_turn"),
    ], { default: "merge_follow_ups" }),
    /**
     * Hard cap on how long a single merged episode can grow before we
     * force a new episode boundary even if relation says "follow_up".
     * Prevents infinite growth and keeps reward scoring tractable.
     * 0 disables the cap. Default: 2 hours — matches the legacy
     * `taskIdleTimeoutMs`.
     */
    mergeMaxGapMs: NumberInRange(2 * 60 * 60 * 1000, 0, 24 * 60 * 60 * 1000),
  }, { default: {} }),
  retrieval: Type.Object({
    /** How many Skill snippets to inject at turn start. */
    tier1TopK: NumberInRange(3, 0, 100),
    /** How many trace/episode snippets to inject. */
    tier2TopK: NumberInRange(5, 0, 100),
    /** How many world-model snippets to inject. */
    tier3TopK: NumberInRange(2, 0, 100),
    /** Fetch K·factor candidates from SQLite before MMR/priority re-rank. */
    candidatePoolFactor: NumberInRange(4, 1, 50),
    /** Tier 2 fusion weight for cosine similarity (vs. priority). */
    weightCosine: NumberInRange(0.6, 0, 1),
    /** Tier 2 fusion weight for max(V,0)·decay(Δt) priority. */
    weightPriority: NumberInRange(0.4, 0, 1),
    /** MMR λ — 1 = pure relevance, 0 = pure diversity. */
    mmrLambda: NumberInRange(0.7, 0, 1),
    /** Hide V<0 traces by default (Decision Repair can override). */
    includeLowValue: Bool(false),
    /** Classic Reciprocal Rank Fusion constant. */
    rrfConstant: NumberInRange(60, 1, 10_000),
    /** Skip Tier-1 skills whose η is below this floor. */
    minSkillEta: NumberInRange(0.1, 0, 1),
    /** Drop Tier-2 hits whose cosine is below this floor. */
    minTraceSim: NumberInRange(0.35, 0, 1),
    /**
     * V7 §2.6 Tier 2b — minimum goal-level cosine for "episode replay"
     * to fire. Below this, we don't rollup episodes into a reference
     * action sequence (individual trace hits still go through).
     */
    episodeGoalMinSim: NumberInRange(0.45, 0, 1),
    /** auto | off | strict — controls tag-based pre-filtering. */
    tagFilter: Type.Union([
      Type.Literal("auto"),
      Type.Literal("off"),
      Type.Literal("strict"),
    ], { default: "auto" }),
    /**
     * Per-tier keyword (FTS5 + pattern) channel size. Each tier issues
     * a vector channel + an FTS channel + a pattern channel; this is
     * the K for the keyword channels (vector still uses
     * `tier{1,2,3}TopK · candidatePoolFactor`).
     */
    keywordTopK: NumberInRange(20, 0, 200),
    /**
     * Drop ranked candidates whose blended `relevance` is below
     * `topRelevance * relativeThresholdFloor`. Adaptive cousin of
     * `minTraceSim` — when the best hit is weak, we keep more (lower
     * absolute floor); when there's a clear winner, we drop noise.
     * Set to 0 to disable the relative cutoff entirely.
     *
     * Default lowered to 0.2 with the 2026 ranker overhaul: the new
     * base formula already weighs channel-rank evidence (so a raw
     * FTS-only hit lands in a comparable range to a cosine-0.8 hit),
     * and the old 0.4 floor was over-pruning keyword matches with
     * modest V·decay.
     */
    relativeThresholdFloor: NumberInRange(0.2, 0, 1),
    /**
     * Tier-1 skill relevance blend weight for `η` (skill reliability).
     * Old default `0.4` made well-trodden skills outrank obviously-more-
     * relevant new ones. `0.15` keeps the η nudge but lets the query↔skill
     * cosine dominate.
     */
    skillEtaBlend: NumberInRange(0.15, 0, 1),
    /**
     * MMR Phase-A seed-by-tier policy. When `true`, only seed a tier
     * if its best candidate's relevance ≥ `poolTopRelevance *
     * smartSeedRatio` (see below). This prevents the ranker from
     * force-injecting a stale Tier-1 skill / Tier-3 world-model just
     * because it cleared the absolute floors.
     */
    smartSeed: Bool(true),
    /**
     * Seed cutoff for smart-seed MMR — tier is seeded iff its best
     * candidate's relevance ≥ `poolTopRelevance * smartSeedRatio`.
     * Independent of `relativeThresholdFloor` so the seed gate can be
     * stricter than the generic drop floor (0.7 is "within 30% of the
     * best available candidate anywhere in the pool").
     */
    smartSeedRatio: NumberInRange(0.7, 0, 1),
    /**
     * When a candidate is surfaced by ≥ 2 retrieval channels (e.g.
     * both vec and fts hit the same trace), bypass the relative
     * threshold. Multi-channel agreement is a strong signal, and
     * without this keyword-only matches with modest V·decay often
     * get dropped by a noisy `topRelevance`.
     */
    multiChannelBypass: Bool(true),
    /**
     * How Tier-1 skills are surfaced in the injected prompt:
     *   - "summary" (default): inject only `name + η + 1-line summary +
     *     a `memos_skill_get(id="…")` hint`. The agent decides whether to
     *     fetch the full procedure via the `memos_skill_get` tool. Keeps the
     *     prompt small and avoids paying for skills the agent never
     *     uses.
     *   - "full": inline the entire `invocationGuide` body (legacy
     *     behaviour — useful for hosts that don't support tool calls).
     */
    skillInjectionMode: Type.Union(
      [Type.Literal("summary"), Type.Literal("full")],
      { default: "summary" },
    ),
    /**
     * Char cap for the per-skill summary body when `skillInjectionMode`
     * is `summary`. We trim the first paragraph of `invocationGuide`
     * and clamp to this many chars before appending the call-hint.
     */
    skillSummaryChars: NumberInRange(200, 40, 800),
    /**
     * LLM-based relevance filter (`core/retrieval/llm-filter.ts`).
     * Default on because cosine retrieval over-matches and a single
     * small LLM call dramatically cuts down irrelevant injections.
     */
    llmFilterEnabled: Bool(true),
    /** Keep at most this many candidates after the LLM filter. */
    llmFilterMaxKeep: NumberInRange(5, 1, 30),
    /**
     * Skip the filter when the ranked list has fewer than this many
     * items. Default 1 — even a single candidate gets a precision
     * pass, matching `memos-local-openclaw`'s tool-level filter and
     * preventing a lone off-topic memory from sneaking through
     * unchecked.
     */
    llmFilterMinCandidates: NumberInRange(1, 1, 50),
    /**
     * Body-text budget per candidate when building the LLM filter
     * prompt. Higher = more context for precise judgement, at the
     * cost of more tokens per round-trip. Default 500 (openclaw uses
     * 300 without tags/channels; we include richer metadata, so a
     * slightly larger window pays for itself).
     */
    llmFilterCandidateBodyChars: NumberInRange(500, 120, 2000),
    /**
     * Tier-2 vector scan time-window bound (ms). When > 0, the
     * vector scan path (`scanAndTopK` in `core/storage/vector.ts`)
     * only considers traces written within the last
     * `vectorScanMaxAgeMs` milliseconds. Set to `0` to disable the
     * cap (legacy behaviour: full-table brute-force scan).
     *
     * Background: at 93K rows × 1536 dims the unbounded scan blocks
     * the Node event loop for 5–30 s every `onTurnStart`
     * (https://github.com/MemTensor/MemOS/issues/1929). A 24-hour
     * window keeps onTurnStart latency under control without
     * sacrificing recall for active-session memories. FTS keyword
     * channels still cover older traces, so this bound only affects
     * the cosine-only path.
     *
     * Hard cap is one year (31_536_000_000 ms) — anything larger is
     * indistinguishable from "unbounded" at the corpus sizes where
     * the bound starts to matter, and accepting absurdly large
     * values lets misconfigured deployments silently revert to the
     * old behaviour.
     */
    vectorScanMaxAgeMs: NumberInRange(0, 0, 31_536_000_000),
  }, { default: {} }),
}, { default: {} });

const HubSchema = Type.Object({
  enabled: Bool(false),
  role: Type.Union([Type.Literal("hub"), Type.Literal("client")], { default: "client" }),
  port: NumberInRange(18912, 1, 65535),
  address: StringWithDefault(""),
  teamName: StringWithDefault(""),
  teamToken: StringWithDefault(""),
  userToken: StringWithDefault(""),
  nickname: StringWithDefault(""),
}, { default: {} });

const TelemetrySchema = Type.Object({
  enabled: Bool(true),
}, { default: {} });

const LoggingSchema = Type.Object({
  level: Type.Union([
    Type.Literal("trace"),
    Type.Literal("debug"),
    Type.Literal("info"),
    Type.Literal("warn"),
    Type.Literal("error"),
    Type.Literal("fatal"),
  ], { default: "info" }),
  /** Viewer-only switch: expose detailed logs, lifecycle tags and chain view. */
  detailedView: Bool(false),
  console: Type.Object({
    enabled: Bool(true),
    pretty: Bool(true),
    channels: Type.Array(Type.String(), { default: ["*"] }),
  }, { default: {} }),
  file: Type.Object({
    enabled: Bool(true),
    format: Type.Union([Type.Literal("json"), Type.Literal("compact")], { default: "json" }),
    rotate: Type.Object({
      maxSizeMb: NumberInRange(50, 1),
      maxFiles: NumberInRange(14, 1),
      gzip: Bool(true),
    }, { default: {} }),
    /** Days to keep regular app/error/perf/llm/events files. */
    retentionDays: NumberInRange(30, 1),
  }, { default: {} }),
  audit: Type.Object({
    enabled: Bool(true),
    /** Audit retention is "forever": rotate by month, gzip; never delete. */
    rotate: Type.Object({
      monthly: Bool(true),
      gzip: Bool(true),
    }, { default: {} }),
  }, { default: {} }),
  llmLog: Type.Object({
    enabled: Bool(true),
    redactPrompts: Bool(false),
    redactCompletions: Bool(false),
  }, { default: {} }),
  perfLog: Type.Object({
    enabled: Bool(true),
    sampleRate: NumberInRange(1.0, 0, 1),
  }, { default: {} }),
  eventsLog: Type.Object({
    enabled: Bool(true),
  }, { default: {} }),
  redact: Type.Object({
    extraKeys: Type.Array(Type.String(), { default: ["api_key", "secret", "token", "password", "authorization"] }),
    extraPatterns: Type.Array(Type.String(), { default: [] }),
  }, { default: {} }),
  /** Per-channel level overrides, e.g. `{ "core.l2.cross-task": "debug" }`. */
  channels: Type.Record(Type.String(), Type.String(), { default: {} }),
}, { default: {} });

// ─── Top-level schema ───────────────────────────────────────────────────────

export const ConfigSchema = Type.Object({
  version: NumberInRange(1, 1),
  viewer: ViewerSchema,
  bridge: BridgeSchema,
  embedding: EmbeddingSchema,
  llm: LlmSchema,
  skillEvolver: SkillEvolverSchema,
  algorithm: AlgorithmSchema,
  hub: HubSchema,
  telemetry: TelemetrySchema,
  logging: LoggingSchema,
}, { default: {} });

export type ResolvedConfig = Static<typeof ConfigSchema>;
