/**
 * Channel Manager — orchestrates multi-channel messaging.
 *
 * Responsibilities:
 * - Manages channel providers (WhatsApp, Telegram, etc.)
 * - Resolves sender role (admin vs customer) based on mode
 * - Routes inbound messages to the agent with appropriate system prompt
 * - Routes agent responses back to channels
 * - Manages parallel agent instances for customer conversations (business mode)
 *
 * Modes:
 * - channel: Just talk to me. Only self-chat (fromMe=true) triggers the agent.
 *            All other messages are ignored — it's the user's personal WhatsApp.
 * - business: Bloby has its own number. Numbers in the admins array get the main
 *             system prompt. Everyone else gets the customer support prompt.
 * - assistant: Personal assistant in conversations. Self-chat = admin channel.
 *              Others' messages stored for context. Only responds when the owner
 *              triggers with @botname in someone else's chat.
 */

import fs from 'fs';
import path from 'path';
import { loadConfig, saveConfig } from '../../shared/config.js';
import { WORKSPACE_DIR } from '../../shared/paths.js';
import { log } from '../../shared/logger.js';
import { startBlobyAgentQuery, startConversation, pushMessage, hasConversation, type RecentMessage } from '../bloby-agent.js';
import { WhatsAppChannel } from './whatsapp.js';
import { AlexaChannel } from './alexa.js';
import { TelegramChannel, type TelegramInbound } from './telegram.js';
import type { ChannelConfig, ChannelProvider, ChannelStatus, ChannelType, InboundMessage, InboundMessageAttachment, RoutingTarget, SenderRole } from './types.js';
import type { AgentAttachment } from '../bloby-agent.js';
import { saveAttachment, type SavedFile } from '../file-saver.js';
import type { WAMessageKey } from '@whiskeysockets/baileys';

const MAX_CONCURRENT_AGENTS = 5;
const MAX_BUFFER_MESSAGES = 30;
const DEBOUNCE_MS = 4000; // 4s — wait for the user to finish typing

/** Persist channel-inbound attachments to disk so harnesses that consume file
 *  paths (Codex's `localImage`) can see them. Failures are logged and the
 *  attachment is dropped — text-only delivery is still useful. */
function saveInboundAttachments(attachments?: AgentAttachment[]): SavedFile[] {
  if (!attachments?.length) return [];
  const saved: SavedFile[] = [];
  for (const att of attachments) {
    try { saved.push(saveAttachment(att)); }
    catch (err: any) { log.warn(`[channels] Failed to save inbound attachment: ${err.message}`); }
  }
  return saved;
}

interface ChannelManagerOpts {
  broadcastBloby: (type: string, data: any) => void;
  workerApi: (path: string, method?: string, body?: any) => Promise<any>;
  restartBackend: () => void;
  getModel: () => string;
  /** Fired after a channel turn ends — the supervisor uses it to flush a queued self-update. */
  onTurnComplete?: () => void;
}

interface ActiveAgentQuery {
  sender: string;
  channel: ChannelType;
}

interface BufferedMessage {
  role: 'user' | 'assistant';
  content: string;
}

interface DebounceEntry {
  messages: string[];
  attachments: InboundMessageAttachment[];
  timer: ReturnType<typeof setTimeout>;
  channel: ChannelType;
  sender: string;
  senderName?: string;
  fromMe: boolean;
  isSelfChat: boolean;
  chatJid: string;
  isGroup: boolean;
  /** Latest inbound message key in the batch (used for reactions/quotes on the freshest message). */
  inboundKey?: WAMessageKey;
}

/** Per-conversation accumulator for streaming bot text → WhatsApp. */
export interface WaStreamState {
  chunkBuf: string;
  /** True once the CURRENT turn has consumed its routing target (via `bot:response` or
   *  `bot:error`). Reset on `bot:turn-complete`. Guards the turn-complete safety-net drain so a
   *  normal turn — which already consumed on `bot:response` — never double-drains and eats the
   *  NEXT queued message's target (the root cause of chat↔WhatsApp bleed and DM-answered-in-group). */
  consumedThisTurn?: boolean;
}

/** Agent-turn events that carry per-turn content. Broadcast only for dashboard surfaces
 *  ('workspace' / 'chat'); suppressed for WhatsApp/Alexa turns so their replies don't
 *  bleed into the chat-bubble UI. Non-turn events (bot:idle, bot:error, channel:*) are
 *  always broadcast. */
const CHAT_TURN_EVENTS = new Set([
  'bot:token', 'bot:response', 'bot:tool',
  'bot:task-created', 'bot:task-progress', 'bot:task-done',
]);

export class ChannelManager {
  private providers = new Map<ChannelType, ChannelProvider>();
  private opts: ChannelManagerOpts;
  private activeAgents = new Map<string, ActiveAgentQuery>();
  private messageQueue: InboundMessage[] = [];
  private statusListeners: ((status: ChannelStatus) => void)[] = [];
  /** In-memory conversation history per customer (keyed by "channel:phone") */
  private customerBuffers = new Map<string, BufferedMessage[]>();
  /** Debounce buffers per sender (keyed by "channel:sender") */
  private debounceBuffers = new Map<string, DebounceEntry>();
  /**
   * Per-conversation FIFO of routing targets. One entry pushed for every user message,
   * one consumed for every `bot:response`. This is the supervisor-enforced anti-bleed
   * mechanism — the agent's reply is pinned to whatever surface triggered it, regardless
   * of any mid-turn inbound from another channel.
   */
  private routingQueues = new Map<string, RoutingTarget[]>();

  constructor(opts: ChannelManagerOpts) {
    this.opts = opts;
  }

  /** Initialize channels based on config. Idempotent — calling repeatedly
   *  initializes any newly-enabled channel without disturbing ones already up. */
  async init(): Promise<void> {
    const config = loadConfig();
    const channelConfigs = config.channels;

    if (channelConfigs?.whatsapp?.enabled && !this.providers.has('whatsapp')) {
      log.info('[channels] Initializing WhatsApp channel...');
      const whatsapp = new WhatsAppChannel(
        (sender, senderName, text, fromMe, isSelfChat, chatJid, isGroup, images, inboundKey) => {
          const attachments = images?.map((img) => ({ type: 'image' as const, mediaType: img.mediaType, data: img.data }));
          this.handleInboundMessage('whatsapp', sender, senderName, text, fromMe, isSelfChat, chatJid, isGroup, attachments, inboundKey);
        },
        (status) => this.handleStatusChange(status),
        (audioBase64) => this.transcribeAudio(audioBase64),
      );
      this.providers.set('whatsapp', whatsapp);

      // Auto-connect if credentials exist (previously linked)
      if (whatsapp.hasCredentials()) {
        try {
          await whatsapp.connect();
        } catch (err: any) {
          log.warn(`[channels] WhatsApp auto-connect failed: ${err.message}`);
        }
      }
    } else if (!channelConfigs?.whatsapp?.enabled && !this.providers.has('whatsapp')) {
      log.info('[channels] WhatsApp not enabled — skipping');
    }

    if (channelConfigs?.alexa?.enabled && !this.providers.has('alexa')) {
      log.info('[channels] Initializing Alexa channel...');
      const alexa = new AlexaChannel();
      this.providers.set('alexa', alexa);
      await alexa.connect();
    }

    // Telegram — only when a BYO @BotFather bot token has been supplied (via the connect
    // page). The provider long-polls Telegram directly; no relay is involved.
    if (channelConfigs?.telegram?.enabled && channelConfigs.telegram.botToken && !this.providers.has('telegram')) {
      log.info('[channels] Initializing Telegram channel...');
      const telegram = new TelegramChannel(
        (msg) => this.handleTelegramMessage(msg),
        (status) => this.handleStatusChange(status),
        (audioBase64) => this.transcribeAudio(audioBase64),
      );
      this.providers.set('telegram', telegram);
      try {
        await telegram.connect();
      } catch (err: any) {
        log.warn(`[channels] Telegram connect failed: ${err.message}`);
      }
    }
  }

  /** Map a Telegram inbound onto the shared channel pipeline.
   *
   *  Telegram has no "self-chat" like WhatsApp. Instead the bot OWNER (the human who created the
   *  bot) is the "self" identity: fromMe=true and (in 1:1) isSelfChat=true. This makes channel mode
   *  respond only to the owner's DMs and makes assistant-mode `@botname` triggers work — reusing the
   *  exact mode/debounce/role gating in handleInboundMessage with no special-casing. */
  private handleTelegramMessage(msg: TelegramInbound) {
    let ownerUserId = loadConfig().channels?.telegram?.ownerUserId;
    // Trust-on-first-use: a BYO bot starts with no owner recorded, so adopt the first person to
    // DM it as the owner. (Cleared on token change so a new bot re-adopts.) Guarantees channel
    // mode never silently ignores its owner.
    if (!ownerUserId && !msg.isGroup && msg.fromUserId) {
      ownerUserId = msg.fromUserId;
      const cfg = loadConfig();
      if (cfg.channels?.telegram) {
        cfg.channels.telegram.ownerUserId = ownerUserId;
        saveConfig(cfg);
        log.info(`[channels] Telegram owner adopted (trust-on-first-use): userId=${ownerUserId}`);
      }
    }
    const isOwner = !!ownerUserId && msg.fromUserId === String(ownerUserId);
    const attachments = msg.images?.map((img) => ({ type: 'image' as const, mediaType: img.mediaType, data: img.data }));
    // Sanitize the attacker-controlled display name so it can't fake a `[Telegram | … | admin]`
    // context tag or inject newlines into the agent's context.
    const safeName = msg.senderName ? msg.senderName.replace(/[\[\]|\r\n]/g, ' ').slice(0, 64).trim() || undefined : undefined;
    this.handleInboundMessage(
      'telegram',
      msg.fromUserId,
      safeName,
      msg.text,
      isOwner,                  // fromMe — the owner is treated as "me"
      isOwner && !msg.isGroup,  // isSelfChat — owner's 1:1 DM is the personal channel
      msg.chatId,               // chatJid (reply target)
      msg.isGroup,
      attachments,
      undefined,                // inboundKey — Baileys-only (reactions not in Telegram v1)
    );
  }

  /** Start WhatsApp connection (triggers QR flow if no credentials) */
  async connectWhatsApp(): Promise<void> {
    let provider = this.providers.get('whatsapp');
    // Already linked and online — don't tear down a live socket to start a new one
    if (provider?.getStatus().connected) return;
    if (!provider) {
      const whatsapp = new WhatsAppChannel(
        (sender, senderName, text, fromMe, isSelfChat, chatJid, isGroup, images, inboundKey) => {
          const attachments = images?.map((img) => ({ type: 'image' as const, mediaType: img.mediaType, data: img.data }));
          this.handleInboundMessage('whatsapp', sender, senderName, text, fromMe, isSelfChat, chatJid, isGroup, attachments, inboundKey);
        },
        (status) => this.handleStatusChange(status),
        (audioBase64) => this.transcribeAudio(audioBase64),
      );
      this.providers.set('whatsapp', whatsapp);
      provider = whatsapp;
    }
    await provider.connect();
  }

  /** Disconnect a specific channel */
  async disconnectChannel(type: ChannelType): Promise<void> {
    const provider = this.providers.get(type);
    if (provider) {
      await provider.disconnect();
      this.providers.delete(type);
    }
  }

  /** Disconnect all channels */
  async disconnectAll(): Promise<void> {
    for (const [, provider] of this.providers) {
      await provider.disconnect();
    }
    this.providers.clear();
  }

  /** Send a message via a specific channel, processing any custom UI tags */
  async sendMessage(channel: ChannelType, to: string, text: string): Promise<void> {
    const provider = this.providers.get(channel);
    if (!provider) throw new Error(`Channel ${channel} not available`);

    // Process custom tags for channel delivery
    let processed = text;

    // <BlobyImage> — send the image natively, remove tag from text
    const imgRegex = /<BlobyImage\s+src="([^"]+)"(?:\s+alt="([^"]*)")?\s*\/>/g;
    const images: { src: string; alt: string }[] = [];
    let imgMatch;
    while ((imgMatch = imgRegex.exec(text)) !== null) {
      images.push({ src: imgMatch[1], alt: imgMatch[2] || '' });
    }
    processed = processed.replace(imgRegex, '').trim();

    // <BlobyText> — flatten to plain title + content
    processed = processed.replace(
      /<BlobyText\s+title="([^"]+)">([\s\S]*?)<\/BlobyText>/g,
      (_match, title, content) => `📄 *${title}*\n\n${content.trim()}`,
    );

    // Send text (if any remains after stripping tags)
    if (processed) {
      await provider.sendMessage(to, processed);
    }

    // Send images natively via WhatsApp / Telegram (both expose sendImage)
    if (images.length > 0 && (provider instanceof WhatsAppChannel || provider instanceof TelegramChannel)) {
      for (const img of images) {
        try {
          const resolved = this.resolveMediaFile(img.src);
          if (resolved) {
            await provider.sendImage(to, resolved.buffer, img.alt || undefined, resolved.mimetype);
          } else {
            log.warn(`[channels] Image file not found in any location: ${img.src}`);
          }
        } catch (err: any) {
          log.warn(`[channels] Failed to send image via ${channel}: ${err.message}`);
        }
      }
    }
  }

  /**
   * Send arbitrary media (audio, image, video, document) via a channel.
   * Accepts a file path (absolute, workspace-relative, or /api/files/... URL).
   */
  async sendMedia(
    channel: ChannelType,
    to: string,
    media: { type: 'audio' | 'image' | 'video' | 'document'; path: string; mimetype?: string; fileName?: string; voiceNote?: boolean },
    caption?: string,
  ): Promise<void> {
    const provider = this.providers.get(channel);
    if (!provider) throw new Error(`Channel ${channel} not available`);

    const resolved = this.resolveMediaFile(media.path);
    if (!resolved) throw new Error(`Media file not found: ${media.path}`);

    const mimetype = media.mimetype || resolved.mimetype;

    // Telegram supports images via the provider's sendImage (other media types not wired in v1).
    if (provider instanceof TelegramChannel) {
      if (media.type === 'image') {
        await provider.sendImage(to, resolved.buffer, caption, mimetype);
      } else {
        throw new Error(`Telegram supports image media only (got ${media.type})`);
      }
      return;
    }

    if (!(provider instanceof WhatsAppChannel)) {
      throw new Error(`Channel ${channel} does not support media`);
    }

    switch (media.type) {
      case 'image':
        await provider.sendImage(to, resolved.buffer, caption, mimetype);
        break;
      case 'audio':
        await provider.sendAudio(to, resolved.buffer, { mimetype, voiceNote: media.voiceNote });
        if (caption) await provider.sendMessage(to, caption);
        break;
      case 'video':
        await provider.sendVideo(to, resolved.buffer, caption, mimetype);
        break;
      case 'document':
        await provider.sendDocument(to, resolved.buffer, media.fileName || path.basename(resolved.absPath), mimetype, caption);
        break;
      default:
        throw new Error(`Unsupported media type: ${(media as any).type}`);
    }
  }

  /** Resolve a media path (absolute, /api/files/..., or workspace-relative) to buffer + inferred mimetype. */
  private resolveMediaFile(src: string): { buffer: Buffer; mimetype: string; absPath: string } | null {
    let absPath: string | undefined;

    if (path.isAbsolute(src) && fs.existsSync(src)) {
      absPath = src;
    } else {
      const relPath = src.replace(/^\/api\/files\//, '').replace(/^\/+/, '');
      const candidates = [
        path.join(WORKSPACE_DIR, 'files', relPath),
        path.join(WORKSPACE_DIR, relPath),
        path.join(WORKSPACE_DIR, 'client', 'public', relPath),
      ];
      absPath = candidates.find((p) => fs.existsSync(p));
    }

    if (!absPath) return null;

    const buffer = fs.readFileSync(absPath);
    const ext = path.extname(absPath).slice(1).toLowerCase();
    const mimeMap: Record<string, string> = {
      png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp',
      mp3: 'audio/mpeg', m4a: 'audio/mp4', ogg: 'audio/ogg', opus: 'audio/ogg; codecs=opus', wav: 'audio/wav',
      mp4: 'video/mp4', mov: 'video/quicktime', webm: 'video/webm',
      pdf: 'application/pdf', zip: 'application/zip', txt: 'text/plain',
    };
    return { buffer, mimetype: mimeMap[ext] || 'application/octet-stream', absPath };
  }

  /** Show "typing..." indicator in a chat */
  startTyping(channel: ChannelType, jid: string): void {
    const provider = this.providers.get(channel);
    if (provider && 'startTyping' in provider) {
      (provider as WhatsAppChannel).startTyping(jid);
    }
  }

  /** Get status of all channels */
  getStatuses(): ChannelStatus[] {
    return Array.from(this.providers.values()).map((p) => p.getStatus());
  }

  /** Get status of a specific channel */
  getStatus(type: ChannelType): ChannelStatus | null {
    return this.providers.get(type)?.getStatus() || null;
  }

  /** Get QR code SVG for a channel */
  getQrCode(type: ChannelType): string | null {
    return this.providers.get(type)?.getQrCode() || null;
  }

  /** Register a listener for status changes (used for WS broadcasting) */
  onStatusChange(listener: (status: ChannelStatus) => void) {
    this.statusListeners.push(listener);
  }

  /** Request a pairing code for phone-number-based linking (mobile alternative to QR) */
  async requestWhatsAppPairingCode(phoneNumber: string): Promise<string> {
    const provider = this.providers.get('whatsapp') as WhatsAppChannel | undefined;
    if (!provider) throw new Error('WhatsApp not initialized — call connect first');
    return provider.requestPairingCode(phoneNumber);
  }

  /** Delete WhatsApp credentials and disconnect */
  async logoutWhatsApp(): Promise<void> {
    const provider = this.providers.get('whatsapp') as WhatsAppChannel | undefined;
    if (provider) {
      await provider.disconnect();
      await provider.deleteCredentials();
      this.providers.delete('whatsapp');
    }
  }

  // ── Internal ──

  /** Format a bot reply with the agent's name prefix (for admin & assistant messages, NOT customer) */
  private formatBotReply(text: string, botName: string): string {
    return `🤖 *${botName}:*\n\n${text}`;
  }

  /** Human-facing channel label used in the `[Channel | ...]` context tag the agent sees. */
  private channelLabel(channel: ChannelType): string {
    return channel === 'telegram' ? 'Telegram' : channel === 'alexa' ? 'Alexa' : 'WhatsApp';
  }

  /** Allocate per-conv state for WhatsApp text streaming. Both the orchestrator
   * (chat UI websocket) and the manager's own admin handler create one of these
   * and feed each agent stream event through `routeWaStreamEvent` below. */
  createWaStreamState(): WaStreamState {
    return { chunkBuf: '' };
  }

  /** Push a user message into a live conversation and pin where the assistant's reply must go.
   *
   *  THIS IS THE SINGLE SOURCE OF TRUTH for routing — every caller (chat-WS, channel inbound
   *  handlers, scheduler, etc.) MUST push via this method, never via the raw harness pushMessage.
   *
   *  Each call enqueues exactly one routing target onto a per-conversation FIFO. Each
   *  `bot:response` consumes exactly one entry. Concurrent inbounds from different surfaces
   *  during the same turn cannot bleed into each other's replies — the SDK queues inputs in
   *  order, and the FIFO mirrors that ordering. Turns that end without a response (error /
   *  empty turn / aborted) drain the head entry in `routeWaStreamEvent` so the queue stays
   *  in sync. */
  pushWithRouting(
    convId: string,
    target: RoutingTarget,
    content: string,
    attachments?: AgentAttachment[],
    savedFiles?: SavedFile[],
  ): void {
    let q = this.routingQueues.get(convId);
    if (!q) {
      q = [];
      this.routingQueues.set(convId, q);
    }
    q.push({ ...target, pushedAt: Date.now() });
    pushMessage(convId, content, attachments, savedFiles);
  }

  /** Peek the head of the routing queue without consuming. Used during intermediate streaming
   *  flushes (bot:tool) so chunks land on the correct surface before the final response. */
  private peekRoute(convId: string): RoutingTarget | undefined {
    return this.routingQueues.get(convId)?.[0];
  }

  /** Consume one entry from the routing queue. Called on `bot:response`, and as a safety net
   *  on `bot:turn-complete`/`bot:error` if no response fired (so the head doesn't bleed into
   *  the next turn). */
  private consumeRoute(convId: string): RoutingTarget | undefined {
    const q = this.routingQueues.get(convId);
    if (!q || q.length === 0) return undefined;
    const target = q.shift();
    if (q.length === 0) this.routingQueues.delete(convId);
    if (target?.pushedAt && Date.now() - target.pushedAt > 30_000) {
      log.warn(`[channels] Stale route popped: surface=${target.surface}, age=${Math.round((Date.now() - target.pushedAt) / 1000)}s, to=${target.waSendTo || 'none'}, queueDepth=${q?.length ?? 0}`);
    }
    return target;
  }

  /** Return the surface of the current turn's routing target without consuming it.
   *  Used by broadcast guards to suppress chat-bubble events for non-dashboard turns. */
  peekCurrentSurface(convId: string): RoutingTarget['surface'] | undefined {
    return this.routingQueues.get(convId)?.[0]?.surface;
  }

  /** Drop all pending routes for a conversation — used when the live conversation ends.
   *  Accepts undefined for ergonomics in callers that hold a possibly-undefined convId. */
  clearRoutes(convId: string | undefined): void {
    if (!convId) return;
    const q = this.routingQueues.get(convId);
    if (q && q.length > 0) {
      log.warn(`[channels] Discarding ${q.length} pending route(s) for ended conversation ${convId}`);
    }
    this.routingQueues.delete(convId);
  }

  /** Send a reaction emoji on the inbound message that triggered a turn. Used to acknowledge
   *  long-running work without spamming text. Pass `''` to remove a previous reaction. */
  async reactToInbound(target: RoutingTarget, emoji: string): Promise<void> {
    if (!target.inboundKey || !target.waSendTo) return;
    const provider = this.providers.get('whatsapp');
    if (provider instanceof WhatsAppChannel) {
      await provider.sendReaction(target.waSendTo, target.inboundKey as WAMessageKey, emoji);
    }
  }

  /** Direct reaction API (for the /api/channels/whatsapp/react endpoint). */
  async reactToMessage(channel: ChannelType, chatJid: string, key: WAMessageKey, emoji: string): Promise<void> {
    const provider = this.providers.get(channel);
    if (!(provider instanceof WhatsAppChannel)) {
      throw new Error(`Channel ${channel} does not support reactions`);
    }
    await provider.sendReaction(chatJid, key, emoji);
  }

  /** Centralized WhatsApp routing for streaming agent events.
   *
   * Every event passes through here exactly once (only one onMessage callback is registered
   * per live conversation, regardless of who started it). The routing decision is pure: it
   * consults the per-conversation FIFO populated by `pushWithRouting`. No fallback, no
   * implicit channel inference — the trigger surface owns the reply.
   *
   * Also keeps assistant-mode context buffers up to date.
   */
  routeWaStreamEvent(
    state: WaStreamState,
    type: string,
    eventData: any,
    botName: string,
  ): void {
    const convId = eventData?.conversationId as string | undefined;

    if (type === 'bot:token' && eventData?.token) {
      state.chunkBuf += eventData.token;
      return;
    }

    if (type === 'bot:tool' && state.chunkBuf.trim() && convId) {
      // Agent paused for a tool call — flush streamed text so the user sees progress
      // before the tool result lands. Peek (don't consume) — the final bot:response
      // is what closes out the turn.
      const head = this.peekRoute(convId);
      if (head?.surface === 'alexa') {
        // For Alexa, send the preamble as a Progressive Response so the user
        // hears the agent's actual "I'll do X..." line. Final bot:response is
        // still what closes the turn with the agent's last words.
        const alexa = this.providers.get('alexa') as AlexaChannel | undefined;
        alexa?.tryFlushProgressive(convId, state.chunkBuf.trim());
        state.chunkBuf = '';
      } else {
        this.sendStreamChunk(head, state.chunkBuf.trim(), botName);
        state.chunkBuf = '';
      }
      return;
    }

    if (type === 'bot:response' && eventData?.content && convId) {
      const target = this.consumeRoute(convId);
      state.consumedThisTurn = true;
      const remaining = state.chunkBuf.trim();
      state.chunkBuf = '';

      if (target?.surface === 'alexa') {
        // Resolve the HTTP handler's promise with the full reply text.
        const alexa = this.providers.get('alexa') as AlexaChannel | undefined;
        if (!alexa || !alexa.resolveHead(convId, eventData.content)) {
          log.warn(`[channels] bot:response for alexa surface but no pending resolver (conv=${convId})`);
        }
      } else if (remaining) {
        this.sendStreamChunk(target, remaining, botName);
      }

      // Append the assistant's reply into the per-chat context buffer so the next
      // trigger in that chat sees it as conversation history.
      if (target?.assistantBufferKey) {
        const buf = this.customerBuffers.get(target.assistantBufferKey);
        if (buf) {
          buf.push({ role: 'assistant', content: eventData.content });
          if (buf.length > MAX_BUFFER_MESSAGES) buf.splice(0, buf.length - MAX_BUFFER_MESSAGES);
        }
      }
      return;
    }

    // Turn errored without a usable reply — drain THIS turn's route so it can't bleed into the
    // next turn. Guarded by `consumedThisTurn`: if `bot:response` already fired this turn it
    // consumed the target, so we must NOT drain again.
    if (type === 'bot:error' && convId) {
      if (!state.consumedThisTurn) {
        const dropped = this.consumeRoute(convId);
        if (dropped) {
          log.warn(`[channels] bot:error without bot:response — dropping pending route (surface=${dropped.surface}, to=${dropped.waSendTo || 'none'})`);
          if (dropped.surface === 'alexa') {
            const alexa = this.providers.get('alexa') as AlexaChannel | undefined;
            alexa?.rejectHead(convId, type);
          }
        }
        state.consumedThisTurn = true;
      }
      state.chunkBuf = '';
      return;
    }

    // Turn finished. Drain the head ONLY if this turn never consumed its route — i.e. a true
    // empty turn (no `bot:response`, no `bot:error`; see harness claude.ts which always emits
    // `bot:turn-complete` after every result). A normal turn already consumed on `bot:response`,
    // so draining here would eat the NEXT queued message's target and every later reply would
    // land on the wrong surface (chat↔WhatsApp bleed, DM answered in a group). Reset the per-turn
    // flag afterwards so the next turn starts clean. Turns are strictly sequential per conversation
    // (single input queue, one `result` at a time), so this per-conversation flag is race-free.
    if (type === 'bot:turn-complete' && convId) {
      if (!state.consumedThisTurn) {
        const head = this.peekRoute(convId);
        if (head) {
          const dropped = this.consumeRoute(convId);
          log.warn(`[channels] turn-complete without bot:response — dropping pending route (surface=${dropped?.surface}, to=${dropped?.waSendTo || 'none'})`);
          if (dropped?.surface === 'alexa') {
            const alexa = this.providers.get('alexa') as AlexaChannel | undefined;
            alexa?.rejectHead(convId, type);
          }
        }
      }
      state.consumedThisTurn = false;
      state.chunkBuf = '';
    }
  }

  /** Deliver a streamed chunk to the WhatsApp side of a routing target.
   *  No-op when the target has no `waSendTo` (e.g., chat-UI turn with WA disconnected). */
  private sendStreamChunk(target: RoutingTarget | undefined, text: string, botName: string): void {
    // Telegram: the bot is its own contact, so no "🤖 Bot:" prefix — send the agent's text as-is.
    if (target?.surface === 'telegram') {
      if (!target.telegramChatId) return;
      this.sendMessage('telegram', target.telegramChatId, text).catch((err) =>
        log.warn(`[channels] Telegram send failed (${target.telegramChatId}): ${err.message}`),
      );
      return;
    }
    if (!target?.waSendTo) return;
    // Prefix only when the trigger came from WhatsApp AND it isn't the user's own self-chat —
    // the user doesn't need to see "🤖 Bot:" before their own bot's reply in their own chat.
    const usePrefix = target.surface === 'whatsapp' && !target.isSelfChat;
    const body = usePrefix ? this.formatBotReply(text, botName) : text;
    this.sendMessage('whatsapp', target.waSendTo, body).catch((err) =>
      log.warn(`[channels] WA send failed (${target.waSendTo}): ${err.message}`),
    );
  }

  private handleStatusChange(status: ChannelStatus) {
    for (const listener of this.statusListeners) {
      listener(status);
    }
  }

  /** Get the channel config, re-reading from disk each time */
  private getChannelConfig(channel: ChannelType): ChannelConfig | undefined {
    const config = loadConfig();
    // Only ever called for the ChannelConfig-shaped providers (whatsapp/telegram),
    // both of which carry `mode`. Alexa's config has a different shape (no `mode`)
    // and is handled via its own handleAlexaInbound path, so narrowing the stored
    // channel union to ChannelConfig here is safe.
    return config.channels?.[channel] as ChannelConfig | undefined;
  }

  /** Robust "is this the account owner's own self-chat?" check.
   *
   *  Keys purely on JID equality (`isSelfChat` — the chat resolves to the owner's OWN number,
   *  computed in whatsapp.ts from `ownPhoneJid` + LID translation). This is authoritative and is
   *  UNAFFECTED by Baileys' `fromMe` decode regressions (e.g. the rc11→rc13 protocolMessage
   *  `fromMe=false` bug): the chat JID of a self-message is still the owner's own number even when
   *  `fromMe` decodes wrong. So we deliberately do NOT also require `fromMe` (which the old gate
   *  did — that's what silently dropped self-messages under rc11).
   *
   *  We also deliberately do NOT treat `fromMe` alone as self-chat: `fromMe` is true for the owner
   *  messaging a CONTACT from a linked device too, so a `fromMe`-based OR would misroute those into
   *  the admin brain. Only own-number JID equality is safe and false-positive-free. */
  private isOwnerSelfChat(isSelfChat: boolean, isGroup: boolean): boolean {
    return !isGroup && isSelfChat;
  }

  /** Handle an incoming message from any channel — debounces rapid messages from the same sender.
   *
   * Per-mode behavior is decided here. To add a new mode: extend the gating block below
   * (filter inbound) and the routing block in flushDebounce (route to admin/customer/assistant).
   */
  private async handleInboundMessage(
    channel: ChannelType,
    sender: string,
    senderName: string | undefined,
    text: string,
    fromMe: boolean,
    isSelfChat: boolean,
    chatJid: string,
    isGroup: boolean,
    attachments?: InboundMessageAttachment[],
    inboundKey?: WAMessageKey,
  ) {
    const channelConfig = this.getChannelConfig(channel);
    if (!channelConfig) return;

    const mode = channelConfig.mode || 'channel';

    // ── Group gating ──
    // Channel mode is self-chat only — groups never apply.
    // Other modes: opt-in via channelConfig.allowGroups (default false).
    if (isGroup) {
      if (mode === 'channel') return;
      if (!channelConfig.allowGroups) return;
    }

    // Owner self-chat — JID-based, immune to Baileys `fromMe` decode regressions.
    const selfChat = this.isOwnerSelfChat(isSelfChat, isGroup);

    // ── Channel mode: ONLY respond to self-chat ──
    if (mode === 'channel') {
      if (!selfChat) return;
    }

    // ── Business mode: filter outgoing to others (your messages to customers, not self-chat) ──
    if (mode === 'business' && fromMe && !selfChat) return;

    // ── Assistant mode ──
    // Self-chat: falls through to debounce (processed as admin)
    // Others' messages or untriggered messages: store for context, don't invoke
    // Triggered messages: fall through to debounce → agent (owner always; others only if opted-in)
    if (mode === 'assistant' && !selfChat) {
      // Store every message for context (both mine and theirs) — keyed by the chat (group or 1:1)
      this.storeAssistantContext(channel, chatJid, senderName, text, fromMe);

      // Trigger must be present.
      const botName = loadConfig().username || 'bloby';
      const triggerPattern = new RegExp(`(?:^|\\n)\\s*@${botName}[:\\s]`, 'i');
      if (!triggerPattern.test(text)) return;

      // Who may drive the agent? By default ONLY the account owner (fromMe). When the channel is
      // explicitly configured with `allowOthersToTrigger`, anyone who tags the bot can — a
      // deliberately dangerous shared-control mode (the triggerer gets an agent with Bash, file
      // access, etc.; see the WhatsApp SKILL.md disclaimer).
      const allowOthers = channelConfig.allowOthersToTrigger === true;
      if (!fromMe && !allowOthers) return;
      // Falls through to debounce → flushDebounce → handleAssistantMessage
    }

    // Debounce: accumulate rapid messages from the same chat
    // (key by chatJid so a group's rapid messages collapse into one turn)
    const debounceKey = `${channel}:${chatJid}`;
    const existing = this.debounceBuffers.get(debounceKey);

    if (existing) {
      // Another message in the same chat — reset timer, append text
      clearTimeout(existing.timer);
      existing.messages.push(text);
      if (attachments?.length) existing.attachments.push(...attachments);
      existing.senderName = senderName || existing.senderName;
      if (inboundKey) existing.inboundKey = inboundKey; // track the freshest message for reactions
      existing.timer = setTimeout(() => this.flushDebounce(debounceKey), DEBOUNCE_MS);
      log.info(`[channels] Debounce: buffered message ${existing.messages.length} from ${sender} in ${chatJid}`);
      return;
    }

    // First message in this chat's debounce window
    const entry: DebounceEntry = {
      messages: [text],
      attachments: attachments ? [...attachments] : [],
      channel,
      sender,
      senderName,
      fromMe,
      isSelfChat,
      chatJid,
      isGroup,
      inboundKey,
      timer: setTimeout(() => this.flushDebounce(debounceKey), DEBOUNCE_MS),
    };
    this.debounceBuffers.set(debounceKey, entry);
  }

  /** Flush debounced messages — combine and route to the appropriate handler */
  private async flushDebounce(key: string) {
    const entry = this.debounceBuffers.get(key);
    if (!entry) return;
    this.debounceBuffers.delete(key);

    const { channel, sender, senderName, fromMe, isSelfChat, chatJid, isGroup, messages, attachments, inboundKey } = entry;
    const combinedText = messages.join('\n');

    const channelConfig = this.getChannelConfig(channel);
    if (!channelConfig) return;

    const mode = channelConfig.mode || 'channel';

    // Reply identifier — strip JID suffix to get a stable id (phone for 1:1, group hash for groups)
    const chatId = chatJid.replace(/@.*/, '');

    // Owner self-chat — JID-based, immune to Baileys `fromMe` decode regressions (matches the
    // gate in handleInboundMessage so a self-message can't pass one check and fail the other).
    const selfChat = this.isOwnerSelfChat(isSelfChat, isGroup);

    // Route based on mode and role
    if (mode === 'channel' || (mode === 'business' && selfChat) || (mode === 'assistant' && selfChat)) {
      // Admin (self-chat in any mode)
      const message: InboundMessage = {
        channel,
        sender: chatId,
        senderName,
        role: 'admin',
        text: combinedText,
        rawSender: chatJid,
        attachments: attachments.length > 0 ? attachments : undefined,
        inboundKey,
        isGroup,
      };

      const modeLabel = mode === 'channel' ? 'Channel mode | self-chat'
        : mode === 'assistant' ? 'Assistant mode | self-chat | admin'
        : 'Business mode | self-chat | admin';
      log.info(`[channels] ${modeLabel} | "${combinedText.slice(0, 60)}"`);
      await this.handleAdminMessage(message);
      return;
    }

    // Assistant mode — triggered message in someone else's chat (or a group) → route through admin (shared brain)
    if (mode === 'assistant') {
      const bufferKey = `${channel}:${chatId}`;
      const buffer = this.customerBuffers.get(bufferKey) || [];

      // Strip trigger prefix
      const cfgBotName = loadConfig().username || 'bloby';
      const triggerRegex = new RegExp(`@${cfgBotName}[:\\s]+`, 'i');
      const triggerMatch = combinedText.match(triggerRegex);
      let cleanText = combinedText;
      if (triggerMatch && triggerMatch.index !== undefined) {
        cleanText = combinedText.slice(triggerMatch.index + triggerMatch[0].length).trim();
      }

      // Load skill context (SCRIPT.md + contact memory)
      const scriptPrompt = this.loadActiveScript(channelConfig);
      let contactMemory = '';
      try {
        const customerDataDir = this.getSkillCustomerDataDir(channelConfig);
        if (customerDataDir) {
          const memoryPath = path.join(WORKSPACE_DIR, customerDataDir, `${chatId}.md`);
          if (fs.existsSync(memoryPath)) {
            contactMemory = fs.readFileSync(memoryPath, 'utf-8').trim();
          }
        }
      } catch {}

      // Build enriched text: skill instructions + conversation context + command
      const chatLabel = isGroup ? `group ${chatId}` : (senderName || chatId);
      let enrichedText = '';
      if (scriptPrompt) enrichedText += `# Assistant Skill Instructions\n${scriptPrompt}\n\n---\n`;
      if (contactMemory) enrichedText += `# Contact Memory (${chatId})\n${contactMemory}\n\n---\n`;
      if (buffer.length > 0) {
        enrichedText += `# Recent conversation with ${chatLabel}\n`;
        enrichedText += buffer.map((m) => m.content).join('\n');
        enrichedText += '\n\n---\n';
      }
      enrichedText += cleanText;

      const message: InboundMessage = {
        channel,
        sender: chatId,
        senderName,
        role: 'assistant',
        text: enrichedText,
        displayText: cleanText,
        rawSender: chatJid,
        attachments: attachments.length > 0 ? attachments : undefined,
        inboundKey,
        isGroup,
      };

      log.info(`[channels] Assistant mode | triggered in ${isGroup ? 'group ' : 'chat with '}${chatId} | buffer=${buffer.length} msgs | "${cleanText.slice(0, 60)}"`);
      await this.handleAdminMessage(message);
      return;
    }

    // Business mode — incoming message. Role is resolved against the actual sender JID (not the chat JID).
    const role = this.resolveBusinessRole(channelConfig, sender, channel);

    const message: InboundMessage = {
      channel,
      sender: sender.replace(/@.*/, ''),
      senderName,
      role,
      text: combinedText,
      rawSender: chatJid,
      attachments: attachments.length > 0 ? attachments : undefined,
      inboundKey,
      isGroup,
    };

    log.info(`[channels] Business mode | ${message.sender} | role=${role} | "${combinedText.slice(0, 60)}"`);

    if (role === 'admin') {
      await this.handleAdminMessage(message);
    } else {
      await this.handleCustomerMessage(message, channelConfig);
    }
  }

  /** Resolve role in business mode — check admins array */
  private resolveBusinessRole(config: ChannelConfig, sender: string, channel: ChannelType): SenderRole {
    if (config.admins?.length) {
      // Telegram admins are EXACT numeric user ids — no suffix/country-code fuzz (that's
      // phone-number tolerance and would wrongly grant admin on a numeric-id suffix collision).
      if (channel === 'telegram') {
        const senderId = sender.replace(/@.*/, '').trim();
        return config.admins.some((a) => String(a).trim() === senderId) ? 'admin' : 'customer';
      }
      const senderPhone = sender.replace(/@.*/, '').replace(/[^0-9]/g, '');
      for (const admin of config.admins) {
        const adminPhone = admin.replace(/[^0-9]/g, '');
        if (senderPhone === adminPhone || senderPhone.endsWith(adminPhone) || adminPhone.endsWith(senderPhone)) {
          return 'admin';
        }
      }
    }

    return 'customer';
  }

  /** Handle message from an admin (or assistant trigger) — mirrors to chat conversation, uses main system prompt */
  private async handleAdminMessage(msg: InboundMessage) {
    const { workerApi, broadcastBloby, getModel } = this.opts;
    const model = getModel();

    // Get or create conversation (shared with chat for mirroring)
    let convId: string | undefined;
    try {
      const ctx = await workerApi('/api/context/current');
      if (ctx.conversationId) {
        convId = ctx.conversationId;
      } else {
        const conv = await workerApi('/api/conversations', 'POST', { title: this.channelLabel(msg.channel), model });
        convId = conv.id;
        await workerApi('/api/context/set', 'POST', { conversationId: convId });
      }
    } catch (err: any) {
      log.warn(`[channels] Failed to get/create conversation: ${err.message}`);
      return;
    }
    // Mirrors handleAlexaInbound's guard: never proceed with an undefined convId
    // (e.g. the conversation API returned no id) — that would push the turn into a
    // broken conversation key and silently drop the reply.
    if (!convId) {
      log.warn('[channels] No conversation id resolved — dropping inbound message');
      return;
    }

    // Use display text for DB/chat (hides enriched agent context from the UI).
    // Prepend the channel tag so the UI can detect the source for icons.
    const rawDisplay = msg.displayText || msg.text;
    const earlyRoleTag = msg.senderName && msg.role === 'assistant'
      ? `${msg.role} | ${msg.senderName}` : msg.role;
    const channelTag = `[${this.channelLabel(msg.channel)} | ${msg.sender} | ${earlyRoleTag}]\n`;
    const displayContent = channelTag + rawDisplay;

    // Save user message to DB
    try {
      await workerApi(`/api/conversations/${convId}/messages`, 'POST', {
        role: 'user',
        content: displayContent,
        meta: { model, channel: msg.channel },
      });
    } catch (err: any) {
      log.warn(`[channels] DB persist error: ${err.message}`);
    }

    // Broadcast to chat clients (mirroring)
    broadcastBloby('chat:sync', {
      conversationId: convId,
      message: { role: 'user', content: displayContent, timestamp: new Date().toISOString() },
    });

    // Fetch names and recent messages
    let botName = 'Bloby', humanName = 'Human';
    let recentMessages: RecentMessage[] = [];
    try {
      const [status, recentRaw] = await Promise.all([
        workerApi('/api/onboard/status'),
        workerApi(`/api/conversations/${convId}/messages/recent?limit=20`),
      ]);
      botName = status.agentName || 'Bloby';
      humanName = status.userName || 'Human';
      if (Array.isArray(recentRaw)) {
        const filtered = recentRaw.filter((m: any) => m.role === 'user' || m.role === 'assistant');
        if (filtered.length > 0) {
          recentMessages = filtered.slice(0, -1).map((m: any) => ({
            role: m.role as 'user' | 'assistant',
            content: m.content,
          }));
        }
      }
    } catch {}

    // Channel context — same tag we already prepended to the stored display content
    const channelContext = channelTag;

    // Convert inbound attachments to agent format
    const agentAttachments: AgentAttachment[] | undefined = msg.attachments?.map((att) => ({
      type: 'image' as const,
      name: `whatsapp_image.${att.mediaType.split('/')[1] || 'jpg'}`,
      mediaType: att.mediaType,
      data: att.data,
    }));
    // Save to disk so providers that consume file paths (Codex → localImage)
    // can see the attachment. Claude consumes raw base64 from `agentAttachments`
    // directly, but the on-disk copy is still useful for the path mention.
    const savedFiles = saveInboundAttachments(agentAttachments);

    // Show "typing..." in the correct chat
    this.startTyping(msg.channel, msg.rawSender);

    // Per-conversation streaming state for WhatsApp routing
    const waState = this.createWaStreamState();

    // Start a live conversation if one doesn't exist (shared with chat UI)
    if (!hasConversation(convId)) {
      log.info(`[channels] Starting live conversation for admin: ${convId}`);

      await startConversation(convId, model, (type, eventData) => {
        // Snapshot the surface BEFORE routeWaStreamEvent consumes the routing target
        // on bot:response — used to decide whether to mirror turn events to chat clients.
        const triggerSurface = this.peekCurrentSurface(convId);
        const isDashboardTurn = !triggerSurface || triggerSurface === 'chat' || triggerSurface === 'workspace';

        // WhatsApp routing — purely queue-driven, no fallback mirror jid. The
        // routing target carries the destination; whatever surface triggered the
        // turn owns the reply.
        this.routeWaStreamEvent(waState, type, eventData, botName);

        // Persist the assistant's reply to the conversation's DB and mirror to
        // the chat-bubble UI via chat:sync (bot:response is suppressed for non-
        // dashboard turns, so the chat would otherwise stay empty until refresh).
        if (type === 'bot:response' && eventData.content) {
          workerApi(`/api/conversations/${convId}/messages`, 'POST', {
            role: 'assistant',
            content: eventData.content,
            meta: { model },
          }).catch(() => {});
          broadcastBloby('chat:sync', {
            conversationId: convId,
            message: { role: 'assistant', content: eventData.content, timestamp: new Date().toISOString() },
          });
        }

        // Handle turn completion — restart backend if file tools were used,
        // and tell every chat client the agent is idle so the typing dots
        // stop. This callback owns the live conversation whenever a WhatsApp
        // self-chat arrives before the dashboard does, so without this signal
        // the dashboard's typing indicator would stay on forever.
        if (type === 'bot:turn-complete') {
          if (eventData.usedFileTools) this.opts.restartBackend();
          this.opts.onTurnComplete?.(); // flush a queued self-update after a channel turn
          broadcastBloby('bot:idle', { conversationId: convId });
          return;
        }

        // Live conversation ended — drop any pending routes so a future
        // conversation under the same convId starts clean.
        if (type === 'bot:conversation-ended') {
          this.clearRoutes(convId);
          this.opts.onTurnComplete?.(); // flush a queued self-update if this turn ended by exception
          return;
        }

        // Suppress turn events (bot:token / bot:response / bot:tool) only for
        // non-dashboard surfaces (WhatsApp/Alexa). Dashboard turns (chat /
        // workspace) — Mac via /bloby/ws is `surface: 'chat'`, the tablet
        // workspace chat is `surface: 'workspace'` — MUST receive bot:response
        // so the Morphy continuation resolves and TTS plays, and so the
        // workspace stops the "Thinking" spinner.
        if (CHAT_TURN_EVENTS.has(type) && !isDashboardTurn) return;
        broadcastBloby(type, eventData);
      }, { botName, humanName }, recentMessages);
    }

    // Push into the live conversation with a pinned WhatsApp routing target.
    // The agent's reply for THIS specific input will go to msg.rawSender — no other
    // surface can hijack it via the FIFO ordering of the shared conversation.
    const channelContent = channelContext + msg.text;
    const isTelegram = msg.channel === 'telegram';
    const target: RoutingTarget = {
      surface: isTelegram ? 'telegram' : 'whatsapp',
      waSendTo: isTelegram ? undefined : msg.rawSender,
      telegramChatId: isTelegram ? msg.rawSender : undefined,
      isGroup: msg.isGroup,
      // Self-chat in 1:1: don't prefix "🤖 Bot:" — it's the user's own chat with themselves.
      isSelfChat: msg.role === 'admin' && !msg.isGroup,
      assistantBufferKey: msg.role === 'assistant' ? `${msg.channel}:${msg.sender}` : undefined,
      inboundKey: msg.inboundKey,
    };
    this.pushWithRouting(convId, target, channelContent, agentAttachments, savedFiles);
  }

  /** Synchronously handle an Alexa utterance: push into the shared conversation,
   *  await the agent's reply, return it. Used by /api/channels/alexa/handle.
   *
   *  Returns the final reply text. Throws on timeout / aborted turn so the HTTP
   *  handler can fall back to "I'll get back to you in chat" (or HA announce).
   */
  async handleAlexaInbound(opts: {
    text: string;
    alexaUserId: string;
    alexaSessionId?: string;
    deviceId?: string;
    locale?: string;
    /** Alexa Directive Service base URL — passed through from the relay. */
    apiEndpoint?: string;
    /** Alexa apiAccessToken for the current request — required to fire Progressive Response. */
    apiAccessToken?: string;
    /** Original Alexa requestId — required to fire Progressive Response. */
    requestId?: string;
    timeoutMs?: number;
  }): Promise<string> {
    const { text, alexaUserId, alexaSessionId, deviceId, locale, apiEndpoint, apiAccessToken, requestId, timeoutMs = 25_000 } = opts;
    const { workerApi, broadcastBloby, getModel } = this.opts;
    const model = getModel();

    const alexa = this.providers.get('alexa') as AlexaChannel | undefined;
    if (!alexa) throw new Error('alexa-channel-not-initialized');

    // Get or create the shared conversation (same one chat + WhatsApp use).
    let convId: string | undefined;
    try {
      const ctx = await workerApi('/api/context/current');
      if (ctx.conversationId) {
        convId = ctx.conversationId;
      } else {
        const conv = await workerApi('/api/conversations', 'POST', { title: 'Alexa', model });
        convId = conv.id;
        await workerApi('/api/context/set', 'POST', { conversationId: convId });
      }
    } catch (err: any) {
      log.warn(`[channels/alexa] Failed to get/create conversation: ${err.message}`);
      throw err;
    }
    if (!convId) throw new Error('no-conversation');

    // Build the channel tag early so we can prepend it to the stored content
    // (lets the UI detect the source for the channel icon).
    const alexaDeviceTag = deviceId ? ` | device=${deviceId.slice(-8)}` : '';
    const alexaSessionTag = alexaSessionId ? ` | session=${alexaSessionId.slice(-6)}` : '';
    const alexaLocaleTag = locale ? ` | ${locale}` : '';
    const alexaChannelTag = `[Alexa | user=${alexaUserId.slice(-8)}${alexaDeviceTag}${alexaSessionTag}${alexaLocaleTag}]\n`;
    const taggedText = alexaChannelTag + text;

    // Persist + mirror to dashboard so the user sees the Alexa utterance in chat.
    workerApi(`/api/conversations/${convId}/messages`, 'POST', {
      role: 'user',
      content: taggedText,
      meta: { model, channel: 'alexa' },
    }).catch((err: any) => log.warn(`[channels/alexa] DB persist error: ${err.message}`));
    broadcastBloby('chat:sync', {
      conversationId: convId,
      message: { role: 'user', content: taggedText, timestamp: new Date().toISOString() },
    });

    // Make sure a live conversation exists (mirrors handleAdminMessage's setup).
    if (!hasConversation(convId)) {
      log.info(`[channels/alexa] Starting live conversation: ${convId}`);
      let botName = 'Bloby', humanName = 'Human';
      let recentMessages: RecentMessage[] = [];
      try {
        const [status, recentRaw] = await Promise.all([
          workerApi('/api/onboard/status'),
          workerApi(`/api/conversations/${convId}/messages/recent?limit=20`),
        ]);
        botName = status.agentName || 'Bloby';
        humanName = status.userName || 'Human';
        if (Array.isArray(recentRaw)) {
          const filtered = recentRaw.filter((m: any) => m.role === 'user' || m.role === 'assistant');
          if (filtered.length > 0) {
            recentMessages = filtered.slice(0, -1).map((m: any) => ({
              role: m.role as 'user' | 'assistant',
              content: m.content,
            }));
          }
        }
      } catch {}

      const waState = this.createWaStreamState();
      await startConversation(convId, model, (type, eventData) => {
        // Snapshot the surface BEFORE routeWaStreamEvent consumes it on bot:response —
        // dashboard turns (chat / workspace / Mac) still need bot:response broadcast.
        const triggerSurface = this.peekCurrentSurface(convId);
        const isDashboardTurn = !triggerSurface || triggerSurface === 'chat' || triggerSurface === 'workspace';

        // Same routing as WhatsApp — alexa surface branch lives inside this method.
        this.routeWaStreamEvent(waState, type, eventData, botName);

        if (type === 'bot:response' && eventData.content) {
          workerApi(`/api/conversations/${convId}/messages`, 'POST', {
            role: 'assistant',
            content: eventData.content,
            meta: { model },
          }).catch(() => {});
          // Mirror the assistant reply to the chat-bubble UI (bot:response is
          // suppressed for non-dashboard turns, so the chat would otherwise stay
          // empty until refresh).
          broadcastBloby('chat:sync', {
            conversationId: convId,
            message: { role: 'assistant', content: eventData.content, timestamp: new Date().toISOString() },
          });
        }

        if (type === 'bot:turn-complete') {
          if (eventData.usedFileTools) this.opts.restartBackend();
          this.opts.onTurnComplete?.(); // flush a queued self-update after a channel turn
          broadcastBloby('bot:idle', { conversationId: convId });
          return;
        }

        if (type === 'bot:conversation-ended') {
          this.clearRoutes(convId);
          this.opts.onTurnComplete?.(); // flush a queued self-update if this turn ended by exception
          return;
        }

        // Dashboard turns (chat / workspace / Mac) need bot:response broadcast so
        // the Morphy continuation resolves and the workspace stops "Thinking".
        // Alexa/WhatsApp turns keep them suppressed (routing FIFO handles delivery).
        if (CHAT_TURN_EVENTS.has(type) && !isDashboardTurn) return;
        broadcastBloby(type, eventData);
      }, { botName, humanName }, recentMessages);
    }

    // Reserve the resolver slot FIRST — the agent may respond very quickly and
    // we don't want the bot:response to arrive before the resolver is in the FIFO.
    // Also pass the Alexa Directive Service credentials so the channel can fire
    // Progressive Response on every preamble chunk the agent emits before tool calls.
    const creds = (apiEndpoint && apiAccessToken && requestId)
      ? { apiEndpoint, apiAccessToken, requestId }
      : null;
    const pending = alexa.reservePending(convId, creds, timeoutMs);

    const target: RoutingTarget = {
      surface: 'alexa',
      isSelfChat: false,
      isGroup: false,
    };
    this.pushWithRouting(convId, target, taggedText);

    return pending; // resolves with the reply text (or rejects on timeout)
  }

  /** Handle message from a customer — runs support agent in parallel with conversation context */
  private async handleCustomerMessage(msg: InboundMessage, channelConfig: ChannelConfig) {
    const agentKey = `${msg.channel}:${msg.sender}`;

    // Check concurrent limit
    if (this.activeAgents.size >= MAX_CONCURRENT_AGENTS && !this.activeAgents.has(agentKey)) {
      log.info(`[channels] Max concurrent agents reached — queuing message from ${msg.sender}`);
      this.messageQueue.push(msg);
      return;
    }

    const { workerApi, getModel } = this.opts;
    const model = getModel();

    // Load the active skill's SCRIPT.md as the customer-facing system prompt
    const scriptPrompt = this.loadActiveScript(channelConfig);

    // Fetch agent name
    let botName = 'Bloby', humanName = 'Human';
    try {
      const status = await workerApi('/api/onboard/status');
      botName = status.agentName || 'Bloby';
      humanName = status.userName || 'Human';
    } catch {}

    // Get or create conversation buffer for this customer
    let buffer = this.customerBuffers.get(agentKey);
    if (!buffer) {
      buffer = [];
      this.customerBuffers.set(agentKey, buffer);
    }

    // Add the new user message to the buffer
    buffer.push({ role: 'user', content: msg.text });

    // Trim buffer to max size
    if (buffer.length > MAX_BUFFER_MESSAGES) {
      buffer.splice(0, buffer.length - MAX_BUFFER_MESSAGES);
    }

    // Build recent messages for context (everything except the last one, which is the current message)
    const recentMessages: RecentMessage[] = buffer.length > 1
      ? buffer.slice(0, -1).map((m) => ({ role: m.role, content: m.content }))
      : [];

    // Load long-term memory from the skill's customer_data directory
    let customerMemory = '';
    try {
      const customerDataDir = this.getSkillCustomerDataDir(channelConfig);
      if (customerDataDir) {
        const memoryPath = path.join(WORKSPACE_DIR, customerDataDir, `${msg.sender}.md`);
        if (fs.existsSync(memoryPath)) {
          customerMemory = fs.readFileSync(memoryPath, 'utf-8').trim();
        }
      }
    } catch {}

    const channelContext = `[${this.channelLabel(msg.channel)} | ${msg.sender} | customer${msg.senderName ? ` | ${msg.senderName}` : ''}]\n`;

    // Convert inbound attachments to agent format
    const agentAttachments: AgentAttachment[] | undefined = msg.attachments?.map((att) => ({
      type: 'image' as const,
      name: `whatsapp_image.${att.mediaType.split('/')[1] || 'jpg'}`,
      mediaType: att.mediaType,
      data: att.data,
    }));
    const savedFiles = saveInboundAttachments(agentAttachments);

    // Stable convId per customer (not per message)
    const convId = `channel-${agentKey}`;

    this.activeAgents.set(agentKey, { sender: msg.sender, channel: msg.channel });

    // Show "typing..." while the agent processes
    this.startTyping(msg.channel, msg.rawSender);

    // Build an enriched script prompt with customer memory if available
    let enrichedScript = scriptPrompt;
    if (customerMemory && enrichedScript) {
      enrichedScript += `\n\n---\n# Customer History (${msg.sender})\n\n${customerMemory}`;
    }

    // Track text chunks for WhatsApp — send intermediate chunks when agent pauses for tool use
    let waChunkBuf = '';

    startBlobyAgentQuery(
      convId,
      channelContext + msg.text,
      model,
      (type, eventData) => {
        // Accumulate text tokens
        if (type === 'bot:token' && eventData.token) {
          waChunkBuf += eventData.token;
        }

        // Agent paused to use a tool — send accumulated text as an intermediate WhatsApp message
        if (type === 'bot:tool' && waChunkBuf.trim()) {
          this.sendMessage(msg.channel, msg.rawSender, waChunkBuf.trim()).catch((err) => {
            log.warn(`[channels] Failed to send WhatsApp chunk: ${err.message}`);
          });
          waChunkBuf = '';
        }

        if (type === 'bot:response' && eventData.content) {
          // Add full response to the conversation buffer
          buffer!.push({ role: 'assistant', content: eventData.content });
          if (buffer!.length > MAX_BUFFER_MESSAGES) {
            buffer!.splice(0, buffer!.length - MAX_BUFFER_MESSAGES);
          }

          // Send remaining text after the last tool use (or full response if no tools were used)
          const remaining = waChunkBuf.trim();
          if (remaining) {
            this.sendMessage(msg.channel, msg.rawSender, remaining).catch((err) => {
              log.warn(`[channels] Failed to send customer reply: ${err.message}`);
            });
            waChunkBuf = '';
          }
        }

        if (type === 'bot:done') {
          this.activeAgents.delete(agentKey);
          if (eventData.usedFileTools) this.opts.restartBackend();
          this.opts.onTurnComplete?.(); // flush a queued self-update after a channel turn
          this.processQueue();
        }
      },
      agentAttachments,
      savedFiles,
      { botName, humanName },
      recentMessages,
      enrichedScript,
    );
  }

  /** Store a message in the assistant context buffer (for conversation history when triggered).
   * Keyed by the chat (not the sender) so groups accumulate one shared buffer per group.
   */
  private storeAssistantContext(
    channel: ChannelType,
    chatJid: string,
    senderName: string | undefined,
    text: string,
    fromMe: boolean,
  ) {
    const chatId = chatJid.replace(/@.*/, '');
    const bufferKey = `${channel}:${chatId}`;
    let buffer = this.customerBuffers.get(bufferKey);
    if (!buffer) {
      buffer = [];
      this.customerBuffers.set(bufferKey, buffer);
    }
    const label = fromMe ? 'me' : (senderName || chatId);
    buffer.push({ role: 'user', content: `[${label}]: ${text}` });
    if (buffer.length > MAX_BUFFER_MESSAGES) {
      buffer.splice(0, buffer.length - MAX_BUFFER_MESSAGES);
    }
    log.info(`[channels] Assistant context stored: ${bufferKey} | ${buffer.length} msgs | [${label}]: "${text.slice(0, 60)}"`);
  }


  /** Transcribe audio via the existing whisper endpoint */
  private async transcribeAudio(audioBase64: string): Promise<string | null> {
    try {
      const result = await this.opts.workerApi('/api/whisper/transcribe', 'POST', { audio: audioBase64 });
      if (result.error) {
        log.warn(`[channels] Whisper error: ${result.error}`);
        return null;
      }
      return result.transcript || null;
    } catch (err: any) {
      log.warn(`[channels] Whisper transcription failed: ${err.message}`);
      return null;
    }
  }

  /** Read customer_data directory from a skill's skill.json */
  private getSkillCustomerDataDir(channelConfig: ChannelConfig): string | undefined {
    const skillName = channelConfig.skill;
    if (!skillName) return undefined;

    try {
      const skillJsonPath = path.join(WORKSPACE_DIR, 'skills', skillName, 'skill.json');
      if (fs.existsSync(skillJsonPath)) {
        const skillJson = JSON.parse(fs.readFileSync(skillJsonPath, 'utf-8'));
        if (skillJson.customer_data) return skillJson.customer_data;
      }
    } catch {}
    return undefined;
  }

  /** Load SCRIPT.md from the active skill configured for this channel */
  private loadActiveScript(channelConfig: ChannelConfig): string | undefined {
    const skillName = channelConfig.skill;
    if (!skillName) {
      log.warn('[channels] No active skill configured — customer will get no script');
      return undefined;
    }

    const scriptPath = path.join(WORKSPACE_DIR, 'skills', skillName, 'SCRIPT.md');
    try {
      if (fs.existsSync(scriptPath)) {
        const content = fs.readFileSync(scriptPath, 'utf-8').trim();
        if (content) {
          log.info(`[channels] Loaded SCRIPT.md from skill: ${skillName}`);
          return content;
        }
      }
      log.warn(`[channels] SCRIPT.md not found in skill: ${skillName}`);
    } catch (err: any) {
      log.warn(`[channels] Failed to load SCRIPT.md from ${skillName}: ${err.message}`);
    }
    return undefined;
  }

  /** Process queued messages when an agent slot frees up */
  private processQueue() {
    while (this.messageQueue.length > 0 && this.activeAgents.size < MAX_CONCURRENT_AGENTS) {
      const queued = this.messageQueue.shift()!;
      const config = this.getChannelConfig(queued.channel);
      if (!config) continue;
      log.info(`[channels] Processing queued message from ${queued.sender}`);
      this.handleCustomerMessage(queued, config);
    }
  }
}
