import { Test, TestingModule } from '@nestjs/testing';
import { OutboxDispatcher } from './outbox-dispatcher.service';
import { OutboxRepository } from '../../infrastructure/outbox/outbox.repository';
import { QueueService } from '../../infrastructure/queue/queue.service';
import { PrismaService } from '../../infrastructure/database/prisma.service';

describe('OutboxDispatcher', () => {
  let dispatcher: OutboxDispatcher;
  let outboxRepo: any;
  let queueService: any;
  let prisma: any;

  function makeEvent(overrides?: any) {
    return {
      id: 'evt-001',
      eventType: 'ai.job.enqueue',
      aggregateType: 'AiJob',
      aggregateId: 'job-001',
      dedupeKey: 'ai.job.enqueue:job-001',
      payload: { jobId: 'job-001' },
      status: 'pending',
      attemptCount: 0,
      availableAt: new Date(),
      lockedAt: null,
      lockedBy: null,
      lastErrorCode: null,
      lastErrorMessage: null,
      ...overrides,
    };
  }

  beforeEach(async () => {
    outboxRepo = {
      findDispatchable: jest.fn(),
      markProcessing: jest.fn(),
      markPublished: jest.fn(),
      markFailed: jest.fn(),
      releaseExpiredLocks: jest.fn(),
    };

    queueService = { add: jest.fn() };

    prisma = {
      aiJob: { findUnique: jest.fn() },
      outboxEvent: { update: jest.fn() },
    };

    // Suppress NestJS logger noise in tests
    jest.spyOn(require('@nestjs/common').Logger.prototype, 'log').mockImplementation(() => {});
    jest.spyOn(require('@nestjs/common').Logger.prototype, 'warn').mockImplementation(() => {});
    jest.spyOn(require('@nestjs/common').Logger.prototype, 'error').mockImplementation(() => {});

    const module: TestingModule = await Test.createTestingModule({
      providers: [
        OutboxDispatcher,
        { provide: OutboxRepository, useValue: outboxRepo },
        { provide: QueueService, useValue: queueService },
        { provide: PrismaService, useValue: prisma },
      ],
    }).compile();

    dispatcher = module.get(OutboxDispatcher);
    // Don't start the interval
  });

  describe('正常分发', () => {
    it('领取 → CAS 抢锁 → 验证 → 投递 BullMQ → 标记 published', async () => {
      outboxRepo.releaseExpiredLocks.mockResolvedValue(0);
      outboxRepo.findDispatchable.mockResolvedValue([makeEvent()]);
      outboxRepo.markProcessing.mockResolvedValue(makeEvent({ status: 'processing', lockedBy: 'x' }));
      prisma.aiJob.findUnique.mockResolvedValue({
        id: 'job-001',
        queueName: 'ai-interactive',
        lifecycleStatus: 'queued',
      });
      queueService.add.mockResolvedValue({ id: 'job-001' });
      outboxRepo.markPublished.mockResolvedValue(undefined);

      const result = await dispatcher.cycle();
      expect(result.published).toBe(1);
      expect(result.skipped).toBe(0);

      // Step order: CAS first, then enqueue
      expect(outboxRepo.markProcessing).toHaveBeenCalledWith('evt-001', expect.any(String));
      expect(queueService.add).toHaveBeenCalledWith(
        'ai-interactive',
        { jobId: 'job-001' },
        { jobId: 'job-001' }, // BullMQ jobId = AiJob.id
      );
      expect(outboxRepo.markPublished).toHaveBeenCalledWith('evt-001');
    });
  });

  describe('CAS 并发安全', () => {
    it('markProcessing 返回 null → skip（被其他 Dispatcher 抢走）', async () => {
      outboxRepo.releaseExpiredLocks.mockResolvedValue(0);
      outboxRepo.findDispatchable.mockResolvedValue([makeEvent()]);
      outboxRepo.markProcessing.mockResolvedValue(null); // CAS failed

      const result = await dispatcher.cycle();
      expect(result.skipped).toBe(1);
      expect(result.published).toBe(0);
      // 不应投递 BullMQ
      expect(queueService.add).not.toHaveBeenCalled();
    });
  });

  describe('幂等 BullMQ 入队', () => {
    it('相同 jobId 重复 add → BullMQ 视为 no-op（不抛错）', async () => {
      outboxRepo.releaseExpiredLocks.mockResolvedValue(0);
      outboxRepo.findDispatchable.mockResolvedValue([makeEvent()]);
      outboxRepo.markProcessing.mockResolvedValue(makeEvent({ status: 'processing' }));
      prisma.aiJob.findUnique.mockResolvedValue({
        id: 'job-001',
        queueName: 'ai-interactive',
        lifecycleStatus: 'queued',
      });
      // BullMQ 同 jobId repeat add → no error
      queueService.add.mockResolvedValue({ id: 'job-001' });
      outboxRepo.markPublished.mockResolvedValue(undefined);

      // 第一次
      await dispatcher.cycle();
      // 模拟第二次（相同 event 被 releaseExpiredLocks 恢复）
      outboxRepo.findDispatchable.mockResolvedValue([makeEvent()]);
      outboxRepo.markProcessing.mockResolvedValue(makeEvent({ status: 'processing' }));
      await dispatcher.cycle();

      // 两次都成功 published
      expect(outboxRepo.markPublished).toHaveBeenCalledTimes(2);
    });
  });

  describe('锁超时恢复', () => {
    it('releaseExpiredLocks 在每轮开始调用', async () => {
      outboxRepo.releaseExpiredLocks.mockResolvedValue(3); // 3 expired locks released
      outboxRepo.findDispatchable.mockResolvedValue([]);

      await dispatcher.cycle();
      expect(outboxRepo.releaseExpiredLocks).toHaveBeenCalledWith(60_000);
    });
  });

  describe('未知 eventType', () => {
    it('非 ai.job.enqueue → 永久 failed', async () => {
      outboxRepo.releaseExpiredLocks.mockResolvedValue(0);
      outboxRepo.findDispatchable.mockResolvedValue([
        makeEvent({ eventType: 'unknown.event', id: 'evt-bad' }),
      ]);
      outboxRepo.markProcessing.mockResolvedValue(
        makeEvent({ eventType: 'unknown.event', id: 'evt-bad' }),
      );

      const result = await dispatcher.cycle();
      expect(result.failed).toBe(1);
      expect(outboxRepo.markFailed).toHaveBeenCalledWith(
        'evt-bad',
        'UNKNOWN_EVENT_TYPE',
        expect.stringContaining('unknown.event'),
      );
      expect(queueService.add).not.toHaveBeenCalled();
    });
  });

  describe('Job 不存在', () => {
    it('AiJob 已删除 → 永久 failed', async () => {
      outboxRepo.releaseExpiredLocks.mockResolvedValue(0);
      outboxRepo.findDispatchable.mockResolvedValue([makeEvent()]);
      outboxRepo.markProcessing.mockResolvedValue(makeEvent());
      prisma.aiJob.findUnique.mockResolvedValue(null); // Job not found

      const result = await dispatcher.cycle();
      expect(result.failed).toBe(1);
      expect(outboxRepo.markFailed).toHaveBeenCalledWith(
        'evt-001',
        'JOB_NOT_FOUND',
        expect.any(String),
      );
    });
  });

  describe('非法 queueName', () => {
    it('queueName 不在允许列表 → 永久 failed', async () => {
      outboxRepo.releaseExpiredLocks.mockResolvedValue(0);
      outboxRepo.findDispatchable.mockResolvedValue([makeEvent()]);
      outboxRepo.markProcessing.mockResolvedValue(makeEvent());
      prisma.aiJob.findUnique.mockResolvedValue({
        id: 'job-001',
        queueName: 'ai-analysis', // 旧队列，不允许
        lifecycleStatus: 'queued',
      });

      const result = await dispatcher.cycle();
      expect(result.failed).toBe(1);
      expect(outboxRepo.markFailed).toHaveBeenCalledWith(
        'evt-001',
        'ILLEGAL_QUEUE_NAME',
        expect.stringContaining('ai-analysis'),
      );
    });
  });

  describe('BullMQ 入队失败回退', () => {
    it('入队抛错 → 回退到 pending（未达最大尝试）', async () => {
      outboxRepo.releaseExpiredLocks.mockResolvedValue(0);
      outboxRepo.findDispatchable.mockResolvedValue([makeEvent()]);
      outboxRepo.markProcessing.mockResolvedValue(makeEvent({ attemptCount: 0 }));
      prisma.aiJob.findUnique.mockResolvedValue({
        id: 'job-001',
        queueName: 'ai-interactive',
        lifecycleStatus: 'queued',
      });
      queueService.add.mockRejectedValue(new Error('Redis connection lost'));

      const result = await dispatcher.cycle();
      expect(result.failed).toBe(1);

      // 应重置为 pending（退避重试）
      expect(prisma.outboxEvent.update).toHaveBeenCalledWith(
        expect.objectContaining({
          where: { id: 'evt-001' },
          data: expect.objectContaining({
            status: 'pending',
            attemptCount: { increment: 1 },
          }),
        }),
      );
    });

    it('入队抛错 → 已达最大尝试 → 永久 failed', async () => {
      outboxRepo.releaseExpiredLocks.mockResolvedValue(0);
      outboxRepo.findDispatchable.mockResolvedValue([makeEvent()]);
      outboxRepo.markProcessing.mockResolvedValue(makeEvent({ attemptCount: 2 })); // 已达 2 次
      prisma.aiJob.findUnique.mockResolvedValue({
        id: 'job-001',
        queueName: 'ai-interactive',
        lifecycleStatus: 'queued',
      });
      queueService.add.mockRejectedValue(new Error('Redis down'));

      const result = await dispatcher.cycle();
      expect(result.failed).toBe(1);

      // 应调用 markFailed（永久）
      expect(outboxRepo.markFailed).toHaveBeenCalledWith(
        'evt-001',
        expect.any(String),
        expect.any(String),
      );
    });
  });

  describe('空轮询', () => {
    it('无可分发事件 → 返回全零', async () => {
      outboxRepo.releaseExpiredLocks.mockResolvedValue(0);
      outboxRepo.findDispatchable.mockResolvedValue([]);

      const result = await dispatcher.cycle();
      expect(result).toEqual({ processed: 0, published: 0, failed: 0, skipped: 0 });
    });
  });

  describe('无效 payload', () => {
    it('payload 缺少 jobId → 永久 failed', async () => {
      outboxRepo.releaseExpiredLocks.mockResolvedValue(0);
      outboxRepo.findDispatchable.mockResolvedValue([makeEvent({ payload: {} })]);
      outboxRepo.markProcessing.mockResolvedValue(makeEvent({ payload: {} }));

      const result = await dispatcher.cycle();
      expect(result.failed).toBe(1);
      expect(outboxRepo.markFailed).toHaveBeenCalledWith(
        'evt-001',
        'INVALID_PAYLOAD',
        expect.any(String),
      );
    });
  });
});
