import { EventEmitter } from 'node:events';
import type {
  ChannelConversationRouteDto,
  ChannelEgressDto,
  ChannelIngressDto,
  ChannelIngressPort,
} from '@mosaicstack/types';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
  createDiscordIngressEnvelope,
  DiscordPlugin,
  parseDiscordInteractionBindings,
  resolveDiscordInteractionActorId,
  resolveDiscordInteractionBinding,
  verifyDiscordIngressEnvelope,
  type DiscordIngressEnvelope,
  type DiscordInteractionRole,
  type DiscordPluginConfig,
} from './index.js';

const SERVICE_TOKEN = 'test-service-token';

interface FakeDiscordMessageOptions {
  id?: string;
  guildId?: string;
  content: string;
  mentioned?: boolean;
  userId?: string;
  channelId?: string;
  parentChannelId?: string | null;
  isThread?: boolean;
  hasThread?: boolean;
  existingThreadId?: string;
  fetchedThreadId?: string;
  createdThreadId?: string;
  attachments?: Map<string, FakeDiscordAttachment>;
}

interface FakeDiscordAttachment {
  id: string;
  name: string;
  url: string;
  contentType: string | null;
  size?: number;
}

interface FakeDiscordMessage {
  id: string;
  guildId: string;
  channelId: string;
  author: { id: string; bot: boolean };
  mentions: { has(user: { id: string }): boolean };
  content: string;
  createdAt: Date;
  channel: {
    parentId: string | null;
    isThread(): boolean;
    threads?: { fetch(id: string): Promise<{ id: string }> };
  };
  attachments: Map<string, FakeDiscordAttachment>;
  hasThread: boolean;
  thread: { id: string } | null;
  startThread: ReturnType<typeof vi.fn>;
}

interface FakeGatewaySocket extends EventEmitter {
  connected: boolean;
  disconnect?: ReturnType<typeof vi.fn>;
}

interface FakeLifecycleClient extends EventEmitter {
  user: { id: string; tag: string };
  login: ReturnType<typeof vi.fn>;
  destroy: ReturnType<typeof vi.fn>;
  isReady(): boolean;
  guilds: { cache: Map<string, object> };
  channels: { cache: Map<string, object> };
}

interface DiscordPluginInternals {
  client: {
    user: { id: string };
    isReady(): boolean;
    channels?: {
      cache: { get(id: string): { send(options: unknown): Promise<void> } | undefined };
    };
  };
  socket: {
    connected: boolean;
    emit: ReturnType<typeof vi.fn>;
  } | null;
  conversationRoutes: Map<string, ChannelConversationRouteDto>;
  handleDiscordMessage(message: FakeDiscordMessage): void | Promise<void>;
  sendToDiscord(conversationId: string, text: string): Promise<void>;
}

function lifecyclePlugin(): {
  plugin: DiscordPlugin;
  socket: FakeGatewaySocket;
  client: FakeLifecycleClient;
} {
  const socket = Object.assign(new EventEmitter(), {
    connected: false,
    disconnect: vi.fn(),
  });
  const client = Object.assign(new EventEmitter(), {
    user: { id: 'bot-001', tag: 'mosaic-bot' },
    login: vi.fn().mockResolvedValue('token'),
    destroy: vi.fn().mockResolvedValue(undefined),
    isReady: (): boolean => true,
    guilds: { cache: new Map<string, object>() },
    channels: { cache: new Map<string, object>() },
  });
  const plugin = new DiscordPlugin(
    {
      token: 'unused',
      gatewayUrl: 'http://gateway.invalid',
      serviceToken: SERVICE_TOKEN,
      allowedGuildIds: ['guild-001'],
      allowedChannelIds: ['channel-001'],
      allowedUserIds: ['user-001'],
      interactionBindings: [
        {
          instanceId: 'Nova',
          agentConfigId: 'agent-config-nova',
          guildId: 'guild-001',
          channelId: 'channel-001',
          pairedUsers: {
            'user-001': { role: 'operator', mosaicUserId: 'mosaic-user-001' },
          },
        },
      ],
    },
    {
      client: client as never,
      socketFactory: vi.fn().mockReturnValue(socket) as never,
    },
  );
  return { plugin, socket, client };
}

function createPlugin(
  role: DiscordInteractionRole = 'operator',
  paired = true,
  ingressPort?: ChannelIngressPort,
  configOverrides: Partial<DiscordPluginConfig> = {},
): {
  plugin: DiscordPlugin;
  internals: DiscordPluginInternals;
  emit: ReturnType<typeof vi.fn>;
} {
  const plugin = new DiscordPlugin(
    {
      token: 'unused',
      gatewayUrl: 'http://unused',
      serviceToken: SERVICE_TOKEN,
      allowedGuildIds: ['guild-001'],
      allowedChannelIds: ['channel-001'],
      allowedUserIds: ['user-001'],
      interactionBindings: [
        {
          instanceId: 'Nova',
          agentConfigId: 'agent-config-nova',
          guildId: 'guild-001',
          channelId: 'channel-001',
          pairedUsers: paired
            ? {
                'user-001': { role, mosaicUserId: 'mosaic-user-001' },
              }
            : {},
        },
      ],
      ...configOverrides,
    },
    { ingressPort },
  );
  const emit = vi.fn();
  const internals = plugin as unknown as DiscordPluginInternals;
  internals.client = { user: { id: 'bot-001' }, isReady: (): boolean => true };
  internals.socket = { connected: true, emit };
  return { plugin, internals, emit };
}

function fakeMessage(options: FakeDiscordMessageOptions): FakeDiscordMessage {
  const channelId = options.channelId ?? 'channel-001';
  const parentChannelId = options.parentChannelId;
  const existingThreadId = options.existingThreadId;
  return {
    id: options.id ?? 'message-001',
    guildId: options.guildId ?? 'guild-001',
    channelId,
    author: { id: options.userId ?? 'user-001', bot: false },
    mentions: { has: (): boolean => options.mentioned ?? false },
    content: options.content,
    createdAt: new Date('2026-07-14T12:00:00.000Z'),
    channel: {
      parentId: parentChannelId ?? null,
      isThread: (): boolean =>
        options.isThread ?? (parentChannelId !== undefined && parentChannelId !== null),
      ...(options.fetchedThreadId
        ? {
            threads: {
              fetch: vi.fn().mockResolvedValue({ id: options.fetchedThreadId }),
            },
          }
        : {}),
    },
    attachments: options.attachments ?? new Map<string, FakeDiscordAttachment>(),
    hasThread: options.hasThread ?? existingThreadId !== undefined,
    thread: existingThreadId ? { id: existingThreadId } : null,
    startThread: vi.fn().mockResolvedValue({ id: options.createdThreadId ?? 'thread-created-001' }),
  };
}

function emittedEnvelope(emit: ReturnType<typeof vi.fn>): DiscordIngressEnvelope {
  const call = emit.mock.calls[0] as [string, DiscordIngressEnvelope] | undefined;
  expect(call?.[0]).toBe('message');
  expect(call?.[1]).toBeDefined();
  return (
    call?.[1] ??
    createDiscordIngressEnvelope(
      {
        correlationId: 'unreachable',
        messageId: 'unreachable',
        guildId: 'unreachable',
        channelId: 'unreachable',
        userId: 'unreachable',
        conversationId: 'unreachable',
        content: 'unreachable',
      },
      SERVICE_TOKEN,
    )
  );
}

afterEach((): void => {
  vi.useRealTimers();
  vi.restoreAllMocks();
});

describe('Discord binding configuration', () => {
  it('parses role-only and Mosaic-linked pairings', () => {
    const bindings = parseDiscordInteractionBindings(
      JSON.stringify([
        {
          instanceId: 'Nova',
          agentConfigId: 'agent-config-nova',
          guildId: 'guild-001',
          channelId: 'channel-001',
          pairedUsers: {
            'legacy-user': 'operator',
            'linked-user': { role: 'admin', mosaicUserId: 'mosaic-admin-001' },
            'linked-without-id': { role: 'operator' },
          },
        },
      ]),
    );

    expect(bindings).toHaveLength(1);
    expect(resolveDiscordInteractionActorId(bindings[0]!, 'legacy-user')).toBeNull();
    expect(resolveDiscordInteractionActorId(bindings[0]!, 'linked-user')).toBe('mosaic-admin-001');
    expect(resolveDiscordInteractionActorId(bindings[0]!, 'linked-without-id')).toBeNull();
    expect(resolveDiscordInteractionActorId(bindings[0]!, 'missing-user')).toBeNull();
    expect(
      resolveDiscordInteractionBinding(bindings, 'guild-001', 'channel-001', 'linked-user', 'bind'),
    ).toBe(bindings[0]);
    expect(
      resolveDiscordInteractionBinding(
        bindings,
        'guild-001',
        'channel-001',
        'linked-without-id',
        'attach',
      ),
    ).toBe(bindings[0]);
    expect(
      resolveDiscordInteractionBinding(
        bindings,
        'other-guild',
        'channel-001',
        'linked-user',
        'send',
      ),
    ).toBeNull();
  });

  it.each([
    ['missing value', undefined],
    ['empty array', '[]'],
    ['non-object binding', '[null]'],
    ['missing binding fields', '[{"instanceId":"Nova"}]'],
    [
      'empty Discord user ID',
      '[{"instanceId":"Nova","guildId":"g","channelId":"c","pairedUsers":{"":"operator"}}]',
    ],
    [
      'invalid role-only pairing',
      '[{"instanceId":"Nova","guildId":"g","channelId":"c","pairedUsers":{"u":"owner"}}]',
    ],
    [
      'non-object pairing',
      '[{"instanceId":"Nova","guildId":"g","channelId":"c","pairedUsers":{"u":42}}]',
    ],
    [
      'invalid linked pairing',
      '[{"instanceId":"Nova","guildId":"g","channelId":"c","pairedUsers":{"u":{"role":"admin","mosaicUserId":" "}}}]',
    ],
  ])('rejects %s', (_case: string, value: string | undefined) => {
    expect(() => parseDiscordInteractionBindings(value)).toThrow();
  });
});

describe('Discord ingress integrity', () => {
  it.each([
    ['guild', { guildIds: ['other'], channelIds: ['channel-001'], userIds: ['user-001'] }],
    ['channel', { guildIds: ['guild-001'], channelIds: ['other'], userIds: ['user-001'] }],
    ['user', { guildIds: ['guild-001'], channelIds: ['channel-001'], userIds: ['other'] }],
  ])('rejects an unallowlisted %s', (_field: string, allowlists) => {
    const payload = {
      correlationId: 'correlation-001',
      messageId: 'message-001',
      guildId: 'guild-001',
      channelId: 'channel-001',
      userId: 'user-001',
      conversationId: 'Nova:discord:channel-001',
      content: 'hello',
    };
    const envelope = createDiscordIngressEnvelope(payload, SERVICE_TOKEN);

    expect(verifyDiscordIngressEnvelope(envelope, SERVICE_TOKEN, allowlists)).toBeNull();
  });
});

describe('Discord adapter lifecycle', () => {
  it('starts while the in-process gateway is still connecting, then reports connected', async () => {
    const { plugin, socket, client } = lifecyclePlugin();

    await plugin.start();

    expect(client.login).toHaveBeenCalledWith('unused');
    expect(await plugin.health()).toEqual({ status: 'degraded' });
    socket.connected = true;
    socket.emit('connect');
    expect(await plugin.health()).toEqual({ status: 'connected' });
    await plugin.stop();
    expect(socket.disconnect).toHaveBeenCalledOnce();
    expect(client.destroy).toHaveBeenCalledOnce();
  });

  it('keeps running while Socket.IO reconnects after an initial gateway error', async () => {
    const { plugin, socket } = lifecyclePlugin();
    const error = vi.spyOn(console, 'error').mockImplementation((): void => undefined);

    await plugin.start();
    socket.emit('connect_error', new Error('gateway not listening yet'));

    expect(error).toHaveBeenCalledWith(
      '[discord] Gateway connection error: gateway not listening yet',
    );
    expect(await plugin.health()).toEqual({ status: 'degraded' });
  });

  it('cleans up when Discord login fails', async () => {
    const { plugin, socket, client } = lifecyclePlugin();
    client.login.mockRejectedValueOnce(new Error('Discord authentication rejected'));

    await expect(plugin.start()).rejects.toThrow('Discord authentication rejected');

    expect(socket.disconnect).toHaveBeenCalledOnce();
    expect(client.destroy).toHaveBeenCalledOnce();
  });
});

describe('Discord project channel provisioning', () => {
  it('returns null without a configured guild or visible guild', async () => {
    const { plugin } = createPlugin();
    await expect(
      plugin.createProjectChannel({ id: 'project-1', name: 'Alpha' }),
    ).resolves.toBeNull();

    const { client } = lifecyclePlugin();
    const configured = new DiscordPlugin(
      {
        token: 'unused',
        gatewayUrl: 'http://unused',
        serviceToken: SERVICE_TOKEN,
        guildId: 'missing-guild',
        allowedGuildIds: ['guild-001'],
        allowedChannelIds: ['channel-001'],
        allowedUserIds: ['user-001'],
        interactionBindings: [],
      },
      { client: client as never },
    );
    await expect(
      configured.createProjectChannel({ id: 'project-1', name: 'Alpha' }),
    ).resolves.toBeNull();
  });

  it('creates a normalized Discord project channel', async () => {
    const create = vi.fn().mockResolvedValue({ id: 'created-channel-001' });
    const { client } = lifecyclePlugin();
    client.guilds.cache.set('guild-001', { channels: { create } });
    const plugin = new DiscordPlugin(
      {
        token: 'unused',
        gatewayUrl: 'http://unused',
        serviceToken: SERVICE_TOKEN,
        guildId: 'guild-001',
        allowedGuildIds: ['guild-001'],
        allowedChannelIds: ['channel-001'],
        allowedUserIds: ['user-001'],
        interactionBindings: [],
      },
      { client: client as never },
    );

    await expect(
      plugin.createProjectChannel({
        id: 'project-1',
        name: ' Project Alpha! ',
        description: 'Alpha workspace',
      }),
    ).resolves.toEqual({ channelId: 'created-channel-001' });
    expect(create).toHaveBeenCalledWith(
      expect.objectContaining({ name: 'mosaic-project-alpha', topic: 'Alpha workspace' }),
    );

    await plugin.createProjectChannel({ id: 'project-2', name: 'Beta' });
    expect(create).toHaveBeenLastCalledWith(
      expect.objectContaining({ topic: 'Mosaic project: Beta' }),
    );
  });
});

function egressFor(
  route: ChannelConversationRouteDto,
  content = 'agent response',
): ChannelEgressDto {
  return {
    correlationId: 'egress-correlation-001',
    route,
    message: {
      id: 'egress-message-001',
      channelName: 'discord',
      channelId: route.responseTarget.channelId,
      senderId: route.logicalAgentId,
      senderKind: 'agent',
      content,
      contentKind: 'markdown',
      timestamp: '2026-07-14T12:00:00.000Z',
      metadata: {},
    },
  };
}

describe('official Discord channel routing', () => {
  it('normalizes an authorized turn through the shared channel ingress port', async () => {
    const receive = vi.fn<(ingress: ChannelIngressDto) => Promise<void>>().mockResolvedValue();
    const { internals, emit } = createPlugin('operator', true, { receive });
    internals.socket = null;

    await internals.handleDiscordMessage(fakeMessage({ content: 'normalized turn' }));

    expect(emit).not.toHaveBeenCalled();
    expect(receive).toHaveBeenCalledWith(
      expect.objectContaining({
        operation: 'message.send',
        principal: {
          channelUserId: 'user-001',
          role: 'operator',
          mosaicUserId: 'mosaic-user-001',
        },
        message: expect.objectContaining({
          channelName: 'discord',
          channelId: 'channel-001',
          content: 'normalized turn',
          senderKind: 'user',
        }),
        route: expect.objectContaining({
          logicalAgentId: 'Nova',
          conversationId: 'Nova:discord:channel-001',
        }),
      }),
    );
  });

  it('releases a response route when typed ingress rejects', async () => {
    const receive = vi.fn().mockRejectedValue(new Error('gateway rejected ingress'));
    const { internals } = createPlugin('operator', true, { receive });

    await expect(
      internals.handleDiscordMessage(fakeMessage({ content: 'rejected typed ingress' })),
    ).rejects.toThrow('gateway rejected ingress');

    expect(internals.conversationRoutes).toHaveLength(0);
  });

  it('routes an authorized untagged parent-channel message and responds in that channel', async () => {
    const { internals, emit } = createPlugin();
    const message = fakeMessage({ content: 'channel conversation' });

    await internals.handleDiscordMessage(message);

    expect(message.startThread).not.toHaveBeenCalled();
    const payload = verifyDiscordIngressEnvelope(emittedEnvelope(emit), SERVICE_TOKEN);
    expect(payload).toMatchObject({
      channelId: 'channel-001',
      conversationId: 'Nova:discord:channel-001',
      content: 'channel conversation',
    });
    expect(payload?.threadId).toBeUndefined();
  });

  it('creates a thread for a mentioned parent-channel message and routes the response there', async () => {
    const { internals, emit } = createPlugin();
    const message = fakeMessage({
      content: '<@bot-001> investigate this topic',
      mentioned: true,
      createdThreadId: 'thread-created-001',
    });

    await internals.handleDiscordMessage(message);

    expect(message.startThread).toHaveBeenCalledOnce();
    const payload = verifyDiscordIngressEnvelope(emittedEnvelope(emit), SERVICE_TOKEN);
    expect(payload).toMatchObject({
      channelId: 'channel-001',
      conversationId: 'Nova:discord:thread-created-001',
      content: 'investigate this topic',
      threadId: 'thread-created-001',
    });
  });

  it('fetches and reuses an existing thread that is missing from the cache', async () => {
    const { internals, emit } = createPlugin();
    const message = fakeMessage({
      content: '<@bot-001> continue uncached topic',
      mentioned: true,
      hasThread: true,
      fetchedThreadId: 'thread-fetched-001',
    });

    await internals.handleDiscordMessage(message);

    expect(message.startThread).not.toHaveBeenCalled();
    expect(verifyDiscordIngressEnvelope(emittedEnvelope(emit), SERVICE_TOKEN)).toMatchObject({
      conversationId: 'Nova:discord:thread-fetched-001',
      threadId: 'thread-fetched-001',
    });
  });

  it('delivers the agent response to the thread selected by the mentioned turn', async () => {
    const { internals } = createPlugin();
    const send = vi.fn().mockResolvedValue(undefined);
    internals.client = {
      user: { id: 'bot-001' },
      isReady: (): boolean => true,
      channels: {
        cache: {
          get: (id: string): { send(options: unknown): Promise<void> } | undefined =>
            id === 'thread-response-001' ? { send } : undefined,
        },
      },
    };

    await internals.handleDiscordMessage(
      fakeMessage({
        content: '<@bot-001> threaded response',
        mentioned: true,
        createdThreadId: 'thread-response-001',
      }),
    );
    await internals.sendToDiscord('Nova:discord:thread-response-001', 'agent answer');

    expect(send).toHaveBeenCalledWith(
      expect.objectContaining({ content: 'agent answer', enforceNonce: true }),
    );
  });

  it('reuses a thread already attached to a mentioned message instead of creating another', async () => {
    const { internals, emit } = createPlugin();
    const message = fakeMessage({
      content: '<@bot-001> continue existing topic',
      mentioned: true,
      existingThreadId: 'thread-existing-001',
    });

    await internals.handleDiscordMessage(message);

    expect(message.startThread).not.toHaveBeenCalled();
    expect(verifyDiscordIngressEnvelope(emittedEnvelope(emit), SERVICE_TOKEN)).toMatchObject({
      conversationId: 'Nova:discord:thread-existing-001',
      threadId: 'thread-existing-001',
    });
  });

  it('keeps an untagged follow-up inside an authorized thread without nesting threads', async () => {
    const { internals, emit } = createPlugin();
    const message = fakeMessage({
      content: 'thread follow-up',
      channelId: 'thread-001',
      parentChannelId: 'channel-001',
    });

    await internals.handleDiscordMessage(message);

    expect(message.startThread).not.toHaveBeenCalled();
    expect(verifyDiscordIngressEnvelope(emittedEnvelope(emit), SERVICE_TOKEN)).toMatchObject({
      channelId: 'channel-001',
      conversationId: 'Nova:discord:thread-001',
      content: 'thread follow-up',
      threadId: 'thread-001',
    });
  });

  it.each([
    ['guild', { guildId: 'guild-not-allowed' }],
    ['channel', { channelId: 'channel-not-allowed' }],
    ['user', { userId: 'user-not-allowed' }],
  ])(
    'rejects an unauthorized %s before thread creation or gateway dispatch',
    async (_boundary: string, override: Partial<FakeDiscordMessageOptions>) => {
      const { internals, emit } = createPlugin();
      const message = fakeMessage({
        content: '<@bot-001> unauthorized topic',
        mentioned: true,
        ...override,
      });

      await internals.handleDiscordMessage(message);

      expect(message.startThread).not.toHaveBeenCalled();
      expect(emit).not.toHaveBeenCalled();
    },
  );

  it.each([
    ['parent channel', {}],
    ['existing thread', { channelId: 'thread-unpaired-001', parentChannelId: 'channel-001' }],
  ])(
    'rejects an allowlisted but unpaired user in %s',
    async (_location: string, override: Partial<FakeDiscordMessageOptions>) => {
      const { internals, emit } = createPlugin('operator', false);
      const message = fakeMessage({ content: 'unpaired message', ...override });

      await internals.handleDiscordMessage(message);

      expect(message.startThread).not.toHaveBeenCalled();
      expect(emit).not.toHaveBeenCalled();
    },
  );

  it('rejects a paired viewer before thread creation or gateway dispatch', async () => {
    const { internals, emit } = createPlugin('viewer');
    const message = fakeMessage({
      content: '<@bot-001> viewer cannot send',
      mentioned: true,
    });

    await internals.handleDiscordMessage(message);

    expect(message.startThread).not.toHaveBeenCalled();
    expect(emit).not.toHaveBeenCalled();
  });

  it('uses a normal channel ID rather than its category parent for authorization', async () => {
    const { internals, emit } = createPlugin();
    const message = fakeMessage({
      content: 'message from categorized channel',
      parentChannelId: 'category-001',
      isThread: false,
    });

    await internals.handleDiscordMessage(message);

    expect(verifyDiscordIngressEnvelope(emittedEnvelope(emit), SERVICE_TOKEN)).toMatchObject({
      channelId: 'channel-001',
      conversationId: 'Nova:discord:channel-001',
    });
  });

  it.each([
    ['parent channel', {}],
    ['existing thread', { channelId: 'thread-attachment-001', parentChannelId: 'channel-001' }],
  ])(
    'preserves an attachment-only turn in an authorized %s',
    async (_location: string, override: Partial<FakeDiscordMessageOptions>) => {
      const { internals, emit } = createPlugin();
      const attachments = new Map<string, FakeDiscordAttachment>([
        [
          'attachment-001',
          {
            id: 'attachment-001',
            name: 'diagram.png',
            url: 'https://cdn.example.invalid/diagram.png',
            contentType: 'image/png',
            size: 4_096,
          },
        ],
      ]);

      await internals.handleDiscordMessage(fakeMessage({ content: '', attachments, ...override }));

      expect(verifyDiscordIngressEnvelope(emittedEnvelope(emit), SERVICE_TOKEN)).toMatchObject({
        content: '',
        attachments: [
          {
            id: 'attachment-001',
            name: 'diagram.png',
            contentType: 'image/png',
            sizeBytes: 4_096,
          },
        ],
      });
    },
  );

  it('does not dispatch when Discord cannot create the requested thread', async () => {
    const { internals, emit } = createPlugin();
    const message = fakeMessage({ content: '<@bot-001> new topic', mentioned: true });
    message.startThread.mockRejectedValueOnce(new Error('missing thread permission'));

    await expect(internals.handleDiscordMessage(message)).rejects.toThrow(
      'missing thread permission',
    );
    expect(emit).not.toHaveBeenCalled();
  });

  it.each(['/approve', '/stop approval-001'])(
    'rejects operator use of privileged command %s before gateway dispatch',
    async (command: string) => {
      const { internals, emit } = createPlugin('operator');
      const message = fakeMessage({ content: `<@bot-001> ${command}`, mentioned: true });

      await internals.handleDiscordMessage(message);

      expect(message.startThread).not.toHaveBeenCalled();
      expect(emit).not.toHaveBeenCalled();
    },
  );

  it('keeps runtime control commands on the current durable session', async () => {
    const { internals, emit } = createPlugin('admin');
    const message = fakeMessage({ content: '<@bot-001> /approve', mentioned: true });

    await internals.handleDiscordMessage(message);

    expect(message.startThread).not.toHaveBeenCalled();
    expect(emit).toHaveBeenCalledWith('discord:approve', expect.any(Object));
  });

  it('keeps the stable conversation address independent of a runtime harness', async () => {
    const first = createPlugin();
    const second = createPlugin();

    await first.internals.handleDiscordMessage(
      fakeMessage({ id: 'message-claude', content: 'before runtime handoff' }),
    );
    await second.internals.handleDiscordMessage(
      fakeMessage({ id: 'message-pi', content: 'after runtime handoff' }),
    );

    const firstPayload = verifyDiscordIngressEnvelope(emittedEnvelope(first.emit), SERVICE_TOKEN);
    const secondPayload = verifyDiscordIngressEnvelope(emittedEnvelope(second.emit), SERVICE_TOKEN);
    expect(firstPayload?.conversationId).toBe('Nova:discord:channel-001');
    expect(secondPayload?.conversationId).toBe(firstPayload?.conversationId);
  });

  it('rate-limits authorized turns before thread creation or dispatch', async () => {
    const { internals, emit } = createPlugin('operator', true, undefined, {
      messageRateLimitPerMinute: 1,
      threadRateLimitPerMinute: 1,
    });
    const error = vi.spyOn(console, 'error').mockImplementation((): void => undefined);
    const first = fakeMessage({ id: 'rate-first', content: 'first turn' });
    const second = fakeMessage({
      id: 'rate-second',
      content: '<@bot-001> second turn',
      mentioned: true,
    });

    await internals.handleDiscordMessage(first);
    await internals.handleDiscordMessage(second);

    expect(emit).toHaveBeenCalledOnce();
    expect(second.startThread).not.toHaveBeenCalled();
    expect(error).toHaveBeenCalledWith(expect.stringContaining('Message rate limit reached'));
  });

  it('applies a stricter mention-thread rate limit before Discord side effects', async () => {
    const { internals, emit } = createPlugin('operator', true, undefined, {
      messageRateLimitPerMinute: 10,
      threadRateLimitPerMinute: 1,
    });
    vi.spyOn(console, 'error').mockImplementation((): void => undefined);
    const first = fakeMessage({ id: 'thread-rate-first', content: 'first topic', mentioned: true });
    const second = fakeMessage({
      id: 'thread-rate-second',
      content: 'second topic',
      mentioned: true,
    });

    await internals.handleDiscordMessage(first);
    await internals.handleDiscordMessage(second);

    expect(first.startThread).toHaveBeenCalledOnce();
    expect(second.startThread).not.toHaveBeenCalled();
    expect(emit).toHaveBeenCalledOnce();
  });

  it('rejects unconfigured egress and handles missing or failed Discord destinations', async () => {
    const { plugin, internals } = createPlugin();
    const route: ChannelConversationRouteDto = {
      bindingId: 'guild-001:channel-001:Nova',
      logicalAgentId: 'Nova',
      conversationId: 'Nova:discord:channel-001',
      channelName: 'discord',
      authorizationChannelId: 'channel-001',
      responseTarget: { channelId: 'channel-001' },
    };

    await expect(
      plugin.send(
        egressFor({
          ...route,
          bindingId: 'forged-binding',
        }),
      ),
    ).rejects.toMatchObject({ code: 'invalid_route' });
    await expect(
      plugin.send(
        egressFor({
          ...route,
          conversationId: 'Nova:discord:unrelated-conversation',
        }),
      ),
    ).rejects.toMatchObject({ code: 'invalid_route' });
    await expect(
      plugin.send({
        ...egressFor(route),
        message: { ...egressFor(route).message, channelId: 'other-channel' },
      }),
    ).rejects.toMatchObject({ code: 'invalid_route' });

    const forgedSend = vi.fn().mockResolvedValue(undefined);
    internals.client = {
      user: { id: 'bot-001' },
      isReady: (): boolean => true,
      channels: {
        cache: {
          get: (): { send(options: unknown): Promise<void> } => ({ send: forgedSend }),
        },
      },
    };
    await expect(
      plugin.send(
        egressFor({
          ...route,
          conversationId: 'Nova:discord:forged-channel',
          responseTarget: { channelId: 'forged-channel', threadId: 'forged-channel' },
        }),
      ),
    ).rejects.toMatchObject({ code: 'invalid_route' });
    expect(forgedSend).not.toHaveBeenCalled();

    internals.client = {
      user: { id: 'bot-001' },
      isReady: (): boolean => true,
      channels: { cache: { get: (): undefined => undefined } },
    };
    await expect(plugin.send(egressFor(route))).rejects.toMatchObject({
      code: 'destination_unavailable',
    });
    await expect(
      internals.sendToDiscord('missing-conversation', 'missing route'),
    ).rejects.toMatchObject({ code: 'invalid_route' });

    vi.useFakeTimers();
    const send = vi
      .fn()
      .mockRejectedValue(Object.assign(new Error('Discord unavailable'), { status: 503 }));
    internals.client.channels = {
      cache: { get: (): { send(options: unknown): Promise<void> } => ({ send }) },
    };
    const delivery = plugin.send(egressFor(route));
    const rejection = expect(delivery).rejects.toMatchObject({
      code: 'delivery_failed',
      retryable: true,
    });
    await vi.runAllTimersAsync();
    await rejection;

    expect(send).toHaveBeenCalledTimes(3);
    expect(new Set(send.mock.calls.map(([options]) => options.nonce)).size).toBe(1);
    expect(send).toHaveBeenCalledWith(
      expect.objectContaining({ enforceNonce: true, content: 'agent response' }),
    );

    const permanentSend = vi
      .fn()
      .mockRejectedValue(Object.assign(new Error('Discord forbidden'), { status: 403 }));
    internals.client.channels = {
      cache: {
        get: (): { send(options: unknown): Promise<void> } => ({ send: permanentSend }),
      },
    };
    await expect(plugin.send(egressFor(route))).rejects.toMatchObject({
      code: 'delivery_failed',
      retryable: false,
    });
    expect(permanentSend).toHaveBeenCalledOnce();
  });

  it('chunks long egress responses at Discord-safe boundaries', async () => {
    const { plugin, internals } = createPlugin();
    const send = vi.fn().mockResolvedValue(undefined);
    internals.client = {
      user: { id: 'bot-001' },
      isReady: (): boolean => true,
      channels: {
        cache: { get: (): { send(options: unknown): Promise<void> } => ({ send }) },
      },
    };
    const route: ChannelConversationRouteDto = {
      bindingId: 'guild-001:channel-001:Nova',
      logicalAgentId: 'Nova',
      conversationId: 'Nova:discord:channel-001',
      channelName: 'discord',
      authorizationChannelId: 'channel-001',
      responseTarget: { channelId: 'channel-001' },
    };

    await plugin.send(egressFor(route, `${'a'.repeat(1_500)}\n${'b'.repeat(1_500)}`));

    expect(send).toHaveBeenCalledTimes(2);
  });

  it('reports channel adapter health without exposing runtime-provider state', async () => {
    const { plugin, internals } = createPlugin();

    expect(await plugin.health()).toEqual({ status: 'connected' });
    internals.socket = { connected: false, emit: vi.fn() };
    expect(await plugin.health()).toEqual({ status: 'degraded' });
    internals.client = { user: { id: 'bot-001' }, isReady: (): boolean => false };
    expect(await plugin.health()).toEqual({ status: 'disconnected' });
    internals.socket = { connected: true, emit: vi.fn() };
    expect(await plugin.health()).toEqual({ status: 'degraded' });
  });
});
