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

// ── Mock discord.js before importing adapter ──────────────────────

/** Map of event name → handler function for the mock client. */
type HandlerMap = Map<string, ((...args: unknown[]) => void)[]>;

const mockChannelSend = vi.fn();

/** Create a fresh mock client instance. */
function createMockClient() {
  const handlers: HandlerMap = new Map();
  return {
    _handlers: handlers,
    user: null as { id: string; tag: string } | null,
    on: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
      if (!handlers.has(event)) {handlers.set(event, []);}
      handlers.get(event)!.push(handler);
    }),
    login: vi.fn(async (_token: string) => {
      // Set user info after login
      mockClient.user = { id: '123456789', tag: 'TestBot#0001' };
      // Trigger ready event asynchronously
      setTimeout(() => {
        const readyHandlers = handlers.get('ready') ?? [];
        for (const h of readyHandlers) {h();}
      }, 0);
    }),
    destroy: vi.fn(),
    channels: {
      fetch: vi.fn(async (_id: string) => ({
        send: mockChannelSend,
      })),
    },
  };
}

let mockClient = createMockClient();

vi.mock('discord.js', () => ({
  Client: vi.fn().mockImplementation(() => mockClient),
  GatewayIntentBits: {
    Guilds: 1,
    GuildMessages: 2,
    MessageContent: 4,
    DirectMessages: 8,
  },
  Events: {
    ClientReady: 'ready',
    MessageCreate: 'messageCreate',
  },
}));

import { DiscordAdapter, type DiscordAdapterConfig } from './adapter.js';
import type { InboundMessage } from '../types.js';

const baseConfig: DiscordAdapterConfig = {
  botToken: 'test-discord-token',
};

/** Helper: emit a mock event on the client. */
function emitEvent(eventName: string, ...args: unknown[]) {
  const eventHandlers = mockClient._handlers.get(eventName) ?? [];
  for (const handler of eventHandlers) {
    handler(...args);
  }
}

/** Helper: create a mock Discord message for guild channels. */
function createGuildMessage(overrides: Record<string, unknown> = {}) {
  return {
    id: 'msg-1',
    content: 'Hello Flynn',
    author: { bot: false, username: 'TestUser' },
    guild: { id: 'guild-1' },
    channelId: 'channel-1',
    mentions: {
      has: vi.fn().mockReturnValue(false),
    },
    ...overrides,
  };
}

/** Helper: create a mock Discord DM message. */
function createDMMessage(overrides: Record<string, unknown> = {}) {
  return {
    id: 'dm-1',
    content: 'Hello from DM',
    author: { bot: false, username: 'DMUser' },
    guild: null,
    channelId: 'dm-channel-1',
    mentions: {
      has: vi.fn().mockReturnValue(false),
    },
    ...overrides,
  };
}

describe('DiscordAdapter', () => {
  let adapter: DiscordAdapter;

  beforeEach(async () => {
    vi.clearAllMocks();
    mockClient = createMockClient();
    // Re-wire the Client mock to return the fresh mockClient
    const { Client } = vi.mocked(await import('discord.js'));
    (Client as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => mockClient);
    adapter = new DiscordAdapter(baseConfig);
  });

  // ── Basic properties ──────────────────────────────────────────

  it('has name "discord"', () => {
    expect(adapter.name).toBe('discord');
  });

  it('starts as disconnected', () => {
    expect(adapter.status).toBe('disconnected');
  });

  // ── connect / disconnect ──────────────────────────────────────

  it('connect creates client and sets connected status', async () => {
    await adapter.connect();

    expect(adapter.status).toBe('connected');
    const { Client } = await import('discord.js');
    expect(Client).toHaveBeenCalledWith({
      intents: [1, 2, 4, 8],
    });
    expect(mockClient.login).toHaveBeenCalledWith('test-discord-token');
  });

  it('connect registers messageCreate handler', async () => {
    await adapter.connect();

    // Should have registered handlers for 'ready' and 'messageCreate'
    const eventNames = Array.from(mockClient._handlers.keys());
    expect(eventNames).toContain('ready');
    expect(eventNames).toContain('messageCreate');
  });

  it('disconnect destroys client and sets disconnected', async () => {
    await adapter.connect();
    expect(adapter.status).toBe('connected');

    await adapter.disconnect();
    expect(mockClient.destroy).toHaveBeenCalledTimes(1);
    expect(adapter.status).toBe('disconnected');
  });

  it('disconnect is safe when not connected', async () => {
    await adapter.disconnect();
    expect(adapter.status).toBe('disconnected');
    // No client to destroy — should not throw
  });

  // ── send ──────────────────────────────────────────────────────

  it('send throws when not connected', async () => {
    await expect(adapter.send('channel-1', { text: 'hello' })).rejects.toThrow(
      'Discord adapter not connected',
    );
  });

  it('send delivers a short message', async () => {
    await adapter.connect();

    await adapter.send('channel-1', { text: 'Hello there' });

    expect(mockClient.channels.fetch).toHaveBeenCalledWith('channel-1');
    expect(mockChannelSend).toHaveBeenCalledTimes(1);
    expect(mockChannelSend).toHaveBeenCalledWith('Hello there');
  });

  it('send chunks long messages (>2000 chars)', async () => {
    await adapter.connect();

    // Create a message longer than 2000 chars — two halves joined by a newline
    const half = 'A'.repeat(1500);
    const longMessage = `${half}\n${'B'.repeat(1500)}`;

    await adapter.send('channel-1', { text: longMessage });

    // Should have been split into at least 2 chunks
    expect(mockChannelSend.mock.calls.length).toBeGreaterThanOrEqual(2);
  });

  // ── onMessage / inbound handling ──────────────────────────────

  it('inbound message from guild with mention triggers handler', async () => {
    const handler = vi.fn();
    adapter.onMessage(handler);

    await adapter.connect();

    const message = createGuildMessage({
      content: '<@123456789> Hello Flynn',
      mentions: { has: vi.fn().mockReturnValue(true) },
    });

    emitEvent('messageCreate', message);

    expect(handler).toHaveBeenCalledTimes(1);
    const msg: InboundMessage = handler.mock.calls[0][0];
    expect(msg.channel).toBe('discord');
    expect(msg.senderId).toBe('channel-1');
    expect(msg.senderName).toBe('TestUser');
    expect(msg.text).toBe('Hello Flynn');
  });

  it('inbound message from DM triggers handler without mention', async () => {
    const handler = vi.fn();
    adapter.onMessage(handler);

    await adapter.connect();

    const message = createDMMessage();
    emitEvent('messageCreate', message);

    expect(handler).toHaveBeenCalledTimes(1);
    const msg: InboundMessage = handler.mock.calls[0][0];
    expect(msg.channel).toBe('discord');
    expect(msg.senderId).toBe('dm-channel-1');
    expect(msg.senderName).toBe('DMUser');
    expect(msg.text).toBe('Hello from DM');
  });

  it('ignores bot messages (author.bot = true)', async () => {
    const handler = vi.fn();
    adapter.onMessage(handler);

    await adapter.connect();

    const message = createDMMessage({
      author: { bot: true, username: 'SomeBot' },
    });

    emitEvent('messageCreate', message);

    expect(handler).not.toHaveBeenCalled();
  });

  it('ignores messages in disallowed guilds', async () => {
    const restrictedAdapter = new DiscordAdapter({
      ...baseConfig,
      allowedGuildIds: ['guild-allowed'],
    });
    const handler = vi.fn();
    restrictedAdapter.onMessage(handler);

    await restrictedAdapter.connect();

    const message = createGuildMessage({
      guild: { id: 'guild-not-allowed' },
      mentions: { has: vi.fn().mockReturnValue(true) },
    });

    emitEvent('messageCreate', message);

    expect(handler).not.toHaveBeenCalled();
  });

  it('ignores messages in disallowed channels', async () => {
    const restrictedAdapter = new DiscordAdapter({
      ...baseConfig,
      allowedChannelIds: ['channel-allowed'],
      requireMention: false,
    });
    const handler = vi.fn();
    restrictedAdapter.onMessage(handler);

    await restrictedAdapter.connect();

    const message = createGuildMessage({
      channelId: 'channel-not-allowed',
    });

    emitEvent('messageCreate', message);

    expect(handler).not.toHaveBeenCalled();
  });

  it('strips bot mention from message text', async () => {
    const handler = vi.fn();
    adapter.onMessage(handler);

    await adapter.connect();

    const message = createGuildMessage({
      content: '<@123456789> What is the weather?',
      mentions: { has: vi.fn().mockReturnValue(true) },
    });

    emitEvent('messageCreate', message);

    expect(handler).toHaveBeenCalledTimes(1);
    const msg: InboundMessage = handler.mock.calls[0][0];
    expect(msg.text).toBe('What is the weather?');
  });

  it('!reset command delivers reset metadata', async () => {
    const handler = vi.fn();
    adapter.onMessage(handler);

    await adapter.connect();

    const message = createDMMessage({
      content: '!reset',
    });

    emitEvent('messageCreate', message);

    expect(handler).toHaveBeenCalledTimes(1);
    const msg: InboundMessage = handler.mock.calls[0][0];
    expect(msg.text).toBe('!reset');
    expect(msg.metadata).toEqual({ isCommand: true, command: 'reset' });
  });

  it('guild message without mention is ignored when requireMention is true', async () => {
    // Default config has requireMention undefined (defaults to true)
    const handler = vi.fn();
    adapter.onMessage(handler);

    await adapter.connect();

    const message = createGuildMessage({
      mentions: { has: vi.fn().mockReturnValue(false) },
    });

    emitEvent('messageCreate', message);

    expect(handler).not.toHaveBeenCalled();
  });

  // ── Additional edge cases ─────────────────────────────────────

  it('guild message without mention is accepted when requireMention is false', async () => {
    const noMentionAdapter = new DiscordAdapter({
      ...baseConfig,
      requireMention: false,
    });
    const handler = vi.fn();
    noMentionAdapter.onMessage(handler);

    await noMentionAdapter.connect();

    const message = createGuildMessage({
      mentions: { has: vi.fn().mockReturnValue(false) },
    });

    emitEvent('messageCreate', message);

    expect(handler).toHaveBeenCalledTimes(1);
  });

  it('does nothing when no message handler is registered', async () => {
    // Don't call onMessage — no handler registered
    await adapter.connect();

    const message = createDMMessage();
    // Should not throw
    emitEvent('messageCreate', message);
  });

  it('"reset" text (without !) delivers reset metadata in DMs', async () => {
    const handler = vi.fn();
    adapter.onMessage(handler);

    await adapter.connect();

    const message = createDMMessage({
      content: 'reset',
    });

    emitEvent('messageCreate', message);

    expect(handler).toHaveBeenCalledTimes(1);
    const msg: InboundMessage = handler.mock.calls[0][0];
    expect(msg.metadata).toEqual({ isCommand: true, command: 'reset' });
  });

  it('strips mention and recognizes reset command after mention', async () => {
    const handler = vi.fn();
    adapter.onMessage(handler);

    await adapter.connect();

    const message = createGuildMessage({
      content: '<@123456789> reset',
      mentions: { has: vi.fn().mockReturnValue(true) },
    });

    emitEvent('messageCreate', message);

    expect(handler).toHaveBeenCalledTimes(1);
    const msg: InboundMessage = handler.mock.calls[0][0];
    expect(msg.metadata).toEqual({ isCommand: true, command: 'reset' });
  });

  it('allowed guild messages with mention pass through', async () => {
    const restrictedAdapter = new DiscordAdapter({
      ...baseConfig,
      allowedGuildIds: ['guild-1'],
      allowedChannelIds: ['channel-1'],
    });
    const handler = vi.fn();
    restrictedAdapter.onMessage(handler);

    await restrictedAdapter.connect();

    const message = createGuildMessage({
      content: '<@123456789> Hello!',
      guild: { id: 'guild-1' },
      channelId: 'channel-1',
      mentions: { has: vi.fn().mockReturnValue(true) },
    });

    emitEvent('messageCreate', message);

    expect(handler).toHaveBeenCalledTimes(1);
    const msg: InboundMessage = handler.mock.calls[0][0];
    expect(msg.text).toBe('Hello!');
  });
});
