import { OutboxDispatcherService } from './outbox-dispatcher.service';

describe('OutboxDispatcherService', () => {
  const event = (attempts = 0) => ({
    _id: 'mongo-id', eventId: 'follow.request.approved:request-1',
    eventType: 'follow_request_approved_notification', status: 'processing', attempts,
    createdAt: new Date('2026-08-03T12:00:00.000Z'),
    payload: { actorId: 'actor', recipientId: 'recipient', requestId: 'request-1' },
  });

  function setup(values: Record<string, string> = {}) {
    const model = {
      findOneAndUpdate: jest.fn(), updateMany: jest.fn(), updateOne: jest.fn(),
    };
    model.updateMany.mockReturnValue({ exec: jest.fn().mockResolvedValue({ modifiedCount: 0 }) });
    model.updateOne.mockReturnValue({ exec: jest.fn().mockResolvedValue({ modifiedCount: 1 }) });
    const config = { get: jest.fn((key: string, fallback?: string) => values[key] ?? fallback) };
    const client = { publishFollowRequestApproved: jest.fn() };
    const logger = { log: jest.fn(), warn: jest.fn(), error: jest.fn() };
    const connection = { db: { admin: () => ({ command: jest.fn().mockResolvedValue({ setName: 'rs0' }) }) } };
    const service = new OutboxDispatcherService(model as never, connection as never, config as never, client as never, logger as never);
    return { service, model, client, logger };
  }

  it('does not start or claim when either phase-1 flag is false', async () => {
    const ctx = setup({ NOTIFICATION_SERVICE_ENABLED: 'false', OUTBOX_DISPATCHER_ENABLED: 'true' });
    await ctx.service.onApplicationBootstrap();
    await ctx.service.poll();
    expect(ctx.model.findOneAndUpdate).not.toHaveBeenCalled();
    expect(ctx.client.publishFollowRequestApproved).not.toHaveBeenCalled();
  });

  it('uses an atomic claim and recovers expired processing leases', async () => {
    const ctx = setup({ NOTIFICATION_SERVICE_ENABLED: 'true', OUTBOX_DISPATCHER_ENABLED: 'true', OUTBOX_LEASE_TIMEOUT_MS: '1000' });
    ctx.model.findOneAndUpdate.mockReturnValue({ exec: jest.fn().mockResolvedValue(event()) });
    await ctx.service.claimNext(new Date('2026-08-03T12:00:05.000Z'));
    expect(ctx.model.findOneAndUpdate).toHaveBeenCalledWith(
      expect.objectContaining({ status: { $in: ['pending', 'failed'] }, $and: expect.any(Array) }),
      { $set: { status: 'processing', lockedAt: expect.any(Date), lockedBy: expect.any(String) } },
      { new: true, sort: { createdAt: 1 } },
    );
    await ctx.service.recoverExpiredLeases(new Date('2026-08-03T12:00:05.000Z'));
    expect(ctx.model.updateMany).toHaveBeenCalledWith(
      expect.objectContaining({ status: 'processing', lockedAt: { $lte: new Date('2026-08-03T12:00:04.000Z') } }),
      expect.objectContaining({ $set: expect.objectContaining({ status: 'failed', lockedAt: null, lockedBy: null }) }),
    );
  });

  it('applies bounded exponential backoff and moves exhausted events to dead', async () => {
    const ctx = setup({
      NOTIFICATION_SERVICE_ENABLED: 'true', OUTBOX_DISPATCHER_ENABLED: 'true', OUTBOX_BATCH_SIZE: '1',
      OUTBOX_MAX_ATTEMPTS: '2', OUTBOX_RETRY_BASE_MS: '100', OUTBOX_RETRY_MAX_MS: '250',
    });
    expect(ctx.service.calculateBackoff(1)).toBe(100);
    expect(ctx.service.calculateBackoff(4)).toBe(250);
    ctx.model.findOneAndUpdate.mockReturnValue({ exec: jest.fn().mockResolvedValue(event(1)) });
    ctx.client.publishFollowRequestApproved.mockRejectedValue(new Error('amqp://secret:password@broker failed'));
    await ctx.service.poll();
    expect(ctx.model.updateOne).toHaveBeenCalledWith(
      expect.objectContaining({ status: 'processing' }),
      expect.objectContaining({ $set: expect.objectContaining({ status: 'dead', attempts: 2, lockedAt: null, lockedBy: null }) }),
    );
    expect(ctx.logger.warn).toHaveBeenCalledWith(expect.objectContaining({ error: expect.not.stringContaining('secret:password') }), 'OutboxDispatcherService');
  });

  it('retries after a lost RPC response and marks processed only after a later response', async () => {
    const ctx = setup({ NOTIFICATION_SERVICE_ENABLED: 'true', OUTBOX_DISPATCHER_ENABLED: 'true', OUTBOX_BATCH_SIZE: '1' });
    ctx.model.findOneAndUpdate
      .mockReturnValueOnce({ exec: jest.fn().mockResolvedValue(event(0)) })
      .mockReturnValueOnce({ exec: jest.fn().mockResolvedValue(event(1)) });
    ctx.client.publishFollowRequestApproved
      .mockRejectedValueOnce(new Error('RPC response timeout'))
      .mockResolvedValueOnce(undefined);
    await ctx.service.poll();
    await ctx.service.poll();
    expect(ctx.client.publishFollowRequestApproved).toHaveBeenCalledTimes(2);
    expect(ctx.model.updateOne.mock.calls[0][1].$set.status).toBe('failed');
    expect(ctx.model.updateOne.mock.calls[1][1].$set.status).toBe('processed');
  });
});
