/**
 * TelegramService — Conversational agent interface over Telegram.
 *
 * Public surface (unchanged):
 *   getTelegramService(logger) → singleton
 *   .setOrchestrator() .setAgentPool() .setWebSocketManager() .setFlowExecutor()
 *   .autoConnect() / .connect(botToken) / .disconnect()
 *   .getStatus() / .sendTestMessage()
 *   .getBridgedChannels(agentId) / .isAgentBridged(agentId)
 *
 * Capabilities (UX-focused):
 *   • Multi-chat — any number of private chats can /start the bot; the
 *     first-chat-wins lock is gone. Each chat keeps its own sticky agent
 *     + watch state. (`chats: Map<chatId, ChatState>`)
 *   • Group chat — bots in Telegram groups respond ONLY when @mentioned
 *     by their username (Telegram convention; prevents bot spam).
 *   • Voice notes — downloaded, base64-posted to the backend's
 *     /llm/transcribe endpoint (which proxies to whichever Azure AI
 *     Foundry speech-to-text deployment is in the model catalog —
 *     gpt-4o-mini-transcribe by default), then handled as text. Falls
 *     back to a friendly "voice not configured" message when the
 *     backend route returns 503.
 *   • Typing indicator + "thinking" placeholder — emitted before the
 *     orchestrator call so the user sees instant feedback; replaced
 *     with the real reply via editMessageText.
 *   • Rich reply blocks via `<actions>` / `<image>` / `<attachment>`
 *     tags inside `<external>` — see telegramBlockParser.js.
 *   • Errors and timeouts always relayed (not gated by /watch).
 *   • Prompt-request timeout sends a "your prompt expired" message
 *     instead of silently dropping the request.
 *   • Paginated `/agents` and `/flows` for large pools.
 *   • Long code-only replies are sent as a `.txt` document so
 *     Telegram's MarkdownV2 escaping never mangles them.
 *
 * Config persistence (backward-compatible):
 *   Old: `{ chatId, watchEnabled }` (scalar)
 *   New: `{ chats: [{ chatId, addedAt, watchEnabled, lastAgentId }], … }`
 *   On load, a legacy scalar `chatId` is migrated to a single-entry
 *   chats array. Writes always emit the new shape; the legacy field
 *   is dropped silently.
 */

import { promises as fs } from 'fs';
import path from 'path';
import { getUserDataPaths, ensureUserDataDirs } from '../utilities/userDataDir.js';
import { filterContentForExternalRelay, resolveBlockTargets } from './channelFilter.js';
import { createTelegramSource } from './messageSource.js';
import { parseTelegramBlock } from './telegramBlockParser.js';

const TELEGRAM_STATUS = {
  DISCONNECTED: 'disconnected',
  CONNECTING:   'connecting',
  CONNECTED:    'connected',
  FAILED:       'failed',
};

const MAX_MESSAGE_LENGTH = 4000;            // Telegram limit is 4096 — leave room for headers
const NOTIFICATION_BATCH_INTERVAL_MS = 10000;
const PROMPT_REMINDER_MS = 180000;          // 3 min — "still waiting" nudge
const PROMPT_TIMEOUT_MS  = 600000;          // 10 min — hard timeout, send cancel
const PAGE_SIZE = 8;                        // /agents and /flows page size
const CODE_AS_FILE_THRESHOLD = 3200;        // chars; above this, send code as .txt
const IMAGE_PENDING_TTL_MS = 5 * 60 * 1000; // 5 min — buffer window for imageGenerated → stream_complete

class TelegramService {
  constructor(logger = null) {
    this.logger = logger;

    // Dependencies (set via setters)
    this.orchestrator = null;
    this.agentPool = null;
    this.webSocketManager = null;
    this.flowExecutor = null;

    // Bot state
    this.bot = null;
    this.status = TELEGRAM_STATUS.DISCONNECTED;
    this.botUsername = null;                // populated by getMe() at connect

    /**
     * Per-chat state. Keyed by chatId (string). Each value:
     *   {
     *     type: 'private' | 'group' | 'supergroup' | 'channel',
     *     title?: string,
     *     addedAt: ISO string,
     *     watchEnabled: boolean,
     *     lastAgentId: string|null,
     *     activeAgentIds: Set<string>,
     *     // Tracking for in-flight "🔄 Thinking" placeholders:
     *     thinkingByAgentId: Map<agentId, messageId>,
     *   }
     */
    this.chats = new Map();

    // Relay state (global — keyed by requestId)
    this.pendingRelays = new Map();         // requestId → { type, agentId, chatId, timeoutId, reminderId }
    this.replyContext = new Map();          // chatId → { type, requestId, agentId }

    // Notifications (batched per-chat)
    this.notificationQueue = new Map();     // chatId → string[]
    this.notificationTimers = new Map();    // chatId → timeout handle

    // Config
    this.dataDir = null;
    this.configPath = null;
    this.config = {};

    // Backend (for transcription) — falls back to the same default
    // the rest of the CLI uses. webServer.js sets this via
    // setBackendBaseUrl().
    this.backendBaseUrl = 'https://autopilot-api.azurewebsites.net';
    // Platform API key resolver. May be set as either:
    //   a) a function () => string|null  (preferred — re-resolves on
    //      every call, so signin/signout after boot is handled
    //      automatically)
    //   b) a plain string (legacy — captured once)
    // Both are exposed through `_resolvePlatformKey()` below.
    this._platformApiKey = null;

    // Original broadcast (saved before wrapping)
    this._originalBroadcast = null;

    /**
     * Pending generated images, keyed by agentId. The imageTool emits
     * its own `imageGenerated` broadcast independently of the agent's
     * text completion; we buffer those URLs here and flush them in
     * `_relayAgentResponse` so the image reaches Telegram even if the
     * agent's final reply text doesn't embed the image markdown.
     *
     * Shape: Map<agentId, Array<{ imageUrl, prompt, addedAt }>>
     *
     * Entries older than IMAGE_PENDING_TTL_MS are pruned at flush time
     * — guards against a stranded image surfacing later in an unrelated
     * conversation turn. The TTL is long enough for slow generations
     * (DALL-E 3, video previews) but short enough to keep the buffer
     * from accumulating across hours of idle.
     */
    this._pendingImagesByAgent = new Map();
  }

  // ── Dependency Injection ───────────────────────────────────────────

  setOrchestrator(orchestrator) { this.orchestrator = orchestrator; }
  setAgentPool(agentPool) { this.agentPool = agentPool; }
  setWebSocketManager(wsManager) {
    this.webSocketManager = wsManager;
    this._interceptBroadcasts(wsManager);
  }
  setFlowExecutor(flowExecutor) { this.flowExecutor = flowExecutor; }
  setBackendBaseUrl(url) { if (url) this.backendBaseUrl = String(url).replace(/\/$/, ''); }
  /** Accept a string OR a () => string|null getter. The getter form is
   *  preferred so a sign-in / sign-out cycle after boot is reflected
   *  without re-wiring. */
  setPlatformApiKey(keyOrGetter) { this._platformApiKey = keyOrGetter || null; }
  _resolvePlatformKey() {
    const k = this._platformApiKey;
    if (typeof k === 'function') {
      try { return k() || null; } catch { return null; }
    }
    return k || null;
  }

  // ── Config Persistence ─────────────────────────────────────────────

  async _ensureDataDir() {
    if (!this.dataDir) {
      await ensureUserDataDirs();
      const paths = getUserDataPaths();
      this.dataDir = path.join(paths.base, 'telegram');
      this.configPath = path.join(this.dataDir, 'telegram-config.json');
      await fs.mkdir(this.dataDir, { recursive: true });
    }
  }

  async _loadConfig() {
    await this._ensureDataDir();
    try {
      const data = await fs.readFile(this.configPath, 'utf8');
      this.config = JSON.parse(data);
    } catch {
      this.config = {};
    }

    // Migration: legacy `{ chatId, watchEnabled }` → `{ chats: [...] }`.
    // We intentionally do NOT delete the legacy field from this.config
    // until the next save so a hand-edited file roundtrips cleanly.
    if (this.config.chatId && !Array.isArray(this.config.chats)) {
      this.config.chats = [{
        chatId: String(this.config.chatId),
        type: 'private',
        title: null,
        addedAt: this.config.updatedAt || new Date().toISOString(),
        watchEnabled: !!this.config.watchEnabled,
        lastAgentId: null,
      }];
    }

    this.chats = new Map();
    for (const entry of (this.config.chats || [])) {
      if (!entry?.chatId) continue;
      this.chats.set(String(entry.chatId), {
        type: entry.type || 'private',
        title: entry.title || null,
        addedAt: entry.addedAt || new Date().toISOString(),
        watchEnabled: !!entry.watchEnabled,
        lastAgentId: entry.lastAgentId || null,
        activeAgentIds: new Set(entry.activeAgentIds || []),
        thinkingByAgentId: new Map(),
      });
    }
  }

  async _saveConfig() {
    await this._ensureDataDir();
    // Re-shape in-memory state for persistence.
    this.config.chats = [...this.chats.entries()].map(([chatId, s]) => ({
      chatId,
      type: s.type,
      title: s.title,
      addedAt: s.addedAt,
      watchEnabled: s.watchEnabled,
      lastAgentId: s.lastAgentId,
      activeAgentIds: [...s.activeAgentIds],
    }));
    delete this.config.chatId;
    delete this.config.watchEnabled;
    this.config.updatedAt = new Date().toISOString();
    await fs.writeFile(this.configPath, JSON.stringify(this.config, null, 2), 'utf8');
  }

  // ── Lifecycle ──────────────────────────────────────────────────────

  async autoConnect() {
    await this._loadConfig();
    if (this.config.botToken) {
      try {
        await this.connect(this.config.botToken);
      } catch (error) {
        this.logger?.warn('[TelegramService] Auto-connect failed', { error: error.message });
      }
    }
  }

  async connect(botToken) {
    if (this.status === TELEGRAM_STATUS.CONNECTED) {
      await this.disconnect();
    }
    this.status = TELEGRAM_STATUS.CONNECTING;
    this.logger?.info('[TelegramService] Connecting...');

    try {
      const TelegramBot = (await import('node-telegram-bot-api')).default;
      this.bot = new TelegramBot(botToken, { polling: true });

      const me = await this.bot.getMe();
      this.botUsername = me.username || null;
      this.logger?.info('[TelegramService] Connected', { botName: this.botUsername });

      this.config.botToken = botToken;
      this.config.botUsername = this.botUsername;
      await this._saveConfig();

      this._setupHandlers();
      this.status = TELEGRAM_STATUS.CONNECTED;

      return { username: this.botUsername, id: me.id };
    } catch (error) {
      this.status = TELEGRAM_STATUS.FAILED;
      this.logger?.error('[TelegramService] Connection failed', { error: error.message });
      throw error;
    }
  }

  async disconnect() {
    if (this.bot) {
      try { await this.bot.stopPolling(); } catch { /* ignore */ }
      this.bot = null;
    }
    this.status = TELEGRAM_STATUS.DISCONNECTED;
    for (const t of this.notificationTimers.values()) clearTimeout(t);
    this.notificationTimers.clear();
    this.notificationQueue.clear();
    this.logger?.info('[TelegramService] Disconnected');
  }

  getStatus() {
    return {
      status: this.status,
      connected: this.status === TELEGRAM_STATUS.CONNECTED,
      botUsername: this.botUsername,
      chatCount: this.chats.size,
      // Surface the legacy `chatId` as the first chat for UI back-compat;
      // any new UI should switch to `chats`.
      chatId: this.chats.size > 0 ? [...this.chats.keys()][0] : null,
      chats: [...this.chats.entries()].map(([chatId, s]) => ({
        chatId,
        type: s.type,
        title: s.title,
        watchEnabled: s.watchEnabled,
      })),
      watchEnabled: [...this.chats.values()].some(s => s.watchEnabled),
    };
  }

  // ── Command & Message Handlers ─────────────────────────────────────

  _setupHandlers() {
    if (!this.bot) return;

    this.bot.onText(/^\/start(@\w+)?(\s|$)/,            (msg) => this._cmdStart(msg));
    this.bot.onText(/^\/help(@\w+)?(\s|$)/,             (msg) => this._cmdHelp(msg));
    this.bot.onText(/^\/status(@\w+)?(\s|$)/,           (msg) => this._cmdStatus(msg));
    this.bot.onText(/^\/agents(@\w+)?(\s|$)/,           (msg) => this._cmdAgents(msg, 0));
    this.bot.onText(/^\/agent(@\w+)?\s+(.+)/,           (msg, m) => this._cmdAgentDetail(msg, m[2].trim()));
    this.bot.onText(/^\/flows(@\w+)?(\s|$)/,            (msg) => this._cmdFlows(msg, 0));
    this.bot.onText(/^\/run(@\w+)?\s+(.+)/,             (msg, m) => this._cmdRunFlow(msg, m[2].trim()));
    this.bot.onText(/^\/stop(@\w+)?\s+(.+)/,            (msg, m) => this._cmdStopAgent(msg, m[2].trim()));
    this.bot.onText(/^\/following(@\w+)?(\s|$)/,        (msg) => this._cmdFollowing(msg));
    this.bot.onText(/^\/unfollow(@\w+)?\s+(.+)/,        (msg, m) => this._cmdUnfollow(msg, m[2].trim()));
    this.bot.onText(/^\/watch(@\w+)?(\s|$)/,            (msg) => this._cmdWatch(msg));
    this.bot.onText(/^\/unwatch(@\w+)?(\s|$)/,          (msg) => this._cmdUnwatch(msg));
    this.bot.onText(/^\/watching(@\w+)?(\s|$)/,         (msg) => this._cmdWatching(msg));
    this.bot.onText(/^\/reset(@\w+)?(\s|$)/,            (msg) => this._cmdReset(msg));
    this.bot.onText(/^\/new(@\w+)?\s+(.+)/,             (msg, m) => this._cmdNew(msg, m[2].trim()));
    this.bot.onText(/^\/logout(@\w+)?(\s|$)/,           (msg) => this._cmdLogout(msg));

    // Generic message handler — branches on type.
    this.bot.on('message', (msg) => this._onMessage(msg));

    // Inline-keyboard callbacks.
    this.bot.on('callback_query', (q) => this._handleCallbackQuery(q));
  }

  async _onMessage(msg) {
    // Skip slash commands; their handlers fire via onText.
    if (typeof msg.text === 'string' && msg.text.startsWith('/')) return;

    if (msg.voice) {
      await this._handleVoiceMessage(msg);
      return;
    }
    if (msg.text) {
      await this._handleTextMessage(msg);
      return;
    }
    // Other media types (photos, documents, stickers) are intentionally
    // ignored for now — agents can't yet consume them. Future work:
    // forward image_url to a vision-capable agent.
  }

  _isAuthorized(msg) {
    const chatId = String(msg.chat.id);
    return this.chats.has(chatId);
  }

  /**
   * Parse `@agent-name ...` (or just `agent-name ...`) at the start of
   * a message, doing longest-prefix matching against the live agent
   * list so names with spaces work without quoting.
   *
   * Accepts:
   *   "@Cody  hello"        → { agentName: 'Cody', matchedAgent: <agent>, messageText: 'hello' }
   *   "@John Smith report"  → { agentName: 'John Smith', matchedAgent: <agent>, messageText: 'report' }
   *   "@John_Smith report"  → same — underscore + dash are accepted as a
   *                           space-substitute for slug-style addressing
   *   "no prefix"           → { agentName: null, matchedAgent: null, messageText: 'no prefix' }
   *   "@Unknown msg"        → { agentName: 'Unknown', matchedAgent: null, messageText: 'msg' }
   *                           (caller emits a friendly "not found" reply)
   *
   * Algorithm:
   *   1. Strip leading `@` if present. Remember whether one was there.
   *   2. For each agent, see if the remaining text starts with the
   *      agent's name (case-insensitive). Try TWO normalisations:
   *        a) literal name with all spaces
   *        b) name with spaces replaced by [ _\-]+ (slug form)
   *      Treat a match as valid only when followed by whitespace or
   *      end-of-string (so "@John" doesn't accidentally match "Johnson").
   *   3. Among valid matches, pick the LONGEST agent name — handles
   *      the "@John Smith" vs "@John" ambiguity correctly.
   *   4. If no agent name matched but there WAS an `@` prefix, fall
   *      back to the legacy single-token capture so the caller can
   *      surface a "not found" error with the user-typed text.
   *
   * @param {string} text
   * @param {Array<{id:string, name:string}>} agents
   * @returns {{ agentName: string|null, matchedAgent: object|null, messageText: string }}
   */
  _resolveAtPrefix(text, agents) {
    if (typeof text !== 'string' || text.length === 0) {
      return { agentName: null, matchedAgent: null, messageText: text || '' };
    }

    const hasAt = text.startsWith('@');
    const body = hasAt ? text.slice(1) : text;

    // Try to find the longest agent name that the body starts with.
    let best = null;   // { agent, matchedRaw, restStart }
    for (const agent of agents || []) {
      const name = String(agent?.name || '');
      if (!name) continue;
      // Build a regex that matches either the literal name or its slug
      // variant (spaces → any of `[ _\-]+`). Case-insensitive. Must be
      // followed by whitespace or end-of-string so partial-word
      // collisions don't false-match.
      const slugPattern = name
        .replace(/[.*+?^${}()|[\]\\]/g, '\\$&')   // escape regex metas
        .replace(/\s+/g, '[ _\\-]+');               // any space substitute
      const re = new RegExp(`^(${slugPattern})(?:\\s|$)`, 'i');
      const m = body.match(re);
      if (m) {
        if (!best || m[1].length > best.matchedRaw.length) {
          best = { agent, matchedRaw: m[1], restStart: m[1].length };
        }
      }
    }

    if (best) {
      const rest = body.slice(best.restStart).replace(/^\s+/, '');
      return {
        agentName: best.agent.name,
        matchedAgent: best.agent,
        messageText: rest,
      };
    }

    if (!hasAt) {
      return { agentName: null, matchedAgent: null, messageText: text };
    }

    // No agent matched but the user clearly intended an @-prefix
    // (typo, agent renamed, etc.). Fall back to the legacy "first
    // whitespace-free token after @" capture so the caller can quote
    // the user-typed name in the "not found" reply.
    const legacy = body.match(/^(\S+)(?:\s+([\s\S]+))?/);
    if (legacy) {
      return {
        agentName: legacy[1],
        matchedAgent: null,
        messageText: (legacy[2] || '').trim(),
      };
    }
    return { agentName: null, matchedAgent: null, messageText: text };
  }

  /**
   * In a group chat, the bot should only respond to a text message
   * when @-mentioned. This prevents bot spam in shared channels.
   * Returns the message text with the @mention stripped when matched.
   * In private chats, the text is returned unchanged.
   */
  _stripMentionOrSkip(msg) {
    const isGroup = msg.chat.type === 'group' || msg.chat.type === 'supergroup';
    const text = (msg.text || msg.caption || '').trim();
    if (!isGroup) return { addressed: true, text };
    if (!this.botUsername) return { addressed: false, text };

    const mention = `@${this.botUsername.toLowerCase()}`;
    const lower = text.toLowerCase();
    if (lower.includes(mention)) {
      const stripped = text.replace(new RegExp(`@${this.botUsername}\\b`, 'gi'), '').replace(/\s+/g, ' ').trim();
      return { addressed: true, text: stripped };
    }
    return { addressed: false, text };
  }

  // ── Commands ───────────────────────────────────────────────────────

  async _cmdStart(msg) {
    const chatId = String(msg.chat.id);
    const type = msg.chat.type || 'private';
    const title = msg.chat.title || null;

    if (!this.chats.has(chatId)) {
      this.chats.set(chatId, {
        type, title,
        addedAt: new Date().toISOString(),
        watchEnabled: false,
        lastAgentId: null,
        activeAgentIds: new Set(),
        thinkingByAgentId: new Map(),
      });
      await this._saveConfig();
      const greeting = type === 'private'
        ? this._md`*Loxia Autopilot connected\\!* 🚀\n\nThis chat is now linked\\. Use /help for commands\\.\n\nAddress agents with \`@agent\\-name your message\` or just start typing once you’ve picked one\\.`
        : this._md`*Loxia Autopilot connected\\!* 🚀\n\nGroup registered: *${title || chatId}*\\. Mention me with @${this.botUsername || 'loxia\\_bot'} to address an agent\\.`;
      await this._send(chatId, greeting);
    } else {
      await this._send(chatId, this._md`Already connected\\. Use /help for commands\\.`);
    }
  }

  async _cmdHelp(msg) {
    if (!this._isAuthorized(msg)) return;
    // Hand-written MarkdownV2. Special chars (`.`, `-`, `!`, `(`, `)`,
    // `>`) MUST be escaped per Telegram's MarkdownV2 spec. The
    // _escapeMarkdown wrapper that used to wrap this whole thing
    // turned every `*` and backtick into literal text — that's why
    // the help message displayed asterisks instead of bold.
    const help =
`*Loxia Autopilot — Telegram*

*Chat with agents:*
\`@agent\\-name message\` — send to a specific agent
Names with spaces work too: \`@John Smith hello\` or \`@John\\_Smith hello\`
Type without prefix — sends to your last\\-used agent

*Commands:*
/agents — list all agents \\(paginated\\)
/agent <name> — agent detail card
/status — system overview
/following — agents you have spoken with from this chat
/unfollow <name> — stop receiving replies from an agent
/flows — list flows \\(paginated\\)
/run <flow> — start a flow
/stop <agent> — halt agent execution
/reset — clear the conversation with your active agent
/new <agent> — switch to a different agent with fresh context
/watch — subscribe to event notifications
/unwatch — unsubscribe
/watching — show notification status
/logout — disconnect this chat from Loxia
/help — this message

_Voice notes are transcribed automatically\\._`;
    await this._send(msg.chat.id, help);
  }

  async _cmdStatus(msg) {
    if (!this._isAuthorized(msg)) return;
    try {
      const agents = this.agentPool ? await this.agentPool.getAllAgents() : [];
      const active = agents.filter(a => a.status === 'active' || a.mode === 'auto');
      const idle = agents.filter(a => a.mode === 'chat' || a.status === 'idle');
      const state = this._chatState(msg);
      const watch = state?.watchEnabled ? '🔔 On' : '🔕 Off';

      const text = [
        '*System Status*',
        '',
        `Agents: ${agents.length} total, ${active.length} active, ${idle.length} idle`,
        `Notifications: ${watch}`,
        `Linked chats: ${this.chats.size}`,
      ].join('\n');
      await this._send(msg.chat.id, this._escapeMarkdown(text));
    } catch (error) {
      await this._send(msg.chat.id, this._escapeMarkdown(`❌ Error: ${error.message}`));
    }
  }

  async _cmdAgents(msg, page = 0) {
    if (!this._isAuthorized(msg)) return;
    try {
      const agents = this.agentPool ? await this.agentPool.getAllAgents() : [];
      if (agents.length === 0) {
        await this._send(msg.chat.id, this._escapeMarkdown('No agents loaded.'));
        return;
      }
      await this._renderAgentsPage(msg.chat.id, agents, page);
    } catch (error) {
      await this._send(msg.chat.id, this._escapeMarkdown(`❌ Error: ${error.message}`));
    }
  }

  async _renderAgentsPage(chatId, agents, page) {
    const pages = Math.max(1, Math.ceil(agents.length / PAGE_SIZE));
    const safePage = Math.min(Math.max(0, page), pages - 1);
    const slice = agents.slice(safePage * PAGE_SIZE, (safePage + 1) * PAGE_SIZE);

    let text = `*Agents (${agents.length})* — page ${safePage + 1}/${pages}\n\n`;
    const buttons = [];
    for (const agent of slice) {
      const dot = agent.mode === 'auto' ? '🟢' : agent.status === 'active' ? '🟡' : '⚪';
      const mode = agent.mode === 'auto' ? 'autonomous' : 'chat';
      text += `${dot} *${this._escapeMarkdown(agent.name)}* — ${mode}\n`;
      buttons.push([{ text: agent.name, callback_data: `agent_detail:${agent.id}` }]);
    }
    if (pages > 1) {
      const nav = [];
      if (safePage > 0)        nav.push({ text: '‹ Prev', callback_data: `agents_page:${safePage - 1}` });
      nav.push({ text: `${safePage + 1}/${pages}`, callback_data: 'noop' });
      if (safePage < pages - 1) nav.push({ text: 'Next ›', callback_data: `agents_page:${safePage + 1}` });
      buttons.push(nav);
    }
    await this._send(chatId, text, { reply_markup: { inline_keyboard: buttons } });
  }

  async _cmdAgentDetail(msg, agentName) {
    if (!this._isAuthorized(msg)) return;
    await this._showAgentDetail(msg.chat.id, agentName);
  }

  async _showAgentDetail(chatId, agentNameOrId) {
    try {
      const agents = this.agentPool ? await this.agentPool.getAllAgents() : [];
      const agent = agents.find(a =>
        a.name.toLowerCase() === agentNameOrId.toLowerCase() ||
        a.id === agentNameOrId
      );
      if (!agent) {
        await this._send(chatId, this._escapeMarkdown(`Agent "${agentNameOrId}" not found.`));
        return;
      }
      const status = agent.mode === 'auto' ? '🟢 Autonomous' : '🟡 Chat';
      let text = `*${this._escapeMarkdown(agent.name)}*\n\n`;
      text += `Status: ${status}\n`;
      text += `Model: \`${this._escapeMarkdown(agent.currentModel || 'unknown')}\`\n`;
      if (agent.lastActivity) {
        text += `Last active: ${new Date(agent.lastActivity).toLocaleTimeString()}\n`;
      }
      const buttons = [[
        { text: '💬 Send Message', callback_data: `msg_agent:${agent.id}` },
        { text: '⏹ Stop',          callback_data: `stop_agent:${agent.id}` },
      ]];
      await this._send(chatId, text, { reply_markup: { inline_keyboard: buttons } });
    } catch (error) {
      await this._send(chatId, this._escapeMarkdown(`❌ Error: ${error.message}`));
    }
  }

  async _cmdFlows(msg, page = 0) {
    if (!this._isAuthorized(msg)) return;
    try {
      if (!this.orchestrator?.stateManager) {
        await this._send(msg.chat.id, this._escapeMarkdown('Flows not available.'));
        return;
      }
      const projectDir = this.orchestrator.config?.project?.directory || process.cwd();
      const flowIndex = await this.orchestrator.stateManager.loadFlowIndex?.(projectDir) || {};
      const flows = Object.entries(flowIndex);
      if (flows.length === 0) {
        await this._send(msg.chat.id, this._escapeMarkdown('No flows defined.'));
        return;
      }
      await this._renderFlowsPage(msg.chat.id, flows, page);
    } catch (error) {
      await this._send(msg.chat.id, this._escapeMarkdown(`❌ Error: ${error.message}`));
    }
  }

  async _renderFlowsPage(chatId, flows, page) {
    const pages = Math.max(1, Math.ceil(flows.length / PAGE_SIZE));
    const safePage = Math.min(Math.max(0, page), pages - 1);
    const slice = flows.slice(safePage * PAGE_SIZE, (safePage + 1) * PAGE_SIZE);
    let text = `*Flows (${flows.length})* — page ${safePage + 1}/${pages}\n\n`;
    const buttons = [];
    for (const [id, flow] of slice) {
      text += `📋 *${this._escapeMarkdown(flow.name || id)}*\n`;
      buttons.push([{ text: `▶️ Run ${flow.name || id}`, callback_data: `run_flow:${id}` }]);
    }
    if (pages > 1) {
      const nav = [];
      if (safePage > 0)        nav.push({ text: '‹ Prev', callback_data: `flows_page:${safePage - 1}` });
      nav.push({ text: `${safePage + 1}/${pages}`, callback_data: 'noop' });
      if (safePage < pages - 1) nav.push({ text: 'Next ›', callback_data: `flows_page:${safePage + 1}` });
      buttons.push(nav);
    }
    await this._send(chatId, text, { reply_markup: { inline_keyboard: buttons } });
  }

  async _cmdRunFlow(msg, flowName) {
    if (!this._isAuthorized(msg)) return;
    await this._runFlow(msg.chat.id, flowName);
  }

  async _runFlow(chatId, flowNameOrId) {
    try {
      if (!this.flowExecutor) {
        await this._send(chatId, this._escapeMarkdown('Flow executor not available.'));
        return;
      }
      await this._send(chatId, this._escapeMarkdown(`▶️ Starting flow: ${flowNameOrId}...`));
      const projectDir = this.orchestrator?.config?.project?.directory || process.cwd();
      await this.flowExecutor.executeFlow(flowNameOrId, { projectDir });
    } catch (error) {
      await this._send(chatId, this._escapeMarkdown(`❌ Flow error: ${error.message}`));
    }
  }

  async _cmdStopAgent(msg, agentName) {
    if (!this._isAuthorized(msg)) return;
    try {
      const agents = this.agentPool ? await this.agentPool.getAllAgents() : [];
      const agent = agents.find(a => a.name.toLowerCase() === agentName.toLowerCase());
      if (!agent) {
        await this._send(msg.chat.id, this._escapeMarkdown(`Agent "${agentName}" not found.`));
        return;
      }
      if (this.orchestrator?.messageProcessor) {
        await this.orchestrator.messageProcessor.stopAutonomousExecution(agent.id);
      }
      await this._send(msg.chat.id, this._escapeMarkdown(`⏹ Stopped ${agent.name}`));
    } catch (error) {
      await this._send(msg.chat.id, this._escapeMarkdown(`❌ Error: ${error.message}`));
    }
  }

  async _cmdFollowing(msg) {
    if (!this._isAuthorized(msg)) return;
    const state = this._chatState(msg);
    if (!state || state.activeAgentIds.size === 0) {
      await this._send(msg.chat.id, this._escapeMarkdown('Not following any agents in this chat. Send @agent-name to start.'));
      return;
    }
    const agents = this.agentPool ? await this.agentPool.getAllAgents() : [];
    let text = `*Following ${state.activeAgentIds.size} agent\\(s\\):*\n\n`;
    const buttons = [];
    for (const id of state.activeAgentIds) {
      const agent = agents.find(a => a.id === id);
      const name = agent?.name || id;
      const isLast = id === state.lastAgentId;
      text += `${isLast ? '💬' : '👁'} *${this._escapeMarkdown(name)}*${isLast ? ' \\(active\\)' : ''}\n`;
      buttons.push([{ text: `❌ Unfollow ${name}`, callback_data: `unfollow:${id}` }]);
    }
    text += '\n_Active = default for messages without @prefix_';
    await this._send(msg.chat.id, text, { reply_markup: { inline_keyboard: buttons } });
  }

  async _cmdUnfollow(msg, agentName) {
    if (!this._isAuthorized(msg)) return;
    const state = this._chatState(msg);
    const agents = this.agentPool ? await this.agentPool.getAllAgents() : [];
    const agent = agents.find(a => a.name.toLowerCase() === agentName.toLowerCase());
    if (!agent || !state.activeAgentIds.has(agent.id)) {
      await this._send(msg.chat.id, this._escapeMarkdown(`Not following "${agentName}".`));
      return;
    }
    state.activeAgentIds.delete(agent.id);
    if (state.lastAgentId === agent.id) {
      state.lastAgentId = state.activeAgentIds.size > 0
        ? [...state.activeAgentIds][state.activeAgentIds.size - 1]
        : null;
    }
    await this._saveConfig();
    await this._send(msg.chat.id, this._escapeMarkdown(`Unfollowed ${agent.name}.`));
  }

  async _cmdWatch(msg) {
    if (!this._isAuthorized(msg)) return;
    const state = this._chatState(msg);
    state.watchEnabled = true;
    await this._saveConfig();
    await this._send(msg.chat.id, this._escapeMarkdown('🔔 Notifications enabled. You\'ll receive alerts for completions and prompts.'));
  }

  async _cmdUnwatch(msg) {
    if (!this._isAuthorized(msg)) return;
    const state = this._chatState(msg);
    state.watchEnabled = false;
    await this._saveConfig();
    await this._send(msg.chat.id, this._escapeMarkdown('🔕 Notifications disabled. (Errors and timeouts are still relayed.)'));
  }

  async _cmdWatching(msg) {
    if (!this._isAuthorized(msg)) return;
    const state = this._chatState(msg);
    await this._send(msg.chat.id, this._escapeMarkdown(state.watchEnabled
      ? '🔔 Notifications are ON. Use /unwatch to disable.'
      : '🔕 Notifications are OFF. Use /watch to enable.'
    ));
  }

  async _cmdReset(msg) {
    if (!this._isAuthorized(msg)) return;
    const state = this._chatState(msg);
    if (!state.lastAgentId) {
      await this._send(msg.chat.id, this._escapeMarkdown('No active agent to reset. Use /agents to pick one.'));
      return;
    }
    const agentId = state.lastAgentId;
    const agent = this.agentPool ? await this.agentPool.getAgent(agentId) : null;
    const name = agent?.name || agentId;
    try {
      // Reset agent conversation if the orchestrator exposes it.
      if (this.orchestrator?.messageProcessor?.resetAgentConversation) {
        await this.orchestrator.messageProcessor.resetAgentConversation(agentId);
      } else if (this.agentPool?.clearAgentMessages) {
        await this.agentPool.clearAgentMessages(agentId);
      }
      await this._send(msg.chat.id, this._escapeMarkdown(`🔄 Cleared conversation with ${name}.`));
    } catch (error) {
      await this._send(msg.chat.id, this._escapeMarkdown(`❌ Reset failed: ${error.message}`));
    }
  }

  async _cmdNew(msg, agentName) {
    if (!this._isAuthorized(msg)) return;
    const state = this._chatState(msg);
    const agents = this.agentPool ? await this.agentPool.getAllAgents() : [];
    const agent = agents.find(a => a.name.toLowerCase() === agentName.toLowerCase());
    if (!agent) {
      await this._send(msg.chat.id, this._escapeMarkdown(`Agent "${agentName}" not found.`));
      return;
    }
    state.lastAgentId = agent.id;
    state.activeAgentIds.add(agent.id);
    // Reset their context.
    try {
      if (this.orchestrator?.messageProcessor?.resetAgentConversation) {
        await this.orchestrator.messageProcessor.resetAgentConversation(agent.id);
      } else if (this.agentPool?.clearAgentMessages) {
        await this.agentPool.clearAgentMessages(agent.id);
      }
    } catch { /* non-fatal */ }
    await this._saveConfig();
    await this._send(msg.chat.id, this._escapeMarkdown(`✨ Switched to ${agent.name} with a fresh context.`));
  }

  async _cmdLogout(msg) {
    const chatId = String(msg.chat.id);
    if (!this.chats.has(chatId)) {
      await this._send(chatId, this._escapeMarkdown('Not registered.'));
      return;
    }
    this.chats.delete(chatId);
    await this._saveConfig();
    await this._send(chatId, this._escapeMarkdown('👋 This chat has been disconnected from Loxia. /start to re-link.'));
  }

  _chatState(msg) {
    return this.chats.get(String(msg?.chat?.id));
  }

  // ── Agent Conversation ─────────────────────────────────────────────

  async _handleTextMessage(msg) {
    if (!this._isAuthorized(msg)) return;
    const state = this._chatState(msg);
    if (!state) return;

    // Pending prompt reply check uses chat-local context.
    if (this.replyContext.has(String(msg.chat.id))) {
      await this._handlePromptReply(msg);
      return;
    }

    // Group chats: require @mention.
    const { addressed, text } = this._stripMentionOrSkip(msg);
    if (!addressed) return;
    if (!text) return;

    await this._dispatchToAgent(msg, text);
  }

  async _handleVoiceMessage(msg) {
    if (!this._isAuthorized(msg)) return;
    const state = this._chatState(msg);
    if (!state) return;

    // Groups: skip voice unless the chat has had at least one @mention
    // (we can't transcribe-and-then-check; too costly to transcribe
    // every voice). For now, ignore voice in groups entirely.
    if (msg.chat.type !== 'private') return;

    try {
      await this.bot.sendChatAction(msg.chat.id, 'typing');
    } catch { /* ignore */ }

    let transcribed;
    try {
      transcribed = await this._transcribeVoice(msg.voice, msg);
    } catch (err) {
      // Log at ERROR level (not warn) so operators see the failure
      // in the local server console. Then surface a friendly but
      // diagnostic message to the user — include the upstream status
      // / code when present so the cause is visible without server
      // log access.
      this.logger?.error?.('[TelegramService] voice transcription failed', {
        error: err.message, code: err.code, status: err.status,
      });
      let userHint;
      if (err.code === 'not_configured') {
        userHint = 'Voice transcription needs an Azure speech-to-text deployment (e.g. `gpt-4o-mini-transcribe`). Please type your message for now.';
      } else if (err.status === 401 || err.status === 403) {
        userHint = 'Your account isn\'t authorised to call the transcription endpoint. Sign out and back in, then try again.';
      } else if (err.status === 502 || err.status === 503) {
        userHint = 'The transcription service is temporarily unavailable. Please try again in a moment.';
      } else {
        // Surface the upstream message itself so subtle bugs (audio
        // format, file size, etc.) self-diagnose. Truncated to keep
        // the Telegram bubble readable.
        const tail = (err.message || 'unknown error').slice(0, 200);
        userHint = `Please try again, or type your message. (debug: ${tail})`;
      }
      await this._send(msg.chat.id, this._escapeMarkdown(`🎤 Couldn't transcribe that voice note. ${userHint}`));
      return;
    }

    if (!transcribed) {
      await this._send(msg.chat.id, this._escapeMarkdown('🎤 Voice note was empty. Please try again or type your message.'));
      return;
    }

    // Show the user what we heard, then act on it as if they had typed.
    // `_Heard:_` is MarkdownV2 italic markup — must stay UNESCAPED, or
    // the user just sees literal underscores. Only the transcribed
    // body gets escaped, since it can contain arbitrary characters
    // that would otherwise break the parse_mode.
    await this._send(msg.chat.id, `🎤 _Heard:_ ${this._escapeMarkdown(transcribed)}`);
    await this._dispatchToAgent(msg, transcribed);
  }

  /**
   * Common path: resolve target agent and submit to the orchestrator.
   * Emits a typing indicator + a "🔄 Thinking…" placeholder which the
   * broadcast handler will edit with the real reply.
   */
  async _dispatchToAgent(msg, text) {
    const state = this._chatState(msg);

    // Parse @agent-name prefix. Supports names with spaces by doing
    // longest-prefix matching against the live agent list — see
    // _resolveAtPrefix() for the contract.
    const agents = this.agentPool ? await this.agentPool.getAllAgents() : [];
    const parsed = this._resolveAtPrefix(text, agents);
    const agentName = parsed.agentName;     // null when message has no @prefix
    const messageText = parsed.messageText;

    // Resolve agent.
    let targetAgent = null;
    if (agentName) {
      targetAgent = parsed.matchedAgent || null;
      if (!targetAgent) {
        await this._send(msg.chat.id, this._escapeMarkdown(
          `❌ Agent "${agentName}" not found. Use /agents to see available agents. ` +
          'Tip: names with spaces can be written either as `@John Smith` or `@John_Smith`.'
        ));
        return;
      }
      state.lastAgentId = targetAgent.id;
      state.activeAgentIds.add(targetAgent.id);
      await this._saveConfig();
    } else if (state.lastAgentId) {
      targetAgent = this.agentPool ? await this.agentPool.getAgent(state.lastAgentId) : null;
      if (!targetAgent) {
        await this._send(msg.chat.id, this._escapeMarkdown('No agent selected. Use @agent-name message to address one.'));
        return;
      }
    } else {
      await this._send(msg.chat.id, this._escapeMarkdown('No agent selected. Use @agent-name message to address one.'));
      return;
    }

    // Begin typing + thinking placeholder. Saved so the broadcast
    // handler can edit it with the agent's actual reply.
    try { await this.bot.sendChatAction(msg.chat.id, 'typing'); } catch { /* ignore */ }
    try {
      const placeholder = await this.bot.sendMessage(
        msg.chat.id,
        `🔄 *${this._escapeMarkdown(targetAgent.name)}* is thinking…`,
        { parse_mode: 'MarkdownV2' }
      );
      state.thinkingByAgentId.set(targetAgent.id, placeholder.message_id);
    } catch (err) {
      this.logger?.debug?.('[TelegramService] thinking placeholder send failed', { error: err.message });
    }

    // Dispatch to orchestrator.
    try {
      if (this.orchestrator) {
        const sessionId = `telegram-${msg.chat.id}`;
        const source = createTelegramSource(msg);
        await this.orchestrator.processRequest({
          interface: 'telegram',
          sessionId,
          action: 'send_message',
          payload: {
            agentId: targetAgent.id,
            message: messageText,
            streamingEnabled: false,
            source,
          },
          projectDir: this.orchestrator.config?.project?.directory || process.cwd(),
        });
      }
    } catch (error) {
      // Clear the placeholder, surface the failure.
      await this._clearThinking(msg.chat.id, targetAgent.id);
      await this._send(msg.chat.id, this._escapeMarkdown(`❌ Failed to send: ${error.message}`));
    }
  }

  async _clearThinking(chatId, agentId) {
    const state = this.chats.get(String(chatId));
    if (!state) return;
    const messageId = state.thinkingByAgentId.get(agentId);
    if (!messageId) return;
    state.thinkingByAgentId.delete(agentId);
    try {
      await this.bot.deleteMessage(chatId, messageId);
    } catch { /* if user deleted it, that's fine */ }
  }

  // ── Voice transcription (via backend) ──────────────────────────────

  async _transcribeVoice(voice, msg = null) {
    if (!voice?.file_id) throw Object.assign(new Error('no file_id'), { code: 'bad_request' });
    const platformKey = this._resolvePlatformKey();
    if (!platformKey) {
      throw Object.assign(new Error('platform key not set'), { code: 'not_configured' });
    }
    // 1) Get the file URL from Telegram + download bytes.
    const fileLink = await this.bot.getFileLink(voice.file_id);
    const audioRes = await fetch(fileLink);
    if (!audioRes.ok) throw new Error(`audio download failed: ${audioRes.status}`);
    const audioBuf = Buffer.from(await audioRes.arrayBuffer());
    const audioBase64 = audioBuf.toString('base64');

    // 2) POST to backend /llm/transcribe. Telegram knows the audio
    // duration upfront (voice.duration); pass it so the backend can
    // price the call correctly even when the model's response shape
    // doesn't include duration (gpt-4o-*-transcribe with json format).
    // The Telegram message id is a natural idempotency key — a retry
    // of the same voice note dedups at the recorder layer.
    const url = `${this.backendBaseUrl}/llm/transcribe`;
    const apiRes = await fetch(url, {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${platformKey}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        audioBase64,
        mimeType: voice.mime_type || 'audio/ogg',
        filename: `voice_${Date.now()}.ogg`,
        durationSeconds: typeof voice.duration === 'number' ? voice.duration : undefined,
        idempotencyKey: msg?.message_id
          ? `tg-${msg.chat?.id}-${msg.message_id}`
          : undefined,
      }),
    });
    if (apiRes.status === 503) {
      throw Object.assign(new Error('not configured'), { code: 'not_configured', status: 503 });
    }
    if (!apiRes.ok) {
      const body = await apiRes.text().catch(() => '');
      let parsedMsg = '';
      try { parsedMsg = JSON.parse(body)?.error || ''; } catch { /* not JSON */ }
      const detail = parsedMsg || body.slice(0, 200);
      throw Object.assign(
        new Error(`backend transcribe ${apiRes.status}: ${detail}`),
        { status: apiRes.status }
      );
    }
    const data = await apiRes.json();
    return (data?.text || '').trim();
  }

  // ── Callback Query Handler ─────────────────────────────────────────

  async _handleCallbackQuery(query) {
    const chatId = String(query.message.chat.id);
    if (!this.chats.has(chatId)) return;
    const state = this.chats.get(chatId);

    const data = query.data || '';
    try { await this.bot.answerCallbackQuery(query.id); } catch { /* ignore */ }

    if (data === 'noop') return;

    if (data.startsWith('agents_page:')) {
      const page = parseInt(data.split(':')[1], 10) || 0;
      const agents = this.agentPool ? await this.agentPool.getAllAgents() : [];
      await this._renderAgentsPage(chatId, agents, page);
      return;
    }
    if (data.startsWith('flows_page:')) {
      const page = parseInt(data.split(':')[1], 10) || 0;
      if (!this.orchestrator?.stateManager) return;
      const projectDir = this.orchestrator.config?.project?.directory || process.cwd();
      const flowIndex = await this.orchestrator.stateManager.loadFlowIndex?.(projectDir) || {};
      await this._renderFlowsPage(chatId, Object.entries(flowIndex), page);
      return;
    }
    if (data.startsWith('agent_detail:')) {
      await this._showAgentDetail(chatId, data.slice('agent_detail:'.length));
      return;
    }
    if (data.startsWith('msg_agent:')) {
      const agentId = data.slice('msg_agent:'.length);
      state.lastAgentId = agentId;
      state.activeAgentIds.add(agentId);
      await this._saveConfig();
      const agent = this.agentPool ? await this.agentPool.getAgent(agentId) : null;
      await this._send(chatId, this._escapeMarkdown(`💬 Now chatting with ${agent?.name || agentId}. Type your message.`));
      return;
    }
    if (data.startsWith('stop_agent:')) {
      const agentId = data.slice('stop_agent:'.length);
      if (this.orchestrator?.messageProcessor) {
        await this.orchestrator.messageProcessor.stopAutonomousExecution(agentId);
      }
      await this._send(chatId, this._escapeMarkdown('⏹ Agent stopped.'));
      return;
    }
    if (data.startsWith('run_flow:')) {
      await this._runFlow(chatId, data.slice('run_flow:'.length));
      return;
    }
    if (data.startsWith('unfollow:')) {
      const agentId = data.slice('unfollow:'.length);
      state.activeAgentIds.delete(agentId);
      if (state.lastAgentId === agentId) {
        state.lastAgentId = state.activeAgentIds.size > 0 ? [...state.activeAgentIds][0] : null;
      }
      await this._saveConfig();
      const agent = this.agentPool ? await this.agentPool.getAgent(agentId) : null;
      await this._send(chatId, this._escapeMarkdown(`Unfollowed ${agent?.name || agentId}.`));
      return;
    }
    if (data.startsWith('prompt_reply:')) {
      const m = data.match(/prompt_reply:(.+):(\d+)/);
      if (m && this.pendingRelays.has(m[1])) {
        await this._submitPromptReply(m[1], m[2]);
      }
      return;
    }
    if (data.startsWith('act:')) {
      // <actions> button tapped — treat value as a user message to the
      // sticky agent. Format: `act:<agentId>:<value>` where value may
      // contain colons; split on first two.
      const idx1 = data.indexOf(':');
      const idx2 = data.indexOf(':', idx1 + 1);
      const agentId = data.slice(idx1 + 1, idx2);
      const value   = data.slice(idx2 + 1);
      if (!agentId || !value) return;

      // Synthesize a message that mirrors what a real user input would
      // look like in the same handler. Forwarding the tap as a
      // synthetic user turn keeps the agent's transcript honest.
      const synthetic = {
        chat: query.message.chat,
        from: query.from,
        message_id: query.message.message_id,
        text: `@${(await this._agentName(agentId))} ${value}`,
      };
      await this._dispatchToAgent(synthetic, synthetic.text);
    }
  }

  async _agentName(agentId) {
    const agent = this.agentPool ? await this.agentPool.getAgent(agentId) : null;
    return agent?.name || agentId;
  }

  // ── Broadcast Interceptor ──────────────────────────────────────────

  _interceptBroadcasts(wsManager) {
    if (!wsManager || this._originalBroadcast) return;
    const originalBroadcast = wsManager.broadcastToSession.bind(wsManager);
    this._originalBroadcast = originalBroadcast;
    wsManager.broadcastToSession = (sessionId, message) => {
      originalBroadcast(sessionId, message);
      this._handleBroadcastEvent(message);
    };
  }

  async _handleBroadcastEvent(message) {
    if (!this.bot || this.chats.size === 0 || this.status !== TELEGRAM_STATUS.CONNECTED) return;
    const type = message?.type;
    if (!type) return;

    if (type === 'user_prompt_request') {
      await this._relayPromptRequest(message);
      return;
    }
    if (type === 'credential_request') {
      await this._relayCredentialRequest(message);
      return;
    }
    if (type === 'stream_complete') {
      await this._relayAgentResponse(message);
      return;
    }
    if (type === 'imageGenerated') {
      // Buffer the URL keyed by agentId. The actual send happens when
      // the matching stream_complete arrives (typical case — agent
      // says "here's your image: ..." with the image markdown), or as
      // a standalone send if no text reply ever lands (image-only
      // turn, e.g. an automated render flow).
      this._bufferGeneratedImage(message);
      return;
    }

    // Errors and timeouts ALWAYS reach the user. Other completions
    // require /watch.
    const ALWAYS_RELAY = new Set(['agent_error', 'agent_timeout', 'criticalError', 'flow_run_failed']);
    const WATCH_GATED  = new Set(['execution_stopped', 'flow_run_completed']);

    const headers = {
      agent_error:         '⚠️ *Agent Error*',
      agent_timeout:       '⏰ *Agent Timeout*',
      criticalError:       '🔴 *Critical Error*',
      flow_run_failed:     '❌ *Flow Failed*',
      execution_stopped:   '✅ *Agent Finished*',
      flow_run_completed:  '✅ *Flow Completed*',
    };
    if (!headers[type]) return;

    let text = `${headers[type]}\n`;
    if (message.agentName || message.data?.agentName) {
      text += `Agent: \`${message.agentName || message.data?.agentName}\`\n`;
    }
    if (message.message || message.data?.message || message.error) {
      text += `${message.message || message.data?.message || message.error}\n`;
    }

    if (ALWAYS_RELAY.has(type)) {
      this._fanoutNotification(text);
    } else if (WATCH_GATED.has(type)) {
      for (const [chatId, state] of this.chats.entries()) {
        if (state.watchEnabled) this._queueNotification(chatId, text);
      }
    }
  }

  /** Send `text` to every linked chat regardless of /watch state. */
  async _fanoutNotification(text) {
    for (const chatId of this.chats.keys()) {
      try { await this._send(chatId, text); } catch { /* per-chat failures shouldn't block others */ }
    }
  }

  async _relayAgentResponse(message) {
    const agentId = message.agentId || message.data?.agentId;
    if (!agentId) return;

    const content = message.content || message.data?.content ||
      message.message?.content || message.data?.message?.content;

    // Skip user/tool roles.
    const role = message.role || message.data?.role || message.message?.role;
    if (role === 'user' || role === 'tool') return;

    // Drain any image buffered by the imageTool. We do this even when
    // there's no text content — image-only completions still need to
    // reach Telegram. We do it for every active chat with this agent.
    const pendingImages = this._consumePendingImages(agentId);

    const { blocks } = content ? filterContentForExternalRelay(content) : { blocks: [] };

    if (blocks.length === 0) {
      // No <external> text block. Clear thinking placeholders so the
      // user isn't left looking at "🔄 …", and — critically — still
      // ship any buffered images for this agent so "draw me a sunset"
      // through Telegram doesn't go silent when the agent's text
      // reply omits the image markdown.
      for (const [chatId, state] of this.chats.entries()) {
        await this._clearThinking(chatId, agentId);
        if (pendingImages.length > 0 && state.activeAgentIds.has(agentId)) {
          for (const img of pendingImages) {
            await this._sendMedia(chatId, { kind: 'image', src: img.imageUrl, caption: img.prompt || undefined });
          }
        }
      }
      return;
    }

    const agent = this.agentPool ? await this.agentPool.getAgent(agentId) : null;
    const agentName = agent?.name || agentId;

    // For each chat where this agent is active, send the block(s).
    for (const [chatId, state] of this.chats.entries()) {
      if (!state.activeAgentIds.has(agentId)) continue;

      const ownedAliases = this._aliasesForChat(chatId);
      let firstBlock = true;

      for (const block of blocks) {
        const targets = resolveBlockTargets(block, ownedAliases);
        if (targets.length === 0) continue;

        try {
          const parsed = parseTelegramBlock(block.text);

          // Edit the thinking placeholder on the first block; subsequent
          // blocks go as new messages so users still see them as separate
          // turns when an agent emits multiple <external> blocks.
          if (firstBlock) {
            await this._replaceThinkingWithReply(chatId, agentId, agentName, parsed);
            firstBlock = false;
          } else {
            await this._sendParsedPayload(chatId, agentName, parsed);
          }

          // Media is sent after the text body in either path.
          for (const media of parsed.media) {
            await this._sendMedia(chatId, media);
          }
        } catch (error) {
          this.logger?.warn?.('[TelegramService] block relay failed', {
            chatId, agentId, error: error.message,
          });
        }
      }

      // After the text blocks land, ship any imageTool-generated
      // images that aren't already embedded in a parsed block. We
      // dedupe by URL against the block parser's media list so we
      // don't double-post an image the agent already referenced
      // inline.
      if (pendingImages.length > 0) {
        const embeddedUrls = new Set();
        for (const block of blocks) {
          try {
            for (const m of parseTelegramBlock(block.text).media) {
              if (m?.src) embeddedUrls.add(m.src);
            }
          } catch { /* parser failures already logged above */ }
        }
        for (const img of pendingImages) {
          if (embeddedUrls.has(img.imageUrl)) continue;
          await this._sendMedia(chatId, { kind: 'image', src: img.imageUrl, caption: img.prompt || undefined });
        }
      }
    }
  }

  /**
   * Stash an `imageGenerated` broadcast for later flush by
   * `_relayAgentResponse`. Prunes stale entries (>5min) on insert so
   * the buffer can't grow unboundedly if no stream_complete ever
   * arrives for that agent.
   */
  _bufferGeneratedImage(message) {
    const agentId = message.agentId || message.data?.agentId;
    const imageUrl = message.imageUrl || message.data?.imageUrl;
    if (!agentId || !imageUrl) return;
    const prompt = message.prompt || message.data?.prompt || null;

    const arr = this._pendingImagesByAgent.get(agentId) || [];
    const now = Date.now();
    // Drop stale entries while we're touching this list.
    const fresh = arr.filter(e => now - e.addedAt < IMAGE_PENDING_TTL_MS);
    fresh.push({ imageUrl, prompt, addedAt: now });
    this._pendingImagesByAgent.set(agentId, fresh);
  }

  /** Atomically read-and-clear pending images for `agentId`. */
  _consumePendingImages(agentId) {
    const arr = this._pendingImagesByAgent.get(agentId);
    if (!arr || arr.length === 0) return [];
    this._pendingImagesByAgent.delete(agentId);
    const now = Date.now();
    return arr.filter(e => now - e.addedAt < IMAGE_PENDING_TTL_MS);
  }

  _aliasesForChat(chatId) {
    // Today we advertise a single chat-scoped alias; expanding later
    // to per-channel aliases is a one-line change.
    return ['telegram', `telegram:chat-${chatId}`];
  }

  /**
   * Edit the in-flight thinking placeholder to show the agent's real
   * reply. Falls back to a fresh message if editing fails (e.g. the
   * placeholder was deleted by the user).
   */
  async _replaceThinkingWithReply(chatId, agentId, agentName, parsed) {
    const state = this.chats.get(String(chatId));
    const placeholderId = state?.thinkingByAgentId.get(agentId);
    state?.thinkingByAgentId.delete(agentId);

    const header = `*${this._escapeMarkdown(agentName)}:*\n\n`;
    const formatted = this._formatAgentBody(parsed.text);
    const replyMarkup = this._buildActionKeyboard(agentId, parsed.actions);

    if (formatted.kind === 'document') {
      // Code-heavy reply: drop the placeholder, send the document.
      if (placeholderId) {
        try { await this.bot.deleteMessage(chatId, placeholderId); } catch {}
      }
      await this._sendCodeDocument(chatId, agentName, formatted.body);
      return;
    }

    const fullText = header + formatted.body;
    const sendOpts = { parse_mode: formatted.parseMode };
    if (replyMarkup) sendOpts.reply_markup = replyMarkup;

    if (placeholderId) {
      try {
        await this.bot.editMessageText(fullText, {
          chat_id: chatId,
          message_id: placeholderId,
          ...sendOpts,
        });
        return;
      } catch (err) {
        this.logger?.debug?.('[TelegramService] edit placeholder failed, sending fresh', { error: err.message });
      }
    }
    await this._sendRaw(chatId, fullText, sendOpts);
  }

  /** Send a parsed payload (additional blocks after the first). */
  async _sendParsedPayload(chatId, agentName, parsed) {
    const header = `*${this._escapeMarkdown(agentName)}:*\n\n`;
    const formatted = this._formatAgentBody(parsed.text);
    if (formatted.kind === 'document') {
      await this._sendCodeDocument(chatId, agentName, formatted.body);
      return;
    }
    const replyMarkup = this._buildActionKeyboard(null, parsed.actions);
    const opts = { parse_mode: formatted.parseMode };
    if (replyMarkup) opts.reply_markup = replyMarkup;
    await this._sendRaw(chatId, header + formatted.body, opts);
  }

  _buildActionKeyboard(agentId, actions) {
    if (!Array.isArray(actions) || actions.length === 0) return null;
    if (!agentId) return null;          // can't dispatch without an agent
    return {
      inline_keyboard: actions.map(a => ([{
        text: a.label,
        // Telegram caps callback_data at 64 bytes. The shape is
        // `act:<agentId>:<value>` so the value is what's first to be
        // truncated. agentId is typically a UUID (36 chars) + 'act:'
        // prefix (4 chars) leaves 24 chars for the value.
        callback_data: `act:${agentId}:${a.value}`.slice(0, 64),
      }])),
    };
  }

  // ── Text formatting: HTML for code blocks, MarkdownV2 otherwise ────

  _formatAgentBody(body) {
    if (!body || typeof body !== 'string' || body.trim() === '') {
      return { kind: 'text', parseMode: 'MarkdownV2', body: this._escapeMarkdown('(empty response)') };
    }

    const fenceCount = (body.match(/```/g) || []).length;
    const hasFencedCode = fenceCount >= 2;
    const isCodeHeavy = hasFencedCode && body.length > CODE_AS_FILE_THRESHOLD;

    if (isCodeHeavy) {
      // Send as document — guarantees no markdown mangling for big payloads.
      return { kind: 'document', body };
    }

    if (hasFencedCode) {
      // HTML mode is far more forgiving than MarkdownV2 for mixed-code text.
      // Convert fenced blocks to <pre><code>…</code></pre>.
      const htmlBody = body.replace(/```(\w+)?\n([\s\S]*?)```/g, (_full, _lang, code) => {
        const escaped = String(code)
          .replace(/&/g, '&amp;')
          .replace(/</g, '&lt;')
          .replace(/>/g, '&gt;');
        return `<pre>${escaped}</pre>`;
      });
      // Inline-code backticks too.
      const withInline = htmlBody.replace(/`([^`\n]+)`/g, (_m, c) => {
        const esc = String(c).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
        return `<code>${esc}</code>`;
      });
      // Escape everything else that HTML mode cares about (only <, >, &
      // outside the tags). Easiest: split by <pre>/<code> tags and
      // escape non-code parts.
      const safe = this._escapeHtmlPreservingTags(withInline);
      const truncated = safe.length > MAX_MESSAGE_LENGTH
        ? safe.slice(0, MAX_MESSAGE_LENGTH - 100) + '\n\n… (truncated)'
        : safe;
      return { kind: 'text', parseMode: 'HTML', body: truncated };
    }

    // Plain text: full MarkdownV2 escape.
    const escaped = this._escapeMarkdown(body);
    const truncated = escaped.length > MAX_MESSAGE_LENGTH
      ? escaped.slice(0, MAX_MESSAGE_LENGTH - 100) + '\n\n… (truncated)'
      : escaped;
    return { kind: 'text', parseMode: 'MarkdownV2', body: truncated };
  }

  _escapeHtmlPreservingTags(text) {
    // Split into segments alternating between tag-segments and text.
    // We only allow <pre>/<code>; anything else gets escaped.
    const parts = text.split(/(<\/?(?:pre|code)>)/g);
    return parts.map((p) => {
      if (/^<\/?(pre|code)>$/i.test(p)) return p;
      return p.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
    }).join('');
  }

  async _sendCodeDocument(chatId, agentName, body) {
    if (!this.bot) return;
    try {
      const filename = `${(agentName || 'reply').replace(/[^A-Za-z0-9-]/g, '_')}-${Date.now()}.txt`;
      // node-telegram-bot-api accepts a Buffer for sendDocument with
      // options.filename for the displayed name.
      await this.bot.sendDocument(
        chatId,
        Buffer.from(body, 'utf8'),
        { caption: `*${this._escapeMarkdown(agentName)}* — full reply (sent as a file because it\\\'s code\\-heavy).`, parse_mode: 'MarkdownV2' },
        { filename, contentType: 'text/plain' }
      );
    } catch (error) {
      this.logger?.warn?.('[TelegramService] code-document send failed, falling back to truncated text', { error: error.message });
      const fallback = body.slice(0, MAX_MESSAGE_LENGTH - 200) + '\n\n… (truncated; see local web UI for full reply)';
      await this._sendRaw(chatId, `*${this._escapeMarkdown(agentName)}:*\n\n` + this._escapeMarkdown(fallback), { parse_mode: 'MarkdownV2' });
    }
  }

  async _sendMedia(chatId, media) {
    if (!this.bot) return;
    try {
      const captionMd = media.caption ? this._escapeMarkdown(media.caption) : undefined;
      if (media.kind === 'image') {
        await this.bot.sendPhoto(chatId, media.src, {
          caption: captionMd, parse_mode: 'MarkdownV2',
        });
      } else if (media.kind === 'document') {
        await this.bot.sendDocument(chatId, media.src, {
          caption: captionMd, parse_mode: 'MarkdownV2',
        });
      }
    } catch (error) {
      this.logger?.warn?.('[TelegramService] media send failed', { error: error.message, kind: media.kind });
    }
  }

  // ── Prompt Relay ───────────────────────────────────────────────────

  async _relayPromptRequest(message) {
    if (this.chats.size === 0) return;
    const data = message.data || message;
    const requestId = data.requestId;
    const agentId = data.agentId;
    const questions = data.questions || [];
    if (!requestId || questions.length === 0) return;

    const agent = this.agentPool ? await this.agentPool.getAgent(agentId) : null;
    const agentName = agent?.name || agentId;

    // Fan out to every chat that has this agent active.
    for (const [chatId, state] of this.chats.entries()) {
      if (!state.activeAgentIds.has(agentId)) continue;

      this.pendingRelays.set(requestId, { type: 'user_prompt', agentId, chatId, questions, timestamp: Date.now() });

      for (const q of questions) {
        let text = `🔔 *${this._escapeMarkdown(agentName)}* needs your input:\n\n`;
        text += this._escapeMarkdown(q.question) + '\n';
        const options = q.options || [];
        if (options.length > 0) {
          const buttons = options.map((opt, i) => ([{
            text: opt.label,
            callback_data: `prompt_reply:${requestId}:${i}`,
          }]));
          await this._send(chatId, text, { reply_markup: { inline_keyboard: buttons } });
        } else {
          text += '\n_Type your reply:_';
          this.replyContext.set(chatId, { type: 'user_prompt', requestId, agentId });
          await this._send(chatId, text);
        }
      }

      // Two timers per request:
      //   - A 3-min reminder ("still waiting")
      //   - A 10-min hard timeout ("expired — please re-trigger the action")
      const reminderId = setTimeout(() => {
        if (this.pendingRelays.has(requestId)) {
          this._send(chatId, this._escapeMarkdown(`⏰ Reminder: ${agentName} is still waiting for your input.`)).catch(() => {});
        }
      }, PROMPT_REMINDER_MS);
      const timeoutId = setTimeout(() => {
        if (this.pendingRelays.has(requestId)) {
          this.pendingRelays.delete(requestId);
          this.replyContext.delete(chatId);
          this._send(chatId, this._escapeMarkdown(`⏱ Prompt from ${agentName} expired without a reply. Re-run the action if you still need it.`)).catch(() => {});
        }
      }, PROMPT_TIMEOUT_MS);
      const existing = this.pendingRelays.get(requestId);
      if (existing) {
        existing.reminderId = reminderId;
        existing.timeoutId = timeoutId;
      }
    }
  }

  async _relayCredentialRequest(message) {
    if (this.chats.size === 0) return;
    const data = message.data || message;
    const requestId = data.requestId;
    const agentId = data.agentId;
    const agent = this.agentPool ? await this.agentPool.getAgent(agentId) : null;
    const agentName = agent?.name || agentId;

    for (const [chatId, state] of this.chats.entries()) {
      if (!state.activeAgentIds.has(agentId)) continue;
      this.pendingRelays.set(requestId, { type: 'credential', agentId, chatId, timestamp: Date.now() });
      this.replyContext.set(chatId, { type: 'credential', requestId, agentId });
      const text = `🔐 *${this._escapeMarkdown(agentName)}* needs credentials:\n\n` +
        this._escapeMarkdown(data.message || 'Please provide the requested credentials.') +
        '\n\n_Type your reply:_';
      await this._send(chatId, text);

      // Timeout for credential requests too.
      const timeoutId = setTimeout(() => {
        if (this.pendingRelays.has(requestId)) {
          this.pendingRelays.delete(requestId);
          this.replyContext.delete(chatId);
          this._send(chatId, this._escapeMarkdown(`⏱ Credential request from ${agentName} expired. Re-trigger if needed.`)).catch(() => {});
        }
      }, PROMPT_TIMEOUT_MS);
      const existing = this.pendingRelays.get(requestId);
      if (existing) existing.timeoutId = timeoutId;
    }
  }

  async _handlePromptReply(msg) {
    const chatId = String(msg.chat.id);
    const ctx = this.replyContext.get(chatId);
    if (!ctx) return;

    const { type, requestId } = ctx;
    const text = msg.text?.trim();
    if (!text) return;

    this.replyContext.delete(chatId);
    const relay = this.pendingRelays.get(requestId);
    if (relay) {
      if (relay.reminderId) clearTimeout(relay.reminderId);
      if (relay.timeoutId)  clearTimeout(relay.timeoutId);
    }
    this.pendingRelays.delete(requestId);

    try {
      if (type === 'user_prompt' && this.webSocketManager) {
        this.webSocketManager._handleUserPromptResult?.({
          requestId, answers: { default: text },
        });
      } else if (type === 'credential' && this.webSocketManager) {
        this.webSocketManager._handleCredentialResponse?.({
          requestId, credentials: { value: text }, saveForFuture: false,
        });
      }
      await this._send(msg.chat.id, this._escapeMarkdown('✅ Response submitted.'));
    } catch (error) {
      await this._send(msg.chat.id, this._escapeMarkdown(`❌ Failed to submit: ${error.message}`));
    }
  }

  async _submitPromptReply(requestId, answerIndex) {
    const relay = this.pendingRelays.get(requestId);
    if (!relay) return;
    if (relay.reminderId) clearTimeout(relay.reminderId);
    if (relay.timeoutId)  clearTimeout(relay.timeoutId);
    this.pendingRelays.delete(requestId);
    this.replyContext.delete(String(relay.chatId));

    try {
      const question = relay.questions?.[0];
      const option = question?.options?.[parseInt(answerIndex)];
      const answer = option?.label || String(answerIndex);
      if (this.webSocketManager?._handleUserPromptResult) {
        this.webSocketManager._handleUserPromptResult({
          requestId, answers: { [question.question]: answer },
        });
      }
      await this._send(relay.chatId, this._escapeMarkdown(`✅ Selected: ${answer}`));
    } catch (error) {
      await this._send(relay.chatId, this._escapeMarkdown(`❌ Failed: ${error.message}`));
    }
  }

  // ── Notification Batching ──────────────────────────────────────────

  _queueNotification(chatId, text) {
    let q = this.notificationQueue.get(chatId);
    if (!q) { q = []; this.notificationQueue.set(chatId, q); }
    q.push(text);
    if (!this.notificationTimers.has(chatId)) {
      this.notificationTimers.set(chatId, setTimeout(() => this._flushNotifications(chatId), NOTIFICATION_BATCH_INTERVAL_MS));
    }
  }

  async _flushNotifications(chatId) {
    this.notificationTimers.delete(chatId);
    const q = this.notificationQueue.get(chatId) || [];
    this.notificationQueue.set(chatId, []);
    if (q.length === 0) return;
    const combined = q.join('\n\n');
    if (combined.length <= MAX_MESSAGE_LENGTH) {
      await this._send(chatId, combined);
    } else {
      await this._send(chatId, q[0] + `\n\n_…and ${q.length - 1} more events_`);
    }
  }

  // ── Telegram API Helpers ───────────────────────────────────────────

  async _send(chatId, text, options = {}) {
    return this._sendRaw(chatId, text, { parse_mode: 'MarkdownV2', ...options });
  }

  async _sendRaw(chatId, text, options) {
    if (!this.bot || !chatId) return;
    try {
      return await this.bot.sendMessage(chatId, text, options);
    } catch (error) {
      // Fallback path: strip formatting and retry plain. This catches
      // MarkdownV2-escape gaps the regex doesn't cover.
      this.logger?.warn('[TelegramService] formatted send failed, retrying plain', { error: error.message });
      try {
        const plainOpts = { ...options };
        delete plainOpts.parse_mode;
        return await this.bot.sendMessage(
          chatId,
          String(text).replace(/[\\*_`\[\]()~>#+\-=|{}.!]/g, ''),
          plainOpts
        );
      } catch (e2) {
        this.logger?.error('[TelegramService] send failed', { error: e2.message });
      }
    }
  }

  async sendPhoto(chatId, photoPath, caption = '') {
    if (!this.bot || !chatId) return;
    try { return await this.bot.sendPhoto(chatId, photoPath, { caption }); }
    catch (error) { this.logger?.error('[TelegramService] sendPhoto failed', { error: error.message }); }
  }

  async sendDocument(chatId, docPath, caption = '') {
    if (!this.bot || !chatId) return;
    try { return await this.bot.sendDocument(chatId, docPath, { caption }); }
    catch (error) { this.logger?.error('[TelegramService] sendDocument failed', { error: error.message }); }
  }

  async sendTestMessage() {
    if (this.chats.size === 0) {
      throw new Error('No chats registered. Send /start from Telegram first.');
    }
    for (const chatId of this.chats.keys()) {
      await this._send(chatId, this._escapeMarkdown('✅ Loxia Autopilot — test message received!'));
    }
  }

  // ── Markdown Escaping ──────────────────────────────────────────────

  _escapeMarkdown(text) {
    if (!text) return '';
    return text.replace(/([_*\[\]()~`>#+\-=|{}.!\\])/g, '\\$1');
  }

  /**
   * Tagged-template helper for MarkdownV2 messages with safe interpolation.
   *
   * The LITERAL parts of the template (the bits between `${...}`) are
   * passed through unchanged — author writes raw MarkdownV2 there
   * (e.g. `*bold*`, `` `code` ``, `_italic_`, with `\.` / `\!` etc.
   * spelled out where Telegram requires).
   *
   * The INTERPOLATED parts (`${value}`) are escaped automatically so a
   * variable containing `_` or `*` can't accidentally break formatting
   * or inject markup.
   *
   * The original `_escapeMarkdown(entireString)` pattern that wrapped
   * a hand-written MarkdownV2 string in escapes is the bug we keep
   * hitting — every intended `*bold*` ended up as literal `*bold*`.
   *
   * @example
   *   const text = this._md`*Welcome* ${userName}\\. Use /help for commands\\.`;
   *
   * @returns {string} ready to pass to this._send() with MarkdownV2 parse_mode.
   */
  _md(strings, ...values) {
    let out = '';
    for (let i = 0; i < strings.length; i++) {
      out += strings[i];
      if (i < values.length) out += this._escapeMarkdown(String(values[i] ?? ''));
    }
    return out;
  }

  // ── Public bridge introspection ────────────────────────────────────

  /**
   * Bridged channels for an agent: one alias per chat the agent has
   * been addressed from. The scheduler injects the `<external to="…">`
   * prompt guidance based on this list.
   */
  getBridgedChannels(agentId) {
    const out = [];
    for (const [chatId, state] of this.chats.entries()) {
      if (state.activeAgentIds.has(agentId)) {
        out.push({
          alias: `telegram:chat-${chatId}`,
          label: state.title ? `Telegram > ${state.title}` : 'Telegram chat',
        });
      }
    }
    if (out.length > 0) {
      // Also expose the bare `telegram` alias for backward compat
      // with agents that don't know about per-chat addressing.
      out.push({ alias: 'telegram', label: 'Telegram (any chat)' });
    }
    return out;
  }

  isAgentBridged(agentId) {
    if (!agentId || this.status !== TELEGRAM_STATUS.CONNECTED) return false;
    for (const state of this.chats.values()) {
      if (state.activeAgentIds.has(agentId)) return true;
    }
    return false;
  }
}

// Singleton
let instance = null;

export function getTelegramService(logger = null) {
  if (!instance) {
    instance = new TelegramService(logger);
  }
  return instance;
}

// Tests need to drop the singleton between cases.
export function _resetTelegramSingletonForTests() {
  instance = null;
}

export { TelegramService, TELEGRAM_STATUS };
export default TelegramService;
