/**
 * Shared config utilities for SmartServer.
 */

import fs from 'node:fs';
import path from 'node:path';
import type {
  IFinalizer,
  ILlm,
  ISubAgentContextBuilder,
  IToolSelectionStrategy,
  PlanNode,
} from '@mcp-abap-adt/llm-agent';
import {
  AutoActivation,
  ExplicitActivation,
  HybridDispatch,
  LlmFinalizer,
  OneShotPlanning,
  PassthroughFinalizer,
  ReplanOnErrorPlanning,
  ScoreThresholdToolSelection,
  SelfDispatch,
  SkillStepsPlanning,
  SubAgentDispatch,
  TemplateFinalizer,
  TopKToolSelection,
} from '@mcp-abap-adt/llm-agent-libs';
import { parse as parseYaml } from 'yaml';
import type {
  SmartServerConfig,
  SmartServerLlmConfig,
  SmartServerMode,
  SmartServerSubAgentConfig,
} from './smart-server.js';

export type LlmConfigMap = Record<string, SmartServerLlmConfig>;
export type NormalizedLlmMap = { main: SmartServerLlmConfig } & LlmConfigMap;

/**
 * Detect whether an object is a flat SmartServerLlmConfig shape.
 * Flat shape is identified by the presence of ANY of the known flat-shape
 * fields: `provider`, `apiKey`, `model`, or `url`. This covers keyless
 * providers (Ollama, SAP AI Core) that omit `apiKey` — using `apiKey`-only
 * detection silently misclassified those configs as a "map" shape.
 */
function isFlatLlmConfig(input: SmartServerLlmConfig | LlmConfigMap): boolean {
  const flat = input as Partial<SmartServerLlmConfig>;
  return (
    typeof flat.provider === 'string' ||
    typeof flat.apiKey === 'string' ||
    typeof flat.model === 'string' ||
    typeof flat.url === 'string'
  );
}

/**
 * Normalize the optional top-level `llm:` block.
 * - undefined → undefined (pipeline-only configs stay valid)
 * - flat shape (has `provider` | `apiKey` | `model` | `url`) → { main: flat } (backward compat)
 * - map shape → must include `main`; returned as NormalizedLlmMap
 */
export function normalizeLlmConfig(
  input?: SmartServerLlmConfig | LlmConfigMap,
): NormalizedLlmMap | undefined {
  if (input === undefined) return undefined;
  if (isFlatLlmConfig(input)) {
    return { main: input as SmartServerLlmConfig } as NormalizedLlmMap;
  }
  const map = input as LlmConfigMap;
  if (!map.main) {
    throw new Error(
      "llm: map must include a 'main' key (default LLM for unspecified roles)",
    );
  }
  return map as NormalizedLlmMap;
}

/**
 * Strict lookup: returns map[name] if explicitly present, else undefined.
 * Does NOT fall through to map.main. Use when the caller needs to
 * detect explicit-presence (e.g. to decide between an alias and the
 * named map entry).
 */
export function resolveLlmConfigStrict(
  map: NormalizedLlmMap | undefined,
  name: string | undefined,
): SmartServerLlmConfig | undefined {
  if (!map || !name) return undefined;
  return map[name];
}

/**
 * Resolve a per-role LLM config by name from a normalized map.
 * Lookup chain: map[name] → map.main → pipelineFallback.
 * When map is undefined, falls back to pipelineFallback (so pipeline-only
 * configs keep working with no top-level llm: block).
 *
 * The caller decides whether `undefined` is an error.
 */
export function resolveLlmConfig(
  map: NormalizedLlmMap | undefined,
  name?: string,
  pipelineFallback?: SmartServerLlmConfig,
): SmartServerLlmConfig | undefined {
  if (!map) return pipelineFallback;
  if (!name || name === 'main') return map.main;
  return map[name] ?? map.main;
}

/**
 * Read the reviewer block's LLM-name selector, accepting both the
 * preferred `reviewerLlm` field and the deprecated `plannerLlm` alias.
 * When the alias is used, calls `warn(message)`.
 */
export function resolveReviewerLlmName(
  block: { reviewerLlm?: string; plannerLlm?: string } | undefined,
  warn: (msg: string) => void,
): string | undefined {
  if (!block) return undefined;
  if (typeof block.reviewerLlm === 'string') return block.reviewerLlm;
  if (typeof block.plannerLlm === 'string') {
    warn(
      "coordinator.reviewer.plannerLlm is deprecated; rename to 'reviewerLlm'",
    );
    return block.plannerLlm;
  }
  return undefined;
}

export interface YamlCoordinator {
  planning?: 'one-shot' | 'replan-on-error' | 'skill-steps';
  dispatch?: 'subagent' | 'self' | 'hybrid';
  activation?: 'auto' | 'explicit';
  plannerLlm?: 'main' | 'planner' | 'helper';
  maxSteps?: number;
  maxRetriesPerStep?: number;
  failPolicy?: 'abort' | 'continue';
  maxLayer?: number;
  planner?:
    | { type?: string; plannerLlm?: 'main' | 'planner' | 'helper' }
    | Record<string, unknown>;
  interpreter?: { type?: string } | Record<string, unknown>;
  reviewer?: {
    type?: string;
    reviewerLlm?: string;
    plannerLlm?: 'main' | 'planner' | 'helper';
  };
  finalizer?: {
    type?: 'passthrough' | 'llm' | 'template';
    finalizerLlm?: string;
    systemPrompt?: string;
  };
  errorStrategy?: { type?: string; maxReplans?: number };
  stateOracle?: string;
  maxRoundTrips?: number;
}

export function resolveCoordinatorPlanning(name: string, plannerLlm: ILlm) {
  switch (name) {
    case 'one-shot':
      return new OneShotPlanning(plannerLlm);
    case 'replan-on-error':
      return new ReplanOnErrorPlanning(plannerLlm);
    case 'skill-steps':
      // SkillStepsPlanning reads `ctx.activeSkillMeta` (populated by
      // CoordinatorHandler from `ctx.selectedSkills`). No planner LLM
      // needed — the plan comes directly from the skill's `steps:` block.
      return new SkillStepsPlanning();
    default:
      throw new Error(
        `Unknown coordinator.planning strategy: '${name}'. Allowed: one-shot, replan-on-error, skill-steps.`,
      );
  }
}

/**
 * Default coordinator dispatch kind. Omitted → 'hybrid' for ALL planning kinds:
 * agentless steps — the synthesized answer-directly step (#155) and skill steps
 * without an explicit `agent:` — need a self-LLM fallback. Pin 'subagent'
 * explicitly for strict subagent-only routing.
 */
export function resolveCoordinatorDispatchKind(
  explicit?: 'subagent' | 'self' | 'hybrid',
): 'subagent' | 'self' | 'hybrid' {
  return explicit ?? 'hybrid';
}

export function resolveCoordinatorDispatch(
  name: string,
  fallbackLlm?: ILlm,
  contextBuilder?: ISubAgentContextBuilder,
) {
  switch (name) {
    case 'subagent':
      return new SubAgentDispatch(contextBuilder);
    case 'self':
      if (!fallbackLlm) {
        throw new Error(
          'coordinator.dispatch=self requires a planner or main LLM',
        );
      }
      return new SelfDispatch(fallbackLlm);
    case 'hybrid':
      if (!fallbackLlm) {
        throw new Error(
          'coordinator.dispatch=hybrid requires a planner or main LLM',
        );
      }
      return new HybridDispatch(
        new SubAgentDispatch(contextBuilder),
        new SelfDispatch(fallbackLlm),
      );
    default:
      throw new Error(
        `Unknown coordinator.dispatch strategy: '${name}'. Allowed: subagent, self, hybrid.`,
      );
  }
}

export function resolveCoordinatorActivation(name: string) {
  switch (name) {
    case 'auto':
      return new AutoActivation();
    case 'explicit':
      return new ExplicitActivation();
    default:
      throw new Error(
        `Unknown coordinator.activation strategy: '${name}'. Allowed: auto, explicit.`,
      );
  }
}

export function resolveToolSelectionStrategy(
  name: string,
  params?: { minScore?: number },
): IToolSelectionStrategy {
  switch (name) {
    case 'top-k':
      return new TopKToolSelection();
    case 'threshold': {
      const minScore = params?.minScore;
      if (typeof minScore !== 'number') {
        throw new Error(
          "agent.toolSelection.strategy 'threshold' requires a numeric 'minScore'",
        );
      }
      return new ScoreThresholdToolSelection(minScore);
    }
    default:
      throw new Error(
        `Unknown agent.toolSelection.strategy '${name}'. Allowed: top-k, threshold.`,
      );
  }
}

const VALID_PROVIDERS = [
  'openai',
  'anthropic',
  'deepseek',
  'sap-ai-sdk',
  'ollama',
] as const;

const VALID_RAG_TYPES = [
  'in-memory',
  'qdrant',
  'hana-vector',
  'pg-vector',
] as const;

export class ConfigValidationError extends Error {
  constructor(issues: string[]) {
    super(
      `Configuration error in smart-server.yaml:\n${issues
        .map((i) => `  - ${i}`)
        .join('\n')}\nSet these fields in your YAML and restart.`,
    );
    this.name = 'ConfigValidationError';
  }
}

export type YamlConfig = Record<string, unknown>;

export interface ResolveConfigArgs {
  port?: string | boolean;
  host?: string | boolean;
  'llm-api-key'?: string | boolean;
  'llm-model'?: string | boolean;
  'llm-temperature'?: string | boolean;
  'rag-type'?: string | boolean;
  'rag-url'?: string | boolean;
  'rag-model'?: string | boolean;
  'rag-collection-name'?: string | boolean;
  'rag-vector-weight'?: string | boolean;
  'rag-keyword-weight'?: string | boolean;
  'qdrant-api-key'?: string | boolean;
  'mcp-type'?: string | boolean;
  'mcp-url'?: string | boolean;
  'mcp-command'?: string | boolean;
  'mcp-args'?: string | boolean;
  'prompt-system'?: string | boolean;
  'prompt-classifier'?: string | boolean;
  'agent-show-reasoning'?: boolean;
  'log-dir'?: string;
  'plugin-dir'?: string;
  mode?: string | boolean;
}

export const YAML_TEMPLATE = `port: 4004
host: 0.0.0.0

# Request routing mode:
#   hard        — Fully managed context. Ignores client history/system prompt. Uses RAG + internal MCP tools only.
#   pass        — Transparent proxy. Logs everything but modifies nothing.
#   smart       — Hybrid. Preserves client history but enriches it with RAG context and MCP tools based on analysis. (default)
mode: smart

llm:
  provider: deepseek                  # deepseek | openai | anthropic | sap-ai-sdk | ollama
  apiKey: \${DEEPSEEK_API_KEY}        # not required for ollama / sap-ai-sdk
  model: deepseek-chat
  temperature: 0.7
  classifierTemperature: 0.1

rag:
  type: in-memory                     # in-memory | qdrant | hana-vector | pg-vector
  embedder: ollama                    # Embedder to use: ollama | openai | sap-ai-core | <custom>
  url: http://localhost:11434
  model: bge-m3
  # resourceGroup: default            # SAP AI Core resource group (sap-ai-core embedder)
  # scenario: orchestration           # SAP AI Core scenario: orchestration (default) | foundation-models
  # collectionName: llm-agent         # Collection/table name (qdrant | hana-vector | pg-vector)
  dedupThreshold: 0.92
  vectorWeight: 0.7                   # Semantic similarity weight (0..1)
  keywordWeight: 0.3                  # Lexical matching weight (0..1)

mcp:
  # type: none | http | stdio
  # To disable MCP, set type to 'none'
  type: http
  url: http://localhost:3001/mcp/stream/http

agent:
  externalToolsValidationMode: permissive  # permissive | strict
  maxIterations: 10
  maxToolCalls: 30
  toolUnavailableTtlMs: 600000       # Temporary tool blacklist TTL (ms)
  ragQueryK: 10
  # contextBudgetTokens: 4000          # Max tokens for RAG context in system prompt (0 = no limit)
  # semanticHistoryEnabled: false      # Enable semantic history via RAG
  # historyRecencyWindow: 4            # Last N messages from client history in LLM context
  # historyTurnSummaryPrompt: "..."    # LLM prompt for turn summarization
  showReasoning: false                # Explain strategy at start of response
  historyAutoSummarizeLimit: 10       # History length to trigger compression
  queryExpansionEnabled: false        # Expand RAG queries with LLM-generated synonyms
  toolResultCacheTtlMs: 300000       # Tool result cache TTL (ms); 0 to disable
  sessionTokenBudget: 0              # Multi-turn token budget; 0 to disable
  # ragTranslateEnabled: true        # Translate non-ASCII RAG queries to English (default: true)
  # classificationEnabled: false     # Enable for custom pipelines with multi-store routing
  # toolReselectPerIteration: false  # Re-select tools via RAG on each tool-loop iteration
  # llmCallStrategy: streaming       # streaming | non-streaming | fallback
  # streamMode: full                 # full | final — streaming behavior for tool loops
  # heartbeatIntervalMs: 5000       # SSE heartbeat interval during tool execution (ms)
  # healthTimeoutMs: 5000            # Health check probe timeout (ms); increase for slow providers (SAP AI Core: 15000)
  # retry:                           # LLM retry config for 429/5xx errors
  #   maxAttempts: 3
  #   backoffMs: 1000
  #   retryOn: [429, 500, 502, 503]
  #   retryOnMidStream: ['SSE stream']  # Substrings triggering mid-stream retry

# --- Advanced Multi-Model LLM (optional) ------------------------------------
# Assign different models for different internal roles via a top-level llm: map.
# (The legacy pipeline.llm override was removed — use the top-level llm: map now.)
# llm:
#   main:
#     provider: deepseek              # deepseek | openai | anthropic | sap-ai-sdk
#     apiKey: \${DEEPSEEK_API_KEY}
#     model: deepseek-chat
#     temperature: 0.7
#   classifier:                       # optional; if absent, main config is reused
#     provider: deepseek
#     apiKey: \${DEEPSEEK_API_KEY}
#     model: deepseek-chat
#     temperature: 0.1
#   helper:                           # optional; if absent, main config is reused
#     provider: deepseek
#     apiKey: \${DEEPSEEK_API_KEY}
#     model: deepseek-chat
#     temperature: 0.1

# prompts:
#   system: "You are a helpful assistant specialized in SAP ABAP development."
#   classifier: |
#     You are an intent classifier... (see source for full default prompt)
#   reasoning: |
#     IMPORTANT: Always start your response with a brief <reasoning> block...
#   ragTranslate: |
#     Translate the user request to English for search purposes...
#   historySummary: |
#     Summarize the conversation so far...

log: smart-server.log                 # path to log file; omit for stdout
# logDir: sessions                    # Directory for detailed session debug logs
# pluginDir: ./my-plugins             # Additional plugin directory (loaded after defaults)
# plugins:                            # Explicit plugin module specifiers (npm packages or paths)
#   - "@scope/my-pipeline-plugin"     #   bare specifier resolved from cwd
#   - ./local-plugin.mjs              #   relative path resolved against cwd

# subagents:                          # Optional: nested agents callable from pipeline
#   - name: code-reviewer             # Used as stage config: { agent: code-reviewer }
#     description: |                  # Optional. Shown to the Coordinator planner LLM
#       Reviews code and returns      # so it can pick this agent for the right step.
#       structured JSON.
#     config: ./agents/code-reviewer.yaml

# pipeline:                           # Optional: select the request pipeline.
#   name: flat                        # flat (default) | linear | dag | stepper | <plugin>
#   config:                           # Opaque per-pipeline dialect (validated by the plugin)
#     mode: planned-react             # e.g. stepper: cyclic-react | planned-react | deep-stepper
#     knowledgeSeed: []               # stepper: deployment-supplied tool guidance
#
# NOTE: the legacy 'coordinator:' block and the old 'pipeline:' shape
# (mcp/rag/stages/llm overrides) were REMOVED in this major. A config that
# still uses them fails loud at startup. Top-level 'llm:', 'mcp:', 'rag:' now
# own those concerns; pipeline behavior moves under 'pipeline.config'.
`;

export function resolveEnvVars(
  value: unknown,
  env: NodeJS.ProcessEnv = process.env,
): unknown {
  if (typeof value === 'string')
    return value.replace(
      /\$\{([^}:]+)(?::-(.*?))?\}/g,
      (_, name, fallback) => env[name] || fallback || '',
    );
  if (Array.isArray(value)) return value.map((v) => resolveEnvVars(v, env));
  if (value !== null && typeof value === 'object')
    return Object.fromEntries(
      Object.entries(value as Record<string, unknown>).map(([k, v]) => [
        k,
        resolveEnvVars(v, env),
      ]),
    );
  return value;
}

export function loadYamlConfig(
  filePath: string,
  env: NodeJS.ProcessEnv = process.env,
): YamlConfig {
  const raw = fs.readFileSync(filePath, 'utf8');
  return resolveEnvVars(parseYaml(raw), env) as YamlConfig;
}

export function generateConfigTemplate(outputPath: string): void {
  fs.writeFileSync(outputPath, YAML_TEMPLATE, 'utf8');
}

export type FinalizerYaml = {
  type?: 'passthrough' | 'llm' | 'template';
  finalizerLlm?: string;
  systemPrompt?: string;
};

/**
 * Build the IFinalizer impl from `coordinator.finalizer:` YAML.
 *
 * Lookup chain for `type: llm`:
 *   resolveLlmConfig(llmMap, cfg.finalizerLlm, pipelineFallback)
 *   → top-level llm.<name> → llm.main → pipelineFallback (pipeline.llm.main)
 *   → ConfigError if all three are missing.
 *
 * Absent block / `type: passthrough` → PassthroughFinalizer.
 * `type: template` → TemplateFinalizer.
 */
export async function buildFinalizer(
  cfg: FinalizerYaml | undefined,
  llmMap: NormalizedLlmMap | undefined,
  pipelineFallback: SmartServerLlmConfig | undefined,
  makeLlm: (config: SmartServerLlmConfig) => Promise<ILlm>,
): Promise<IFinalizer> {
  const kind = cfg?.type ?? 'passthrough';
  if (kind === 'passthrough') return new PassthroughFinalizer();
  if (kind === 'template') return new TemplateFinalizer();
  // kind === 'llm'
  const resolved = resolveLlmConfig(
    llmMap,
    cfg?.finalizerLlm,
    pipelineFallback,
  );
  if (!resolved) {
    throw new Error(
      'coordinator.finalizer (type: llm) requires an LLM config: provide top-level llm.<name>, llm.main, or pipeline.llm.main',
    );
  }
  const llm = await makeLlm(resolved);
  return new LlmFinalizer(llm, {
    systemPrompt: cfg?.systemPrompt,
  });
}

const get = (obj: unknown, ...keys: string[]): unknown =>
  keys.reduce<unknown>((o, k) => {
    if (o !== null && typeof o === 'object' && k in o) {
      return (o as Record<string, unknown>)[k];
    }
    return undefined;
  }, obj);

function checkLlmRole(
  label: string,
  role: { provider?: unknown; apiKey?: unknown; model?: unknown } | undefined,
  requireModel: boolean,
  env: NodeJS.ProcessEnv,
  issues: string[],
): void {
  const provider = role?.provider as string | undefined;
  if (!provider) {
    issues.push(
      `${label}.provider: required (one of: openai, anthropic, deepseek, sap-ai-sdk, ollama)`,
    );
    return;
  }
  // `as readonly string[]` is required so .includes() accepts an arbitrary
  // string; do not "simplify" — it preserves the const-tuple narrowing.
  if (!(VALID_PROVIDERS as readonly string[]).includes(provider)) {
    issues.push(
      `${label}.provider: "${provider}" is invalid (one of: openai, anthropic, deepseek, sap-ai-sdk, ollama)`,
    );
    return;
  }
  if (requireModel && !role?.model) {
    issues.push(`${label}.model: required (string)`);
  }
  if (
    provider === 'openai' ||
    provider === 'anthropic' ||
    provider === 'deepseek'
  ) {
    if (!role?.apiKey) {
      issues.push(
        `${provider} requires ${label}.apiKey to resolve to a non-empty value (typically via \${${provider.toUpperCase()}_API_KEY} env reference).`,
      );
    }
  } else if (provider === 'sap-ai-sdk') {
    if (!env.AICORE_SERVICE_KEY) {
      issues.push(
        'sap-ai-sdk requires the AICORE_SERVICE_KEY env var to be set with the SAP AI Core service-key JSON content. None found.',
      );
    }
  }
  // ollama: no credential check.
}

function checkRagStore(
  label: string,
  store:
    | {
        type?: unknown;
        url?: unknown;
        collectionName?: unknown;
        embedder?: unknown;
        model?: unknown;
      }
    | undefined,
  issues: string[],
): void {
  if (!store) return;
  const ragType = store.type as string | undefined;
  if (!ragType) {
    issues.push(
      `${label}.type: required (one of: in-memory, qdrant, hana-vector, pg-vector)`,
    );
  } else if (ragType === 'ollama' || ragType === 'openai') {
    issues.push(
      `${label}.type: "${ragType}" is an embedder, not a store — use \`type: in-memory\` with \`embedder: ${ragType}\` (or a real store: qdrant, hana-vector, pg-vector)`,
    );
  } else if (!(VALID_RAG_TYPES as readonly string[]).includes(ragType)) {
    issues.push(
      `${label}.type: "${ragType}" is invalid (one of: in-memory, qdrant, hana-vector, pg-vector)`,
    );
  } else {
    if (ragType === 'qdrant' && !store.url) {
      issues.push(`${label}.url: required for ${label}.type qdrant`);
    }
    if (
      (ragType === 'hana-vector' || ragType === 'pg-vector') &&
      !store.collectionName
    ) {
      issues.push(
        `${label}.collectionName: required for ${label}.type ${ragType}`,
      );
    }
  }
  // Blocklist (NOT allowlist): consumers can register custom embedder
  // factories, so only known embedder-less providers are hard-rejected here.
  const embedder = store.embedder as string | undefined;
  if (embedder === 'deepseek' || embedder === 'anthropic') {
    issues.push(
      `${label}.embedder: "${embedder}" provider has no embedder; embedding-capable providers are ollama, openai, sap-ai-core`,
    );
  }
  // Require model when an embedder is used: vector stores always use an
  // embedder; in-memory uses one only when embedder is explicitly set.
  const usesEmbedder =
    ragType === 'qdrant' ||
    ragType === 'hana-vector' ||
    ragType === 'pg-vector' ||
    (ragType === 'in-memory' && embedder != null);
  if (usesEmbedder && !store.model) {
    issues.push(
      `${label}.model: required when an embedder is used (e.g. bge-m3 for ollama)`,
    );
  }
}

function validateLlmEntry(
  label: string,
  cfg: { provider?: unknown; apiKey?: unknown; model?: unknown } | undefined,
  required: boolean,
  env: NodeJS.ProcessEnv,
  issues: string[],
): void {
  checkLlmRole(label, cfg, required, env, issues);
}

/**
 * Fail-loud migration guard for the clean break to `pipeline: { name, config }`.
 *
 * Throws when the raw YAML still carries either:
 *   - a `coordinator:` block (the old runtime dispatch, removed in this major), OR
 *   - a `pipeline:` value in the LEGACY PipelineConfig shape — an object that
 *     carries `mcp`/`rag`/`stages`/`llm` but NO `name`.
 *
 * A new `pipeline: { name, ... }` object or a bare string shorthand
 * (`pipeline: stepper`) passes untouched.
 */
export function assertNoLegacyPipelineConfig(yaml: YamlConfig): void {
  const hasCoordinator =
    (yaml as { coordinator?: unknown }).coordinator !== undefined;

  const rawPipeline = (yaml as { pipeline?: unknown }).pipeline;
  const isLegacyPipeline =
    rawPipeline !== undefined &&
    rawPipeline !== null &&
    typeof rawPipeline === 'object' &&
    !Array.isArray(rawPipeline) &&
    typeof (rawPipeline as { name?: unknown }).name !== 'string' &&
    ['mcp', 'rag', 'stages', 'llm'].some(
      (k) => (rawPipeline as Record<string, unknown>)[k] !== undefined,
    );

  if (hasCoordinator || isLegacyPipeline) {
    throw new Error(
      "Legacy 'coordinator:' / 'pipeline:' config is no longer supported (removed in this major). " +
        'Migrate to: pipeline: { name: <flat|linear|dag|stepper>, config: { ... } }. ' +
        "(Stepper's knowledgeSeed moves under pipeline.config.knowledgeSeed.) " +
        'Pin a version <= 18 for the old behavior.',
    );
  }
}

function validateResolvedConfig(
  _resolved: Omit<SmartServerConfig, 'log'>,
  yaml: YamlConfig,
  env: NodeJS.ProcessEnv,
): void {
  const issues: string[] = [];

  // LLM is always sourced from the top-level `llm:` block now — the legacy
  // `pipeline.llm.*` override has been removed with the `pipeline: {name,config}`
  // migration. Read from the raw YAML so we can distinguish flat vs map shape.
  // `resolved.llm` is always constructed as a flat object by
  // resolveSmartServerConfig, so it cannot be used to detect the map shape.
  const rawLlm = get(yaml, 'llm') as
    | { provider?: unknown; apiKey?: unknown; model?: unknown }
    | Record<string, { provider?: unknown; apiKey?: unknown; model?: unknown }>
    | undefined;
  if (rawLlm === undefined) {
    issues.push('llm: required (top-level llm.main or a flat llm block)');
  } else if (typeof (rawLlm as { provider?: unknown }).provider === 'string') {
    // Flat shape — existing behaviour.
    validateLlmEntry(
      'llm',
      rawLlm as { provider?: unknown; apiKey?: unknown; model?: unknown },
      true,
      env,
      issues,
    );
  } else {
    // Map shape — llm.main is required; every named entry is validated.
    const map = rawLlm as Record<
      string,
      { provider?: unknown; apiKey?: unknown; model?: unknown }
    >;
    if (!map.main) {
      issues.push("llm.main: required when 'llm' is a named map");
    } else {
      validateLlmEntry('llm.main', map.main, true, env, issues);
    }
    for (const [name, entry] of Object.entries(map)) {
      if (name === 'main') continue;
      validateLlmEntry(`llm.${name}`, entry, true, env, issues);
    }
  }

  // Pipeline selection shape: `pipeline:` must name a pipeline (string or
  // { name } object). The plugin validates its own `config` dialect at build
  // time — we only enforce the presence of a name here.
  const rawPipeline = (yaml as { pipeline?: unknown }).pipeline;
  if (rawPipeline !== undefined && rawPipeline !== null) {
    const ok =
      typeof rawPipeline === 'string' ||
      (typeof rawPipeline === 'object' &&
        typeof (rawPipeline as { name?: unknown }).name === 'string');
    if (!ok) {
      issues.push(
        "pipeline: requires a 'name' (string, or { name, config }); built-ins: flat, linear, dag, stepper",
      );
    }
  }

  if (get(yaml, 'mcp')) {
    const rawMcpVal = yaml.mcp;
    const mcpEntries = Array.isArray(rawMcpVal)
      ? (rawMcpVal as Array<Record<string, unknown>>)
      : [rawMcpVal as Record<string, unknown>];
    mcpEntries.forEach((entry, i) => {
      const label = Array.isArray(rawMcpVal) ? `mcp[${i}]` : 'mcp';
      const mcpType = entry?.type as string | undefined;
      if (mcpType && !['http', 'stdio', 'none'].includes(mcpType)) {
        issues.push(
          `${label}.type: "${mcpType}" is invalid (one of: http, stdio, none)`,
        );
      }
      if (mcpType === 'http' && !entry?.url) {
        issues.push(`${label}.url: required when ${label}.type is http`);
      }
      if (mcpType === 'stdio' && !entry?.command) {
        issues.push(`${label}.command: required when ${label}.type is stdio`);
      }
    });
  }

  if (get(yaml, 'rag')) {
    checkRagStore('rag', get(yaml, 'rag') as Record<string, unknown>, issues);
  }
  // NOTE: the legacy `pipeline.rag.{name}` multistore was removed with the
  // `pipeline: {name,config}` migration; the top-level `rag:` block is the sole
  // RAG source, validated above.

  if (issues.length > 0) throw new ConfigValidationError([...new Set(issues)]);
}

/**
 * Recursively parse the top-level `subagents:` block from a YAML config.
 *
 * Each entry references a sibling YAML file whose resolved config (sans
 * `subagents:` itself — nested orchestration is rejected) becomes a
 * `SmartServerSubAgentConfig`. Relative `config:` paths are resolved
 * against `configPath`'s directory. A `subagents:` block inside a
 * sub-YAML is rejected to guard against unbounded recursion.
 *
 * Returns `undefined` when the parent YAML has no `subagents:` block or
 * when `configPath` is not provided (relative paths cannot be resolved).
 */
function parseSubAgents(
  yaml: YamlConfig,
  configPath: string | undefined,
  args: ResolveConfigArgs,
  env: NodeJS.ProcessEnv,
): SmartServerSubAgentConfig[] | undefined {
  const raw = (yaml as { subagents?: unknown }).subagents;
  if (!Array.isArray(raw) || raw.length === 0) return undefined;
  if (!configPath) {
    throw new Error(
      "subagents: parent YAML must be loaded from a file path so 'config' entries can be resolved",
    );
  }

  const baseDir = path.dirname(path.resolve(configPath));
  const out: SmartServerSubAgentConfig[] = [];
  for (const entry of raw) {
    if (
      !entry ||
      typeof entry !== 'object' ||
      typeof (entry as { name?: unknown }).name !== 'string' ||
      typeof (entry as { config?: unknown }).config !== 'string'
    ) {
      throw new Error(
        `subagents[]: each entry needs 'name' and 'config' (got ${JSON.stringify(entry)})`,
      );
    }
    const name = (entry as { name: string }).name;
    const cfgRel = (entry as { config: string }).config;
    const description = (entry as { description?: unknown }).description;
    if (description !== undefined && typeof description !== 'string') {
      throw new Error(
        `subagents[].description must be a string when present (got ${JSON.stringify(description)})`,
      );
    }
    const subConfigPath = path.isAbsolute(cfgRel)
      ? cfgRel
      : path.resolve(baseDir, cfgRel);

    const subYaml = loadYamlConfig(subConfigPath, env);
    if ((subYaml as { subagents?: unknown }).subagents !== undefined) {
      throw new Error(
        `subagent '${name}' must not define its own 'subagents:' (nested orchestration is not supported)`,
      );
    }

    // Loudly reject fields that the sub-agent builder silently drops today.
    // Keeps the contract honest: if a sub YAML declares these, it gets an
    // error rather than a misleadingly-quiet partial config.
    const unsupported: string[] = [];
    if ((subYaml as { pluginDir?: unknown }).pluginDir !== undefined) {
      unsupported.push('pluginDir');
    }
    if ((subYaml as { plugins?: unknown }).plugins !== undefined) {
      unsupported.push('plugins');
    }
    if ((subYaml as { clientAdapter?: unknown }).clientAdapter !== undefined) {
      unsupported.push('clientAdapter');
    }
    if (
      (subYaml as { circuitBreaker?: unknown }).circuitBreaker !== undefined
    ) {
      unsupported.push('circuitBreaker');
    }
    // A subagent's `pipeline:` (if present) is the new `{name, config}` shape;
    // there are no per-subagent reranker/queryExpander/outputValidator/rag
    // overrides to reject anymore (they were tied to the removed legacy shape).
    if (unsupported.length > 0) {
      throw new Error(
        `subagent '${name}': unsupported fields [${unsupported.join(', ')}]`,
      );
    }

    // Recursive call — we just verified the sub YAML has no `subagents:`, so
    // the parseSubAgents call inside will short-circuit to undefined.
    const subResolved = resolveSmartServerConfig(args, subYaml, env, {
      configPath: subConfigPath,
    });
    out.push({ name, description, config: subResolved });
  }
  return out;
}

export interface ResolveSmartServerConfigOptions {
  /**
   * Filesystem path of the YAML config that produced `yaml`. Required for
   * resolving relative `subagents[].config` paths. When omitted, a `subagents:`
   * block in `yaml` will cause an error.
   */
  configPath?: string;
}

export function resolveSmartServerConfig(
  args: ResolveConfigArgs = {},
  yaml: YamlConfig = {},
  env: NodeJS.ProcessEnv = process.env,
  options: ResolveSmartServerConfigOptions = {},
): Omit<SmartServerConfig, 'log'> {
  // Clean-break migration guard FIRST — before any pipeline-shape parsing — so a
  // legacy `coordinator:`/`pipeline:` config gets the actionable migration error
  // rather than the generic "pipeline requires a name" diagnostic.
  assertNoLegacyPipelineConfig(yaml);

  // API key derives solely from the top-level `llm:` block now (the legacy
  // `pipeline.llm.main.apiKey` override was removed with the schema migration).
  const apiKey = (get(yaml, 'llm', 'apiKey') as string) ?? '';

  const rawMcp = yaml.mcp;
  const mcpIsArray = Array.isArray(rawMcp);
  const mcpUrl = get(yaml, 'mcp', 'url') as string | undefined;
  const mcpCommand = get(yaml, 'mcp', 'command') as string | undefined;
  const mcpTypeRaw = mcpIsArray
    ? null // array form: type resolved per-entry inside connectMcpClientsFromConfig
    : ((get(yaml, 'mcp', 'type') as string) ??
      (mcpUrl ? 'http' : mcpCommand ? 'stdio' : null));
  const mcpType = (mcpTypeRaw === 'none' ? null : mcpTypeRaw) as
    | 'http'
    | 'stdio'
    | null;

  const promptSystem = (get(yaml, 'prompts', 'system') as string) ?? null;
  const promptClassifier =
    (get(yaml, 'prompts', 'classifier') as string) ?? null;
  const promptReasoning = get(yaml, 'prompts', 'reasoning') ?? null;
  const promptRagTranslate = get(yaml, 'prompts', 'ragTranslate') ?? null;
  const promptHistorySummary = get(yaml, 'prompts', 'historySummary') ?? null;

  const resolved: Omit<SmartServerConfig, 'log'> = {
    port: Number(
      (args.port as string) ?? get(yaml, 'port') ?? env.PORT ?? 4004,
    ),
    host: (args.host as string) ?? get(yaml, 'host') ?? '0.0.0.0',
    llm: get(yaml, 'llm')
      ? typeof get(yaml, 'llm', 'provider') === 'string'
        ? {
            provider: get(yaml, 'llm', 'provider') as
              | 'deepseek'
              | 'openai'
              | 'anthropic'
              | 'sap-ai-sdk'
              | 'ollama'
              | undefined,
            apiKey,
            url: get(yaml, 'llm', 'url') as string | undefined,
            model: get(yaml, 'llm', 'model') as string | undefined,
            temperature: Number(get(yaml, 'llm', 'temperature') ?? 0.7),
            classifierTemperature: Number(
              get(yaml, 'llm', 'classifierTemperature') ?? 0.1,
            ),
          }
        : (get(yaml, 'llm') as Record<string, SmartServerLlmConfig>)
      : undefined,
    rag: get(yaml, 'rag')
      ? {
          type: get(yaml, 'rag', 'type') as
            | 'in-memory'
            | 'qdrant'
            | 'hana-vector'
            | 'pg-vector'
            | undefined,
          embedder: (get(yaml, 'rag', 'embedder') as string) ?? undefined,
          url: get(yaml, 'rag', 'url') as string | undefined,
          model: get(yaml, 'rag', 'model') as string | undefined,
          collectionName:
            (args['rag-collection-name'] as string) ??
            get(yaml, 'rag', 'collectionName') ??
            undefined,
          dedupThreshold: Number(get(yaml, 'rag', 'dedupThreshold') ?? 0.92),
          vectorWeight: Number(get(yaml, 'rag', 'vectorWeight') ?? 0.7),
          keywordWeight: Number(get(yaml, 'rag', 'keywordWeight') ?? 0.3),
          ...(get(yaml, 'rag', 'resourceGroup') !== undefined
            ? { resourceGroup: String(get(yaml, 'rag', 'resourceGroup')) }
            : {}),
          ...(get(yaml, 'rag', 'scenario') !== undefined
            ? {
                scenario: String(get(yaml, 'rag', 'scenario')) as
                  | 'orchestration'
                  | 'foundation-models',
              }
            : {}),
        }
      : undefined,
    mcp: mcpIsArray
      ? // Array form: pass through as-is so connectMcpClientsFromConfig can
        // iterate and connect each entry. Typed as SmartServerMcpConfig[].
        (rawMcp as import('./smart-server.js').SmartServerMcpConfig[])
      : mcpType
        ? {
            type: mcpType,
            url: mcpUrl || undefined,
            command: mcpCommand || undefined,
            args:
              (args['mcp-args'] as string) || get(yaml, 'mcp', 'args')
                ? String(args['mcp-args'] || get(yaml, 'mcp', 'args')).split(
                    ' ',
                  )
                : undefined,
            headers:
              (get(yaml, 'mcp', 'headers') as Record<string, string>) ||
              undefined,
          }
        : undefined,
    agent: {
      externalToolsValidationMode: (get(
        yaml,
        'agent',
        'externalToolsValidationMode',
      ) ?? 'permissive') as string as 'permissive' | 'strict',
      maxIterations: Number(get(yaml, 'agent', 'maxIterations') ?? 10),
      maxToolCalls: Number(get(yaml, 'agent', 'maxToolCalls') ?? 30),
      toolUnavailableTtlMs: Number(
        get(yaml, 'agent', 'toolUnavailableTtlMs') ?? 600000,
      ),
      ragQueryK: Number(get(yaml, 'agent', 'ragQueryK') ?? 10),
      ...(get(yaml, 'agent', 'contextBudgetTokens') !== undefined
        ? {
            contextBudgetTokens: Number(
              get(yaml, 'agent', 'contextBudgetTokens'),
            ),
          }
        : {}),
      ...(get(yaml, 'agent', 'semanticHistoryEnabled') !== undefined
        ? {
            semanticHistoryEnabled: Boolean(
              get(yaml, 'agent', 'semanticHistoryEnabled'),
            ),
          }
        : {}),
      ...(get(yaml, 'agent', 'historyRecencyWindow') !== undefined
        ? {
            historyRecencyWindow: Number(
              get(yaml, 'agent', 'historyRecencyWindow'),
            ),
          }
        : {}),
      ...(get(yaml, 'agent', 'historyTurnSummaryPrompt') !== undefined
        ? {
            historyTurnSummaryPrompt: String(
              get(yaml, 'agent', 'historyTurnSummaryPrompt'),
            ),
          }
        : {}),
      showReasoning: Boolean(
        args['agent-show-reasoning'] ??
          get(yaml, 'agent', 'showReasoning') ??
          false,
      ),
      historyAutoSummarizeLimit: Number(
        get(yaml, 'agent', 'historyAutoSummarizeLimit') ?? 10,
      ),
      queryExpansionEnabled: Boolean(
        get(yaml, 'agent', 'queryExpansionEnabled') ?? false,
      ),
      toolResultCacheTtlMs: Number(
        get(yaml, 'agent', 'toolResultCacheTtlMs') ?? 300000,
      ),
      sessionTokenBudget: Number(get(yaml, 'agent', 'sessionTokenBudget') ?? 0),
      ...(get(yaml, 'agent', 'classificationEnabled') !== undefined
        ? {
            classificationEnabled: Boolean(
              get(yaml, 'agent', 'classificationEnabled'),
            ),
          }
        : {}),
      ...(get(yaml, 'agent', 'toolReselectPerIteration') !== undefined
        ? {
            toolReselectPerIteration: Boolean(
              get(yaml, 'agent', 'toolReselectPerIteration'),
            ),
          }
        : {}),
      ...(get(yaml, 'agent', 'ragTranslateEnabled') !== undefined
        ? {
            ragTranslateEnabled: Boolean(
              get(yaml, 'agent', 'ragTranslateEnabled'),
            ),
          }
        : {}),
      ...(get(yaml, 'agent', 'refreshToolsPerIteration') !== undefined
        ? {
            refreshToolsPerIteration: Boolean(
              get(yaml, 'agent', 'refreshToolsPerIteration'),
            ),
          }
        : {}),
      ...(get(yaml, 'agent', 'streamMode') !== undefined
        ? {
            streamMode: String(get(yaml, 'agent', 'streamMode')) as
              | 'full'
              | 'final',
          }
        : {}),
      ...(get(yaml, 'agent', 'llmCallStrategy') !== undefined
        ? {
            llmCallStrategy: String(get(yaml, 'agent', 'llmCallStrategy')) as
              | 'streaming'
              | 'non-streaming'
              | 'fallback',
          }
        : {}),
      ...(get(yaml, 'agent', 'heartbeatIntervalMs') !== undefined
        ? {
            heartbeatIntervalMs: Number(
              get(yaml, 'agent', 'heartbeatIntervalMs'),
            ),
          }
        : {}),
      ...(get(yaml, 'agent', 'healthTimeoutMs') !== undefined
        ? {
            healthTimeoutMs: Number(get(yaml, 'agent', 'healthTimeoutMs')),
          }
        : {}),
      ...(get(yaml, 'agent', 'retry') !== undefined
        ? {
            retry: get(yaml, 'agent', 'retry') as {
              maxAttempts?: number;
              backoffMs?: number;
              retryOn?: number[];
              retryOnMidStream?: string[];
            },
          }
        : {}),
      ...(get(yaml, 'agent', 'toolSelection') !== undefined
        ? {
            toolSelection: get(yaml, 'agent', 'toolSelection') as {
              strategy: string;
              minScore?: number;
            },
          }
        : {}),
    },
    prompts:
      promptSystem ||
      promptClassifier ||
      promptReasoning ||
      promptRagTranslate ||
      promptHistorySummary
        ? {
            ...(promptSystem ? { system: promptSystem } : {}),
            ...(promptClassifier ? { classifier: promptClassifier } : {}),
            ...(typeof promptReasoning === 'string'
              ? { reasoning: promptReasoning }
              : {}),
            ...(typeof promptRagTranslate === 'string'
              ? { ragTranslate: promptRagTranslate }
              : {}),
            ...(typeof promptHistorySummary === 'string'
              ? { historySummary: promptHistorySummary }
              : {}),
          }
        : undefined,
    mode: (get(yaml, 'mode') as SmartServerMode) ?? undefined,
    logDir: (args['log-dir'] as string) ?? get(yaml, 'logDir') ?? null,
    pluginDir:
      (args['plugin-dir'] as string) ?? get(yaml, 'pluginDir') ?? undefined,
    plugins: (() => {
      const raw = get(yaml, 'plugins');
      if (!Array.isArray(raw)) return undefined;
      const specs = raw.filter((s): s is string => typeof s === 'string');
      return specs.length > 0 ? specs : undefined;
    })(),
    ...(() => {
      const subAgentConfigs = parseSubAgents(
        yaml,
        options.configPath,
        args,
        env,
      );
      return subAgentConfigs ? { subAgentConfigs } : {};
    })(),
    ...(() => {
      // Pipeline selection: `pipeline: { name, config }`. `name` is required;
      // `config` is the plugin's opaque dialect (validated by the plugin's
      // parseConfig at build time). A bare string (`pipeline: stepper`) is
      // accepted as shorthand for `{ name: <string> }`.
      const raw = (yaml as { pipeline?: unknown }).pipeline;
      if (raw === undefined || raw === null) return {};
      if (typeof raw === 'string') return { pipeline: { name: raw } };
      const obj = raw as { name?: unknown; config?: unknown };
      if (typeof obj.name !== 'string') {
        throw new Error(
          "pipeline: requires a 'name' (one of: flat, linear, dag, stepper, or a registered plugin)",
        );
      }
      return {
        pipeline: {
          name: obj.name,
          ...(obj.config && typeof obj.config === 'object'
            ? { config: obj.config as Record<string, unknown> }
            : {}),
        },
      };
    })(),
    ...(yaml.skills
      ? {
          skills: {
            type: (get(yaml, 'skills', 'type') ?? 'claude') as
              | 'claude'
              | 'codex'
              | 'filesystem',
            dirs: get(yaml, 'skills', 'dirs') as string[] | undefined,
            projectRoot: get(yaml, 'skills', 'projectRoot') as
              | string
              | undefined,
          },
        }
      : {}),
  };
  validateResolvedConfig(resolved, yaml, env);
  return resolved;
}

/**
 * Stepper coordinator modes.
 */
// `deep-stepper` (18.1): flow + demand-driven recursion. Re-enabled now that the
// runaway is fenced — the Evaluator is the per-level TERMINATION judge (executable
// → leaf, needs-work → recurse), identity-dedup stops re-doing work, and maxDepth
// + the token ledger bound it. Recursion is REJECTED unless the Evaluator is on.
export type StepperMode = 'cyclic-react' | 'planned-react' | 'deep-stepper';

/**
 * Configuration for the recursive Stepper coordinator.
 */
/**
 * A node of a declared composition tree. A leaf executes via the executor; a
 * node with a nested `flow` runs as a child Stepper (structural recursion —
 * the sub-cycle is declared and visible).
 */
export interface CompositionNode {
  id: string;
  goal: string;
  dependsOn?: string[];
  flow?: StepperCompositionSpec;
}

/**
 * Front-end-agnostic description of a Stepper composition. Produced by BOTH the
 * yaml parser (`toCompositionSpec`) and a code builder; consumed by the runtime
 * (`buildFromComposition`). Recursive via `nodes[].flow`.
 */
export interface StepperCompositionSpec {
  planner: 'none' | 'llm' | 'static';
  granularity: 'shallow' | 'detailed';
  plan?: PlanNode[];
  /** Declared composition nodes; a node with a nested `flow` is a sub-Stepper. */
  nodes?: CompositionNode[];
  executor: 'simple' | 'cyclic-react' | 'recursive';
  finalizer: 'llm';
  /** Optional system-prompt overrides (consumer-supplied via yaml/builder).
   *  Undefined → the built-in STEPPER_PLANNER_SYSTEM / EXECUTOR_SYSTEM. */
  plannerSystemPrompt?: string;
  executorSystemPrompt?: string;
  /** 18.1 Evaluator: ON by default at all depths. Judges (sub-)prompt
   *  completeness WITH the RAG context before planning. */
  evaluatorEnabled: boolean;
  evaluatorAtDepths: { has(depth: number): boolean };
  evaluatorSystemPrompt?: string;
  reviewerAtDepths: { has(depth: number): boolean };
  maxParallelSteps: number;
  maxDepth: number;
  tokenBudget: number;
  formalizeTask: boolean;
}

export interface StepperCoordinatorConfig {
  mode: StepperMode;
  reviewerAtDepths: { has(depth: number): boolean };
  maxParallelSteps: number;
  maxDepth: number;
  tokenBudget: number;
  /**
   * Session-scope knowledge entries written into a NEW session's knowledge-RAG
   * before planning. A deployment/config PARAMETER (not agent code) — the
   * operator fills it with guidance for THEIR actual MCP tools (e.g. which read
   * tool reads what). Surfaced to the planner/executor as "Known facts", and the
   * executor enriches its tool-search query with these facts, so a tool named in
   * a seed takes priority over tools the bare-prompt MCP search would surface.
   * The runtime stays MCP-agnostic: tool knowledge lives here as data.
   */
  knowledgeSeed: ReadonlyArray<{ content: string; artifactType: string }>;
  /**
   * Opt-in (default false): formalize the raw prompt into a compact TaskSpec
   * (objective + scope + constraints + deliverable) ONCE at the root, then
   * thread it down to every planner and executor as a persistent anchor and as
   * the overall-intent prefix for tool search. Off → behaves exactly as before.
   */
  formalizeTask: boolean;
  /**
   * Resolved program flow (the composition the coordinator runs). Always
   * present: parsed from an explicit `coordinator.flow` block when given, else
   * derived from `mode` as a preset (so `mode` is now just a preset alias).
   *
   *  - planner  'none'   → trivial single-node plan (node goal = prompt)
   *             'llm'    → LlmStepperPlanner (LLM decomposition)
   *             'static' → StaticPlanner (declarative `flow.plan`, no LLM)
   *  - executor 'cyclic-react' → leaf ReAct loop, no recursion
   *             'recursive'    → may spawn child Steppers up to maxDepth
   *  - finalizer 'llm' (RootFinalizer). 'passthrough' is reserved (not yet built).
   */
  flow: {
    planner: 'none' | 'llm' | 'static';
    /** How much the LLM planner decomposes up front (eager): 'shallow' (few
     *  high-level steps) | 'detailed' (full concrete-leaf decomposition).
     *  Ignored by 'none'/'static'. Default 'shallow'. */
    granularity: 'shallow' | 'detailed';
    /** Leaf executor profile: 'simple' (single pass) | 'cyclic-react' (ReAct
     *  loop) | 'recursive' (spawns child Steppers — lazy decomposition). */
    executor: 'simple' | 'cyclic-react' | 'recursive';
    finalizer: 'llm';
    /** Optional per-role system-prompt overrides:
     *  `flow.planner.systemPrompt` / `flow.executor.systemPrompt`. */
    plannerSystemPrompt?: string;
    executorSystemPrompt?: string;
    /** 18.1 Evaluator (ON by default at all depths). Configure via
     *  `flow.evaluator: { enabled?, atDepths?, systemPrompt? }`. */
    evaluatorEnabled: boolean;
    evaluatorAtDepths: { has(depth: number): boolean };
    evaluatorSystemPrompt?: string;
    /** Declarative plan nodes, required when planner === 'static'. */
    plan?: PlanNode[];
    /**
     * Declared composition nodes (the "yaml is a tree" shape). A node with a
     * nested `flow` is a sub-Stepper. When present at the root, the planner is
     * effectively static over these nodes.
     */
    nodes?: CompositionNode[];
  };
}

const MODES = new Set<StepperMode>([
  'cyclic-react',
  'planned-react',
  'deep-stepper',
]);

/**
 * Preset expansion: each `mode` maps to a default `flow` composition.
 * An explicit `coordinator.flow` block overrides these per-component.
 * `deep-stepper` = llm planner + RECURSIVE executor (demand-driven recursion),
 * relying on the Evaluator as terminator (enforced below).
 */
const MODE_FLOW_PRESET: Record<
  StepperMode,
  { planner: 'none' | 'llm'; executor: 'cyclic-react' | 'recursive' }
> = {
  'cyclic-react': { planner: 'none', executor: 'cyclic-react' },
  'planned-react': { planner: 'llm', executor: 'cyclic-react' },
  'deep-stepper': { planner: 'llm', executor: 'recursive' },
};

/** Parse declarative `flow.plan` nodes (for the static planner). */
function parseFlowPlan(raw: unknown): PlanNode[] | undefined {
  if (!Array.isArray(raw)) return undefined;
  const nodes = raw
    .filter(
      (n): n is Record<string, unknown> =>
        !!n && typeof (n as { goal?: unknown }).goal === 'string',
    )
    .map((n, i) => ({
      id: typeof n.id === 'string' && n.id ? n.id : `n${i}`,
      goal: n.goal as string,
      ...(Array.isArray(n.dependsOn)
        ? {
            dependsOn: (n.dependsOn as unknown[]).filter(
              (d) => typeof d === 'string',
            ) as string[],
          }
        : {}),
      ...(typeof n.agent === 'string' ? { agent: n.agent } : {}),
    }));
  return nodes.length > 0 ? nodes : undefined;
}

/** Bounds a nested composition flow inherits from the root. */
type FlowBounds = Pick<
  StepperCompositionSpec,
  | 'reviewerAtDepths'
  | 'evaluatorEnabled'
  | 'evaluatorAtDepths'
  | 'evaluatorSystemPrompt'
  | 'maxParallelSteps'
  | 'maxDepth'
  | 'tokenBudget'
  | 'formalizeTask'
>;

/**
 * Parse a (possibly nested) `flow` block into a full StepperCompositionSpec,
 * inheriting bounds from the root. Mutually recursive with
 * parseCompositionNodes (function declarations are hoisted).
 */
function parseNestedFlowSpec(
  flowCfg:
    | {
        planner?: {
          type?: string;
          granularity?: string;
          systemPrompt?: string;
        };
        executor?: { type?: string; systemPrompt?: string };
        plan?: unknown;
        nodes?: unknown;
      }
    | undefined,
  bounds: FlowBounds,
): StepperCompositionSpec {
  const plannerType = flowCfg?.planner?.type ?? 'llm';
  if (!['none', 'llm', 'static'].includes(plannerType))
    throw new Error(`flow.planner.type must be none|llm|static`);
  const granularity = flowCfg?.planner?.granularity ?? 'shallow';
  if (!['shallow', 'detailed'].includes(granularity))
    throw new Error(`flow.planner.granularity must be shallow|detailed`);
  const executor = flowCfg?.executor?.type ?? 'cyclic-react';
  if (!['simple', 'cyclic-react', 'recursive'].includes(executor))
    throw new Error(`flow.executor.type must be simple|cyclic-react|recursive`);
  const plannerSystemPrompt = parseSystemPromptOverride(
    flowCfg?.planner?.systemPrompt,
    'flow.planner.systemPrompt',
  );
  const executorSystemPrompt = parseSystemPromptOverride(
    flowCfg?.executor?.systemPrompt,
    'flow.executor.systemPrompt',
  );
  const plan = parseFlowPlan(flowCfg?.plan);
  const nodes = parseCompositionNodes(flowCfg?.nodes, bounds);
  return {
    // Declared nodes ARE the plan ⇒ this level is static (keep the spec honest:
    // buildFromComposition routes a node-bearing level to a StaticPlanner).
    planner: (nodes ? 'static' : plannerType) as 'none' | 'llm' | 'static',
    granularity: granularity as 'shallow' | 'detailed',
    ...(plan ? { plan } : {}),
    ...(nodes ? { nodes } : {}),
    executor: executor as 'simple' | 'cyclic-react' | 'recursive',
    finalizer: 'llm',
    ...(plannerSystemPrompt ? { plannerSystemPrompt } : {}),
    ...(executorSystemPrompt ? { executorSystemPrompt } : {}),
    ...bounds,
  };
}

/** Validate an optional system-prompt override: must be a non-empty string. */
function parseSystemPromptOverride(
  raw: unknown,
  label: string,
): string | undefined {
  if (raw === undefined || raw === null) return undefined;
  if (typeof raw !== 'string' || raw.trim() === '')
    throw new Error(`coordinator.${label} must be a non-empty string`);
  return raw;
}

/** Parse composition nodes; a node with a nested `flow` recurses into a sub-spec. */
function parseCompositionNodes(
  raw: unknown,
  bounds: FlowBounds,
): CompositionNode[] | undefined {
  if (!Array.isArray(raw)) return undefined;
  const nodes = raw
    .filter(
      (n): n is Record<string, unknown> =>
        !!n && typeof (n as { goal?: unknown }).goal === 'string',
    )
    .map((n, i) => ({
      id: typeof n.id === 'string' && n.id ? n.id : `n${i}`,
      goal: n.goal as string,
      ...(Array.isArray(n.dependsOn)
        ? {
            dependsOn: (n.dependsOn as unknown[]).filter(
              (d) => typeof d === 'string',
            ) as string[],
          }
        : {}),
      ...(n.flow ? { flow: parseNestedFlowSpec(n.flow as never, bounds) } : {}),
    }));
  return nodes.length > 0 ? nodes : undefined;
}

/**
 * Parse stepper coordinator configuration from a raw config object.
 *
 * Supports:
 * - `mode` (string) — default 'planned-react'; one of cyclic-react | planned-react
 * - `stepper.maxParallelSteps` (number) — default 4
 * - `stepper.maxDepth` (number) — default 4
 * - `stepper.tokenBudget` (number) — default 1,000,000
 * - `stepper.reviewer.atDepths` (number[] | 'all') — default [0,1]; 'all' means accept any depth
 */
export function parseStepperCoordinatorConfig(
  coord: Record<string, unknown>,
): StepperCoordinatorConfig {
  const mode = (coord.mode as StepperMode | undefined) ?? 'planned-react';
  if (!MODES.has(mode))
    throw new Error(`unknown coordinator.mode '${String(coord.mode)}'`);

  // Tool permissioning is the MCP SERVER's responsibility — whatever it exposes
  // via tools/list is allowed. The agent does not classify tools (read-only vs
  // mutating); there is no agent-side gate. The consumer wires the agent to a
  // server that exposes only the permitted tools (e.g. a read-only MCP proxy).

  const stepper = (coord.stepper as Record<string, unknown> | undefined) ?? {};
  const reviewerCfg =
    (stepper.reviewer as { atDepths?: number[] | 'all' } | undefined) ?? {};
  const atDepths = reviewerCfg.atDepths ?? [0, 1];
  const reviewerAtDepths =
    atDepths === 'all'
      ? { has: () => true }
      : (() => {
          const s = new Set(atDepths as number[]);
          return { has: (d: number) => s.has(d) };
        })();

  const knowledgeSeed = Array.isArray(coord.knowledgeSeed)
    ? (
        coord.knowledgeSeed as Array<{
          content?: unknown;
          artifactType?: unknown;
        }>
      )
        .filter(
          (e) => e && typeof e.content === 'string' && e.content.trim() !== '',
        )
        .map((e) => ({
          content: e.content as string,
          artifactType:
            typeof e.artifactType === 'string' && e.artifactType
              ? e.artifactType
              : 'guidance',
        }))
    : [];

  // Resolve the program flow: explicit `coordinator.flow` overrides the
  // mode-derived preset per component. `mode` thus becomes a preset alias.
  const preset = MODE_FLOW_PRESET[mode];
  const flowCfg = coord.flow as
    | {
        planner?: {
          type?: string;
          granularity?: string;
          systemPrompt?: string;
        };
        executor?: { type?: string; systemPrompt?: string };
        finalizer?: { type?: string };
        evaluator?: {
          enabled?: boolean;
          atDepths?: number[] | 'all';
          systemPrompt?: string;
        };
        plan?: unknown;
        nodes?: unknown;
      }
    | undefined;
  const plannerType = flowCfg?.planner?.type ?? preset.planner;
  if (!['none', 'llm', 'static'].includes(plannerType))
    throw new Error(`coordinator.flow.planner.type must be none|llm|static`);
  const granularity = flowCfg?.planner?.granularity ?? 'shallow';
  if (!['shallow', 'detailed'].includes(granularity))
    throw new Error(
      `coordinator.flow.planner.granularity must be shallow|detailed`,
    );
  const executorType = flowCfg?.executor?.type ?? preset.executor;
  if (!['simple', 'cyclic-react', 'recursive'].includes(executorType))
    throw new Error(
      `coordinator.flow.executor.type must be simple|cyclic-react|recursive`,
    );
  const finalizerType = flowCfg?.finalizer?.type ?? 'llm';
  if (finalizerType !== 'llm')
    throw new Error(
      `coordinator.flow.finalizer.type 'passthrough' is not yet implemented (use 'llm')`,
    );
  const plannerSystemPrompt = parseSystemPromptOverride(
    flowCfg?.planner?.systemPrompt,
    'flow.planner.systemPrompt',
  );
  const executorSystemPrompt = parseSystemPromptOverride(
    flowCfg?.executor?.systemPrompt,
    'flow.executor.systemPrompt',
  );
  // 18.1 Evaluator: ON by default at all depths (per design). Disable via
  // `flow.evaluator.enabled: false`; narrow via `flow.evaluator.atDepths`.
  const evaluatorEnabled = flowCfg?.evaluator?.enabled !== false;
  // RUNAWAY GUARD: demand-driven recursion (executor:recursive / deep-stepper)
  // terminates via the Evaluator (executable → leaf, needs-work → recurse).
  // Without it recursion has no termination judge — that is exactly the 18.0
  // runaway (141 spawns). So recursion REQUIRES the Evaluator enabled.
  if (executorType === 'recursive' && !evaluatorEnabled)
    throw new Error(
      'coordinator.flow.executor.type "recursive" (deep-stepper) requires the Evaluator ' +
        '(it is the recursion terminator) — do not set coordinator.flow.evaluator.enabled: false',
    );
  const evalAtDepths = flowCfg?.evaluator?.atDepths ?? 'all';
  const evaluatorAtDepths =
    evalAtDepths === 'all'
      ? { has: () => true }
      : (() => {
          const s = new Set(evalAtDepths as number[]);
          return { has: (d: number) => s.has(d) };
        })();
  const evaluatorSystemPrompt = parseSystemPromptOverride(
    flowCfg?.evaluator?.systemPrompt,
    'flow.evaluator.systemPrompt',
  );
  const plan = parseFlowPlan(flowCfg?.plan);

  const maxParallelSteps = Number(stepper.maxParallelSteps ?? 4);
  const maxDepth = Number(stepper.maxDepth ?? 4);
  const tokenBudget = Number(stepper.tokenBudget ?? 1_000_000);
  const formalizeTask = coord.formalizeTask === true;

  // Nested composition nodes inherit the root bounds (a sub-cycle uses the same
  // parallelism / depth / budget / safety unless the runtime threads otherwise).
  const bounds: FlowBounds = {
    reviewerAtDepths,
    evaluatorEnabled,
    evaluatorAtDepths,
    ...(evaluatorSystemPrompt ? { evaluatorSystemPrompt } : {}),
    maxParallelSteps,
    maxDepth,
    tokenBudget,
    formalizeTask,
  };
  const nodes = parseCompositionNodes(flowCfg?.nodes, bounds);

  // Static planner needs an explicit plan OR declared nodes (nodes ARE the plan).
  if (plannerType === 'static' && !plan && !nodes)
    throw new Error(
      `coordinator.flow.planner.type 'static' requires coordinator.flow.plan or coordinator.flow.nodes`,
    );

  return {
    mode,
    reviewerAtDepths,
    maxParallelSteps,
    maxDepth,
    tokenBudget,
    knowledgeSeed,
    formalizeTask,
    flow: {
      // Declared root nodes ARE the plan ⇒ static at the root (honest spec).
      planner: (nodes ? 'static' : plannerType) as 'none' | 'llm' | 'static',
      granularity: granularity as 'shallow' | 'detailed',
      executor: executorType as 'simple' | 'cyclic-react' | 'recursive',
      finalizer: 'llm',
      ...(plannerSystemPrompt ? { plannerSystemPrompt } : {}),
      ...(executorSystemPrompt ? { executorSystemPrompt } : {}),
      evaluatorEnabled,
      evaluatorAtDepths,
      ...(evaluatorSystemPrompt ? { evaluatorSystemPrompt } : {}),
      ...(plan ? { plan } : {}),
      ...(nodes ? { nodes } : {}),
    },
  };
}
