import fs from 'fs';
import os from 'os';
import path from 'path';

import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';

// --- Mocks ---

// Mock registry (registerChannel runs at import time)
const registeredChannelFactories = vi.hoisted(
  () => new Map<string, (...args: any[]) => any>(),
);

vi.mock('./registry.js', () => ({
  registerChannel: vi.fn((name: string, factory: (...args: any[]) => any) => {
    registeredChannelFactories.set(name, factory);
  }),
}));

// Mock env reader (used by the factory, not needed in unit tests)
vi.mock('../env.js', () => ({
  readEnvFile: vi.fn(() => ({})),
  getEnv: vi.fn(() => undefined),
}));

// Mock config
vi.mock('../config.js', () => ({
  ASSISTANT_NAME: 'Andy',
  TRIGGER_PATTERN: /^@Andy\b/i,
  DATA_DIR: '/tmp/ejclaw-test-data',
  CACHE_DIR: '/tmp/ejclaw-test-cache',
  STATUS_CHANNEL_ID: 'status-channel',
}));

// Mock logger
vi.mock('../logger.js', () => ({
  logger: {
    debug: vi.fn(),
    info: vi.fn(),
    warn: vi.fn(),
    error: vi.fn(),
  },
}));

const hasReviewerLeaseMock = vi.hoisted(() => vi.fn(() => false));

vi.mock('../service-routing.js', () => ({
  hasReviewerLease: hasReviewerLeaseMock,
}));

// --- discord.js mock ---

type Handler = (...args: any[]) => any;

const clientRef = vi.hoisted(() => ({ current: null as any }));
const loginShouldRejectRef = vi.hoisted(() => ({ value: false }));

vi.mock('discord.js', () => {
  const Events = {
    MessageCreate: 'messageCreate',
    ClientReady: 'ready',
    Error: 'error',
  };

  const GatewayIntentBits = {
    Guilds: 1,
    GuildMessages: 2,
    MessageContent: 4,
    DirectMessages: 8,
  };

  class MockClient {
    eventHandlers = new Map<string, Handler[]>();
    user: any = { id: '999888777', tag: 'Andy#1234' };
    private _ready = false;

    constructor(_opts: any) {
      clientRef.current = this;
    }

    on(event: string, handler: Handler) {
      const existing = this.eventHandlers.get(event) || [];
      existing.push(handler);
      this.eventHandlers.set(event, existing);
      return this;
    }

    once(event: string, handler: Handler) {
      return this.on(event, handler);
    }

    async login(_token: string) {
      if (loginShouldRejectRef.value) {
        throw new Error('An invalid token was provided.');
      }
      this._ready = true;
      // Fire the ready event
      const readyHandlers = this.eventHandlers.get('ready') || [];
      for (const h of readyHandlers) {
        h({ user: this.user });
      }
    }

    isReady() {
      return this._ready;
    }

    channels = {
      fetch: vi.fn().mockResolvedValue({
        send: vi.fn().mockResolvedValue(undefined),
        sendTyping: vi.fn().mockResolvedValue(undefined),
      }),
    };

    destroy() {
      this._ready = false;
    }
  }

  // Mock TextChannel type
  class TextChannel {}

  return {
    Client: MockClient,
    Events,
    GatewayIntentBits,
    MessageFlags: { SuppressEmbeds: 1 << 2, IsVoiceMessage: 1 << 13 },
    TextChannel,
  };
});

import { DiscordChannel, DiscordChannelOpts } from './discord.js';
import { logger } from '../logger.js';

// --- Test helpers ---

const ONE_PIXEL_PNG = Buffer.from(
  'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=',
  'base64',
);

function createTempPng(name = 'image.png'): { dir: string; filePath: string } {
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-discord-image-'));
  const filePath = path.join(dir, name);
  fs.writeFileSync(filePath, ONE_PIXEL_PNG);
  return { dir, filePath };
}

function createTestOpts(
  overrides?: Partial<DiscordChannelOpts>,
): DiscordChannelOpts {
  return {
    onMessage: vi.fn(),
    onChatMetadata: vi.fn(),
    roomBindings: vi.fn(() => ({
      'dc:1234567890123456': {
        name: 'Test Server #general',
        folder: 'test-server',
        trigger: '@Andy',
        added_at: '2024-01-01T00:00:00.000Z',
      },
    })),
    ...overrides,
  };
}

function createMessage(overrides: {
  channelId?: string;
  content?: string;
  authorId?: string;
  authorUsername?: string;
  authorDisplayName?: string;
  memberDisplayName?: string;
  isBot?: boolean;
  guildName?: string;
  channelName?: string;
  messageId?: string;
  createdAt?: Date;
  attachments?: Map<string, any>;
  reference?: { messageId?: string };
  mentionsBotId?: boolean;
}) {
  const channelId = overrides.channelId ?? '1234567890123456';
  const authorId = overrides.authorId ?? '55512345';
  const botId = '999888777'; // matches mock client user id

  const mentionsMap = new Map();
  if (overrides.mentionsBotId) {
    mentionsMap.set(botId, { id: botId });
  }

  return {
    channelId,
    id: overrides.messageId ?? 'msg_001',
    content: overrides.content ?? 'Hello everyone',
    createdAt: overrides.createdAt ?? new Date('2024-01-01T00:00:00.000Z'),
    author: {
      id: authorId,
      username: overrides.authorUsername ?? 'alice',
      displayName: overrides.authorDisplayName ?? 'Alice',
      bot: overrides.isBot ?? false,
    },
    member: overrides.memberDisplayName
      ? { displayName: overrides.memberDisplayName }
      : null,
    guild: overrides.guildName ? { name: overrides.guildName } : null,
    channel: {
      name: overrides.channelName ?? 'general',
      messages: {
        fetch: vi.fn().mockResolvedValue({
          author: { username: 'Bob', displayName: 'Bob' },
          member: { displayName: 'Bob' },
        }),
      },
    },
    mentions: {
      users: mentionsMap,
    },
    attachments: overrides.attachments ?? new Map(),
    reference: overrides.reference ?? null,
    flags: { has: () => false },
  };
}

function currentClient() {
  return clientRef.current;
}

async function triggerMessage(message: any) {
  const handlers = currentClient().eventHandlers.get('messageCreate') || [];
  for (const h of handlers) await h(message);
}

// --- Tests ---

beforeEach(() => {
  vi.clearAllMocks();
  hasReviewerLeaseMock.mockReturnValue(false);
  loginShouldRejectRef.value = false;
});

afterEach(() => {
  vi.restoreAllMocks();
});

describe('channel registration', () => {
  it('warns when the canonical owner token is not configured', () => {
    const ownerFactory = registeredChannelFactories.get('discord');

    expect(ownerFactory).toBeTypeOf('function');
    expect(ownerFactory?.(createTestOpts() as any)).toBeNull();
    expect(logger.warn).toHaveBeenCalledWith(
      'Discord: DISCORD_OWNER_BOT_TOKEN not set',
    );
  });
});

// --- Connection lifecycle ---

describe('connection lifecycle', () => {
  it('resolves connect() when client is ready', async () => {
    const opts = createTestOpts();
    const channel = new DiscordChannel('test-token', opts);

    await channel.connect();

    expect(channel.isConnected()).toBe(true);
  });

  it('rejects connect() when login fails', async () => {
    loginShouldRejectRef.value = true;
    const opts = createTestOpts();
    const channel = new DiscordChannel('bad-token', opts);

    await expect(channel.connect()).rejects.toThrow(
      'An invalid token was provided.',
    );
    expect(channel.isConnected()).toBe(false);
  });

  it('registers message handlers on connect', async () => {
    const opts = createTestOpts();
    const channel = new DiscordChannel('test-token', opts);

    await channel.connect();

    expect(currentClient().eventHandlers.has('messageCreate')).toBe(true);
    expect(currentClient().eventHandlers.has('error')).toBe(true);
    expect(currentClient().eventHandlers.has('ready')).toBe(true);
  });

  it('disconnects cleanly', async () => {
    const opts = createTestOpts();
    const channel = new DiscordChannel('test-token', opts);

    await channel.connect();
    expect(channel.isConnected()).toBe(true);

    await channel.disconnect();
    expect(channel.isConnected()).toBe(false);
  });

  it('isConnected() returns false before connect', () => {
    const opts = createTestOpts();
    const channel = new DiscordChannel('test-token', opts);

    expect(channel.isConnected()).toBe(false);
  });
});

// --- Text message handling ---

describe('text message handling', () => {
  it('delivers message for registered channel', async () => {
    const opts = createTestOpts();
    const channel = new DiscordChannel('test-token', opts);
    await channel.connect();

    const msg = createMessage({
      content: 'Hello everyone',
      guildName: 'Test Server',
      channelName: 'general',
    });
    await triggerMessage(msg);

    expect(opts.onChatMetadata).toHaveBeenCalledWith(
      'dc:1234567890123456',
      expect.any(String),
      'Test Server #general',
      'discord',
      true,
    );
    expect(opts.onMessage).toHaveBeenCalledWith(
      'dc:1234567890123456',
      expect.objectContaining({
        id: 'msg_001',
        chat_jid: 'dc:1234567890123456',
        sender: '55512345',
        sender_name: 'Alice',
        content: 'Hello everyone',
        is_from_me: false,
      }),
    );
  });

  it('only emits metadata for unregistered channels', async () => {
    const opts = createTestOpts();
    const channel = new DiscordChannel('test-token', opts);
    await channel.connect();

    const msg = createMessage({
      channelId: '9999999999999999',
      content: 'Unknown channel',
      guildName: 'Other Server',
    });
    await triggerMessage(msg);

    expect(opts.onChatMetadata).toHaveBeenCalledWith(
      'dc:9999999999999999',
      expect.any(String),
      expect.any(String),
      'discord',
      true,
    );
    expect(opts.onMessage).not.toHaveBeenCalled();
  });

  it('ignores its own bot messages', async () => {
    const opts = createTestOpts();
    const channel = new DiscordChannel('test-token', opts);
    await channel.connect();

    const msg = createMessage({
      authorId: '999888777',
      isBot: true,
      content: 'I am the connected bot',
    });
    await triggerMessage(msg);

    expect(opts.onMessage).not.toHaveBeenCalled();
  });

  it('ignores other bot messages in normal rooms', async () => {
    const opts = createTestOpts();
    const channel = new DiscordChannel('test-token', opts);
    await channel.connect();

    const msg = createMessage({
      authorId: '111222333',
      isBot: true,
      content: 'I am another bot',
    });
    await triggerMessage(msg);

    expect(opts.onMessage).not.toHaveBeenCalled();
  });

  it('delivers other bot messages in paired rooms', async () => {
    hasReviewerLeaseMock.mockReturnValue(true);

    const opts = createTestOpts();
    const channel = new DiscordChannel('test-token', opts);
    await channel.connect();

    const msg = createMessage({
      authorId: '111222333',
      isBot: true,
      content: 'I am another bot',
    });
    await triggerMessage(msg);

    expect(opts.onMessage).toHaveBeenCalledWith(
      'dc:1234567890123456',
      expect.objectContaining({
        content: 'I am another bot',
        is_bot_message: true,
      }),
    );
  });

  it('uses member displayName when available (server nickname)', async () => {
    const opts = createTestOpts();
    const channel = new DiscordChannel('test-token', opts);
    await channel.connect();

    const msg = createMessage({
      content: 'Hi',
      memberDisplayName: 'Alice Nickname',
      authorDisplayName: 'Alice Global',
      guildName: 'Server',
    });
    await triggerMessage(msg);

    expect(opts.onMessage).toHaveBeenCalledWith(
      'dc:1234567890123456',
      expect.objectContaining({ sender_name: 'Alice Nickname' }),
    );
  });

  it('falls back to author displayName when no member', async () => {
    const opts = createTestOpts();
    const channel = new DiscordChannel('test-token', opts);
    await channel.connect();

    const msg = createMessage({
      content: 'Hi',
      memberDisplayName: undefined,
      authorDisplayName: 'Alice Global',
      guildName: 'Server',
    });
    await triggerMessage(msg);

    expect(opts.onMessage).toHaveBeenCalledWith(
      'dc:1234567890123456',
      expect.objectContaining({ sender_name: 'Alice Global' }),
    );
  });

  it('uses sender name for DM chats (no guild)', async () => {
    const opts = createTestOpts({
      roomBindings: vi.fn(() => ({
        'dc:1234567890123456': {
          name: 'DM',
          folder: 'dm',
          trigger: '@Andy',
          added_at: '2024-01-01T00:00:00.000Z',
        },
      })),
    });
    const channel = new DiscordChannel('test-token', opts);
    await channel.connect();

    const msg = createMessage({
      content: 'Hello',
      guildName: undefined,
      authorDisplayName: 'Alice',
    });
    await triggerMessage(msg);

    expect(opts.onChatMetadata).toHaveBeenCalledWith(
      'dc:1234567890123456',
      expect.any(String),
      'Alice',
      'discord',
      false,
    );
  });

  it('uses guild name + channel name for server messages', async () => {
    const opts = createTestOpts();
    const channel = new DiscordChannel('test-token', opts);
    await channel.connect();

    const msg = createMessage({
      content: 'Hello',
      guildName: 'My Server',
      channelName: 'bot-chat',
    });
    await triggerMessage(msg);

    expect(opts.onChatMetadata).toHaveBeenCalledWith(
      'dc:1234567890123456',
      expect.any(String),
      'My Server #bot-chat',
      'discord',
      true,
    );
  });
});

// --- bot mention handling ---

describe('bot mention handling', () => {
  it('passes through <@botId> mentions without rewriting them', async () => {
    const opts = createTestOpts();
    const channel = new DiscordChannel('test-token', opts);
    await channel.connect();

    const msg = createMessage({
      content: '<@999888777> what time is it?',
      mentionsBotId: true,
      guildName: 'Server',
    });
    await triggerMessage(msg);

    expect(opts.onMessage).toHaveBeenCalledWith(
      'dc:1234567890123456',
      expect.objectContaining({
        content: '<@999888777> what time is it?',
      }),
    );
  });

  it('leaves mixed text and mentions untouched', async () => {
    const opts = createTestOpts();
    const channel = new DiscordChannel('test-token', opts);
    await channel.connect();

    const msg = createMessage({
      content: '@Andy hello <@999888777>',
      mentionsBotId: true,
      guildName: 'Server',
    });
    await triggerMessage(msg);

    // Should NOT prepend @Andy — already starts with trigger
    // But the <@botId> should still be stripped
    expect(opts.onMessage).toHaveBeenCalledWith(
      'dc:1234567890123456',
      expect.objectContaining({
        content: '@Andy hello <@999888777>',
      }),
    );
  });

  it('does not translate when bot is not mentioned', async () => {
    const opts = createTestOpts();
    const channel = new DiscordChannel('test-token', opts);
    await channel.connect();

    const msg = createMessage({
      content: 'hello everyone',
      guildName: 'Server',
    });
    await triggerMessage(msg);

    expect(opts.onMessage).toHaveBeenCalledWith(
      'dc:1234567890123456',
      expect.objectContaining({
        content: 'hello everyone',
      }),
    );
  });

  it('passes through <@!botId> nickname mentions without rewriting them', async () => {
    const opts = createTestOpts();
    const channel = new DiscordChannel('test-token', opts);
    await channel.connect();

    const msg = createMessage({
      content: '<@!999888777> check this',
      mentionsBotId: true,
      guildName: 'Server',
    });
    await triggerMessage(msg);

    expect(opts.onMessage).toHaveBeenCalledWith(
      'dc:1234567890123456',
      expect.objectContaining({
        content: '<@!999888777> check this',
      }),
    );
  });
});

// --- Attachments ---

describe('attachments', () => {
  let originalFetch: typeof globalThis.fetch;

  beforeEach(() => {
    originalFetch = globalThis.fetch;
    vi.stubGlobal(
      'fetch',
      vi.fn().mockResolvedValue({
        ok: true,
        arrayBuffer: () =>
          Promise.resolve(
            new TextEncoder().encode('Hello from text file').buffer,
          ),
        text: () => Promise.resolve('Hello from text file'),
      }),
    );
  });

  afterEach(() => {
    globalThis.fetch = originalFetch;
  });

  it('stores image attachment with placeholder', async () => {
    const opts = createTestOpts();
    const channel = new DiscordChannel('test-token', opts);
    await channel.connect();

    const attachments = new Map([
      [
        'att1',
        {
          id: 'att1',
          name: 'photo.png',
          contentType: 'image/png',
          url: 'https://cdn.example.com/photo.png',
        },
      ],
    ]);
    const msg = createMessage({
      content: '',
      attachments,
      guildName: 'Server',
    });
    await triggerMessage(msg);

    expect(opts.onMessage).toHaveBeenCalledWith(
      'dc:1234567890123456',
      expect.objectContaining({
        content: expect.stringMatching(/^\[Image: .+\.png\]$/),
      }),
    );
  });

  it('stores video attachment with a visible unsupported marker', async () => {
    const opts = createTestOpts();
    const channel = new DiscordChannel('test-token', opts);
    await channel.connect();

    const attachments = new Map([
      [
        'att1',
        {
          id: 'att1',
          name: 'clip.mp4',
          contentType: 'video/mp4',
          url: 'https://cdn.example.com/clip.mp4',
        },
      ],
    ]);
    const msg = createMessage({
      content: '',
      attachments,
      guildName: 'Server',
    });
    await triggerMessage(msg);

    expect(opts.onMessage).toHaveBeenCalledWith(
      'dc:1234567890123456',
      expect.objectContaining({
        content: expect.stringMatching(
          /^\[Video unavailable: clip\.mp4 → .+\.mp4 — video\/mp4 is not loaded as structured model input\]$/,
        ),
      }),
    );
  });

  it('stores PDF file attachment as a structured document reference', async () => {
    const opts = createTestOpts();
    const channel = new DiscordChannel('test-token', opts);
    await channel.connect();

    const attachments = new Map([
      [
        'att1',
        {
          id: 'att1',
          name: 'report.pdf',
          contentType: 'application/pdf',
          url: 'https://cdn.example.com/report.pdf',
        },
      ],
    ]);
    const msg = createMessage({
      content: '',
      attachments,
      guildName: 'Server',
    });
    await triggerMessage(msg);

    expect(opts.onMessage).toHaveBeenCalledWith(
      'dc:1234567890123456',
      expect.objectContaining({
        content: expect.stringMatching(/^\[File: report\.pdf → .+\.pdf\]$/),
      }),
    );
  });

  it('stores zip attachment with a visible unsupported marker', async () => {
    const opts = createTestOpts();
    const channel = new DiscordChannel('test-token', opts);
    await channel.connect();

    const attachments = new Map([
      [
        'att1',
        {
          id: 'att1',
          name: 'display_latency_atopile_rev09.zip',
          contentType: 'application/zip',
          url: 'https://cdn.example.com/display_latency_atopile_rev09.zip',
        },
      ],
    ]);
    const msg = createMessage({
      content: '',
      attachments,
      guildName: 'Server',
    });
    await triggerMessage(msg);

    expect(opts.onMessage).toHaveBeenCalledWith(
      'dc:1234567890123456',
      expect.objectContaining({
        content: expect.stringMatching(
          /^\[File unavailable: display_latency_atopile_rev09\.zip → .+\.zip — application\/zip is not loaded as structured model input\]$/,
        ),
      }),
    );
  });

  it('skips oversized file attachments before download', async () => {
    const opts = createTestOpts();
    const channel = new DiscordChannel('test-token', opts);
    await channel.connect();

    const attachments = new Map([
      [
        'att1',
        {
          id: 'att1',
          name: 'huge.zip',
          contentType: 'application/zip',
          size: 51 * 1024 * 1024,
          url: 'https://cdn.example.com/huge.zip',
        },
      ],
    ]);
    const msg = createMessage({
      content: '',
      attachments,
      guildName: 'Server',
    });
    await triggerMessage(msg);

    expect(globalThis.fetch).not.toHaveBeenCalled();
    expect(opts.onMessage).toHaveBeenCalledWith(
      'dc:1234567890123456',
      expect.objectContaining({
        content:
          '[File: huge.zip (too large to download: 51.0 MiB > 50.0 MiB)]',
      }),
    );
  });

  it('includes text content with attachments', async () => {
    const opts = createTestOpts();
    const channel = new DiscordChannel('test-token', opts);
    await channel.connect();

    const attachments = new Map([
      [
        'att1',
        {
          id: 'att1',
          name: 'photo.jpg',
          contentType: 'image/jpeg',
          url: 'https://cdn.example.com/photo.jpg',
        },
      ],
    ]);
    const msg = createMessage({
      content: 'Check this out',
      attachments,
      guildName: 'Server',
    });
    await triggerMessage(msg);

    expect(opts.onMessage).toHaveBeenCalledWith(
      'dc:1234567890123456',
      expect.objectContaining({
        content: expect.stringMatching(/^Check this out\n\[Image: .+\.jpg\]$/),
      }),
    );
  });

  it('handles multiple attachments', async () => {
    const opts = createTestOpts();
    const channel = new DiscordChannel('test-token', opts);
    await channel.connect();

    const attachments = new Map([
      [
        'att1',
        {
          id: 'att1',
          name: 'a.png',
          contentType: 'image/png',
          url: 'https://cdn.example.com/a.png',
        },
      ],
      [
        'att2',
        {
          id: 'att2',
          name: 'b.txt',
          contentType: 'text/plain',
          url: 'https://cdn.example.com/b.txt',
        },
      ],
    ]);
    const msg = createMessage({
      content: '',
      attachments,
      guildName: 'Server',
    });
    await triggerMessage(msg);

    expect(opts.onMessage).toHaveBeenCalledWith(
      'dc:1234567890123456',
      expect.objectContaining({
        content: expect.stringMatching(
          /^\[Image: .+\.png\]\n\[File: b\.txt → .+\.txt\]\nHello from text file$/,
        ),
      }),
    );
  });
});

// --- Reply context ---

describe('reply context', () => {
  it('includes reply author in content', async () => {
    const opts = createTestOpts();
    const channel = new DiscordChannel('test-token', opts);
    await channel.connect();

    const msg = createMessage({
      content: 'I agree with that',
      reference: { messageId: 'original_msg_id' },
      guildName: 'Server',
    });
    await triggerMessage(msg);

    expect(opts.onMessage).toHaveBeenCalledWith(
      'dc:1234567890123456',
      expect.objectContaining({
        content: '[Reply to Bob] I agree with that',
      }),
    );
  });
});

// --- sendMessage ---

describe('sendMessage', () => {
  it('sends message via channel', async () => {
    const opts = createTestOpts();
    const channel = new DiscordChannel('test-token', opts);
    await channel.connect();

    await channel.sendMessage('dc:1234567890123456', 'Hello');

    await currentClient().channels.fetch('1234567890123456');
    expect(currentClient().channels.fetch).toHaveBeenCalledWith(
      '1234567890123456',
    );
  });

  it('strips dc: prefix from JID', async () => {
    const opts = createTestOpts();
    const channel = new DiscordChannel('test-token', opts);
    await channel.connect();

    await channel.sendMessage('dc:9876543210', 'Test');

    expect(currentClient().channels.fetch).toHaveBeenCalledWith('9876543210');
  });

  it('propagates send failure to the caller', async () => {
    const opts = createTestOpts();
    const channel = new DiscordChannel('test-token', opts);
    await channel.connect();

    currentClient().channels.fetch.mockRejectedValueOnce(
      new Error('Channel not found'),
    );

    await expect(
      channel.sendMessage('dc:1234567890123456', 'Will fail'),
    ).rejects.toThrow('Channel not found');
  });

  it('rejects when client is not initialized', async () => {
    const opts = createTestOpts();
    const channel = new DiscordChannel('test-token', opts);

    // Don't connect — client is null
    await expect(
      channel.sendMessage('dc:1234567890123456', 'No client'),
    ).rejects.toThrow('Discord client not initialized');
  });

  it('splits messages exceeding 2000 characters', async () => {
    const opts = createTestOpts();
    const channel = new DiscordChannel('test-token', opts);
    await channel.connect();

    const mockChannel = {
      send: vi.fn().mockResolvedValue(undefined),
      sendTyping: vi.fn(),
    };
    currentClient().channels.fetch.mockResolvedValue(mockChannel);

    const longText = 'x'.repeat(3000);
    await channel.sendMessage('dc:1234567890123456', longText);

    expect(mockChannel.send).toHaveBeenCalledTimes(2);
    expect(mockChannel.send).toHaveBeenNthCalledWith(1, {
      content: 'x'.repeat(2000),
      files: undefined,
      flags: 1 << 2,
    });
    expect(mockChannel.send).toHaveBeenNthCalledWith(2, {
      content: 'x'.repeat(1000),
      files: undefined,
      flags: 1 << 2,
    });
  });

  it('sends structured attachments as Discord files', async () => {
    const opts = createTestOpts();
    const channel = new DiscordChannel('test-token', opts);
    await channel.connect();
    const { dir, filePath } = createTempPng('structured.png');
    const mockChannel = {
      send: vi.fn().mockResolvedValue({ id: 'discord-message-1' }),
      sendTyping: vi.fn(),
    };
    currentClient().channels.fetch.mockResolvedValue(mockChannel);

    await channel.sendMessage('dc:1234567890123456', '이미지를 생성했습니다.', {
      attachments: [{ path: filePath, name: 'result.png', mime: 'image/png' }],
    });

    expect(mockChannel.send).toHaveBeenCalledWith({
      content: '이미지를 생성했습니다.',
      files: [{ attachment: filePath, name: 'result.png' }],
      flags: 1 << 2,
    });
    fs.rmSync(dir, { recursive: true, force: true });
  });

  it('uses legacy image tags as Discord attachment fallback', async () => {
    const opts = createTestOpts();
    const channel = new DiscordChannel('test-token', opts);
    await channel.connect();
    const { dir, filePath } = createTempPng('screenshot.png');
    const mockChannel = {
      send: vi.fn().mockResolvedValue({ id: 'discord-message-1' }),
      sendTyping: vi.fn(),
    };
    currentClient().channels.fetch.mockResolvedValue(mockChannel);

    await channel.sendMessage(
      'dc:1234567890123456',
      `스크린샷입니다.\n[Image: screenshot.png → ${filePath}]`,
    );

    expect(mockChannel.send).toHaveBeenCalledWith({
      content: '스크린샷입니다.',
      files: [{ attachment: filePath, name: 'screenshot.png' }],
      flags: 1 << 2,
    });
    fs.rmSync(dir, { recursive: true, force: true });
  });

  it('logs channel name and Discord message ids after sending', async () => {
    const opts = createTestOpts();
    const channel = new DiscordChannel('test-token', opts);
    await channel.connect();

    const mockChannel = {
      send: vi.fn().mockResolvedValue({ id: 'discord-message-1' }),
      sendTyping: vi.fn(),
    };
    currentClient().channels.fetch.mockResolvedValue(mockChannel);

    await channel.sendMessage('dc:1234567890123456', 'Hello');

    expect(logger.info).toHaveBeenCalledWith(
      expect.objectContaining({
        jid: 'dc:1234567890123456',
        channelName: 'discord',
        deliveryMode: 'send',
        chunkCount: 1,
        messageId: 'discord-message-1',
        messageIds: ['discord-message-1'],
      }),
      'Discord message sent',
    );
  });
});

// --- ownsJid ---

describe('ownsJid', () => {
  it('owns dc: JIDs', () => {
    const channel = new DiscordChannel('test-token', createTestOpts());
    expect(channel.ownsJid('dc:1234567890123456')).toBe(true);
  });

  it('can be configured as an outbound-only role bot', () => {
    const channel = new DiscordChannel(
      'test-token',
      createTestOpts(),
      undefined,
      'discord-review',
      false,
      false,
    );
    expect(channel.ownsJid('dc:1234567890123456')).toBe(false);
  });

  it('does not own WhatsApp group JIDs', () => {
    const channel = new DiscordChannel('test-token', createTestOpts());
    expect(channel.ownsJid('12345@g.us')).toBe(false);
  });

  it('does not own Telegram JIDs', () => {
    const channel = new DiscordChannel('test-token', createTestOpts());
    expect(channel.ownsJid('tg:123456789')).toBe(false);
  });

  it('does not own unknown JID formats', () => {
    const channel = new DiscordChannel('test-token', createTestOpts());
    expect(channel.ownsJid('random-string')).toBe(false);
  });
});

// --- setTyping ---

describe('setTyping', () => {
  it('sends typing indicator when isTyping is true', async () => {
    const opts = createTestOpts();
    const channel = new DiscordChannel('test-token', opts);
    await channel.connect();

    const mockChannel = {
      send: vi.fn(),
      sendTyping: vi.fn().mockResolvedValue(undefined),
    };
    currentClient().channels.fetch.mockResolvedValue(mockChannel);

    await channel.setTyping('dc:1234567890123456', true);

    expect(mockChannel.sendTyping).toHaveBeenCalled();
  });

  it('does nothing when isTyping is false', async () => {
    const opts = createTestOpts();
    const channel = new DiscordChannel('test-token', opts);
    await channel.connect();

    await channel.setTyping('dc:1234567890123456', false);

    // channels.fetch should NOT be called
    expect(currentClient().channels.fetch).not.toHaveBeenCalled();
  });

  it('does not send stale typing after typing was disabled mid-flight', async () => {
    const opts = createTestOpts();
    const channel = new DiscordChannel('test-token', opts);
    await channel.connect();

    let resolveFetch!: (value: unknown) => void;
    const fetchPromise = new Promise((resolve) => {
      resolveFetch = resolve;
    });
    const mockChannel = {
      send: vi.fn(),
      sendTyping: vi.fn().mockResolvedValue(undefined),
    };

    currentClient().channels.fetch.mockImplementationOnce(() => fetchPromise);

    const typingPromise = channel.setTyping('dc:1234567890123456', true);
    await Promise.resolve();
    await channel.setTyping('dc:1234567890123456', false);
    resolveFetch(mockChannel);
    await typingPromise;

    expect(mockChannel.sendTyping).not.toHaveBeenCalled();
  });

  it('does nothing when client is not initialized', async () => {
    const opts = createTestOpts();
    const channel = new DiscordChannel('test-token', opts);

    // Don't connect
    await channel.setTyping('dc:1234567890123456', true);

    // No error
  });
});

// --- Channel properties ---

describe('channel properties', () => {
  it('has name "discord"', () => {
    const channel = new DiscordChannel('test-token', createTestOpts());
    expect(channel.name).toBe('discord');
  });
});
