/**
 * LettaBot Configuration Types
 * 
 * Two modes:
 * 1. Docker server: Uses baseUrl (e.g., http://localhost:8283), no API key
 * 2. Letta API: Uses apiKey, optional BYOK providers
 */

import { createLogger } from '../logger.js';

const log = createLogger('Config');
export type ServerMode = 'api' | 'docker' | 'cloud' | 'selfhosted';
export type CanonicalServerMode = 'api' | 'docker';

export function canonicalizeServerMode(mode?: ServerMode): CanonicalServerMode {
  return mode === 'docker' || mode === 'selfhosted' ? 'docker' : 'api';
}

export function isDockerServerMode(mode?: ServerMode): boolean {
  return canonicalizeServerMode(mode) === 'docker';
}

export function isApiServerMode(mode?: ServerMode): boolean {
  return canonicalizeServerMode(mode) === 'api';
}

export function serverModeLabel(mode?: ServerMode): string {
  return canonicalizeServerMode(mode);
}

/**
 * Display configuration for tool calls and reasoning in channel output.
 */
export interface DisplayConfig {
  /** Show tool invocations in channel output (default: false) */
  showToolCalls?: boolean;
  /** Show agent reasoning/thinking in channel output (default: false) */
  showReasoning?: boolean;
  /** Truncate reasoning to N characters (default: 0 = no limit) */
  reasoningMaxChars?: number;
}

export type SleeptimeTrigger = 'off' | 'step-count' | 'compaction-event';
export type SleeptimeBehavior = 'reminder' | 'auto-launch';

export interface SleeptimeConfig {
  trigger?: SleeptimeTrigger;
  behavior?: SleeptimeBehavior;
  stepCount?: number;
}

/**
 * Configuration for a single agent in multi-agent mode.
 * Each agent has its own name, channels, and features.
 */
export interface AgentConfig {
  /** Agent name (used for display, agent creation, and store keying) */
  name: string;
  /** Use existing agent ID (skip creation) */
  id?: string;
  /** Display name prefixed to outbound messages (e.g. "💜 Signo") */
  displayName?: string;
  /** Model for initial agent creation */
  model?: string;
  /** Working directory for this agent's SDK sessions (overrides global) */
  workingDir?: string;
  /** Channels this agent connects to */
  channels: {
    telegram?: TelegramConfig;
    'telegram-mtproto'?: TelegramMTProtoConfig;
    slack?: SlackConfig;
    whatsapp?: WhatsAppConfig;
    signal?: SignalConfig;
    discord?: DiscordConfig;
    bluesky?: BlueskyConfig;
  };
  /** Conversation routing */
  conversations?: {
    mode?: 'disabled' | 'shared' | 'per-channel' | 'per-chat';  // Default: shared (single conversation across all channels)
    heartbeat?: string;               // "dedicated" | "last-active" | "<channel>" (default: last-active)
    perChannel?: string[];            // Channels that should always have their own conversation
    maxSessions?: number;             // Max concurrent sessions in per-chat mode (default: 10, LRU eviction)
    reuseSession?: boolean;           // Reuse SDK subprocess across messages (default: true). Set false to eliminate stream state bleed.
  };
  /** Features for this agent */
  features?: {
    cron?: boolean;
    heartbeat?: {
      enabled: boolean;
      intervalMin?: number;
      skipRecentUserMin?: number; // Skip auto-heartbeats for N minutes after user message (0 disables)
      prompt?: string;       // Custom heartbeat prompt (replaces default body)
      promptFile?: string;   // Path to prompt file (re-read each tick for live editing)
      target?: string;       // Delivery target ("telegram:123", "slack:C123", etc.)
    };
    memfs?: boolean;          // Enable memory filesystem (git-backed context repository) for SDK sessions
    sleeptime?: SleeptimeConfig; // Configure SDK reflection reminders (/sleeptime equivalent)
    maxToolCalls?: number;
    sendFileDir?: string;    // Restrict <send-file> directive to this directory (default: data/outbound)
    sendFileMaxSize?: number; // Max file size in bytes for <send-file> (default: 50MB)
    sendFileCleanup?: boolean; // Allow <send-file cleanup="true"> to delete after send (default: false)
    display?: DisplayConfig;
    allowedTools?: string[];       // Per-agent tool whitelist (overrides global/env ALLOWED_TOOLS)
    disallowedTools?: string[];    // Per-agent tool blocklist (overrides global/env DISALLOWED_TOOLS)
    logging?: {
      turnLogFile?: string;        // Path to JSONL file for turn logging (one record per agent turn)
      maxTurns?: number;           // Max turns to retain in the log file (default: 1000, oldest trimmed)
    };
  };
  /** Security settings */
  security?: {
    redaction?: {
      secrets?: boolean;
      pii?: boolean;
    };
  };
  /** Polling config */
  polling?: PollingYamlConfig;
  /** Integrations */
  integrations?: {
    google?: GoogleConfig;
  };
}

export interface LettaBotConfig {
  // Server connection
  server: {
    // Canonical values: 'api' or 'docker'
    // Legacy aliases accepted for compatibility: 'cloud', 'selfhosted'
    mode: ServerMode;
    // Only for docker mode
    baseUrl?: string;
    // Only for api mode
    apiKey?: string;
    // Log level (fatal|error|warn|info|debug|trace). Env vars LOG_LEVEL / LETTABOT_LOG_LEVEL override.
    logLevel?: 'fatal' | 'error' | 'warn' | 'info' | 'debug' | 'trace';
    // API server config (port, host, CORS) — canonical location
    api?: {
      port?: number;       // Default: 8080 (or PORT env var)
      host?: string;       // Default: 127.0.0.1 (secure). Use '0.0.0.0' for Docker/Railway
      corsOrigin?: string; // CORS origin. Default: same-origin only
    };
  };

  // Multi-agent configuration
  agents?: AgentConfig[];

  // Agent configuration
  agent: {
    id?: string;
    name: string;
    displayName?: string;
    // model is configured on the Letta agent server-side, not in config
    // Kept as optional for backward compat (ignored if present in existing configs)
    model?: string;
  };

  // BYOK providers (api mode only)
  providers?: ProviderConfig[];

  // Channel configurations
  channels: {
    telegram?: TelegramConfig;
    'telegram-mtproto'?: TelegramMTProtoConfig;
    slack?: SlackConfig;
    whatsapp?: WhatsAppConfig;
    signal?: SignalConfig;
    discord?: DiscordConfig;
    bluesky?: BlueskyConfig;
  };

  // Conversation routing
  conversations?: {
    mode?: 'disabled' | 'shared' | 'per-channel' | 'per-chat';  // Default: shared (single conversation across all channels)
    heartbeat?: string;               // "dedicated" | "last-active" | "<channel>" (default: last-active)
    perChannel?: string[];            // Channels that should always have their own conversation
    maxSessions?: number;             // Max concurrent sessions in per-chat mode (default: 10, LRU eviction)
    reuseSession?: boolean;           // Reuse SDK subprocess across messages (default: true). Set false to eliminate stream state bleed.
  };

  // Features
  features?: {
    cron?: boolean;
    heartbeat?: {
      enabled: boolean;
      intervalMin?: number;
      skipRecentUserMin?: number; // Skip auto-heartbeats for N minutes after user message (0 disables)
      prompt?: string;       // Custom heartbeat prompt (replaces default body)
      promptFile?: string;   // Path to prompt file (re-read each tick for live editing)
      target?: string;       // Delivery target ("telegram:123", "slack:C123", etc.)
    };
    inlineImages?: boolean;   // Send images directly to the LLM (default: true). Set false to only send file paths.
    memfs?: boolean;          // Enable memory filesystem (git-backed context repository) for SDK sessions
    sleeptime?: SleeptimeConfig; // Configure SDK reflection reminders (/sleeptime equivalent)
    maxToolCalls?: number;  // Abort if agent calls this many tools in one turn (default: 100)
    sendFileDir?: string;   // Restrict <send-file> directive to this directory (default: data/outbound)
    sendFileMaxSize?: number; // Max file size in bytes for <send-file> (default: 50MB)
    sendFileCleanup?: boolean; // Allow <send-file cleanup="true"> to delete after send (default: false)
    display?: DisplayConfig;  // Show tool calls / reasoning in channel output
    allowedTools?: string[];       // Global tool whitelist (overridden by per-agent, falls back to ALLOWED_TOOLS env)
    disallowedTools?: string[];    // Global tool blocklist (overridden by per-agent, falls back to DISALLOWED_TOOLS env)
    logging?: {
      turnLogFile?: string;        // Global turn log file (overridden by per-agent)
      maxTurns?: number;           // Global maxTurns default (overridden by per-agent)
    };
  };

  // Polling - system-level background checks (Gmail, etc.)
  polling?: PollingYamlConfig;

  // Integrations (Google Workspace, etc.)
  // NOTE: integrations.google is a legacy path for polling config.
  // Prefer the top-level `polling` section instead.
  integrations?: {
    google?: GoogleConfig;
  };

  // Transcription (inbound voice messages)
  transcription?: TranscriptionConfig;

  // Text-to-speech (outbound voice memos)
  tts?: TtsConfig;

  // Attachment handling
  attachments?: {
    maxMB?: number;
    maxAgeDays?: number;
  };

  // Security
  security?: {
    /** Outbound message redaction (catches leaked secrets/PII before channel delivery) */
    redaction?: {
      /** Redact common secret patterns (API keys, tokens, bearer tokens). Default: true */
      secrets?: boolean;
      /** Redact PII patterns (emails, phone numbers). Default: false */
      pii?: boolean;
    };
  };

  // API server (health checks, CLI messaging)
  /** @deprecated Use server.api instead */
  api?: {
    port?: number;       // Default: 8080 (or PORT env var)
    host?: string;       // Default: 127.0.0.1 (secure). Use '0.0.0.0' for Docker/Railway
    corsOrigin?: string; // CORS origin. Default: same-origin only
  };
}

export interface TtsConfig {
  provider?: 'elevenlabs' | 'openai';  // Default: 'elevenlabs'
  apiKey?: string;                      // Falls back to ELEVENLABS_API_KEY or OPENAI_API_KEY env var
  voiceId?: string;                     // ElevenLabs voice ID or OpenAI voice name
  model?: string;                       // Model ID (provider-specific defaults)
}

export interface TranscriptionConfig {
  provider: 'openai' | 'mistral';
  apiKey?: string;     // Falls back to OPENAI_API_KEY or MISTRAL_API_KEY env var
  model?: string;      // Defaults to 'whisper-1' (OpenAI) or 'voxtral-mini-latest' (Mistral)
}

export interface GmailAccountConfig {
  /** Gmail account email address */
  account: string;
  /** Custom email prompt for this account (inline) - replaces default body */
  prompt?: string;
  /** Path to prompt file (re-read each poll for live editing) */
  promptFile?: string;
}

export interface PollingYamlConfig {
  enabled?: boolean;      // Master switch (default: auto-detected from sub-configs)
  intervalMs?: number;    // Polling interval in milliseconds (default: 60000)
  gmail?: {
    enabled?: boolean;    // Enable Gmail polling
    account?: string;     // Single Gmail account (simple string form)
    accounts?: (string | GmailAccountConfig)[];  // Multiple accounts (string or config object)
    /** Default prompt for all accounts (can be overridden per-account) */
    prompt?: string;
    /** Default prompt file for all accounts (re-read each poll for live editing) */
    promptFile?: string;
  };
}

export interface ProviderConfig {
  id: string;           // e.g., 'anthropic', 'openai'
  name: string;         // e.g., 'lc-anthropic'
  type: string;         // e.g., 'anthropic', 'openai'
  apiKey: string;
}

export type GroupMode = 'open' | 'listen' | 'mention-only' | 'disabled';

export interface GroupConfig {
  mode?: GroupMode;
  /** Only process group messages from these user IDs. Omit to allow all users. */
  allowedUsers?: string[];
  /** Process messages from other bots instead of dropping them. Default: false. */
  receiveBotMessages?: boolean;
  /** Maximum total bot triggers per day in this group. Omit for unlimited. */
  dailyLimit?: number;
  /** Maximum bot triggers per user per day in this group. Omit for unlimited. */
  dailyUserLimit?: number;
  /** Discord only: require messages to be in a thread before the bot responds. */
  threadMode?: 'any' | 'thread-only';
  /** Discord only: when true, @mentions in parent channels auto-create a thread. */
  autoCreateThreadOnMention?: boolean;
  /**
   * @deprecated Use mode: "mention-only" (true) or "open" (false).
   */
  requireMention?: boolean;
}

export interface TelegramConfig {
  enabled: boolean;
  token?: string;
  dmPolicy?: 'pairing' | 'allowlist' | 'open';
  allowedUsers?: string[];
  streaming?: boolean;              // Stream responses via progressive message edits (default: false)
  groupDebounceSec?: number;      // Debounce interval in seconds (default: 5, 0 = immediate)
  groupPollIntervalMin?: number;  // @deprecated Use groupDebounceSec instead
  instantGroups?: string[];       // Group chat IDs that bypass batching
  listeningGroups?: string[];     // @deprecated Use groups.<id>.mode = "listen"
  mentionPatterns?: string[];     // Regex patterns for mention detection (e.g., ["@mybot"])
  groups?: Record<string, GroupConfig>;  // Per-group settings, "*" for defaults
}

export interface TelegramMTProtoConfig {
  enabled: boolean;
  phoneNumber?: string;          // E.164 format: +1234567890
  apiId?: number;                // From my.telegram.org
  apiHash?: string;              // From my.telegram.org
  databaseDirectory?: string;    // Default: ./data/telegram-mtproto
  dmPolicy?: 'pairing' | 'allowlist' | 'open';
  allowedUsers?: number[];       // Telegram user IDs
  groupPolicy?: 'mention' | 'reply' | 'both' | 'off';
  adminChatId?: number;          // Chat ID for pairing request notifications
}

export interface SlackConfig {
  enabled: boolean;
  appToken?: string;
  botToken?: string;
  dmPolicy?: 'pairing' | 'allowlist' | 'open';
  allowedUsers?: string[];
  streaming?: boolean;              // Stream responses via progressive message edits (default: false)
  groupDebounceSec?: number;      // Debounce interval in seconds (default: 5, 0 = immediate)
  groupPollIntervalMin?: number;  // @deprecated Use groupDebounceSec instead
  instantGroups?: string[];       // Channel IDs that bypass batching
  listeningGroups?: string[];     // @deprecated Use groups.<id>.mode = "listen"
  groups?: Record<string, GroupConfig>;  // Per-channel settings, "*" for defaults
}

export interface WhatsAppConfig {
  enabled: boolean;
  sessionPath?: string;   // Auth/session directory (default: ./data/whatsapp-session)
  selfChat?: boolean;
  dmPolicy?: 'pairing' | 'allowlist' | 'open';
  allowedUsers?: string[];
  groupPolicy?: 'open' | 'disabled' | 'allowlist';
  groupAllowFrom?: string[];
  mentionPatterns?: string[];
  groups?: Record<string, GroupConfig>;
  groupDebounceSec?: number;      // Debounce interval in seconds (default: 5, 0 = immediate)
  groupPollIntervalMin?: number;  // @deprecated Use groupDebounceSec instead
  instantGroups?: string[];       // Group JIDs that bypass batching
  listeningGroups?: string[];     // @deprecated Use groups.<id>.mode = "listen"
}

export interface SignalConfig {
  enabled: boolean;
  phone?: string;
  cliPath?: string;     // Path to signal-cli binary (default: "signal-cli")
  httpHost?: string;    // Daemon HTTP host (default: "127.0.0.1")
  httpPort?: number;    // Daemon HTTP port (default: 8090)
  readReceipts?: boolean; // Send read receipts for incoming messages (default: true)
  selfChat?: boolean;
  dmPolicy?: 'pairing' | 'allowlist' | 'open';
  allowedUsers?: string[];
  // Group gating
  mentionPatterns?: string[];  // Regex patterns for mention detection (e.g., ["@bot"])
  groups?: Record<string, GroupConfig>;  // Per-group settings, "*" for defaults
  groupDebounceSec?: number;      // Debounce interval in seconds (default: 5, 0 = immediate)
  groupPollIntervalMin?: number;  // @deprecated Use groupDebounceSec instead
  instantGroups?: string[];       // Group IDs that bypass batching
  listeningGroups?: string[];     // @deprecated Use groups.<id>.mode = "listen"
}

export interface DiscordConfig {
  enabled: boolean;
  token?: string;
  dmPolicy?: 'pairing' | 'allowlist' | 'open';
  allowedUsers?: string[];
  streaming?: boolean;              // Stream responses via progressive message edits (default: false)
  groupDebounceSec?: number;      // Debounce interval in seconds (default: 5, 0 = immediate)
  groupPollIntervalMin?: number;  // @deprecated Use groupDebounceSec instead
  instantGroups?: string[];       // Guild/server IDs or channel IDs that bypass batching
  listeningGroups?: string[];     // @deprecated Use groups.<id>.mode = "listen"
  groups?: Record<string, GroupConfig>;  // Per-guild/channel settings, "*" for defaults
  ignoreBotReactions?: boolean;   // Ignore all bot reactions (default: true). Set false for multi-bot setups.
}

export interface BlueskyConfig {
  enabled: boolean;
  jetstreamUrl?: string;
  wantedDids?: string[];         // DID(s) to follow (e.g., did:plc:...)
  wantedCollections?: string[];  // Optional collection filters (e.g., app.bsky.feed.post)
  cursor?: number;               // Jetstream cursor (microseconds)
  handle?: string;               // Bluesky handle (for posting)
  appPassword?: string;          // App password (for posting)
  serviceUrl?: string;           // ATProto service URL (default: https://bsky.social)
  appViewUrl?: string;           // AppView URL for list/notification APIs
  groups?: Record<string, GroupConfig>; // Use "*" for defaults, DID for overrides
  notifications?: BlueskyNotificationsConfig;
  lists?: Record<string, GroupConfig>;  // List URI -> mode
}

export interface BlueskyNotificationsConfig {
  enabled?: boolean;        // Poll notifications API (requires auth)
  intervalSec?: number;     // Poll interval (default: 60s)
  limit?: number;           // Max notifications per request (default: 50)
  priority?: boolean;       // Priority only
  reasons?: string[];       // Filter reasons (e.g., ['mention','reply'])
  backfill?: boolean;       // Process unread notifications on startup (default: false)
}

/**
 * Telegram MTProto (user account) configuration.
 * Uses TDLib for user account mode instead of Bot API.
 * Cannot be used simultaneously with TelegramConfig (bot mode).
 */
export interface TelegramMTProtoConfig {
  enabled: boolean;
  phoneNumber?: string;          // E.164 format: +1234567890
  apiId?: number;                // From my.telegram.org
  apiHash?: string;              // From my.telegram.org
  databaseDirectory?: string;    // Default: ./data/telegram-mtproto
  dmPolicy?: 'pairing' | 'allowlist' | 'open';
  allowedUsers?: number[];              // Telegram user IDs
  groupPolicy?: 'mention' | 'reply' | 'both' | 'off';
  adminChatId?: number;          // Chat ID for pairing request notifications
  groupDebounceSec?: number;     // Debounce interval in seconds (default: 5, 0 = immediate)
  instantGroups?: string[];      // Chat IDs that bypass batching
}

export interface GoogleAccountConfig {
  account: string;
  services?: string[];  // e.g., ['gmail', 'calendar', 'drive', 'contacts', 'docs', 'sheets']
}

export interface GoogleConfig {
  enabled: boolean;
  account?: string;
  accounts?: GoogleAccountConfig[];
  services?: string[];  // e.g., ['gmail', 'calendar', 'drive', 'contacts', 'docs', 'sheets']
  pollIntervalSec?: number;  // Polling interval in seconds (default: 60)
}

// Default config
export const DEFAULT_CONFIG: LettaBotConfig = {
  server: {
    mode: 'api',
  },
  agent: {
    name: 'LettaBot',
    // model is configured on the Letta agent server-side (via onboarding or `lettabot model set`)
  },
  channels: {},
};

type ChannelWithLegacyGroupFields = {
  groups?: Record<string, GroupConfig>;
  listeningGroups?: string[];
};

const warnedGroupConfigDeprecations = new Set<string>();

function warnGroupConfigDeprecation(path: string, detail: string): void {
  const key = `${path}:${detail}`;
  if (warnedGroupConfigDeprecations.has(key)) return;
  warnedGroupConfigDeprecations.add(key);
  log.warn(`WARNING: ${path} ${detail}`);
}

function normalizeLegacyGroupFields(
  channel: ChannelWithLegacyGroupFields | undefined,
  path: string,
): void {
  if (!channel) return;

  const hadOriginalGroups = !!(
    channel.groups &&
    typeof channel.groups === 'object' &&
    Object.keys(channel.groups).length > 0
  );

  const groups: Record<string, GroupConfig> = channel.groups && typeof channel.groups === 'object'
    ? { ...channel.groups }
    : {};
  const modeDerivedFromRequireMention = new Set<string>();

  let sawLegacyRequireMention = false;
  for (const [groupId, value] of Object.entries(groups)) {
    const group = value && typeof value === 'object' ? { ...value } : {};
    const hasLegacyRequireMention = typeof group.requireMention === 'boolean';
    if (hasLegacyRequireMention) {
      sawLegacyRequireMention = true;
    }
    if (!group.mode && hasLegacyRequireMention) {
      group.mode = group.requireMention ? 'mention-only' : 'open';
      modeDerivedFromRequireMention.add(groupId);
    }
    if ('requireMention' in group) {
      delete group.requireMention;
    }
    groups[groupId] = group;
  }
  if (sawLegacyRequireMention) {
    warnGroupConfigDeprecation(
      `${path}.groups.<id>.requireMention`,
      'is deprecated. Use groups.<id>.mode: "mention-only" | "open" | "listen".'
    );
  }

  const legacyListeningGroups = Array.isArray(channel.listeningGroups)
    ? channel.listeningGroups.map((id) => String(id).trim()).filter(Boolean)
    : [];

  if (legacyListeningGroups.length > 0) {
    warnGroupConfigDeprecation(
      `${path}.listeningGroups`,
      'is deprecated. Use groups.<id>.mode: "listen".'
    );
    for (const id of legacyListeningGroups) {
      const existing = groups[id] ? { ...groups[id] } : {};
      if (!existing.mode || modeDerivedFromRequireMention.has(id)) {
        existing.mode = 'listen';
      } else if (existing.mode !== 'listen') {
        warnGroupConfigDeprecation(
          `${path}.groups.${id}.mode`,
          `is "${existing.mode}" while ${path}.listeningGroups also includes "${id}". Keeping mode "${existing.mode}".`
        );
      }
      groups[id] = existing;
    }

    // Legacy listeningGroups never restricted other groups.
    // Add wildcard open when there was no explicit groups config.
    if (!hadOriginalGroups && !groups['*']) {
      groups['*'] = { mode: 'open' };
    }
  }

  channel.groups = Object.keys(groups).length > 0 ? groups : undefined;
  delete channel.listeningGroups;
}

/**
 * Normalize config to multi-agent format.
 *
 * If the config uses legacy single-agent format (agent: + channels:),
 * it's converted to an agents[] array with one entry.
 * Channels with `enabled: false` are dropped during normalization.
 */
export function normalizeAgents(config: LettaBotConfig): AgentConfig[] {
  const normalizeChannels = (channels?: AgentConfig['channels'], sourcePath = 'channels'): AgentConfig['channels'] => {
    const normalized: AgentConfig['channels'] = {};
    if (!channels) return normalized;

    // Merge env vars into YAML blocks that are missing their key credential.
    // Without this, `signal: enabled: true` + SIGNAL_PHONE_NUMBER env var
    // silently fails because the env-var-only fallback (below) only fires
    // when the YAML block is completely absent.
    if (channels.telegram && !channels.telegram.token && process.env.TELEGRAM_BOT_TOKEN) {
      channels.telegram.token = process.env.TELEGRAM_BOT_TOKEN;
    }
    if (channels.slack) {
      if (!channels.slack.botToken && process.env.SLACK_BOT_TOKEN) channels.slack.botToken = process.env.SLACK_BOT_TOKEN;
      if (!channels.slack.appToken && process.env.SLACK_APP_TOKEN) channels.slack.appToken = process.env.SLACK_APP_TOKEN;
    }
    if (channels.signal && !channels.signal.phone && process.env.SIGNAL_PHONE_NUMBER) {
      channels.signal.phone = process.env.SIGNAL_PHONE_NUMBER;
    }
    if (channels.discord && !channels.discord.token && process.env.DISCORD_BOT_TOKEN) {
      channels.discord.token = process.env.DISCORD_BOT_TOKEN;
    }

    if (channels.telegram?.enabled !== false && channels.telegram?.token) {
      const telegram = { ...channels.telegram };
      normalizeLegacyGroupFields(telegram, `${sourcePath}.telegram`);
      normalized.telegram = telegram;
    }
    // telegram-mtproto: check apiId as the key credential
    if (channels['telegram-mtproto']?.enabled !== false && channels['telegram-mtproto']?.apiId) {
      normalized['telegram-mtproto'] = channels['telegram-mtproto'];
    }
    if (channels.slack?.enabled !== false && channels.slack?.botToken && channels.slack?.appToken) {
      const slack = { ...channels.slack };
      normalizeLegacyGroupFields(slack, `${sourcePath}.slack`);
      normalized.slack = slack;
    }
    // WhatsApp has no credential to check (uses QR pairing), so just check enabled
    if (channels.whatsapp?.enabled) {
      const whatsapp = { ...channels.whatsapp };
      normalizeLegacyGroupFields(whatsapp, `${sourcePath}.whatsapp`);
      normalized.whatsapp = whatsapp;
    }
    if (channels.signal?.enabled !== false && channels.signal?.phone) {
      const signal = { ...channels.signal };
      normalizeLegacyGroupFields(signal, `${sourcePath}.signal`);
      normalized.signal = signal;
    }
    if (channels.discord?.enabled !== false && channels.discord?.token) {
      const discord = { ...channels.discord };
      normalizeLegacyGroupFields(discord, `${sourcePath}.discord`);
      normalized.discord = discord;
    }
    if (channels.bluesky && channels.bluesky.enabled !== false) {
      const bluesky = { ...channels.bluesky, enabled: channels.bluesky.enabled ?? true };
      const wantsDids = Array.isArray(bluesky.wantedDids) && bluesky.wantedDids.length > 0;
      const canReply = !!(bluesky.handle && bluesky.appPassword);
      const hasLists = !!(bluesky.lists && Object.keys(bluesky.lists).length > 0);
      const wantsNotifications = !!bluesky.notifications?.enabled;
      if (wantsDids || canReply || hasLists || wantsNotifications) {
        normalized.bluesky = bluesky;
      }
    }

    // Warn when a channel block exists but was dropped due to missing credentials
    const channelCredentials: Array<[string, unknown, boolean]> = [
      ['telegram', channels.telegram, !!normalized.telegram],
      ['slack', channels.slack, !!normalized.slack],
      ['signal', channels.signal, !!normalized.signal],
      ['discord', channels.discord, !!normalized.discord],
    ];
    for (const [name, raw, included] of channelCredentials) {
      if (raw && (raw as Record<string, unknown>).enabled !== false && !included) {
        log.warn(`Channel '${name}' is in ${sourcePath} but missing required credentials -- skipping. Check your lettabot.yaml or environment variables.`);
      }
    }

    return normalized;
  };

  // Multi-agent mode: normalize channels for each configured agent
  if (config.agents && config.agents.length > 0) {
    return config.agents.map((agent, index) => ({
      ...agent,
      channels: normalizeChannels(agent.channels, `agents[${index}].channels`),
    }));
  }

  // Legacy single-agent mode: normalize to agents[]
  const envAgentName = process.env.LETTA_AGENT_NAME || process.env.AGENT_NAME;
  const agentName = envAgentName || config.agent?.name || 'LettaBot';
  const model = config.agent?.model;
  const id = config.agent?.id;

  // Filter out disabled/misconfigured channels
  const channels = normalizeChannels(config.channels, 'channels');

  // Env var fallback for container deploys without lettabot.yaml (e.g. Railway)
  // Helper: parse comma-separated env var into string array (or undefined)
  const parseList = (envVar?: string): string[] | undefined =>
    envVar ? envVar.split(',').map(s => s.trim()).filter(Boolean) : undefined;

  if (!channels.telegram && process.env.TELEGRAM_BOT_TOKEN) {
    channels.telegram = {
      enabled: true,
      token: process.env.TELEGRAM_BOT_TOKEN,
      dmPolicy: (process.env.TELEGRAM_DM_POLICY as 'pairing' | 'allowlist' | 'open') || 'pairing',
      allowedUsers: parseList(process.env.TELEGRAM_ALLOWED_USERS),
    };
  }
  // telegram-mtproto env var fallback (only if telegram bot not configured)
  if (!channels.telegram && !channels['telegram-mtproto'] && process.env.TELEGRAM_API_ID && process.env.TELEGRAM_API_HASH && process.env.TELEGRAM_PHONE_NUMBER) {
    channels['telegram-mtproto'] = {
      enabled: true,
      apiId: parseInt(process.env.TELEGRAM_API_ID, 10),
      apiHash: process.env.TELEGRAM_API_HASH,
      phoneNumber: process.env.TELEGRAM_PHONE_NUMBER,
      databaseDirectory: process.env.TELEGRAM_MTPROTO_DB_DIR || './data/telegram-mtproto',
      dmPolicy: (process.env.TELEGRAM_DM_POLICY as 'pairing' | 'allowlist' | 'open') || 'pairing',
      allowedUsers: parseList(process.env.TELEGRAM_ALLOWED_USERS)?.map(s => parseInt(s, 10)).filter(n => !isNaN(n)),
      groupPolicy: (process.env.TELEGRAM_GROUP_POLICY as 'mention' | 'reply' | 'both' | 'off') || 'both',
      adminChatId: process.env.TELEGRAM_ADMIN_CHAT_ID ? parseInt(process.env.TELEGRAM_ADMIN_CHAT_ID, 10) : undefined,
    };
  }
  if (!channels.slack && process.env.SLACK_BOT_TOKEN && process.env.SLACK_APP_TOKEN) {
    channels.slack = {
      enabled: true,
      botToken: process.env.SLACK_BOT_TOKEN,
      appToken: process.env.SLACK_APP_TOKEN,
      dmPolicy: (process.env.SLACK_DM_POLICY as 'pairing' | 'allowlist' | 'open') || 'pairing',
      allowedUsers: parseList(process.env.SLACK_ALLOWED_USERS),
    };
  }
  if (!channels.whatsapp && process.env.WHATSAPP_ENABLED === 'true') {
    channels.whatsapp = {
      enabled: true,
      selfChat: process.env.WHATSAPP_SELF_CHAT_MODE !== 'false',
      dmPolicy: (process.env.WHATSAPP_DM_POLICY as 'pairing' | 'allowlist' | 'open') || 'pairing',
      allowedUsers: parseList(process.env.WHATSAPP_ALLOWED_USERS),
    };
  }
  if (!channels.signal && process.env.SIGNAL_PHONE_NUMBER) {
    channels.signal = {
      enabled: true,
      phone: process.env.SIGNAL_PHONE_NUMBER,
      readReceipts: process.env.SIGNAL_READ_RECEIPTS !== 'false',
      selfChat: process.env.SIGNAL_SELF_CHAT_MODE !== 'false',
      dmPolicy: (process.env.SIGNAL_DM_POLICY as 'pairing' | 'allowlist' | 'open') || 'pairing',
      allowedUsers: parseList(process.env.SIGNAL_ALLOWED_USERS),
    };
  }
  if (!channels.discord && process.env.DISCORD_BOT_TOKEN) {
    channels.discord = {
      enabled: true,
      token: process.env.DISCORD_BOT_TOKEN,
      dmPolicy: (process.env.DISCORD_DM_POLICY as 'pairing' | 'allowlist' | 'open') || 'pairing',
      allowedUsers: parseList(process.env.DISCORD_ALLOWED_USERS),
    };
  }
  if (!channels.bluesky && process.env.BLUESKY_WANTED_DIDS) {
    channels.bluesky = {
      enabled: true,
      wantedDids: parseList(process.env.BLUESKY_WANTED_DIDS),
      wantedCollections: parseList(process.env.BLUESKY_WANTED_COLLECTIONS),
      jetstreamUrl: process.env.BLUESKY_JETSTREAM_URL,
      cursor: process.env.BLUESKY_CURSOR ? parseInt(process.env.BLUESKY_CURSOR, 10) : undefined,
      handle: process.env.BLUESKY_HANDLE,
      appPassword: process.env.BLUESKY_APP_PASSWORD,
      serviceUrl: process.env.BLUESKY_SERVICE_URL,
      appViewUrl: process.env.BLUESKY_APPVIEW_URL,
      notifications: process.env.BLUESKY_NOTIFICATIONS_ENABLED === 'true'
        ? {
            enabled: true,
            intervalSec: process.env.BLUESKY_NOTIFICATIONS_INTERVAL_SEC
              ? parseInt(process.env.BLUESKY_NOTIFICATIONS_INTERVAL_SEC, 10)
              : undefined,
            limit: process.env.BLUESKY_NOTIFICATIONS_LIMIT
              ? parseInt(process.env.BLUESKY_NOTIFICATIONS_LIMIT, 10)
              : undefined,
            priority: process.env.BLUESKY_NOTIFICATIONS_PRIORITY === 'true',
            reasons: parseList(process.env.BLUESKY_NOTIFICATIONS_REASONS),
          }
        : undefined,
    };
  }

  // Field-level env var fallback for features (heartbeat, cron).
  // Unlike channels (all-or-nothing), features are independent toggles so we
  // merge at the field level: env vars fill in fields missing from YAML.
  const features = { ...config.features } as NonNullable<LettaBotConfig['features']>;

  if (features.cron == null && process.env.CRON_ENABLED === 'true') {
    features.cron = true;
  }

  if (!features.heartbeat && process.env.HEARTBEAT_ENABLED === 'true') {
    const intervalMin = process.env.HEARTBEAT_INTERVAL_MIN
      ? parseInt(process.env.HEARTBEAT_INTERVAL_MIN, 10)
      : undefined;
    const skipRecentUserMin = process.env.HEARTBEAT_SKIP_RECENT_USER_MIN
      ? parseInt(process.env.HEARTBEAT_SKIP_RECENT_USER_MIN, 10)
      : undefined;

    features.heartbeat = {
      enabled: true,
      ...(Number.isFinite(intervalMin) ? { intervalMin } : {}),
      ...(Number.isFinite(skipRecentUserMin) ? { skipRecentUserMin } : {}),
    };
  }

  const sleeptimeTriggerRaw = process.env.SLEEPTIME_TRIGGER;
  const sleeptimeBehaviorRaw = process.env.SLEEPTIME_BEHAVIOR;
  const sleeptimeStepCountRaw = process.env.SLEEPTIME_STEP_COUNT;

  const sleeptimeTrigger = sleeptimeTriggerRaw === 'off'
    || sleeptimeTriggerRaw === 'step-count'
    || sleeptimeTriggerRaw === 'compaction-event'
    ? sleeptimeTriggerRaw
    : undefined;
  const sleeptimeBehavior = sleeptimeBehaviorRaw === 'reminder'
    || sleeptimeBehaviorRaw === 'auto-launch'
    ? sleeptimeBehaviorRaw
    : undefined;
  const sleeptimeStepCountParsed = sleeptimeStepCountRaw ? parseInt(sleeptimeStepCountRaw, 10) : undefined;
  const sleeptimeStepCount = Number.isFinite(sleeptimeStepCountParsed)
    && (sleeptimeStepCountParsed as number) > 0
    ? sleeptimeStepCountParsed
    : undefined;

  if (!features.sleeptime && (sleeptimeTrigger || sleeptimeBehavior || sleeptimeStepCount)) {
    features.sleeptime = {
      ...(sleeptimeTrigger ? { trigger: sleeptimeTrigger } : {}),
      ...(sleeptimeBehavior ? { behavior: sleeptimeBehavior } : {}),
      ...(sleeptimeStepCount ? { stepCount: sleeptimeStepCount } : {}),
    };
  }

  // Only pass features if there's actually something set
  const hasFeatures = Object.keys(features).length > 0;

  return [{
    name: agentName,
    id,
    displayName: config.agent?.displayName,
    model,
    channels,
    conversations: config.conversations,
    features: hasFeatures ? features : config.features,
    polling: config.polling,
    integrations: config.integrations,
  }];
}
