/**
 * Tests for the rewritten TelegramService.
 *
 * Covers the user-experience-critical surfaces:
 *   • Multi-chat /start (no global lock)
 *   • Group chat @mention gating
 *   • Voice → backend transcribe → text dispatch
 *   • Thinking placeholder edit-on-completion
 *   • <actions> / <image> / <attachment> block dispatch
 *   • Always-relay error broadcasts vs watch-gated completions
 *   • /reset, /new, /logout commands
 *   • Pagination of /agents
 *   • Markdown vs HTML vs document selection by content shape
 *   • Config migration (legacy `chatId` scalar)
 *
 * The mock TelegramBot records every call and exposes them via
 * `bot.calls.<method>` arrays. node-telegram-bot-api is fully mocked
 * so no network and no dependency on the real bot library.
 */

import { jest, describe, test, expect, beforeEach } from '@jest/globals';

// ─── Module mocks ─────────────────────────────────────────────────────

// In-memory virtual fs for the config file. Each beforeEach resets it.
const virtualFs = { content: null };

jest.unstable_mockModule('fs', () => ({
  promises: {
    mkdir: jest.fn().mockResolvedValue(undefined),
    readFile: jest.fn().mockImplementation(async () => {
      if (virtualFs.content === null) throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
      return virtualFs.content;
    }),
    writeFile: jest.fn().mockImplementation(async (_p, data) => {
      virtualFs.content = data;
    }),
  },
}));

jest.unstable_mockModule('../../utilities/userDataDir.js', () => ({
  getUserDataPaths: () => ({ base: '/mock/data' }),
  ensureUserDataDirs: jest.fn().mockResolvedValue(undefined),
}));

// MockBot — a deterministic stand-in for node-telegram-bot-api. Records
// every call, exposes hooks so tests can simulate incoming messages.
class MockBot {
  constructor() {
    this._textHandlers = []; // [{regex, fn}]
    this._messageHandlers = [];
    this._callbackHandlers = [];
    this.calls = {
      sendMessage: [], sendPhoto: [], sendDocument: [],
      sendChatAction: [], editMessageText: [], deleteMessage: [],
      answerCallbackQuery: [],
    };
    this.nextMessageId = 100;
    this.stopPolling = jest.fn().mockResolvedValue(undefined);
  }
  async getMe() { return { username: 'loxia_bot', id: 1 }; }
  onText(regex, fn) { this._textHandlers.push({ regex, fn }); }
  on(event, fn) {
    if (event === 'message') this._messageHandlers.push(fn);
    else if (event === 'callback_query') this._callbackHandlers.push(fn);
  }
  async sendMessage(chatId, text, opts) {
    const id = this.nextMessageId++;
    this.calls.sendMessage.push({ chatId, text, opts, message_id: id });
    return { message_id: id, chat: { id: chatId } };
  }
  async editMessageText(text, opts) { this.calls.editMessageText.push({ text, opts }); return { ok: true }; }
  async deleteMessage(chatId, message_id) { this.calls.deleteMessage.push({ chatId, message_id }); }
  async sendChatAction(chatId, action) { this.calls.sendChatAction.push({ chatId, action }); }
  async sendPhoto(chatId, photo, opts) { this.calls.sendPhoto.push({ chatId, photo, opts }); }
  async sendDocument(chatId, doc, opts) { this.calls.sendDocument.push({ chatId, doc, opts }); }
  async answerCallbackQuery(id) { this.calls.answerCallbackQuery.push(id); }
  async getFileLink(fileId) { return `https://t.example/file/${fileId}`; }

  // Test helpers: simulate an incoming message / callback.
  async simulateMessage(msg) {
    // Telegram fires onText first when text matches a regex, then `message` listeners.
    if (typeof msg.text === 'string') {
      for (const { regex, fn } of this._textHandlers) {
        const m = msg.text.match(regex);
        if (m) await fn(msg, m);
      }
    }
    for (const fn of this._messageHandlers) await fn(msg);
  }
  async simulateCallback(query) {
    for (const fn of this._callbackHandlers) await fn(query);
  }
}

let currentBot;
jest.unstable_mockModule('node-telegram-bot-api', () => ({
  default: jest.fn(() => {
    currentBot = new MockBot();
    return currentBot;
  }),
}));

// ─── Imports after mocks ──────────────────────────────────────────────

const { TelegramService, TELEGRAM_STATUS, _resetTelegramSingletonForTests } = await import('../telegramService.js');

// ─── Test helpers ─────────────────────────────────────────────────────

const SAMPLE_AGENTS = [
  { id: 'a1', name: 'Cody',  mode: 'chat', status: 'idle', currentModel: 'gpt-4.1' },
  { id: 'a2', name: 'Lina',  mode: 'auto', status: 'active', currentModel: 'claude-sonnet-4-5' },
];

function makeService({ agents = SAMPLE_AGENTS, withOrchestrator = true } = {}) {
  const svc = new TelegramService(null);
  svc.setAgentPool({
    getAllAgents: jest.fn().mockResolvedValue(agents),
    getAgent: jest.fn((id) => Promise.resolve(agents.find(a => a.id === id) || null)),
  });
  if (withOrchestrator) {
    svc.setOrchestrator({
      config: { project: { directory: '/proj' } },
      processRequest: jest.fn().mockResolvedValue(undefined),
      messageProcessor: {
        stopAutonomousExecution: jest.fn().mockResolvedValue(undefined),
        resetAgentConversation: jest.fn().mockResolvedValue(undefined),
      },
      stateManager: {
        loadFlowIndex: jest.fn().mockResolvedValue({}),
      },
    });
  }
  return svc;
}

function userMsg(chatId, text, { chatType = 'private', from = { username: 'alice' }, title } = {}) {
  return {
    chat: { id: chatId, type: chatType, title },
    from,
    text,
    message_id: 1,
  };
}

beforeEach(() => {
  virtualFs.content = null;
  _resetTelegramSingletonForTests();
  currentBot = null;
});

async function connected(svc) {
  await svc.connect('TEST_TOKEN');
  return currentBot;
}

// ─────────────────────────────────────────────────────────────────────
// @-prefix resolution (names with spaces, slug form, ambiguity)
// ─────────────────────────────────────────────────────────────────────

describe('_resolveAtPrefix — @agent-name parsing', () => {
  const agents = [
    { id: 'a1', name: 'Cody' },
    { id: 'a2', name: 'John Smith' },
    { id: 'a3', name: 'John' },                  // prefix-conflict with #a2
    { id: 'a4', name: 'Multi Word Long Name' },
  ];

  test('no @prefix → agentName null, messageText unchanged', () => {
    const svc = makeService({ agents });
    const r = svc._resolveAtPrefix('just a message', agents);
    expect(r.agentName).toBeNull();
    expect(r.matchedAgent).toBeNull();
    expect(r.messageText).toBe('just a message');
  });

  test('single-word name (legacy path)', () => {
    const svc = makeService({ agents });
    const r = svc._resolveAtPrefix('@Cody hello', agents);
    expect(r.matchedAgent?.id).toBe('a1');
    expect(r.messageText).toBe('hello');
  });

  test('multi-word name with literal spaces works', () => {
    const svc = makeService({ agents });
    const r = svc._resolveAtPrefix('@John Smith write the report', agents);
    expect(r.matchedAgent?.id).toBe('a2');
    expect(r.agentName).toBe('John Smith');
    expect(r.messageText).toBe('write the report');
  });

  test('slug form @John_Smith resolves to "John Smith"', () => {
    const svc = makeService({ agents });
    const r = svc._resolveAtPrefix('@John_Smith write the report', agents);
    expect(r.matchedAgent?.id).toBe('a2');
    expect(r.messageText).toBe('write the report');
  });

  test('dash form @John-Smith also works', () => {
    const svc = makeService({ agents });
    const r = svc._resolveAtPrefix('@John-Smith hi', agents);
    expect(r.matchedAgent?.id).toBe('a2');
    expect(r.messageText).toBe('hi');
  });

  test('longest-match wins when one name is a prefix of another', () => {
    // "@John Smith hello" must NOT resolve to the "John" agent.
    const svc = makeService({ agents });
    const r = svc._resolveAtPrefix('@John Smith hello', agents);
    expect(r.matchedAgent?.id).toBe('a2');
  });

  test('shorter name still wins when the longer name doesn\'t match', () => {
    const svc = makeService({ agents });
    const r = svc._resolveAtPrefix('@John hello', agents);
    expect(r.matchedAgent?.id).toBe('a3');
  });

  test('case-insensitive match', () => {
    const svc = makeService({ agents });
    const r = svc._resolveAtPrefix('@JOHN SMITH hi', agents);
    expect(r.matchedAgent?.id).toBe('a2');
  });

  test('extra whitespace between the name and the message is collapsed', () => {
    const svc = makeService({ agents });
    const r = svc._resolveAtPrefix('@Cody   \t  hello', agents);
    expect(r.messageText).toBe('hello');
  });

  test('partial-word collision does NOT false-match (Johnson should not match John)', () => {
    const svc = makeService({ agents });
    const r = svc._resolveAtPrefix('@Johnson hi', agents);
    // Falls back to legacy capture so caller can show "not found"
    expect(r.matchedAgent).toBeNull();
    expect(r.agentName).toBe('Johnson');
  });

  test('unknown @name → legacy capture for "not found" error', () => {
    const svc = makeService({ agents });
    const r = svc._resolveAtPrefix('@Stranger hi', agents);
    expect(r.matchedAgent).toBeNull();
    expect(r.agentName).toBe('Stranger');
    expect(r.messageText).toBe('hi');
  });

  test('handles regex-sensitive characters in agent names safely', () => {
    const dangerous = [{ id: 'a1', name: 'C++.parser' }];
    const svc = makeService({ agents: dangerous });
    const r = svc._resolveAtPrefix('@C++.parser go', dangerous);
    expect(r.matchedAgent?.id).toBe('a1');
    expect(r.messageText).toBe('go');
  });

  test('many-word names ("Multi Word Long Name") match end-to-end', () => {
    const svc = makeService({ agents });
    const r = svc._resolveAtPrefix('@Multi Word Long Name run the audit', agents);
    expect(r.matchedAgent?.id).toBe('a4');
    expect(r.messageText).toBe('run the audit');
  });
});

// ─────────────────────────────────────────────────────────────────────
// Multi-chat /start
// ─────────────────────────────────────────────────────────────────────

describe('multi-chat /start', () => {
  test('first chat /start registers; second chat /start ALSO registers (no global lock)', async () => {
    const svc = makeService();
    const bot = await connected(svc);
    await bot.simulateMessage(userMsg(111, '/start'));
    await bot.simulateMessage(userMsg(222, '/start'));
    expect(svc.chats.has('111')).toBe(true);
    expect(svc.chats.has('222')).toBe(true);
    expect(svc.getStatus().chatCount).toBe(2);
  });

  test('same chat /start twice does NOT duplicate state', async () => {
    const svc = makeService();
    const bot = await connected(svc);
    await bot.simulateMessage(userMsg(111, '/start'));
    await bot.simulateMessage(userMsg(111, '/start'));
    expect(svc.chats.size).toBe(1);
    // Second invocation should reply "Already connected".
    const greetings = bot.calls.sendMessage.filter(c => String(c.chatId) === '111');
    expect(greetings.length).toBeGreaterThanOrEqual(2);
    expect(greetings[1].text).toMatch(/Already connected/);
  });
});

// ─────────────────────────────────────────────────────────────────────
// /logout, /reset, /new
// ─────────────────────────────────────────────────────────────────────

describe('/logout', () => {
  test('removes chat from registered set', async () => {
    const svc = makeService();
    const bot = await connected(svc);
    await bot.simulateMessage(userMsg(111, '/start'));
    await bot.simulateMessage(userMsg(111, '/logout'));
    expect(svc.chats.has('111')).toBe(false);
  });
});

describe('/reset', () => {
  test('calls resetAgentConversation on the sticky agent', async () => {
    const svc = makeService();
    const bot = await connected(svc);
    await bot.simulateMessage(userMsg(111, '/start'));
    svc.chats.get('111').lastAgentId = 'a1';
    svc.chats.get('111').activeAgentIds.add('a1');

    await bot.simulateMessage(userMsg(111, '/reset'));
    expect(svc.orchestrator.messageProcessor.resetAgentConversation).toHaveBeenCalledWith('a1');
  });

  test('returns friendly message when no sticky agent is set', async () => {
    const svc = makeService();
    const bot = await connected(svc);
    await bot.simulateMessage(userMsg(111, '/start'));
    bot.calls.sendMessage = [];
    await bot.simulateMessage(userMsg(111, '/reset'));
    const last = bot.calls.sendMessage[bot.calls.sendMessage.length - 1];
    expect(last.text).toMatch(/No active agent/);
    expect(svc.orchestrator.messageProcessor.resetAgentConversation).not.toHaveBeenCalled();
  });
});

describe('/new <agent>', () => {
  test('switches sticky agent + resets the new one', async () => {
    const svc = makeService();
    const bot = await connected(svc);
    await bot.simulateMessage(userMsg(111, '/start'));
    await bot.simulateMessage(userMsg(111, '/new Lina'));
    expect(svc.chats.get('111').lastAgentId).toBe('a2');
    expect(svc.orchestrator.messageProcessor.resetAgentConversation).toHaveBeenCalledWith('a2');
  });

  test('unknown agent returns error', async () => {
    const svc = makeService();
    const bot = await connected(svc);
    await bot.simulateMessage(userMsg(111, '/start'));
    bot.calls.sendMessage = [];
    await bot.simulateMessage(userMsg(111, '/new Nobody'));
    expect(bot.calls.sendMessage[0].text).toMatch(/not found/);
  });
});

// ─────────────────────────────────────────────────────────────────────
// End-to-end @-prefix with spaced name
// ─────────────────────────────────────────────────────────────────────

describe('@-prefix dispatch — name with spaces', () => {
  test('"@John Smith write the report" dispatches to the right agent with the right message', async () => {
    const agents = [
      { id: 'js-1', name: 'John Smith', mode: 'chat', status: 'idle', currentModel: 'gpt-4.1' },
      { id: 'cody', name: 'Cody', mode: 'chat', status: 'idle', currentModel: 'gpt-4.1' },
    ];
    const svc = makeService({ agents });
    const bot = await connected(svc);
    await bot.simulateMessage(userMsg(111, '/start'));

    await bot.simulateMessage(userMsg(111, '@John Smith write the report'));

    expect(svc.orchestrator.processRequest).toHaveBeenCalled();
    const payload = svc.orchestrator.processRequest.mock.calls.slice(-1)[0][0].payload;
    expect(payload.agentId).toBe('js-1');
    expect(payload.message).toBe('write the report');
  });

  test('unknown @name surfaces a helpful "not found" reply, no orchestrator call', async () => {
    const svc = makeService();
    const bot = await connected(svc);
    await bot.simulateMessage(userMsg(111, '/start'));
    bot.calls.sendMessage = [];

    await bot.simulateMessage(userMsg(111, '@Nobody hello'));

    expect(svc.orchestrator.processRequest).not.toHaveBeenCalled();
    const reply = bot.calls.sendMessage[0];
    expect(reply.text).toMatch(/not found/);
    // The friendly tip explaining slug syntax must be present so users
    // who typed an underscore version learn the syntax. Underscores
    // are MarkdownV2-escaped on the wire (`John\_Smith`), so allow
    // any single character between the two halves.
    expect(reply.text).toMatch(/John.Smith/);
  });
});

// ─────────────────────────────────────────────────────────────────────
// Group chat @mention gating
// ─────────────────────────────────────────────────────────────────────

describe('group chat — @mention required', () => {
  test('plain message in group is IGNORED (no orchestrator call)', async () => {
    const svc = makeService();
    const bot = await connected(svc);
    await bot.simulateMessage(userMsg(-100, '/start', { chatType: 'supergroup', title: 'Ops' }));
    svc.chats.get('-100').lastAgentId = 'a1';
    svc.chats.get('-100').activeAgentIds.add('a1');

    await bot.simulateMessage(userMsg(-100, 'hello team', { chatType: 'supergroup' }));
    expect(svc.orchestrator.processRequest).not.toHaveBeenCalled();
  });

  test('@bot message in group strips mention and dispatches', async () => {
    const svc = makeService();
    const bot = await connected(svc);
    await bot.simulateMessage(userMsg(-100, '/start', { chatType: 'supergroup', title: 'Ops' }));
    svc.chats.get('-100').lastAgentId = 'a1';
    svc.chats.get('-100').activeAgentIds.add('a1');

    await bot.simulateMessage(userMsg(-100, '@loxia_bot summarize last meeting', { chatType: 'supergroup' }));
    expect(svc.orchestrator.processRequest).toHaveBeenCalled();
    const payload = svc.orchestrator.processRequest.mock.calls[0][0].payload;
    expect(payload.message).toBe('summarize last meeting');
  });

  test('private chat does NOT require @mention', async () => {
    const svc = makeService();
    const bot = await connected(svc);
    await bot.simulateMessage(userMsg(111, '/start'));
    svc.chats.get('111').lastAgentId = 'a1';
    svc.chats.get('111').activeAgentIds.add('a1');

    await bot.simulateMessage(userMsg(111, 'hi there'));
    expect(svc.orchestrator.processRequest).toHaveBeenCalled();
  });
});

// ─────────────────────────────────────────────────────────────────────
// Thinking placeholder & edit
// ─────────────────────────────────────────────────────────────────────

describe('thinking placeholder', () => {
  test('typing action + placeholder sent before orchestrator call', async () => {
    const svc = makeService();
    const bot = await connected(svc);
    await bot.simulateMessage(userMsg(111, '/start'));
    await bot.simulateMessage(userMsg(111, '@Cody help me'));

    expect(bot.calls.sendChatAction).toContainEqual({ chatId: 111, action: 'typing' });
    const thinking = bot.calls.sendMessage.find(c => c.text.includes('thinking'));
    expect(thinking).toBeDefined();
    expect(thinking.chatId).toBe(111);
  });

  test('placeholder is replaced (edit) when agent emits stream_complete', async () => {
    const svc = makeService();
    const bot = await connected(svc);
    await bot.simulateMessage(userMsg(111, '/start'));
    await bot.simulateMessage(userMsg(111, '@Cody hello'));

    // Simulate broadcast.
    await svc._handleBroadcastEvent({
      type: 'stream_complete',
      agentId: 'a1',
      content: '<external>Sure — here you go.</external>',
      role: 'assistant',
    });

    expect(bot.calls.editMessageText).toHaveLength(1);
    expect(bot.calls.editMessageText[0].text).toMatch(/Cody/);
    expect(bot.calls.editMessageText[0].text).toMatch(/Sure/);
  });
});

// ─────────────────────────────────────────────────────────────────────
// <actions> / <image> / <attachment> blocks
// ─────────────────────────────────────────────────────────────────────

describe('rich reply blocks', () => {
  test('<actions> block becomes inline keyboard with act:agentId:value callbacks', async () => {
    const svc = makeService();
    const bot = await connected(svc);
    await bot.simulateMessage(userMsg(111, '/start'));
    await bot.simulateMessage(userMsg(111, '@Cody what now'));

    await svc._handleBroadcastEvent({
      type: 'stream_complete', agentId: 'a1', role: 'assistant',
      content:
        '<external>Pick one:\n' +
        '<actions>[{"label":"Continue","value":"go"},{"label":"Restart","value":"reset"}]</actions>' +
        '</external>',
    });

    expect(bot.calls.editMessageText).toHaveLength(1);
    const opts = bot.calls.editMessageText[0].opts;
    const kb = opts.reply_markup.inline_keyboard;
    expect(kb.map(row => row[0].text)).toEqual(['Continue', 'Restart']);
    expect(kb[0][0].callback_data).toBe('act:a1:go');
    expect(kb[1][0].callback_data).toBe('act:a1:reset');
  });

  test('button tap dispatches a synthetic agent message', async () => {
    const svc = makeService();
    const bot = await connected(svc);
    await bot.simulateMessage(userMsg(111, '/start'));
    svc.chats.get('111').activeAgentIds.add('a1');
    svc.chats.get('111').lastAgentId = 'a1';

    await bot.simulateCallback({
      id: 'cb-1',
      data: 'act:a1:keep going',
      message: { chat: { id: 111, type: 'private' }, message_id: 999 },
      from: { username: 'alice' },
    });
    expect(svc.orchestrator.processRequest).toHaveBeenCalled();
    const payload = svc.orchestrator.processRequest.mock.calls.slice(-1)[0][0].payload;
    expect(payload.message).toBe('keep going');
  });

  test('<image> block triggers sendPhoto', async () => {
    const svc = makeService();
    const bot = await connected(svc);
    await bot.simulateMessage(userMsg(111, '/start'));
    await bot.simulateMessage(userMsg(111, '@Cody chart'));

    await svc._handleBroadcastEvent({
      type: 'stream_complete', agentId: 'a1', role: 'assistant',
      content: '<external>Here it is:\n<image src="https://example.com/chart.png">Q4 chart</image></external>',
    });
    expect(bot.calls.sendPhoto).toHaveLength(1);
    expect(String(bot.calls.sendPhoto[0].chatId)).toBe('111');
    expect(bot.calls.sendPhoto[0].photo).toBe('https://example.com/chart.png');
  });

  test('<attachment> block triggers sendDocument', async () => {
    const svc = makeService();
    const bot = await connected(svc);
    await bot.simulateMessage(userMsg(111, '/start'));
    await bot.simulateMessage(userMsg(111, '@Cody report'));

    await svc._handleBroadcastEvent({
      type: 'stream_complete', agentId: 'a1', role: 'assistant',
      content: '<external>See report:\n<attachment src="/tmp/r.pdf" type="application/pdf">Report</attachment></external>',
    });
    expect(bot.calls.sendDocument).toHaveLength(1);
    expect(String(bot.calls.sendDocument[0].chatId)).toBe('111');
    expect(bot.calls.sendDocument[0].doc).toBe('/tmp/r.pdf');
  });
});

// ─────────────────────────────────────────────────────────────────────
// Always-relay errors / watch-gated completions
// ─────────────────────────────────────────────────────────────────────

describe('error fan-out vs watch-gating', () => {
  test('agent_error relays to a chat that has NOT subscribed via /watch', async () => {
    const svc = makeService();
    const bot = await connected(svc);
    await bot.simulateMessage(userMsg(111, '/start'));
    expect(svc.chats.get('111').watchEnabled).toBe(false);

    await svc._handleBroadcastEvent({
      type: 'agent_error', agentId: 'a1', agentName: 'Cody', message: 'boom',
    });
    const sent = bot.calls.sendMessage.find(c => c.text.includes('Agent Error'));
    expect(sent).toBeDefined();
  });

  test('execution_stopped does NOT relay unless /watch is on', async () => {
    const svc = makeService();
    const bot = await connected(svc);
    await bot.simulateMessage(userMsg(111, '/start'));

    await svc._handleBroadcastEvent({
      type: 'execution_stopped', agentId: 'a1', agentName: 'Cody', message: 'done',
    });
    // Notifications are batched on a timer, so we just assert nothing
    // queued at the immediate level.
    expect(bot.calls.sendMessage.find(c => c.text.includes('Agent Finished'))).toBeUndefined();
  });

  test('execution_stopped relays when /watch is on', async () => {
    jest.useFakeTimers();
    try {
      const svc = makeService();
      const bot = await connected(svc);
      await bot.simulateMessage(userMsg(111, '/start'));
      await bot.simulateMessage(userMsg(111, '/watch'));

      await svc._handleBroadcastEvent({
        type: 'execution_stopped', agentId: 'a1', agentName: 'Cody', message: 'done',
      });
      jest.advanceTimersByTime(11000); // flush batch interval (10s)
      await Promise.resolve();          // microtask drain
      // Sent messages are async — check that the queue WOULD have been flushed.
      expect(svc.notificationQueue.size === 0 || bot.calls.sendMessage.some(c => c.text.includes('Agent Finished')))
        .toBe(true);
    } finally {
      jest.useRealTimers();
    }
  });
});

// ─────────────────────────────────────────────────────────────────────
// Pagination
// ─────────────────────────────────────────────────────────────────────

describe('paginated /agents', () => {
  test('first page only shows PAGE_SIZE entries when many agents exist', async () => {
    const many = Array.from({ length: 20 }, (_, i) => ({
      id: `a${i}`, name: `Agent${i}`, mode: 'chat', status: 'idle',
    }));
    const svc = makeService({ agents: many });
    const bot = await connected(svc);
    await bot.simulateMessage(userMsg(111, '/start'));
    bot.calls.sendMessage = [];
    await bot.simulateMessage(userMsg(111, '/agents'));

    const listMsg = bot.calls.sendMessage.find(c => c.text.includes('Agents (20)'));
    expect(listMsg).toBeDefined();
    // Page 1/3 of 20 with PAGE_SIZE=8 → 8 entries shown.
    const buttons = listMsg.opts.reply_markup.inline_keyboard;
    expect(buttons.filter(row => row[0].callback_data?.startsWith('agent_detail:'))).toHaveLength(8);
    // Last row is the pagination nav.
    const nav = buttons[buttons.length - 1];
    expect(nav.some(b => b.callback_data?.startsWith('agents_page:'))).toBe(true);
  });
});

// ─────────────────────────────────────────────────────────────────────
// Markdown vs HTML vs document selection
// ─────────────────────────────────────────────────────────────────────

describe('text formatting selection', () => {
  test('plain text uses MarkdownV2', () => {
    const svc = makeService();
    const out = svc._formatAgentBody('hello world.');
    expect(out.parseMode).toBe('MarkdownV2');
    expect(out.body).toContain('hello world\\.');  // dot escaped
  });

  test('short fenced code uses HTML', () => {
    const svc = makeService();
    const out = svc._formatAgentBody('See:\n```js\nconst x = 1;\n```');
    expect(out.parseMode).toBe('HTML');
    expect(out.body).toMatch(/<pre>const x = 1;/);
  });

  test('long fenced code (> threshold) is sent as document', () => {
    const svc = makeService();
    const big = 'x = 1;\n'.repeat(800);
    const out = svc._formatAgentBody('```py\n' + big + '\n```');
    expect(out.kind).toBe('document');
  });
});

// ─────────────────────────────────────────────────────────────────────
// Config migration
// ─────────────────────────────────────────────────────────────────────

describe('config migration', () => {
  test('legacy { chatId: "x", watchEnabled: true } loads as a single-chat entry', async () => {
    virtualFs.content = JSON.stringify({ chatId: 'X1', watchEnabled: true, botToken: 'T' });
    const svc = makeService();
    await svc._loadConfig();
    expect(svc.chats.has('X1')).toBe(true);
    expect(svc.chats.get('X1').watchEnabled).toBe(true);
  });

  test('legacy fields are dropped on next save', async () => {
    virtualFs.content = JSON.stringify({ chatId: 'X1', watchEnabled: true, botToken: 'T' });
    const svc = makeService();
    await svc._loadConfig();
    await svc._saveConfig();
    const persisted = JSON.parse(virtualFs.content);
    expect(persisted.chatId).toBeUndefined();
    expect(persisted.watchEnabled).toBeUndefined();
    expect(persisted.chats).toHaveLength(1);
    expect(persisted.chats[0].chatId).toBe('X1');
  });
});

// ─────────────────────────────────────────────────────────────────────
// Bridged channels per-chat
// ─────────────────────────────────────────────────────────────────────

describe('getBridgedChannels', () => {
  test('returns one alias per chat the agent is active in, plus a bare "telegram" alias', async () => {
    const svc = makeService();
    const bot = await connected(svc);
    await bot.simulateMessage(userMsg(111, '/start'));
    await bot.simulateMessage(userMsg(222, '/start'));
    svc.chats.get('111').activeAgentIds.add('a1');
    svc.chats.get('222').activeAgentIds.add('a1');

    const bridged = svc.getBridgedChannels('a1');
    const aliases = bridged.map(c => c.alias);
    expect(aliases).toEqual(expect.arrayContaining([
      'telegram:chat-111',
      'telegram:chat-222',
      'telegram',
    ]));
  });

  test('returns empty array when agent has no chat', async () => {
    const svc = makeService();
    await connected(svc);
    expect(svc.getBridgedChannels('a1')).toEqual([]);
  });
});

// ─────────────────────────────────────────────────────────────────────
// imageGenerated → Telegram relay
//
// Background: the imageTool emits a separate broadcast (`imageGenerated`)
// alongside the agent's textual `stream_complete`. Before this fix the
// `imageGenerated` event was silently dropped and the user — who'd
// asked the bot to "draw me X" — saw no image come back to the chat.
//
// Behavior we lock in:
//   - imageGenerated is buffered per agent, flushed on the next
//     stream_complete for the same agent into every active chat that
//     has that agent linked.
//   - dedupes against images the agent already embedded as
//     `<image src="…">` markup so we don't double-post.
//   - works even when the agent's text reply has no `<external>` block
//     at all (image-only turn).
//   - skips chats where the agent isn't active (multi-chat isolation).
// ─────────────────────────────────────────────────────────────────────

describe('imageGenerated relay', () => {
  test('imageGenerated buffered, flushed on stream_complete into the active chat', async () => {
    const svc = makeService();
    const bot = await connected(svc);
    await bot.simulateMessage(userMsg(111, '/start'));
    await bot.simulateMessage(userMsg(111, '@Cody draw a sunset'));

    // Image arrives from the tool first…
    await svc._handleBroadcastEvent({
      type: 'imageGenerated', agentId: 'a1',
      imageUrl: 'https://cdn.example/sunset.png',
      prompt: 'a sunset over Paris',
      success: true,
    });
    // Bot should not have posted the photo yet — we wait for the
    // agent's text completion to keep the order stable.
    expect(bot.calls.sendPhoto).toHaveLength(0);

    // Then the agent's text completion arrives.
    await svc._handleBroadcastEvent({
      type: 'stream_complete', agentId: 'a1', role: 'assistant',
      content: '<external>Here is your sunset.</external>',
    });

    expect(bot.calls.sendPhoto).toHaveLength(1);
    expect(bot.calls.sendPhoto[0].photo).toBe('https://cdn.example/sunset.png');
    expect(String(bot.calls.sendPhoto[0].chatId)).toBe('111');
  });

  test('still flushes even when stream_complete has no <external> block', async () => {
    const svc = makeService();
    const bot = await connected(svc);
    await bot.simulateMessage(userMsg(111, '/start'));
    await bot.simulateMessage(userMsg(111, '@Cody draw'));

    await svc._handleBroadcastEvent({
      type: 'imageGenerated', agentId: 'a1',
      imageUrl: 'https://cdn.example/only.png',
      success: true,
    });
    await svc._handleBroadcastEvent({
      type: 'stream_complete', agentId: 'a1', role: 'assistant',
      content: 'internal-only narration, no external block',
    });
    expect(bot.calls.sendPhoto).toHaveLength(1);
    expect(bot.calls.sendPhoto[0].photo).toBe('https://cdn.example/only.png');
  });

  test('deduplicates against images the agent already embedded in <external>', async () => {
    const svc = makeService();
    const bot = await connected(svc);
    await bot.simulateMessage(userMsg(111, '/start'));
    await bot.simulateMessage(userMsg(111, '@Cody draw'));

    const url = 'https://cdn.example/dup.png';
    await svc._handleBroadcastEvent({
      type: 'imageGenerated', agentId: 'a1', imageUrl: url, success: true,
    });
    await svc._handleBroadcastEvent({
      type: 'stream_complete', agentId: 'a1', role: 'assistant',
      content: `<external>See:\n<image src="${url}">my pic</image></external>`,
    });
    expect(bot.calls.sendPhoto).toHaveLength(1);
  });

  test('does NOT leak the image to a chat where the agent isn\'t active', async () => {
    const svc = makeService();
    const bot = await connected(svc);
    await bot.simulateMessage(userMsg(111, '/start'));
    await bot.simulateMessage(userMsg(222, '/start'));
    await bot.simulateMessage(userMsg(111, '@Cody draw'));
    // Chat 222 never @-mentioned the agent → activeAgentIds is empty.
    expect(svc.chats.get('222').activeAgentIds.has('a1')).toBe(false);

    await svc._handleBroadcastEvent({
      type: 'imageGenerated', agentId: 'a1',
      imageUrl: 'https://cdn.example/private.png',
      success: true,
    });
    await svc._handleBroadcastEvent({
      type: 'stream_complete', agentId: 'a1', role: 'assistant',
      content: '<external>done</external>',
    });

    const chats = bot.calls.sendPhoto.map(c => String(c.chatId));
    expect(chats).toEqual(['111']);
  });

  test('buffer drops stale entries (>5 min) on next insert', async () => {
    // Direct unit test of the buffer helper — bypasses the
    // `_handleBroadcastEvent` early-return guard (which requires a
    // connected bot + a /start'ed chat) so we can focus on the TTL.
    const svc = makeService();
    const original = Date.now;
    try {
      let now = 1_000_000_000_000;
      Date.now = () => now;

      svc._bufferGeneratedImage({ agentId: 'a1', imageUrl: 'https://cdn.example/stale.png' });
      now += 6 * 60 * 1000; // 6 min later
      svc._bufferGeneratedImage({ agentId: 'a1', imageUrl: 'https://cdn.example/fresh.png' });
      const remaining = svc._pendingImagesByAgent.get('a1');
      expect(remaining.map(e => e.imageUrl)).toEqual(['https://cdn.example/fresh.png']);
    } finally {
      Date.now = original;
    }
  });

  test('imageGenerated with no imageUrl is ignored (no crash)', () => {
    const svc = makeService();
    svc._bufferGeneratedImage({ agentId: 'a1' });
    expect(svc._pendingImagesByAgent.has('a1')).toBe(false);
  });
});

